Enhance vault search behavior

This commit is contained in:
mirivlad 2026-06-28 02:50:19 +08:00
parent e465658be7
commit 59e48d7ea4
2 changed files with 109 additions and 16 deletions

View File

@ -12,6 +12,7 @@
'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'py', 'go', 'rs', 'java', 'kt', 'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'py', 'go', 'rs', 'java', 'kt',
'swift', 'rb', 'php', 'c', 'cpp', 'h', 'hpp', 'sh', 'bash', 'zsh', 'sql' 'swift', 'rb', 'php', 'c', 'cpp', 'h', 'hpp', 'sh', 'bash', 'zsh', 'sql'
]; ];
var SEARCH_DEBOUNCE_MS = 300;
var MAX_FILES = 500; var MAX_FILES = 500;
var MAX_RESULTS = 100; var MAX_RESULTS = 100;
@ -82,6 +83,13 @@
return entry && entry.type === 'file' && TEXT_EXTS.indexOf(extension(entry)) !== -1; return entry && entry.type === 'file' && TEXT_EXTS.indexOf(extension(entry)) !== -1;
} }
function pathMatches(entry, query) {
var needle = String(query || '').toLowerCase();
var path = String((entry && entry.relativePath) || '').toLowerCase();
var name = String((entry && entry.name) || '').toLowerCase();
return path.indexOf(needle) !== -1 || name.indexOf(needle) !== -1;
}
function lineNumber(text, index) { function lineNumber(text, index) {
return text.slice(0, index).split('\n').length; return text.slice(0, index).split('\n').length;
} }
@ -99,13 +107,28 @@
if (idx === -1) return null; if (idx === -1) return null;
return { return {
path: path, path: path,
type: 'file',
matchType: 'Content match',
openable: true,
line: lineNumber(text, idx), line: lineNumber(text, idx),
snippet: snippet(text, idx, query.length) snippet: snippet(text, idx, query.length)
}; };
} }
async function collectFiles(api, rootPath) { function scanPath(entry) {
var files = []; var isFolder = entry.type === 'folder';
return {
path: entry.relativePath,
type: entry.type,
matchType: isFolder ? 'Folder name' : 'File name',
openable: !isFolder,
line: 0,
snippet: isFolder ? 'Folder name/path match' : 'File name/path match'
};
}
async function collectEntries(api, rootPath) {
var found = [];
var folders = [cleanPath(rootPath)]; var folders = [cleanPath(rootPath)];
var visited = 0; var visited = 0;
while (folders.length && visited < MAX_FILES) { while (folders.length && visited < MAX_FILES) {
@ -115,25 +138,27 @@
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
var entry = entries[i]; var entry = entries[i];
if (!entry || !entry.relativePath) continue; if (!entry || !entry.relativePath) continue;
found.push(entry);
if (entry.type === 'folder') { if (entry.type === 'folder') {
folders.push(entry.relativePath); folders.push(entry.relativePath);
continue;
} }
if (isTextFile(entry)) files.push(entry);
visited += 1; visited += 1;
if (visited >= MAX_FILES) break; if (visited >= MAX_FILES) break;
} }
} }
return files; return found;
} }
async function runSearch(api, rootPath, query) { async function runSearch(api, rootPath, query) {
query = String(query || '').trim(); query = String(query || '').trim();
if (query.length < 2) return []; if (query.length < 2) return [];
var files = await collectFiles(api, rootPath); var entries = await collectEntries(api, rootPath);
var results = []; var results = [];
for (var i = 0; i < files.length && results.length < MAX_RESULTS; i++) { for (var i = 0; i < entries.length && results.length < MAX_RESULTS; i++) {
var path = files[i].relativePath; var entry = entries[i];
var path = entry.relativePath;
if (pathMatches(entry, query)) results.push(scanPath(entry));
if (!isTextFile(entry) || results.length >= MAX_RESULTS) continue;
try { try {
var text = await api.files.readText(path); var text = await api.files.readText(path);
var match = scanText(path, String(text || ''), query); var match = scanText(path, String(text || ''), query);
@ -150,6 +175,8 @@
injectStyles(); injectStyles();
var rootPath = cleanPath(props && (props.workspaceRootPath || props.workspaceName)); var rootPath = cleanPath(props && (props.workspaceRootPath || props.workspaceName));
var state = { query: '', searching: false, results: [], status: 'Enter at least 2 characters.', error: '' }; var state = { query: '', searching: false, results: [], status: 'Enter at least 2 characters.', error: '' };
var searchTimer = null;
var searchSeq = 0;
function render() { function render() {
containerEl.innerHTML = ''; containerEl.innerHTML = '';
@ -159,10 +186,13 @@
var input = el('input', { var input = el('input', {
className: 'search-input', className: 'search-input',
type: 'search', type: 'search',
placeholder: 'Search text files', placeholder: 'Search files, folders, text',
value: state.query, value: state.query,
'data-search-input': 'query', 'data-search-input': 'query',
onInput: function (event) { state.query = event.target.value; } onInput: function (event) {
state.query = event.target.value;
scheduleSearch();
}
}); });
var button = el('button', { var button = el('button', {
className: 'search-btn', className: 'search-btn',
@ -189,9 +219,9 @@
el('div', {}, [ el('div', {}, [
el('div', { className: 'search-path' }, [result.path]), el('div', { className: 'search-path' }, [result.path]),
el('div', { className: 'search-snippet' }, [result.snippet]), el('div', { className: 'search-snippet' }, [result.snippet]),
el('div', { className: 'search-meta' }, ['Line ' + result.line]) el('div', { className: 'search-meta' }, [result.matchType + (result.line ? ' - Line ' + result.line : '')])
]), ]),
el('button', { result.openable ? el('button', {
className: 'search-btn', className: 'search-btn',
textContent: 'Open', textContent: 'Open',
'data-search-open': result.path, 'data-search-open': result.path,
@ -202,12 +232,34 @@
mode: 'view' mode: 'view'
}).catch(function (err) { console.error('[search] openResource:', err); }); }).catch(function (err) { console.error('[search] openResource:', err); });
} }
}) }) : null
])); ]));
}); });
} }
function scheduleSearch() {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
searchSeq += 1;
var query = String(state.query || '').trim();
if (query.length < 2) {
state.searching = false;
state.results = [];
state.status = 'Enter at least 2 characters.';
state.error = '';
render();
return;
}
searchTimer = setTimeout(search, SEARCH_DEBOUNCE_MS);
}
async function search() { async function search() {
if (searchTimer) {
clearTimeout(searchTimer);
searchTimer = null;
}
state.query = String(state.query || '').trim(); state.query = String(state.query || '').trim();
if (state.query.length < 2) { if (state.query.length < 2) {
state.results = []; state.results = [];
@ -219,22 +271,34 @@
state.searching = true; state.searching = true;
state.error = ''; state.error = '';
state.status = 'Searching...'; state.status = 'Searching...';
var seq = searchSeq + 1;
searchSeq = seq;
render(); render();
try { try {
state.results = await runSearch(api, rootPath, state.query); var results = await runSearch(api, rootPath, state.query);
if (seq !== searchSeq) return;
state.results = results;
state.status = state.results.length + ' result' + (state.results.length === 1 ? '' : 's'); state.status = state.results.length + ' result' + (state.results.length === 1 ? '' : 's');
} catch (err) { } catch (err) {
if (seq !== searchSeq) return;
state.results = []; state.results = [];
state.error = err && err.message ? err.message : String(err); state.error = err && err.message ? err.message : String(err);
} finally { } finally {
if (seq !== searchSeq) return;
state.searching = false; state.searching = false;
render(); render();
} }
} }
render(); render();
containerEl.__verstakSearchCleanup = function () {
if (searchTimer) clearTimeout(searchTimer);
searchSeq += 1;
};
}, },
unmount: function (containerEl) { unmount: function (containerEl) {
if (containerEl.__verstakSearchCleanup) containerEl.__verstakSearchCleanup();
delete containerEl.__verstakSearchCleanup;
containerEl.innerHTML = ''; containerEl.innerHTML = '';
} }
}; };

View File

@ -120,6 +120,11 @@ async function flush() {
} }
} }
async function wait(ms) {
await new Promise((resolve) => setTimeout(resolve, ms));
await flush();
}
(async () => { (async () => {
const document = makeDocument(); const document = makeDocument();
const component = loadComponent(document); const component = loadComponent(document);
@ -130,6 +135,7 @@ async function flush() {
if (relativeDir === 'Project') { if (relativeDir === 'Project') {
return [ return [
{ name: 'Docs', relativePath: 'Project/Docs', type: 'folder' }, { name: 'Docs', relativePath: 'Project/Docs', type: 'folder' },
{ name: 'Target Assets', relativePath: 'Project/Target Assets', type: 'folder' },
{ name: 'image.png', relativePath: 'Project/image.png', type: 'file', extension: 'png' }, { name: 'image.png', relativePath: 'Project/image.png', type: 'file', extension: 'png' },
]; ];
} }
@ -158,13 +164,36 @@ async function flush() {
component.mount(container, { workspaceRootPath: 'Project' }, api); component.mount(container, { workspaceRootPath: 'Project' }, api);
await flush(); await flush();
function queryInput() {
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-input') === 'query'); const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-input') === 'query');
if (!input) throw new Error('query input not found'); if (!input) throw new Error('query input not found');
return input;
}
let input = queryInput();
input.value = 'target'; input.value = 'target';
input.dispatchEvent('input'); input.dispatchEvent('input');
await wait(360);
if (!container.textContent.includes('Project/Docs/case.md')) throw new Error('typing should search file contents');
if (!container.textContent.includes('Target phrase is here')) throw new Error('typing should render content snippet');
if (!container.textContent.includes('Project/Target Assets')) throw new Error('typing should search folder paths');
if (!container.textContent.includes('Content match')) throw new Error('content result type was not rendered');
if (!container.textContent.includes('Folder name')) throw new Error('folder result type was not rendered');
input = queryInput();
input.value = 'image';
input.dispatchEvent('input');
await wait(360);
if (!container.textContent.includes('Project/image.png')) throw new Error('binary file path match should be rendered');
if (!container.textContent.includes('File name')) throw new Error('file name result type was not rendered');
const button = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-action') === 'run'); const button = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-action') === 'run');
if (!button) throw new Error('search button not found'); if (!button) throw new Error('search button not found');
input = queryInput();
input.value = 'target';
input.dispatchEvent('input');
button.click(); button.click();
await flush(); await flush();