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:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
+65 -42
View File
@@ -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)}
+26 -11
View File
@@ -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>
);
}
+202
View File
@@ -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} &middot; {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');
});
});