Fix global search result navigation

This commit is contained in:
mirivlad 2026-06-30 22:43:09 +08:00
parent b28039cf0d
commit 578ce2f2a5
3 changed files with 191 additions and 7 deletions

View File

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

View File

@ -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 @@
<Icon name="search" size={14} class="global-search-icon" />
<input
bind:value={query}
on:focus={() => focused = true}
on:focus={handleFocus}
on:blur={() => setTimeout(() => focused = false, 120)}
type="search"
placeholder={loading ? 'Индексируем...' : 'Поиск'}

View File

@ -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' }]
}
},