mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory
Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.
* feat(files): copy and duplicate files in the explorer
Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.
* feat(files): make the file tree keyboard accessible
Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.
* feat(files): bulk select, delete, move, and download files
Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.
* docs(files): document copy, bulk actions, and keyboard navigation
Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.
* fix(files): inline path-injection barriers at the new file-op sinks
CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.
* fix(files): guard the ancestor-walk realpath sink too
The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.
* fix(files): resolve the root case off the taint path in the ancestor walk
The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.
* fix(files): harden helper-backend bulk download and uploads
Address three issues found in the named-volume (helper) backend:
- Bulk download could send 200 headers before discovering a file the
helper download path refuses, tearing the archive mid-stream. The
prewalk now rejects symlinks, non-regular ("other") entries, and
files over the per-file download cap before any header (400/413).
FileEntry gains an 'other' type so non-regular entries stay distinct
from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
entry cap could fire. listDir now accepts a limit; the list script
stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
real nonzero exit code (and its 4xx mapping) as a generic 500. The
nonzero exit now wins; the masked stream error is logged.
* feat(files): add a New file toolbar button with server-enforced create-only
The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.
Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.
* fix(files): widen the tree row hit area and add horizontal scroll for long names
Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.
Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.
* docs(files): document the New file button, full-row right-click, and long-name scrolling
* test(files): cover createEmptyStackFile targeting the stack root
Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, Fragment } from 'react';
|
||||
import type { ReactNode, DragEvent } from 'react';
|
||||
import type { ReactNode, DragEvent, KeyboardEvent } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -31,14 +31,23 @@ interface FileTreeProps {
|
||||
canEdit?: boolean;
|
||||
onContextMenuRename?: (relPath: string) => void;
|
||||
onContextMenuMove?: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuDuplicate?: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuCopy?: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuNewFile?: (dirRelPath: string) => void;
|
||||
onContextMenuNewFolder?: (dirRelPath: string) => void;
|
||||
onContextMenuDelete?: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuPermissions?: (relPath: string, entry: FileEntry) => void;
|
||||
/** Relocate `fromRel` into `destDir` (''=stack root) via drag-and-drop. */
|
||||
onMove?: (fromRel: string, entryName: string, destDir: string) => void;
|
||||
/** Current bulk selection (rel paths). Drives the per-row checkboxes. Read-only
|
||||
* here; FileTree emits the next set via onSelectionChange. */
|
||||
selectedPaths?: ReadonlySet<string>;
|
||||
/** Emit the next bulk selection after a checkbox click or modifier-click. */
|
||||
onSelectionChange?: (next: Set<string>) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SELECTION: ReadonlySet<string> = new Set<string>();
|
||||
|
||||
const COMPOSE_NAMES = new Set(['compose.yaml', 'compose.yml']);
|
||||
const ENV_NAMES = new Set(['.env']);
|
||||
// The server caps the response at 1000 entries and exposes the unfiltered
|
||||
@@ -58,11 +67,15 @@ export function FileTree({
|
||||
canEdit = false,
|
||||
onContextMenuRename = () => undefined,
|
||||
onContextMenuMove = () => undefined,
|
||||
onContextMenuDuplicate = () => undefined,
|
||||
onContextMenuCopy = () => undefined,
|
||||
onContextMenuNewFile = () => undefined,
|
||||
onContextMenuNewFolder = () => undefined,
|
||||
onContextMenuDelete = () => undefined,
|
||||
onContextMenuPermissions = () => undefined,
|
||||
onMove = () => undefined,
|
||||
selectedPaths = EMPTY_SELECTION,
|
||||
onSelectionChange = () => undefined,
|
||||
}: FileTreeProps) {
|
||||
const [rootEntries, setRootEntries] = useState<FileEntry[] | null>(null);
|
||||
const [rootLoading, setRootLoading] = useState(true);
|
||||
@@ -73,6 +86,23 @@ export function FileTree({
|
||||
const [filter, setFilter] = useState('');
|
||||
const [isRootDropTarget, setIsRootDropTarget] = useState(false);
|
||||
|
||||
// Roving-tabindex state for keyboard tree navigation: exactly one visible node
|
||||
// is focusable at a time. `activeRelPath` is the intended focus; DOM focus is
|
||||
// moved imperatively (only when a key press requested it, via shouldFocusRef)
|
||||
// so a re-render never steals focus from elsewhere on the page.
|
||||
const [activeRelPath, setActiveRelPath] = useState<string | null>(null);
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const shouldFocusRef = useRef(false);
|
||||
// Anchor for Shift+click range selection (the last row toggled on its own).
|
||||
const selectionAnchorRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFocusRef.current && activeRelPath) {
|
||||
nodeRefs.current.get(activeRelPath)?.focus();
|
||||
shouldFocusRef.current = false;
|
||||
}
|
||||
}, [activeRelPath]);
|
||||
|
||||
// The scroll area is the stack-root drop target. Folder nodes stop propagation
|
||||
// on their own drops, so an event only reaches here when it lands on a file
|
||||
// row or empty space. A root-level entry dropped here is a no-op and ignored.
|
||||
@@ -206,32 +236,160 @@ export function FileTree({
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
|
||||
// When the filter is active, keep entries that either match by name OR
|
||||
// are directories with a matching loaded descendant. Without the
|
||||
// ancestor-keep rule, the parent directory of a match would be filtered
|
||||
// out at this level and its loaded children would never render.
|
||||
// The entries kept at one level: name matches, or directories with a matching
|
||||
// loaded descendant while filtering, capped at MAX_ENTRIES.
|
||||
function keptEntries(entries: FileEntry[], parentRelPath: string): { visible: FileEntry[]; capped: boolean; total: number } {
|
||||
const filtered = filter
|
||||
? entries.filter(e => {
|
||||
if (matchesFilter(e.name)) return true;
|
||||
if (e.type !== 'directory') return false;
|
||||
const path = parentRelPath ? `${parentRelPath}/${e.name}` : e.name;
|
||||
return hasMatchingDescendant(path);
|
||||
const childPath = parentRelPath ? `${parentRelPath}/${e.name}` : e.name;
|
||||
return hasMatchingDescendant(childPath);
|
||||
})
|
||||
: entries;
|
||||
const capped = filtered.length > MAX_ENTRIES;
|
||||
const visible = capped ? filtered.slice(0, MAX_ENTRIES) : filtered;
|
||||
return { visible: capped ? filtered.slice(0, MAX_ENTRIES) : filtered, capped, total: filtered.length };
|
||||
}
|
||||
|
||||
// A row renders expanded when the user expanded it, or while a filter is active
|
||||
// and it has a matching descendant (auto-expanded into view). Files are never
|
||||
// in expandedDirs and have no descendants, so this is safe to call for any row.
|
||||
function computeExpanded(entryRelPath: string): boolean {
|
||||
return expandedDirs.has(entryRelPath) || (filter !== '' && hasMatchingDescendant(entryRelPath));
|
||||
}
|
||||
|
||||
// The flattened, in-order list of visible treeitems. It mirrors the recursive
|
||||
// render and drives keyboard navigation and roving focus (a later bulk-select
|
||||
// feature can reuse this ordering for shift-click ranges).
|
||||
function buildVisibleNodes(): { relPath: string; entry: FileEntry; depth: number }[] {
|
||||
const out: { relPath: string; entry: FileEntry; depth: number }[] = [];
|
||||
const walk = (entries: FileEntry[], parent: string, depth: number) => {
|
||||
for (const entry of keptEntries(entries, parent).visible) {
|
||||
const relPath = parent ? `${parent}/${entry.name}` : entry.name;
|
||||
out.push({ relPath, entry, depth });
|
||||
if (entry.type === 'directory' && computeExpanded(relPath)) {
|
||||
const children = dirContents.get(relPath);
|
||||
if (children) walk(children, relPath, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (rootEntries) walk(rootEntries, '', 0);
|
||||
return out;
|
||||
}
|
||||
const visibleNodes = buildVisibleNodes();
|
||||
|
||||
// The single roving-focusable node: a prior keyboard target if still visible,
|
||||
// else the selected file, else the first node.
|
||||
function computeActiveKey(): string | null {
|
||||
if (activeRelPath && visibleNodes.some(n => n.relPath === activeRelPath)) return activeRelPath;
|
||||
if (visibleNodes.some(n => n.relPath === selectedPath)) return selectedPath;
|
||||
return visibleNodes[0]?.relPath ?? null;
|
||||
}
|
||||
const activeKey = computeActiveKey();
|
||||
|
||||
const registerNodeRef = (relPath: string, el: HTMLDivElement | null) => {
|
||||
if (el) nodeRefs.current.set(relPath, el);
|
||||
else nodeRefs.current.delete(relPath);
|
||||
};
|
||||
|
||||
// Keep the roving target in sync when a row is focused by click or Tab,
|
||||
// without requesting a re-focus (which would fight the user's own click).
|
||||
const handleFocusNode = (relPath: string) => setActiveRelPath(relPath);
|
||||
|
||||
// Move the roving focus to `relPath` and pull DOM focus there after the render.
|
||||
const moveActive = (relPath: string) => {
|
||||
shouldFocusRef.current = true;
|
||||
setActiveRelPath(relPath);
|
||||
};
|
||||
|
||||
// Toggle one row in the bulk selection (checkbox or Ctrl/Cmd+click) and set the
|
||||
// range anchor to it.
|
||||
const handleToggleSelect = (relPath: string) => {
|
||||
selectionAnchorRef.current = relPath;
|
||||
const next = new Set(selectedPaths);
|
||||
if (next.has(relPath)) next.delete(relPath);
|
||||
else next.add(relPath);
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
// Add the contiguous range from the anchor (or this row, if none) to this row,
|
||||
// using the flattened visible order so it matches what the user sees.
|
||||
const handleRangeSelect = (relPath: string) => {
|
||||
const order = visibleNodes.map((n) => n.relPath);
|
||||
const anchor = selectionAnchorRef.current && order.includes(selectionAnchorRef.current)
|
||||
? selectionAnchorRef.current
|
||||
: relPath;
|
||||
const from = order.indexOf(anchor);
|
||||
const to = order.indexOf(relPath);
|
||||
if (from === -1 || to === -1) return;
|
||||
const [lo, hi] = from <= to ? [from, to] : [to, from];
|
||||
const next = new Set(selectedPaths);
|
||||
for (let k = lo; k <= hi; k++) next.add(order[k]);
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
function handleTreeKeyDown(e: KeyboardEvent<HTMLDivElement>) {
|
||||
if (visibleNodes.length === 0) return;
|
||||
const idx = visibleNodes.findIndex(n => n.relPath === activeKey);
|
||||
const cur = idx >= 0 ? visibleNodes[idx] : undefined;
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
moveActive(visibleNodes[Math.min(idx + 1, visibleNodes.length - 1)]?.relPath ?? visibleNodes[0].relPath);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
if (idx > 0) moveActive(visibleNodes[idx - 1].relPath);
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
moveActive(visibleNodes[0].relPath);
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
moveActive(visibleNodes[visibleNodes.length - 1].relPath);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (!cur || cur.entry.type !== 'directory') break;
|
||||
e.preventDefault();
|
||||
if (!computeExpanded(cur.relPath)) {
|
||||
handleDirClick(cur.relPath); // expand in place
|
||||
} else if (visibleNodes[idx + 1] && visibleNodes[idx + 1].depth > cur.depth) {
|
||||
moveActive(visibleNodes[idx + 1].relPath); // step into the first child
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft': {
|
||||
if (!cur) break;
|
||||
e.preventDefault();
|
||||
// Only collapse a directory the user actually expanded. A directory that
|
||||
// is open only because the active filter auto-expanded it is not in
|
||||
// expandedDirs, so toggling it would wrongly ADD it; fall through to
|
||||
// move-to-parent instead.
|
||||
if (cur.entry.type === 'directory' && expandedDirs.has(cur.relPath)) {
|
||||
handleDirClick(cur.relPath); // collapse in place
|
||||
break;
|
||||
}
|
||||
const parent = relPathParentDir(cur.relPath);
|
||||
if (parent && visibleNodes.some(n => n.relPath === parent)) moveActive(parent);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderEntries(entries: FileEntry[], parentRelPath: string, depth: number): ReactNode {
|
||||
// keptEntries applies the filter (keeping ancestors of matches) and the
|
||||
// per-level MAX_ENTRIES cap; it is the same logic the flat visible-node list
|
||||
// uses, so the rendered rows and the keyboard order stay in lockstep.
|
||||
const { visible, capped, total } = keptEntries(entries, parentRelPath);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map((entry) => {
|
||||
const entryRelPath = parentRelPath ? `${parentRelPath}/${entry.name}` : entry.name;
|
||||
const isDir = entry.type === 'directory';
|
||||
// While a filter is active, auto-expand any directory that is being
|
||||
// kept solely because it has a matching descendant. The user gets
|
||||
// the match in view without manually expanding every ancestor.
|
||||
const isExpanded = expandedDirs.has(entryRelPath)
|
||||
|| (filter !== '' && isDir && hasMatchingDescendant(entryRelPath));
|
||||
const isExpanded = computeExpanded(entryRelPath);
|
||||
const isLoading = loadingDirs.has(entryRelPath);
|
||||
const children = dirContents.get(entryRelPath);
|
||||
|
||||
@@ -241,6 +399,13 @@ export function FileTree({
|
||||
entry={entry}
|
||||
relPath={entryRelPath}
|
||||
depth={depth}
|
||||
isActive={entryRelPath === activeKey}
|
||||
registerRef={registerNodeRef}
|
||||
onFocusNode={handleFocusNode}
|
||||
isChecked={selectedPaths.has(entryRelPath)}
|
||||
selectionActive={selectedPaths.size > 0}
|
||||
onToggleSelect={() => handleToggleSelect(entryRelPath)}
|
||||
onRangeSelect={() => handleRangeSelect(entryRelPath)}
|
||||
isSelected={selectedPath === entryRelPath}
|
||||
isExpanded={isExpanded}
|
||||
isLoading={isLoading}
|
||||
@@ -254,6 +419,8 @@ export function FileTree({
|
||||
canEdit={canEdit}
|
||||
onContextMenuRename={onContextMenuRename}
|
||||
onContextMenuMove={onContextMenuMove}
|
||||
onContextMenuDuplicate={onContextMenuDuplicate}
|
||||
onContextMenuCopy={onContextMenuCopy}
|
||||
onContextMenuNewFile={onContextMenuNewFile}
|
||||
onContextMenuNewFolder={onContextMenuNewFolder}
|
||||
onContextMenuDelete={onContextMenuDelete}
|
||||
@@ -274,10 +441,10 @@ export function FileTree({
|
||||
})}
|
||||
{capped && (
|
||||
<div className="text-xs text-muted-foreground pl-4 py-0.5">
|
||||
Showing {MAX_ENTRIES} of {filtered.length} - refine the filter or use a shell
|
||||
Showing {MAX_ENTRIES} of {total} - refine the filter or use a shell
|
||||
</div>
|
||||
)}
|
||||
{filter && filtered.length === 0 && depth === 0 && (
|
||||
{filter && total === 0 && depth === 0 && (
|
||||
<div className="text-xs text-muted-foreground pl-4 py-0.5 italic">
|
||||
No entries match “{filter}”
|
||||
</div>
|
||||
@@ -335,10 +502,14 @@ export function FileTree({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea type="hover" className="flex-1 min-h-0">
|
||||
<ScrollArea type="hover" horizontal className="flex-1 min-h-0">
|
||||
<div
|
||||
data-testid="file-tree-root-dropzone"
|
||||
className={cn('py-1 min-h-full', isRootDropTarget && 'bg-accent/20')}
|
||||
role="tree"
|
||||
aria-label="Files"
|
||||
aria-multiselectable
|
||||
className={cn('py-1 min-h-full min-w-full w-max', isRootDropTarget && 'bg-accent/20')}
|
||||
onKeyDown={handleTreeKeyDown}
|
||||
onDragOver={handleRootDragOver}
|
||||
onDragLeave={() => setIsRootDropTarget(false)}
|
||||
onDrop={handleRootDrop}
|
||||
@@ -346,6 +517,10 @@ export function FileTree({
|
||||
{renderEntries(rootEntries, '', 0)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{/* Announce the selected file to assistive tech without a visual change. */}
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{selectedPath ? `Selected ${selectedPath.split('/').pop()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { FilePlus, FolderPlus, Pencil, FolderInput, Lock, Trash2 } from 'lucide-react';
|
||||
import { FilePlus, FolderPlus, Pencil, FolderInput, Copy, CopyPlus, Lock, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -15,6 +15,8 @@ interface FileTreeContextMenuProps {
|
||||
canEdit: boolean;
|
||||
onRequestRename: (relPath: string) => void;
|
||||
onRequestMove: (relPath: string, entry: FileEntry) => void;
|
||||
onRequestDuplicate: (relPath: string, entry: FileEntry) => void;
|
||||
onRequestCopy: (relPath: string, entry: FileEntry) => void;
|
||||
onRequestNewFile: (dirRelPath: string) => void;
|
||||
onRequestNewFolder: (dirRelPath: string) => void;
|
||||
onRequestDelete: (relPath: string, entry: FileEntry) => void;
|
||||
@@ -28,6 +30,8 @@ export function FileTreeContextMenu({
|
||||
canEdit,
|
||||
onRequestRename,
|
||||
onRequestMove,
|
||||
onRequestDuplicate,
|
||||
onRequestCopy,
|
||||
onRequestNewFile,
|
||||
onRequestNewFolder,
|
||||
onRequestDelete,
|
||||
@@ -45,6 +49,22 @@ export function FileTreeContextMenu({
|
||||
<span>Move to…</span>
|
||||
</ContextMenuItem>
|
||||
);
|
||||
// Duplicate (same folder, auto-suffixed) and Copy to… are offered for any
|
||||
// editable entry. Unlike Move, a protected root file may be duplicated/copied:
|
||||
// the copy gets a new name, and the destination picker disables the stack root
|
||||
// for a reserved name so it can only land in a subfolder.
|
||||
const copyItems = canWrite && (
|
||||
<>
|
||||
<ContextMenuItem onSelect={() => onRequestDuplicate(relPath, entry)}>
|
||||
<CopyPlus className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Duplicate</span>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => onRequestCopy(relPath, entry)}>
|
||||
<Copy className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span>Copy to…</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
@@ -75,6 +95,7 @@ export function FileTreeContextMenu({
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{copyItems}
|
||||
{moveItem}
|
||||
{canWrite && (
|
||||
<>
|
||||
@@ -97,6 +118,7 @@ export function FileTreeContextMenu({
|
||||
<span>Rename</span>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{copyItems}
|
||||
{moveItem}
|
||||
<ContextMenuItem onSelect={() => onRequestPermissions(relPath, entry)}>
|
||||
<Lock className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
|
||||
@@ -21,10 +21,28 @@ interface FileTreeNodeProps {
|
||||
isExpanded?: boolean;
|
||||
isLoading?: boolean;
|
||||
onClick: () => void;
|
||||
// Accessibility / roving-tabindex wiring (the parent owns keyboard navigation).
|
||||
/** The single roving-focusable node holds tabIndex 0; all others hold -1. */
|
||||
isActive: boolean;
|
||||
/** Register/unregister this row's element so the parent can move DOM focus. */
|
||||
registerRef: (relPath: string, el: HTMLDivElement | null) => void;
|
||||
/** Keep the parent's active node in sync when this row gains focus (click/Tab). */
|
||||
onFocusNode: (relPath: string) => void;
|
||||
// Bulk selection wiring (checkbox + modifier-clicks; the parent owns the set).
|
||||
/** Whether this row is in the bulk selection. */
|
||||
isChecked: boolean;
|
||||
/** True while any row is selected, so checkboxes stay visible (not hover-only). */
|
||||
selectionActive: boolean;
|
||||
/** Toggle this row in the selection (checkbox click or Ctrl/Cmd+click). */
|
||||
onToggleSelect: () => void;
|
||||
/** Select the range from the selection anchor to this row (Shift+click). */
|
||||
onRangeSelect: () => void;
|
||||
// Context menu wiring
|
||||
canEdit: boolean;
|
||||
onContextMenuRename: (relPath: string) => void;
|
||||
onContextMenuMove: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuDuplicate: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuCopy: (relPath: string, entry: FileEntry) => void;
|
||||
onContextMenuNewFile: (dirRelPath: string) => void;
|
||||
onContextMenuNewFolder: (dirRelPath: string) => void;
|
||||
onContextMenuDelete: (relPath: string, entry: FileEntry) => void;
|
||||
@@ -41,9 +59,18 @@ export function FileTreeNode({
|
||||
isExpanded,
|
||||
isLoading,
|
||||
onClick,
|
||||
isActive,
|
||||
registerRef,
|
||||
onFocusNode,
|
||||
isChecked,
|
||||
selectionActive,
|
||||
onToggleSelect,
|
||||
onRangeSelect,
|
||||
canEdit,
|
||||
onContextMenuRename,
|
||||
onContextMenuMove,
|
||||
onContextMenuDuplicate,
|
||||
onContextMenuCopy,
|
||||
onContextMenuNewFile,
|
||||
onContextMenuNewFolder,
|
||||
onContextMenuDelete,
|
||||
@@ -101,26 +128,49 @@ export function FileTreeNode({
|
||||
canEdit={canEdit}
|
||||
onRequestRename={onContextMenuRename}
|
||||
onRequestMove={onContextMenuMove}
|
||||
onRequestDuplicate={onContextMenuDuplicate}
|
||||
onRequestCopy={onContextMenuCopy}
|
||||
onRequestNewFile={onContextMenuNewFile}
|
||||
onRequestNewFolder={onContextMenuNewFolder}
|
||||
onRequestDelete={onContextMenuDelete}
|
||||
onRequestPermissions={onContextMenuPermissions}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
ref={(el) => registerRef(relPath, el)}
|
||||
role="treeitem"
|
||||
aria-level={depth + 1}
|
||||
aria-selected={isSelected || isChecked}
|
||||
aria-expanded={isDir ? isExpanded : undefined}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
draggable={canDrag}
|
||||
onDragStart={canDrag ? handleDragStart : undefined}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick();
|
||||
onClick={(e) => {
|
||||
// Shift / Ctrl / Cmd clicks drive bulk selection (never open the file);
|
||||
// a plain click opens it in the viewer as before.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onRangeSelect();
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
onToggleSelect();
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
onFocus={() => onFocusNode(relPath)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter/Space activate the row; arrow/Home/End navigation is owned by
|
||||
// the parent tree (it needs the flattened visible order).
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
aria-expanded={isDir ? isExpanded : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm',
|
||||
'group flex items-center gap-1.5 py-0.5 cursor-pointer select-none rounded-sm min-w-full w-max',
|
||||
isSelected
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50 text-foreground',
|
||||
@@ -128,6 +178,19 @@ export function FileTreeNode({
|
||||
)}
|
||||
style={{ paddingLeft: depth * 16 + 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
// Stop the row's click/keydown from also opening or navigating.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onChange={() => onToggleSelect()}
|
||||
aria-label={`Select ${entry.name}`}
|
||||
className={cn(
|
||||
'h-3 w-3 shrink-0 accent-accent-foreground cursor-pointer',
|
||||
!isChecked && !selectionActive && 'opacity-0 group-hover:opacity-100 focus:opacity-100',
|
||||
)}
|
||||
/>
|
||||
{isDir && (
|
||||
isLoading
|
||||
? <Loader2 className="w-3.5 h-3.5 shrink-0 animate-spin" strokeWidth={1.5} />
|
||||
@@ -141,7 +204,7 @@ export function FileTreeNode({
|
||||
? <Link className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
: <File className="w-3.5 h-3.5 shrink-0" strokeWidth={1.5} />
|
||||
}
|
||||
<span className="font-mono text-sm truncate">{entry.name}</span>
|
||||
<span className="font-mono text-sm whitespace-nowrap">{entry.name}</span>
|
||||
{entry.isProtected && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -23,9 +23,19 @@ interface MoveFileDialogProps {
|
||||
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. */
|
||||
/** 'move' relocates the entry; 'copy' duplicates it into the destination. The
|
||||
* destination rules are identical (the current parent stays disabled in both,
|
||||
* so a same-folder copy goes through Duplicate, not this picker). */
|
||||
mode?: 'move' | 'copy';
|
||||
/** Bulk mode: the rel paths of every selected source. When set, the dialog
|
||||
* validates the destination against all of them and calls onConfirmDestination
|
||||
* instead of onMove (the single relPath/entry props are ignored). */
|
||||
bulkSourcePaths?: string[];
|
||||
/** Relocate or copy `fromRel` into `destDir` (''=stack root). Resolves true only
|
||||
* when the action succeeded, so the dialog stays open on a blocked/failed run. */
|
||||
onMove: (fromRel: string, entryName: string, destDir: string) => boolean | Promise<boolean>;
|
||||
/** Bulk confirm: move the whole selection into `destDir`. Resolves true on success. */
|
||||
onConfirmDestination?: (destDir: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export function MoveFileDialog({
|
||||
@@ -35,8 +45,14 @@ export function MoveFileDialog({
|
||||
relPath,
|
||||
entry,
|
||||
rootId,
|
||||
mode = 'move',
|
||||
bulkSourcePaths,
|
||||
onMove,
|
||||
onConfirmDestination,
|
||||
}: MoveFileDialogProps) {
|
||||
const isCopy = mode === 'copy';
|
||||
// Treat an empty bulk list as not-bulk so the picker never enables a no-op move.
|
||||
const bulkSources = bulkSourcePaths && bulkSourcePaths.length > 0 ? bulkSourcePaths : null;
|
||||
// Loaded directory children, keyed by directory rel path ('' = stack root).
|
||||
const [dirChildren, setDirChildren] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
@@ -53,6 +69,16 @@ export function MoveFileDialog({
|
||||
// (a no-op), the entry itself or a descendant (for a directory), or the stack
|
||||
// root when the entry's name is reserved at the root (compose/.env files).
|
||||
const isValidDest = (dir: string): boolean => {
|
||||
if (bulkSources) {
|
||||
// No destination inside or equal to any selected directory (would move a
|
||||
// folder into its own subtree).
|
||||
if (bulkSources.some((s) => isSameOrDescendantPath(s, dir))) return false;
|
||||
// Not a no-op for the whole selection (every item already lives here).
|
||||
if (bulkSources.every((s) => relPathParentDir(s) === dir)) return false;
|
||||
// The stack root is reserved if any item carries a protected root name.
|
||||
if (dir === '' && bulkSources.some((s) => isProtectedRootRelPath(s.split('/').pop() ?? s))) return false;
|
||||
return true;
|
||||
}
|
||||
if (!entry) return false;
|
||||
if (dir === currentParent) return false;
|
||||
if (entry.type === 'directory' && isSameOrDescendantPath(relPath, dir)) return false;
|
||||
@@ -123,12 +149,17 @@ export function MoveFileDialog({
|
||||
};
|
||||
|
||||
const handleMove = async () => {
|
||||
if (!entry || selectedDest === null || !isValidDest(selectedDest)) return;
|
||||
if (selectedDest === null || !isValidDest(selectedDest)) return;
|
||||
setMoving(true);
|
||||
try {
|
||||
// Close only when the move actually succeeded; a blocked or failed move
|
||||
// Close only when the action actually succeeded; a blocked or failed run
|
||||
// (handled and toasted upstream) leaves the picker open to retry.
|
||||
if (await onMove(relPath, entry.name, selectedDest)) onOpenChange(false);
|
||||
const ok = bulkSources
|
||||
? await onConfirmDestination?.(selectedDest)
|
||||
: entry
|
||||
? await onMove(relPath, entry.name, selectedDest)
|
||||
: false;
|
||||
if (ok) onOpenChange(false);
|
||||
} finally {
|
||||
setMoving(false);
|
||||
}
|
||||
@@ -207,9 +238,15 @@ export function MoveFileDialog({
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
<ModalHeader
|
||||
kicker={`${stackName.toUpperCase()} · MOVE`}
|
||||
title="Move to…"
|
||||
description={entry ? `Choose a destination folder for ${entry.name}.` : 'Choose a destination folder.'}
|
||||
kicker={`${stackName.toUpperCase()} · ${isCopy ? 'COPY' : 'MOVE'}`}
|
||||
title={isCopy ? 'Copy to…' : 'Move to…'}
|
||||
description={
|
||||
bulkSources
|
||||
? `Choose a destination folder for ${bulkSources.length} ${bulkSources.length === 1 ? 'item' : 'items'}.`
|
||||
: entry
|
||||
? `Choose a destination folder for ${entry.name}.`
|
||||
: 'Choose a destination folder.'
|
||||
}
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="rounded-md border border-glass-border max-h-72 overflow-y-auto p-1">
|
||||
@@ -250,7 +287,7 @@ export function MoveFileDialog({
|
||||
disabled={moving || selectedDest === null || !isValidDest(selectedDest)}
|
||||
>
|
||||
{moving && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />}
|
||||
Move
|
||||
{isCopy ? 'Copy' : 'Move'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { writeStackFile } from '@/lib/stackFilesApi';
|
||||
import { createEmptyStackFile, UploadConflictError } from '@/lib/stackFilesApi';
|
||||
|
||||
function isValidFileName(name: string): boolean {
|
||||
if (!name || name === '.' || name === '..') return false;
|
||||
@@ -51,15 +51,21 @@ export function NewFileDialog({
|
||||
}
|
||||
setValidationError(null);
|
||||
setCreating(true);
|
||||
const relPath = currentDir ? `${currentDir}/${trimmed}` : trimmed;
|
||||
try {
|
||||
await writeStackFile(stackName, relPath, '', { rootId });
|
||||
await createEmptyStackFile(stackName, currentDir, trimmed, { rootId });
|
||||
toast.success('File created.');
|
||||
onCreated();
|
||||
onOpenChange(false);
|
||||
setName('');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create file.');
|
||||
// A name already taken by a file comes back as UploadConflictError;
|
||||
// surface it inline so the user can pick another. Other rejections
|
||||
// (including a same-named folder) fall through to the toast below.
|
||||
if (e instanceof UploadConflictError) {
|
||||
setValidationError('A file with that name already exists.');
|
||||
} else {
|
||||
toast.error(e instanceof Error ? e.message : 'Failed to create file.');
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Trash2, FolderPlus, Download, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { Trash2, FilePlus, FolderPlus, FolderInput, Download, Loader2, AlertTriangle, X } 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, listFileRoots, renameStackPath, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi';
|
||||
import { downloadStackFile, listStackDirectory, listFileRoots, renameStackPath, copyStackFile, relPathParentDir, nextDuplicateName, isProtectedRootRelPath, normalizeSelection, bulkDeleteStackPaths, bulkMoveStackPaths, bulkDownloadStackFiles, STACK_SOURCE_ROOT_ID } from '@/lib/stackFilesApi';
|
||||
import { downloadBlob } from '@/lib/download';
|
||||
import { FileTree } from './FileTree';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { FileUploadDropzone } from './FileUploadDropzone';
|
||||
@@ -42,6 +43,13 @@ const STACK_SOURCE_FALLBACK: FileRoot = {
|
||||
backend: 'fs',
|
||||
};
|
||||
|
||||
/** A short, actionable summary of bulk per-item failures for a toast. */
|
||||
function describeFailures(failed: { path: string; error: string }[]): string {
|
||||
if (failed.length === 0) return '';
|
||||
const first = `${failed[0].path} (${failed[0].error})`;
|
||||
return failed.length > 1 ? `${first} and ${failed.length - 1} more` : first;
|
||||
}
|
||||
|
||||
/** 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';
|
||||
@@ -101,6 +109,17 @@ export function StackFileExplorer({
|
||||
const [moveRelPath, setMoveRelPath] = useState('');
|
||||
const [moveEntry, setMoveEntry] = useState<FileEntry | null>(null);
|
||||
|
||||
// ── context menu: copy to… ──
|
||||
const [copyOpen, setCopyOpen] = useState(false);
|
||||
const [copyRelPath, setCopyRelPath] = useState('');
|
||||
const [copyEntry, setCopyEntry] = useState<FileEntry | null>(null);
|
||||
|
||||
// ── bulk selection (checkboxes + shift/ctrl) ──
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
const [bulkMoveOpen, setBulkMoveOpen] = useState(false);
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
|
||||
// ── context menu: delete ──
|
||||
const [ctxDeleteOpen, setCtxDeleteOpen] = useState(false);
|
||||
const [ctxDeletePath, setCtxDeletePath] = useState('');
|
||||
@@ -124,6 +143,7 @@ export function StackFileExplorer({
|
||||
setRoots([STACK_SOURCE_FALLBACK]);
|
||||
setSelectedRootId(STACK_SOURCE_ROOT_ID);
|
||||
setPendingRootId(null);
|
||||
setSelectedPaths(new Set());
|
||||
}, [stackName]);
|
||||
|
||||
// Discover the stack's file roots and default to the first browsable volume
|
||||
@@ -154,6 +174,8 @@ export function StackFileExplorer({
|
||||
setSelectedPath(null);
|
||||
setSelectedEntry(null);
|
||||
setCurrentDir('');
|
||||
// Selection paths are scoped to the previous root; drop them on switch.
|
||||
setSelectedPaths(new Set());
|
||||
}, []);
|
||||
|
||||
// Switch roots, guarding unsaved edits in the viewer first.
|
||||
@@ -242,6 +264,23 @@ export function StackFileExplorer({
|
||||
}
|
||||
}, [stackName, selectedRootId, selectedPath, isViewerDirty, handleDeleted, refresh]);
|
||||
|
||||
// Copy handler for the "Copy to…" dialog. Copying never touches the open file,
|
||||
// so there is no unsaved-changes guard. Returns true on success so the dialog
|
||||
// closes; the current parent is disabled in the picker, so a same-folder copy
|
||||
// goes through Duplicate instead.
|
||||
const handleCopy = useCallback(async (fromRel: string, entryName: string, destDir: string): Promise<boolean> => {
|
||||
const toRel = destDir ? `${destDir}/${entryName}` : entryName;
|
||||
try {
|
||||
await copyStackFile(stackName, fromRel, toRel, selectedRootId);
|
||||
toast.success('Copied successfully.');
|
||||
refresh();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Copy failed.');
|
||||
return false;
|
||||
}
|
||||
}, [stackName, selectedRootId, refresh]);
|
||||
|
||||
// ── Context menu callbacks ──
|
||||
|
||||
const handleContextMenuMove = useCallback((relPath: string, entry: FileEntry) => {
|
||||
@@ -250,6 +289,118 @@ export function StackFileExplorer({
|
||||
setMoveOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleContextMenuCopy = useCallback((relPath: string, entry: FileEntry) => {
|
||||
setCopyRelPath(relPath);
|
||||
setCopyEntry(entry);
|
||||
setCopyOpen(true);
|
||||
}, []);
|
||||
|
||||
// ── Bulk selection helpers ──
|
||||
|
||||
// The selection, with descendants of a selected ancestor dropped (UX mirror of
|
||||
// the backend's authoritative normalization).
|
||||
const selection = useMemo(() => normalizeSelection([...selectedPaths]), [selectedPaths]);
|
||||
// Protected root files (compose/.env) cannot be deleted or moved, so they are
|
||||
// excluded from those bulk actions (but may still be downloaded).
|
||||
const movableSelection = useMemo(() => selection.filter((p) => !isProtectedRootRelPath(p)), [selection]);
|
||||
const protectedExcludedCount = selection.length - movableSelection.length;
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedPaths(new Set()), []);
|
||||
|
||||
// True when the open file is one of `paths` (or inside a selected folder), so
|
||||
// a bulk op that removed it should clear the viewer.
|
||||
const openFileAffectedBy = useCallback(
|
||||
(paths: string[]): boolean =>
|
||||
selectedPath !== null && paths.some((p) => selectedPath === p || selectedPath.startsWith(`${p}/`)),
|
||||
[selectedPath],
|
||||
);
|
||||
|
||||
// Keep the items that failed in the selection (clearing the rest) so a partial
|
||||
// failure leaves the action bar scoped to exactly what still needs attention.
|
||||
const keepFailedSelected = useCallback((failed: { path: string }[]) => {
|
||||
setSelectedPaths(new Set(failed.map((f) => f.path)));
|
||||
}, []);
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const result = await bulkDeleteStackPaths(stackName, movableSelection, selectedRootId);
|
||||
const okN = result.deleted.length;
|
||||
if (result.failed.length === 0) toast.success(`Deleted ${okN} ${okN === 1 ? 'item' : 'items'}.`);
|
||||
else if (okN > 0) toast.error(`Deleted ${okN}, ${result.failed.length} failed: ${describeFailures(result.failed)}`);
|
||||
else toast.error(`Delete failed: ${describeFailures(result.failed)}`);
|
||||
setBulkDeleteOpen(false);
|
||||
const affected = openFileAffectedBy(result.deleted);
|
||||
keepFailedSelected(result.failed);
|
||||
if (affected) handleDeleted();
|
||||
else refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Delete failed.');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}, [stackName, selectedRootId, movableSelection, openFileAffectedBy, keepFailedSelected, handleDeleted, refresh]);
|
||||
|
||||
// Bulk move confirm handler for the destination picker. Resolves true (closing
|
||||
// the dialog) only when at least one item moved.
|
||||
const handleBulkMove = useCallback(async (destDir: string): Promise<boolean> => {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const result = await bulkMoveStackPaths(stackName, movableSelection, destDir, selectedRootId);
|
||||
const okN = result.moved.length;
|
||||
if (result.failed.length === 0) toast.success(`Moved ${okN} ${okN === 1 ? 'item' : 'items'}.`);
|
||||
else if (okN > 0) toast.error(`Moved ${okN}, ${result.failed.length} failed: ${describeFailures(result.failed)}`);
|
||||
else toast.error(`Move failed: ${describeFailures(result.failed)}`);
|
||||
if (okN === 0) return false; // nothing moved: keep the dialog and selection
|
||||
const affected = openFileAffectedBy(result.moved);
|
||||
keepFailedSelected(result.failed);
|
||||
if (affected) handleDeleted();
|
||||
else refresh();
|
||||
return true;
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Move failed.');
|
||||
return false;
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}, [stackName, selectedRootId, movableSelection, openFileAffectedBy, keepFailedSelected, handleDeleted, refresh]);
|
||||
|
||||
const handleBulkDownload = useCallback(async () => {
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res = await bulkDownloadStackFiles(stackName, selection, selectedRootId);
|
||||
if (!res.ok) {
|
||||
// Prefer the server's specific reason (e.g. a volume file that is a
|
||||
// symlink or exceeds the per-file limit); fall back per status.
|
||||
let msg = res.status === 413 ? 'The selection is too large to download.' : 'Download failed.';
|
||||
try { const body = await res.json(); if (body?.error) msg = body.error as string; } catch { /* keep the default */ }
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
downloadBlob(`${stackName}-files.tar.gz`, await res.blob());
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Download failed.');
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
}, [stackName, selectedRootId, selection]);
|
||||
|
||||
// Duplicate: copy the entry into its own folder under a non-colliding "copy"
|
||||
// name, derived from the parent's current listing.
|
||||
const handleContextMenuDuplicate = useCallback(async (relPath: string, entry: FileEntry) => {
|
||||
const parent = relPathParentDir(relPath);
|
||||
try {
|
||||
const siblings = await listStackDirectory(stackName, parent, selectedRootId);
|
||||
const newName = nextDuplicateName(entry.name, new Set(siblings.map((e) => e.name)));
|
||||
const toRel = parent ? `${parent}/${newName}` : newName;
|
||||
await copyStackFile(stackName, relPath, toRel, selectedRootId);
|
||||
toast.success('Duplicated successfully.');
|
||||
refresh();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'Duplicate failed.');
|
||||
}
|
||||
}, [stackName, selectedRootId, refresh]);
|
||||
|
||||
const handleContextMenuRename = useCallback((relPath: string) => {
|
||||
const name = relPath.split('/').pop() ?? relPath;
|
||||
setRenameRelPath(relPath);
|
||||
@@ -328,12 +479,28 @@ export function StackFileExplorer({
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
</div>
|
||||
{rootCanEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="New file"
|
||||
aria-label="New file"
|
||||
onClick={() => {
|
||||
setNewFileDir(currentDir);
|
||||
setNewFileOpen(true);
|
||||
}}
|
||||
>
|
||||
<FilePlus className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{rootCanEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
title="New folder"
|
||||
aria-label="New folder"
|
||||
onClick={() => {
|
||||
setNewFolderDir(currentDir);
|
||||
setNewFolderOpen(true);
|
||||
@@ -343,6 +510,59 @@ export function StackFileExplorer({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedPaths.size > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-glass-border shrink-0 bg-accent/10">
|
||||
<span className="text-xs font-mono mr-auto">{selectedPaths.size} selected</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label="Download selection"
|
||||
title="Download selection"
|
||||
onClick={() => void handleBulkDownload()}
|
||||
disabled={bulkBusy}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{rootCanEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label="Move selection"
|
||||
title="Move selection"
|
||||
onClick={() => setBulkMoveOpen(true)}
|
||||
disabled={bulkBusy || movableSelection.length === 0}
|
||||
>
|
||||
<FolderInput className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-destructive"
|
||||
aria-label="Delete selection"
|
||||
title="Delete selection"
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
disabled={bulkBusy || movableSelection.length === 0}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
onClick={clearSelection}
|
||||
disabled={bulkBusy}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<FileTree
|
||||
key={`${stackName}:${selectedRootId}:${refreshKey}`}
|
||||
@@ -357,11 +577,15 @@ export function StackFileExplorer({
|
||||
canEdit={rootCanEdit}
|
||||
onContextMenuRename={handleContextMenuRename}
|
||||
onContextMenuMove={handleContextMenuMove}
|
||||
onContextMenuDuplicate={handleContextMenuDuplicate}
|
||||
onContextMenuCopy={handleContextMenuCopy}
|
||||
onContextMenuNewFile={handleContextMenuNewFile}
|
||||
onContextMenuNewFolder={handleContextMenuNewFolder}
|
||||
onContextMenuDelete={handleContextMenuDelete}
|
||||
onContextMenuPermissions={handleContextMenuPermissions}
|
||||
onMove={handleMove}
|
||||
selectedPaths={selectedPaths}
|
||||
onSelectionChange={setSelectedPaths}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -486,6 +710,49 @@ export function StackFileExplorer({
|
||||
onMove={handleMove}
|
||||
/>
|
||||
|
||||
{/* Copy to… (reuses the move picker; current parent disabled, Duplicate covers same-folder) */}
|
||||
<MoveFileDialog
|
||||
open={copyOpen}
|
||||
onOpenChange={setCopyOpen}
|
||||
stackName={stackName}
|
||||
relPath={copyRelPath}
|
||||
entry={copyEntry}
|
||||
rootId={selectedRootId}
|
||||
mode="copy"
|
||||
onMove={handleCopy}
|
||||
/>
|
||||
|
||||
{/* Bulk move: a destination picker validated against every selected source. */}
|
||||
<MoveFileDialog
|
||||
open={bulkMoveOpen}
|
||||
onOpenChange={setBulkMoveOpen}
|
||||
stackName={stackName}
|
||||
relPath=""
|
||||
entry={null}
|
||||
rootId={selectedRootId}
|
||||
bulkSourcePaths={movableSelection}
|
||||
onMove={handleMove}
|
||||
onConfirmDestination={handleBulkMove}
|
||||
/>
|
||||
|
||||
{/* Bulk delete confirmation */}
|
||||
<ConfirmModal
|
||||
open={bulkDeleteOpen}
|
||||
onOpenChange={setBulkDeleteOpen}
|
||||
onCancel={() => setBulkDeleteOpen(false)}
|
||||
variant="destructive"
|
||||
kicker={`${stackName.toUpperCase()} · DELETE`}
|
||||
title="Delete selected items?"
|
||||
description={
|
||||
`Permanently delete ${movableSelection.length} ${movableSelection.length === 1 ? 'item' : 'items'}? `
|
||||
+ 'Folders are removed with their contents. This cannot be undone.'
|
||||
+ (protectedExcludedCount > 0 ? ` ${protectedExcludedCount} protected file(s) are kept.` : '')
|
||||
}
|
||||
confirmLabel="Delete"
|
||||
confirming={bulkBusy}
|
||||
onConfirm={() => void handleBulkDelete()}
|
||||
/>
|
||||
|
||||
{/* Permissions */}
|
||||
<FilePermissionsDialog
|
||||
open={permissionsOpen}
|
||||
|
||||
@@ -21,9 +21,14 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// ScrollArea just renders children so the tree nodes are accessible in jsdom.
|
||||
const sa = vi.hoisted(() => ({ props: {} as Record<string, unknown> }));
|
||||
// ScrollArea just renders children so the tree nodes are accessible in jsdom;
|
||||
// capture its props so the horizontal-scroll opt-in is testable.
|
||||
vi.mock('@/components/ui/scroll-area', () => ({
|
||||
ScrollArea: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ScrollArea: ({ children, ...props }: { children: React.ReactNode } & Record<string, unknown>) => {
|
||||
sa.props = props;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/skeleton', () => ({
|
||||
@@ -251,6 +256,235 @@ describe('FileTree', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── accessibility: tree roles + keyboard navigation ────────────────────────
|
||||
|
||||
describe('FileTree accessibility', () => {
|
||||
it('exposes a tree with treeitem rows carrying level and selected state', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} selectedPath="README.md" />);
|
||||
|
||||
await screen.findByText('src');
|
||||
expect(screen.getByRole('tree', { name: /files/i })).toBeInTheDocument();
|
||||
const items = screen.getAllByRole('treeitem');
|
||||
expect(items.length).toBe(2);
|
||||
// aria-level is 1-based at the root.
|
||||
expect(rowFor('src')).toHaveAttribute('aria-level', '1');
|
||||
// The selected file reports aria-selected.
|
||||
expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true');
|
||||
expect(rowFor('src')).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('uses roving tabindex: only one row is tabbable at a time', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} selectedPath="README.md" />);
|
||||
|
||||
await screen.findByText('src');
|
||||
// The selected row is the roving focus, so it holds tabIndex 0; the other -1.
|
||||
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
||||
expect(rowFor('src')).toHaveAttribute('tabindex', '-1');
|
||||
});
|
||||
|
||||
it('moves focus with ArrowDown/ArrowUp and jumps with Home/End', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
|
||||
const tree = screen.getByRole('tree');
|
||||
// No prior selection: the first node is the roving target.
|
||||
expect(rowFor('src')).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
||||
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
||||
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
||||
expect(rowFor('src')).toHaveAttribute('tabindex', '-1');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowUp' });
|
||||
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'End' });
|
||||
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'Home' });
|
||||
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
||||
});
|
||||
|
||||
it('expands a collapsed directory with ArrowRight and collapses it with ArrowLeft', async () => {
|
||||
mockLoadDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
|
||||
const tree = screen.getByRole('tree');
|
||||
// src is the first (roving) node; ArrowRight expands it.
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' });
|
||||
expect(await screen.findByText('index.ts')).toBeInTheDocument();
|
||||
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// ArrowLeft on the expanded directory collapses it.
|
||||
fireEvent.keyDown(tree, { key: 'ArrowLeft' });
|
||||
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
||||
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('activates the focused row with Enter', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
|
||||
const tree = screen.getByRole('tree');
|
||||
// Move focus to the file, then Enter selects it.
|
||||
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
||||
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
||||
fireEvent.keyDown(rowFor('README.md'), { key: 'Enter' });
|
||||
expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' }));
|
||||
});
|
||||
|
||||
it('steps into the first child with ArrowRight on an already-expanded directory', async () => {
|
||||
mockLoadDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
const tree = screen.getByRole('tree');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src in place
|
||||
await screen.findByText('index.ts');
|
||||
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // step into the first child
|
||||
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
||||
expect(rowFor('index.ts')).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
|
||||
it('moves to the parent directory with ArrowLeft from a child, without collapsing it', async () => {
|
||||
mockLoadDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
const tree = screen.getByRole('tree');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src
|
||||
await screen.findByText('index.ts');
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // focus index.ts
|
||||
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowLeft' }); // back up to the parent
|
||||
await waitFor(() => expect(rowFor('src')).toHaveFocus());
|
||||
// Moving to the parent must NOT collapse it.
|
||||
expect(rowFor('src')).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('keeps exactly one tabbable row when the active node is filtered out', async () => {
|
||||
mockLoadDir
|
||||
.mockReturnValueOnce(fakeOk(ROOT_ENTRIES))
|
||||
.mockReturnValueOnce(fakeOk(SRC_ENTRIES));
|
||||
const user = userEvent.setup();
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
const tree = screen.getByRole('tree');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // expand src
|
||||
await screen.findByText('index.ts');
|
||||
fireEvent.keyDown(tree, { key: 'ArrowRight' }); // active = src/index.ts
|
||||
await waitFor(() => expect(rowFor('index.ts')).toHaveFocus());
|
||||
|
||||
// Filter to "src": the active child is removed from view, so the roving
|
||||
// target must fall back rather than leave the tree with no tabbable row.
|
||||
await user.type(screen.getByLabelText(/filter files/i), 'src');
|
||||
await waitFor(() => expect(screen.queryByText('index.ts')).not.toBeInTheDocument());
|
||||
const tabbable = screen.getAllByRole('treeitem').filter(r => r.getAttribute('tabindex') === '0');
|
||||
expect(tabbable).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('announces the selected file basename in the live region', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(SRC_ENTRIES));
|
||||
const { container } = render(<FileTree {...defaultProps()} selectedPath="src/index.ts" />);
|
||||
await screen.findByText('index.ts');
|
||||
const live = container.querySelector('[aria-live="polite"]');
|
||||
expect(live).toHaveTextContent('Selected index.ts');
|
||||
});
|
||||
|
||||
it('clamps focus at the ends (ArrowUp at top, ArrowDown at bottom are no-ops)', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
const tree = screen.getByRole('tree');
|
||||
|
||||
// src is the first node; ArrowUp at the top stays on src.
|
||||
fireEvent.keyDown(tree, { key: 'ArrowUp' });
|
||||
expect(rowFor('src')).toHaveAttribute('tabindex', '0');
|
||||
|
||||
fireEvent.keyDown(tree, { key: 'End' });
|
||||
await waitFor(() => expect(rowFor('README.md')).toHaveFocus());
|
||||
// ArrowDown at the bottom stays on the last row.
|
||||
fireEvent.keyDown(tree, { key: 'ArrowDown' });
|
||||
expect(rowFor('README.md')).toHaveAttribute('tabindex', '0');
|
||||
});
|
||||
});
|
||||
|
||||
// ── bulk selection (checkbox + modifier clicks) ─────────────────────────────
|
||||
|
||||
describe('FileTree bulk selection', () => {
|
||||
it('Ctrl/Cmd+click toggles a row into the selection without opening it', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
const onSelectionChange = vi.fn();
|
||||
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
||||
await screen.findByText('README.md');
|
||||
|
||||
fireEvent.click(rowFor('README.md'), { ctrlKey: true });
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md']));
|
||||
// Modifier-click must not open the file in the viewer.
|
||||
expect(onSelectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a checkbox click toggles selection without opening the row', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
const onSelectionChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
||||
await screen.findByText('README.md');
|
||||
|
||||
await user.click(screen.getByLabelText('Select README.md'));
|
||||
expect(onSelectionChange).toHaveBeenCalledWith(new Set(['README.md']));
|
||||
expect(onSelectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Shift+click selects the contiguous range from the anchor in visible order', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk([makeFile('a.txt'), makeFile('b.txt'), makeFile('c.txt')]));
|
||||
const onSelectionChange = vi.fn();
|
||||
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
||||
await screen.findByText('a.txt');
|
||||
|
||||
// Ctrl+click sets the anchor on the first row.
|
||||
fireEvent.click(rowFor('a.txt'), { ctrlKey: true });
|
||||
// Shift+click the third row selects the whole a..c range.
|
||||
fireEvent.click(rowFor('c.txt'), { shiftKey: true });
|
||||
expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['a.txt', 'b.txt', 'c.txt']));
|
||||
expect(onSelectFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a plain click still opens the file and does not change the selection', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
const onSelectionChange = vi.fn();
|
||||
render(<FileTree {...defaultProps()} selectedPaths={new Set()} onSelectionChange={onSelectionChange} />);
|
||||
await screen.findByText('README.md');
|
||||
|
||||
fireEvent.click(rowFor('README.md'));
|
||||
expect(onSelectFile).toHaveBeenCalledWith('README.md', expect.objectContaining({ name: 'README.md' }));
|
||||
expect(onSelectionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reflects checkbox membership via aria-selected', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} selectedPaths={new Set(['README.md'])} onSelectionChange={vi.fn()} />);
|
||||
await screen.findByText('README.md');
|
||||
expect(rowFor('README.md')).toHaveAttribute('aria-selected', 'true');
|
||||
expect(rowFor('src')).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ── drag-and-drop move ──────────────────────────────────────────────────────
|
||||
|
||||
/** A minimal DataTransfer stand-in carrying our custom move payload (or an OS file drag). */
|
||||
@@ -266,7 +500,7 @@ function makeDataTransfer(payload: FileEntryDragPayload | null): DataTransfer {
|
||||
}
|
||||
|
||||
function rowFor(name: string): HTMLElement {
|
||||
const el = screen.getByText(name).closest('[role="button"]');
|
||||
const el = screen.getByText(name).closest('[role="treeitem"]');
|
||||
if (!el) throw new Error(`no row for ${name}`);
|
||||
return el as HTMLElement;
|
||||
}
|
||||
@@ -365,3 +599,43 @@ describe('FileTree drag-and-drop move', () => {
|
||||
expect(rowFor('compose.yaml').draggable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── layout: full-row hit area + horizontal scroll for long names ────────────
|
||||
|
||||
describe('FileTree layout', () => {
|
||||
it('opts the tree ScrollArea into a horizontal scrollbar', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
expect(sa.props.horizontal).toBe(true);
|
||||
});
|
||||
|
||||
it('spans the tree and rows to the full pane width and stops clipping names', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} />);
|
||||
await screen.findByText('src');
|
||||
|
||||
// The container and each row fill the pane while growing to content, so the
|
||||
// right-click hit area covers the whole row at any horizontal scroll offset.
|
||||
const tree = screen.getByRole('tree');
|
||||
expect(tree.className).toMatch(/\bmin-w-full\b/);
|
||||
expect(tree.className).toMatch(/\bw-max\b/);
|
||||
expect(rowFor('src').className).toMatch(/\bmin-w-full\b/);
|
||||
expect(rowFor('src').className).toMatch(/\bw-max\b/);
|
||||
|
||||
// The name renders in full (no truncation), it just does not wrap.
|
||||
const nameSpan = screen.getByText('README.md');
|
||||
expect(nameSpan.className).toContain('whitespace-nowrap');
|
||||
expect(nameSpan.className).not.toContain('truncate');
|
||||
});
|
||||
|
||||
it('opens the Sencho context menu when a directory row is right-clicked', async () => {
|
||||
mockLoadDir.mockReturnValue(fakeOk(ROOT_ENTRIES));
|
||||
render(<FileTree {...defaultProps()} canEdit />);
|
||||
await screen.findByText('src');
|
||||
|
||||
fireEvent.contextMenu(rowFor('src'));
|
||||
expect(await screen.findByText('New File')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rename')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,4 +145,60 @@ describe('MoveFileDialog', () => {
|
||||
// Root is the source's current parent here, so it is also a no-op.
|
||||
expect(labelButton('Stack root')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('in copy mode shows a Copy button and keeps the current parent disabled', async () => {
|
||||
listMock.mockResolvedValue([dir('configs'), dir('services')]);
|
||||
const onMove = vi.fn().mockResolvedValue(true);
|
||||
const onOpenChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
stackName="my-stack"
|
||||
relPath="configs/app.conf"
|
||||
entry={file('app.conf')}
|
||||
mode="copy"
|
||||
onMove={onMove}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('services');
|
||||
// The confirm button is labelled Copy, and there is no Move button.
|
||||
const copyBtn = screen.getByRole('button', { name: /^copy$/i });
|
||||
expect(copyBtn).toBeDisabled();
|
||||
expect(screen.queryByRole('button', { name: /^move$/i })).toBeNull();
|
||||
// Same destination gating as move: the current parent stays disabled, so a
|
||||
// same-folder copy must go through Duplicate, not this picker.
|
||||
expect(labelButton('configs')).toBeDisabled();
|
||||
|
||||
await user.click(labelButton('services'));
|
||||
expect(copyBtn).toBeEnabled();
|
||||
await user.click(copyBtn);
|
||||
expect(onMove).toHaveBeenCalledWith('configs/app.conf', 'app.conf', 'services');
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
|
||||
});
|
||||
|
||||
it('in copy mode disables the stack root for a reserved name so a protected file can only land in a subfolder', async () => {
|
||||
listMock.mockResolvedValue([dir('backups')]);
|
||||
|
||||
render(
|
||||
<MoveFileDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
stackName="my-stack"
|
||||
relPath="compose.yaml"
|
||||
entry={file('compose.yaml')}
|
||||
mode="copy"
|
||||
onMove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText('backups');
|
||||
// The stack root is disabled for a reserved name; a subfolder stays valid,
|
||||
// so copying compose.yaml is allowed but only into a subfolder.
|
||||
expect(labelButton('Stack root')).toBeDisabled();
|
||||
expect(labelButton('backups')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Coverage for NewFileDialog create semantics.
|
||||
*
|
||||
* Creating a file routes through createEmptyStackFile (a zero-byte upload with
|
||||
* no overwrite), so the server decides collisions. A fresh name creates and
|
||||
* fires onCreated; an existing name comes back as UploadConflictError and must
|
||||
* surface inline without clobbering the existing file or closing the dialog.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
createMock: vi.fn<(stack: string, dir: string, name: string, opts?: { rootId?: string }) => Promise<void>>(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
// Keep the real module (UploadConflictError must stay a real class for
|
||||
// instanceof to hold) and swap only the create call.
|
||||
vi.mock('@/lib/stackFilesApi', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/stackFilesApi')>()),
|
||||
createEmptyStackFile: h.createMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: h.toastError, success: h.toastSuccess, loading: vi.fn(() => 'id'), dismiss: vi.fn() },
|
||||
}));
|
||||
|
||||
import { NewFileDialog } from '../NewFileDialog';
|
||||
import { UploadConflictError } from '@/lib/stackFilesApi';
|
||||
|
||||
function setup(currentDir = 'configs') {
|
||||
const onCreated = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<NewFileDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
stackName="my-stack"
|
||||
currentDir={currentDir}
|
||||
rootId="stack-source"
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
);
|
||||
return { onCreated, onOpenChange };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.createMock.mockReset();
|
||||
h.toastError.mockReset();
|
||||
h.toastSuccess.mockReset();
|
||||
});
|
||||
|
||||
describe('NewFileDialog', () => {
|
||||
it('creates a blank file in the current dir and reports success', async () => {
|
||||
h.createMock.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
const { onCreated, onOpenChange } = setup('configs');
|
||||
|
||||
await user.type(screen.getByLabelText(/file name/i), 'app.conf');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(h.createMock).toHaveBeenCalledWith('my-stack', 'configs', 'app.conf', { rootId: 'stack-source' }),
|
||||
);
|
||||
expect(onCreated).toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(h.toastSuccess).toHaveBeenCalledWith('File created.');
|
||||
});
|
||||
|
||||
it('surfaces an inline error and does not overwrite when the name already exists', async () => {
|
||||
h.createMock.mockRejectedValueOnce(new UploadConflictError('app.conf already exists.'));
|
||||
const user = userEvent.setup();
|
||||
const { onCreated, onOpenChange } = setup('configs');
|
||||
|
||||
await user.type(screen.getByLabelText(/file name/i), 'app.conf');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
expect(await screen.findByText(/a file with that name already exists/i)).toBeInTheDocument();
|
||||
// The dialog stays open and the create is not treated as a success.
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
expect(h.toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid name client-side without calling the server', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup('configs');
|
||||
|
||||
await user.type(screen.getByLabelText(/file name/i), 'bad/name');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
expect(await screen.findByText(/must not be empty/i)).toBeInTheDocument();
|
||||
expect(h.createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes an unexpected failure to a toast', async () => {
|
||||
h.createMock.mockRejectedValueOnce(new Error('disk full'));
|
||||
const user = userEvent.setup();
|
||||
const { onCreated } = setup('configs');
|
||||
|
||||
await user.type(screen.getByLabelText(/file name/i), 'app.conf');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('disk full'));
|
||||
expect(onCreated).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,26 +11,54 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { FileEntry } from '@/lib/stackFilesApi';
|
||||
import { downloadBlob } from '@/lib/download';
|
||||
|
||||
// Holder for the renameStackPath mock and the captured onMove callback, so tests
|
||||
// can drive the shared move handler directly (the DnD path passes it as onMove).
|
||||
const h = vi.hoisted(() => ({
|
||||
renameMock: vi.fn<(stack: string, from: string, to: string) => Promise<void>>(),
|
||||
copyMock: vi.fn<(stack: string, from: string, to: string, rootId?: string) => Promise<void>>(),
|
||||
listMock: vi.fn<(stack: string, dir: string, rootId?: string) => Promise<FileEntry[]>>(),
|
||||
bulkDeleteMock: vi.fn<(stack: string, paths: string[], rootId?: string) => Promise<{ deleted: string[]; failed: { path: string; error: string }[] }>>(),
|
||||
bulkMoveMock: vi.fn<(stack: string, from: string[], toDir: string, rootId?: string) => Promise<{ moved: string[]; failed: { path: string; error: string }[] }>>(),
|
||||
bulkDownloadMock: vi.fn<(stack: string, paths: string[], rootId?: string) => Promise<Response>>(),
|
||||
onMove: null as null | ((fromRel: string, entryName: string, destDir: string) => void),
|
||||
onCopy: null as null | ((fromRel: string, entryName: string, destDir: string) => boolean | Promise<boolean>),
|
||||
onConfirmDestination: null as null | ((destDir: string) => boolean | Promise<boolean>),
|
||||
onSelectionChange: null as null | ((next: Set<string>) => void),
|
||||
newFileProps: null as null | { open: boolean; currentDir: string; rootId?: string },
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stackFilesApi', () => ({
|
||||
STACK_SOURCE_ROOT_ID: 'stack-source',
|
||||
listStackDirectory: vi.fn().mockResolvedValue([]),
|
||||
listStackDirectory: h.listMock,
|
||||
listFileRoots: vi.fn().mockResolvedValue([]),
|
||||
downloadStackFile: vi.fn(),
|
||||
readStackFile: vi.fn(),
|
||||
writeStackFile: vi.fn(),
|
||||
renameStackPath: h.renameMock,
|
||||
copyStackFile: h.copyMock,
|
||||
bulkDeleteStackPaths: h.bulkDeleteMock,
|
||||
bulkMoveStackPaths: h.bulkMoveMock,
|
||||
bulkDownloadStackFiles: h.bulkDownloadMock,
|
||||
relPathParentDir: (p: string) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : ''),
|
||||
nextDuplicateName: (n: string) => `${n} copy`,
|
||||
isProtectedRootRelPath: (rel: string) =>
|
||||
['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml', '.env'].includes(rel),
|
||||
normalizeSelection: (paths: string[]) => {
|
||||
const set = new Set(paths);
|
||||
return [...set].filter((p) => {
|
||||
const seg = p.split('/');
|
||||
for (let i = 1; i < seg.length; i++) if (set.has(seg.slice(0, i).join('/'))) return false;
|
||||
return true;
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/download', () => ({ downloadBlob: vi.fn() }));
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: h.toastError, success: h.toastSuccess, loading: vi.fn(() => 'id'), dismiss: vi.fn() },
|
||||
}));
|
||||
@@ -40,22 +68,45 @@ vi.mock('../FileUploadDropzone', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../NewFolderDialog', () => ({ NewFolderDialog: () => null }));
|
||||
vi.mock('../NewFileDialog', () => ({ NewFileDialog: () => null }));
|
||||
// Capture the dialog props so the toolbar button's open/dir/root wiring is testable.
|
||||
vi.mock('../NewFileDialog', () => ({
|
||||
NewFileDialog: (props: { open: boolean; currentDir: string; rootId?: string }) => {
|
||||
h.newFileProps = { open: props.open, currentDir: props.currentDir, rootId: props.rootId };
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../DeleteFileConfirm', () => ({ DeleteFileConfirm: () => null }));
|
||||
vi.mock('../RenameDialog', () => ({ RenameDialog: () => null }));
|
||||
vi.mock('../MoveFileDialog', () => ({ MoveFileDialog: () => null }));
|
||||
// Capture the copy-mode dialog's confirm callback so handleCopy can be driven
|
||||
// directly (the move-mode instance is exercised via the drag-and-drop onMove).
|
||||
vi.mock('../MoveFileDialog', () => ({
|
||||
MoveFileDialog: ({ mode, onMove, onConfirmDestination }: {
|
||||
mode?: 'move' | 'copy';
|
||||
onMove?: (fromRel: string, entryName: string, destDir: string) => boolean | Promise<boolean>;
|
||||
onConfirmDestination?: (destDir: string) => boolean | Promise<boolean>;
|
||||
}) => {
|
||||
if (mode === 'copy') h.onCopy = onMove ?? null;
|
||||
if (onConfirmDestination) h.onConfirmDestination = onConfirmDestination; // the bulk-move instance
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../FilePermissionsDialog', () => ({ FilePermissionsDialog: () => null }));
|
||||
|
||||
// FileTree mock: selection buttons (two siblings + one nested file) plus capture
|
||||
// of the onMove callback so move-handler behaviour can be driven directly.
|
||||
vi.mock('../FileTree', () => ({
|
||||
FileTree: ({ onSelectFile, onMove }: {
|
||||
FileTree: ({ onSelectFile, onMove, onContextMenuDuplicate, onSelectionChange }: {
|
||||
onSelectFile: (rel: string, entry: FileEntry) => void;
|
||||
onMove?: (fromRel: string, entryName: string, destDir: string) => void;
|
||||
onContextMenuDuplicate?: (relPath: string, entry: FileEntry) => void;
|
||||
onSelectionChange?: (next: Set<string>) => void;
|
||||
}) => {
|
||||
h.onMove = onMove ?? null;
|
||||
h.onSelectionChange = onSelectionChange ?? null;
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onSelectionChange?.(new Set(['a.txt', 'b.txt']))}>bulk-select-two</button>
|
||||
<button onClick={() => onSelectionChange?.(new Set(['compose.yaml', 'a.txt']))}>bulk-select-protected</button>
|
||||
<button onClick={() => onSelectFile('a.txt', { name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-a
|
||||
</button>
|
||||
@@ -65,6 +116,9 @@ vi.mock('../FileTree', () => ({
|
||||
<button onClick={() => onSelectFile('dir/a.txt', { name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
select-nested
|
||||
</button>
|
||||
<button onClick={() => onContextMenuDuplicate?.('configs/app.conf', { name: 'app.conf', type: 'file', size: 1, mtime: 0, isProtected: false })}>
|
||||
ctx-duplicate
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -217,3 +271,186 @@ describe('StackFileExplorer move handling', () => {
|
||||
expect(screen.getByTestId('viewer-selected').textContent).toBe('a.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackFileExplorer copy and duplicate handling', () => {
|
||||
beforeEach(() => {
|
||||
h.copyMock.mockReset().mockResolvedValue(undefined);
|
||||
h.listMock.mockReset().mockResolvedValue([]);
|
||||
h.toastError.mockReset();
|
||||
h.toastSuccess.mockReset();
|
||||
});
|
||||
|
||||
it('duplicates into the same folder under a non-colliding "copy" name', async () => {
|
||||
h.listMock.mockResolvedValue([
|
||||
{ name: 'app.conf', type: 'file', size: 1, mtime: 0, isProtected: false },
|
||||
]);
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
|
||||
await user.click(screen.getByText('ctx-duplicate'));
|
||||
|
||||
// Siblings are listed from the entry's parent dir, then copied to the derived name.
|
||||
await waitFor(() => expect(h.listMock).toHaveBeenCalledWith('my-stack', 'configs', 'stack-source'));
|
||||
await waitFor(() => expect(h.copyMock).toHaveBeenCalledWith('my-stack', 'configs/app.conf', 'configs/app.conf copy', 'stack-source'));
|
||||
await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Duplicated successfully.'));
|
||||
});
|
||||
|
||||
it('surfaces an error toast when the sibling listing for duplicate fails', async () => {
|
||||
h.listMock.mockRejectedValueOnce(new Error('Failed to load folders.'));
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
|
||||
await user.click(screen.getByText('ctx-duplicate'));
|
||||
|
||||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('Failed to load folders.'));
|
||||
expect(h.copyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('copies via the copy-to dialog handler and reports success', async () => {
|
||||
setup();
|
||||
await waitFor(() => expect(h.onCopy).not.toBeNull());
|
||||
|
||||
const result = await h.onCopy?.('a.txt', 'a.txt', 'sub');
|
||||
expect(result).toBe(true);
|
||||
expect(h.copyMock).toHaveBeenCalledWith('my-stack', 'a.txt', 'sub/a.txt', 'stack-source');
|
||||
expect(h.toastSuccess).toHaveBeenCalledWith('Copied successfully.');
|
||||
});
|
||||
|
||||
it('surfaces an error toast and stays open when the copy fails', async () => {
|
||||
h.copyMock.mockRejectedValueOnce(new Error('already exists'));
|
||||
setup();
|
||||
await waitFor(() => expect(h.onCopy).not.toBeNull());
|
||||
|
||||
const result = await h.onCopy?.('a.txt', 'a.txt', 'sub');
|
||||
expect(result).toBe(false);
|
||||
expect(h.toastError).toHaveBeenCalledWith('already exists');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackFileExplorer bulk selection', () => {
|
||||
beforeEach(() => {
|
||||
h.bulkDeleteMock.mockReset().mockResolvedValue({ deleted: [], failed: [] });
|
||||
h.bulkMoveMock.mockReset().mockResolvedValue({ moved: [], failed: [] });
|
||||
h.bulkDownloadMock.mockReset();
|
||||
h.onConfirmDestination = null;
|
||||
h.toastError.mockReset();
|
||||
h.toastSuccess.mockReset();
|
||||
vi.mocked(downloadBlob).mockReset();
|
||||
});
|
||||
|
||||
it('shows the bulk action bar with a count once files are selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Download selection' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Move selection' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Delete selection' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('downloads the selection as an archive', async () => {
|
||||
h.bulkDownloadMock.mockResolvedValue({ ok: true, blob: async () => new Blob(['x']) } as unknown as Response);
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
await user.click(screen.getByRole('button', { name: 'Download selection' }));
|
||||
await waitFor(() => expect(h.bulkDownloadMock).toHaveBeenCalledWith('my-stack', ['a.txt', 'b.txt'], 'stack-source'));
|
||||
await waitFor(() => expect(vi.mocked(downloadBlob)).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('moves the selection through the destination picker', async () => {
|
||||
h.bulkMoveMock.mockResolvedValue({ moved: ['a.txt', 'b.txt'], failed: [] });
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
expect(h.onConfirmDestination).not.toBeNull();
|
||||
|
||||
const ok = await h.onConfirmDestination?.('dest');
|
||||
expect(ok).toBe(true);
|
||||
expect(h.bulkMoveMock).toHaveBeenCalledWith('my-stack', ['a.txt', 'b.txt'], 'dest', 'stack-source');
|
||||
expect(h.toastSuccess).toHaveBeenCalledWith('Moved 2 items.');
|
||||
});
|
||||
|
||||
it('deletes the selection but excludes protected files from the request', async () => {
|
||||
h.bulkDeleteMock.mockResolvedValue({ deleted: ['a.txt'], failed: [] });
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-protected')); // compose.yaml + a.txt
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete selection' }));
|
||||
const confirm = await screen.findByRole('button', { name: /^delete$/i });
|
||||
await user.click(confirm);
|
||||
|
||||
// compose.yaml (protected) is excluded; only a.txt is sent.
|
||||
await waitFor(() => expect(h.bulkDeleteMock).toHaveBeenCalledWith('my-stack', ['a.txt'], 'stack-source'));
|
||||
await waitFor(() => expect(h.toastSuccess).toHaveBeenCalledWith('Deleted 1 item.'));
|
||||
});
|
||||
|
||||
it('reports a partial delete failure with detail and keeps the failed item selected', async () => {
|
||||
h.bulkDeleteMock.mockResolvedValue({ deleted: ['a.txt'], failed: [{ path: 'b.txt', error: 'locked' }] });
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
await user.click(screen.getByRole('button', { name: 'Delete selection' }));
|
||||
await user.click(await screen.findByRole('button', { name: /^delete$/i }));
|
||||
|
||||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith(expect.stringContaining('b.txt (locked)')));
|
||||
// The failed item stays selected so the user can retry it.
|
||||
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('surfaces the server message when the bulk download is rejected (e.g. a volume symlink)', async () => {
|
||||
h.bulkDownloadMock.mockResolvedValue({
|
||||
ok: false, status: 400, json: async () => ({ error: '"x" cannot be downloaded from this volume' }),
|
||||
} as unknown as Response);
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
await user.click(screen.getByRole('button', { name: 'Download selection' }));
|
||||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith('"x" cannot be downloaded from this volume'));
|
||||
});
|
||||
|
||||
it('falls back to a too-large message when a 413 body cannot be parsed', async () => {
|
||||
// No json() on the response: the parse fails and the per-status default shows.
|
||||
h.bulkDownloadMock.mockResolvedValue({ ok: false, status: 413 } as unknown as Response);
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
await user.click(screen.getByRole('button', { name: 'Download selection' }));
|
||||
await waitFor(() => expect(h.toastError).toHaveBeenCalledWith(expect.stringMatching(/too large/i)));
|
||||
});
|
||||
|
||||
it('clears the selection with the Clear button', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('bulk-select-two'));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Clear selection' }));
|
||||
expect(screen.queryByText('2 selected')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StackFileExplorer new file affordance', () => {
|
||||
beforeEach(() => { h.newFileProps = null; });
|
||||
|
||||
it('opens the New file dialog at the stack root from the toolbar button', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByRole('button', { name: 'New file' }));
|
||||
expect(h.newFileProps).toMatchObject({ open: true, currentDir: '', rootId: 'stack-source' });
|
||||
});
|
||||
|
||||
it('targets the current directory once a nested file is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
await user.click(screen.getByText('select-nested')); // dir/a.txt -> currentDir 'dir'
|
||||
await user.click(screen.getByRole('button', { name: 'New file' }));
|
||||
expect(h.newFileProps).toMatchObject({ open: true, currentDir: 'dir' });
|
||||
});
|
||||
|
||||
it('hides the New file button on a non-editable root', () => {
|
||||
render(<StackFileExplorer stackName="my-stack" canEdit={false} isDarkMode={false} />);
|
||||
expect(screen.queryByRole('button', { name: 'New file' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,13 @@ const ScrollArea = React.forwardRef<
|
||||
// viewport width and trigger its own scroll. Also renders a horizontal
|
||||
// ScrollBar for viewports that overflow directly.
|
||||
block?: boolean;
|
||||
// Opt in to a horizontal ScrollBar while keeping Radix's default
|
||||
// content-sizing wrapper, so content wider than the viewport (e.g. a file
|
||||
// tree with long names) scrolls horizontally with the styled thumb. Unlike
|
||||
// `block`, this does not clamp the content to the viewport width.
|
||||
horizontal?: boolean;
|
||||
}
|
||||
>(({ className, children, viewportRef, block, ...props }, ref) => (
|
||||
>(({ className, children, viewportRef, block, horizontal, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
@@ -30,7 +35,7 @@ const ScrollArea = React.forwardRef<
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar orientation="vertical" />
|
||||
{block && <ScrollBar orientation="horizontal" />}
|
||||
{(block || horizontal) && <ScrollBar orientation="horizontal" />}
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user