From b83b4d53a0e638d09243934f97564a8a09c34556 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 12:25:35 +0000 Subject: [PATCH 01/17] fix(app): clarify create settings flow --- .../app/src/docker-git/menu-create-shared.ts | 89 ++++++++++++++- packages/app/src/docker-git/menu-create.ts | 11 ++ packages/app/src/docker-git/menu-render.ts | 14 +-- packages/app/src/web/actions-projects.ts | 2 +- packages/app/src/web/app-ready-create.ts | 13 ++- packages/app/src/web/app-ready-url.ts | 3 +- packages/app/src/web/panel-create-select.tsx | 18 +-- .../tests/docker-git/app-ready-create.test.ts | 95 ++++++++++++++- .../docker-git/create-flow-render.test.ts | 108 ++++++++++++++++++ .../docker-git/menu-create-shared.test.ts | 102 +++++++++++++++++ .../app/tests/docker-git/menu-create.test.ts | 106 +++++++++++++++++ 11 files changed, 539 insertions(+), 22 deletions(-) create mode 100644 packages/app/tests/docker-git/create-flow-render.test.ts create mode 100644 packages/app/tests/docker-git/menu-create.test.ts diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 91fe9dd2..f43a574c 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -43,6 +43,32 @@ type AdvanceCreateFlowOptions = { readonly quickCreate?: boolean } +/** + * Direction over the finite ordered set of unresolved Create settings rows. + * + * @pure true + * @effect none + * @invariant value ∈ {"up", "down"} + * @precondition n/a + * @postcondition navigation direction is total for settings rows + * @complexity O(1) + */ +export type CreateSettingsNavigationDirection = "up" | "down" + +/** + * User-facing key guide shown only after Create leaves the repo URL step. + * + * @pure true + * @effect none + * @invariant hint contains the complete settings-mode key contract + * @precondition CreateFlowView.step > 0 + * @postcondition no repo-step quick-create guidance is rendered from this value + * @complexity O(1) + */ +export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply" + +const firstCreateSettingsStepIndex = 1 + const trimLeftSlash = (value: string): string => { let start = 0 while (start < value.length && value[start] === "/") { @@ -462,6 +488,65 @@ const continueCreateFlow = ( } }) +const clampCreateSettingsStep = ( + step: number, + lastStep: number +): number => Math.min(Math.max(step, firstCreateSettingsStepIndex), lastStep) + +const nextCreateSettingsStep = ( + step: number, + lastStep: number, + direction: CreateSettingsNavigationDirection +): number => + Match.value(direction).pipe( + Match.when("up", () => step === firstCreateSettingsStepIndex ? lastStep : step - 1), + Match.when("down", () => step === lastStep ? firstCreateSettingsStepIndex : step + 1), + Match.exhaustive + ) + +/** + * Moves the selected Create settings row without applying the current buffer. + * + * @pure true + * @effect none + * @invariant view.step = 0 -> result = null + * @invariant result != null -> 1 <= result.step < |resolveCreateFlowSteps(result.values)| + * @invariant result != null && result.step != view.step -> result.buffer = "" + * @precondition view is a CreateFlowView snapshot + * @postcondition result values are identical to the input values + * @complexity O(n) where n is the number of unresolved Create steps + */ +export const moveCreateSettingsStep = ( + view: CreateFlowView, + direction: CreateSettingsNavigationDirection +): CreateFlowView | null => { + const steps = resolveCreateFlowSteps(view.values) + const lastStep = steps.length - 1 + if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { + return null + } + + const currentStep = clampCreateSettingsStep(view.step, lastStep) + const step = nextCreateSettingsStep(currentStep, lastStep, direction) + if (step === view.step) { + return view + } + return { + ...view, + step, + buffer: "" + } +} + +const resolveNextCreateFlowStep = ( + currentStep: CreateStep, + currentStepIndex: number, + nextSteps: ReadonlyArray +): number => + currentStep === "repoUrl" + ? firstCreateSettingsStepIndex + : clampCreateSettingsStep(currentStepIndex, nextSteps.length - 1) + export const advanceCreateFlow = ( contextOrCwd: string | CreateFlowContext, view: CreateFlowView, @@ -499,8 +584,8 @@ export const advanceCreateFlow = ( } const nextSteps = resolveCreateFlowSteps(nextValues) - const nextStep = step === "repoUrl" ? 1 : view.step - if (nextStep < nextSteps.length) { + const nextStep = resolveNextCreateFlowStep(step, view.step, nextSteps) + if (nextSteps.length > firstCreateSettingsStepIndex && nextStep < nextSteps.length) { return continueCreateFlow(nextStep, nextValues) } diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 4525f49f..08f04f86 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -11,6 +11,7 @@ import { advanceCreateFlow, createInitialFlowView, handleAdvanceCreateFlowResult, + moveCreateSettingsStep, resolveCreateInputs } from "./menu-create-shared.js" import { resetToMenu } from "./menu-shared.js" @@ -178,6 +179,8 @@ export const handleCreateInput = ( input: string, key: { readonly escape?: boolean + readonly upArrow?: boolean + readonly downArrow?: boolean readonly return?: boolean readonly shift?: boolean readonly backspace?: boolean @@ -190,6 +193,14 @@ export const handleCreateInput = ( resetToMenu(context) return } + if (key.upArrow || key.downArrow) { + const nextView = moveCreateSettingsStep(view, key.upArrow ? "up" : "down") + if (nextView !== null) { + context.setView({ _tag: "Create", ...nextView }) + context.setMessage(null) + } + return + } if (key.return) { handleCreateReturn({ ...context, view }, key.shift === true) return diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index de3c1d0a..03155b20 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -1,7 +1,7 @@ import React from "react" import { Box, Text } from "../ui/primitives.js" -import { renderCreateStepLabel } from "./menu-create-shared.js" +import { createSettingsHint, renderCreateStepLabel } from "./menu-create-shared.js" import { renderLayout } from "./menu-render-layout.js" import { buildSelectLabels, @@ -120,9 +120,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => { export const renderCreate = (input: CreateRenderInput): React.ReactElement => { const { buffer, defaults, label, message, stepIndex, steps } = input const el = React.createElement - const hint = stepIndex === 0 - ? "Enter = next, Shift+Enter = quick create, Esc = cancel." - : "Enter = next, Esc = cancel." + const hint = stepIndex > 0 ? createSettingsHint : null const stepViews = steps.map((step, index) => el( Text, @@ -132,7 +130,7 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => { ) return renderLayout( "docker-git / Create", - [ + compactElements([ el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews), el( Box, @@ -140,8 +138,10 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => { el(Text, null, `${label}: `), el(Text, { fg: "green" }, buffer) ), - el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) - ], + hint === null + ? null + : el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) + ]), message ) } diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 59251bd2..b8de9301 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -448,7 +448,7 @@ export const runProjectMenuAction = ( context: BrowserActionContext ) => { if (currentMenu === "Create") { - context.setMessage("Create mode is active. Paste URL or URL + flags, Enter = next, Shift+Enter = quick create.") + context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.") return } if (currentMenu === "Select") { diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index 7068100e..68acd782 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -6,7 +6,8 @@ import { advanceCreateFlow, type CreateFlowView, createInitialFlowView, - handleAdvanceCreateFlowResult + handleAdvanceCreateFlowResult, + moveCreateSettingsStep } from "../docker-git/menu-create-shared.js" import { submitCreateInputs } from "./actions-projects.js" import { requireGithubAuthConfigured } from "./actions-shared.js" @@ -109,6 +110,16 @@ export const handleCreateKey = ( cancelCreate(context, setCreateView) return true } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + const nextView = moveCreateSettingsStep(createView, event.key === "ArrowUp" ? "up" : "down") + if (nextView === null) { + return false + } + event.preventDefault() + setCreateView(nextView) + context.setMessage(null) + return true + } if (event.key === "Enter") { event.preventDefault() submitCreateView({ diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index be550e37..493e5e76 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -88,8 +88,7 @@ const activeTerminalReadyPath = (session: ActiveTerminalSession | null): string return projectSshRoutePath(session.browserProjectKey, session.session.id) } -const selectReadyPath = (token: string | null): string => - token === null ? "/menu/select" : `/select/${encodePathTail(token)}` +const selectReadyPath = (token: string | null): string => token === null ? "/menu/select" : projectSshRoutePath(token) const menuActionReadyPath = ( activeScreen: BrowserScreen, diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 45600e3f..4e054922 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -3,6 +3,7 @@ import type { JSX } from "react" import { type CreateFlowContext, type CreateFlowView, + createSettingsHint, renderCreateStepLabel, resolveCreateFlowSteps, resolveCreateInputs @@ -26,11 +27,6 @@ const createPrompt = ( } } -const createHint = (isRepoStep: boolean): string => - isRepoStep - ? "Enter = next, Shift+Enter = quick create, Esc = cancel." - : "Enter = next, Esc = cancel." - const CreatePromptInput = ( { createView, @@ -111,11 +107,17 @@ export const CreatePanel = ( ? ( ") + expect(html).not.toContain("Quick Create") + expect(html).not.toContain("Settings") + }) + + it("marks only the current row active in compact settings mode", () => { + const createView = createSettingsView() + const html = renderCreatePanel(createView, { compact: true }) + const activeLabel = renderStepLabels(createView)[createView.step] ?? "Repo URL (optional for empty workspace)" + + expect(countActiveStepMarkers(html)).toBe(1) + expect(html).toContain(`${activeStepMarker}${activeLabel}`) + }) + + it("previews side-arrow choices in the active settings row brackets without applying values", () => { + const createView = createSettingsViewAtStep("mcpPlaywright", "y") + const html = renderCreatePanel(createView, { compact: true }) + + expect(html).toContain(`${activeStepMarker}Enable Playwright MCP (nested Chromium browser)? [Y]`) + expect(html).toContain("Enable Playwright MCP (nested Chromium browser)? [Y]:") + expect(html).toContain("Force recreate (overwrite files + wipe volumes)? [N]") + expect(html).not.toContain(`${activeStepMarker}Enable Playwright MCP (nested Chromium browser)? [N]`) + }) + + it("drops unapplied bracket previews after settings navigation clears the buffer", () => { + const createView = createSettingsViewAtStep("force", "") + const html = renderCreatePanel(createView, { compact: true }) + + expect(html).toContain(`${activeStepMarker}Force recreate (overwrite files + wipe volumes)? [N]`) + expect(html).not.toContain("Force recreate (overwrite files + wipe volumes)? [Y]") }) it("renders the settings navigation hint only after leaving the repo URL step", () => { expect(renderCreatePanel(createInitialFlowView(repoUrl))).not.toContain(createSettingsHint) + expect(renderCreatePanel(createInitialFlowView(repoUrl))).not.toContain(webCreateSettingsChoiceHint) expect(renderCreatePanel(createSettingsView())).toContain(createSettingsHint) + expect(renderCreatePanel(createSettingsView())).toContain(webCreateSettingsChoiceHint) }) it("renders terminal Create hints with the same repo/settings split", () => { @@ -83,6 +198,7 @@ describe("Create flow rendering", () => { expect(repoHtml).not.toContain("Enter = next, Esc = cancel.") expect(repoHtml).not.toContain("Shift+Enter") expect(settingsHtml).toContain(createSettingsHint) + expect(settingsHtml).not.toContain(webCreateSettingsChoiceHint) }) it("preserves hint visibility invariants for every Create step", () => { @@ -94,13 +210,20 @@ describe("Create flow rendering", () => { const view = step === 0 ? createInitialFlowView(repoUrl) : { ...settingsView, step } const isSettings = step > 0 const panelHtml = renderCreatePanel(view) + const compactPanelHtml = renderCreatePanel(view, { compact: true }) const terminalHtml = renderTerminalCreate(view) expect(panelHtml.includes(createSettingsHint)).toBe(isSettings) + expect(compactPanelHtml.includes(createSettingsHint)).toBe(isSettings) expect(terminalHtml.includes(createSettingsHint)).toBe(isSettings) + expect(panelHtml.includes(webCreateSettingsChoiceHint)).toBe(isSettings) + expect(compactPanelHtml.includes(webCreateSettingsChoiceHint)).toBe(isSettings) + expect(terminalHtml).not.toContain(webCreateSettingsChoiceHint) expect(panelHtml).not.toContain("Enter = next, Esc = cancel.") + expect(compactPanelHtml).not.toContain("Enter = next, Esc = cancel.") expect(terminalHtml).not.toContain("Enter = next, Esc = cancel.") expect(panelHtml).not.toContain("Shift+Enter") + expect(compactPanelHtml).not.toContain("Shift+Enter") expect(terminalHtml).not.toContain("Shift+Enter") }) ) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 399a92a6..6443c941 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -3,10 +3,18 @@ import { describe, expect, it } from "vitest" import { advanceCreateFlow, + applyCreateDisplaySettingsStep, + completeCreateDisplaySettingsFlow, createInitialFlowView, + moveCreateDisplaySettingsStep, moveCreateSettingsStep, + renderCreateStepLabelWithBufferPreview, + resolveCreateDisplaySteps, + resolveCreateSettingsChoiceBuffer, + resolveCreateInputs, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" const expectContinueResult = ( next: ReturnType @@ -52,6 +60,17 @@ const expectedSettingsStep = ( return step === lastStep ? 1 : step + 1 } +const viewForStep = ( + view: ReturnType, + stepName: CreateStep +): ReturnType => { + const step = resolveCreateDisplaySteps().indexOf(stepName) + if (step < 0) { + throw new TypeError(`expected Create step: ${stepName}`) + } + return { ...view, step, buffer: "draft" } +} + describe("menu-create-shared", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` @@ -209,6 +228,149 @@ describe("menu-create-shared", () => { ) }) + it("resolves horizontal choices to buffer tokens for discrete settings rows", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "gpu"), "left")).toBe("none") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "gpu"), "right")).toBe("all") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "runUp"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "runUp"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "mcpPlaywright"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "mcpPlaywright"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "force"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "force"), "right")).toBe("y") + }) + + it("does not resolve horizontal choices for free-text rows or unknown steps", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const unknownStepView = { + ...view, + step: resolveCreateFlowSteps(view.values).length + 1, + buffer: "draft" + } + + expect(resolveCreateSettingsChoiceBuffer(createInitialFlowView("https://github.com/org/repo"), "right")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "cpuLimit"), "left")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "ramLimit"), "right")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(unknownStepView, "left")).toBeNull() + }) + + it("keeps every browser display row after settings are applied", () => { + const appliedValues = { + cpuLimit: "40%", + enableMcpPlaywright: true, + force: true, + gpu: "all", + ramLimit: "8g", + runUp: false + } satisfies Partial> + + expect(resolveCreateDisplaySteps(appliedValues)).toEqual([ + "repoUrl", + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright", + "force" + ]) + }) + + it("applies a browser display setting in place", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const mcpView = viewForStep(view, "mcpPlaywright") + const next = expectContinueResult(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) + + expect(next.step).toBe(mcpView.step) + expect(next.buffer).toBe("") + expect(next.values.enableMcpPlaywright).toBe(true) + expect(resolveCreateDisplaySteps()[next.step]).toBe("mcpPlaywright") + }) + + it("navigates browser display settings without skipping applied rows", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const applied = expectContinueResult(applyCreateDisplaySettingsStep( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + const down = moveCreateDisplaySettingsStep(applied, "down") + const up = moveCreateDisplaySettingsStep(applied, "up") + + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) + expect(down?.buffer).toBe("") + expect(up?.values.enableMcpPlaywright).toBe(true) + }) + + it("resolves horizontal choices against applied browser display rows", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const applied = expectContinueResult(applyCreateDisplaySettingsStep( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + + expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(applied, "right")).toBe("y") + }) + + it("completes browser display settings with a valid active buffer", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const complete = expectCompleteResult(completeCreateDisplaySettingsFlow( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + + expect(complete.enableMcpPlaywright).toBe(true) + }) + + it("renders unapplied buffer previews for discrete settings labels", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "all")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "none")).toBe("GPU access [none]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "y")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("runUp", defaults, "n")).toBe( + "Run docker compose up now? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "y")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [Y]" + ) + expect(renderCreateStepLabelWithBufferPreview("force", defaults, "y")).toBe( + "Force recreate (overwrite files + wipe volumes)? [Y]" + ) + }) + + it("preserves committed/default labels for empty, invalid, and free-text preview buffers", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "maybe")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("cpuLimit", defaults, "80%")).toBe("CPU limit [30%]") + expect(renderCreateStepLabelWithBufferPreview("ramLimit", defaults, "8g")).toBe("RAM limit [30%]") + }) + it("continues after applying a navigated setting while earlier settings remain unresolved", () => { const view = expectContinueResult(advanceCreateFlow( cwd, From 8988560c5fcbc58da7cf6272bcc5b0b4477f1319 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 17:32:22 +0000 Subject: [PATCH 03/17] fix(web): require create repo URL before submit --- .../app/src/docker-git/menu-create-shared.ts | 9 +- packages/app/src/docker-git/menu-types.ts | 8 +- packages/app/src/web/app-ready-create.ts | 9 +- packages/app/src/web/panel-create-select.tsx | 35 +++--- .../tests/docker-git/app-ready-create.test.ts | 104 +++++++++++++++++- .../docker-git/create-flow-render.test.ts | 26 +++++ .../app/tests/docker-git/menu-create.test.ts | 8 +- 7 files changed, 178 insertions(+), 21 deletions(-) diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index ec49978a..b794dda3 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -25,6 +25,7 @@ export type CreateFlowContext = { export type CreateFlowView = { readonly step: number readonly buffer: string + readonly inputError: string | null readonly values: Partial } @@ -567,6 +568,7 @@ const applyCreateStep = (input: { export const createInitialFlowView = (buffer = ""): CreateFlowView => ({ step: 0, buffer, + inputError: null, values: {} }) @@ -585,6 +587,7 @@ const continueCreateFlow = ( view: { step: nextStep, buffer: "", + inputError: null, values: nextValues } }) @@ -683,7 +686,8 @@ export const moveCreateSettingsStep = ( return { ...view, step, - buffer: "" + buffer: "", + inputError: null } } @@ -717,7 +721,8 @@ export const moveCreateDisplaySettingsStep = ( return { ...view, step, - buffer: "" + buffer: "", + inputError: null } } diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 44df2630..8fcfe7b4 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -142,7 +142,13 @@ export interface ProjectAuthSnapshot { export type ViewState = | { readonly _tag: "Menu" } - | { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial } + | { + readonly _tag: "Create" + readonly step: number + readonly buffer: string + readonly inputError: string | null + readonly values: Partial + } | { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot } | { readonly _tag: "AuthPrompt" diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index a638eda5..fb261c40 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -20,6 +20,8 @@ import { menuScreen } from "./screen.js" type Setter = Dispatch> +const emptyRepoUrlInputError = "Insert URL first" + type CreateKeyArgs = { readonly context: BrowserActionContext readonly controllerCwd: string @@ -57,7 +59,7 @@ export const setCreateBuffer = ( setCreateView: Setter, buffer: string ) => { - setCreateView({ ...createView, buffer }) + setCreateView({ ...createView, buffer, inputError: null }) } const resolveCreateSubmitResult = ( @@ -85,6 +87,11 @@ export const submitCreateView = ( setCreateView }: CreateSubmitArgs ): void => { + if (createView.step === 0 && createView.buffer.trim().length === 0) { + setCreateView({ ...createView, inputError: emptyRepoUrlInputError }) + return + } + if (!requireGithubAuthConfigured(context)) { return } diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 92762b69..41d3a7bd 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -53,21 +53,26 @@ const CreatePromptInput = ( readonly promptLabel: string } ): JSX.Element => ( - { - onBufferChange(value) - }} - {...(onArrowLeft === undefined ? {} : { onArrowLeft })} - {...(onArrowRight === undefined ? {} : { onArrowRight })} - onEnter={(shift) => { - onSubmit(isRepoStep ? shift : undefined) - }} - onEscape={onCancel} - placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} - value={createView.buffer} - /> + <> + { + onBufferChange(value) + }} + {...(onArrowLeft === undefined ? {} : { onArrowLeft })} + {...(onArrowRight === undefined ? {} : { onArrowRight })} + onEnter={(shift) => { + onSubmit(isRepoStep ? shift : undefined) + }} + onEscape={onCancel} + placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} + value={createView.buffer} + /> + {createView.inputError === null || !isRepoStep + ? null + : {createView.inputError}} + ) export const CreatePanel = ( diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index f91128f0..7e7de3da 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -12,7 +12,7 @@ import { import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" import type { submitCreateInputs } from "../../src/web/actions-projects.js" import type { GithubAuthStatus } from "../../src/web/api.js" -import { handleCreateKey, submitCreateView } from "../../src/web/app-ready-create.js" +import { handleCreateKey, setCreateBuffer, submitCreateView } from "../../src/web/app-ready-create.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" const submitCreateInputsMock = vi.hoisted(() => vi.fn()) @@ -133,6 +133,7 @@ const createKeyEvent = ( const createSettingsFlowView = (): CreateFlowView => ({ step: 1, buffer: "30%", + inputError: null, values: { outDir: "/home/dev/.docker-git/org/repo", repoRef: "feature-x", @@ -208,6 +209,31 @@ const expectCreateSideArrowBufferHandling = ( expect(context.setMessage).toHaveBeenCalledWith(null) } +const expectEmptyRepoInlineError = ( + quickCreate: boolean | undefined +) => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createInitialFlowView(" ") + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + quickCreate, + setCreateView + }) + + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expect(setCreateViewSpy).toHaveBeenCalledTimes(1) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + inputError: "Insert URL first" + }) + expect(context.setMessage).not.toHaveBeenCalled() +} + describe("app-ready-create", () => { beforeEach(() => { submitCreateInputsMock.mockReset() @@ -255,6 +281,82 @@ describe("app-ready-create", () => { }) }) + it("shows an inline error for empty repo URL quick create without submitting", () => { + expectEmptyRepoInlineError(true) + }) + + it("shows an inline error for empty repo URL settings without entering Settings", () => { + expectEmptyRepoInlineError(false) + }) + + it("shows an inline error for empty repo URL Enter without advancing", () => { + expectEmptyRepoInlineError(undefined) + }) + + it("shows an inline error for empty repo URL keyboard submits", () => { + for (const shiftKey of [false, true]) { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createInitialFlowView("") + const event = createKeyEvent("Enter", shiftKey) + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + expect(handled).toBe(true) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + inputError: "Insert URL first" + }) + expect(context.setMessage).not.toHaveBeenCalled() + } + }) + + it("validates empty repo URL before GitHub auth", () => { + const { context } = makeBrowserActionContext() + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createInitialFlowView("") + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + quickCreate: true, + setCreateView + }) + + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + inputError: "Insert URL first" + }) + expect(context.setMessage).not.toHaveBeenCalled() + expect(context.setActiveScreen).not.toHaveBeenCalled() + }) + + it("clears the inline repo URL error after editing the buffer", () => { + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView: CreateFlowView = { + ...createInitialFlowView(""), + inputError: "Insert URL first" + } + + setCreateBuffer(createView, setCreateView, "https://github.com/org/repo") + + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + buffer: "https://github.com/org/repo", + inputError: null + }) + }) + it("moves between settings with arrows and clears the uncommitted buffer", () => { expectCreateArrowHandling("ArrowDown", (view) => view.step + 1) }) diff --git a/packages/app/tests/docker-git/create-flow-render.test.ts b/packages/app/tests/docker-git/create-flow-render.test.ts index 5eb8c631..a8ec76fa 100644 --- a/packages/app/tests/docker-git/create-flow-render.test.ts +++ b/packages/app/tests/docker-git/create-flow-render.test.ts @@ -110,6 +110,32 @@ describe("Create flow rendering", () => { expect(compactHtml).not.toContain("Shift+Enter") }) + it("renders repo URL inline errors in red", () => { + const html = renderCreatePanel({ + ...createInitialFlowView(""), + inputError: "Insert URL first" + }) + + expect(html).toContain("Insert URL first") + expect(html).toContain("#ff6b6b") + }) + + it("omits repo URL inline errors when there is no error", () => { + const html = renderCreatePanel(createInitialFlowView("")) + + expect(html).not.toContain("Insert URL first") + expect(html).not.toContain("#ff6b6b") + }) + + it("does not render repo URL inline errors in Settings mode", () => { + const html = renderCreatePanel({ + ...createSettingsView(), + inputError: "Insert URL first" + }, { compact: true }) + + expect(html).not.toContain("Insert URL first") + }) + it("keeps the compact repo URL step focused on the repo input and action buttons", () => { const createView = createInitialFlowView(repoUrl) const html = renderCreatePanel(createView, { compact: true }) diff --git a/packages/app/tests/docker-git/menu-create.test.ts b/packages/app/tests/docker-git/menu-create.test.ts index a9a09c6e..fa98ce8b 100644 --- a/packages/app/tests/docker-git/menu-create.test.ts +++ b/packages/app/tests/docker-git/menu-create.test.ts @@ -19,6 +19,7 @@ const createSettingsView = (): CreateView => ({ _tag: "Create", step: 1, buffer: "30%", + inputError: null, values: settingsValues }) @@ -73,7 +74,12 @@ describe("menu-create", () => { Effect.sync(() => { const context = createContext() - handleCreateInput("", { downArrow: true }, { _tag: "Create", step: 0, buffer: "", values: {} }, context) + handleCreateInput( + "", + { downArrow: true }, + { _tag: "Create", step: 0, buffer: "", inputError: null, values: {} }, + context + ) expect(context.setViewMock).not.toHaveBeenCalled() })) From 9c5e2d126bde43a6d07e27c65c4623891400dac7 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 17:49:57 +0000 Subject: [PATCH 04/17] fix(app): restore checks after main merge --- packages/app/src/docker-git/program-auth.ts | 31 +++++++------ .../app/src/web/app-ready-terminal-screen.tsx | 1 + packages/app/src/web/panel-terminal.tsx | 2 +- .../tests/docker-git/core-templates.test.ts | 46 ------------------- packages/lib/tests/core/templates.test.ts | 1 + 5 files changed, 21 insertions(+), 60 deletions(-) delete mode 100644 packages/app/tests/docker-git/core-templates.test.ts diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 77d7adde..034b82ad 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -15,6 +15,7 @@ import { gitlabLogout, gitlabStatus, type JsonValue, + type ApiTerminalSession, renderJsonPayload } from "./api-client.js" import { type ControllerRuntime, ensureControllerReady } from "./controller.js" @@ -24,6 +25,7 @@ import { terminalAuthTitle } from "./menu-auth-shared.js" import { attachTerminalSession } from "./terminal-session-client.js" type OperationalCommand = Exclude +type RoutedAuthEffect = Effect.Effect export type RoutedAuthCommand = Extract< OperationalCommand, @@ -45,7 +47,9 @@ export type RoutedAuthCommand = Extract< } > -const withControllerReady = (effect: Effect.Effect) => +const withControllerReady = ( + effect: Effect.Effect +): Effect.Effect => pipe(ensureControllerReady(), Effect.zipRight(effect)) const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) @@ -106,21 +110,22 @@ const handleCodexLoginCommand = ( command: Extract ) => withControllerReady(codexLogin(command)) +const attachGrokAuthTerminalSession = ( + session: ApiTerminalSession | null +): Effect.Effect => + session === null + ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) + : attachTerminalSession({ + header: terminalAuthTitle("GrokOauth"), + session, + websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` + }) + const handleGrokLoginCommand = ( command: Extract ) => withControllerReady( - createAuthTerminalSession("GrokOauth", command.label).pipe( - Effect.flatMap((session) => - session === null - ? Effect.fail(missingAuthTerminalSessionError("GrokOauth")) - : attachTerminalSession({ - header: terminalAuthTitle("GrokOauth"), - session, - websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` - }) - ) - ) + createAuthTerminalSession("GrokOauth", command.label).pipe(Effect.flatMap(attachGrokAuthTerminalSession)) ) const handleCodexImportCommand = ( @@ -151,7 +156,7 @@ const handleCodexLogoutCommand = ( export const dispatchRoutedAuthCommand = ( command: RoutedAuthCommand -): Effect.Effect => +): RoutedAuthEffect => Match.value(command).pipe( Match.when({ _tag: "AuthGithubLogin" }, handleGithubLoginCommand), Match.when({ _tag: "AuthGithubStatus" }, handleGithubStatusCommand), diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index 225ed982..b26002ee 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -521,6 +521,7 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = { setTerminalView("terminal") }} diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index d3d584f9..1ce2f429 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -3,13 +3,13 @@ import "xterm/css/xterm.css" import { type CSSProperties, type JSX, useCallback, useEffect, useRef, useState } from "react" import { - type TerminalExitInfo, isModifierOnlyTerminalKey, type MobileTerminalKey, mobileTerminalKeyInput, terminalControlCharacterForKey } from "./terminal-mobile-controls.js" import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" +import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { type TerminalConnectionState, type TerminalInputController, diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts deleted file mode 100644 index d5d31e7b..00000000 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" - -import { defaultTemplateConfig, type TemplateConfig } from "../../src/lib/core/domain.js" -import { planFiles } from "../../src/lib/core/templates.js" - -const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ - ...defaultTemplateConfig, - repoUrl: "https://github.com/org/repo.git", - containerName: "dg-test", - serviceName: "dg-test", - sshUser: "dev", - targetDir: "/home/dev/org/repo", - volumeName: "dg-test-home", - dockerGitPath: "/workspace/.docker-git", - authorizedKeysPath: "/workspace/authorized_keys", - envGlobalPath: "/workspace/.orch/env/global.env", - envProjectPath: "/workspace/.orch/env/project.env", - codexAuthPath: "/workspace/.orch/auth/codex", - codexSharedAuthPath: "/workspace/.orch/auth/codex-shared", - codexHome: "/home/dev/.codex", - geminiAuthPath: "/workspace/.orch/auth/gemini", - geminiHome: "/home/dev/.gemini", - grokAuthPath: "/workspace/.orch/auth/grok", - grokHome: "/home/dev/.grok", - gpu: "none", - ...overrides -}) - -describe("app planFiles", () => { - it("includes nested browser runtime artifacts when Playwright is enabled", () => { - const files = planFiles(makeTemplateConfig({ enableMcpPlaywright: true })) - const filePaths = files.flatMap((file) => file._tag === "File" ? [file.relativePath] : []) - const runtime = files.find( - (file): file is Extract<(typeof files)[number], { readonly _tag: "File" }> => - file._tag === "File" && file.relativePath === "docker-git-browser-runtime.sh" - ) - - expect(filePaths).toContain("Dockerfile.browser") - expect(filePaths).toContain("mcp-playwright-start-extra.sh") - expect(filePaths).toContain("docker-git-browser-runtime.sh") - expect(runtime).toBeDefined() - expect(runtime?.mode).toBe(0o755) - expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') - expect(runtime?.contents).not.toContain('\\${MCP_PLAYWRIGHT_ENABLE:-0}') - }) -}) diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 6c34a241..11b810f2 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -737,6 +737,7 @@ describe("renderDockerCompose", () => { expect(runtime).toBeDefined() expect(runtime?.mode).toBe(0o755) expect(runtime?.contents).toContain('if [[ "${MCP_PLAYWRIGHT_ENABLE:-0}" != "1" ]]; then') + expect(runtime?.contents).not.toContain('\\${MCP_PLAYWRIGHT_ENABLE:-0}') }) it("renders local Docker socket mount only when explicitly enabled", () => { From d834ef3dd1e5614b2d0c383522f3f5dc89f63892 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 18:14:02 +0000 Subject: [PATCH 05/17] test(app): split create flow coverage --- packages/app/src/docker-git/program-auth.ts | 13 +- .../app/src/web/app-ready-terminal-screen.tsx | 2 +- .../docker-git/app-ready-create-fixture.ts | 259 ++++++++++ .../app-ready-create-settings.test.ts | 271 ++++++++++ .../tests/docker-git/app-ready-create.test.ts | 486 +----------------- .../docker-git/create-flow-render.test.ts | 4 +- .../menu-create-display-settings.test.ts | 159 ++++++ .../docker-git/menu-create-shared.test.ts | 121 +---- 8 files changed, 727 insertions(+), 588 deletions(-) create mode 100644 packages/app/tests/docker-git/app-ready-create-fixture.ts create mode 100644 packages/app/tests/docker-git/app-ready-create-settings.test.ts create mode 100644 packages/app/tests/docker-git/menu-create-display-settings.test.ts diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 034b82ad..8f8cc4f8 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -1,6 +1,7 @@ import { Effect, Match, pipe } from "effect" import { + type ApiTerminalSession, codexImport, codexLogin, codexLogout, @@ -9,13 +10,12 @@ import { githubLogin, githubLogout, githubStatus, - grokLogout, - grokStatus, gitlabLogin, gitlabLogout, gitlabStatus, + grokLogout, + grokStatus, type JsonValue, - type ApiTerminalSession, renderJsonPayload } from "./api-client.js" import { type ControllerRuntime, ensureControllerReady } from "./controller.js" @@ -49,8 +49,7 @@ export type RoutedAuthCommand = Extract< const withControllerReady = ( effect: Effect.Effect -): Effect.Effect => - pipe(ensureControllerReady(), Effect.zipRight(effect)) +): Effect.Effect => pipe(ensureControllerReady(), Effect.zipRight(effect)) const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) @@ -125,7 +124,9 @@ const handleGrokLoginCommand = ( command: Extract ) => withControllerReady( - createAuthTerminalSession("GrokOauth", command.label).pipe(Effect.flatMap(attachGrokAuthTerminalSession)) + createAuthTerminalSession("GrokOauth", command.label).pipe( + Effect.flatMap((session) => attachGrokAuthTerminalSession(session)) + ) ) const handleCodexImportCommand = ( diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index b26002ee..98fae077 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -7,9 +7,9 @@ import type { ReadyLayoutProps } from "./app-ready-layout.js" import { Box, Text } from "./elements.js" import { TaskPanel } from "./panel-tasks.js" import { TerminalPanel } from "./panel-terminal.js" -import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { type BrowserScreen, projectPickerScreen } from "./screen.js" import { shouldShowTerminalTabs } from "./terminal-mobile-layout.js" +import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { terminalSessionId } from "./terminal-state.js" import { type ActiveTerminalSession, isPendingActiveTerminalSession, terminalTitleById } from "./terminal.js" diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts new file mode 100644 index 00000000..c597d805 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -0,0 +1,259 @@ +import * as fc from "fast-check" +import type { Dispatch, SetStateAction } from "react" +import { expect, vi } from "vitest" + +import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" +import { + type CreateFlowView, + createInitialFlowView, + resolveCreateDisplaySteps +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" +import type { submitCreateInputs } from "../../src/web/actions-projects.js" +import type { GithubAuthStatus } from "../../src/web/api.js" +import type * as AppReadyCreate from "../../src/web/app-ready-create.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" + +export { + createInitialFlowView, + resolveCreateDisplaySteps, + resolveCreateFlowSteps +} from "../../src/docker-git/menu-create-shared.js" +export type { CreateFlowView } from "../../src/docker-git/menu-create-shared.js" +export type { CreateStep } from "../../src/docker-git/menu-types.js" + +type HandleCreateKey = typeof AppReadyCreate.handleCreateKey +type SubmitCreateView = typeof AppReadyCreate.submitCreateView +export type SubmitCreateInputsMock = ReturnType> + +export const validGithubStatus: GithubAuthStatus = { + summary: "valid", + tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] +} + +const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" +const githubNameCharArbitrary = fc + .integer({ min: 0, max: githubNameChars.length - 1 }) + .map((index) => githubNameChars[index] ?? "a") + +const githubSegmentArbitrary = fc + .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) + .map((chars) => chars.join("")) + .filter((value) => !value.startsWith("-") && !value.endsWith("-")) + +export const repositoryCreateInputArbitrary = fc.record({ + branch: fc.option(githubSegmentArbitrary, { nil: null }), + owner: githubSegmentArbitrary, + repo: githubSegmentArbitrary +}).map(({ branch, owner, repo }) => ({ + expectedRepoRef: branch ?? "main", + repoUrl: branch === null + ? `https://github.com/${owner}/${repo}` + : `https://github.com/${owner}/${repo}/tree/${branch}` +})) + +const defaultQuickCreateInputs = { + cpuLimit: "", + enableMcpPlaywright: false, + force: false, + forceEnv: false, + gpu: "none", + ramLimit: "", + runUp: true +} satisfies Omit + +export const createSetCreateViewSpy = () => { + const spy = vi.fn<(value: SetStateAction) => void>() + const setCreateView: Dispatch> = spy + return { setCreateView, spy } +} + +type SetCreateViewSpy = ReturnType["spy"] + +export const requireCreateViewValue = ( + value: SetStateAction | undefined +): CreateFlowView => { + if (value === undefined || typeof value === "function") { + throw new Error("Expected CreateFlowView value.") + } + return value +} + +export const submitCreateBuffer = ( + submitCreateView: SubmitCreateView, + buffer: string, + options: { readonly quickCreate?: boolean } = {} +) => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const quickCreate = options.quickCreate === undefined ? {} : { quickCreate: options.quickCreate } + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView: createInitialFlowView(buffer), + projectsRoot: "/home/dev/.docker-git", + ...quickCreate, + setCreateView + }) + + return { context, setCreateViewSpy } +} + +export const requireSubmittedCreateInputs = ( + submitCreateInputsMock: SubmitCreateInputsMock +): CreateInputs => { + const inputs = submitCreateInputsMock.mock.calls[0]?.[0] + if (inputs === undefined) { + throw new Error("Expected submitted CreateInputs.") + } + return inputs +} + +export const expectQuickCreateInputs = ( + submitCreateInputsMock: SubmitCreateInputsMock, + expected: Pick +) => { + expect(requireSubmittedCreateInputs(submitCreateInputsMock)).toEqual( + { + ...defaultQuickCreateInputs, + ...expected + } satisfies CreateInputs + ) +} + +export const expectCreateViewReset = ( + setCreateViewSpy: SetCreateViewSpy +) => { + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView()) +} + +export const createSubmitCreateBuffer = (submitCreateView: SubmitCreateView) => +( + buffer: string, + options: { readonly quickCreate?: boolean } = {} +) => submitCreateBuffer(submitCreateView, buffer, options) + +export const expectedOutDirForRepoUrl = (repoUrl: string): string => + `/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` + +export const createKeyEvent = ( + key: string, + shiftKey = false +): Parameters[0] => { + const event = { + key, + shiftKey, + preventDefault: vi.fn() + } + return event +} + +export const createSettingsFlowView = (): CreateFlowView => ({ + step: 1, + buffer: "30%", + inputError: null, + values: { + outDir: "/home/dev/.docker-git/org/repo", + repoRef: "feature-x", + repoUrl: "https://github.com/org/repo/tree/feature-x" + } +}) + +export const createSettingsFlowViewAtStep = ( + stepName: CreateStep, + buffer = "draft" +): CreateFlowView => { + const view = createSettingsFlowView() + const step = resolveCreateDisplaySteps().indexOf(stepName) + if (step === -1) { + throw new TypeError(`expected Create step: ${stepName}`) + } + return { ...view, step, buffer } +} + +export const expectCreateArrowHandling = ( + handleCreateKey: HandleCreateKey, + key: "ArrowDown" | "ArrowUp", + expectedStep: (view: CreateFlowView) => number +) => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const event = createKeyEvent(key) + const createView = createSettingsFlowView() + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + expect(handled).toBe(true) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + step: expectedStep(createView), + buffer: "" + }) + expect(context.setMessage).toHaveBeenCalledWith(null) +} + +export const expectCreateSideArrowBufferHandling = ( + handleCreateKey: HandleCreateKey, + submitCreateInputsMock: SubmitCreateInputsMock, + key: "ArrowLeft" | "ArrowRight", + stepName: CreateStep, + expectedBuffer: string +) => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const event = createKeyEvent(key) + const createView = createSettingsFlowViewAtStep(stepName, "typed") + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + expect(handled).toBe(true) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + buffer: expectedBuffer + }) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]).values).toEqual(createView.values) + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expect(context.setMessage).toHaveBeenCalledWith(null) +} + +export const expectEmptyRepoInlineError = ( + submitCreateView: SubmitCreateView, + submitCreateInputsMock: SubmitCreateInputsMock, + quickCreate?: boolean +) => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createInitialFlowView(" ") + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + quickCreate, + setCreateView + }) + + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expect(setCreateViewSpy).toHaveBeenCalledTimes(1) + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + ...createView, + inputError: "Insert URL first" + }) + expect(context.setMessage).not.toHaveBeenCalled() +} diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts new file mode 100644 index 00000000..fd6943e3 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { submitCreateInputs } from "../../src/web/actions-projects.js" +import { handleCreateKey, submitCreateView } from "../../src/web/app-ready-create.js" +import { + type CreateFlowView, + createInitialFlowView, + createKeyEvent, + createSetCreateViewSpy, + createSettingsFlowView, + createSettingsFlowViewAtStep, + type CreateStep, + expectCreateArrowHandling, + expectCreateSideArrowBufferHandling, + expectCreateViewReset, + requireCreateViewValue, + requireSubmittedCreateInputs, + resolveCreateDisplaySteps, + resolveCreateFlowSteps, + validGithubStatus +} from "./app-ready-create-fixture.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" + +const mocks = vi.hoisted(() => ({ + submitCreateInputsMock: vi.fn() +})) + +vi.mock("../../src/web/actions-projects.js", () => ({ + submitCreateInputs: mocks.submitCreateInputsMock +})) + +const submitCreateInputsMock = mocks.submitCreateInputsMock + +describe("app-ready-create settings", () => { + beforeEach(() => { + submitCreateInputsMock.mockReset() + }) + + it("moves between settings with arrows and clears the uncommitted buffer", () => { + expectCreateArrowHandling(handleCreateKey, "ArrowDown", (view) => view.step + 1) + }) + + it("wraps settings selection upward with ArrowUp and clears the uncommitted buffer", () => { + expectCreateArrowHandling(handleCreateKey, "ArrowUp", (view) => resolveCreateFlowSteps(view.values).length - 1) + }) + + it("fills discrete settings buffers with side arrows without applying values", () => { + const cases: ReadonlyArray<{ + readonly expectedBuffer: string + readonly key: "ArrowLeft" | "ArrowRight" + readonly stepName: CreateStep + }> = [ + { expectedBuffer: "none", key: "ArrowLeft", stepName: "gpu" }, + { expectedBuffer: "all", key: "ArrowRight", stepName: "gpu" }, + { expectedBuffer: "n", key: "ArrowLeft", stepName: "runUp" }, + { expectedBuffer: "y", key: "ArrowRight", stepName: "runUp" }, + { expectedBuffer: "n", key: "ArrowLeft", stepName: "mcpPlaywright" }, + { expectedBuffer: "y", key: "ArrowRight", stepName: "mcpPlaywright" }, + { expectedBuffer: "n", key: "ArrowLeft", stepName: "force" }, + { expectedBuffer: "y", key: "ArrowRight", stepName: "force" } + ] + + for (const { expectedBuffer, key, stepName } of cases) { + submitCreateInputsMock.mockReset() + expectCreateSideArrowBufferHandling(handleCreateKey, submitCreateInputsMock, key, stepName, expectedBuffer) + } + }) + + it("applies a side-arrow choice only after Enter", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createSettingsFlowViewAtStep("gpu", "typed") + const arrowEvent = createKeyEvent("ArrowRight") + + const arrowHandled = handleCreateKey(arrowEvent, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const arrowView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) + const enterEvent = createKeyEvent("Enter") + + const enterHandled = handleCreateKey(enterEvent, { + context, + controllerCwd: "/workspace", + createView: arrowView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) + + expect(arrowHandled).toBe(true) + expect(arrowView.values.gpu).toBeUndefined() + expect(enterHandled).toBe(true) + expect(enteredView.values.gpu).toBe("all") + expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("gpu")) + expect(enteredView.buffer).toBe("") + expect(submitCreateInputsMock).not.toHaveBeenCalled() + }) + + it("keeps an applied settings row selected and visible instead of submitting", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView: CreateFlowView = { + ...createSettingsFlowViewAtStep("force", "y"), + values: { + ...createSettingsFlowView().values, + cpuLimit: "40%", + enableMcpPlaywright: true, + gpu: "all", + ramLimit: "8g", + runUp: false + } + } + const event = createKeyEvent("Enter") + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) + + expect(handled).toBe(true) + expect(enteredView.values.force).toBe(true) + expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(enteredView.buffer).toBe("") + expect(submitCreateInputsMock).not.toHaveBeenCalled() + }) + + it("navigates to the next visible row after applying a settings row", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") + const enterEvent = createKeyEvent("Enter") + + handleCreateKey(enterEvent, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) + const downEvent = createKeyEvent("ArrowDown") + + const handled = handleCreateKey(downEvent, { + context, + controllerCwd: "/workspace", + createView: enteredView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const downView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) + + expect(handled).toBe(true) + expect(downView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(downView.values.enableMcpPlaywright).toBe(true) + expect(downView.buffer).toBe("") + }) + + it("clears an unconfirmed preview when navigating away from a settings row", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") + const event = createKeyEvent("ArrowDown") + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) + + expect(handled).toBe(true) + expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(nextView.values.enableMcpPlaywright).toBeUndefined() + expect(nextView.buffer).toBe("") + }) + + it("submits settings Done with a valid active preview applied first", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView: createSettingsFlowViewAtStep("mcpPlaywright", "y"), + projectsRoot: "/home/dev/.docker-git", + quickCreate: false, + setCreateView + }) + + expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) + expect(requireSubmittedCreateInputs(submitCreateInputsMock).enableMcpPlaywright).toBe(true) + expectCreateViewReset(setCreateViewSpy) + }) + + it("shows a parse error when settings Done has an invalid active preview", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView: createSettingsFlowViewAtStep("gpu", "bogus"), + projectsRoot: "/home/dev/.docker-git", + quickCreate: false, + setCreateView + }) + + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expect(setCreateViewSpy).not.toHaveBeenCalled() + expect(context.setMessage).toHaveBeenCalledWith("Invalid option create: gpu must be one of: none, all, yes, no") + }) + + it("ignores settings arrows before the Settings flow starts", () => { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const event = createKeyEvent("ArrowDown") + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView: createInitialFlowView("https://github.com/org/repo"), + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + expect(handled).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(setCreateViewSpy).not.toHaveBeenCalled() + expect(context.setMessage).not.toHaveBeenCalled() + }) + + it("ignores side arrows before Settings and on free-text settings", () => { + const keys: ReadonlyArray<"ArrowLeft" | "ArrowRight"> = ["ArrowLeft", "ArrowRight"] + const views: ReadonlyArray = [ + createInitialFlowView("https://github.com/org/repo"), + createSettingsFlowViewAtStep("cpuLimit"), + createSettingsFlowViewAtStep("ramLimit") + ] + + for (const key of keys) { + for (const createView of views) { + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() + const event = createKeyEvent(key) + + const handled = handleCreateKey(event, { + context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + expect(handled).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(setCreateViewSpy).not.toHaveBeenCalled() + expect(context.setMessage).not.toHaveBeenCalled() + } + } + }) +}) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 7e7de3da..5d489c75 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,238 +1,35 @@ import * as fc from "fast-check" -import type { Dispatch, SetStateAction } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" -import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" +import type { submitCreateInputs } from "../../src/web/actions-projects.js" +import { handleCreateKey, setCreateBuffer, submitCreateView } from "../../src/web/app-ready-create.js" import { type CreateFlowView, createInitialFlowView, - resolveCreateDisplaySteps, - resolveCreateFlowSteps -} from "../../src/docker-git/menu-create-shared.js" -import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" -import type { submitCreateInputs } from "../../src/web/actions-projects.js" -import type { GithubAuthStatus } from "../../src/web/api.js" -import { handleCreateKey, setCreateBuffer, submitCreateView } from "../../src/web/app-ready-create.js" + createKeyEvent, + createSetCreateViewSpy, + createSubmitCreateBuffer, + expectCreateViewReset, + expectedOutDirForRepoUrl, + expectEmptyRepoInlineError, + expectQuickCreateInputs, + repositoryCreateInputArbitrary, + requireCreateViewValue, + resolveCreateFlowSteps, + validGithubStatus +} from "./app-ready-create-fixture.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" -const submitCreateInputsMock = vi.hoisted(() => vi.fn()) - -vi.mock("../../src/web/actions-projects.js", () => ({ - submitCreateInputs: submitCreateInputsMock +const mocks = vi.hoisted(() => ({ + submitCreateInputsMock: vi.fn() })) -const validGithubStatus: GithubAuthStatus = { - summary: "valid", - tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] -} - -const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" -const githubNameCharArbitrary = fc - .integer({ min: 0, max: githubNameChars.length - 1 }) - .map((index) => githubNameChars[index] ?? "a") - -const githubSegmentArbitrary = fc - .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) - .map((chars) => chars.join("")) - .filter((value) => !value.startsWith("-") && !value.endsWith("-")) - -const repositoryCreateInputArbitrary = fc.record({ - branch: fc.option(githubSegmentArbitrary, { nil: null }), - owner: githubSegmentArbitrary, - repo: githubSegmentArbitrary -}).map(({ branch, owner, repo }) => ({ - expectedRepoRef: branch ?? "main", - repoUrl: branch === null - ? `https://github.com/${owner}/${repo}` - : `https://github.com/${owner}/${repo}/tree/${branch}` +vi.mock("../../src/web/actions-projects.js", () => ({ + submitCreateInputs: mocks.submitCreateInputsMock })) -const defaultQuickCreateInputs = { - cpuLimit: "", - enableMcpPlaywright: false, - force: false, - forceEnv: false, - gpu: "none", - ramLimit: "", - runUp: true -} satisfies Omit - -const createSetCreateViewSpy = () => { - const spy = vi.fn<(value: SetStateAction) => void>() - const setCreateView: Dispatch> = spy - return { setCreateView, spy } -} - -const requireCreateViewValue = ( - value: SetStateAction | undefined -): CreateFlowView => { - if (value === undefined || typeof value === "function") { - throw new Error("Expected CreateFlowView value.") - } - return value -} - -const submitCreateBuffer = ( - buffer: string, - options: { readonly quickCreate?: boolean } = {} -) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const quickCreate = options.quickCreate === undefined ? {} : { quickCreate: options.quickCreate } - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createInitialFlowView(buffer), - projectsRoot: "/home/dev/.docker-git", - ...quickCreate, - setCreateView - }) - - return { context, setCreateViewSpy } -} - -const requireSubmittedCreateInputs = (): CreateInputs => { - const inputs = submitCreateInputsMock.mock.calls[0]?.[0] - if (inputs === undefined) { - throw new Error("Expected submitted CreateInputs.") - } - return inputs -} - -const expectQuickCreateInputs = ( - expected: Pick -) => { - expect(requireSubmittedCreateInputs()).toEqual( - { - ...defaultQuickCreateInputs, - ...expected - } satisfies CreateInputs - ) -} - -const expectCreateViewReset = (setCreateViewSpy: ReturnType["setCreateViewSpy"]) => { - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView()) -} - -const expectedOutDirForRepoUrl = (repoUrl: string): string => - `/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` - -const createKeyEvent = ( - key: string, - shiftKey = false -): Parameters[0] => { - const event = { - key, - shiftKey, - preventDefault: vi.fn() - } - return event -} - -const createSettingsFlowView = (): CreateFlowView => ({ - step: 1, - buffer: "30%", - inputError: null, - values: { - outDir: "/home/dev/.docker-git/org/repo", - repoRef: "feature-x", - repoUrl: "https://github.com/org/repo/tree/feature-x" - } -}) - -const createSettingsFlowViewAtStep = ( - stepName: CreateStep, - buffer = "draft" -): CreateFlowView => { - const view = createSettingsFlowView() - const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step < 0) { - throw new TypeError(`expected Create step: ${stepName}`) - } - return { ...view, step, buffer } -} - -const expectCreateArrowHandling = ( - key: "ArrowDown" | "ArrowUp", - expectedStep: (view: CreateFlowView) => number -) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) - const createView = createSettingsFlowView() - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(true) - expect(event.preventDefault).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - step: expectedStep(createView), - buffer: "" - }) - expect(context.setMessage).toHaveBeenCalledWith(null) -} - -const expectCreateSideArrowBufferHandling = ( - key: "ArrowLeft" | "ArrowRight", - stepName: CreateStep, - expectedBuffer: string -) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) - const createView = createSettingsFlowViewAtStep(stepName, "typed") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(true) - expect(event.preventDefault).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - buffer: expectedBuffer - }) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]).values).toEqual(createView.values) - expect(submitCreateInputsMock).not.toHaveBeenCalled() - expect(context.setMessage).toHaveBeenCalledWith(null) -} - -const expectEmptyRepoInlineError = ( - quickCreate: boolean | undefined -) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createInitialFlowView(" ") - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - quickCreate, - setCreateView - }) - - expect(submitCreateInputsMock).not.toHaveBeenCalled() - expect(setCreateViewSpy).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - inputError: "Insert URL first" - }) - expect(context.setMessage).not.toHaveBeenCalled() -} +const submitCreateInputsMock = mocks.submitCreateInputsMock +const submitCreateBuffer = createSubmitCreateBuffer(submitCreateView) describe("app-ready-create", () => { beforeEach(() => { @@ -282,15 +79,15 @@ describe("app-ready-create", () => { }) it("shows an inline error for empty repo URL quick create without submitting", () => { - expectEmptyRepoInlineError(true) + expectEmptyRepoInlineError(submitCreateView, submitCreateInputsMock, true) }) it("shows an inline error for empty repo URL settings without entering Settings", () => { - expectEmptyRepoInlineError(false) + expectEmptyRepoInlineError(submitCreateView, submitCreateInputsMock, false) }) it("shows an inline error for empty repo URL Enter without advancing", () => { - expectEmptyRepoInlineError(undefined) + expectEmptyRepoInlineError(submitCreateView, submitCreateInputsMock) }) it("shows an inline error for empty repo URL keyboard submits", () => { @@ -357,239 +154,6 @@ describe("app-ready-create", () => { }) }) - it("moves between settings with arrows and clears the uncommitted buffer", () => { - expectCreateArrowHandling("ArrowDown", (view) => view.step + 1) - }) - - it("wraps settings selection upward with ArrowUp and clears the uncommitted buffer", () => { - expectCreateArrowHandling("ArrowUp", (view) => resolveCreateFlowSteps(view.values).length - 1) - }) - - it("fills discrete settings buffers with side arrows without applying values", () => { - const cases: ReadonlyArray<{ - readonly expectedBuffer: string - readonly key: "ArrowLeft" | "ArrowRight" - readonly stepName: CreateStep - }> = [ - { expectedBuffer: "none", key: "ArrowLeft", stepName: "gpu" }, - { expectedBuffer: "all", key: "ArrowRight", stepName: "gpu" }, - { expectedBuffer: "n", key: "ArrowLeft", stepName: "runUp" }, - { expectedBuffer: "y", key: "ArrowRight", stepName: "runUp" }, - { expectedBuffer: "n", key: "ArrowLeft", stepName: "mcpPlaywright" }, - { expectedBuffer: "y", key: "ArrowRight", stepName: "mcpPlaywright" }, - { expectedBuffer: "n", key: "ArrowLeft", stepName: "force" }, - { expectedBuffer: "y", key: "ArrowRight", stepName: "force" } - ] - - for (const { expectedBuffer, key, stepName } of cases) { - submitCreateInputsMock.mockReset() - expectCreateSideArrowBufferHandling(key, stepName, expectedBuffer) - } - }) - - it("applies a side-arrow choice only after Enter", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createSettingsFlowViewAtStep("gpu", "typed") - const arrowEvent = createKeyEvent("ArrowRight") - - const arrowHandled = handleCreateKey(arrowEvent, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const arrowView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - const enterEvent = createKeyEvent("Enter") - - const enterHandled = handleCreateKey(enterEvent, { - context, - controllerCwd: "/workspace", - createView: arrowView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) - - expect(arrowHandled).toBe(true) - expect(arrowView.values.gpu).toBeUndefined() - expect(enterHandled).toBe(true) - expect(enteredView.values.gpu).toBe("all") - expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("gpu")) - expect(enteredView.buffer).toBe("") - expect(submitCreateInputsMock).not.toHaveBeenCalled() - }) - - it("keeps an applied settings row selected and visible instead of submitting", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView: CreateFlowView = { - ...createSettingsFlowViewAtStep("force", "y"), - values: { - ...createSettingsFlowView().values, - cpuLimit: "40%", - enableMcpPlaywright: true, - gpu: "all", - ramLimit: "8g", - runUp: false - } - } - const event = createKeyEvent("Enter") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - - expect(handled).toBe(true) - expect(enteredView.values.force).toBe(true) - expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) - expect(enteredView.buffer).toBe("") - expect(submitCreateInputsMock).not.toHaveBeenCalled() - }) - - it("navigates to the next visible row after applying a settings row", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") - const enterEvent = createKeyEvent("Enter") - - handleCreateKey(enterEvent, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - const downEvent = createKeyEvent("ArrowDown") - - const handled = handleCreateKey(downEvent, { - context, - controllerCwd: "/workspace", - createView: enteredView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const downView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) - - expect(handled).toBe(true) - expect(downView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) - expect(downView.values.enableMcpPlaywright).toBe(true) - expect(downView.buffer).toBe("") - }) - - it("clears an unconfirmed preview when navigating away from a settings row", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") - const event = createKeyEvent("ArrowDown") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - - expect(handled).toBe(true) - expect(nextView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) - expect(nextView.values.enableMcpPlaywright).toBeUndefined() - expect(nextView.buffer).toBe("") - }) - - it("submits settings Done with a valid active preview applied first", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createSettingsFlowViewAtStep("mcpPlaywright", "y"), - projectsRoot: "/home/dev/.docker-git", - quickCreate: false, - setCreateView - }) - - expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) - expect(requireSubmittedCreateInputs().enableMcpPlaywright).toBe(true) - expectCreateViewReset(setCreateViewSpy) - }) - - it("shows a parse error when settings Done has an invalid active preview", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createSettingsFlowViewAtStep("gpu", "bogus"), - projectsRoot: "/home/dev/.docker-git", - quickCreate: false, - setCreateView - }) - - expect(submitCreateInputsMock).not.toHaveBeenCalled() - expect(setCreateViewSpy).not.toHaveBeenCalled() - expect(context.setMessage).toHaveBeenCalledWith("Invalid option create: gpu must be one of: none, all, yes, no") - }) - - it("ignores settings arrows before the Settings flow starts", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent("ArrowDown") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView: createInitialFlowView("https://github.com/org/repo"), - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(false) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(setCreateViewSpy).not.toHaveBeenCalled() - expect(context.setMessage).not.toHaveBeenCalled() - }) - - it("ignores side arrows before Settings and on free-text settings", () => { - const keys: ReadonlyArray<"ArrowLeft" | "ArrowRight"> = ["ArrowLeft", "ArrowRight"] - const views: ReadonlyArray = [ - createInitialFlowView("https://github.com/org/repo"), - createSettingsFlowViewAtStep("cpuLimit"), - createSettingsFlowViewAtStep("ramLimit") - ] - - for (const key of keys) { - for (const createView of views) { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(false) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(setCreateViewSpy).not.toHaveBeenCalled() - expect(context.setMessage).not.toHaveBeenCalled() - } - } - }) - it("shows a parse error instead of submitting on invalid inline flags", () => { const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo --bogus") @@ -604,7 +168,7 @@ describe("app-ready-create", () => { ) expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) - expectQuickCreateInputs({ + expectQuickCreateInputs(submitCreateInputsMock, { outDir: "/home/dev/.docker-git/octocat/hello-world", repoRef: "feature-x", repoUrl: "https://github.com/octocat/Hello-World/tree/feature-x" @@ -619,7 +183,7 @@ describe("app-ready-create", () => { const { setCreateViewSpy } = submitCreateBuffer(repoUrl, { quickCreate: true }) expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) - expectQuickCreateInputs({ + expectQuickCreateInputs(submitCreateInputsMock, { outDir: expectedOutDirForRepoUrl(repoUrl), repoRef: expectedRepoRef, repoUrl diff --git a/packages/app/tests/docker-git/create-flow-render.test.ts b/packages/app/tests/docker-git/create-flow-render.test.ts index a8ec76fa..17ccab76 100644 --- a/packages/app/tests/docker-git/create-flow-render.test.ts +++ b/packages/app/tests/docker-git/create-flow-render.test.ts @@ -14,8 +14,8 @@ import { resolveCreateFlowSteps, resolveCreateInputs } from "../../src/docker-git/menu-create-shared.js" -import type { CreateStep } from "../../src/docker-git/menu-types.js" import { renderCreate } from "../../src/docker-git/menu-render.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" import { webPrimitives } from "../../src/ui/primitives-web.js" import { UiProvider } from "../../src/ui/primitives.js" import { CreatePanel } from "../../src/web/panel-create-select.js" @@ -75,7 +75,7 @@ const createSettingsViewAtStep = ( ): CreateFlowView => { const createView = createSettingsView() const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step < 0) { + if (step === -1) { throw new TypeError(`expected Create step: ${stepName}`) } return { ...createView, buffer, step } diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts new file mode 100644 index 00000000..871e581e --- /dev/null +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest" + +import { + advanceCreateFlow, + applyCreateDisplaySettingsStep, + completeCreateDisplaySettingsFlow, + createInitialFlowView, + moveCreateDisplaySettingsStep, + renderCreateStepLabelWithBufferPreview, + resolveCreateDisplaySteps, + resolveCreateInputs, + resolveCreateSettingsChoiceBuffer +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" + +const expectContinueResult = ( + next: ReturnType +) => { + expect(next?._tag).toBe("Continue") + if (next === null || next._tag !== "Continue") { + throw new TypeError("expected continue create flow result") + } + return next.view +} + +const expectCompleteResult = ( + next: ReturnType +) => { + expect(next?._tag).toBe("Complete") + if (next === null || next._tag !== "Complete") { + throw new TypeError("expected complete create flow result") + } + return next.inputs +} + +const viewForStep = ( + view: ReturnType, + stepName: CreateStep +): ReturnType => { + const step = resolveCreateDisplaySteps().indexOf(stepName) + if (step === -1) { + throw new TypeError(`expected Create step: ${stepName}`) + } + return { ...view, step, buffer: "draft" } +} + +describe("menu-create-shared display settings", () => { + const cwd = process.cwd() + + it("keeps every browser display row after settings are applied", () => { + const appliedValues = { + cpuLimit: "40%", + enableMcpPlaywright: true, + force: true, + gpu: "all", + ramLimit: "8g", + runUp: false + } satisfies Partial> + + expect(resolveCreateDisplaySteps(appliedValues)).toEqual([ + "repoUrl", + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright", + "force" + ]) + }) + + it("applies a browser display setting in place", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const mcpView = viewForStep(view, "mcpPlaywright") + const next = expectContinueResult(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) + + expect(next.step).toBe(mcpView.step) + expect(next.buffer).toBe("") + expect(next.values.enableMcpPlaywright).toBe(true) + expect(resolveCreateDisplaySteps()[next.step]).toBe("mcpPlaywright") + }) + + it("navigates browser display settings without skipping applied rows", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const applied = expectContinueResult(applyCreateDisplaySettingsStep( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + const down = moveCreateDisplaySettingsStep(applied, "down") + const up = moveCreateDisplaySettingsStep(applied, "up") + + expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) + expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) + expect(down?.buffer).toBe("") + expect(up?.values.enableMcpPlaywright).toBe(true) + }) + + it("resolves horizontal choices against applied browser display rows", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const applied = expectContinueResult(applyCreateDisplaySettingsStep( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + + expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(applied, "right")).toBe("y") + }) + + it("completes browser display settings with a valid active buffer", () => { + const view = expectContinueResult(advanceCreateFlow( + cwd, + createInitialFlowView("https://github.com/org/repo/tree/feature-x") + )) + const complete = expectCompleteResult(completeCreateDisplaySettingsFlow( + cwd, + { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + )) + + expect(complete.enableMcpPlaywright).toBe(true) + }) + + it("renders unapplied buffer previews for discrete settings labels", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "all")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "none")).toBe("GPU access [none]") + expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "y")).toBe("GPU access [all]") + expect(renderCreateStepLabelWithBufferPreview("runUp", defaults, "n")).toBe( + "Run docker compose up now? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "y")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [Y]" + ) + expect(renderCreateStepLabelWithBufferPreview("force", defaults, "y")).toBe( + "Force recreate (overwrite files + wipe volumes)? [Y]" + ) + }) + + it("preserves committed/default labels for empty, invalid, and free-text preview buffers", () => { + const defaults = resolveCreateInputs(cwd, {}) + + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "maybe")).toBe( + "Enable Playwright MCP (nested Chromium browser)? [N]" + ) + expect(renderCreateStepLabelWithBufferPreview("cpuLimit", defaults, "80%")).toBe("CPU limit [30%]") + expect(renderCreateStepLabelWithBufferPreview("ramLimit", defaults, "8g")).toBe("RAM limit [30%]") + }) +}) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 6443c941..1121dd73 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -3,16 +3,11 @@ import { describe, expect, it } from "vitest" import { advanceCreateFlow, - applyCreateDisplaySettingsStep, - completeCreateDisplaySettingsFlow, createInitialFlowView, - moveCreateDisplaySettingsStep, moveCreateSettingsStep, - renderCreateStepLabelWithBufferPreview, resolveCreateDisplaySteps, - resolveCreateSettingsChoiceBuffer, - resolveCreateInputs, - resolveCreateFlowSteps + resolveCreateFlowSteps, + resolveCreateSettingsChoiceBuffer } from "../../src/docker-git/menu-create-shared.js" import type { CreateStep } from "../../src/docker-git/menu-types.js" @@ -65,7 +60,7 @@ const viewForStep = ( stepName: CreateStep ): ReturnType => { const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step < 0) { + if (step === -1) { throw new TypeError(`expected Create step: ${stepName}`) } return { ...view, step, buffer: "draft" } @@ -261,116 +256,6 @@ describe("menu-create-shared", () => { expect(resolveCreateSettingsChoiceBuffer(unknownStepView, "left")).toBeNull() }) - it("keeps every browser display row after settings are applied", () => { - const appliedValues = { - cpuLimit: "40%", - enableMcpPlaywright: true, - force: true, - gpu: "all", - ramLimit: "8g", - runUp: false - } satisfies Partial> - - expect(resolveCreateDisplaySteps(appliedValues)).toEqual([ - "repoUrl", - "cpuLimit", - "ramLimit", - "gpu", - "runUp", - "mcpPlaywright", - "force" - ]) - }) - - it("applies a browser display setting in place", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const mcpView = viewForStep(view, "mcpPlaywright") - const next = expectContinueResult(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) - - expect(next.step).toBe(mcpView.step) - expect(next.buffer).toBe("") - expect(next.values.enableMcpPlaywright).toBe(true) - expect(resolveCreateDisplaySteps()[next.step]).toBe("mcpPlaywright") - }) - - it("navigates browser display settings without skipping applied rows", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const applied = expectContinueResult(applyCreateDisplaySettingsStep( - cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } - )) - const down = moveCreateDisplaySettingsStep(applied, "down") - const up = moveCreateDisplaySettingsStep(applied, "up") - - expect(down?.step).toBe(resolveCreateDisplaySteps().indexOf("force")) - expect(up?.step).toBe(resolveCreateDisplaySteps().indexOf("runUp")) - expect(down?.buffer).toBe("") - expect(up?.values.enableMcpPlaywright).toBe(true) - }) - - it("resolves horizontal choices against applied browser display rows", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const applied = expectContinueResult(applyCreateDisplaySettingsStep( - cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } - )) - - expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") - expect(resolveCreateSettingsChoiceBuffer(applied, "right")).toBe("y") - }) - - it("completes browser display settings with a valid active buffer", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const complete = expectCompleteResult(completeCreateDisplaySettingsFlow( - cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } - )) - - expect(complete.enableMcpPlaywright).toBe(true) - }) - - it("renders unapplied buffer previews for discrete settings labels", () => { - const defaults = resolveCreateInputs(cwd, {}) - - expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "all")).toBe("GPU access [all]") - expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "none")).toBe("GPU access [none]") - expect(renderCreateStepLabelWithBufferPreview("gpu", defaults, "y")).toBe("GPU access [all]") - expect(renderCreateStepLabelWithBufferPreview("runUp", defaults, "n")).toBe( - "Run docker compose up now? [N]" - ) - expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "y")).toBe( - "Enable Playwright MCP (nested Chromium browser)? [Y]" - ) - expect(renderCreateStepLabelWithBufferPreview("force", defaults, "y")).toBe( - "Force recreate (overwrite files + wipe volumes)? [Y]" - ) - }) - - it("preserves committed/default labels for empty, invalid, and free-text preview buffers", () => { - const defaults = resolveCreateInputs(cwd, {}) - - expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "")).toBe( - "Enable Playwright MCP (nested Chromium browser)? [N]" - ) - expect(renderCreateStepLabelWithBufferPreview("mcpPlaywright", defaults, "maybe")).toBe( - "Enable Playwright MCP (nested Chromium browser)? [N]" - ) - expect(renderCreateStepLabelWithBufferPreview("cpuLimit", defaults, "80%")).toBe("CPU limit [30%]") - expect(renderCreateStepLabelWithBufferPreview("ramLimit", defaults, "8g")).toBe("RAM limit [30%]") - }) - it("continues after applying a navigated setting while earlier settings remain unresolved", () => { const view = expectContinueResult(advanceCreateFlow( cwd, From 6ddefd2f9e04421803bb8dd544c45827d0ed1ee7 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 18:37:34 +0000 Subject: [PATCH 06/17] test(app): deduplicate create flow tests --- .../docker-git/app-ready-create-fixture.ts | 225 ++++++++++-------- .../app-ready-create-settings.test.ts | 174 +++----------- .../tests/docker-git/app-ready-create.test.ts | 56 +---- .../docker-git/create-flow-render.test.ts | 42 ++-- .../docker-git/create-flow-test-helpers.ts | 82 +++++++ .../menu-create-display-settings.test.ts | 73 ++---- .../docker-git/menu-create-shared.test.ts | 111 +++------ 7 files changed, 325 insertions(+), 438 deletions(-) create mode 100644 packages/app/tests/docker-git/create-flow-test-helpers.ts diff --git a/packages/app/tests/docker-git/app-ready-create-fixture.ts b/packages/app/tests/docker-git/app-ready-create-fixture.ts index c597d805..6705fd18 100644 --- a/packages/app/tests/docker-git/app-ready-create-fixture.ts +++ b/packages/app/tests/docker-git/app-ready-create-fixture.ts @@ -1,18 +1,13 @@ -import * as fc from "fast-check" import type { Dispatch, SetStateAction } from "react" import { expect, vi } from "vitest" -import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" -import { - type CreateFlowView, - createInitialFlowView, - resolveCreateDisplaySteps -} from "../../src/docker-git/menu-create-shared.js" +import { type CreateFlowView, createInitialFlowView } from "../../src/docker-git/menu-create-shared.js" import type { CreateInputs, CreateStep } from "../../src/docker-git/menu-types.js" import type { submitCreateInputs } from "../../src/web/actions-projects.js" import type { GithubAuthStatus } from "../../src/web/api.js" import type * as AppReadyCreate from "../../src/web/app-ready-create.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" +import { featureCreateRepoUrl, resolveRequiredCreateStepIndex } from "./create-flow-test-helpers.js" export { createInitialFlowView, @@ -24,6 +19,7 @@ export type { CreateStep } from "../../src/docker-git/menu-types.js" type HandleCreateKey = typeof AppReadyCreate.handleCreateKey type SubmitCreateView = typeof AppReadyCreate.submitCreateView +type BrowserActionContextOverrides = Parameters[0] export type SubmitCreateInputsMock = ReturnType> export const validGithubStatus: GithubAuthStatus = { @@ -31,27 +27,6 @@ export const validGithubStatus: GithubAuthStatus = { tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] } -const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" -const githubNameCharArbitrary = fc - .integer({ min: 0, max: githubNameChars.length - 1 }) - .map((index) => githubNameChars[index] ?? "a") - -const githubSegmentArbitrary = fc - .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) - .map((chars) => chars.join("")) - .filter((value) => !value.startsWith("-") && !value.endsWith("-")) - -export const repositoryCreateInputArbitrary = fc.record({ - branch: fc.option(githubSegmentArbitrary, { nil: null }), - owner: githubSegmentArbitrary, - repo: githubSegmentArbitrary -}).map(({ branch, owner, repo }) => ({ - expectedRepoRef: branch ?? "main", - repoUrl: branch === null - ? `https://github.com/${owner}/${repo}` - : `https://github.com/${owner}/${repo}/tree/${branch}` -})) - const defaultQuickCreateInputs = { cpuLimit: "", enableMcpPlaywright: false, @@ -79,23 +54,35 @@ export const requireCreateViewValue = ( return value } +export const expectCreateViewUpdate = ( + setCreateViewSpy: SetCreateViewSpy, + expected: CreateFlowView, + callIndex = 0 +) => { + expect(requireCreateViewValue(setCreateViewSpy.mock.calls[callIndex]?.[0])).toEqual(expected) +} + +export const expectCreateViewInputError = ( + setCreateViewSpy: SetCreateViewSpy, + createView: CreateFlowView +) => { + expectCreateViewUpdate(setCreateViewSpy, { + ...createView, + inputError: "Insert URL first" + }) +} + export const submitCreateBuffer = ( submitCreateView: SubmitCreateView, buffer: string, options: { readonly quickCreate?: boolean } = {} ) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() const quickCreate = options.quickCreate === undefined ? {} : { quickCreate: options.quickCreate } - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createInitialFlowView(buffer), - projectsRoot: "/home/dev/.docker-git", - ...quickCreate, - setCreateView - }) + const { context, setCreateViewSpy } = runSubmitCreateView( + submitCreateView, + createInitialFlowView(buffer), + quickCreate + ) return { context, setCreateViewSpy } } @@ -125,7 +112,7 @@ export const expectQuickCreateInputs = ( export const expectCreateViewReset = ( setCreateViewSpy: SetCreateViewSpy ) => { - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual(createInitialFlowView()) + expectCreateViewUpdate(setCreateViewSpy, createInitialFlowView()) } export const createSubmitCreateBuffer = (submitCreateView: SubmitCreateView) => @@ -134,9 +121,6 @@ export const createSubmitCreateBuffer = (submitCreateView: SubmitCreateView) => options: { readonly quickCreate?: boolean } = {} ) => submitCreateBuffer(submitCreateView, buffer, options) -export const expectedOutDirForRepoUrl = (repoUrl: string): string => - `/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` - export const createKeyEvent = ( key: string, shiftKey = false @@ -156,48 +140,102 @@ export const createSettingsFlowView = (): CreateFlowView => ({ values: { outDir: "/home/dev/.docker-git/org/repo", repoRef: "feature-x", - repoUrl: "https://github.com/org/repo/tree/feature-x" + repoUrl: featureCreateRepoUrl } }) export const createSettingsFlowViewAtStep = ( stepName: CreateStep, buffer = "draft" -): CreateFlowView => { - const view = createSettingsFlowView() - const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step === -1) { - throw new TypeError(`expected Create step: ${stepName}`) - } - return { ...view, step, buffer } -} +): CreateFlowView => ({ + ...createSettingsFlowView(), + buffer, + step: resolveRequiredCreateStepIndex(stepName) +}) -export const expectCreateArrowHandling = ( - handleCreateKey: HandleCreateKey, - key: "ArrowDown" | "ArrowUp", - expectedStep: (view: CreateFlowView) => number +const createActionFrame = ( + contextOverrides?: BrowserActionContextOverrides ) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { context } = makeBrowserActionContext(contextOverrides ?? { githubStatus: validGithubStatus }) const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) - const createView = createSettingsFlowView() + return { context, setCreateView, setCreateViewSpy } +} +export const runCreateKey = ( + handleCreateKey: HandleCreateKey, + createView: CreateFlowView, + key: string, + options: { + readonly contextOverrides?: BrowserActionContextOverrides + readonly shiftKey?: boolean + } = {} +) => { + const frame = createActionFrame(options.contextOverrides) + const event = createKeyEvent(key, options.shiftKey ?? false) const handled = handleCreateKey(event, { - context, + context: frame.context, controllerCwd: "/workspace", createView, projectsRoot: "/home/dev/.docker-git", - setCreateView + setCreateView: frame.setCreateView }) + return { ...frame, event, handled } +} - expect(handled).toBe(true) - expect(event.preventDefault).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - step: expectedStep(createView), - buffer: "" +const expectHandledCreateKey = ( + result: Pick, "event" | "handled"> +) => { + expect(result.handled).toBe(true) + expect(result.event.preventDefault).toHaveBeenCalledTimes(1) +} + +export const expectIgnoredCreateKey = ( + handleCreateKey: HandleCreateKey, + createView: CreateFlowView, + key: "ArrowDown" | "ArrowLeft" | "ArrowRight" +) => { + const result = runCreateKey(handleCreateKey, createView, key) + + expect(result.handled).toBe(false) + expect(result.event.preventDefault).not.toHaveBeenCalled() + expect(result.setCreateViewSpy).not.toHaveBeenCalled() + expect(result.context.setMessage).not.toHaveBeenCalled() +} + +export const runSubmitCreateView = ( + submitCreateView: SubmitCreateView, + createView: CreateFlowView, + options: { + readonly contextOverrides?: BrowserActionContextOverrides + readonly quickCreate?: boolean + } = {} +) => { + const frame = createActionFrame(options.contextOverrides) + submitCreateView({ + context: frame.context, + controllerCwd: "/workspace", + createView, + projectsRoot: "/home/dev/.docker-git", + quickCreate: options.quickCreate, + setCreateView: frame.setCreateView }) - expect(context.setMessage).toHaveBeenCalledWith(null) + return frame +} + +export const expectCreateArrowHandling = ( + handleCreateKey: HandleCreateKey, + key: "ArrowDown" | "ArrowUp", + expectedStep: (view: CreateFlowView) => number +) => { + const createView = createSettingsFlowView() + const result = runCreateKey(handleCreateKey, createView, key) + const nextView = requireCreateViewValue(result.setCreateViewSpy.mock.calls[0]?.[0]) + + expectHandledCreateKey(result) + expect(nextView.step).toBe(expectedStep(createView)) + expect(nextView.buffer).toBe("") + expect(nextView.values).toEqual(createView.values) + expect(result.context.setMessage).toHaveBeenCalledWith(null) } export const expectCreateSideArrowBufferHandling = ( @@ -207,22 +245,12 @@ export const expectCreateSideArrowBufferHandling = ( stepName: CreateStep, expectedBuffer: string ) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) const createView = createSettingsFlowViewAtStep(stepName, "typed") + const result = runCreateKey(handleCreateKey, createView, key) + const { context, setCreateViewSpy } = result - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(true) - expect(event.preventDefault).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ + expectHandledCreateKey(result) + expectCreateViewUpdate(setCreateViewSpy, { ...createView, buffer: expectedBuffer }) @@ -236,24 +264,27 @@ export const expectEmptyRepoInlineError = ( submitCreateInputsMock: SubmitCreateInputsMock, quickCreate?: boolean ) => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() const createView = createInitialFlowView(" ") - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - quickCreate, - setCreateView - }) + const quickCreateOption = quickCreate === undefined ? {} : { quickCreate } + const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, createView, quickCreateOption) expect(submitCreateInputsMock).not.toHaveBeenCalled() expect(setCreateViewSpy).toHaveBeenCalledTimes(1) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - inputError: "Insert URL first" - }) + expectCreateViewInputError(setCreateViewSpy, createView) + expect(context.setMessage).not.toHaveBeenCalled() +} + +export const expectEmptyRepoKeyboardInlineError = ( + handleCreateKey: HandleCreateKey, + submitCreateInputsMock: SubmitCreateInputsMock, + shiftKey: boolean +) => { + const createView = createInitialFlowView("") + const result = runCreateKey(handleCreateKey, createView, "Enter", { shiftKey }) + const { context, setCreateViewSpy } = result + + expectHandledCreateKey(result) + expect(submitCreateInputsMock).not.toHaveBeenCalled() + expectCreateViewInputError(setCreateViewSpy, createView) expect(context.setMessage).not.toHaveBeenCalled() } diff --git a/packages/app/tests/docker-git/app-ready-create-settings.test.ts b/packages/app/tests/docker-git/app-ready-create-settings.test.ts index fd6943e3..7c69171e 100644 --- a/packages/app/tests/docker-git/app-ready-create-settings.test.ts +++ b/packages/app/tests/docker-git/app-ready-create-settings.test.ts @@ -5,31 +5,31 @@ import { handleCreateKey, submitCreateView } from "../../src/web/app-ready-creat import { type CreateFlowView, createInitialFlowView, - createKeyEvent, - createSetCreateViewSpy, createSettingsFlowView, createSettingsFlowViewAtStep, type CreateStep, expectCreateArrowHandling, expectCreateSideArrowBufferHandling, expectCreateViewReset, + expectIgnoredCreateKey, requireCreateViewValue, requireSubmittedCreateInputs, resolveCreateDisplaySteps, resolveCreateFlowSteps, - validGithubStatus + runCreateKey, + runSubmitCreateView } from "./app-ready-create-fixture.js" -import { makeBrowserActionContext } from "./browser-action-context-fixture.js" -const mocks = vi.hoisted(() => ({ - submitCreateInputsMock: vi.fn() +const actionSpies = vi.hoisted(() => ({ + submitProjectCreate: vi.fn() })) -vi.mock("../../src/web/actions-projects.js", () => ({ - submitCreateInputs: mocks.submitCreateInputsMock -})) +vi.mock("../../src/web/actions-projects.js", () => { + const actionsProjectModule = { submitCreateInputs: actionSpies.submitProjectCreate } + return actionsProjectModule +}) -const submitCreateInputsMock = mocks.submitCreateInputsMock +const submitCreateInputsMock = actionSpies.submitProjectCreate describe("app-ready-create settings", () => { beforeEach(() => { @@ -67,33 +67,14 @@ describe("app-ready-create settings", () => { }) it("applies a side-arrow choice only after Enter", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createSettingsFlowViewAtStep("gpu", "typed") - const arrowEvent = createKeyEvent("ArrowRight") - - const arrowHandled = handleCreateKey(arrowEvent, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const arrowView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - const enterEvent = createKeyEvent("Enter") + const arrowResult = runCreateKey(handleCreateKey, createSettingsFlowViewAtStep("gpu", "typed"), "ArrowRight") + const arrowView = requireCreateViewValue(arrowResult.setCreateViewSpy.mock.calls[0]?.[0]) + const enterResult = runCreateKey(handleCreateKey, arrowView, "Enter") + const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) - const enterHandled = handleCreateKey(enterEvent, { - context, - controllerCwd: "/workspace", - createView: arrowView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) - - expect(arrowHandled).toBe(true) + expect(arrowResult.handled).toBe(true) expect(arrowView.values.gpu).toBeUndefined() - expect(enterHandled).toBe(true) + expect(enterResult.handled).toBe(true) expect(enteredView.values.gpu).toBe("all") expect(enteredView.step).toBe(resolveCreateDisplaySteps().indexOf("gpu")) expect(enteredView.buffer).toBe("") @@ -101,8 +82,6 @@ describe("app-ready-create settings", () => { }) it("keeps an applied settings row selected and visible instead of submitting", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() const createView: CreateFlowView = { ...createSettingsFlowViewAtStep("force", "y"), values: { @@ -114,15 +93,7 @@ describe("app-ready-create settings", () => { runUp: false } } - const event = createKeyEvent("Enter") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) + const { handled, setCreateViewSpy } = runCreateKey(handleCreateKey, createView, "Enter") const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(handled).toBe(true) @@ -133,49 +104,20 @@ describe("app-ready-create settings", () => { }) it("navigates to the next visible row after applying a settings row", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") - const enterEvent = createKeyEvent("Enter") - - handleCreateKey(enterEvent, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const enteredView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) - const downEvent = createKeyEvent("ArrowDown") + const enterResult = runCreateKey(handleCreateKey, createSettingsFlowViewAtStep("mcpPlaywright", "y"), "Enter") + const enteredView = requireCreateViewValue(enterResult.setCreateViewSpy.mock.calls[0]?.[0]) + const downResult = runCreateKey(handleCreateKey, enteredView, "ArrowDown") + const downView = requireCreateViewValue(downResult.setCreateViewSpy.mock.calls[0]?.[0]) - const handled = handleCreateKey(downEvent, { - context, - controllerCwd: "/workspace", - createView: enteredView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - const downView = requireCreateViewValue(setCreateViewSpy.mock.calls[1]?.[0]) - - expect(handled).toBe(true) + expect(downResult.handled).toBe(true) expect(downView.step).toBe(resolveCreateDisplaySteps().indexOf("force")) expect(downView.values.enableMcpPlaywright).toBe(true) expect(downView.buffer).toBe("") }) it("clears an unconfirmed preview when navigating away from a settings row", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() const createView = createSettingsFlowViewAtStep("mcpPlaywright", "y") - const event = createKeyEvent("ArrowDown") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) + const { handled, setCreateViewSpy } = runCreateKey(handleCreateKey, createView, "ArrowDown") const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(handled).toBe(true) @@ -185,17 +127,11 @@ describe("app-ready-create settings", () => { }) it("submits settings Done with a valid active preview applied first", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createSettingsFlowViewAtStep("mcpPlaywright", "y"), - projectsRoot: "/home/dev/.docker-git", - quickCreate: false, - setCreateView - }) + const { setCreateViewSpy } = runSubmitCreateView( + submitCreateView, + createSettingsFlowViewAtStep("mcpPlaywright", "y"), + { quickCreate: false } + ) expect(submitCreateInputsMock).toHaveBeenCalledTimes(1) expect(requireSubmittedCreateInputs(submitCreateInputsMock).enableMcpPlaywright).toBe(true) @@ -203,17 +139,11 @@ describe("app-ready-create settings", () => { }) it("shows a parse error when settings Done has an invalid active preview", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createSettingsFlowViewAtStep("gpu", "bogus"), - projectsRoot: "/home/dev/.docker-git", - quickCreate: false, - setCreateView - }) + const { context, setCreateViewSpy } = runSubmitCreateView( + submitCreateView, + createSettingsFlowViewAtStep("gpu", "bogus"), + { quickCreate: false } + ) expect(submitCreateInputsMock).not.toHaveBeenCalled() expect(setCreateViewSpy).not.toHaveBeenCalled() @@ -221,22 +151,11 @@ describe("app-ready-create settings", () => { }) it("ignores settings arrows before the Settings flow starts", () => { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent("ArrowDown") - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView: createInitialFlowView("https://github.com/org/repo"), - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(false) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(setCreateViewSpy).not.toHaveBeenCalled() - expect(context.setMessage).not.toHaveBeenCalled() + expectIgnoredCreateKey( + handleCreateKey, + createInitialFlowView("https://github.com/org/repo"), + "ArrowDown" + ) }) it("ignores side arrows before Settings and on free-text settings", () => { @@ -249,22 +168,7 @@ describe("app-ready-create settings", () => { for (const key of keys) { for (const createView of views) { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const event = createKeyEvent(key) - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(false) - expect(event.preventDefault).not.toHaveBeenCalled() - expect(setCreateViewSpy).not.toHaveBeenCalled() - expect(context.setMessage).not.toHaveBeenCalled() + expectIgnoredCreateKey(handleCreateKey, createView, key) } } }) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 5d489c75..9631e40e 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -6,29 +6,25 @@ import { handleCreateKey, setCreateBuffer, submitCreateView } from "../../src/we import { type CreateFlowView, createInitialFlowView, - createKeyEvent, createSetCreateViewSpy, createSubmitCreateBuffer, + expectCreateViewInputError, expectCreateViewReset, - expectedOutDirForRepoUrl, expectEmptyRepoInlineError, + expectEmptyRepoKeyboardInlineError, expectQuickCreateInputs, - repositoryCreateInputArbitrary, requireCreateViewValue, resolveCreateFlowSteps, - validGithubStatus + runSubmitCreateView } from "./app-ready-create-fixture.js" -import { makeBrowserActionContext } from "./browser-action-context-fixture.js" +import { expectedOutDirForRepoUrl, repositoryCreateInputArbitrary } from "./create-flow-test-helpers.js" -const mocks = vi.hoisted(() => ({ - submitCreateInputsMock: vi.fn() -})) +const submitCreateInputsMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/actions-projects.js", () => ({ - submitCreateInputs: mocks.submitCreateInputsMock + submitCreateInputs: submitCreateInputsMock })) -const submitCreateInputsMock = mocks.submitCreateInputsMock const submitCreateBuffer = createSubmitCreateBuffer(submitCreateView) describe("app-ready-create", () => { @@ -92,48 +88,18 @@ describe("app-ready-create", () => { it("shows an inline error for empty repo URL keyboard submits", () => { for (const shiftKey of [false, true]) { - const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() - const createView = createInitialFlowView("") - const event = createKeyEvent("Enter", shiftKey) - - const handled = handleCreateKey(event, { - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) - - expect(handled).toBe(true) - expect(event.preventDefault).toHaveBeenCalledTimes(1) - expect(submitCreateInputsMock).not.toHaveBeenCalled() - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - inputError: "Insert URL first" - }) - expect(context.setMessage).not.toHaveBeenCalled() + expectEmptyRepoKeyboardInlineError(handleCreateKey, submitCreateInputsMock, shiftKey) } }) it("validates empty repo URL before GitHub auth", () => { - const { context } = makeBrowserActionContext() - const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() const createView = createInitialFlowView("") - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView, - projectsRoot: "/home/dev/.docker-git", - quickCreate: true, - setCreateView + const { context, setCreateViewSpy } = runSubmitCreateView(submitCreateView, createView, { + contextOverrides: {}, + quickCreate: true }) - expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({ - ...createView, - inputError: "Insert URL first" - }) + expectCreateViewInputError(setCreateViewSpy, createView) expect(context.setMessage).not.toHaveBeenCalled() expect(context.setActiveScreen).not.toHaveBeenCalled() }) diff --git a/packages/app/tests/docker-git/create-flow-render.test.ts b/packages/app/tests/docker-git/create-flow-render.test.ts index 17ccab76..24452aa7 100644 --- a/packages/app/tests/docker-git/create-flow-render.test.ts +++ b/packages/app/tests/docker-git/create-flow-render.test.ts @@ -4,7 +4,6 @@ import { renderToStaticMarkup } from "react-dom/server" import { describe, expect, it, vi } from "vitest" import { - advanceCreateFlow, type CreateFlowContext, type CreateFlowView, createInitialFlowView, @@ -15,10 +14,14 @@ import { resolveCreateInputs } from "../../src/docker-git/menu-create-shared.js" import { renderCreate } from "../../src/docker-git/menu-render.js" -import type { CreateStep } from "../../src/docker-git/menu-types.js" import { webPrimitives } from "../../src/ui/primitives-web.js" import { UiProvider } from "../../src/ui/primitives.js" import { CreatePanel } from "../../src/web/panel-create-select.js" +import { + createFeatureRepoSettingsView, + createFlowViewAtStep, + featureCreateRepoUrl +} from "./create-flow-test-helpers.js" const createContext: CreateFlowContext = { cwd: "/workspace", @@ -28,16 +31,8 @@ const createContext: CreateFlowContext = { const renderWithUi = (element: ReactElement): string => renderToStaticMarkup(createElement(UiProvider, { primitives: webPrimitives }, element)) -const repoUrl = "https://github.com/org/repo/tree/feature-x" const webCreateSettingsChoiceHint = "←/→ - choose yes/no or GPU" - -const createSettingsView = (): CreateFlowView => { - const next = advanceCreateFlow(createContext, createInitialFlowView(repoUrl)) - if (next === null || next._tag !== "Continue") { - throw new TypeError("expected settings view") - } - return next.view -} +const createSettingsView = (): CreateFlowView => createFeatureRepoSettingsView(createContext) const renderCreatePanel = ( createView: CreateFlowView, @@ -70,16 +65,9 @@ const renderSettingsStepLabels = (createView: CreateFlowView): ReadonlyArray[1], buffer: string -): CreateFlowView => { - const createView = createSettingsView() - const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step === -1) { - throw new TypeError(`expected Create step: ${stepName}`) - } - return { ...createView, buffer, step } -} +): CreateFlowView => createFlowViewAtStep(createSettingsView(), stepName, buffer) const renderTerminalCreate = (createView: CreateFlowView): string => { const defaults = resolveCreateInputs(createContext, createView.values) @@ -97,8 +85,8 @@ const renderTerminalCreate = (createView: CreateFlowView): string => { describe("Create flow rendering", () => { it("renders Quick Create and Settings on the repo URL step without the old micro-guide", () => { - const html = renderCreatePanel(createInitialFlowView(repoUrl)) - const compactHtml = renderCreatePanel(createInitialFlowView(repoUrl), { compact: true }) + const html = renderCreatePanel(createInitialFlowView(featureCreateRepoUrl)) + const compactHtml = renderCreatePanel(createInitialFlowView(featureCreateRepoUrl), { compact: true }) expect(html).toContain("Quick Create") expect(html).toContain("Settings") @@ -137,7 +125,7 @@ describe("Create flow rendering", () => { }) it("keeps the compact repo URL step focused on the repo input and action buttons", () => { - const createView = createInitialFlowView(repoUrl) + const createView = createInitialFlowView(featureCreateRepoUrl) const html = renderCreatePanel(createView, { compact: true }) expect(html).toContain("Repo URL (optional for empty workspace)") @@ -211,14 +199,14 @@ describe("Create flow rendering", () => { }) it("renders the settings navigation hint only after leaving the repo URL step", () => { - expect(renderCreatePanel(createInitialFlowView(repoUrl))).not.toContain(createSettingsHint) - expect(renderCreatePanel(createInitialFlowView(repoUrl))).not.toContain(webCreateSettingsChoiceHint) + expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain(createSettingsHint) + expect(renderCreatePanel(createInitialFlowView(featureCreateRepoUrl))).not.toContain(webCreateSettingsChoiceHint) expect(renderCreatePanel(createSettingsView())).toContain(createSettingsHint) expect(renderCreatePanel(createSettingsView())).toContain(webCreateSettingsChoiceHint) }) it("renders terminal Create hints with the same repo/settings split", () => { - const repoHtml = renderTerminalCreate(createInitialFlowView(repoUrl)) + const repoHtml = renderTerminalCreate(createInitialFlowView(featureCreateRepoUrl)) const settingsHtml = renderTerminalCreate(createSettingsView()) expect(repoHtml).not.toContain("Enter = next, Esc = cancel.") @@ -233,7 +221,7 @@ describe("Create flow rendering", () => { fc.assert( fc.property(fc.integer({ min: 0, max: lastStep }), (step) => { - const view = step === 0 ? createInitialFlowView(repoUrl) : { ...settingsView, step } + const view = step === 0 ? createInitialFlowView(featureCreateRepoUrl) : { ...settingsView, step } const isSettings = step > 0 const panelHtml = renderCreatePanel(view) const compactPanelHtml = renderCreatePanel(view, { compact: true }) diff --git a/packages/app/tests/docker-git/create-flow-test-helpers.ts b/packages/app/tests/docker-git/create-flow-test-helpers.ts new file mode 100644 index 00000000..bdb23115 --- /dev/null +++ b/packages/app/tests/docker-git/create-flow-test-helpers.ts @@ -0,0 +1,82 @@ +import * as fc from "fast-check" +import { expect } from "vitest" + +import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" +import { + advanceCreateFlow, + type CreateFlowView, + createInitialFlowView, + resolveCreateDisplaySteps +} from "../../src/docker-git/menu-create-shared.js" +import type { CreateStep } from "../../src/docker-git/menu-types.js" + +type CreateFlowAdvanceResult = NonNullable> + +export const featureCreateRepoUrl = "https://github.com/org/repo/tree/feature-x" + +const githubNameChars = "abcdefghijklmnopqrstuvwxyz0123456789-" +const githubNameCharArbitrary = fc + .integer({ min: 0, max: githubNameChars.length - 1 }) + .map((index) => githubNameChars[index] ?? "a") + +const githubSegmentArbitrary = fc + .array(githubNameCharArbitrary, { minLength: 1, maxLength: 12 }) + .map((chars) => chars.join("")) + .filter((value) => !value.startsWith("-") && !value.endsWith("-")) + +export const repositoryCreateInputArbitrary = fc.record({ + branch: fc.option(githubSegmentArbitrary, { nil: null }), + owner: githubSegmentArbitrary, + repo: githubSegmentArbitrary +}).map(({ branch, owner, repo }) => ({ + expectedRepoRef: branch ?? "main", + repoUrl: branch === null + ? `https://github.com/${owner}/${repo}` + : `https://github.com/${owner}/${repo}/tree/${branch}` +})) + +export const expectedOutDirForRepoUrl = (repoUrl: string): string => + `/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}` + +export const expectCreateContinueView = ( + next: ReturnType +): Extract["view"] => { + expect(next?._tag).toBe("Continue") + if (next === null || next._tag !== "Continue") { + throw new TypeError("expected continue create flow result") + } + return next.view +} + +export const expectCreateCompleteInputs = ( + next: ReturnType +): Extract["inputs"] => { + expect(next?._tag).toBe("Complete") + if (next === null || next._tag !== "Complete") { + throw new TypeError("expected complete create flow result") + } + return next.inputs +} + +export const resolveRequiredCreateStepIndex = (stepName: CreateStep): number => { + const step = resolveCreateDisplaySteps().indexOf(stepName) + if (step === -1) { + throw new TypeError(`expected Create step: ${stepName}`) + } + return step +} + +export const createFeatureRepoSettingsView = ( + contextOrCwd: Parameters[0] +): CreateFlowView => + expectCreateContinueView(advanceCreateFlow(contextOrCwd, createInitialFlowView(featureCreateRepoUrl))) + +export const createFlowViewAtStep = ( + view: CreateFlowView, + stepName: CreateStep, + buffer = "draft" +): CreateFlowView => ({ + ...view, + buffer, + step: resolveRequiredCreateStepIndex(stepName) +}) diff --git a/packages/app/tests/docker-git/menu-create-display-settings.test.ts b/packages/app/tests/docker-git/menu-create-display-settings.test.ts index 871e581e..8070d0cd 100644 --- a/packages/app/tests/docker-git/menu-create-display-settings.test.ts +++ b/packages/app/tests/docker-git/menu-create-display-settings.test.ts @@ -1,48 +1,20 @@ import { describe, expect, it } from "vitest" import { - advanceCreateFlow, applyCreateDisplaySettingsStep, completeCreateDisplaySettingsFlow, - createInitialFlowView, moveCreateDisplaySettingsStep, renderCreateStepLabelWithBufferPreview, resolveCreateDisplaySteps, resolveCreateInputs, resolveCreateSettingsChoiceBuffer } from "../../src/docker-git/menu-create-shared.js" -import type { CreateStep } from "../../src/docker-git/menu-types.js" - -const expectContinueResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Continue") - if (next === null || next._tag !== "Continue") { - throw new TypeError("expected continue create flow result") - } - return next.view -} - -const expectCompleteResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Complete") - if (next === null || next._tag !== "Complete") { - throw new TypeError("expected complete create flow result") - } - return next.inputs -} - -const viewForStep = ( - view: ReturnType, - stepName: CreateStep -): ReturnType => { - const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step === -1) { - throw new TypeError(`expected Create step: ${stepName}`) - } - return { ...view, step, buffer: "draft" } -} +import { + createFeatureRepoSettingsView, + createFlowViewAtStep, + expectCreateCompleteInputs, + expectCreateContinueView +} from "./create-flow-test-helpers.js" describe("menu-create-shared display settings", () => { const cwd = process.cwd() @@ -69,12 +41,8 @@ describe("menu-create-shared display settings", () => { }) it("applies a browser display setting in place", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const mcpView = viewForStep(view, "mcpPlaywright") - const next = expectContinueResult(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) + const mcpView = createFlowViewAtStep(createFeatureRepoSettingsView(cwd), "mcpPlaywright") + const next = expectCreateContinueView(applyCreateDisplaySettingsStep(cwd, { ...mcpView, buffer: "y" })) expect(next.step).toBe(mcpView.step) expect(next.buffer).toBe("") @@ -83,13 +51,10 @@ describe("menu-create-shared display settings", () => { }) it("navigates browser display settings without skipping applied rows", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const applied = expectContinueResult(applyCreateDisplaySettingsStep( + const view = createFeatureRepoSettingsView(cwd) + const applied = expectCreateContinueView(applyCreateDisplaySettingsStep( cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + { ...createFlowViewAtStep(view, "mcpPlaywright"), buffer: "y" } )) const down = moveCreateDisplaySettingsStep(applied, "down") const up = moveCreateDisplaySettingsStep(applied, "up") @@ -101,13 +66,9 @@ describe("menu-create-shared display settings", () => { }) it("resolves horizontal choices against applied browser display rows", () => { - const view = expectContinueResult(advanceCreateFlow( + const applied = expectCreateContinueView(applyCreateDisplaySettingsStep( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const applied = expectContinueResult(applyCreateDisplaySettingsStep( - cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + { ...createFlowViewAtStep(createFeatureRepoSettingsView(cwd), "mcpPlaywright"), buffer: "y" } )) expect(resolveCreateSettingsChoiceBuffer(applied, "left")).toBe("n") @@ -115,13 +76,9 @@ describe("menu-create-shared display settings", () => { }) it("completes browser display settings with a valid active buffer", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - const complete = expectCompleteResult(completeCreateDisplaySettingsFlow( + const complete = expectCreateCompleteInputs(completeCreateDisplaySettingsFlow( cwd, - { ...viewForStep(view, "mcpPlaywright"), buffer: "y" } + { ...createFlowViewAtStep(createFeatureRepoSettingsView(cwd), "mcpPlaywright"), buffer: "y" } )) expect(complete.enableMcpPlaywright).toBe(true) diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 1121dd73..6260b127 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -5,31 +5,16 @@ import { advanceCreateFlow, createInitialFlowView, moveCreateSettingsStep, - resolveCreateDisplaySteps, resolveCreateFlowSteps, resolveCreateSettingsChoiceBuffer } from "../../src/docker-git/menu-create-shared.js" -import type { CreateStep } from "../../src/docker-git/menu-types.js" - -const expectContinueResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Continue") - if (next === null || next._tag !== "Continue") { - throw new TypeError("expected continue create flow result") - } - return next.view -} - -const expectCompleteResult = ( - next: ReturnType -) => { - expect(next?._tag).toBe("Complete") - if (next === null || next._tag !== "Complete") { - throw new TypeError("expected complete create flow result") - } - return next.inputs -} +import { + createFeatureRepoSettingsView, + createFlowViewAtStep, + expectCreateCompleteInputs, + expectCreateContinueView, + featureCreateRepoUrl +} from "./create-flow-test-helpers.js" const expectFeatureRepoDefaults = ( value: { @@ -39,7 +24,7 @@ const expectFeatureRepoDefaults = ( }, defaultRoot: string ) => { - expect(value.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") + expect(value.repoUrl).toBe(featureCreateRepoUrl) expect(value.repoRef).toBe("feature-x") expect(value.outDir).toBe(defaultRoot) } @@ -55,26 +40,15 @@ const expectedSettingsStep = ( return step === lastStep ? 1 : step + 1 } -const viewForStep = ( - view: ReturnType, - stepName: CreateStep -): ReturnType => { - const step = resolveCreateDisplaySteps().indexOf(stepName) - if (step === -1) { - throw new TypeError(`expected Create step: ${stepName}`) - } - return { ...view, step, buffer: "draft" } -} - describe("menu-create-shared", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` const settingsDirectionArbitrary: fc.Arbitrary<"up" | "down"> = fc.constantFrom("up", "down") it("advances from repo URL into the wizard by default", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") + createInitialFlowView(featureCreateRepoUrl) )) expect(view.step).toBe(1) @@ -92,9 +66,9 @@ describe("menu-create-shared", () => { }) it("quick-creates from repo URL only when requested explicitly", () => { - const inputs = expectCompleteResult(advanceCreateFlow( + const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x"), + createInitialFlowView(featureCreateRepoUrl), { quickCreate: true } )) @@ -103,9 +77,9 @@ describe("menu-create-shared", () => { }) it("prefills create values from inline CLI flags on the repo step", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x --force --mcp-playwright --no-up") + createInitialFlowView(`${featureCreateRepoUrl} --force --mcp-playwright --no-up`) )) expectFeatureRepoDefaults(view.values, defaultRoot) @@ -121,10 +95,10 @@ describe("menu-create-shared", () => { }) it("completes immediately when every remaining prompt was passed inline", () => { - const inputs = expectCompleteResult(advanceCreateFlow( + const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, createInitialFlowView( - "https://github.com/org/repo/tree/feature-x --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force" + `${featureCreateRepoUrl} --cpu 25% --ram 4g --gpu all --no-up --mcp-playwright --force` ) )) @@ -155,22 +129,19 @@ describe("menu-create-shared", () => { }) it("uses server-provided projectsRoot in browser mode", () => { - const view = expectContinueResult(advanceCreateFlow( + const view = expectCreateContinueView(advanceCreateFlow( { cwd: "/repo/packages/api", projectsRoot: "/home/dev/.docker-git" }, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") + createInitialFlowView(featureCreateRepoUrl) )) expect(view.values.outDir).toBe("/home/dev/.docker-git/org/repo") }) it("moves between remaining settings rows and clears the input buffer", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) + const view = createFeatureRepoSettingsView(cwd) const editingView = { ...view, buffer: "stale" } const lastStep = resolveCreateFlowSteps(view.values).length - 1 @@ -192,10 +163,7 @@ describe("menu-create-shared", () => { }) it("preserves settings navigation wraparound and buffer invariants", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) + const view = createFeatureRepoSettingsView(cwd) const lastStep = resolveCreateFlowSteps(view.values).length - 1 fc.assert( @@ -224,26 +192,20 @@ describe("menu-create-shared", () => { }) it("resolves horizontal choices to buffer tokens for discrete settings rows", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) - - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "gpu"), "left")).toBe("none") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "gpu"), "right")).toBe("all") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "runUp"), "left")).toBe("n") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "runUp"), "right")).toBe("y") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "mcpPlaywright"), "left")).toBe("n") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "mcpPlaywright"), "right")).toBe("y") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "force"), "left")).toBe("n") - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "force"), "right")).toBe("y") + const view = createFeatureRepoSettingsView(cwd) + + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "gpu"), "left")).toBe("none") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "gpu"), "right")).toBe("all") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "runUp"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "runUp"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "mcpPlaywright"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "mcpPlaywright"), "right")).toBe("y") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "force"), "left")).toBe("n") + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "force"), "right")).toBe("y") }) it("does not resolve horizontal choices for free-text rows or unknown steps", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) + const view = createFeatureRepoSettingsView(cwd) const unknownStepView = { ...view, step: resolveCreateFlowSteps(view.values).length + 1, @@ -251,23 +213,20 @@ describe("menu-create-shared", () => { } expect(resolveCreateSettingsChoiceBuffer(createInitialFlowView("https://github.com/org/repo"), "right")).toBeNull() - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "cpuLimit"), "left")).toBeNull() - expect(resolveCreateSettingsChoiceBuffer(viewForStep(view, "ramLimit"), "right")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "cpuLimit"), "left")).toBeNull() + expect(resolveCreateSettingsChoiceBuffer(createFlowViewAtStep(view, "ramLimit"), "right")).toBeNull() expect(resolveCreateSettingsChoiceBuffer(unknownStepView, "left")).toBeNull() }) it("continues after applying a navigated setting while earlier settings remain unresolved", () => { - const view = expectContinueResult(advanceCreateFlow( - cwd, - createInitialFlowView("https://github.com/org/repo/tree/feature-x") - )) + const view = createFeatureRepoSettingsView(cwd) const forceView = moveCreateSettingsStep(view, "up") if (forceView === null) { throw new TypeError("expected settings navigation result") } - const next = expectContinueResult(advanceCreateFlow( + const next = expectCreateContinueView(advanceCreateFlow( cwd, { ...forceView, From b628c4a202e21ee793b1428dc53911fab9a31f97 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 18:43:05 +0000 Subject: [PATCH 07/17] fix(web): preserve select ready urls --- packages/app/src/web/app-ready-url.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index 64ec5c31..845f0621 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -88,7 +88,8 @@ const activeTerminalReadyPath = (session: ActiveTerminalSession | null): string return projectSshRoutePath(session.browserProjectKey, session.session.id) } -const selectReadyPath = (token: string | null): string => token === null ? "/menu/select" : projectSshRoutePath(token) +const selectReadyPath = (token: string | null): string => + token === null ? "/menu/select" : `/select/${encodePathTail(token)}` const menuActionReadyPath = ( activeScreen: BrowserScreen, From 1b2776ad5f8fca83b4709897d011ce2ad8e34d04 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 19:10:23 +0000 Subject: [PATCH 08/17] fix(app): restore ci lint and grok auth --- packages/app/src/ui/primitives-web.tsx | 57 +++++++++----- packages/app/src/web/app-ready-create.ts | 81 +++++++++++++------- packages/lib/src/usecases/auth-grok-oauth.ts | 14 ++-- 3 files changed, 96 insertions(+), 56 deletions(-) diff --git a/packages/app/src/ui/primitives-web.tsx b/packages/app/src/ui/primitives-web.tsx index 384c2269..dd520453 100644 --- a/packages/app/src/ui/primitives-web.tsx +++ b/packages/app/src/ui/primitives-web.tsx @@ -1,4 +1,4 @@ -import { createElement, type CSSProperties, type JSX } from "react" +import { createElement, type CSSProperties, type JSX, type KeyboardEvent } from "react" import type { UiBoxProps, UiButtonProps, UiTextInputProps, UiTextProps } from "./primitives.js" @@ -127,6 +127,40 @@ const horizontalArrowAction = ( return null } +type TextInputKeyboardHandlers = { + readonly onArrowLeft: (() => void) | undefined + readonly onArrowRight: (() => void) | undefined + readonly onEnter: ((shift: boolean) => void) | undefined + readonly onEscape: (() => void) | undefined +} + +const stopTextInputKey = ( + event: KeyboardEvent +): void => { + event.preventDefault() + event.stopPropagation() +} + +const handleMultilineTextInputKeyDown = + ({ onArrowLeft, onArrowRight, onEnter, onEscape }: TextInputKeyboardHandlers) => + (event: KeyboardEvent): void => { + const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight) + if (onArrow !== null) { + stopTextInputKey(event) + onArrow() + return + } + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + stopTextInputKey(event) + onEnter?.(event.shiftKey) + return + } + if (event.key === "Escape") { + stopTextInputKey(event) + onEscape?.() + } + } + const MultilineTextInput = ( { ariaLabel, @@ -149,26 +183,7 @@ const MultilineTextInput = ( onChange={(event) => { onChange(event.currentTarget.value) }} - onKeyDown={(event) => { - const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight) - if (onArrow !== null) { - event.preventDefault() - event.stopPropagation() - onArrow() - return - } - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault() - event.stopPropagation() - onEnter?.(event.shiftKey) - return - } - if (event.key === "Escape") { - event.preventDefault() - event.stopPropagation() - onEscape?.() - } - }} + onKeyDown={handleMultilineTextInputKeyDown({ onArrowLeft, onArrowRight, onEnter, onEscape })} placeholder={placeholder} rows={rows} style={{ diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index fb261c40..32b8e373 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -124,6 +124,56 @@ export const useCreateMenuReset = ( }, [currentMenu, setCreateView]) } +const handleCreateVerticalArrow = ( + event: CreateKeyboardEvent, + createView: CreateFlowView, + setCreateView: Setter, + context: BrowserActionContext +): boolean => { + const nextView = moveCreateDisplaySettingsStep(createView, event.key === "ArrowUp" ? "up" : "down") + if (nextView === null) { + return false + } + event.preventDefault() + setCreateView(nextView) + context.setMessage(null) + return true +} + +const handleCreateHorizontalArrow = ( + event: CreateKeyboardEvent, + createView: CreateFlowView, + setCreateView: Setter, + context: BrowserActionContext +): boolean => { + const nextBuffer = resolveCreateSettingsChoiceBuffer( + createView, + event.key === "ArrowLeft" ? "left" : "right" + ) + if (nextBuffer === null) { + return false + } + event.preventDefault() + setCreateBuffer(createView, setCreateView, nextBuffer) + context.setMessage(null) + return true +} + +const submitCreateFromKeyboard = ( + event: CreateKeyboardEvent, + { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs +): void => { + event.preventDefault() + submitCreateView({ + context, + controllerCwd, + projectsRoot, + createView, + quickCreate: createView.step > 0 ? undefined : event.shiftKey, + setCreateView + }) +} + export const handleCreateKey = ( event: CreateKeyboardEvent, { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs @@ -134,38 +184,13 @@ export const handleCreateKey = ( return true } if (event.key === "ArrowUp" || event.key === "ArrowDown") { - const nextView = moveCreateDisplaySettingsStep(createView, event.key === "ArrowUp" ? "up" : "down") - if (nextView === null) { - return false - } - event.preventDefault() - setCreateView(nextView) - context.setMessage(null) - return true + return handleCreateVerticalArrow(event, createView, setCreateView, context) } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - const nextBuffer = resolveCreateSettingsChoiceBuffer( - createView, - event.key === "ArrowLeft" ? "left" : "right" - ) - if (nextBuffer === null) { - return false - } - event.preventDefault() - setCreateBuffer(createView, setCreateView, nextBuffer) - context.setMessage(null) - return true + return handleCreateHorizontalArrow(event, createView, setCreateView, context) } if (event.key === "Enter") { - event.preventDefault() - submitCreateView({ - context, - controllerCwd, - projectsRoot, - createView, - quickCreate: createView.step > 0 ? undefined : event.shiftKey, - setCreateView - }) + submitCreateFromKeyboard(event, { context, controllerCwd, createView, projectsRoot, setCreateView }) return true } diff --git a/packages/lib/src/usecases/auth-grok-oauth.ts b/packages/lib/src/usecases/auth-grok-oauth.ts index 16d9db55..5c36404e 100644 --- a/packages/lib/src/usecases/auth-grok-oauth.ts +++ b/packages/lib/src/usecases/auth-grok-oauth.ts @@ -6,8 +6,8 @@ import { runCommandWithExitCodes } from "../shell/command-runner.js" import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" -// CHANGE: run the standard Grok CLI browser login flow inside the auth container -// WHY: `docker-git auth grok login` should behave like other interactive CLI logins +// CHANGE: run the official Grok CLI device-code login flow inside the auth container +// WHY: `docker-git auth grok login` should work in the headless Docker auth container // REF: issue-304 // SOURCE: https://x.ai/news/grok-build-cli // FORMAT THEOREM: forall cmd: runGrokOauthLogin(cmd) -> grok_credentials_stored | error @@ -41,15 +41,15 @@ const buildDockerGrokAuthSpec = ( }) /** - * Builds the Docker CLI argument vector for the standard interactive Grok login flow. + * Builds the Docker CLI argument vector for the official Grok device-code login flow. * * @param spec Docker auth container paths, image, working directory, and environment bindings. - * @returns Immutable Docker argument vector ending with `grok login`. + * @returns Immutable Docker argument vector ending with `grok login --device-auth`. * @pure true * @effect none; CORE argument builder only transforms immutable input data. * @invariant every non-empty environment binding is emitted as an adjacent `-e` argument pair. * @precondition spec.hostPath and spec.containerPath identify the selected Grok auth account directory. - * @postcondition returned args execute the standard Grok CLI browser login flow. + * @postcondition returned args execute the official headless Grok login mode documented by xAI. * @complexity O(n) time / O(n) space, where n is spec.env.length. * @throws Never - invalid process execution is represented by callers through typed Effect errors. */ @@ -73,7 +73,7 @@ export const buildDockerGrokAuthArgs = (spec: DockerGrokAuthSpec): ReadonlyArray } base.push("-e", trimmed) } - return [...base, spec.image, "grok", "login"] + return [...base, spec.image, "grok", "login", "--device-auth"] } const grokAuthPermissionScript = [ @@ -109,7 +109,7 @@ const fixGrokAuthPermissions = (cwd: string, hostPath: string, containerPath: st ) /** - * Runs the standard interactive Grok login inside the docker-git auth container. + * Runs the Grok OAuth device login inside the docker-git auth container. * * @param cwd Working directory used for Docker command execution. * @param accountPath Selected docker-git Grok account directory. From b9eda6a4b3df938fcff4ef0b187585f4b1f072fe Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 19:23:58 +0000 Subject: [PATCH 09/17] fix(app): deduplicate create flow source --- .../app/src/docker-git/menu-create-shared.ts | 252 +++++++++--------- 1 file changed, 124 insertions(+), 128 deletions(-) diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index b794dda3..2511f26b 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -132,7 +132,7 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N" -const parseExplicitBooleanChoice = (input: string): boolean | null => { +const parseBooleanChoice = (input: string): boolean | null => { const normalized = input.trim().toLowerCase() if (normalized === "y" || normalized === "yes") { return true @@ -143,6 +143,8 @@ const parseExplicitBooleanChoice = (input: string): boolean | null => { return null } +const parseExplicitBooleanChoice = parseBooleanChoice + const parseExplicitGpuChoice = ( input: string ): GpuMode | null => { @@ -267,16 +269,7 @@ const parseGpuInput = ( return Either.left(createParseError("gpu must be one of: none, all, yes, no")) } -const parseYesDefault = (input: string, fallback: boolean): boolean => { - const normalized = input.trim().toLowerCase() - if (normalized === "y" || normalized === "yes") { - return true - } - if (normalized === "n" || normalized === "no") { - return false - } - return fallback -} +const parseYesDefault = (input: string, fallback: boolean): boolean => parseBooleanChoice(input) ?? fallback const createParseError = (reason: string): ParseError => ({ _tag: "InvalidOption", @@ -565,6 +558,24 @@ const applyCreateStep = (input: { Match.exhaustive ) +const applyCreateBufferToValues = ( + context: CreateFlowContext, + view: CreateFlowView, + step: CreateStep +): Either.Either>, ParseError> => { + const buffer = view.buffer.trim() + const currentDefaults = resolveCreateInputs(context, view.values) + const nextValues: Partial> = { ...view.values } + const updated = applyCreateStep({ + step, + buffer, + currentDefaults, + nextValues, + context + }) + return Either.isLeft(updated) ? Either.left(updated.left) : Either.right(nextValues) +} + export const createInitialFlowView = (buffer = ""): CreateFlowView => ({ step: 0, buffer, @@ -608,6 +619,27 @@ const nextCreateSettingsStep = ( Match.exhaustive ) +const moveCreateSettingsWithin = ( + view: CreateFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): CreateFlowView | null => { + if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { + return null + } + + const currentStep = clampCreateSettingsStep(view.step, lastStep) + const step = nextCreateSettingsStep(currentStep, lastStep, direction) + return step === view.step + ? view + : { + ...view, + step, + buffer: "", + inputError: null + } +} + const booleanChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => Match.value(direction).pipe( Match.when("left", () => "n"), @@ -671,25 +703,7 @@ export const resolveCreateSettingsChoiceBuffer = ( export const moveCreateSettingsStep = ( view: CreateFlowView, direction: CreateSettingsNavigationDirection -): CreateFlowView | null => { - const steps = resolveCreateFlowSteps(view.values) - const lastStep = steps.length - 1 - if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { - return null - } - - const currentStep = clampCreateSettingsStep(view.step, lastStep) - const step = nextCreateSettingsStep(currentStep, lastStep, direction) - if (step === view.step) { - return view - } - return { - ...view, - step, - buffer: "", - inputError: null - } -} +): CreateFlowView | null => moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) /** * Moves the selected browser Create settings row over the full display list. @@ -706,24 +720,57 @@ export const moveCreateSettingsStep = ( export const moveCreateDisplaySettingsStep = ( view: CreateFlowView, direction: CreateSettingsNavigationDirection -): CreateFlowView | null => { - const steps = resolveCreateDisplaySteps() - const lastStep = steps.length - 1 - if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { - return null - } +): CreateFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) - const currentStep = clampCreateSettingsStep(view.step, lastStep) - const step = nextCreateSettingsStep(currentStep, lastStep, direction) - if (step === view.step) { - return view - } - return { - ...view, - step, - buffer: "", - inputError: null - } +const resolveActiveCreateDisplayStep = (view: CreateFlowView): CreateStep | null => { + const step = resolveCreateDisplaySteps()[view.step] + return view.step < firstCreateSettingsStepIndex || step === undefined ? null : step +} + +type ActiveCreateDisplayContext = { + readonly context: CreateFlowContext + readonly step: CreateStep +} + +const resolveActiveCreateDisplayContext = ( + contextOrCwd: string | CreateFlowContext, + view: CreateFlowView +): ActiveCreateDisplayContext | null => { + const step = resolveActiveCreateDisplayStep(view) + return step === null + ? null + : { + context: normalizeCreateFlowContext(contextOrCwd), + step + } +} + +const completeCreateFlow = ( + context: CreateFlowContext, + values: Partial +): AdvanceCreateFlowResult => ({ + _tag: "Complete", + inputs: resolveCreateInputs(context, values) +}) + +const foldAppliedCreateValues = ( + appliedValues: Either.Either>, ParseError>, + onSuccess: (nextValues: Partial>) => AdvanceCreateFlowResult +): AdvanceCreateFlowResult => + Either.isLeft(appliedValues) + ? { + _tag: "Error", + error: appliedValues.left + } + : onSuccess(appliedValues.right) + +const withActiveCreateDisplayContext = ( + contextOrCwd: string | CreateFlowContext, + view: CreateFlowView, + onActive: (active: ActiveCreateDisplayContext) => AdvanceCreateFlowResult | null +): AdvanceCreateFlowResult | null => { + const active = resolveActiveCreateDisplayContext(contextOrCwd, view) + return active === null ? null : onActive(active) } /** @@ -740,32 +787,12 @@ export const moveCreateDisplaySettingsStep = ( export const applyCreateDisplaySettingsStep = ( contextOrCwd: string | CreateFlowContext, view: CreateFlowView -): AdvanceCreateFlowResult | null => { - const step = resolveCreateDisplaySteps()[view.step] - if (view.step < firstCreateSettingsStepIndex || step === undefined) { - return null - } - - const context = normalizeCreateFlowContext(contextOrCwd) - const buffer = view.buffer.trim() - const currentDefaults = resolveCreateInputs(context, view.values) - const nextValues: Partial> = { ...view.values } - const updated = applyCreateStep({ - step, - buffer, - currentDefaults, - nextValues, - context - }) - if (Either.isLeft(updated)) { - return { - _tag: "Error", - error: updated.left - } - } - - return continueCreateFlow(view.step, nextValues) -} +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => + foldAppliedCreateValues( + applyCreateBufferToValues(active.context, view, active.step), + (nextValues) => continueCreateFlow(view.step, nextValues) + )) /** * Completes browser Create settings by applying a non-empty active buffer first. @@ -781,32 +808,21 @@ export const applyCreateDisplaySettingsStep = ( export const completeCreateDisplaySettingsFlow = ( contextOrCwd: string | CreateFlowContext, view: CreateFlowView -): AdvanceCreateFlowResult | null => { - const step = resolveCreateDisplaySteps()[view.step] - if (view.step < firstCreateSettingsStepIndex || step === undefined) { - return null - } - - const context = normalizeCreateFlowContext(contextOrCwd) - if (view.buffer.trim().length === 0) { - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, view.values) +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => { + if (view.buffer.trim().length === 0) { + return completeCreateFlow(active.context, view.values) } - } - const applied = applyCreateDisplaySettingsStep(context, view) - if (applied === null || applied._tag === "Error") { - return applied - } - if (applied._tag === "Continue") { - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, applied.view.values) + const applied = applyCreateDisplaySettingsStep(active.context, view) + if (applied === null || applied._tag === "Error") { + return applied } - } - return applied -} + if (applied._tag === "Continue") { + return completeCreateFlow(active.context, applied.view.values) + } + return applied + }) const resolveNextCreateFlowStep = ( currentStep: CreateStep, @@ -829,40 +845,20 @@ export const advanceCreateFlow = ( return null } - const buffer = view.buffer.trim() - const currentDefaults = resolveCreateInputs(context, view.values) - const nextValues: Partial> = { ...view.values } - const updated = applyCreateStep({ - step, - buffer, - currentDefaults, - nextValues, - context - }) - if (Either.isLeft(updated)) { - return { - _tag: "Error", - error: updated.left - } - } + return foldAppliedCreateValues( + applyCreateBufferToValues(context, view, step), + (nextValues) => { + if (shouldQuickCreate(step, options)) { + return completeCreateFlow(context, nextValues) + } - if (shouldQuickCreate(step, options)) { - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, nextValues) + const nextSteps = resolveCreateFlowSteps(nextValues) + const nextStep = resolveNextCreateFlowStep(step, view.step, nextSteps) + return nextSteps.length > firstCreateSettingsStepIndex && nextStep < nextSteps.length + ? continueCreateFlow(nextStep, nextValues) + : completeCreateFlow(context, nextValues) } - } - - const nextSteps = resolveCreateFlowSteps(nextValues) - const nextStep = resolveNextCreateFlowStep(step, view.step, nextSteps) - if (nextSteps.length > firstCreateSettingsStepIndex && nextStep < nextSteps.length) { - return continueCreateFlow(nextStep, nextValues) - } - - return { - _tag: "Complete", - inputs: resolveCreateInputs(context, nextValues) - } + ) } export const handleAdvanceCreateFlowResult = ( From 99f1ec3d9051cb37f7fceb703d3229953be29ea2 Mon Sep 17 00:00:00 2001 From: rikohomeless <163776849+rikohomeless@users.noreply.github.com> Date: Mon, 18 May 2026 21:01:53 +0000 Subject: [PATCH 10/17] fix(app): address create flow review feedback --- .../app/src/docker-git/menu-create-shared.ts | 125 +++++++++++++--- packages/app/src/docker-git/menu-create.ts | 4 +- packages/app/src/docker-git/menu-types.ts | 1 + packages/app/src/docker-git/program-auth.ts | 15 +- packages/app/src/web/app-ready-controller.ts | 12 +- packages/app/src/web/app-ready-create.ts | 83 +++++++---- packages/app/src/web/app-ready-layout.tsx | 3 +- packages/app/src/web/app-ready.tsx | 3 +- packages/app/src/web/panel-content.tsx | 3 +- packages/app/src/web/panel-create-select.tsx | 57 +++++--- .../docker-git/app-ready-create-fixture.ts | 39 +++-- .../app-ready-create-settings.test.ts | 7 +- .../tests/docker-git/app-ready-create.test.ts | 13 +- .../docker-git/create-flow-render.test.ts | 5 +- .../docker-git/create-flow-test-helpers.ts | 40 ++++- .../menu-create-display-settings.test.ts | 138 ++++++++++++++++-- .../docker-git/menu-create-shared.test.ts | 9 +- .../app/tests/docker-git/menu-create.test.ts | 3 +- 18 files changed, 442 insertions(+), 118 deletions(-) diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 2511f26b..974b66e5 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -22,13 +22,24 @@ export type CreateFlowContext = { readonly projectsRoot?: string | undefined } -export type CreateFlowView = { - readonly step: number +type BaseCreateFlowView = { readonly buffer: string readonly inputError: string | null readonly values: Partial } +export type CreateModeFlowView = BaseCreateFlowView & { + readonly mode: "create" + readonly step: number +} + +export type DisplayModeFlowView = BaseCreateFlowView & { + readonly mode: "display" + readonly step: number +} + +export type CreateFlowView = CreateModeFlowView | DisplayModeFlowView + type AdvanceCreateFlowResult = | { readonly _tag: "Continue"; readonly view: CreateFlowView } | { readonly _tag: "Error"; readonly error: ParseError } @@ -82,6 +93,42 @@ export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply" const firstCreateSettingsStepIndex = 1 +/** + * Narrows a Create flow snapshot to the unresolved step scale. + * + * @pure true + * @effect none + * @invariant true iff view.mode = "create" + * @precondition view is a CreateFlowView snapshot + * @postcondition result=true narrows the view to the unresolved Create step scale + * @complexity O(1) + */ +export const isCreateModeFlowView = (view: CreateFlowView): view is CreateModeFlowView => view.mode === "create" + +/** + * Narrows a Create flow snapshot to the browser display-settings step scale. + * + * @pure true + * @effect none + * @invariant true iff view.mode = "display" + * @precondition view is a CreateFlowView snapshot + * @postcondition result=true narrows the view to the display-settings step scale + * @complexity O(1) + */ +export const isDisplayModeFlowView = (view: CreateFlowView): view is DisplayModeFlowView => view.mode === "display" + +/** + * Detects the web Create repo URL entry state. + * + * @pure true + * @effect none + * @invariant result=true -> view.mode = "create" ∧ view.step = 0 + * @precondition view is a CreateFlowView snapshot + * @postcondition display settings rows are never reported as repo-step submissions + * @complexity O(1) + */ +export const isCreateFlowRepoStep = (view: CreateFlowView): boolean => isCreateModeFlowView(view) && view.step === 0 + const trimLeftSlash = (value: string): string => { let start = 0 while (start < value.length && value[start] === "/") { @@ -576,13 +623,32 @@ const applyCreateBufferToValues = ( return Either.isLeft(updated) ? Either.left(updated.left) : Either.right(nextValues) } -export const createInitialFlowView = (buffer = ""): CreateFlowView => ({ +export const createInitialFlowView = (buffer = ""): CreateModeFlowView => ({ + mode: "create", step: 0, buffer, inputError: null, values: {} }) +/** + * Converts a parsed repo Create snapshot into browser display-settings mode. + * + * @pure true + * @effect none + * @invariant result.mode = "display" + * @precondition view contains already-applied repo values + * @postcondition result.step is clamped to a valid display settings row + * @complexity O(1) + */ +export const createDisplayFlowView = (view: CreateFlowView): DisplayModeFlowView => ({ + mode: "display", + step: clampCreateSettingsStep(view.step, resolveCreateDisplaySteps().length - 1), + buffer: view.buffer, + inputError: null, + values: view.values +}) + const shouldQuickCreate = ( step: CreateStep, options: AdvanceCreateFlowOptions @@ -596,6 +662,7 @@ const continueCreateFlow = ( ): AdvanceCreateFlowResult => ({ _tag: "Continue", view: { + mode: "create", step: nextStep, buffer: "", inputError: null, @@ -603,6 +670,19 @@ const continueCreateFlow = ( } }) +const continueCreateDisplayFlow = ( + view: DisplayModeFlowView, + nextValues: Partial> +): AdvanceCreateFlowResult => ({ + _tag: "Continue", + view: { + ...view, + buffer: "", + inputError: null, + values: nextValues + } +}) + const clampCreateSettingsStep = ( step: number, lastStep: number @@ -619,11 +699,21 @@ const nextCreateSettingsStep = ( Match.exhaustive ) -const moveCreateSettingsWithin = ( +function moveCreateSettingsWithin( + view: CreateModeFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): CreateModeFlowView | null +function moveCreateSettingsWithin( + view: DisplayModeFlowView, + lastStep: number, + direction: CreateSettingsNavigationDirection +): DisplayModeFlowView | null +function moveCreateSettingsWithin( view: CreateFlowView, lastStep: number, direction: CreateSettingsNavigationDirection -): CreateFlowView | null => { +): CreateFlowView | null { if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) { return null } @@ -666,7 +756,7 @@ const gpuChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => * @complexity O(1) */ export const resolveCreateSettingsChoiceBuffer = ( - view: CreateFlowView, + view: DisplayModeFlowView, direction: CreateSettingsChoiceDirection ): string | null => { const step = resolveCreateDisplaySteps()[view.step] @@ -701,9 +791,10 @@ export const resolveCreateSettingsChoiceBuffer = ( * @complexity O(n) where n is the number of unresolved Create steps */ export const moveCreateSettingsStep = ( - view: CreateFlowView, + view: CreateModeFlowView, direction: CreateSettingsNavigationDirection -): CreateFlowView | null => moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) +): CreateModeFlowView | null => + moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) /** * Moves the selected browser Create settings row over the full display list. @@ -718,11 +809,11 @@ export const moveCreateSettingsStep = ( * @complexity O(1) */ export const moveCreateDisplaySettingsStep = ( - view: CreateFlowView, + view: DisplayModeFlowView, direction: CreateSettingsNavigationDirection -): CreateFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) +): DisplayModeFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) -const resolveActiveCreateDisplayStep = (view: CreateFlowView): CreateStep | null => { +const resolveActiveCreateDisplayStep = (view: DisplayModeFlowView): CreateStep | null => { const step = resolveCreateDisplaySteps()[view.step] return view.step < firstCreateSettingsStepIndex || step === undefined ? null : step } @@ -734,7 +825,7 @@ type ActiveCreateDisplayContext = { const resolveActiveCreateDisplayContext = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView + view: DisplayModeFlowView ): ActiveCreateDisplayContext | null => { const step = resolveActiveCreateDisplayStep(view) return step === null @@ -766,7 +857,7 @@ const foldAppliedCreateValues = ( const withActiveCreateDisplayContext = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView, + view: DisplayModeFlowView, onActive: (active: ActiveCreateDisplayContext) => AdvanceCreateFlowResult | null ): AdvanceCreateFlowResult | null => { const active = resolveActiveCreateDisplayContext(contextOrCwd, view) @@ -786,12 +877,12 @@ const withActiveCreateDisplayContext = ( */ export const applyCreateDisplaySettingsStep = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView + view: DisplayModeFlowView ): AdvanceCreateFlowResult | null => withActiveCreateDisplayContext(contextOrCwd, view, (active) => foldAppliedCreateValues( applyCreateBufferToValues(active.context, view, active.step), - (nextValues) => continueCreateFlow(view.step, nextValues) + (nextValues) => continueCreateDisplayFlow(view, nextValues) )) /** @@ -807,7 +898,7 @@ export const applyCreateDisplaySettingsStep = ( */ export const completeCreateDisplaySettingsFlow = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView + view: DisplayModeFlowView ): AdvanceCreateFlowResult | null => withActiveCreateDisplayContext(contextOrCwd, view, (active) => { if (view.buffer.trim().length === 0) { @@ -835,7 +926,7 @@ const resolveNextCreateFlowStep = ( export const advanceCreateFlow = ( contextOrCwd: string | CreateFlowContext, - view: CreateFlowView, + view: CreateModeFlowView, options: AdvanceCreateFlowOptions = {} ): AdvanceCreateFlowResult | null => { const context = normalizeCreateFlowContext(contextOrCwd) diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index 08f04f86..8de64caf 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -157,7 +157,9 @@ const handleCreateReturn = ( }) }, onContinue: (view) => { - context.setView({ _tag: "Create", ...view }) + if (view.mode === "create") { + context.setView({ _tag: "Create", ...view }) + } context.setMessage(null) }, onError: (error) => { diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 8fcfe7b4..7936cd15 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -144,6 +144,7 @@ export type ViewState = | { readonly _tag: "Menu" } | { readonly _tag: "Create" + readonly mode: "create" readonly step: number readonly buffer: string readonly inputError: string | null diff --git a/packages/app/src/docker-git/program-auth.ts b/packages/app/src/docker-git/program-auth.ts index 8f8cc4f8..1892bcf1 100644 --- a/packages/app/src/docker-git/program-auth.ts +++ b/packages/app/src/docker-git/program-auth.ts @@ -47,8 +47,8 @@ export type RoutedAuthCommand = Extract< } > -const withControllerReady = ( - effect: Effect.Effect +const withControllerReady = ( + effect: Effect.Effect ): Effect.Effect => pipe(ensureControllerReady(), Effect.zipRight(effect)) const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload)) @@ -109,6 +109,17 @@ const handleCodexLoginCommand = ( command: Extract ) => withControllerReady(codexLogin(command)) +/** + * Attaches the Grok OAuth terminal session created by the controller. + * + * @pure false + * @effect terminal websocket attachment through `attachTerminalSession` + * @invariant null controller sessions fail with a typed ApiRequestError + * @precondition controller response has already been decoded as ApiTerminalSession | null + * @postcondition non-null sessions are attached through the auth terminal websocket path + * @complexity O(1) before terminal IO + * @throws Never; errors are represented in the Effect error channel as CliError + */ const attachGrokAuthTerminalSession = ( session: ApiTerminalSession | null ): Effect.Effect => diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 4b5c0efb..cbca0ae3 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -19,7 +19,13 @@ import { resolveCurrentMenu, runAuthActionByIndex, runProjectAuthActionByIndex } import { useProjectBrowserReset, useTerminalBrowserAutoload } from "./app-ready-browser-hook.js" import { useBrowserShortcuts } from "./app-ready-browser-shortcuts-hook.js" import { createReadyActionContext } from "./app-ready-controller-context.js" -import { cancelCreate, setCreateBuffer, submitCreateView, useCreateMenuReset } from "./app-ready-create.js" +import { + cancelCreate, + type CreateSubmitMode, + setCreateBuffer, + submitCreateView, + useCreateMenuReset +} from "./app-ready-create.js" import { bindDatabaseActions } from "./app-ready-database-actions.js" import { useProjectDatabasesReset } from "./app-ready-databases-hook.js" import { useGithubAuthGate } from "./app-ready-github-auth-gate-hook.js" @@ -198,13 +204,13 @@ const bindCreateActions = ( onCreateCancel: () => { cancelCreate(actionContext, state.setCreateView) }, - onCreateSubmit: (quickCreate?: boolean) => { + onCreateSubmit: (mode: CreateSubmitMode) => { submitCreateView({ context: actionContext, controllerCwd: dashboard.health.cwd, projectsRoot: dashboard.health.projectsRoot, createView: state.createView, - quickCreate, + mode, setCreateView: state.setCreateView }) } diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index 32b8e373..a57bb8fd 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -6,9 +6,13 @@ import { advanceCreateFlow, applyCreateDisplaySettingsStep, completeCreateDisplaySettingsFlow, + createDisplayFlowView, type CreateFlowView, createInitialFlowView, + type DisplayModeFlowView, handleAdvanceCreateFlowResult, + isCreateFlowRepoStep, + isDisplayModeFlowView, moveCreateDisplaySettingsStep, resolveCreateSettingsChoiceBuffer } from "../docker-git/menu-create-shared.js" @@ -22,6 +26,8 @@ type Setter = Dispatch> const emptyRepoUrlInputError = "Insert URL first" +export type CreateSubmitMode = "advance" | "quick-create" | "complete-settings" + type CreateKeyArgs = { readonly context: BrowserActionContext readonly controllerCwd: string @@ -31,7 +37,7 @@ type CreateKeyArgs = { } type CreateSubmitArgs = CreateKeyArgs & { - readonly quickCreate?: boolean | undefined + readonly mode: CreateSubmitMode } type CreateKeyboardEvent = { @@ -65,16 +71,15 @@ export const setCreateBuffer = ( const resolveCreateSubmitResult = ( createContext: { readonly cwd: string; readonly projectsRoot: string }, createView: CreateFlowView, - quickCreate: boolean | undefined + mode: CreateSubmitMode ): ReturnType => { - if (createView.step > 0) { - return quickCreate === undefined + if (isDisplayModeFlowView(createView)) { + return mode === "advance" ? applyCreateDisplaySettingsStep(createContext, createView) : completeCreateDisplaySettingsFlow(createContext, createView) } - return quickCreate === undefined - ? advanceCreateFlow(createContext, createView) - : advanceCreateFlow(createContext, createView, { quickCreate }) + const next = advanceCreateFlow(createContext, createView, { quickCreate: mode === "quick-create" }) + return next?._tag === "Continue" ? { ...next, view: createDisplayFlowView(next.view) } : next } export const submitCreateView = ( @@ -82,12 +87,12 @@ export const submitCreateView = ( context, controllerCwd, createView, + mode, projectsRoot, - quickCreate, setCreateView }: CreateSubmitArgs ): void => { - if (createView.step === 0 && createView.buffer.trim().length === 0) { + if (isCreateFlowRepoStep(createView) && createView.buffer.trim().length === 0) { setCreateView({ ...createView, inputError: emptyRepoUrlInputError }) return } @@ -97,7 +102,7 @@ export const submitCreateView = ( } const createContext = { cwd: controllerCwd, projectsRoot } - const next = resolveCreateSubmitResult(createContext, createView, quickCreate) + const next = resolveCreateSubmitResult(createContext, createView, mode) handleAdvanceCreateFlowResult(next, { onError: (error) => { context.setMessage(formatParseError(error)) @@ -126,7 +131,7 @@ export const useCreateMenuReset = ( const handleCreateVerticalArrow = ( event: CreateKeyboardEvent, - createView: CreateFlowView, + createView: DisplayModeFlowView, setCreateView: Setter, context: BrowserActionContext ): boolean => { @@ -142,7 +147,7 @@ const handleCreateVerticalArrow = ( const handleCreateHorizontalArrow = ( event: CreateKeyboardEvent, - createView: CreateFlowView, + createView: DisplayModeFlowView, setCreateView: Setter, context: BrowserActionContext ): boolean => { @@ -169,31 +174,35 @@ const submitCreateFromKeyboard = ( controllerCwd, projectsRoot, createView, - quickCreate: createView.step > 0 ? undefined : event.shiftKey, + mode: event.shiftKey && isCreateFlowRepoStep(createView) ? "quick-create" : "advance", setCreateView }) } -export const handleCreateKey = ( +const handleCreateArrowKey = ( event: CreateKeyboardEvent, - { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs -): boolean => { - if (event.key === "Escape") { - event.preventDefault() - cancelCreate(context, setCreateView) - return true - } + createView: CreateFlowView, + setCreateView: Setter, + context: BrowserActionContext +): boolean | null => { if (event.key === "ArrowUp" || event.key === "ArrowDown") { - return handleCreateVerticalArrow(event, createView, setCreateView, context) + return isDisplayModeFlowView(createView) + ? handleCreateVerticalArrow(event, createView, setCreateView, context) + : false } if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - return handleCreateHorizontalArrow(event, createView, setCreateView, context) - } - if (event.key === "Enter") { - submitCreateFromKeyboard(event, { context, controllerCwd, createView, projectsRoot, setCreateView }) - return true + return isDisplayModeFlowView(createView) + ? handleCreateHorizontalArrow(event, createView, setCreateView, context) + : false } + return null +} +const handleCreateTextKey = ( + event: CreateKeyboardEvent, + createView: CreateFlowView, + setCreateView: Setter +): boolean => { const nextBuffer = nextBufferValue( createCharacterInput(event), { backspace: event.key === "Backspace", delete: event.key === "Delete" }, @@ -206,3 +215,23 @@ export const handleCreateKey = ( setCreateBuffer(createView, setCreateView, nextBuffer) return true } + +export const handleCreateKey = ( + event: CreateKeyboardEvent, + { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs +): boolean => { + if (event.key === "Escape") { + event.preventDefault() + cancelCreate(context, setCreateView) + return true + } + const arrowHandled = handleCreateArrowKey(event, createView, setCreateView, context) + if (arrowHandled !== null) { + return arrowHandled + } + if (event.key === "Enter") { + submitCreateFromKeyboard(event, { context, controllerCwd, createView, projectsRoot, setCreateView }) + return true + } + return handleCreateTextKey(event, createView, setCreateView) +} diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 37af1afe..4611bc86 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -20,6 +20,7 @@ import type { ProjectSkillScope, ProjectSkillsSnapshot } from "./api.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { MainPanels } from "./app-ready-main-panels.js" import { Box, Text } from "./elements.js" import type { BrowserMenuTag } from "./menu.js" @@ -63,7 +64,7 @@ export type ReadyLayoutProps = { readonly onBackScreen: () => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onCloseProjectPortForward: (targetPort: number) => void readonly onDatabaseConnectionInputChange: (value: string) => void readonly onDatabaseLabelInputChange: (value: string) => void diff --git a/packages/app/src/web/app-ready.tsx b/packages/app/src/web/app-ready.tsx index e5a9f301..14697262 100644 --- a/packages/app/src/web/app-ready.tsx +++ b/packages/app/src/web/app-ready.tsx @@ -2,6 +2,7 @@ import { type JSX } from "react" import type { DashboardData } from "./api.js" import { useReadyController } from "./app-ready-controller.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { ReadyLayout } from "./app-ready-layout.js" import type { ViewportLayout } from "./viewport-layout.js" @@ -29,7 +30,7 @@ type ReadyLayoutRenderArgs = { readonly onBackScreen: () => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onDatabaseConnectionInputChange: (value: string) => void readonly onDatabaseLabelInputChange: (value: string) => void readonly onCloseDatabaseForward: ReturnType["onCloseDatabaseForward"] diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index 52d27b8c..0d0ddebd 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -4,6 +4,7 @@ import type { JSX } from "react" import type { CreateFlowView } from "../docker-git/menu-create-shared.js" import type { ActionPromptState } from "./action-prompt.js" import type { AuthSnapshot, GithubAuthStatus, ProjectAuthSnapshot, ProjectDetails, ProjectSummary } from "./api.js" +import type { CreateSubmitMode } from "./app-ready-create.js" import { Box, Text } from "./elements.js" import type { BrowserMenuTag } from "./menu.js" import { AuthPanel } from "./panel-auth.js" @@ -32,7 +33,7 @@ type ContentPanelProps = { ) => void readonly onCreateBufferChange: (buffer: string) => void readonly onCreateCancel: () => void - readonly onCreateSubmit: (quickCreate?: boolean) => void + readonly onCreateSubmit: (mode: CreateSubmitMode) => void readonly onKillProjectTerminalSession: (projectId: string, projectKey: string, sessionId: string) => void readonly onOpenProjectTerminalById: (projectId: string, projectKey?: string) => void readonly onRunAuthAction: (index: number) => void diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 41d3a7bd..4a01a293 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -5,15 +5,18 @@ import { type CreateFlowView, type CreateSettingsChoiceDirection, createSettingsHint, + isCreateFlowRepoStep, + isDisplayModeFlowView, renderCreateStepLabel, renderCreateStepLabelWithBufferPreview, resolveCreateDisplaySteps, - resolveCreateSettingsChoiceBuffer, - resolveCreateInputs + resolveCreateInputs, + resolveCreateSettingsChoiceBuffer } from "../docker-git/menu-create-shared.js" import type { CreateStep } from "../docker-git/menu-types.js" import { Box, Button, Text, TextInput } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" +import type { CreateSubmitMode } from "./app-ready-create.js" const renderStepColor = (active: boolean): string => active ? "#56f39a" : "#8fa6c4" @@ -49,7 +52,7 @@ const CreatePromptInput = ( readonly onArrowRight?: () => void readonly onBufferChange: (buffer: string) => void readonly onCancel: () => void - readonly onSubmit: (quickCreate?: boolean) => void + readonly onSubmit: (mode: CreateSubmitMode) => void readonly promptLabel: string } ): JSX.Element => ( @@ -63,7 +66,7 @@ const CreatePromptInput = ( {...(onArrowLeft === undefined ? {} : { onArrowLeft })} {...(onArrowRight === undefined ? {} : { onArrowRight })} onEnter={(shift) => { - onSubmit(isRepoStep ? shift : undefined) + onSubmit(isRepoStep && shift ? "quick-create" : "advance") }} onEscape={onCancel} placeholder={isRepoStep ? "https://github.com/org/repo/tree/branch --force --mcp-playwright" : promptLabel} @@ -91,20 +94,26 @@ export const CreatePanel = ( readonly projectsRoot: string readonly onBufferChange: (buffer: string) => void readonly onCancel: () => void - readonly onSubmit: (quickCreate?: boolean) => void + readonly onSubmit: (mode: CreateSubmitMode) => void } ): JSX.Element => { const prompt = createPrompt({ cwd: controllerCwd, projectsRoot }, createView) const steps = resolveCreateDisplaySteps() - const activeStep = steps[createView.step] ?? "repoUrl" - const isRepoStep = activeStep === "repoUrl" + const activeStep = isDisplayModeFlowView(createView) ? steps[createView.step] ?? "repoUrl" : "repoUrl" + const isRepoStep = isCreateFlowRepoStep(createView) const visibleSteps = compact && isRepoStep ? [activeStep] : steps - const leftChoiceBuffer = resolveCreateSettingsChoiceBuffer(createView, "left") - const rightChoiceBuffer = resolveCreateSettingsChoiceBuffer(createView, "right") + const leftChoiceBuffer = isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "left") + : null + const rightChoiceBuffer = isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "right") + : null const chooseSettingsBuffer = (direction: CreateSettingsChoiceDirection): void => { - const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) - if (nextBuffer !== null) { - onBufferChange(nextBuffer) + if (isDisplayModeFlowView(createView)) { + const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) + if (nextBuffer !== null) { + onBufferChange(nextBuffer) + } } } @@ -124,14 +133,18 @@ export const CreatePanel = ( isRepoStep={isRepoStep} {...(leftChoiceBuffer === null ? {} - : { onArrowLeft: () => { - chooseSettingsBuffer("left") - } })} + : { + onArrowLeft: () => { + chooseSettingsBuffer("left") + } + })} {...(rightChoiceBuffer === null ? {} - : { onArrowRight: () => { - chooseSettingsBuffer("right") - } })} + : { + onArrowRight: () => { + chooseSettingsBuffer("right") + } + })} onBufferChange={onBufferChange} onCancel={onCancel} onSubmit={onSubmit} @@ -144,13 +157,13 @@ export const CreatePanel = (