- 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
301 lines
7.4 KiB
Svelte
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>
|