Files
sencho/frontend/src/components/files/FileViewer.tsx
T
Anso 3e56696c91 fix(stack-files): prompt before discarding unsaved edits on file switch (#1203)
FileViewer tracks dirty state via content !== originalContent, but
StackFileExplorer would swap selectedPath on every tree click and silently
drop the buffered edit. A user editing a script and clicking a sibling
file lost their work with no warning.

FileViewer now exposes onDirtyChange so the explorer learns when the
viewer is dirty. StackFileExplorer intercepts the tree-node click: if
dirty, the next selection is stashed and a ConfirmModal asks whether to
discard. Confirm applies the stash, Cancel keeps the current file.
Clicking the already-selected file is a no-op (no spurious prompt).

The dirty signal is reported via a ref so future consumers passing an
inline callback identity each render do not retrigger the unmount
cleanup effect.
2026-05-24 23:25:52 -04:00

295 lines
8.0 KiB
TypeScript

import { useState, useEffect, useMemo, useRef, Suspense } from 'react';
import { Editor } from '@/lib/monacoLoader';
import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi';
import { extensionToLanguage } from '@/lib/monacoLanguages';
import { formatBytes } from '@/lib/utils';
interface FileViewerProps {
stackName: string;
selectedPath: string | null;
canEdit: boolean;
isDarkMode: boolean;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}
function getFilename(path: string): string {
return path.split('/').pop() ?? path;
}
interface SpecialFilePanelProps {
filename: string;
size: number;
label: string;
stackName: string;
relPath: string;
}
function SpecialFilePanel({
filename,
size,
label,
stackName,
relPath,
}: SpecialFilePanelProps) {
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
const res = await downloadStackFile(stackName, relPath);
if (!res.ok) {
toast.error('Download failed.');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 100);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Download failed.');
} finally {
setDownloading(false);
}
};
return (
<div className="flex flex-col items-center justify-center gap-4 h-full min-h-[200px] text-muted-foreground">
<FileIcon className="w-10 h-10 text-stat-icon" strokeWidth={1.25} />
<div className="text-center space-y-1">
<p className="font-mono text-sm text-stat-title">{filename}</p>
<p className="text-xs text-stat-subtitle">{label} &middot; {formatBytes(size)}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void handleDownload()}
disabled={downloading}
>
{downloading ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
) : (
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
)}
Download
</Button>
</div>
);
}
export function FileViewer({
stackName,
selectedPath,
canEdit,
isDarkMode,
onSaved,
onDirtyChange,
}: FileViewerProps) {
const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState('');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isBinary, setIsBinary] = useState(false);
const [isOversized, setIsOversized] = useState(false);
const [size, setSize] = useState(0);
const readOnly = !canEdit;
const hasChanges = content !== originalContent;
// Stash the latest callback in a ref so the unmount-cleanup effect can be
// truly unmount-scoped without re-running every time a parent passes a fresh
// function identity.
const onDirtyChangeRef = useRef(onDirtyChange);
useEffect(() => {
onDirtyChangeRef.current = onDirtyChange;
}, [onDirtyChange]);
useEffect(() => {
onDirtyChangeRef.current?.(hasChanges);
}, [hasChanges]);
useEffect(() => {
return () => onDirtyChangeRef.current?.(false);
}, []);
const editorOptions = useMemo(
() => ({
readOnly,
minimap: { enabled: false },
fontFamily: "'Geist Mono', monospace",
fontSize: 13,
padding: { top: 8 },
scrollBeyondLastLine: false,
}),
[readOnly],
);
useEffect(() => {
if (!selectedPath) {
setContent('');
setOriginalContent('');
setIsBinary(false);
setIsOversized(false);
setError(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
setIsBinary(false);
setIsOversized(false);
readStackFile(stackName, selectedPath)
.then((result) => {
if (cancelled) return;
setSize(result.size);
if (result.binary) {
setIsBinary(true);
} else if (result.oversized) {
setIsOversized(true);
} else {
const text = result.content ?? '';
setContent(text);
setOriginalContent(text);
}
})
.catch((e: unknown) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : 'Failed to load file.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [stackName, selectedPath]);
const handleSave = async () => {
if (!selectedPath) return;
setSaving(true);
const loadingId = toast.loading('Saving...');
try {
await writeStackFile(stackName, selectedPath, content);
setOriginalContent(content);
toast.success('Saved.');
onSaved?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Save failed.');
} finally {
toast.dismiss(loadingId);
setSaving(false);
}
};
if (!selectedPath) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Select a file to view it
</div>
);
}
if (loading) {
return (
<div className="flex flex-col h-full p-3 gap-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="flex-1 w-full" />
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center gap-2 h-full text-muted-foreground">
<AlertCircle className="w-6 h-6 text-destructive" strokeWidth={1.5} />
<p className="text-sm text-center px-4">{error}</p>
</div>
);
}
const filename = getFilename(selectedPath);
const language = extensionToLanguage(filename);
if (isBinary) {
return (
<SpecialFilePanel
filename={filename}
size={size}
label="Binary file"
stackName={stackName}
relPath={selectedPath}
/>
);
}
if (isOversized) {
return (
<SpecialFilePanel
filename={filename}
size={size}
label="File too large to preview"
stackName={stackName}
relPath={selectedPath}
/>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-glass-border shrink-0">
<span className="font-mono text-xs text-stat-subtitle truncate">{filename}</span>
<div className="flex items-center gap-2 shrink-0">
{readOnly && (
<span className="text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle border border-border rounded px-1.5 py-0.5">
Read-only
</span>
)}
{!readOnly && (
<Button
size="sm"
className="h-7"
onClick={() => void handleSave()}
disabled={saving || !hasChanges}
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" strokeWidth={1.5} />
) : (
<Save className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
)}
Save
</Button>
)}
</div>
</div>
<div className="flex-1 min-h-0">
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<Editor
height="100%"
language={language}
value={content}
onChange={(val) => {
if (!readOnly) setContent(val ?? '');
}}
theme={isDarkMode ? 'vs-dark' : 'light'}
options={editorOptions}
/>
</Suspense>
</div>
</div>
);
}