Enhance vault search behavior
This commit is contained in:
parent
e465658be7
commit
59e48d7ea4
|
|
@ -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 = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-input') === 'query');
|
function queryInput() {
|
||||||
if (!input) throw new Error('query input not found');
|
const input = walk(container, (node) => node.getAttribute && node.getAttribute('data-search-input') === 'query');
|
||||||
|
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();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue