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
This commit is contained in:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal file
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue