350 lines
9.9 KiB
Svelte
350 lines
9.9 KiB
Svelte
<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>
|