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" />
|
||||
</svg>
|
||||
</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 class="sidebar-panel" id="sidebar-panel">
|
||||
<div class="sidebar-panel-header">
|
||||
|
||||
@@ -2197,6 +2197,32 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
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', {
|
||||
title: 'Git',
|
||||
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 };
|
||||
Reference in New Issue
Block a user