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:
Hibryda 2026-03-22 01:36:02 +01:00
parent 29a3370e79
commit 252fca70df
22 changed files with 8116 additions and 227 deletions

View file

@ -5,9 +5,12 @@
import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte';
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
import StatusBar from './StatusBar.svelte';
import SearchOverlay from './SearchOverlay.svelte';
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { trackProject } from './health-store.svelte.ts';
import { appRpc } from './rpc.ts';
// ── Types ─────────────────────────────────────────────────────
@ -137,6 +140,7 @@
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let searchOpen = $state(false);
let sessionStart = $state(Date.now());
let notifications = $state<Notification[]>([
@ -236,15 +240,8 @@
}
// ── Status bar aggregates ──────────────────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
let attentionItems = $derived(PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75));
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
@ -299,13 +296,29 @@
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
keybindingStore.on('minimize', () => handleMinimize());
// Ctrl+Shift+F for search overlay
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
searchOpen = !searchOpen;
}
}
document.addEventListener('keydown', handleSearchShortcut);
// Track projects for health monitoring
for (const p of PROJECTS) trackProject(p.id);
const cleanup = keybindingStore.installListener();
return cleanup;
return () => {
cleanup();
document.removeEventListener('keydown', handleSearchShortcut);
};
});
</script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
<ToastContainer />
<NotifDrawer
open={drawerOpen}
@ -423,59 +436,14 @@
</aside>
</div>
<!-- Status bar -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
{#if runningCount > 0}
<span class="status-segment">
<span class="status-dot-sm green" aria-hidden="true"></span>
<span class="status-value">{runningCount}</span>
<span>running</span>
</span>
{/if}
{#if idleCount > 0}
<span class="status-segment">
<span class="status-dot-sm gray" aria-hidden="true"></span>
<span class="status-value">{idleCount}</span>
<span>idle</span>
</span>
{/if}
{#if stalledCount > 0}
<span class="status-segment">
<span class="status-dot-sm orange" aria-hidden="true"></span>
<span class="status-value">{stalledCount}</span>
<span>stalled</span>
</span>
{/if}
{#if attentionItems.length > 0}
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p => p.name).join(', ')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="status-value">{attentionItems.length}</span>
<span>attention</span>
</span>
{/if}
<span class="status-bar-spacer"></span>
<span class="status-segment" title="Active group">
<span class="status-value">{activeGroup?.name}</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="status-value">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens used">
<span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total session cost">
<span>cost</span>
<span class="status-value">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<!-- Status bar (health-backed) -->
<StatusBar
projectCount={filteredProjects.length}
{totalTokens}
totalCost={totalCost}
{sessionDuration}
groupName={activeGroup?.name ?? ''}
/>
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
@ -728,55 +696,5 @@
line-height: 1;
}
/* ── Status bar ───────────────────────────────────────────── */
.status-bar {
height: var(--status-bar-height);
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0 0.625rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--ctp-subtext0);
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.status-dot-sm {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot-sm.green { background: var(--ctp-green); }
.status-dot-sm.gray { background: var(--ctp-overlay0); }
.status-dot-sm.orange { background: var(--ctp-peach); }
.status-value { color: var(--ctp-text); font-weight: 500; }
.status-bar-spacer { flex: 1; }
.attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
.palette-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
/* Status bar styles are in StatusBar.svelte */
</style>