Skip to content

Commit 5512c96

Browse files
committed
fix(web): keep create settings visible
1 parent b83b4d5 commit 5512c96

9 files changed

Lines changed: 957 additions & 33 deletions

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

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ type AdvanceCreateFlowOptions = {
5555
*/
5656
export type CreateSettingsNavigationDirection = "up" | "down"
5757

58+
/**
59+
* Horizontal choice direction over finite Create settings with discrete values.
60+
*
61+
* @pure true
62+
* @effect none
63+
* @invariant value ∈ {"left", "right"}
64+
* @precondition n/a
65+
* @postcondition direction maps only to an input-buffer token, never to applied Create values
66+
* @complexity O(1)
67+
*/
68+
export type CreateSettingsChoiceDirection = "left" | "right"
69+
5870
/**
5971
* User-facing key guide shown only after Create leaves the repo URL step.
6072
*
@@ -117,6 +129,81 @@ export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs):
117129
Match.exhaustive
118130
)
119131

132+
const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N"
133+
134+
const parseExplicitBooleanChoice = (input: string): boolean | null => {
135+
const normalized = input.trim().toLowerCase()
136+
if (normalized === "y" || normalized === "yes") {
137+
return true
138+
}
139+
if (normalized === "n" || normalized === "no") {
140+
return false
141+
}
142+
return null
143+
}
144+
145+
const parseExplicitGpuChoice = (
146+
input: string
147+
): GpuMode | null => {
148+
const normalized = input.trim().toLowerCase()
149+
if (normalized === "y" || normalized === "yes") {
150+
return "all"
151+
}
152+
if (normalized === "n" || normalized === "no") {
153+
return "none"
154+
}
155+
if (isGpuMode(normalized)) {
156+
return normalized
157+
}
158+
return null
159+
}
160+
161+
/**
162+
* Renders the active Create settings label with an unapplied input-buffer preview.
163+
*
164+
* @pure true
165+
* @effect none
166+
* @invariant invalid or empty preview buffers preserve the committed/default label
167+
* @precondition defaults are resolved Create inputs
168+
* @postcondition Create values are not mutated or applied by rendering
169+
* @complexity O(1)
170+
*/
171+
export const renderCreateStepLabelWithBufferPreview = (
172+
step: CreateStep,
173+
defaults: CreateInputs,
174+
buffer: string
175+
): string =>
176+
Match.value(step).pipe(
177+
Match.when("repoUrl", () => renderCreateStepLabel(step, defaults)),
178+
Match.when("repoRef", () => renderCreateStepLabel(step, defaults)),
179+
Match.when("outDir", () => renderCreateStepLabel(step, defaults)),
180+
Match.when("cpuLimit", () => renderCreateStepLabel(step, defaults)),
181+
Match.when("ramLimit", () => renderCreateStepLabel(step, defaults)),
182+
Match.when("gpu", () => {
183+
const gpu = parseExplicitGpuChoice(buffer)
184+
return gpu === null ? renderCreateStepLabel(step, defaults) : `GPU access [${gpu}]`
185+
}),
186+
Match.when("runUp", () => {
187+
const runUp = parseExplicitBooleanChoice(buffer)
188+
return runUp === null
189+
? renderCreateStepLabel(step, defaults)
190+
: `Run docker compose up now? [${renderExplicitBooleanChoice(runUp)}]`
191+
}),
192+
Match.when("mcpPlaywright", () => {
193+
const enableMcpPlaywright = parseExplicitBooleanChoice(buffer)
194+
return enableMcpPlaywright === null
195+
? renderCreateStepLabel(step, defaults)
196+
: `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]`
197+
}),
198+
Match.when("force", () => {
199+
const force = parseExplicitBooleanChoice(buffer)
200+
return force === null
201+
? renderCreateStepLabel(step, defaults)
202+
: `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(force)}]`
203+
}),
204+
Match.exhaustive
205+
)
206+
120207
const normalizeCreateFlowContext = (
121208
context: string | CreateFlowContext
122209
): CreateFlowContext =>
@@ -405,6 +492,20 @@ export const resolveCreateFlowSteps = (
405492
.filter((step) => !isCreateStepSatisfied(step, values))
406493
]
407494

495+
/**
496+
* Resolves the stable Create display rows used by browser Settings mode.
497+
*
498+
* @pure true
499+
* @effect none
500+
* @invariant result = createSteps and is independent of applied values
501+
* @precondition n/a
502+
* @postcondition applied settings rows remain present in the result
503+
* @complexity O(1)
504+
*/
505+
export const resolveCreateDisplaySteps = (
506+
_values: Partial<CreateInputs> = {}
507+
): ReadonlyArray<CreateStep> => createSteps
508+
408509
const applyCreateStep = (input: {
409510
readonly step: CreateStep
410511
readonly buffer: string
@@ -504,6 +605,54 @@ const nextCreateSettingsStep = (
504605
Match.exhaustive
505606
)
506607

608+
const booleanChoiceBuffer = (direction: CreateSettingsChoiceDirection): string =>
609+
Match.value(direction).pipe(
610+
Match.when("left", () => "n"),
611+
Match.when("right", () => "y"),
612+
Match.exhaustive
613+
)
614+
615+
const gpuChoiceBuffer = (direction: CreateSettingsChoiceDirection): string =>
616+
Match.value(direction).pipe(
617+
Match.when("left", () => "none"),
618+
Match.when("right", () => "all"),
619+
Match.exhaustive
620+
)
621+
622+
/**
623+
* Resolves a horizontal settings choice to the Create input buffer without applying it.
624+
*
625+
* @pure true
626+
* @effect none
627+
* @invariant result = null for free-text Create rows
628+
* @invariant result != null -> view.values are unchanged by caller-visible semantics
629+
* @precondition view is a CreateFlowView snapshot
630+
* @postcondition result ∈ {"none", "all", "n", "y"} ∪ {null}
631+
* @complexity O(1)
632+
*/
633+
export const resolveCreateSettingsChoiceBuffer = (
634+
view: CreateFlowView,
635+
direction: CreateSettingsChoiceDirection
636+
): string | null => {
637+
const step = resolveCreateDisplaySteps()[view.step]
638+
if (step === undefined) {
639+
return null
640+
}
641+
642+
return Match.value(step).pipe(
643+
Match.when("repoUrl", () => null),
644+
Match.when("repoRef", () => null),
645+
Match.when("outDir", () => null),
646+
Match.when("cpuLimit", () => null),
647+
Match.when("ramLimit", () => null),
648+
Match.when("gpu", () => gpuChoiceBuffer(direction)),
649+
Match.when("runUp", () => booleanChoiceBuffer(direction)),
650+
Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)),
651+
Match.when("force", () => booleanChoiceBuffer(direction)),
652+
Match.exhaustive
653+
)
654+
}
655+
507656
/**
508657
* Moves the selected Create settings row without applying the current buffer.
509658
*
@@ -538,6 +687,122 @@ export const moveCreateSettingsStep = (
538687
}
539688
}
540689

690+
/**
691+
* Moves the selected browser Create settings row over the full display list.
692+
*
693+
* @pure true
694+
* @effect none
695+
* @invariant applied rows do not affect navigation order
696+
* @invariant view.step = 0 -> result = null
697+
* @invariant result != null -> 1 <= result.step < |resolveCreateDisplaySteps()|
698+
* @precondition view is a CreateFlowView snapshot
699+
* @postcondition result values are identical to input values
700+
* @complexity O(1)
701+
*/
702+
export const moveCreateDisplaySettingsStep = (
703+
view: CreateFlowView,
704+
direction: CreateSettingsNavigationDirection
705+
): CreateFlowView | null => {
706+
const steps = resolveCreateDisplaySteps()
707+
const lastStep = steps.length - 1
708+
if (view.step < firstCreateSettingsStepIndex || lastStep < firstCreateSettingsStepIndex) {
709+
return null
710+
}
711+
712+
const currentStep = clampCreateSettingsStep(view.step, lastStep)
713+
const step = nextCreateSettingsStep(currentStep, lastStep, direction)
714+
if (step === view.step) {
715+
return view
716+
}
717+
return {
718+
...view,
719+
step,
720+
buffer: ""
721+
}
722+
}
723+
724+
/**
725+
* Applies one browser Create settings display row without advancing or submitting.
726+
*
727+
* @pure true
728+
* @effect none
729+
* @invariant result._tag = "Continue" -> result.view.step = view.step
730+
* @invariant result._tag = "Continue" -> result.view.buffer = ""
731+
* @precondition view.step points at a settings display row
732+
* @postcondition successful result stores the parsed setting in result.view.values
733+
* @complexity O(1)
734+
*/
735+
export const applyCreateDisplaySettingsStep = (
736+
contextOrCwd: string | CreateFlowContext,
737+
view: CreateFlowView
738+
): AdvanceCreateFlowResult | null => {
739+
const step = resolveCreateDisplaySteps()[view.step]
740+
if (view.step < firstCreateSettingsStepIndex || step === undefined) {
741+
return null
742+
}
743+
744+
const context = normalizeCreateFlowContext(contextOrCwd)
745+
const buffer = view.buffer.trim()
746+
const currentDefaults = resolveCreateInputs(context, view.values)
747+
const nextValues: Partial<Mutable<CreateInputs>> = { ...view.values }
748+
const updated = applyCreateStep({
749+
step,
750+
buffer,
751+
currentDefaults,
752+
nextValues,
753+
context
754+
})
755+
if (Either.isLeft(updated)) {
756+
return {
757+
_tag: "Error",
758+
error: updated.left
759+
}
760+
}
761+
762+
return continueCreateFlow(view.step, nextValues)
763+
}
764+
765+
/**
766+
* Completes browser Create settings by applying a non-empty active buffer first.
767+
*
768+
* @pure true
769+
* @effect none
770+
* @invariant non-empty invalid buffer -> result._tag = "Error"
771+
* @invariant successful result._tag = "Complete"
772+
* @precondition view.step points at a settings display row
773+
* @postcondition submitted inputs include all committed values and defaults
774+
* @complexity O(1)
775+
*/
776+
export const completeCreateDisplaySettingsFlow = (
777+
contextOrCwd: string | CreateFlowContext,
778+
view: CreateFlowView
779+
): AdvanceCreateFlowResult | null => {
780+
const step = resolveCreateDisplaySteps()[view.step]
781+
if (view.step < firstCreateSettingsStepIndex || step === undefined) {
782+
return null
783+
}
784+
785+
const context = normalizeCreateFlowContext(contextOrCwd)
786+
if (view.buffer.trim().length === 0) {
787+
return {
788+
_tag: "Complete",
789+
inputs: resolveCreateInputs(context, view.values)
790+
}
791+
}
792+
793+
const applied = applyCreateDisplaySettingsStep(context, view)
794+
if (applied === null || applied._tag === "Error") {
795+
return applied
796+
}
797+
if (applied._tag === "Continue") {
798+
return {
799+
_tag: "Complete",
800+
inputs: resolveCreateInputs(context, applied.view.values)
801+
}
802+
}
803+
return applied
804+
}
805+
541806
const resolveNextCreateFlowStep = (
542807
currentStep: CreateStep,
543808
currentStepIndex: number,

packages/app/src/ui/primitives-web.tsx

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,33 @@ export const webPrimitives = {
113113
props.multiline === true ? <MultilineTextInput {...props} /> : <SingleLineTextInput {...props} />
114114
} as const
115115

116+
const horizontalArrowAction = (
117+
key: string,
118+
onArrowLeft: (() => void) | undefined,
119+
onArrowRight: (() => void) | undefined
120+
): (() => void) | null => {
121+
if (key === "ArrowLeft") {
122+
return onArrowLeft ?? null
123+
}
124+
if (key === "ArrowRight") {
125+
return onArrowRight ?? null
126+
}
127+
return null
128+
}
129+
116130
const MultilineTextInput = (
117-
{ ariaLabel, autoFocus, minRows, onChange, onEnter, onEscape, placeholder, value }: UiTextInputProps
131+
{
132+
ariaLabel,
133+
autoFocus,
134+
minRows,
135+
onArrowLeft,
136+
onArrowRight,
137+
onChange,
138+
onEnter,
139+
onEscape,
140+
placeholder,
141+
value
142+
}: UiTextInputProps
118143
): JSX.Element => {
119144
const rows = minRows ?? 6
120145
return (
@@ -125,6 +150,13 @@ const MultilineTextInput = (
125150
onChange(event.currentTarget.value)
126151
}}
127152
onKeyDown={(event) => {
153+
const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight)
154+
if (onArrow !== null) {
155+
event.preventDefault()
156+
event.stopPropagation()
157+
onArrow()
158+
return
159+
}
128160
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
129161
event.preventDefault()
130162
event.stopPropagation()
@@ -152,7 +184,18 @@ const MultilineTextInput = (
152184
}
153185

154186
const SingleLineTextInput = (
155-
{ ariaLabel, autoFocus, onChange, onEnter, onEscape, placeholder, secret, value }: UiTextInputProps
187+
{
188+
ariaLabel,
189+
autoFocus,
190+
onArrowLeft,
191+
onArrowRight,
192+
onChange,
193+
onEnter,
194+
onEscape,
195+
placeholder,
196+
secret,
197+
value
198+
}: UiTextInputProps
156199
): JSX.Element => (
157200
<input
158201
aria-label={ariaLabel}
@@ -161,6 +204,13 @@ const SingleLineTextInput = (
161204
onChange(event.currentTarget.value)
162205
}}
163206
onKeyDown={(event) => {
207+
const onArrow = horizontalArrowAction(event.key, onArrowLeft, onArrowRight)
208+
if (onArrow !== null) {
209+
event.preventDefault()
210+
event.stopPropagation()
211+
onArrow()
212+
return
213+
}
164214
if (event.key === "Enter") {
165215
event.preventDefault()
166216
event.stopPropagation()

packages/app/src/ui/primitives.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ export type UiTextInputProps = {
5656
readonly minRows?: number
5757
readonly multiline?: boolean
5858
readonly onChange: (value: string) => void
59+
readonly onArrowLeft?: () => void
60+
readonly onArrowRight?: () => void
5961
readonly onEnter?: (shift: boolean) => void
6062
readonly onEscape?: () => void
6163
readonly placeholder?: string

0 commit comments

Comments
 (0)