Compare commits

..

16 Commits

43 changed files with 1237 additions and 319 deletions

View File

@ -27,6 +27,38 @@ test.describe('Activity workflow', () => {
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
});
test('clearing activity requires destructive confirmation for the current case', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'activity-to-clear',
occurredAt: '2026-06-30T08:00:00.000Z',
type: 'note.saved',
title: 'Keep until confirmed',
workspaceRootPath: 'Project',
}],
});
});
await page.getByRole('tab', { name: 'Activity' }).click();
const activity = page.locator('.activity-root');
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
const confirmation = activity.locator('[data-activity-clear-confirmation]');
await expect(confirmation).toBeVisible();
await expect(confirmation).toContainText(/Project|Дело/);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await confirmation.locator('[data-activity-clear-cancel]').click();
await expect(confirmation).toHaveCount(0);
await expect(activity.locator('.activity-count')).toHaveText('1 event');
await activity.locator('[data-activity-action="clear"]').click();
await activity.locator('[data-activity-clear-confirm]').click();
await expect(activity.locator('.activity-count')).toHaveText('0 events');
});
test('workspace activity keeps raw events and renders factual work session candidates', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
@ -75,7 +107,7 @@ test.describe('Activity workflow', () => {
const candidate = candidateSection.locator('[data-work-session-candidate]');
await expect(candidate).toHaveCount(1);
await expect(candidate).toContainText('Workspace: Project');
await expect(candidate).toContainText('Estimated duration: 20 min');
await expect(candidate).toContainText('Estimated duration: 10 min');
await expect(candidate).toContainText('Activities: 2');
await expect(candidate).not.toContainText('Project work on');
await expect(candidate.locator('[data-work-session-action="review"]')).toBeVisible();
@ -111,10 +143,10 @@ test.describe('Activity workflow', () => {
const journal = page.locator('.journal-root');
await expect(journal).toBeVisible({ timeout: 10000 });
await expect(journal.locator('[data-journal-candidate]')).toContainText('Workspace: Project');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 20 min');
await expect(journal.locator('[data-journal-candidate]')).toContainText('Estimated duration: 10 min');
await expect(journal.locator('[data-journal-input="title"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="summary"]')).toHaveValue('');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('20');
await expect(journal.locator('[data-journal-input="minutes"]')).toHaveValue('10');
const activityInputs = journal.locator('[data-journal-candidate-activity]');
await expect(activityInputs).toHaveCount(2);
@ -126,7 +158,7 @@ test.describe('Activity workflow', () => {
await journal.locator('[data-journal-action="save-entry"]').click();
await expect(journal).toContainText('Review research capture');
await expect(journal).toContainText('20 min');
await expect(journal).toContainText('10 min');
const stored = await page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
return Array.isArray(result) ? result[0]['worklog:workspace:Project'] : result['worklog:workspace:Project'];

View File

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test.describe('Browser Inbox workflow', () => {
test.describe('Browser workflow', () => {
let consoleCollector;
test.beforeEach(async ({ page }) => {
@ -16,14 +16,14 @@ test.describe('Browser Inbox workflow', () => {
});
test('global inbox explains empty capture flow and exposes assignment filters', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox).toBeVisible({ timeout: 10000 });
await expect(inbox.locator('.browser-inbox-title')).toContainText('Browser Inbox');
await expect(inbox.locator('.browser-inbox-title')).toHaveText('Browser');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('0 items');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser captures yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('send a page, selection, link, or file from the browser extension');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('No browser materials yet');
await expect(inbox.locator('.browser-inbox-empty')).toContainText('Send a page, selection, or link from the extension');
await expect(inbox.locator('[data-browser-inbox-filter="status"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-filter="workspace"]')).toBeVisible();
await expect(inbox.locator('[data-browser-inbox-action="clear"]')).toBeDisabled();
@ -47,7 +47,7 @@ test.describe('Browser Inbox workflow', () => {
}],
});
});
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
await page.getByRole('tab', { name: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
@ -94,7 +94,7 @@ test.describe('Browser Inbox workflow', () => {
],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
const inbox = page.locator('.browser-inbox-root');
await expect(inbox.locator('.browser-inbox-count')).toHaveText('3 items');

View File

@ -124,4 +124,20 @@ test.describe('Command Palette', () => {
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.modal-header h3')).toContainText('Sync');
});
test('keeps technical command failures out of the palette status', async ({ page }) => {
await page.evaluate(() => {
window.go.api.App.PluginSyncNow = () => Promise.resolve('[plugin:verstak.sync] sync.now failed: internal service code 42');
});
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
const palette = page.locator('.command-palette');
await palette.locator('[data-command-palette-input]').fill('sync now');
await page.keyboard.press('Enter');
const status = page.locator('[data-command-palette-status="error"]');
await expect(status).toContainText('Could not run Sync Now. Please try again.');
await expect(status).not.toContainText('[plugin:');
await expect(status).not.toContainText('sync.now');
});
});

View File

@ -36,6 +36,69 @@ test.describe('F: Default Editor Plugin', () => {
await expect(textarea).toHaveValue('Buy groceries\nWrite tests');
});
test('secret link opens its exact secret and closing it restores the note preview', async ({ page }) => {
const notePath = 'Notes/Secret Link.md';
const noteContent = '# Secret link\n\n[Target secret](verstak-secret://target.secret)';
await page.evaluate(async ({ notePath, noteContent }) => {
const writeError = await window.go.api.App.WriteVaultTextFile(
'verstak.platform-test',
notePath,
noteContent,
{ createIfMissing: true, overwrite: true },
);
if (writeError) throw new Error(writeError);
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.platform-test', {
kind: 'vault-file',
path: notePath,
extension: '.md',
context: { sourceView: 'notes', isInsideNotesFolder: true, notesMode: true },
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}, { notePath, noteContent });
const note = page.locator('[data-editor-mode="notes-markdown"]');
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await note.locator('.secret-link').click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active .secrets-item-title')).toHaveText('Target secret');
await expect(secrets.locator('.secrets-card h2')).toHaveText('Target secret');
await page.locator('.workbench-host .close-btn').click();
await expect(note).toBeVisible({ timeout: 10000 });
await expect(note.locator('[data-preview]')).toContainText('Target secret');
await expect(note.locator('[data-editor-textarea]')).toHaveCount(0);
await expect(note.locator('[data-save-state]')).toHaveText('');
const storedContent = await page.evaluate(async ({ notePath }) => {
const [content, readError] = await window.go.api.App.ReadVaultTextFile('verstak.platform-test', notePath);
if (readError) throw new Error(readError);
return content;
}, { notePath });
expect(storedContent).toBe(noteContent);
});
test('unavailable secret does not fall back to the first secret', async ({ page }) => {
await page.evaluate(async () => {
const [result, openError] = await window.go.api.App.OpenWorkbenchResource('verstak.default-editor', {
kind: 'secret',
path: 'missing.secret',
mode: 'view',
});
if (openError) throw new Error(openError);
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
});
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets.locator('.secrets-item.active')).toHaveCount(0);
await expect(secrets.locator('.secrets-status.error')).toContainText(/unavailable|недоступен/i);
});
test('editor supports markdown toolbar split save reopen and revert', async ({ page }) => {
await page.evaluate(async () => {
const err = await window.go.api.App.WriteVaultTextFile(

View File

@ -0,0 +1,35 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Journal creates an entry in the selected Deal', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.journal', {
'worklog:workspace:Project': [{ entryId: 'existing-project-entry', workspaceRootPath: 'Project', date: '2026-07-14', title: 'Existing entry', minutes: 5 }],
});
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Journal' }).click();
const journal = page.locator('.journal-root');
await expect(journal.locator('.journal-title')).toHaveText('Journal');
await journal.locator('[data-journal-action="add"]').click();
await journal.locator('[data-journal-input="workspaceRootPath"]').selectOption('Project');
await journal.locator('[data-journal-input="title"]').fill('Prepare project handoff');
await journal.locator('[data-journal-input="minutes"]').fill('30');
await journal.locator('[data-journal-action="save-entry"]').click();
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.journal');
const settings = Array.isArray(result) ? result[0] : result;
return settings['worklog:workspace:Project']?.[0]?.title;
})).toBe('Prepare project handoff');
await expect(journal).toContainText('Prepare project handoff');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await page.getByRole('tab', { name: 'Journal' }).click();
await expect(page.locator('.journal-root')).toContainText('Prepare project handoff');
consoleCollector.assertNoErrors();
});

View File

@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
test('global Secrets lists, filters, and creates Deal-scoped secrets', async ({ page }) => {
const consoleCollector = setupConsoleCollector(page);
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.evaluate(async () => {
const [record, error] = await window.go.api.App.PluginSecretsWrite('verstak.secrets', {
id: 'project.global-secret',
title: 'Project API',
username: 'project-user',
value: 'project-value',
scope: { kind: 'workspace', workspaceRootPath: 'Project' },
});
if (error || !record?.id) throw new Error(error || 'could not create a project secret');
});
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Secrets' }).click();
const secrets = page.locator('.secrets-root');
await expect(secrets).toBeVisible({ timeout: 10000 });
await expect(secrets).toContainText('First secret');
await expect(secrets).toContainText('Project API');
await secrets.locator('[data-secret-scope-filter]').selectOption('workspace:Project');
await expect(secrets).toContainText('Project API');
await expect(secrets).not.toContainText('First secret');
await secrets.locator('[data-secret-search]').fill('no matching secret');
await expect(secrets).toContainText('No secrets');
await secrets.locator('[data-secret-search]').fill('');
await secrets.locator('[data-secret-scope-filter]').selectOption('all');
await secrets.getByRole('button', { name: 'New' }).click();
await secrets.locator('[data-secret-title]').fill('Project deployment token');
await secrets.locator('[data-secret-value]').fill('deployment-token-value');
await secrets.locator('[data-secret-scope]').selectOption('workspace');
await secrets.locator('[data-secret-workspace]').selectOption('Project');
await secrets.locator('[data-secret-save]').click();
await expect.poll(async () => page.evaluate(async () => {
const [records, error] = await window.go.api.App.PluginSecretsList('verstak.secrets');
if (error) throw new Error(error);
const record = records.find((item) => item.title === 'Project deployment token');
return record && [record.scope?.kind, record.scope?.workspaceRootPath].join('|');
})).toBe('workspace|Project');
consoleCollector.assertNoErrors();
});

View File

@ -30,18 +30,45 @@ test.describe('B: Sidebar opens plugin view by item.view', () => {
test('Sidebar item exists with correct label', async ({ page }) => {
await expect(page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' })).not.toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
const sidebarItem = page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' });
await expect(sidebarItem).toBeVisible();
});
test('Global Activity and Browser Inbox sidebar items open plugin views', async ({ page }) => {
test('Global Activity and Browser sidebar items open plugin views', async ({ page }) => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Activity', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser Inbox', { timeout: 10000 });
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
});
test('selected global tool remains visibly active through navigation and sidebar reloads', async ({ page }) => {
const activity = page.locator('.sidebar .plugin-item').filter({ hasText: 'Activity' });
const browserInbox = page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' });
await activity.click();
await expect(activity).toHaveAttribute('aria-current', 'page');
await expect(activity).toHaveClass(/is-active/);
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
await expect(activity).toHaveAttribute('aria-current', 'page');
await page.evaluate(() => {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: 'verstak.browser-inbox.view', pluginId: 'verstak.browser-inbox' },
}));
});
await expect(page.locator('.view-container .view-header h2')).toHaveText('Browser', { timeout: 10000 });
await expect(browserInbox).toHaveAttribute('aria-current', 'page');
await expect(browserInbox).toHaveClass(/is-active/);
await expect(activity).not.toHaveAttribute('aria-current', 'page');
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(browserInbox).not.toHaveAttribute('aria-current', 'page');
});
test('Click sidebar item opens diagnostics view by view ID, not sidebar ID', async ({ page }) => {

View File

@ -27,7 +27,10 @@ test.describe('Todo plugin workflow', () => {
await todos.locator('[data-todo-input="description"]').fill('Collect factual review notes.');
await todos.locator('[data-todo-input="priority"]').selectOption('high');
await todos.locator('[data-todo-input="dueAt"]').fill('2000-01-01');
await todos.locator('[data-todo-input="reminderAt"]').fill('2000-01-01T09:00');
await todos.locator('[data-todo-input="reminderDate"]').fill('2000-01-01');
const reminderTime = todos.locator('[data-todo-input="reminderTime"]');
await expect(reminderTime).toHaveAttribute('type', 'text');
await reminderTime.fill('09:30');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Overdue');
@ -36,13 +39,23 @@ test.describe('Todo plugin workflow', () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01T09:00');
return todo && [todo.workspaceRootPath, todo.priority, todo.dueAt, todo.reminderDate, todo.reminderAt].join('|');
})).toBe('Project|high|2000-01-01|2000-01-01|2000-01-01T09:30');
await todos.locator('[data-todo-action="edit"]').click();
await todos.locator('[data-todo-input="title"]').fill('Prepare project review updated');
await todos.locator('[data-todo-input="reminderTime"]').fill('not-a-time');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Enter a valid reminder time');
await todos.locator('[data-todo-input="reminderTime"]').fill('');
await todos.locator('[data-todo-action="save"]').click();
await expect(todos).toContainText('Prepare project review updated');
await expect.poll(async () => page.evaluate(async () => {
const result = await window.go.api.App.ReadPluginSettings('verstak.todo');
const settings = Array.isArray(result) ? result[0] : result;
const todo = settings['todos:global'].find((item) => item.title === 'Prepare project review updated');
return todo && [todo.reminderDate, todo.reminderAt].join('|');
})).toBe('2000-01-01|');
await todos.locator('[data-todo-action="mark-done"]').click();
await expect(todos.locator('[data-todo-action="create-journal-entry"]')).toBeVisible();

View File

@ -18,7 +18,7 @@ test.describe('UX follow-up fixes', () => {
await expect(search).toBeVisible();
await expect(page.locator('.sidebar [data-global-search-input]')).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
@ -67,7 +67,7 @@ test.describe('UX follow-up fixes', () => {
const search = page.locator('[data-global-search-input]');
await search.fill('Research Search Result');
const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' });
const result = page.locator('[data-global-search-result-type="Browser"]').filter({ hasText: 'Research Search Result' });
await expect(result).toBeVisible({ timeout: 10000 });
await result.click();

View File

@ -38,6 +38,11 @@ test.describe('UX P0 shell flow', () => {
await expect(page.locator('.plugin-manager')).toHaveCount(0);
});
test('Deal header does not expose the internal workspace type badge', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.workspace-type')).toHaveCount(0);
});
test('status bar plugin contribution failures do not render large error panels', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Plugin View Error')).toHaveCount(0);

View File

@ -56,7 +56,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="captures"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
@ -70,7 +70,7 @@ test.describe('UX Overview workspace flow', () => {
await page.getByRole('tab', { name: 'Overview' }).click();
await overview.locator('[data-overview-summary="attention"]').click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
});
test('Overview keeps the Notes total factual when the Files plugin is unavailable', async ({ page }) => {
@ -80,6 +80,54 @@ test.describe('UX Overview workspace flow', () => {
await expect(page.locator('[data-overview-summary="notes"]')).toContainText('1 total');
});
test('Overview hides Browser cards and actions when the current Deal does not include it', async ({ page }) => {
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await modal.locator('[data-workspace-name]').fill('MinimalOverview');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'hidden-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Inbox material must stay hidden',
workspaceRootPath: 'MinimalOverview',
}],
});
});
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
await expect(overview).not.toContainText('Inbox material must stay hidden');
});
test('Overview refreshes when Browser is disabled through plugin state changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
'captures:global': [{
captureId: 'disabled-browser-capture',
capturedAt: '2026-07-14T08:00:00.000Z',
kind: 'page',
title: 'Disabled inbox material',
workspaceRootPath: 'Project',
}],
});
window.__wailsMock.setPluginStatus('verstak.browser-inbox', 'disabled', false);
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
});
const overview = page.locator('[data-overview-root]');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveCount(0);
await expect(overview.locator('[data-overview-summary="captures"]')).toHaveCount(0);
await expect(overview.locator('[data-overview-action="browser-inbox"]')).toHaveCount(0);
});
test('Overview prioritizes resume work and filters meaningful recent changes', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
@ -200,10 +248,10 @@ test.describe('UX Overview workspace flow', () => {
await expect(overview.locator('[data-overview-summary="attention"]')).toContainText('3');
const attention = overview.locator('[data-overview-section="attention"]');
await expect(attention).toContainText('Possible journal entry');
await expect(attention).toContainText('Workspace: Project · 10 min · 2 activities');
await expect(attention).toContainText('Deal: Project · 10 min · 2 activities');
await attention.locator('.overview-attention-row', { hasText: 'Possible journal entry' }).getByRole('button', { name: 'Review candidate' }).click();
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Workspace: Project');
await expect(page.locator('.journal-root [data-journal-candidate]')).toContainText('Deal: Project');
await page.locator('.journal-modal-actions').getByRole('button', { name: 'Cancel' }).click();
await page.getByRole('tab', { name: 'Overview' }).click();
@ -216,7 +264,7 @@ test.describe('UX Overview workspace flow', () => {
await expect(candidates.nth(3)).toContainText('Changed file "draft.md"');
await candidates.nth(0).click();
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await page.getByRole('tab', { name: 'Overview' }).click();
@ -247,4 +295,28 @@ test.describe('UX Overview workspace flow', () => {
await expect(recent).toContainText('Added journal entry "Write project summary"');
await expect(recent).not.toContainText('Changed file "draft.md"');
});
test('Overview localizes activity labels without exposing internal event names', async ({ page }) => {
await page.evaluate(async () => {
await window.go.api.App.WritePluginSettings('verstak.activity', {
'events:workspace:Project': [{
activityId: 'overview-russian-note',
occurredAt: '2026-06-30T08:25:00.000Z',
type: 'note.saved',
title: 'Локализация',
summary: 'Project/Notes/Localization.md',
workspaceRootPath: 'Project',
}],
});
});
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-language="ru"]').click();
const overview = page.locator('[data-overview-root]');
await overview.locator('[data-overview-action="refresh"]').click();
const recent = overview.locator('[data-overview-section="recent"]');
await expect(recent).toContainText('Изменена заметка «Локализация»');
await expect(recent).not.toContainText('note.saved');
await expect(recent).not.toContainText('Edited note');
});
});

View File

@ -16,7 +16,7 @@ test.describe('Workspace templates', () => {
});
async function openCreateModal(page) {
await page.locator('button[title="New workspace"]').click();
await page.locator('button[title="New Deal"]').click();
const modal = page.locator('[data-workspace-create-modal]');
await expect(modal).toBeVisible();
return modal;
@ -24,22 +24,24 @@ test.describe('Workspace templates', () => {
test('creation modal validates names, shows template tools, and persists the selected snapshot', async ({ page }) => {
const modal = await openCreateModal(page);
await expect(modal.locator('[data-workspace-template]')).toHaveValue('default');
const templateSelect = modal.locator('[data-workspace-template]');
await expect(templateSelect).toHaveValue('default');
await expect(templateSelect).toHaveCSS('appearance', 'none');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Notes');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser Inbox');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Browser');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Name is required');
await modal.locator('[data-workspace-name]').fill('bad/name');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('invalid-workspace-name');
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(modal.locator('[data-workspace-create-error]')).toContainText('Could not create the Deal. Please try again.');
await modal.locator('[data-workspace-name]').fill('ProjectPlan');
await modal.locator('[data-workspace-template]').selectOption('project');
await expect(modal.locator('[data-workspace-template-description]')).toContainText('Project planning');
await expect(modal.locator('[data-workspace-template-tools]')).toContainText('Todos');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.locator('.wt-label').filter({ hasText: 'ProjectPlan' })).toBeVisible();
await expect.poll(async () => page.evaluate(async () => {
@ -63,7 +65,7 @@ test.describe('Workspace templates', () => {
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('MinimalSpace');
await modal.locator('[data-workspace-template]').selectOption('minimal');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
@ -72,21 +74,40 @@ test.describe('Workspace templates', () => {
await expect(page.getByRole('tab', { name: 'Journal' })).toHaveCount(0);
await expect(page.getByRole('tab', { name: 'Secrets' })).toHaveCount(0);
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Todos' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser Inbox' })).toBeVisible();
await expect(page.locator('.sidebar .plugin-item').filter({ hasText: 'Browser' })).toBeVisible();
});
test('template explains an unavailable plugin and warns after incomplete creation', async ({ page }) => {
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
const modal = await openCreateModal(page);
await modal.locator('[data-workspace-template]').selectOption('project');
const todo = modal.locator('[data-workspace-template-tool="verstak.todo"]');
await expect(todo).toContainText('Todos');
await expect(todo).toContainText('Plugin is disabled');
await expect(todo).toHaveAttribute('data-template-tool-status', 'unavailable');
await modal.locator('[data-workspace-name]').fill('ProjectWithWarning');
await modal.getByRole('button', { name: 'Create Deal' }).click();
const warning = page.locator('[data-workspace-template-warning]');
await expect(warning).toContainText('ProjectWithWarning');
await expect(warning).toContainText('Todos');
await expect(warning).toContainText('Plugin is disabled');
});
test('Admin shows Secrets when available and missing workspace plugins degrade without breaking tabs', async ({ page }) => {
let modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('AdminSpace');
await modal.locator('[data-workspace-template]').selectOption('admin');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Secrets' })).toBeVisible();
await page.evaluate(() => window.__wailsMock.setPluginStatus('verstak.todo', 'disabled', false));
modal = await openCreateModal(page);
await modal.locator('[data-workspace-name]').fill('ProjectWithoutTodo');
await modal.locator('[data-workspace-template]').selectOption('project');
await modal.getByRole('button', { name: 'Create workspace' }).click();
await modal.getByRole('button', { name: 'Create Deal' }).click();
await expect(page.getByRole('tab', { name: 'Notes' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Files' })).toBeVisible();

View File

@ -395,7 +395,11 @@
<VaultSelection />
{:else}
<main>
<Sidebar showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'} />
<Sidebar
showGlobalSearch={currentView !== 'workspace' && currentView !== 'workspace-empty'}
{activeView}
{activeViewPluginId}
/>
<CommandPalette />
<section class="content-shell">

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Failed to load sidebar',
'sidebar.error.ui': 'Plugin UI error',
'workspace.overview': 'Overview',
'workspace.search': 'Workspace search',
'workspace.tools': 'Workspace tools',
'workspace.tool': 'Workspace tool',
'workspace.emptyTools': 'No workspace tools available',
'workspace.emptyToolsHint': 'Enable plugins with workspace tools or open Plugin Manager from settings.',
'workspace.select': 'Select a workspace',
'workspace.selectHint': 'Use the + button in Workspaces to add your first project.',
'workspace.search': 'Search in this Deal',
'workspace.tools': 'Deal tools',
'workspace.tool': 'Deal tool',
'workspace.emptyTools': 'No Deal tools available',
'workspace.emptyToolsHint': 'Enable plugins with Deal tools or open Plugin Manager from settings.',
'workspace.select': 'Select a Deal',
'workspace.selectHint': 'Use the + button in Deals to add your first project.',
'common.details': 'Details',
'common.plugin': 'Plugin',
'common.component': 'Component',
@ -74,7 +74,7 @@ export default {
'pluginCard.count.sidebar': '{count} sidebar item(s)',
'pluginCard.count.statusbar': '{count} status bar item(s)',
'pluginCard.count.openProviders': '{count} openProviders',
'pluginCard.count.workspace': '{count} workspace item(s)',
'pluginCard.count.workspace': '{count} Deal tool(s)',
'pluginCard.degraded': 'Plugin is usable, but some optional capabilities are unavailable.',
'pluginCard.name': 'Name',
'pluginCard.contributions': 'Contributions',
@ -96,12 +96,12 @@ export default {
'common.loading': 'Loading...',
'common.browse': 'Browse...',
'vaultSelection.chooseNew': 'Choose or enter a folder for the new vault.',
'vaultSelection.createError': 'Could not create vault: {error}',
'vaultSelection.openError': 'Could not open vault: {error}',
'vaultSelection.createError': 'Could not create the vault. Please try again.',
'vaultSelection.openError': 'Could not open the vault. Please try again.',
'vaultSelection.chooseExisting': 'Choose or enter an existing vault.',
'vaultSelection.subtitle': 'Choose a vault to start working',
'vaultSelection.createTitle': 'Create a new vault',
'vaultSelection.createHint': 'Create a local vault folder for workspaces and projects.',
'vaultSelection.createHint': 'Create a local vault folder for Deals and projects.',
'vaultSelection.pathPlaceholder': 'Choose or enter a path...',
'vaultSelection.creating': 'Creating...',
'vaultSelection.create': 'Create vault',
@ -112,21 +112,45 @@ export default {
'vaultSelection.recent': 'Recent vaults',
'common.cancel': 'Cancel',
'common.close': 'Close',
'workspaceTree.title': 'Workspaces',
'workspaceTree.new': 'New workspace',
'workspaceTree.title': 'Deals',
'workspaceTree.new': 'New Deal',
'workspaceTree.nameRequired': 'Name is required',
'workspaceTree.chooseTemplate': 'Choose a workspace template',
'workspaceTree.chooseTemplate': 'Choose a Deal template',
'workspaceTree.saveRename': 'Save rename',
'workspaceTree.rename': 'Rename workspace',
'workspaceTree.trash': 'Trash workspace',
'workspaceTree.create': 'Create workspace',
'workspaceTree.namePlaceholder': 'Workspace name',
'workspaceTree.rename': 'Rename Deal',
'workspaceTree.trash': 'Move Deal to trash',
'workspaceTree.create': 'Create Deal',
'workspaceTree.namePlaceholder': 'Deal name',
'workspaceTree.template': 'Template',
'workspaceTree.creating': 'Creating...',
'workspaceTree.templateAvailable': 'Available',
'workspaceTree.templateLimited': 'Available with limitations',
'workspaceTree.templateMissingPlugin': 'Plugin is not installed',
'workspaceTree.templatePluginDisabled': 'Plugin is disabled',
'workspaceTree.templateCapabilityUnavailable': 'A required capability is unavailable',
'workspaceTree.templateIncompatible': 'Plugin is incompatible with this version of Verstak',
'workspaceTree.templateLoadFailed': 'Plugin failed to load',
'workspaceTree.templateNotReady': 'Plugin is not ready',
'workspaceTree.templateToolTabs': 'Creates: {tabs}',
'workspaceTree.templateToolNoTabs': 'No Deal tab provided',
'workspaceTree.templateWillBeIncomplete': 'This Deal will be created without the unavailable components.',
'workspaceTree.templateIncompleteCreated': '«{name}» was created with unavailable components.',
'workspaceTree.templatesError': 'Could not load Deal templates. Please try again.',
'workspaceTree.loadError': 'Could not load Deals. Please try again.',
'workspaceTree.selectError': 'Could not select this Deal. Please try again.',
'workspaceTree.createError': 'Could not create the Deal. Please try again.',
'workspaceTree.renameError': 'Could not rename the Deal. Please try again.',
'workspaceTree.trashError': 'Could not move the Deal to trash. Please try again.',
'pluginManager.loadError': 'Could not load plugins. Please try again.',
'pluginManager.reloadError': 'Could not reload plugins. Please try again.',
'pluginManager.enableError': 'Could not enable the plugin. Please try again.',
'pluginManager.disableError': 'Could not disable the plugin. Please try again.',
'pluginManager.settingsLoadError': 'Could not load plugin settings. Please try again.',
'pluginManager.settingsUnavailable': 'Plugin settings are unavailable.',
'command.openOverview': 'Open Overview',
'command.openFiles': 'Open Files',
'command.openActivity': 'Open Activity',
'command.openBrowserInbox': 'Open Browser Inbox',
'command.openBrowserInbox': 'Open Browser',
'command.createMarkdown': 'Create Markdown File',
'command.createText': 'Create Text File',
'command.syncNow': 'Sync Now',
@ -134,6 +158,7 @@ export default {
'command.openPluginManager': 'Open Plugin Manager',
'command.handled': '{title} handled',
'command.result': '{title} {status}',
'command.failed': 'Could not run {title}. Please try again.',
'command.statusHandled': 'handled',
'command.palette': 'Command Palette',
'command.run': 'Run command',
@ -142,26 +167,26 @@ export default {
'search.placeholder': 'Search',
'search.global': 'Global search',
'search.noResults': 'No results',
'search.type.workspace': 'Workspace',
'search.type.workspace': 'Deal',
'search.type.tool': 'Tool',
'search.type.folder': 'Folder',
'search.type.file': 'File',
'search.type.journal': 'Journal',
'search.type.browserInbox': 'Browser Inbox',
'search.type.browserInbox': 'Browser',
'search.type.activity': 'Activity',
'workbench.noProvider': 'No provider',
'workbench.noViewer': 'No viewer/editor available',
'workbench.noResource': 'No resource opened',
'bundle.noFrontend': 'Plugin has no frontend bundle',
'bundle.notFound': 'Plugin not found',
'bundle.loadFailed': 'Failed to load bundle: {error}',
'bundle.loadFailed': 'Could not load the plugin interface. Please try again.',
'bundle.emptyContent': 'empty content',
'bundle.executionError': 'Bundle execution error: {error}',
'bundle.executionError': 'Could not start the plugin interface. Please try again.',
'bundle.registrationMissing': 'Bundle loaded but no VerstakPluginRegister call detected',
'bundle.componentMissing': 'Component "{component}" not found in bundle. Available: {available}',
'bundle.mountError': 'Component mount error: {error}',
'bundle.componentMissing': 'The requested plugin interface is unavailable.',
'bundle.mountError': 'Could not open the plugin interface. Please try again.',
'bundle.mountUnavailable': 'Mount container not available',
'bundle.unexpectedError': 'Unexpected error: {error}',
'bundle.unexpectedError': 'Could not open the plugin interface. Please try again.',
'bundle.unknownError': 'Unknown error',
'bundle.frontendEntry': 'Frontend entry',
'bundle.availableComponents': 'Available components',
@ -197,18 +222,57 @@ export default {
'overview.count.journal.many': '{count} journal entries',
'overview.count.pending.one': '{count} pending item',
'overview.count.pending.many': '{count} pending items',
'overview.loadingContext': 'Loading workspace context...',
'overview.loadingContext': 'Loading Deal context...',
'overview.lastActive': 'Last active {time}',
'overview.noRecentActivity': 'No recent workspace activity',
'overview.noRecentActivity': 'No recent Deal activity',
'overview.refresh': 'Refresh',
'overview.summary': 'Workspace overview summary',
'overview.summary': 'Deal overview summary',
'overview.continue': 'Continue working',
'overview.continueHint': 'Pick up the next useful item in this workspace.',
'overview.loadingSignals': 'Loading workspace signals...',
'overview.continueHint': 'Pick up the next useful item in this Deal.',
'overview.loadingSignals': 'Loading Deal signals...',
'overview.noResume': 'No clear resume point yet',
'overview.noResumeHint': 'Recent notes, files, captures, and journal entries will appear here.',
'overview.recentChanges': 'Recent changes',
'overview.recentChangesHint': 'Latest meaningful activity in this workspace.',
'overview.recentChangesHint': 'Latest meaningful activity in this Deal.',
'overview.event.workspaceOpened': 'Opened Deal',
'overview.event.activity': 'Deal activity',
'overview.event.noteEdited': 'Edited note "{name}"',
'overview.event.noteOpened': 'Opened note "{name}"',
'overview.event.noteCreated': 'Created note "{name}"',
'overview.event.fileOpened': 'Opened file "{name}"',
'overview.event.fileChanged': 'Changed file "{name}"',
'overview.event.fileCreated': 'Created file "{name}"',
'overview.event.fileRemoved': 'Removed file "{name}"',
'overview.event.capturePage': 'Captured page "{name}"',
'overview.event.captureSelection': 'Captured selection "{name}"',
'overview.event.captureLink': 'Captured link "{name}"',
'overview.event.captureFile': 'Captured file "{name}"',
'overview.event.captureConverted': 'Converted capture "{name}"',
'overview.event.capture': 'Captured {kind} "{title}"',
'overview.event.journalAdded': 'Added journal entry "{name}"',
'overview.event.workSessionDetected': 'Work session detected',
'overview.event.todo': 'Todo "{title}"',
'overview.event.reviewCapture': 'Review capture "{title}"',
'overview.event.continueJournal': 'Continue journal entry "{title}"',
'overview.event.possibleJournalEntry': 'Possible journal entry',
'overview.captureKind.page': 'page',
'overview.captureKind.selection': 'selection',
'overview.captureKind.link': 'link',
'overview.captureKind.file': 'file',
'overview.captureKind.item': 'item',
'overview.todo.reminderDue': 'Reminder due',
'overview.todo.overdue': 'Overdue',
'overview.todo.dueSoon': 'Due soon',
'overview.todo.due': 'Due {date}',
'overview.candidateMeta': 'Deal: {deal} · {minutes} min · {activities} activities',
'overview.minutes': '{count} min',
'overview.browserCapture': 'Browser capture',
'overview.untitledTodo': 'Untitled todo',
'overview.untitledCapture': 'Untitled capture',
'overview.untitledJournal': 'Journal entry',
'overview.unknownDeal': 'Unknown Deal',
'overview.item': 'item',
'overview.overviewNote': 'Deal overview note',
'overview.recentFilter': 'Recent changes filter',
'overview.loadingRecent': 'Loading recent changes...',
'overview.noChanges': 'No meaningful changes for this filter yet.',

View File

@ -16,13 +16,13 @@ export default {
'sidebar.error.load': 'Не удалось загрузить боковую панель',
'sidebar.error.ui': 'Ошибка интерфейса плагина',
'workspace.overview': 'Обзор',
'workspace.search': 'Поиск в рабочем пространстве',
'workspace.tools': 'Инструменты рабочего пространства',
'workspace.tool': 'Инструмент рабочего пространства',
'workspace.search': 'Поиск в Деле',
'workspace.tools': 'Инструменты Дела',
'workspace.tool': 'Инструмент Дела',
'workspace.emptyTools': 'Нет доступных инструментов',
'workspace.emptyToolsHint': 'Включите плагины с инструментами рабочего пространства или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите рабочее пространство',
'workspace.selectHint': 'Используйте кнопку + в разделе рабочих пространств, чтобы добавить первый проект.',
'workspace.emptyToolsHint': 'Включите плагины с инструментами Дела или откройте Менеджер плагинов в настройках.',
'workspace.select': 'Выберите Дело',
'workspace.selectHint': 'Используйте кнопку + в разделе Дел, чтобы добавить первый проект.',
'common.details': 'Подробнее',
'common.plugin': 'Плагин',
'common.component': 'Компонент',
@ -74,7 +74,7 @@ export default {
'pluginCard.count.sidebar': 'элементов боковой панели: {count}',
'pluginCard.count.statusbar': 'элементов строки состояния: {count}',
'pluginCard.count.openProviders': 'обработчиков открытия: {count}',
'pluginCard.count.workspace': 'инструментов рабочего пространства: {count}',
'pluginCard.count.workspace': 'инструментов Дела: {count}',
'pluginCard.degraded': 'Плагин работает, но некоторые необязательные capabilities недоступны.',
'pluginCard.name': 'Название',
'pluginCard.contributions': 'Элементы интерфейса',
@ -96,12 +96,12 @@ export default {
'common.loading': 'Загрузка...',
'common.browse': 'Обзор...',
'vaultSelection.chooseNew': 'Выберите или введите папку для нового хранилища.',
'vaultSelection.createError': 'Не удалось создать хранилище: {error}',
'vaultSelection.openError': 'Не удалось открыть хранилище: {error}',
'vaultSelection.createError': 'Не удалось создать хранилище. Повторите попытку.',
'vaultSelection.openError': 'Не удалось открыть хранилище. Повторите попытку.',
'vaultSelection.chooseExisting': 'Выберите или введите существующее хранилище.',
'vaultSelection.subtitle': 'Выберите хранилище, чтобы начать работу',
'vaultSelection.createTitle': 'Создать новое хранилище',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для рабочих пространств и проектов.',
'vaultSelection.createHint': 'Создайте локальную папку хранилища для Дел и проектов.',
'vaultSelection.pathPlaceholder': 'Выберите или введите путь...',
'vaultSelection.creating': 'Создание...',
'vaultSelection.create': 'Создать хранилище',
@ -112,21 +112,45 @@ export default {
'vaultSelection.recent': 'Недавние хранилища',
'common.cancel': 'Отмена',
'common.close': 'Закрыть',
'workspaceTree.title': 'Рабочие пространства',
'workspaceTree.new': 'Новое рабочее пространство',
'workspaceTree.title': 'Дела',
'workspaceTree.new': 'Новое Дело',
'workspaceTree.nameRequired': 'Введите название',
'workspaceTree.chooseTemplate': 'Выберите шаблон рабочего пространства',
'workspaceTree.chooseTemplate': 'Выберите шаблон Дела',
'workspaceTree.saveRename': 'Сохранить новое название',
'workspaceTree.rename': 'Переименовать рабочее пространство',
'workspaceTree.trash': 'Переместить рабочее пространство в корзину',
'workspaceTree.create': 'Создать рабочее пространство',
'workspaceTree.namePlaceholder': 'Название рабочего пространства',
'workspaceTree.rename': 'Переименовать Дело',
'workspaceTree.trash': 'Переместить Дело в корзину',
'workspaceTree.create': 'Создать Дело',
'workspaceTree.namePlaceholder': 'Название Дела',
'workspaceTree.template': 'Шаблон',
'workspaceTree.creating': 'Создание...',
'workspaceTree.templateAvailable': 'Доступен',
'workspaceTree.templateLimited': 'Доступен с ограничениями',
'workspaceTree.templateMissingPlugin': 'Плагин не установлен',
'workspaceTree.templatePluginDisabled': 'Плагин выключен',
'workspaceTree.templateCapabilityUnavailable': 'Не хватает обязательной возможности',
'workspaceTree.templateIncompatible': 'Плагин несовместим с этой версией Верстака',
'workspaceTree.templateLoadFailed': 'Плагин не удалось загрузить',
'workspaceTree.templateNotReady': 'Плагин ещё не готов к работе',
'workspaceTree.templateToolTabs': 'Создаёт вкладки: {tabs}',
'workspaceTree.templateToolNoTabs': 'Не предоставляет вкладку Дела',
'workspaceTree.templateWillBeIncomplete': 'Дело будет создано без недоступных компонентов.',
'workspaceTree.templateIncompleteCreated': 'Дело «{name}» создано с недоступными компонентами.',
'workspaceTree.templatesError': 'Не удалось загрузить шаблоны Дел. Повторите попытку.',
'workspaceTree.loadError': 'Не удалось загрузить Дела. Повторите попытку.',
'workspaceTree.selectError': 'Не удалось выбрать это Дело. Повторите попытку.',
'workspaceTree.createError': 'Не удалось создать Дело. Повторите попытку.',
'workspaceTree.renameError': 'Не удалось переименовать Дело. Повторите попытку.',
'workspaceTree.trashError': 'Не удалось переместить Дело в корзину. Повторите попытку.',
'pluginManager.loadError': 'Не удалось загрузить плагины. Повторите попытку.',
'pluginManager.reloadError': 'Не удалось перезагрузить плагины. Повторите попытку.',
'pluginManager.enableError': 'Не удалось включить плагин. Повторите попытку.',
'pluginManager.disableError': 'Не удалось выключить плагин. Повторите попытку.',
'pluginManager.settingsLoadError': 'Не удалось загрузить настройки плагина. Повторите попытку.',
'pluginManager.settingsUnavailable': 'Настройки плагина недоступны.',
'command.openOverview': 'Открыть обзор',
'command.openFiles': 'Открыть файлы',
'command.openActivity': 'Открыть активность',
'command.openBrowserInbox': 'Открыть входящие из браузера',
'command.openBrowserInbox': 'Открыть Браузер',
'command.createMarkdown': 'Создать файл Markdown',
'command.createText': 'Создать текстовый файл',
'command.syncNow': 'Синхронизировать сейчас',
@ -134,6 +158,7 @@ export default {
'command.openPluginManager': 'Открыть менеджер плагинов',
'command.handled': 'Выполнено: {title}',
'command.result': '{title}: {status}',
'command.failed': 'Не удалось выполнить команду «{title}». Повторите попытку.',
'command.statusHandled': 'выполнено',
'command.palette': 'Палитра команд',
'command.run': 'Выполнить команду',
@ -142,26 +167,26 @@ export default {
'search.placeholder': 'Поиск',
'search.global': 'Глобальный поиск',
'search.noResults': 'Ничего не найдено',
'search.type.workspace': 'Рабочее пространство',
'search.type.workspace': 'Дело',
'search.type.tool': 'Инструмент',
'search.type.folder': 'Папка',
'search.type.file': 'Файл',
'search.type.journal': 'Журнал',
'search.type.browserInbox': 'Входящие из браузера',
'search.type.browserInbox': 'Браузер',
'search.type.activity': 'Активность',
'workbench.noProvider': 'Нет обработчика',
'workbench.noViewer': 'Нет доступного средства просмотра или редактора',
'workbench.noResource': 'Ресурс не открыт',
'bundle.noFrontend': 'У плагина нет frontend bundle',
'bundle.notFound': 'Плагин не найден',
'bundle.loadFailed': 'Не удалось загрузить bundle: {error}',
'bundle.loadFailed': 'Не удалось загрузить интерфейс плагина. Повторите попытку.',
'bundle.emptyContent': 'пустое содержимое',
'bundle.executionError': 'Ошибка выполнения bundle: {error}',
'bundle.executionError': 'Не удалось запустить интерфейс плагина. Повторите попытку.',
'bundle.registrationMissing': 'Bundle загружен, но вызов VerstakPluginRegister не обнаружен',
'bundle.componentMissing': 'Компонент «{component}» не найден в bundle. Доступны: {available}',
'bundle.mountError': 'Ошибка подключения компонента: {error}',
'bundle.componentMissing': 'Запрошенный интерфейс плагина недоступен.',
'bundle.mountError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.mountUnavailable': 'Контейнер для подключения недоступен',
'bundle.unexpectedError': 'Непредвиденная ошибка: {error}',
'bundle.unexpectedError': 'Не удалось открыть интерфейс плагина. Повторите попытку.',
'bundle.unknownError': 'Неизвестная ошибка',
'bundle.frontendEntry': 'Точка входа frontend',
'bundle.availableComponents': 'Доступные компоненты',
@ -197,18 +222,57 @@ export default {
'overview.count.journal.many': 'Записей журнала: {count}',
'overview.count.pending.one': 'Ожидают: {count}',
'overview.count.pending.many': 'Ожидают: {count}',
'overview.loadingContext': 'Загрузка данных рабочего пространства...',
'overview.loadingContext': 'Загрузка данных Дела...',
'overview.lastActive': 'Последняя активность: {time}',
'overview.noRecentActivity': 'В рабочем пространстве пока нет недавней активности',
'overview.noRecentActivity': 'В Деле пока нет недавней активности',
'overview.refresh': 'Обновить',
'overview.summary': 'Сводка рабочего пространства',
'overview.summary': 'Сводка Дела',
'overview.continue': 'Продолжить работу',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом рабочем пространстве.',
'overview.loadingSignals': 'Загрузка рабочих данных...',
'overview.continueHint': 'Вернитесь к следующему полезному делу в этом Деле.',
'overview.loadingSignals': 'Загрузка данных Дела...',
'overview.noResume': 'Пока неясно, с чего продолжить',
'overview.noResumeHint': 'Здесь появятся недавние заметки, файлы, сохранённое и записи журнала.',
'overview.recentChanges': 'Недавние изменения',
'overview.recentChangesHint': 'Последняя важная активность в этом рабочем пространстве.',
'overview.recentChangesHint': 'Последняя важная активность в этом Деле.',
'overview.event.workspaceOpened': 'Открыто Дело',
'overview.event.activity': 'Активность Дела',
'overview.event.noteEdited': 'Изменена заметка «{name}»',
'overview.event.noteOpened': 'Открыта заметка «{name}»',
'overview.event.noteCreated': 'Создана заметка «{name}»',
'overview.event.fileOpened': 'Открыт файл «{name}»',
'overview.event.fileChanged': 'Изменён файл «{name}»',
'overview.event.fileCreated': 'Создан файл «{name}»',
'overview.event.fileRemoved': 'Удалён файл «{name}»',
'overview.event.capturePage': 'Сохранена страница «{name}»',
'overview.event.captureSelection': 'Сохранено выделение «{name}»',
'overview.event.captureLink': 'Сохранена ссылка «{name}»',
'overview.event.captureFile': 'Сохранён файл «{name}»',
'overview.event.captureConverted': 'Сохранённое преобразовано: «{name}»',
'overview.event.capture': 'Сохранено: {kind} «{title}»',
'overview.event.journalAdded': 'Добавлена запись в журнал «{name}»',
'overview.event.workSessionDetected': 'Обнаружена рабочая сессия',
'overview.event.todo': 'Задача «{title}»',
'overview.event.reviewCapture': 'Разобрать сохранённое «{title}»',
'overview.event.continueJournal': 'Продолжить запись журнала «{title}»',
'overview.event.possibleJournalEntry': 'Возможная запись в журнал',
'overview.captureKind.page': 'страница',
'overview.captureKind.selection': 'выделение',
'overview.captureKind.link': 'ссылка',
'overview.captureKind.file': 'файл',
'overview.captureKind.item': 'элемент',
'overview.todo.reminderDue': 'Пора напомнить',
'overview.todo.overdue': 'Просрочено',
'overview.todo.dueSoon': 'Скоро срок',
'overview.todo.due': 'Срок: {date}',
'overview.candidateMeta': 'Дело: {deal} · {minutes} мин. · активностей: {activities}',
'overview.minutes': '{count} мин.',
'overview.browserCapture': 'Сохранённое из браузера',
'overview.untitledTodo': 'Задача без названия',
'overview.untitledCapture': 'Сохранённое без названия',
'overview.untitledJournal': 'Запись журнала',
'overview.unknownDeal': 'Неизвестное Дело',
'overview.item': 'элемент',
'overview.overviewNote': 'Обзорная заметка Дела',
'overview.recentFilter': 'Фильтр недавних изменений',
'overview.loadingRecent': 'Загрузка недавних изменений...',
'overview.noChanges': 'Для этого фильтра пока нет важных изменений.',

View File

@ -83,6 +83,11 @@
return { value: result, error: '' };
}
function reportError(key, fallback, details) {
console.warn('[PluginBundleHost] ' + key + ':', details);
return tr(key, undefined, fallback);
}
async function loadAndMount(pId, compId, nextPropsKey) {
// If same plugin+component and already mounted, skip
if (currentPluginId === pId && currentComponent === compId && currentPropsKey === nextPropsKey && loadState === 'loaded') {
@ -125,7 +130,7 @@
const content = assetResult.value;
if (assetResult.error || !content) {
loadState = 'error';
errorText = tr('bundle.loadFailed', { error: assetResult.error || tr('bundle.emptyContent') });
errorText = reportError('bundle.loadFailed', 'Could not load the plugin interface. Please try again.', assetResult.error || tr('bundle.emptyContent'));
return;
}
@ -136,8 +141,7 @@
fn();
} catch (e) {
loadState = 'error';
errorText = tr('bundle.executionError', { error: e.message });
console.error('[PluginBundleHost] bundle exec error:', e);
errorText = reportError('bundle.executionError', 'Could not start the plugin interface. Please try again.', e);
return;
}
@ -154,10 +158,7 @@
const comp = components[compId];
if (!comp || !comp.mount) {
loadState = 'error';
errorText = tr('bundle.componentMissing', {
component: compId,
available: Object.keys(components).join(', ') || tr('common.none'),
});
errorText = tr('bundle.componentMissing', undefined, 'The requested plugin interface is unavailable.');
return;
}
@ -177,8 +178,7 @@
errorText = '';
} catch (e) {
loadState = 'error';
errorText = tr('bundle.mountError', { error: e.message });
console.error('[PluginBundleHost] mount error:', e);
errorText = reportError('bundle.mountError', 'Could not open the plugin interface. Please try again.', e);
}
} else {
loadState = 'error';
@ -186,8 +186,7 @@
}
} catch (e) {
loadState = 'error';
errorText = tr('bundle.unexpectedError', { error: e.message || e });
console.error('[PluginBundleHost] error:', e);
errorText = reportError('bundle.unexpectedError', 'Could not open the plugin interface. Please try again.', e);
}
}
@ -208,17 +207,18 @@
<div class="host-state error">
<Icon name="warning" size={24} class="error-icon" />
<p class="error-title">{tr('pluginView.error')}</p>
<div class="error-details">
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
<details class="error-details">
<summary>{tr('common.details')}</summary>
<p><strong>{tr('common.plugin')}:</strong> {currentPluginId || tr('common.unknown')}</p>
<p><strong>{tr('common.component')}:</strong> {currentComponent || tr('common.unknown')}</p>
<p class="error-message">{errorText || tr('bundle.unknownError')}</p>
{#if pluginInfo}
<p class="error-meta">{tr('bundle.frontendEntry')}: {pluginInfo.entry || tr('common.none')}</p>
{/if}
{#if getComponentList().length > 0}
<p class="error-meta">{tr('bundle.availableComponents')}: {getComponentList().join(', ')}</p>
{/if}
</div>
</details>
</div>
{:else}
@ -303,11 +303,21 @@
max-width: 400px;
text-align: left;
background: #16213e;
padding: 1rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid #0f3460;
}
.error-details[open] {
padding: 0.75rem 1rem;
}
.error-details summary {
cursor: pointer;
color: #e0e0f0;
font-weight: 600;
}
.error-details p {
margin: 0.3rem 0;
}
@ -318,12 +328,8 @@
.error-message {
color: #e94560;
font-family: monospace;
font-size: 0.8rem;
margin-top: 0.5rem !important;
padding: 0.5rem;
background: rgba(233, 69, 96, 0.1);
border-radius: 4px;
max-width: 400px;
margin: 0 0 0.75rem;
}
.error-meta {

View File

@ -53,6 +53,12 @@
}, 4000);
}
function reportError(key, fallback, details) {
debug.log('[PluginManager] ' + key + ':', String(details));
WriteFrontendLog('PluginManager', key + ': ' + String(details)).catch(() => {});
return tr(key, undefined, fallback);
}
function notifyPluginsChanged() {
window.dispatchEvent(new CustomEvent('verstak:plugins-changed'));
}
@ -89,14 +95,14 @@
ReadPluginSettings(pluginId).then(result => {
const unpacked = unpackBackendResult(result);
if (unpacked.error) {
settingsError = unpacked.error;
settingsError = reportError('pluginManager.settingsLoadError', 'Could not load plugin settings. Please try again.', unpacked.error);
settingsData = {};
return;
}
settingsData = unpacked.value || {};
}).catch(() => { settingsData = {}; });
} else {
settingsError = `Settings panel not found for plugin "${pluginId}". Check that the plugin is enabled and has settingsPanels in its manifest.`;
settingsError = tr('pluginManager.settingsUnavailable', undefined, 'Plugin settings are unavailable.');
}
}
@ -127,7 +133,7 @@
} catch (e) {
debug.log('[PluginManager] loadAll: GetPlugins ERROR:', String(e));
WriteFrontendLog('PluginManager', 'loadAll: GetPlugins ERROR: ' + String(e));
error = 'GetPlugins: ' + String(e);
error = reportError('pluginManager.loadError', 'Could not load plugins. Please try again.', e);
loading = false;
return;
}
@ -191,7 +197,7 @@
resultMsg = `Reloaded ${count} plugin(s). ${summary}`;
} catch (e) {
debug.log('[PluginManager] reload: ReloadPlugins ERROR:', String(e));
error = 'Reload: ' + String(e);
error = reportError('pluginManager.reloadError', 'Could not reload plugins. Please try again.', e);
reloading = false;
return;
}
@ -211,7 +217,7 @@
if (err) {
debug.log('[PluginManager] enablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Enable: ' + err;
error = reportError('pluginManager.enableError', 'Could not enable the plugin. Please try again.', err);
return;
}
debug.log('[PluginManager] enablePlugin: success, reloading...');
@ -232,7 +238,7 @@
if (err) {
debug.log('[PluginManager] disablePlugin: ERROR:', err);
actionFeedback = { ...actionFeedback, [pluginId]: null };
error = 'Disable: ' + err;
error = reportError('pluginManager.disableError', 'Could not disable the plugin. Please try again.', err);
return;
}
debug.log('[PluginManager] disablePlugin: success, reloading...');

View File

@ -3,6 +3,7 @@
import * as App from '../../../wailsjs/go/api/App';
import { executePluginCommand } from '../plugin-host/VerstakPluginAPI.js';
import { i18n } from '../i18n/index.js';
import { debug } from '../log/debug.js';
let open = false;
let query = '';
@ -194,7 +195,12 @@
closePalette();
setStatus('success', tr('command.result', { title: command.title || command.id, status: result.status || tr('command.statusHandled') }));
} catch (err) {
setStatus('error', `${command.title || command.id}: ${err?.message || String(err)}`);
const details = err?.message || String(err);
debug.log('[CommandPalette] command failed:', details);
App.WriteFrontendLog('CommandPalette', `command failed: ${details}`).catch(() => {});
setStatus('error', tr('command.failed', {
title: command.title || command.id,
}, `Could not run ${command.title || command.id}. Please try again.`));
}
}

View File

@ -8,6 +8,8 @@
import { i18n } from '../i18n/index.js';
export let showGlobalSearch = true;
export let activeView = null;
export let activeViewPluginId = '';
function flog(msg) {
App.WriteFrontendLog('Sidebar', msg);
@ -26,6 +28,7 @@
})(locale);
$: vaultOpen = vaultStatus.status === 'open';
$: activeSidebarKey = activeView ? `${activeViewPluginId}:${activeView}` : '';
async function loadSidebar() {
debug.log('[Sidebar] onMount: START');
@ -107,6 +110,8 @@
{#each sidebarItems as item}
<button
class="nav-item plugin-item vt-list-row"
class:is-active={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}`}
aria-current={activeSidebarKey === `${item.pluginId || ''}:${item.view || item.id}` ? 'page' : undefined}
on:click={() => handleSidebarItem(item)}
type="button"
>
@ -210,6 +215,16 @@
color: var(--vt-color-text-primary);
}
.nav-item.is-active {
background: var(--vt-color-surface-selected);
color: var(--vt-color-accent);
}
.nav-item.is-active :global(.nav-icon.icon-plugin) {
color: currentColor;
opacity: 1;
}
:global(.nav-icon) {
width: 0.9rem;
height: 0.9rem;

View File

@ -30,6 +30,7 @@
let keyResources = [];
let totalNotes = 0;
let loadedWorkspaceRoot = '';
let loadedToolKey = '';
let toolProbe = 0;
let locale = i18n.getLocale();
let unsubscribeLocale = null;
@ -38,17 +39,25 @@
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
$: FILTERS = ['all', 'notes', 'files', 'captures', 'journal'].map((key) => ({
key,
label: tr(`overview.filter.${key}`),
}));
function countText(key, count, params = {}) {
return tr(`${key}.${count === 1 ? 'one' : 'many'}`, { count, ...params });
}
$: hasNotes = hasTool('notes', availableTools);
$: hasFiles = hasTool('files', availableTools);
$: hasBrowserInbox = hasTool('browser-inbox', availableTools);
$: hasActivity = hasTool('activity', availableTools);
$: hasJournal = hasTool('journal', availableTools);
$: hasTodos = hasTool('todo', availableTools);
$: recentChanges = buildRecentChanges(activityEvents, captures, journalEntries);
$: overviewToolKey = [hasNotes, hasFiles, hasBrowserInbox, hasActivity, hasJournal, hasTodos].join('|');
$: FILTERS = ['all']
.concat(hasNotes ? ['notes'] : [])
.concat(hasFiles ? ['files'] : [])
.concat(hasBrowserInbox ? ['captures'] : [])
.concat(hasJournal ? ['journal'] : [])
.map((key) => ({ key, label: tr(`overview.filter.${key}`) }));
$: if (!FILTERS.some(filter => filter.key === activeFilter)) activeFilter = 'all';
$: recentChanges = filterAvailableItems(buildRecentChanges(activityEvents, captures, journalEntries), overviewToolKey);
$: filteredRecentChanges = activeFilter === 'all'
? recentChanges
: recentChanges.filter(item => item.category === activeFilter);
@ -58,18 +67,19 @@
$: linkedCandidateIds = new Set(journalEntries.map(item => String(item?.sourceCandidateId || '')).filter(Boolean));
$: pendingWorkSessionCandidates = workSessionCandidates.filter(item => !linkedCandidateIds.has(String(item?.candidateId || '')));
$: urgentTodos = hasTodos ? todos.filter(item => todoAttentionState(item)) : [];
$: needsAttention = buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos);
$: continueItems = buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos);
$: attentionActionKind = needsAttention[0]?.actionKind || 'browser-inbox';
$: needsAttention = filterAvailableItems(buildNeedsAttention(unprocessedCaptures, pendingWorkSessionCandidates, urgentTodos), overviewToolKey);
$: continueItems = filterAvailableItems(buildContinueItems(activityEvents, unprocessedCaptures, journalEntries, urgentTodos), overviewToolKey);
$: hasAttentionTools = hasBrowserInbox || hasTodos || (hasActivity && hasJournal);
$: attentionActionKind = needsAttention[0]?.actionKind || fallbackAttentionAction();
$: lastActive = lastActiveDate([...recentChanges, ...continueItems], captures, journalEntries, todos);
$: summaryItems = [
{ key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') },
{ key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') },
{ key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') },
{ key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') },
{ key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') },
{ key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') },
];
hasNotes ? { key: 'notes', label: tr('overview.notes'), count: totalNotes, detail: countText('overview.count.totalRecent', noteRecentChanges, { total: totalNotes, recent: noteRecentChanges }), actionKind: 'notes', actionLabel: tr('overview.openNotes') } : null,
hasFiles ? { key: 'files', label: tr('overview.files'), count: fileRecentChanges, detail: countText('overview.count.recentChanges', fileRecentChanges), actionKind: 'files', actionLabel: tr('overview.openFiles') } : null,
hasBrowserInbox ? { key: 'captures', label: tr('overview.captures'), count: unprocessedCaptures.length, detail: countText('overview.count.captures', unprocessedCaptures.length), actionKind: 'browser-inbox', actionLabel: tr('overview.reviewInbox') } : null,
hasActivity ? { key: 'activity', label: tr('overview.activity'), count: activityEvents.length, detail: countText('overview.count.events', activityEvents.length), actionKind: 'activity', actionLabel: tr('overview.viewActivity') } : null,
hasJournal ? { key: 'journal', label: tr('overview.journal'), count: journalEntries.length, detail: countText('overview.count.journal', journalEntries.length), actionKind: 'journal', actionLabel: tr('overview.openJournal') } : null,
hasAttentionTools ? { key: 'attention', label: tr('overview.attention'), count: needsAttention.length, detail: countText('overview.count.pending', needsAttention.length), actionKind: attentionActionKind, actionLabel: tr('overview.reviewPending') } : null,
].filter(Boolean);
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => locale = nextLocale);
@ -78,7 +88,7 @@
onDestroy(() => unsubscribeLocale?.());
$: if (workspaceRootPath && workspaceRootPath !== loadedWorkspaceRoot) {
$: if (workspaceRootPath && (workspaceRootPath !== loadedWorkspaceRoot || overviewToolKey !== loadedToolKey)) {
loadOverview();
}
@ -89,11 +99,29 @@
const label = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
return label.includes(name);
});
if (fromProps) return true;
if (typeof document === 'undefined') return false;
return Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]')).some(tab => {
return String(tab.textContent || '').trim().toLowerCase().includes(name);
});
return fromProps;
}
function actionIsAvailable(kind) {
if (kind === 'notes') return hasNotes;
if (kind === 'files') return hasFiles;
if (kind === 'browser-inbox') return hasBrowserInbox;
if (kind === 'activity') return hasActivity;
if (kind === 'journal') return hasJournal;
if (kind === 'todo') return hasTodos;
return false;
}
function filterAvailableItems(items, _toolKey) {
void _toolKey;
return (items || []).filter(item => actionIsAvailable(item?.actionKind));
}
function fallbackAttentionAction() {
if (hasBrowserInbox) return 'browser-inbox';
if (hasTodos) return 'todo';
if (hasActivity && hasJournal) return 'journal';
return '';
}
function decodeTuple(response, fallback) {
@ -167,7 +195,7 @@
}
function todoTitle(item) {
return String(item?.title || item?.name || 'Untitled todo').trim() || 'Untitled todo';
return String(item?.title || item?.name || tr('overview.untitledTodo')).trim() || tr('overview.untitledTodo');
}
function todoAttentionState(item) {
@ -175,9 +203,9 @@
const now = Date.now();
const dueAt = todoDateMs(item?.dueAt);
const reminderAt = todoDateMs(item?.reminderAt);
if (reminderAt && reminderAt <= now) return 'Reminder due';
if (dueAt && dueAt < now) return 'Overdue';
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return 'Due soon';
if (reminderAt && reminderAt <= now) return tr('overview.todo.reminderDue');
if (dueAt && dueAt < now) return tr('overview.todo.overdue');
if (dueAt && dueAt <= now + 3 * 24 * 60 * 60 * 1000) return tr('overview.todo.dueSoon');
return '';
}
@ -210,17 +238,12 @@
return fileName(path).replace(/\.(md|markdown|txt)$/i, '').replace(/_/g, ' ') || 'Untitled';
}
function quoted(value) {
const clean = String(value || '').trim();
return clean ? `"${clean}"` : '';
}
function entityName(item) {
const payload = item?.payload && typeof item.payload === 'object' ? item.payload : {};
const path = payload.path || payload.notePath || item?.path || item?.relativePath || looksLikePath(item?.summary);
const title = String(item?.title || payload.title || '').trim();
if (path && (!title || /^(saved note|file opened|file changed|activity event)$/i.test(title))) return titleFromPath(path);
return title || titleFromPath(path) || String(item?.summary || item?.activityId || 'item');
return title || titleFromPath(path) || String(item?.summary || item?.activityId || tr('overview.item'));
}
function looksLikePath(value) {
@ -230,11 +253,11 @@
}
function captureTitle(capture) {
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || 'Untitled capture';
return capture?.title || capture?.fileName || capture?.url || capture?.captureId || tr('overview.untitledCapture');
}
function journalTitle(entry) {
return entry?.title || entry?.summary || entry?.date || entry?.entryId || 'Journal entry';
return entry?.title || entry?.summary || entry?.date || entry?.entryId || tr('overview.untitledJournal');
}
function itemTimeLabel(item) {
@ -284,22 +307,31 @@
function activityTitle(item) {
const type = String(item?.type || '').toLowerCase();
const name = entityName(item);
if (type === 'note.saved' || type === 'note.edited') return `Edited note ${quoted(name)}`;
if (type === 'note.opened') return `Opened note ${quoted(name)}`;
if (type === 'note.created') return `Created note ${quoted(name)}`;
if (type === 'file.opened') return `Opened file ${quoted(name)}`;
if (type === 'file.changed') return `Changed file ${quoted(name)}`;
if (type === 'file.created') return `Created file ${quoted(name)}`;
if (type === 'file.deleted' || type === 'file.trashed') return `Removed file ${quoted(name)}`;
if (type === 'browser.capture.page') return `Captured page ${quoted(name)}`;
if (type === 'browser.capture.selection') return `Captured selection ${quoted(name)}`;
if (type === 'browser.capture.link') return `Captured link ${quoted(name)}`;
if (type === 'browser.capture.file') return `Captured file ${quoted(name)}`;
if (type === 'browser.capture.converted') return `Converted capture ${quoted(name)}`;
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return `Added journal entry ${quoted(name)}`;
if (type === 'action.started') return 'Work session detected';
if (type === 'workspace.opened') return 'Workspace opened';
return item?.title || item?.summary || 'Workspace activity';
if (type === 'note.saved' || type === 'note.edited') return tr('overview.event.noteEdited', { name });
if (type === 'note.opened') return tr('overview.event.noteOpened', { name });
if (type === 'note.created') return tr('overview.event.noteCreated', { name });
if (type === 'file.opened') return tr('overview.event.fileOpened', { name });
if (type === 'file.changed') return tr('overview.event.fileChanged', { name });
if (type === 'file.created') return tr('overview.event.fileCreated', { name });
if (type === 'file.deleted' || type === 'file.trashed') return tr('overview.event.fileRemoved', { name });
if (type === 'browser.capture.page') return tr('overview.event.capturePage', { name });
if (type === 'browser.capture.selection') return tr('overview.event.captureSelection', { name });
if (type === 'browser.capture.link') return tr('overview.event.captureLink', { name });
if (type === 'browser.capture.file') return tr('overview.event.captureFile', { name });
if (type === 'browser.capture.converted') return tr('overview.event.captureConverted', { name });
if (type === 'journal.entry.added' || type === 'worklog.entry.added') return tr('overview.event.journalAdded', { name });
if (type === 'action.started') return tr('overview.event.workSessionDetected');
if (type === 'workspace.opened') return tr('overview.event.workspaceOpened');
return item?.title || item?.summary || tr('overview.event.activity');
}
function captureKindLabel(capture) {
const kind = String(capture?.kind || '').toLowerCase();
if (kind === 'page') return tr('overview.captureKind.page');
if (kind === 'selection') return tr('overview.captureKind.selection');
if (kind === 'link') return tr('overview.captureKind.link');
if (kind === 'file') return tr('overview.captureKind.file');
return tr('overview.captureKind.item');
}
function actionForCategory(category) {
@ -320,7 +352,7 @@
id: item.activityId || `${item.type}:${timeValue(item)}`,
category,
title: activityTitle(item),
meta: `${itemTimeLabel(item)}${item.sourcePluginId ? ' · ' + item.sourcePluginId.replace('verstak.', '') : ''}`,
meta: itemTimeLabel(item),
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: action.kind,
@ -330,8 +362,8 @@
const captureItems = captureRows.map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures',
title: `Captured ${item.kind || 'item'} ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || 'Browser capture'}`,
title: tr('overview.event.capture', { kind: captureKindLabel(item), title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || item.url || tr('overview.browserCapture')}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
@ -340,8 +372,8 @@
const journalItems = journalRows.map(item => ({
id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal',
title: `Added journal entry ${quoted(journalTitle(item))}`,
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + item.minutes + ' min' : ''}`,
title: tr('overview.event.journalAdded', { name: journalTitle(item) }),
meta: `${itemTimeLabel(item)}${item.minutes ? ' · ' + tr('overview.minutes', { count: item.minutes }) : ''}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'journal',
@ -381,8 +413,8 @@
const todoCandidates = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
category: 'todo',
title: `Todo ${quoted(todoTitle(item))}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
title: tr('overview.event.todo', { title: todoTitle(item) }),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
time: todoTimeValue(item),
absolute: absoluteTime(todoTimeValue(item)),
actionKind: 'todo',
@ -391,8 +423,8 @@
const captureCandidates = sortByTime(captureRows).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
category: 'captures',
title: `Review capture ${quoted(captureTitle(item))}`,
meta: `${itemTimeLabel(item)} · ${item.domain || item.kind || 'Browser capture'}`,
title: tr('overview.event.reviewCapture', { title: captureTitle(item) }),
meta: `${itemTimeLabel(item)} · ${item.domain || captureKindLabel(item) || tr('overview.browserCapture')}`,
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
actionKind: 'browser-inbox',
@ -407,7 +439,7 @@
const journalCandidates = sortByTime(journalRows).map(item => ({
id: item.entryId || `journal:${timeValue(item)}`,
category: 'journal',
title: `Continue journal entry ${quoted(journalTitle(item))}`,
title: tr('overview.event.continueJournal', { title: journalTitle(item) }),
meta: itemTimeLabel(item),
time: timeValue(item),
absolute: absoluteTime(timeValue(item)),
@ -421,14 +453,18 @@
const captureItems = sortByTime(captureRows).slice(0, 4).map(item => ({
id: item.captureId || `capture:${timeValue(item)}`,
title: captureTitle(item),
meta: `${item.kind || 'capture'} · ${itemTimeLabel(item)}`,
meta: `${captureKindLabel(item)} · ${itemTimeLabel(item)}`,
actionKind: 'browser-inbox',
actionLabel: tr('overview.reviewInbox'),
}));
const candidateItems = sortByTime(candidates).slice(0, 4).map(item => ({
id: item.candidateId || `work-session:${timeValue(item)}`,
title: 'Possible journal entry',
meta: `Workspace: ${item.workspaceRootPath || workspaceRootPath || 'Unknown'} · ${item.estimatedMinutes || 0} min · ${item.activityCount || (item.activityIds || []).length || 0} activities`,
title: tr('overview.event.possibleJournalEntry'),
meta: tr('overview.candidateMeta', {
deal: item.workspaceRootPath || workspaceRootPath || tr('overview.unknownDeal'),
minutes: item.estimatedMinutes || 0,
activities: item.activityCount || (item.activityIds || []).length || 0,
}),
actionKind: 'journal',
actionLabel: tr('overview.reviewCandidate'),
toolRequest: { type: 'work-session-candidate', candidate: item },
@ -436,7 +472,7 @@
const todoItems = [...todoRows].sort((a, b) => todoDateMs(todoTimeValue(a)) - todoDateMs(todoTimeValue(b))).slice(0, 4).map(item => ({
id: item.id || `todo:${todoTitle(item)}`,
title: todoTitle(item),
meta: `${todoAttentionState(item)}${item.dueAt ? ` · Due ${item.dueAt}` : ''}`,
meta: `${todoAttentionState(item)}${item.dueAt ? ` · ${tr('overview.todo.due', { date: item.dueAt })}` : ''}`,
actionKind: 'todo',
actionLabel: tr('overview.openTodos'),
}));
@ -477,12 +513,12 @@
}
}
async function loadWorkspaceResources() {
async function loadWorkspaceResources(toolState) {
const workspace = String(workspaceRootPath || '').trim();
if (!workspace) return { keyResources: [], totalNotes: 0 };
const [rootEntries, notesEntries] = await Promise.all([
listFiles('verstak.files', workspace),
listFiles('verstak.notes', `${workspace}/Notes`),
toolState.files ? listFiles('verstak.files', workspace) : Promise.resolve([]),
toolState.notes ? listFiles('verstak.notes', `${workspace}/Notes`) : Promise.resolve([]),
]);
const noteFiles = notesEntries.filter(item => item?.type === 'file');
const overview = [...notesEntries, ...rootEntries].find(item => /(^|\/)overview\.md$/i.test(String(item.relativePath || item.name || '')));
@ -491,44 +527,54 @@
keyResources: overview ? [{
id: overview.relativePath || overview.name,
title: overview.name || fileName(overview.relativePath) || 'Overview.md',
meta: overview.relativePath || 'Workspace overview note',
actionKind: hasNotes ? 'notes' : 'files',
actionLabel: hasNotes ? tr('overview.openNotes') : tr('overview.openFiles'),
meta: overview.relativePath || tr('overview.overviewNote'),
actionKind: toolState.notes ? 'notes' : 'files',
actionLabel: toolState.notes ? tr('overview.openNotes') : tr('overview.openFiles'),
}] : [],
};
}
async function loadOverview() {
const workspaceAtStart = String(workspaceRootPath || '').trim();
const toolKeyAtStart = overviewToolKey;
const toolState = {
notes: hasNotes,
files: hasFiles,
browserInbox: hasBrowserInbox,
activity: hasActivity,
journal: hasJournal,
todos: hasTodos,
};
loadedWorkspaceRoot = workspaceAtStart;
loadedToolKey = toolKeyAtStart;
loading = true;
const [browserSettings, activitySettings, activityRecords, journalSettings, todoSettings, resources] = await Promise.all([
readPluginSettings('verstak.browser-inbox'),
readPluginSettings('verstak.activity'),
App.ReadPluginDataNDJSON
toolState.browserInbox ? readPluginSettings('verstak.browser-inbox') : Promise.resolve({}),
toolState.activity ? readPluginSettings('verstak.activity') : Promise.resolve({}),
toolState.activity && App.ReadPluginDataNDJSON
? App.ReadPluginDataNDJSON('verstak.activity', 'activity-events')
.then(value => decodeTuple(value, []))
.catch(() => [])
: Promise.resolve([]),
readPluginSettings('verstak.journal'),
readPluginSettings('verstak.todo'),
loadWorkspaceResources(),
toolState.journal ? readPluginSettings('verstak.journal') : Promise.resolve({}),
toolState.todos ? readPluginSettings('verstak.todo') : Promise.resolve({}),
loadWorkspaceResources(toolState),
]);
if (workspaceAtStart !== String(workspaceRootPath || '').trim()) return;
if (workspaceAtStart !== String(workspaceRootPath || '').trim() || toolKeyAtStart !== overviewToolKey) return;
captures = browserCaptureRowsForWorkspace(browserSettings);
activityEvents = normalizeRows(activityRecords).filter(item => {
captures = toolState.browserInbox ? browserCaptureRowsForWorkspace(browserSettings) : [];
activityEvents = toolState.activity ? normalizeRows(activityRecords).filter(item => {
const tagged = String(item.workspaceRootPath || item.workspaceName || item.workspaceNodeId || '').trim();
return !tagged || tagged === workspaceAtStart;
});
journalEntries = rowsFor(journalSettings, [
}) : [];
journalEntries = toolState.journal ? rowsFor(journalSettings, [
workspaceKey('worklog:workspace:'),
'worklog',
]);
todos = todoRowsForWorkspace(todoSettings);
workSessionCandidates = rowsFor(activitySettings, [
]) : [];
todos = toolState.todos ? todoRowsForWorkspace(todoSettings) : [];
workSessionCandidates = toolState.activity && toolState.journal ? rowsFor(activitySettings, [
workspaceKey('work-session-candidates:workspace:'),
]);
]) : [];
keyResources = resources.keyResources;
totalNotes = resources.totalNotes;
loading = false;
@ -657,7 +703,7 @@
</main>
<aside class="overview-side">
{#if needsAttention.length || !loading}
{#if hasAttentionTools && (needsAttention.length || !loading)}
<section class="today-panel overview-panel" data-overview-section="attention">
<div class="today-panel-head overview-panel-head">
<div>

View File

@ -18,6 +18,12 @@
void activeLocale;
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[VaultSelection] operation failed:', details);
return tr(key, undefined, fallback);
}
onDestroy(unsubscribeLocale);
onMount(async () => {
@ -54,13 +60,13 @@
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = tr('vaultSelection.createError', { error: createErr });
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', createErr);
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
creating = false;
return;
}
@ -70,7 +76,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.createError', 'Could not create the vault. Please try again.', e);
creating = false;
}
}
@ -85,7 +91,7 @@
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false;
return;
}
@ -95,7 +101,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false;
}
}
@ -106,7 +112,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = tr('vaultSelection.openError', { error: openErr });
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', openErr);
opening = false;
return;
}
@ -116,7 +122,7 @@
}
window.dispatchEvent(new CustomEvent('verstak:vault-opened'));
} catch (e) {
error = String(e);
error = reportError('vaultSelection.openError', 'Could not open the vault. Please try again.', e);
opening = false;
}
}

View File

@ -45,7 +45,6 @@
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
$: workspaceId = selectedWorkspace?.workspaceId || '';
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
$: workspaceType = selectedWorkspace?.type || 'workspace';
$: if (workspaceRootPath !== metadataWorkspaceRoot) {
metadataWorkspaceRoot = workspaceRootPath;
workspaceMetadata = null;
@ -80,11 +79,13 @@
if (changed && selectedWorkspaceName) loadTools();
});
window.addEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.addEventListener('verstak:plugins-changed', handlePluginsChanged);
});
onDestroy(() => {
if (unsubscribeLocale) unsubscribeLocale();
window.removeEventListener('verstak:workspace-open-tool', handleWorkspaceOpenTool);
window.removeEventListener('verstak:plugins-changed', handlePluginsChanged);
});
function toolKey(tool) {
@ -170,6 +171,10 @@
requestWorkspaceTool(event?.detail?.kind, event?.detail?.toolRequest || null);
}
function handlePluginsChanged() {
if (selectedWorkspaceName) loadTools();
}
async function loadTools() {
try {
toolsLoaded = false;
@ -201,7 +206,6 @@
<div class="workspace-header vt-page-header">
<div class="workspace-title-group">
<span class="workspace-title vt-page-title">{workspaceTitle}</span>
<span class="workspace-type vt-badge accent">{workspaceType}</span>
</div>
<div class="workspace-search" aria-label={tr('workspace.search')}>
<GlobalSearch />
@ -288,14 +292,6 @@
font-weight: 600;
}
.workspace-type {
color: var(--vt-color-accent);
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: var(--vt-radius-sm);
background: var(--vt-color-accent-muted);
}
.workspace-search {
width: min(27rem, 46vw);
min-width: 16rem;

View File

@ -18,8 +18,11 @@
let newWorkspaceName = '';
let workspaceTemplates = [];
let templatePluginNames = {};
let templatePlugins = {};
let templateCapabilities = new Set();
let selectedTemplateId = 'default';
let createError = '';
let templateWarning = null;
let templatesLoading = false;
let creating = false;
let renamingId = '';
@ -32,6 +35,11 @@
return i18n.t(key, params, fallback);
})(locale);
function reportError(key, fallback, details) {
console.warn('[WorkspaceTree] operation failed:', details);
return tr(key, undefined, fallback);
}
onMount(() => {
unsubscribeLocale = i18n.subscribe((nextLocale) => {
const changed = locale !== nextLocale;
@ -61,21 +69,71 @@
}
$: selectedTemplate = workspaceTemplates.find(template => template.id === selectedTemplateId) || workspaceTemplates[0] || null;
$: selectedTemplateTools = (selectedTemplate?.workspaceTools || []).map((pluginId) => (
templateToolState(pluginId, templatePlugins, templateCapabilities, templatePluginNames, tr)
));
$: selectedTemplateIssues = selectedTemplateTools.filter(tool => tool.status !== 'available');
function toolLabel(pluginId) {
return templatePluginNames[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
function toolLabel(pluginId, names = templatePluginNames) {
return names[pluginId] || String(pluginId || '').replace(/^verstak\./, '');
}
function templateToolState(pluginId, plugins, capabilities, names, translate) {
const plugin = plugins[pluginId];
if (!plugin) {
return {
pluginId,
name: toolLabel(pluginId, names),
tabs: [],
status: 'unavailable',
reason: translate('workspaceTree.templateMissingPlugin'),
};
}
const manifest = plugin.manifest || {};
const tabs = Array.isArray(manifest.contributes?.workspaceItems)
? manifest.contributes.workspaceItems.map(item => item?.title || item?.id).filter(Boolean)
: [];
const pluginStatus = String(plugin.status || '').toLowerCase();
const missingCapability = Array.isArray(manifest.requires)
&& manifest.requires.some(capabilityId => !capabilities.has(capabilityId));
let status = 'available';
let reason = translate('workspaceTree.templateAvailable');
if (!plugin.enabled || pluginStatus === 'disabled') {
status = 'unavailable';
reason = translate('workspaceTree.templatePluginDisabled');
} else if (pluginStatus === 'missing-required-capability' || missingCapability) {
status = 'unavailable';
reason = translate('workspaceTree.templateCapabilityUnavailable');
} else if (pluginStatus === 'incompatible') {
status = 'unavailable';
reason = translate('workspaceTree.templateIncompatible');
} else if (pluginStatus === 'failed') {
status = 'unavailable';
reason = translate('workspaceTree.templateLoadFailed');
} else if (pluginStatus === 'degraded') {
status = 'limited';
reason = translate('workspaceTree.templateLimited');
} else if (pluginStatus !== 'loaded') {
status = 'unavailable';
reason = translate('workspaceTree.templateNotReady');
}
return { pluginId, name: manifest.name || toolLabel(pluginId, names), tabs, status, reason };
}
async function loadWorkspaceTemplates() {
templatesLoading = true;
try {
const [templates, plugins] = await Promise.all([
const [templates, plugins, capabilities] = await Promise.all([
App.ListWorkspaceTemplates ? App.ListWorkspaceTemplates() : [],
App.GetPlugins ? App.GetPlugins() : [],
App.GetCapabilities ? App.GetCapabilities() : [],
]);
const [list, err] = resultOrError(templates, []);
if (err) {
createError = err;
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', err);
workspaceTemplates = [];
return;
}
@ -83,17 +141,25 @@
await Promise.all((Array.isArray(plugins) ? plugins : []).map((plugin) => (
i18n.loadPlugin(plugin.manifest?.id, plugin.manifest?.localization).catch(() => {})
)));
templatePluginNames = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin)).reduce((names, plugin) => {
const localizedPlugins = (Array.isArray(plugins) ? plugins : []).map((plugin) => i18n.localizePlugin(plugin));
templatePluginNames = localizedPlugins.reduce((names, plugin) => {
const id = plugin?.manifest?.id;
const name = plugin?.manifest?.name;
if (id && name) names[id] = name;
return names;
}, {});
templatePlugins = localizedPlugins.reduce((result, plugin) => {
const id = plugin?.manifest?.id;
if (id) result[id] = plugin;
return result;
}, {});
const [capabilityList] = resultOrError(capabilities, []);
templateCapabilities = new Set((Array.isArray(capabilityList) ? capabilityList : []).map(capability => capability?.name).filter(Boolean));
if (!workspaceTemplates.some(template => template.id === selectedTemplateId)) {
selectedTemplateId = workspaceTemplates[0]?.id || '';
}
} catch (error) {
createError = String(error);
createError = reportError('workspaceTree.templatesError', 'Could not load Deal templates. Please try again.', error);
workspaceTemplates = [];
} finally {
templatesLoading = false;
@ -127,7 +193,7 @@
try {
const [list, err] = resultOrError(await App.ListWorkspaces(), []);
if (err) {
localError = err;
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', err);
workspaces = [];
} else {
workspaces = list || [];
@ -148,7 +214,7 @@
activeWorkspaceId.set(currentWorkspaceId);
}
} catch (e) {
localError = String(e);
localError = reportError('workspaceTree.loadError', 'Could not load Deals. Please try again.', e);
}
loading = false;
}
@ -157,7 +223,7 @@
const id = wsName(workspace);
const err = await App.SetCurrentWorkspace(id);
if (err) {
localError = err;
localError = reportError('workspaceTree.selectError', 'Could not select this Deal. Please try again.', err);
return;
}
currentWorkspaceId = id;
@ -177,11 +243,16 @@
createError = tr('workspaceTree.chooseTemplate');
return;
}
const creationIssues = selectedTemplateIssues.map(tool => ({
pluginId: tool.pluginId,
name: tool.name,
reason: tool.reason,
}));
creating = true;
createError = '';
const [, err] = resultOrError(await App.CreateWorkspace(name, selectedTemplate.id), null);
if (err) {
createError = err;
createError = reportError('workspaceTree.createError', 'Could not create the Deal. Please try again.', err);
creating = false;
return;
}
@ -191,13 +262,14 @@
await loadWorkspaces();
const created = workspaces.find((ws) => wsName(ws) === name);
if (created) await selectWorkspace(created);
templateWarning = creationIssues.length > 0 ? { workspaceName: name, issues: creationIssues } : null;
}
function openCreateDialog() {
async function openCreateDialog() {
showCreate = true;
newWorkspaceName = '';
createError = '';
if (!workspaceTemplates.length && !templatesLoading) loadWorkspaceTemplates();
await loadWorkspaceTemplates();
}
function closeCreateDialog() {
@ -207,6 +279,10 @@
createError = '';
}
function dismissTemplateWarning() {
templateWarning = null;
}
function startRename(workspace) {
renamingId = wsName(workspace);
renameValue = renamingId;
@ -228,7 +304,7 @@
busyId = oldName;
const err = await App.RenameWorkspace(oldName, newName);
if (err) {
localError = err;
localError = reportError('workspaceTree.renameError', 'Could not rename the Deal. Please try again.', err);
busyId = '';
return;
}
@ -246,7 +322,7 @@
busyId = name;
const [, err] = resultOrError(await App.TrashWorkspace(name), null);
if (err) {
localError = err;
localError = reportError('workspaceTree.trashError', 'Could not move the Deal to trash. Please try again.', err);
busyId = '';
return;
}
@ -304,6 +380,18 @@
{/each}
</div>
{#if templateWarning}
<section class="workspace-template-warning" data-workspace-template-warning role="alert">
<div>
<strong>{tr('workspaceTree.templateIncompleteCreated', { name: templateWarning.workspaceName })}</strong>
{#each templateWarning.issues as issue (issue.pluginId)}
<div class="workspace-template-warning-item">{issue.name}: {issue.reason}</div>
{/each}
</div>
<button class="wt-btn" type="button" on:click={dismissTemplateWarning} title={tr('common.close')}>{tr('common.close')}</button>
</section>
{/if}
{#if showCreate}
<div class="workspace-create-overlay" data-workspace-create-modal role="dialog" aria-modal="true" aria-label={tr('workspaceTree.create')}>
<div class="workspace-create-modal">
@ -329,10 +417,23 @@
<div class="workspace-template-summary">
<p data-workspace-template-description>{selectedTemplate.description}</p>
<div class="workspace-template-tools" data-workspace-template-tools>
{#each selectedTemplate.workspaceTools || [] as pluginId (pluginId)}
<span>{toolLabel(pluginId)}</span>
{#each selectedTemplateTools as tool (tool.pluginId)}
<div
class="workspace-template-tool"
class:limited={tool.status === 'limited'}
class:unavailable={tool.status === 'unavailable'}
data-workspace-template-tool={tool.pluginId}
data-template-tool-status={tool.status}
>
<span class="workspace-template-tool-name">{tool.name}</span>
<span class="workspace-template-tool-tabs">{tool.tabs.length ? tr('workspaceTree.templateToolTabs', { tabs: tool.tabs.join(', ') }) : tr('workspaceTree.templateToolNoTabs')}</span>
<span class="workspace-template-tool-reason">{tool.reason}</span>
</div>
{/each}
</div>
{#if selectedTemplateIssues.length > 0}
<p class="workspace-template-incomplete" data-workspace-template-incomplete>{tr('workspaceTree.templateWillBeIncomplete')}</p>
{/if}
</div>
{/if}
{#if createError}
@ -378,11 +479,25 @@
.workspace-create-header h2 { margin: 0; font-size: 1rem; }
.workspace-create-field { display: grid; gap: 0.35rem; color: var(--vt-color-text-muted); font-size: 0.75rem; }
.workspace-create-field input, .workspace-create-field select { width: 100%; min-height: 2rem; box-sizing: border-box; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); background: #0f1424; color: var(--vt-color-text-primary); padding: 0.35rem 0.5rem; font: inherit; font-size: 0.84rem; }
.workspace-create-field select { appearance: none; background-color: #0f1424; background-image: linear-gradient(45deg, transparent 50%, var(--vt-color-text-muted) 50%), linear-gradient(135deg, var(--vt-color-text-muted) 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.7rem; }
.workspace-create-field select option { background: #0f1424; color: var(--vt-color-text-primary); }
.workspace-create-field input:focus, .workspace-create-field select:focus { outline: none; border-color: var(--vt-color-accent); box-shadow: var(--vt-focus-ring); }
.workspace-template-summary { display: grid; gap: 0.55rem; padding: 0.75rem; border: 1px solid var(--vt-color-border); border-radius: var(--vt-radius-md); background: var(--vt-color-surface-muted); }
.workspace-template-summary p { margin: 0; color: var(--vt-color-text-secondary); font-size: 0.8rem; line-height: 1.45; }
.workspace-template-tools { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.workspace-template-tools span { min-height: 1.35rem; display: inline-flex; align-items: center; padding: 0 0.35rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tools { display: grid; gap: 0.4rem; }
.workspace-template-tool { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 1.4fr) minmax(0, 1.2fr); gap: 0.5rem; align-items: center; padding: 0.45rem 0.55rem; border: 1px solid var(--vt-color-border-strong); border-radius: var(--vt-radius-sm); color: var(--vt-color-text-secondary); font-size: 0.72rem; }
.workspace-template-tool-name { color: var(--vt-color-text-primary); font-weight: 600; }
.workspace-template-tool-tabs { color: var(--vt-color-text-muted); }
.workspace-template-tool-reason { color: var(--vt-color-accent); }
.workspace-template-tool.limited { border-color: rgba(220, 164, 57, 0.58); background: rgba(220, 164, 57, 0.08); }
.workspace-template-tool.limited .workspace-template-tool-reason { color: #e8bc63; }
.workspace-template-tool.unavailable { border-color: rgba(233, 69, 96, 0.58); background: var(--vt-color-danger-muted); }
.workspace-template-tool.unavailable .workspace-template-tool-reason { color: var(--vt-color-danger); }
.workspace-template-incomplete { color: var(--vt-color-danger) !important; }
.workspace-template-warning { display: flex; gap: 0.5rem; align-items: flex-start; justify-content: space-between; margin: 0.25rem 0.6rem 0.5rem; padding: 0.6rem; border: 1px solid rgba(233, 69, 96, 0.58); border-radius: var(--vt-radius-md); background: var(--vt-color-danger-muted); color: var(--vt-color-text-secondary); font-size: 0.73rem; line-height: 1.4; }
.workspace-template-warning strong { color: var(--vt-color-danger); }
.workspace-template-warning-item { margin-top: 0.2rem; }
@media (max-width: 700px) { .workspace-template-tool { grid-template-columns: 1fr; gap: 0.18rem; } }
.workspace-create-error { margin: 0; color: var(--vt-color-danger); font-size: 0.78rem; line-height: 1.4; }
.workspace-create-actions { display: flex; gap: 0.4rem; justify-content: flex-end; }
.wt-btn-primary { background: var(--vt-color-accent); color: #101827; border: none; padding: 0.3rem 0.6rem; border-radius: var(--vt-radius-sm); cursor: pointer; font-size: 0.75rem; font-weight: 600; }

View File

@ -4,6 +4,12 @@
* Каждый метод возвращает Promise с данными, совместимыми с Wails-контрактом.
* Состояние мутабельно тесты могут менять его между сценариями.
*/
import defaultEditorSource from '../../../../../verstak-official-plugins/plugins/default-editor/frontend/src/index.js?raw';
import secretsSource from '../../../../../verstak-official-plugins/plugins/secrets/frontend/src/index.js?raw';
import activitySource from '../../../../../verstak-official-plugins/plugins/activity/frontend/src/index.js?raw';
import todoSource from '../../../../../verstak-official-plugins/plugins/todo/frontend/src/index.js?raw';
import journalSource from '../../../../../verstak-official-plugins/plugins/journal/frontend/src/index.js?raw';
(function () {
if (window.__wailsMockReady) return;
@ -219,7 +225,7 @@
source: 'official',
icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -236,19 +242,19 @@
manifest: {
schemaVersion: 1,
id: 'verstak.browser-inbox',
name: 'Browser Inbox',
name: 'Browser',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.',
description: 'Global browser materials with explicit Deal assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
}
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -291,7 +297,7 @@
'verstak.sync': 'Синхронизация',
'verstak.activity': 'Активность',
'verstak.journal': 'Журнал',
'verstak.browser-inbox': 'Входящие из браузера',
'verstak.browser-inbox': 'Браузер',
'verstak.search': 'Поиск',
'verstak.trash': 'Корзина',
'verstak.todo': 'Задачи',
@ -359,7 +365,9 @@
var pluginSettings = {
'verstak.platform-test': { savedText: 'initial value' }
};
var pluginNotifications = {};
var pluginData = {};
var secretRecords = makeDefaultSecretRecords();
var vaultFiles = makeDefaultVaultFiles();
var externalOpens = [];
var trashEntries = [];
@ -404,6 +412,27 @@
return JSON.parse(JSON.stringify(value));
}
function makeDefaultSecretRecords() {
return [
{
id: 'first.secret',
title: 'First secret',
username: 'first-user',
value: 'first-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
},
{
id: 'target.secret',
title: 'Target secret',
username: 'target-user',
value: 'target-value',
scope: { kind: 'global' },
updatedAt: '2026-07-14T00:00:00Z'
}
];
}
function builtInWorkspaceTemplates() {
return [
{
@ -545,7 +574,8 @@
source: 'official',
icon: 'list-todo',
provides: ['todo.list', 'todo.workspace'],
permissions: ['files.read', 'storage.namespace', 'ui.register'],
requires: ['verstak/core/notifications/v1'],
permissions: ['files.read', 'storage.namespace', 'ui.register', 'notifications.schedule'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.todo.view', title: 'Todos', icon: 'list-todo', component: 'TodoView' }],
@ -572,9 +602,18 @@
source: 'official',
icon: 'key-round',
provides: ['secret-store', 'secrets.read-ui', 'secrets.write-ui'],
permissions: ['secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
permissions: ['files.read', 'secrets.read', 'secrets.write', 'ui.register'],
frontend: { entry: 'frontend/src/index.js' },
contributes: {
views: [{ id: 'verstak.secrets.view', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }],
sidebarItems: [{ id: 'verstak.secrets.sidebar', title: 'Secrets', icon: 'key-round', view: 'verstak.secrets.view', position: 45 }],
openProviders: [{
id: 'verstak.secrets.secret',
title: 'Secrets',
priority: 100,
component: 'SecretsView',
supports: [{ kind: 'secret', modes: ['view'] }]
}],
workspaceItems: [{ id: 'verstak.secrets.workspace', title: 'Secrets', icon: 'key-round', component: 'SecretsView' }]
}
},
@ -678,6 +717,7 @@
caps.push({ name: 'verstak/core/files/v1', description: 'Files API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/workbench/v1', description: 'Workbench routing', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/sync/v1', description: 'Sync API', pluginId: 'verstak-desktop', status: 'stable' });
caps.push({ name: 'verstak/core/notifications/v1', description: 'Native notifications', pluginId: 'verstak-desktop', status: 'stable' });
for (var id in pluginStates) {
var s = pluginStates[id];
if (s.status === 'loaded' && s.enabled && s.manifest && s.manifest.provides) {
@ -2416,7 +2456,7 @@
}
function title(capture) {
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled material';
}
function renderBrowserInbox(containerEl, props, api) {
@ -2433,7 +2473,7 @@
containerEl.innerHTML = '';
var root = el('div', { className: 'browser-inbox-root', 'data-plugin-id': PLUGIN_ID });
var toolbar = el('div', { className: 'browser-inbox-toolbar' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser Inbox · ' + rootPath : 'Browser Inbox' });
var titleEl = el('span', { className: 'browser-inbox-title', textContent: rootPath ? 'Browser · ' + rootPath : 'Browser' });
var countEl = el('span', { className: 'browser-inbox-count' });
var statusEl = el('span', { className: 'browser-inbox-status' });
var filtersEl = el('div', { className: 'browser-inbox-filters' });
@ -2517,7 +2557,7 @@
function renderWorkspaceFilterOptions() {
if (rootPath) return;
workspaceFilterEl.innerHTML = '';
workspaceFilterEl.appendChild(option('', 'All workspaces'));
workspaceFilterEl.appendChild(option('', 'All Deals'));
workspaceRoots().forEach(function (workspace) {
workspaceFilterEl.appendChild(option(workspace, workspace));
});
@ -2590,7 +2630,7 @@
}) : captures).map(function (capture) { return capture.captureId; });
captures = captures.filter(function (capture) { return ids.indexOf(capture.captureId) === -1; });
selectedId = '';
statusText = rootPath ? 'Workspace captures cleared' : 'Inbox cleared';
statusText = rootPath ? 'Deal materials cleared' : 'Inbox cleared';
return persist();
}
@ -2632,7 +2672,7 @@
listEl.appendChild(el('div', {
className: 'browser-inbox-empty',
textContent: captures.length === 0
? 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
? 'No browser materials yet. Send a page, selection, or link from the extension.'
: 'No captures match the current filters.'
}));
return;
@ -2677,7 +2717,7 @@
el('div', { className: 'browser-inbox-meta-value', textContent: capture.domain || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Browser' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.browserName || capture.source || '-' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Workspace' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Deal' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.workspaceRootPath || 'Unassigned' }),
el('div', { className: 'browser-inbox-meta-label', textContent: 'Status' }),
el('div', { className: 'browser-inbox-meta-value', textContent: capture.processed ? 'Processed' : 'Unprocessed' })
@ -3328,6 +3368,43 @@
GetPluginLocalization: function (pluginId, locale) {
return Promise.resolve([mockPluginCatalog(pluginId, locale), '']);
},
PluginSecretsStatus: function () {
return Promise.resolve([{ initialized: true, unlocked: true }, '']);
},
PluginSecretsUnlock: function () {
return Promise.resolve('');
},
PluginSecretsList: function () {
return Promise.resolve([secretRecords.map(function (record) {
var listed = cloneJson(record);
delete listed.value;
return listed;
}), '']);
},
PluginSecretsRead: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve([{}, 'not-found: secret ' + secretID]);
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsWrite: function (_pluginId, nextRecord) {
var record = Object.assign({}, nextRecord || {});
if (!record.id) return Promise.resolve([{}, 'secret id is required']);
record.scope = record.scope || { kind: 'global' };
record.updatedAt = new Date().toISOString();
var index = secretRecords.findIndex(function (item) { return item.id === record.id; });
if (index === -1) secretRecords.push(record);
else secretRecords[index] = record;
return Promise.resolve([cloneJson(record), '']);
},
PluginSecretsDelete: function (_pluginId, secretID) {
secretRecords = secretRecords.filter(function (record) { return record.id !== secretID; });
return Promise.resolve('');
},
PluginSecretsCopyLink: function (_pluginId, secretID) {
var record = secretRecords.find(function (item) { return item.id === secretID; });
if (!record) return Promise.resolve(['', 'not-found: secret ' + secretID]);
return Promise.resolve(['[' + (record.title || record.id) + '](verstak-secret://' + encodeURIComponent(record.id) + ')', '']);
},
ReadPluginSettings: function (pluginId) {
return Promise.resolve([Object.assign({}, pluginSettings[pluginId] || {}), '']);
},
@ -3335,8 +3412,22 @@
pluginSettings[pluginId] = Object.assign({}, settings || {});
return Promise.resolve('');
},
ReadPluginSetting: function () { return Promise.resolve(null); },
WritePluginSetting: function () { return Promise.resolve(null); },
ReadPluginSetting: function (pluginId, key) {
return Promise.resolve([pluginSettings[pluginId] && pluginSettings[pluginId][key], '']);
},
WritePluginSetting: function (pluginId, key, value) {
pluginSettings[pluginId] = pluginSettings[pluginId] || {};
pluginSettings[pluginId][key] = value;
return Promise.resolve('');
},
ReplacePluginNotifications: function (pluginId, items) {
pluginNotifications[pluginId] = Array.isArray(items) ? items.slice() : [];
return Promise.resolve('');
},
ClearPluginNotifications: function (pluginId) {
delete pluginNotifications[pluginId];
return Promise.resolve('');
},
ReadPluginDataJSON: function (pluginId, name) {
var data = (pluginData[pluginId] && pluginData[pluginId][name]) || {};
return Promise.resolve([Object.assign({}, data), '']);
@ -3455,8 +3546,8 @@
if (pluginId === 'verstak.platform-test' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(platformTestBundle());
}
if (pluginId === 'verstak.default-editor' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(defaultEditorBundle());
if (pluginId === 'verstak.default-editor') {
return Promise.resolve(defaultEditorSource);
}
if (pluginId === 'verstak.files' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(filesPluginBundle());
@ -3467,20 +3558,20 @@
if (pluginId === 'verstak.notes' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.notes', 'NotesView', 'notes-root', 'Notes'));
}
if (pluginId === 'verstak.activity' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(activityBundle());
if (pluginId === 'verstak.activity') {
return Promise.resolve(activitySource);
}
if (pluginId === 'verstak.journal' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(journalBundle());
return Promise.resolve(journalSource);
}
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle());
}
if (pluginId === 'verstak.todo' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(todoBundle());
return Promise.resolve(todoSource);
}
if (pluginId === 'verstak.secrets' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(simplePluginBundle('verstak.secrets', 'SecretsView', 'secrets-root', 'Secrets'));
if (pluginId === 'verstak.secrets') {
return Promise.resolve(secretsSource);
}
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(searchPluginBundle());
@ -4088,7 +4179,7 @@
source: 'official',
icon: 'book-open',
provides: ['worklog', 'journal', 'report.worklog'],
permissions: ['storage.namespace', 'ui.register'],
permissions: ['events.publish', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.journal.view', title: 'Journal', icon: 'book-open', component: 'JournalView' }],
@ -4105,19 +4196,19 @@
manifest: {
schemaVersion: 1,
id: 'verstak.browser-inbox',
name: 'Browser Inbox',
name: 'Browser',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Global browser capture queue with explicit workspace assignment.',
description: 'Global browser materials with explicit Deal assignment.',
source: 'official',
icon: 'inbox',
provides: ['browser.inbox'],
permissions: ['events.subscribe', 'files.read', 'storage.namespace', 'ui.register'],
frontend: { entry: 'frontend/dist/index.js' },
contributes: {
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser Inbox', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser Inbox', icon: 'inbox', component: 'BrowserInboxView' }]
views: [{ id: 'verstak.browser-inbox.view', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }],
sidebarItems: [{ id: 'verstak.browser-inbox.sidebar', title: 'Browser', icon: 'inbox', view: 'verstak.browser-inbox.view', position: 30 }],
workspaceItems: [{ id: 'verstak.browser-inbox.workspace', title: 'Browser', icon: 'inbox', component: 'BrowserInboxView' }]
}
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
@ -4164,6 +4255,8 @@
workbenchPreferences = {};
openedResources = [];
pluginSettings = { 'verstak.platform-test': { savedText: 'initial value' } };
pluginNotifications = {};
secretRecords = makeDefaultSecretRecords();
vaultFiles = makeDefaultVaultFiles();
externalOpens = [];
trashEntries = [];

View File

@ -0,0 +1,36 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const files = [
'src/lib/shell/CommandPalette.svelte',
'src/lib/plugin-manager/PluginManager.svelte',
'src/lib/shell/VaultSelection.svelte',
'src/lib/shell/WorkspaceTree.svelte',
'src/lib/plugin-host/PluginBundleHost.svelte',
];
const patterns = [
/(?:^|\n)\s*(?:error|localError|createError)\s*=\s*(?:err|String\()/,
/(?:^|\n)\s*error\s*=\s*['"].*['"]\s*\+\s*(?:err|String\()/,
/tr\([^\n]*\{\s*error\s*:/,
];
const violations = [];
for (const relativePath of files) {
const source = fs.readFileSync(path.join(root, relativePath), 'utf8');
for (const pattern of patterns) {
if (pattern.test(source)) violations.push(relativePath);
}
}
if (violations.length) {
throw new Error(`Technical backend errors reach desktop UI in: ${[...new Set(violations)].join(', ')}`);
}
const bundleHost = fs.readFileSync(path.join(root, 'src/lib/plugin-host/PluginBundleHost.svelte'), 'utf8');
if (!/<p class="error-message">\{errorText \|\| tr\('bundle\.unknownError'\)\}<\/p>\s*<details class="error-details">/.test(bundleHost)) {
throw new Error('Plugin bundle errors must keep the user-facing message outside expandable technical details');
}
console.log('desktop UI does not interpolate raw backend errors');

View File

@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const source = await readFile(new URL('../src/lib/shell/WorkspaceTree.svelte', import.meta.url), 'utf8');
assert.match(source, /\.workspace-create-field select\s*\{[^}]*appearance:\s*none/, 'workspace template select must hide the native arrow');
assert.match(source, /\.workspace-create-field select option\s*\{[^}]*background/, 'workspace template options must use the application surface');
console.log('workspace select style contract passed');

View File

@ -0,0 +1,28 @@
import en from '../src/lib/i18n/catalogs/en.js';
import ru from '../src/lib/i18n/catalogs/ru.js';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const forbidden = [
['English catalog', /\bworkspace(?:s)?\b/i],
['Russian catalog', /рабоч(?:ее|ие|ем|его|их)\s+пространств(?:о|а|е)?/i],
];
for (const [name, pattern] of forbidden) {
const violations = Object.entries(name === 'English catalog' ? en : ru)
.filter(([, value]) => pattern.test(value));
if (violations.length) {
throw new Error(`${name} still exposes workspace terminology: ${violations.map(([key]) => key).join(', ')}`);
}
}
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const overview = fs.readFileSync(path.join(root, 'src/lib/shell/TodaySurface.svelte'), 'utf8');
for (const phrase of ['Workspace opened', 'Workspace activity', 'Workspace overview note']) {
if (overview.includes(phrase)) {
throw new Error(`Overview exposes workspace terminology: ${phrase}`);
}
}
console.log('desktop user terminology uses Deal/Дело');

View File

@ -1,3 +1,4 @@
import { resolve } from 'node:path';
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
@ -13,6 +14,9 @@ export default defineConfig(({ mode }) => {
server: {
port: isTest ? 5174 : 5173,
strictPort: true,
fs: {
allow: [resolve(__dirname, '..', '..')],
},
},
optimizeDeps: {
include: [

View File

@ -1064,30 +1064,23 @@ func (a *App) ReloadPlugins() (int, string) {
// Unregister all non-core capabilities
a.capRegistry.UnregisterAll()
// Re-register core capabilities
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
}
if err := a.capRegistry.Register("verstak-desktop", coreCaps); err != nil {
// Re-register the same core capabilities as initial startup. Keeping this
// list in the capability package prevents a plugin reload from dropping a
// required capability such as native notifications.
if err := a.capRegistry.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register core capabilities: %v", err)
}
// Re-register vault capability if vault is open
if a.vault != nil && a.vault.GetVaultStatus() == vault.StatusOpen {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/vault/v1"}); err != nil {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/vault/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register vault capability: %v", err)
}
}
// Re-register workspace capability if workspace is initialized
if a.workspace != nil && a.workspace.IsInitialized() {
if err := a.capRegistry.Register("verstak-desktop", []string{"verstak/core/workspace/v1"}); err != nil {
if err := a.capRegistry.Register(capability.CorePluginID, []string{"verstak/core/workspace/v1"}); err != nil {
log.Printf("[api] ReloadPlugins: failed to re-register workspace capability: %v", err)
}
}

View File

@ -225,6 +225,19 @@ func TestReplaceAndClearPluginNotificationsStayWithinPluginNamespace(t *testing.
}
}
func TestReloadPluginsRestoresNotificationCoreCapability(t *testing.T) {
app := &App{
capRegistry: capability.NewRegistry(),
contribRegistry: contribution.NewRegistry(),
}
app.ReloadPlugins()
if !app.capRegistry.Has("verstak/core/notifications/v1") {
t.Fatal("ReloadPlugins did not restore verstak/core/notifications/v1")
}
}
func TestDomReadyInitializesNotificationsBeforeScheduler(t *testing.T) {
oldInitialize := initializeNativeNotifications
defer func() { initializeNativeNotifications = oldInitialize }()

View File

@ -0,0 +1,20 @@
package capability
const CorePluginID = "verstak-desktop"
var platformCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
// CorePlatformCapabilities returns a copy of the capabilities registered by
// the desktop before dynamic plugins are resolved.
func CorePlatformCapabilities() []string {
return append([]string(nil), platformCapabilities...)
}

View File

@ -3,25 +3,16 @@ package plugin
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/verstak/verstak-desktop/internal/core/capability"
)
// coreCapabilities lists the 5 core capabilities that the platform registers
// before any plugins are loaded.
var coreCapabilities = []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
}
// registerCoreCapabilities registers the 5 core capabilities on a registry.
// registerCoreCapabilities registers the desktop's core capabilities on a registry.
func registerCoreCapabilities(t *testing.T, reg *capability.Registry) {
t.Helper()
if err := reg.Register("verstak-core", coreCapabilities); err != nil {
if err := reg.Register(capability.CorePluginID, capability.CorePlatformCapabilities()); err != nil {
t.Fatalf("failed to register core capabilities: %v", err)
}
}
@ -76,6 +67,43 @@ func TestLifecycle_CoreCapabilitiesRegisteredBeforePlugins(t *testing.T) {
}
}
func TestBundledOfficialPluginRequirementsResolve(t *testing.T) {
plugins, errs := DiscoverPlugins([]string{bundledPluginDir(t)})
if len(errs) > 0 {
t.Fatalf("discover bundled plugins: %v", errs)
}
reg := capability.NewRegistry()
registerCoreCapabilities(t, reg)
ResolveLifecycle(plugins, reg, nil)
foundTodo := false
for _, loaded := range plugins {
if loaded.Manifest.ID == "verstak.todo" {
foundTodo = true
}
if loaded.Status == StatusMissingRequiredCapability {
t.Fatalf("bundled plugin %q is missing required capabilities: %s", loaded.Manifest.ID, loaded.Error)
}
}
if !foundTodo {
t.Fatal("bundled Todo manifest was not discovered")
}
}
func bundledPluginDir(t *testing.T) string {
t.Helper()
if configured := os.Getenv("VERSTAK_RELEASE_PLUGIN_DIR"); configured != "" {
return configured
}
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("could not resolve lifecycle test path")
}
return filepath.Join(filepath.Dir(sourceFile), "../../../plugins")
}
// TestLifecycle_MissingRequiredCapability verifies that when the required
// capability is NOT registered, CheckRequired reports it as missing and the
// plugin status should be set to StatusMissingRequiredCapability.

13
main.go
View File

@ -86,17 +86,8 @@ func main() {
}
// ─── Register Core Capabilities ─────────────────────────
corePluginID := "verstak-desktop"
coreCaps := []string{
"verstak/core/plugin-manager/v1",
"verstak/core/capability-registry/v1",
"verstak/core/contribution-registry/v1",
"verstak/core/permissions/v1",
"verstak/core/events/v1",
"verstak/core/files/v1",
"verstak/core/workbench/v1",
"verstak/core/notifications/v1",
}
corePluginID := capability.CorePluginID
coreCaps := capability.CorePlatformCapabilities()
if err := capRegistry.Register(corePluginID, coreCaps); err != nil {
log.Fatalf("[main] failed to register core capabilities: %v", err)
}

13
packaging/deb/postinst Executable file
View File

@ -0,0 +1,13 @@
#!/bin/sh
set -eu
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database -q /usr/share/applications || true
fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
gtk-update-icon-cache -q -t -f /usr/share/icons/hicolor || true
fi
exit 0

View File

@ -10,4 +10,4 @@ export GST_PLUGIN_SYSTEM_PATH_1_0="$HERE/usr/lib/gstreamer-1.0"
export WEBKIT_EXEC_PATH="$HERE/usr/lib/webkit2gtk-4.1"
cd "$HERE/usr/bin"
exec "$HERE/usr/bin/verstak-desktop" "$@"
exec "$HERE/usr/bin/verstak" "$@"

6
packaging/linux/verstak Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env sh
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
exec "$HERE/verstak-desktop" "$@"

View File

@ -2,8 +2,10 @@
Type=Application
Name=Verstak
Comment=Local-first workspace
Exec=verstak-desktop %U
Exec=verstak %U
Icon=verstak
Terminal=false
Categories=Office;Utility;
Categories=Office;
StartupNotify=true
X-Verstak-Desktop-Entry=true
X-AppImage-Name=Verstak

View File

@ -13,6 +13,7 @@ fi
echo "=== verstak desktop Linux amd64 bundle ==="
(cd "$OFFICIAL_PLUGINS" && ./scripts/build.sh)
"$ROOT/scripts/install-dev-plugins.sh"
(cd "$ROOT" && go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1)
"$ROOT/scripts/build.sh"
BINARY="$ROOT/build/bin/verstak-desktop"

View File

@ -52,6 +52,7 @@ if [[ ! -d "$WINDOWS_PLUGIN_DIST" ]]; then
echo "Windows plugin packages were not produced: $WINDOWS_PLUGIN_DIST" >&2
exit 1
fi
VERSTAK_RELEASE_PLUGIN_DIR="$WINDOWS_PLUGIN_DIST" go test ./internal/core/plugin -run TestBundledOfficialPluginRequirementsResolve -count=1
# Wails' -compiler option selects a Go binary, not a C compiler. Cross-CGO
# therefore has to be supplied through the standard Go environment instead.

View File

@ -45,6 +45,7 @@ mkdir -p "$APPDIR/usr/bin" "$APPDIR/usr/lib" "$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/scalable/apps"
install -m 755 "$ROOT/packaging/linux/AppRun" "$APPDIR/AppRun"
install -m 755 "$BUNDLE/verstak-desktop" "$APPDIR/usr/bin/verstak-desktop"
install -m 755 "$ROOT/packaging/linux/verstak" "$APPDIR/usr/bin/verstak"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.desktop" "$APPDIR/usr/share/applications/verstak.desktop"
install -m 644 "$ROOT/packaging/linux/verstak.svg" "$APPDIR/verstak.svg"

View File

@ -28,6 +28,7 @@ mkdir -p "$STAGING/DEBIAN" "$STAGING/opt/verstak" "$STAGING/usr/bin" \
"$STAGING/usr/share/doc/verstak"
sed "s/@VERSION@/$PACKAGE_VERSION/g" "$ROOT/packaging/deb/control" > "$STAGING/DEBIAN/control"
install -m 755 "$ROOT/packaging/deb/postinst" "$STAGING/DEBIAN/postinst"
install -m 755 "$ROOT/packaging/deb/verstak" "$STAGING/usr/bin/verstak"
install -m 755 "$BUNDLE/verstak-desktop" "$STAGING/opt/verstak/verstak-desktop"
install -m 644 "$BUNDLE/README.md" "$BUNDLE/LICENSE" "$STAGING/usr/share/doc/verstak/"

View File

@ -15,6 +15,16 @@ done
grep -Fq 'dpkg-deb' "$ROOT/scripts/package-deb.sh"
grep -Fq -- '--build' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/control' "$ROOT/scripts/package-deb.sh"
grep -Fq 'packaging/deb/postinst' "$ROOT/scripts/package-deb.sh"
test -x "$ROOT/packaging/deb/postinst"
sh -n "$ROOT/packaging/deb/postinst"
grep -Fxq 'Exec=verstak %U' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'X-Verstak-Desktop-Entry=true' "$ROOT/packaging/linux/verstak.desktop"
grep -Fxq 'Categories=Office;' "$ROOT/packaging/linux/verstak.desktop"
grep -Fq 'update-desktop-database' "$ROOT/packaging/deb/postinst"
grep -Fq 'gtk-update-icon-cache' "$ROOT/packaging/deb/postinst"
grep -Fq 'packaging/linux/verstak' "$ROOT/scripts/package-appimage.sh"
grep -Fq 'usr/bin/verstak' "$ROOT/packaging/linux/AppRun"
grep -Fq 'libwebkit2gtk-4.1-0' "$ROOT/packaging/deb/control"
grep -Fq 'libayatana-appindicator3-1' "$ROOT/packaging/deb/control"
grep -Fq 'appimagetool' "$ROOT/scripts/package-appimage.sh"
@ -41,5 +51,7 @@ grep -Fq 'package-appimage.sh' "$ROOT/scripts/release.sh"
grep -Fq 'package-windows-portable.sh' "$ROOT/scripts/release.sh"
git -C "$ROOT" check-ignore -q verstak-desktop-res.syso
grep -Fq 'chmod -R a+rX' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-linux-bundle.sh"
grep -Fq 'TestBundledOfficialPluginRequirementsResolve' "$ROOT/scripts/build-windows.sh"
echo "desktop package script contracts passed"

View File

@ -47,6 +47,10 @@ DEBUG_MODE_STATUS=0
(cd "$ROOT" && node --experimental-vm-modules frontend/tests/debug-mode-test.mjs) || DEBUG_MODE_STATUS=$?
report "session-only debug mode" "$DEBUG_MODE_STATUS"
SELECT_STYLE_STATUS=0
(cd "$ROOT" && node frontend/tests/select-styles-test.mjs) || SELECT_STYLE_STATUS=$?
report "workspace select styles" "$SELECT_STYLE_STATUS"
# ── Frontend tests ──
echo "[frontend]"
if ensure_npm_deps "$ROOT/frontend"; then