diff --git a/frontend/e2e/ux-followup.spec.js b/frontend/e2e/ux-followup.spec.js
index e846ba6..10636ac 100644
--- a/frontend/e2e/ux-followup.spec.js
+++ b/frontend/e2e/ux-followup.spec.js
@@ -46,6 +46,47 @@ test.describe('UX follow-up fixes', () => {
await expect(page.locator('.files-item')).toContainText('Overview.md');
});
+ test('global search opens indexed browser inbox results', async ({ page }) => {
+ await page.evaluate(async () => {
+ await window.go.api.App.WritePluginSettings('verstak.browser-inbox', {
+ 'captures:workspace:Project': [{
+ captureId: 'capture-search-1',
+ capturedAt: '2026-06-30T08:00:00.000Z',
+ kind: 'page',
+ url: 'https://example.com/research',
+ title: 'Research Search Result',
+ domain: 'example.com',
+ text: 'Searchable captured browser text',
+ workspaceRootPath: 'Project',
+ browserName: 'Firefox',
+ }],
+ });
+ });
+
+ const search = page.locator('[data-global-search-input]');
+ await search.fill('Research Search Result');
+ const result = page.locator('[data-global-search-result-type="Browser Inbox"]').filter({ hasText: 'Research Search Result' });
+ await expect(result).toBeVisible({ timeout: 10000 });
+ await result.click();
+
+ await expect(page.locator('.browser-inbox-root')).toBeVisible({ timeout: 10000 });
+ await expect(page.locator('.browser-inbox-detail-title')).toHaveText('Research Search Result');
+ });
+
+ test('workspace Search input keeps focus while typing', async ({ page }) => {
+ await page.locator('.wt-label').filter({ hasText: 'Project' }).click();
+ await page.getByRole('tab', { name: 'Search' }).click();
+ const searchInput = page.locator('[data-search-input="query"]');
+ await expect(searchInput).toBeVisible({ timeout: 10000 });
+
+ await searchInput.click();
+ await page.keyboard.press('p');
+ await expect(searchInput).toBeFocused();
+ await page.keyboard.press('r');
+ await expect(searchInput).toBeFocused();
+ await expect(searchInput).toHaveValue('pr');
+ });
+
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();
diff --git a/frontend/src/lib/shell/GlobalSearch.svelte b/frontend/src/lib/shell/GlobalSearch.svelte
index c50db5a..863761c 100644
--- a/frontend/src/lib/shell/GlobalSearch.svelte
+++ b/frontend/src/lib/shell/GlobalSearch.svelte
@@ -15,6 +15,7 @@
let focused = false;
let loading = true;
let searchTimer = null;
+ let buildSeq = 0;
$: scheduleSearch(query);
@@ -114,7 +115,14 @@
return String(text || '').slice(0, 900);
}
- async function indexPluginSettings(pluginId, label, rank) {
+ function pluginToolKind(pluginId, label) {
+ if (pluginId === 'verstak.browser-inbox') return 'browser-inbox';
+ if (pluginId === 'verstak.activity') return 'activity';
+ if (pluginId === 'verstak.journal') return 'journal';
+ return String(label || pluginId || '').toLowerCase();
+ }
+
+ async function indexPluginSettings(pluginId, label, rank, view, nodes) {
const settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
const items = [];
Object.keys(settings || {}).forEach(key => {
@@ -123,12 +131,19 @@
rows.forEach(row => {
if (!row || typeof row !== 'object') return;
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
+ const workspaceName = row.workspaceRootPath || row.workspaceName || '';
items.push({
type: label,
title,
subtitle: row.url || row.summary || row.workspaceRootPath || key,
keywords: JSON.stringify(row),
rank,
+ action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
+ viewId: view?.id || '',
+ pluginId,
+ workspaceName,
+ toolKind: pluginToolKind(pluginId, label),
+ nodes,
});
});
});
@@ -136,6 +151,7 @@
}
async function buildIndex() {
+ const seq = ++buildSeq;
loading = true;
const next = [];
@@ -155,6 +171,10 @@
});
const contributions = await resultOrEmpty(App.GetContributions(), {});
+ const viewByPluginId = new Map();
+ (contributions.views || []).forEach(view => {
+ if (view.pluginId && !viewByPluginId.has(view.pluginId)) viewByPluginId.set(view.pluginId, view);
+ });
(contributions.sidebarItems || []).forEach(item => {
next.push({
type: 'Tool',
@@ -185,16 +205,22 @@
}
const pluginItems = await Promise.all([
- indexPluginSettings('verstak.journal', 'Journal', 50),
- indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55),
- indexPluginSettings('verstak.activity', 'Activity', 60),
+ indexPluginSettings('verstak.journal', 'Journal', 50, viewByPluginId.get('verstak.journal'), nodes),
+ indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
+ indexPluginSettings('verstak.activity', 'Activity', 60, viewByPluginId.get('verstak.activity'), nodes),
]);
+ if (seq !== buildSeq) return;
index = next.concat(pluginItems.flat());
loading = false;
runSearch(query);
}
+ function handleFocus() {
+ focused = true;
+ buildIndex();
+ }
+
async function openResult(item) {
query = '';
results = [];
@@ -210,6 +236,18 @@
}));
return;
}
+ if (item.action === 'workspace-tool') {
+ const workspaceName = item.workspaceName || '';
+ if (workspaceName) {
+ window.dispatchEvent(new CustomEvent('verstak:workspace-selected', {
+ detail: { workspaceName, nodes: item.nodes || [] }
+ }));
+ window.dispatchEvent(new CustomEvent('verstak:workspace-open-tool', {
+ detail: { kind: item.toolKind || item.type || '' }
+ }));
+ }
+ return;
+ }
if (item.action === 'file-folder') {
const parts = String(item.path || '').split('/').filter(Boolean);
const workspaceName = parts[0] || '';
@@ -252,7 +290,7 @@
focused = true}
+ on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? 'Индексируем...' : 'Поиск'}
diff --git a/frontend/src/lib/test/wails-mock.js b/frontend/src/lib/test/wails-mock.js
index 63514be..b604f73 100644
--- a/frontend/src/lib/test/wails-mock.js
+++ b/frontend/src/lib/test/wails-mock.js
@@ -220,8 +220,11 @@
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'],
+ permissions: ['files.read', 'workbench.open', 'storage.namespace', 'ui.register', 'events.subscribe', 'commands.register'],
+ frontend: { entry: 'frontend/dist/index.js' },
contributes: {
+ workspaceItems: [{ id: 'verstak.search.workspace', title: 'Search', icon: 'search', component: 'SearchView' }],
+ commands: [{ id: 'verstak.search.searchVaultText', title: 'Search Vault Text', handler: 'verstak.search.searchVaultText' }],
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
}
},
@@ -1422,6 +1425,102 @@
}.toString() + ')();';
}
+ function searchPluginBundle() {
+ return '(' + function () {
+ function el(tag, attrs, children) {
+ var node = document.createElement(tag);
+ attrs = attrs || {};
+ Object.keys(attrs).forEach(function (key) {
+ if (attrs[key] == null) return;
+ if (key === 'className') node.className = attrs[key];
+ else if (key.indexOf('on') === 0) node.addEventListener(key.slice(2).toLowerCase(), attrs[key]);
+ else if (key === 'textContent') node.textContent = attrs[key];
+ else node.setAttribute(key, attrs[key]);
+ });
+ (children || []).forEach(function (child) {
+ if (child == null) return;
+ node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
+ });
+ return node;
+ }
+ function clean(path) { return String(path || '').split('/').filter(Boolean).join('/'); }
+ function SearchView(containerEl, props, api) {
+ if (!document.getElementById('mock-search-style')) {
+ var style = document.createElement('style');
+ style.id = 'mock-search-style';
+ style.textContent = '.search-root{height:100%;min-height:0;display:flex;flex-direction:column;background:#0d0d1a;color:#e0e0e0}.search-toolbar{display:flex;gap:.5rem;padding:.55rem .75rem;border-bottom:1px solid #16213e;background:#12122a}.search-input{flex:1;min-width:180px;font-size:.86rem;padding:.42rem .55rem;border:1px solid #333;border-radius:4px;background:#0d0d1a;color:#e0e0e0;outline:none}.search-input:focus{border-color:#4ecca3}.search-btn{font-size:.8rem;padding:.42rem .7rem;border:1px solid #333;border-radius:4px;background:#1a1a2e;color:#ddd}.search-scope,.search-status{font-size:.78rem;color:#8b8ba8}.search-status{padding:.45rem .75rem;border-bottom:1px solid rgba(22,33,62,.55)}.search-results{flex:1;min-height:0;overflow:auto}.search-empty{padding:2rem;color:#666;text-align:center}.search-result{padding:.7rem .85rem;border-bottom:1px solid rgba(22,33,62,.55)}.search-path{color:#4ecca3}.search-snippet{margin-top:.25rem;color:#cfcfe0;font-size:.8rem}';
+ document.head.appendChild(style);
+ }
+ containerEl.innerHTML = '';
+ containerEl.className = 'search-root';
+ containerEl.setAttribute('data-plugin-id', 'verstak.search');
+ var rootPath = clean(props && (props.workspaceRootPath || props.workspaceName));
+ var query = '';
+ var timer = null;
+ var results = [];
+ var input = el('input', { className: 'search-input', type: 'search', placeholder: 'Search files, folders, text', 'data-search-input': 'query' });
+ var button = el('button', { className: 'search-btn', 'data-search-action': 'run', textContent: 'Search' });
+ var status = el('div', { className: 'search-status', textContent: 'Enter at least 2 characters.' });
+ var list = el('div', { className: 'search-results' });
+ containerEl.appendChild(el('div', { className: 'search-toolbar' }, [
+ input,
+ button,
+ el('span', { className: 'search-scope', title: rootPath || 'Vault' }, [rootPath || 'Vault'])
+ ]));
+ containerEl.appendChild(status);
+ containerEl.appendChild(list);
+ function render() {
+ list.innerHTML = '';
+ if (!results.length) {
+ list.appendChild(el('div', { className: 'search-empty' }, [query.length < 2 ? 'Enter at least 2 characters.' : 'No results']));
+ return;
+ }
+ results.forEach(function (item) {
+ list.appendChild(el('div', { className: 'search-result' }, [
+ el('div', { className: 'search-path', textContent: item.relativePath }),
+ el('div', { className: 'search-snippet', textContent: item.name })
+ ]));
+ });
+ }
+ async function run() {
+ query = input.value.trim();
+ if (query.length < 2) {
+ results = [];
+ status.textContent = 'Enter at least 2 characters.';
+ render();
+ return;
+ }
+ var entries = await api.files.list(rootPath);
+ var needle = query.toLowerCase();
+ results = (Array.isArray(entries) ? entries : []).filter(function (item) {
+ return String(item.name || item.relativePath || '').toLowerCase().indexOf(needle) !== -1;
+ });
+ status.textContent = results.length + ' result' + (results.length === 1 ? '' : 's');
+ render();
+ }
+ function schedule() {
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(run, 100);
+ }
+ input.addEventListener('input', schedule);
+ button.addEventListener('click', run);
+ render();
+ containerEl.__searchMockCleanup = function () { if (timer) clearTimeout(timer); };
+ }
+ window.VerstakPluginRegister('verstak.search', {
+ components: {
+ SearchView: {
+ mount: SearchView,
+ unmount: function (containerEl) {
+ if (containerEl.__searchMockCleanup) containerEl.__searchMockCleanup();
+ containerEl.innerHTML = '';
+ }
+ }
+ }
+ });
+ }.toString() + ')();';
+ }
+
function platformTestBundle() {
return [
"(function(){",
@@ -1665,6 +1764,9 @@
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
return Promise.resolve(browserInboxBundle());
}
+ if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
+ return Promise.resolve(searchPluginBundle());
+ }
return Promise.resolve('');
},
GetPluginCapability: function (pluginId, capId) {
@@ -2226,8 +2328,11 @@
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'],
+ permissions: ['files.read', 'workbench.open', 'storage.namespace', 'ui.register', 'events.subscribe', 'commands.register'],
+ frontend: { entry: 'frontend/dist/index.js' },
contributes: {
+ workspaceItems: [{ id: 'verstak.search.workspace', title: 'Search', icon: 'search', component: 'SearchView' }],
+ commands: [{ id: 'verstak.search.searchVaultText', title: 'Search Vault Text', handler: 'verstak.search.searchVaultText' }],
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
}
},