feat(stacks): browse and edit mounted volume files in the explorer (#1403)

* feat(stacks): browse and edit mounted volume files in the explorer

Reposition the stack file explorer around runtime configuration access:
discover a stack's declared mounts and expose each as a safe, stack-scoped
file root. The explorer opens on a Volumes group (bind mounts and named
Docker volumes) by default, with the stack source directory as a secondary
group, on a "Files & Volumes" tab.

- Discover roots from the rendered effective compose model; resolve named
  volumes to their Docker name and browse/edit them through the hardened
  helper container, with bind mounts handled directly when reachable.
- Re-derive the allowed roots server-side on every file operation and match
  the client root id against them, so a request can never address a path the
  stack did not declare. Block dangerous host mounts and binds that overlap
  Sencho's managed directories; reject writes to read-only mounts.
- Thread an optional root id through the existing file endpoints and an
  opaque, parseable optimistic-concurrency token through read, conflict,
  and write, for both filesystem and helper backends.
- Keep compose and env file protection on the stack source root only.

* fix(stacks): theme the Files & Volumes root switcher

Replace the raw native select in the file-root switcher with the design
system Select component. The native control did not honour the dark theme,
so the panel rendered white with unreadable text. The themed Select gives a
dark popover with grouped Volumes / Stack source labels and disabled items.

* fix(stacks): contain the bind-root probe and de-taint the file-op error log

Gate the volume-root bind probe's realpath/stat behind a compose-base
containment check (mirroring the storage host-path probe) so they never run
on an unvalidated host path; a source outside the compose dir is unreachable
in the containerized deployment anyway and is reported non-accessible without
touching the filesystem. Log the helper-backed file-op failure through a
constant format string with sanitized arguments instead of an interpolated
template literal.

* fix(stacks): inline the bind-probe containment guard at the fs sinks

The wrapped containment predicate was not recognized as a path barrier, so
the bind probe's realpath/stat still flagged as uncontrolled-data-in-path.
Inline the path.resolve + startsWith check directly at each filesystem sink
(and re-check the resolved canonical before stat, so a within-base symlink
that resolves outside the compose dir is also rejected).

* fix(stacks): harden file-root lifecycle, upload race, and helper errors

Address review findings on the Files & Volumes feature:

- Invalidate the file-root allowlist on stack create/delete/import/from-git
  (wire StackFileRootsService.invalidateNode into invalidateNodeCaches), so a
  stack deleted and recreated under the same name cannot serve the old stack's
  roots from the 15s cache.
- Use the atomic exclusive write for a non-overwrite upload so a file created
  by another writer after the existence check is not silently clobbered.
- Let the helper's real cd errno through and map permission failures to 403
  consistently across list/stat/read/write/mkdir/delete/pathKind, instead of
  reporting EACCES as 404/500; pathKind no longer reports a permission-denied
  parent as absent.
- Document the realpath-then-open TOCTOU as a known, pre-existing limitation of
  every file op (O_NOFOLLOW is not viable because config volumes legitimately
  contain symlinks); the bind root is contained to the compose dir and the op
  requires stack:edit.
- Docs: drop a missing screenshot reference and correct the protected-file
  delete behavior (stack-root compose/.env cannot be deleted via the explorer).
This commit is contained in:
Anso
2026-06-21 18:16:20 -04:00
committed by GitHub
parent b611f41872
commit b9d8e9f490
24 changed files with 1986 additions and 205 deletions
@@ -415,7 +415,7 @@ export function EditorView(props: EditorViewProps) {
<TabsHighlightItem value="files">
<TabsTrigger value="files">
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Files
Files &amp; Volumes
</TabsTrigger>
</TabsHighlightItem>
)}
@@ -14,6 +14,7 @@ interface DeleteFileConfirmProps {
stackName: string;
relPath: string;
entry: FileEntry | null;
rootId?: string;
onDeleted: () => void;
}
@@ -23,6 +24,7 @@ export function DeleteFileConfirm({
stackName,
relPath,
entry,
rootId,
onDeleted,
}: DeleteFileConfirmProps) {
const [deleting, setDeleting] = useState(false);
@@ -47,7 +49,7 @@ export function DeleteFileConfirm({
const executeDelete = async (recursive: boolean) => {
setDeleting(true);
try {
await deleteStackPath(stackName, relPath, recursive || undefined);
await deleteStackPath(stackName, relPath, recursive || undefined, rootId);
onDeleted();
onOpenChange(false);
} catch (e: unknown) {
@@ -49,6 +49,7 @@ interface FilePermissionsDialogProps {
stackName: string;
relPath: string;
entryName: string;
rootId?: string;
canEdit: boolean;
}
@@ -58,6 +59,7 @@ export function FilePermissionsDialog({
stackName,
relPath,
entryName,
rootId,
canEdit,
}: FilePermissionsDialogProps) {
const [mode, setMode] = useState<number>(0o644);
@@ -69,14 +71,14 @@ export function FilePermissionsDialog({
setLoading(true);
setError(null);
try {
const result = await getStackEntryPermissions(stackName, relPath);
const result = await getStackEntryPermissions(stackName, relPath, rootId);
setMode(result.mode);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load permissions.');
} finally {
setLoading(false);
}
}, [stackName, relPath]);
}, [stackName, relPath, rootId]);
useEffect(() => {
if (open) void load();
@@ -90,7 +92,7 @@ export function FilePermissionsDialog({
const handleSave = async () => {
setSaving(true);
try {
await setStackEntryPermissions(stackName, relPath, mode);
await setStackEntryPermissions(stackName, relPath, mode, rootId);
toast.success('Permissions updated.');
onOpenChange(false);
} catch (e) {
+9 -2
View File
@@ -23,6 +23,10 @@ interface FileTreeProps {
onSelectFile: (relPath: string, entry: FileEntry) => void;
onNavigateToCompose?: () => void;
onNavigateToEnv?: () => void;
/** When true (stack source only), clicking compose/.env redirects to their
* dedicated editors. For volume roots a file named .env is just an ordinary
* file and opens in the viewer. */
redirectProtected?: boolean;
// Context menu wiring
canEdit?: boolean;
onContextMenuRename?: (relPath: string) => void;
@@ -50,6 +54,7 @@ export function FileTree({
onSelectFile,
onNavigateToCompose,
onNavigateToEnv,
redirectProtected = true,
canEdit = false,
onContextMenuRename = () => undefined,
onContextMenuMove = () => undefined,
@@ -166,12 +171,14 @@ export function FileTree({
}
function handleFileClick(relPath: string, entry: FileEntry) {
if (COMPOSE_NAMES.has(entry.name)) {
// Only the stack source root redirects compose/.env to their dedicated
// editors; on a volume root these are ordinary files opened in the viewer.
if (redirectProtected && relPath === entry.name && 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 (redirectProtected && relPath === entry.name && ENV_NAMES.has(entry.name)) {
if (onNavigateToEnv) onNavigateToEnv();
else toast.info('Open the Env tab to edit this file.');
return;
@@ -11,6 +11,7 @@ interface FileUploadDropzoneProps {
stackName: string;
currentDir: string;
canEdit: boolean;
rootId?: string;
onUploaded: () => void;
}
@@ -18,6 +19,7 @@ export function FileUploadDropzone({
stackName,
currentDir,
canEdit,
rootId,
onUploaded,
}: FileUploadDropzoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
@@ -29,7 +31,7 @@ export function FileUploadDropzone({
const runUpload = async (file: File, overwrite: boolean): Promise<void> => {
const loadingId = toast.loading(`Uploading ${file.name}...`);
try {
await uploadStackFile(stackName, currentDir, file, { overwrite });
await uploadStackFile(stackName, currentDir, file, { overwrite, rootId });
toast.success(overwrite ? 'Replaced.' : 'Uploaded.');
onUploaded();
} catch (e: unknown) {
+24 -11
View File
@@ -13,6 +13,8 @@ interface FileViewerProps {
selectedPath: string | null;
canEdit: boolean;
isDarkMode: boolean;
/** The selected file root; undefined/`stack-source` is the legacy behaviour. */
rootId?: string;
onSaved?: () => void;
onDirtyChange?: (dirty: boolean) => void;
}
@@ -21,12 +23,18 @@ function getFilename(path: string): string {
return path.split('/').pop() ?? path;
}
/** Build the fs version token from a millisecond mtime, for a server response that omits `version`. */
function fsVersionFromMtime(mtimeMs: number | undefined): string | undefined {
return typeof mtimeMs === 'number' ? `W/"${Math.floor(mtimeMs)}"` : undefined;
}
interface SpecialFilePanelProps {
filename: string;
size: number;
label: string;
stackName: string;
relPath: string;
rootId?: string;
extraAction?: { label: string; onClick: () => void; disabled?: boolean };
}
@@ -36,6 +44,7 @@ function SpecialFilePanel({
label,
stackName,
relPath,
rootId,
extraAction,
}: SpecialFilePanelProps) {
const [downloading, setDownloading] = useState(false);
@@ -43,7 +52,7 @@ function SpecialFilePanel({
const handleDownload = async () => {
setDownloading(true);
try {
const res = await downloadStackFile(stackName, relPath);
const res = await downloadStackFile(stackName, relPath, rootId);
if (!res.ok) {
toast.error('Download failed.');
return;
@@ -105,6 +114,7 @@ export function FileViewer({
selectedPath,
canEdit,
isDarkMode,
rootId,
onSaved,
onDirtyChange,
}: FileViewerProps) {
@@ -116,7 +126,7 @@ export function FileViewer({
const [isBinary, setIsBinary] = useState(false);
const [isOversized, setIsOversized] = useState(false);
const [size, setSize] = useState(0);
const [loadedMtimeMs, setLoadedMtimeMs] = useState<number | null>(null);
const [loadedVersion, setLoadedVersion] = useState<string | null>(null);
const readOnly = !canEdit;
const hasChanges = content !== originalContent;
@@ -174,11 +184,11 @@ export function FileViewer({
setIsBinary(false);
setIsOversized(false);
readStackFile(stackName, selectedPath)
readStackFile(stackName, selectedPath, { rootId })
.then((result) => {
if (cancelled) return;
setSize(result.size);
setLoadedMtimeMs(result.mtimeMs);
setLoadedVersion(result.version ?? fsVersionFromMtime(result.mtimeMs) ?? null);
// Check oversized BEFORE binary: the backend returns oversized:true
// for files past the 2 MB inline-preview cap regardless of the binary
// probe, and the body intentionally carries no content for those
@@ -207,7 +217,7 @@ export function FileViewer({
return () => {
cancelled = true;
};
}, [stackName, selectedPath]);
}, [stackName, selectedPath, rootId]);
const handleSave = async () => {
if (!selectedPath) return;
@@ -215,22 +225,23 @@ export function FileViewer({
const loadingId = toast.loading('Saving...');
try {
const result = await writeStackFile(stackName, selectedPath, content, {
ifMatchMtimeMs: loadedMtimeMs ?? undefined,
ifMatchVersion: loadedVersion ?? undefined,
rootId,
});
setOriginalContent(content);
if (result.mtimeMs !== null) setLoadedMtimeMs(result.mtimeMs);
if (result.version !== null) setLoadedVersion(result.version);
toast.success('Saved.');
onSaved?.();
} catch (e) {
if (e instanceof FileConflictError) {
// The server-side content has moved on. Update the baseline (so the
// next save sends the fresh mtime and stops looping on the same
// next save sends the fresh version token and stops looping on the same
// precondition) but leave the user's typed buffer untouched. Their
// edits remain in the editor, Save stays enabled, and a follow-up
// click will apply their changes on top of the new server content
// without silently destroying what they typed.
setOriginalContent(e.currentContent);
setLoadedMtimeMs(e.currentMtimeMs);
setLoadedVersion(e.currentVersion);
toast.error('File changed elsewhere. Review your edits then save again to apply them on top of the current version.');
} else {
toast.error(e instanceof Error ? e.message : 'Save failed.');
@@ -276,13 +287,13 @@ export function FileViewer({
setLoading(true);
setError(null);
try {
const result = await readStackFile(stackName, requestedPath, { forceText: true });
const result = await readStackFile(stackName, requestedPath, { forceText: true, rootId });
// Stale-request guard: the user may have navigated to a different
// file while the override request was in flight. Drop the response
// rather than stomp on the new file's state.
if (selectedPathRef.current !== requestedPath) return;
setSize(result.size);
setLoadedMtimeMs(result.mtimeMs);
setLoadedVersion(result.version ?? fsVersionFromMtime(result.mtimeMs) ?? null);
if (result.oversized) {
// Backend keeps oversized files out of the inline editor even with
// force=text set; the body has no content. Surface the Download
@@ -312,6 +323,7 @@ export function FileViewer({
label="Binary file"
stackName={stackName}
relPath={selectedPath}
rootId={rootId}
extraAction={{
label: 'Open as text anyway',
onClick: () => void handleForceText(),
@@ -328,6 +340,7 @@ export function FileViewer({
label="File too large to preview"
stackName={stackName}
relPath={selectedPath}
rootId={rootId}
/>
);
}
@@ -21,6 +21,8 @@ interface MoveFileDialogProps {
relPath: string;
/** The entry being moved (null until a source is chosen). */
entry: FileEntry | null;
/** The selected file root; the destination tree is loaded within it. */
rootId?: string;
/** Relocate `fromRel` into `destDir` (''=stack root). Resolves true only when
* the entry actually moved, so the dialog stays open on a blocked/failed move. */
onMove: (fromRel: string, entryName: string, destDir: string) => boolean | Promise<boolean>;
@@ -32,6 +34,7 @@ export function MoveFileDialog({
stackName,
relPath,
entry,
rootId,
onMove,
}: MoveFileDialogProps) {
// Loaded directory children, keyed by directory rel path ('' = stack root).
@@ -66,7 +69,7 @@ export function MoveFileDialog({
next.delete(dir);
return next;
});
listStackDirectory(stackName, dir)
listStackDirectory(stackName, dir, rootId)
.then((entries) => {
if (requestSeqRef.current !== seq) return;
setDirChildren((prev) => new Map(prev).set(dir, entries.filter((e) => e.type === 'directory')));
@@ -18,6 +18,7 @@ interface NewFileDialogProps {
stackName: string;
/** Directory within the stack where the file will be created */
currentDir: string;
rootId?: string;
onCreated: () => void;
}
@@ -26,6 +27,7 @@ export function NewFileDialog({
onOpenChange,
stackName,
currentDir,
rootId,
onCreated,
}: NewFileDialogProps) {
const [name, setName] = useState('');
@@ -51,7 +53,7 @@ export function NewFileDialog({
setCreating(true);
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
try {
await writeStackFile(stackName, relPath, '');
await writeStackFile(stackName, relPath, '', { rootId });
toast.success('File created.');
onCreated();
onOpenChange(false);
@@ -12,6 +12,7 @@ interface NewFolderDialogProps {
onOpenChange: (open: boolean) => void;
stackName: string;
currentDir: string;
rootId?: string;
onCreated: () => void;
}
@@ -25,6 +26,7 @@ export function NewFolderDialog({
onOpenChange,
stackName,
currentDir,
rootId,
onCreated,
}: NewFolderDialogProps) {
const [name, setName] = useState('');
@@ -50,7 +52,7 @@ export function NewFolderDialog({
setCreating(true);
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
try {
await mkdirStackPath(stackName, relPath);
await mkdirStackPath(stackName, relPath, rootId);
toast.success('Folder created.');
onCreated();
onOpenChange(false);
@@ -20,6 +20,7 @@ interface RenameDialogProps {
relPath: string;
/** Current basename of the entry */
currentName: string;
rootId?: string;
onRenamed: () => void;
}
@@ -29,6 +30,7 @@ export function RenameDialog({
stackName,
relPath,
currentName,
rootId,
onRenamed,
}: RenameDialogProps) {
const [name, setName] = useState('');
@@ -62,7 +64,7 @@ export function RenameDialog({
const parentDir = relPath.includes('/') ? relPath.slice(0, relPath.lastIndexOf('/')) : '';
const toRel = parentDir ? `${parentDir}/${trimmed}` : trimmed;
try {
await renameStackPath(stackName, relPath, toRel);
await renameStackPath(stackName, relPath, toRel, rootId);
toast.success('Renamed successfully.');
onRenamed();
onOpenChange(false);
@@ -1,9 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Trash2, FolderPlus, Download, Loader2, AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ConfirmModal } from '@/components/ui/modal';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from '@/components/ui/toast-store';
import { downloadStackFile, listStackDirectory, renameStackPath } from '@/lib/stackFilesApi';
import { downloadStackFile, listStackDirectory, listFileRoots, renameStackPath, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi';
import { FileTree } from './FileTree';
import { FileViewer } from './FileViewer';
import { FileUploadDropzone } from './FileUploadDropzone';
@@ -13,7 +14,7 @@ import { DeleteFileConfirm } from './DeleteFileConfirm';
import { RenameDialog } from './RenameDialog';
import { MoveFileDialog } from './MoveFileDialog';
import { FilePermissionsDialog } from './FilePermissionsDialog';
import type { FileEntry } from '@/lib/stackFilesApi';
import type { FileEntry, FileRoot } from '@/lib/stackFilesApi';
interface StackFileExplorerProps {
stackName: string;
@@ -23,6 +24,33 @@ interface StackFileExplorerProps {
onNavigateToEnv?: () => void;
}
/** The synthetic stack-source root used before roots load or if discovery fails. */
const STACK_SOURCE_FALLBACK: FileRoot = {
id: STACK_SOURCE_ROOT_ID,
kind: 'stack-source',
label: 'Stack source',
hostPathOrName: '',
mounts: [],
readonly: false,
accessible: true,
browsable: true,
writable: true,
chmodable: true,
dangerous: false,
managedSourceOverlap: false,
warning: null,
backend: 'fs',
};
/** Short label for a root option: container path (or volume name) + how many service mounts. */
function rootOptionLabel(root: FileRoot): string {
if (root.kind === 'stack-source') return 'Stack source';
const primary = root.mounts[0]?.containerPath || root.label;
const count = root.mounts.length > 1 ? ` · ${root.mounts.length} mounts` : '';
const ro = root.readonly ? ' · read-only' : '';
return `${primary}${count}${ro}`;
}
export function StackFileExplorer({
stackName,
canEdit,
@@ -36,6 +64,22 @@ export function StackFileExplorer({
const [refreshKey, setRefreshKey] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);
// ── file roots (Volumes + Stack source) ──
const [roots, setRoots] = useState<FileRoot[]>([STACK_SOURCE_FALLBACK]);
const [selectedRootId, setSelectedRootId] = useState<string>(STACK_SOURCE_ROOT_ID);
// When a root switch is requested while the viewer has unsaved edits, hold it
// here until the user confirms or cancels in the guard modal.
const [pendingRootId, setPendingRootId] = useState<string | null>(null);
const selectedRoot = useMemo(
() => roots.find((r) => r.id === selectedRootId) ?? STACK_SOURCE_FALLBACK,
[roots, selectedRootId],
);
const volumeRoots = useMemo(() => roots.filter((r) => r.kind !== 'stack-source'), [roots]);
const isStackSource = selectedRoot.kind === 'stack-source';
// Edits are allowed only when the user can edit AND the selected root is writable.
const rootCanEdit = canEdit && selectedRoot.writable;
// ── toolbar delete (existing behaviour) ──
const [deleteOpen, setDeleteOpen] = useState(false);
@@ -77,10 +121,51 @@ export function StackFileExplorer({
setCurrentDir('');
setIsViewerDirty(false);
setPendingSelection(null);
setRoots([STACK_SOURCE_FALLBACK]);
setSelectedRootId(STACK_SOURCE_ROOT_ID);
setPendingRootId(null);
}, [stackName]);
// Discover the stack's file roots and default to the first browsable volume
// root when one exists, otherwise the stack source.
useEffect(() => {
let cancelled = false;
listFileRoots(stackName)
.then((fetched) => {
if (cancelled) return;
const list = fetched.length ? fetched : [STACK_SOURCE_FALLBACK];
setRoots(list);
const defaultVolume = list.find((r) => r.kind !== 'stack-source' && r.browsable);
setSelectedRootId(defaultVolume?.id ?? STACK_SOURCE_ROOT_ID);
})
.catch(() => {
if (cancelled) return;
setRoots([STACK_SOURCE_FALLBACK]);
setSelectedRootId(STACK_SOURCE_ROOT_ID);
});
return () => { cancelled = true; };
}, [stackName]);
const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
// Apply a root switch: reset the open file/tree to the new root's contents.
const applyRootSwitch = useCallback((rootId: string) => {
setSelectedRootId(rootId);
setSelectedPath(null);
setSelectedEntry(null);
setCurrentDir('');
}, []);
// Switch roots, guarding unsaved edits in the viewer first.
const handleRootChange = useCallback((rootId: string) => {
if (rootId === selectedRootId) return;
if (isViewerDirty) {
setPendingRootId(rootId);
return;
}
applyRootSwitch(rootId);
}, [selectedRootId, isViewerDirty, applyRootSwitch]);
const applySelection = useCallback((relPath: string, entry: FileEntry) => {
setSelectedPath(relPath);
setSelectedEntry(entry);
@@ -108,7 +193,7 @@ export function StackFileExplorer({
if (!selectedPath) return;
setIsDownloading(true);
try {
const res = await downloadStackFile(stackName, selectedPath);
const res = await downloadStackFile(stackName, selectedPath, selectedRootId);
if (!res.ok) {
toast.error('Download failed.');
return;
@@ -146,7 +231,7 @@ export function StackFileExplorer({
return false;
}
try {
await renameStackPath(stackName, fromRel, toRel);
await renameStackPath(stackName, fromRel, toRel, selectedRootId);
toast.success('Moved successfully.');
if (affectsOpen) handleDeleted();
else refresh();
@@ -155,7 +240,7 @@ export function StackFileExplorer({
toast.error(e instanceof Error ? e.message : 'Move failed.');
return false;
}
}, [stackName, selectedPath, isViewerDirty, handleDeleted, refresh]);
}, [stackName, selectedRootId, selectedPath, isViewerDirty, handleDeleted, refresh]);
// ── Context menu callbacks ──
@@ -196,18 +281,54 @@ export function StackFileExplorer({
return (
<div className="flex h-full min-h-0">
{/* Left pane: tree + upload + new folder */}
{/* Left pane: root switcher + tree + upload + new folder */}
<div className="flex flex-col w-56 shrink-0 border-r border-glass-border min-h-0">
<div className="flex flex-col gap-1 px-2 py-1.5 border-b border-glass-border shrink-0">
<span className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Browsing</span>
<Select value={selectedRootId} onValueChange={handleRootChange}>
<SelectTrigger className="h-8 px-2 text-xs font-mono" aria-label="File root">
<SelectValue />
</SelectTrigger>
<SelectContent>
{volumeRoots.length > 0 && (
<SelectGroup>
<SelectLabel className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Volumes</SelectLabel>
{volumeRoots.map((r) => (
<SelectItem key={r.id} value={r.id} disabled={!r.browsable} className="text-xs font-mono">
{rootOptionLabel(r)}{r.browsable ? '' : ' (unavailable)'}
</SelectItem>
))}
</SelectGroup>
)}
<SelectGroup>
<SelectLabel className="text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Stack source</SelectLabel>
<SelectItem value={STACK_SOURCE_ROOT_ID} className="text-xs font-mono">Stack source</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{selectedRoot.warning && (
<p className="flex items-start gap-1 text-[10px] text-stat-subtitle">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" strokeWidth={1.5} />
<span>{selectedRoot.warning}</span>
</p>
)}
{volumeRoots.length === 0 && (
<p className="text-[10px] text-stat-subtitle italic">
No browsable stack volumes detected. Sencho can only browse mounted folders declared by this stack.
</p>
)}
</div>
<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}
canEdit={canEdit}
canEdit={rootCanEdit}
rootId={selectedRootId}
onUploaded={refresh}
/>
</div>
{canEdit && (
{rootCanEdit && (
<Button
variant="ghost"
size="icon"
@@ -224,15 +345,16 @@ export function StackFileExplorer({
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<FileTree
key={`${stackName}:${refreshKey}`}
sourceKey={stackName}
loadDir={(p) => listStackDirectory(stackName, p)}
key={`${stackName}:${selectedRootId}:${refreshKey}`}
sourceKey={`${stackName}:${selectedRootId}`}
loadDir={(p) => listStackDirectory(stackName, p, selectedRootId)}
refreshKey={refreshKey}
selectedPath={selectedPath ?? ''}
onSelectFile={handleSelectFile}
onNavigateToCompose={onNavigateToCompose}
onNavigateToEnv={onNavigateToEnv}
canEdit={canEdit}
onNavigateToCompose={isStackSource ? onNavigateToCompose : undefined}
onNavigateToEnv={isStackSource ? onNavigateToEnv : undefined}
redirectProtected={isStackSource}
canEdit={rootCanEdit}
onContextMenuRename={handleContextMenuRename}
onContextMenuMove={handleContextMenuMove}
onContextMenuNewFile={handleContextMenuNewFile}
@@ -262,7 +384,7 @@ export function StackFileExplorer({
)}
Download
</Button>
{canEdit && (
{rootCanEdit && (
<Button
variant="ghost"
size="sm"
@@ -280,8 +402,9 @@ export function StackFileExplorer({
<FileViewer
stackName={stackName}
selectedPath={selectedPath}
canEdit={canEdit}
canEdit={rootCanEdit}
isDarkMode={isDarkMode}
rootId={selectedRootId}
onSaved={refresh}
onDirtyChange={setIsViewerDirty}
/>
@@ -297,6 +420,7 @@ export function StackFileExplorer({
stackName={stackName}
relPath={selectedPath ?? ''}
entry={selectedEntry}
rootId={selectedRootId}
onDeleted={handleDeleted}
/>
@@ -307,6 +431,7 @@ export function StackFileExplorer({
stackName={stackName}
relPath={ctxDeletePath}
entry={ctxDeleteEntry}
rootId={selectedRootId}
onDeleted={() => {
if (ctxDeletePath === selectedPath) handleDeleted();
else refresh();
@@ -321,6 +446,7 @@ export function StackFileExplorer({
onOpenChange={setNewFolderOpen}
stackName={stackName}
currentDir={newFolderDir}
rootId={selectedRootId}
onCreated={refresh}
/>
@@ -330,6 +456,7 @@ export function StackFileExplorer({
onOpenChange={setNewFileOpen}
stackName={stackName}
currentDir={newFileDir}
rootId={selectedRootId}
onCreated={refresh}
/>
@@ -340,6 +467,7 @@ export function StackFileExplorer({
stackName={stackName}
relPath={renameRelPath}
currentName={renameCurrentName}
rootId={selectedRootId}
onRenamed={() => {
// If the renamed item was selected, deselect since the path changed.
if (renameRelPath === selectedPath) handleDeleted();
@@ -354,6 +482,7 @@ export function StackFileExplorer({
stackName={stackName}
relPath={moveRelPath}
entry={moveEntry}
rootId={selectedRootId}
onMove={handleMove}
/>
@@ -364,7 +493,8 @@ export function StackFileExplorer({
stackName={stackName}
relPath={permissionsRelPath}
entryName={permissionsEntryName}
canEdit={canEdit}
rootId={selectedRootId}
canEdit={rootCanEdit}
/>
{/* Unsaved-changes guard on file switch */}
@@ -387,6 +517,27 @@ export function StackFileExplorer({
You have unsaved changes in the current file. Switching to another file will discard them.
</p>
</ConfirmModal>
{/* Unsaved-changes guard on root switch */}
<ConfirmModal
open={pendingRootId !== null}
onOpenChange={(next) => { if (!next) setPendingRootId(null); }}
onCancel={() => setPendingRootId(null)}
kicker="FILES · UNSAVED CHANGES"
title="Discard unsaved changes?"
description="Switching roots will discard the edits in the current viewer."
confirmLabel="Discard and switch"
onConfirm={() => {
if (pendingRootId) {
applyRootSwitch(pendingRootId);
setPendingRootId(null);
}
}}
>
<p className="text-sm text-muted-foreground">
You have unsaved changes in the current file. Switching to another root will discard them.
</p>
</ConfirmModal>
</div>
);
}
@@ -34,11 +34,13 @@ vi.mock('@/lib/stackFilesApi', () => {
readonly code = 'PRECONDITION_FAILED' as const;
readonly currentContent: string;
readonly currentMtimeMs: number;
constructor(message: string, currentContent: string, currentMtimeMs: number) {
readonly currentVersion: string | null;
constructor(message: string, currentContent: string, currentMtimeMs: number, currentVersion: string | null) {
super(message);
this.name = 'FileConflictError';
this.currentContent = currentContent;
this.currentMtimeMs = currentMtimeMs;
this.currentVersion = currentVersion;
}
}
return {
@@ -96,7 +98,7 @@ const mockReadFile = readStackFile as unknown as ReturnType<typeof vi.fn>;
const mockWriteFile = writeStackFile 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', mtimeMs: 1_700_000_000_000 };
return { content, binary: false, oversized: false, size: content.length, mime: 'text/plain', mtimeMs: 1_700_000_000_000, version: 'W/"1700000000000"' };
}
function binaryResult(): FileContentResult {
@@ -142,7 +144,7 @@ describe('FileViewer', () => {
render(<FileViewer {...defaultProps} selectedPath="src/index.ts" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts'));
await waitFor(() => expect(mockReadFile).toHaveBeenCalledWith('my-stack', 'src/index.ts', { rootId: undefined }));
});
it('renders binary panel (not Monaco) for a binary file', async () => {
@@ -190,7 +192,7 @@ describe('FileViewer', () => {
rerender(<FileViewer {...defaultProps} selectedPath="b.txt" />);
await waitFor(() => expect(mockReadFile).toHaveBeenCalledTimes(2));
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt');
expect(mockReadFile).toHaveBeenNthCalledWith(2, 'my-stack', 'b.txt', { rootId: undefined });
});
it('reports clean dirty state on initial load of a text file', async () => {
@@ -217,9 +219,9 @@ describe('FileViewer', () => {
expect(onDirtyChange).toHaveBeenCalledWith(false);
});
it('sends If-Match with the loaded mtime on save and updates the local mtime from the response', async () => {
it('sends If-Match with the loaded version on save and updates the local version from the response', async () => {
mockReadFile.mockResolvedValue(textResult('hello'));
mockWriteFile.mockResolvedValue({ mtimeMs: 1_700_000_000_999 });
mockWriteFile.mockResolvedValue({ version: 'W/"1700000000999"', mtimeMs: 1_700_000_000_999 });
render(<FileViewer {...defaultProps} selectedPath="config.txt" />);
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
@@ -235,7 +237,7 @@ describe('FileViewer', () => {
expect(s).toBe('my-stack');
expect(p).toBe('config.txt');
expect(c).toBe('edited content');
expect(opts).toEqual({ ifMatchMtimeMs: 1_700_000_000_000 });
expect(opts).toEqual({ ifMatchVersion: 'W/"1700000000000"', rootId: undefined });
});
it('binary panel offers "Open as text anyway"; click refetches with forceText and renders Monaco', async () => {
@@ -298,11 +300,11 @@ describe('FileViewer', () => {
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
});
it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses new mtime', async () => {
it('updates baseline on FileConflictError without discarding the user buffer; follow-up save uses the fresh version', async () => {
mockReadFile.mockResolvedValue(textResult('stale local copy'));
mockWriteFile
.mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000))
.mockResolvedValueOnce({ mtimeMs: 1_700_001_000_000 });
.mockRejectedValueOnce(new FileConflictError('changed elsewhere', 'SERVER NOW', 1_700_000_999_000, 'W/"1700000999000"'))
.mockResolvedValueOnce({ version: 'W/"1700001000000"', mtimeMs: 1_700_001_000_000 });
render(<FileViewer {...defaultProps} selectedPath="config.txt" />);
await waitFor(() => expect(screen.getByTestId('monaco-editor')).toBeInTheDocument());
@@ -319,6 +321,6 @@ describe('FileViewer', () => {
saveBtn.click();
await waitFor(() => expect(mockWriteFile).toHaveBeenCalledTimes(2));
expect(mockWriteFile.mock.calls[1][2]).toBe('edited content');
expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchMtimeMs: 1_700_000_999_000 });
expect(mockWriteFile.mock.calls[1][3]).toEqual({ ifMatchVersion: 'W/"1700000999000"', rootId: undefined });
});
});
@@ -22,7 +22,9 @@ const h = vi.hoisted(() => ({
}));
vi.mock('@/lib/stackFilesApi', () => ({
STACK_SOURCE_ROOT_ID: 'stack-source',
listStackDirectory: vi.fn().mockResolvedValue([]),
listFileRoots: vi.fn().mockResolvedValue([]),
downloadStackFile: vi.fn(),
readStackFile: vi.fn(),
writeStackFile: vi.fn(),
@@ -160,7 +162,7 @@ describe('StackFileExplorer move handling', () => {
h.onMove?.('other.txt', 'other.txt', 'sub');
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'other.txt', 'sub/other.txt'));
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'other.txt', 'sub/other.txt', 'stack-source'));
await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Moved successfully.'));
// The open file was not the one moved, so the viewer keeps its selection.
expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt');
@@ -186,7 +188,7 @@ describe('StackFileExplorer move handling', () => {
h.onMove?.('a.txt', 'a.txt', 'sub');
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt'));
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt', 'stack-source'));
await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)'));
});
@@ -198,7 +200,7 @@ describe('StackFileExplorer move handling', () => {
h.onMove?.('dir', 'dir', 'other');
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'dir', 'other/dir'));
await waitFor(() => expect(h.renameMock).toHaveBeenCalledWith('my-stack', 'dir', 'other/dir', 'stack-source'));
await waitFor(() => expect(screen.getByTestId('viewer-selected').textContent).toBe('(none)'));
});