mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
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:
@@ -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 & 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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)'));
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user