agent-orchestrator/ui-electrobun/src/mainview/FileBrowser.svelte
Hibryda f0850f0785 feat: @agor/stores package (3 stores) + 58 BackendAdapter tests
@agor/stores:
- theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted
- Original files replaced with re-exports (zero consumer changes needed)
- pnpm workspace + Vite/tsconfig aliases configured

BackendAdapter tests (58 new):
- backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam)
- tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params)
- electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs)

Total: 523 tests passing (was 465, +58)
2026-03-22 04:45:56 +01:00

632 lines
18 KiB
Svelte

<script lang="ts">
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';
size: number;
}
// 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);
// 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('');
// Fix #6: Request token to discard stale file load responses
let fileRequestToken = 0;
// Feature 2: Track mtime at read time for conflict detection
let readMtimeMs = $state(0);
let showConflictDialog = $state(false);
// 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() : '';
}
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. Fix #6: uses request token to discard stale responses. */
async function selectFile(filePath: string) {
if (selectedFile === filePath) return;
selectedFile = filePath;
isDirty = false;
fileContent = null;
fileError = null;
fileLoading = true;
const token = ++fileRequestToken;
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 (token !== fileRequestToken) return;
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
} catch (err) {
if (token !== fileRequestToken) return;
fileError = err instanceof Error ? err.message : String(err);
} finally {
if (token === fileRequestToken) fileLoading = false;
}
return;
}
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (token !== fileRequestToken) return;
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
editorContent = fileContent;
// Feature 2: Record mtime at read time
try {
const stat = await appRpc.request["files.stat"]({ path: filePath });
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
} catch { /* non-critical */ }
} catch (err) {
if (token !== fileRequestToken) return;
fileError = err instanceof Error ? err.message : String(err);
} finally {
if (token === fileRequestToken) fileLoading = false;
}
}
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
async function saveFile() {
if (!selectedFile || !isDirty) return;
try {
// Feature 2: Check if file was modified externally since we read it
if (readMtimeMs > 0) {
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
if (!stat.error && stat.mtimeMs > readMtimeMs) {
showConflictDialog = true;
return;
}
}
await doSave();
} catch (err) {
console.error('[files.write]', err);
}
}
/** Force-save, bypassing conflict check. */
async function doSave() {
if (!selectedFile) return;
try {
const result = await appRpc.request["files.write"]({
path: selectedFile,
content: editorContent,
});
if (result.ok) {
isDirty = false;
fileContent = editorContent;
showConflictDialog = false;
// Update mtime after successful save
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
if (!stat.error) readMtimeMs = stat.mtimeMs;
} else if (result.error) {
console.error('[files.write]', result.error);
}
} catch (err) {
console.error('[files.write]', err);
}
}
/** Reload file from disk (discard local changes). */
async function reloadFile() {
showConflictDialog = false;
if (selectedFile) {
isDirty = false;
const saved = selectedFile;
selectedFile = null;
await selectFile(saved);
}
}
function onEditorChange(newContent: string) {
editorContent = newContent;
isDirty = newContent !== fileContent;
}
function fileIcon(name: string): string {
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 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}
{@render renderEntries(cwd, 0)}
</div>
<!-- Feature 2: Conflict dialog -->
{#if showConflictDialog}
<div class="conflict-overlay">
<div class="conflict-dialog">
<p class="conflict-title">File modified externally</p>
<p class="conflict-desc">This file was changed on disk since you opened it.</p>
<div class="conflict-actions">
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
</div>
</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;
height: 100%;
overflow: hidden;
font-size: 0.8125rem;
position: relative;
}
/* ── Tree panel ── */
.fb-tree {
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; }
.fb-tree::-webkit-scrollbar-track { background: transparent; }
.fb-tree::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
.fb-row {
display: flex;
align-items: center;
gap: 0.3rem;
width: 100%;
background: transparent;
border: none;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding-top: 0.2rem;
padding-bottom: 0.2rem;
padding-right: 0.5rem;
cursor: pointer;
text-align: left;
white-space: nowrap;
transition: background 0.08s;
}
.fb-row:hover { background: var(--ctp-surface0); }
.fb-file.selected {
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 15%, transparent);
color: var(--accent, var(--ctp-mauve));
}
.fb-chevron {
display: inline-block;
width: 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; }
.file-type {
font-size: 0.6rem;
color: var(--ctp-overlay1);
font-family: var(--term-font-family, monospace);
width: 1.25rem;
text-align: center;
}
.fb-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
.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-editor-path {
font-size: 0.7rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
/* Feature 2: Conflict dialog */
.conflict-overlay {
position: absolute;
inset: 0;
z-index: 50;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.conflict-dialog {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
max-width: 20rem;
}
.conflict-title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ctp-peach);
}
.conflict-desc {
margin: 0 0 0.75rem;
font-size: 0.75rem;
color: var(--ctp-subtext0);
}
.conflict-actions {
display: flex;
gap: 0.375rem;
}
.conflict-btn {
flex: 1;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface1);
background: var(--ctp-surface0);
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.conflict-btn.overwrite { border-color: var(--ctp-red); color: var(--ctp-red); }
.conflict-btn.reload { border-color: var(--ctp-blue); color: var(--ctp-blue); }
.conflict-btn:hover { background: var(--ctp-surface1); }
</style>