12 Commits
45 changed files with 1135 additions and 371 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ create a GitHub Release.
deletion is a separate action. deletion is a separate action.
- Saving a browser link creates a collision-safe `.url` file. Opening a link is - Saving a browser link creates a collision-safe `.url` file. Opening a link is
handled by Verstak, so it does not depend on a Linux `.url` file association. handled by Verstak, so it does not depend on a Linux `.url` file association.
- Activity sessions can be scoped to a durable workspace identity or to an - Activity sessions can be scoped to a durable Deal identity or to an
explicit unassigned scope. Journal creation is always a user action. explicit unassigned scope. Journal creation is always a user action.
- Todo is optional: Overview uses it only when the Todo capability is present. - Todo is optional: Overview uses it only when the Todo capability is present.
+12 -12
View File
@@ -4,22 +4,22 @@ Browser Inbox is one global queue of browser captures. New and changed records a
stored in `captures:global`; previous `captures` and `captures:workspace:*` keys stored in `captures:global`; previous `captures` and `captures:workspace:*` keys
remain readable for migration compatibility. remain readable for migration compatibility.
Each capture contains `workspaceRootPath` and `processed`. An empty Each capture contains a durable `workspaceId`, its current or historical
`workspaceRootPath` means the capture is **Unassigned**. The global view can filter `workspaceRootPath`, and `processed`. An empty `workspaceRootPath` means the
all captures by assignment, workspace, processed state, and text search. A capture capture is **Unassigned**. The global view can filter all captures by assignment,
Deal, processed state, and text search. A capture
can be assigned, reassigned, made unassigned, marked processed or unprocessed, and can be assigned, reassigned, made unassigned, marked processed or unprocessed, and
deleted. deleted.
The local browser receiver adds the currently active workspace before publishing a The local browser receiver adds the currently active Deal before publishing a
capture event. When it has no active workspace, the capture remains unassigned capture event. When it has no active Deal, the capture remains unassigned
unless an explicit existing domain binding matches it. The frontend never assigns an unless an explicit existing domain binding matches it. The frontend never assigns an
untagged capture merely because a workspace view happens to be open. untagged capture merely because a Deal view happens to be open.
Workspace assignment currently uses the top-level vault folder path as the Deal assignment uses the immutable `workspaceId` stored in Deal metadata;
identifier because the core workspace model has no separate immutable ID. In the `workspaceRootPath` is an address and display value. A rename updates the latter,
current model that path is also the displayed workspace name, so a workspace rename while a newly created folder with the old name cannot take over existing captures.
requires a later reassignment of existing captures.
Workspace Inbox and Overview show only captures whose `workspaceRootPath` exactly Deal Inbox and Overview show only captures whose `workspaceId` matches the selected
matches the selected workspace. Unassigned captures remain visible only in the Deal. Unassigned captures remain visible only in the
global Browser Inbox. global Browser Inbox.
+14 -16
View File
@@ -2,12 +2,12 @@
Todos are stored by the official `verstak.todo` plugin under the canonical Todos are stored by the official `verstak.todo` plugin under the canonical
`todos:global` settings key. A todo is global when `workspaceRootPath` is empty; `todos:global` settings key. A todo is global when `workspaceRootPath` is empty;
otherwise that field identifies the top-level workspace folder that owns it. otherwise that field identifies the top-level Deal folder that owns it.
The global Todos view aggregates all records and supports workspace, status, text, The global Todos view aggregates all records and supports Deal, status, text,
and due/reminder/updated sorting filters. The workspace Todos view shows only and due/reminder/updated sorting filters. The Deal Todos view shows only records
records whose `workspaceRootPath` exactly matches the current workspace. This whose `workspaceRootPath` exactly matches the current Deal. This keeps unassigned
keeps unassigned and other-workspace records out of a workspace tab and its and other-Deal records out of a Deal tab and its
Overview signals. Overview signals.
## Stored fields ## Stored fields
@@ -20,15 +20,15 @@ Each record has a stable `id`, `title`, optional `description`, optional
## Reminders ## Reminders
The plugin stores reminder metadata and renders clear indicators for overdue, The plugin stores reminder metadata, renders clear indicators for overdue,
due-soon, and reminder-due todos. Verstak does not yet have a notification due-soon, and reminder-due todos, and schedules native desktop notifications when
scheduler, so a reminder does not create a native desktop notification or run in the Desktop notification capability is available. Without that capability, the
the background. reminder remains visible in Todos but is not treated as an error.
## Completed Todo to Journal ## Completed Todo to Journal
From a workspace Todo tab, a completed todo exposes **Create Journal Entry**. It From a Deal Todo tab, a completed todo exposes **Create Journal Entry**. It opens
opens the current workspace Journal with a normal, editable form. The form copies the current Deal Journal with a normal, editable form. The form copies
only factual data from the todo: title, description, completion date, and zero only factual data from the todo: title, description, completion date, and zero
minutes. It never generates a summary, duration, or billable status. minutes. It never generates a summary, duration, or billable status.
@@ -40,8 +40,6 @@ own record.
## Visibility ## Visibility
The Plugin Manager can globally enable or disable the Todo plugin. The current The Plugin Manager can globally enable or disable the Todo plugin. A Deal template
workspace host has no per-workspace or template-level contribution filter yet, so controls whether its Todos tab is available; disabling the plugin hides the tab
a globally enabled Todo plugin contributes its tab to every workspace. Template without affecting stored todo records.
visibility will be handled with the workspace/template model rather than by a
Todo-specific exception.
+42 -42
View File
@@ -37,19 +37,19 @@
'activity.session.handled' 'activity.session.handled'
]; ];
var EVENT_LABELS = { var EVENT_LABELS = {
'workspace.selected': 'Workspace selected', 'workspace.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'case.selected': 'Workspace selected', 'case.selected': { key: 'ui.event.workspaceSelected', fallback: 'Deal selected' },
'file.opened': 'File opened', 'file.opened': { key: 'ui.event.fileOpened', fallback: 'File opened' },
'file.changed': 'File changed', 'file.changed': { key: 'ui.event.fileChanged', fallback: 'File changed' },
'note.saved': 'Note edited', 'note.saved': { key: 'ui.event.noteEdited', fallback: 'Note edited' },
'action.started': 'Work session detected', 'action.started': { key: 'ui.event.workSessionDetected', fallback: 'Work session detected' },
'browser.capture.received': 'Browser capture received', 'browser.capture.received': { key: 'ui.event.browserCaptureReceived', fallback: 'Browser capture received' },
'browser.capture.page': 'Page captured', 'browser.capture.page': { key: 'ui.event.pageCaptured', fallback: 'Page captured' },
'browser.capture.selection': 'Selection captured', 'browser.capture.selection': { key: 'ui.event.selectionCaptured', fallback: 'Selection captured' },
'browser.capture.link': 'Link captured', 'browser.capture.link': { key: 'ui.event.linkCaptured', fallback: 'Link captured' },
'browser.capture.file': 'File captured', 'browser.capture.file': { key: 'ui.event.fileCaptured', fallback: 'File captured' },
'browser.capture.converted': 'Capture converted', 'browser.capture.converted': { key: 'ui.event.captureConverted', fallback: 'Capture converted' },
'browser.activity.domain': 'Browser domain activity' 'browser.activity.domain': { key: 'ui.event.browserDomainActivity', fallback: 'Browser domain activity' }
}; };
var LOW_VALUE_EVENT_TYPES = { var LOW_VALUE_EVENT_TYPES = {
'workspace.selected': true, 'workspace.selected': true,
@@ -154,7 +154,7 @@
var workspaceRoot = workspaceFromProps(props); var workspaceRoot = workspaceFromProps(props);
var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim(); var workspaceId = text(props && (props.workspaceId || (props.workspaceNode && props.workspaceNode.workspaceId))).trim();
if (!workspaceRoot) { if (!workspaceRoot) {
return { mode: 'global', key: GLOBAL_KEY, label: 'All workspaces', workspaceRoot: '', workspaceId: '' }; return { mode: 'global', key: GLOBAL_KEY, label: 'All Deals', workspaceRoot: '', workspaceId: '' };
} }
return { return {
mode: 'workspace', mode: 'workspace',
@@ -412,25 +412,27 @@
}).slice(0, MAX_CANDIDATES); }).slice(0, MAX_CANDIDATES);
} }
function humanEventType(type) { function humanEventType(type, translate) {
return EVENT_LABELS[text(type).toLowerCase()] || ''; var label = EVENT_LABELS[text(type).toLowerCase()];
return label ? translate(label.key, null, label.fallback) : '';
} }
function humanEventTitle(activity) { function humanEventTitle(activity, translate) {
var explicit = text(activity && activity.title).trim(); var explicit = text(activity && activity.title).trim();
var type = text(activity && activity.type).trim(); var type = text(activity && activity.type).trim();
if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return explicit; var label = humanEventType(type, translate);
return humanEventType(type) || text(activity && (activity.summary || activity.activityId)).trim() || 'Activity event'; if (explicit && explicit.toLowerCase() !== type.toLowerCase()) return label ? label + ' — ' + explicit : explicit;
return label || text(activity && (activity.summary || activity.activityId)).trim() || translate('ui.event.activity', null, 'Activity event');
} }
function eventKind(activity) { function eventKind(activity, translate) {
var type = text(activity && activity.type).toLowerCase(); var type = text(activity && activity.type).toLowerCase();
if (type.indexOf('browser.capture') === 0) return 'Capture'; if (type.indexOf('browser.capture') === 0) return translate('ui.kind.capture', null, 'Capture');
if (type.indexOf('file.') === 0) return 'File'; if (type.indexOf('file.') === 0) return translate('ui.kind.file', null, 'File');
if (type.indexOf('note.') === 0) return 'Note'; if (type.indexOf('note.') === 0) return translate('ui.kind.note', null, 'Note');
if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return 'Workspace'; if (type.indexOf('workspace') !== -1 || type.indexOf('case.') === 0) return translate('ui.kind.deal', null, 'Deal');
if (type.indexOf('action.') === 0) return 'Work'; if (type.indexOf('action.') === 0) return translate('ui.kind.work', null, 'Work');
return 'Activity'; return translate('ui.kind.activity', null, 'Activity');
} }
function globalEventKeys(settings) { function globalEventKeys(settings) {
@@ -760,14 +762,10 @@
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }), el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt) || '-' }),
el('div', { className: 'activity-main' }, [ el('div', { className: 'activity-main' }, [
el('div', { className: 'activity-row-head' }, [ el('div', { className: 'activity-row-head' }, [
el('span', { className: 'activity-type', textContent: eventKind(activity) }), el('span', { className: 'activity-type', textContent: eventKind(activity, tr) }),
el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity) }) el('span', { className: 'activity-title-text', textContent: humanEventTitle(activity, tr) })
]), ]),
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null, activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null
el('details', { className: 'activity-details' }, [
el('summary', {}, [tr('ui.details', null, 'Details')]),
el('div', { className: 'activity-source', textContent: tr('ui.eventSource', { event: activity.type, source: activity.sourcePluginId ? ' · ' + activity.sourcePluginId : '' }, 'Event: ' + activity.type + (activity.sourcePluginId ? ' · Source: ' + activity.sourcePluginId : '')) })
])
]) ])
])); ]));
}); });
@@ -824,19 +822,19 @@
el('div', {}, [ el('div', {}, [
el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }), el('div', { className: 'activity-candidate-title', textContent: tr('ui.candidate', null, 'Possible journal entry') }),
el('div', { className: 'activity-candidate-facts' }, [ el('div', { className: 'activity-candidate-facts' }, [
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), el('div', { textContent: tr('ui.candidateDeal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: 'Time: ' + candidateTimeRange(candidate) }), el('div', { textContent: tr('ui.candidateTime', { time: candidateTimeRange(candidate) }, 'Time: ' + candidateTimeRange(candidate)) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), el('div', { textContent: tr('ui.candidateDuration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: 'Activities: ' + candidate.activityCount }) el('div', { textContent: tr('ui.candidateActivities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]), ]),
el('details', { className: 'activity-candidate-activities' }, [ el('details', { className: 'activity-candidate-activities' }, [
el('summary', { textContent: 'Included activities (' + candidate.activityCount + ')' }) el('summary', { textContent: tr('ui.includedActivities', { count: candidate.activityCount }, 'Included activities (' + candidate.activityCount + ')') })
].concat(candidate.activities.map(function (activity) { ].concat(candidate.activities.map(function (activity) {
return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + activity.type + ' · ' + activity.activityId }); return el('div', { className: 'activity-candidate-activity', textContent: activity.occurredAt + ' · ' + humanEventTitle(activity, tr) });
}))) })))
]), ]),
el('div', { className: 'activity-candidate-actions' }, [ el('div', { className: 'activity-candidate-actions' }, [
el('div', { className: 'activity-candidate-duration', textContent: candidate.estimatedMinutes + ' min' }), el('div', { className: 'activity-candidate-duration', textContent: tr('ui.minutes', { count: candidate.estimatedMinutes }, candidate.estimatedMinutes + ' min') }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }), el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'review', textContent: tr('ui.review', null, 'Review'), onClick: function () { reviewCandidate(candidate); } }),
el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } }) el('button', { className: 'activity-btn', type: 'button', 'data-work-session-action': 'dismiss', textContent: tr('ui.dismiss', null, 'Dismiss'), onClick: function () { dismissCandidate(candidate); } })
]) ])
@@ -845,7 +843,7 @@
} }
function render() { function render() {
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's'); countEl.textContent = tr(events.length === 1 ? 'ui.eventCount.one' : 'ui.eventCount.many', { count: events.length }, events.length + ' event' + (events.length === 1 ? '' : 's'));
clearBtn.disabled = events.length === 0; clearBtn.disabled = events.length === 0;
statusEl.textContent = statusText; statusEl.textContent = statusText;
statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : ''); statusEl.className = 'activity-status' + (statusClass ? ' ' + statusClass : '');
@@ -917,7 +915,9 @@
if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe); if (typeof unsubscribe === 'function') unsubscribers.push(unsubscribe);
}); });
})).then(function () { })).then(function () {
statusText = scope.mode === 'global' ? 'Listening for all activity' : 'Listening for workspace activity'; statusText = scope.mode === 'global'
? tr('ui.listeningGlobal', null, 'Listening for all activity')
: tr('ui.listeningDeal', null, 'Listening for Deal activity');
statusClass = ''; statusClass = '';
}).catch(function (err) { }).catch(function (err) {
reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err); reportError('ui.subscriptionsUnavailable', 'Activity updates are unavailable. Please try again.', err);
+33 -4
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Activity", "manifest.name": "Activity",
"manifest.description": "Workspace-scoped activity log for public plugin events.", "manifest.description": "Deal-scoped activity log for public plugin events.",
"contributions.views.verstak.activity.view.title": "Activity", "contributions.views.verstak.activity.view.title": "Activity",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Activity", "contributions.sidebarItems.verstak.activity.sidebar.title": "Activity",
"contributions.workspaceItems.verstak.activity.workspace.title": "Activity", "contributions.workspaceItems.verstak.activity.workspace.title": "Activity",
@@ -10,8 +10,8 @@
"ui.workspaceTitle": "Activity · {workspace}", "ui.workspaceTitle": "Activity · {workspace}",
"ui.clear": "Clear", "ui.clear": "Clear",
"ui.clearConfirmTitle": "Clear activity?", "ui.clearConfirmTitle": "Clear activity?",
"ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every case.", "ui.clearGlobalWarning": "This permanently deletes all recorded activity and journal suggestions in every Deal.",
"ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other cases remains.", "ui.clearWorkspaceWarning": "This permanently deletes recorded activity for {workspace}. Activity in other Deals remains.",
"ui.confirmClear": "Clear activity", "ui.confirmClear": "Clear activity",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.saveError": "Could not save activity. Please try again.", "ui.saveError": "Could not save activity. Please try again.",
@@ -29,5 +29,34 @@
"ui.candidates": "Possible journal entries", "ui.candidates": "Possible journal entries",
"ui.candidate": "Possible journal entry", "ui.candidate": "Possible journal entry",
"ui.review": "Review", "ui.review": "Review",
"ui.dismiss": "Dismiss" "ui.dismiss": "Dismiss",
"ui.event.workspaceSelected": "Deal selected",
"ui.event.fileOpened": "File opened",
"ui.event.fileChanged": "File changed",
"ui.event.noteEdited": "Note edited",
"ui.event.workSessionDetected": "Work session detected",
"ui.event.browserCaptureReceived": "Browser capture received",
"ui.event.pageCaptured": "Page captured",
"ui.event.selectionCaptured": "Selection captured",
"ui.event.linkCaptured": "Link captured",
"ui.event.fileCaptured": "File captured",
"ui.event.captureConverted": "Capture converted",
"ui.event.browserDomainActivity": "Browser domain activity",
"ui.event.activity": "Activity event",
"ui.kind.capture": "Capture",
"ui.kind.file": "File",
"ui.kind.note": "Note",
"ui.kind.deal": "Deal",
"ui.kind.work": "Work",
"ui.kind.activity": "Activity",
"ui.candidateDeal": "Deal: {deal}",
"ui.candidateTime": "Time: {time}",
"ui.candidateDuration": "Estimated duration: {minutes} min",
"ui.candidateActivities": "Activities: {count}",
"ui.includedActivities": "Included activities ({count})",
"ui.minutes": "{count} min",
"ui.eventCount.one": "{count} event",
"ui.eventCount.many": "{count} events",
"ui.listeningGlobal": "Listening for all activity",
"ui.listeningDeal": "Listening for Deal activity"
} }
+31 -2
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Активность", "manifest.name": "Активность",
"manifest.description": "Журнал публичных событий плагинов в рабочем пространстве.", "manifest.description": "Журнал публичных событий плагинов в Деле.",
"contributions.views.verstak.activity.view.title": "Активность", "contributions.views.verstak.activity.view.title": "Активность",
"contributions.sidebarItems.verstak.activity.sidebar.title": "Активность", "contributions.sidebarItems.verstak.activity.sidebar.title": "Активность",
"contributions.workspaceItems.verstak.activity.workspace.title": "Активность", "contributions.workspaceItems.verstak.activity.workspace.title": "Активность",
@@ -29,5 +29,34 @@
"ui.candidates": "Возможные записи журнала", "ui.candidates": "Возможные записи журнала",
"ui.candidate": "Возможная запись журнала", "ui.candidate": "Возможная запись журнала",
"ui.review": "Проверить", "ui.review": "Проверить",
"ui.dismiss": "Скрыть" "ui.dismiss": "Скрыть",
"ui.event.workspaceSelected": "Выбрано Дело",
"ui.event.fileOpened": "Открыт файл",
"ui.event.fileChanged": "Изменён файл",
"ui.event.noteEdited": "Изменена заметка",
"ui.event.workSessionDetected": "Обнаружена рабочая сессия",
"ui.event.browserCaptureReceived": "Получено сохранённое из браузера",
"ui.event.pageCaptured": "Сохранена страница",
"ui.event.selectionCaptured": "Сохранено выделение",
"ui.event.linkCaptured": "Сохранена ссылка",
"ui.event.fileCaptured": "Сохранён файл",
"ui.event.captureConverted": "Сохранённое преобразовано",
"ui.event.browserDomainActivity": "Активность на домене в браузере",
"ui.event.activity": "Событие активности",
"ui.kind.capture": "Сохранённое",
"ui.kind.file": "Файл",
"ui.kind.note": "Заметка",
"ui.kind.deal": "Дело",
"ui.kind.work": "Работа",
"ui.kind.activity": "Активность",
"ui.candidateDeal": "Дело: {deal}",
"ui.candidateTime": "Время: {time}",
"ui.candidateDuration": "Оценка длительности: {minutes} мин.",
"ui.candidateActivities": "Активностей: {count}",
"ui.includedActivities": "Учтённые активности ({count})",
"ui.minutes": "{count} мин.",
"ui.eventCount.one": "Событий: {count}",
"ui.eventCount.many": "Событий: {count}",
"ui.listeningGlobal": "Учёт всей активности включён",
"ui.listeningDeal": "Учёт активности Дела включён"
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Activity", "name": "Activity",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped activity log for public plugin events.", "description": "Deal-scoped activity log for public plugin events.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "activity", "icon": "activity",
+2 -2
View File
@@ -459,7 +459,7 @@
if (scope.mode === 'global') { if (scope.mode === 'global') {
filtersEl.appendChild(workspaceFilterEl); filtersEl.appendChild(workspaceFilterEl);
} else { } else {
filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: tr('ui.assignedHere', null, 'Assigned to this workspace') })); filtersEl.appendChild(el('span', { className: 'browser-inbox-count', textContent: tr('ui.assignedHere', null, 'Assigned to this Deal') }));
} }
filtersEl.appendChild(searchInput); filtersEl.appendChild(searchInput);
toolbar.appendChild(filtersEl); toolbar.appendChild(filtersEl);
@@ -865,7 +865,7 @@
el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: formatDate(capture.capturedAt) || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.browser', null, 'Browser') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.browser', null, 'Browser') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Workspace') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.workspace', null, 'Deal') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }), el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || tr('ui.unassigned', null, 'Unassigned') }),
el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }), el('div', { className: 'browser-inbox-meta-label', textContent: tr('ui.status', null, 'Status') }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed
+27 -21
View File
@@ -18,7 +18,6 @@
'.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}', '.de-root{display:flex;flex-direction:column;height:100%;min-height:0;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;color:#e0e0e0;background:#0d0d1a}',
'.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}', '.de-toolbar,.de-md-toolbar{display:flex;align-items:center;gap:.45rem;padding:.45rem .75rem;border-bottom:1px solid #16213e;flex-shrink:0;background:#12122a;flex-wrap:wrap}',
'.de-md-toolbar{background:#101028;padding:.38rem .75rem}', '.de-md-toolbar{background:#101028;padding:.38rem .75rem}',
'.de-toolbar-mode{font-size:.75rem;color:#4ecca3;padding:.15rem .5rem;border-radius:3px;background:#1a2a3a}',
'.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.de-toolbar-context{font-size:.75rem;color:#a0a0bb;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.de-toolbar-spacer{flex:1}', '.de-toolbar-spacer{flex:1}',
'.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}', '.de-toolbar-btn,.de-md-btn{font-size:.75rem;padding:.28rem .58rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}',
@@ -45,7 +44,6 @@
'.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}', '.de-preview table{border-collapse:collapse;margin:.8rem 0;max-width:100%;display:block;overflow:auto}.de-preview th,.de-preview td{border:1px solid #333;padding:.35rem .6rem;text-align:left}.de-preview th{background:#1a1a2e}',
'.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}', '.de-preview img{max-width:100%;height:auto;border-radius:4px}.de-preview .task{margin-right:.4rem}',
'.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}', '.de-notes-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:3px;background:#2a1a3a;color:#b388ff}',
'.de-notes-info{padding:.45rem .75rem;background:#111126;border-top:1px solid #16213e;font-size:.75rem;color:#8b8ba8;flex-shrink:0}',
'.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}', '.de-loading,.de-error{flex:1;display:flex;align-items:center;justify-content:center;color:#777;padding:2rem}.de-error{color:#e74c3c;flex-direction:column;gap:.5rem}.de-error-msg{font-size:.85rem;color:#aaa;max-width:420px;text-align:center}',
'@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}' '@media(max-width:780px){.de-editor-wrap{flex-direction:column}.de-pane+.de-pane{border-left:0;border-top:1px solid #16213e}.de-toolbar-context{max-width:100%}}'
].join('\n'); ].join('\n');
@@ -303,9 +301,8 @@
containerEl.setAttribute('data-resource-path', resourcePath); containerEl.setAttribute('data-resource-path', resourcePath);
containerEl.setAttribute('data-request-mode', requestedMode); containerEl.setAttribute('data-request-mode', requestedMode);
var modeLabel = el('span', { className: 'de-toolbar-mode' }, [editorMode]);
var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]); var contextLabel = el('span', { className: 'de-toolbar-context', title: resourcePath }, [resourcePath || fileName(resourcePath)]);
var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.notesContext', null, 'notes context')]) : null; var notesBadge = editorMode === 'notes-markdown' ? el('span', { className: 'de-notes-badge', 'data-notes-badge': '' }, [tr('ui.note', null, 'Note')]) : null;
var spacer = el('span', { className: 'de-toolbar-spacer' }); var spacer = el('span', { className: 'de-toolbar-spacer' });
var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null; var editBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'edit' }, [tr('ui.edit', null, 'Edit')]) : null;
var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null; var previewBtn = isMarkdown ? el('button', { className: 'de-toolbar-btn', 'data-editor-mode-button': 'preview' }, [tr('ui.preview', null, 'Preview')]) : null;
@@ -313,7 +310,7 @@
var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]); var reloadBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'reload' }, [tr('ui.reload', null, 'Reload')]);
var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]); var saveBtn = el('button', { className: 'de-toolbar-btn', 'data-editor-action': 'save' }, [tr('ui.save', null, 'Save')]);
var statusEl = el('span', { className: 'de-status', 'data-save-state': '' }); var statusEl = el('span', { className: 'de-status', 'data-save-state': '' });
var toolbarChildren = [modeLabel, contextLabel]; var toolbarChildren = [contextLabel];
if (notesBadge) toolbarChildren.push(notesBadge); if (notesBadge) toolbarChildren.push(notesBadge);
toolbarChildren.push(spacer); toolbarChildren.push(spacer);
[editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); }); [editBtn, previewBtn, splitBtn, reloadBtn, saveBtn, statusEl].forEach(function (node) { if (node) toolbarChildren.push(node); });
@@ -323,18 +320,18 @@
if (isMarkdown) { if (isMarkdown) {
mdToolbar = el('div', { className: 'de-md-toolbar' }); mdToolbar = el('div', { className: 'de-md-toolbar' });
[ [
['heading', 'H', 'Heading'], ['heading', 'H', 'ui.md.heading', 'Heading'],
['bold', 'B', 'Bold'], ['bold', 'B', 'ui.md.bold', 'Bold'],
['italic', 'I', 'Italic'], ['italic', 'I', 'ui.md.italic', 'Italic'],
['link', 'Link', 'Link'], ['link', 'Link', 'ui.md.link', 'Link'],
['code', 'Code', 'Inline code'], ['code', 'Code', 'ui.md.inlineCode', 'Inline code'],
['code-block', '```', 'Code block'], ['code-block', '```', 'ui.md.codeBlock', 'Code block'],
['bullet', '• List', 'Bullet list'], ['bullet', '• List', 'ui.md.bulletList', 'Bullet list'],
['numbered', '1. List', 'Numbered list'], ['numbered', '1. List', 'ui.md.numberedList', 'Numbered list'],
['quote', 'Quote', 'Quote'], ['quote', 'Quote', 'ui.md.quote', 'Quote'],
['task', 'Task', 'Task item'] ['task', 'Task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) { ].forEach(function (item) {
mdToolbar.appendChild(el('button', { className: 'de-md-btn', 'data-md-action': item[0], title: item[2] }, [item[1]])); mdToolbar.appendChild(el('button', { className: 'de-md-btn', type: 'button', 'data-md-action': item[0], title: tr(item[2], null, item[3]), 'aria-label': tr(item[2], null, item[3]) }, [item[1]]));
}); });
containerEl.appendChild(mdToolbar); containerEl.appendChild(mdToolbar);
} }
@@ -342,10 +339,6 @@
var editorWrap = el('div', { className: 'de-editor-wrap' }); var editorWrap = el('div', { className: 'de-editor-wrap' });
containerEl.appendChild(editorWrap); containerEl.appendChild(editorWrap);
if (editorMode === 'notes-markdown') {
containerEl.appendChild(el('div', { className: 'de-notes-info' }, [tr('ui.notesInfo', null, 'Notes context active. Note actions, backlinks, and graph tools are reserved for the future Notes plugin.')]));
}
function updateLineNumbers() { function updateLineNumbers() {
if (!linesEl || !textarea) return; if (!linesEl || !textarea) return;
var count = textarea.value.split('\n').length; var count = textarea.value.split('\n').length;
@@ -543,12 +536,25 @@
reloadFromDisk(); reloadFromDisk();
var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function' var localeUnsubscribe = api.i18n && typeof api.i18n.onDidChangeLocale === 'function'
? api.i18n.onDidChangeLocale(function () { ? api.i18n.onDidChangeLocale(function () {
if (notesBadge) notesBadge.textContent = tr('ui.notesContext', null, 'notes context'); if (notesBadge) notesBadge.textContent = tr('ui.note', null, 'Note');
if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit'); if (editBtn) editBtn.textContent = tr('ui.edit', null, 'Edit');
if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview'); if (previewBtn) previewBtn.textContent = tr('ui.preview', null, 'Preview');
if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split'); if (splitBtn) splitBtn.textContent = tr('ui.split', null, 'Split');
reloadBtn.textContent = tr('ui.reload', null, 'Reload'); reloadBtn.textContent = tr('ui.reload', null, 'Reload');
saveBtn.textContent = tr('ui.save', null, 'Save'); saveBtn.textContent = tr('ui.save', null, 'Save');
if (mdToolbar) {
[
['heading', 'ui.md.heading', 'Heading'], ['bold', 'ui.md.bold', 'Bold'], ['italic', 'ui.md.italic', 'Italic'],
['link', 'ui.md.link', 'Link'], ['code', 'ui.md.inlineCode', 'Inline code'], ['code-block', 'ui.md.codeBlock', 'Code block'],
['bullet', 'ui.md.bulletList', 'Bullet list'], ['numbered', 'ui.md.numberedList', 'Numbered list'], ['quote', 'ui.md.quote', 'Quote'], ['task', 'ui.md.taskItem', 'Task item']
].forEach(function (item) {
var button = mdToolbar.querySelector('[data-md-action="' + item[0] + '"]');
if (!button) return;
var label = tr(item[1], null, item[2]);
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
});
}
updateStatus(); updateStatus();
}) })
: null; : null;
+12 -1
View File
@@ -5,6 +5,7 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor", "contributions.openProviders.verstak.default-editor.markdown.title": "Default Markdown Editor",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Default Notes Markdown Editor",
"ui.notesContext": "notes context", "ui.notesContext": "notes context",
"ui.note": "Note",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.preview": "Preview", "ui.preview": "Preview",
"ui.split": "Split", "ui.split": "Split",
@@ -18,5 +19,15 @@
"ui.saved": "Saved", "ui.saved": "Saved",
"ui.discardConfirm": "Discard unsaved changes and reload from disk?", "ui.discardConfirm": "Discard unsaved changes and reload from disk?",
"ui.loading": "Loading...", "ui.loading": "Loading...",
"ui.loadFailed": "Could not load the file. Please try again." "ui.loadFailed": "Could not load the file. Please try again.",
"ui.md.heading": "Heading",
"ui.md.bold": "Bold",
"ui.md.italic": "Italic",
"ui.md.link": "Link",
"ui.md.inlineCode": "Inline code",
"ui.md.codeBlock": "Code block",
"ui.md.bulletList": "Bullet list",
"ui.md.numberedList": "Numbered list",
"ui.md.quote": "Quote",
"ui.md.taskItem": "Task item"
} }
+12 -1
View File
@@ -5,6 +5,7 @@
"contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown", "contributions.openProviders.verstak.default-editor.markdown.title": "Стандартный редактор Markdown",
"contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок", "contributions.openProviders.verstak.default-editor.notes-markdown.title": "Стандартный редактор Markdown-заметок",
"ui.notesContext": "контекст заметки", "ui.notesContext": "контекст заметки",
"ui.note": "Заметка",
"ui.edit": "Редактировать", "ui.edit": "Редактировать",
"ui.preview": "Просмотр", "ui.preview": "Просмотр",
"ui.split": "Разделить", "ui.split": "Разделить",
@@ -18,5 +19,15 @@
"ui.saved": "Сохранено", "ui.saved": "Сохранено",
"ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?", "ui.discardConfirm": "Отменить несохранённые изменения и перечитать файл с диска?",
"ui.loading": "Загрузка...", "ui.loading": "Загрузка...",
"ui.loadFailed": "Не удалось загрузить файл. Повторите попытку." "ui.loadFailed": "Не удалось загрузить файл. Повторите попытку.",
"ui.md.heading": "Заголовок",
"ui.md.bold": "Жирный текст",
"ui.md.italic": "Курсив",
"ui.md.link": "Ссылка",
"ui.md.inlineCode": "Встроенный код",
"ui.md.codeBlock": "Блок кода",
"ui.md.bulletList": "Маркированный список",
"ui.md.numberedList": "Нумерованный список",
"ui.md.quote": "Цитата",
"ui.md.taskItem": "Пункт задачи"
} }
+197 -97
View File
@@ -34,10 +34,10 @@
'.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}', '.files-breadcrumb-item:hover{background:var(--vt-color-accent-muted,rgba(78,204,163,.14))}',
'.files-breadcrumb-current{color:var(--vt-color-text-primary,#f4f7fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}', '.files-breadcrumb-current{color:var(--vt-color-text-primary,#f4f7fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.files-breadcrumb-sep{color:var(--vt-color-text-muted,#7f8aa3)}', '.files-breadcrumb-sep{color:var(--vt-color-text-muted,#7f8aa3)}',
'.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}', '.files-filter,.files-sort{font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-filter{width:11rem}', '.files-filter{width:11rem}',
'.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.files-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}', '.files-sort{width:9.5rem;appearance:none;background-color:#0f1424;background-image:linear-gradient(45deg,transparent 50%,#8b8ba8 50%),linear-gradient(135deg,#8b8ba8 50%,transparent 50%);background-position:calc(100% - 14px) 50%,calc(100% - 9px) 50%;background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-right:1.6rem}.files-sort option{background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb)}',
'.files-filter:focus,.files-sort:focus,.files-create-input:focus,.files-rename-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}', '.files-filter:focus,.files-sort:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-list{flex:1;overflow:auto;min-height:0}', '.files-list{flex:1;overflow:auto;min-height:0}',
'.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 220px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}', '.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 220px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}',
'.files-header{position:sticky;top:0;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;z-index:1}', '.files-header{position:sticky;top:0;background:var(--vt-color-surface-muted,#111629);color:var(--vt-color-text-muted,#7f8aa3);font-size:.7rem;text-transform:uppercase;letter-spacing:.04em;z-index:1}',
@@ -60,10 +60,6 @@
'.files-empty-btn svg{width:15px;height:15px;display:block;fill:currentColor}', '.files-empty-btn svg{width:15px;height:15px;display:block;fill:currentColor}',
'.files-error{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);gap:.5rem;padding:1rem}', '.files-error{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);gap:.5rem;padding:1rem}',
'.files-error-msg{font-size:.85rem;color:var(--vt-color-text-secondary,#b7c0d4);max-width:420px;text-align:center}', '.files-error-msg{font-size:.85rem;color:var(--vt-color-text-secondary,#b7c0d4);max-width:420px;text-align:center}',
'.files-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.files-field-stack{display:flex;flex:1;min-width:160px;flex-direction:column;gap:.25rem}',
'.files-panel-error{display:none;color:#ff9aaa;font-size:.72rem;line-height:1.2}',
'.files-create-input,.files-rename-input{flex:1;min-width:160px}',
'@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}', '@media(max-width:760px){.files-header,.files-item{grid-template-columns:minmax(130px,1fr) 70px 0 0 150px}.files-header span:nth-child(3),.files-header span:nth-child(4),.files-item-meta.hide-narrow{display:none}.files-toolbar{align-items:stretch}.files-filter,.files-sort{width:100%}}',
'.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:var(--vt-color-surface,#15152c);border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);padding:6px 0;box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42));font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);user-select:none}', '.files-ctx-menu{position:fixed;z-index:9999;min-width:180px;background:var(--vt-color-surface,#15152c);border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-md,6px);padding:6px 0;box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42));font-size:.84rem;color:var(--vt-color-text-primary,#f4f7fb);user-select:none}',
'.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}', '.files-ctx-menu-item{padding:6px 16px;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:.5rem}',
@@ -75,6 +71,12 @@
'.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}', '.files-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}', '.files-modal{width:400px;max-width:90vw;padding:24px;background:#1a1a2e;border:1px solid #333;border-radius:12px;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}', '.files-modal-title{font-size:.95rem;line-height:1.5;margin-bottom:20px;word-wrap:break-word}',
'.files-modal-form{display:grid;gap:.65rem}',
'.files-modal-form .files-modal-title{margin-bottom:0}',
'.files-modal-input{width:100%;font:inherit;font-size:.86rem;padding:.52rem .65rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.files-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.files-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.files-modal-error{display:none;color:#ff9aaa;font-size:.78rem;line-height:1.35}',
'.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}', '.files-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}', '.files-modal-btn{font-size:.82rem;padding:.4rem 1rem;border:1px solid #333;border-radius:6px;cursor:pointer;font-family:inherit}',
'.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}', '.files-modal-btn.cancel{background:#2a2a4e;color:#ccc;border-color:#444}',
@@ -222,7 +224,7 @@
return EXT_MAP[ext] || 'generic'; return EXT_MAP[ext] || 'generic';
} }
function fileIconLabel(category) { function fileIconLabel(category, translate) {
var labels = { var labels = {
folder: 'Folder', folder: 'Folder',
markdown: 'Markdown file', markdown: 'Markdown file',
@@ -242,7 +244,8 @@
json: 'JSON file', json: 'JSON file',
generic: 'File' generic: 'File'
}; };
return labels[category] || labels.generic; var label = labels[category] || labels.generic;
return typeof translate === 'function' ? translate('ui.icon.' + (labels[category] ? category : 'generic'), null, label) : label;
} }
function fileIcon(entry) { function fileIcon(entry) {
@@ -265,8 +268,8 @@
return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
} }
function typeLabel(entry) { function typeLabel(entry, folderLabel) {
if (entry.type === 'folder') return 'folder'; if (entry.type === 'folder') return folderLabel || 'Folder';
return (entry.extension || extension(entry.name) || 'file').toLowerCase(); return (entry.extension || extension(entry.name) || 'file').toLowerCase();
} }
@@ -320,15 +323,16 @@
return Promise.reject(new Error('clipboard unavailable')); return Promise.reject(new Error('clipboard unavailable'));
} }
function showExternalFallback(entry, mode) { function showExternalFallback(entry, mode, labels) {
if (!entry) return; if (!entry) return;
labels = labels || {};
var pathToShow = entry.relativePath; var pathToShow = entry.relativePath;
if (mode === 'explorer' && entry.type !== 'folder') { if (mode === 'explorer' && entry.type !== 'folder') {
pathToShow = parentPath(entry.relativePath) || entry.relativePath; pathToShow = parentPath(entry.relativePath) || entry.relativePath;
} }
var title = mode === 'explorer' ? 'Show in Explorer' : 'Open External'; var title = mode === 'explorer' ? (labels.showInFolder || 'Show in folder') : (labels.openExternal || 'Open externally');
var message = title + ' failed.\nVault-relative path:\n' + pathToShow; var message = (labels.externalFailed || '{action} failed.').replace('{action}', title) + '\n' + (labels.vaultRelativePath || 'Vault-relative path:') + '\n' + pathToShow;
confirmModal(message, { confirmText: 'Copy Path', cancelText: 'Close' }).then(function (copy) { confirmModal(message, { confirmText: labels.copyPath || 'Copy path', cancelText: labels.close || 'Close' }).then(function (copy) {
if (!copy) return; if (!copy) return;
copyTextToClipboard(pathToShow).catch(function (err) { copyTextToClipboard(pathToShow).catch(function (err) {
console.error('[files] copy path failed:', err); console.error('[files] copy path failed:', err);
@@ -346,7 +350,7 @@
var workspaceNode = props && props.workspaceNode; var workspaceNode = props && props.workspaceNode;
var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || ''); var workspaceRoot = cleanPath(props && (props.workspaceRootPath || (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id))) || '');
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || 'Workspace'; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title || workspaceNode.id)) || '';
window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {}; window.__filesHistoryByWorkspace = window.__filesHistoryByWorkspace || {};
var historyKey = workspaceRoot || workspaceName; var historyKey = workspaceRoot || workspaceName;
var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' }; var savedHistory = window.__filesHistoryByWorkspace[historyKey] || { stack: [''], index: 0, currentPath: '' };
@@ -358,6 +362,12 @@
var sortMode = 'folder-name'; var sortMode = 'folder-name';
var createMode = ''; var createMode = '';
var renameTarget = null; var renameTarget = null;
var createModal = null;
var createInput = null;
var createError = null;
var renameModal = null;
var renameInput = null;
var renameError = null;
var disposed = false; var disposed = false;
var fileActions = []; var fileActions = [];
var contextMenuEntries = []; var contextMenuEntries = [];
@@ -374,6 +384,10 @@
return fallback || key; return fallback || key;
} }
function displayType(entry) {
return typeLabel(entry, tr('ui.folderType', null, 'Folder'));
}
function reportError(key, fallback, err) { function reportError(key, fallback, err) {
if (typeof console !== 'undefined' && typeof console.warn === 'function') { if (typeof console !== 'undefined' && typeof console.warn === 'function') {
console.warn('[verstak.files] ' + key, err); console.warn('[verstak.files] ' + key, err);
@@ -438,32 +452,6 @@
var listContainer = el('div', { className: 'files-list', 'data-files-list': '' }); var listContainer = el('div', { className: 'files-list', 'data-files-list': '' });
containerEl.appendChild(listContainer); containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var createField = el('div', { className: 'files-field-stack' });
var createInput = el('input', { className: 'files-create-input', 'data-files-create-input': '' });
var createError = el('div', { className: 'files-panel-error', 'data-files-create-error': '', role: 'alert' });
var createConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-create-confirm': '' }, [tr('ui.create', null, 'Create')]);
var createCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
createField.appendChild(createInput);
createField.appendChild(createError);
createPanel.appendChild(createField);
createPanel.appendChild(createConfirm);
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'files-panel', style: { display: 'none' } });
var renameField = el('div', { className: 'files-field-stack' });
var renameInput = el('input', { className: 'files-rename-input', 'data-files-rename-input': '' });
var renameError = el('div', { className: 'files-panel-error', 'data-files-rename-error': '', role: 'alert' });
var renameConfirm = el('button', { className: 'files-toolbar-btn', 'data-files-rename-confirm': '' }, [tr('ui.rename', null, 'Rename')]);
var renameCancel = el('button', { className: 'files-toolbar-btn' }, [tr('ui.cancel', null, 'Cancel')]);
renameField.appendChild(renameInput);
renameField.appendChild(renameError);
renamePanel.appendChild(renameField);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
function selectedEntry() { function selectedEntry() {
var keys = Object.keys(selectedPaths); var keys = Object.keys(selectedPaths);
if (keys.length === 0) return null; if (keys.length === 0) return null;
@@ -540,7 +528,7 @@
if (a.type !== 'folder' && b.type === 'folder') return 1; if (a.type !== 'folder' && b.type === 'folder') return 1;
} }
if (sortMode === 'type') { if (sortMode === 'type') {
var typeCmp = typeLabel(a).localeCompare(typeLabel(b)); var typeCmp = displayType(a).localeCompare(displayType(b));
if (typeCmp) return typeCmp; if (typeCmp) return typeCmp;
} }
if (sortMode === 'modified-desc') { if (sortMode === 'modified-desc') {
@@ -662,7 +650,7 @@
shown.forEach(function (entry) { shown.forEach(function (entry) {
var iconCategory = fileIconCategory(entry); var iconCategory = fileIconCategory(entry);
var iconLabel = fileIconLabel(iconCategory); var iconLabel = fileIconLabel(iconCategory, tr);
var row = el('div', { var row = el('div', {
className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''), className: 'files-item' + (selectedPaths[entry.relativePath] ? ' selected' : ''),
'data-file-name': entry.name, 'data-file-name': entry.name,
@@ -695,7 +683,7 @@
el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }), el('span', { className: 'files-item-icon', 'data-file-icon': iconCategory, title: iconLabel, 'aria-label': iconLabel, innerHTML: fileIcon(entry) }),
el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name }) el('span', { className: 'files-item-name', textContent: entry.name, title: entry.name })
]), ]),
el('span', { className: 'files-item-meta' }, [typeLabel(entry)]), el('span', { className: 'files-item-meta' }, [displayType(entry)]),
el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]), el('span', { className: 'files-item-meta hide-narrow' }, [entry.type === 'folder' ? '' : formatSize(entry.size)]),
el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]), el('span', { className: 'files-item-meta hide-narrow' }, [formatDate(entry.modifiedAt)]),
el('div', { className: 'files-row-actions' }, [ el('div', { className: 'files-row-actions' }, [
@@ -820,35 +808,131 @@
}).catch(function (err) { console.error('[files] openResource error:', err); }); }).catch(function (err) { console.error('[files] openResource error:', err); });
} }
function showFileFormModal(mode, details) {
var isRename = mode === 'rename';
var overlay = el('div', {
className: 'files-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-files-' + mode + '-modal', '');
var input = el('input', {
className: 'files-modal-input',
type: 'text',
value: details.value || '',
placeholder: details.placeholder || '',
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-files-' + mode + '-input', '');
input.value = details.value || '';
var error = el('div', {
className: 'files-modal-error',
role: 'alert'
});
error.setAttribute('data-files-' + mode + '-error', '');
function closeForm() {
if (isRename) cancelRename();
else cancelCreate();
}
function confirmForm() {
if (isRename) confirmRename();
else confirmCreate();
}
input.addEventListener('input', function () {
if (isRename) setRenameError('');
else setCreateError('');
});
input.addEventListener('keydown', function (event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
confirmForm();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
closeForm();
}
});
var modal = el('div', {
className: 'files-modal files-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': details.title
}, [
el('div', { className: 'files-modal-title', textContent: details.title }),
input,
error,
el('div', { className: 'files-modal-actions' }, [
el('button', {
className: 'files-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: closeForm
}),
el('button', {
className: 'files-modal-btn confirm',
type: 'button',
textContent: details.confirmLabel,
onClick: confirmForm
})
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
function startCreate(mode) { function startCreate(mode) {
cancelCreate();
createMode = mode; createMode = mode;
createInput.value = ''; createModal = showFileFormModal('create', {
createInput.placeholder = mode === 'folder' ? 'Folder name' : (mode === 'markdown' ? 'Markdown file name' : 'Text file name'); title: mode === 'folder'
setCreateError(''); ? tr('ui.createFolderTitle', null, 'Create folder')
createPanel.style.display = 'flex'; : (mode === 'markdown'
createInput.focus(); ? tr('ui.createMarkdownTitle', null, 'Create Markdown file')
: tr('ui.createTextTitle', null, 'Create text file')),
placeholder: mode === 'folder'
? tr('ui.folderName', null, 'Folder name')
: (mode === 'markdown'
? tr('ui.markdownFileName', null, 'Markdown file name')
: tr('ui.textFileName', null, 'Text file name')),
confirmLabel: tr('ui.create', null, 'Create')
});
} }
function setCreateError(message) { function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || ''; createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none'; createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false'); createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function validateCreateName(name) { function validateCreateName(name) {
if (!name) return 'Name is required'; if (!name) return tr('ui.nameRequired', null, 'Enter a name.');
if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return 'Invalid characters in name'; if (/[\\/:*?"<>|\x00-\x1f]/.test(name)) return tr('ui.invalidCharacters', null, 'The name contains invalid characters.');
if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return 'Invalid name'; if (name === '.' || name === '..' || name[0] === ' ' || name[name.length - 1] === ' ' || name[name.length - 1] === '.') return tr('ui.invalidName', null, 'Enter a valid name.');
return ''; return '';
} }
function cancelCreate() { function cancelCreate() {
createMode = ''; createMode = '';
setCreateError(''); if (createModal && typeof createModal.remove === 'function') createModal.remove();
createPanel.style.display = 'none'; else if (createModal && createModal.parentNode) createModal.parentNode.removeChild(createModal);
createModal = null;
createInput = null;
createError = null;
} }
function confirmCreate() { function confirmCreate() {
if (!createInput || !createMode) return;
var name = createInput.value.trim(); var name = createInput.value.trim();
var validationError = validateCreateName(name); var validationError = validateCreateName(name);
if (validationError) { if (validationError) {
@@ -877,39 +961,49 @@
function beginRename(entry) { function beginRename(entry) {
entry = entry || selectedEntry(); entry = entry || selectedEntry();
if (!entry) return; if (!entry) return;
cancelRename();
renameTarget = entry; renameTarget = entry;
renameInput.value = entry.name; renameModal = showFileFormModal('rename', {
setRenameError(''); title: tr('ui.renameTitle', { name: entry.name }, 'Rename ' + entry.name),
renamePanel.style.display = 'flex'; placeholder: tr('ui.fileName', null, 'File or folder name'),
renameInput.focus(); value: entry.name,
renameInput.select(); confirmLabel: tr('ui.rename', null, 'Rename')
});
} }
function cancelRename() { function cancelRename() {
renameTarget = null; renameTarget = null;
setRenameError(''); if (renameModal && typeof renameModal.remove === 'function') renameModal.remove();
renamePanel.style.display = 'none'; else if (renameModal && renameModal.parentNode) renameModal.parentNode.removeChild(renameModal);
renameModal = null;
renameInput = null;
renameError = null;
} }
function setRenameError(message) { function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || ''; renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none'; renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false'); renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmRename() { function confirmRename() {
if (!renameTarget) return; if (!renameTarget || !renameInput) return;
var newName = renameInput.value.trim(); var newName = renameInput.value.trim();
if (!newName || newName === renameTarget.name) { if (!newName) {
setRenameError(tr('ui.nameRequired', null, 'Enter a name.'));
return;
}
if (newName === renameTarget.name) {
cancelRename(); cancelRename();
return; return;
} }
if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) { if (/[\\/:*?"<>|\x00-\x1f]/.test(newName)) {
setRenameError('Invalid characters in name'); setRenameError(tr('ui.invalidCharacters', null, 'The name contains invalid characters.'));
return; return;
} }
if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') { if (newName === '.' || newName === '..' || newName[0] === ' ' || newName[newName.length - 1] === ' ' || newName[newName.length - 1] === '.') {
setRenameError('Invalid name'); setRenameError(tr('ui.invalidName', null, 'Enter a valid name.'));
return; return;
} }
var from = renameTarget.relativePath; var from = renameTarget.relativePath;
@@ -917,17 +1011,17 @@
var to = targetParent ? targetParent + '/' + newName : newName; var to = targetParent ? targetParent + '/' + newName : newName;
api.files.metadata(to).then(function () { api.files.metadata(to).then(function () {
if (to.toLowerCase() === from.toLowerCase() && to !== from) { if (to.toLowerCase() === from.toLowerCase() && to !== from) {
setRenameError('Name differs only by case'); setRenameError(tr('ui.nameDiffersOnlyByCase', null, 'The name differs only by letter case.'));
return; return;
} }
setRenameError('A file with that name already exists'); setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
}, function () { }, function () {
api.files.move(from, to, { overwrite: false }).then(function () { api.files.move(from, to, { overwrite: false }).then(function () {
cancelRename(); cancelRename();
loadEntries(); loadEntries();
}).catch(function (err) { }).catch(function (err) {
if (isConflictError(err)) { if (isConflictError(err)) {
setRenameError('A file with that name already exists'); setRenameError(tr('ui.nameConflict', null, 'An item with that name already exists.'));
return; return;
} }
setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err)); setRenameError(reportError('ui.renameError', 'Could not rename this item. Please try again.', err));
@@ -938,7 +1032,11 @@
function trashEntry(entry) { function trashEntry(entry) {
var count = selectedCount(); var count = selectedCount();
if (entry) { if (entry) {
confirmModal('Move "' + entry.name + '" to trash?', { danger: true }).then(function (ok) { confirmModal(tr('ui.trashConfirm', { name: entry.name }, 'Move "' + entry.name + '" to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return; if (!ok) return;
api.files.trash(entry.relativePath).then(function () { api.files.trash(entry.relativePath).then(function () {
loadEntries(); loadEntries();
@@ -947,7 +1045,11 @@
}); });
}); });
} else if (count > 1) { } else if (count > 1) {
confirmModal('Move ' + count + ' items to trash?', { danger: true }).then(function (ok) { confirmModal(tr('ui.trashManyConfirm', { count: count }, 'Move ' + count + ' items to trash?'), {
danger: true,
confirmText: tr('ui.trash', null, 'Move to trash'),
cancelText: tr('ui.cancel', null, 'Cancel')
}).then(function (ok) {
if (!ok) return; if (!ok) return;
var paths = Object.keys(selectedPaths); var paths = Object.keys(selectedPaths);
Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () { Promise.allSettled(paths.map(function (p) { return api.files.trash(p); })).then(function () {
@@ -963,14 +1065,6 @@
filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); }); filterInput.addEventListener('input', function () { filterText = filterInput.value; renderList(); });
sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); }); sortSelect.addEventListener('change', function () { sortMode = sortSelect.value; renderList(); });
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', cancelCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', cancelRename);
createInput.addEventListener('input', function () { setCreateError(''); });
createInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmCreate(); if (event.key === 'Escape') cancelCreate(); });
renameInput.addEventListener('input', function () { setRenameError(''); });
renameInput.addEventListener('keydown', function (event) { if (event.key === 'Enter') confirmRename(); if (event.key === 'Escape') cancelRename(); });
/* --- Context menu --- */ /* --- Context menu --- */
var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } }); var ctxMenu = el('div', { className: 'files-ctx-menu', style: { display: 'none' } });
document.body.appendChild(ctxMenu); document.body.appendChild(ctxMenu);
@@ -1000,15 +1094,23 @@
function openExternalEntry(entry, mode) { function openExternalEntry(entry, mode) {
if (!entry) return; if (!entry) return;
var fallbackLabels = {
openExternal: tr('ui.openExternal', null, 'Open externally'),
showInFolder: tr('ui.showInFolder', null, 'Show in folder'),
externalFailed: tr('ui.externalFailed', null, '{action} failed.'),
vaultRelativePath: tr('ui.vaultRelativePath', null, 'Vault-relative path:'),
copyPath: tr('ui.copyPath', null, 'Copy path'),
close: tr('ui.close', null, 'Close')
};
var filesApi = api && api.files; var filesApi = api && api.files;
var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal; var action = mode === 'explorer' ? filesApi && filesApi.showInFolder : filesApi && filesApi.openExternal;
if (typeof action !== 'function') { if (typeof action !== 'function') {
showExternalFallback(entry, mode); showExternalFallback(entry, mode, fallbackLabels);
return; return;
} }
action(entry.relativePath).catch(function (err) { action(entry.relativePath).catch(function (err) {
console.warn('[verstak.files] external open error:', err); console.warn('[verstak.files] external open error:', err);
showExternalFallback(entry, mode); showExternalFallback(entry, mode, fallbackLabels);
}); });
} }
@@ -1054,27 +1156,27 @@
renderList(); renderList();
} }
var isFolder = entry.type === 'folder'; var isFolder = entry.type === 'folder';
ctxMenu.appendChild(ctxItem(isFolder ? 'Open Folder' : 'Open', '', function () { openEntry(entry); }, 'open', 'open')); ctxMenu.appendChild(ctxItem(isFolder ? tr('ui.openFolder', null, 'Open folder') : tr('ui.open', null, 'Open'), '', function () { openEntry(entry); }, 'open', 'open'));
ctxMenu.appendChild(ctxItem('Open External', '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external')); ctxMenu.appendChild(ctxItem(tr('ui.openExternal', null, 'Open externally'), '', function () { openExternalEntry(entry, 'external'); }, 'open-external', 'external'));
ctxMenu.appendChild(ctxItem('Show in Explorer', '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer')); ctxMenu.appendChild(ctxItem(tr('ui.showInFolder', null, 'Show in folder'), '', function () { openExternalEntry(entry, 'explorer'); }, 'show-in-explorer', 'explorer'));
appendContributionMenuItems(entry); appendContributionMenuItems(entry);
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Rename', '', function () { beginRename(entry); }, 'rename', 'rename')); ctxMenu.appendChild(ctxItem(tr('ui.rename', null, 'Rename'), '', function () { beginRename(entry); }, 'rename', 'rename'));
if (entry.type !== 'folder') { if (entry.type !== 'folder') {
ctxMenu.appendChild(ctxItem('Duplicate', '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate')); ctxMenu.appendChild(ctxItem(tr('ui.duplicate', null, 'Duplicate'), '', function () { duplicateEntry(entry); }, 'duplicate', 'duplicate'));
} }
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Cut', '', function () { cutSelection(); }, 'cut', 'cut')); ctxMenu.appendChild(ctxItem(tr('ui.cut', null, 'Cut'), '', function () { cutSelection(); }, 'cut', 'cut'));
ctxMenu.appendChild(ctxItem('Copy', '', function () { copySelection(); }, 'copy', 'copy')); ctxMenu.appendChild(ctxItem(tr('ui.copy', null, 'Copy'), '', function () { copySelection(); }, 'copy', 'copy'));
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Move to Trash', 'danger', function () { trashEntry(); }, 'trash', 'trash')); ctxMenu.appendChild(ctxItem(tr('ui.trash', null, 'Move to trash'), 'danger', function () { trashEntry(); }, 'trash', 'trash'));
} else { } else {
ctxMenu.appendChild(ctxItem('New Folder', '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newFolder', null, 'New folder'), '', function () { startCreate('folder'); }, 'new-folder', 'folderAdd'));
ctxMenu.appendChild(ctxItem('New Markdown', '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newMarkdown', null, 'New Markdown file'), '', function () { startCreate('markdown'); }, 'new-markdown', 'markdownAdd'));
ctxMenu.appendChild(ctxItem('New Text', '', function () { startCreate('text'); }, 'new-text', 'textAdd')); ctxMenu.appendChild(ctxItem(tr('ui.newText', null, 'New text file'), '', function () { startCreate('text'); }, 'new-text', 'textAdd'));
if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) { if (window.__filesClipboard && window.__filesClipboard.items && window.__filesClipboard.items.length) {
ctxMenu.appendChild(ctxSep()); ctxMenu.appendChild(ctxSep());
ctxMenu.appendChild(ctxItem('Paste', '', function () { pasteEntry(); }, 'paste', 'paste')); ctxMenu.appendChild(ctxItem(tr('ui.paste', null, 'Paste'), '', function () { pasteEntry(); }, 'paste', 'paste'));
} }
} }
ctxMenu.style.display = 'block'; ctxMenu.style.display = 'block';
@@ -1167,7 +1269,7 @@
var clip = window.__filesClipboard; var clip = window.__filesClipboard;
if (!clip || !clip.items || clip.items.length === 0) return; if (!clip || !clip.items || clip.items.length === 0) return;
if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) { if (clip.workspaceRoot && clip.workspaceRoot !== workspaceRoot) {
window.alert('Clipboard items belong to another workspace.'); window.alert(tr('ui.clipboardDifferentDeal', null, 'Clipboard items belong to another Deal.'));
return; return;
} }
var destinationDir = scopedPath(currentPath); var destinationDir = scopedPath(currentPath);
@@ -1508,10 +1610,6 @@
item[0].setAttribute('aria-label', label); item[0].setAttribute('aria-label', label);
}); });
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter current folder'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@@ -1530,6 +1628,8 @@
containerEl.__filesCleanup = function () { containerEl.__filesCleanup = function () {
disposed = true; disposed = true;
cancelCreate();
cancelRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
document.removeEventListener('click', onDocClick); document.removeEventListener('click', onDocClick);
+43 -1
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Files", "manifest.name": "Files",
"manifest.description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "manifest.description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"contributions.workspaceItems.verstak.files.workspace.title": "Files", "contributions.workspaceItems.verstak.files.workspace.title": "Files",
"ui.back": "Back", "ui.back": "Back",
"ui.forward": "Forward", "ui.forward": "Forward",
@@ -10,7 +10,11 @@
"ui.newMarkdown": "New markdown file", "ui.newMarkdown": "New markdown file",
"ui.newText": "New text file", "ui.newText": "New text file",
"ui.open": "Open", "ui.open": "Open",
"ui.openFolder": "Open folder",
"ui.openExternal": "Open externally",
"ui.showInFolder": "Show in folder",
"ui.rename": "Rename", "ui.rename": "Rename",
"ui.duplicate": "Duplicate",
"ui.trash": "Move to trash", "ui.trash": "Move to trash",
"ui.cut": "Cut", "ui.cut": "Cut",
"ui.copy": "Copy", "ui.copy": "Copy",
@@ -22,8 +26,46 @@
"ui.column.modified": "Modified", "ui.column.modified": "Modified",
"ui.column.size": "Size", "ui.column.size": "Size",
"ui.column.actions": "Actions", "ui.column.actions": "Actions",
"ui.folderType": "Folder",
"ui.icon.folder": "Folder",
"ui.icon.markdown": "Markdown file",
"ui.icon.image": "Image file",
"ui.icon.video": "Video file",
"ui.icon.audio": "Audio file",
"ui.icon.archive": "Archive file",
"ui.icon.pdf": "PDF file",
"ui.icon.text": "Text file",
"ui.icon.document": "Document file",
"ui.icon.spreadsheet": "Spreadsheet file",
"ui.icon.presentation": "Presentation file",
"ui.icon.code": "Code file",
"ui.icon.database": "Database file",
"ui.icon.font": "Font file",
"ui.icon.config": "Config file",
"ui.icon.json": "JSON file",
"ui.icon.generic": "File",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.createFolderTitle": "Create folder",
"ui.createMarkdownTitle": "Create Markdown file",
"ui.createTextTitle": "Create text file",
"ui.renameTitle": "Rename {name}",
"ui.folderName": "Folder name",
"ui.markdownFileName": "Markdown file name",
"ui.textFileName": "Text file name",
"ui.fileName": "File or folder name",
"ui.nameRequired": "Enter a name.",
"ui.invalidCharacters": "The name contains invalid characters.",
"ui.invalidName": "Enter a valid name.",
"ui.nameDiffersOnlyByCase": "The name differs only by letter case.",
"ui.nameConflict": "An item with that name already exists.",
"ui.externalFailed": "{action} failed.",
"ui.vaultRelativePath": "Vault-relative path:",
"ui.copyPath": "Copy path",
"ui.close": "Close",
"ui.trashConfirm": "Move \"{name}\" to trash?",
"ui.trashManyConfirm": "Move {count} items to trash?",
"ui.clipboardDifferentDeal": "Clipboard items belong to another Deal.",
"ui.emptyFolder": "Empty folder", "ui.emptyFolder": "Empty folder",
"ui.noMatches": "No matches", "ui.noMatches": "No matches",
"ui.clearFilter": "Clear filter", "ui.clearFilter": "Clear filter",
+43 -1
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Файлы", "manifest.name": "Файлы",
"manifest.description": "Файловый менеджер рабочего пространства с созданием, переименованием, корзиной, фильтрацией и сортировкой.", "manifest.description": "Файловый менеджер Дела с созданием, переименованием, корзиной, фильтрацией и сортировкой.",
"contributions.workspaceItems.verstak.files.workspace.title": "Файлы", "contributions.workspaceItems.verstak.files.workspace.title": "Файлы",
"ui.back": "Назад", "ui.back": "Назад",
"ui.forward": "Вперёд", "ui.forward": "Вперёд",
@@ -10,7 +10,11 @@
"ui.newMarkdown": "Новый Markdown-файл", "ui.newMarkdown": "Новый Markdown-файл",
"ui.newText": "Новый текстовый файл", "ui.newText": "Новый текстовый файл",
"ui.open": "Открыть", "ui.open": "Открыть",
"ui.openFolder": "Открыть папку",
"ui.openExternal": "Открыть во внешнем приложении",
"ui.showInFolder": "Показать в папке",
"ui.rename": "Переименовать", "ui.rename": "Переименовать",
"ui.duplicate": "Создать копию",
"ui.trash": "Переместить в корзину", "ui.trash": "Переместить в корзину",
"ui.cut": "Вырезать", "ui.cut": "Вырезать",
"ui.copy": "Копировать", "ui.copy": "Копировать",
@@ -22,8 +26,46 @@
"ui.column.modified": "Изменён", "ui.column.modified": "Изменён",
"ui.column.size": "Размер", "ui.column.size": "Размер",
"ui.column.actions": "Действия", "ui.column.actions": "Действия",
"ui.folderType": "Папка",
"ui.icon.folder": "Папка",
"ui.icon.markdown": "Файл Markdown",
"ui.icon.image": "Изображение",
"ui.icon.video": "Видео",
"ui.icon.audio": "Аудиофайл",
"ui.icon.archive": "Архив",
"ui.icon.pdf": "PDF-файл",
"ui.icon.text": "Текстовый файл",
"ui.icon.document": "Документ",
"ui.icon.spreadsheet": "Таблица",
"ui.icon.presentation": "Презентация",
"ui.icon.code": "Исходный код",
"ui.icon.database": "База данных",
"ui.icon.font": "Шрифт",
"ui.icon.config": "Файл конфигурации",
"ui.icon.json": "JSON-файл",
"ui.icon.generic": "Файл",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.createFolderTitle": "Создать папку",
"ui.createMarkdownTitle": "Создать Markdown-файл",
"ui.createTextTitle": "Создать текстовый файл",
"ui.renameTitle": "Переименовать {name}",
"ui.folderName": "Название папки",
"ui.markdownFileName": "Название Markdown-файла",
"ui.textFileName": "Название текстового файла",
"ui.fileName": "Название файла или папки",
"ui.nameRequired": "Введите название.",
"ui.invalidCharacters": "В названии есть недопустимые символы.",
"ui.invalidName": "Введите корректное название.",
"ui.nameDiffersOnlyByCase": "Название отличается только регистром букв.",
"ui.nameConflict": "Элемент с таким названием уже существует.",
"ui.externalFailed": "Не удалось выполнить действие «{action}».",
"ui.vaultRelativePath": "Путь относительно хранилища:",
"ui.copyPath": "Скопировать путь",
"ui.close": "Закрыть",
"ui.trashConfirm": "Переместить «{name}» в корзину?",
"ui.trashManyConfirm": "Переместить в корзину {count} элементов?",
"ui.clipboardDifferentDeal": "Элементы буфера обмена принадлежат другому Делу.",
"ui.emptyFolder": "Папка пуста", "ui.emptyFolder": "Папка пуста",
"ui.noMatches": "Совпадений нет", "ui.noMatches": "Совпадений нет",
"ui.clearFilter": "Сбросить фильтр", "ui.clearFilter": "Сбросить фильтр",
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Files", "name": "Files",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.", "description": "Deal-scoped vault file explorer with create, rename, trash, filtering, sorting, and Workbench openResource integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "folder", "icon": "folder",
+42 -16
View File
@@ -108,7 +108,7 @@
function scopeFromProps(props) { function scopeFromProps(props) {
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
if (!workspaceRoot) return { mode: 'global', key: '', label: 'All workspaces', workspaceRoot: '' }; if (!workspaceRoot) return { mode: 'global', key: '', label: 'All Deals', workspaceRoot: '' };
return { return {
mode: 'workspace', mode: 'workspace',
key: WORKLOG_PREFIX + encodeKey(workspaceRoot), key: WORKLOG_PREFIX + encodeKey(workspaceRoot),
@@ -363,6 +363,32 @@
modalHost.setAttribute('hidden', 'hidden'); modalHost.setAttribute('hidden', 'hidden');
} }
function activityLabel(activity) {
var type = text(activity && activity.type);
var labels = {
'browser.capture.link': ['ui.activity.captureLink', 'Link captured'],
'browser.capture.selection': ['ui.activity.captureSelection', 'Selection captured'],
'browser.capture.page': ['ui.activity.capturePage', 'Page captured'],
'file.created': ['ui.activity.fileCreated', 'File created'],
'file.updated': ['ui.activity.fileUpdated', 'File updated'],
'file.deleted': ['ui.activity.fileDeleted', 'File deleted'],
'note.saved': ['ui.activity.noteSaved', 'Note saved'],
'workspace.created': ['ui.activity.dealCreated', 'Deal created'],
'workspace.renamed': ['ui.activity.dealRenamed', 'Deal renamed']
};
var label = labels[type] || ['ui.activity.generic', 'Activity'];
return tr(label[0], null, label[1]);
}
function entryMeta(entry) {
var facts = [entry.workspaceRootPath, entry.billable ? tr('ui.meta.billable', null, 'billable') : tr('ui.meta.nonBillable', null, 'non-billable')];
if (entry.activityIds.length) {
facts.push(tr(entry.activityIds.length === 1 ? 'ui.meta.activities.one' : 'ui.meta.activities.many', { count: entry.activityIds.length }, entry.activityIds.length + ' linked activities'));
}
if (entry.sourceTodoId) facts.push(tr('ui.meta.todo', null, 'linked todo'));
return facts.join(' · ');
}
function showEntryModal(existingEntry, candidate, completedTodo) { function showEntryModal(existingEntry, candidate, completedTodo) {
var editing = !!existingEntry; var editing = !!existingEntry;
var reviewingCandidate = !editing && !!candidate; var reviewingCandidate = !editing && !!candidate;
@@ -406,22 +432,22 @@
} }
var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [ var candidateContext = reviewingCandidate ? el('div', { className: 'journal-candidate-context', 'data-journal-candidate': candidate.candidateId }, [
el('strong', { textContent: 'Possible journal entry' }), el('strong', { textContent: tr('ui.candidate.title', null, 'Possible journal entry') }),
el('div', { textContent: 'Workspace: ' + candidate.workspaceRootPath }), el('div', { textContent: tr('ui.candidate.deal', { deal: candidate.workspaceRootPath }, 'Deal: ' + candidate.workspaceRootPath) }),
el('div', { textContent: 'Time: ' + candidateTime(candidate.startedAt) + ' - ' + candidateTime(candidate.endedAt) }), el('div', { textContent: tr('ui.candidate.time', { start: candidateTime(candidate.startedAt), end: candidateTime(candidate.endedAt) }, 'Time: ' + candidateTime(candidate.startedAt) + ' – ' + candidateTime(candidate.endedAt)) }),
el('div', { textContent: 'Estimated duration: ' + candidate.estimatedMinutes + ' min' }), el('div', { textContent: tr('ui.candidate.duration', { minutes: candidate.estimatedMinutes }, 'Estimated duration: ' + candidate.estimatedMinutes + ' min') }),
el('div', { textContent: 'Activities: ' + candidate.activityCount }) el('div', { textContent: tr('ui.candidate.activities', { count: candidate.activityCount }, 'Activities: ' + candidate.activityCount) })
]) : null; ]) : null;
var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [ var candidateActivities = reviewingCandidate ? el('fieldset', { className: 'journal-candidate-activities' }, [
el('legend', { textContent: 'Linked activities' }) el('legend', { textContent: tr('ui.candidate.linkedActivities', null, 'Linked activities') })
].concat(activityInputs.map(function (item) { ].concat(activityInputs.map(function (item) {
var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + item.activity.type + ' · ' + item.activity.activityId; var detail = (item.activity.occurredAt ? candidateTime(item.activity.occurredAt) + ' · ' : '') + activityLabel(item.activity);
return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]); return el('label', { className: 'journal-candidate-activity' }, [item.input, detail]);
}))) : null; }))) : null;
var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [ var todoContext = reviewingTodo ? el('div', { className: 'journal-candidate-context', 'data-journal-todo': completedTodo.id }, [
el('strong', { textContent: 'Completed todo' }), el('strong', { textContent: tr('ui.todo.title', null, 'Completed todo') }),
el('div', { textContent: 'Workspace: ' + completedTodo.workspaceRootPath }), el('div', { textContent: tr('ui.candidate.deal', { deal: completedTodo.workspaceRootPath }, 'Deal: ' + completedTodo.workspaceRootPath) }),
completedTodo.completedAt ? el('div', { textContent: 'Completed: ' + candidateTime(completedTodo.completedAt) }) : null completedTodo.completedAt ? el('div', { textContent: tr('ui.todo.completed', { time: candidateTime(completedTodo.completedAt) }, 'Completed: ' + candidateTime(completedTodo.completedAt)) }) : null
]) : null; ]) : null;
modalHost.innerHTML = ''; modalHost.innerHTML = '';
@@ -467,13 +493,13 @@
var handledThrough = text(formValue && formValue.handledThrough).trim(); var handledThrough = text(formValue && formValue.handledThrough).trim();
var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim(); var sourceTodoId = text(formValue && formValue.sourceTodoId || (existingEntry && existingEntry.sourceTodoId)).trim();
if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) { if (!existingEntry && sourceCandidateId && entries.some(function (entry) { return entry.sourceCandidateId === sourceCandidateId; })) {
statusText = 'A journal entry already references this candidate'; statusText = tr('ui.candidate.duplicate', null, 'A journal entry already references this candidate');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
} }
if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) { if (!existingEntry && sourceTodoId && entries.some(function (entry) { return entry.sourceTodoId === sourceTodoId; })) {
statusText = 'A journal entry already references this todo'; statusText = tr('ui.todo.duplicate', null, 'A journal entry already references this todo');
statusClass = 'error'; statusClass = 'error';
render(); render();
return; return;
@@ -500,7 +526,7 @@
entries = sortEntries(entries); entries = sortEntries(entries);
var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; }); var targetEntries = entries.filter(function (item) { return item.workspaceRootPath === workspaceRoot; });
closeEntryModal(); closeEntryModal();
statusText = existingEntry ? 'Entry updated' : 'Entry added'; statusText = existingEntry ? tr('ui.updated', null, 'Entry updated') : tr('ui.added', null, 'Entry added');
statusClass = ''; statusClass = '';
persist(workspaceRoot, targetEntries).then(function () { persist(workspaceRoot, targetEntries).then(function () {
if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined; if (!sessionID || !handledThrough || !api || !api.events || typeof api.events.publish !== 'function') return undefined;
@@ -535,9 +561,9 @@
el('div', { className: 'journal-main' }, [ el('div', { className: 'journal-main' }, [
el('div', { className: 'journal-entry-title', textContent: entry.title }), el('div', { className: 'journal-entry-title', textContent: entry.title }),
entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null, entry.summary ? el('div', { className: 'journal-summary', textContent: entry.summary }) : null,
el('div', { className: 'journal-meta', textContent: entry.workspaceRootPath + (entry.billable ? ' · billable' : ' · non-billable') + (entry.activityIds.length ? ' · ' + entry.activityIds.length + ' linked activities' : '') + (entry.sourceTodoId ? ' · linked todo' : '') }) el('div', { className: 'journal-meta', textContent: entryMeta(entry) })
]), ]),
el('div', { className: 'journal-minutes', textContent: entry.minutes + ' min' }), el('div', { className: 'journal-minutes', textContent: tr('ui.minutesValue', { minutes: entry.minutes }, entry.minutes + ' min') }),
scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [ scope.mode === 'workspace' ? el('div', { className: 'journal-row-actions' }, [
el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }), el('button', { className: 'journal-icon-btn', type: 'button', title: tr('ui.edit', null, 'Edit'), 'aria-label': tr('ui.edit', null, 'Edit'), 'data-journal-action': 'edit', innerHTML: iconSvg('edit'), onClick: function () { showEntryModal(entry); } }),
el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } }) el('button', { className: 'journal-icon-btn danger', type: 'button', title: tr('ui.delete', null, 'Delete'), 'aria-label': tr('ui.delete', null, 'Delete'), 'data-journal-action': 'delete', innerHTML: iconSvg('trash'), onClick: function () { deleteEntry(entry); } })
+30 -2
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Journal", "manifest.name": "Journal",
"manifest.description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "manifest.description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"contributions.views.verstak.journal.view.title": "Journal", "contributions.views.verstak.journal.view.title": "Journal",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Journal", "contributions.sidebarItems.verstak.journal.sidebar.title": "Journal",
"contributions.workspaceItems.verstak.journal.workspace.title": "Journal", "contributions.workspaceItems.verstak.journal.workspace.title": "Journal",
@@ -31,5 +31,33 @@
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.delete": "Delete", "ui.delete": "Delete",
"ui.entryCount.one": "{count} entry", "ui.entryCount.one": "{count} entry",
"ui.entryCount.many": "{count} entries" "ui.entryCount.many": "{count} entries",
"ui.candidate.title": "Possible journal entry",
"ui.candidate.deal": "Deal: {deal}",
"ui.candidate.time": "Time: {start} – {end}",
"ui.candidate.duration": "Estimated duration: {minutes} min",
"ui.candidate.activities": "Activities: {count}",
"ui.candidate.linkedActivities": "Linked activities",
"ui.candidate.duplicate": "A journal entry already references this candidate",
"ui.todo.title": "Completed todo",
"ui.todo.completed": "Completed: {time}",
"ui.todo.duplicate": "A journal entry already references this todo",
"ui.activity.captureLink": "Link captured",
"ui.activity.captureSelection": "Selection captured",
"ui.activity.capturePage": "Page captured",
"ui.activity.fileCreated": "File created",
"ui.activity.fileUpdated": "File updated",
"ui.activity.fileDeleted": "File deleted",
"ui.activity.noteSaved": "Note saved",
"ui.activity.dealCreated": "Deal created",
"ui.activity.dealRenamed": "Deal renamed",
"ui.activity.generic": "Activity",
"ui.updated": "Entry updated",
"ui.added": "Entry added",
"ui.minutesValue": "{minutes} min",
"ui.meta.billable": "billable",
"ui.meta.nonBillable": "non-billable",
"ui.meta.activities.one": "{count} linked activity",
"ui.meta.activities.many": "{count} linked activities",
"ui.meta.todo": "linked todo"
} }
+30 -2
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Журнал", "manifest.name": "Журнал",
"manifest.description": "Журнал рабочего пространства с ручными записями и необязательными связями с активностью.", "manifest.description": "Журнал Дела с ручными записями и необязательными связями с активностью.",
"contributions.views.verstak.journal.view.title": "Журнал", "contributions.views.verstak.journal.view.title": "Журнал",
"contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал", "contributions.sidebarItems.verstak.journal.sidebar.title": "Журнал",
"contributions.workspaceItems.verstak.journal.workspace.title": "Журнал", "contributions.workspaceItems.verstak.journal.workspace.title": "Журнал",
@@ -31,5 +31,33 @@
"ui.edit": "Изменить", "ui.edit": "Изменить",
"ui.delete": "Удалить", "ui.delete": "Удалить",
"ui.entryCount.one": "{count} запись", "ui.entryCount.one": "{count} запись",
"ui.entryCount.many": "Записей: {count}" "ui.entryCount.many": "Записей: {count}",
"ui.candidate.title": "Возможная запись журнала",
"ui.candidate.deal": "Дело: {deal}",
"ui.candidate.time": "Время: {start} – {end}",
"ui.candidate.duration": "Расчётная длительность: {minutes} мин",
"ui.candidate.activities": "Активностей: {count}",
"ui.candidate.linkedActivities": "Связанные активности",
"ui.candidate.duplicate": "Запись журнала уже ссылается на этого кандидата",
"ui.todo.title": "Выполненная задача",
"ui.todo.completed": "Выполнена: {time}",
"ui.todo.duplicate": "Запись журнала уже ссылается на эту задачу",
"ui.activity.captureLink": "Сохранена ссылка",
"ui.activity.captureSelection": "Захвачено выделение",
"ui.activity.capturePage": "Сохранена страница",
"ui.activity.fileCreated": "Создан файл",
"ui.activity.fileUpdated": "Изменён файл",
"ui.activity.fileDeleted": "Удалён файл",
"ui.activity.noteSaved": "Сохранена заметка",
"ui.activity.dealCreated": "Создано Дело",
"ui.activity.dealRenamed": "Переименовано Дело",
"ui.activity.generic": "Активность",
"ui.updated": "Запись обновлена",
"ui.added": "Запись добавлена",
"ui.minutesValue": "{minutes} мин",
"ui.meta.billable": "оплачиваемая",
"ui.meta.nonBillable": "неоплачиваемая",
"ui.meta.activities.one": "{count} связанная активность",
"ui.meta.activities.many": "Связанных активностей: {count}",
"ui.meta.todo": "связанная задача"
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Journal", "name": "Journal",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped journal with user-authored entries and optional Activity links.", "description": "Deal-scoped journal with user-authored entries and optional Activity links.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "book-open", "icon": "book-open",
+149 -74
View File
@@ -43,14 +43,16 @@
'.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}', '.notes-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--vt-color-text-muted,#7f8aa3);font-size:.85rem;padding:2rem;gap:.5rem;text-align:center}',
'.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}', '.notes-empty-hint{font-size:.75rem;color:var(--vt-color-text-muted,#7f8aa3)}',
'.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}', '.notes-error{flex:1;display:flex;align-items:center;justify-content:center;color:var(--vt-color-danger,#e94560);padding:1rem;font-size:.85rem}',
'.notes-panel{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-top:1px solid var(--vt-color-border,#202b46);flex-shrink:0;background:var(--vt-color-surface-muted,#111629)}',
'.notes-input{flex:1;font-size:.78rem;padding:.32rem .5rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none;min-width:120px}',
'.notes-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}', '.notes-title-bar{padding:.4rem .75rem;font-size:.72rem;color:var(--vt-color-text-muted,#7f8aa3);background:var(--vt-color-surface-muted,#111629);border-bottom:1px solid var(--vt-color-border,#202b46);text-transform:uppercase;letter-spacing:.04em}',
'.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}', '.notes-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:10000;display:flex;align-items:center;justify-content:center}',
'.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}', '.notes-modal{width:380px;max-width:90vw;padding:20px;background:#1a1a2e;border:1px solid #333;border-radius:10px;color:#e0e0e0;font-family:inherit;box-shadow:0 12px 40px rgba(0,0,0,.5)}',
'.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}', '.notes-modal-title{font-size:.9rem;font-weight:600;margin-bottom:12px}',
'.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}', '.notes-modal-msg{font-size:.82rem;color:#aaa;margin-bottom:16px;word-wrap:break-word}',
'.notes-modal-form{display:grid;gap:.55rem}',
'.notes-modal-input{width:100%;font:inherit;font-size:.84rem;padding:.5rem .6rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-sm,4px);background:#0f1424;color:var(--vt-color-text-primary,#f4f7fb);outline:none}',
'.notes-modal-input:focus{border-color:var(--vt-color-accent,#4ecca3);box-shadow:var(--vt-focus-ring,0 0 0 2px rgba(78,204,163,.34))}',
'.notes-modal-input[aria-invalid="true"]{border-color:var(--vt-color-danger,#e94560)}',
'.notes-modal-error{display:none;font-size:.78rem;line-height:1.35;color:#ffc6ce}',
'.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}', '.notes-modal-actions{display:flex;justify-content:flex-end;gap:8px}',
'.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}', '.notes-modal-btn{font-size:.8rem;padding:.35rem .9rem;border:1px solid #333;border-radius:5px;cursor:pointer;font-family:inherit}',
'.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}', '.notes-modal-btn.cancel{background:#2a2a4e;color:#ccc}',
@@ -171,7 +173,7 @@
var workspaceNode = props && props.workspaceNode; var workspaceNode = props && props.workspaceNode;
var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || ''; var workspaceRoot = (workspaceNode && (workspaceNode.rootPath || workspaceNode.name || workspaceNode.id)) || '';
var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || 'Workspace'; var workspaceName = workspaceRoot || (workspaceNode && (workspaceNode.name || workspaceNode.title)) || '';
var notes = []; var notes = [];
var selectedPath = ''; var selectedPath = '';
@@ -287,23 +289,12 @@
var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' }); var listContainer = el('div', { className: 'notes-list', 'data-notes-list': '' });
containerEl.appendChild(listContainer); containerEl.appendChild(listContainer);
var createPanel = el('div', { className: 'notes-panel', style: { display: 'none' } }); var createModal = null;
var createInput = el('input', { className: 'notes-input', 'data-notes-create-input': '', placeholder: tr('ui.noteTitle', null, 'Note title') }); var createInput = null;
var createConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.create', null, 'Create') }); var createError = null;
var createCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') }); var renameModal = null;
createPanel.appendChild(createInput); var renameInput = null;
createPanel.appendChild(createConfirm); var renameError = null;
createPanel.appendChild(createCancel);
containerEl.appendChild(createPanel);
var renamePanel = el('div', { className: 'notes-panel', style: { display: 'none' } });
var renameInput = el('input', { className: 'notes-input', 'data-notes-rename-input': '', placeholder: tr('ui.newTitle', null, 'New title') });
var renameConfirm = el('button', { className: 'notes-btn', textContent: tr('ui.rename', null, 'Rename') });
var renameCancel = el('button', { className: 'notes-btn', textContent: tr('ui.cancel', null, 'Cancel') });
renamePanel.appendChild(renameInput);
renamePanel.appendChild(renameConfirm);
renamePanel.appendChild(renameCancel);
containerEl.appendChild(renamePanel);
// ─── Core Functions ───────────────────────────────────── // ─── Core Functions ─────────────────────────────────────
@@ -495,28 +486,134 @@
}).catch(function (err) { console.error('[notes] openResource:', err); }); }).catch(function (err) { console.error('[notes] openResource:', err); });
} }
function conflictMessage(title, existingPath) {
return [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('');
}
function showNoteFormModal(mode, note) {
var isRename = mode === 'rename';
var modalTitle = isRename
? tr('ui.renameTitle', null, 'Rename note')
: tr('ui.createTitle', null, 'Create note');
var overlay = el('div', {
className: 'notes-modal-overlay',
role: 'presentation'
});
overlay.setAttribute('data-notes-' + mode + '-modal', '');
var input = el('input', {
className: 'notes-modal-input',
type: 'text',
value: isRename ? (note.title || fileName(note.path)) : '',
placeholder: isRename
? tr('ui.newTitle', null, 'New title')
: tr('ui.noteTitle', null, 'Note title'),
autocomplete: 'off',
'aria-invalid': 'false'
});
input.setAttribute('data-notes-' + mode + '-input', '');
input.value = isRename ? (note.title || fileName(note.path)) : '';
var error = el('div', {
className: 'notes-modal-error',
role: 'alert'
});
error.setAttribute('data-notes-' + mode + '-error', '');
var confirm = el('button', {
className: 'notes-modal-btn confirm',
type: 'button',
textContent: isRename ? tr('ui.rename', null, 'Rename') : tr('ui.create', null, 'Create'),
onClick: function () {
if (isRename) confirmRename();
else confirmCreate();
}
});
var cancel = el('button', {
className: 'notes-modal-btn cancel',
type: 'button',
textContent: tr('ui.cancel', null, 'Cancel'),
onClick: function () {
if (isRename) hideRename();
else hideCreate();
}
});
function handleFormKeydown(event) {
if (event.key === 'Enter') {
if (event.preventDefault) event.preventDefault();
if (isRename) confirmRename();
else confirmCreate();
}
if (event.key === 'Escape') {
if (event.preventDefault) event.preventDefault();
if (isRename) hideRename();
else hideCreate();
}
}
input.addEventListener('keydown', handleFormKeydown);
var modal = el('div', {
className: 'notes-modal notes-modal-form',
role: 'dialog',
'aria-modal': 'true',
'aria-label': modalTitle
}, [
el('div', { className: 'notes-modal-title', textContent: modalTitle }),
input,
error,
el('div', { className: 'notes-modal-actions' }, [cancel, confirm])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
if (isRename) {
renameInput = input;
renameError = error;
} else {
createInput = input;
createError = error;
}
input.focus();
if (isRename && input.select) input.select();
return overlay;
}
// ─── Create ──────────────────────────────────────────── // ─── Create ────────────────────────────────────────────
function showCreate() { function showCreate() {
createInput.value = ''; hideCreate();
createPanel.style.display = 'flex'; createModal = showNoteFormModal('create');
createInput.focus();
} }
function hideCreate() { function hideCreate() {
createPanel.style.display = 'none'; if (createModal) createModal.remove();
createModal = null;
createInput = null;
createError = null;
}
function setCreateError(message) {
if (!createError || !createInput) return;
createError.textContent = message || '';
createError.style.display = message ? 'block' : 'none';
createInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmCreate() { function confirmCreate() {
if (!createInput) return;
var title = createInput.value.trim(); var title = createInput.value.trim();
if (!title) return; if (!title) {
setCreateError(tr('ui.titleRequired', null, 'Enter a note title.'));
createInput.focus();
return;
}
setStatus(tr('ui.creating', null, 'Creating note...'), 'loading'); setStatus(tr('ui.creating', null, 'Creating note...'), 'loading');
var parent = notesParent(); var parent = notesParent();
createNote(parent, title).then(function (data) { createNote(parent, title).then(function (data) {
if (disposed) return; if (disposed) return;
data = data || {}; data = data || {};
if (data.conflict) { if (data.conflict) {
showConflictModal(title, data.path, createInput); setCreateError(conflictMessage(title, data.path));
createInput.focus();
return; return;
} }
hideCreate(); hideCreate();
@@ -539,42 +636,55 @@
}).catch(function () {}); }).catch(function () {});
} }
}).catch(function (err) { }).catch(function (err) {
setStatus(userFacingError('ui.createError', 'Could not create the note. Please try again.', err), 'error'); setCreateError(userFacingError('ui.createError', 'Could not create the note. Please try again.', err));
}); });
} }
// ─── Rename ───────────────────────────────────────────── // ─── Rename ─────────────────────────────────────────────
function beginRename(note) { function beginRename(note) {
hideRename();
renameTarget = note; renameTarget = note;
renameInput.value = note.title || fileName(note.path); renameModal = showNoteFormModal('rename', note);
renamePanel.style.display = 'flex';
renameInput.focus();
renameInput.select();
} }
function hideRename() { function hideRename() {
renameTarget = null; renameTarget = null;
renamePanel.style.display = 'none'; if (renameModal) renameModal.remove();
renameModal = null;
renameInput = null;
renameError = null;
}
function setRenameError(message) {
if (!renameError || !renameInput) return;
renameError.textContent = message || '';
renameError.style.display = message ? 'block' : 'none';
renameInput.setAttribute('aria-invalid', message ? 'true' : 'false');
} }
function confirmRename() { function confirmRename() {
if (!renameTarget) return; if (!renameTarget || !renameInput) return;
var newTitle = renameInput.value.trim(); var newTitle = renameInput.value.trim();
if (!newTitle) return; if (!newTitle) {
setRenameError(tr('ui.titleRequired', null, 'Enter a note title.'));
renameInput.focus();
return;
}
setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading'); setStatus(tr('ui.renaming', null, 'Renaming...'), 'loading');
renameNote(renameTarget.path, newTitle).then(function (data) { renameNote(renameTarget.path, newTitle).then(function (data) {
if (disposed) return; if (disposed) return;
data = data || {}; data = data || {};
if (data.conflict) { if (data.conflict) {
showConflictModal(newTitle, data.path, renameInput); setRenameError(conflictMessage(newTitle, data.path));
renameInput.focus();
return; return;
} }
hideRename(); hideRename();
setStatus(tr('ui.renamed', null, 'Note renamed'), 'success'); setStatus(tr('ui.renamed', null, 'Note renamed'), 'success');
loadNotes(); loadNotes();
}).catch(function (err) { }).catch(function (err) {
setStatus(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err), 'error'); setRenameError(userFacingError('ui.renameError', 'Could not rename the note. Please try again.', err));
}); });
} }
@@ -596,25 +706,6 @@
}); });
} }
// ─── Conflict Modal ─────────────────────────────────────
function showConflictModal(title, existingPath, focusTarget) {
var overlay = el('div', { className: 'notes-modal-overlay' });
var modal = el('div', { className: 'notes-modal' }, [
el('div', { className: 'notes-modal-title' }, [tr('ui.conflictTitle', null, 'Name Conflict')]),
el('div', { className: 'notes-modal-msg' }, [
tr('ui.conflictMessage', { title: title }, 'A note with the title "' + title + '" already exists.'),
existingPath ? tr('ui.existingFile', { path: existingPath }, ' Existing file: ' + existingPath + '.') : '',
tr('ui.chooseDifferent', null, ' Please choose a different title.')
].join('')),
el('div', { className: 'notes-modal-actions' }, [
el('button', { className: 'notes-modal-btn confirm', textContent: 'OK', onClick: function () { overlay.remove(); (focusTarget || createInput).focus(); } })
])
]);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function showTrashModal(note) { function showTrashModal(note) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
var overlay = el('div', { className: 'notes-modal-overlay' }); var overlay = el('div', { className: 'notes-modal-overlay' });
@@ -648,18 +739,6 @@
sortMode = sortSelect.value || 'title-asc'; sortMode = sortSelect.value || 'title-asc';
renderList(); renderList();
}); });
createConfirm.addEventListener('click', confirmCreate);
createCancel.addEventListener('click', hideCreate);
renameConfirm.addEventListener('click', confirmRename);
renameCancel.addEventListener('click', hideRename);
createInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmCreate();
if (e.key === 'Escape') hideCreate();
});
renameInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') confirmRename();
if (e.key === 'Escape') hideRename();
});
// ─── Init ─────────────────────────────────────────────── // ─── Init ───────────────────────────────────────────────
@@ -672,12 +751,6 @@
createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note'); createBtn.innerHTML = iconSvg('add') + ' ' + tr('ui.newNote', null, 'New Note');
filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes')); filterInput.setAttribute('placeholder', tr('ui.filter', null, 'Filter notes'));
titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName); titleBar.textContent = tr('ui.title', { workspace: workspaceName }, 'Notes in ' + workspaceName);
createInput.setAttribute('placeholder', tr('ui.noteTitle', null, 'Note title'));
renameInput.setAttribute('placeholder', tr('ui.newTitle', null, 'New title'));
createConfirm.textContent = tr('ui.create', null, 'Create');
createCancel.textContent = tr('ui.cancel', null, 'Cancel');
renameConfirm.textContent = tr('ui.rename', null, 'Rename');
renameCancel.textContent = tr('ui.cancel', null, 'Cancel');
renderList(); renderList();
}); });
} }
@@ -696,6 +769,8 @@
containerEl.__notesCleanup = function () { containerEl.__notesCleanup = function () {
disposed = true; disposed = true;
hideCreate();
hideRename();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe(); if (typeof fileChangedUnsubscribe === 'function') fileChangedUnsubscribe();
}; };
+4 -1
View File
@@ -1,11 +1,14 @@
{ {
"manifest.name": "Notes", "manifest.name": "Notes",
"manifest.description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "manifest.description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Notes", "contributions.workspaceItems.verstak.notes.workspace.title": "Notes",
"ui.newNote": "New Note", "ui.newNote": "New Note",
"ui.filter": "Filter notes", "ui.filter": "Filter notes",
"ui.title": "Notes in {workspace}", "ui.title": "Notes in {workspace}",
"ui.noteTitle": "Note title", "ui.noteTitle": "Note title",
"ui.createTitle": "Create note",
"ui.renameTitle": "Rename note",
"ui.titleRequired": "Enter a note title.",
"ui.create": "Create", "ui.create": "Create",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.newTitle": "New title", "ui.newTitle": "New title",
+4 -1
View File
@@ -1,11 +1,14 @@
{ {
"manifest.name": "Заметки", "manifest.name": "Заметки",
"manifest.description": "Менеджер Markdown-заметок рабочего пространства с созданием, переименованием и корзиной.", "manifest.description": "Менеджер Markdown-заметок Дела с созданием, переименованием и корзиной.",
"contributions.workspaceItems.verstak.notes.workspace.title": "Заметки", "contributions.workspaceItems.verstak.notes.workspace.title": "Заметки",
"ui.newNote": "Новая заметка", "ui.newNote": "Новая заметка",
"ui.filter": "Фильтр заметок", "ui.filter": "Фильтр заметок",
"ui.title": "Заметки · {workspace}", "ui.title": "Заметки · {workspace}",
"ui.noteTitle": "Название заметки", "ui.noteTitle": "Название заметки",
"ui.createTitle": "Создать заметку",
"ui.renameTitle": "Переименовать заметку",
"ui.titleRequired": "Введите название заметки.",
"ui.create": "Создать", "ui.create": "Создать",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.newTitle": "Новое название", "ui.newTitle": "Новое название",
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Notes", "name": "Notes",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.", "description": "Deal-scoped notes manager for Markdown files in canonical Notes folders with create, rename, trash, and Workbench integration.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "edit", "icon": "edit",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Search", "manifest.name": "Search",
"manifest.description": "Workspace-scoped vault text search provider.", "manifest.description": "Deal-scoped vault text search provider.",
"contributions.workspaceItems.verstak.search.workspace.title": "Search", "contributions.workspaceItems.verstak.search.workspace.title": "Search",
"contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text", "contributions.commands.verstak.search.searchVaultText.title": "Search Vault Text",
"contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search", "contributions.searchProviders.verstak.search.vault-text.label": "Vault Text Search",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Поиск", "manifest.name": "Поиск",
"manifest.description": "Поиск текста в хранилище в пределах рабочего пространства.", "manifest.description": "Поиск текста в хранилище в пределах Дела.",
"contributions.workspaceItems.verstak.search.workspace.title": "Поиск", "contributions.workspaceItems.verstak.search.workspace.title": "Поиск",
"contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище", "contributions.commands.verstak.search.searchVaultText.title": "Искать текст в хранилище",
"contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища", "contributions.searchProviders.verstak.search.vault-text.label": "Поиск по тексту хранилища",
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Search", "name": "Search",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Workspace-scoped vault text search provider.", "description": "Deal-scoped vault text search provider.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "search", "icon": "search",
+95 -11
View File
@@ -60,6 +60,11 @@
'.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}', '.secrets-table th{width:9rem;color:var(--vt-color-text-muted,#7f8aa3);font-weight:500;background:var(--vt-color-surface-muted,#111629)}',
'.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}', '.secrets-table td{color:var(--vt-color-text-primary,#f4f7fb);overflow-wrap:anywhere}',
'.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}', '.secrets-table tr:last-child th,.secrets-table tr:last-child td{border-bottom:0}',
'.secrets-modal-overlay{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6)}',
'.secrets-modal{width:24rem;max-width:90vw;display:grid;gap:.8rem;padding:1rem;border:1px solid var(--vt-color-border-strong,#2c456a);border-radius:var(--vt-radius-lg,8px);background:var(--vt-color-surface,#15152c);box-shadow:var(--vt-elevation-menu,0 14px 32px rgba(0,0,0,.42))}',
'.secrets-modal-title{margin:0;font-size:.96rem}',
'.secrets-modal-message{margin:0;color:var(--vt-color-text-secondary,#b7c0d4);font-size:.84rem;line-height:1.45}',
'.secrets-modal-actions{display:flex;justify-content:flex-end;gap:.5rem}',
'@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}' '@media(max-width:780px){.secrets-root{grid-template-columns:1fr}.secrets-panel{border-right:0;border-bottom:1px solid #252b36;max-height:45vh}.secrets-row,.secrets-filters{grid-template-columns:1fr}}'
].join('\n'); ].join('\n');
@@ -97,10 +102,10 @@
|| (node && (node.rootPath || node.name || node.id))); || (node && (node.rootPath || node.name || node.id)));
} }
function scopeLabel(record) { function scopeLabel(record, translate) {
var scope = record && record.scope || {}; var scope = record && record.scope || {};
if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || 'Deal'; if (scope.kind === ScopeWorkspace) return cleanWorkspace(scope.workspaceRootPath) || translate('ui.deal', null, 'Deal');
return 'Global'; return translate('ui.global', null, 'Global');
} }
function selectedIDFromProps(props) { function selectedIDFromProps(props) {
@@ -111,16 +116,16 @@
return decodeURIComponent(path.replace(/^\/+/, '')); return decodeURIComponent(path.replace(/^\/+/, ''));
} }
function groupRecords(records) { function groupRecords(records, translate) {
var groups = {}; var groups = {};
records.forEach(function (record) { records.forEach(function (record) {
var label = scopeLabel(record); var label = scopeLabel(record, translate);
groups[label] = groups[label] || []; groups[label] = groups[label] || [];
groups[label].push(record); groups[label].push(record);
}); });
return Object.keys(groups).sort(function (a, b) { return Object.keys(groups).sort(function (a, b) {
if (a === 'Global') return -1; if (a === translate('ui.global', null, 'Global')) return -1;
if (b === 'Global') return 1; if (b === translate('ui.global', null, 'Global')) return 1;
return a.localeCompare(b); return a.localeCompare(b);
}).map(function (label) { }).map(function (label) {
groups[label].sort(function (a, b) { groups[label].sort(function (a, b) {
@@ -152,6 +157,8 @@
var searchQuery = ''; var searchQuery = '';
var selectedRecord = null; var selectedRecord = null;
var selectedValue = ''; var selectedValue = '';
var isValueVisible = false;
var deleteModal = null;
var initialized = false; var initialized = false;
var unlocked = false; var unlocked = false;
var statusText = ''; var statusText = '';
@@ -190,6 +197,7 @@
read: 'Could not open this secret. Please try again.', read: 'Could not open this secret. Please try again.',
save: 'Could not save the secret. Please try again.', save: 'Could not save the secret. Please try again.',
delete: 'Could not delete the secret. Please try again.', delete: 'Could not delete the secret. Please try again.',
copyValue: 'Could not copy the secret value. Please try again.',
copyLink: 'Could not copy the secret link. Please try again.', copyLink: 'Could not copy the secret link. Please try again.',
unlock: 'Could not unlock secrets. Check the master password and try again.' unlock: 'Could not unlock secrets. Check the master password and try again.'
}[action] || 'Could not complete this action. Please try again.'); }[action] || 'Could not complete this action. Please try again.');
@@ -358,7 +366,7 @@
children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')])); children.push(el('div', { className: 'secrets-empty' }, [tr('ui.empty', null, 'No secrets')]));
return children; return children;
} }
groupRecords(visibleRecords).forEach(function (group) { groupRecords(visibleRecords, tr).forEach(function (group) {
children.push(el('div', { className: 'secrets-group' }, [group.label])); children.push(el('div', { className: 'secrets-group' }, [group.label]));
children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) { children.push(el('div', { className: 'secrets-list' }, group.records.map(function (record) {
var active = selectedRecord && selectedRecord.id === record.id; var active = selectedRecord && selectedRecord.id === record.id;
@@ -382,14 +390,16 @@
el('h2', {}, [tr('ui.select', null, 'Select a secret')]), el('h2', {}, [tr('ui.select', null, 'Select a secret')]),
el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText]) el('div', { className: statusError ? 'secrets-status error' : 'secrets-status' }, [statusText])
]); ]);
var valueIsAvailable = !!selectedValue;
var valueDisplay = valueIsAvailable && isValueVisible ? selectedValue : '••••••••••••';
return el('div', { className: 'secrets-card' }, [ return el('div', { className: 'secrets-card' }, [
el('h2', {}, [selectedRecord.title || selectedRecord.id]), el('h2', {}, [selectedRecord.title || selectedRecord.id]),
el('table', { className: 'secrets-table' }, [ el('table', { className: 'secrets-table' }, [
el('tbody', {}, [ el('tbody', {}, [
fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord)), fieldRow(tr('ui.group', null, 'Group'), scopeLabel(selectedRecord, tr)),
fieldRow('ID', selectedRecord.id), fieldRow('ID', selectedRecord.id),
fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''), fieldRow(tr('ui.username', null, 'Username'), selectedRecord.username || ''),
fieldRow(tr('ui.password', null, 'Password'), selectedValue ? selectedValue : '••••••••••••', selectedValue ? '' : 'secrets-hidden-value'), fieldRow(tr('ui.password', null, 'Password'), valueDisplay, isValueVisible && valueIsAvailable ? 'secrets-secret-value' : 'secrets-hidden-value'),
fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '') fieldRow(tr('ui.updated', null, 'Updated'), selectedRecord.updatedAt || '')
]) ])
]), ]),
@@ -400,6 +410,24 @@
'data-secret-copy-link': selectedRecord.id, 'data-secret-copy-link': selectedRecord.id,
onClick: function () { copySecretLink(selectedRecord.id); } onClick: function () { copySecretLink(selectedRecord.id); }
}, [tr('ui.copyLink', null, 'Copy secret link')]), }, [tr('ui.copyLink', null, 'Copy secret link')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-copy-value': selectedRecord.id,
onClick: function () { copySecretValue(selectedValue); }
}, [tr('ui.copyValue', null, 'Copy value')]),
el('button', {
className: 'secrets-btn',
type: 'button',
disabled: !valueIsAvailable,
'data-secret-toggle-value': selectedRecord.id,
onClick: function () {
if (!selectedValue) return;
isValueVisible = !isValueVisible;
render();
}
}, [isValueVisible ? tr('ui.hideValue', null, 'Hide value') : tr('ui.showValue', null, 'Show value')]),
el('button', { el('button', {
className: 'secrets-btn', className: 'secrets-btn',
type: 'button', type: 'button',
@@ -547,6 +575,7 @@
selectedID = id; selectedID = id;
selectedRecord = records.find(function (record) { return record.id === id; }) || null; selectedRecord = records.find(function (record) { return record.id === id; }) || null;
selectedValue = ''; selectedValue = '';
isValueVisible = false;
render(); render();
if (!id) return Promise.resolve(); if (!id) return Promise.resolve();
return api.secrets.read(id).then(function (record) { return api.secrets.read(id).then(function (record) {
@@ -576,17 +605,71 @@
} }
function deleteSecret(id) { function deleteSecret(id) {
if (!id || !window.confirm(tr('ui.deleteConfirm', null, 'Delete this secret?'))) return; if (!id) return;
removeDeleteModal();
var title = selectedRecord && selectedRecord.id === id ? selectedRecord.title || selectedRecord.id : id;
var overlay = el('div', {
className: 'secrets-modal-overlay',
'data-secret-delete-modal': '',
role: 'presentation'
});
var cancel = el('button', {
className: 'secrets-btn',
type: 'button',
'data-secret-delete-cancel': '',
onClick: removeDeleteModal
}, [tr('ui.cancel', null, 'Cancel')]);
var confirm = el('button', {
className: 'secrets-btn danger',
type: 'button',
'data-secret-delete-confirm': '',
onClick: function () {
removeDeleteModal();
confirmDeleteSecret(id);
}
}, [tr('ui.delete', null, 'Delete')]);
overlay.appendChild(el('div', {
className: 'secrets-modal',
role: 'dialog',
'aria-modal': 'true',
'aria-label': tr('ui.deleteSecretTitle', null, 'Delete secret')
}, [
el('h2', { className: 'secrets-modal-title' }, [tr('ui.deleteSecretTitle', null, 'Delete secret')]),
el('p', { className: 'secrets-modal-message' }, [tr('ui.deleteConfirm', { title: title }, 'Delete "' + title + '"?')]),
el('div', { className: 'secrets-modal-actions' }, [cancel, confirm])
]));
document.body.appendChild(overlay);
deleteModal = overlay;
if (cancel.focus) cancel.focus();
}
function removeDeleteModal() {
if (deleteModal && typeof deleteModal.remove === 'function') deleteModal.remove();
else if (deleteModal && deleteModal.parentNode) deleteModal.parentNode.removeChild(deleteModal);
deleteModal = null;
}
function confirmDeleteSecret(id) {
api.secrets.delete(id).then(function () { api.secrets.delete(id).then(function () {
selectedID = ''; selectedID = '';
selectedRecord = null; selectedRecord = null;
selectedValue = ''; selectedValue = '';
isValueVisible = false;
return loadRecords(); return loadRecords();
}).catch(function (err) { }).catch(function (err) {
setUserFacingError('delete', err); setUserFacingError('delete', err);
}); });
} }
function copySecretValue(value) {
if (!value) return;
writeClipboard(api, value).then(function () {
setStatus(tr('ui.valueCopied', null, 'Secret value copied'), false);
}).catch(function (err) {
setUserFacingError('copyValue', err);
});
}
function copySecretLink(id) { function copySecretLink(id) {
api.secrets.copyLink(id).then(function (link) { api.secrets.copyLink(id).then(function (link) {
return writeClipboard(api, link).then(function () { return writeClipboard(api, link).then(function () {
@@ -614,6 +697,7 @@
containerEl.__secretsCleanup = function () { containerEl.__secretsCleanup = function () {
disposed = true; disposed = true;
removeDeleteModal();
if (typeof localeUnsubscribe === 'function') localeUnsubscribe(); if (typeof localeUnsubscribe === 'function') localeUnsubscribe();
}; };
}, },
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Secrets", "manifest.name": "Secrets",
"manifest.description": "Encrypted global and workspace-scoped secret manager.", "manifest.description": "Encrypted global and Deal-scoped secret manager.",
"contributions.views.verstak.secrets.view.title": "Secrets", "contributions.views.verstak.secrets.view.title": "Secrets",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets", "contributions.sidebarItems.verstak.secrets.sidebar.title": "Secrets",
"contributions.openProviders.verstak.secrets.secret.title": "Secrets", "contributions.openProviders.verstak.secrets.secret.title": "Secrets",
@@ -28,11 +28,14 @@
"ui.copyLink": "Copy secret link", "ui.copyLink": "Copy secret link",
"ui.edit": "Edit", "ui.edit": "Edit",
"ui.delete": "Delete", "ui.delete": "Delete",
"ui.copyValue": "Copy value",
"ui.showValue": "Show value",
"ui.hideValue": "Hide value",
"ui.fieldTitle": "Title", "ui.fieldTitle": "Title",
"ui.optionalUsername": "optional username", "ui.optionalUsername": "optional username",
"ui.secretValue": "Secret value", "ui.secretValue": "Secret value",
"ui.global": "Global", "ui.global": "Global",
"ui.workspace": "Workspace", "ui.workspace": "Deal",
"ui.editSecret": "Edit secret", "ui.editSecret": "Edit secret",
"ui.newSecret": "New secret", "ui.newSecret": "New secret",
"ui.scope": "Scope", "ui.scope": "Scope",
@@ -46,11 +49,14 @@
"ui.readError": "Could not open this secret. Please try again.", "ui.readError": "Could not open this secret. Please try again.",
"ui.saveError": "Could not save the secret. Please try again.", "ui.saveError": "Could not save the secret. Please try again.",
"ui.deleteError": "Could not delete the secret. Please try again.", "ui.deleteError": "Could not delete the secret. Please try again.",
"ui.copyValueError": "Could not copy the secret value. Please try again.",
"ui.copyLinkError": "Could not copy the secret link. Please try again.", "ui.copyLinkError": "Could not copy the secret link. Please try again.",
"ui.unlockError": "Could not unlock secrets. Check the master password and try again.", "ui.unlockError": "Could not unlock secrets. Check the master password and try again.",
"ui.value": "Value", "ui.value": "Value",
"ui.save": "Save", "ui.save": "Save",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.deleteConfirm": "Delete this secret?", "ui.deleteSecretTitle": "Delete secret",
"ui.linkCopied": "Secret link copied" "ui.deleteConfirm": "Delete \"{title}\"? This cannot be undone.",
"ui.linkCopied": "Secret link copied",
"ui.valueCopied": "Secret value copied"
} }
+10 -4
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Секреты", "manifest.name": "Секреты",
"manifest.description": "Зашифрованное хранилище общих секретов и секретов рабочих пространств.", "manifest.description": "Зашифрованное хранилище общих секретов и секретов Дел.",
"contributions.views.verstak.secrets.view.title": "Секреты", "contributions.views.verstak.secrets.view.title": "Секреты",
"contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты", "contributions.sidebarItems.verstak.secrets.sidebar.title": "Секреты",
"contributions.openProviders.verstak.secrets.secret.title": "Секреты", "contributions.openProviders.verstak.secrets.secret.title": "Секреты",
@@ -28,11 +28,14 @@
"ui.copyLink": "Копировать ссылку на секрет", "ui.copyLink": "Копировать ссылку на секрет",
"ui.edit": "Изменить", "ui.edit": "Изменить",
"ui.delete": "Удалить", "ui.delete": "Удалить",
"ui.copyValue": "Копировать значение",
"ui.showValue": "Показать значение",
"ui.hideValue": "Скрыть значение",
"ui.fieldTitle": "Название", "ui.fieldTitle": "Название",
"ui.optionalUsername": "необязательное имя пользователя", "ui.optionalUsername": "необязательное имя пользователя",
"ui.secretValue": "Значение секрета", "ui.secretValue": "Значение секрета",
"ui.global": "Общий", "ui.global": "Общий",
"ui.workspace": "Рабочее пространство", "ui.workspace": "Дело",
"ui.editSecret": "Изменить секрет", "ui.editSecret": "Изменить секрет",
"ui.newSecret": "Новый секрет", "ui.newSecret": "Новый секрет",
"ui.scope": "Область", "ui.scope": "Область",
@@ -46,11 +49,14 @@
"ui.readError": "Не удалось открыть секрет. Повторите попытку.", "ui.readError": "Не удалось открыть секрет. Повторите попытку.",
"ui.saveError": "Не удалось сохранить секрет. Повторите попытку.", "ui.saveError": "Не удалось сохранить секрет. Повторите попытку.",
"ui.deleteError": "Не удалось удалить секрет. Повторите попытку.", "ui.deleteError": "Не удалось удалить секрет. Повторите попытку.",
"ui.copyValueError": "Не удалось скопировать значение секрета. Повторите попытку.",
"ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.", "ui.copyLinkError": "Не удалось скопировать ссылку на секрет. Повторите попытку.",
"ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.", "ui.unlockError": "Не удалось разблокировать секреты. Проверьте мастер-пароль и повторите попытку.",
"ui.value": "Значение", "ui.value": "Значение",
"ui.save": "Сохранить", "ui.save": "Сохранить",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.deleteConfirm": "Удалить этот секрет?", "ui.deleteSecretTitle": "Удалить секрет",
"ui.linkCopied": "Ссылка на секрет скопирована" "ui.deleteConfirm": "Удалить «{title}»? Это действие нельзя отменить.",
"ui.linkCopied": "Ссылка на секрет скопирована",
"ui.valueCopied": "Значение секрета скопировано"
} }
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Secrets", "name": "Secrets",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Encrypted global and workspace-scoped secret manager.", "description": "Encrypted global and Deal-scoped secret manager.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "key-round", "icon": "key-round",
+5 -5
View File
@@ -78,7 +78,7 @@
var workspaceRoot = workspaceFromProps(props || {}); var workspaceRoot = workspaceFromProps(props || {});
return workspaceRoot return workspaceRoot
? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot } ? { mode: 'workspace', workspaceRoot: workspaceRoot, label: workspaceRoot }
: { mode: 'global', workspaceRoot: '', label: 'All workspaces' }; : { mode: 'global', workspaceRoot: '', label: 'All Deals' };
} }
function now() { function now() {
@@ -360,7 +360,7 @@
function renderWorkspaceFilterOptions() { function renderWorkspaceFilterOptions() {
if (scope.mode !== 'global') return; if (scope.mode !== 'global') return;
workspaceFilterEl.innerHTML = ''; workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All workspaces'))); workspaceFilterEl.appendChild(option('', tr('ui.allWorkspaces', null, 'All Deals')));
workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned'))); workspaceFilterEl.appendChild(option('__unassigned__', tr('ui.unassigned', null, 'Unassigned')));
workspaceRoots().forEach(function (workspace) { workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace)); workspaceFilterEl.appendChild(option(workspace, workspace));
@@ -540,8 +540,8 @@
el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]), el('label', { className: 'todo-field' }, [tr('ui.field.reminderDate', null, 'Reminder date'), reminderDateInput]),
el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput]) el('label', { className: 'todo-field' }, [tr('ui.field.reminderTime', null, 'Reminder time'), reminderTimeInput])
]; ];
if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Workspace'), workspaceInput])); if (workspaceInput) fields.push(el('label', { className: 'todo-field' }, [tr('ui.field.workspace', null, 'Deal'), workspaceInput]));
else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Workspace: ' + scope.workspaceRoot) })); else fields.push(el('div', { className: 'todo-field', textContent: tr('ui.workspaceValue', { workspace: scope.workspaceRoot }, 'Deal: ' + scope.workspaceRoot) }));
modalHost.innerHTML = ''; modalHost.innerHTML = '';
if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden'); if (typeof modalHost.removeAttribute === 'function') modalHost.removeAttribute('hidden');
@@ -636,7 +636,7 @@
visible.forEach(function (todo) { visible.forEach(function (todo) {
var actionButtons = []; var actionButtons = [];
if (scope.mode === 'global' && todo.workspaceRootPath) { if (scope.mode === 'global' && todo.workspaceRootPath) {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open workspace'), onClick: function () { openWorkspace(todo); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'open-workspace', textContent: tr('ui.openWorkspace', null, 'Open Deal'), onClick: function () { openWorkspace(todo); } }));
} }
if (todo.status === 'open') { if (todo.status === 'open') {
actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } })); actionButtons.push(el('button', { className: 'todo-btn', type: 'button', 'data-todo-action': 'mark-done', textContent: tr('ui.status.done', null, 'Done'), onClick: function () { setTodoStatus(todo, 'done'); } }));
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Todos", "manifest.name": "Todos",
"manifest.description": "Global and workspace todo tracking with due and reminder metadata.", "manifest.description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"contributions.views.verstak.todo.view.title": "Todos", "contributions.views.verstak.todo.view.title": "Todos",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Todos", "contributions.sidebarItems.verstak.todo.sidebar.title": "Todos",
"contributions.workspaceItems.verstak.todo.workspace.title": "Todos", "contributions.workspaceItems.verstak.todo.workspace.title": "Todos",
@@ -15,7 +15,7 @@
"ui.sort.updated": "Sort by updated", "ui.sort.updated": "Sort by updated",
"ui.search": "Search todos", "ui.search": "Search todos",
"ui.add": "Add Todo", "ui.add": "Add Todo",
"ui.allWorkspaces": "All workspaces", "ui.allWorkspaces": "All Deals",
"ui.unassigned": "Unassigned", "ui.unassigned": "Unassigned",
"ui.saveError": "Could not save tasks. Please try again.", "ui.saveError": "Could not save tasks. Please try again.",
"ui.loadError": "Could not load tasks. Please try again.", "ui.loadError": "Could not load tasks. Please try again.",
@@ -40,8 +40,8 @@
"ui.reminderTimePlaceholder": "14:30", "ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)", "ui.reminderTimeInvalid": "Enter a valid reminder time (HH:MM)",
"ui.reminderDateRequired": "Choose a reminder date before setting its time", "ui.reminderDateRequired": "Choose a reminder date before setting its time",
"ui.field.workspace": "Workspace", "ui.field.workspace": "Deal",
"ui.workspaceValue": "Workspace: {workspace}", "ui.workspaceValue": "Deal: {workspace}",
"ui.edit": "Edit Todo", "ui.edit": "Edit Todo",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.saveChanges": "Save changes", "ui.saveChanges": "Save changes",
@@ -58,7 +58,7 @@
"ui.reminderDateValue": "Reminder date {date}", "ui.reminderDateValue": "Reminder date {date}",
"ui.noMatches": "No todos match the current filters.", "ui.noMatches": "No todos match the current filters.",
"ui.empty": "No todos yet.", "ui.empty": "No todos yet.",
"ui.openWorkspace": "Open workspace", "ui.openWorkspace": "Open Deal",
"ui.reopen": "Reopen", "ui.reopen": "Reopen",
"ui.createJournal": "Create Journal Entry", "ui.createJournal": "Create Journal Entry",
"ui.editAction": "Edit", "ui.editAction": "Edit",
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"manifest.name": "Задачи", "manifest.name": "Задачи",
"manifest.description": "Общие задачи и задачи рабочих пространств со сроками и напоминаниями.", "manifest.description": "Общие задачи и задачи Дел со сроками и напоминаниями.",
"contributions.views.verstak.todo.view.title": "Задачи", "contributions.views.verstak.todo.view.title": "Задачи",
"contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи", "contributions.sidebarItems.verstak.todo.sidebar.title": "Задачи",
"contributions.workspaceItems.verstak.todo.workspace.title": "Задачи", "contributions.workspaceItems.verstak.todo.workspace.title": "Задачи",
@@ -15,7 +15,7 @@
"ui.sort.updated": "Сортировать по обновлению", "ui.sort.updated": "Сортировать по обновлению",
"ui.search": "Поиск задач", "ui.search": "Поиск задач",
"ui.add": "Добавить задачу", "ui.add": "Добавить задачу",
"ui.allWorkspaces": "Все рабочие пространства", "ui.allWorkspaces": "Все Дела",
"ui.unassigned": "Не назначено", "ui.unassigned": "Не назначено",
"ui.saveError": "Не удалось сохранить задачи. Повторите попытку.", "ui.saveError": "Не удалось сохранить задачи. Повторите попытку.",
"ui.loadError": "Не удалось загрузить задачи. Повторите попытку.", "ui.loadError": "Не удалось загрузить задачи. Повторите попытку.",
@@ -40,8 +40,8 @@
"ui.reminderTimePlaceholder": "14:30", "ui.reminderTimePlaceholder": "14:30",
"ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)", "ui.reminderTimeInvalid": "Введите корректное время напоминания (ЧЧ:ММ)",
"ui.reminderDateRequired": "Сначала выберите дату напоминания", "ui.reminderDateRequired": "Сначала выберите дату напоминания",
"ui.field.workspace": "Рабочее пространство", "ui.field.workspace": "Дело",
"ui.workspaceValue": "Рабочее пространство: {workspace}", "ui.workspaceValue": "Дело: {workspace}",
"ui.edit": "Изменить задачу", "ui.edit": "Изменить задачу",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.saveChanges": "Сохранить изменения", "ui.saveChanges": "Сохранить изменения",
@@ -58,7 +58,7 @@
"ui.reminderDateValue": "Дата напоминания: {date}", "ui.reminderDateValue": "Дата напоминания: {date}",
"ui.noMatches": "Нет задач, соответствующих фильтрам.", "ui.noMatches": "Нет задач, соответствующих фильтрам.",
"ui.empty": "Задач пока нет.", "ui.empty": "Задач пока нет.",
"ui.openWorkspace": "Открыть рабочее пространство", "ui.openWorkspace": "Открыть Дело",
"ui.reopen": "Открыть снова", "ui.reopen": "Открыть снова",
"ui.createJournal": "Создать запись журнала", "ui.createJournal": "Создать запись журнала",
"ui.editAction": "Изменить", "ui.editAction": "Изменить",
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "Todos", "name": "Todos",
"version": "0.1.0", "version": "0.1.0",
"apiVersion": "0.1.0", "apiVersion": "0.1.0",
"description": "Global and workspace todo tracking with due and reminder metadata.", "description": "Global and Deal-scoped todo tracking with due and reminder metadata.",
"localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } }, "localization": { "defaultLocale": "en", "locales": { "en": "locales/en.json", "ru": "locales/ru.json" } },
"source": "official", "source": "official",
"icon": "list-todo", "icon": "list-todo",
+2 -2
View File
@@ -217,7 +217,7 @@
value: state.workspace, value: state.workspace,
'data-trash-filter-workspace': '', 'data-trash-filter-workspace': '',
onChange: function (event) { state.workspace = event.target.value; render(); } onChange: function (event) { state.workspace = event.target.value; render(); }
}, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All workspaces')])]); }, [el('option', { value: '' }, [tr('ui.allWorkspaces', null, 'All Deals')])]);
workspaceOptions().forEach(function (workspace) { workspaceOptions().forEach(function (workspace) {
workspaceSelect.appendChild(el('option', { value: workspace }, [workspace])); workspaceSelect.appendChild(el('option', { value: workspace }, [workspace]));
}); });
@@ -270,7 +270,7 @@
var list = el('div', { className: 'trash-list', 'data-trash-list': '' }); var list = el('div', { className: 'trash-list', 'data-trash-list': '' });
list.appendChild(el('div', { className: 'trash-header' }, [ list.appendChild(el('div', { className: 'trash-header' }, [
el('span', {}, [tr('ui.name', null, 'Name')]), el('span', {}, [tr('ui.name', null, 'Name')]),
el('span', {}, [tr('ui.workspace', null, 'Workspace')]), el('span', {}, [tr('ui.workspace', null, 'Deal')]),
el('span', {}, [tr('ui.originalPath', null, 'Original path')]), el('span', {}, [tr('ui.originalPath', null, 'Original path')]),
el('span', {}, [tr('ui.deleted', null, 'Deleted')]), el('span', {}, [tr('ui.deleted', null, 'Deleted')]),
el('span', {}, [tr('ui.typeSize', null, 'Type / size')]), el('span', {}, [tr('ui.typeSize', null, 'Type / size')]),
+2 -2
View File
@@ -10,7 +10,7 @@
"ui.cannotUndo": "This cannot be undone.", "ui.cannotUndo": "This cannot be undone.",
"ui.cancel": "Cancel", "ui.cancel": "Cancel",
"ui.deleting": "Deleting...", "ui.deleting": "Deleting...",
"ui.allWorkspaces": "All workspaces", "ui.allWorkspaces": "All Deals",
"ui.allTypes": "All types", "ui.allTypes": "All types",
"ui.files": "Files", "ui.files": "Files",
"ui.folders": "Folders", "ui.folders": "Folders",
@@ -22,7 +22,7 @@
"ui.total": "{count} total", "ui.total": "{count} total",
"ui.filter": "Filter name or path", "ui.filter": "Filter name or path",
"ui.refresh": "Refresh", "ui.refresh": "Refresh",
"ui.workspace": "Workspace", "ui.workspace": "Deal",
"ui.originalPath": "Original path", "ui.originalPath": "Original path",
"ui.deleted": "Deleted", "ui.deleted": "Deleted",
"ui.typeSize": "Type / size", "ui.typeSize": "Type / size",
+2 -2
View File
@@ -10,7 +10,7 @@
"ui.cannotUndo": "Это действие нельзя отменить.", "ui.cannotUndo": "Это действие нельзя отменить.",
"ui.cancel": "Отмена", "ui.cancel": "Отмена",
"ui.deleting": "Удаление...", "ui.deleting": "Удаление...",
"ui.allWorkspaces": "Все рабочие пространства", "ui.allWorkspaces": "Все Дела",
"ui.allTypes": "Все типы", "ui.allTypes": "Все типы",
"ui.files": "Файлы", "ui.files": "Файлы",
"ui.folders": "Папки", "ui.folders": "Папки",
@@ -22,7 +22,7 @@
"ui.total": "Всего: {count}", "ui.total": "Всего: {count}",
"ui.filter": "Фильтр по имени или пути", "ui.filter": "Фильтр по имени или пути",
"ui.refresh": "Обновить", "ui.refresh": "Обновить",
"ui.workspace": "Рабочее пространство", "ui.workspace": "Дело",
"ui.originalPath": "Исходный путь", "ui.originalPath": "Исходный путь",
"ui.deleted": "Удалён", "ui.deleted": "Удалён",
"ui.typeSize": "Тип / размер", "ui.typeSize": "Тип / размер",
+6 -3
View File
@@ -280,13 +280,16 @@ async function mountWithApi(api, props = { workspaceNode: { name: 'Project' }, w
} }
if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage'); if (api.storedEvents(globalKey).length !== 0) throw new Error('workspace activity leaked into global storage');
if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered'); if (!container.textContent.includes('Example Article')) throw new Error('browser capture title was not rendered');
if (!container.textContent.includes('browser.capture.selection')) throw new Error('event type was not rendered'); if (!container.textContent.includes('Selection captured')) throw new Error('browser capture label was not rendered');
if (!container.textContent.includes('browser.capture.converted')) throw new Error('conversion event type was not rendered'); if (!container.textContent.includes('Capture converted')) throw new Error('conversion label was not rendered');
if (container.textContent.includes('browser.capture.selection') || container.textContent.includes('browser.capture.converted') || container.textContent.includes('verstak.browser-inbox')) {
throw new Error('Activity must not expose technical event or plugin identifiers in its UI');
}
if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered'); if (!container.textContent.includes('Possible journal entries')) throw new Error('work session candidate section was not rendered');
if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title'); if (container.textContent.includes('Project work on 2026-06-27')) throw new Error('candidate must not invent a worklog title');
const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate')); const candidateNode = walk(container, (node) => node.getAttribute && node.getAttribute('data-work-session-candidate'));
if (!candidateNode) throw new Error('work session candidate data attribute was not rendered'); if (!candidateNode) throw new Error('work session candidate data attribute was not rendered');
if (!candidateNode.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not rendered'); if (!candidateNode.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not rendered');
if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered'); if (!candidateNode.textContent.includes('Estimated duration: 20 min')) throw new Error('candidate duration was not rendered');
if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered'); if (!candidateNode.textContent.includes('Activities: 3')) throw new Error('candidate activity count was not rendered');
if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered'); if (!walk(candidateNode, (node) => node.getAttribute && node.getAttribute('data-work-session-action') === 'review')) throw new Error('candidate review action was not rendered');
+22 -1
View File
@@ -120,7 +120,7 @@ async function flush() {
} }
} }
async function mountEditor(secretProviderEnabled) { async function mountEditor(secretProviderEnabled, translations) {
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const component = loadComponent(document);
const opened = []; const opened = [];
@@ -146,6 +146,15 @@ async function mountEditor(secretProviderEnabled) {
return { status: 'opened', request }; return { status: 'opened', request };
}, },
}, },
i18n: {
t: (key, params, fallback) => {
let value = translations && translations[key] ? translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
}; };
const container = document.createElement('div'); const container = document.createElement('div');
component.mount(container, { component.mount(container, {
@@ -162,6 +171,12 @@ async function mountEditor(secretProviderEnabled) {
if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider'); if (disabledPreview.innerHTML.includes('data-secret-id')) throw new Error('secret link rendered without secrets provider');
const enabled = await mountEditor(true); const enabled = await mountEditor(true);
if (enabled.container.textContent.includes('notes-markdown') || enabled.container.textContent.includes('generic-markdown')) {
throw new Error('editor must not expose the technical editor mode in its toolbar');
}
if (enabled.container.textContent.includes('Notes context active')) {
throw new Error('editor must not show an implementation roadmap in the notes UI');
}
const preview = walk(enabled.container, (node) => node.className === 'de-preview'); const preview = walk(enabled.container, (node) => node.className === 'de-preview');
if (!preview) throw new Error('enabled preview missing'); if (!preview) throw new Error('enabled preview missing');
if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider'); if (!preview.innerHTML.includes('data-secret-id="client-a.db"')) throw new Error('secret link did not render with provider');
@@ -182,6 +197,12 @@ async function mountEditor(secretProviderEnabled) {
throw new Error('secret link did not open through workbench'); throw new Error('secret link did not open through workbench');
} }
const russian = await mountEditor(true, { 'ui.md.heading': 'Заголовок' });
const headingButton = walk(russian.container, (node) => node.getAttribute && node.getAttribute('data-md-action') === 'heading');
if (!headingButton || headingButton.getAttribute('title') !== 'Заголовок') {
throw new Error('Markdown toolbar titles must use the locale catalog');
}
console.log('default editor smoke passed'); console.log('default editor smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);
+123 -4
View File
@@ -195,9 +195,11 @@ function loadFilesComponent(document) {
return { component, clipboard: sandbox.navigator.clipboard }; return { component, clipboard: sandbox.navigator.clipboard };
} }
function makeApi() { function makeApi(options = {}) {
const externalCalls = []; const externalCalls = [];
const contributionCalls = []; const contributionCalls = [];
const created = [];
const moved = [];
const eventHandlers = {}; const eventHandlers = {};
let restored = false; let restored = false;
let externalVisible = false; let externalVisible = false;
@@ -212,6 +214,8 @@ function makeApi() {
return { return {
externalCalls, externalCalls,
contributionCalls, contributionCalls,
created,
moved,
emitFileChanged(payload) { emitFileChanged(payload) {
(eventHandlers['file.changed'] || []).forEach((handler) => handler({ (eventHandlers['file.changed'] || []).forEach((handler) => handler({
name: 'file.changed', name: 'file.changed',
@@ -229,6 +233,15 @@ function makeApi() {
size: 12, size: 12,
modifiedAt: '2026-06-27T00:00:00Z', modifiedAt: '2026-06-27T00:00:00Z',
}]; }];
if (options.includeFolder) {
entries.push({
name: 'Archive',
relativePath: 'Docs/Archive',
type: 'folder',
size: 0,
modifiedAt: '2026-06-27T00:00:00Z',
});
}
if (restored) { if (restored) {
entries.push({ entries.push({
name: 'deleted.md', name: 'deleted.md',
@@ -253,9 +266,15 @@ function makeApi() {
}, },
metadata: async () => { throw new Error('not-found'); }, metadata: async () => { throw new Error('not-found'); },
readText: async () => '# Readme\n', readText: async () => '# Readme\n',
writeText: async () => undefined, writeText: async (relativePath, content, options) => {
createFolder: async () => undefined, created.push({ type: 'file', relativePath, content, options });
move: async () => undefined, },
createFolder: async (relativePath) => {
created.push({ type: 'folder', relativePath });
},
move: async (fromRelativePath, toRelativePath) => {
moved.push({ fromRelativePath, toRelativePath });
},
trash: async () => undefined, trash: async () => undefined,
listTrash: async () => trashEntries.slice(), listTrash: async () => trashEntries.slice(),
restoreTrash: async (trashId) => { restoreTrash: async (trashId) => {
@@ -307,6 +326,15 @@ function makeApi() {
}; };
}, },
}, },
i18n: {
t: (key, params, fallback) => {
let value = options.translations && options.translations[key] ? options.translations[key] : (fallback || key);
Object.entries(params || {}).forEach(([name, replacement]) => {
value = value.replace(`{${name}}`, String(replacement));
});
return value;
},
},
showExternalFile() { showExternalFile() {
externalVisible = true; externalVisible = true;
}, },
@@ -328,6 +356,62 @@ async function flush() {
const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md'); const row = walk(container, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
if (!row) throw new Error('file row not rendered'); if (!row) throw new Error('file row not rendered');
const newFolder = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-folder');
if (!newFolder) throw new Error('new folder button not found');
newFolder.click();
const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
if (!createModal) throw new Error('create file modal not found');
const createInput = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
if (!createInput) throw new Error('create file input not found');
const createConfirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!createConfirm) throw new Error('create file confirm button not found');
createConfirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-files-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a name') || createInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('create file modal does not show a validation error for an empty name');
}
createInput.value = 'New folder';
createInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'folder' && entry.relativePath === 'New folder')) {
throw new Error(`new folder was not created: ${JSON.stringify(api.created)}`);
}
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined)) {
throw new Error('create file modal should close after a successful create');
}
const newMarkdown = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'new-markdown');
if (!newMarkdown) throw new Error('new markdown button not found');
newMarkdown.click();
const markdownModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-create-modal') !== undefined);
const markdownInput = walk(markdownModal, (node) => node.getAttribute && node.getAttribute('data-files-create-input') !== undefined);
markdownInput.value = 'Readme';
markdownInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.created.some((entry) => entry.type === 'file' && entry.relativePath === 'Readme.md')) {
throw new Error(`new markdown file was not created: ${JSON.stringify(api.created)}`);
}
const rename = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-action') === 'row-rename');
if (!rename) throw new Error('rename file button not found');
rename.click();
const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-files-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename file modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-input') !== undefined);
if (!renameInput || renameInput.value !== 'readme.md') throw new Error('rename file input should keep the current filename and extension');
renameInput.value = '';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-files-rename-error') !== undefined);
if (!renameError || !renameError.textContent.includes('Enter a name') || renameInput.getAttribute('aria-invalid') !== 'true') {
throw new Error('rename file modal does not show a validation error for an empty name');
}
renameInput.value = 'renamed.md';
renameInput.dispatchEvent('keydown', { key: 'Enter' });
await flush();
if (!api.moved.some((entry) => entry.fromRelativePath === 'Docs/readme.md' && entry.toRelativePath === 'Docs/renamed.md')) {
throw new Error(`rename did not preserve the extension: ${JSON.stringify(api.moved)}`);
}
const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined); const list = walk(container, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
if (!list) throw new Error('files list not rendered'); if (!list) throw new Error('files list not rendered');
list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 }); list.dispatchEvent('contextmenu', { target: row, clientX: 20, clientY: 20 });
@@ -378,6 +462,41 @@ async function flush() {
throw new Error(`external file row not rendered after file.changed: ${container.textContent}`); throw new Error(`external file row not rendered after file.changed: ${container.textContent}`);
} }
const russianDocument = makeDocument();
const { component: russianComponent } = loadFilesComponent(russianDocument);
const russianApi = makeApi({
includeFolder: true,
translations: {
'ui.open': 'Открыть',
'ui.openExternal': 'Открыть во внешнем приложении',
'ui.showInFolder': 'Показать в папке',
'ui.rename': 'Переименовать',
'ui.duplicate': 'Создать копию',
'ui.cut': 'Вырезать',
'ui.copy': 'Копировать',
'ui.trash': 'Переместить в корзину',
'ui.folderType': 'Папка',
'ui.icon.markdown': 'Файл Markdown',
},
});
const russianContainer = new FakeNode('div');
russianComponent.mount(russianContainer, {}, russianApi);
await flush();
if (!russianContainer.textContent.includes('Папка') || russianContainer.textContent.includes('folder')) {
throw new Error(`folder type was not localized: ${russianContainer.textContent}`);
}
const russianList = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-files-list') !== undefined);
const russianRow = walk(russianContainer, (node) => node.getAttribute && node.getAttribute('data-file-path') === 'Docs/readme.md');
const russianIcon = walk(russianRow, (node) => node.getAttribute && node.getAttribute('data-file-icon') === 'markdown');
if (!russianIcon || russianIcon.getAttribute('title') !== 'Файл Markdown' || russianIcon.getAttribute('aria-label') !== 'Файл Markdown') {
throw new Error(`file icon label was not localized: ${russianIcon && russianIcon.getAttribute('title')}`);
}
russianList.dispatchEvent('contextmenu', { target: russianRow, clientX: 20, clientY: 20 });
const russianMenuText = russianDocument.body.textContent;
['Открыть во внешнем приложении', 'Показать в папке', 'Переименовать', 'Создать копию', 'Вырезать', 'Копировать', 'Переместить в корзину'].forEach((label) => {
if (!russianMenuText.includes(label)) throw new Error(`context menu label was not localized: ${label}`);
});
console.log('files frontend smoke passed'); console.log('files frontend smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);
+22 -2
View File
@@ -6,12 +6,14 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json'); const manifestPath = path.join(root, 'plugins', 'journal', 'plugin.json');
const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js'); const sourcePath = path.join(root, 'plugins', 'journal', 'frontend', 'src', 'index.js');
const russianLocalePath = path.join(root, 'plugins', 'journal', 'locales', 'ru.json');
if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing'); if (!fs.existsSync(manifestPath)) throw new Error('journal plugin manifest missing');
if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing'); if (!fs.existsSync(sourcePath)) throw new Error('journal frontend entry missing');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeNode { class FakeNode {
constructor(tagName) { constructor(tagName) {
@@ -131,7 +133,7 @@ function loadComponent(document) {
return component; return component;
} }
function makeApi(initialSettings = {}) { function makeApi(initialSettings = {}, locale = null) {
const settings = { ...initialSettings }; const settings = { ...initialSettings };
const publishedEvents = []; const publishedEvents = [];
return { return {
@@ -154,6 +156,11 @@ function makeApi(initialSettings = {}) {
{ type: 'folder', relativePath: 'Client', name: 'Client' }, { type: 'folder', relativePath: 'Client', name: 'Client' },
], ],
}, },
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
storedEntries(key) { storedEntries(key) {
return settings[key] || []; return settings[key] || [];
}, },
@@ -245,8 +252,11 @@ function byData(container, attr, value) {
toolRequest: { type: 'work-session-candidate', candidate }, toolRequest: { type: 'work-session-candidate', candidate },
}); });
if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened'); if (!candidateView.container.textContent.includes('Review possible journal entry')) throw new Error('candidate review modal was not opened');
if (!candidateView.container.textContent.includes('Workspace: Project')) throw new Error('candidate workspace was not shown for review'); if (!candidateView.container.textContent.includes('Deal: Project')) throw new Error('candidate Deal was not shown for review');
if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review'); if (!candidateView.container.textContent.includes('Estimated duration: 51 min')) throw new Error('candidate duration was not shown for review');
if (candidateView.container.textContent.includes('browser.capture.selection') || candidateView.container.textContent.includes('verstak.browser-inbox') || candidateView.container.textContent.includes('capture-1')) {
throw new Error('candidate review exposed technical Activity identifiers');
}
if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title'); if (byData(candidateView.container, 'data-journal-input', 'title').value !== '') throw new Error('candidate review must start with an empty title');
if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body'); if (byData(candidateView.container, 'data-journal-input', 'summary').value !== '') throw new Error('candidate review must start with an empty body');
if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration'); if (byData(candidateView.container, 'data-journal-input', 'minutes').value !== '51') throw new Error('candidate review must prefill the factual duration');
@@ -273,6 +283,15 @@ function byData(container, attr, value) {
throw new Error('journal rows must not navigate to Activity by default'); throw new Error('journal rows must not navigate to Activity by default');
} }
const russianCandidateView = await mountWithApi(makeApi({}, russianLocale), {
workspaceNode: { name: 'Project' },
workspaceRootPath: 'Project',
toolRequest: { type: 'work-session-candidate', candidate },
});
if (!russianCandidateView.container.textContent.includes('Дело: Project') || !russianCandidateView.container.textContent.includes('Захвачено выделение')) {
throw new Error('candidate review was not localized');
}
byData(candidateView.container, 'data-journal-action', 'delete').click(); byData(candidateView.container, 'data-journal-action', 'delete').click();
await flush(); await flush();
if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted'); if (api.storedEntries(projectKey).length !== 1) throw new Error('journal entry was not deleted');
@@ -350,6 +369,7 @@ function byData(container, attr, value) {
component.unmount && component.unmount(container); component.unmount && component.unmount(container);
component.unmount && component.unmount(candidateView.container); component.unmount && component.unmount(candidateView.container);
component.unmount && component.unmount(russianCandidateView.container);
component.unmount && component.unmount(todoView.container); component.unmount && component.unmount(todoView.container);
component.unmount && component.unmount(duplicateTodoView.container); component.unmount && component.unmount(duplicateTodoView.container);
component.unmount && component.unmount(globalView.container); component.unmount && component.unmount(globalView.container);
+24 -13
View File
@@ -264,12 +264,19 @@ async function mountNotes(api) {
const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create'); const createButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-action') === 'create');
if (!createButton) throw new Error('create button not found'); if (!createButton) throw new Error('create button not found');
createButton.click(); createButton.click();
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined); const createModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-create-modal') !== undefined);
if (!createModal) throw new Error('create modal not found');
const input = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-input') !== undefined);
if (!input) throw new Error('create input not found'); if (!input) throw new Error('create input not found');
input.value = 'First Note'; const confirm = walk(createModal, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
const confirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Create');
if (!confirm) throw new Error('create confirm button not found'); if (!confirm) throw new Error('create confirm button not found');
confirm.click(); confirm.click();
const createError = walk(createModal, (node) => node.getAttribute && node.getAttribute('data-notes-create-error') !== undefined);
if (!createError || !createError.textContent.includes('Enter a note title') || input.getAttribute('aria-invalid') !== 'true') {
throw new Error('create modal does not show a validation error for an empty title');
}
input.value = 'First Note';
input.dispatchEvent('keydown', { key: 'Enter' });
await flush(); await flush();
const created = createApi.entries.get('Project/Notes/First_Note.md'); const created = createApi.entries.get('Project/Notes/First_Note.md');
@@ -319,21 +326,25 @@ async function mountNotes(api) {
const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename'); const renameButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-action') === 'rename');
if (!renameButton) throw new Error('rename note button not found'); if (!renameButton) throw new Error('rename note button not found');
renameButton.click(); renameButton.click();
const renameInput = walk(container, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined); const renameModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined);
if (!renameModal) throw new Error('rename modal not found');
const renameInput = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-input') !== undefined);
if (!renameInput) throw new Error('rename input not found'); if (!renameInput) throw new Error('rename input not found');
renameInput.value = 'Second Note'; renameInput.value = 'Second Note';
const renameConfirm = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Rename'); renameInput.dispatchEvent('keydown', { key: 'Enter' });
if (!renameConfirm) throw new Error('rename confirm button not found');
renameConfirm.click();
await flush(); await flush();
const conflictModal = walk(document.body, (node) => node.className === 'notes-modal-msg'); const renameError = walk(renameModal, (node) => node.getAttribute && node.getAttribute('data-notes-rename-error') !== undefined);
if (!conflictModal || !conflictModal.textContent.includes('Project/Notes/Second_Note.md')) { if (!renameError || !renameError.textContent.includes('Project/Notes/Second_Note.md')) {
throw new Error(`rename conflict modal should include existing path, got ${conflictModal && conflictModal.textContent}`); throw new Error(`rename conflict should stay in the form modal, got ${renameError && renameError.textContent}`);
} }
const conflictOk = walk(document.body, (node) => node.tagName === 'BUTTON' && node.textContent === 'OK'); if (walk(document.body, (node) => node.className === 'notes-modal-msg')) {
if (!conflictOk) throw new Error('rename conflict OK button not found'); throw new Error('rename conflict opened a second modal instead of reporting in the form');
conflictOk.click(); }
renameInput.dispatchEvent('keydown', { key: 'Escape' });
await flush(); await flush();
if (walk(document.body, (node) => node.getAttribute && node.getAttribute('data-notes-rename-modal') !== undefined)) {
throw new Error('Escape did not close the rename modal');
}
const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action'); const providerAction = walk(container, (node) => node.getAttribute && node.getAttribute('data-note-contribution-action') === 'provider.note.action');
if (!providerAction) throw new Error('provider note action button not found'); if (!providerAction) throw new Error('provider note action button not found');
+35 -1
View File
@@ -28,6 +28,16 @@ class FakeNode {
return node; return node;
} }
removeChild(node) {
this.children = this.children.filter((child) => child !== node);
node.parentNode = null;
return node;
}
remove() {
if (this.parentNode) this.parentNode.removeChild(this);
}
setAttribute(name, value) { setAttribute(name, value) {
this.attributes[name] = String(value); this.attributes[name] = String(value);
} }
@@ -235,7 +245,17 @@ async function flush() {
if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row'); if (!container.textContent.includes('Group')) throw new Error('secret field table missing Group row');
if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row'); if (!container.textContent.includes('Username')) throw new Error('secret field table missing Username row');
if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row'); if (!container.textContent.includes('Password')) throw new Error('secret field table missing Password row');
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not shown in the field table'); if (container.textContent.includes('secret-value')) throw new Error('secret value must stay hidden until the user explicitly reveals it');
const copyValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-value') === 'client-a.db');
if (!copyValueButton) throw new Error('copy value button missing');
copyValueButton.click();
await flush();
if (!copied.includes('secret-value')) throw new Error('secret value was not copied');
const showValueButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-toggle-value') === 'client-a.db');
if (!showValueButton) throw new Error('show value button missing');
showValueButton.click();
await flush();
if (!container.textContent.includes('secret-value')) throw new Error('secret value was not revealed after explicit request');
const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db'); const copyButton = walk(container, (node) => node.getAttribute && node.getAttribute('data-secret-copy-link') === 'client-a.db');
if (!copyButton) throw new Error('copy link button missing'); if (!copyButton) throw new Error('copy link button missing');
@@ -261,6 +281,20 @@ async function flush() {
if (!deleteButton) throw new Error('delete button missing'); if (!deleteButton) throw new Error('delete button missing');
deleteButton.click(); deleteButton.click();
await flush(); await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete must wait for confirmation');
const deleteConfirmModal = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-modal') === '');
if (!deleteConfirmModal) throw new Error('secret delete confirmation modal missing');
const deleteCancelButton = walk(deleteConfirmModal, (node) => node.getAttribute && node.getAttribute('data-secret-delete-cancel') === '');
if (!deleteCancelButton) throw new Error('secret delete cancel button missing');
deleteCancelButton.click();
await flush();
if (deleted.includes('client-a.db')) throw new Error('secret delete ran after cancelling the confirmation');
deleteButton.click();
await flush();
const deleteConfirmButton = walk(document.body, (node) => node.getAttribute && node.getAttribute('data-secret-delete-confirm') === '');
if (!deleteConfirmButton) throw new Error('secret delete confirm button missing');
deleteConfirmButton.click();
await flush();
if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called'); if (!deleted.includes('client-a.db')) throw new Error('secret delete was not called');
records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' }); records.push({ id: 'client-a.global-view', title: 'Client A Global View', username: 'app', scope: { kind: 'workspace', workspaceRootPath: 'ClientA' }, updatedAt: '2026-06-29T00:00:00Z' });
+10
View File
@@ -261,6 +261,13 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) { if (!container.textContent.includes('Задачи · Project') || !container.textContent.includes('Просрочено')) {
throw new Error('Todo view did not update to Russian without remounting'); throw new Error('Todo view did not update to Russian without remounting');
} }
byData(container, 'data-todo-action', 'add').click();
if (!container.textContent.includes('Дело: Project')) {
throw new Error('Todo editor did not use Deal terminology in Russian');
}
const cancelModal = walk(container, (node) => node.tagName === 'BUTTON' && node.textContent === 'Отмена' && !node.getAttribute('data-todo-action'));
if (!cancelModal) throw new Error('Todo editor cancel action was not rendered in Russian');
cancelModal.click();
if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state'); if ((apiState.settings['todos:global'] || []).length !== 1) throw new Error('locale change lost Todo state');
apiState.setLocale('en'); apiState.setLocale('en');
@@ -322,6 +329,9 @@ async function mountWithApi(apiState, props, emittedEvents = [], document = make
if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) { if (!globalView.container.textContent.includes('Prepare project review updated') || !globalView.container.textContent.includes('Client follow-up')) {
throw new Error('global Todo view did not aggregate workspace todos'); throw new Error('global Todo view did not aggregate workspace todos');
} }
if (!globalView.container.textContent.includes('All Deals')) {
throw new Error('global Todo filter did not use Deal terminology');
}
const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace'); const workspaceFilter = byData(globalView.container, 'data-todo-filter', 'workspace');
workspaceFilter.value = 'ClientA'; workspaceFilter.value = 'ClientA';
workspaceFilter.dispatchEvent('change'); workspaceFilter.dispatchEvent('change');
+20 -1
View File
@@ -5,8 +5,10 @@ const vm = require('vm');
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js'); const sourcePath = path.join(root, 'plugins', 'trash', 'frontend', 'src', 'index.js');
const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json'); const manifestPath = path.join(root, 'plugins', 'trash', 'plugin.json');
const russianLocalePath = path.join(root, 'plugins', 'trash', 'locales', 'ru.json');
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const russianLocale = JSON.parse(fs.readFileSync(russianLocalePath, 'utf8'));
class FakeClassList { class FakeClassList {
constructor(node) { constructor(node) {
@@ -159,7 +161,7 @@ function loadTrashComponent(document) {
return component; return component;
} }
function makeApi() { function makeApi(locale = null) {
let entries = [ let entries = [
{ {
trashId: 'old-project-file', trashId: 'old-project-file',
@@ -194,6 +196,11 @@ function makeApi() {
return { return {
restoreCalls, restoreCalls,
deleteCalls, deleteCalls,
i18n: locale ? {
t(key, params, fallback) {
return String(locale[key] || fallback || key).replace(/\{(\w+)\}/g, (_match, name) => String((params || {})[name] ?? ''));
},
} : null,
files: { files: {
listTrash: async () => entries.slice(), listTrash: async () => entries.slice(),
restoreTrash: async (trashId, options) => { restoreTrash: async (trashId, options) => {
@@ -237,6 +244,10 @@ function findByData(rootNode, name, value) {
component.mount(container, {}, api); component.mount(container, {}, api);
await flush(); await flush();
if (!container.textContent.includes('All Deals') || !container.textContent.includes('Deal')) {
throw new Error('global Trash did not use Deal terminology');
}
const latestRow = findByData(container, 'data-trash-row', 'latest-client-report'); const latestRow = findByData(container, 'data-trash-row', 'latest-client-report');
if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) { if (!latestRow || !latestRow.textContent.includes('ClientA') || !latestRow.textContent.includes('ClientA/Archive/report.pdf')) {
throw new Error(`global Trash must render workspace and original path: ${container.textContent}`); throw new Error(`global Trash must render workspace and original path: ${container.textContent}`);
@@ -298,6 +309,14 @@ function findByData(rootNode, name, value) {
} }
component.unmount(container); component.unmount(container);
const russianContainer = new FakeNode('div');
component.mount(russianContainer, {}, makeApi(russianLocale));
await flush();
if (!russianContainer.textContent.includes('Все Дела') || !russianContainer.textContent.includes('Дело')) {
throw new Error('global Trash did not localize Deal terminology');
}
component.unmount(russianContainer);
console.log('trash frontend smoke passed'); console.log('trash frontend smoke passed');
})().catch((err) => { })().catch((err) => {
console.error(err); console.error(err);