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:
mirivlad 2026-06-30 12:32:04 +08:00
parent 4bb9e84c35
commit 46f754cc2d
24 changed files with 845 additions and 174 deletions

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();
});
});

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');

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');
});

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 = [];

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();

View File

@ -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 () => {

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();

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 () => {

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();

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);
});
});

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();
});
});

View File

@ -12,7 +12,7 @@
import { onMount } from 'svelte';
import { tick } from 'svelte';
let currentView = 'plugin-manager';
let currentView = 'workspace';
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
let needsVaultSelection = false;
let loading = true;
@ -35,6 +35,75 @@
App.WriteFrontendLog('App', msg);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
}
function workspaceName(workspace) {
return String(workspace?.name || workspace?.rootPath || workspace?.id || '');
}
function workspaceAsNode(workspace, order) {
const name = workspaceName(workspace);
return {
id: name,
type: workspace?.type || 'space',
title: workspace?.title || name,
name,
rootPath: workspace?.rootPath || name,
status: workspace?.status || 'active',
order,
};
}
function emitWorkspaceActive(name) {
window.dispatchEvent(new CustomEvent('verstak:workspace-active-changed', {
detail: { workspaceName: name || '' }
}));
}
function clearWorkspaceSelection() {
selectedWorkspaceName = '';
emitWorkspaceActive('');
}
async function openDefaultWorkspaceRoute() {
try {
const [workspaces, err] = resultOrError(await App.ListWorkspaces(), []);
if (err || !workspaces || workspaces.length === 0) {
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
return;
}
workspaceNodes = workspaces.map(workspaceAsNode);
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = workspaceName(currentWorkspace);
const selected = workspaces.find((workspace) => workspaceName(workspace) === currentName) || workspaces[0];
selectedWorkspaceName = workspaceName(selected);
if (selectedWorkspaceName) {
try { await App.SetCurrentWorkspace(selectedWorkspaceName); } catch {}
currentView = 'workspace';
} else {
currentView = 'workspace-empty';
}
emitWorkspaceActive(selectedWorkspaceName);
} catch (e) {
debug.log('[App] openDefaultWorkspaceRoute ERROR', String(e));
workspaceNodes = [];
selectedWorkspaceName = '';
currentView = 'workspace-empty';
emitWorkspaceActive('');
}
}
function currentSnapshot() {
return {
currentView,
@ -70,6 +139,7 @@
activeSettingsPanelId = snapshot.activeSettingsPanelId;
openedResource = snapshot.openedResource;
selectedWorkspaceName = snapshot.selectedWorkspaceName;
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
applyingNavigation = false;
}
@ -153,6 +223,7 @@
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
flog('checkVault: needsVaultSelection=false');
needsVaultSelection = false;
await openDefaultWorkspaceRoute();
}
} catch (e) {
debug.log('[App] checkVault: ERROR', String(e));
@ -166,15 +237,18 @@
flog('checkVault: END, loading=false');
}
function onVaultOpened() {
async function onVaultOpened() {
debug.log('[App] onVaultOpened');
needsVaultSelection = false;
vaultStatus = { status: 'open', path: '', vaultId: '' };
await openDefaultWorkspaceRoute();
pushNavigation();
}
function onNav(e) {
debug.log('[App] onNav:', e.detail.viewId);
currentView = e.detail.viewId;
if (currentView !== 'workspace') clearWorkspaceSelection();
pushNavigation();
}
@ -183,6 +257,7 @@
activeView = e.detail.viewId;
activeViewPluginId = e.detail.pluginId || '';
currentView = 'plugin-view';
clearWorkspaceSelection();
pushNavigation();
}
@ -191,6 +266,7 @@
activeSettingsPluginId = e.detail.pluginId;
activeSettingsPanelId = e.detail.panelId || '';
currentView = 'plugin-manager';
clearWorkspaceSelection();
pushNavigation();
}
@ -206,7 +282,13 @@
selectedWorkspaceName = e.detail?.workspaceName || '';
workspaceNodes = e.detail?.nodes || workspaceNodes;
if (selectedWorkspaceName) {
activeView = null;
activeViewPluginId = '';
activeSettingsPluginId = '';
activeSettingsPanelId = '';
openedResource = null;
currentView = 'workspace';
emitWorkspaceActive(selectedWorkspaceName);
pushNavigation();
}
}
@ -302,7 +384,7 @@
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
{:else if currentView === 'workbench'}
<WorkbenchHost {openedResource} />
{:else if currentView === 'workspace'}
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
{:else}
<ViewContainer {activeView} {activeViewPluginId} />

View File

@ -97,35 +97,39 @@
<span class="label">Name:</span>
<span>{m.name || '-'}</span>
</div>
<div class="meta-row">
<span class="label">API Version:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Source:</span>
<span>{m.source || 'unknown'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Contributions:</span>
<span>{contribSummary}</span>
</div>
</div>
<!-- Capabilities -->
<div class="section">
<span class="section-title">Provides</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
<details class="plugin-details">
<summary>Technical details</summary>
<div class="card-meta technical-meta">
<div class="meta-row">
<span class="label">API Version:</span>
<span>{m.apiVersion || '-'}</span>
</div>
<div class="meta-row">
<span class="label">Root:</span>
<span class="path">{p.rootPath || '-'}</span>
</div>
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">Provides</span>
<div class="tags">
{#each m.provides || [] as cap}
<span class="tag provides">{cap}</span>
{/each}
</div>
</div>
{#if m.requires && m.requires.length > 0}
<div class="section">
<span class="section-title">Requires</span>
<div class="tags">
@ -141,9 +145,9 @@
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
{/if}
</div>
{/if}
{/if}
{#if m.optionalRequires && m.optionalRequires.length > 0}
{#if m.optionalRequires && m.optionalRequires.length > 0}
<div class="section">
<span class="section-title">Optional Requires</span>
<div class="tags">
@ -159,10 +163,9 @@
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
{/if}
</div>
{/if}
{/if}
<!-- Permissions -->
{#if m.permissions && m.permissions.length > 0}
{#if m.permissions && m.permissions.length > 0}
<div class="section">
<span class="section-title">Permissions</span>
<div class="tags">
@ -175,7 +178,8 @@
{/each}
</div>
</div>
{/if}
{/if}
</details>
<!-- Error -->
{#if p.error}
@ -289,6 +293,24 @@
font-size: 0.8rem;
}
.technical-meta {
margin-top: 0.55rem;
}
.plugin-details {
margin: 0.6rem 0;
padding: 0.45rem 0;
border-top: 1px solid rgba(15, 52, 96, 0.75);
border-bottom: 1px solid rgba(15, 52, 96, 0.75);
}
.plugin-details summary {
cursor: pointer;
color: #8b8ba8;
font-size: 0.78rem;
font-weight: 600;
}
.meta-row {
display: flex;
gap: 0.5rem;

View File

@ -514,7 +514,7 @@
}
.modal {
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
width: 480px; max-width: 90vw; max-height: 80vh; display: flex; flex-direction: column;
width: min(880px, calc(100vw - 4rem)); max-width: calc(100vw - 4rem); height: min(680px, calc(100vh - 4rem)); max-height: calc(100vh - 4rem); display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
@ -523,7 +523,8 @@
.modal-header h3 { margin: 0; color: #e0e0f0; font-size: 1.1rem; }
.modal-close { background: none; border: none; color: #a0a0b8; font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem; }
.modal-close:hover { color: #e94560; }
.modal-body { padding: 1rem; overflow-y: auto; }
.modal-body { padding: 1rem; overflow: auto; min-height: 0; flex: 1; display: flex; flex-direction: column; }
.modal-body :global(.plugin-bundle-host) { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.settings-hint { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
.settings-hint code { color: #4ecca3; }
@ -542,7 +543,8 @@
}
.modal {
width: min(480px, calc(100vw - 2rem));
width: min(880px, calc(100vw - 2rem));
height: min(680px, calc(100vh - 2rem));
max-height: calc(100vh - 2rem);
}
}

View File

@ -0,0 +1,349 @@
<script>
import { onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
const TEXT_EXTENSIONS = new Set(['txt', 'md', 'markdown', 'log', 'json', 'csv', 'yaml', 'yml', 'toml']);
const FILE_INDEX_LIMIT = 220;
const RESULT_LIMIT = 8;
const RU = 'ёйцукенгшщзхъфывапролджэячсмитьбю';
const EN = '`qwertyuiop[]asdfghjkl;\\zxcvbnm,.';
let query = '';
let index = [];
let results = [];
let focused = false;
let loading = true;
let searchTimer = null;
$: scheduleSearch(query);
onMount(() => {
buildIndex();
return () => clearTimeout(searchTimer);
});
function normalize(value) {
return String(value == null ? '' : value).trim().toLowerCase();
}
function swapLayout(value, from, to) {
return String(value || '').split('').map(ch => {
const lower = ch.toLowerCase();
const idx = from.indexOf(lower);
if (idx === -1) return ch;
const mapped = to[idx] || ch;
return ch === lower ? mapped : mapped.toUpperCase();
}).join('');
}
function queryVariants(value) {
const base = normalize(value);
return [...new Set([
base,
normalize(swapLayout(base, RU, EN)),
normalize(swapLayout(base, EN, RU)),
].filter(Boolean))];
}
function matchScore(item, variants) {
const haystack = normalize(`${item.title} ${item.subtitle || ''} ${item.keywords || ''}`);
for (const variant of variants) {
if (!variant) continue;
if (normalize(item.title) === variant) return 100;
if (normalize(item.title).startsWith(variant)) return 80;
if (haystack.includes(variant)) return 50;
}
return 0;
}
function scheduleSearch(value) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(value), 80);
}
function runSearch(value) {
const variants = queryVariants(value);
if (!variants.length) {
results = [];
return;
}
results = index
.map(item => ({ item, score: matchScore(item, variants) }))
.filter(row => row.score > 0)
.sort((a, b) => b.score - a.score || a.item.rank - b.item.rank || a.item.title.localeCompare(b.item.title))
.slice(0, RESULT_LIMIT)
.map(row => row.item);
}
function workspaceTitle(node) {
return node?.title || node?.name || node?.id || node?.rootPath || '';
}
function workspaceName(node) {
return node?.name || node?.id || node?.rootPath || '';
}
async function resultOrEmpty(promise, fallback) {
try {
const response = await promise;
if (Array.isArray(response) && response.length === 2) return response[1] ? fallback : response[0];
return response || fallback;
} catch (_) {
return fallback;
}
}
async function listFilesRecursive(dir = '', depth = 0, acc = []) {
if (acc.length >= FILE_INDEX_LIMIT || depth > 5) return acc;
const entries = await resultOrEmpty(App.ListVaultFiles('verstak.search', dir), []);
for (const entry of entries || []) {
if (acc.length >= FILE_INDEX_LIMIT) break;
const path = entry.relativePath || entry.path || entry.name || '';
if (!path) continue;
acc.push(entry);
if (entry.type === 'folder') await listFilesRecursive(path, depth + 1, acc);
}
return acc;
}
async function readFileSnippet(path) {
const ext = String(path).split('.').pop().toLowerCase();
if (!TEXT_EXTENSIONS.has(ext)) return '';
const text = await resultOrEmpty(App.ReadVaultTextFile('verstak.search', path), '');
return String(text || '').slice(0, 900);
}
async function indexPluginSettings(pluginId, label, rank) {
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
const items = [];
Object.keys(settings || {}).forEach(key => {
const value = settings[key];
const rows = Array.isArray(value) ? value : [];
rows.forEach(row => {
if (!row || typeof row !== 'object') return;
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
items.push({
type: label,
title,
subtitle: row.url || row.summary || row.workspaceRootPath || key,
keywords: JSON.stringify(row),
rank,
});
});
});
return items;
}
async function buildIndex() {
loading = true;
const next = [];
const tree = await resultOrEmpty(App.GetWorkspaceTree(), { nodes: [] });
const nodes = Array.isArray(tree.nodes) ? tree.nodes : [];
nodes.forEach(node => {
next.push({
type: 'Workspace',
title: workspaceTitle(node),
subtitle: 'Рабочее пространство',
keywords: `${node.id || ''} ${node.rootPath || ''}`,
rank: 10,
action: 'workspace',
workspaceName: workspaceName(node),
nodes,
});
});
const contributions = await resultOrEmpty(App.GetContributions(), {});
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
title: item.title || item.id,
subtitle: item.pluginId || '',
keywords: `${item.id || ''} ${item.view || ''}`,
rank: 20,
action: 'view',
viewId: item.view || item.id,
pluginId: item.pluginId,
});
});
const files = await listFilesRecursive();
for (const entry of files) {
const path = entry.relativePath || entry.path || entry.name || '';
const snippet = await readFileSnippet(path);
next.push({
type: entry.type === 'folder' ? 'Folder' : 'File',
title: path.split('/').pop() || path,
subtitle: path,
keywords: snippet,
rank: entry.type === 'folder' ? 30 : 40,
action: entry.type === 'folder' ? 'file-folder' : 'file',
path,
});
}
const pluginItems = await Promise.all([
indexPluginSettings('verstak.journal', 'Journal', 50),
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55),
indexPluginSettings('verstak.activity', 'Activity', 60),
]);
index = next.concat(pluginItems.flat());
loading = false;
runSearch(query);
}
async function openResult(item) {
query = '';
results = [];
if (item.action === 'workspace') {
window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
detail: { workspaceName: item.workspaceName, nodes: item.nodes || [] }
}));
return;
}
if (item.action === 'view') {
window.dispatchEvent(new CustomEvent('verstak:open-view', {
detail: { viewId: item.viewId, pluginId: item.pluginId }
}));
return;
}
if (item.action === 'file') {
const response = await App.OpenWorkbenchResource('verstak.search', {
kind: 'vault-file',
path: item.path,
mode: 'view',
context: { sourceView: 'global-search' }
});
const [result, err] = Array.isArray(response) ? response : [response, ''];
if (!err && result) {
window.dispatchEvent(new CustomEvent('verstak:workbench-opened', { detail: result }));
}
}
}
</script>
<div class="global-search" class:open={focused && (query || results.length)}>
<div class="global-search-box">
<Icon name="search" size={14} class="global-search-icon" />
<input
bind:value={query}
on:focus={() => focused = true}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? 'Индексируем...' : 'Поиск'}
aria-label="Глобальный поиск"
data-global-search-input
/>
</div>
{#if focused && query}
<div class="global-search-results" data-global-search-results>
{#if results.length}
{#each results as item}
<button type="button" class="global-search-result" on:mousedown|preventDefault={() => openResult(item)}>
<span class="global-search-result-title">{item.title}</span>
<span class="global-search-result-meta">{item.type} · {item.subtitle}</span>
</button>
{/each}
{:else}
<div class="global-search-empty">Ничего не найдено</div>
{/if}
</div>
{/if}
</div>
<style>
.global-search {
position: relative;
padding: 0.55rem 0.75rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.global-search-box {
display: flex;
align-items: center;
gap: 0.35rem;
height: 2rem;
padding: 0 0.55rem;
border: 1px solid #263653;
border-radius: 5px;
background: #101626;
color: #8b8ba8;
}
:global(.global-search-icon) {
color: #8b8ba8;
flex-shrink: 0;
}
.global-search input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: #e0e0f0;
font: inherit;
font-size: 0.78rem;
}
.global-search input::placeholder {
color: #6f7894;
}
.global-search-box:focus-within {
border-color: #4ecca3;
}
.global-search-results {
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: calc(100% - 0.25rem);
z-index: 400;
max-height: 20rem;
overflow: auto;
border: 1px solid #28466f;
border-radius: 6px;
background: #101626;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
}
.global-search-result {
display: flex;
flex-direction: column;
gap: 0.12rem;
width: 100%;
padding: 0.55rem 0.65rem;
border: 0;
border-bottom: 1px solid rgba(40, 70, 111, 0.55);
background: transparent;
color: #d9e1ef;
text-align: left;
cursor: pointer;
}
.global-search-result:hover {
background: rgba(78, 204, 163, 0.1);
}
.global-search-result-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
}
.global-search-result-meta,
.global-search-empty {
color: #8b8ba8;
font-size: 0.7rem;
}
.global-search-empty {
padding: 0.7rem;
}
</style>

View File

@ -2,6 +2,7 @@
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import WorkspaceTree from './WorkspaceTree.svelte';
import GlobalSearch from './GlobalSearch.svelte';
import Icon from '../ui/Icon.svelte';
import { debug } from '../log/debug.js';
@ -76,6 +77,10 @@
<span class="sidebar-title">Verstak</span>
</div>
{#if vaultOpen}
<GlobalSearch />
{/if}
{#if sidebarItems.length > 0}
<div class="sidebar-section">
<span class="section-label">Tools</span>
@ -141,7 +146,7 @@
.sidebar-section {
display: flex;
flex-direction: column;
padding: 0.5rem 0.75rem;
padding: 0.45rem 0.6rem 0.55rem;
gap: 0.15rem;
border-bottom: 1px solid #0f3460;
}
@ -154,38 +159,40 @@
}
.section-label {
color: #666;
color: #a0a0b8;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.5rem;
padding: 0.25rem 0.45rem 0.35rem;
font-weight: 600;
}
.nav-item {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
padding: 0.45rem 0.75rem;
gap: 0.45rem;
min-height: 1.7rem;
padding: 0.15rem 0.45rem;
background: none;
border: none;
color: #a0a0b8;
font-size: 0.85rem;
font-size: 0.78rem;
cursor: pointer;
border-radius: 6px;
border-radius: 3px;
text-align: left;
width: 100%;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover {
background: #0f3460;
background: rgba(15,52,96,0.4);
color: #e0e0f0;
}
:global(.nav-icon) {
width: 1.2rem;
height: 1.2rem;
width: 0.9rem;
height: 0.9rem;
flex-shrink: 0;
color: currentColor;
}

View File

@ -1,7 +1,6 @@
<script>
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
import Icon from '../ui/Icon.svelte';
let items = [];
@ -66,10 +65,6 @@
settingsOpen = false;
}
function statusItemProps(item) {
return { statusBarItem: item };
}
onMount(() => {
loadStatusBar();
window.addEventListener('verstak:plugins-changed', loadStatusBar);
@ -96,34 +91,40 @@
{vaultLabel}
</span>
{#each leftItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id}
{/if}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-center">
{#each centerItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id}
{/if}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
</div>
<div class="status-bar-group status-right">
{#each rightItems as item}
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
{#if item.handler}
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
{:else}
{item.label || item.id}
{/if}
<span
class:status-bar-warning={item.handler}
class="status-bar-item"
data-status-item-id={item.id}
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
>
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
{item.label || item.id}
</span>
{/each}
<div class="settings-menu-wrap">
@ -207,6 +208,9 @@
}
.status-bar-item {
display: inline-flex;
align-items: center;
gap: 0.25rem;
max-width: 18rem;
overflow: hidden;
padding: 0.12rem 0.35rem;
@ -215,6 +219,15 @@
white-space: nowrap;
}
.status-bar-warning {
color: #ffc857;
}
:global(.status-warning-icon) {
flex-shrink: 0;
color: currentColor;
}
.vault-status {
display: inline-flex;
align-items: center;

View File

@ -39,20 +39,20 @@
async function createVault() {
error = '';
if (!newVaultPath.trim()) {
error = 'Please enter or select a path for the new vault';
error = 'Укажите или выберите папку для нового vault';
return;
}
creating = true;
try {
const createErr = await App.CreateVault(newVaultPath.trim());
if (createErr) {
error = 'Create vault: ' + createErr;
error = 'Не удалось создать vault: ' + createErr;
creating = false;
return;
}
const openErr = await App.OpenVault(newVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
error = 'Не удалось открыть vault: ' + openErr;
creating = false;
return;
}
@ -70,14 +70,14 @@
async function openExistingVault() {
error = '';
if (!openVaultPath.trim()) {
error = 'Please enter or select a path to an existing vault';
error = 'Укажите или выберите существующий vault';
return;
}
opening = true;
try {
const openErr = await App.OpenVault(openVaultPath.trim());
if (openErr) {
error = 'Open vault: ' + openErr;
error = 'Не удалось открыть vault: ' + openErr;
opening = false;
return;
}
@ -98,7 +98,7 @@
try {
const openErr = await App.OpenVault(path);
if (openErr) {
error = 'Open vault: ' + openErr;
error = 'Не удалось открыть vault: ' + openErr;
opening = false;
return;
}
@ -117,7 +117,7 @@
{#if loading}
<div class="vault-selection">
<div class="vault-selection-inner">
<p class="loading-text">Loading...</p>
<p class="loading-text">Загрузка...</p>
</div>
</div>
{:else}
@ -130,7 +130,7 @@
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
<h1>Verstak</h1>
<p class="subtitle">Choose a vault to get started</p>
<p class="subtitle">Выберите vault для начала работы</p>
</div>
{#if error}
@ -142,43 +142,43 @@
<div class="actions">
<div class="action-card">
<h3>Create New Vault</h3>
<p class="hint">Create a new vault folder. This will be your workspace.</p>
<h3>Создать новый vault</h3>
<p class="hint">Создайте локальную папку vault. В ней будут храниться рабочие пространства и проекты.</p>
<div class="input-row">
<input
type="text"
bind:value={newVaultPath}
placeholder="Select or type a path..."
placeholder="Выберите или введите путь..."
disabled={creating}
/>
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
Browse
Выбрать
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
{creating ? 'Creating...' : 'Create & Open'}
{creating ? 'Создаём...' : 'Создать vault'}
</button>
</div>
</div>
<div class="action-card">
<h3>Open Existing Vault</h3>
<p class="hint">Open a vault that already exists on this computer.</p>
<h3>Открыть существующий vault</h3>
<p class="hint">Используйте vault, который уже есть на этом компьютере.</p>
<div class="input-row">
<input
type="text"
bind:value={openVaultPath}
placeholder="Select or type a path..."
placeholder="Выберите или введите путь..."
disabled={opening}
/>
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
Browse
Выбрать
</button>
</div>
<div class="button-row">
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Opening...' : 'Open'}
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
{opening ? 'Открываем...' : 'Открыть существующий'}
</button>
</div>
</div>
@ -186,7 +186,7 @@
{#if recentVaults.length > 0}
<div class="recent-section">
<h3>Recent Vaults</h3>
<h3>Недавние vault</h3>
<ul class="recent-list">
{#each recentVaults as path}
<li>

View File

@ -60,11 +60,6 @@
<div class="view-header">
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
<h2>{currentView.title}</h2>
{#if hasFrontend}
<span class="frontend-badge">frontend bundle</span>
{:else}
<span class="no-frontend-badge">no frontend bundle</span>
{/if}
</div>
<div class="view-content">
{#if hasFrontend}
@ -134,26 +129,6 @@
color: #a78bfa;
flex-shrink: 0;
}
.frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(78, 204, 163, 0.15);
color: #4ecca3;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.no-frontend-badge {
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(233, 69, 96, 0.1);
color: #e94560;
border-radius: 8px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.view-content {
flex: 1;
min-width: 0;
@ -235,6 +210,7 @@
height: 100%;
color: #555;
font-size: 1rem;
text-align: center;
}
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
</style>

View File

@ -76,9 +76,6 @@
<span class="workspace-title">{workspaceTitle}</span>
<span class="workspace-type">{workspaceType}</span>
</div>
<div class="workspace-search" data-workspace-search>
<input type="search" placeholder="Search workspace" aria-label="Search workspace" />
</div>
</div>
{#if workspaceTools.length > 0}
@ -107,13 +104,14 @@
</div>
{:else}
<div class="workspace-empty">
<p>No workspace tools available</p>
<p class="workspace-hint">Install plugins that contribute workspaceItems to see tools here.</p>
<p>Для этого рабочего пространства пока нет инструментов</p>
<p class="workspace-hint">Включите плагины с workspace-инструментами или откройте Plugin Manager через меню настроек.</p>
</div>
{/if}
{:else}
<div class="workspace-empty">
<p>Select a workspace node from the sidebar</p>
<p>Создайте рабочее пространство или выберите существующее в боковой панели</p>
<p class="workspace-hint">Нажмите «+» в разделе Workspaces, чтобы добавить первый проект.</p>
</div>
{/if}
</div>
@ -131,7 +129,7 @@
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid #16213e;
@ -159,38 +157,11 @@
background: #1a2a3a;
}
.workspace-search {
flex: 0 1 22rem;
min-width: 12rem;
}
.workspace-search input {
width: 100%;
height: 2rem;
padding: 0.25rem 0.55rem;
border: 1px solid #283653;
border-radius: 4px;
background: #101626;
color: #e0e0f0;
font: inherit;
font-size: 0.82rem;
outline: none;
}
.workspace-search input:focus {
border-color: #4ecca3;
}
@media (max-width: 720px) {
.workspace-header {
align-items: stretch;
flex-direction: column;
}
.workspace-search {
flex-basis: auto;
min-width: 0;
}
}
.workspace-tabs {
@ -241,6 +212,8 @@
justify-content: center;
color: #666;
gap: 0.5rem;
padding: 2rem;
text-align: center;
}
.workspace-hint {

View File

@ -5,7 +5,7 @@
</script>
<script>
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import * as App from '../../../wailsjs/go/api/App';
import Icon from '../ui/Icon.svelte';
@ -20,7 +20,19 @@
let renameValue = '';
let busyId = '';
onMount(loadWorkspaces);
onMount(() => {
loadWorkspaces();
window.addEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
onDestroy(() => {
window.removeEventListener('verstak:workspace-active-changed', onActiveWorkspaceChanged);
});
function onActiveWorkspaceChanged(event) {
currentWorkspaceId = event.detail?.workspaceName || '';
activeWorkspaceId.set(currentWorkspaceId);
}
function resultOrError(response, fallbackValue) {
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
@ -57,8 +69,19 @@
workspaces = [];
} else {
workspaces = list || [];
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = wsName(workspaces[0] || {});
if (!currentWorkspaceId) {
let currentWorkspace = null;
try {
currentWorkspace = await App.GetCurrentWorkspace();
} catch {
currentWorkspace = null;
}
const currentName = wsName(currentWorkspace);
if (workspaces.some((ws) => wsName(ws) === currentName)) {
currentWorkspaceId = currentName;
}
} else if (!workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
currentWorkspaceId = '';
}
activeWorkspaceId.set(currentWorkspaceId);
}
@ -215,9 +238,9 @@
<style>
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.4rem 0.6rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-header { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0.6rem 0.35rem; border-bottom: 1px solid #0f3460; flex-shrink: 0; }
.wt-title { color: #a0a0b8; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0; }
.wt-list { min-height: 0; overflow-y: auto; padding: 0.2rem 0.6rem; }
.wt-btn { min-height: 0; background: none; border: none; color: #666; cursor: pointer; font-size: 0.85rem; padding: 0.1rem 0.3rem; border-radius: 3px; }
.wt-btn:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
@ -225,12 +248,12 @@
.wt-row:hover .wt-btn-small { opacity: 1; }
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
.wt-error { color: #e94560; }
.wt-row { display: flex; align-items: center; gap: 0.25rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; }
.wt-row { display: flex; align-items: center; gap: 0.45rem; padding: 0.15rem 0.45rem; min-height: 1.7rem; border-radius: 3px; }
.wt-row:hover { background: rgba(15,52,96,0.4); }
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
.wt-icon { width: 0.9rem; height: 0.9rem; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #a0a0b8; }
:global(.wt-node-icon) { display: block; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #e0e0f0; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0.2rem; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label { flex: 1; min-width: 0; min-height: 0; justify-content: flex-start; background: none; border: none; color: #a0a0b8; font-size: 0.78rem; text-align: left; cursor: pointer; padding: 0.1rem 0; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wt-label:hover { color: #4ecca3; }
.wt-icon-btn { width: 1.25rem; height: 1.25rem; min-height: 0; padding: 0; border: none; background: transparent; color: #666; opacity: 0; flex-shrink: 0; cursor: pointer; border-radius: 3px; }
.wt-row:hover .wt-icon-btn { opacity: 1; }

View File

@ -205,11 +205,33 @@
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
error: ''
},
'verstak.search': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.search',
name: 'Search',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.',
source: 'official',
icon: 'search',
provides: ['verstak/search/v1', 'search.provider'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['files.read', 'workbench.open', 'storage.namespace', 'ui.register'],
contributes: {
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
}
},
rootPath: '/tmp/verstak-test/plugins/search',
error: ''
}
};
var vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }] };
var vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
var appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
var workbenchPreferences = {};
var openedResources = [];
@ -671,12 +693,18 @@
function parent(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? '' : path.slice(0, i); }
function ext(name) { var i = String(name || '').lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase() : ''; }
function base(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? path : path.slice(i + 1); }
function formatDate(value) {
if (!value) return '';
var date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }).format(date);
}
var FilesView = {
mount: function (c, p, api) {
if (!document.getElementById('mock-files-styles')) {
var style = document.createElement('style');
style.id = 'mock-files-styles';
style.textContent = '.files-root{display:flex;flex-direction:column;height:100%;min-height:0;background:#0d0d1a;color:#e0e0e0;outline:0}.files-toolbar{display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;background:#12122a;border-bottom:1px solid #16213e;flex-wrap:wrap}.files-toolbar-btn,.files-row-btn{display:inline-flex;align-items:center;justify-content:center;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer}.files-toolbar-btn{width:2rem;height:2rem}.files-row-btn{width:1.75rem;height:1.75rem}.files-toolbar-btn svg,.files-row-btn svg{width:16px;height:16px}.files-breadcrumb{flex:1;min-width:150px;color:#8b8ba8}.files-breadcrumb-item{color:#4ecca3;cursor:pointer}.files-breadcrumb-current{color:#ddd}.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid #333;border-radius:4px;background:#0d0d1a;color:#e0e0e0}.files-sort{appearance:none;background-color:#0d0d1a;padding-right:1rem}.files-list{flex:1;overflow:auto}.files-header,.files-item{display:grid;grid-template-columns:minmax(160px,1fr) 90px 90px 150px 160px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}.files-header{background:#101028;color:#8b8ba8;font-size:.7rem;text-transform:uppercase}.files-item:hover{background:#17172d}.files-item.selected{background:#1a2a3a}.files-namecell{display:flex;align-items:center;gap:.5rem;min-width:0}.files-item-icon{width:1.25rem;color:#8b8ba8}.files-item-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-item-meta{font-size:.74rem;color:#777;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-row-actions{display:flex;justify-content:flex-end;gap:.35rem}.files-panel{display:flex;gap:.5rem;padding:.5rem .75rem;border-top:1px solid #16213e;background:#12122a}.files-create-input,.files-rename-input{flex:1}.files-ctx-menu{position:fixed;z-index:9999;min-width:170px;background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:6px 0;box-shadow:0 8px 24px rgba(0,0,0,.5);font-size:.84rem;color:#e0e0e0}.files-ctx-menu-item{display:flex;align-items:center;gap:.5rem;padding:6px 16px;cursor:pointer}.files-ctx-menu-item:hover{background:#2a2a4e}.files-ctx-menu-item svg{width:14px;height:14px}.files-ctx-menu-sep{height:1px;background:#333;margin:4px 8px}.files-drag-over{outline:2px dashed #4ecca3;outline-offset:-2px}';
style.textContent = '.files-root{display:flex;flex-direction:column;height:100%;min-height:0;background:#0d0d1a;color:#e0e0e0;outline:0}.files-toolbar{display:flex;align-items:center;gap:.4rem;padding:.5rem .75rem;background:#12122a;border-bottom:1px solid #16213e;flex-wrap:wrap}.files-toolbar-btn,.files-row-btn{display:inline-flex;align-items:center;justify-content:center;gap:.3rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ccc;cursor:pointer;white-space:nowrap}.files-toolbar-btn{min-height:2rem;padding:.35rem .55rem}.files-row-btn{min-height:1.75rem;padding:.25rem .45rem;font-size:.72rem}.files-toolbar-btn svg,.files-row-btn svg{width:15px;height:15px;flex-shrink:0}.files-btn-label{line-height:1}.files-breadcrumb{flex:1;min-width:150px;color:#8b8ba8}.files-breadcrumb-item{color:#4ecca3;cursor:pointer}.files-breadcrumb-current{color:#ddd}.files-filter,.files-sort,.files-create-input,.files-rename-input{font-size:.78rem;padding:.32rem .5rem;border:1px solid #333;border-radius:4px;background:#0d0d1a;color:#e0e0e0}.files-sort{appearance:none;background-color:#0d0d1a;padding-right:1rem}.files-list{flex:1;overflow:auto}.files-header,.files-item{display:grid;grid-template-columns:minmax(180px,1fr) 90px 80px 150px 210px;align-items:center;gap:.5rem;padding:.38rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}.files-header{background:#101028;color:#8b8ba8;font-size:.7rem;text-transform:uppercase}.files-item:hover{background:#17172d}.files-item.selected{background:#1a2a3a}.files-namecell{display:flex;align-items:center;gap:.5rem;min-width:0}.files-item-icon{width:1.25rem;color:#8b8ba8}.files-item-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-item-meta{font-size:.74rem;color:#777;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.files-row-actions{display:flex;justify-content:flex-end;gap:.35rem}.files-panel{display:flex;gap:.5rem;padding:.5rem .75rem;border-top:1px solid #16213e;background:#12122a}.files-create-input,.files-rename-input{flex:1}.files-ctx-menu{position:fixed;z-index:9999;min-width:170px;background:#1a1a2e;border:1px solid #333;border-radius:6px;padding:6px 0;box-shadow:0 8px 24px rgba(0,0,0,.5);font-size:.84rem;color:#e0e0e0}.files-ctx-menu-item{display:flex;align-items:center;gap:.5rem;padding:6px 16px;cursor:pointer}.files-ctx-menu-item:hover{background:#2a2a4e}.files-ctx-menu-item svg{width:14px;height:14px}.files-ctx-menu-sep{height:1px;background:#333;margin:4px 8px}.files-drag-over{outline:2px dashed #4ecca3;outline-offset:-2px}';
document.head.appendChild(style);
}
c.innerHTML = '';
@ -704,8 +732,8 @@
function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; }
var toolbar = e('div', { className: 'files-toolbar' }, []);
var breadcrumb = e('div', { className: 'files-breadcrumb' }, []);
function btn(title, action, fn) { return e('button', { className: 'files-toolbar-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG, onClick: fn }, []); }
function rowBtn(title, action, fn) { return e('button', { className: 'files-row-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG, onClick: fn }, []); }
function btn(title, action, fn) { return e('button', { className: 'files-toolbar-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG + '<span class="files-btn-label">' + title + '</span>', onClick: fn }, []); }
function rowBtn(title, action, fn) { return e('button', { className: 'files-row-btn', 'data-files-action': action, title: title, 'aria-label': title, innerHTML: SVG + '<span class="files-btn-label">' + title + '</span>', onClick: fn }, []); }
toolbar.appendChild(breadcrumb);
toolbar.appendChild(btn('Back', 'back', goBack));
toolbar.appendChild(btn('Forward', 'forward', goForward));
@ -787,7 +815,7 @@
row.appendChild(e('span', { className: 'files-namecell' }, [e('span', { className: 'files-item-icon', innerHTML: item.type === 'folder' ? FOLDER_SVG : SVG }, []), e('span', { className: 'files-item-name' }, [item.name])]));
row.appendChild(e('span', { className: 'files-item-meta' }, [item.type === 'folder' ? 'folder' : (item.extension || ext(item.name) || 'file')]));
row.appendChild(e('span', { className: 'files-item-meta' }, [item.size ? String(item.size) : '']));
row.appendChild(e('span', { className: 'files-item-meta' }, [item.modifiedAt || '']));
row.appendChild(e('span', { className: 'files-item-meta' }, [formatDate(item.modifiedAt)]));
row.appendChild(e('span', { className: 'files-row-actions' }, [rowBtn('Open', 'row-open', function (ev) { ev.stopPropagation(); open(item); }), rowBtn('Rename', 'row-rename', function (ev) { ev.stopPropagation(); startRename(item); }), rowBtn('Move to trash', 'row-trash', function (ev) { ev.stopPropagation(); trash(item); })]));
list.appendChild(row);
});
@ -1780,10 +1808,32 @@
},
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
error: ''
},
'verstak.search': {
status: 'loaded',
enabled: true,
manifest: {
schemaVersion: 1,
id: 'verstak.search',
name: 'Search',
version: '0.1.0',
apiVersion: '0.1.0',
description: 'Workspace-scoped vault text search provider.',
source: 'official',
icon: 'search',
provides: ['verstak/search/v1', 'search.provider'],
requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
permissions: ['files.read', 'workbench.open', 'storage.namespace', 'ui.register'],
contributes: {
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
}
},
rootPath: '/tmp/verstak-test/plugins/search',
error: ''
}
};
vaultStatus = { status: 'open', path: '/tmp/verstak-test/vault', vaultId: 'test-vault-001' };
vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }] };
vaultPluginState = { enabledPlugins: ['verstak.platform-test', 'verstak.default-editor', 'verstak.files', 'verstak.sync', 'verstak.activity', 'verstak.browser-inbox', 'verstak.search'], disabledPlugins: [], desiredPlugins: [{ id: 'verstak.platform-test', version: '0.1.0', source: 'official' }, { id: 'verstak.default-editor', version: '0.1.0', source: 'official' }, { id: 'verstak.files', version: '0.1.0', source: 'official' }, { id: 'verstak.sync', version: '0.1.0', source: 'official' }, { id: 'verstak.activity', version: '0.1.0', source: 'official' }, { id: 'verstak.browser-inbox', version: '0.1.0', source: 'official' }, { id: 'verstak.search', version: '0.1.0', source: 'official' }] };
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
workbenchPreferences = {};
openedResources = [];

View File

@ -1315,3 +1315,4 @@ export namespace workspace {
}
}

View File

@ -1444,9 +1444,30 @@ func decodeSecretRecord(raw map[string]interface{}) (coresecrets.SecretRecord, e
if err := json.Unmarshal(data, &record); err != nil {
return coresecrets.SecretRecord{}, err
}
if strings.TrimSpace(record.ID) == "" {
record.ID = generatedSecretID(record.Title)
}
return record, nil
}
func generatedSecretID(title string) string {
base := strings.ToLower(strings.TrimSpace(title))
base = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-' {
return r
}
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
return '-'
}
return -1
}, base)
base = strings.Trim(base, ".-_")
if base == "" {
base = "secret"
}
return fmt.Sprintf("%s-%d", base, time.Now().UnixNano())
}
func secretRecordMap(record coresecrets.SecretRecord, includeValue bool) map[string]interface{} {
result := map[string]interface{}{
"id": record.ID,