mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -53,7 +53,7 @@ Named-volume editing is best-effort and bound by file ownership. The helper cont
|
||||
|
||||
## Layout
|
||||
|
||||
The Files & Volumes tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the new-folder button, and the directory tree for the selected root. The right pane is the action bar plus the file viewer.
|
||||
The Files & Volumes tab splits into two panes. The left pane holds the Browsing selector, the upload affordance, the **New file** and **New folder** buttons, and the directory tree for the selected root. The right pane is the action bar plus the file viewer.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-file-explorer/layout-panes.png" alt="Files tab two-pane layout showing the upload widget and tree on the left and the file viewer on the right" />
|
||||
@@ -65,6 +65,10 @@ Folders sort before files, and entries within each group sort alphabetically.
|
||||
|
||||
Click a folder to expand or collapse it. Click a file to open it in the viewer on the right. Symlinks render with a chain icon and behave like files when clicked. Deleting a symlink removes only the link entry; the file it points to is untouched.
|
||||
|
||||
The tree is fully keyboard navigable. Tab into it and use the arrow keys to move between rows: **Up** and **Down** move row to row, **Right** expands a folder (then steps into it), **Left** collapses it (or steps out to the parent), **Home** and **End** jump to the first and last visible row, and **Enter** opens a file or toggles a folder.
|
||||
|
||||
Each row is fully clickable across the pane, so right-clicking anywhere on a row (not just on its name) opens that entry's context menu. Long names are never truncated: the tree scrolls horizontally so you can read the full name.
|
||||
|
||||
**Display cap.** Each directory render is capped at 1000 entries. A folder with more than 1000 children shows the first 1000 alphabetically with a footer noting how many entries the directory holds in total. The tree also has a filter input at the top of the list so you can narrow a large directory to the entries you care about without dropping to a shell.
|
||||
|
||||
## Protected files
|
||||
@@ -114,7 +118,7 @@ Writes are atomic at the filesystem level. Sencho stages the new content into a
|
||||
|
||||
## Creating files and folders
|
||||
|
||||
The toolbar **New folder** button at the top of the tree creates a folder in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open).
|
||||
The toolbar **New file** and **New folder** buttons at the top of the tree create an entry in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open). Use the toolbar buttons to create an entry directly at the stack root, where there is no folder row to right-click.
|
||||
|
||||
Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder. These write controls appear only when your account has stack edit permission.
|
||||
|
||||
@@ -124,6 +128,8 @@ Right-click any folder for **New File** and **New Folder** entries that scope to
|
||||
|
||||
Filenames cannot be empty, cannot contain `/` or `\`, and cannot be `.` or `..`. The Create button stays disabled until the input passes validation.
|
||||
|
||||
Creating a file never overwrites an existing one. If a file of that name already exists in the target directory, the dialog reports inline that the name is taken; if a folder of that name exists, Sencho rejects the creation with a message. Either way the existing entry is left untouched.
|
||||
|
||||
## Uploading files
|
||||
|
||||
The dashed **Upload file** affordance at the top of the tree opens a file picker. You can also drag and drop a file onto the dashed zone; the border lights up in the brand colour when a file is hovered over a valid drop target. Uploads are one file at a time.
|
||||
@@ -159,6 +165,33 @@ Move a file or folder into a different directory in one of two ways:
|
||||
|
||||
Moving requires stack edit permission. The protected stack files (compose / docker-compose / `.env`) at the stack root stay put: the compose CLI reads them from the stack directory, so they are not offered as move sources or root destinations. Moving a file changes only where it lives on disk; it does not redeploy or restart the stack.
|
||||
|
||||
## Copying and duplicating
|
||||
|
||||
Copy a file or folder without removing the original in one of two ways:
|
||||
|
||||
- **Duplicate.** Right-click the entry and choose **Duplicate** to make a copy in the same folder under an auto-suffixed name (`config.yaml` becomes `config copy.yaml`, then `config copy 2.yaml`, and so on). This is the quickest way to snapshot a file before you edit it.
|
||||
- **Copy to…** Right-click the entry and choose **Copy to…** to pick a destination folder, the same way Move works. The current folder stays disabled in the picker, since a same-folder copy is what Duplicate is for.
|
||||
|
||||
Copying requires stack edit permission and stays within the current root. A folder is copied with its full contents, and a symlink is copied as a link rather than as the file it points to. Unlike Move, a protected stack file can be copied: duplicating `compose.yaml` to `compose.yaml.bak`, or copying it into a subfolder, is allowed because the copy is an ordinary file. Only creating a reserved name (`compose.yaml`, `.env`, and the rest) directly at the stack root is blocked.
|
||||
|
||||
## Working with multiple files
|
||||
|
||||
Select more than one entry to act on them together.
|
||||
|
||||
- **Select.** Hover a row to reveal its checkbox, or hold **Ctrl** (**Cmd** on macOS) and click a row to add it to the selection. Hold **Shift** and click to select a contiguous range. A plain click still opens a file in the viewer and leaves the selection untouched.
|
||||
- **Act.** Once anything is selected, a bar at the top of the tree shows the count and the bulk actions.
|
||||
|
||||
| Bulk action | What it does |
|
||||
|---|---|
|
||||
| Download | Streams the whole selection as a single `.tar.gz` archive, preserving folder structure. Available to anyone who can read the stack. |
|
||||
| Move | Opens the destination picker (the same one as single Move) and relocates every selected item into the chosen folder. Requires stack edit. |
|
||||
| Delete | Asks for confirmation, then removes every selected item; selected folders are removed with their contents. Requires stack edit. |
|
||||
| Clear | Drops the current selection. |
|
||||
|
||||
Bulk Move and Delete skip the protected stack files (`compose.yaml`, `.env`, and the rest) at the stack root, which are reported as kept rather than acted on; they can still be included in a Download.
|
||||
|
||||
Each item is processed independently. If some succeed and others fail (for example, a permission error on one file), Sencho reports how many succeeded and leaves the items that failed selected so you can see which they were and retry. Very large selections are capped: a Download that would exceed the archive's file-count or total-size limit is refused before it starts.
|
||||
|
||||
## Permissions (chmod)
|
||||
|
||||
Right-click any file and choose **Permissions** to inspect or edit its Unix mode bits. The dialog shows a 3 by 3 grid of `r` / `w` / `x` toggles for Owner, Group, and Other, plus the current octal value.
|
||||
@@ -201,10 +234,12 @@ When the entry is one of the five protected names, the modal asks you to type th
|
||||
<img src="/images/stack-file-explorer/context-menu-file.png" alt="Right-click menu on a file showing Rename, Permissions, and Delete entries" />
|
||||
</Frame>
|
||||
|
||||
Right-click anywhere on a row, not only on its name, to open the menu.
|
||||
|
||||
| Right-click target | Admin entries (with stack edit) | Viewer entries |
|
||||
|---|---|---|
|
||||
| Folder | New File, New Folder, Rename, Move to…, Delete | No write entries |
|
||||
| File | Rename, Move to…, Permissions, Delete | Permissions (read-only) |
|
||||
| Folder | New File, New Folder, Rename, Duplicate, Copy to…, Move to…, Delete | No write entries |
|
||||
| File | Rename, Duplicate, Copy to…, Move to…, Permissions, Delete | Permissions (read-only) |
|
||||
|
||||
**Move to…** is absent for the protected stack files (compose / docker-compose / `.env`) at the stack root, which cannot be moved out of the stack directory.
|
||||
|
||||
@@ -256,6 +291,12 @@ The Permissions dialog opens for everyone; only users with stack edit permission
|
||||
Each directory render is capped at 1000 entries to keep the tree responsive. Use the filter input above the tree to narrow the list, or drop into a host shell with `cd` into the stack directory if you need to work with entries past the cap.
|
||||
</Accordion>
|
||||
<Accordion title="Write controls are missing">
|
||||
Upload, create, rename, move, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, and inspect permissions in read-only mode.
|
||||
Upload, create, rename, copy, move, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, inspect permissions in read-only mode, and download (including a bulk download).
|
||||
</Accordion>
|
||||
<Accordion title="A bulk action reports that some items failed">
|
||||
Bulk delete and move process each item independently and report how many succeeded. The items that failed stay selected so you can see which they were; open one to find out why (a common cause is a permission error on a named-volume file, or a name collision at the destination), fix it, and retry on the still-selected items.
|
||||
</Accordion>
|
||||
<Accordion title="Bulk download is refused as too large">
|
||||
A bulk download is packed into a single archive with a fixed cap on both the number of files and the total uncompressed size. A selection that would exceed either cap is refused before the download starts. Narrow the selection, or download large folders in smaller batches.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
Reference in New Issue
Block a user