Improve workspace UX flows
This commit is contained in:
parent
46f754cc2d
commit
268e79d2f0
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Activity workflow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace activity explains empty event flow', async ({ page }) => {
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
|
||||||
|
const activity = page.locator('.activity-root');
|
||||||
|
await expect(activity).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(activity.locator('.activity-title')).toContainText('Activity');
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('0 events');
|
||||||
|
await expect(activity.locator('.activity-empty')).toContainText('No activity events yet');
|
||||||
|
await expect(activity.locator('.activity-empty')).toContainText('File changes, browser captures, and conversions will appear here');
|
||||||
|
await expect(activity.locator('[data-activity-action="clear"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace activity renders stored events and worklog suggestions', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.activity', {
|
||||||
|
'events:workspace:Project': [
|
||||||
|
{
|
||||||
|
activityId: 'activity-e2e-capture',
|
||||||
|
occurredAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
type: 'browser.capture.selection',
|
||||||
|
title: 'Research Capture',
|
||||||
|
summary: 'Selected text from the article',
|
||||||
|
sourcePluginId: 'verstak.browser-inbox',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activityId: 'activity-e2e-note',
|
||||||
|
occurredAt: '2026-06-30T08:25:00.000Z',
|
||||||
|
type: 'note.saved',
|
||||||
|
title: 'Saved note',
|
||||||
|
summary: 'Project/Notes/Research Capture.md',
|
||||||
|
sourcePluginId: 'verstak.files',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.getByRole('tab', { name: 'Activity' }).click();
|
||||||
|
|
||||||
|
const activity = page.locator('.activity-root');
|
||||||
|
await expect(activity.locator('.activity-count')).toHaveText('2 events');
|
||||||
|
await expect(activity.locator('[data-activity-id="activity-e2e-capture"]')).toContainText('Research Capture');
|
||||||
|
await expect(activity.locator('[data-activity-id="activity-e2e-note"]')).toContainText('Saved note');
|
||||||
|
await expect(activity.locator('[data-activity-section="worklog-suggestions"]')).toContainText('Worklog suggestions');
|
||||||
|
await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('Project work on 2026-06-30');
|
||||||
|
await expect(activity.locator('[data-worklog-suggestion="worklog:Project:2026-06-30"]')).toContainText('50 min');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('Browser Inbox workflow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace inbox explains empty capture flow and primary actions', async ({ page }) => {
|
||||||
|
await page.getByRole('tab', { name: 'Browser Inbox' }).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-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('[data-browser-inbox-action="clear"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace inbox renders stored captures with conversion actions', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
|
||||||
|
'captures:workspace:Project': [{
|
||||||
|
captureId: 'capture-e2e-1',
|
||||||
|
capturedAt: '2026-06-30T08:00:00.000Z',
|
||||||
|
kind: 'file',
|
||||||
|
url: 'https://example.com/report',
|
||||||
|
title: 'Research Report',
|
||||||
|
domain: 'example.com',
|
||||||
|
text: 'Selected page text',
|
||||||
|
fileName: 'report.txt',
|
||||||
|
fileText: 'Report file contents',
|
||||||
|
workspaceRootPath: 'Project',
|
||||||
|
browserName: 'Firefox',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.getByRole('tab', { name: 'Browser Inbox' }).click();
|
||||||
|
|
||||||
|
const inbox = page.locator('.browser-inbox-root');
|
||||||
|
await expect(inbox.locator('.browser-inbox-count')).toHaveText('1 item');
|
||||||
|
await expect(inbox.locator('[data-browser-capture-id="capture-e2e-1"]')).toContainText('Research Report');
|
||||||
|
await expect(inbox.locator('.browser-inbox-detail-title')).toHaveText('Research Report');
|
||||||
|
await expect(inbox.locator('.browser-inbox-meta')).toContainText('example.com');
|
||||||
|
await expect(inbox.locator('.browser-inbox-text').first()).toContainText('Selected page text');
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-note"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-link"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="create-file"]')).toBeVisible();
|
||||||
|
await expect(inbox.locator('[data-browser-inbox-action="remove"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -45,4 +45,83 @@ test.describe('Command Palette', () => {
|
||||||
await expect(page.locator('.command-palette')).not.toBeVisible();
|
await expect(page.locator('.command-palette')).not.toBeVisible();
|
||||||
await expect(page.locator('.workspace-host')).toBeVisible();
|
await expect(page.locator('.workspace-host')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows user workflow commands before diagnostics and opens workspace tools', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
const palette = page.locator('.command-palette');
|
||||||
|
await expect(palette).toBeVisible();
|
||||||
|
const items = palette.locator('.command-palette-item');
|
||||||
|
await expect(items.nth(0)).toHaveAttribute('data-command-id', 'verstak.shell.open-today');
|
||||||
|
await expect(items.nth(1)).toHaveAttribute('data-command-id', 'verstak.shell.open-files');
|
||||||
|
await expect(items.nth(2)).toHaveAttribute('data-command-id', 'verstak.shell.open-activity');
|
||||||
|
await expect(items.nth(3)).toHaveAttribute('data-command-id', 'verstak.shell.open-browser-inbox');
|
||||||
|
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('activity');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.open-activity"]')).toBeVisible();
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.platform-test.run-tests"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Activity' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('starts file creation workflows from user commands', async ({ page }) => {
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
let palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('markdown');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.create-markdown"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('[data-files-create-input]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.locator('[data-files-create-input]').fill('Palette Note.md');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Palette Note.md"]')).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('text file');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.create-text"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('[data-files-create-input]')).toBeVisible();
|
||||||
|
await page.locator('[data-files-create-input]').fill('Palette Text.txt');
|
||||||
|
await page.locator('[data-files-create-confirm]').click();
|
||||||
|
await expect(page.locator('[data-file-name="Palette Text.txt"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runs sync workflow commands', async ({ page }) => {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const err = await window.go.api.App.PluginSyncConfigure('verstak.sync', 'https://sync.example.test', 'alice', 'secret');
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
|
||||||
|
let palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('sync now');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.sync-now"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(palette).not.toBeVisible();
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('Sync Now');
|
||||||
|
await expect(page.locator('[data-command-palette-status="success"]')).toContainText('handled');
|
||||||
|
|
||||||
|
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+K' : 'Control+K');
|
||||||
|
palette = page.locator('.command-palette');
|
||||||
|
await palette.locator('[data-command-palette-input]').fill('sync settings');
|
||||||
|
await expect(palette.locator('[data-command-id="verstak.shell.open-sync-settings"]')).toBeVisible();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.locator('.plugin-manager')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.modal[aria-label="Plugin Settings"]')).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.locator('.modal-header h3')).toContainText('Sync');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
|
||||||
|
|
||||||
|
async function openFilesTool(page) {
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
await expect(page.locator('.files-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('G: Files Plugin', () => {
|
test.describe('G: Files Plugin', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
||||||
|
|
@ -29,6 +34,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
|
test('workspace Files view is scoped to selected workspace folder', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
|
|
||||||
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('.files-item-name').filter({ hasText: 'project-only.txt' })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
@ -42,6 +48,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
|
test('files explorer supports create navigate rename filter sort open and trash', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('[data-files-action="new-folder"]').click();
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
|
@ -90,6 +97,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
|
test('files explorer uses labeled controls and no row New Here action', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
|
for (const action of ['back', 'forward', 'up', 'refresh', 'new-folder', 'new-markdown', 'new-text', 'open', 'rename', 'trash', 'cut', 'copy', 'paste']) {
|
||||||
|
|
@ -108,6 +116,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
|
test('files explorer supports empty-space context paste after cutting a folder', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('[data-files-action="new-folder"]').click();
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
|
@ -132,6 +141,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
|
test('files explorer supports multiselect and internal drag/drop move', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('[data-files-action="new-folder"]').click();
|
await page.locator('[data-files-action="new-folder"]').click();
|
||||||
|
|
@ -166,6 +176,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
|
test('files history persists in workspace context and handles mouse back forward buttons', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('[data-file-name="Notes"]').dblclick();
|
await page.locator('[data-file-name="Notes"]').dblclick();
|
||||||
|
|
@ -191,6 +202,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
|
|
||||||
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
|
test('workbench close and mouse back return from editor to the previous Files folder', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await openFilesTool(page);
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
||||||
|
|
||||||
await page.locator('[data-file-name="Notes"]').dblclick();
|
await page.locator('[data-file-name="Notes"]').dblclick();
|
||||||
|
|
|
||||||
|
|
@ -84,15 +84,21 @@ test.describe('E: Plugin Manager layout', () => {
|
||||||
await expect(selected).toHaveText('Test');
|
await expect(selected).toHaveText('Test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workspace tools render as tabs with Files as one tab', async ({ page }) => {
|
test('workspace tools render Today first with Files as one tab', async ({ page }) => {
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
|
||||||
const tabs = page.locator('.workspace-tabs');
|
const tabs = page.locator('.workspace-tabs');
|
||||||
await expect(tabs).toBeVisible({ timeout: 10000 });
|
await expect(tabs).toBeVisible({ timeout: 10000 });
|
||||||
|
const todayTab = tabs.locator('[role="tab"]').filter({ hasText: 'Today' });
|
||||||
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
|
const filesTab = tabs.locator('[role="tab"]').filter({ hasText: 'Files' });
|
||||||
|
await expect(todayTab).toBeVisible();
|
||||||
|
await expect(todayTab).toHaveAttribute('aria-selected', 'true');
|
||||||
await expect(filesTab).toBeVisible();
|
await expect(filesTab).toBeVisible();
|
||||||
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
|
await expect(filesTab).toHaveAttribute('aria-selected', 'false');
|
||||||
await expect(page.locator('.workspace-tool')).toHaveCount(0);
|
await expect(page.locator('.workspace-tool')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.today-root')).toBeVisible();
|
||||||
|
|
||||||
|
await filesTab.click();
|
||||||
await expect(page.locator('.files-root')).toBeVisible();
|
await expect(page.locator('.files-root')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ test.describe('UX quick wins', () => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
|
await page.getByRole('tab', { name: 'Files' }).click();
|
||||||
|
|
||||||
const files = page.locator('.files-root');
|
const files = page.locator('.files-root');
|
||||||
await expect(files).toBeVisible({ timeout: 10000 });
|
await expect(files).toBeVisible({ timeout: 10000 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
|
||||||
|
|
||||||
|
test.describe('UX Today workspace flow', () => {
|
||||||
|
let consoleCollector;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
consoleCollector = setupConsoleCollector(page);
|
||||||
|
await resetMockState(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await waitForAppReady(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
consoleCollector.assertNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('workspace opens with Today before plugin tools', async ({ page }) => {
|
||||||
|
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const tabs = page.getByRole('tab');
|
||||||
|
await expect(tabs.nth(0)).toHaveText('Today');
|
||||||
|
await expect(tabs.nth(1)).toHaveText('Files');
|
||||||
|
await expect(page.getByRole('tab', { name: 'Today' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
|
||||||
|
const today = page.locator('.today-root');
|
||||||
|
await expect(today).toBeVisible();
|
||||||
|
await expect(today.locator('[data-today-section="captured"]')).toContainText('Captured');
|
||||||
|
await expect(today.locator('[data-today-section="activity"]')).toContainText('Recent Activity');
|
||||||
|
await expect(today.locator('[data-today-section="worklog"]')).toContainText('Worklog Suggestions');
|
||||||
|
await expect(today.locator('[data-today-section="quick-actions"]')).toContainText('Quick Actions');
|
||||||
|
await expect(today).toContainText('No browser captures yet');
|
||||||
|
await expect(today).toContainText('No activity events yet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Today quick action opens Browser Inbox workspace tool', async ({ page }) => {
|
||||||
|
await page.locator('[data-today-action="browser-inbox"]').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('tab', { name: 'Browser Inbox' })).toHaveAttribute('aria-selected', 'true');
|
||||||
|
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
|
|
||||||
let workspaceNodes = [];
|
let workspaceNodes = [];
|
||||||
let selectedWorkspaceName = '';
|
let selectedWorkspaceName = '';
|
||||||
|
let activeWorkspaceToolKey = '';
|
||||||
let navigationStack = [];
|
let navigationStack = [];
|
||||||
let navigationIndex = -1;
|
let navigationIndex = -1;
|
||||||
let applyingNavigation = false;
|
let applyingNavigation = false;
|
||||||
|
|
@ -113,6 +114,7 @@
|
||||||
activeSettingsPanelId,
|
activeSettingsPanelId,
|
||||||
openedResource,
|
openedResource,
|
||||||
selectedWorkspaceName,
|
selectedWorkspaceName,
|
||||||
|
activeWorkspaceToolKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,6 +141,7 @@
|
||||||
activeSettingsPanelId = snapshot.activeSettingsPanelId;
|
activeSettingsPanelId = snapshot.activeSettingsPanelId;
|
||||||
openedResource = snapshot.openedResource;
|
openedResource = snapshot.openedResource;
|
||||||
selectedWorkspaceName = snapshot.selectedWorkspaceName;
|
selectedWorkspaceName = snapshot.selectedWorkspaceName;
|
||||||
|
activeWorkspaceToolKey = snapshot.activeWorkspaceToolKey || '';
|
||||||
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
|
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
|
||||||
applyingNavigation = false;
|
applyingNavigation = false;
|
||||||
}
|
}
|
||||||
|
|
@ -272,11 +275,17 @@
|
||||||
|
|
||||||
function onWorkbenchOpened(e) {
|
function onWorkbenchOpened(e) {
|
||||||
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
|
debug.log('[App] onWorkbenchOpened:', e.detail?.request?.path, e.detail?.providerId);
|
||||||
|
if (currentView === 'workspace') pushNavigation();
|
||||||
openedResource = e.detail;
|
openedResource = e.detail;
|
||||||
currentView = 'workbench';
|
currentView = 'workbench';
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onWorkspaceToolSelected(e) {
|
||||||
|
activeWorkspaceToolKey = e.detail?.toolKey || '';
|
||||||
|
if (currentView === 'workspace') pushNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
function onWorkspaceSelected(e) {
|
function onWorkspaceSelected(e) {
|
||||||
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
|
debug.log('[App] onWorkspaceSelected:', e.detail?.workspaceName);
|
||||||
selectedWorkspaceName = e.detail?.workspaceName || '';
|
selectedWorkspaceName = e.detail?.workspaceName || '';
|
||||||
|
|
@ -351,6 +360,7 @@
|
||||||
window.addEventListener('verstak:close-settings', onCloseSettings);
|
window.addEventListener('verstak:close-settings', onCloseSettings);
|
||||||
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
|
window.addEventListener('verstak:workbench-opened', onWorkbenchOpened);
|
||||||
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
|
window.addEventListener('verstak:workspace-selected', onWorkspaceSelected);
|
||||||
|
window.addEventListener('verstak:workspace-tool-selected', onWorkspaceToolSelected);
|
||||||
window.addEventListener('verstak:navigate-back', onNavigateBack);
|
window.addEventListener('verstak:navigate-back', onNavigateBack);
|
||||||
window.addEventListener('verstak:navigate-forward', onNavigateForward);
|
window.addEventListener('verstak:navigate-forward', onNavigateForward);
|
||||||
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
|
window.addEventListener('verstak:close-workbench', onCloseWorkbench);
|
||||||
|
|
@ -385,7 +395,11 @@
|
||||||
{:else if currentView === 'workbench'}
|
{:else if currentView === 'workbench'}
|
||||||
<WorkbenchHost {openedResource} />
|
<WorkbenchHost {openedResource} />
|
||||||
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
|
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
|
||||||
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
|
<WorkspaceHost
|
||||||
|
selectedWorkspaceName={selectedWorkspaceName}
|
||||||
|
nodes={workspaceNodes}
|
||||||
|
bind:activeToolKey={activeWorkspaceToolKey}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ViewContainer {activeView} {activeViewPluginId} />
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,17 @@
|
||||||
let statusType = '';
|
let statusType = '';
|
||||||
|
|
||||||
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
const inactiveStatuses = new Set(['disabled', 'failed', 'incompatible', 'missing-required-capability']);
|
||||||
|
const shellCommands = [
|
||||||
|
{ id: 'verstak.shell.open-today', title: 'Open Today', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 10, shellAction: 'today' },
|
||||||
|
{ id: 'verstak.shell.open-files', title: 'Open Files', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 20, shellAction: 'files' },
|
||||||
|
{ id: 'verstak.shell.open-activity', title: 'Open Activity', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 30, shellAction: 'activity' },
|
||||||
|
{ id: 'verstak.shell.open-browser-inbox', title: 'Open Browser Inbox', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 40, shellAction: 'browser-inbox' },
|
||||||
|
{ id: 'verstak.shell.create-markdown', title: 'Create Markdown File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 50, shellAction: 'create-markdown' },
|
||||||
|
{ id: 'verstak.shell.create-text', title: 'Create Text File', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 60, shellAction: 'create-text' },
|
||||||
|
{ id: 'verstak.shell.sync-now', title: 'Sync Now', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 70, shellAction: 'sync-now' },
|
||||||
|
{ id: 'verstak.shell.open-sync-settings', title: 'Open Sync Settings', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 80, shellAction: 'sync-settings' },
|
||||||
|
{ id: 'verstak.shell.open-plugin-manager', title: 'Open Plugin Manager', pluginId: 'verstak.shell', pluginName: 'Verstak', priority: 90, shellAction: 'plugin-manager' },
|
||||||
|
];
|
||||||
|
|
||||||
$: normalizedQuery = query.trim().toLowerCase();
|
$: normalizedQuery = query.trim().toLowerCase();
|
||||||
$: filteredCommands = commands.filter((command) => {
|
$: filteredCommands = commands.filter((command) => {
|
||||||
|
|
@ -33,7 +44,7 @@
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => ({})),
|
||||||
]);
|
]);
|
||||||
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
const pluginById = new Map((plugins || []).map((plugin) => [plugin.manifest?.id, plugin]));
|
||||||
commands = (contributions.commands || [])
|
const pluginCommands = (contributions.commands || [])
|
||||||
.filter((command) => {
|
.filter((command) => {
|
||||||
const plugin = pluginById.get(command.pluginId);
|
const plugin = pluginById.get(command.pluginId);
|
||||||
if (!plugin) return false;
|
if (!plugin) return false;
|
||||||
|
|
@ -44,13 +55,16 @@
|
||||||
return {
|
return {
|
||||||
...command,
|
...command,
|
||||||
pluginName: plugin?.manifest?.name || command.pluginId,
|
pluginName: plugin?.manifest?.name || command.pluginId,
|
||||||
|
priority: 1000,
|
||||||
};
|
};
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
|
|
||||||
if (title) return title;
|
|
||||||
return String(a.pluginId).localeCompare(String(b.pluginId));
|
|
||||||
});
|
});
|
||||||
|
commands = [...shellCommands, ...pluginCommands].sort((a, b) => {
|
||||||
|
const priority = (a.priority || 1000) - (b.priority || 1000);
|
||||||
|
if (priority) return priority;
|
||||||
|
const title = String(a.title || a.id).localeCompare(String(b.title || b.id));
|
||||||
|
if (title) return title;
|
||||||
|
return String(a.pluginId).localeCompare(String(b.pluginId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openPalette() {
|
async function openPalette() {
|
||||||
|
|
@ -78,9 +92,87 @@
|
||||||
}, 4000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clickWorkspaceTool(label) {
|
||||||
|
const tabs = Array.from(document.querySelectorAll('.workspace-tabs [role="tab"]'));
|
||||||
|
const tab = tabs.find((node) => String(node.textContent || '').trim().toLowerCase() === label);
|
||||||
|
if (tab) {
|
||||||
|
tab.click();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceTool(label) {
|
||||||
|
if (clickWorkspaceTool(label)) return;
|
||||||
|
const selectedWorkspace = document.querySelector('.wt-node.selected .wt-label');
|
||||||
|
const firstWorkspace = document.querySelector('.wt-label');
|
||||||
|
const workspaceButton = selectedWorkspace || firstWorkspace;
|
||||||
|
if (workspaceButton) {
|
||||||
|
workspaceButton.click();
|
||||||
|
await tick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
}
|
||||||
|
clickWorkspaceTool(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startFilesCreate(action) {
|
||||||
|
await openWorkspaceTool('files');
|
||||||
|
await tick();
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
|
const button = document.querySelector(`[data-files-action="${action}"]`);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error(`Files action not available: ${action}`);
|
||||||
|
}
|
||||||
|
button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runShellCommand(command) {
|
||||||
|
if (command.shellAction === 'plugin-manager') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', { detail: {} }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'sync-settings') {
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:open-settings', {
|
||||||
|
detail: { pluginId: 'verstak.sync', panelId: 'verstak.sync.settings' }
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'sync-now') {
|
||||||
|
const result = await App.PluginSyncNow('verstak.sync');
|
||||||
|
if (typeof result === 'string' && result) {
|
||||||
|
throw new Error(result);
|
||||||
|
}
|
||||||
|
if (Array.isArray(result) && result[1]) {
|
||||||
|
throw new Error(result[1]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'create-markdown') {
|
||||||
|
await startFilesCreate('new-markdown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.shellAction === 'create-text') {
|
||||||
|
await startFilesCreate('new-text');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const actionToTab = {
|
||||||
|
today: 'today',
|
||||||
|
files: 'files',
|
||||||
|
activity: 'activity',
|
||||||
|
'browser-inbox': 'browser inbox',
|
||||||
|
};
|
||||||
|
await openWorkspaceTool(actionToTab[command.shellAction] || command.shellAction);
|
||||||
|
}
|
||||||
|
|
||||||
async function runCommand(command) {
|
async function runCommand(command) {
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
try {
|
try {
|
||||||
|
if (command.shellAction) {
|
||||||
|
await runShellCommand(command);
|
||||||
|
closePalette();
|
||||||
|
setStatus('success', `${command.title || command.id} handled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await executePluginCommand(command.pluginId, command.id, {
|
const result = await executePluginCommand(command.pluginId, command.id, {
|
||||||
source: 'command-palette',
|
source: 'command-palette',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,327 @@
|
||||||
|
<script>
|
||||||
|
import { createEventDispatcher, onMount } from 'svelte';
|
||||||
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
|
export let workspaceRootPath = '';
|
||||||
|
export let workspaceTitle = '';
|
||||||
|
export let availableTools = [];
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let captures = [];
|
||||||
|
let activity = [];
|
||||||
|
let worklogSuggestions = [];
|
||||||
|
|
||||||
|
$: hasBrowserInbox = hasTool('browser inbox') || hasTool('inbox');
|
||||||
|
$: hasActivity = hasTool('activity');
|
||||||
|
$: hasFiles = hasTool('files');
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadToday();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hasTool(name) {
|
||||||
|
name = String(name || '').toLowerCase();
|
||||||
|
return (availableTools || []).some(tool => {
|
||||||
|
const label = String(tool?.title || tool?.id || tool?.pluginId || '').toLowerCase();
|
||||||
|
return label.includes(name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeTuple(response, fallback) {
|
||||||
|
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : (response[0] || fallback);
|
||||||
|
return response || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPluginSettings(pluginId) {
|
||||||
|
try {
|
||||||
|
return decodeTuple(await App.ReadPluginSettings(pluginId), {});
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceKey(prefix) {
|
||||||
|
return prefix + encodeURIComponent(String(workspaceRootPath || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRows(value) {
|
||||||
|
return Array.isArray(value) ? value.filter(item => item && typeof item === 'object') : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsFor(settings, keys) {
|
||||||
|
return keys.flatMap(key => normalizeRows(settings?.[key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureTitle(capture) {
|
||||||
|
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
|
||||||
|
}
|
||||||
|
|
||||||
|
function activityTitle(item) {
|
||||||
|
return item.title || item.summary || item.type || item.activityId || 'Activity event';
|
||||||
|
}
|
||||||
|
|
||||||
|
function worklogTitle(item) {
|
||||||
|
return item.title || item.summary || item.date || item.entryId || 'Worklog item';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadToday() {
|
||||||
|
loading = true;
|
||||||
|
const [browserSettings, activitySettings, journalSettings] = await Promise.all([
|
||||||
|
readPluginSettings('verstak.browser-inbox'),
|
||||||
|
readPluginSettings('verstak.activity'),
|
||||||
|
readPluginSettings('verstak.journal'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
captures = rowsFor(browserSettings, [
|
||||||
|
workspaceKey('captures:workspace:'),
|
||||||
|
'captures:global',
|
||||||
|
'captures',
|
||||||
|
]).slice(0, 4);
|
||||||
|
|
||||||
|
activity = rowsFor(activitySettings, [
|
||||||
|
workspaceKey('events:workspace:'),
|
||||||
|
'events:global',
|
||||||
|
'events',
|
||||||
|
]).slice(0, 4);
|
||||||
|
|
||||||
|
worklogSuggestions = rowsFor(journalSettings, [
|
||||||
|
workspaceKey('suggestions:workspace:'),
|
||||||
|
workspaceKey('worklog:workspace:'),
|
||||||
|
'suggestions',
|
||||||
|
'worklog',
|
||||||
|
]).slice(0, 4);
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTool(kind) {
|
||||||
|
dispatch('openTool', { kind });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="today-root" aria-label="Today">
|
||||||
|
<div class="today-header">
|
||||||
|
<div>
|
||||||
|
<h2>Today</h2>
|
||||||
|
<p>{workspaceTitle || workspaceRootPath || 'Workspace'} overview</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" on:click={loadToday}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="today-grid">
|
||||||
|
<section class="today-panel" data-today-section="captured">
|
||||||
|
<div class="today-panel-head">
|
||||||
|
<h3>Captured</h3>
|
||||||
|
<button type="button" data-today-action="browser-inbox" on:click={() => openTool('browser-inbox')}>Open Inbox</button>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<p class="today-empty">Loading captures...</p>
|
||||||
|
{:else if captures.length}
|
||||||
|
<div class="today-list">
|
||||||
|
{#each captures as capture}
|
||||||
|
<div class="today-row">
|
||||||
|
<strong>{captureTitle(capture)}</strong>
|
||||||
|
<span>{capture.url || capture.domain || capture.kind || 'Browser capture'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="today-empty">No browser captures yet. Send a page, selection, link, or file from the browser extension.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="today-panel" data-today-section="activity">
|
||||||
|
<div class="today-panel-head">
|
||||||
|
<h3>Recent Activity</h3>
|
||||||
|
{#if hasActivity}
|
||||||
|
<button type="button" data-today-action="activity" on:click={() => openTool('activity')}>Open Activity</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<p class="today-empty">Loading activity...</p>
|
||||||
|
{:else if activity.length}
|
||||||
|
<div class="today-list">
|
||||||
|
{#each activity as item}
|
||||||
|
<div class="today-row">
|
||||||
|
<strong>{activityTitle(item)}</strong>
|
||||||
|
<span>{item.occurredAt || item.receivedAt || item.type || 'Activity event'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="today-empty">No activity events yet. File changes, captures, and conversions will appear here.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="today-panel" data-today-section="worklog">
|
||||||
|
<div class="today-panel-head">
|
||||||
|
<h3>Worklog Suggestions</h3>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<p class="today-empty">Loading worklog suggestions...</p>
|
||||||
|
{:else if worklogSuggestions.length}
|
||||||
|
<div class="today-list">
|
||||||
|
{#each worklogSuggestions as item}
|
||||||
|
<div class="today-row">
|
||||||
|
<strong>{worklogTitle(item)}</strong>
|
||||||
|
<span>{item.minutes ? item.minutes + ' min' : item.date || 'Suggested worklog'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="today-empty">No worklog suggestions yet. Activity will be grouped into suggestions once work starts.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="today-panel" data-today-section="quick-actions">
|
||||||
|
<div class="today-panel-head">
|
||||||
|
<h3>Quick Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="today-actions">
|
||||||
|
{#if hasFiles}
|
||||||
|
<button type="button" data-today-action="files" on:click={() => openTool('files')}>Open Files</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" on:click={() => openTool('browser-inbox')}>Process Captures</button>
|
||||||
|
{#if hasActivity}
|
||||||
|
<button type="button" on:click={() => openTool('activity')}>Review Activity</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.today-root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #101020;
|
||||||
|
color: #e0e0f0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #16213e;
|
||||||
|
background: #12122a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #8b8ba8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-panel {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 10rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #16213e;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #15152c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-panel-head {
|
||||||
|
min-height: 2.8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(22, 33, 62, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #f0f0ff;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-panel-head button,
|
||||||
|
.today-actions button,
|
||||||
|
.today-header button {
|
||||||
|
min-height: 1.85rem;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #8b8ba8;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1px solid rgba(78, 204, 163, 0.16);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #101827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-row strong {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #f4f7fb;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-row span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #8b8ba8;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.today-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.today-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
<script>
|
<script>
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
||||||
|
import TodaySurface from './TodaySurface.svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
|
|
||||||
export let selectedWorkspaceName = '';
|
export let selectedWorkspaceName = '';
|
||||||
export let nodes = [];
|
export let nodes = [];
|
||||||
|
export let activeToolKey = '';
|
||||||
|
|
||||||
let contributions = {};
|
let contributions = {};
|
||||||
let plugins = [];
|
let plugins = [];
|
||||||
let workspaceTools = [];
|
let workspaceTools = [];
|
||||||
let activeToolKey = '';
|
let toolsLoaded = false;
|
||||||
|
const todayTool = { id: '__today', title: 'Today', pluginId: 'verstak.shell', component: 'TodaySurface', shell: true };
|
||||||
|
|
||||||
const toolOrder = new Map([
|
const toolOrder = new Map([
|
||||||
['notes', 10],
|
['notes', 10],
|
||||||
|
|
@ -23,9 +26,10 @@
|
||||||
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
|
$: workspaceRootPath = selectedWorkspace?.rootPath || selectedWorkspace?.name || selectedWorkspace?.id || '';
|
||||||
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
|
$: workspaceTitle = selectedWorkspace?.title || selectedWorkspace?.name || selectedWorkspace?.id || selectedWorkspaceName;
|
||||||
$: workspaceType = selectedWorkspace?.type || 'workspace';
|
$: workspaceType = selectedWorkspace?.type || 'workspace';
|
||||||
$: activeTool = workspaceTools.find(tool => toolKey(tool) === activeToolKey) || workspaceTools[0] || null;
|
$: displayedTools = selectedWorkspace ? [todayTool, ...workspaceTools] : [];
|
||||||
$: if (workspaceTools.length > 0 && (!activeToolKey || !workspaceTools.some(tool => toolKey(tool) === activeToolKey))) {
|
$: activeTool = displayedTools.find(tool => toolKey(tool) === activeToolKey) || displayedTools[0] || null;
|
||||||
activeToolKey = toolKey(workspaceTools[0]);
|
$: if (displayedTools.length > 0 && (!activeToolKey || (toolsLoaded && !displayedTools.some(tool => toolKey(tool) === activeToolKey)))) {
|
||||||
|
activeToolKey = toolKey(todayTool);
|
||||||
}
|
}
|
||||||
$: if (selectedWorkspaceName) loadTools();
|
$: if (selectedWorkspaceName) loadTools();
|
||||||
|
|
||||||
|
|
@ -49,8 +53,30 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTool(tool) {
|
||||||
|
activeToolKey = toolKey(tool);
|
||||||
|
window.dispatchEvent(new CustomEvent('verstak:workspace-tool-selected', {
|
||||||
|
detail: {
|
||||||
|
toolKey: activeToolKey,
|
||||||
|
toolId: tool?.id || '',
|
||||||
|
pluginId: tool?.pluginId || '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWorkspaceTool(event) {
|
||||||
|
const kind = String(event?.detail?.kind || '').toLowerCase();
|
||||||
|
const match = workspaceTools.find(tool => {
|
||||||
|
const text = `${tool?.title || ''} ${tool?.id || ''} ${tool?.pluginId || ''}`.toLowerCase();
|
||||||
|
if (kind === 'browser-inbox') return text.includes('browser') || text.includes('inbox');
|
||||||
|
return text.includes(kind);
|
||||||
|
});
|
||||||
|
if (match) selectTool(match);
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTools() {
|
async function loadTools() {
|
||||||
try {
|
try {
|
||||||
|
toolsLoaded = false;
|
||||||
const [c, p] = await Promise.all([
|
const [c, p] = await Promise.all([
|
||||||
App.GetContributions().catch(() => ({})),
|
App.GetContributions().catch(() => ({})),
|
||||||
App.GetPlugins().catch(() => []),
|
App.GetPlugins().catch(() => []),
|
||||||
|
|
@ -65,6 +91,8 @@
|
||||||
workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)));
|
workspaceTools = sortWorkspaceTools((contributions.workspaceItems || []).filter(tool => enabledIds.has(tool.pluginId)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[WorkspaceHost] loadTools error:', e);
|
console.error('[WorkspaceHost] loadTools error:', e);
|
||||||
|
} finally {
|
||||||
|
toolsLoaded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -78,16 +106,16 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if workspaceTools.length > 0}
|
{#if displayedTools.length > 0}
|
||||||
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
|
<div class="workspace-tabs" role="tablist" aria-label="Workspace tools">
|
||||||
{#each workspaceTools as tool (tool.id + tool.pluginId)}
|
{#each displayedTools as tool (tool.id + tool.pluginId)}
|
||||||
<button
|
<button
|
||||||
class:active={toolKey(tool) === toolKey(activeTool)}
|
class:active={toolKey(tool) === toolKey(activeTool)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={toolKey(tool) === toolKey(activeTool)}
|
aria-selected={toolKey(tool) === toolKey(activeTool)}
|
||||||
type="button"
|
type="button"
|
||||||
title={tool.pluginId}
|
title={tool.pluginId}
|
||||||
on:click={() => activeToolKey = toolKey(tool)}
|
on:click={() => selectTool(tool)}
|
||||||
>
|
>
|
||||||
{tool.title || tool.id}
|
{tool.title || tool.id}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -95,11 +123,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
|
<div class="workspace-tool-content" role="tabpanel" aria-label={activeTool?.title || activeTool?.id || 'Workspace tool'}>
|
||||||
{#if activeTool}
|
{#if activeTool}
|
||||||
<PluginBundleHost
|
{#if activeTool.shell}
|
||||||
pluginId={activeTool.pluginId}
|
<TodaySurface
|
||||||
componentId={activeTool.component}
|
{workspaceRootPath}
|
||||||
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
|
{workspaceTitle}
|
||||||
/>
|
availableTools={workspaceTools}
|
||||||
|
on:openTool={openWorkspaceTool}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<PluginBundleHost
|
||||||
|
pluginId={activeTool.pluginId}
|
||||||
|
componentId={activeTool.component}
|
||||||
|
componentProps={{ workspaceName: selectedWorkspaceName, workspaceNodeId: selectedWorkspaceName, workspaceNode: selectedWorkspace, workspaceRootPath }}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
|
||||||
|
|
@ -1002,21 +1002,424 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function activityBundle() {
|
function activityBundle() {
|
||||||
return [
|
return '(' + function () {
|
||||||
"(function(){",
|
var PLUGIN_ID = 'verstak.activity';
|
||||||
"var ActivityView={mount:function(containerEl){containerEl.innerHTML='';var root=document.createElement('div');root.className='activity-root';root.setAttribute('data-plugin-id','verstak.activity');var title=document.createElement('h2');title.textContent='Activity';var body=document.createElement('div');body.textContent='Global activity feed';root.appendChild(title);root.appendChild(body);containerEl.appendChild(root);},unmount:function(containerEl){containerEl.innerHTML='';}};",
|
|
||||||
"window.VerstakPluginRegister('verstak.activity',{components:{ActivityView:ActivityView}});",
|
function injectStyles() {
|
||||||
"})();"
|
if (document.getElementById('mock-activity-style')) return;
|
||||||
].join('');
|
var style = document.createElement('style');
|
||||||
|
style.id = 'mock-activity-style';
|
||||||
|
style.textContent = [
|
||||||
|
'.activity-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}',
|
||||||
|
'.activity-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}',
|
||||||
|
'.activity-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.activity-count,.activity-status{font-size:.74rem;color:#8b8ba8}.activity-spacer{flex:1}',
|
||||||
|
'.activity-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.activity-btn:hover{background:#1a4a7a}.activity-btn:disabled{opacity:.45;cursor:default}.activity-btn.danger{border-color:#633;color:#ffb0b0}',
|
||||||
|
'.activity-suggestions{display:grid;gap:.5rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);background:#111126}.activity-suggestions-title{font-size:.76rem;font-weight:600;color:#8b8ba8;text-transform:uppercase}.activity-suggestion{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;padding:.55rem .65rem;border:1px solid rgba(78,204,163,.24);border-radius:4px;background:#14142c}.activity-suggestion-title{font-size:.84rem;font-weight:600;color:#f4f7fb}.activity-suggestion-summary{margin-top:.22rem;color:#aaa;font-size:.76rem;line-height:1.4}.activity-suggestion-minutes{color:#4ecca3;font-size:.76rem;white-space:nowrap}',
|
||||||
|
'.activity-list{flex:1;min-height:0;overflow:auto;background:#101020}.activity-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#8b8ba8;font-size:.84rem;line-height:1.45;text-align:center}.activity-row{display:grid;grid-template-columns:9.5rem minmax(0,1fr);gap:.75rem;padding:.72rem .85rem;border-bottom:1px solid rgba(22,33,62,.7)}.activity-time{font-size:.72rem;color:#777;white-space:nowrap}.activity-main{min-width:0}.activity-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.activity-type{font-size:.68rem;color:#4ecca3;text-transform:uppercase;letter-spacing:.04em}.activity-title-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e0e0f0;font-size:.86rem}.activity-summary{margin-top:.25rem;color:#aaa;font-size:.78rem;line-height:1.4}.activity-source{margin-top:.25rem;color:#777;font-size:.72rem}',
|
||||||
|
'@media(max-width:760px){.activity-row,.activity-suggestion{grid-template-columns:1fr;gap:.25rem}.activity-status{width:100%}}'
|
||||||
|
].join('');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs, children) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
attrs = attrs || {};
|
||||||
|
Object.keys(attrs).forEach(function (key) {
|
||||||
|
if (key === 'textContent') node.textContent = attrs[key];
|
||||||
|
else if (key === 'className') node.className = attrs[key];
|
||||||
|
else if (key === 'disabled') node.disabled = !!attrs[key];
|
||||||
|
else if (key.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
|
||||||
|
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
|
||||||
|
else node[key] = attrs[key];
|
||||||
|
});
|
||||||
|
(children || []).forEach(function (child) {
|
||||||
|
if (child) node.appendChild(child);
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRoot(props) {
|
||||||
|
return String(
|
||||||
|
props.workspaceRootPath ||
|
||||||
|
(props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id)) ||
|
||||||
|
props.workspaceName ||
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceKey(root) {
|
||||||
|
return 'events:workspace:' + encodeURIComponent(root || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(value) {
|
||||||
|
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTime(value) {
|
||||||
|
var date = new Date(value || 0);
|
||||||
|
return isNaN(date.getTime()) ? 0 : date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDay(activity) {
|
||||||
|
var value = activity.occurredAt || activity.receivedAt || new Date().toISOString();
|
||||||
|
return String(value).slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
var date = new Date(value || 0);
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
return date.toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(activity) {
|
||||||
|
return activity.title || activity.summary || activity.type || activity.activityId || 'Activity event';
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(group) {
|
||||||
|
return group.map(title).filter(Boolean).slice(0, 3).join('; ') || group.length + ' activity events';
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggestions(events, root) {
|
||||||
|
var groups = {};
|
||||||
|
events.forEach(function (activity) {
|
||||||
|
var workspace = activity.workspaceRootPath || root || 'Global';
|
||||||
|
var day = eventDay(activity);
|
||||||
|
var key = workspace + '|' + day;
|
||||||
|
groups[key] = groups[key] || { workspaceRootPath: workspace, date: day, events: [] };
|
||||||
|
groups[key].events.push(activity);
|
||||||
|
});
|
||||||
|
return Object.keys(groups).map(function (key) {
|
||||||
|
var group = groups[key];
|
||||||
|
var ordered = group.events.slice().sort(function (a, b) { return eventTime(a.occurredAt || a.receivedAt) - eventTime(b.occurredAt || b.receivedAt); });
|
||||||
|
return {
|
||||||
|
suggestionId: 'worklog:' + group.workspaceRootPath + ':' + group.date,
|
||||||
|
title: group.workspaceRootPath + ' work on ' + group.date,
|
||||||
|
summary: summarize(ordered),
|
||||||
|
minutes: Math.max(25, ordered.length * 25)
|
||||||
|
};
|
||||||
|
}).sort(function (a, b) { return b.suggestionId.localeCompare(a.suggestionId); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActivity(containerEl, props, api) {
|
||||||
|
injectStyles();
|
||||||
|
var rootPath = workspaceRoot(props || {});
|
||||||
|
var key = workspaceKey(rootPath);
|
||||||
|
var events = [];
|
||||||
|
var statusText = 'Listening for workspace activity';
|
||||||
|
|
||||||
|
containerEl.innerHTML = '';
|
||||||
|
var root = el('div', { className: 'activity-root', 'data-plugin-id': PLUGIN_ID });
|
||||||
|
var toolbar = el('div', { className: 'activity-toolbar' });
|
||||||
|
var titleEl = el('span', { className: 'activity-title', textContent: rootPath ? 'Activity · ' + rootPath : 'Activity' });
|
||||||
|
var countEl = el('span', { className: 'activity-count' });
|
||||||
|
var statusEl = el('span', { className: 'activity-status' });
|
||||||
|
var clearBtn = el('button', {
|
||||||
|
className: 'activity-btn danger',
|
||||||
|
'data-activity-action': 'clear',
|
||||||
|
textContent: 'Clear',
|
||||||
|
onClick: function () {
|
||||||
|
events = [];
|
||||||
|
persist().then(render);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var suggestionsEl = el('div', { className: 'activity-suggestions', 'data-activity-section': 'worklog-suggestions' });
|
||||||
|
var listEl = el('div', { className: 'activity-list' });
|
||||||
|
|
||||||
|
toolbar.appendChild(titleEl);
|
||||||
|
toolbar.appendChild(countEl);
|
||||||
|
toolbar.appendChild(el('span', { className: 'activity-spacer' }));
|
||||||
|
toolbar.appendChild(statusEl);
|
||||||
|
toolbar.appendChild(clearBtn);
|
||||||
|
root.appendChild(toolbar);
|
||||||
|
root.appendChild(suggestionsEl);
|
||||||
|
root.appendChild(listEl);
|
||||||
|
containerEl.appendChild(root);
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve({});
|
||||||
|
return api.settings.read().then(function (settings) { return settings || {}; }).catch(function () { return {}; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||||
|
return api.settings.write(key, events.slice()).catch(function (err) {
|
||||||
|
statusText = 'Could not save activity: ' + (err && err.message ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSuggestions() {
|
||||||
|
suggestionsEl.innerHTML = '';
|
||||||
|
var items = suggestions(events, rootPath);
|
||||||
|
if (!items.length) return;
|
||||||
|
suggestionsEl.appendChild(el('div', { className: 'activity-suggestions-title', textContent: 'Worklog suggestions' }));
|
||||||
|
items.forEach(function (item) {
|
||||||
|
suggestionsEl.appendChild(el('div', { className: 'activity-suggestion', 'data-worklog-suggestion': item.suggestionId }, [
|
||||||
|
el('div', {}, [
|
||||||
|
el('div', { className: 'activity-suggestion-title', textContent: item.title }),
|
||||||
|
el('div', { className: 'activity-suggestion-summary', textContent: item.summary })
|
||||||
|
]),
|
||||||
|
el('div', { className: 'activity-suggestion-minutes', textContent: item.minutes + ' min' })
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!events.length) {
|
||||||
|
listEl.appendChild(el('div', {
|
||||||
|
className: 'activity-empty',
|
||||||
|
textContent: 'No activity events yet. File changes, browser captures, and conversions will appear here.'
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.slice().sort(function (a, b) { return eventTime(b.occurredAt || b.receivedAt) - eventTime(a.occurredAt || a.receivedAt); }).forEach(function (activity) {
|
||||||
|
listEl.appendChild(el('div', { className: 'activity-row', 'data-activity-id': activity.activityId }, [
|
||||||
|
el('div', { className: 'activity-time', textContent: formatDate(activity.occurredAt || activity.receivedAt) }),
|
||||||
|
el('div', { className: 'activity-main' }, [
|
||||||
|
el('div', { className: 'activity-row-head' }, [
|
||||||
|
el('span', { className: 'activity-type', textContent: activity.type || 'activity.event' }),
|
||||||
|
el('span', { className: 'activity-title-text', textContent: title(activity) })
|
||||||
|
]),
|
||||||
|
activity.summary ? el('div', { className: 'activity-summary', textContent: activity.summary }) : null,
|
||||||
|
activity.sourcePluginId ? el('div', { className: 'activity-source', textContent: activity.sourcePluginId }) : null
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
countEl.textContent = events.length + ' event' + (events.length === 1 ? '' : 's');
|
||||||
|
clearBtn.disabled = events.length === 0;
|
||||||
|
statusEl.textContent = statusText;
|
||||||
|
renderSuggestions();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
readSettings().then(function (settings) {
|
||||||
|
events = rows(settings[key]);
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ActivityView = {
|
||||||
|
mount: renderActivity,
|
||||||
|
unmount: function (containerEl) { containerEl.innerHTML = ''; }
|
||||||
|
};
|
||||||
|
window.VerstakPluginRegister('verstak.activity', { components: { ActivityView: ActivityView } });
|
||||||
|
}.toString() + ')();';
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserInboxBundle() {
|
function browserInboxBundle() {
|
||||||
return [
|
return '(' + function () {
|
||||||
"(function(){",
|
var PLUGIN_ID = 'verstak.browser-inbox';
|
||||||
"var BrowserInboxView={mount:function(containerEl){containerEl.innerHTML='';var root=document.createElement('div');root.className='browser-inbox-root';root.setAttribute('data-plugin-id','verstak.browser-inbox');var title=document.createElement('h2');title.textContent='Browser Inbox';var body=document.createElement('div');body.textContent='Global browser inbox';root.appendChild(title);root.appendChild(body);containerEl.appendChild(root);},unmount:function(containerEl){containerEl.innerHTML='';}};",
|
|
||||||
"window.VerstakPluginRegister('verstak.browser-inbox',{components:{BrowserInboxView:BrowserInboxView}});",
|
function injectStyles() {
|
||||||
"})();"
|
if (document.getElementById('mock-browser-inbox-style')) return;
|
||||||
].join('');
|
var style = document.createElement('style');
|
||||||
|
style.id = 'mock-browser-inbox-style';
|
||||||
|
style.textContent = [
|
||||||
|
'.browser-inbox-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0f0}',
|
||||||
|
'.browser-inbox-toolbar{display:flex;align-items:center;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a;flex-wrap:wrap}',
|
||||||
|
'.browser-inbox-title{font-size:.86rem;font-weight:600;color:#f0f0ff}.browser-inbox-count,.browser-inbox-status{font-size:.74rem;color:#8b8ba8}.browser-inbox-spacer{flex:1}',
|
||||||
|
'.browser-inbox-btn{min-height:1.85rem;padding:.3rem .65rem;border:1px solid #1a3a5c;border-radius:4px;background:#0f3460;color:#e0e0f0;font-size:.76rem;cursor:pointer}.browser-inbox-btn:hover{background:#1a4a7a}.browser-inbox-btn:disabled{opacity:.45;cursor:default}.browser-inbox-btn.danger{border-color:#633;color:#ffb0b0}',
|
||||||
|
'.browser-inbox-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(260px,360px) minmax(0,1fr)}.browser-inbox-list{min-height:0;overflow:auto;border-right:1px solid #16213e;background:#101020}.browser-inbox-detail{min-width:0;min-height:0;overflow:auto;padding:1rem;display:flex;flex-direction:column;gap:.75rem}',
|
||||||
|
'.browser-inbox-empty,.browser-inbox-detail-empty{height:100%;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#8b8ba8;font-size:.84rem;line-height:1.45;text-align:center}.browser-inbox-detail-empty{height:auto;margin:auto}',
|
||||||
|
'.browser-inbox-row{display:grid;gap:.25rem;padding:.65rem .75rem;border-bottom:1px solid rgba(22,33,62,.75);cursor:pointer}.browser-inbox-row:hover{background:#17172d}.browser-inbox-row.selected{background:#1a2a3a}.browser-inbox-row-head{display:flex;align-items:center;gap:.45rem;min-width:0}.browser-inbox-kind{color:#4ecca3;font-size:.68rem;text-transform:uppercase}.browser-inbox-row-title{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.86rem}.browser-inbox-row-url,.browser-inbox-row-text{min-width:0;overflow:hidden;text-overflow:ellipsis;color:#8b8ba8;font-size:.74rem}',
|
||||||
|
'.browser-inbox-detail-title{font-size:1rem;font-weight:600;color:#f4f7fb;overflow-wrap:anywhere}.browser-inbox-meta{display:grid;grid-template-columns:7rem minmax(0,1fr);gap:.35rem .75rem;font-size:.78rem}.browser-inbox-meta-label{color:#777}.browser-inbox-meta-value{color:#ccc;overflow-wrap:anywhere}.browser-inbox-text{padding:.75rem;border:1px solid #24304f;border-radius:6px;background:#101020;color:#ddd;font-size:.84rem;line-height:1.5;white-space:pre-wrap}.browser-inbox-detail-actions{display:flex;gap:.5rem;flex-wrap:wrap}',
|
||||||
|
'@media(max-width:760px){.browser-inbox-body{grid-template-columns:1fr}.browser-inbox-list{border-right:0;border-bottom:1px solid #16213e;max-height:45vh}.browser-inbox-meta{grid-template-columns:1fr}}'
|
||||||
|
].join('');
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs, children) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
attrs = attrs || {};
|
||||||
|
Object.keys(attrs).forEach(function (key) {
|
||||||
|
if (key === 'textContent') node.textContent = attrs[key];
|
||||||
|
else if (key === 'className') node.className = attrs[key];
|
||||||
|
else if (key === 'disabled') node.disabled = !!attrs[key];
|
||||||
|
else if (key.indexOf('data-') === 0) node.setAttribute(key, attrs[key]);
|
||||||
|
else if (key === 'onClick') node.addEventListener('click', attrs[key]);
|
||||||
|
else node[key] = attrs[key];
|
||||||
|
});
|
||||||
|
(children || []).forEach(function (child) {
|
||||||
|
if (child) node.appendChild(child);
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceRoot(props) {
|
||||||
|
return String(
|
||||||
|
props.workspaceRootPath ||
|
||||||
|
(props.workspaceNode && (props.workspaceNode.rootPath || props.workspaceNode.name || props.workspaceNode.id)) ||
|
||||||
|
props.workspaceName ||
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceKey(root) {
|
||||||
|
return 'captures:workspace:' + encodeURIComponent(root || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rows(value) {
|
||||||
|
return Array.isArray(value) ? value.filter(function (item) { return item && typeof item === 'object'; }) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(capture) {
|
||||||
|
return capture.title || capture.fileName || capture.url || capture.captureId || 'Untitled capture';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrowserInbox(containerEl, props, api) {
|
||||||
|
injectStyles();
|
||||||
|
var rootPath = workspaceRoot(props || {});
|
||||||
|
var key = workspaceKey(rootPath);
|
||||||
|
var captures = [];
|
||||||
|
var selectedId = '';
|
||||||
|
var statusText = 'Ready for browser captures';
|
||||||
|
|
||||||
|
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 countEl = el('span', { className: 'browser-inbox-count' });
|
||||||
|
var statusEl = el('span', { className: 'browser-inbox-status' });
|
||||||
|
var clearBtn = el('button', {
|
||||||
|
className: 'browser-inbox-btn danger',
|
||||||
|
'data-browser-inbox-action': 'clear',
|
||||||
|
textContent: 'Clear',
|
||||||
|
onClick: function () {
|
||||||
|
captures = [];
|
||||||
|
selectedId = '';
|
||||||
|
persist().then(render);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var body = el('div', { className: 'browser-inbox-body' });
|
||||||
|
var listEl = el('div', { className: 'browser-inbox-list' });
|
||||||
|
var detailEl = el('div', { className: 'browser-inbox-detail' });
|
||||||
|
|
||||||
|
toolbar.appendChild(titleEl);
|
||||||
|
toolbar.appendChild(countEl);
|
||||||
|
toolbar.appendChild(el('span', { className: 'browser-inbox-spacer' }));
|
||||||
|
toolbar.appendChild(statusEl);
|
||||||
|
toolbar.appendChild(clearBtn);
|
||||||
|
body.appendChild(listEl);
|
||||||
|
body.appendChild(detailEl);
|
||||||
|
root.appendChild(toolbar);
|
||||||
|
root.appendChild(body);
|
||||||
|
containerEl.appendChild(root);
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
if (!api || !api.settings || typeof api.settings.read !== 'function') return Promise.resolve({});
|
||||||
|
return api.settings.read().then(function (settings) { return settings || {}; }).catch(function () { return {}; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
if (!api || !api.settings || typeof api.settings.write !== 'function') return Promise.resolve();
|
||||||
|
return api.settings.write(key, captures.slice()).catch(function (err) {
|
||||||
|
statusText = 'Could not save inbox: ' + (err && err.message ? err.message : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCapture() {
|
||||||
|
for (var i = 0; i < captures.length; i += 1) {
|
||||||
|
if (captures[i].captureId === selectedId) return captures[i];
|
||||||
|
}
|
||||||
|
return captures[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCapture(captureId) {
|
||||||
|
captures = captures.filter(function (item) { return item.captureId !== captureId; });
|
||||||
|
selectedId = captures[0] ? captures[0].captureId : '';
|
||||||
|
statusText = 'Capture removed';
|
||||||
|
return persist().then(render);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversionAction(kind, capture) {
|
||||||
|
statusText = 'Ready to create ' + kind + ': ' + title(capture);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList() {
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (captures.length === 0) {
|
||||||
|
listEl.appendChild(el('div', {
|
||||||
|
className: 'browser-inbox-empty',
|
||||||
|
textContent: 'No browser captures yet. Keep this view open, then send a page, selection, link, or file from the browser extension.'
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
captures.forEach(function (capture) {
|
||||||
|
var row = el('div', {
|
||||||
|
className: 'browser-inbox-row' + (capture.captureId === selectedId ? ' selected' : ''),
|
||||||
|
'data-browser-capture-id': capture.captureId,
|
||||||
|
onClick: function () {
|
||||||
|
selectedId = capture.captureId;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
el('div', { className: 'browser-inbox-row-head' }, [
|
||||||
|
el('span', { className: 'browser-inbox-kind', textContent: capture.kind || 'capture' }),
|
||||||
|
el('span', { className: 'browser-inbox-row-title', textContent: title(capture) })
|
||||||
|
]),
|
||||||
|
el('div', { className: 'browser-inbox-row-url', textContent: capture.url || capture.domain || capture.captureId || '' })
|
||||||
|
]);
|
||||||
|
if (capture.text) row.appendChild(el('div', { className: 'browser-inbox-row-text', textContent: capture.text }));
|
||||||
|
listEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail() {
|
||||||
|
detailEl.innerHTML = '';
|
||||||
|
var capture = selectedCapture();
|
||||||
|
if (!capture) {
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-empty', textContent: 'Select a capture to inspect it.' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedId = capture.captureId;
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-title', textContent: title(capture) }));
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-meta' }, [
|
||||||
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Kind' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.kind || '-' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'URL' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-value', textContent: capture.url || '-' }),
|
||||||
|
el('div', { className: 'browser-inbox-meta-label', textContent: 'Domain' }),
|
||||||
|
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 || '-' })
|
||||||
|
]));
|
||||||
|
if (capture.text) detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.text }));
|
||||||
|
if (capture.fileText) detailEl.appendChild(el('div', { className: 'browser-inbox-text', textContent: capture.fileText }));
|
||||||
|
detailEl.appendChild(el('div', { className: 'browser-inbox-detail-actions' }, [
|
||||||
|
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-note', textContent: 'Create Note', onClick: function () { conversionAction('note', capture); } }),
|
||||||
|
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-link', textContent: 'Create Link', onClick: function () { conversionAction('link', capture); } }),
|
||||||
|
el('button', { className: 'browser-inbox-btn', 'data-browser-inbox-action': 'create-file', textContent: 'Create File', onClick: function () { conversionAction('file', capture); } }),
|
||||||
|
el('button', { className: 'browser-inbox-btn danger', 'data-browser-inbox-action': 'remove', textContent: 'Remove', onClick: function () { removeCapture(capture.captureId); } })
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
countEl.textContent = captures.length + ' item' + (captures.length === 1 ? '' : 's');
|
||||||
|
clearBtn.disabled = captures.length === 0;
|
||||||
|
statusEl.textContent = statusText;
|
||||||
|
renderList();
|
||||||
|
renderDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
readSettings().then(function (settings) {
|
||||||
|
captures = rows(settings[key]);
|
||||||
|
selectedId = captures[0] ? captures[0].captureId : '';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
var BrowserInboxView = {
|
||||||
|
mount: renderBrowserInbox,
|
||||||
|
unmount: function (containerEl) { containerEl.innerHTML = ''; }
|
||||||
|
};
|
||||||
|
window.VerstakPluginRegister('verstak.browser-inbox', { components: { BrowserInboxView: BrowserInboxView } });
|
||||||
|
}.toString() + ')();';
|
||||||
}
|
}
|
||||||
|
|
||||||
function platformTestBundle() {
|
function platformTestBundle() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue