mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 05:58:37 +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)}
|
||||
|
||||
Reference in New Issue
Block a user