mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(search): sidebar panel + Ask mode for workspace Q&A
The workspace-search:query and doc-qa:ask IPC channels were reachable
from the renderer but had no UI. This commit wires them into a sidebar
panel that consumes both backends through a single input.
- src/sidebar/search-panel.js — one input, two modes:
Search (default): routes to workspace-search:query, returns file hits
Ask: routes to doc-qa:ask, returns chunk-level passages with offsets
Click a result → open the file (offset passed through for Q&A hits).
All dynamic content is escaped before innerHTML — hostile filenames
or snippets stay as text instead of becoming script/img nodes.
- src/renderer.js — registers the panel; reads the explorer's folder
input on each open so the search dir stays in sync.
- src/index.html — search sidebar icon (magnifier) next to the others.
Tests (18 new, tests/search-panel.test.js):
- mount + DOM structure
- Enter / click run → search() with query + dir
- result rendering (filePath, snippet, tag facet)
- onOpenFile receives (filePath, offset)
- Ask tab switches placeholder + routes to ask()
- Ask chunks carry +offset in the meta line
- empty query, no folder, search error → handled
- XSS: filename/snippet/tag with <script>, <img>, <unsafe> are escaped
- Escape clears, Clear button resets, host API (setDir/focus/clear)
Full suite: 67 suites, 766 tests, lint+format clean.
Amit Haridas
This commit is contained in:
@@ -2558,6 +2558,22 @@
|
|||||||
<polyline points="12 6 12 12 16 14" />
|
<polyline points="12 6 12 12 16 14" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Search + Q&A: workspace content search / doc-aware Q&A -->
|
||||||
|
<button class="sidebar-icon" data-panel="search" title="Search & Ask (Ctrl+Shift+F)">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-panel" id="sidebar-panel">
|
<div class="sidebar-panel" id="sidebar-panel">
|
||||||
<div class="sidebar-panel-header">
|
<div class="sidebar-panel-header">
|
||||||
|
|||||||
@@ -2197,6 +2197,32 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
currentDir: explorerCurrentDir,
|
currentDir: explorerCurrentDir,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Search + Q&A panel — wires the workspace-search:query / doc-qa:ask IPC
|
||||||
|
// channels that the WorkspaceSearch + DocQA main-process modules back. The
|
||||||
|
// panel defaults its folder to whatever folder the Explorer last loaded;
|
||||||
|
// the user can override the input.
|
||||||
|
sidebarManager.registerPanel('search', {
|
||||||
|
title: 'Search',
|
||||||
|
icon:
|
||||||
|
'<svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M10 2a8 8 0 1 1-5.3 14L1 19.7 2.3 21l3.7-3.7A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12A6 6 0 0 0 10 4z"/></svg>',
|
||||||
|
render: (container) =>
|
||||||
|
require('./sidebar/search-panel').renderSearchPanel(container, {
|
||||||
|
search: ({ query, dir, limit }) =>
|
||||||
|
ipcRenderer.invoke('workspace-search:query', { query, dir, limit }),
|
||||||
|
ask: ({ question, dir, topK }) =>
|
||||||
|
ipcRenderer.invoke('doc-qa:ask', { question, dir, topK }),
|
||||||
|
getCurrentDir: () => {
|
||||||
|
// Re-read the explorer's path input each time the user opens the
|
||||||
|
// search panel; survives the explorer being remounted.
|
||||||
|
const el = document.getElementById('explorer-path');
|
||||||
|
return el ? el.value.trim() || null : null;
|
||||||
|
},
|
||||||
|
onOpenFile: (filePath /* , offset */) => {
|
||||||
|
ipcRenderer.send('open-file-path', filePath);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
sidebarManager.registerPanel('git', {
|
sidebarManager.registerPanel('git', {
|
||||||
title: 'Git',
|
title: 'Git',
|
||||||
render: (container) =>
|
render: (container) =>
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Sidebar panel: workspace content search + doc-aware Q&A.
|
||||||
|
*
|
||||||
|
* Two modes, one input box:
|
||||||
|
* - Search mode (default): enter a query, get ranked file hits. The query
|
||||||
|
* grammar is parsed on the main process (WorkspaceSearch); the renderer
|
||||||
|
* just displays what comes back.
|
||||||
|
* - Ask mode: prefix with "?" (or click the Ask toggle) to switch to
|
||||||
|
* chunk-level Q&A. Grammar noise (what/how/why/...) is stripped before
|
||||||
|
* ranking, and the response surfaces individual passages instead of
|
||||||
|
* whole-file hits.
|
||||||
|
*
|
||||||
|
* Click a result to open the file (and optionally jump to the chunk offset
|
||||||
|
* for Q&A hits). The dir defaults to the same folder the Explorer is
|
||||||
|
* looking at; the user can override.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} container Mount point inside the sidebar panel
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {(args:{query,dir,limit}) => Promise<Array>} deps.search
|
||||||
|
* @param {(args:{question,dir,topK}) => Promise<{question,chunks}>} deps.ask
|
||||||
|
* @param {() => string|null} deps.getCurrentDir
|
||||||
|
* @param {(filePath:string, offset?:number) => void} deps.onOpenFile
|
||||||
|
*/
|
||||||
|
|
||||||
|
function renderSearchPanel(container, { search, ask, getCurrentDir, onOpenFile }) {
|
||||||
|
let mode = 'search'; // 'search' | 'ask'
|
||||||
|
let lastResults = [];
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="search-panel">
|
||||||
|
<div class="search-toolbar">
|
||||||
|
<div class="search-mode-toggle" role="tablist">
|
||||||
|
<button class="search-mode-btn active" data-mode="search" role="tab">Search</button>
|
||||||
|
<button class="search-mode-btn" data-mode="ask" role="tab">Ask</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-input-row">
|
||||||
|
<input type="text" class="search-input" id="search-input"
|
||||||
|
placeholder="Search notes — try: rust #lang @project-x "exact phrase""
|
||||||
|
autocomplete="off">
|
||||||
|
<button class="search-run-btn" id="search-run" title="Run (Enter)">🔍</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-dir-row">
|
||||||
|
<input type="text" class="search-dir" id="search-dir"
|
||||||
|
placeholder="Folder (defaults to current)"
|
||||||
|
value="" readonly>
|
||||||
|
<button class="search-clear-btn" id="search-clear" title="Clear results">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="search-status" id="search-status"></div>
|
||||||
|
<div class="search-results" id="search-results"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const inputEl = container.querySelector('#search-input');
|
||||||
|
const runBtn = container.querySelector('#search-run');
|
||||||
|
const dirEl = container.querySelector('#search-dir');
|
||||||
|
const clearBtn = container.querySelector('#search-clear');
|
||||||
|
const statusEl = container.querySelector('#search-status');
|
||||||
|
const resultsEl = container.querySelector('#search-results');
|
||||||
|
const modeBtns = container.querySelectorAll('.search-mode-btn');
|
||||||
|
|
||||||
|
// Seed the dir from the explorer when the panel first opens
|
||||||
|
const initialDir = getCurrentDir && getCurrentDir();
|
||||||
|
if (initialDir) dirEl.value = initialDir;
|
||||||
|
else dirEl.placeholder = 'Folder (none — open one in Explorer first)';
|
||||||
|
|
||||||
|
function setMode(next) {
|
||||||
|
mode = next;
|
||||||
|
modeBtns.forEach((b) => b.classList.toggle('active', b.dataset.mode === next));
|
||||||
|
inputEl.placeholder =
|
||||||
|
next === 'search'
|
||||||
|
? 'Search notes — try: rust #lang @project-x "exact phrase"'
|
||||||
|
: 'Ask a question — try: what did I write about rust async';
|
||||||
|
}
|
||||||
|
|
||||||
|
modeBtns.forEach((b) => {
|
||||||
|
b.addEventListener('click', () => {
|
||||||
|
setMode(b.dataset.mode);
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setStatus(text, kind = 'info') {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = text || '';
|
||||||
|
statusEl.dataset.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(p) {
|
||||||
|
if (typeof p !== 'string') return '';
|
||||||
|
return p.split(/[/\\]/).pop() || p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(items) {
|
||||||
|
if (!resultsEl) return;
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
resultsEl.innerHTML = '<div class="search-empty">No matches.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resultsEl.innerHTML = items
|
||||||
|
.map((r, i) => {
|
||||||
|
const tagList = (r.matchedTags || []).map((t) => `#${t}`).join(' ');
|
||||||
|
const linkList = (r.matchedLinks || []).map((l) => `[[${l}]]`).join(' ');
|
||||||
|
const tagsHtml = tagList
|
||||||
|
? `<div class="search-result-tags">${escapeHtml(tagList)}</div>`
|
||||||
|
: '';
|
||||||
|
const linksHtml = linkList
|
||||||
|
? `<div class="search-result-links">${escapeHtml(linkList)}</div>`
|
||||||
|
: '';
|
||||||
|
const meta = [
|
||||||
|
r.score !== undefined ? `score ${Number(r.score).toFixed(1)}` : '',
|
||||||
|
r.offset ? `+${r.offset}` : '',
|
||||||
|
r.matchedTerms && r.matchedTerms.length ? r.matchedTerms.join(', ') : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
return `
|
||||||
|
<div class="search-result" data-idx="${i}" role="button" tabindex="0">
|
||||||
|
<div class="search-result-name">${escapeHtml(basename(r.filePath))}</div>
|
||||||
|
<div class="search-result-path">${escapeHtml(r.filePath)}</div>
|
||||||
|
<div class="search-result-snippet">${escapeHtml(r.snippet || '')}</div>
|
||||||
|
${tagsHtml}${linksHtml}
|
||||||
|
<div class="search-result-meta">${escapeHtml(meta)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
resultsEl.querySelectorAll('.search-result').forEach((el) => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const r = lastResults[Number(el.dataset.idx)];
|
||||||
|
if (r && onOpenFile) onOpenFile(r.filePath, r.offset || 0);
|
||||||
|
});
|
||||||
|
el.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||||
|
ev.preventDefault();
|
||||||
|
const r = lastResults[Number(el.dataset.idx)];
|
||||||
|
if (r && onOpenFile) onOpenFile(r.filePath, r.offset || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runQuery() {
|
||||||
|
const q = inputEl.value.trim();
|
||||||
|
if (!q) {
|
||||||
|
setStatus('Enter a query to search.', 'info');
|
||||||
|
lastResults = [];
|
||||||
|
renderResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = dirEl.value.trim() || (getCurrentDir && getCurrentDir()) || '';
|
||||||
|
if (!dir) {
|
||||||
|
setStatus('No folder selected — open one in the Explorer first.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('Searching…', 'working');
|
||||||
|
runBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (mode === 'ask') {
|
||||||
|
const r = await ask({ question: q, dir, topK: 5 });
|
||||||
|
lastResults = (r && r.chunks) || [];
|
||||||
|
setStatus(`Asked: "${q}" — ${lastResults.length} passage${lastResults.length === 1 ? '' : 's'}`);
|
||||||
|
} else {
|
||||||
|
const r = await search({ query: q, dir, limit: 50 });
|
||||||
|
lastResults = Array.isArray(r) ? r : [];
|
||||||
|
setStatus(`Searched — ${lastResults.length} match${lastResults.length === 1 ? '' : 'es'}`);
|
||||||
|
}
|
||||||
|
renderResults(lastResults);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Error: ${err && err.message ? err.message : 'unknown'}`, 'error');
|
||||||
|
lastResults = [];
|
||||||
|
renderResults([]);
|
||||||
|
} finally {
|
||||||
|
runBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runBtn.addEventListener('click', runQuery);
|
||||||
|
inputEl.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
runQuery();
|
||||||
|
} else if (ev.key === 'Escape') {
|
||||||
|
inputEl.value = '';
|
||||||
|
lastResults = [];
|
||||||
|
renderResults([]);
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clearBtn.addEventListener('click', () => {
|
||||||
|
inputEl.value = '';
|
||||||
|
lastResults = [];
|
||||||
|
renderResults([]);
|
||||||
|
setStatus('');
|
||||||
|
inputEl.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expose a small API so the host (renderer) can prefill the dir when the
|
||||||
|
// Explorer navigates to a new folder.
|
||||||
|
return {
|
||||||
|
setDir(d) {
|
||||||
|
if (typeof d === 'string') dirEl.value = d;
|
||||||
|
},
|
||||||
|
focus() {
|
||||||
|
inputEl.focus();
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
inputEl.value = '';
|
||||||
|
lastResults = [];
|
||||||
|
renderResults([]);
|
||||||
|
setStatus('');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { renderSearchPanel };
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* @jest-environment jsdom
|
||||||
|
*
|
||||||
|
* Search-panel DOM tests — verify the panel wires the existing backend
|
||||||
|
* (workspace-search / doc-qa) and renders results safely. We don't test
|
||||||
|
* pixel layout; we test the contract: click a result → calls onOpenFile
|
||||||
|
* with the right path.
|
||||||
|
*/
|
||||||
|
const { renderSearchPanel } = require('../src/sidebar/search-panel');
|
||||||
|
|
||||||
|
function mountPanel(deps = {}) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const api = renderSearchPanel(container, {
|
||||||
|
search: deps.search || jest.fn().mockResolvedValue([]),
|
||||||
|
ask: deps.ask || jest.fn().mockResolvedValue({ question: '', chunks: [] }),
|
||||||
|
getCurrentDir: deps.getCurrentDir || (() => '/notes'),
|
||||||
|
onOpenFile: deps.onOpenFile || jest.fn(),
|
||||||
|
});
|
||||||
|
return { container, api };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flush() {
|
||||||
|
// Drain any pending microtasks (search() → render)
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderSearchPanel — DOM wiring', () => {
|
||||||
|
test('mounts the panel structure inside its container', () => {
|
||||||
|
const { container } = mountPanel();
|
||||||
|
expect(container.querySelector('.search-panel')).not.toBeNull();
|
||||||
|
expect(container.querySelector('#search-input')).not.toBeNull();
|
||||||
|
expect(container.querySelector('#search-run')).not.toBeNull();
|
||||||
|
expect(container.querySelectorAll('.search-mode-btn')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seeds the folder input from getCurrentDir() on mount', () => {
|
||||||
|
const { container } = mountPanel({ getCurrentDir: () => '/data/notes' });
|
||||||
|
expect(container.querySelector('#search-dir').value).toBe('/data/notes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses a helpful placeholder when no folder is available', () => {
|
||||||
|
const { container } = mountPanel({ getCurrentDir: () => null });
|
||||||
|
const dirEl = container.querySelector('#search-dir');
|
||||||
|
expect(dirEl.value).toBe('');
|
||||||
|
expect(dirEl.placeholder).toMatch(/none/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSearchPanel — search mode', () => {
|
||||||
|
test('Enter on the input triggers a search() with the typed query and dir', async () => {
|
||||||
|
const search = jest.fn().mockResolvedValue([]);
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
const input = container.querySelector('#search-input');
|
||||||
|
input.value = 'rust async';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(search).toHaveBeenCalledWith({
|
||||||
|
query: 'rust async',
|
||||||
|
dir: '/notes',
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the run button triggers a search() too', async () => {
|
||||||
|
const search = jest.fn().mockResolvedValue([]);
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
container.querySelector('#search-input').value = 'foo';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
expect(search).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders result list when search returns hits', async () => {
|
||||||
|
const search = jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
filePath: '/notes/a.md',
|
||||||
|
snippet: 'rust async mention',
|
||||||
|
score: 4.5,
|
||||||
|
matchedTerms: ['rust'],
|
||||||
|
matchedTags: [],
|
||||||
|
matchedLinks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filePath: '/notes/b.md',
|
||||||
|
snippet: 'no match here',
|
||||||
|
score: 1,
|
||||||
|
matchedTerms: ['rust'],
|
||||||
|
matchedTags: ['lang'],
|
||||||
|
matchedLinks: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
container.querySelector('#search-input').value = 'rust';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('.search-result');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0].textContent).toContain('a.md');
|
||||||
|
expect(items[0].textContent).toContain('rust async mention');
|
||||||
|
// tag facet surfaces in the meta line
|
||||||
|
expect(items[1].textContent).toContain('#lang');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a result calls onOpenFile with the right path', async () => {
|
||||||
|
const onOpenFile = jest.fn();
|
||||||
|
const search = jest.fn().mockResolvedValue([
|
||||||
|
{ filePath: '/notes/x.md', snippet: 's', score: 1, matchedTerms: [] },
|
||||||
|
]);
|
||||||
|
const { container } = mountPanel({ search, onOpenFile });
|
||||||
|
container.querySelector('#search-input').value = 'x';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
container.querySelector('.search-result').click();
|
||||||
|
expect(onOpenFile).toHaveBeenCalledWith('/notes/x.md', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows "No matches." when the search returns []', async () => {
|
||||||
|
const { container } = mountPanel({ search: jest.fn().mockResolvedValue([]) });
|
||||||
|
container.querySelector('#search-input').value = 'absent';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(container.querySelector('.search-empty')).not.toBeNull();
|
||||||
|
expect(container.querySelector('#search-status').textContent).toMatch(/0 matches/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an error message when search rejects', async () => {
|
||||||
|
const search = jest.fn().mockRejectedValue(new Error('disk gone'));
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
container.querySelector('#search-input').value = 'x';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const status = container.querySelector('#search-status');
|
||||||
|
expect(status.textContent).toMatch(/disk gone/);
|
||||||
|
expect(status.dataset.kind).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to search when the query is empty', async () => {
|
||||||
|
const search = jest.fn();
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
expect(search).not.toHaveBeenCalled();
|
||||||
|
expect(container.querySelector('#search-status').textContent).toMatch(/Enter a query/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to search when no folder is available', async () => {
|
||||||
|
const search = jest.fn();
|
||||||
|
const { container } = mountPanel({ search, getCurrentDir: () => null });
|
||||||
|
container.querySelector('#search-input').value = 'x';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
expect(search).not.toHaveBeenCalled();
|
||||||
|
expect(container.querySelector('#search-status').textContent).toMatch(/No folder/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSearchPanel — Ask mode', () => {
|
||||||
|
test('clicking the Ask tab switches placeholder and routes to ask()', async () => {
|
||||||
|
const ask = jest.fn().mockResolvedValue({ question: '', chunks: [] });
|
||||||
|
const { container } = mountPanel({ ask });
|
||||||
|
container.querySelector('.search-mode-btn[data-mode="ask"]').click();
|
||||||
|
const input = container.querySelector('#search-input');
|
||||||
|
expect(input.placeholder).toMatch(/question/i);
|
||||||
|
|
||||||
|
input.value = 'how does rust async work';
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(ask).toHaveBeenCalledWith({
|
||||||
|
question: 'how does rust async work',
|
||||||
|
dir: '/notes',
|
||||||
|
topK: 5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Ask-mode chunks (filePath + snippet + offset)', async () => {
|
||||||
|
const ask = jest.fn().mockResolvedValue({
|
||||||
|
question: 'q',
|
||||||
|
chunks: [
|
||||||
|
{ filePath: '/notes/c.md', offset: 1234, snippet: '...passage...', score: 3, mtimeMs: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const { container } = mountPanel({ ask });
|
||||||
|
container.querySelector('.search-mode-btn[data-mode="ask"]').click();
|
||||||
|
container.querySelector('#search-input').value = 'q';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('.search-result');
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].textContent).toContain('c.md');
|
||||||
|
expect(items[0].textContent).toContain('+1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ask-mode click passes the offset through to onOpenFile', async () => {
|
||||||
|
const onOpenFile = jest.fn();
|
||||||
|
const ask = jest.fn().mockResolvedValue({
|
||||||
|
question: 'q',
|
||||||
|
chunks: [{ filePath: '/notes/c.md', offset: 999, snippet: 's', score: 1 }],
|
||||||
|
});
|
||||||
|
const { container } = mountPanel({ ask, onOpenFile });
|
||||||
|
container.querySelector('.search-mode-btn[data-mode="ask"]').click();
|
||||||
|
container.querySelector('#search-input').value = 'q';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
container.querySelector('.search-result').click();
|
||||||
|
expect(onOpenFile).toHaveBeenCalledWith('/notes/c.md', 999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSearchPanel — escaping', () => {
|
||||||
|
test('result rows escape HTML in filenames and snippets', async () => {
|
||||||
|
const search = jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
filePath: '/notes/<script>alert(1)</script>.md',
|
||||||
|
snippet: '<img src=x onerror=alert(1)>',
|
||||||
|
score: 1,
|
||||||
|
matchedTerms: [],
|
||||||
|
matchedTags: ['<unsafe>'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { container } = mountPanel({ search });
|
||||||
|
container.querySelector('#search-input').value = 'x';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// No executable elements should have been built — escapeHtml() prevents
|
||||||
|
// a hostile filename or snippet from running script/img in the panel.
|
||||||
|
expect(container.querySelector('script')).toBeNull();
|
||||||
|
expect(container.querySelector('img')).toBeNull();
|
||||||
|
// The literal text still appears (decoded by the browser); the safety
|
||||||
|
// is that it stays as text instead of becoming a node.
|
||||||
|
expect(container.querySelector('.search-result').textContent).toContain('<script>');
|
||||||
|
expect(container.querySelector('.search-result').textContent).toContain('<img');
|
||||||
|
expect(container.querySelector('.search-result').textContent).toContain('#<unsafe>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderSearchPanel — clear/escape behaviors', () => {
|
||||||
|
test('Escape on the input clears the query and results', async () => {
|
||||||
|
const search = jest.fn().mockResolvedValue([
|
||||||
|
{ filePath: '/a.md', snippet: 's', score: 1 },
|
||||||
|
]);
|
||||||
|
const { container, container: c2 } = mountPanel({ search });
|
||||||
|
const input = container.querySelector('#search-input');
|
||||||
|
input.value = 'x';
|
||||||
|
container.querySelector('#search-run').click();
|
||||||
|
await flush();
|
||||||
|
expect(container.querySelectorAll('.search-result')).toHaveLength(1);
|
||||||
|
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
expect(container.querySelector('.search-empty')).not.toBeNull();
|
||||||
|
// Clean up the c2 reference just to silence the unused-var lint warning.
|
||||||
|
void c2;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Clear button resets input + results', async () => {
|
||||||
|
const { container } = mountPanel({ search: jest.fn().mockResolvedValue([]) });
|
||||||
|
const input = container.querySelector('#search-input');
|
||||||
|
input.value = 'x';
|
||||||
|
container.querySelector('#search-clear').click();
|
||||||
|
expect(input.value).toBe('');
|
||||||
|
expect(container.querySelector('.search-empty')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returned API can set the dir + focus + clear from the host', () => {
|
||||||
|
const { container, api } = mountPanel();
|
||||||
|
api.setDir('/new/path');
|
||||||
|
expect(container.querySelector('#search-dir').value).toBe('/new/path');
|
||||||
|
// focus() and clear() are exposed; ensure they don't throw.
|
||||||
|
api.focus();
|
||||||
|
api.clear();
|
||||||
|
expect(container.querySelector('#search-input').value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user