Skip to content

Assets page - #182

Merged
dkackman merged 12 commits into
developfrom
assets-page
Sep 16, 2026
Merged

dkackman merged 12 commits into
developfrom
assets-page

Conversation

@dkackman

Copy link
Copy Markdown
Owner

No description provided.

dkackman and others added 12 commits September 14, 2026 19:33
The shared `common` library and every `--examples-dir` one sit on every
workspace's search path, so most of the grid can be identical in two
workspaces - on a real box 34 shared assets against a handful the workspace
owns. Switching the picker then looks like a page that ignored it. Three
things say so rather than leaving it to be inferred:

- the count breaks out `N from other libraries`
- the hint names the rule
- the library pick is offered whenever *anything* came from elsewhere,
  not only when two origins are in play. It used to unmount exactly in the
  workspace where the question comes up - every asset `common`, one origin,
  no control - and filtering to `workspace` is what shows you what is
  actually yours

The upload destination becomes a named pick (`upload to [this workspace |
shared library]`) rather than a bare `shared` tickbox. It is the one thing
about an upload that cannot be changed afterwards and nothing on the page
explained what the tickbox meant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hree archives

The gallery's bulk selection had been copied into the assets page whole, and
the copies had already drifted: the assets page's Escape guard knew that
ConfirmDialog renders `alertdialog` and the gallery's did not, so Escape in
the gallery's delete confirm cancelled the dialog *and* closed the detail
behind it. That is what two copies of a keyboard contract does.

It is now `picks.svelte.ts` - a `Picks` over a getter for the grid's current
order, so a filter changing under the selection is seen rather than
snapshotted, plus `actOnEach` for the sequential act-and-collect-failures
loop and `dialogOpen()` for the guard - with `BulkBar.svelte` for the sticky
bar and select-all, and the two rules that have to reach a tile the page
lays out in app.css. The two pages lose 314 lines between them. The gallery's
Escape bug is fixed by construction and pinned by a test.

Server: the temp-zip dance - and specifically the `except BaseException:
unlink` that stops a half-written archive leaking into tmp - was in three
routes; it is now `_zip_download`. `AssetArchiveRequest` was a byte-identical
copy of `ArchiveRequest`. `archive_assets` rebuilt the asset search path once
per name, so a thousand-name archive stat'd every root a thousand times;
`_asset_in` takes the path already built, and `_asset_file` delegates to it
so the two resolvers cannot drift.

Archives now store rather than deflate anything but `.bmp`/`.wav`. Every
other extension the libraries hold is an already-compressed container, so
deflating buys ~0.03% for a full CPU pass - and since the response does not
start until the temp file is complete, that pass is latency the caller waits
through: measured, about 13s for a gigabyte of video.

Also: `originOffered` is `borrowed > 0` rather than that OR'd onto the rule
it replaced; the upload destination is read straight off `uploadTo`; the
header count's separator is one expression, since Svelte trims a block's
leading whitespace and had eaten that space twice; and AssetsPage's tests
reset their mocks rather than clearing them, so an implementation set by one
test cannot leak into the next.

Left alone deliberately, as open questions rather than cleanups: `size`
counts every ticked name while the actions run over the visible ones
(`Picks.hidden` is there for whichever way that is settled), the bulk-delete
confirm does not say that a shared asset goes for every workspace, and the
upload destination offers a shared library on servers that have none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_asset_in resolved each root by calling resolve_asset_reference, which
already walks the pinned fallbacks on its own - 107 syscalls per hit
where 29 would do. Validate the name once and let each root be a plain
join-and-isfile check instead, via a new _resolution_roots(ws) helper
shared with the validate route and _asset_file.

The compression policy inverted itself for anything outside {.bmp,
.wav}: COMPRESSIBLE_EXTENSIONS named the two formats that deflate, but
the check stored everything *not* in that set - including plain text
like workflow.json, manifest.json and the export README. Replace it
with RAW_MEDIA_EXTENSIONS (what it actually named) and store only a
recognized media extension that isn't raw.

archive_assets now strips and dedupes names before resolving, so a
repeated or whitespace-varied selection collapses onto one zip entry
instead of colliding on write. The two archive routes' duplicated
three-line tail (timestamp, log line, _zip_download call) is now
_archive_selection(entries, kind), logged after the archive is
written so a failed write never logs a false success.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Picks.size counted every ticked name, including ones a filter was
hiding, so the bar's number and what Delete/Download would actually
act on (names, which is visible-only) could disagree. keepFailed
made it worse: it cleared the whole selection and re-added only the
failures, silently dropping a hidden tick the action never attempted.

size now returns names.length, hidden is gone (nothing rendered it),
and keepFailed(attempted, failed) only removes names it was told
were attempted and did not fail, so a hidden tick survives. Both
pages early-return when there is nothing visible to act on, for the
keyboard path that could still reach the handler with the bar hidden.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A client that only sees the resolved asset list cannot show which of
its names hide a shared one, or say why a delete lands on one library
and 403s on another. `libraries` reports the search path in order with
origin and writability; `shadowed` reports the entries the resolution
loop used to silently skip - same shape as an `assets` entry minus
`url` (that URL would serve the shadowing file), plus `shadowed_by`.
MCP `list_assets` returns this body verbatim, so its docstring and
docs/MCP.md/docs/SERVER.md describe the new fields too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_asset_in used to call resolve_asset_reference once per root, which walks
the pinned fallbacks on its own and let a SecurityError from an escaping
symlink surface as a caught 404 - each call's resolution swallowed its own
walk's failures. The rewrite in the prior commit validates the name once
and probes each root with a plain isfile + validate_path, but validate_path
raising SecurityError on a resolved-through-symlink escape was left
uncaught, so the route handler's default exception path turned it into a
500 instead of a miss for that root. Wrap the per-root validate_path in a
try/except SecurityError so an escaping symlink falls through to the next
root like any other miss, ending in the same 404 the caller gets for a name
that was never there.

Adds test_archive_assets_rejects_a_name_behind_a_symlink: a symlink inside
the asset library pointing outside it, archived through POST
/api/assets/archive, now answers 404 rather than 500.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The search path is the real top level, and the page was hiding it. One
mtime-sorted grid with an `origin` badge per tile meant folders cut across
libraries, so a `cast/` group could hold two files the workspace owns and
four it only borrows; shadowing was named in the hint and never shown, so
"I uploaded it and asset: still loads the old one" was unanswerable from
the page; deleting a shared asset looked exactly like deleting a workspace
one, though it goes for every workspace under the root; and the library
`select` could be left pointing at a library that unmounted on the next
workspace switch - a stale pick this removes by removing the control.

A library is now a section, in the server's own resolution order, with
folders inside it. The header carries the label (`This workspace`,
`Shared library`, `Examples`), the count, the root it reads and a collapse
chevron, persisted under `collapsed-asset-libraries` and forced open while
a filter is on, as FolderGroups has it. An empty library still shows its
header, so an empty workspace says where an upload would land.

Upload moves onto the section, because which library a file lands in is the
one thing about an upload that cannot be changed afterwards: `Upload` on the
workspace section, a quiet `Upload to shared` on the shared one, a muted
`read-only` where the server would answer 403. For the same reason a tile
from a read-only library carries no checkbox - nothing bulk can do to it -
and the delete confirms say what a shared delete costs, singly and in the
bulk count.

`shadowed` is rendered rather than described: each section shows what it
holds under a name a nearer library has taken, as dimmed inert tiles under
a `shadowed/` heading. The server serves no url for one, so it is a label
rather than a picture. The Picks order is the concatenation of the sections'
visible assets, so a shift-range and the bulk actions follow the page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Picks order was every asset in every section, so `Select all` ticked
two kinds of tile the user cannot act on. An examples tile renders no
checkbox - nothing bulk can do to a read-only library - so it came back
`.cellwrap.picked` with no control on it to untick, recoverable only
through Clear, and Delete then 403'd on each one and left them ticked. A
collapsed library section was worse: a bulk delete reached files that were
not on screen at all, which is the one invariant ui/CLAUDE.md states about
this selection.

The order now spans open, writable sections only, so the bar's count,
`selectAll`, `size` and `names` all follow what the page is showing with a
checkbox on it. Collapsing a section is now as narrowing as a filter -
deliberately, since it hides rows the same way.

Also: the shadowed tile carried its explanation only in `title`, which a
bare div does not expose - it is `role="note"` with the same text as an
`aria-label`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Four pages each rounded a byte count and rendered an mtime their own
way, so the same number read differently depending which page showed
it; format.ts is the one implementation now (formatBytes, formatMtime),
covered by tests for the KB/MB/GB ranges and the 1 MB boundary.
ModelsPage's day (date-only) and gb (used well beyond the size column)
stay put - only the size/mb/kb/day pairs the review flagged move.

.flex and button.withicon/.withicon were scoped copies in a dozen
components; BulkBar, AssetsPage and GalleryPage's copies are gone in
favour of one global rule each beside button.quiet/button.bare.

.cellwrap.picked .cell tied a page's own scoped .cell.active/.cell:hover
on specificity and only won by source order - true by accident of where
a stylesheet happens to load. .cellwrap.picked > .cell.cell pushes the
global rule to (0,4,0) so it outranks the scoped tie on purpose.

Also: Picks.keepOnly/keepFailed used a Set for a one-shot lookup over a
small local list, which is what tripped svelte/prefer-svelte-reactivity
in eslint - swapped for a plain array, since nothing about either lookup
is reactive state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dkackman
dkackman merged commit da568c6 into develop Sep 16, 2026
4 of 5 checks passed
@dkackman
dkackman deleted the assets-page branch September 16, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant