Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 767298b66444b5db93b8f3d7edadfa49f8e3607d
parent 5f47a7f138eb7b9cdeccd6c5b8fc67d10dd347fe
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 20 Sep 2026 17:51:39 -0400

channel storage: one verb, and the corpus volume is a destination

"Move media to…" and "Move back in place" were two sections with two buttons
whose availability was the inverse of each other — one decision ("where should
this channel's media be") split across two controls. The corpus volume is now
an option in the destination select; picking it runs
moveChannelMediaBackAction, unchanged and by that name, so the API routes on
another branch keep working.

While the media IS on a location it is the ONLY option, because the controller
refuses a location-to-location move by name; a select full of options that all
refuse is worse than one that works with a sentence saying why. No preview gate
on the way back: the preview answers "is there room on the destination", and
move-back asks the corpus volume that itself, charging only the bytes a resumed
data.incoming is still missing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/[slug]/components/stages/StorageStage.tsx | 223++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Meditor/e2e/channel-storage.spec.ts | 15+++++++++++++--
2 files changed, 138 insertions(+), 100 deletions(-)

diff --git a/editor/app/channels/[slug]/components/stages/StorageStage.tsx b/editor/app/channels/[slug]/components/stages/StorageStage.tsx @@ -68,6 +68,13 @@ export type StorageDestination = { // can never collide with one. const CUSTOM = "__custom"; +// THE CORPUS VOLUME AS A DESTINATION. Not a location id either (`/storage` +// never stores one — see INTERNAL_LOCATION_ID in the storage controller), so it +// cannot collide with a configured one, and picking it runs +// moveChannelMediaBackAction rather than a relocate. +const INTERNAL = "__internal"; +const INTERNAL_LABEL = "Internal (in place)"; + type Props = { slug: string; location: ChannelMediaLocation; @@ -176,20 +183,21 @@ export function StorageStage({ marker={location.marker ?? null} /> - <MoveOut - key="move-out" + {/* ONE VERB. "Move media to…" used to be two sections with two buttons — + Move media, and Move back in place — which is one decision ("where + should this channel's media be") split across two controls whose + availability was the inverse of each other. The corpus volume is a + destination like any other now; picking it IS moving back, and it + runs the same action it always did. */} + <MoveMedia + key="move-media" slug={slug} canMoveOut={!location.relocated} - blockedReason={blockedReason} - destinations={destinations} - defaultLocationId={defaultLocationId} - /> - <MoveBack - key="move-back" - slug={slug} relocated={location.relocated} target={location.target} blockedReason={blockedReason} + destinations={destinations} + defaultLocationId={defaultLocationId} // MOVE BACK IS THE ONE DIRECTION THAT ENDS BY DELETING THE TARGET, so // it is not offered for a location nobody can vouch for. `relocated` is // true for `inconsistent` and `unreachable` as well as `ok` — config @@ -211,18 +219,31 @@ export function StorageStage({ ); } -function MoveOut({ +function MoveMedia({ slug, canMoveOut, + relocated, + target, blockedReason, destinations, defaultLocationId, + unvouched, }: { slug: string; canMoveOut: boolean; + // The channel's media is on a location right now. Then the ONLY destination + // offered is the corpus volume: the controller refuses a location-to-location + // move by name ("already relocated to X. Move it back in place first."), and + // a select full of options that all refuse is worse than a select with one + // that works and a sentence saying why. + relocated: boolean; + target: string | undefined; blockedReason: string | null; destinations: StorageDestination[]; defaultLocationId: string; + // Why moving back is refused for THIS location, or null. Separate from + // blockedReason, which is about jobs and markers. + unvouched: string | null; }) { // WHICH CONFIGURED LOCATION, or `__custom` for a one-off root. Opens on the // default location; with none configured there is nothing to pick, so the @@ -249,21 +270,36 @@ function MoveOut({ const [previewing, setPreviewing] = useState(false); const [ranHere, setRanHere] = useState(false); - if (!canMoveOut && !ranHere) return null; + if (!canMoveOut && !relocated && !ranHere) return null; - const chosen = destinations.find((d) => d.id === destId) ?? null; - const custom = destId === CUSTOM || chosen === null; + // DERIVED, never the raw state: a run that just finished flips `relocated` + // under this component (the panel stays mounted for its log), and a select + // still showing the destination of the move that has already happened would + // be offering to do it again. + const effectiveId = relocated ? INTERNAL : destId; + const movingBack = effectiveId === INTERNAL; + const chosen = movingBack + ? null + : (destinations.find((d) => d.id === effectiveId) ?? null); + const custom = !movingBack && (destId === CUSTOM || chosen === null); const trimmed = customRoot.trim(); // THE ID ALONE GOES TO THE SERVER for a configured location: the root is // looked up there, from the settings the location list lives in, so the form // can never send a root a re-point has since moved. const destination: MoveDestination = custom ? { kind: "custom", root: trimmed } - : { kind: "location", locationId: chosen.id }; - const key = custom ? `custom:${trimmed}` : `location:${chosen.id}`; + : { kind: "location", locationId: chosen?.id ?? "" }; + const key = custom ? `custom:${trimmed}` : `location:${chosen?.id ?? ""}`; const named = custom ? trimmed !== "" : true; - const confirmed = named && key === previewedKey; - const disabled = !canMoveOut || !confirmed || blockedReason !== null; + // NO PREVIEW GATE ON THE WAY BACK, and it is not an oversight: the preview + // answers "is there room on the destination", and the controller's move-back + // asks the corpus volume that itself, charging only the bytes still missing + // from a resumed `data.incoming`. There is no root for the operator to get + // wrong either — it is the channel's own directory. + const confirmed = movingBack || (named && key === previewedKey); + const disabled = movingBack + ? !relocated || unvouched !== null || blockedReason !== null + : !canMoveOut || !confirmed || blockedReason !== null; async function runPreview() { setPreviewing(true); @@ -285,13 +321,32 @@ function MoveOut({ <div> <h3 className="text-base font-semibold">Move media to…</h3> <p className="text-sm text-muted-foreground"> - Copies <code>data/</code> to <code>&lt;root&gt;/{slug}/data</code>, - verifies it, and leaves a symlink behind so every reader, yt-dlp and - the index keep working unchanged. The source is not touched until the - copy verifies. + {movingBack ? ( + <> + Copies <code>{target ?? "the target"}</code> back into the channel + dir, verifies it, replaces the symlink with a real directory and + clears the recorded location. + </> + ) : ( + <> + Copies <code>data/</code> to{" "} + <code>&lt;root&gt;/{slug}/data</code>, verifies it, and leaves a + symlink behind so every reader, yt-dlp and the index keep working + unchanged. The source is not touched until the copy verifies. + </> + )} </p> </div> - {destinations.length > 0 && ( + {unvouched && ( + <p + role="status" + aria-label="move back refused" + className="text-sm rounded border border-destructive/50 bg-destructive/5 px-3 py-2" + > + {unvouched} + </p> + )} + {(destinations.length > 0 || relocated) && ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Destination</span> <select @@ -301,8 +356,8 @@ function MoveOut({ // naming an id that is no longer an option, and a <select> whose // value matches nothing silently shows the first one. `custom` is // already true in that case — this makes the control agree with it. - value={custom ? CUSTOM : destId} - disabled={!canMoveOut} + value={movingBack ? INTERNAL : custom ? CUSTOM : destId} + disabled={relocated || !canMoveOut} onChange={(e) => { setDestId(e.target.value); // A new destination is a new question. Dropping the preview with @@ -315,13 +370,29 @@ function MoveOut({ }} className="rounded border border-border bg-card px-2 py-1 text-sm" > - {destinations.map((d) => ( - <option key={d.id} value={d.id}> - {d.label} — {d.statusLabel} - </option> - ))} - <option value={CUSTOM}>Another root…</option> + {/* THE CORPUS VOLUME IS AN OPTION, and while the media is on a + location it is the ONLY one — see the prop's comment. */} + {relocated ? ( + <option value={INTERNAL}>{INTERNAL_LABEL}</option> + ) : ( + <> + {destinations.map((d) => ( + <option key={d.id} value={d.id}> + {d.label} — {d.statusLabel} + </option> + ))} + <option value={CUSTOM}>Another root…</option> + </> + )} </select> + {relocated && ( + <span className="text-xs text-muted-foreground"> + This channel&rsquo;s media is on {target ?? "another root"}. Move + it back in place first to send it somewhere else — a move from one + location straight to another is refused, because the corpus volume + is the one place both halves can be verified against. + </span> + )} {chosen && ( <span className="text-xs text-muted-foreground font-mono break-all"> {chosen.root} @@ -342,7 +413,7 @@ function MoveOut({ )} </label> )} - {custom && ( + {custom && !movingBack && ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Destination root</span> <input @@ -366,16 +437,18 @@ function MoveOut({ </span> </label> )} - <div> - <button - type="button" - onClick={runPreview} - disabled={!canMoveOut || !named || previewing} - className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" - > - {previewing ? "Checking…" : "Preview"} - </button> - </div> + {!movingBack && ( + <div> + <button + type="button" + onClick={runPreview} + disabled={!canMoveOut || !named || previewing} + className="px-3 py-1.5 rounded-md border border-border text-sm font-medium disabled:opacity-50" + > + {previewing ? "Checking…" : "Preview"} + </button> + </div> + )} {previewError && ( <p role="alert" className="text-sm text-destructive"> {previewError} @@ -419,20 +492,26 @@ function MoveOut({ run resumes it rather than starting over. </p> )} - {!confirmed && named && ( + {!confirmed && named && !movingBack && ( <p className="text-xs text-muted-foreground"> Preview this destination to enable the move. </p> )} + {/* ONE LOG, ONE ACCESSIBLE NAME, both directions. `moveChannelMediaBackAction` + is untouched — it is still the action, still by that name, and the API + routes another branch is adding call it unchanged. What moved is which + control reaches it. */} <StreamActionLog - key="move-out-log" + key="move-media-log" trigger={() => { setRanHere(true); - return relocateChannelMediaAction(slug, destination); + return movingBack + ? moveChannelMediaBackAction(slug) + : relocateChannelMediaAction(slug, destination); }} cancelAction={cancelJobAction} - buttonLabel="Move media" - runningLabel="Moving media…" + buttonLabel={movingBack ? "Move back in place" : "Move media"} + runningLabel={movingBack ? "Moving back…" : "Moving media…"} label="Move media" disabled={disabled} /> @@ -440,58 +519,6 @@ function MoveOut({ ); } -function MoveBack({ - slug, - relocated, - target, - blockedReason, - unvouched, -}: { - slug: string; - relocated: boolean; - target: string | undefined; - blockedReason: string | null; - // Why moving back is refused for THIS location, or null. Separate from - // blockedReason, which is about jobs and markers and disables both halves. - unvouched: string | null; -}) { - const [ranHere, setRanHere] = useState(false); - if (!relocated && !ranHere) return null; - return ( - <section className="flex flex-col gap-2"> - <div> - <h3 className="text-base font-semibold">Move back in place</h3> - <p className="text-sm text-muted-foreground"> - {relocated - ? `Copies ${target ?? "the target"} back into the channel dir, verifies it, replaces the symlink with a real directory and clears the recorded location.` - : "This channel's media is in place."} - </p> - </div> - {unvouched && ( - <p - role="status" - aria-label="move back refused" - className="text-sm rounded border border-destructive/50 bg-destructive/5 px-3 py-2" - > - {unvouched} - </p> - )} - <StreamActionLog - key="move-back-log" - trigger={() => { - setRanHere(true); - return moveChannelMediaBackAction(slug); - }} - cancelAction={cancelJobAction} - buttonLabel="Move back in place" - runningLabel="Moving back…" - label="Move back in place" - disabled={!relocated || blockedReason !== null || unvouched !== null} - /> - </section> - ); -} - // THE TWO WAYS OUT OF A MARKER WHOSE RUN IS GONE — finish it, or throw it away. // // Both are offered ONLY when the channel is in-transition AND no job is running: diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts @@ -157,15 +157,26 @@ test("relocate a channel's media to another root, and move it back", async ({ page.getByLabel(/^media location: Media relocated/), ).toBeVisible(); - // --- back --------------------------------------------------------------- + // --- back, WHICH IS A DESTINATION AND NOT A SECOND BUTTON ---------------- + // "Move back in place" was its own section with its own button; it is now + // the one destination the select offers while the media is on a location, + // and it runs the same action (moveChannelMediaBackAction) it always did. await page.goto(channelStage(SLUG, "storage")); await expect(page.getByLabel("media path")).toHaveText(target); + const destination = page.getByLabel("destination location"); + await expect(destination).toHaveValue("__internal"); + // The only option: a location-to-location move is refused by the controller, + // so the select does not offer one. + await expect(destination.locator("option")).toHaveCount(1); + await expect(destination.locator("option")).toHaveText( + "Internal (in place)", + ); const backButton = page.getByRole("button", { name: "Move back in place", }); await expect(backButton).toBeEnabled(); await backButton.click(); - await expect(page.getByLabel("Move back in place output")).toContainText( + await expect(page.getByLabel("Move media output")).toContainText( "Moved back", { timeout: 60_000 }, );