mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
535023b350
* feat(files): open stack file explorer to every tier
Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.
The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.
* docs(files): treat download as a read action, not a write
Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.
* test(e2e): align stack-files spec with the new tier rule
The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.
Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
278 lines
7.5 KiB
TypeScript
278 lines
7.5 KiB
TypeScript
import { useState, useEffect, useMemo, 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;
|
|
}
|
|
|
|
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,
|
|
}: 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 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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const hasChanges = content !== originalContent;
|
|
|
|
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>
|
|
);
|
|
}
|