diff --git a/packages/tui/src/routes/session/location-missing.tsx b/packages/tui/src/routes/session/location-missing.tsx index 83796e94e74..d42aada29fa 100644 --- a/packages/tui/src/routes/session/location-missing.tsx +++ b/packages/tui/src/routes/session/location-missing.tsx @@ -4,11 +4,56 @@ import { useTheme } from "../../context/theme" import { Locale } from "../../util/locale" import { abbreviateHome } from "../../util/path-format" import { SessionQuestion } from "./permission" -import { usePromptMove } from "../../component/prompt/move" +import { useDialog } from "../../ui/dialog" +import { useClient } from "../../context/client" +import { useToast } from "../../ui/toast" +import { errorMessage } from "../../util/error" +import { DialogWorkspaces, type WorkspaceSelection } from "../../component/dialog-workspaces" +import { useData } from "../../context/data" export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) { - const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID }) - return + const dialog = useDialog() + const client = useClient() + const toast = useToast() + const data = useData() + + function open() { + dialog.replace(() => ( + void select(selection)} + /> + )) + } + + async function select(selection: WorkspaceSelection) { + dialog.clear() + const directory = + selection.type === "directory" + ? selection.directory + : await client.api.worktree + .create({ projectID: props.projectID, name: selection.name }) + .then((result) => { + if (!result.directory) throw new Error("No worktree directory returned") + return result.directory + }) + .catch((error) => { + toast.show({ title: "Creating workspace failed", message: errorMessage(error), variant: "error" }) + return undefined + }) + if (!directory) return + await client.api.session.move({ sessionID: props.sessionID, directory }).catch((error) => { + toast.show({ title: "Failed to move session", message: errorMessage(error), variant: "error" }) + }) + } + + return } export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) { diff --git a/packages/tui/test/cli/tui/prompt-move.test.tsx b/packages/tui/test/cli/tui/prompt-move.test.tsx index 1b1dead8b11..159ef16a7a7 100644 --- a/packages/tui/test/cli/tui/prompt-move.test.tsx +++ b/packages/tui/test/cli/tui/prompt-move.test.tsx @@ -12,6 +12,7 @@ import { RouteProvider, useRoute } from "../../../src/context/route" import { ThemeProvider } from "../../../src/context/theme" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider, useToast } from "../../../src/ui/toast" +import { SessionLocationMissing } from "../../../src/routes/session/location-missing" import { emptyThemeSource } from "../../fixture/fixture" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" @@ -175,6 +176,68 @@ test.each([false, true])("Ctrl+M moves only an existing session (home=%s)", asyn } }) +test("choosing a directory recovers the session when its location is unavailable", async () => { + const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true }) + try { + await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable")) + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked)) + await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable) + await fixture.app.mockInput.typeText("linked") + await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(main)) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.moves.length === 1) + + expect(fixture.moves).toEqual([{ directory: linked }]) + expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" }) + expect(fixture.requests).toEqual([]) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("creating a worktree recovers the session without reading its removed location", async () => { + const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true }) + try { + await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable")) + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked)) + fixture.app.mockInput.pressKey("a", { ctrl: true }) + await fixture.app.waitForFrame((frame) => frame.includes("Name worktree")) + await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable) + await fixture.app.mockInput.typeText("fresh") + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.moves.length === 1) + + expect(fixture.requests).toEqual([{ payload: { projectID: "proj_test", name: "fresh" }, directory: null }]) + expect(fixture.moves).toEqual([{ directory: created }]) + expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" }) + expect(fixture.reads.locations).not.toContain(clone) + } finally { + fixture.app.renderer.destroy() + } +}) + +test("failed recovery does not navigate away from the session", async () => { + const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true, moveFails: true }) + try { + await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable")) + fixture.app.mockInput.pressEnter() + await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked)) + await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable) + await fixture.app.mockInput.typeText("linked") + await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(main)) + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.toast.currentToast !== null) + + expect(fixture.moves).toEqual([{ directory: linked }]) + expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" }) + expect(fixture.toast.currentToast).toMatchObject({ title: "Failed to move session", variant: "error" }) + } finally { + fixture.app.renderer.destroy() + } +}) + test.each([ { name: "session", unavailable: "session" as const }, { name: "location", unavailable: "location" as const }, @@ -223,6 +286,8 @@ async function renderMove(input: { launch?: string launchProjectID?: string unavailable?: "session" | "location" + showMissingLocation?: boolean + moveFails?: boolean }) { const launch = input.launch ?? (input.home ? input.directory : main) const requests: unknown[] = [] @@ -294,6 +359,7 @@ async function renderMove(input: { } if (url.pathname === "/api/session/ses_clone/move") { moves.push(await request.json()) + if (input.moveFails) return json({ message: "Destination unavailable" }, { status: 503 }) return new Response(null, { status: 204 }) } return undefined @@ -313,7 +379,9 @@ async function renderMove(input: { projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"), sessionID: () => (input.home ? undefined : "ses_clone"), }) - return null + return input.showMissingLocation ? ( + + ) : null } const app = await testRender(