fix(electrobun): address all 22 Codex review #2 findings
CRITICAL:
- DocsTab XSS: DOMPurify sanitization on all {@html} output
- File RPC path traversal: guardPath() validates against project CWDs
HIGH:
- SSH injection: spawn /usr/bin/ssh via PTY args, no shell string
- Search XSS: strip HTML, highlight matches client-side with <mark>
- Terminal listener leak: cleanup functions stored + called in onDestroy
- FileBrowser race: request token, discard stale responses
- SearchOverlay race: same request token pattern
- App startup ordering: groups.list chains into active_group restore
- PtyClient timeout: 5-second auth timeout on connect()
- Rule 55: 6 {#if} patterns converted to style:display toggle
MEDIUM:
- Agent persistence: only persist NEW messages (lastPersistedIndex)
- Search errors: typed error response, "Invalid query" UI
- Health store wired: agent events call recordActivity/setProjectStatus
- index.ts SRP: split into 8 domain handler modules (298 lines)
- App.svelte: extracted workspace-store.svelte.ts
- rpc.ts: typed AppRpcHandle, removed `any`
LOW:
- CommandPalette listener wired in App.svelte
- Dead code removed (removeGroup, onDragStart, plugin loaded)
This commit is contained in:
parent
8e756d3523
commit
1cd4558740
28 changed files with 1342 additions and 1164 deletions
|
|
@ -22,9 +22,12 @@
|
|||
let loading = $state(false);
|
||||
let selectedIndex = $state(0);
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
// Debounce timer
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// Fix #7: Request counter to discard stale results
|
||||
let requestToken = 0;
|
||||
|
||||
// Group results by type
|
||||
let grouped = $derived(() => {
|
||||
|
|
@ -52,18 +55,30 @@
|
|||
const q = query.trim();
|
||||
if (!q) {
|
||||
results = [];
|
||||
searchError = null;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
searchError = null;
|
||||
const token = ++requestToken;
|
||||
try {
|
||||
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
|
||||
results = res.results ?? [];
|
||||
// Fix #7: Discard stale results if a newer request was issued
|
||||
if (token !== requestToken) return;
|
||||
if (res.error) {
|
||||
searchError = res.error;
|
||||
results = [];
|
||||
} else {
|
||||
results = res.results ?? [];
|
||||
}
|
||||
selectedIndex = 0;
|
||||
} catch (err) {
|
||||
if (token !== requestToken) return;
|
||||
console.error('[search]', err);
|
||||
results = [];
|
||||
searchError = 'Search failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
if (token === requestToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +110,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix #4: Render snippet as plain text, highlight query matches client-side with <mark>.
|
||||
* Strips any HTML from snippet (from FTS5 <b> tags), then highlights matches safely.
|
||||
*/
|
||||
function highlightQuery(snippet: string, q: string): string {
|
||||
// Strip existing HTML tags (FTS5 returns <b>...</b>)
|
||||
const plain = snippet.replace(/<[^>]*>/g, '');
|
||||
// Escape HTML entities
|
||||
const escaped = plain
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
if (!q.trim()) return escaped;
|
||||
// Escape regex special chars in query
|
||||
const safeQ = q.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const re = new RegExp(`(${safeQ})`, 'gi');
|
||||
return escaped.replace(re, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
// Focus input when opened
|
||||
$effect(() => {
|
||||
if (open && inputEl) {
|
||||
|
|
@ -108,11 +142,11 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<!-- Fix #11: display toggle instead of {#if} to avoid DOM add/remove during click events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-backdrop" style:display={open ? 'flex' : 'none'} onclick={onClose} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
|
||||
<div class="search-input-wrap">
|
||||
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
|
|
@ -145,18 +179,19 @@
|
|||
onmouseenter={() => selectedIndex = flatIdx}
|
||||
>
|
||||
<span class="result-title">{item.title}</span>
|
||||
<span class="result-snippet">{@html item.snippet}</span>
|
||||
<span class="result-snippet">{@html highlightQuery(item.snippet, query)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if searchError}
|
||||
<div class="no-results search-error">Invalid query: {searchError}</div>
|
||||
{:else if query.trim() && !loading}
|
||||
<div class="no-results">No results for "{query}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay-backdrop {
|
||||
|
|
@ -283,9 +318,14 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-snippet :global(b) {
|
||||
.result-snippet :global(mark) {
|
||||
color: var(--ctp-yellow);
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.search-error {
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue