Fix global search result navigation
This commit is contained in:
parent
b28039cf0d
commit
578ce2f2a5
|
|
@ -46,6 +46,47 @@ test.describe('UX follow-up fixes', () => {
|
||||||
await expect(page.locator('.files-item')).toContainText('Overview.md');
|
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 }) => {
|
test('plugin settings modal gives complex panels enough space', async ({ page }) => {
|
||||||
await openPluginManager(page);
|
await openPluginManager(page);
|
||||||
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
|
await page.locator('.plugin-card').filter({ hasText: 'verstak.platform-test' }).getByRole('button', { name: 'Settings' }).click();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
let focused = false;
|
let focused = false;
|
||||||
let loading = true;
|
let loading = true;
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
|
let buildSeq = 0;
|
||||||
|
|
||||||
$: scheduleSearch(query);
|
$: scheduleSearch(query);
|
||||||
|
|
||||||
|
|
@ -114,7 +115,14 @@
|
||||||
return String(text || '').slice(0, 900);
|
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 settings = await resultOrEmpty(App.ReadPluginSettings(pluginId), {});
|
||||||
const items = [];
|
const items = [];
|
||||||
Object.keys(settings || {}).forEach(key => {
|
Object.keys(settings || {}).forEach(key => {
|
||||||
|
|
@ -123,12 +131,19 @@
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (!row || typeof row !== 'object') return;
|
if (!row || typeof row !== 'object') return;
|
||||||
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
|
const title = row.title || row.summary || row.url || row.captureId || row.activityId || row.entryId || label;
|
||||||
|
const workspaceName = row.workspaceRootPath || row.workspaceName || '';
|
||||||
items.push({
|
items.push({
|
||||||
type: label,
|
type: label,
|
||||||
title,
|
title,
|
||||||
subtitle: row.url || row.summary || row.workspaceRootPath || key,
|
subtitle: row.url || row.summary || row.workspaceRootPath || key,
|
||||||
keywords: JSON.stringify(row),
|
keywords: JSON.stringify(row),
|
||||||
rank,
|
rank,
|
||||||
|
action: workspaceName ? 'workspace-tool' : (view ? 'view' : ''),
|
||||||
|
viewId: view?.id || '',
|
||||||
|
pluginId,
|
||||||
|
workspaceName,
|
||||||
|
toolKind: pluginToolKind(pluginId, label),
|
||||||
|
nodes,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -136,6 +151,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildIndex() {
|
async function buildIndex() {
|
||||||
|
const seq = ++buildSeq;
|
||||||
loading = true;
|
loading = true;
|
||||||
const next = [];
|
const next = [];
|
||||||
|
|
||||||
|
|
@ -155,6 +171,10 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const contributions = await resultOrEmpty(App.GetContributions(), {});
|
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 => {
|
(contributions.sidebarItems || []).forEach(item => {
|
||||||
next.push({
|
next.push({
|
||||||
type: 'Tool',
|
type: 'Tool',
|
||||||
|
|
@ -185,16 +205,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginItems = await Promise.all([
|
const pluginItems = await Promise.all([
|
||||||
indexPluginSettings('verstak.journal', 'Journal', 50),
|
indexPluginSettings('verstak.journal', 'Journal', 50, viewByPluginId.get('verstak.journal'), nodes),
|
||||||
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55),
|
indexPluginSettings('verstak.browser-inbox', 'Browser Inbox', 55, viewByPluginId.get('verstak.browser-inbox'), nodes),
|
||||||
indexPluginSettings('verstak.activity', 'Activity', 60),
|
indexPluginSettings('verstak.activity', 'Activity', 60, viewByPluginId.get('verstak.activity'), nodes),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (seq !== buildSeq) return;
|
||||||
index = next.concat(pluginItems.flat());
|
index = next.concat(pluginItems.flat());
|
||||||
loading = false;
|
loading = false;
|
||||||
runSearch(query);
|
runSearch(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
focused = true;
|
||||||
|
buildIndex();
|
||||||
|
}
|
||||||
|
|
||||||
async function openResult(item) {
|
async function openResult(item) {
|
||||||
query = '';
|
query = '';
|
||||||
results = [];
|
results = [];
|
||||||
|
|
@ -210,6 +236,18 @@
|
||||||
}));
|
}));
|
||||||
return;
|
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') {
|
if (item.action === 'file-folder') {
|
||||||
const parts = String(item.path || '').split('/').filter(Boolean);
|
const parts = String(item.path || '').split('/').filter(Boolean);
|
||||||
const workspaceName = parts[0] || '';
|
const workspaceName = parts[0] || '';
|
||||||
|
|
@ -252,7 +290,7 @@
|
||||||
<Icon name="search" size={14} class="global-search-icon" />
|
<Icon name="search" size={14} class="global-search-icon" />
|
||||||
<input
|
<input
|
||||||
bind:value={query}
|
bind:value={query}
|
||||||
on:focus={() => focused = true}
|
on:focus={handleFocus}
|
||||||
on:blur={() => setTimeout(() => focused = false, 120)}
|
on:blur={() => setTimeout(() => focused = false, 120)}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={loading ? 'Индексируем...' : 'Поиск'}
|
placeholder={loading ? 'Индексируем...' : 'Поиск'}
|
||||||
|
|
|
||||||
|
|
@ -220,8 +220,11 @@
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
provides: ['verstak/search/v1', 'search.provider'],
|
provides: ['verstak/search/v1', 'search.provider'],
|
||||||
requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
|
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: {
|
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' }]
|
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -1422,6 +1425,102 @@
|
||||||
}.toString() + ')();';
|
}.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() {
|
function platformTestBundle() {
|
||||||
return [
|
return [
|
||||||
"(function(){",
|
"(function(){",
|
||||||
|
|
@ -1665,6 +1764,9 @@
|
||||||
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
|
if (pluginId === 'verstak.browser-inbox' && assetPath === 'frontend/dist/index.js') {
|
||||||
return Promise.resolve(browserInboxBundle());
|
return Promise.resolve(browserInboxBundle());
|
||||||
}
|
}
|
||||||
|
if (pluginId === 'verstak.search' && assetPath === 'frontend/dist/index.js') {
|
||||||
|
return Promise.resolve(searchPluginBundle());
|
||||||
|
}
|
||||||
return Promise.resolve('');
|
return Promise.resolve('');
|
||||||
},
|
},
|
||||||
GetPluginCapability: function (pluginId, capId) {
|
GetPluginCapability: function (pluginId, capId) {
|
||||||
|
|
@ -2226,8 +2328,11 @@
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
provides: ['verstak/search/v1', 'search.provider'],
|
provides: ['verstak/search/v1', 'search.provider'],
|
||||||
requires: ['verstak/core/files/v1', 'verstak/core/workbench/v1'],
|
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: {
|
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' }]
|
searchProviders: [{ id: 'verstak.search.vault-text', label: 'Vault Text Search', handler: 'verstak.search.searchVaultText' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue