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

@ -1,133 +1,352 @@
<script lang="ts">
interface FileNode {
import { appRpc } from './rpc.ts';
import CodeEditor from './CodeEditor.svelte';
import PdfViewer from './PdfViewer.svelte';
import CsvTable from './CsvTable.svelte';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
interface DirEntry {
name: string;
type: 'file' | 'dir';
children?: FileNode[];
size: number;
}
// Demo directory tree
const TREE: FileNode[] = [
{
name: 'src', type: 'dir', children: [
{
name: 'lib', type: 'dir', children: [
{
name: 'stores', type: 'dir', children: [
{ name: 'workspace.svelte.ts', type: 'file' },
{ name: 'agents.svelte.ts', type: 'file' },
{ name: 'health.svelte.ts', type: 'file' },
],
},
{
name: 'adapters', type: 'dir', children: [
{ name: 'claude-messages.ts', type: 'file' },
{ name: 'agent-bridge.ts', type: 'file' },
],
},
{ name: 'agent-dispatcher.ts', type: 'file' },
],
},
{ name: 'App.svelte', type: 'file' },
],
},
{
name: 'src-tauri', type: 'dir', children: [
{
name: 'src', type: 'dir', children: [
{ name: 'lib.rs', type: 'file' },
{ name: 'btmsg.rs', type: 'file' },
],
},
],
},
{ name: 'Cargo.toml', type: 'file' },
{ name: 'package.json', type: 'file' },
{ name: 'vite.config.ts', type: 'file' },
];
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
// Tree state: track loaded children and open/closed per path
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
let openDirs = $state<Set<string>>(new Set());
let loadingDirs = $state<Set<string>>(new Set());
let selectedFile = $state<string | null>(null);
function toggleDir(path: string) {
const s = new Set(openDirs);
if (s.has(path)) s.delete(path);
else s.add(path);
openDirs = s;
// File viewer state
let fileContent = $state<string | null>(null);
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
let fileSize = $state(0);
let fileError = $state<string | null>(null);
let fileLoading = $state(false);
let isDirty = $state(false);
let editorContent = $state('');
// Extension-based type detection
const CODE_EXTS = new Set([
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
'py', 'rs', 'go', 'css', 'scss', 'less',
'html', 'svelte', 'vue',
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
]);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
const PDF_EXTS = new Set(['pdf']);
const CSV_EXTS = new Set(['csv', 'tsv']);
function getExt(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function selectFile(path: string) {
selectedFile = path;
type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
function detectFileType(name: string): FileType {
const ext = getExt(name);
if (PDF_EXTS.has(ext)) return 'pdf';
if (CSV_EXTS.has(ext)) return 'csv';
if (IMAGE_EXTS.has(ext)) return 'image';
if (CODE_EXTS.has(ext)) return 'code';
return 'text';
}
/** Map extension to CodeMirror language name. */
function extToLang(name: string): string {
const ext = getExt(name);
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',
py: 'python', rs: 'rust', go: 'go',
css: 'css', scss: 'css', less: 'css',
html: 'html', svelte: 'html', vue: 'html',
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
toml: 'toml', sh: 'bash', bash: 'bash',
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
java: 'java', php: 'php',
};
return map[ext] ?? 'text';
}
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
/** Load directory entries via RPC. */
async function loadDir(dirPath: string) {
if (childrenCache.has(dirPath)) return;
const key = dirPath;
loadingDirs = new Set([...loadingDirs, key]);
try {
const result = await appRpc.request["files.list"]({ path: dirPath });
if (result.error) {
console.error(`[files.list] ${dirPath}: ${result.error}`);
return;
}
const next = new Map(childrenCache);
next.set(dirPath, result.entries);
childrenCache = next;
} catch (err) {
console.error('[files.list]', err);
} finally {
const s = new Set(loadingDirs);
s.delete(key);
loadingDirs = s;
}
}
/** Toggle a directory open/closed. Lazy-loads on first open. */
async function toggleDir(dirPath: string) {
const s = new Set(openDirs);
if (s.has(dirPath)) {
s.delete(dirPath);
openDirs = s;
} else {
s.add(dirPath);
openDirs = s;
await loadDir(dirPath);
}
}
/** Select and load a file. */
async function selectFile(filePath: string) {
if (selectedFile === filePath) return;
selectedFile = filePath;
isDirty = false;
fileContent = null;
fileError = null;
fileLoading = true;
const type = detectFileType(filePath);
// PDF uses its own loader via PdfViewer
if (type === 'pdf') {
fileLoading = false;
return;
}
// Images: read as base64 for display
if (type === 'image') {
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
return;
}
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
editorContent = fileContent;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
}
/** Save current file. */
async function saveFile() {
if (!selectedFile || !isDirty) return;
try {
const result = await appRpc.request["files.write"]({
path: selectedFile,
content: editorContent,
});
if (result.ok) {
isDirty = false;
fileContent = editorContent;
} else if (result.error) {
console.error('[files.write]', result.error);
}
} catch (err) {
console.error('[files.write]', err);
}
}
function onEditorChange(newContent: string) {
editorContent = newContent;
isDirty = newContent !== fileContent;
}
function fileIcon(name: string): string {
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩';
if (name.endsWith('.svelte')) return '◈';
if (name.endsWith('.rs')) return '⊕';
if (name.endsWith('.toml')) return '⚙';
if (name.endsWith('.json')) return '{}';
return '·';
const ext = getExt(name);
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
if (ext === 'svelte' || ext === 'vue') return '~';
if (ext === 'rs') return 'Rs';
if (ext === 'py') return 'Py';
if (ext === 'go') return 'Go';
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
if (ext === 'md') return 'M';
if (ext === 'css' || ext === 'scss') return '#';
if (ext === 'html') return '<>';
if (IMAGE_EXTS.has(ext)) return 'Im';
if (ext === 'pdf') return 'Pd';
if (CSV_EXTS.has(ext)) return 'Tb';
return '..';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Load root directory on mount
$effect(() => {
if (cwd) {
loadDir(cwd);
const s = new Set(openDirs);
s.add(cwd);
openDirs = s;
}
});
</script>
<div class="file-browser">
<!-- Tree panel -->
<div class="fb-tree">
{#snippet renderNode(node: FileNode, path: string, depth: number)}
{#if node.type === 'dir'}
<button
class="fb-row fb-dir"
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => toggleDir(path)}
aria-expanded={openDirs.has(path)}
>
<span class="fb-chevron" class:open={openDirs.has(path)}></span>
<span class="fb-icon dir-icon">📁</span>
<span class="fb-name">{node.name}</span>
</button>
{#if openDirs.has(path) && node.children}
{#each node.children as child}
{@render renderNode(child, `${path}/${child.name}`, depth + 1)}
{/each}
{/if}
{:else}
<button
class="fb-row fb-file"
class:selected={selectedFile === path}
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => selectFile(path)}
title={path}
>
<span class="fb-icon file-type">{fileIcon(node.name)}</span>
<span class="fb-name">{node.name}</span>
</button>
{#snippet renderEntries(dirPath: string, depth: number)}
{#if childrenCache.has(dirPath)}
{#each childrenCache.get(dirPath) ?? [] as entry}
{@const fullPath = `${dirPath}/${entry.name}`}
{#if entry.type === 'dir'}
<button
class="fb-row fb-dir"
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => toggleDir(fullPath)}
aria-expanded={openDirs.has(fullPath)}
>
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
</span>
<span class="fb-name">{entry.name}</span>
</button>
{#if openDirs.has(fullPath)}
{@render renderEntries(fullPath, depth + 1)}
{/if}
{:else}
<button
class="fb-row fb-file"
class:selected={selectedFile === fullPath}
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => selectFile(fullPath)}
title={`${entry.name} (${formatSize(entry.size)})`}
>
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
<span class="fb-name">{entry.name}</span>
{#if selectedFile === fullPath && isDirty}
<span class="dirty-dot" title="Unsaved changes"></span>
{/if}
</button>
{/if}
{/each}
{:else if loadingDirs.has(dirPath)}
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
{/if}
{/snippet}
{#each TREE as node}
{@render renderNode(node, node.name, 0)}
{/each}
{@render renderEntries(cwd, 0)}
</div>
{#if selectedFile}
<div class="fb-preview">
<div class="fb-preview-label">{selectedFile}</div>
<div class="fb-preview-content">(click to open in editor)</div>
</div>
{/if}
<!-- Viewer panel -->
<div class="fb-viewer">
{#if !selectedFile}
<div class="fb-empty">Select a file to view</div>
{:else if fileLoading}
<div class="fb-empty">Loading...</div>
{:else if fileError}
<div class="fb-error">{fileError}</div>
{:else if selectedType === 'pdf'}
<PdfViewer filePath={selectedFile} />
{:else if selectedType === 'csv' && fileContent != null}
<CsvTable content={fileContent} filename={selectedName} />
{:else if selectedType === 'image' && fileContent}
{@const ext = getExt(selectedName)}
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
<div class="fb-image-wrap">
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
<img
class="fb-image"
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
alt={selectedName}
/>
</div>
{:else if selectedType === 'code' && fileContent != null}
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang={extToLang(selectedFile)}
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{:else if fileContent != null}
<!-- Raw text fallback -->
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang="text"
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{/if}
</div>
</div>
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
font-size: 0.8125rem;
}
/* ── Tree panel ── */
.fb-tree {
flex: 1;
width: 14rem;
min-width: 10rem;
overflow-y: auto;
padding: 0.25rem 0;
border-right: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
@ -163,25 +382,25 @@
.fb-chevron {
display: inline-block;
width: 0.875rem;
font-size: 0.875rem;
font-size: 0.75rem;
color: var(--ctp-overlay1);
transition: transform 0.12s;
transform: rotate(0deg);
flex-shrink: 0;
line-height: 1;
font-family: var(--term-font-family, monospace);
}
.fb-chevron.open { transform: rotate(90deg); }
.fb-icon {
flex-shrink: 0;
font-style: normal;
}
.fb-icon { flex-shrink: 0; font-style: normal; }
.file-type {
font-size: 0.6875rem;
font-size: 0.6rem;
color: var(--ctp-overlay1);
font-family: var(--term-font-family);
font-family: var(--term-font-family, monospace);
width: 1.25rem;
text-align: center;
}
.fb-name {
@ -192,26 +411,104 @@
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
.fb-preview {
border-top: 1px solid var(--ctp-surface0);
padding: 0.5rem 0.75rem;
.fb-loading {
font-size: 0.7rem;
color: var(--ctp-overlay0);
font-style: italic;
padding: 0.15rem 0;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-peach);
flex-shrink: 0;
margin-left: auto;
}
/* ── Viewer panel ── */
.fb-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ctp-base);
}
.fb-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
font-style: italic;
}
.fb-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
.fb-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.fb-preview-label {
font-size: 0.75rem;
.fb-editor-path {
font-size: 0.7rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family);
margin-bottom: 0.2rem;
font-family: var(--term-font-family, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fb-preview-content {
font-size: 0.75rem;
color: var(--ctp-overlay0);
.dirty-indicator {
color: var(--ctp-peach);
font-style: italic;
}
.fb-editor-size {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
margin-left: 0.5rem;
}
.fb-image-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
}
.fb-image-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family, monospace);
flex-shrink: 0;
padding: 0.5rem;
}
.fb-image {
max-width: 100%;
max-height: calc(100% - 2rem);
object-fit: contain;
border-radius: 0.25rem;
}
</style>