agent-orchestrator/ui-electrobun/src/mainview/SearchOverlay.svelte
Hibryda 252fca70df feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O
- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
2026-03-22 01:36:02 +01:00

301 lines
7.4 KiB
Svelte

<script lang="ts">
import { appRpc } from './rpc.ts';
interface Props {
open: boolean;
onClose: () => void;
onNavigate?: (resultType: string, id: string) => void;
}
let { open, onClose, onNavigate }: Props = $props();
interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
let query = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Group results by type
let grouped = $derived(() => {
const groups: Record<string, SearchResult[]> = {};
for (const r of results) {
const key = r.resultType;
if (!groups[key]) groups[key] = [];
groups[key].push(r);
}
return groups;
});
let groupLabels: Record<string, string> = {
message: 'Messages',
task: 'Tasks',
btmsg: 'Communications',
};
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(), 300);
}
async function doSearch() {
const q = query.trim();
if (!q) {
results = [];
return;
}
loading = true;
try {
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
results = res.results ?? [];
selectedIndex = 0;
} catch (err) {
console.error('[search]', err);
results = [];
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < results.length - 1) selectedIndex++;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) selectedIndex--;
} else if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
const item = results[selectedIndex];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
}
function selectResult(idx: number) {
const item = results[idx];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
// Focus input when opened
$effect(() => {
if (open && inputEl) {
requestAnimationFrame(() => inputEl?.focus());
}
if (!open) {
query = '';
results = [];
selectedIndex = 0;
}
});
</script>
{#if open}
<!-- 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="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"/>
</svg>
<input
bind:this={inputEl}
class="search-input"
type="text"
placeholder="Search messages, tasks, communications..."
bind:value={query}
oninput={handleInput}
/>
{#if loading}
<span class="loading-dot" aria-label="Searching"></span>
{/if}
<kbd class="esc-hint">Esc</kbd>
</div>
{#if results.length > 0}
<div class="results-list">
{#each Object.entries(grouped()) as [type, items]}
<div class="result-group">
<div class="group-label">{groupLabels[type] ?? type}</div>
{#each items as item, i}
{@const flatIdx = results.indexOf(item)}
<button
class="result-item"
class:selected={flatIdx === selectedIndex}
onclick={() => selectResult(flatIdx)}
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="result-title">{item.title}</span>
<span class="result-snippet">{@html item.snippet}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else if query.trim() && !loading}
<div class="no-results">No results for "{query}"</div>
{/if}
</div>
</div>
{/if}
<style>
.overlay-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
z-index: 200;
display: flex;
justify-content: center;
padding-top: 15vh;
}
.overlay-panel {
width: min(36rem, 90vw);
max-height: 60vh;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.search-icon {
width: 1rem;
height: 1rem;
color: var(--ctp-overlay1);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--ctp-text);
font-size: 0.875rem;
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.search-input::placeholder {
color: var(--ctp-overlay0);
}
.loading-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-blue);
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.esc-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.625rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.results-list {
overflow-y: auto;
padding: 0.25rem 0;
}
.result-group {
padding: 0.25rem 0;
}
.group-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 1rem;
}
.result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 1rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
color: var(--ctp-text);
font: inherit;
font-size: 0.8125rem;
}
.result-item:hover, .result-item.selected {
background: var(--ctp-surface0);
}
.result-title {
font-weight: 500;
color: var(--ctp-text);
}
.result-snippet {
font-size: 0.75rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-snippet :global(b) {
color: var(--ctp-yellow);
font-weight: 600;
}
.no-results {
padding: 1.5rem 1rem;
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
}
.results-list::-webkit-scrollbar { width: 0.375rem; }
.results-list::-webkit-scrollbar-track { background: transparent; }
.results-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
</style>