Skip to content

Commit f349ee7

Browse files
committed
fix(app): clarify create settings flow
1 parent 1cb62d4 commit f349ee7

11 files changed

Lines changed: 496 additions & 22 deletions

packages/app/src/docker-git/menu-create-shared.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,32 @@ type AdvanceCreateFlowOptions = {
4343
readonly quickCreate?: boolean
4444
}
4545

46+
/**
47+
* Direction over the finite ordered set of unresolved Create settings rows.
48+
*
49+
* @pure true
50+
* @effect none
51+
* @invariant value ∈ {"up", "down"}
52+
* @precondition n/a
53+
* @postcondition navigation direction is total for settings rows
54+
* @complexity O(1)
55+
*/
56+
export type CreateSettingsNavigationDirection = "up" | "down"
57+
58+
/**
59+
* User-facing key guide shown only after Create leaves the repo URL step.
60+
*
61+
* @pure true
62+
* @effect none
63+
* @invariant hint contains the complete settings-mode key contract
64+
* @precondition CreateFlowView.step > 0
65+
* @postcondition no repo-step quick-create guidance is rendered from this value
66+
* @complexity O(1)
67+
*/
68+
export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply"
69+
70+
const firstCreateSettingsStepIndex = 1
71+
4672
const trimLeftSlash = (value: string): string => {
4773
let start = 0
4874
while (start < value.length && value[start] === "/") {
@@ -462,6 +488,65 @@ const continueCreateFlow = (
462488
}
463489
})
464490

491+
const clampCreateSettingsStep = (
492+
step: number,
493+
lastStep: number
494+
): number => Math.min(Math.max(step, firstCreateSettingsStepIndex), lastStep)
495+
496+
const nextCreateSettingsStep = (
497+
step: number,
498+
lastStep: number,
499+
direction: CreateSettingsNavigationDirection
500+
): number =>
501+
Match.value(direction).pipe(
502+
Match.when("up", () => step === firstCreateSettingsStepIndex ? lastStep : step - 1),
503+
Match.when("down", () => step === lastStep ? firstCreateSettingsStepIndex : step + 1),
504+
Match.exhaustive
505+
)
506+
507+
/**
508+
* Moves the selected Create settings row without applying the current buffer.
509+
*
510+
* @pure true
511+
* @effect none
512+
* @invariant view.step = 0 -> result = null
513+
* @invariant result != null -> 1 <= result.step < |resolveCreateFlowSteps(result.values)|
514+
* @invariant result != null && result.step != view.step -> result.buffer = ""
515+
* @precondition view is a CreateFlowView snapshot
516+
* @postcondition result values are identical to the input values
517+
* @complexity O(n) where n is the number of unresolved Create steps
518+
*/
519+
export const moveCreateSettingsStep = (
520+
view: CreateFlowView,
521+
direction: CreateSettingsNavigationDirection
522+
): CreateFlowView | null => {
523+
const steps = resolveCreateFlowSteps(view.values)
524+
const lastStep = steps.length - 1
525+
if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) {
526+
return null
527+
}
528+
529+
const currentStep = clampCreateSettingsStep(view.step, lastStep)
530+
const step = nextCreateSettingsStep(currentStep, lastStep, direction)
531+
if (step === view.step) {
532+
return view
533+
}
534+
return {
535+
...view,
536+
step,
537+
buffer: ""
538+
}
539+
}
540+
541+
const resolveNextCreateFlowStep = (
542+
currentStep: CreateStep,
543+
currentStepIndex: number,
544+
nextSteps: ReadonlyArray<CreateStep>
545+
): number =>
546+
currentStep === "repoUrl"
547+
? firstCreateSettingsStepIndex
548+
: clampCreateSettingsStep(currentStepIndex, nextSteps.length - 1)
549+
465550
export const advanceCreateFlow = (
466551
contextOrCwd: string | CreateFlowContext,
467552
view: CreateFlowView,
@@ -499,8 +584,8 @@ export const advanceCreateFlow = (
499584
}
500585

501586
const nextSteps = resolveCreateFlowSteps(nextValues)
502-
const nextStep = step === "repoUrl" ? 1 : view.step
503-
if (nextStep < nextSteps.length) {
587+
const nextStep = resolveNextCreateFlowStep(step, view.step, nextSteps)
588+
if (nextSteps.length > firstCreateSettingsStepIndex && nextStep < nextSteps.length) {
504589
return continueCreateFlow(nextStep, nextValues)
505590
}
506591

packages/app/src/docker-git/menu-create.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
advanceCreateFlow,
1212
createInitialFlowView,
1313
handleAdvanceCreateFlowResult,
14+
moveCreateSettingsStep,
1415
resolveCreateInputs
1516
} from "./menu-create-shared.js"
1617
import { resetToMenu } from "./menu-shared.js"
@@ -178,6 +179,8 @@ export const handleCreateInput = (
178179
input: string,
179180
key: {
180181
readonly escape?: boolean
182+
readonly upArrow?: boolean
183+
readonly downArrow?: boolean
181184
readonly return?: boolean
182185
readonly shift?: boolean
183186
readonly backspace?: boolean
@@ -190,6 +193,14 @@ export const handleCreateInput = (
190193
resetToMenu(context)
191194
return
192195
}
196+
if (key.upArrow || key.downArrow) {
197+
const nextView = moveCreateSettingsStep(view, key.upArrow ? "up" : "down")
198+
if (nextView !== null) {
199+
context.setView({ _tag: "Create", ...nextView })
200+
context.setMessage(null)
201+
}
202+
return
203+
}
193204
if (key.return) {
194205
handleCreateReturn({ ...context, view }, key.shift === true)
195206
return

packages/app/src/docker-git/menu-render.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from "react"
22

33
import { Box, Text } from "../ui/primitives.js"
4-
import { renderCreateStepLabel } from "./menu-create-shared.js"
4+
import { createSettingsHint, renderCreateStepLabel } from "./menu-create-shared.js"
55
import { renderLayout } from "./menu-render-layout.js"
66
import {
77
buildSelectLabels,
@@ -120,9 +120,7 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => {
120120
export const renderCreate = (input: CreateRenderInput): React.ReactElement => {
121121
const { buffer, defaults, label, message, stepIndex, steps } = input
122122
const el = React.createElement
123-
const hint = stepIndex === 0
124-
? "Enter = next, Shift+Enter = quick create, Esc = cancel."
125-
: "Enter = next, Esc = cancel."
123+
const hint = stepIndex > 0 ? createSettingsHint : null
126124
const stepViews = steps.map((step, index) =>
127125
el(
128126
Text,
@@ -132,16 +130,18 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => {
132130
)
133131
return renderLayout(
134132
"docker-git / Create",
135-
[
133+
compactElements([
136134
el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews),
137135
el(
138136
Box,
139137
{ marginTop: 1 },
140138
el(Text, null, `${label}: `),
141139
el(Text, { fg: "green" }, buffer)
142140
),
143-
el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint))
144-
],
141+
hint === null
142+
? null
143+
: el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint))
144+
]),
145145
message
146146
)
147147
}

packages/app/src/web/actions-projects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ export const runProjectMenuAction = (
448448
context: BrowserActionContext
449449
) => {
450450
if (currentMenu === "Create") {
451-
context.setMessage("Create mode is active. Paste URL or URL + flags, Enter = next, Shift+Enter = quick create.")
451+
context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.")
452452
return
453453
}
454454
if (currentMenu === "Select") {

packages/app/src/web/app-ready-create.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
advanceCreateFlow,
77
type CreateFlowView,
88
createInitialFlowView,
9-
handleAdvanceCreateFlowResult
9+
handleAdvanceCreateFlowResult,
10+
moveCreateSettingsStep
1011
} from "../docker-git/menu-create-shared.js"
1112
import { submitCreateInputs } from "./actions-projects.js"
1213
import { requireGithubAuthConfigured } from "./actions-shared.js"
@@ -109,6 +110,16 @@ export const handleCreateKey = (
109110
cancelCreate(context, setCreateView)
110111
return true
111112
}
113+
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
114+
const nextView = moveCreateSettingsStep(createView, event.key === "ArrowUp" ? "up" : "down")
115+
if (nextView === null) {
116+
return false
117+
}
118+
event.preventDefault()
119+
setCreateView(nextView)
120+
context.setMessage(null)
121+
return true
122+
}
112123
if (event.key === "Enter") {
113124
event.preventDefault()
114125
submitCreateView({

packages/app/src/web/app-ready-url.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,7 @@ const activeTerminalReadyPath = (session: ActiveTerminalSession | null): string
8888
return projectSshRoutePath(session.browserProjectKey, session.session.id)
8989
}
9090

91-
const selectReadyPath = (token: string | null): string =>
92-
token === null ? "/menu/select" : `/select/${encodePathTail(token)}`
91+
const selectReadyPath = (token: string | null): string => token === null ? "/menu/select" : projectSshRoutePath(token)
9392

9493
const menuActionReadyPath = (
9594
activeScreen: BrowserScreen,

packages/app/src/web/panel-create-select.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { JSX } from "react"
33
import {
44
type CreateFlowContext,
55
type CreateFlowView,
6+
createSettingsHint,
67
renderCreateStepLabel,
78
resolveCreateFlowSteps,
89
resolveCreateInputs
@@ -26,11 +27,6 @@ const createPrompt = (
2627
}
2728
}
2829

29-
const createHint = (isRepoStep: boolean): string =>
30-
isRepoStep
31-
? "Enter = next, Shift+Enter = quick create, Esc = cancel."
32-
: "Enter = next, Esc = cancel."
33-
3430
const CreatePromptInput = (
3531
{
3632
createView,
@@ -111,11 +107,17 @@ export const CreatePanel = (
111107
? (
112108
<Box gap={1} marginTop={1}>
113109
<Button
114-
label="Quick create"
110+
label="Quick Create"
115111
onPress={() => {
116112
onSubmit(true)
117113
}}
118114
/>
115+
<Button
116+
label="Settings"
117+
onPress={() => {
118+
onSubmit(false)
119+
}}
120+
/>
119121
</Box>
120122
)
121123
: null}
@@ -161,8 +163,8 @@ const CreateHintBlock = (
161163
<HelpLines
162164
lines={[
163165
...(isRepoStep ? ["Repo URL or URL + CLI flags."] : []),
164-
createHint(isRepoStep),
165-
...(compact ? ["↑/↓ = menu, ←/→ = project"] : []),
166+
...(isRepoStep ? [] : [createSettingsHint]),
167+
...(compact && isRepoStep ? ["↑/↓ = menu, ←/→ = project"] : []),
166168
`Current cwd: ${controllerCwd}`
167169
]}
168170
/>

packages/app/tests/docker-git/app-ready-create.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import type { CreateInputs } from "../../src/docker-git/menu-types.js"
1212
import type { submitCreateInputs } from "../../src/web/actions-projects.js"
1313
import type { GithubAuthStatus } from "../../src/web/api.js"
14-
import { submitCreateView } from "../../src/web/app-ready-create.js"
14+
import { handleCreateKey, submitCreateView } from "../../src/web/app-ready-create.js"
1515
import { makeBrowserActionContext } from "./browser-action-context-fixture.js"
1616

1717
const submitCreateInputsMock = vi.hoisted(() => vi.fn<typeof submitCreateInputs>())
@@ -117,6 +117,18 @@ const expectCreateViewReset = (setCreateViewSpy: ReturnType<typeof submitCreateB
117117
const expectedOutDirForRepoUrl = (repoUrl: string): string =>
118118
`/home/dev/.docker-git/${deriveRepoPathParts(resolveRepoInput(repoUrl).repoUrl).pathParts.join("/")}`
119119

120+
const createKeyEvent = (
121+
key: string,
122+
shiftKey = false
123+
): Parameters<typeof handleCreateKey>[0] => {
124+
const event = {
125+
key,
126+
shiftKey,
127+
preventDefault: vi.fn()
128+
}
129+
return event
130+
}
131+
120132
describe("app-ready-create", () => {
121133
beforeEach(() => {
122134
submitCreateInputsMock.mockReset()
@@ -147,6 +159,55 @@ describe("app-ready-create", () => {
147159
expect(context.setMessage).toHaveBeenCalledWith(null)
148160
})
149161

162+
it("enters the settings wizard when explicitly requested from the repo URL step", () => {
163+
const { setCreateViewSpy } = submitCreateBuffer(
164+
"https://github.com/org/repo/tree/feature-x",
165+
{ quickCreate: false }
166+
)
167+
168+
expect(submitCreateInputsMock).not.toHaveBeenCalled()
169+
expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toMatchObject({
170+
step: 1,
171+
values: {
172+
outDir: "/home/dev/.docker-git/org/repo",
173+
repoRef: "feature-x",
174+
repoUrl: "https://github.com/org/repo/tree/feature-x"
175+
}
176+
})
177+
})
178+
179+
it("moves between settings with arrows and clears the uncommitted buffer", () => {
180+
const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus })
181+
const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy()
182+
const event = createKeyEvent("ArrowDown")
183+
const createView: CreateFlowView = {
184+
step: 1,
185+
buffer: "30%",
186+
values: {
187+
outDir: "/home/dev/.docker-git/org/repo",
188+
repoRef: "feature-x",
189+
repoUrl: "https://github.com/org/repo/tree/feature-x"
190+
}
191+
}
192+
193+
const handled = handleCreateKey(event, {
194+
context,
195+
controllerCwd: "/workspace",
196+
createView,
197+
projectsRoot: "/home/dev/.docker-git",
198+
setCreateView
199+
})
200+
201+
expect(handled).toBe(true)
202+
expect(event.preventDefault).toHaveBeenCalledTimes(1)
203+
expect(requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0])).toEqual({
204+
...createView,
205+
step: 2,
206+
buffer: ""
207+
})
208+
expect(context.setMessage).toHaveBeenCalledWith(null)
209+
})
210+
150211
it("shows a parse error instead of submitting on invalid inline flags", () => {
151212
const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo --bogus")
152213

0 commit comments

Comments
 (0)