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:
2026-09-14 08:53:27 +05:30
parent 3f856bc857
commit da97369b44
4 changed files with 551 additions and 0 deletions
+16
View File
@@ -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 &amp; 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">
+26
View File
@@ -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) =>
+223
View File
@@ -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 &quot;exact phrase&quot;"
autocomplete="off">
<button class="search-run-btn" id="search-run" title="Run (Enter)">&#x1F50D;</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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
+286
View File
@@ -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('');
});
});