mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
3e56696c91
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.
295 lines
8.0 KiB
TypeScript
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} · {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>
|
|
);
|
|
}
|