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

@ -0,0 +1,275 @@
<script lang="ts">
import {
getHealthAggregates,
getAttentionQueue,
type ProjectHealth,
} from './health-store.svelte.ts';
interface Props {
projectCount: number;
totalTokens: number;
totalCost: number;
sessionDuration: string;
groupName: string;
onFocusProject?: (projectId: string) => void;
}
let {
projectCount,
totalTokens,
totalCost,
sessionDuration,
groupName,
onFocusProject,
}: Props = $props();
let health = $derived(getHealthAggregates());
let attentionQueue = $derived(getAttentionQueue(5));
let showAttention = $state(false);
function formatRate(rate: number): string {
if (rate < 0.01) return '$0/hr';
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
return `$${rate.toFixed(1)}/hr`;
}
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function fmtCost(n: number): string {
return `$${n.toFixed(3)}`;
}
function attentionColor(item: ProjectHealth): string {
if (item.attentionScore >= 90) return 'var(--ctp-red)';
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay1)';
}
function focusProject(projectId: string) {
onFocusProject?.(projectId);
showAttention = false;
}
</script>
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
<!-- Left: agent state counts -->
{#if health.running > 0}
<span class="status-segment">
<span class="dot green pulse-dot" aria-hidden="true"></span>
<span class="val">{health.running}</span>
<span>running</span>
</span>
{/if}
{#if health.idle > 0}
<span class="status-segment">
<span class="dot gray" aria-hidden="true"></span>
<span class="val">{health.idle}</span>
<span>idle</span>
</span>
{/if}
{#if health.stalled > 0}
<span class="status-segment stalled">
<span class="dot orange" aria-hidden="true"></span>
<span class="val">{health.stalled}</span>
<span>stalled</span>
</span>
{/if}
<!-- Attention queue -->
{#if attentionQueue.length > 0}
<button
class="status-segment attn-btn"
class:attn-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention"
>
<span class="dot orange pulse-dot" aria-hidden="true"></span>
<span class="val">{attentionQueue.length}</span>
<span>attention</span>
</button>
{/if}
<span class="spacer"></span>
<!-- Right: aggregates -->
{#if health.totalBurnRatePerHour > 0}
<span class="status-segment burn" title="Burn rate">
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
</span>
{/if}
<span class="status-segment" title="Active group">
<span class="val">{groupName}</span>
</span>
<span class="status-segment" title="Projects">
<span class="val">{projectCount}</span>
<span>projects</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="val">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens">
<span>tokens</span>
<span class="val">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total cost">
<span>cost</span>
<span class="val cost">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
</footer>
<!-- Attention dropdown -->
{#if showAttention && attentionQueue.length > 0}
<div class="attention-panel">
{#each attentionQueue as item (item.projectId)}
<button
class="attention-card"
onclick={() => focusProject(item.projectId)}
>
<span class="card-id">{item.projectId.slice(0, 12)}</span>
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
{#if item.contextPressure !== null && item.contextPressure > 0.5}
<span class="card-ctx">ctx {Math.round(item.contextPressure * 100)}%</span>
{/if}
</button>
{/each}
</div>
{/if}
<style>
.status-bar {
height: var(--status-bar-height, 1.5rem);
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);
font-family: var(--ui-font-family, system-ui, sans-serif);
user-select: none;
position: relative;
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.dot.green { background: var(--ctp-green); }
.dot.gray { background: var(--ctp-overlay0); }
.dot.orange { background: var(--ctp-peach); }
.pulse-dot {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.val { color: var(--ctp-text); font-weight: 500; }
.cost { color: var(--ctp-yellow); }
.burn { color: var(--ctp-mauve); font-weight: 600; }
.stalled { color: var(--ctp-peach); font-weight: 600; }
.spacer { flex: 1; }
/* Attention button */
.attn-btn {
background: none;
border: none;
color: var(--ctp-peach);
font: inherit;
font-size: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0;
font-weight: 600;
}
.attn-btn:hover, .attn-btn.attn-open {
color: var(--ctp-red);
}
/* Attention panel */
.attention-panel {
position: absolute;
bottom: var(--status-bar-height, 1.5rem);
left: 0;
right: 0;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface1);
display: flex;
gap: 1px;
padding: 0.25rem 0.5rem;
z-index: 100;
overflow-x: auto;
}
.attention-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.attention-card:hover {
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.card-id { font-weight: 600; }
.card-reason { font-size: 0.625rem; }
.card-ctx {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
padding: 0 0.25rem;
border-radius: 0.125rem;
}
.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, system-ui, sans-serif);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
</style>