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:
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><root>/{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><root>/{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’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 },
);