Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
553 changes: 504 additions & 49 deletions packages/app/src/docker-git/menu-create-shared.ts

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion packages/app/src/docker-git/menu-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
advanceCreateFlow,
createInitialFlowView,
handleAdvanceCreateFlowResult,
moveCreateSettingsStep,
resolveCreateInputs
} from "./menu-create-shared.js"
import { resetToMenu } from "./menu-shared.js"
Expand Down Expand Up @@ -156,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) => {
Expand All @@ -178,6 +181,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
Expand All @@ -190,6 +195,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
Expand Down
14 changes: 7 additions & 7 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -132,16 +130,18 @@ export const renderCreate = (input: CreateRenderInput): React.ReactElement => {
)
return renderLayout(
"docker-git / Create",
[
compactElements([
el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews),
el(
Box,
{ marginTop: 1 },
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
)
}
Expand Down
9 changes: 8 additions & 1 deletion packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,14 @@ export interface ProjectAuthSnapshot {

export type ViewState =
| { readonly _tag: "Menu" }
| { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial<CreateInputs> }
| {
readonly _tag: "Create"
readonly mode: "create"
readonly step: number
readonly buffer: string
readonly inputError: string | null
readonly values: Partial<CreateInputs>
}
| { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot }
| {
readonly _tag: "AuthPrompt"
Expand Down
49 changes: 30 additions & 19 deletions packages/app/src/docker-git/program-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import {
} from "./api-client.js"
import { type ControllerRuntime, ensureControllerReady } from "./controller.js"
import type { Command } from "./frontend-lib/core/domain.js"
import type { ApiRequestError, CliError, ControllerBootstrapError } from "./host-errors.js"
import type { ApiRequestError, CliError } from "./host-errors.js"
import { terminalAuthTitle } from "./menu-auth-shared.js"
import { attachTerminalSession, type TerminalSessionClientError } from "./terminal-session-client.js"
import { attachTerminalSession } from "./terminal-session-client.js"

type OperationalCommand = Exclude<Command, { readonly _tag: "Help" }>
type RoutedAuthEffect = Effect.Effect<void, CliError, ControllerRuntime>

export type RoutedAuthCommand = Extract<
OperationalCommand,
Expand All @@ -46,10 +47,9 @@ export type RoutedAuthCommand = Extract<
}
>

const withControllerReady = <E extends CliError, R>(
effect: Effect.Effect<void, E, R>
): Effect.Effect<void, E | ControllerBootstrapError, R | ControllerRuntime> =>
pipe(ensureControllerReady(), Effect.zipRight(effect))
const withControllerReady = <R>(
effect: Effect.Effect<void, CliError, R>
): Effect.Effect<void, CliError, ControllerRuntime | R> => pipe(ensureControllerReady(), Effect.zipRight(effect))

const renderAuthPayload = (payload: JsonValue) => Effect.log(renderJsonPayload(payload))

Expand All @@ -60,17 +60,6 @@ const missingAuthTerminalSessionError = (provider: "GrokOauth"): ApiRequestError
message: `Controller did not create a terminal session for ${provider}.`
})

const attachGrokTerminalSession = (
session: ApiTerminalSession | null
): Effect.Effect<void, ApiRequestError | TerminalSessionClientError> =>
session === null
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
: attachTerminalSession({
header: terminalAuthTitle("GrokOauth"),
session,
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
})

const routedAuthTags: Readonly<Record<string, true>> = {
AuthCodexImport: true,
AuthCodexLogin: true,
Expand Down Expand Up @@ -120,12 +109,34 @@ const handleCodexLoginCommand = (
command: Extract<OperationalCommand, { readonly _tag: "AuthCodexLogin" }>
) => 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<void, CliError> =>
session === null
? Effect.fail(missingAuthTerminalSessionError("GrokOauth"))
: attachTerminalSession({
header: terminalAuthTitle("GrokOauth"),
session,
websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws`
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const handleGrokLoginCommand = (
command: Extract<OperationalCommand, { readonly _tag: "AuthGrokLogin" }>
) =>
withControllerReady(
createAuthTerminalSession("GrokOauth", command.label).pipe(
Effect.flatMap((session) => attachGrokTerminalSession(session))
Effect.flatMap((session) => attachGrokAuthTerminalSession(session))
)
)

Expand Down Expand Up @@ -157,7 +168,7 @@ const handleCodexLogoutCommand = (

export const dispatchRoutedAuthCommand = (
command: RoutedAuthCommand
): Effect.Effect<void, CliError, ControllerRuntime> =>
): RoutedAuthEffect =>
Match.value(command).pipe(
Match.when({ _tag: "AuthGithubLogin" }, handleGithubLoginCommand),
Match.when({ _tag: "AuthGithubStatus" }, handleGithubStatusCommand),
Expand Down
97 changes: 81 additions & 16 deletions packages/app/src/ui/primitives-web.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -113,8 +113,67 @@ export const webPrimitives = {
props.multiline === true ? <MultilineTextInput {...props} /> : <SingleLineTextInput {...props} />
} as const

const horizontalArrowAction = (
key: string,
onArrowLeft: (() => void) | undefined,
onArrowRight: (() => void) | undefined
): (() => void) | null => {
if (key === "ArrowLeft") {
return onArrowLeft ?? null
}
if (key === "ArrowRight") {
return onArrowRight ?? null
}
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<HTMLInputElement | HTMLTextAreaElement>
): void => {
event.preventDefault()
event.stopPropagation()
}

const handleMultilineTextInputKeyDown =
({ onArrowLeft, onArrowRight, onEnter, onEscape }: TextInputKeyboardHandlers) =>
(event: KeyboardEvent<HTMLTextAreaElement>): 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, autoFocus, minRows, onChange, onEnter, onEscape, placeholder, value }: UiTextInputProps
{
ariaLabel,
autoFocus,
minRows,
onArrowLeft,
onArrowRight,
onChange,
onEnter,
onEscape,
placeholder,
value
}: UiTextInputProps
): JSX.Element => {
const rows = minRows ?? 6
return (
Expand All @@ -124,19 +183,7 @@ const MultilineTextInput = (
onChange={(event) => {
onChange(event.currentTarget.value)
}}
onKeyDown={(event) => {
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={{
Expand All @@ -152,7 +199,18 @@ const MultilineTextInput = (
}

const SingleLineTextInput = (
{ ariaLabel, autoFocus, onChange, onEnter, onEscape, placeholder, secret, value }: UiTextInputProps
{
ariaLabel,
autoFocus,
onArrowLeft,
onArrowRight,
onChange,
onEnter,
onEscape,
placeholder,
secret,
value
}: UiTextInputProps
): JSX.Element => (
<input
aria-label={ariaLabel}
Expand All @@ -161,6 +219,13 @@ const SingleLineTextInput = (
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.preventDefault()
event.stopPropagation()
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/ui/primitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type UiTextInputProps = {
readonly minRows?: number
readonly multiline?: boolean
readonly onChange: (value: string) => void
readonly onArrowLeft?: () => void
readonly onArrowRight?: () => void
readonly onEnter?: (shift: boolean) => void
readonly onEscape?: () => void
readonly placeholder?: string
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/web/actions-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/web/app-ready-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -198,13 +204,13 @@ const bindCreateActions = (
onCreateCancel: () => {
cancelCreate(actionContext, state.setCreateView)
},
onCreateSubmit: (quickCreate = false) => {
onCreateSubmit: (mode: CreateSubmitMode) => {
submitCreateView({
context: actionContext,
controllerCwd: dashboard.health.cwd,
projectsRoot: dashboard.health.projectsRoot,
createView: state.createView,
quickCreate,
mode,
setCreateView: state.setCreateView
})
}
Expand Down
Loading
Loading