feat: workspace routing, GlobalSearch, and shell refinements

- Add workspace-aware startup routing (openDefaultWorkspaceRoute)
- Add GlobalSearch component and workspace-empty state
- Refactor Sidebar, StatusBar, WorkspaceTree, VaultSelection
- Update PluginCard/PluginManager UI
- Extend e2e coverage (ux-p0, ux-followup, helpers)
- Add ListWorkspaces/SetCurrentWorkspace backend bindings
This commit is contained in:
2026-06-30 12:32:04 +08:00
parent 4bb9e84c35
commit 46f754cc2d
24 changed files with 845 additions and 174 deletions
+1 -1
View File
@@ -43,6 +43,6 @@ test.describe('Command Palette', () => {
await page.keyboard.press('Escape');
await expect(page.locator('.command-palette')).not.toBeVisible();
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.workspace-host')).toBeVisible();
});
});
+4 -4
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('F: Default Editor Plugin', () => {
let consoleCollector;
@@ -134,14 +134,14 @@ test.describe('F: Default Editor Plugin', () => {
});
test('default-editor plugin is listed as loaded in plugin manager', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
});
test('disable default-editor plugin removes its providers', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await card.locator('button.btn-disable').click();
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
@@ -161,7 +161,7 @@ test.describe('F: Default Editor Plugin', () => {
});
test('default-editor plugin card shows openProviders contribution count', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
+5 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('G: Files Plugin', () => {
let consoleCollector;
@@ -16,7 +16,7 @@ test.describe('G: Files Plugin', () => {
});
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card.locator('.status-badge')).toHaveText('loaded');
@@ -88,7 +88,7 @@ test.describe('G: Files Plugin', () => {
await expect(page.locator('.files-breadcrumb')).not.toContainText('Daily');
});
test('files explorer uses icon 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 expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
@@ -96,13 +96,13 @@ test.describe('G: Files Plugin', () => {
const button = page.locator(`[data-files-action="${action}"]`);
await expect(button).toHaveAttribute('title', /.+/);
await expect(button.locator('svg')).toBeVisible();
await expect(button).not.toHaveText(/\S/);
await expect(button).toHaveText(/\S/);
}
await expect(page.locator('.files-row-btn').filter({ hasText: 'New here' })).toHaveCount(0);
const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first();
await expect(firstRowButton).toBeVisible();
await expect(firstRowButton).not.toHaveText(/\S/);
await expect(firstRowButton).toHaveText(/\S/);
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
});
+7
View File
@@ -11,6 +11,13 @@ export async function waitForAppReady(page) {
await page.waitForTimeout(1000);
}
/** Open the secondary Plugin Manager route from the status-bar settings menu. */
export async function openPluginManager(page) {
await page.locator('[data-settings-menu-button]').click();
await page.locator('[data-settings-action="plugin-manager"]').click();
await page.waitForSelector('.plugin-manager', { state: 'visible', timeout: 10000 });
}
/** Collect all console errors since last reset */
export function setupConsoleCollector(page) {
const errors = [];
+5 -5
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('D: Plugin API bridge', () => {
let consoleCollector;
@@ -26,7 +26,7 @@ test.describe('D: Plugin API bridge', () => {
await page.locator('.pt-save-setting').click();
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
await page.locator('button.reload-btn').click();
@@ -155,7 +155,7 @@ test.describe('D: Plugin API bridge', () => {
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(1);
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
await expect.poll(() => page.evaluate(() => Object.keys(window.__VERSTAK_COMMAND_HANDLERS__ || {}).length)).toBe(0);
await expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
@@ -165,7 +165,7 @@ test.describe('D: Plugin API bridge', () => {
await page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-disable').click();
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
@@ -175,7 +175,7 @@ test.describe('D: Plugin API bridge', () => {
});
test('platform-test settings panel loads bundle content returned as raw string', async ({ page }) => {
await page.locator('.sidebar .nav-item').filter({ hasText: 'Plugin Manager' }).click();
await openPluginManager(page);
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await pluginCard.locator('button.btn-settings').click();
@@ -12,7 +12,7 @@
* 8. Verify plugin sidebar item returns
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
let consoleCollector;
@@ -22,6 +22,7 @@ test.describe('A: Plugin Manager Disable/Enable refresh', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
+2 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('E: Plugin Manager layout', () => {
let consoleCollector;
@@ -16,6 +16,7 @@ test.describe('E: Plugin Manager layout', () => {
});
test('plugin list scrolls through the global main scroll surface and stays responsive', async ({ page }) => {
await openPluginManager(page);
const basePluginCount = await page.locator('.plugin-card').count();
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
await page.locator('button.reload-btn').click();
+2 -1
View File
@@ -7,7 +7,7 @@
* 3. Verify UI reflects the updated state
*/
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, setPluginStatus, openPluginManager } from './helpers.js';
test.describe('C: Reload updates UI state', () => {
let consoleCollector;
@@ -17,6 +17,7 @@ test.describe('C: Reload updates UI state', () => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await openPluginManager(page);
});
test.afterEach(async () => {
+2 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState } from './helpers.js';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('Status Bar host', () => {
let consoleCollector;
@@ -39,6 +39,7 @@ test.describe('Status Bar host', () => {
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
await expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
await openPluginManager(page);
await pluginCard.locator('button.btn-disable').click();
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX follow-up fixes', () => {
test.beforeEach(async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
});
test('global search stays available after opening tool sidebar views', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await expect(search).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Activity' }).click();
await expect(page.locator('.activity-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
await page.locator('.sidebar .nav-item').filter({ hasText: 'Browser Inbox' }).click();
await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
await expect(search).toBeVisible();
});
test('global search types ahead across workspaces and file contents with keyboard layout fallback', async ({ page }) => {
const search = page.locator('[data-global-search-input]');
await search.fill('Зкщоусе');
await expect(page.locator('[data-global-search-results]')).toContainText('Project', { timeout: 10000 });
await search.fill('project file');
await expect(page.locator('[data-global-search-results]')).toContainText('project-only.txt', { timeout: 10000 });
});
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
await openPluginManager(page);
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
const modal = page.locator('.modal[aria-label="Plugin Settings"]');
await expect(modal).toBeVisible({ timeout: 10000 });
const box = await modal.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(760);
expect(box.height).toBeGreaterThanOrEqual(560);
});
});
+97
View File
@@ -0,0 +1,97 @@
import { test, expect } from '@playwright/test';
import { waitForAppReady, setupConsoleCollector, resetMockState, openPluginManager } from './helpers.js';
test.describe('UX P0 shell 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('starts in the first workspace instead of Plugin Manager', async ({ page }) => {
await expect(page.locator('.workspace-host')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Project');
await expect(page.locator('.workspace-title')).toHaveText('Project');
});
test('workspace selection and main content stay in sync across plugin manager round trip', async ({ page }) => {
await page.locator('.wt-label').filter({ hasText: 'Test' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Test', { timeout: 10000 });
await expect(page.locator('.wt-node.selected .wt-label')).toHaveText('Test');
await expect(page.locator('.plugin-manager')).toHaveCount(0);
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.wt-node.selected .wt-label')).toHaveCount(0);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
await expect(page.locator('.workspace-title')).toHaveText('Project', { timeout: 10000 });
await expect(page.locator('.plugin-manager')).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);
await expect(page.locator('.status-bar [data-status-item-id]')).toHaveCount(2);
});
test('Plugin Manager remains reachable from the settings menu', async ({ page }) => {
await openPluginManager(page);
await expect(page.locator('.plugin-manager')).toBeVisible();
await expect(page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' })).toBeVisible();
});
});
test.describe('UX quick wins', () => {
test('Files screen uses readable dates and understandable action controls', async ({ page }) => {
await resetMockState(page);
await page.goto('/');
await waitForAppReady(page);
await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
const files = page.locator('.files-root');
await expect(files).toBeVisible({ timeout: 10000 });
await expect(files.getByText(/T\d{2}:\d{2}:\d{2}/)).toHaveCount(0);
const actions = ['New folder', 'New markdown file', 'New text file'];
for (const action of actions) {
const button = page.locator(`[data-files-action]`).filter({ hasText: action }).first();
await expect(button).toBeVisible();
await expect(button).toHaveAttribute('title', action);
}
});
test('Vault Selection is localized and has a clear primary action', async ({ browser }) => {
const page = await browser.newPage();
await page.addInitScript(() => {
window.go = { api: { App: {
GetAppSettings: async () => ({ currentVaultPath: '', recentVaults: ['/tmp/verstak-recent-vault'] }),
GetVaultStatus: async () => ({ status: 'closed', path: '', vaultId: '' }),
SelectDirectory: async () => '',
SelectVaultForOpen: async () => '',
CreateVault: async () => null,
OpenVault: async () => null,
SetCurrentVault: async () => '',
WriteFrontendLog: async () => {},
} } };
});
await page.goto('/');
await page.waitForSelector('.vault-selection', { timeout: 10000 });
await expect(page.getByText('Выберите vault для начала работы')).toBeVisible();
await expect(page.getByRole('button', { name: 'Создать vault' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Открыть существующий' })).toBeVisible();
await page.close();
});
});