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:
parent
4bb9e84c35
commit
46f754cc2d
|
|
@ -43,6 +43,6 @@ test.describe('Command Palette', () => {
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
await expect(page.locator('.command-palette')).not.toBeVisible();
|
await expect(page.locator('.command-palette')).not.toBeVisible();
|
||||||
await expect(page.locator('.plugin-manager')).toBeVisible();
|
await expect(page.locator('.workspace-host')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('F: Default Editor Plugin', () => {
|
||||||
let consoleCollector;
|
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 }) => {
|
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' });
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
await expect(card).toBeVisible({ timeout: 10000 });
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
await expect(card.locator('.status-badge')).toHaveText('loaded');
|
await expect(card.locator('.status-badge')).toHaveText('loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disable default-editor plugin removes its providers', async ({ page }) => {
|
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' });
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
await card.locator('button.btn-disable').click();
|
await card.locator('button.btn-disable').click();
|
||||||
await expect(card.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
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 }) => {
|
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' });
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.default-editor' });
|
||||||
await expect(card).toBeVisible({ timeout: 10000 });
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
|
await expect(card.locator('.meta-row').filter({ hasText: 'Contributions:' })).toContainText('3 openProviders');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('G: Files Plugin', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
@ -16,7 +16,7 @@ test.describe('G: Files Plugin', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('files plugin appears in Plugin Manager as loaded', async ({ page }) => {
|
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' });
|
const card = page.locator('.plugin-card').filter({ hasText: 'verstak.files' });
|
||||||
await expect(card).toBeVisible({ timeout: 10000 });
|
await expect(card).toBeVisible({ timeout: 10000 });
|
||||||
await expect(card.locator('.status-badge')).toHaveText('loaded');
|
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');
|
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 page.locator('.wt-label').filter({ hasText: 'Project' }).click();
|
||||||
await expect(page.locator('.files-breadcrumb')).toContainText('Project', { timeout: 10000 });
|
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}"]`);
|
const button = page.locator(`[data-files-action="${action}"]`);
|
||||||
await expect(button).toHaveAttribute('title', /.+/);
|
await expect(button).toHaveAttribute('title', /.+/);
|
||||||
await expect(button.locator('svg')).toBeVisible();
|
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);
|
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();
|
const firstRowButton = page.locator('[data-file-name="Notes"] .files-row-btn').first();
|
||||||
await expect(firstRowButton).toBeVisible();
|
await expect(firstRowButton).toBeVisible();
|
||||||
await expect(firstRowButton).not.toHaveText(/\S/);
|
await expect(firstRowButton).toHaveText(/\S/);
|
||||||
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
|
expect(await firstRowButton.evaluate((node) => node.innerHTML)).toContain('<svg');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,13 @@ export async function waitForAppReady(page) {
|
||||||
await page.waitForTimeout(1000);
|
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 */
|
/** Collect all console errors since last reset */
|
||||||
export function setupConsoleCollector(page) {
|
export function setupConsoleCollector(page) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('D: Plugin API bridge', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
@ -26,7 +26,7 @@ test.describe('D: Plugin API bridge', () => {
|
||||||
await page.locator('.pt-save-setting').click();
|
await page.locator('.pt-save-setting').click();
|
||||||
await expect(saved).toHaveText('Saved setting: persisted through bridge', { timeout: 10000 });
|
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(() => 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 expect.poll(() => page.evaluate(() => (window.__VERSTAK_EVENT_HANDLERS__?.['verstak.platform-test.echo'] || []).length)).toBe(0);
|
||||||
await page.locator('button.reload-btn').click();
|
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(() => 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 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(() => 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 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 page.locator('.sidebar .plugin-item').filter({ hasText: 'Platform Test' }).click();
|
||||||
await expect(page.locator('.pt-command-result')).toContainText('Command: handled', { timeout: 10000 });
|
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' });
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
await pluginCard.locator('button.btn-disable').click();
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
await expect(pluginCard.locator('button.btn-enable')).toBeVisible({ timeout: 10000 });
|
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 }) => {
|
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' });
|
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
||||||
await pluginCard.locator('button.btn-settings').click();
|
await pluginCard.locator('button.btn-settings').click();
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
* 8. Verify plugin sidebar item returns
|
* 8. Verify plugin sidebar item returns
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('A: Plugin Manager Disable/Enable refresh', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
@ -22,6 +22,7 @@ test.describe('A: Plugin Manager Disable/Enable refresh', () => {
|
||||||
await resetMockState(page);
|
await resetMockState(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
|
await openPluginManager(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('E: Plugin Manager layout', () => {
|
||||||
let consoleCollector;
|
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 }) => {
|
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();
|
const basePluginCount = await page.locator('.plugin-card').count();
|
||||||
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
|
await page.evaluate(() => window.__wailsMock.addSyntheticPlugins(18));
|
||||||
await page.locator('button.reload-btn').click();
|
await page.locator('button.reload-btn').click();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
* 3. Verify UI reflects the updated state
|
* 3. Verify UI reflects the updated state
|
||||||
*/
|
*/
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('C: Reload updates UI state', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
@ -17,6 +17,7 @@ test.describe('C: Reload updates UI state', () => {
|
||||||
await resetMockState(page);
|
await resetMockState(page);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await waitForAppReady(page);
|
await waitForAppReady(page);
|
||||||
|
await openPluginManager(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from '@playwright/test';
|
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', () => {
|
test.describe('Status Bar host', () => {
|
||||||
let consoleCollector;
|
let consoleCollector;
|
||||||
|
|
@ -39,6 +39,7 @@ test.describe('Status Bar host', () => {
|
||||||
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
|
test('refreshes statusBarItems after disabling plugin', async ({ page }) => {
|
||||||
const pluginCard = page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' });
|
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 expect(page.locator('[data-status-item-id="verstak.platform-test.status"]')).toBeVisible();
|
||||||
|
await openPluginManager(page);
|
||||||
|
|
||||||
await pluginCard.locator('button.btn-disable').click();
|
await pluginCard.locator('button.btn-disable').click();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
|
|
||||||
let currentView = 'plugin-manager';
|
let currentView = 'workspace';
|
||||||
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
let vaultStatus = { status: 'unknown', path: '', vaultId: '' };
|
||||||
let needsVaultSelection = false;
|
let needsVaultSelection = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
@ -35,6 +35,75 @@
|
||||||
App.WriteFrontendLog('App', msg);
|
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() {
|
function currentSnapshot() {
|
||||||
return {
|
return {
|
||||||
currentView,
|
currentView,
|
||||||
|
|
@ -70,6 +139,7 @@
|
||||||
activeSettingsPanelId = snapshot.activeSettingsPanelId;
|
activeSettingsPanelId = snapshot.activeSettingsPanelId;
|
||||||
openedResource = snapshot.openedResource;
|
openedResource = snapshot.openedResource;
|
||||||
selectedWorkspaceName = snapshot.selectedWorkspaceName;
|
selectedWorkspaceName = snapshot.selectedWorkspaceName;
|
||||||
|
emitWorkspaceActive(currentView === 'workspace' ? selectedWorkspaceName : '');
|
||||||
applyingNavigation = false;
|
applyingNavigation = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -153,6 +223,7 @@
|
||||||
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
|
debug.log('[App] checkVault: vault open, needsVaultSelection=false');
|
||||||
flog('checkVault: needsVaultSelection=false');
|
flog('checkVault: needsVaultSelection=false');
|
||||||
needsVaultSelection = false;
|
needsVaultSelection = false;
|
||||||
|
await openDefaultWorkspaceRoute();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debug.log('[App] checkVault: ERROR', String(e));
|
debug.log('[App] checkVault: ERROR', String(e));
|
||||||
|
|
@ -166,15 +237,18 @@
|
||||||
flog('checkVault: END, loading=false');
|
flog('checkVault: END, loading=false');
|
||||||
}
|
}
|
||||||
|
|
||||||
function onVaultOpened() {
|
async function onVaultOpened() {
|
||||||
debug.log('[App] onVaultOpened');
|
debug.log('[App] onVaultOpened');
|
||||||
needsVaultSelection = false;
|
needsVaultSelection = false;
|
||||||
vaultStatus = { status: 'open', path: '', vaultId: '' };
|
vaultStatus = { status: 'open', path: '', vaultId: '' };
|
||||||
|
await openDefaultWorkspaceRoute();
|
||||||
|
pushNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onNav(e) {
|
function onNav(e) {
|
||||||
debug.log('[App] onNav:', e.detail.viewId);
|
debug.log('[App] onNav:', e.detail.viewId);
|
||||||
currentView = e.detail.viewId;
|
currentView = e.detail.viewId;
|
||||||
|
if (currentView !== 'workspace') clearWorkspaceSelection();
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +257,7 @@
|
||||||
activeView = e.detail.viewId;
|
activeView = e.detail.viewId;
|
||||||
activeViewPluginId = e.detail.pluginId || '';
|
activeViewPluginId = e.detail.pluginId || '';
|
||||||
currentView = 'plugin-view';
|
currentView = 'plugin-view';
|
||||||
|
clearWorkspaceSelection();
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +266,7 @@
|
||||||
activeSettingsPluginId = e.detail.pluginId;
|
activeSettingsPluginId = e.detail.pluginId;
|
||||||
activeSettingsPanelId = e.detail.panelId || '';
|
activeSettingsPanelId = e.detail.panelId || '';
|
||||||
currentView = 'plugin-manager';
|
currentView = 'plugin-manager';
|
||||||
|
clearWorkspaceSelection();
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,7 +282,13 @@
|
||||||
selectedWorkspaceName = e.detail?.workspaceName || '';
|
selectedWorkspaceName = e.detail?.workspaceName || '';
|
||||||
workspaceNodes = e.detail?.nodes || workspaceNodes;
|
workspaceNodes = e.detail?.nodes || workspaceNodes;
|
||||||
if (selectedWorkspaceName) {
|
if (selectedWorkspaceName) {
|
||||||
|
activeView = null;
|
||||||
|
activeViewPluginId = '';
|
||||||
|
activeSettingsPluginId = '';
|
||||||
|
activeSettingsPanelId = '';
|
||||||
|
openedResource = null;
|
||||||
currentView = 'workspace';
|
currentView = 'workspace';
|
||||||
|
emitWorkspaceActive(selectedWorkspaceName);
|
||||||
pushNavigation();
|
pushNavigation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +384,7 @@
|
||||||
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
<PluginManager {activeSettingsPluginId} {activeSettingsPanelId} />
|
||||||
{:else if currentView === 'workbench'}
|
{:else if currentView === 'workbench'}
|
||||||
<WorkbenchHost {openedResource} />
|
<WorkbenchHost {openedResource} />
|
||||||
{:else if currentView === 'workspace'}
|
{:else if currentView === 'workspace' || currentView === 'workspace-empty'}
|
||||||
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
|
<WorkspaceHost selectedWorkspaceName={selectedWorkspaceName} nodes={workspaceNodes} />
|
||||||
{:else}
|
{:else}
|
||||||
<ViewContainer {activeView} {activeViewPluginId} />
|
<ViewContainer {activeView} {activeViewPluginId} />
|
||||||
|
|
|
||||||
|
|
@ -97,35 +97,39 @@
|
||||||
<span class="label">Name:</span>
|
<span class="label">Name:</span>
|
||||||
<span>{m.name || '-'}</span>
|
<span>{m.name || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
|
||||||
<span class="label">API Version:</span>
|
|
||||||
<span>{m.apiVersion || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Source:</span>
|
<span class="label">Source:</span>
|
||||||
<span>{m.source || 'unknown'}</span>
|
<span>{m.source || 'unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
|
||||||
<span class="label">Root:</span>
|
|
||||||
<span class="path">{p.rootPath || '-'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="label">Contributions:</span>
|
<span class="label">Contributions:</span>
|
||||||
<span>{contribSummary}</span>
|
<span>{contribSummary}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Capabilities -->
|
<details class="plugin-details">
|
||||||
<div class="section">
|
<summary>Technical details</summary>
|
||||||
<span class="section-title">Provides</span>
|
<div class="card-meta technical-meta">
|
||||||
<div class="tags">
|
<div class="meta-row">
|
||||||
{#each m.provides || [] as cap}
|
<span class="label">API Version:</span>
|
||||||
<span class="tag provides">{cap}</span>
|
<span>{m.apiVersion || '-'}</span>
|
||||||
{/each}
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="label">Root:</span>
|
||||||
|
<span class="path">{p.rootPath || '-'}</span>
|
||||||
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<span class="section-title">Requires</span>
|
<span class="section-title">Requires</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
|
|
@ -141,9 +145,9 @@
|
||||||
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
|
<p class="warning"><Icon name="warning" size={12} /> Missing required capabilities: {missingRequired.join(', ')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
{#if m.optionalRequires && m.optionalRequires.length > 0}
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Optional Requires</span>
|
<span class="section-title">Optional Requires</span>
|
||||||
<div class="tags">
|
<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>
|
<p class="info"><Icon name="warning" size={12} /> Optional capabilities not available — plugin running in degraded mode</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Permissions -->
|
{#if m.permissions && m.permissions.length > 0}
|
||||||
{#if m.permissions && m.permissions.length > 0}
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<span class="section-title">Permissions</span>
|
<span class="section-title">Permissions</span>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
|
|
@ -175,7 +178,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
{#if p.error}
|
{#if p.error}
|
||||||
|
|
@ -289,6 +293,24 @@
|
||||||
font-size: 0.8rem;
|
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 {
|
.meta-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
|
||||||
|
|
@ -514,7 +514,7 @@
|
||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
|
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 {
|
.modal-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
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-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 { background: none; border: none; color: #a0a0b8; font-size: 1.2rem; cursor: pointer; padding: 0.2rem 0.5rem; }
|
||||||
.modal-close:hover { color: #e94560; }
|
.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 { color: #666; font-size: 0.8rem; margin: 0.25rem 0; }
|
||||||
.settings-hint code { color: #4ecca3; }
|
.settings-hint code { color: #4ecca3; }
|
||||||
|
|
||||||
|
|
@ -542,7 +543,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
width: min(480px, calc(100vw - 2rem));
|
width: min(880px, calc(100vw - 2rem));
|
||||||
|
height: min(680px, calc(100vh - 2rem));
|
||||||
max-height: calc(100vh - 2rem);
|
max-height: calc(100vh - 2rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import WorkspaceTree from './WorkspaceTree.svelte';
|
import WorkspaceTree from './WorkspaceTree.svelte';
|
||||||
|
import GlobalSearch from './GlobalSearch.svelte';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
import { debug } from '../log/debug.js';
|
import { debug } from '../log/debug.js';
|
||||||
|
|
||||||
|
|
@ -76,6 +77,10 @@
|
||||||
<span class="sidebar-title">Verstak</span>
|
<span class="sidebar-title">Verstak</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if vaultOpen}
|
||||||
|
<GlobalSearch />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if sidebarItems.length > 0}
|
{#if sidebarItems.length > 0}
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<span class="section-label">Tools</span>
|
<span class="section-label">Tools</span>
|
||||||
|
|
@ -141,7 +146,7 @@
|
||||||
.sidebar-section {
|
.sidebar-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.45rem 0.6rem 0.55rem;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
}
|
}
|
||||||
|
|
@ -154,38 +159,40 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
color: #666;
|
color: #a0a0b8;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.45rem 0.35rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.45rem;
|
||||||
padding: 0.45rem 0.75rem;
|
min-height: 1.7rem;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #a0a0b8;
|
color: #a0a0b8;
|
||||||
font-size: 0.85rem;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 3px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: #0f3460;
|
background: rgba(15,52,96,0.4);
|
||||||
color: #e0e0f0;
|
color: #e0e0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.nav-icon) {
|
:global(.nav-icon) {
|
||||||
width: 1.2rem;
|
width: 0.9rem;
|
||||||
height: 1.2rem;
|
height: 0.9rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
<script>
|
<script>
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import PluginBundleHost from '../plugin-host/PluginBundleHost.svelte';
|
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
|
||||||
let items = [];
|
let items = [];
|
||||||
|
|
@ -66,10 +65,6 @@
|
||||||
settingsOpen = false;
|
settingsOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusItemProps(item) {
|
|
||||||
return { statusBarItem: item };
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadStatusBar();
|
loadStatusBar();
|
||||||
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
window.addEventListener('verstak:plugins-changed', loadStatusBar);
|
||||||
|
|
@ -96,34 +91,40 @@
|
||||||
{vaultLabel}
|
{vaultLabel}
|
||||||
</span>
|
</span>
|
||||||
{#each leftItems as item}
|
{#each leftItems as item}
|
||||||
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
<span
|
||||||
{#if item.handler}
|
class:status-bar-warning={item.handler}
|
||||||
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
|
class="status-bar-item"
|
||||||
{:else}
|
data-status-item-id={item.id}
|
||||||
{item.label || item.id}
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
{/if}
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-bar-group status-center">
|
<div class="status-bar-group status-center">
|
||||||
{#each centerItems as item}
|
{#each centerItems as item}
|
||||||
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
<span
|
||||||
{#if item.handler}
|
class:status-bar-warning={item.handler}
|
||||||
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
|
class="status-bar-item"
|
||||||
{:else}
|
data-status-item-id={item.id}
|
||||||
{item.label || item.id}
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
{/if}
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="status-bar-group status-right">
|
<div class="status-bar-group status-right">
|
||||||
{#each rightItems as item}
|
{#each rightItems as item}
|
||||||
<span class="status-bar-item" data-status-item-id={item.id} title={item.pluginId}>
|
<span
|
||||||
{#if item.handler}
|
class:status-bar-warning={item.handler}
|
||||||
<PluginBundleHost pluginId={item.pluginId} componentId={item.handler} componentProps={statusItemProps(item)} />
|
class="status-bar-item"
|
||||||
{:else}
|
data-status-item-id={item.id}
|
||||||
{item.label || item.id}
|
title={item.handler ? `${item.pluginId}: compact status only` : item.pluginId}
|
||||||
{/if}
|
>
|
||||||
|
{#if item.handler}<Icon name="warning" size={11} class="status-warning-icon" />{/if}
|
||||||
|
{item.label || item.id}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="settings-menu-wrap">
|
<div class="settings-menu-wrap">
|
||||||
|
|
@ -207,6 +208,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar-item {
|
.status-bar-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
max-width: 18rem;
|
max-width: 18rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0.12rem 0.35rem;
|
padding: 0.12rem 0.35rem;
|
||||||
|
|
@ -215,6 +219,15 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-bar-warning {
|
||||||
|
color: #ffc857;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.status-warning-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.vault-status {
|
.vault-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -39,20 +39,20 @@
|
||||||
async function createVault() {
|
async function createVault() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!newVaultPath.trim()) {
|
if (!newVaultPath.trim()) {
|
||||||
error = 'Please enter or select a path for the new vault';
|
error = 'Укажите или выберите папку для нового vault';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
creating = true;
|
creating = true;
|
||||||
try {
|
try {
|
||||||
const createErr = await App.CreateVault(newVaultPath.trim());
|
const createErr = await App.CreateVault(newVaultPath.trim());
|
||||||
if (createErr) {
|
if (createErr) {
|
||||||
error = 'Create vault: ' + createErr;
|
error = 'Не удалось создать vault: ' + createErr;
|
||||||
creating = false;
|
creating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const openErr = await App.OpenVault(newVaultPath.trim());
|
const openErr = await App.OpenVault(newVaultPath.trim());
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Open vault: ' + openErr;
|
error = 'Не удалось открыть vault: ' + openErr;
|
||||||
creating = false;
|
creating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -70,14 +70,14 @@
|
||||||
async function openExistingVault() {
|
async function openExistingVault() {
|
||||||
error = '';
|
error = '';
|
||||||
if (!openVaultPath.trim()) {
|
if (!openVaultPath.trim()) {
|
||||||
error = 'Please enter or select a path to an existing vault';
|
error = 'Укажите или выберите существующий vault';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
opening = true;
|
opening = true;
|
||||||
try {
|
try {
|
||||||
const openErr = await App.OpenVault(openVaultPath.trim());
|
const openErr = await App.OpenVault(openVaultPath.trim());
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Open vault: ' + openErr;
|
error = 'Не удалось открыть vault: ' + openErr;
|
||||||
opening = false;
|
opening = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +98,7 @@
|
||||||
try {
|
try {
|
||||||
const openErr = await App.OpenVault(path);
|
const openErr = await App.OpenVault(path);
|
||||||
if (openErr) {
|
if (openErr) {
|
||||||
error = 'Open vault: ' + openErr;
|
error = 'Не удалось открыть vault: ' + openErr;
|
||||||
opening = false;
|
opening = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="vault-selection">
|
<div class="vault-selection">
|
||||||
<div class="vault-selection-inner">
|
<div class="vault-selection-inner">
|
||||||
<p class="loading-text">Loading...</p>
|
<p class="loading-text">Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1>Verstak</h1>
|
<h1>Verstak</h1>
|
||||||
<p class="subtitle">Choose a vault to get started</p>
|
<p class="subtitle">Выберите vault для начала работы</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
|
|
@ -142,43 +142,43 @@
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
<h3>Create New Vault</h3>
|
<h3>Создать новый vault</h3>
|
||||||
<p class="hint">Create a new vault folder. This will be your workspace.</p>
|
<p class="hint">Создайте локальную папку vault. В ней будут храниться рабочие пространства и проекты.</p>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newVaultPath}
|
bind:value={newVaultPath}
|
||||||
placeholder="Select or type a path..."
|
placeholder="Выберите или введите путь..."
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
/>
|
/>
|
||||||
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
<button class="btn-secondary" on:click={browseNewVault} type="button" disabled={creating}>
|
||||||
Browse…
|
Выбрать…
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
<button class="btn-primary" on:click={createVault} type="button" disabled={creating}>
|
||||||
{creating ? 'Creating...' : 'Create & Open'}
|
{creating ? 'Создаём...' : 'Создать vault'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="action-card">
|
<div class="action-card">
|
||||||
<h3>Open Existing Vault</h3>
|
<h3>Открыть существующий vault</h3>
|
||||||
<p class="hint">Open a vault that already exists on this computer.</p>
|
<p class="hint">Используйте vault, который уже есть на этом компьютере.</p>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={openVaultPath}
|
bind:value={openVaultPath}
|
||||||
placeholder="Select or type a path..."
|
placeholder="Выберите или введите путь..."
|
||||||
disabled={opening}
|
disabled={opening}
|
||||||
/>
|
/>
|
||||||
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
<button class="btn-secondary" on:click={browseOpenVault} type="button" disabled={opening}>
|
||||||
Browse…
|
Выбрать…
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="btn-primary" on:click={openExistingVault} type="button" disabled={opening}>
|
<button class="btn-secondary open-existing-btn" on:click={openExistingVault} type="button" disabled={opening}>
|
||||||
{opening ? 'Opening...' : 'Open'}
|
{opening ? 'Открываем...' : 'Открыть существующий'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,7 +186,7 @@
|
||||||
|
|
||||||
{#if recentVaults.length > 0}
|
{#if recentVaults.length > 0}
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<h3>Recent Vaults</h3>
|
<h3>Недавние vault</h3>
|
||||||
<ul class="recent-list">
|
<ul class="recent-list">
|
||||||
{#each recentVaults as path}
|
{#each recentVaults as path}
|
||||||
<li>
|
<li>
|
||||||
|
|
|
||||||
|
|
@ -60,11 +60,6 @@
|
||||||
<div class="view-header">
|
<div class="view-header">
|
||||||
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
|
<Icon name={currentView.icon || 'logo'} size={20} class="view-icon" />
|
||||||
<h2>{currentView.title}</h2>
|
<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>
|
||||||
<div class="view-content">
|
<div class="view-content">
|
||||||
{#if hasFrontend}
|
{#if hasFrontend}
|
||||||
|
|
@ -134,26 +129,6 @@
|
||||||
color: #a78bfa;
|
color: #a78bfa;
|
||||||
flex-shrink: 0;
|
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 {
|
.view-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -235,6 +210,7 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: #555;
|
color: #555;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
.empty .sub { font-size: 0.85rem; color: #444; margin-top: 0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,6 @@
|
||||||
<span class="workspace-title">{workspaceTitle}</span>
|
<span class="workspace-title">{workspaceTitle}</span>
|
||||||
<span class="workspace-type">{workspaceType}</span>
|
<span class="workspace-type">{workspaceType}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="workspace-search" data-workspace-search>
|
|
||||||
<input type="search" placeholder="Search workspace" aria-label="Search workspace" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if workspaceTools.length > 0}
|
{#if workspaceTools.length > 0}
|
||||||
|
|
@ -107,13 +104,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-empty">
|
<div class="workspace-empty">
|
||||||
<p>No workspace tools available</p>
|
<p>Для этого рабочего пространства пока нет инструментов</p>
|
||||||
<p class="workspace-hint">Install plugins that contribute workspaceItems to see tools here.</p>
|
<p class="workspace-hint">Включите плагины с workspace-инструментами или откройте Plugin Manager через меню настроек.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-empty">
|
<div class="workspace-empty">
|
||||||
<p>Select a workspace node from the sidebar</p>
|
<p>Создайте рабочее пространство или выберите существующее в боковой панели</p>
|
||||||
|
<p class="workspace-hint">Нажмите «+» в разделе Workspaces, чтобы добавить первый проект.</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -131,7 +129,7 @@
|
||||||
.workspace-header {
|
.workspace-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid #16213e;
|
border-bottom: 1px solid #16213e;
|
||||||
|
|
@ -159,38 +157,11 @@
|
||||||
background: #1a2a3a;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.workspace-header {
|
.workspace-header {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-search {
|
|
||||||
flex-basis: auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-tabs {
|
.workspace-tabs {
|
||||||
|
|
@ -241,6 +212,8 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-hint {
|
.workspace-hint {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import * as App from '../../../wailsjs/go/api/App';
|
import * as App from '../../../wailsjs/go/api/App';
|
||||||
import Icon from '../ui/Icon.svelte';
|
import Icon from '../ui/Icon.svelte';
|
||||||
|
|
||||||
|
|
@ -20,7 +20,19 @@
|
||||||
let renameValue = '';
|
let renameValue = '';
|
||||||
let busyId = '';
|
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) {
|
function resultOrError(response, fallbackValue) {
|
||||||
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
|
return typeof response === 'string' ? [fallbackValue, response] : [response, ''];
|
||||||
|
|
@ -57,8 +69,19 @@
|
||||||
workspaces = [];
|
workspaces = [];
|
||||||
} else {
|
} else {
|
||||||
workspaces = list || [];
|
workspaces = list || [];
|
||||||
if (!currentWorkspaceId || !workspaces.some((ws) => wsName(ws) === currentWorkspaceId)) {
|
if (!currentWorkspaceId) {
|
||||||
currentWorkspaceId = wsName(workspaces[0] || {});
|
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);
|
activeWorkspaceId.set(currentWorkspaceId);
|
||||||
}
|
}
|
||||||
|
|
@ -215,9 +238,9 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wt { display: flex; flex-direction: column; flex: 1; overflow: hidden; position: relative; }
|
.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-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 { 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:hover:not(:disabled) { color: #4ecca3; background: rgba(78,204,163,0.1); }
|
||||||
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
|
.wt-btn-small { font-size: 0.68rem; opacity: 0; }
|
||||||
|
|
@ -225,12 +248,12 @@
|
||||||
.wt-row:hover .wt-btn-small { opacity: 1; }
|
.wt-row:hover .wt-btn-small { opacity: 1; }
|
||||||
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
|
.wt-loading, .wt-error { padding: 0.5rem; font-size: 0.75rem; color: #666; }
|
||||||
.wt-error { color: #e94560; }
|
.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-row:hover { background: rgba(15,52,96,0.4); }
|
||||||
.wt-node.selected > .wt-row { background: rgba(78,204,163,0.1); }
|
.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; }
|
.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; }
|
: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-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-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; }
|
.wt-row:hover .wt-icon-btn { opacity: 1; }
|
||||||
|
|
|
||||||
|
|
@ -205,11 +205,33 @@
|
||||||
},
|
},
|
||||||
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
|
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
|
||||||
error: ''
|
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 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 appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
var workbenchPreferences = {};
|
var workbenchPreferences = {};
|
||||||
var openedResources = [];
|
var openedResources = [];
|
||||||
|
|
@ -671,12 +693,18 @@
|
||||||
function parent(path) { path = clean(path); var i = path.lastIndexOf('/'); return i < 0 ? '' : path.slice(0, i); }
|
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 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 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 = {
|
var FilesView = {
|
||||||
mount: function (c, p, api) {
|
mount: function (c, p, api) {
|
||||||
if (!document.getElementById('mock-files-styles')) {
|
if (!document.getElementById('mock-files-styles')) {
|
||||||
var style = document.createElement('style');
|
var style = document.createElement('style');
|
||||||
style.id = 'mock-files-styles';
|
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);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
c.innerHTML = '';
|
c.innerHTML = '';
|
||||||
|
|
@ -704,8 +732,8 @@
|
||||||
function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; }
|
function saveHistory() { window.__filesHistoryByWorkspace[historyKey] = { stack: history.slice(), index: historyIndex, currentPath: current }; }
|
||||||
var toolbar = e('div', { className: 'files-toolbar' }, []);
|
var toolbar = e('div', { className: 'files-toolbar' }, []);
|
||||||
var breadcrumb = e('div', { className: 'files-breadcrumb' }, []);
|
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 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, 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(breadcrumb);
|
||||||
toolbar.appendChild(btn('Back', 'back', goBack));
|
toolbar.appendChild(btn('Back', 'back', goBack));
|
||||||
toolbar.appendChild(btn('Forward', 'forward', goForward));
|
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-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.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.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); })]));
|
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);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
|
|
@ -1780,10 +1808,32 @@
|
||||||
},
|
},
|
||||||
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
|
rootPath: '/tmp/verstak-test/plugins/browser-inbox',
|
||||||
error: ''
|
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' };
|
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: [] };
|
appSettings = { currentVaultPath: '/tmp/verstak-test/vault', recentVaults: [] };
|
||||||
workbenchPreferences = {};
|
workbenchPreferences = {};
|
||||||
openedResources = [];
|
openedResources = [];
|
||||||
|
|
|
||||||
|
|
@ -1315,3 +1315,4 @@ export namespace workspace {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1444,9 +1444,30 @@ func decodeSecretRecord(raw map[string]interface{}) (coresecrets.SecretRecord, e
|
||||||
if err := json.Unmarshal(data, &record); err != nil {
|
if err := json.Unmarshal(data, &record); err != nil {
|
||||||
return coresecrets.SecretRecord{}, err
|
return coresecrets.SecretRecord{}, err
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(record.ID) == "" {
|
||||||
|
record.ID = generatedSecretID(record.Title)
|
||||||
|
}
|
||||||
return record, nil
|
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{} {
|
func secretRecordMap(record coresecrets.SecretRecord, includeValue bool) map[string]interface{} {
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"id": record.ID,
|
"id": record.ID,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue