mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
feat(files): per-stack file explorer (#780)
* feat(files): backend foundation for stack file explorer
Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.
* fix(files): reject bare dot segments in isValidRelativeStackPath
* feat(files): add safe stack-scoped file I/O methods to FileSystemService
Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.
Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.
Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.
* feat(files): frontend API wrappers and Monaco language helper
* fix(files): tighten stackFilesApi error handling and localOnly support
* fix(files): FileSystemService safety and correctness fixes
* feat(files): add file explorer API endpoints to stacks router
* feat(files): FileTree and FileTreeNode components
* fix(files): route security hardening and stream cleanup
* fix(files): FileTree accessibility, icon stroke, stale fetch guard
Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.
* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm
* fix(files): resolve code quality findings in file explorer components
- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing
* test(files): unit tests for binary detection, stack path safety, and file explorer routes
- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
rejects matrix) and FileSystemService stack methods against a real temp dir
(listStackDirectory sort and protection flags, readStackFile text/binary/
oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
file explorer endpoints; covers auth gating, Community-tier 403 gates,
input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths
* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar
* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change
* test(files): add missing test coverage for file explorer routes and service
* feat(files): add Files tab to EditorLayout with StackFileExplorer integration
* fix(files): add defensive activeTab guard to saveFile and discardChanges
* test(files): unit tests for FileTree expand/collapse and FileViewer render modes
Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.
* test(e2e): file explorer community and skipper+ flows
Covers the full file-explorer feature surface in two describe blocks:
Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.
Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.
Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.
* fix(e2e): improve test isolation and selector stability in stack-files spec
Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.
Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.
* docs(files): add stack file explorer documentation
Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.
* fix(docs): use canonical Skipper tier name in file explorer overview card
* fix(files): resolve lint errors blocking CI
Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.
* test(files): fix e2e seeding to work on community-tier CI
Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.
Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
This commit is contained in:
@@ -18,7 +18,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
|
||||
import { springs } from '@/lib/motion';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { type Label as StackLabel, type LabelColor } from './label-types';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
@@ -73,6 +73,7 @@ import { usePinnedStacks } from '@/hooks/usePinnedStacks';
|
||||
import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse';
|
||||
import type { StackRowStatus } from '@/components/sidebar/StackRow';
|
||||
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
|
||||
|
||||
interface ContainerInfo {
|
||||
Id: string;
|
||||
@@ -237,7 +238,7 @@ export default function EditorLayout() {
|
||||
// the delta is always computed against the most recent known value, avoiding
|
||||
// the stale-closure bug that occurs when reading containerStats directly.
|
||||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||
const [activeTab, setActiveTab] = useState<'compose' | 'env' | 'files'>('compose');
|
||||
const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => {
|
||||
if (typeof window === 'undefined') return 'structured';
|
||||
return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured';
|
||||
@@ -1107,6 +1108,7 @@ export default function EditorLayout() {
|
||||
setIsFileLoading(true);
|
||||
setIsEditing(false); // Reset to view mode when loading a new file
|
||||
setEditingCompose(false); // Default back to anatomy on stack switch
|
||||
setActiveTab('compose');
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${filename}`);
|
||||
const text = await res.text();
|
||||
@@ -1231,6 +1233,7 @@ export default function EditorLayout() {
|
||||
};
|
||||
|
||||
const saveFile = async () => {
|
||||
if (activeTab === 'files') return;
|
||||
if (!selectedFile) return;
|
||||
const currentContent = activeTab === 'compose' ? (content || '') : (envContent || '');
|
||||
const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`;
|
||||
@@ -1291,6 +1294,7 @@ export default function EditorLayout() {
|
||||
};
|
||||
|
||||
const discardChanges = () => {
|
||||
if (activeTab === 'files') return;
|
||||
if (activeTab === 'compose') {
|
||||
setContent(originalContent);
|
||||
} else {
|
||||
@@ -2713,7 +2717,7 @@ export default function EditorLayout() {
|
||||
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-0 bg-card">
|
||||
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env' | 'files')}>
|
||||
<TabsList>
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="compose">
|
||||
@@ -2722,6 +2726,12 @@ export default function EditorLayout() {
|
||||
<TabsHighlightItem value="env">
|
||||
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="files">
|
||||
<TabsTrigger value="files">
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Files
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
@@ -2742,7 +2752,7 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{can('stack:edit', 'stack', stackName) && (
|
||||
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -2805,45 +2815,57 @@ export default function EditorLayout() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
{activeTab === 'env' && (
|
||||
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
|
||||
<span>
|
||||
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{!isFileLoading && (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
|
||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||
value={activeTab === 'compose' ? safeContent : safeEnvContent}
|
||||
onMount={(editor) => { monacoEditorRef.current = editor; }}
|
||||
onChange={(value) => {
|
||||
if (!isEditing) return; // Prevent changes in view mode
|
||||
if (activeTab === 'compose') {
|
||||
setContent(value || '');
|
||||
} else {
|
||||
setEnvContent(value || '');
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
fontSize: 14,
|
||||
padding: { top: 10 },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isFileLoading && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
{activeTab === 'files' ? (
|
||||
<StackFileExplorer
|
||||
stackName={stackName}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
isDarkMode={isDarkMode}
|
||||
onNavigateToCompose={() => setActiveTab('compose')}
|
||||
onNavigateToEnv={() => setActiveTab('env')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'env' && (
|
||||
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
|
||||
<span>
|
||||
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{!isFileLoading && (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={activeTab === 'compose' ? 'yaml' : 'plaintext'}
|
||||
theme={isDarkMode ? 'vs-dark' : 'vs'}
|
||||
value={activeTab === 'compose' ? safeContent : safeEnvContent}
|
||||
onMount={(editor) => { monacoEditorRef.current = editor; }}
|
||||
onChange={(value) => {
|
||||
if (!isEditing) return; // Prevent changes in view mode
|
||||
if (activeTab === 'compose') {
|
||||
setContent(value || '');
|
||||
} else {
|
||||
setEnvContent(value || '');
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontFamily: "'Geist Mono', monospace",
|
||||
fontSize: 14,
|
||||
padding: { top: 10 },
|
||||
scrollBeyondLastLine: false,
|
||||
readOnly: !isEditing || !can('stack:edit', 'stack', stackName),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isFileLoading && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -2854,6 +2876,7 @@ export default function EditorLayout() {
|
||||
selectedEnvFile={selectedEnvFile}
|
||||
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
|
||||
onEditCompose={() => setEditingCompose(true)}
|
||||
onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }}
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
canEdit={can('stack:edit', 'stack', stackName)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket } from 'lucide-react';
|
||||
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,6 +14,7 @@ interface StackAnatomyPanelProps {
|
||||
onEditCompose: () => void;
|
||||
onOpenGitSource: () => void;
|
||||
onApplyUpdate: () => void;
|
||||
onOpenFiles?: () => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
@@ -229,6 +230,7 @@ export default function StackAnatomyPanel({
|
||||
onEditCompose,
|
||||
onOpenGitSource,
|
||||
onApplyUpdate,
|
||||
onOpenFiles,
|
||||
canEdit,
|
||||
}: StackAnatomyPanelProps) {
|
||||
const anatomy = useMemo(() => parseAnatomy(content), [content]);
|
||||
@@ -327,16 +329,29 @@ export default function StackAnatomyPanel({
|
||||
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
|
||||
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-2">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">anatomy</span>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCompose}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" strokeWidth={1.5} />
|
||||
edit compose.yaml
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{onOpenFiles && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="anatomy-files-btn"
|
||||
onClick={onOpenFiles}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-3 w-3" strokeWidth={1.5} />
|
||||
files
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditCompose}
|
||||
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" strokeWidth={1.5} />
|
||||
edit compose.yaml
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3">
|
||||
{!anatomy ? (
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Loader2, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { deleteStackPath } from '@/lib/stackFilesApi';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
|
||||
interface DeleteFileConfirmProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stackName: string;
|
||||
relPath: string;
|
||||
entry: FileEntry | null;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export function DeleteFileConfirm({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
relPath,
|
||||
entry,
|
||||
onDeleted,
|
||||
}: DeleteFileConfirmProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmInput, setConfirmInput] = useState('');
|
||||
const [notEmpty, setNotEmpty] = useState(false);
|
||||
|
||||
const isProtected = entry?.isProtected ?? false;
|
||||
const entryName = entry?.name ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setConfirmInput('');
|
||||
setNotEmpty(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (deleting) return;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const executeDelete = async (recursive: boolean) => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteStackPath(stackName, relPath, recursive || undefined);
|
||||
onDeleted();
|
||||
onOpenChange(false);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Delete failed.';
|
||||
if (!recursive && msg.toUpperCase().includes('NOT_EMPTY')) {
|
||||
setNotEmpty(true);
|
||||
} else {
|
||||
toast.error(msg);
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => void executeDelete(notEmpty);
|
||||
|
||||
const protectedOk = !isProtected || confirmInput === entryName;
|
||||
|
||||
const deleteLabel = notEmpty ? 'Delete all' : 'Delete';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="w-4 h-4 text-destructive" strokeWidth={1.5} />
|
||||
Delete {entryName ? `"${entryName}"` : 'item'}?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{notEmpty ? (
|
||||
<span className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
This folder is not empty. Delete everything inside?
|
||||
</span>
|
||||
) : (
|
||||
'This action cannot be undone.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isProtected && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" strokeWidth={1.5} />
|
||||
<p>This is a critical stack file. Type the filename to confirm.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="delete-confirm-input" className="text-xs">
|
||||
Type <span className="font-mono">{entryName}</span> to confirm
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirm-input"
|
||||
value={confirmInput}
|
||||
onChange={(e) => setConfirmInput(e.target.value)}
|
||||
placeholder={entryName}
|
||||
disabled={deleting}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleClose(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="delete-confirm-btn"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || !protectedOk}
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
)}
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useRef, Fragment } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { listStackDirectory } from '@/lib/stackFilesApi';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
import { FileTreeNode } from './FileTreeNode';
|
||||
|
||||
interface FileTreeProps {
|
||||
stackName: string;
|
||||
refreshKey?: number;
|
||||
selectedPath: string;
|
||||
onSelectFile: (relPath: string, entry: FileEntry) => void;
|
||||
onNavigateToCompose?: () => void;
|
||||
onNavigateToEnv?: () => void;
|
||||
}
|
||||
|
||||
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
|
||||
const ENV_NAMES = new Set(['.env']);
|
||||
const MAX_ENTRIES = 500;
|
||||
|
||||
export function FileTree({
|
||||
stackName,
|
||||
refreshKey,
|
||||
selectedPath,
|
||||
onSelectFile,
|
||||
onNavigateToCompose,
|
||||
onNavigateToEnv,
|
||||
}: FileTreeProps) {
|
||||
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
|
||||
const [rootLoading, setRootLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
||||
const stackNameRef = useRef(stackName);
|
||||
|
||||
useEffect(() => {
|
||||
stackNameRef.current = stackName;
|
||||
let cancelled = false;
|
||||
|
||||
listStackDirectory(stackName, '')
|
||||
.then((entries) => {
|
||||
if (!cancelled) {
|
||||
setRootEntries(entries);
|
||||
setRootLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load files.';
|
||||
setError(msg);
|
||||
setRootLoading(false);
|
||||
toast.error('Failed to load files.');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [stackName, refreshKey]);
|
||||
|
||||
function handleDirClick(dirRelPath: string) {
|
||||
if (expandedDirs.has(dirRelPath)) {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(dirRelPath);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (dirContents.has(dirRelPath)) {
|
||||
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingDirs((prev) => new Set(prev).add(dirRelPath));
|
||||
|
||||
const capturedStackName = stackName;
|
||||
listStackDirectory(capturedStackName, dirRelPath)
|
||||
.then((entries) => {
|
||||
if (stackNameRef.current !== capturedStackName) return;
|
||||
setDirContents((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(dirRelPath, entries);
|
||||
return next;
|
||||
});
|
||||
setExpandedDirs((prev) => new Set(prev).add(dirRelPath));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load directory.';
|
||||
toast.error(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(dirRelPath);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleFileClick(relPath: string, entry: FileEntry) {
|
||||
if (COMPOSE_NAMES.has(entry.name)) {
|
||||
if (onNavigateToCompose) onNavigateToCompose();
|
||||
else toast.info('Open the Compose tab to edit this file.');
|
||||
return;
|
||||
}
|
||||
if (ENV_NAMES.has(entry.name)) {
|
||||
if (onNavigateToEnv) onNavigateToEnv();
|
||||
else toast.info('Open the Env tab to edit this file.');
|
||||
return;
|
||||
}
|
||||
onSelectFile(relPath, entry);
|
||||
}
|
||||
|
||||
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
|
||||
const capped = entries.length > MAX_ENTRIES;
|
||||
const visible = capped ? entries.slice(0, MAX_ENTRIES) : entries;
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map((entry) => {
|
||||
const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
|
||||
const isDir = entry.type === 'directory';
|
||||
const isExpanded = expandedDirs.has(entryRelPath);
|
||||
const isLoading = loadingDirs.has(entryRelPath);
|
||||
const children = dirContents.get(entryRelPath);
|
||||
|
||||
return (
|
||||
<Fragment key={entryRelPath}>
|
||||
<FileTreeNode
|
||||
entry={entry}
|
||||
depth={depth}
|
||||
isSelected={selectedPath === entryRelPath}
|
||||
isExpanded={isExpanded}
|
||||
isLoading={isLoading}
|
||||
onClick={() => {
|
||||
if (isDir) {
|
||||
handleDirClick(entryRelPath);
|
||||
} else {
|
||||
handleFileClick(entryRelPath, entry);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isDir && isExpanded && children !== undefined && (
|
||||
children.length === 0
|
||||
? (
|
||||
<div className="text-xs text-muted-foreground pl-4 py-0.5 italic">
|
||||
Empty folder
|
||||
</div>
|
||||
)
|
||||
: renderEntries(children, entryRelPath, depth + 1)
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{capped && (
|
||||
<div className="text-xs text-muted-foreground pl-4 py-0.5">
|
||||
Showing {MAX_ENTRIES} of {entries.length} - refine in shell
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (rootLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 p-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
return (
|
||||
<div className="p-2 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rootEntries === null || rootEntries.length === 0) {
|
||||
return (
|
||||
<div className="p-2 text-xs text-muted-foreground italic">
|
||||
Empty folder
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea type="hover" className="h-full">
|
||||
<div className="py-1">
|
||||
{renderEntries(rootEntries, '', 0)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ChevronRight, ChevronDown, Folder, File, Link, Loader2 } from 'lucide-react';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FileTreeNodeProps {
|
||||
entry: FileEntry;
|
||||
depth: number;
|
||||
isSelected: boolean;
|
||||
isExpanded?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FileTreeNode({
|
||||
entry,
|
||||
depth,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
onClick,
|
||||
}: FileTreeNodeProps) {
|
||||
const isDir = entry.type === 'directory';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||
}}
|
||||
aria-expanded={isDir ? isExpanded : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm',
|
||||
isSelected
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-foreground'
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
>
|
||||
{isDir && (
|
||||
isLoading
|
||||
? <Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin" strokeWidth={1.5} />
|
||||
: isExpanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
)}
|
||||
{isDir
|
||||
? <Folder className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
: entry.type === 'symlink'
|
||||
? <Link className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
: <File className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
}
|
||||
<span className="font-mono text-sm truncate">{entry.name}</span>
|
||||
{entry.isProtected && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react';
|
||||
import { UploadCloud, Compass } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { uploadStackFile } from '@/lib/stackFilesApi';
|
||||
|
||||
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
|
||||
interface FileUploadDropzoneProps {
|
||||
stackName: string;
|
||||
currentDir: string;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadDropzone({
|
||||
stackName,
|
||||
currentDir,
|
||||
onUploaded,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isPaid) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs cursor-pointer hover:border-brand/50 transition-colors"
|
||||
onClick={() => window.open('https://sencho.io/pricing', '_blank')}
|
||||
>
|
||||
<Compass className="w-3 h-3" />
|
||||
Upgrade to unlock upload, edit, and delete
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_BYTES) {
|
||||
toast.error('File exceeds 25 MB.');
|
||||
return;
|
||||
}
|
||||
const loadingId = toast.loading(`Uploading ${file.name}...`);
|
||||
try {
|
||||
await uploadStackFile(stackName, currentDir, file);
|
||||
toast.success('Uploaded.');
|
||||
onUploaded();
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Upload failed.');
|
||||
} finally {
|
||||
toast.dismiss(loadingId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = ev.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
ev.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={handleChange}
|
||||
aria-label="Upload file"
|
||||
/>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="border border-dashed border-border rounded-md p-2 text-xs text-muted-foreground flex items-center gap-2 cursor-pointer hover:border-brand/50 transition-colors"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
Upload file
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { AlertCircle, FileIcon, Download, Lock, 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 { useLicense } from '@/context/LicenseContext';
|
||||
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;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
function SpecialFilePanel({
|
||||
filename,
|
||||
size,
|
||||
label,
|
||||
stackName,
|
||||
relPath,
|
||||
canDownload,
|
||||
}: SpecialFilePanelProps) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!canDownload) return;
|
||||
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>
|
||||
{canDownload ? (
|
||||
<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 title="Upgrade to download files">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Lock className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer({
|
||||
stackName,
|
||||
selectedPath,
|
||||
canEdit,
|
||||
isDarkMode,
|
||||
onSaved,
|
||||
}: FileViewerProps) {
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
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 || !isPaid;
|
||||
|
||||
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}
|
||||
canDownload={isPaid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOversized) {
|
||||
return (
|
||||
<SpecialFilePanel
|
||||
filename={filename}
|
||||
size={size}
|
||||
label="File too large to preview"
|
||||
stackName={stackName}
|
||||
relPath={selectedPath}
|
||||
canDownload={isPaid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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] uppercase tracking-widest text-muted-foreground 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">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
value={content}
|
||||
onChange={(val) => {
|
||||
if (!readOnly) setContent(val ?? '');
|
||||
}}
|
||||
theme={isDarkMode ? 'vs-dark' : 'light'}
|
||||
options={editorOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState } from 'react';
|
||||
import { FolderPlus, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { mkdirStackPath } from '@/lib/stackFilesApi';
|
||||
|
||||
interface NewFolderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stackName: string;
|
||||
currentDir: string;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
function isValidFolderName(name: string): boolean {
|
||||
if (!name || name === '.' || name === '..') return false;
|
||||
return /^[^/\\]+$/.test(name);
|
||||
}
|
||||
|
||||
export function NewFolderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
stackName,
|
||||
currentDir,
|
||||
onCreated,
|
||||
}: NewFolderDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleClose = (next: boolean) => {
|
||||
if (creating) return;
|
||||
onOpenChange(next);
|
||||
if (!next) {
|
||||
setName('');
|
||||
setValidationError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!isValidFolderName(trimmed)) {
|
||||
setValidationError('Folder name must not be empty, and must not contain / or \\.');
|
||||
return;
|
||||
}
|
||||
setValidationError(null);
|
||||
setCreating(true);
|
||||
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
|
||||
try {
|
||||
await mkdirStackPath(stackName, relPath);
|
||||
toast.success('Folder created.');
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
setName('');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create folder.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') void handleCreate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderPlus className="w-4 h-4" strokeWidth={1.5} />
|
||||
New Folder
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Enter a name for the new folder.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="folder-name">Folder name</Label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setValidationError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="my-folder"
|
||||
disabled={creating}
|
||||
autoFocus
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="text-xs text-destructive">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleClose(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={creating || !name.trim()}
|
||||
>
|
||||
{creating && (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { downloadStackFile } from '@/lib/stackFilesApi';
|
||||
import { FileTree } from './FileTree';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { FileUploadDropzone } from './FileUploadDropzone';
|
||||
import { NewFolderDialog } from './NewFolderDialog';
|
||||
import { DeleteFileConfirm } from './DeleteFileConfirm';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
|
||||
interface StackFileExplorerProps {
|
||||
stackName: string;
|
||||
canEdit: boolean;
|
||||
isDarkMode: boolean;
|
||||
onNavigateToCompose?: () => void;
|
||||
onNavigateToEnv?: () => void;
|
||||
}
|
||||
|
||||
export function StackFileExplorer({
|
||||
stackName,
|
||||
canEdit,
|
||||
isDarkMode,
|
||||
onNavigateToCompose,
|
||||
onNavigateToEnv,
|
||||
}: StackFileExplorerProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
|
||||
const [currentDir, setCurrentDir] = useState('');
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [newFolderOpen, setNewFolderOpen] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedPath(null);
|
||||
setSelectedEntry(null);
|
||||
setCurrentDir('');
|
||||
}, [stackName]);
|
||||
|
||||
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
|
||||
|
||||
const handleSelectFile = useCallback((relPath: string, entry: FileEntry) => {
|
||||
setSelectedPath(relPath);
|
||||
setSelectedEntry(entry);
|
||||
const parts = relPath.split('/');
|
||||
parts.pop();
|
||||
setCurrentDir(parts.join('/'));
|
||||
}, []);
|
||||
|
||||
const handleDeleted = useCallback(() => {
|
||||
setSelectedPath(null);
|
||||
setSelectedEntry(null);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedPath) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const res = await downloadStackFile(stackName, selectedPath);
|
||||
if (!res.ok) {
|
||||
toast.error('Download failed.');
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const filename = selectedPath.split('/').pop() ?? selectedPath;
|
||||
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 {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* Left pane: tree + upload + new folder */}
|
||||
<div className="flex flex-col w-56 shrink-0 border-r border-glass-border min-h-0">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-glass-border shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<FileUploadDropzone
|
||||
stackName={stackName}
|
||||
currentDir={currentDir}
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
</div>
|
||||
{isPaid && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="New folder"
|
||||
onClick={() => setNewFolderOpen(true)}
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<FileTree
|
||||
key={`${stackName}:${refreshKey}`}
|
||||
stackName={stackName}
|
||||
refreshKey={refreshKey}
|
||||
selectedPath={selectedPath ?? ''}
|
||||
onSelectFile={handleSelectFile}
|
||||
onNavigateToCompose={onNavigateToCompose}
|
||||
onNavigateToEnv={onNavigateToEnv}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right pane: action bar + viewer */}
|
||||
<div className="flex flex-col flex-1 min-h-0 min-w-0">
|
||||
{selectedPath !== null && isPaid && (
|
||||
<div className="flex items-center justify-end gap-1 px-2 py-1 border-b border-glass-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
data-testid="file-action-delete"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileViewer
|
||||
stackName={stackName}
|
||||
selectedPath={selectedPath}
|
||||
canEdit={canEdit}
|
||||
isDarkMode={isDarkMode}
|
||||
onSaved={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteFileConfirm
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
stackName={stackName}
|
||||
relPath={selectedPath ?? ''}
|
||||
entry={selectedEntry}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
|
||||
<NewFolderDialog
|
||||
open={newFolderOpen}
|
||||
onOpenChange={setNewFolderOpen}
|
||||
stackName={stackName}
|
||||
currentDir={currentDir}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Coverage for FileTree.
|
||||
*
|
||||
* Locks the expand/collapse behavior: root directory loaded on mount,
|
||||
* subdirectory fetched on first expand, collapsed on second click, and
|
||||
* re-expanded from cache (no second fetch) on third click.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
listStackDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ScrollArea just renders children so the tree nodes are accessible in jsdom.
|
||||
vi.mock('@/components/ui/scroll-area', () => ({
|
||||
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/skeleton', () => ({
|
||||
Skeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
import { listStackDirectory } from '@/lib/stackFilesApi';
|
||||
import { FileTree } from '../FileTree';
|
||||
|
||||
const mockListDir = listStackDirectory as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeFile(name: string): FileEntry {
|
||||
return { name, type: 'file', size: 100, mtime: 0, isProtected: false };
|
||||
}
|
||||
|
||||
function makeDir(name: string): FileEntry {
|
||||
return { name, type: 'directory', size: 0, mtime: 0, isProtected: false };
|
||||
}
|
||||
|
||||
const ROOT_ENTRIES: FileEntry[] = [makeDir('src'), makeFile('README.md')];
|
||||
const SRC_ENTRIES: FileEntry[] = [makeFile('index.ts'), makeFile('app.ts')];
|
||||
|
||||
function fakeOk(entries: FileEntry[]): Promise<FileEntry[]> {
|
||||
return Promise.resolve(entries);
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
stackName: 'my-stack',
|
||||
selectedPath: '',
|
||||
onSelectFile: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockListDir.mockReset();
|
||||
defaultProps.onSelectFile = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('FileTree', () => {
|
||||
it('fetches root entries on mount and renders them', async () => {
|
||||
mockListDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(mockListDir).toHaveBeenCalledWith('my-stack', ''));
|
||||
expect(await screen.findByText('src')).toBeInTheDocument();
|
||||
expect(screen.getByText('README.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches subdirectory on first expand and shows children', async () => {
|
||||
mockListDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
await screen.findByText('src');
|
||||
|
||||
// One call so far: root fetch.
|
||||
expect(mockListDir).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(screen.getByText('src'));
|
||||
|
||||
await waitFor(() => expect(mockListDir).toHaveBeenCalledTimes(2));
|
||||
expect(mockListDir).toHaveBeenNthCalledWith(2, 'my-stack', 'src');
|
||||
|
||||
expect(await screen.findByText('index.ts')).toBeInTheDocument();
|
||||
expect(screen.getByText('app.ts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses on second click (no additional fetch)', async () => {
|
||||
mockListDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
await screen.findByText('src');
|
||||
|
||||
// Expand.
|
||||
await user.click(screen.getByText('src'));
|
||||
await screen.findByText('index.ts');
|
||||
|
||||
const callsAfterExpand = mockListDir.mock.calls.length;
|
||||
|
||||
// Collapse.
|
||||
await user.click(screen.getByText('src'));
|
||||
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
||||
|
||||
// No extra fetch should have happened.
|
||||
expect(mockListDir).toHaveBeenCalledTimes(callsAfterExpand);
|
||||
});
|
||||
|
||||
it('re-expands from cache on third click (no second fetch for that dir)', async () => {
|
||||
mockListDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
await screen.findByText('src');
|
||||
|
||||
// First click: expand (fetches subdirectory).
|
||||
await user.click(screen.getByText('src'));
|
||||
await screen.findByText('index.ts');
|
||||
|
||||
// Second click: collapse.
|
||||
await user.click(screen.getByText('src'));
|
||||
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
||||
|
||||
const callsAfterCollapse = mockListDir.mock.calls.length;
|
||||
|
||||
// Third click: re-expand from cache.
|
||||
await user.click(screen.getByText('src'));
|
||||
await screen.findByText('index.ts');
|
||||
|
||||
// Fetch count must not have increased.
|
||||
expect(mockListDir).toHaveBeenCalledTimes(callsAfterCollapse);
|
||||
});
|
||||
|
||||
it('shows error message when root fetch fails', async () => {
|
||||
mockListDir.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText('Network error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when root returns no entries', async () => {
|
||||
mockListDir.mockReturnValue(fakeOk([]));
|
||||
|
||||
render(<FileTree {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByText(/empty folder/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Coverage for FileViewer.
|
||||
*
|
||||
* Locks the three content-render modes: Monaco editor for text files,
|
||||
* binary panel for binary files, and oversized panel for files that
|
||||
* exceed the preview limit.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import type { FileContentResult } from '@/lib/stackFilesApi';
|
||||
|
||||
vi.mock('@monaco-editor/react', () => ({
|
||||
default: () => <div data-testid="monaco-editor" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
readStackFile: vi.fn(),
|
||||
writeStackFile: vi.fn(),
|
||||
downloadStackFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
info: vi.fn(),
|
||||
loading: vi.fn(() => 'loading-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/skeleton', () => ({
|
||||
Skeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
formatBytes: (n: number) => `${n}B`,
|
||||
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/monacoLanguages', () => ({
|
||||
extensionToLanguage: () => 'plaintext',
|
||||
}));
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
import { readStackFile } from '@/lib/stackFilesApi';
|
||||
import { FileViewer } from '../FileViewer';
|
||||
|
||||
const mockReadFile = readStackFile as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function textResult(content = 'hello world'): FileContentResult {
|
||||
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain' };
|
||||
}
|
||||
|
||||
function binaryResult(): FileContentResult {
|
||||
return { binary: true, oversized: false, size: 1024, mime: 'application/octet-stream' };
|
||||
}
|
||||
|
||||
function oversizedResult(): FileContentResult {
|
||||
return { binary: false, oversized: true, size: 5_000_000, mime: 'text/plain' };
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
stackName: 'my-stack',
|
||||
canEdit: true,
|
||||
isDarkMode: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
licenseState.isPaid = true;
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('FileViewer', () => {
|
||||
it('shows "Select a file" placeholder when selectedPath is null', () => {
|
||||
render(<FileViewer {...defaultProps} selectedPath={null} />);
|
||||
expect(screen.getByText(/select a file/i)).toBeInTheDocument();
|
||||
expect(mockReadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Monaco editor for a regular text file', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="config/app.txt" />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
|
||||
expect(screen.queryByText(/binary file/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/too large/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls readStackFile with the correct stack name and path', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="src/index.ts" />);
|
||||
|
||||
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts'));
|
||||
});
|
||||
|
||||
it('renders binary panel (not Monaco) for a binary file', async () => {
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="assets/logo.png" />);
|
||||
|
||||
expect(await screen.findByText(/binary file/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders oversized panel (not Monaco) when file is too large to preview', async () => {
|
||||
mockReadFile.mockResolvedValue(oversizedResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="logs/huge.log" />);
|
||||
|
||||
expect(await screen.findByText(/too large to preview/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error message when readStackFile rejects', async () => {
|
||||
mockReadFile.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="missing.txt" />);
|
||||
|
||||
expect(await screen.findByText('Not found')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Download button when user has a paid tier', async () => {
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
|
||||
|
||||
await screen.findByText(/binary file/i);
|
||||
const downloadBtn = screen.getByRole('button', { name: /download/i });
|
||||
expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows disabled Download button for community tier', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
|
||||
|
||||
await screen.findByText(/binary file/i);
|
||||
const downloadBtn = screen.getByRole('button', { name: /download/i });
|
||||
expect(downloadBtn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('re-fetches when selectedPath changes', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult());
|
||||
|
||||
const { rerender } = render(<FileViewer {...defaultProps} selectedPath="a.txt" />);
|
||||
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender(<FileViewer {...defaultProps} selectedPath="b.txt" />);
|
||||
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2));
|
||||
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user