Files
sencho/frontend/src/components/files/StackFileExplorer.tsx
T
Anso 801a098a5b 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.
2026-04-26 13:05:19 -04:00

183 lines
5.8 KiB
TypeScript

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>
);
}