Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions frontend/src/components/dataset/MoveEpisodesDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, test } from "vitest";

import type { DatasetItem, MoveDestination } from "@/lib/replayApi";

import { parseEpisodeList } from "@/lib/datasetApi";

import MoveEpisodesDialog from "./MoveEpisodesDialog";

test("parses episode lists and ranges", () => {
expect(parseEpisodeList("3, 1, 5-7, 3", 10)).toEqual([1, 3, 5, 6, 7]);
expect(parseEpisodeList(" 2 - 4 ,", 10)).toEqual([2, 3, 4]);
expect(parseEpisodeList("", 10)).toBeNull();
expect(parseEpisodeList("10", 10)).toBeNull();
expect(parseEpisodeList("4-2", 10)).toBeNull();
expect(parseEpisodeList("1, x", 10)).toBeNull();
});

const DATASETS: DatasetItem[] = [
{ repo_id: "local/demo", source: "local", last_modified: "", private: false },
{ repo_id: "local/target", source: "local", last_modified: "", private: false },
];

test("opens on the browsed episode and refuses to move every episode", async () => {
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
ResizeObserver: class {
observe() {}
unobserve() {}
disconnect() {}
},
});
Element.prototype.scrollIntoView = () => {};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const moved: [number[], MoveDestination][] = [];

try {
await act(async () => {
root.render(
<MoveEpisodesDialog
datasets={DATASETS}
repoId="local/demo"
totalEpisodes={2}
currentEpisode={1}
open
onOpenChange={() => {}}
onMove={async (indices, destination) => {
moved.push([indices, destination]);
}}
/>,
);
});

const episodes = document.getElementById("move-episodes") as HTMLInputElement;
const moveButton = () =>
[...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.startsWith("Move "),
) as HTMLButtonElement;
const type = async (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
await act(async () => {
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
};
const openPicker = async () => {
const trigger = [...document.body.querySelectorAll("button")].find((b) =>
b.textContent?.includes("Select or create a dataset") || b.textContent?.startsWith("local/"),
) as HTMLButtonElement;
await act(async () => {
trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true }));
trigger.click();
});
};

expect(episodes.value).toBe("1");
expect(moveButton().disabled).toBe(true);

// The source never shows up as a destination.
await openPicker();
const items = [...document.body.querySelectorAll("[cmdk-item]")].map((i) => i.textContent);
expect(items).toEqual(["local/target"]);

// Create a new dataset by typing a name in the shared picker.
const search = document.body.querySelector("[cmdk-input]") as HTMLInputElement;
await type(search, "rejected takes");
await act(async () => {
search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(document.body.textContent).toContain("Saved locally as local/rejected_takes");
expect(moveButton().disabled).toBe(false);

await type(episodes, "0-1");
expect(document.body.textContent).toContain("At least one episode must stay in the dataset.");
expect(moveButton().disabled).toBe(true);

await type(episodes, "1");
await act(async () => moveButton().click());
expect(moved).toEqual([[[1], { newName: "rejected_takes" }]]);

// Pick an existing dataset instead.
await openPicker();
const target = [...document.body.querySelectorAll("[cmdk-item]")].find(
(i) => i.textContent === "local/target",
) as HTMLElement;
await act(async () => target.click());
expect(document.body.textContent).toContain("Merged into local/target");
await act(async () => moveButton().click());
expect(moved[1]).toEqual([[1], { existingRepoId: "local/target" }]);
} finally {
await act(async () => root.unmount());
host.remove();
}
});
172 changes: 172 additions & 0 deletions frontend/src/components/dataset/MoveEpisodesDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronsUpDown, Loader2 } from "lucide-react";

import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import DatasetPicker from "@/components/landing/DatasetPicker";
import { parseEpisodeList } from "@/lib/datasetApi";
import { DatasetItem, MoveDestination } from "@/lib/replayApi";

interface MoveEpisodesDialogProps {
datasets: DatasetItem[];
repoId: string;
totalEpisodes: number;
currentEpisode: number | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onMove: (episodeIndices: number[], destination: MoveDestination) => Promise<void>;
}

export default function MoveEpisodesDialog({
datasets,
repoId,
totalEpisodes,
currentEpisode,
open,
onOpenChange,
onMove,
}: MoveEpisodesDialogProps) {
const [episodesText, setEpisodesText] = useState("");
const [destination, setDestination] = useState<MoveDestination | null>(null);
const [moving, setMoving] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (open) {
setEpisodesText(currentEpisode === null ? "" : String(currentEpisode));
setDestination(null);
setMoving(false);
setError(null);
}
}, [open, currentEpisode]);

const candidates = useMemo(() => datasets.filter((d) => d.repo_id !== repoId), [datasets, repoId]);
const episodes = parseEpisodeList(episodesText, totalEpisodes);
const movesEverything = episodes !== null && episodes.length >= totalEpisodes;
const canMove = episodes !== null && !movesEverything && destination !== null && !moving;
const pickDestination = (next: MoveDestination) => {
setError(null);
setDestination(next);
};

const handleOpenChange = (nextOpen: boolean) => {
if (moving) return;
onOpenChange(nextOpen);
};

const handleMove = async () => {
if (!canMove || episodes === null || destination === null) return;
setMoving(true);
try {
await onMove(episodes, destination);
onOpenChange(false);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : "Could not move the episodes.");
} finally {
setMoving(false);
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border-gray-700 bg-gray-900 text-gray-300 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-white">Move episodes to another dataset</DialogTitle>
<DialogDescription>
The episodes leave <code>{repoId}</code> and go to a new or an existing local dataset.
The episodes left in <code>{repoId}</code> are renumbered.
</DialogDescription>
</DialogHeader>

<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="move-episodes" className="text-gray-300">
Episodes
</Label>
<Input
id="move-episodes"
value={episodesText}
onChange={(event) => {
setError(null);
setEpisodesText(event.target.value);
}}
placeholder="3, 7, 10-12"
disabled={moving}
className="border-gray-700 bg-gray-800 text-white"
/>
{episodesText.trim() && episodes === null && (
<p className="text-xs text-red-400">
Use episode numbers from 0 to {totalEpisodes - 1}, e.g. 3, 7, 10-12.
</p>
)}
{movesEverything && (
<p className="text-xs text-red-400">At least one episode must stay in the dataset.</p>
)}
</div>

<div className="space-y-2">
<Label className="text-gray-300">Destination</Label>
<DatasetPicker
datasets={candidates}
loading={false}
onPickExisting={(item) => pickDestination({ existingRepoId: item.repo_id })}
onCreateNew={(name) => pickDestination({ newName: name })}
>
<Button
variant="outline"
role="combobox"
disabled={moving}
className="w-full justify-between border-gray-700 bg-gray-800 text-white hover:bg-gray-700"
>
<span className="truncate">
{destination === null
? "Select or create a dataset…"
: "newName" in destination
? `local/${destination.newName}`
: destination.existingRepoId}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DatasetPicker>
{destination !== null && (
<p className="text-xs text-gray-500">
{"newName" in destination ? (
<>
Saved locally as <code>local/{destination.newName}</code>.
</>
) : (
<>
Merged into <code>{destination.existingRepoId}</code> after its own episodes. It must
have the same fps, features and robot type.
</>
)}
</p>
)}
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
</div>

<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={moving}>
Cancel
</Button>
<Button onClick={handleMove} disabled={!canMove}>
{moving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{moving
? "Moving…"
: `Move ${episodes?.length ?? 0} episode${episodes?.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
9 changes: 6 additions & 3 deletions frontend/src/hooks/useEpisodes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useApi } from "@/contexts/ApiContext";
import {
EpisodeDetail,
Expand All @@ -13,6 +13,7 @@ export const useEpisodes = (repoId: string | null) => {
const [data, setData] = useState<EpisodeListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [version, setVersion] = useState(0);

useEffect(() => {
setData(null);
Expand Down Expand Up @@ -44,11 +45,13 @@ export const useEpisodes = (repoId: string | null) => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [baseUrl, fetchWithHeaders, repoId]);
}, [baseUrl, fetchWithHeaders, repoId, version]);

const refresh = useCallback(() => setVersion((v) => v + 1), []);

// Effects run after render: do not let the new dataset auto-select an
// episode from the old list before this effect clears it.
return { data: data?.repo_id === repoId ? data : null, loading, error };
return { data: data?.repo_id === repoId ? data : null, loading, error, refresh };
};

/** One episode's detail. Either arg null → idle. */
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/datasetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,22 @@ export function formatDuration(seconds: number | null | undefined): string {
const s = Math.round(seconds % 60);
return `${m}m ${String(s).padStart(2, "0")}s`;
}

/**
* Parse "3, 7, 10-12" into sorted episode indices. Returns null when the text
* is not a list of indices and ranges below `total`.
*/
export function parseEpisodeList(text: string, total: number): number[] | null {
const indices = new Set<number>();
for (const part of text.split(",")) {
const item = part.trim();
if (!item) continue;
const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(item);
if (!match) return null;
const start = Number(match[1]);
const end = match[2] === undefined ? start : Number(match[2]);
if (end < start || end >= total) return null;
for (let i = start; i <= end; i++) indices.add(i);
}
return indices.size > 0 ? [...indices].sort((a, b) => a - b) : null;
}
29 changes: 29 additions & 0 deletions frontend/src/lib/replayApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ export interface MergeDatasetsResult {
total_frames: number;
}

export interface MoveEpisodesResult {
success: true;
repo_id: string;
moved_episodes: number;
remaining_episodes: number;
}

export async function listDatasets(
baseUrl: string,
fetcher: Fetcher,
Expand Down Expand Up @@ -55,3 +62,25 @@ export async function mergeLocalDatasets(
action: "Merge datasets",
});
}

export type MoveDestination = { newName: string } | { existingRepoId: string };

export async function moveEpisodes(
baseUrl: string,
fetcher: Fetcher,
repoId: string,
episodeIndices: number[],
destination: MoveDestination,
): Promise<MoveEpisodesResult> {
return apiRequest<MoveEpisodesResult>(baseUrl, fetcher, "/datasets/move-episodes", {
method: "POST",
body: {
repo_id: repoId,
episode_indices: episodeIndices,
...("newName" in destination
? { output_name: destination.newName }
: { target_repo_id: destination.existingRepoId }),
},
action: "Move episodes",
});
}
Loading
Loading