diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 7898edaa..55b71a75 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -765,6 +765,174 @@ const writePtyInput = (pty: PtyBridge | null, data: string): void => { const shellQuote = (value: string): string => `'${value.replace(/'/gu, "'\\''")}'` +// CHANGE: Predicate for when tmux should forward right-click pane events. +// WHY: Mouse-aware apps and copy/view mode still need pane mouse events, while tmux menus must stay disabled. +// QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: mouse-aware-or-copy-mode => predicate evaluates truthy in tmux. +// PURITY: CORE +// EFFECT: none +// INVARIANT: The predicate contains only tmux format language and no shell interpolation. +// COMPLEXITY: O(1) time/O(1) space. +/** + * Tmux format predicate used by right-click pane bindings. + * + * @returns A tmux format expression, not a shell command. + * @pure true + * @effect none + * @invariant Expression is constant and contains no user-controlled input. + * @precondition tmux understands mouse_any_flag and pane mode format variables. + * @postcondition The value is safe to embed after shellQuote. + * @complexity O(1) time/O(1) space. + * @throws Never + */ +const tmuxRightClickForwardPredicate = + "#{||:#{mouse_any_flag},#{&&:#{pane_in_mode},#{?#{m/r:(copy|view)-mode,#{pane_mode}},0,1}}}" +// CHANGE: Pane right-click bindings that are overridden at tmux startup. +// WHY: These cover down/drag/up/end and Meta-modified events that previously reached display-menu. +// QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: every binding in the array is mapped to renderTmuxPaneRightClickBinding. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Each entry is a static tmux root-table mouse binding name. +// COMPLEXITY: O(1) time/O(1) space. +/** + * Tmux pane right-click binding names that should conditionally forward mouse events. + * + * @pure true + * @effect none + * @invariant The array contains only static tmux binding identifiers. + * @precondition tmux root key table supports these binding names. + * @postcondition Consumers can map each entry to a shell-safe bind-key command. + * @complexity O(1) time/O(1) space. + * @throws Never + */ +const tmuxRightClickPaneBindings: ReadonlyArray = [ + "MouseDown3Pane", + "MouseDrag3Pane", + "MouseDragEnd3Pane", + "MouseUp3Pane", + "M-MouseDown3Pane", + "M-MouseDrag3Pane", + "M-MouseDragEnd3Pane", + "M-MouseUp3Pane" +] +// CHANGE: Non-pane right-click bindings that are suppressed at tmux startup. +// WHY: Status and border right-clicks are the tmux menu entry points that cannot be forwarded to pane apps. +// QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: every binding in the array is mapped to renderTmuxRightClickSuppressBinding. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Each entry is a static tmux root-table mouse binding name. +// COMPLEXITY: O(1) time/O(1) space. +/** + * Tmux status/border right-click binding names that should be unbound. + * + * @pure true + * @effect none + * @invariant The array contains only static tmux binding identifiers. + * @precondition tmux root key table supports these binding names. + * @postcondition Consumers can map each entry to a shell-safe unbind-key command. + * @complexity O(1) time/O(1) space. + * @throws Never + */ +const tmuxRightClickSuppressBindings: ReadonlyArray = [ + "MouseDown3Status", + "MouseDown3StatusLeft", + "MouseDown3StatusRight", + "MouseDown3Border", + "M-MouseDown3Status", + "M-MouseDown3StatusLeft", + "M-MouseDown3StatusRight", + "M-MouseDown3Border" +] + +// CHANGE: Render one tmux bind-key command for a right-click pane event. +// WHY: Pane events must reach mouse-aware programs without allowing tmux display-menu. +// QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: static binding => shellQuote(protected fragments) in result. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Dynamic shell fragments are emitted through shellQuote. +// COMPLEXITY: O(1) time/O(1) space. +/** + * Builds a tmux root-table command for a pane right-click binding. + * + * @param binding - Static tmux mouse binding name. + * @returns Shell command that binds the event to conditional pane forwarding. + * @pure true + * @effect none + * @invariant Shell-interpreted tmux format/action fragments are quoted. + * @precondition binding is one of tmuxRightClickPaneBindings. + * @postcondition The command exits successfully even when tmux rejects a binding. + * @complexity O(1) time/O(1) space. + * @throws Never + */ +const renderTmuxPaneRightClickBinding = (binding: string): string => + `tmux bind-key -T root ${binding} if-shell -F -t = ${shellQuote(tmuxRightClickForwardPredicate)} ${ + shellQuote("select-pane -t = ; send-keys -M") + } >/dev/null 2>&1 || true` + +// CHANGE: Render one tmux unbind-key command for a suppressed right-click event. +// WHY: Non-pane right-click targets are tmux UI affordances and should not open display-menu. +// QUOTE(TZ): issue #340 right-click must not open the default tmux menu in browser terminals. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: static binding => deterministic unbind command. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Result contains no user-controlled input. +// COMPLEXITY: O(1) time/O(1) space. +/** + * Builds a tmux root-table command that suppresses a non-pane right-click binding. + * + * @param binding - Static tmux mouse binding name. + * @returns Shell command that unbinds the event and tolerates unsupported bindings. + * @pure true + * @effect none + * @invariant The returned command contains only static text plus binding. + * @precondition binding is one of tmuxRightClickSuppressBindings. + * @postcondition The command exits successfully even when the binding is absent. + * @complexity O(1) time/O(1) space. + * @throws Never + */ +const renderTmuxRightClickSuppressBinding = (binding: string): string => + `tmux unbind-key -T root ${binding} >/dev/null 2>&1 || true` + +// CHANGE: Aggregate all tmux right-click startup commands. +// WHY: Terminal session startup needs one ordered command list for pane forwarding and UI suppression. +// QUOTE(TZ): PR #342 preserves right-click copy while tmux mouse tracking is active. +// REF: PR #342 tmux right-click handling. +// SOURCE: n/a +// FORMAT THEOREM: result length = paneBindings length + suppressBindings length. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Pane commands precede suppress commands. +// COMPLEXITY: O(n) time/O(n) space where n is the total binding count. +/** + * Renders the complete tmux right-click binding setup command list. + * + * @returns Readonly array of shell commands for tmux startup. + * @pure true + * @effect none + * @invariant Pane forwarding commands are emitted before suppressing status/border commands. + * @precondition Binding arrays contain static tmux binding identifiers. + * @postcondition The result contains one command per configured binding. + * @complexity O(n) time/O(n) space where n is total binding count. + * @throws Never + */ +const renderTmuxRightClickBindingCommands = (): ReadonlyArray => [ + ...tmuxRightClickPaneBindings.map(renderTmuxPaneRightClickBinding), + ...tmuxRightClickSuppressBindings.map(renderTmuxRightClickSuppressBinding) +] + const writeBufferToProjectContainer = ( containerName: string, containerPath: string, @@ -982,6 +1150,7 @@ export const renderTmuxAttachCommand = ( `tmux set-option -t ${shellQuote(args.tmuxName)} status off >/dev/null 2>&1 || true`, `tmux set-option -t ${shellQuote(args.tmuxName)} history-limit 50000 >/dev/null 2>&1 || true`, `tmux set-option -t ${shellQuote(args.tmuxName)} mouse on >/dev/null 2>&1 || true`, + ...renderTmuxRightClickBindingCommands(), `exec tmux attach-session -t ${shellQuote(args.tmuxName)}` ].join("; ") return `bash --noprofile --norc -lc ${shellQuote(script)}` diff --git a/packages/api/tests/terminal-sessions.test.ts b/packages/api/tests/terminal-sessions.test.ts index 4f507b75..42ad86d2 100644 --- a/packages/api/tests/terminal-sessions.test.ts +++ b/packages/api/tests/terminal-sessions.test.ts @@ -254,6 +254,28 @@ describe("terminal sessions service", () => { expect(command).toContain("status off") expect(command).toContain("history-limit 50000") expect(command).toContain("mouse on") + expect(command).toContain("bind-key -T root MouseDown3Pane") + expect(command).toContain("bind-key -T root MouseDrag3Pane") + expect(command).toContain("bind-key -T root MouseDragEnd3Pane") + expect(command).toContain("bind-key -T root MouseUp3Pane") + expect(command).toContain("bind-key -T root M-MouseDown3Pane") + expect(command).toContain("bind-key -T root M-MouseDrag3Pane") + expect(command).toContain("bind-key -T root M-MouseDragEnd3Pane") + expect(command).toContain("bind-key -T root M-MouseUp3Pane") + expect(command).toContain("#{||:#{mouse_any_flag}") + expect(command).toContain("#{pane_in_mode}") + expect(command).toContain("#{pane_mode}") + expect(command).toContain("select-pane -t = ; send-keys -M") + expect(command).toContain("send-keys -M") + expect(command).toContain("unbind-key -T root MouseDown3Status") + expect(command).toContain("unbind-key -T root MouseDown3StatusLeft") + expect(command).toContain("unbind-key -T root MouseDown3StatusRight") + expect(command).toContain("unbind-key -T root MouseDown3Border") + expect(command).toContain("unbind-key -T root M-MouseDown3Status") + expect(command).toContain("unbind-key -T root M-MouseDown3StatusLeft") + expect(command).toContain("unbind-key -T root M-MouseDown3StatusRight") + expect(command).toContain("unbind-key -T root M-MouseDown3Border") + expect(command).not.toContain("display-menu") expect(command).toContain("tmux attach-session -t") expect(command).toContain("docker-git-session-1") expect(command).toContain("/home/dev/project with spaces") @@ -264,6 +286,7 @@ describe("terminal sessions service", () => { const statusOffIndex = command.indexOf("status off") const sessionHistoryLimitIndex = command.lastIndexOf("history-limit 50000") const mouseOnIndex = command.indexOf("mouse on") + const rightClickBindingIndex = command.indexOf("MouseDown3Pane") const attachSessionIndex = command.indexOf("tmux attach-session -t") expect(startServerIndex).toBeGreaterThanOrEqual(0) @@ -272,13 +295,15 @@ describe("terminal sessions service", () => { expect(statusOffIndex).toBeGreaterThanOrEqual(0) expect(sessionHistoryLimitIndex).toBeGreaterThanOrEqual(0) expect(mouseOnIndex).toBeGreaterThanOrEqual(0) + expect(rightClickBindingIndex).toBeGreaterThan(mouseOnIndex) expect(attachSessionIndex).toBeGreaterThanOrEqual(0) expect(startServerIndex).toBeLessThan(globalHistoryLimitIndex) expect(globalHistoryLimitIndex).toBeLessThan(newSessionIndex) expect(newSessionIndex).toBeLessThan(statusOffIndex) expect(statusOffIndex).toBeLessThan(sessionHistoryLimitIndex) expect(sessionHistoryLimitIndex).toBeLessThan(mouseOnIndex) - expect(mouseOnIndex).toBeLessThan(attachSessionIndex) + expect(mouseOnIndex).toBeLessThan(rightClickBindingIndex) + expect(rightClickBindingIndex).toBeLessThan(attachSessionIndex) }) it("fails before creating a durable session when tmux is unavailable", async () => { diff --git a/packages/app/src/docker-git/menu-create-advance.ts b/packages/app/src/docker-git/menu-create-advance.ts new file mode 100644 index 00000000..0c57d94c --- /dev/null +++ b/packages/app/src/docker-git/menu-create-advance.ts @@ -0,0 +1,352 @@ +import { Either } from "effect" + +import type { ParseError } from "./frontend-lib/core/domain.js" +import { + type AdvanceCreateFlowHandlers, + type AdvanceCreateFlowOptions, + type AdvanceCreateFlowResult, + type CreateFlowContext, + type CreateFlowView, + type CreateModeFlowView, + type DisplayModeFlowView, + firstCreateSettingsStepIndex, + isDisplayModeFlowView, + type Mutable +} from "./menu-create-flow-types.js" +import { normalizeCreateFlowContext, resolveCreateInputs } from "./menu-create-inputs.js" +import { clampCreateSettingsStep, moveCreateDisplaySettingsStep } from "./menu-create-navigation.js" +import { applyCreateBufferToValues } from "./menu-create-step-apply.js" +import { resolveCreateDisplaySteps, resolveCreateFlowSteps } from "./menu-create-steps.js" +import type { CreateInputs, CreateStep } from "./menu-types.js" + +/** + * Creates the initial repo-url prompt view for the create flow. + * + * @pure true + * @invariant step = 0 and values are empty + * @complexity O(1) + */ +// CHANGE: expose a deterministic initial create-flow view constructor +// WHY: CLI and browser callers need the same pure starting state +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall b: initial(b).step = 0 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: initial values contain no committed create inputs +// COMPLEXITY: O(1) +export const createInitialFlowView = (buffer = ""): CreateModeFlowView => ({ + mode: "create", + step: 0, + buffer, + inputError: null, + values: {} +}) + +const resolveDisplayFlowStep = (view: CreateFlowView): number => { + const displaySteps = resolveCreateDisplaySteps() + if (isDisplayModeFlowView(view)) { + return clampCreateSettingsStep(view.step, displaySteps.length - 1) + } + const flowStep = resolveCreateFlowSteps(view.values)[view.step] + const displayStep = flowStep === undefined ? -1 : displaySteps.indexOf(flowStep) + return clampCreateSettingsStep(displayStep === -1 ? view.step : displayStep, displaySteps.length - 1) +} + +/** + * Converts a create-flow view into the browser display-settings projection. + * + * @pure true + * @invariant values are preserved exactly + * @complexity O(s) where s = number of create steps + */ +// CHANGE: map create-mode progress onto browser display settings +// WHY: display mode keeps all rows visible while preserving committed values +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: display(v).values = v.values +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: display step is clamped to the settings range +// COMPLEXITY: O(s) where s = number of create steps +export const createDisplayFlowView = (view: CreateFlowView): DisplayModeFlowView => ({ + mode: "display", + step: resolveDisplayFlowStep(view), + buffer: view.buffer, + inputError: null, + values: view.values +}) + +const shouldQuickCreate = ( + step: CreateStep, + options: AdvanceCreateFlowOptions +): boolean => + step === "repoUrl" && + options.quickCreate === true + +const continueCreateFlow = ( + nextStep: number, + nextValues: Partial> +): AdvanceCreateFlowResult => ({ + _tag: "Continue", + view: { + mode: "create", + step: nextStep, + buffer: "", + inputError: null, + values: nextValues + } +}) + +const continueCreateDisplayFlow = ( + view: DisplayModeFlowView, + nextValues: Partial> +): AdvanceCreateFlowResult => ({ + _tag: "Continue", + view: { + ...view, + buffer: "", + inputError: null, + values: nextValues + } +}) + +type ActiveCreateDisplayContext = { + readonly context: CreateFlowContext + readonly step: CreateStep +} + +const resolveActiveCreateDisplayStep = (view: DisplayModeFlowView): CreateStep | null => { + const step = resolveCreateDisplaySteps()[view.step] + return view.step < firstCreateSettingsStepIndex || step === undefined ? null : step +} + +const resolveActiveCreateDisplayContext = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): 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: DisplayModeFlowView, + onActive: (active: ActiveCreateDisplayContext) => AdvanceCreateFlowResult | null +): AdvanceCreateFlowResult | null => { + const active = resolveActiveCreateDisplayContext(contextOrCwd, view) + return active === null ? null : onActive(active) +} + +/** + * Applies the current browser display-settings row without moving selection. + * + * @pure true + * @invariant display mode remains display mode on Continue + * @complexity O(k) where k = number of stored create inputs + */ +// CHANGE: apply browser display settings through the shared pure step applicator +// WHY: display mode must preserve row position while committing one decoded value +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: active(view) -> result in {Continue, Error} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: inactive rows return null +// COMPLEXITY: O(k) where k = number of stored create inputs +export const applyCreateDisplaySettingsStep = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => + foldAppliedCreateValues( + applyCreateBufferToValues(active.context, view, active.step), + (nextValues) => continueCreateDisplayFlow(view, nextValues) + )) + +/** + * Applies the current browser display-settings row and advances one row. + * + * @pure true + * @invariant successful application clears the buffer + * @complexity O(k + s) where s = number of display steps + */ +// CHANGE: compose display setting application with wrapped display navigation +// WHY: Enter in browser settings should commit the row and move to the next editable row +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: Continue(v) -> step(next(v)) = wrappedSuccessor(v.step) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: errors do not advance selection +// COMPLEXITY: O(k + s) where s = number of display steps +export const advanceCreateDisplaySettingsStep = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): AdvanceCreateFlowResult | null => { + const applied = applyCreateDisplaySettingsStep(contextOrCwd, view) + if (applied === null || applied._tag !== "Continue" || !isDisplayModeFlowView(applied.view)) { + return applied + } + + const movedView = moveCreateDisplaySettingsStep(applied.view, "down") + return movedView === null ? applied : { ...applied, view: movedView } +} + +/** + * Completes browser display settings, applying a non-empty active buffer first. + * + * @pure true + * @invariant completion resolves total CreateInputs + * @complexity O(k) where k = number of stored create inputs + */ +// CHANGE: finish browser settings with optional final-row validation +// WHY: a typed buffer should not be discarded when the user presses Done +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: complete(view) -> Complete(resolve(values')) or Error +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: invalid active buffers return typed parse errors +// COMPLEXITY: O(k) where k = number of stored create inputs +export const completeCreateDisplaySettingsFlow = ( + contextOrCwd: string | CreateFlowContext, + view: DisplayModeFlowView +): AdvanceCreateFlowResult | null => + withActiveCreateDisplayContext(contextOrCwd, view, (active) => { + if (view.buffer.trim().length === 0) { + return completeCreateFlow(active.context, view.values) + } + + const applied = applyCreateDisplaySettingsStep(active.context, view) + if (applied === null || applied._tag === "Error") { + return applied + } + if (applied._tag === "Continue") { + return completeCreateFlow(active.context, applied.view.values) + } + return applied + }) + +const resolveNextCreateFlowStep = ( + currentStep: CreateStep, + currentStepIndex: number +): number => + currentStep === "repoUrl" + ? firstCreateSettingsStepIndex + : currentStepIndex + 1 + +const shouldCompleteCreateFlow = ( + nextSteps: ReadonlyArray, + nextStep: number +): boolean => nextSteps.length <= firstCreateSettingsStepIndex || nextStep >= nextSteps.length + +/** + * Advances create mode by applying the active prompt buffer. + * + * @pure true + * @invariant non-repo steps advance to the next remaining settings index when continuing + * @complexity O(k + s) where s = number of remaining create steps + */ +// CHANGE: advance normal create-flow settings after committing the active prompt +// WHY: applying a non-repo step must move forward instead of reselecting the same index +// QUOTE(ТЗ): "after applying a non-repoUrl step it advances to currentStepIndex + 1" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: remaining = empty or nextStep past end -> Complete, otherwise Continue(next valid setting) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: applying the final settings index completes instead of clamping back to it +// COMPLEXITY: O(k + s) where s = number of remaining create steps +export const advanceCreateFlow = ( + contextOrCwd: string | CreateFlowContext, + view: CreateModeFlowView, + options: AdvanceCreateFlowOptions = {} +): AdvanceCreateFlowResult | null => { + const context = normalizeCreateFlowContext(contextOrCwd) + const currentSteps = resolveCreateFlowSteps(view.values) + const step = currentSteps[view.step] + if (step === undefined) { + return null + } + + return foldAppliedCreateValues( + applyCreateBufferToValues(context, view, step), + (nextValues) => { + if (shouldQuickCreate(step, options)) { + return completeCreateFlow(context, nextValues) + } + + const nextSteps = resolveCreateFlowSteps(nextValues) + const nextStep = resolveNextCreateFlowStep(step, view.step) + if (shouldCompleteCreateFlow(nextSteps, nextStep)) { + return completeCreateFlow(context, nextValues) + } + return continueCreateFlow(clampCreateSettingsStep(nextStep, nextSteps.length - 1), nextValues) + } + ) +} + +/** + * Dispatches an advance result to imperative TUI handlers. + * + * @pure false + * @effect AdvanceCreateFlowHandlers + * @complexity O(1) + */ +// CHANGE: keep create-flow result handling at the shell boundary +// WHY: pure transition results are interpreted by caller-provided side-effect handlers +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall r: exactly one matching handler is invoked, except null +// PURITY: SHELL +// EFFECT: AdvanceCreateFlowHandlers +// INVARIANT: null results invoke no handler +// COMPLEXITY: O(1) +export const handleAdvanceCreateFlowResult = ( + next: AdvanceCreateFlowResult | null, + handlers: AdvanceCreateFlowHandlers +): void => { + if (next === null) { + return + } + if (next._tag === "Error") { + handlers.onError(next.error) + return + } + if (next._tag === "Continue") { + handlers.onContinue(next.view) + return + } + handlers.onComplete(next.inputs) +} diff --git a/packages/app/src/docker-git/menu-create-choices.ts b/packages/app/src/docker-git/menu-create-choices.ts new file mode 100644 index 00000000..34121f2a --- /dev/null +++ b/packages/app/src/docker-git/menu-create-choices.ts @@ -0,0 +1,57 @@ +import { Either } from "effect" + +import { type GpuMode, isGpuMode, type ParseError } from "./frontend-lib/core/domain.js" +import { createParseError } from "./menu-create-errors.js" + +export const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N" + +export const parseBooleanChoice = (input: string): boolean | null => { + const normalized = input.trim().toLowerCase() + if (normalized === "y" || normalized === "yes") { + return true + } + if (normalized === "n" || normalized === "no") { + return false + } + return null +} + +export const parseExplicitBooleanChoice = parseBooleanChoice + +export const parseExplicitGpuChoice = ( + input: string +): GpuMode | null => { + const normalized = input.trim().toLowerCase() + if (normalized === "y" || normalized === "yes") { + return "all" + } + if (normalized === "n" || normalized === "no") { + return "none" + } + if (isGpuMode(normalized)) { + return normalized + } + return null +} + +export const parseGpuInput = ( + input: string, + fallback: GpuMode +): Either.Either => { + const normalized = input.trim().toLowerCase() + if (normalized.length === 0) { + return Either.right(fallback) + } + if (normalized === "y" || normalized === "yes") { + return Either.right("all") + } + if (normalized === "n" || normalized === "no") { + return Either.right("none") + } + if (isGpuMode(normalized)) { + return Either.right(normalized) + } + return Either.left(createParseError("gpu must be one of: none, all, yes, no")) +} + +export const parseYesDefault = (input: string, fallback: boolean): boolean => parseBooleanChoice(input) ?? fallback diff --git a/packages/app/src/docker-git/menu-create-command-parse.ts b/packages/app/src/docker-git/menu-create-command-parse.ts new file mode 100644 index 00000000..0574e0de --- /dev/null +++ b/packages/app/src/docker-git/menu-create-command-parse.ts @@ -0,0 +1,232 @@ +import { Either } from "effect" + +import { buildCreateCommand } from "./cli/parser-create.js" +import { parseRawOptions } from "./cli/parser-options.js" +import { splitPositionalRepo } from "./cli/parser-shared.js" +import type { CreateCommand, ParseError } from "./frontend-lib/core/domain.js" +import { createParseError } from "./menu-create-errors.js" +import type { CreateFlowContext } from "./menu-create-flow-types.js" +import { resolveDefaultOutDir } from "./menu-create-inputs.js" +import type { CreateInputs } from "./menu-types.js" + +type CreateTokenizeState = { + readonly current: string + readonly escaping: boolean + readonly quote: "'" | "\"" | null + readonly tokens: ReadonlyArray +} + +const pushCreateToken = (state: CreateTokenizeState): CreateTokenizeState => + state.current.length > 0 + ? { + ...state, + current: "", + tokens: [...state.tokens, state.current] + } + : state + +const consumeCreateTokenChar = (state: CreateTokenizeState, char: string): CreateTokenizeState => { + if (state.escaping) { + return { + ...state, + current: `${state.current}${char}`, + escaping: false + } + } + if (char === "\\") { + return { + ...state, + escaping: true + } + } + if (state.quote !== null) { + if (char === state.quote) { + return { + ...state, + quote: null + } + } + return { + ...state, + current: `${state.current}${char}` + } + } + if (char === "'" || char === "\"") { + return { + ...state, + quote: char + } + } + if (/\s/u.test(char)) { + return pushCreateToken(state) + } + return { + ...state, + current: `${state.current}${char}` + } +} + +const consumeCreateTokenInput = ( + input: string, + state: CreateTokenizeState +): CreateTokenizeState => { + let index = 0 + let next = state + while (index < input.length) { + const codePoint = input.codePointAt(index) + if (codePoint === undefined) { + return next + } + const char = String.fromCodePoint(codePoint) + next = consumeCreateTokenChar(next, char) + index += char.length + } + return next +} + +const tokenizeCreateCommandLine = ( + input: string +): Either.Either, ParseError> => { + const state = consumeCreateTokenInput( + input.trim(), + { current: "", escaping: false, quote: null, tokens: [] } + ) + + if (state.escaping) { + return Either.left(createParseError("unterminated escape sequence")) + } + if (state.quote !== null) { + return Either.left(createParseError("unterminated quoted value")) + } + + return Either.right(pushCreateToken(state).tokens) +} + +const unsupportedCreatePrefixes = new Set([ + "apply", + "apply-all", + "attach", + "auth", + "browser", + "clone", + "down-all", + "gists", + "help", + "kill-all", + "mcp-playwright", + "menu", + "open", + "panes", + "ps", + "scrap", + "session-gists", + "sessions", + "state", + "status", + "stop-all", + "tmux", + "ui", + "update-all", + "web" +]) + +const normalizeCreateTokens = ( + tokens: ReadonlyArray +): Either.Either, ParseError> => { + const withoutBinary = tokens[0] === "docker-git" ? tokens.slice(1) : tokens + const first = withoutBinary[0] + if (first === undefined) { + return Either.right(withoutBinary) + } + if (first === "create" || first === "init") { + return Either.right(withoutBinary.slice(1)) + } + if (unsupportedCreatePrefixes.has(first)) { + return Either.left(createParseError(`only create/init options are supported here, got command: ${first}`)) + } + return Either.right(withoutBinary) +} + +type RawCreateOptions = Parameters[0] + +const cpuLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.cpuLimit === undefined ? {} : { cpuLimit: command.config.cpuLimit ?? "" } + +const ramLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.ramLimit === undefined ? {} : { ramLimit: command.config.ramLimit ?? "" } + +const gpuCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.gpu === undefined ? {} : { gpu: command.config.gpu } + +const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.up === undefined ? {} : { runUp: command.runUp } + +const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } + +const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.force === undefined ? {} : { force: command.force } + +const forceEnvCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.forceEnv === undefined ? {} : { forceEnv: command.forceEnv } + +const createInputsFromCommand = ( + repoUrl: string, + raw: RawCreateOptions, + command: CreateCommand +): Partial => ({ + repoUrl, + repoRef: command.config.repoRef, + outDir: command.outDir, + ...cpuLimitCreateInput(raw, command), + ...ramLimitCreateInput(raw, command), + ...gpuCreateInput(raw, command), + ...runUpCreateInput(raw, command), + ...playwrightCreateInput(raw, command), + ...forceCreateInput(raw, command), + ...forceEnvCreateInput(raw, command) +}) + +/** + * Parses the repo step buffer as either a raw URL or create/init CLI fragment. + * + * @pure true + * @invariant valid(buffer) -> decoded create defaults are deterministic + * @complexity O(n) where n = |buffer| + */ +// CHANGE: decode the first create-flow prompt through pure CLI-compatible parsing +// WHY: repo URL input may include inline create flags that satisfy later prompts +// QUOTE(ТЗ): "fix CodeRabbit review comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall b in Buffer: parse(b) = Left(error) or Right(partialInputs) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: parser state is immutable across token transitions +// COMPLEXITY: O(n) where n = |buffer| +export const parseRepoStepInput = ( + context: CreateFlowContext, + buffer: string +): Either.Either, ParseError> => { + if (buffer.length === 0) { + return Either.right({ + repoUrl: "", + outDir: resolveDefaultOutDir(context, "") + }) + } + + return Either.gen(function*(_) { + const tokens = yield* _(tokenizeCreateCommandLine(buffer)) + const normalizedTokens = yield* _(normalizeCreateTokens(tokens)) + const { positionalRepoUrl, restArgs } = splitPositionalRepo(normalizedTokens) + const raw = yield* _(parseRawOptions(restArgs)) + const repoUrl = raw.repoUrl ?? positionalRepoUrl ?? "" + const command = yield* _(buildCreateCommand({ + ...raw, + ...(repoUrl.length > 0 ? { repoUrl } : {}), + ...(raw.outDir === undefined ? { outDir: resolveDefaultOutDir(context, repoUrl) } : {}) + })) + + return createInputsFromCommand(repoUrl, raw, command) + }) +} diff --git a/packages/app/src/docker-git/menu-create-draft.ts b/packages/app/src/docker-git/menu-create-draft.ts new file mode 100644 index 00000000..478b4065 --- /dev/null +++ b/packages/app/src/docker-git/menu-create-draft.ts @@ -0,0 +1,28 @@ +import type { GpuMode } from "./frontend-lib/core/domain.js" +import type { CreateInputs } from "./menu-types.js" + +export const createProjectDraftFromInputs = ( + input: CreateInputs +): { + readonly repoUrl: string + readonly repoRef: string + readonly outDir: string + readonly cpuLimit: string + readonly ramLimit: string + readonly gpu: GpuMode + readonly up: boolean + readonly enableMcpPlaywright: boolean + readonly force: boolean + readonly forceEnv: boolean +} => ({ + repoUrl: input.repoUrl, + repoRef: input.repoRef, + outDir: input.outDir, + cpuLimit: input.cpuLimit, + ramLimit: input.ramLimit, + gpu: input.gpu, + up: input.runUp, + enableMcpPlaywright: input.enableMcpPlaywright, + force: input.force, + forceEnv: input.forceEnv +}) diff --git a/packages/app/src/docker-git/menu-create-errors.ts b/packages/app/src/docker-git/menu-create-errors.ts new file mode 100644 index 00000000..cf05b966 --- /dev/null +++ b/packages/app/src/docker-git/menu-create-errors.ts @@ -0,0 +1,7 @@ +import type { ParseError } from "./frontend-lib/core/domain.js" + +export const createParseError = (reason: string): ParseError => ({ + _tag: "InvalidOption", + option: "create", + reason +}) diff --git a/packages/app/src/docker-git/menu-create-flow-types.ts b/packages/app/src/docker-git/menu-create-flow-types.ts new file mode 100644 index 00000000..ff73b48a --- /dev/null +++ b/packages/app/src/docker-git/menu-create-flow-types.ts @@ -0,0 +1,123 @@ +import type { ParseError } from "./frontend-lib/core/domain.js" +import type { CreateInputs } from "./menu-types.js" + +export type Mutable = { -readonly [K in keyof T]: T[K] } + +export type CreateFlowContext = { + readonly cwd: string + readonly projectsRoot?: string | undefined +} + +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 + +export type AdvanceCreateFlowResult = + | { readonly _tag: "Continue"; readonly view: CreateFlowView } + | { readonly _tag: "Error"; readonly error: ParseError } + | { readonly _tag: "Complete"; readonly inputs: CreateInputs } + +export type AdvanceCreateFlowHandlers = { + readonly onComplete: (inputs: CreateInputs) => void + readonly onContinue: (view: CreateFlowView) => void + readonly onError: (error: ParseError) => void +} + +export type AdvanceCreateFlowOptions = { + readonly quickCreate?: boolean +} + +export type CreateSettingsNavigationDirection = "up" | "down" +export type CreateSettingsChoiceDirection = "left" | "right" + +export const createSettingsHint = "↑ - up, ↓ - down, Enter - apply" +export const firstCreateSettingsStepIndex = 1 + +/** + * Narrows a create-flow view to interactive create mode. + * + * @param view - Create-flow view to refine. + * @returns True when `view` is a create-mode view. + * @pure true + * @effect n/a + * @invariant result <=> view.mode = "create" + * @precondition `view` is a non-null CreateFlowView value. + * @postcondition True narrows `view` to CreateModeFlowView; false leaves it as the remaining union member. + * @complexity O(1) + * @throws Never + */ +// CHANGE: expose a pure predicate for create-mode flow views +// WHY: callers need type-safe mode refinement before create-only transitions +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: isCreate(v) <-> v.mode = create +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: predicate does not inspect mutable state +// COMPLEXITY: O(1) +export const isCreateModeFlowView = (view: CreateFlowView): view is CreateModeFlowView => view.mode === "create" + +/** + * Narrows a create-flow view to browser display-settings mode. + * + * @param view - Create-flow view to refine. + * @returns True when `view` is a display-mode view. + * @pure true + * @effect n/a + * @invariant result <=> view.mode = "display" + * @precondition `view` is a non-null CreateFlowView value. + * @postcondition True narrows `view` to DisplayModeFlowView; false leaves it as the remaining union member. + * @complexity O(1) + * @throws Never + */ +// CHANGE: expose a pure predicate for display-mode flow views +// WHY: callers need type-safe mode refinement before display-only transitions +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: isDisplay(v) <-> v.mode = display +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: predicate does not inspect mutable state +// COMPLEXITY: O(1) +export const isDisplayModeFlowView = (view: CreateFlowView): view is DisplayModeFlowView => view.mode === "display" + +/** + * Detects the repo-url prompt in create mode. + * + * @param view - Create-flow view to inspect. + * @returns True when `view` is create mode at step zero. + * @pure true + * @effect n/a + * @invariant result <=> view.mode = "create" and view.step = 0 + * @precondition `view` is a non-null CreateFlowView value. + * @postcondition True implies callers may treat the active row as the repo-url prompt. + * @complexity O(1) + * @throws Never + */ +// CHANGE: expose the repo-step predicate for shared create-flow input handling +// WHY: navigation and input logic treat repo URL entry differently from settings rows +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: repoStep(v) -> v.mode = create and v.step = 0 +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: display-mode views are never repo-step views +// COMPLEXITY: O(1) +export const isCreateFlowRepoStep = (view: CreateFlowView): boolean => isCreateModeFlowView(view) && view.step === 0 diff --git a/packages/app/src/docker-git/menu-create-inputs.ts b/packages/app/src/docker-git/menu-create-inputs.ts new file mode 100644 index 00000000..e3b3263f --- /dev/null +++ b/packages/app/src/docker-git/menu-create-inputs.ts @@ -0,0 +1,125 @@ +import { defaultTemplateConfig, deriveRepoPathParts, resolveRepoInput } from "./frontend-lib/core/domain.js" +import { defaultProjectsRoot } from "./frontend-lib/usecases/menu-helpers.js" +import type { CreateFlowContext } from "./menu-create-flow-types.js" +import type { CreateInputs } from "./menu-types.js" + +const trimLeftSlash = (value: string): string => { + let start = 0 + while (start < value.length && value[start] === "/") { + start += 1 + } + return value.slice(start) +} + +const trimRightSlash = (value: string): string => { + let end = value.length + while (end > 0 && value[end - 1] === "/") { + end -= 1 + } + return value.slice(0, end) +} + +const joinPath = (...parts: ReadonlyArray): string => { + const cleaned = parts + .filter((part) => part.length > 0) + .map((part, index) => { + if (index === 0) { + return trimRightSlash(part) + } + return trimRightSlash(trimLeftSlash(part)) + }) + return cleaned.join("/") +} + +/** + * Normalizes legacy cwd input into the create-flow context record. + * + * @pure true + * @invariant string input maps to { cwd: input } + * @complexity O(1) + */ +// CHANGE: normalize create-flow context boundaries into one record shape +// WHY: pure helpers can share cwd and optional projectsRoot resolution +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall c: normalize(c).cwd is defined +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: object context is preserved by reference +// COMPLEXITY: O(1) +export const normalizeCreateFlowContext = ( + context: string | CreateFlowContext +): CreateFlowContext => + typeof context === "string" + ? { cwd: context } + : context + +const resolveProjectsRoot = (context: CreateFlowContext): string => + context.projectsRoot?.trim().length + ? context.projectsRoot + : defaultProjectsRoot(context.cwd) + +/** + * Resolves the default output directory for a repo input. + * + * @pure true + * @invariant output is rooted under the resolved projects root + * @complexity O(n) where n = |repoUrl| + */ +// CHANGE: derive create-flow output directory from repo identity and context root +// WHY: repo URL, branch suffix, and browser-provided projectsRoot must resolve consistently +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall r: outDir(r) = projectsRoot / repoPathParts(r) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: no duplicate path separator is introduced by joinPath +// COMPLEXITY: O(n) where n = |repoUrl| +export const resolveDefaultOutDir = (context: CreateFlowContext, repoUrl: string): string => { + const resolvedRepo = resolveRepoInput(repoUrl) + const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts + const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts + return joinPath(resolveProjectsRoot(context), ...projectParts) +} + +/** + * Resolves partial create-flow values into total create command inputs. + * + * @pure true + * @invariant every CreateInputs field is defined in the result + * @complexity O(n) where n = |repoUrl| + */ +// CHANGE: totalize create-flow partial values with deterministic defaults +// WHY: completion must hand the shell a complete create command input record +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall p: resolve(p) in CreateInputs +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: explicit false boolean values are preserved +// COMPLEXITY: O(n) where n = |repoUrl| +export const resolveCreateInputs = ( + contextOrCwd: string | CreateFlowContext, + values: Partial +): CreateInputs => { + const context = normalizeCreateFlowContext(contextOrCwd) + const repoUrl = values.repoUrl ?? "" + const resolvedRepoRef = resolveRepoInput(repoUrl).repoRef + const outDir = values.outDir ?? resolveDefaultOutDir(context, repoUrl) + + return { + repoUrl, + repoRef: values.repoRef ?? resolvedRepoRef ?? "main", + outDir, + cpuLimit: values.cpuLimit ?? "", + ramLimit: values.ramLimit ?? "", + gpu: values.gpu ?? defaultTemplateConfig.gpu, + runUp: values.runUp !== false, + enableMcpPlaywright: values.enableMcpPlaywright === true, + force: values.force === true, + forceEnv: values.forceEnv === true + } +} diff --git a/packages/app/src/docker-git/menu-create-labels.ts b/packages/app/src/docker-git/menu-create-labels.ts new file mode 100644 index 00000000..35459ffc --- /dev/null +++ b/packages/app/src/docker-git/menu-create-labels.ts @@ -0,0 +1,67 @@ +import { Match } from "effect" + +import { + parseExplicitBooleanChoice, + parseExplicitGpuChoice, + renderExplicitBooleanChoice +} from "./menu-create-choices.js" +import type { CreateInputs, CreateStep } from "./menu-types.js" + +export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): string => + Match.value(step).pipe( + Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"), + Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`), + Match.when("outDir", () => `Output dir [${defaults.outDir}]`), + Match.when("cpuLimit", () => `CPU limit [${defaults.cpuLimit || "30%"}]`), + Match.when("ramLimit", () => `RAM limit [${defaults.ramLimit || "30%"}]`), + Match.when("gpu", () => `GPU access [${defaults.gpu}]`), + Match.when("runUp", () => `Run docker compose up now? [${renderExplicitBooleanChoice(defaults.runUp)}]`), + Match.when( + "mcpPlaywright", + () => + `Enable Playwright MCP (nested Chromium browser)? [${ + renderExplicitBooleanChoice(defaults.enableMcpPlaywright) + }]` + ), + Match.when( + "force", + () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` + ), + Match.exhaustive + ) + +export const renderCreateStepLabelWithBufferPreview = ( + step: CreateStep, + defaults: CreateInputs, + buffer: string +): string => + Match.value(step).pipe( + Match.when("repoUrl", () => renderCreateStepLabel(step, defaults)), + Match.when("repoRef", () => renderCreateStepLabel(step, defaults)), + Match.when("outDir", () => renderCreateStepLabel(step, defaults)), + Match.when("cpuLimit", () => renderCreateStepLabel(step, defaults)), + Match.when("ramLimit", () => renderCreateStepLabel(step, defaults)), + Match.when("gpu", () => { + const gpu = parseExplicitGpuChoice(buffer) + return gpu === null ? renderCreateStepLabel(step, defaults) : `GPU access [${gpu}]` + }), + Match.when("runUp", () => { + const runUp = parseExplicitBooleanChoice(buffer) + return runUp === null + ? renderCreateStepLabel(step, defaults) + : `Run docker compose up now? [${renderExplicitBooleanChoice(runUp)}]` + }), + Match.when("mcpPlaywright", () => { + const enableMcpPlaywright = parseExplicitBooleanChoice(buffer) + return enableMcpPlaywright === null + ? renderCreateStepLabel(step, defaults) + : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` + }), + Match.when("force", () => { + const force = parseExplicitBooleanChoice(buffer) + return force === null + ? renderCreateStepLabel(step, defaults) + : `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(force)}]` + }), + Match.exhaustive + ) diff --git a/packages/app/src/docker-git/menu-create-navigation.ts b/packages/app/src/docker-git/menu-create-navigation.ts new file mode 100644 index 00000000..89462acb --- /dev/null +++ b/packages/app/src/docker-git/menu-create-navigation.ts @@ -0,0 +1,289 @@ +import { Match } from "effect" + +import type { + CreateModeFlowView, + CreateSettingsChoiceDirection, + CreateSettingsNavigationDirection, + DisplayModeFlowView +} from "./menu-create-flow-types.js" +import { firstCreateSettingsStepIndex } from "./menu-create-flow-types.js" +import { resolveCreateDisplaySteps, resolveCreateFlowSteps } from "./menu-create-steps.js" + +/** + * Clamps a create settings index into the editable settings range. + * + * @param step - Candidate step index. + * @param lastStep - Inclusive upper bound of the current settings range. + * @returns `step` bounded to `[firstCreateSettingsStepIndex, lastStep]`. + * @pure true + * @effect n/a + * @invariant result is between first settings step and lastStep when range is valid + * @precondition `lastStep >= firstCreateSettingsStepIndex` for a non-empty settings range. + * @postcondition result >= firstCreateSettingsStepIndex and result <= lastStep when the precondition holds. + * @complexity O(1) + * @throws Never + */ +// CHANGE: centralize create settings index clamping +// WHY: advancement and navigation must share the same range invariant +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall i: clamp(i) in [first,last] for first <= last +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: repo URL step is excluded from the settings range +// COMPLEXITY: O(1) +export const clampCreateSettingsStep = ( + step: number, + lastStep: number +): number => Math.min(Math.max(step, firstCreateSettingsStepIndex), lastStep) + +/** + * Computes a wrapped adjacent settings index. + * + * @param step - Current valid settings step index. + * @param lastStep - Inclusive upper bound for settings. + * @param direction - Vertical navigation direction. + * @returns The previous or next settings index with wraparound. + * @pure true + * @effect n/a + * @invariant result is inside `[firstCreateSettingsStepIndex, lastStep]`. + * @precondition `step` is inside `[firstCreateSettingsStepIndex, lastStep]`. + * @postcondition `up` decrements or wraps to `lastStep`; `down` increments or wraps to first settings step. + * @complexity O(1) + * @throws Never + */ +// CHANGE: isolate wrapped create-settings index arithmetic +// WHY: both create and display modes share the same vertical navigation law +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall s in [first,last], d: next(s,d) in [first,last] +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: navigation never returns the repo URL step +// COMPLEXITY: O(1) +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 any settings-capable create-flow view within a bounded range. + * + * @param view - Create or display view to move. + * @param lastStep - Inclusive upper bound for the active settings list. + * @param direction - Vertical navigation direction. + * @returns A moved view, the original view if unchanged, or null when no settings range is active. + * @pure true + * @effect n/a + * @invariant Returned views preserve mode and committed values. + * @precondition `view` is non-null and `lastStep` belongs to the step list used by the caller. + * @postcondition Non-null moved views have empty `buffer` and null `inputError`. + * @complexity O(1) + * @throws Never + */ +// CHANGE: share immutable settings movement across create and display modes +// WHY: the two views differ only in the step list that defines `lastStep` +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: valid(v,last) -> move(v).values = v.values +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: repo URL or empty settings ranges return null +// COMPLEXITY: O(1) +const moveCreateSettingsWithin = < + A extends CreateModeFlowView | DisplayModeFlowView +>( + view: A, + lastStep: number, + direction: CreateSettingsNavigationDirection +): A | 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 + } +} + +/** + * Resolves a horizontal boolean choice to the create-flow buffer token. + * + * @param direction - Horizontal choice direction. + * @returns `"n"` for left and `"y"` for right. + * @pure true + * @effect n/a + * @invariant result is always a valid yes/no buffer token. + * @precondition `direction` is a valid CreateSettingsChoiceDirection. + * @postcondition Left maps to false-preview token; right maps to true-preview token. + * @complexity O(1) + * @throws Never + */ +// CHANGE: encode boolean left/right choices as create input buffer values +// WHY: preview buffers reuse the same parser path as typed settings input +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: direction in {left,right} -> token in {n,y} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: output token set is finite and parser-compatible +// COMPLEXITY: O(1) +const booleanChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => + Match.value(direction).pipe( + Match.when("left", () => "n"), + Match.when("right", () => "y"), + Match.exhaustive + ) + +/** + * Resolves a horizontal GPU choice to the create-flow buffer token. + * + * @param direction - Horizontal choice direction. + * @returns `"none"` for left and `"all"` for right. + * @pure true + * @effect n/a + * @invariant result is always a valid GPU buffer token. + * @precondition `direction` is a valid CreateSettingsChoiceDirection. + * @postcondition Left maps to `none`; right maps to `all`. + * @complexity O(1) + * @throws Never + */ +// CHANGE: encode GPU left/right choices as create input buffer values +// WHY: display controls should use the same parser-compatible token space as manual input +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: direction in {left,right} -> token in {none,all} +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: output token set is finite and parser-compatible +// COMPLEXITY: O(1) +const gpuChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => + Match.value(direction).pipe( + Match.when("left", () => "none"), + Match.when("right", () => "all"), + Match.exhaustive + ) + +/** + * Resolves horizontal browser setting controls into preview buffer tokens. + * + * @param view - Browser display-settings view that provides the active row. + * @param direction - Horizontal choice direction. + * @returns A parser-compatible buffer token for discrete rows, otherwise null. + * @pure true + * @effect n/a + * @invariant free-text rows return null + * @precondition `view.step` may be inside or outside the display step range. + * @postcondition Returned non-null tokens do not mutate `view.values`. + * @complexity O(1) + * @throws Never + */ +// CHANGE: map display-mode left/right choices to create setting buffer values +// WHY: browser controls should preview discrete settings without committing values +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall d: choice(d) in BufferToken or null +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: committed view.values are not changed +// COMPLEXITY: O(1) +export const resolveCreateSettingsChoiceBuffer = ( + view: DisplayModeFlowView, + direction: CreateSettingsChoiceDirection +): string | null => { + const step = resolveCreateDisplaySteps()[view.step] + if (step === undefined) { + return null + } + + return Match.value(step).pipe( + Match.when("repoUrl", () => null), + Match.when("repoRef", () => null), + Match.when("outDir", () => null), + Match.when("cpuLimit", () => null), + Match.when("ramLimit", () => null), + Match.when("gpu", () => gpuChoiceBuffer(direction)), + Match.when("runUp", () => booleanChoiceBuffer(direction)), + Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), + Match.when("force", () => booleanChoiceBuffer(direction)), + Match.exhaustive + ) +} + +/** + * Moves the create-mode settings selection with wraparound. + * + * @param view - Create-mode view to move. + * @param direction - Vertical navigation direction. + * @returns Moved create-mode view or null when the repo URL step is active. + * @pure true + * @effect n/a + * @invariant returned view has an empty buffer + * @precondition `view` is a non-null CreateModeFlowView. + * @postcondition Non-null result preserves mode and values while clearing transient input state. + * @complexity O(s) where s = number of remaining create steps + * @throws Never + */ +// CHANGE: expose pure create-mode settings navigation +// WHY: arrow-key handling must not mutate the current view +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: valid(v) -> step(move(v,d)) = wrapped(step(v),d) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: repo URL step navigation returns null +// COMPLEXITY: O(s) where s = number of remaining create steps +export const moveCreateSettingsStep = ( + view: CreateModeFlowView, + direction: CreateSettingsNavigationDirection +): CreateModeFlowView | null => + moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) + +/** + * Moves the browser display-settings selection with wraparound. + * + * @param view - Display-mode view to move. + * @param direction - Vertical navigation direction. + * @returns Moved display-mode view or null when no settings range is active. + * @pure true + * @effect n/a + * @invariant returned view has an empty buffer + * @precondition `view` is a non-null DisplayModeFlowView. + * @postcondition Non-null result preserves mode and values while clearing transient input state. + * @complexity O(s) where s = number of display steps + * @throws Never + */ +// CHANGE: expose pure display-mode settings navigation +// WHY: browser settings keep all rows navigable regardless of committed values +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: valid(v) -> step(moveDisplay(v,d)) = wrapped(step(v),d) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: display settings never skip applied rows +// COMPLEXITY: O(s) where s = number of display steps +export const moveCreateDisplaySettingsStep = ( + view: DisplayModeFlowView, + direction: CreateSettingsNavigationDirection +): DisplayModeFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 123746bc..5c1713f6 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -1,1033 +1,30 @@ -import { Either, Match } from "effect" -import { - type CreateCommand, - defaultTemplateConfig, - deriveRepoPathParts, - type GpuMode, - isGpuMode, - type ParseError, - resolveRepoInput -} from "./frontend-lib/core/domain.js" -import { defaultProjectsRoot } from "./frontend-lib/usecases/menu-helpers.js" - -import { buildCreateCommand } from "./cli/parser-create.js" -import { parseRawOptions } from "./cli/parser-options.js" -import { splitPositionalRepo } from "./cli/parser-shared.js" -import { type CreateInputs, type CreateStep, createSteps } from "./menu-types.js" - -type Mutable = { -readonly [K in keyof T]: T[K] } - -export type CreateFlowContext = { - readonly cwd: string - readonly projectsRoot?: string | undefined -} - -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 } - | { readonly _tag: "Complete"; readonly inputs: CreateInputs } - -type AdvanceCreateFlowHandlers = { - readonly onComplete: (inputs: CreateInputs) => void - readonly onContinue: (view: CreateFlowView) => void - readonly onError: (error: ParseError) => void -} - -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" - -/** - * Horizontal choice direction over finite Create settings with discrete values. - * - * @pure true - * @effect none - * @invariant value ∈ {"left", "right"} - * @precondition n/a - * @postcondition direction maps only to an input-buffer token, never to applied Create values - * @complexity O(1) - */ -export type CreateSettingsChoiceDirection = "left" | "right" - -/** - * 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 - -/** - * 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] === "/") { - start += 1 - } - return value.slice(start) -} - -const trimRightSlash = (value: string): string => { - let end = value.length - while (end > 0 && value[end - 1] === "/") { - end -= 1 - } - return value.slice(0, end) -} - -const joinPath = (...parts: ReadonlyArray): string => { - const cleaned = parts - .filter((part) => part.length > 0) - .map((part, index) => { - if (index === 0) { - return trimRightSlash(part) - } - return trimRightSlash(trimLeftSlash(part)) - }) - return cleaned.join("/") -} - -const renderExplicitBooleanChoice = (value: boolean): string => value ? "Y" : "N" - -export const renderCreateStepLabel = (step: CreateStep, defaults: CreateInputs): string => - Match.value(step).pipe( - Match.when("repoUrl", () => "Repo URL (optional for empty workspace)"), - Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`), - Match.when("outDir", () => `Output dir [${defaults.outDir}]`), - Match.when("cpuLimit", () => `CPU limit [${defaults.cpuLimit || "30%"}]`), - Match.when("ramLimit", () => `RAM limit [${defaults.ramLimit || "30%"}]`), - Match.when("gpu", () => `GPU access [${defaults.gpu}]`), - Match.when("runUp", () => `Run docker compose up now? [${renderExplicitBooleanChoice(defaults.runUp)}]`), - Match.when( - "mcpPlaywright", - () => - `Enable Playwright MCP (nested Chromium browser)? [${ - renderExplicitBooleanChoice(defaults.enableMcpPlaywright) - }]` - ), - Match.when( - "force", - () => `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(defaults.force)}]` - ), - Match.exhaustive - ) - -const parseBooleanChoice = (input: string): boolean | null => { - const normalized = input.trim().toLowerCase() - if (normalized === "y" || normalized === "yes") { - return true - } - if (normalized === "n" || normalized === "no") { - return false - } - return null -} - -const parseExplicitBooleanChoice = parseBooleanChoice - -const parseExplicitGpuChoice = ( - input: string -): GpuMode | null => { - const normalized = input.trim().toLowerCase() - if (normalized === "y" || normalized === "yes") { - return "all" - } - if (normalized === "n" || normalized === "no") { - return "none" - } - if (isGpuMode(normalized)) { - return normalized - } - return null -} - -/** - * Renders the active Create settings label with an unapplied input-buffer preview. - * - * @pure true - * @effect none - * @invariant invalid or empty preview buffers preserve the committed/default label - * @precondition defaults are resolved Create inputs - * @postcondition Create values are not mutated or applied by rendering - * @complexity O(1) - */ -export const renderCreateStepLabelWithBufferPreview = ( - step: CreateStep, - defaults: CreateInputs, - buffer: string -): string => - Match.value(step).pipe( - Match.when("repoUrl", () => renderCreateStepLabel(step, defaults)), - Match.when("repoRef", () => renderCreateStepLabel(step, defaults)), - Match.when("outDir", () => renderCreateStepLabel(step, defaults)), - Match.when("cpuLimit", () => renderCreateStepLabel(step, defaults)), - Match.when("ramLimit", () => renderCreateStepLabel(step, defaults)), - Match.when("gpu", () => { - const gpu = parseExplicitGpuChoice(buffer) - return gpu === null ? renderCreateStepLabel(step, defaults) : `GPU access [${gpu}]` - }), - Match.when("runUp", () => { - const runUp = parseExplicitBooleanChoice(buffer) - return runUp === null - ? renderCreateStepLabel(step, defaults) - : `Run docker compose up now? [${renderExplicitBooleanChoice(runUp)}]` - }), - Match.when("mcpPlaywright", () => { - const enableMcpPlaywright = parseExplicitBooleanChoice(buffer) - return enableMcpPlaywright === null - ? renderCreateStepLabel(step, defaults) - : `Enable Playwright MCP (nested Chromium browser)? [${renderExplicitBooleanChoice(enableMcpPlaywright)}]` - }), - Match.when("force", () => { - const force = parseExplicitBooleanChoice(buffer) - return force === null - ? renderCreateStepLabel(step, defaults) - : `Force recreate (overwrite files + wipe volumes)? [${renderExplicitBooleanChoice(force)}]` - }), - Match.exhaustive - ) - -const normalizeCreateFlowContext = ( - context: string | CreateFlowContext -): CreateFlowContext => - typeof context === "string" - ? { cwd: context } - : context - -const resolveProjectsRoot = (context: CreateFlowContext): string => - context.projectsRoot?.trim().length - ? context.projectsRoot - : defaultProjectsRoot(context.cwd) - -const resolveDefaultOutDir = (context: CreateFlowContext, repoUrl: string): string => { - const resolvedRepo = resolveRepoInput(repoUrl) - const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts - const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts - return joinPath(resolveProjectsRoot(context), ...projectParts) -} - -export const resolveCreateInputs = ( - contextOrCwd: string | CreateFlowContext, - values: Partial -): CreateInputs => { - const context = normalizeCreateFlowContext(contextOrCwd) - const repoUrl = values.repoUrl ?? "" - const resolvedRepoRef = resolveRepoInput(repoUrl).repoRef - const outDir = values.outDir ?? resolveDefaultOutDir(context, repoUrl) - - return { - repoUrl, - repoRef: values.repoRef ?? resolvedRepoRef ?? "main", - outDir, - cpuLimit: values.cpuLimit ?? "", - ramLimit: values.ramLimit ?? "", - gpu: values.gpu ?? defaultTemplateConfig.gpu, - runUp: values.runUp !== false, - enableMcpPlaywright: values.enableMcpPlaywright === true, - force: values.force === true, - forceEnv: values.forceEnv === true - } -} - -const parseGpuInput = ( - input: string, - fallback: GpuMode -): Either.Either => { - const normalized = input.trim().toLowerCase() - if (normalized.length === 0) { - return Either.right(fallback) - } - if (normalized === "y" || normalized === "yes") { - return Either.right("all") - } - if (normalized === "n" || normalized === "no") { - return Either.right("none") - } - if (isGpuMode(normalized)) { - return Either.right(normalized) - } - return Either.left(createParseError("gpu must be one of: none, all, yes, no")) -} - -const parseYesDefault = (input: string, fallback: boolean): boolean => parseBooleanChoice(input) ?? fallback - -const createParseError = (reason: string): ParseError => ({ - _tag: "InvalidOption", - option: "create", - reason -}) - -type CreateTokenizeState = { - current: string - escaping: boolean - quote: "'" | "\"" | null - readonly tokens: Array -} - -const pushCreateToken = (state: CreateTokenizeState): void => { - if (state.current.length > 0) { - state.tokens.push(state.current) - state.current = "" - } -} - -const consumeCreateTokenChar = (state: CreateTokenizeState, char: string): void => { - if (state.escaping) { - state.current += char - state.escaping = false - return - } - if (char === "\\") { - state.escaping = true - return - } - if (state.quote !== null) { - if (char === state.quote) { - state.quote = null - return - } - state.current += char - return - } - if (char === "'" || char === "\"") { - state.quote = char - return - } - if (/\s/u.test(char)) { - pushCreateToken(state) - return - } - state.current += char -} - -const tokenizeCreateCommandLine = ( - input: string -): Either.Either, ParseError> => { - const state: CreateTokenizeState = { current: "", escaping: false, quote: null, tokens: [] } - - for (const char of input.trim()) { - consumeCreateTokenChar(state, char) - } - - if (state.escaping) { - return Either.left(createParseError("unterminated escape sequence")) - } - if (state.quote !== null) { - return Either.left(createParseError("unterminated quoted value")) - } - - pushCreateToken(state) - return Either.right(state.tokens) -} - -const unsupportedCreatePrefixes = new Set([ - "apply", - "apply-all", - "attach", - "auth", - "browser", - "clone", - "down-all", - "gists", - "help", - "kill-all", - "mcp-playwright", - "menu", - "open", - "panes", - "ps", - "scrap", - "session-gists", - "sessions", - "state", - "status", - "stop-all", - "tmux", - "ui", - "update-all", - "web" -]) - -const normalizeCreateTokens = ( - tokens: ReadonlyArray -): Either.Either, ParseError> => { - const withoutBinary = tokens[0] === "docker-git" ? tokens.slice(1) : tokens - const first = withoutBinary[0] - if (first === undefined) { - return Either.right(withoutBinary) - } - if (first === "create" || first === "init") { - return Either.right(withoutBinary.slice(1)) - } - if (unsupportedCreatePrefixes.has(first)) { - return Either.left(createParseError(`only create/init options are supported here, got command: ${first}`)) - } - return Either.right(withoutBinary) -} - -type RawCreateOptions = Parameters[0] - -const cpuLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.cpuLimit === undefined ? {} : { cpuLimit: command.config.cpuLimit ?? "" } - -const ramLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.ramLimit === undefined ? {} : { ramLimit: command.config.ramLimit ?? "" } - -const gpuCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.gpu === undefined ? {} : { gpu: command.config.gpu } - -const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.up === undefined ? {} : { runUp: command.runUp } - -const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } - -const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.force === undefined ? {} : { force: command.force } - -const forceEnvCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => - raw.forceEnv === undefined ? {} : { forceEnv: command.forceEnv } - -const createInputsFromCommand = ( - repoUrl: string, - raw: RawCreateOptions, - command: CreateCommand -): Partial => ({ - repoUrl, - repoRef: command.config.repoRef, - outDir: command.outDir, - ...cpuLimitCreateInput(raw, command), - ...ramLimitCreateInput(raw, command), - ...gpuCreateInput(raw, command), - ...runUpCreateInput(raw, command), - ...playwrightCreateInput(raw, command), - ...forceCreateInput(raw, command), - ...forceEnvCreateInput(raw, command) -}) - -const parseRepoStepInput = ( - context: CreateFlowContext, - buffer: string -): Either.Either, ParseError> => { - if (buffer.length === 0) { - return Either.right({ - repoUrl: "", - outDir: resolveDefaultOutDir(context, "") - }) - } - - return Either.gen(function*(_) { - const tokens = yield* _(tokenizeCreateCommandLine(buffer)) - const normalizedTokens = yield* _(normalizeCreateTokens(tokens)) - const { positionalRepoUrl, restArgs } = splitPositionalRepo(normalizedTokens) - const raw = yield* _(parseRawOptions(restArgs)) - const repoUrl = raw.repoUrl ?? positionalRepoUrl ?? "" - const command = yield* _(buildCreateCommand({ - ...raw, - ...(repoUrl.length > 0 ? { repoUrl } : {}), - ...(raw.outDir === undefined ? { outDir: resolveDefaultOutDir(context, repoUrl) } : {}) - })) - - return createInputsFromCommand(repoUrl, raw, command) - }) -} - -const createStepApplied = (): Either.Either => { - const applied = true - return Either.right(applied) -} - -const hasOwn = (values: Partial, key: keyof CreateInputs): boolean => - Object.prototype.hasOwnProperty.call(values, key) - -const isCreateStepSatisfied = ( - step: CreateStep, - values: Partial -): boolean => - Match.value(step).pipe( - Match.when("repoUrl", () => true), - Match.when("repoRef", () => true), - Match.when("outDir", () => true), - Match.when("cpuLimit", () => hasOwn(values, "cpuLimit")), - Match.when("ramLimit", () => hasOwn(values, "ramLimit")), - Match.when("gpu", () => hasOwn(values, "gpu")), - Match.when("runUp", () => hasOwn(values, "runUp")), - Match.when("mcpPlaywright", () => hasOwn(values, "enableMcpPlaywright")), - Match.when("force", () => hasOwn(values, "force")), - Match.exhaustive - ) - -export const resolveCreateFlowSteps = ( - values: Partial -): ReadonlyArray => [ - "repoUrl", - ...createSteps - .filter((step) => step !== "repoUrl") - .filter((step) => !isCreateStepSatisfied(step, values)) -] - -/** - * Resolves the stable Create display rows used by browser Settings mode. - * - * @pure true - * @effect none - * @invariant result = createSteps and is independent of applied values - * @precondition n/a - * @postcondition applied settings rows remain present in the result - * @complexity O(1) - */ -export const resolveCreateDisplaySteps = ( - _values: Partial = {} -): ReadonlyArray => createSteps - -const applyCreateStep = (input: { - readonly step: CreateStep - readonly buffer: string - readonly currentDefaults: CreateInputs - readonly nextValues: Partial> - readonly context: CreateFlowContext -}): Either.Either => - Match.value(input.step).pipe( - Match.when("repoUrl", () => { - const parsed = parseRepoStepInput(input.context, input.buffer) - if (Either.isLeft(parsed)) { - return Either.left(parsed.left) - } - Object.assign(input.nextValues, parsed.right) - return createStepApplied() - }), - Match.when("repoRef", () => { - input.nextValues.repoRef = input.buffer.length > 0 ? input.buffer : input.currentDefaults.repoRef - return createStepApplied() - }), - Match.when("outDir", () => { - input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir - return createStepApplied() - }), - Match.when("cpuLimit", () => { - input.nextValues.cpuLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.cpuLimit - return createStepApplied() - }), - Match.when("ramLimit", () => { - input.nextValues.ramLimit = input.buffer.length > 0 ? input.buffer : input.currentDefaults.ramLimit - return createStepApplied() - }), - Match.when("gpu", () => { - const gpu = parseGpuInput(input.buffer, input.currentDefaults.gpu) - if (Either.isLeft(gpu)) { - return Either.left(gpu.left) - } - input.nextValues.gpu = gpu.right - return createStepApplied() - }), - Match.when("runUp", () => { - input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp) - return createStepApplied() - }), - Match.when("mcpPlaywright", () => { - input.nextValues.enableMcpPlaywright = parseYesDefault( - input.buffer, - input.currentDefaults.enableMcpPlaywright - ) - return createStepApplied() - }), - Match.when("force", () => { - input.nextValues.force = parseYesDefault(input.buffer, input.currentDefaults.force) - return createStepApplied() - }), - 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 = ""): CreateModeFlowView => ({ - mode: "create", - step: 0, - buffer, - inputError: null, - values: {} -}) - -const resolveDisplayFlowStep = (view: CreateFlowView): number => { - const displaySteps = resolveCreateDisplaySteps() - if (isDisplayModeFlowView(view)) { - return clampCreateSettingsStep(view.step, displaySteps.length - 1) - } - const flowStep = resolveCreateFlowSteps(view.values)[view.step] - const displayStep = flowStep === undefined ? -1 : displaySteps.indexOf(flowStep) - return clampCreateSettingsStep(displayStep === -1 ? view.step : displayStep, displaySteps.length - 1) -} - -/** - * 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: resolveDisplayFlowStep(view), - buffer: view.buffer, - inputError: null, - values: view.values -}) - -const shouldQuickCreate = ( - step: CreateStep, - options: AdvanceCreateFlowOptions -): boolean => - step === "repoUrl" && - options.quickCreate === true - -const continueCreateFlow = ( - nextStep: number, - nextValues: Partial> -): AdvanceCreateFlowResult => ({ - _tag: "Continue", - view: { - mode: "create", - step: nextStep, - buffer: "", - inputError: null, - values: nextValues - } -}) - -const continueCreateDisplayFlow = ( - view: DisplayModeFlowView, - nextValues: Partial> -): AdvanceCreateFlowResult => ({ - _tag: "Continue", - view: { - ...view, - buffer: "", - inputError: null, - values: nextValues - } -}) - -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 - ) - -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 { - 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"), - Match.when("right", () => "y"), - Match.exhaustive - ) - -const gpuChoiceBuffer = (direction: CreateSettingsChoiceDirection): string => - Match.value(direction).pipe( - Match.when("left", () => "none"), - Match.when("right", () => "all"), - Match.exhaustive - ) - -/** - * Resolves a horizontal settings choice to the Create input buffer without applying it. - * - * @pure true - * @effect none - * @invariant result = null for free-text Create rows - * @invariant result != null -> view.values are unchanged by caller-visible semantics - * @precondition view is a CreateFlowView snapshot - * @postcondition result ∈ {"none", "all", "n", "y"} ∪ {null} - * @complexity O(1) - */ -export const resolveCreateSettingsChoiceBuffer = ( - view: DisplayModeFlowView, - direction: CreateSettingsChoiceDirection -): string | null => { - const step = resolveCreateDisplaySteps()[view.step] - if (step === undefined) { - return null - } - - return Match.value(step).pipe( - Match.when("repoUrl", () => null), - Match.when("repoRef", () => null), - Match.when("outDir", () => null), - Match.when("cpuLimit", () => null), - Match.when("ramLimit", () => null), - Match.when("gpu", () => gpuChoiceBuffer(direction)), - Match.when("runUp", () => booleanChoiceBuffer(direction)), - Match.when("mcpPlaywright", () => booleanChoiceBuffer(direction)), - Match.when("force", () => booleanChoiceBuffer(direction)), - 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: CreateModeFlowView, - direction: CreateSettingsNavigationDirection -): CreateModeFlowView | null => - moveCreateSettingsWithin(view, resolveCreateFlowSteps(view.values).length - 1, direction) - -/** - * Moves the selected browser Create settings row over the full display list. - * - * @pure true - * @effect none - * @invariant applied rows do not affect navigation order - * @invariant view.step = 0 -> result = null - * @invariant result != null -> 1 <= result.step < |resolveCreateDisplaySteps()| - * @precondition view is a CreateFlowView snapshot - * @postcondition result values are identical to input values - * @complexity O(1) - */ -export const moveCreateDisplaySettingsStep = ( - view: DisplayModeFlowView, - direction: CreateSettingsNavigationDirection -): DisplayModeFlowView | null => moveCreateSettingsWithin(view, resolveCreateDisplaySteps().length - 1, direction) - -const resolveActiveCreateDisplayStep = (view: DisplayModeFlowView): 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: DisplayModeFlowView -): 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: DisplayModeFlowView, - onActive: (active: ActiveCreateDisplayContext) => AdvanceCreateFlowResult | null -): AdvanceCreateFlowResult | null => { - const active = resolveActiveCreateDisplayContext(contextOrCwd, view) - return active === null ? null : onActive(active) -} - -/** - * Applies one browser Create settings display row without advancing or submitting. - * - * @pure true - * @effect none - * @invariant result._tag = "Continue" -> result.view.step = view.step - * @invariant result._tag = "Continue" -> result.view.buffer = "" - * @precondition view.step points at a settings display row - * @postcondition successful result stores the parsed setting in result.view.values - * @complexity O(1) - */ -export const applyCreateDisplaySettingsStep = ( - contextOrCwd: string | CreateFlowContext, - view: DisplayModeFlowView -): AdvanceCreateFlowResult | null => - withActiveCreateDisplayContext(contextOrCwd, view, (active) => - foldAppliedCreateValues( - applyCreateBufferToValues(active.context, view, active.step), - (nextValues) => continueCreateDisplayFlow(view, nextValues) - )) - -/** - * Applies one browser Create settings display row and advances selection downward. - * - * @pure true - * @effect none - * @invariant successful result preserves applied values and moves over the finite display row cycle - * @precondition view.step points at a settings display row - * @postcondition result._tag = "Continue" -> result.view.buffer = "" - * @complexity O(1) - */ -export const advanceCreateDisplaySettingsStep = ( - contextOrCwd: string | CreateFlowContext, - view: DisplayModeFlowView -): AdvanceCreateFlowResult | null => { - const applied = applyCreateDisplaySettingsStep(contextOrCwd, view) - if (applied === null || applied._tag !== "Continue" || !isDisplayModeFlowView(applied.view)) { - return applied - } - - const movedView = moveCreateDisplaySettingsStep(applied.view, "down") - return movedView === null ? applied : { ...applied, view: movedView } -} - -/** - * Completes browser Create settings by applying a non-empty active buffer first. - * - * @pure true - * @effect none - * @invariant non-empty invalid buffer -> result._tag = "Error" - * @invariant successful result._tag = "Complete" - * @precondition view.step points at a settings display row - * @postcondition submitted inputs include all committed values and defaults - * @complexity O(1) - */ -export const completeCreateDisplaySettingsFlow = ( - contextOrCwd: string | CreateFlowContext, - view: DisplayModeFlowView -): AdvanceCreateFlowResult | null => - withActiveCreateDisplayContext(contextOrCwd, view, (active) => { - if (view.buffer.trim().length === 0) { - return completeCreateFlow(active.context, view.values) - } - - const applied = applyCreateDisplaySettingsStep(active.context, view) - if (applied === null || applied._tag === "Error") { - return applied - } - if (applied._tag === "Continue") { - return completeCreateFlow(active.context, applied.view.values) - } - return applied - }) - -const resolveNextCreateFlowStep = ( - currentStep: CreateStep, - currentStepIndex: number, - nextSteps: ReadonlyArray -): number => - currentStep === "repoUrl" - ? firstCreateSettingsStepIndex - : clampCreateSettingsStep(currentStepIndex, nextSteps.length - 1) - -export const advanceCreateFlow = ( - contextOrCwd: string | CreateFlowContext, - view: CreateModeFlowView, - options: AdvanceCreateFlowOptions = {} -): AdvanceCreateFlowResult | null => { - const context = normalizeCreateFlowContext(contextOrCwd) - const currentSteps = resolveCreateFlowSteps(view.values) - const step = currentSteps[view.step] - if (step === undefined) { - return null - } - - return foldAppliedCreateValues( - applyCreateBufferToValues(context, view, step), - (nextValues) => { - if (shouldQuickCreate(step, options)) { - return completeCreateFlow(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) - } - ) -} - -export const handleAdvanceCreateFlowResult = ( - next: AdvanceCreateFlowResult | null, - handlers: AdvanceCreateFlowHandlers -): void => { - if (next === null) { - return - } - if (next._tag === "Error") { - handlers.onError(next.error) - return - } - if (next._tag === "Continue") { - handlers.onContinue(next.view) - return - } - handlers.onComplete(next.inputs) -} - -export const createProjectDraftFromInputs = ( - input: CreateInputs -): { - readonly repoUrl: string - readonly repoRef: string - readonly outDir: string - readonly cpuLimit: string - readonly ramLimit: string - readonly gpu: GpuMode - readonly up: boolean - readonly enableMcpPlaywright: boolean - readonly force: boolean - readonly forceEnv: boolean -} => ({ - repoUrl: input.repoUrl, - repoRef: input.repoRef, - outDir: input.outDir, - cpuLimit: input.cpuLimit, - ramLimit: input.ramLimit, - gpu: input.gpu, - up: input.runUp, - enableMcpPlaywright: input.enableMcpPlaywright, - force: input.force, - forceEnv: input.forceEnv -}) +export { + advanceCreateDisplaySettingsStep, + advanceCreateFlow, + applyCreateDisplaySettingsStep, + completeCreateDisplaySettingsFlow, + createDisplayFlowView, + createInitialFlowView, + handleAdvanceCreateFlowResult +} from "./menu-create-advance.js" +export { createProjectDraftFromInputs } from "./menu-create-draft.js" +export { + type CreateFlowContext, + type CreateFlowView, + type CreateModeFlowView, + type CreateSettingsChoiceDirection, + createSettingsHint, + type CreateSettingsNavigationDirection, + type DisplayModeFlowView, + isCreateFlowRepoStep, + isCreateModeFlowView, + isDisplayModeFlowView +} from "./menu-create-flow-types.js" +export { resolveCreateInputs } from "./menu-create-inputs.js" +export { renderCreateStepLabel, renderCreateStepLabelWithBufferPreview } from "./menu-create-labels.js" +export { + moveCreateDisplaySettingsStep, + moveCreateSettingsStep, + resolveCreateSettingsChoiceBuffer +} from "./menu-create-navigation.js" +export { resolveCreateDisplaySteps, resolveCreateFlowSteps } from "./menu-create-steps.js" diff --git a/packages/app/src/docker-git/menu-create-step-apply.ts b/packages/app/src/docker-git/menu-create-step-apply.ts new file mode 100644 index 00000000..ed1c84a2 --- /dev/null +++ b/packages/app/src/docker-git/menu-create-step-apply.ts @@ -0,0 +1,102 @@ +import { Either, Match } from "effect" + +import type { ParseError } from "./frontend-lib/core/domain.js" +import { parseGpuInput, parseYesDefault } from "./menu-create-choices.js" +import { parseRepoStepInput } from "./menu-create-command-parse.js" +import type { CreateFlowContext, CreateFlowView, Mutable } from "./menu-create-flow-types.js" +import { resolveCreateInputs } from "./menu-create-inputs.js" +import type { CreateInputs, CreateStep } from "./menu-types.js" + +type ApplyCreateStepInput = { + readonly step: CreateStep + readonly buffer: string + readonly currentDefaults: CreateInputs + readonly context: CreateFlowContext +} + +const applyRepoStep = ( + input: ApplyCreateStepInput +): Either.Either>, ParseError> => parseRepoStepInput(input.context, input.buffer) + +const applyTextStep = ( + input: ApplyCreateStepInput, + key: "repoRef" | "outDir" | "cpuLimit" | "ramLimit" +): Either.Either>, ParseError> => { + const value = input.buffer.length > 0 ? input.buffer : input.currentDefaults[key] + return Match.value(key).pipe( + Match.when("repoRef", () => Either.right({ repoRef: value })), + Match.when("outDir", () => Either.right({ outDir: value })), + Match.when("cpuLimit", () => Either.right({ cpuLimit: value })), + Match.when("ramLimit", () => Either.right({ ramLimit: value })), + Match.exhaustive + ) +} + +const applyGpuStep = ( + input: ApplyCreateStepInput +): Either.Either>, ParseError> => { + const gpu = parseGpuInput(input.buffer, input.currentDefaults.gpu) + return Either.isLeft(gpu) ? Either.left(gpu.left) : Either.right({ gpu: gpu.right }) +} + +const applyBooleanStep = ( + input: ApplyCreateStepInput, + key: "runUp" | "enableMcpPlaywright" | "force" +): Either.Either>, ParseError> => { + const value = parseYesDefault(input.buffer, input.currentDefaults[key]) + return Match.value(key).pipe( + Match.when("runUp", () => Either.right({ runUp: value })), + Match.when("enableMcpPlaywright", () => Either.right({ enableMcpPlaywright: value })), + Match.when("force", () => Either.right({ force: value })), + Match.exhaustive + ) +} + +const applyCreateStep = ( + input: ApplyCreateStepInput +): Either.Either>, ParseError> => + Match.value(input.step).pipe( + Match.when("repoUrl", () => applyRepoStep(input)), + Match.when("repoRef", () => applyTextStep(input, "repoRef")), + Match.when("outDir", () => applyTextStep(input, "outDir")), + Match.when("cpuLimit", () => applyTextStep(input, "cpuLimit")), + Match.when("ramLimit", () => applyTextStep(input, "ramLimit")), + Match.when("gpu", () => applyGpuStep(input)), + Match.when("runUp", () => applyBooleanStep(input, "runUp")), + Match.when("mcpPlaywright", () => applyBooleanStep(input, "enableMcpPlaywright")), + Match.when("force", () => applyBooleanStep(input, "force")), + Match.exhaustive + ) + +/** + * Applies the active create-flow buffer to a fresh partial values object. + * + * @pure true + * @invariant result = previous values plus exactly the decoded active step update + * @complexity O(k) where k = number of stored create inputs + */ +// CHANGE: apply create-flow step buffers without mutating accumulated values +// WHY: CORE state transitions must be referentially transparent for tests and reviewability +// QUOTE(ТЗ): "return immutable partial updates and merge immutably" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v,u: apply(v,u) = merge(v, delta(u)) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: input view.values is never mutated +// COMPLEXITY: O(k) where k = number of stored create inputs +export const applyCreateBufferToValues = ( + context: CreateFlowContext, + view: CreateFlowView, + step: CreateStep +): Either.Either>, ParseError> => { + const buffer = view.buffer.trim() + const currentDefaults = resolveCreateInputs(context, view.values) + const updated = applyCreateStep({ + step, + buffer, + currentDefaults, + context + }) + return Either.isLeft(updated) ? Either.left(updated.left) : Either.right({ ...view.values, ...updated.right }) +} diff --git a/packages/app/src/docker-git/menu-create-steps.ts b/packages/app/src/docker-git/menu-create-steps.ts new file mode 100644 index 00000000..b6a656d8 --- /dev/null +++ b/packages/app/src/docker-git/menu-create-steps.ts @@ -0,0 +1,71 @@ +import { Match } from "effect" + +import type { CreateInputs, CreateStep } from "./menu-types.js" +import { createSteps } from "./menu-types.js" + +const hasOwn = (values: Partial, key: keyof CreateInputs): boolean => + Object.prototype.hasOwnProperty.call(values, key) + +const isCreateStepSatisfied = ( + step: CreateStep, + values: Partial +): boolean => + Match.value(step).pipe( + Match.when("repoUrl", () => true), + Match.when("repoRef", () => true), + Match.when("outDir", () => true), + Match.when("cpuLimit", () => hasOwn(values, "cpuLimit")), + Match.when("ramLimit", () => hasOwn(values, "ramLimit")), + Match.when("gpu", () => hasOwn(values, "gpu")), + Match.when("runUp", () => hasOwn(values, "runUp")), + Match.when("mcpPlaywright", () => hasOwn(values, "enableMcpPlaywright")), + Match.when("force", () => hasOwn(values, "force")), + Match.exhaustive + ) + +/** + * Resolves create-mode prompts that remain after committed values. + * + * @pure true + * @invariant repoUrl is always the first step + * @complexity O(s) where s = number of create steps + */ +// CHANGE: derive the active create-mode step list from satisfied inputs +// WHY: inline CLI flags and applied settings should remove already-satisfied prompts +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: steps(v)[0] = repoUrl +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: only unsatisfied settings remain after repoUrl +// COMPLEXITY: O(s) where s = number of create steps +export const resolveCreateFlowSteps = ( + values: Partial +): ReadonlyArray => [ + "repoUrl", + ...createSteps + .filter((step) => step !== "repoUrl") + .filter((step) => !isCreateStepSatisfied(step, values)) +] + +/** + * Resolves browser display-settings rows. + * + * @pure true + * @invariant all create steps remain visible + * @complexity O(1) + */ +// CHANGE: keep browser display settings independent from committed values +// WHY: browser settings must allow revisiting already-applied rows +// QUOTE(ТЗ): "Add concise but compliant TSDoc + functional comments" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: forall v: displaySteps(v) = createSteps +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: display mode never filters satisfied settings +// COMPLEXITY: O(1) +export const resolveCreateDisplaySteps = ( + _values: Partial = {} +): ReadonlyArray => createSteps diff --git a/packages/app/src/web/actions-project-menu-commands.ts b/packages/app/src/web/actions-project-menu-commands.ts new file mode 100644 index 00000000..40f61e73 --- /dev/null +++ b/packages/app/src/web/actions-project-menu-commands.ts @@ -0,0 +1,140 @@ +import { + type BrowserActionContext, + confirmAction, + projectActionLabel, + requireSelectedProjectId, + withBusy +} from "./actions-shared.js" +import { applyAllProjects, deleteProject, downAllProjects, downProject, loadProjectLogs, loadProjectPs } from "./api.js" +import type { BrowserMenuTag } from "./menu.js" +import { outputScreen } from "./screen.js" + +const runProjectOutputAction = ( + context: BrowserActionContext, + effect: (projectId: string) => ReturnType, + label: string, + successMessage: string +) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + return + } + withBusy({ + context, + effect: effect(projectId), + label, + onSuccess: (output) => { + context.setOutput(output) + context.setActiveScreen(outputScreen()) + context.setMessage(successMessage) + } + }) +} + +const runDownProject = (context: BrowserActionContext) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null || !confirmAction(`Stop ${projectActionLabel(context)}?`)) { + return + } + withBusy({ + context, + effect: downProject(projectId), + label: "Stopping project", + onSuccess: () => { + context.reloadDashboard() + context.setMessage("Project stopped.") + } + }) +} + +const runDeleteProject = (context: BrowserActionContext) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null || !confirmAction(`Delete ${projectActionLabel(context)}?`)) { + return + } + withBusy({ + context, + effect: deleteProject(projectId), + label: "Deleting project", + onSuccess: () => { + context.reloadDashboard() + context.setOutput("") + context.setProjectAuthSnapshot(null) + context.setSelectedProject(null) + context.setSelectedProjectId(null) + context.setMessage("Project deleted.") + } + }) +} + +const runDownAllProjects = (context: BrowserActionContext) => { + if (!confirmAction("Stop all docker-git projects?")) { + return + } + withBusy({ + context, + effect: downAllProjects(), + label: "Stopping all projects", + onSuccess: () => { + context.reloadDashboard() + context.setMessage("All projects were asked to stop.") + } + }) +} + +export const runApplyAllProjects = (context: BrowserActionContext) => { + if (!confirmAction("Apply docker-git config to all projects?")) { + return + } + withBusy({ + context, + effect: applyAllProjects(false), + label: "Applying all projects", + onSuccess: () => { + context.reloadDashboard() + context.setMessage("Applied docker-git config to all projects.") + } + }) +} + +export const runProjectMenuCommand = ( + currentMenu: Exclude< + BrowserMenuTag, + | "Auth" + | "ProjectAuth" + | "Browser" + | "Create" + | "Databases" + | "Select" + | "Info" + | "Ports" + | "Prompts" + | "Share" + | "Skills" + | "Tasks" + >, + context: BrowserActionContext +) => { + if (currentMenu === "Status") { + runProjectOutputAction(context, loadProjectPs, "Loading docker compose ps", "docker compose ps loaded.") + return + } + if (currentMenu === "Logs") { + runProjectOutputAction(context, loadProjectLogs, "Loading logs", "Logs loaded.") + return + } + if (currentMenu === "Down") { + runDownProject(context) + return + } + if (currentMenu === "DownAll") { + runDownAllProjects(context) + return + } + if (currentMenu === "Delete") { + runDeleteProject(context) + return + } + globalThis.close() + context.setMessage("Quit requested. If the browser blocked window.close(), close the tab manually.") +} diff --git a/packages/app/src/web/actions-project-terminal.ts b/packages/app/src/web/actions-project-terminal.ts new file mode 100644 index 00000000..fbb4f4fe --- /dev/null +++ b/packages/app/src/web/actions-project-terminal.ts @@ -0,0 +1,373 @@ +import { readEventPayloadString } from "./actions-event-payload.js" +import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" +import { type BrowserActionContext, withBusy } from "./actions-shared.js" +import type { StartProjectTerminalSessionAccepted } from "./api-types.js" +import { type ApiEvent, loadProjectTerminalSession, startProjectTerminalSession } from "./api.js" +import { openProjectEventStream } from "./project-events.js" +import { buildPendingProjectActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" + +type ProjectActiveTerminalSessionArgs = Omit< + Parameters[0], + "onExit" | "onReady" +> + +export type ConnectProjectLifecycle = { + readonly onFailure?: (error: string) => void + readonly onSuccess?: (sessionId: string) => void +} + +type ConnectProjectRuntime = { + attachedSessionId: string | null + pendingSessionFinalized: boolean + readonly pendingSessionCreatedAt: string + readonly pendingSessionId: string + readonly projectDisplayName: string + readonly projectId: string + readonly projectKey: string + readonly lifecycle: ConnectProjectLifecycle + stream: ReturnType | null +} + +const missingProjectKeyMessage = (projectId: string): string => `Project key is missing for ${projectId}.` + +const resolveProjectTerminalKey = ( + projectId: string, + context: BrowserActionContext, + projectKey?: string +): string | null => { + if (projectKey !== undefined && projectKey.trim().length > 0) { + return projectKey + } + if (context.selectedProjectId === projectId && context.selectedProjectKey !== null) { + return context.selectedProjectKey + } + context.setMessage(missingProjectKeyMessage(projectId)) + return null +} + +const randomHex = (bytes: number): string => { + const webCrypto = "crypto" in globalThis ? globalThis.crypto : null + if (webCrypto !== null && typeof webCrypto.getRandomValues === "function") { + const values = new Uint8Array(bytes) + webCrypto.getRandomValues(values) + return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") + } + + return Date.now().toString(16).padStart(bytes * 2, "0").slice(0, bytes * 2) +} + +const formatUuidV4 = (hex: string): string => { + const value = hex.padEnd(32, "0").slice(0, 32) + const variant = ((Number.parseInt(value.slice(16, 18), 16) & 0x3F) | 0x80) + .toString(16) + .padStart(2, "0") + const segments = [ + value.slice(0, 8), + value.slice(8, 12), + `4${value.slice(13, 16)}`, + `${variant}${value.slice(18, 20)}`, + value.slice(20, 32) + ] + return segments.join("-") +} + +const createPendingTerminalSessionId = (): string => { + const webCrypto = "crypto" in globalThis ? globalThis.crypto : null + if (webCrypto !== null && typeof webCrypto.randomUUID === "function") { + return webCrypto.randomUUID() + } + + return formatUuidV4(randomHex(16)) +} + +const addProjectTerminalSession = ( + context: BrowserActionContext, + args: ProjectActiveTerminalSessionArgs +) => { + context.addTerminalSession(buildProjectActiveTerminalSession({ + ...args, + onExit: context.reloadDashboard, + onReady: context.reloadDashboard + })) +} + +const readTerminalSessionCreatedId = ( + event: ApiEvent, + requestId: string +): string | null => { + if (event.type !== "project.ssh.session") { + return null + } + if (readEventPayloadString(event, "phase") !== "created") { + return null + } + if (readEventPayloadString(event, "requestId") !== requestId) { + return null + } + return readEventPayloadString(event, "sessionId") +} + +const readTerminalStartupFailure = ( + event: ApiEvent, + requestId: string +): string | null => { + if (event.type !== "project.deployment.status") { + return null + } + if (readEventPayloadString(event, "phase") !== "ssh.failed") { + return null + } + if (readEventPayloadString(event, "requestId") !== requestId) { + return null + } + return readEventPayloadString(event, "message") ?? "SSH session startup failed." +} + +const createConnectProjectRuntime = ( + projectId: string, + context: BrowserActionContext, + projectKey: string | undefined, + lifecycle: ConnectProjectLifecycle +): ConnectProjectRuntime | null => { + const resolvedProjectKey = resolveProjectTerminalKey(projectId, context, projectKey) + if (resolvedProjectKey === null) { + return null + } + return { + attachedSessionId: null, + lifecycle, + pendingSessionCreatedAt: new Date().toISOString(), + pendingSessionFinalized: false, + pendingSessionId: createPendingTerminalSessionId(), + projectDisplayName: context.selectedProjectId === projectId && context.selectedProjectName !== null + ? context.selectedProjectName + : resolvedProjectKey, + projectId, + projectKey: resolvedProjectKey, + stream: null + } +} + +const renderPendingTerminalSession = ( + context: BrowserActionContext, + runtime: ConnectProjectRuntime, + message?: string, + phase: "connecting" | "error" = "connecting" +) => + buildPendingProjectActiveTerminalSession({ + createdAt: runtime.pendingSessionCreatedAt, + onExit: context.reloadDashboard, + pendingSessionId: runtime.pendingSessionId, + phase, + projectDisplayName: runtime.projectDisplayName, + projectId: runtime.projectId, + projectKey: runtime.projectKey, + ...(message === undefined ? {} : { message }) + }) + +const closeStream = (runtime: ConnectProjectRuntime): void => { + runtime.stream?.close() + runtime.stream = null +} + +const showPendingTerminalError = ( + context: BrowserActionContext, + runtime: ConnectProjectRuntime, + error: string +): boolean => { + const wasFinalized = runtime.pendingSessionFinalized + if (wasFinalized) { + return false + } + runtime.pendingSessionFinalized = true + runtime.lifecycle.onFailure?.(error) + appendOutputLine(context, `[error] ${error}`) + context.addTerminalSession(renderPendingTerminalSession(context, runtime, error, "error")) + return true +} + +const attachCreatedSession = ( + context: BrowserActionContext, + runtime: ConnectProjectRuntime, + sessionId: string +): void => { + if (runtime.attachedSessionId !== null) { + return + } + runtime.attachedSessionId = sessionId + withBusy({ + context, + effect: loadProjectTerminalSession(runtime.projectKey, sessionId), + label: "Attaching SSH terminal", + onFailure: (error) => { + showPendingTerminalError(context, runtime, error) + closeStream(runtime) + }, + onSuccess: (session) => { + const wasFinalized = runtime.pendingSessionFinalized + runtime.pendingSessionFinalized = true + context.reloadDashboard() + context.closeTerminalSession(runtime.pendingSessionId) + addProjectTerminalSession(context, { ...runtime, session }) + context.setMessage(`Project is ready. SSH terminal is connecting for ${runtime.projectDisplayName}.`) + if (!wasFinalized) { + runtime.lifecycle.onSuccess?.(session.id) + } + closeStream(runtime) + } + }) +} + +const handleProjectEvent = ( + context: BrowserActionContext, + runtime: ConnectProjectRuntime, + requestId: string, + event: ApiEvent +): void => { + const failure = readTerminalStartupFailure(event, requestId) + if (failure !== null) { + if (showPendingTerminalError(context, runtime, failure)) { + context.setMessage(failure) + } + closeStream(runtime) + return + } + + const sessionId = readTerminalSessionCreatedId(event, requestId) + if (sessionId !== null) { + attachCreatedSession(context, runtime, sessionId) + } +} + +const openTerminalEventStream = ( + context: BrowserActionContext, + runtime: ConnectProjectRuntime, + accepted: StartProjectTerminalSessionAccepted +): void => { + const handleOutputLine = appendOutputLineHandler(context) + runtime.stream = openProjectEventStream(runtime.projectId, { + initialCursor: accepted.cursor, + onEvent: (event) => { + handleProjectEvent(context, runtime, accepted.requestId, event) + }, + onLine: (line) => { + handleOutputLine(line) + if (!runtime.pendingSessionFinalized) { + context.addTerminalSession(renderPendingTerminalSession(context, runtime, line)) + } + }, + onRateLimit: () => { + notifyProjectEventRateLimit(context) + } + }) +} + +const startTerminalSession = (context: BrowserActionContext, runtime: ConnectProjectRuntime): void => { + withBusy({ + context, + effect: startProjectTerminalSession(runtime.projectKey, runtime.pendingSessionId), + label: "Opening SSH terminal", + onFailure: (error) => { + showPendingTerminalError(context, runtime, error) + }, + onSuccess: (accepted) => { + appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) + context.setMessage(`SSH terminal startup is running for ${runtime.projectDisplayName}. Live logs are open.`) + openTerminalEventStream(context, runtime, accepted) + } + }) +} + +/** + * Starts a project SSH terminal session and attaches it after the backend emits the created event. + * + * @param projectId - Project identifier to connect. + * @param context - Browser action context used for state updates and terminal tabs. + * @param projectKey - Optional project key override used by deep links and menu actions. + * @param lifecycle - Optional callbacks fired after terminal attach success or terminal startup failure. + * @returns Nothing; state changes are emitted through `context` and lifecycle callbacks. + * @pure false + * @effect BrowserActionContext, backend start/load terminal Effects, project event stream. + * @invariant Lifecycle success fires only after `loadProjectTerminalSession` succeeds. + * @precondition `projectId` identifies a known project and `context` is a live browser action context. + * @postcondition Failure before attach invokes `lifecycle.onFailure`; successful attach invokes `lifecycle.onSuccess`. + * @complexity O(1) setup plus O(e) event handling where e is project stream events. + * @throws Never + */ +// CHANGE: report project SSH connect outcomes separately from request startup +// WHY: browser deep-links must remain retryable until a real terminal attach succeeds +// QUOTE(ТЗ): "deep-link handled only after the connectProjectById pipeline signals a real success" +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: attachSuccess(session) -> handled; startFailure(error) -> retryable +// PURITY: SHELL +// EFFECT: Effect plus project event stream callbacks +// INVARIANT: each ConnectProjectRuntime finalizes its lifecycle outcome at most once +// COMPLEXITY: O(1) setup plus O(e) event handling where e is project stream events +export const connectProjectById = ( + projectId: string, + context: BrowserActionContext, + projectKey?: string, + lifecycle: ConnectProjectLifecycle = {} +) => { + const runtime = createConnectProjectRuntime(projectId, context, projectKey, lifecycle) + if (runtime === null) { + lifecycle.onFailure?.(missingProjectKeyMessage(projectId)) + return + } + context.setSelectedProjectId(projectId) + context.setOutput("") + appendOutputLine(context, "[ssh.prepare] Preparing SSH session") + context.addTerminalSession(renderPendingTerminalSession(context, runtime)) + startTerminalSession(context, runtime) +} + +/** + * Attaches an existing project SSH terminal session to the browser terminal list. + * + * @param projectId - Project identifier whose session should become selected. + * @param projectKey - Project key used to resolve the terminal session API route. + * @param projectDisplayName - Human-readable project name used in the attached-session message. + * @param sessionId - Existing backend terminal session identifier to load and attach. + * @param context - Browser action context used for selection, busy state, and terminal tab updates. + * @returns Nothing; state changes are emitted through `context`. + * @pure false + * @effect BrowserActionContext and `loadProjectTerminalSession` Effect. + * @invariant A terminal tab is added only after `loadProjectTerminalSession` succeeds. + * @precondition `sessionId` names an existing terminal session for the resolved project key. + * @postcondition On success, the project is selected and the loaded session is added to terminal tabs. + * @complexity O(1) setup plus O(1) backend session load. + * @throws Never + */ +// CHANGE: document the existing-session attach shell contract +// WHY: CodeRabbit review requires explicit side-effect and invariant metadata for exported TS functions +// QUOTE(ТЗ): "Add a TSDoc comment block above the exported function attachProjectTerminalById" +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: resolvedProjectKey != null && load(sessionId) succeeds -> terminalTab(sessionId) is added +// PURITY: SHELL +// EFFECT: Effect via loadProjectTerminalSession plus BrowserActionContext mutations +// INVARIANT: no terminal tab is added when project key resolution fails or session load fails +// COMPLEXITY: O(1) setup plus O(1) backend session load +export const attachProjectTerminalById = ( + projectId: string, + projectKey: string, + projectDisplayName: string, + sessionId: string, + context: BrowserActionContext +) => { + const resolvedProjectKey = resolveProjectTerminalKey(projectId, context, projectKey) + if (resolvedProjectKey === null) { + return + } + context.setSelectedProjectId(projectId) + withBusy({ + context, + effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), + label: "Attaching SSH terminal", + onSuccess: (session) => { + addProjectTerminalSession(context, { projectDisplayName, projectId, projectKey: resolvedProjectKey, session }) + context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) + } + }) +} diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index b8de9301..58a9f2d9 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -1,8 +1,8 @@ import { openSelectedProjectBrowser } from "./actions-browser.js" import { openSelectedProjectDatabaseEditor } from "./actions-databases.js" -import { readEventPayloadString } from "./actions-event-payload.js" -import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { openSelectedProjectPort } from "./actions-port-forwards.js" +import { runProjectMenuCommand } from "./actions-project-menu-commands.js" +import { connectProjectById } from "./actions-project-terminal.js" import { loadSelectedProjectPrompts } from "./actions-prompts.js" import { type BrowserActionContext, @@ -15,25 +15,16 @@ import { } from "./actions-shared.js" import { loadSelectedProjectSkills } from "./actions-skills.js" import { loadSelectedProjectTasks } from "./actions-tasks.js" -import { - type ApiEvent, - applyAllProjects, - applyProject, - deleteProject, - downAllProjects, - downProject, - loadProjectDetails, - loadProjectLogs, - loadProjectPs, - loadProjectTerminalSession, - startProjectTerminalSession -} from "./api.js" +import { applyProject, loadProjectDetails } from "./api.js" import type { BrowserMenuTag } from "./menu.js" -import { openProjectEventStream } from "./project-events.js" -import { outputScreen } from "./screen.js" -import { buildPendingProjectActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" export { submitCreateInputs } from "./actions-project-create.js" +export { runApplyAllProjects } from "./actions-project-menu-commands.js" +export { attachProjectTerminalById, connectProjectById } from "./actions-project-terminal.js" + +type ProjectMenuActionTag = Exclude +type ProjectMenuCommandTag = Parameters[0] +type ProjectMenuAction = (context: BrowserActionContext) => void export const loadSelectedProjectInfo = ( context: BrowserActionContext, @@ -66,224 +57,6 @@ export const connectSelectedProject = (context: BrowserActionContext) => { connectProjectById(projectId, context, projectKey) } -const resolveProjectTerminalKey = ( - projectId: string, - context: BrowserActionContext, - projectKey?: string -): string | null => { - if (projectKey !== undefined && projectKey.trim().length > 0) { - return projectKey - } - if (context.selectedProjectId === projectId && context.selectedProjectKey !== null) { - return context.selectedProjectKey - } - context.setMessage(`Project key is missing for ${projectId}.`) - return null -} - -const randomHex = (bytes: number): string => { - if (typeof globalThis.crypto.getRandomValues === "function") { - const values = new Uint8Array(bytes) - globalThis.crypto.getRandomValues(values) - return Array.from(values, (value) => value.toString(16).padStart(2, "0")).join("") - } - - return Date.now().toString(16).padStart(bytes * 2, "0").slice(0, bytes * 2) -} - -/** - * Converts a hex buffer into an RFC 4122-shaped UUID v4 string. - * - * @pure true - * @precondition hex contains hexadecimal characters and may be shorter or longer than 32 chars. - * @postcondition result length is 36, result[14] is "4", and result[19] has RFC variant bits 10xx. - * @invariant output preserves the padded/truncated 128-bit payload except version and variant bits. - * @complexity O(n) time for input normalization, O(1) output space. - */ -const formatUuidV4 = (hex: string): string => { - const value = hex.padEnd(32, "0").slice(0, 32) - const variant = ((Number.parseInt(value.slice(16, 18), 16) & 0x3F) | 0x80) - .toString(16) - .padStart(2, "0") - const segments = [ - value.slice(0, 8), - value.slice(8, 12), - `4${value.slice(13, 16)}`, - `${variant}${value.slice(18, 20)}`, - value.slice(20, 32) - ] - return segments.join("-") -} - -const createPendingTerminalSessionId = (): string => { - if (typeof globalThis.crypto.randomUUID === "function") { - return globalThis.crypto.randomUUID() - } - - return formatUuidV4(randomHex(16)) -} - -type ProjectActiveTerminalSessionArgs = Omit< - Parameters[0], - "onExit" | "onReady" -> - -const addProjectTerminalSession = ( - context: BrowserActionContext, - args: ProjectActiveTerminalSessionArgs -) => { - context.addTerminalSession(buildProjectActiveTerminalSession({ - ...args, - onExit: context.reloadDashboard, - onReady: context.reloadDashboard - })) -} - -const readTerminalSessionCreatedId = ( - event: ApiEvent, - requestId: string -): string | null => { - if (event.type !== "project.ssh.session") { - return null - } - if (readEventPayloadString(event, "phase") !== "created") { - return null - } - if (readEventPayloadString(event, "requestId") !== requestId) { - return null - } - return readEventPayloadString(event, "sessionId") -} - -const readTerminalStartupFailure = ( - event: ApiEvent, - requestId: string -): string | null => { - if (event.type !== "project.deployment.status") { - return null - } - if (readEventPayloadString(event, "phase") !== "ssh.failed") { - return null - } - if (readEventPayloadString(event, "requestId") !== requestId) { - return null - } - return readEventPayloadString(event, "message") ?? "SSH session startup failed." -} - -export const connectProjectById = ( - projectId: string, - context: BrowserActionContext, - projectKey?: string -) => { - const resolvedProjectKey = resolveProjectTerminalKey(projectId, context, projectKey) - if (resolvedProjectKey === null) { - return - } - const pendingSessionId = createPendingTerminalSessionId() - const pendingSessionCreatedAt = new Date().toISOString() - const projectDisplayName = context.selectedProjectId === projectId && context.selectedProjectName !== null - ? context.selectedProjectName - : resolvedProjectKey - let pendingSessionFinalized = false - let attachedSessionId: string | null = null - const handleOutputLine = appendOutputLineHandler(context) - const renderPendingTerminalSession = ( - message?: string, - phase: "connecting" | "error" = "connecting" - ) => - buildPendingProjectActiveTerminalSession({ - createdAt: pendingSessionCreatedAt, - onExit: context.reloadDashboard, - pendingSessionId, - phase, - projectDisplayName, - projectId, - projectKey: resolvedProjectKey, - ...(message === undefined ? {} : { message }) - }) - context.setSelectedProjectId(projectId) - context.setOutput("") - appendOutputLine(context, "[ssh.prepare] Preparing SSH session") - context.addTerminalSession(renderPendingTerminalSession()) - let stream: ReturnType | null = null - const closeStream = () => { - stream?.close() - stream = null - } - const showPendingTerminalError = (error: string) => { - pendingSessionFinalized = true - appendOutputLine(context, `[error] ${error}`) - context.addTerminalSession(renderPendingTerminalSession(error, "error")) - } - const attachCreatedSession = (sessionId: string) => { - if (attachedSessionId !== null) { - return - } - attachedSessionId = sessionId - withBusy({ - context, - effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), - label: "Attaching SSH terminal", - onFailure: (error) => { - showPendingTerminalError(error) - closeStream() - }, - onSuccess: (session) => { - pendingSessionFinalized = true - context.reloadDashboard() - context.closeTerminalSession(pendingSessionId) - addProjectTerminalSession(context, { - projectDisplayName, - projectId, - projectKey: resolvedProjectKey, - session - }) - context.setMessage(`Project is ready. SSH terminal is connecting for ${projectDisplayName}.`) - closeStream() - } - }) - } - withBusy({ - context, - effect: startProjectTerminalSession(resolvedProjectKey, pendingSessionId), - label: "Opening SSH terminal", - onFailure: (error) => { - showPendingTerminalError(error) - }, - onSuccess: (accepted) => { - appendOutputLine(context, `[ssh.prepare] SSH terminal request accepted (${accepted.requestId})`) - context.setMessage(`SSH terminal startup is running for ${projectDisplayName}. Live logs are open.`) - stream = openProjectEventStream(projectId, { - initialCursor: accepted.cursor, - onEvent: (event) => { - const failure = readTerminalStartupFailure(event, accepted.requestId) - if (failure !== null) { - showPendingTerminalError(failure) - context.setMessage(failure) - closeStream() - return - } - - const sessionId = readTerminalSessionCreatedId(event, accepted.requestId) - if (sessionId !== null) { - attachCreatedSession(sessionId) - } - }, - onLine: (line) => { - handleOutputLine(line) - if (!pendingSessionFinalized) { - context.addTerminalSession(renderPendingTerminalSession(line)) - } - }, - onRateLimit: () => { - notifyProjectEventRateLimit(context) - } - }) - } - }) -} - const applyProjectConfirmMessage = ( label: string, gpu?: "none" | "all" @@ -327,207 +100,40 @@ export const applySelectedProject = ( applyProjectById(projectId, context, gpu) } -export const attachProjectTerminalById = ( - projectId: string, - projectKey: string, - projectDisplayName: string, - sessionId: string, - context: BrowserActionContext -) => { - const resolvedProjectKey = resolveProjectTerminalKey(projectId, context, projectKey) - if (resolvedProjectKey === null) { - return - } - context.setSelectedProjectId(projectId) - withBusy({ - context, - effect: loadProjectTerminalSession(resolvedProjectKey, sessionId), - label: "Attaching SSH terminal", - onSuccess: (session) => { - addProjectTerminalSession(context, { - projectDisplayName, - projectId, - projectKey: resolvedProjectKey, - session - }) - context.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) - } - }) -} - -const runProjectOutputAction = ( - context: BrowserActionContext, - effect: (projectId: string) => ReturnType, - label: string, - successMessage: string -) => { - const projectId = requireSelectedProjectId(context) - if (projectId === null) { - return - } - withBusy({ - context, - effect: effect(projectId), - label, - onSuccess: (output) => { - context.setOutput(output) - context.setActiveScreen(outputScreen()) - context.setMessage(successMessage) - } - }) -} - -const runDownProject = (context: BrowserActionContext) => { - const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Stop ${projectActionLabel(context)}?`)) { - return - } - withBusy({ - context, - effect: downProject(projectId), - label: "Stopping project", - onSuccess: () => { - context.reloadDashboard() - context.setMessage("Project stopped.") - } - }) +const setCreateModeMessage = (context: BrowserActionContext): void => { + context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.") } -const runDeleteProject = (context: BrowserActionContext) => { - const projectId = requireSelectedProjectId(context) - if (projectId === null || !confirmAction(`Delete ${projectActionLabel(context)}?`)) { - return - } - withBusy({ - context, - effect: deleteProject(projectId), - label: "Deleting project", - onSuccess: () => { - context.reloadDashboard() - context.setOutput("") - context.setProjectAuthSnapshot(null) - context.setSelectedProject(null) - context.setSelectedProjectId(null) - context.setMessage("Project deleted.") - } - }) +const setShareModeMessage = (context: BrowserActionContext): void => { + context.setMessage("Share screen is active.") } -const runDownAllProjects = (context: BrowserActionContext) => { - if (!confirmAction("Stop all docker-git projects?")) { - return - } - withBusy({ - context, - effect: downAllProjects(), - label: "Stopping all projects", - onSuccess: () => { - context.reloadDashboard() - context.setMessage("All projects were asked to stop.") - } - }) +const runProjectMenuCommandAction = (currentMenu: ProjectMenuCommandTag): ProjectMenuAction => (context) => { + runProjectMenuCommand(currentMenu, context) } -export const runApplyAllProjects = (context: BrowserActionContext) => { - if (!confirmAction("Apply docker-git config to all projects?")) { - return - } - withBusy({ - context, - effect: applyAllProjects(false), - label: "Applying all projects", - onSuccess: () => { - context.reloadDashboard() - context.setMessage("Applied docker-git config to all projects.") - } - }) +const projectMenuActions: Record = { + Browser: openSelectedProjectBrowser, + Create: setCreateModeMessage, + Databases: openSelectedProjectDatabaseEditor, + Delete: runProjectMenuCommandAction("Delete"), + Down: runProjectMenuCommandAction("Down"), + DownAll: runProjectMenuCommandAction("DownAll"), + Info: loadSelectedProjectInfo, + Logs: runProjectMenuCommandAction("Logs"), + Ports: openSelectedProjectPort, + Prompts: loadSelectedProjectPrompts, + Quit: runProjectMenuCommandAction("Quit"), + Select: connectSelectedProject, + Share: setShareModeMessage, + Skills: loadSelectedProjectSkills, + Status: runProjectMenuCommandAction("Status"), + Tasks: loadSelectedProjectTasks } export const runProjectMenuAction = ( currentMenu: Exclude, context: BrowserActionContext ) => { - if (currentMenu === "Create") { - context.setMessage("Create mode is active. Paste URL or URL + flags, then choose Quick Create or Settings.") - return - } - if (currentMenu === "Select") { - connectSelectedProject(context) - return - } - if (currentMenu === "Info") { - loadSelectedProjectInfo(context) - return - } - if (currentMenu === "Ports") { - openSelectedProjectPort(context) - return - } - if (currentMenu === "Databases") { - openSelectedProjectDatabaseEditor(context) - return - } - if (currentMenu === "Browser") { - openSelectedProjectBrowser(context) - return - } - if (currentMenu === "Tasks") { - loadSelectedProjectTasks(context) - return - } - if (currentMenu === "Prompts") { - loadSelectedProjectPrompts(context) - return - } - if (currentMenu === "Skills") { - loadSelectedProjectSkills(context) - return - } - if (currentMenu === "Share") { - context.setMessage("Share screen is active.") - return - } - runProjectMenuCommand(currentMenu, context) -} - -const runProjectMenuCommand = ( - currentMenu: Exclude< - BrowserMenuTag, - | "Auth" - | "ProjectAuth" - | "Browser" - | "Create" - | "Databases" - | "Select" - | "Info" - | "Ports" - | "Prompts" - | "Share" - | "Skills" - | "Tasks" - >, - context: BrowserActionContext -) => { - if (currentMenu === "Status") { - runProjectOutputAction(context, loadProjectPs, "Loading docker compose ps", "docker compose ps loaded.") - return - } - if (currentMenu === "Logs") { - runProjectOutputAction(context, loadProjectLogs, "Loading logs", "Logs loaded.") - return - } - if (currentMenu === "Down") { - runDownProject(context) - return - } - if (currentMenu === "DownAll") { - runDownAllProjects(context) - return - } - if (currentMenu === "Delete") { - runDeleteProject(context) - return - } - globalThis.close() - context.setMessage("Quit requested. If the browser blocked window.close(), close the tab manually.") + projectMenuActions[currentMenu](context) } diff --git a/packages/app/src/web/app-ready-main-panel-labels.ts b/packages/app/src/web/app-ready-main-panel-labels.ts new file mode 100644 index 00000000..b4f3a2ed --- /dev/null +++ b/packages/app/src/web/app-ready-main-panel-labels.ts @@ -0,0 +1,46 @@ +import type { MainPanelsProps } from "./app-ready-main-panels.js" + +const actionLabels: Record = { + Auth: "Run", + Browser: "Open browser", + Create: "Run", + Databases: "Open SQL editor", + Delete: "Delete project", + Down: "Stop project", + DownAll: "Run", + Info: "Run", + Logs: "Load logs", + Ports: "Open port", + ProjectAuth: "Open project auth", + Prompts: "Refresh prompts", + Quit: "Run", + Select: "Open SSH", + Share: "Start tunnel", + Skills: "Refresh skills", + Status: "Load status", + Tasks: "Refresh tasks" +} + +export const actionLabel = (menu: MainPanelsProps["currentMenu"]): string => actionLabels[menu] + +export const screenTitle = (props: Pick): string => { + if (props.activeScreen.tag === "Create") { + return "docker-git / Create" + } + if (props.activeScreen.tag === "Auth") { + return "docker-git / Auth profiles" + } + if (props.activeScreen.tag === "Share") { + return "docker-git / Share" + } + if (props.activeScreen.tag === "ProjectAuth") { + return "docker-git / Project auth" + } + if (props.activeScreen.tag === "Output") { + return props.currentMenu === "Logs" ? "docker compose logs" : "docker compose ps" + } + if (props.activeScreen.tag === "ProjectPicker") { + return `docker-git / ${actionLabel(props.currentMenu)}` + } + return "docker-git" +} diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index 8af488ff..d362fc63 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -1,84 +1,19 @@ import type { JSX } from "react" -import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import { ContentScreen } from "./app-ready-content-screen.js" import type { ReadyLayoutProps } from "./app-ready-layout.js" +import { screenTitle } from "./app-ready-main-panel-labels.js" import { MainMenuScreen } from "./app-ready-menu-screen.js" +import { ProjectPickerScreen } from "./app-ready-project-picker-screen.js" import { ScreenFrame } from "./app-ready-screen-frame.js" import { TerminalScreen } from "./app-ready-terminal-screen.js" -import { Box, Text } from "./elements.js" -import { BrowserPanel } from "./panel-browser.js" -import { ContentPanel } from "./panel-content.js" -import { DatabasePanel } from "./panel-databases.js" -import { PortForwardPanel } from "./panel-port-forwards.js" -import { ProjectDetailsPanel } from "./panel-project-details.js" -import { ProjectPromptsPanel } from "./panel-project-prompts.js" -import { ProjectSkillsPanel } from "./panel-project-skills.js" +import { Box } from "./elements.js" import { SharePanel } from "./panel-share.js" -import { TaskPanel } from "./panel-tasks.js" -import { OutputPanel, ProjectListPanel } from "./panels.js" +import { OutputPanel } from "./panels.js" import { visibleTerminalWorkspaceState } from "./terminal-state.js" export type MainPanelsProps = Omit -const actionLabels: Record = { - Auth: "Run", - Browser: "Open browser", - Create: "Run", - Databases: "Open SQL editor", - Delete: "Delete project", - Down: "Stop project", - DownAll: "Run", - Info: "Run", - Logs: "Load logs", - Ports: "Open port", - ProjectAuth: "Open project auth", - Prompts: "Refresh prompts", - Quit: "Run", - Select: "Open SSH", - Share: "Start tunnel", - Skills: "Refresh skills", - Status: "Load status", - Tasks: "Refresh tasks" -} - -const actionLabel = (menu: MainPanelsProps["currentMenu"]): string => actionLabels[menu] - -type ProjectActionBarProps = Pick< - MainPanelsProps, - | "currentMenu" - | "onApplyAllProjects" - | "onApplySelectedProject" - | "onRunCurrentMenuAction" - | "project" - | "projectBrowser" - | "selectedProjectSummary" -> - -type ProjectGpuMode = NonNullable["gpu"] - -const screenTitle = (props: Pick): string => { - if (props.activeScreen.tag === "Create") { - return "docker-git / Create" - } - if (props.activeScreen.tag === "Auth") { - return "docker-git / Auth profiles" - } - if (props.activeScreen.tag === "Share") { - return "docker-git / Share" - } - if (props.activeScreen.tag === "ProjectAuth") { - return "docker-git / Project auth" - } - if (props.activeScreen.tag === "Output") { - return props.currentMenu === "Logs" ? "docker compose logs" : "docker compose ps" - } - if (props.activeScreen.tag === "ProjectPicker") { - return `docker-git / ${actionLabel(props.currentMenu)}` - } - return "docker-git" -} - const MainMenuRoute = ( props: Pick< MainPanelsProps, @@ -98,356 +33,6 @@ const MainMenuRoute = ( ) -const selectedProjectGpu = ( - { project, selectedProjectSummary }: Pick -): ProjectGpuMode | null => - selectedProjectSummary !== undefined && project !== null && project.id === selectedProjectSummary.id - ? project.gpu - : null - -type ActionButtonProps = { - readonly fg?: string | undefined - readonly label: string - readonly onClick: () => void -} - -const ActionButton = ({ fg = "#78f0a3", label, onClick }: ActionButtonProps): JSX.Element => ( - - {label} - -) - -const ProjectSelectionSummary = ( - { currentMenu, project, selectedProjectSummary }: Pick< - ProjectActionBarProps, - "currentMenu" | "project" | "selectedProjectSummary" - > -): JSX.Element => { - const selectedGpu = selectedProjectGpu({ project, selectedProjectSummary }) - const showGpu = currentMenu === "Select" && selectedProjectSummary !== undefined - - return ( - - - {selectedProjectSummary === undefined ? "No project selected." : selectedProjectSummary.displayName} - - {showGpu ? GPU: {selectedGpu ?? "unknown"} : null} - - ) -} - -const ProjectGpuControls = ( - { onApplySelectedProject, selectedGpu }: Pick & { - readonly selectedGpu: ProjectGpuMode | null - } -): JSX.Element => ( - <> - { - onApplySelectedProject("all") - }} - /> - { - onApplySelectedProject("none") - }} - /> - -) - -const SelectProjectControls = ( - { - currentMenu, - onApplyAllProjects, - onApplySelectedProject, - selectedGpu, - selectedProjectSummary - }: - & Pick< - ProjectActionBarProps, - "currentMenu" | "onApplyAllProjects" | "onApplySelectedProject" | "selectedProjectSummary" - > - & { - readonly selectedGpu: ProjectGpuMode | null - } -): JSX.Element | null => { - if (currentMenu !== "Select") { - return null - } - - return ( - <> - {selectedProjectSummary === undefined - ? null - : } - {selectedProjectSummary === undefined - ? null - : ( - { - onApplySelectedProject() - }} - /> - )} - - - ) -} - -const PrimaryMenuAction = ( - { - currentMenu, - onRunCurrentMenuAction, - projectBrowser, - selectedProjectSummary - }: Pick< - ProjectActionBarProps, - "currentMenu" | "onRunCurrentMenuAction" | "projectBrowser" | "selectedProjectSummary" - > -): JSX.Element => { - const label = actionLabel(currentMenu) - const browserUnavailable = currentMenu === "Browser" && - !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) - - return browserUnavailable - ? {label} - : -} - -const ProjectActionBar = (props: ProjectActionBarProps): JSX.Element => { - const selectedGpu = selectedProjectGpu(props) - - return ( - - - - - - - - ) -} - -const PortForwardDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const BrowserDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const DatabaseDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const ProjectPromptsDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const ProjectSkillsDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const TaskDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const ProjectInfoDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const ProjectContentDetails = (props: MainPanelsProps): JSX.Element => ( - -) - -const ProjectPickerDetails = (props: MainPanelsProps): JSX.Element => { - if (props.currentMenu === "Ports") { - return - } - if (props.currentMenu === "Browser") { - return - } - if (props.currentMenu === "Databases") { - return - } - if (props.currentMenu === "Tasks") { - return - } - if (props.currentMenu === "Prompts") { - return - } - if (props.currentMenu === "Skills") { - return - } - if (props.currentMenu === "ProjectAuth" || props.currentMenu === "Logs" || props.currentMenu === "Status") { - return - } - return -} - -const ProjectPickerScreen = (props: MainPanelsProps): JSX.Element => ( - - - - - - - - - - - -) - const OutputScreen = (props: MainPanelsProps): JSX.Element => ( + +type ProjectGpuMode = NonNullable["gpu"] + +const selectedProjectGpu = ( + { project, selectedProjectSummary }: Pick +): ProjectGpuMode | null => + selectedProjectSummary !== undefined && project !== null && project.id === selectedProjectSummary.id + ? project.gpu + : null + +type ActionButtonProps = { + readonly fg?: string | undefined + readonly label: string + readonly onClick: () => void +} + +const ActionButton = ({ fg = "#78f0a3", label, onClick }: ActionButtonProps): JSX.Element => ( + + {label} + +) + +const ProjectSelectionSummary = ( + props: Pick +): JSX.Element => { + const selectedGpu = selectedProjectGpu(props) + const showGpu = props.currentMenu === "Select" && props.selectedProjectSummary !== undefined + + return ( + + + {props.selectedProjectSummary === undefined ? "No project selected." : props.selectedProjectSummary.displayName} + + {showGpu ? GPU: {selectedGpu ?? "unknown"} : null} + + ) +} + +const ProjectGpuControls = ( + { onApplySelectedProject, selectedGpu }: Pick & { + readonly selectedGpu: ProjectGpuMode | null + } +): JSX.Element => ( + <> + { + onApplySelectedProject("all") + }} + /> + { + onApplySelectedProject("none") + }} + /> + +) + +const SelectProjectControls = ( + props: + & Pick< + ProjectActionBarProps, + "currentMenu" | "onApplyAllProjects" | "onApplySelectedProject" | "selectedProjectSummary" + > + & { + readonly selectedGpu: ProjectGpuMode | null + } +): JSX.Element | null => { + if (props.currentMenu !== "Select") { + return null + } + return ( + <> + {props.selectedProjectSummary === undefined + ? null + : } + {props.selectedProjectSummary === undefined + ? null + : ( + { + props.onApplySelectedProject() + }} + /> + )} + + + ) +} + +const PrimaryMenuAction = ( + props: Pick< + ProjectActionBarProps, + "currentMenu" | "onRunCurrentMenuAction" | "projectBrowser" | "selectedProjectSummary" + > +): JSX.Element => { + const label = actionLabel(props.currentMenu) + const browserUnavailable = props.currentMenu === "Browser" && + !canOpenProjectBrowser(props.projectBrowser, props.selectedProjectSummary?.id ?? null) + + return browserUnavailable + ? {label} + : +} + +export const ProjectActionBar = (props: ProjectActionBarProps): JSX.Element => { + const selectedGpu = selectedProjectGpu(props) + + return ( + + + + + + + + ) +} diff --git a/packages/app/src/web/app-ready-project-picker-screen.tsx b/packages/app/src/web/app-ready-project-picker-screen.tsx new file mode 100644 index 00000000..3a5a29d0 --- /dev/null +++ b/packages/app/src/web/app-ready-project-picker-screen.tsx @@ -0,0 +1,222 @@ +import type { JSX } from "react" + +import { screenTitle } from "./app-ready-main-panel-labels.js" +import type { MainPanelsProps } from "./app-ready-main-panels.js" +import { ProjectActionBar } from "./app-ready-project-action-bar.js" +import { ScreenFrame } from "./app-ready-screen-frame.js" +import { Box } from "./elements.js" +import { BrowserPanel } from "./panel-browser.js" +import { ContentPanel } from "./panel-content.js" +import { DatabasePanel } from "./panel-databases.js" +import { PortForwardPanel } from "./panel-port-forwards.js" +import { ProjectDetailsPanel } from "./panel-project-details.js" +import { ProjectPromptsPanel } from "./panel-project-prompts.js" +import { ProjectSkillsPanel } from "./panel-project-skills.js" +import { TaskPanel } from "./panel-tasks.js" +import { ProjectListPanel } from "./panels.js" + +type ProjectDetailsRenderer = (props: MainPanelsProps) => JSX.Element + +const PortForwardDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const BrowserDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const DatabaseDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const ProjectPromptsDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const ProjectSkillsDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const TaskDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const ProjectInfoDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const ProjectContentDetails = (props: MainPanelsProps): JSX.Element => ( + +) + +const projectDetailsRenderers: Partial> = { + Browser: BrowserDetails, + Databases: DatabaseDetails, + Ports: PortForwardDetails, + Prompts: ProjectPromptsDetails, + Skills: ProjectSkillsDetails, + Tasks: TaskDetails +} + +const projectInfoDetailMenus: ReadonlySet = new Set([ + "Logs", + "ProjectAuth", + "Status" +]) + +const resolveProjectPickerDetails = ( + currentMenu: MainPanelsProps["currentMenu"] +): ProjectDetailsRenderer => { + const renderer = projectDetailsRenderers[currentMenu] + if (renderer !== undefined) { + return renderer + } + return projectInfoDetailMenus.has(currentMenu) ? ProjectInfoDetails : ProjectContentDetails +} + +const ProjectPickerDetails = (props: MainPanelsProps): JSX.Element => { + const Details = resolveProjectPickerDetails(props.currentMenu) + return
+} + +const ProjectPickerDetailsFrame = (props: MainPanelsProps): JSX.Element => ( + + + +) + +const ProjectPickerBody = (props: MainPanelsProps): JSX.Element => ( + + + + + + + +) + +export const ProjectPickerScreen = (props: MainPanelsProps): JSX.Element => ( + + + +) diff --git a/packages/app/src/web/app-ready-ssh-link-core.ts b/packages/app/src/web/app-ready-ssh-link-core.ts new file mode 100644 index 00000000..906926c5 --- /dev/null +++ b/packages/app/src/web/app-ready-ssh-link-core.ts @@ -0,0 +1,335 @@ +import { Either } from "effect" + +import type { TerminalSession } from "./api-types.js" +import type { DashboardData } from "./api.js" +import { terminalSessionId, terminalSessionsForProject } from "./terminal-state.js" +import { type ActiveTerminalSession, terminalSessionRoutePath } from "./terminal.js" + +const sshPathPrefix = "/ssh/" + +export type DashboardProject = DashboardData["projects"][number] +type SessionLookupResult = { readonly sessionId: string } +export type ProjectLookupResult = { readonly terminalId?: string | undefined; readonly token: string } +export type SshLinkRequest = + | ({ readonly kind: "project" } & ProjectLookupResult) + | ({ readonly kind: "session" } & SessionLookupResult) + +const safeDecodeURIComponent = (value: string): string | null => + Either.getOrNull(Either.try({ try: () => decodeURIComponent(value), catch: () => null })) + +const decodePathTail = (value: string): string | null => { + const decodedSegments = value + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => safeDecodeURIComponent(segment)) + return decodedSegments.includes(null) + ? null + : decodedSegments.join("/").trim() +} + +const readSessionPathRequest = (tail: string): SshLinkRequest | null => { + const decoded = safeDecodeURIComponent(tail.slice("session/".length).split("/")[0] ?? "") + const sessionId = decoded?.trim() ?? "" + return sessionId.length === 0 ? null : { kind: "session", sessionId } +} + +const nonEmptyQueryValue = (value: string | null): string | undefined => { + const trimmed = value?.trim() ?? "" + return trimmed.length === 0 ? undefined : trimmed +} + +const readTerminalIdQuery = (url: URL): string | undefined => + nonEmptyQueryValue(url.searchParams.get("terminal")) ?? nonEmptyQueryValue(url.searchParams.get("t")) + +const readProjectPathRequest = (url: URL, tail: string): SshLinkRequest | null => { + const decoded = decodePathTail(tail) + return decoded === null || decoded.length === 0 + ? null + : { kind: "project", terminalId: readTerminalIdQuery(url), token: decoded } +} + +const readSshPathRequest = (url: URL): SshLinkRequest | null => { + if (!url.pathname.startsWith(sshPathPrefix)) { + return null + } + const tail = url.pathname.slice(sshPathPrefix.length) + if (tail.startsWith("session/")) { + return readSessionPathRequest(tail) + } + return readProjectPathRequest(url, tail) +} + +const readSshQueryRequest = (url: URL): SshLinkRequest | null => { + const queryToken = url.searchParams.get("ssh")?.trim() ?? "" + const terminalId = readTerminalIdQuery(url) + return queryToken.length === 0 ? null : { kind: "project", terminalId, token: queryToken } +} + +// CHANGE: Parse browser SSH deep links without throwing on malformed input. +// WHY: User-controlled href/path segments must collapse to null, not crash rendering. +// QUOTE(TZ): CodeRabbit #342: "malformed % encodings yield null/ignored values rather than throwing". +// REF: PR #342 review, issue #340. +// SOURCE: n/a +// FORMAT THEOREM: forall href: invalid(href) => readSshLinkRequestFromHref(href) = null. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Returned requests always have a non-empty project token or session id. +// COMPLEXITY: O(n) time/O(n) space where n = href length. +/** + * Reads a project or session SSH link request from an absolute or relative href. + * + * @param href - Browser href value to parse against a local base URL. + * @returns Parsed SSH request, or null when the route is unrelated or malformed. + * @pure true + * @effect none + * @invariant A non-null result contains a non-empty token or session id. + * @precondition href may be any string. + * @postcondition Malformed URL or percent-encoding input returns null. + * @complexity O(n) time/O(n) space where n is href length. + * @throws Never + */ +export const readSshLinkRequestFromHref = (href: string): SshLinkRequest | null => { + const url = Either.getOrNull(Either.try({ try: () => new URL(href, "http://localhost"), catch: () => null })) + if (url === null) { + return null + } + return readSshPathRequest(url) ?? readSshQueryRequest(url) +} + +// CHANGE: Select a dashboard project by either stable SSH token form. +// WHY: SSH links may carry the project key or the project id depending on source. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: forall p in projects: match(token,p) => result = first matching p. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Search order follows the dashboard project order. +// COMPLEXITY: O(n) time/O(1) space where n = projects length. +/** + * Finds the first project whose project key or id equals the SSH token. + * + * @param projects - Dashboard project collection. + * @param token - Project key or project id carried by an SSH link. + * @returns Matching project, or undefined when none exists. + * @pure true + * @effect none + * @invariant The returned project is a member of projects. + * @precondition projects is a finite readonly array. + * @postcondition Undefined means no project key or id equals token. + * @complexity O(n) time/O(1) space where n is projects length. + * @throws Never + */ +export const findProjectBySshToken = ( + projects: DashboardData["projects"], + token: string +): DashboardProject | undefined => + projects.find((candidate) => candidate.projectKey === token || candidate.id === token) + +// CHANGE: Resolve an already-open terminal session by canonical session id. +// WHY: Deep links should reuse local sessions before requesting backend state. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: forall s in sessions: id(s)=sessionId => result = first s. +// PURITY: CORE +// EFFECT: none +// INVARIANT: The returned session is a member of sessions. +// COMPLEXITY: O(n) time/O(1) space where n = sessions length. +/** + * Finds a local active terminal session by its canonical terminal session id. + * + * @param sessions - Open terminal sessions in the browser state. + * @param sessionId - Terminal session id to find. + * @returns Matching active session, or undefined when not open locally. + * @pure true + * @effect none + * @invariant The result is undefined or belongs to sessions. + * @precondition sessions is finite and each session has a stable id. + * @postcondition Undefined means no local terminal id equals sessionId. + * @complexity O(n) time/O(1) space where n is sessions length. + * @throws Never + */ +export const findLocalTerminalSession = ( + sessions: ReadonlyArray, + sessionId: string +): ActiveTerminalSession | undefined => sessions.find((session) => terminalSessionId(session) === sessionId) + +const newestTerminalSession = ( + sessions: ReadonlyArray +): A | null => { + const reusableSessions = sessions.filter((session) => session.status !== "failed") + const candidates = reusableSessions.length === 0 ? sessions : reusableSessions + return candidates.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null +} + +const selectByExactIdOrUniquePrefix = ( + sessions: ReadonlyArray, + selector: string +): A | null => { + const exact = sessions.find((session) => session.id === selector) + if (exact !== undefined) { + return exact + } + const matches = sessions.filter((session) => session.id.startsWith(selector)) + return matches.length === 1 ? matches[0] ?? null : null +} + +// CHANGE: Choose the best local terminal for a project SSH link. +// WHY: Selection must prefer explicit ids, then current active session, then newest reusable session. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: terminalId defined => result id matches exact/unique prefix or null. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Non-null result belongs to the requested project. +// COMPLEXITY: O(n log n) time/O(n) space where n = sessions length. +/** + * Selects a local project terminal for a project link. + * + * @param sessions - Active terminal sessions in browser memory. + * @param activeTerminalSessionId - Current active terminal id, when any. + * @param projectId - Project id that must own the returned session. + * @param terminalId - Optional exact id or unique id prefix requested by the link. + * @returns The selected active session, or null when no local session satisfies the request. + * @pure true + * @effect none + * @invariant Non-null results belong to projectId. + * @precondition terminalId, when supplied, is compared as an id prefix or exact id. + * @postcondition Missing explicit terminalId may fall back to active or newest non-failed session. + * @complexity O(n log n) time/O(n) space where n is sessions length. + * @throws Never + */ +export const selectLocalProjectTerminal = ( + sessions: ReadonlyArray, + activeTerminalSessionId: string | null, + projectId: string, + terminalId: string | undefined +): ActiveTerminalSession | null => { + const projectSessions = terminalSessionsForProject(sessions, projectId) + if (terminalId !== undefined) { + return selectByExactIdOrUniquePrefix( + projectSessions.map((session) => ({ ...session, id: terminalSessionId(session) })), + terminalId + ) + } + const active = projectSessions.find((session) => terminalSessionId(session) === activeTerminalSessionId) + if (active !== undefined) { + return active + } + const newest = newestTerminalSession(projectSessions.map((session) => session.session)) + return newest === null + ? null + : projectSessions.find((session) => terminalSessionId(session) === newest.id) ?? null +} + +// CHANGE: Choose the backend workspace terminal targeted by an SSH link. +// WHY: Backend attach should honor explicit selectors before falling back to active/newest sessions. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: terminalId defined => result id matches exact/unique prefix or null. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Non-null result belongs to sessions. +// COMPLEXITY: O(n log n) time/O(n) space where n = sessions length. +/** + * Selects a terminal session from a backend workspace session list. + * + * @param sessions - Backend terminal sessions for one workspace. + * @param activeSessionId - Backend active session id, when any. + * @param terminalId - Optional exact id or unique id prefix requested by the link. + * @returns Selected terminal session, or null when an explicit selector is missing or ambiguous. + * @pure true + * @effect none + * @invariant Non-null results belong to sessions. + * @precondition sessions is finite and ordered independently from selection semantics. + * @postcondition Without terminalId, stale activeSessionId falls back to newest reusable session. + * @complexity O(n log n) time/O(n) space where n is sessions length. + * @throws Never + */ +export const selectWorkspaceTerminalSession = ( + sessions: ReadonlyArray, + activeSessionId: string | null, + terminalId?: string +): TerminalSession | null => { + if (terminalId !== undefined) { + return selectByExactIdOrUniquePrefix(sessions, terminalId) + } + if (activeSessionId !== null) { + const active = sessions.find((session) => session.id === activeSessionId) + if (active !== undefined) { + return active + } + } + return newestTerminalSession(sessions) +} + +// CHANGE: Derive a stable idempotency key for one parsed SSH request. +// WHY: The hook needs to distinguish new links from already-handled links. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: equal request fields => equal key. +// PURITY: CORE +// EFFECT: none +// INVARIANT: The key prefix equals the request discriminant. +// COMPLEXITY: O(n) time/O(n) space where n = encoded field length. +/** + * Renders a stable key for deduplicating SSH link handling. + * + * @param request - Parsed SSH project or session request. + * @returns Stable string key containing request kind and identity fields. + * @pure true + * @effect none + * @invariant Session keys start with session: and project keys start with project:. + * @precondition request is a valid SshLinkRequest. + * @postcondition Equal request identity fields produce equal keys. + * @complexity O(n) time/O(n) space where n is request field length. + * @throws Never + */ +export const sshLinkRequestKey = (request: SshLinkRequest): string => + request.kind === "session" + ? `session:${request.sessionId}` + : `project:${request.token}:${request.terminalId ?? ""}` + +// CHANGE: Compute a safe fallback route for stale SSH session links. +// WHY: A 404 for the currently-open session route should return users to Select instead of looping. +// QUOTE(TZ): n/a +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: error lacks HTTP 404 => result = null. +// PURITY: CORE +// EFFECT: none +// INVARIANT: Non-null result is always /menu/select. +// COMPLEXITY: O(n) time/O(n) space where n = href length. +/** + * Resolves the browser fallback path for a stale terminal session deep link. + * + * @param href - Current browser href. + * @param sessionId - Session id whose attach attempt failed. + * @param error - Typed API error message. + * @returns /menu/select only for matching session routes that failed with HTTP 404. + * @pure true + * @effect none + * @invariant Non-null result is the Select route. + * @precondition href may be any string and error is an API message. + * @postcondition Malformed href or non-404 errors return null. + * @complexity O(n) time/O(n) space where n is href length. + * @throws Never + */ +export const resolveMissingSshSessionFallbackPath = ( + href: string, + sessionId: string, + error: string +): string | null => { + if (!error.includes("HTTP 404")) { + return null + } + const url = Either.getOrNull(Either.try({ try: () => new URL(href, "http://localhost"), catch: () => null })) + if (url === null) { + return null + } + return url.pathname === terminalSessionRoutePath(sessionId) ? "/menu/select" : null +} diff --git a/packages/app/src/web/app-ready-ssh-link-hook.ts b/packages/app/src/web/app-ready-ssh-link-hook.ts index 7ad316b6..3240edbb 100644 --- a/packages/app/src/web/app-ready-ssh-link-hook.ts +++ b/packages/app/src/web/app-ready-ssh-link-hook.ts @@ -3,18 +3,36 @@ import { useEffect, useRef } from "react" import { connectProjectById } from "./actions-projects.js" import type { BrowserActionContext } from "./actions-shared.js" -import type { TerminalSession } from "./api-types.js" import { loadProjectTerminalWorkspace, loadTerminalSessionById } from "./api.js" import type { DashboardData } from "./api.js" +import { + type DashboardProject, + findLocalTerminalSession, + findProjectBySshToken, + type ProjectLookupResult, + readSshLinkRequestFromHref, + resolveMissingSshSessionFallbackPath, + selectLocalProjectTerminal, + selectWorkspaceTerminalSession, + type SshLinkRequest, + sshLinkRequestKey +} from "./app-ready-ssh-link-core.js" +import { + attachLoadedSshSessionLink, + attachProjectWorkspaceSessions, + type LoadedSshSessionLink, + showProjectTerminalScreen +} from "./app-ready-ssh-link-terminal.js" import { browserMenuIndex } from "./menu.js" import { projectPickerScreen } from "./screen.js" -import { terminalSessionId, terminalSessionsForProject } from "./terminal-state.js" -import { - type ActiveTerminalSession, - buildProjectActiveTerminalSession, - projectSshRoutePath, - terminalSessionRoutePath -} from "./terminal.js" +import { terminalSessionId } from "./terminal-state.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export { + readSshLinkRequestFromHref, + resolveMissingSshSessionFallbackPath, + selectWorkspaceTerminalSession +} from "./app-ready-ssh-link-core.js" type SshLinkArgs = { readonly actionContext: BrowserActionContext @@ -27,18 +45,12 @@ type SshLinkArgs = { readonly terminalSessions: ReadonlyArray } -const sshPathPrefix = "/ssh/" type ConnectTimerRef = { current: ReturnType | null } type SshTokenRef = { current: string | null } -type DashboardProject = DashboardData["projects"][number] -type SessionLookupResult = { readonly sessionId: string } -type ProjectLookupResult = { readonly terminalId?: string | undefined; readonly token: string } -export type SshLinkRequest = - | ({ readonly kind: "project" } & ProjectLookupResult) - | ({ readonly kind: "session" } & SessionLookupResult) type SshLinkEffectArgs = Omit & { readonly connectTimerRef: ConnectTimerRef readonly handledTokenRef: SshTokenRef + readonly pendingTokenRef: SshTokenRef readonly projects: DashboardData["projects"] } @@ -49,170 +61,66 @@ const clearConnectTimer = (connectTimerRef: ConnectTimerRef): void => { } } -const decodePathTail = (value: string): string => - value - .split("/") - .filter((segment) => segment.length > 0) - .map((segment) => decodeURIComponent(segment)) - .join("/") - .trim() - -const readSessionPathRequest = (tail: string): SshLinkRequest | null => { - const sessionId = decodeURIComponent(tail.slice("session/".length).split("/")[0] ?? "").trim() - return sessionId.length === 0 ? null : { kind: "session", sessionId } -} - -const readSshPathRequest = (url: URL): SshLinkRequest | null => { - if (!url.pathname.startsWith(sshPathPrefix)) { - return null - } - const tail = url.pathname.slice(sshPathPrefix.length) - if (tail.startsWith("session/")) { - return readSessionPathRequest(tail) - } - const decoded = decodePathTail(tail) - const terminalId = url.searchParams.get("terminal")?.trim() || url.searchParams.get("t")?.trim() || undefined - return decoded.length === 0 ? null : { kind: "project", terminalId, token: decoded } -} - -const readSshQueryRequest = (url: URL): SshLinkRequest | null => { - const queryToken = url.searchParams.get("ssh")?.trim() ?? "" - const terminalId = url.searchParams.get("terminal")?.trim() || url.searchParams.get("t")?.trim() || undefined - return queryToken.length === 0 ? null : { kind: "project", terminalId, token: queryToken } -} - -export const readSshLinkRequestFromHref = (href: string): SshLinkRequest | null => { - const url = new URL(href, "http://localhost") - return readSshPathRequest(url) ?? readSshQueryRequest(url) -} - const readSshLinkRequest = (): SshLinkRequest | null => readSshLinkRequestFromHref(globalThis.location.href) -const findProjectBySshToken = ( - projects: DashboardData["projects"], - token: string -): DashboardProject | undefined => - projects.find((candidate) => candidate.projectKey === token || candidate.id === token) - -const showProjectTerminalScreen = (actionContext: BrowserActionContext, projectId: string): void => { - actionContext.setSelectedMenuIndex(browserMenuIndex("Select")) - actionContext.setActiveScreen(projectPickerScreen()) - actionContext.setSelectedProjectId(projectId) -} - -const findLocalTerminalSession = ( - sessions: ReadonlyArray, - sessionId: string -): ActiveTerminalSession | undefined => sessions.find((session) => terminalSessionId(session) === sessionId) - -const newestTerminalSession = ( - sessions: ReadonlyArray -): A | null => { - const reusableSessions = sessions.filter((session) => session.status !== "failed") - const candidates = reusableSessions.length === 0 ? sessions : reusableSessions - return candidates.toSorted((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null -} - -const selectByExactIdOrUniquePrefix = ( - sessions: ReadonlyArray, - selector: string -): A | null => { - const exact = sessions.find((session) => session.id === selector) - if (exact !== undefined) { - return exact +const clearPendingSshLink = (args: SshLinkEffectArgs, requestKey: string): void => { + if (args.pendingTokenRef.current === requestKey) { + args.pendingTokenRef.current = null } - const matches = sessions.filter((session) => session.id.startsWith(selector)) - return matches.length === 1 ? matches[0] ?? null : null } -const selectLocalProjectTerminal = ( - sessions: ReadonlyArray, - activeTerminalSessionId: string | null, - projectId: string, - terminalId: string | undefined -): ActiveTerminalSession | null => { - const projectSessions = terminalSessionsForProject(sessions, projectId) - if (terminalId !== undefined) { - return selectByExactIdOrUniquePrefix( - projectSessions.map((session) => ({ ...session, id: terminalSessionId(session) })), - terminalId - ) - } - const active = projectSessions.find((session) => terminalSessionId(session) === activeTerminalSessionId) - if (active !== undefined) { - return active - } - const newest = newestTerminalSession(projectSessions.map((session) => session.session)) - return newest === null - ? null - : projectSessions.find((session) => terminalSessionId(session) === newest.id) ?? null -} +const isPendingSshLink = (args: SshLinkEffectArgs, requestKey: string): boolean => + args.pendingTokenRef.current === requestKey -export const selectWorkspaceTerminalSession = ( - sessions: ReadonlyArray, - activeSessionId: string | null, - terminalId?: string -): TerminalSession | null => { - if (terminalId !== undefined) { - return selectByExactIdOrUniquePrefix(sessions, terminalId) - } - if (activeSessionId !== null) { - const active = sessions.find((session) => session.id === activeSessionId) - if (active !== undefined) { - return active - } +const markSshLinkHandled = (args: SshLinkEffectArgs, requestKey: string): void => { + if (!isPendingSshLink(args, requestKey)) { + return } - return newestTerminalSession(sessions) + args.pendingTokenRef.current = null + args.handledTokenRef.current = requestKey } -const buildProjectTerminalSession = ( +const handleTerminalSessionAttachFailure = ( args: SshLinkEffectArgs, - project: DashboardProject, - session: TerminalSession -): ActiveTerminalSession => - buildProjectActiveTerminalSession({ - onExit: args.actionContext.reloadDashboard, - onReady: args.actionContext.reloadDashboard, - projectDisplayName: project.displayName, - projectId: project.id, - projectKey: project.projectKey, - session - }) - -const attachProjectWorkspaceSessions = ( - args: SshLinkEffectArgs, - project: DashboardProject, - sessions: ReadonlyArray, - selectedSession: TerminalSession + requestKey: string, + sessionId: string, + error: string ): void => { - const orderedSessions = sessions.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) - for (const session of orderedSessions) { - if (session.id !== selectedSession.id) { - args.addTerminalSession(buildProjectTerminalSession(args, project, session)) - } + if (!isPendingSshLink(args, requestKey)) { + return } - args.addTerminalSession(buildProjectTerminalSession(args, project, selectedSession)) - args.selectTerminalSession(selectedSession.id) + const fallbackPath = resolveMissingSshSessionFallbackPath(globalThis.location.href, sessionId, error) + if (fallbackPath !== null) { + clearPendingSshLink(args, requestKey) + args.handledTokenRef.current = null + args.deactivateTerminalWorkspace() + args.actionContext.setSelectedMenuIndex(browserMenuIndex("Select")) + args.actionContext.setActiveScreen(projectPickerScreen()) + globalThis.history.replaceState(globalThis.history.state, "", fallbackPath) + args.actionContext.setMessage(`SSH terminal is no longer available: ${sessionId}.`) + return + } + clearPendingSshLink(args, requestKey) + args.actionContext.setMessage(error) } -const sshLinkRequestKey = (request: SshLinkRequest): string => - request.kind === "session" - ? `session:${request.sessionId}` - : `project:${request.token}:${request.terminalId ?? ""}` - -export const resolveMissingSshSessionFallbackPath = ( - href: string, - sessionId: string, - error: string -): string | null => { - if (!error.includes("HTTP 404")) { - return null +const handleTerminalSessionAttachSuccess = ( + args: SshLinkEffectArgs, + requestKey: string, + { projectDisplayName, projectKey, session }: LoadedSshSessionLink +): void => { + if (!isPendingSshLink(args, requestKey)) { + return } - const url = new URL(href, "http://localhost") - return url.pathname === terminalSessionRoutePath(sessionId) ? "/menu/select" : null + markSshLinkHandled(args, requestKey) + attachLoadedSshSessionLink(args, { projectDisplayName, projectKey, session }) } -const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: string): void => { +const scheduleTerminalSessionAttach = ( + args: SshLinkEffectArgs, + requestKey: string, + sessionId: string +): void => { clearConnectTimer(args.connectTimerRef) args.connectTimerRef.current = globalThis.setTimeout(() => { args.connectTimerRef.current = null @@ -220,34 +128,10 @@ const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: strin loadTerminalSessionById(sessionId).pipe( Effect.match({ onFailure: (error) => { - const fallbackPath = resolveMissingSshSessionFallbackPath(globalThis.location.href, sessionId, error) - if (fallbackPath !== null) { - args.handledTokenRef.current = null - args.deactivateTerminalWorkspace() - args.actionContext.setSelectedMenuIndex(browserMenuIndex("Select")) - args.actionContext.setActiveScreen(projectPickerScreen()) - globalThis.history.replaceState(globalThis.history.state, "", fallbackPath) - args.actionContext.setMessage(`SSH terminal is no longer available: ${sessionId}.`) - return - } - args.actionContext.setMessage(error) + handleTerminalSessionAttachFailure(args, requestKey, sessionId, error) }, - onSuccess: ({ projectDisplayName, projectKey, session }) => { - globalThis.history.replaceState( - globalThis.history.state, - "", - projectSshRoutePath(projectKey, session.id) - ) - showProjectTerminalScreen(args.actionContext, session.projectId) - args.addTerminalSession(buildProjectActiveTerminalSession({ - onExit: args.actionContext.reloadDashboard, - onReady: args.actionContext.reloadDashboard, - projectDisplayName, - projectId: session.projectId, - projectKey, - session - })) - args.actionContext.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) + onSuccess: (link) => { + handleTerminalSessionAttachSuccess(args, requestKey, link) } }) ) @@ -279,6 +163,7 @@ const attachExistingProjectLink = ( const scheduleProjectTerminalAttach = ( args: SshLinkEffectArgs, project: DashboardProject, + requestKey: string, request: { readonly terminalId?: string | undefined } ): void => { clearConnectTimer(args.connectTimerRef) @@ -289,19 +174,35 @@ const scheduleProjectTerminalAttach = ( loadProjectTerminalWorkspace(project.projectKey).pipe( Effect.match({ onFailure: (error) => { + if (!isPendingSshLink(args, requestKey)) { + return + } + clearPendingSshLink(args, requestKey) args.actionContext.setMessage(error) }, onSuccess: ({ activeSessionId, sessions }) => { + if (!isPendingSshLink(args, requestKey)) { + return + } const selectedSession = selectWorkspaceTerminalSession(sessions, activeSessionId, request.terminalId) if (selectedSession === null) { if (request.terminalId !== undefined) { + clearPendingSshLink(args, requestKey) args.actionContext.setMessage(`SSH terminal link was not found: ${request.terminalId}.`) return } - connectProjectById(project.id, args.actionContext, project.projectKey) + connectProjectById(project.id, args.actionContext, project.projectKey, { + onFailure: () => { + clearPendingSshLink(args, requestKey) + }, + onSuccess: () => { + markSshLinkHandled(args, requestKey) + } + }) return } attachProjectWorkspaceSessions(args, project, sessions, selectedSession) + markSshLinkHandled(args, requestKey) args.actionContext.setMessage(`Attached SSH terminal for ${project.displayName}.`) } }) @@ -310,22 +211,28 @@ const scheduleProjectTerminalAttach = ( }, 0) } -const handleProjectSshLink = (args: SshLinkEffectArgs, request: ProjectLookupResult): void => { +const handleProjectSshLink = (args: SshLinkEffectArgs, requestKey: string, request: ProjectLookupResult): void => { const project = findProjectBySshToken(args.projects, request.token) if (project === undefined) { + clearPendingSshLink(args, requestKey) args.actionContext.setMessage(`Project link was not found: ${request.token}.`) return } if (attachExistingProjectLink(args, project, request)) { + markSshLinkHandled(args, requestKey) return } - scheduleProjectTerminalAttach(args, project, request) + scheduleProjectTerminalAttach(args, project, requestKey, request) } -const handleSessionSshLink = (args: SshLinkEffectArgs, request: { readonly sessionId: string }): void => { +const handleSessionSshLink = ( + args: SshLinkEffectArgs, + requestKey: string, + request: { readonly sessionId: string } +): void => { const localSession = findLocalTerminalSession(args.terminalSessions, request.sessionId) if (localSession === undefined) { - scheduleTerminalSessionAttach(args, request.sessionId) + scheduleTerminalSessionAttach(args, requestKey, request.sessionId) return } clearConnectTimer(args.connectTimerRef) @@ -334,6 +241,7 @@ const handleSessionSshLink = (args: SshLinkEffectArgs, request: { readonly sessi } args.selectTerminalSession(request.sessionId) args.actionContext.setMessage(`Opened existing SSH terminal: ${request.sessionId}.`) + markSshLinkHandled(args, requestKey) } const handleSshLinkEffect = (args: SshLinkEffectArgs): void => { @@ -341,21 +249,47 @@ const handleSshLinkEffect = (args: SshLinkEffectArgs): void => { if (request === null) { clearConnectTimer(args.connectTimerRef) args.handledTokenRef.current = null + args.pendingTokenRef.current = null return } const requestKey = sshLinkRequestKey(request) - if (args.busyLabel !== null || args.handledTokenRef.current === requestKey) { + if ( + args.busyLabel !== null || + args.handledTokenRef.current === requestKey || + args.pendingTokenRef.current === requestKey + ) { return } - args.handledTokenRef.current = requestKey + args.pendingTokenRef.current = requestKey if (request.kind === "project") { - handleProjectSshLink(args, request) + handleProjectSshLink(args, requestKey, request) return } - handleSessionSshLink(args, request) + handleSessionSshLink(args, requestKey, request) } +// CHANGE: React hook for idempotent SSH link dispatch. +// WHY: Links should be marked handled only after the attach/open action succeeds. +// QUOTE(TZ): CodeRabbit #342: "transient failures should not create a dead-end". +// REF: PR #342 SSH terminal link handling. +// SOURCE: n/a +// FORMAT THEOREM: pending(k) and failure(k) => handled != k. +// PURITY: SHELL +// EFFECT: React effects, browser location/history, backend API Effects. +// INVARIANT: handledTokenRef records only successful request keys. +// COMPLEXITY: O(n) per effect where n = local/project session counts. +/** + * Handles browser SSH deep links by attaching or selecting a terminal session. + * + * @pure false + * @effect React useEffect, globalThis.location/history, backend API effects. + * @invariant A request key moves pending -> handled only on successful local or backend attach. + * @precondition Must run in a browser-like environment. + * @postcondition Failed requests clear pending state and remain retryable. + * @complexity O(n) per effect pass where n is project/session count. + * @throws Never + */ export const useSshLink = ({ actionContext, activeTerminalSessionId, @@ -368,6 +302,7 @@ export const useSshLink = ({ }: SshLinkArgs) => { const connectTimerRef = useRef | null>(null) const handledTokenRef = useRef(null) + const pendingTokenRef = useRef(null) const locationSignature = `${globalThis.location.pathname}${globalThis.location.search}` useEffect(() => () => { @@ -383,6 +318,7 @@ export const useSshLink = ({ connectTimerRef, deactivateTerminalWorkspace, handledTokenRef, + pendingTokenRef, projects: dashboard.projects, selectTerminalSession, terminalSessions diff --git a/packages/app/src/web/app-ready-ssh-link-terminal.ts b/packages/app/src/web/app-ready-ssh-link-terminal.ts new file mode 100644 index 00000000..4d2099f4 --- /dev/null +++ b/packages/app/src/web/app-ready-ssh-link-terminal.ts @@ -0,0 +1,184 @@ +import type { BrowserActionContext } from "./actions-shared.js" +import type { TerminalSession } from "./api-types.js" +import type { DashboardProject } from "./app-ready-ssh-link-core.js" +import { browserMenuIndex } from "./menu.js" +import { projectPickerScreen } from "./screen.js" +import { type ActiveTerminalSession, buildProjectActiveTerminalSession, projectSshRoutePath } from "./terminal.js" + +type ProjectTerminalAttachArgs = { + readonly actionContext: Pick< + BrowserActionContext, + "reloadDashboard" | "setActiveScreen" | "setSelectedMenuIndex" | "setSelectedProjectId" + > + readonly addTerminalSession: (session: ActiveTerminalSession) => void + readonly selectTerminalSession: (sessionId: string) => void +} + +export type LoadedSshSessionLink = { + readonly projectDisplayName: string + readonly projectKey: string + readonly session: TerminalSession +} + +type LoadedSshSessionAttachArgs = { + readonly actionContext: Pick< + BrowserActionContext, + "reloadDashboard" | "setActiveScreen" | "setMessage" | "setSelectedMenuIndex" | "setSelectedProjectId" + > + readonly addTerminalSession: (session: ActiveTerminalSession) => void +} + +/** + * Opens the project terminal screen for a selected project. + * + * @param actionContext - Browser screen/menu setters. + * @param projectId - Project id to select. + * @returns Nothing; state is written through actionContext. + * @pure false + * @effect BrowserActionContext screen/menu selection setters. + * @invariant selected menu is Select and selected project id equals projectId. + * @precondition actionContext is live and projectId is non-empty. + * @postcondition project picker screen is active for projectId. + * @complexity O(1) + * @throws Never + */ +// CHANGE: keep SSH link screen selection in a focused shell helper +// WHY: async link handlers need one shared mutation path for project terminal focus +// QUOTE(ТЗ): n/a +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: focus(projectId) -> selectedProjectId = projectId +// PURITY: SHELL +// EFFECT: BrowserActionContext -> setSelectedMenuIndex, setActiveScreen, setSelectedProjectId +// INVARIANT: menu Select and project picker screen are selected together +// COMPLEXITY: O(1) +export const showProjectTerminalScreen = ( + actionContext: ProjectTerminalAttachArgs["actionContext"], + projectId: string +): void => { + actionContext.setSelectedMenuIndex(browserMenuIndex("Select")) + actionContext.setActiveScreen(projectPickerScreen()) + actionContext.setSelectedProjectId(projectId) +} + +/** + * Attaches a loaded legacy SSH session link to browser terminal state. + * + * @param args - Browser action sinks used for route, terminal, and message updates. + * @param link - Loaded terminal session plus project display metadata. + * @returns Nothing; route and terminal state are written through shell dependencies. + * @pure false + * @effect globalThis.history plus BrowserActionContext setters and addTerminalSession. + * @invariant The browser route points to the attached session id. + * @precondition link.session belongs to link.projectKey. + * @postcondition The project terminal screen is focused and the loaded session is added. + * @complexity O(1) + * @throws Never + */ +// CHANGE: isolate loaded legacy session attach side effects +// WHY: stale-link guards in the hook should precede one compact shell transition +// QUOTE(ТЗ): n/a +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: attach(link) -> route = projectSshRoutePath(link.projectKey, link.session.id) +// PURITY: SHELL +// EFFECT: History, BrowserActionContext, addTerminalSession +// INVARIANT: added terminal session is built from the loaded backend session +// COMPLEXITY: O(1) +export const attachLoadedSshSessionLink = ( + args: LoadedSshSessionAttachArgs, + { projectDisplayName, projectKey, session }: LoadedSshSessionLink +): void => { + globalThis.history.replaceState(globalThis.history.state, "", projectSshRoutePath(projectKey, session.id)) + showProjectTerminalScreen(args.actionContext, session.projectId) + args.addTerminalSession(buildProjectActiveTerminalSession({ + onExit: args.actionContext.reloadDashboard, + onReady: args.actionContext.reloadDashboard, + projectDisplayName, + projectId: session.projectId, + projectKey, + session + })) + args.actionContext.setMessage(`Attached SSH terminal for ${projectDisplayName}.`) +} + +/** + * Builds an active terminal session from a backend terminal session. + * + * @param args - Browser callbacks used by the terminal session lifecycle. + * @param project - Dashboard project owning the session. + * @param session - Backend terminal session to wrap. + * @returns Active terminal session ready for the browser tab list. + * @pure true + * @effect n/a + * @invariant session.projectId is preserved in the ActiveTerminalSession. + * @precondition project and session describe the same project. + * @postcondition onExit and onReady both reload the dashboard. + * @complexity O(1) + * @throws Never + */ +// CHANGE: isolate ActiveTerminalSession construction for SSH-link workspace attach +// WHY: route attach and workspace attach must use identical terminal metadata +// QUOTE(ТЗ): n/a +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: build(project, session).session = session +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: project display/key/id are copied without mutation +// COMPLEXITY: O(1) +const buildProjectTerminalSession = ( + args: ProjectTerminalAttachArgs, + project: DashboardProject, + session: TerminalSession +): ActiveTerminalSession => + buildProjectActiveTerminalSession({ + onExit: args.actionContext.reloadDashboard, + onReady: args.actionContext.reloadDashboard, + projectDisplayName: project.displayName, + projectId: project.id, + projectKey: project.projectKey, + session + }) + +/** + * Adds workspace terminal sessions in created-at order and selects the requested one. + * + * @param args - Browser terminal-session sinks. + * @param project - Dashboard project owning the sessions. + * @param sessions - Workspace terminal sessions returned by the backend. + * @param selectedSession - Session that must become active after attach. + * @returns Nothing; terminal tabs and selection are written through args. + * @pure false + * @effect args.addTerminalSession and args.selectTerminalSession. + * @invariant selectedSession is added after all other sessions. + * @precondition selectedSession belongs to sessions. + * @postcondition args.selectTerminalSession is called with selectedSession.id. + * @complexity O(n log n) time and O(n) space where n = sessions.length. + * @throws Never + */ +// CHANGE: preserve deterministic workspace session attach ordering +// WHY: opening selected session last keeps browser tab ordering stable while selecting the intended terminal +// QUOTE(ТЗ): n/a +// REF: PR #342 CodeRabbit review +// SOURCE: n/a +// FORMAT THEOREM: attach(sessions, selected) -> selectedSession.id is selected +// PURITY: SHELL +// EFFECT: ProjectTerminalAttachArgs -> addTerminalSession*, selectTerminalSession +// INVARIANT: non-selected sessions are added by ascending createdAt before selectedSession +// COMPLEXITY: O(n log n) time and O(n) space +export const attachProjectWorkspaceSessions = ( + args: ProjectTerminalAttachArgs, + project: DashboardProject, + sessions: ReadonlyArray, + selectedSession: TerminalSession +): void => { + const orderedSessions = sessions.toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) + for (const session of orderedSessions) { + if (session.id !== selectedSession.id) { + args.addTerminalSession(buildProjectTerminalSession(args, project, session)) + } + } + args.addTerminalSession(buildProjectTerminalSession(args, project, selectedSession)) + args.selectTerminalSession(selectedSession.id) +} diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx new file mode 100644 index 00000000..5e36b41f --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -0,0 +1,237 @@ +import { Effect } from "effect" +import type { CSSProperties, JSX } from "react" + +import { deleteTerminalSessionByPath } from "./api.js" +import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" +import { TerminalTaskManagerBody } from "./app-ready-terminal-task-manager.js" +import type { TerminalPaneProps } from "./app-ready-terminal-types.js" +import { TerminalPanel } from "./panel-terminal.js" +import { type BrowserScreen, projectPickerScreen } from "./screen.js" +import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" +import { terminalSessionId } from "./terminal-state.js" +import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" + +type TerminalPaneRuntime = { + readonly browserProjectId: string | undefined + readonly browserProjectKey: string | undefined + readonly canOpenBrowser: boolean + readonly pendingSession: boolean + readonly sessionId: string +} + +const activeTerminalPaneStyle: CSSProperties = { + display: "flex", + flex: 1, + minHeight: 0, + overflow: "hidden" +} + +const pendingTerminalBodyStyle: CSSProperties = { + alignItems: "center", + background: "rgba(8, 10, 13, 0.92)", + boxSizing: "border-box", + color: "#d6e5f7", + display: "flex", + height: "100%", + justifyContent: "center", + padding: "20px", + textAlign: "center", + whiteSpace: "pre-wrap" +} + +const requestTerminalSessionClose = ( + closePath: string, + onFailure: (error: string) => void, + onSuccess: () => void +): void => { + void Effect.runPromise( + deleteTerminalSessionByPath(closePath).pipe( + Effect.match({ onFailure, onSuccess }) + ) + ) +} + +const terminalReturnScreen = (session: ActiveTerminalSession): BrowserScreen => + session.closePath.startsWith("/auth/") ? { tag: "Auth" } : projectPickerScreen() + +const projectSkillerAction = ( + projectKey: string | undefined, + sessionId: string, + onOpenSkiller: (projectKey?: string, sessionId?: string) => void +): (() => void) | undefined => + projectKey === undefined + ? undefined + : () => { + onOpenSkiller(projectKey, sessionId) + } + +const PendingTerminalBody = ({ session }: { readonly session: ActiveTerminalSession }): JSX.Element | null => { + if (!isPendingActiveTerminalSession(session)) { + return null + } + + const { pendingConnection } = session + const hint = pendingConnection.phase === "error" + ? "Close this tab or open a new terminal to retry." + : "The terminal workspace is open. SSH will attach as soon as the project is ready." + return ( +
+
+
{pendingConnection.message}
+
{hint}
+
+
+ ) +} + +const resolveTerminalPaneRuntime = (props: TerminalPaneProps): TerminalPaneRuntime => { + const browserProjectId = props.terminalSession.browserProjectId + return { + browserProjectId, + browserProjectKey: props.terminalSession.browserProjectKey, + canOpenBrowser: canOpenProjectBrowser(props.projectBrowser, browserProjectId), + pendingSession: isPendingActiveTerminalSession(props.terminalSession), + sessionId: terminalSessionId(props.terminalSession) + } +} + +const terminalBodyContent = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): JSX.Element | undefined => { + if (props.taskManagerOpen && runtime.browserProjectId !== undefined) { + return ( + + ) + } + return runtime.pendingSession ? : undefined +} + +const detachTerminalSession = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): void => { + props.onTerminalClose(runtime.sessionId) + if (props.singleSession) { + props.onSetActiveScreen(terminalReturnScreen(props.terminalSession)) + } +} + +const handleTerminalExit = ( + props: TerminalPaneProps, + runtime: TerminalPaneRuntime, + exitInfo: TerminalExitInfo +): void => { + if (!props.terminalSession.closePath.startsWith("/auth/") || exitInfo.exitCode !== 0) { + return + } + props.onAuthTerminalExitSuccess() + detachTerminalSession(props, runtime) +} + +const handleTerminalKill = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): void => { + requestTerminalSessionClose( + props.terminalSession.closePath, + (error) => { + props.onTerminalMessage(`Could not close SSH terminal ${props.terminalSession.session.id}: ${error}`) + }, + () => { + props.terminalSession.onExit?.() + detachTerminalSession(props, runtime) + props.onTerminalMessage( + `${runtime.pendingSession ? "Closed pending" : "Killed"} SSH terminal: ${props.terminalSession.session.id}.` + ) + } + ) +} + +const openBrowserAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { + const projectId = runtime.browserProjectId + return projectId === undefined || !runtime.canOpenBrowser + ? undefined + : () => { + props.onOpenProjectBrowserById(projectId) + } +} + +const applyProjectAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { + const projectId = runtime.browserProjectId + return projectId === undefined + ? undefined + : () => { + props.onApplyProjectById(projectId) + } +} + +const openTaskManagerAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { + const projectId = runtime.browserProjectId + return projectId === undefined + ? undefined + : () => { + props.onOpenProjectTaskManagerById(projectId) + } +} + +const openTerminalAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { + const projectId = runtime.browserProjectId + return projectId === undefined + ? undefined + : () => { + props.onOpenProjectTerminalById(projectId, runtime.browserProjectKey) + } +} + +const TerminalPanelForPane = ( + { bodyContent, props, runtime }: { + readonly bodyContent: JSX.Element | undefined + readonly props: TerminalPaneProps + readonly runtime: TerminalPaneRuntime + } +): JSX.Element => ( + { + detachTerminalSession(props, runtime) + }} + onDetach={() => { + detachTerminalSession(props, runtime) + props.onTerminalMessage( + `${runtime.pendingSession ? "Closed pending" : "Detached"} SSH terminal: ${props.terminalSession.session.id}.` + ) + }} + onExit={(exitInfo) => { + handleTerminalExit(props, runtime, exitInfo) + }} + onKill={() => { + handleTerminalKill(props, runtime) + }} + onOpenBrowser={openBrowserAction(props, runtime)} + onOpenSkiller={projectSkillerAction( + runtime.browserProjectKey, + props.terminalSession.session.id, + props.onOpenSkiller + )} + onApplyProject={applyProjectAction(props, runtime)} + onOpenTaskManager={openTaskManagerAction(props, runtime)} + onOpenTerminal={openTerminalAction(props, runtime)} + onMessage={props.onTerminalMessage} + session={props.terminalSession} + /> +) + +export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { + const runtime = resolveTerminalPaneRuntime(props) + const bodyContent = terminalBodyContent(props, runtime) + return ( +
+ +
+ ) +} diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index 98fae077..34ad771b 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -1,84 +1,13 @@ -import { Effect } from "effect" -import { type CSSProperties, type JSX, useEffect, useState } from "react" +import type { JSX } from "react" +import { useEffect, useState } from "react" -import { deleteTerminalSessionByPath } from "./api.js" -import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" -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 BrowserScreen, projectPickerScreen } from "./screen.js" +import { TerminalPane } from "./app-ready-terminal-pane.js" +import { TerminalTabs } from "./app-ready-terminal-tabs.js" +import type { TerminalScreenProps, TerminalWorkspaceView } from "./app-ready-terminal-types.js" +import { Box } from "./elements.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" - -type TerminalWorkspaceView = "terminal" | "tasks" - -type TerminalScreenProps = Pick< - ReadyLayoutProps, - | "activeTerminalSessionId" - | "onApplyProjectById" - | "onOpenProjectBrowserById" - | "onOpenProjectTaskManagerById" - | "onOpenProjectTerminalById" - | "onOpenSkiller" - | "onAuthTerminalExitSuccess" - | "onLoadProjectTaskLogs" - | "onProjectTasksIncludeDefaultChange" - | "onRefreshProjectTasks" - | "onSelectTerminal" - | "onSetActiveScreen" - | "onStopProjectTask" - | "onTerminalClose" - | "onTerminalMessage" - | "project" - | "projectBrowser" - | "projectTaskLogs" - | "projectTasks" - | "projectTasksIncludeDefault" - | "selectedProjectSummary" - | "terminalSessions" - | "viewportLayout" -> - -type TerminalPaneProps = - & Pick< - TerminalScreenProps, - | "onApplyProjectById" - | "onOpenProjectBrowserById" - | "onOpenProjectTaskManagerById" - | "onOpenProjectTerminalById" - | "onOpenSkiller" - | "onAuthTerminalExitSuccess" - | "onLoadProjectTaskLogs" - | "onProjectTasksIncludeDefaultChange" - | "onRefreshProjectTasks" - | "onSetActiveScreen" - | "onStopProjectTask" - | "onTerminalClose" - | "onTerminalMessage" - | "project" - | "projectBrowser" - | "projectTaskLogs" - | "projectTasks" - | "projectTasksIncludeDefault" - | "selectedProjectSummary" - | "viewportLayout" - > - & { - readonly taskManagerOpen: boolean - readonly onCloseTaskManager: () => void - readonly singleSession: boolean - readonly terminalSession: ActiveTerminalSession - } - -const requestTerminalSessionClose = (closePath: string): void => { - void Effect.runPromise(deleteTerminalSessionByPath(closePath).pipe(Effect.either, Effect.asVoid)) -} - -const terminalReturnScreen = (session: ActiveTerminalSession): BrowserScreen => - session.closePath.startsWith("/auth/") ? { tag: "Auth" } : projectPickerScreen() +import type { ActiveTerminalSession } from "./terminal.js" const resolveActiveTerminalSessionId = ( sessions: ReadonlyArray, @@ -90,405 +19,97 @@ const resolveActiveTerminalSessionId = ( ) { return activeTerminalSessionId } - return null -} - -const activeTerminalPaneStyle: CSSProperties = { - display: "flex", - flex: 1, - minHeight: 0, - overflow: "hidden" -} - -const taskManagerBodyStyle: CSSProperties = { - background: "#080a0d", - boxSizing: "border-box", - color: "#d6e5f7", - height: "100%", - overflow: "auto", - padding: "10px" -} - -const taskManagerToolbarStyle: CSSProperties = { - alignItems: "center", - display: "flex", - justifyContent: "flex-end", - marginBottom: "10px" -} - -const taskManagerReturnButtonStyle: CSSProperties = { - background: "#171d24", - border: "1px solid #3a4652", - borderRadius: "8px", - color: "#d6e5f7", - cursor: "pointer", - font: "inherit", - padding: "6px 10px" -} - -const pendingTerminalBodyStyle: CSSProperties = { - alignItems: "center", - background: "rgba(8, 10, 13, 0.92)", - boxSizing: "border-box", - color: "#d6e5f7", - display: "flex", - height: "100%", - justifyContent: "center", - padding: "20px", - textAlign: "center", - whiteSpace: "pre-wrap" -} - -const fallbackTerminalTabLabel = (session: ActiveTerminalSession): string => - session.browserProjectName ?? session.header - -const terminalTabLabel = ( - session: ActiveTerminalSession, - labels: ReadonlyMap -): string => - session.browserProjectId === undefined - ? fallbackTerminalTabLabel(session) - : labels.get(terminalSessionId(session)) ?? fallbackTerminalTabLabel(session) - -const projectSkillerAction = ( - projectKey: string | undefined, - sessionId: string, - onOpenSkiller: (projectKey?: string, sessionId?: string) => void -): (() => void) | undefined => - projectKey === undefined - ? undefined - : () => { - onOpenSkiller(projectKey, sessionId) - } - -const PendingTerminalBody = ({ session }: { readonly session: ActiveTerminalSession }): JSX.Element | null => { - if (!isPendingActiveTerminalSession(session)) { - return null - } - - const { pendingConnection } = session - const hint = pendingConnection.phase === "error" - ? "Close this tab or open a new terminal to retry." - : "The terminal workspace is open. SSH will attach as soon as the project is ready." - return ( -
-
-
{pendingConnection.message}
-
{hint}
-
-
- ) + const fallback = sessions[0] + return fallback === undefined ? null : terminalSessionId(fallback) } -const TerminalTaskManagerBody = ( - { - onClose, - onLoadProjectTaskLogs, - onProjectTasksIncludeDefaultChange, - onRefreshProjectTasks, - onStopProjectTask, - project, - projectTaskLogs, - projectTasks, - projectTasksIncludeDefault, - selectedProjectSummary - }: - & Pick< - TerminalScreenProps, - | "onLoadProjectTaskLogs" - | "onProjectTasksIncludeDefaultChange" - | "onRefreshProjectTasks" - | "onStopProjectTask" - | "project" - | "projectTaskLogs" - | "projectTasks" - | "projectTasksIncludeDefault" - | "selectedProjectSummary" - > - & { - readonly onClose: () => void - } -): JSX.Element => ( -
-
- -
- -
-) - -const TerminalTab = ( - { - active, - compactMobile, - onSelect, - session, - terminalLabels - }: { - readonly active: boolean - readonly compactMobile: boolean - readonly onSelect: () => void - readonly session: ActiveTerminalSession - readonly terminalLabels: ReadonlyMap - } -): JSX.Element => ( - - - {terminalTabLabel(session, terminalLabels)} - - -) - -const TerminalTabs = ( - { - activeSessionId, - compactMobile, - onOpenProjectTerminalById, - onSelectTerminal, - terminalSessions - }: Pick & { +const TerminalScreenTabs = ( + props: TerminalScreenProps & { readonly activeSessionId: string | null - readonly compactMobile: boolean - } -): JSX.Element => { - const terminalLabels = terminalTitleById(terminalSessions.map((session) => session.session)) - if (compactMobile) { - return ( -
- {terminalSessions.map((session) => { - const sessionId = terminalSessionId(session) - return ( - { - onSelectTerminal(sessionId) - }} - session={session} - terminalLabels={terminalLabels} - /> - ) - })} - {terminalSessions.length === 0 - ? null - : ( - { - const active = terminalSessions.find((session) => terminalSessionId(session) === activeSessionId) ?? - terminalSessions.at(-1) - const projectId = active?.browserProjectId - const projectKey = active?.browserProjectKey - if (projectId !== undefined) { - onOpenProjectTerminalById(projectId, projectKey) - } - }} - padding="6px" - width="auto" - > - + New - - )} -
- ) + readonly mobileMode: boolean } - - return ( - - {terminalSessions.map((session) => { - const sessionId = terminalSessionId(session) - return ( - { - onSelectTerminal(sessionId) - }} - session={session} - terminalLabels={terminalLabels} - /> - ) - })} - {terminalSessions.length === 0 - ? null - : ( - { - const active = terminalSessions.find((session) => terminalSessionId(session) === activeSessionId) ?? - terminalSessions.at(-1) - const projectId = active?.browserProjectId - const projectKey = active?.browserProjectKey - if (projectId !== undefined) { - onOpenProjectTerminalById(projectId, projectKey) - } - }} - padding="6px" - width="auto" - > - + New terminal - - )} - - ) -} - -const TerminalPane = ( - { - onApplyProjectById, - onAuthTerminalExitSuccess, - onCloseTaskManager, - onLoadProjectTaskLogs, - onOpenProjectBrowserById, - onOpenProjectTaskManagerById, - onOpenProjectTerminalById, - onOpenSkiller, - onProjectTasksIncludeDefaultChange, - onRefreshProjectTasks, - onSetActiveScreen, - onStopProjectTask, - onTerminalClose, - onTerminalMessage, - project, - projectBrowser, - projectTaskLogs, - projectTasks, - projectTasksIncludeDefault, - selectedProjectSummary, - singleSession, - taskManagerOpen, - terminalSession, - viewportLayout - }: TerminalPaneProps -): JSX.Element => { - const sessionId = terminalSessionId(terminalSession) - const browserProjectId = terminalSession.browserProjectId - const browserProjectKey = terminalSession.browserProjectKey - const canOpenBrowser = canOpenProjectBrowser(projectBrowser, browserProjectId) - const pendingSession = isPendingActiveTerminalSession(terminalSession) - let bodyContent: JSX.Element | undefined - if (taskManagerOpen && browserProjectId !== undefined) { - bodyContent = ( - + shouldShowTerminalTabs(props.mobileMode, props.terminalSessions.length) + ? ( + ) - } else if (pendingSession) { - bodyContent = - } - const detachTerminalSession = (): void => { - onTerminalClose(sessionId) - if (singleSession) { - onSetActiveScreen(terminalReturnScreen(terminalSession)) - } - } - const handleTerminalExit = (exitInfo: TerminalExitInfo): void => { - if (!terminalSession.closePath.startsWith("/auth/")) { - return - } - if (exitInfo.exitCode !== 0) { - return - } - onAuthTerminalExitSuccess() - detachTerminalSession() + : null + +const ActiveTerminalPane = ( + props: TerminalScreenProps & { + readonly activeSession: ActiveTerminalSession | undefined + readonly setTerminalView: (view: TerminalWorkspaceView) => void + readonly terminalView: TerminalWorkspaceView } - return ( -
- { - detachTerminalSession() - }} - onDetach={() => { - detachTerminalSession() - onTerminalMessage( - `${pendingSession ? "Closed pending" : "Detached"} SSH terminal: ${terminalSession.session.id}.` - ) +): JSX.Element | null => + props.activeSession === undefined + ? null + : ( + { + props.setTerminalView("terminal") }} - onExit={handleTerminalExit} - onKill={() => { - if (!pendingSession) { - requestTerminalSessionClose(terminalSession.closePath) - terminalSession.onExit?.() - } - detachTerminalSession() - onTerminalMessage( - `${pendingSession ? "Closed pending" : "Killed"} SSH terminal: ${terminalSession.session.id}.` - ) + onLoadProjectTaskLogs={props.onLoadProjectTaskLogs} + onOpenProjectBrowserById={props.onOpenProjectBrowserById} + onOpenProjectTaskManagerById={(projectId) => { + props.setTerminalView("tasks") + props.onOpenProjectTaskManagerById(projectId) }} - onOpenBrowser={browserProjectId === undefined || !canOpenBrowser - ? undefined - : () => { - onOpenProjectBrowserById(browserProjectId) - }} - onOpenSkiller={projectSkillerAction(browserProjectKey, terminalSession.session.id, onOpenSkiller)} - onApplyProject={browserProjectId === undefined - ? undefined - : () => { - onApplyProjectById(browserProjectId) - }} - onOpenTaskManager={browserProjectId === undefined - ? undefined - : () => { - onOpenProjectTaskManagerById(browserProjectId) - }} - onOpenTerminal={browserProjectId === undefined - ? undefined - : () => { - onOpenProjectTerminalById(browserProjectId, browserProjectKey) - }} - onMessage={onTerminalMessage} - session={terminalSession} + onOpenProjectTerminalById={props.onOpenProjectTerminalById} + onOpenSkiller={props.onOpenSkiller} + onProjectTasksIncludeDefaultChange={props.onProjectTasksIncludeDefaultChange} + onRefreshProjectTasks={props.onRefreshProjectTasks} + onSetActiveScreen={props.onSetActiveScreen} + onStopProjectTask={props.onStopProjectTask} + onTerminalClose={props.onTerminalClose} + onTerminalMessage={props.onTerminalMessage} + project={props.project} + projectBrowser={props.projectBrowser} + projectTaskLogs={props.projectTaskLogs} + projectTasks={props.projectTasks} + projectTasksIncludeDefault={props.projectTasksIncludeDefault} + selectedProjectSummary={props.selectedProjectSummary} + singleSession={props.terminalSessions.length === 1} + taskManagerOpen={props.terminalView === "tasks"} + terminalSession={props.activeSession} + viewportLayout={props.viewportLayout} /> -
- ) -} + ) + +const TerminalScreenLayout = ( + props: TerminalScreenProps & { + readonly activeSession: ActiveTerminalSession | undefined + readonly activeSessionId: string | null + readonly mobileMode: boolean + readonly setTerminalView: (view: TerminalWorkspaceView) => void + readonly terminalView: TerminalWorkspaceView + } +): JSX.Element => ( + + + + + + +) export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null => { const [terminalView, setTerminalView] = useState("terminal") @@ -498,60 +119,16 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = useEffect(() => { setTerminalView("terminal") }, [activeSession?.browserProjectId, activeSessionId]) - if (props.terminalSessions.length === 0) { - return null - } - return ( - - {shouldShowTerminalTabs(mobileMode, props.terminalSessions.length) - ? ( - - ) - : null} - - {activeSession === undefined - ? null - : ( - { - setTerminalView("terminal") - }} - onLoadProjectTaskLogs={props.onLoadProjectTaskLogs} - onOpenProjectBrowserById={props.onOpenProjectBrowserById} - onOpenProjectTaskManagerById={(projectId) => { - setTerminalView("tasks") - props.onOpenProjectTaskManagerById(projectId) - }} - onOpenProjectTerminalById={props.onOpenProjectTerminalById} - onOpenSkiller={props.onOpenSkiller} - onProjectTasksIncludeDefaultChange={props.onProjectTasksIncludeDefaultChange} - onRefreshProjectTasks={props.onRefreshProjectTasks} - onSetActiveScreen={props.onSetActiveScreen} - onStopProjectTask={props.onStopProjectTask} - onTerminalClose={props.onTerminalClose} - onTerminalMessage={props.onTerminalMessage} - project={props.project} - projectBrowser={props.projectBrowser} - projectTaskLogs={props.projectTaskLogs} - projectTasks={props.projectTasks} - projectTasksIncludeDefault={props.projectTasksIncludeDefault} - selectedProjectSummary={props.selectedProjectSummary} - singleSession={props.terminalSessions.length === 1} - taskManagerOpen={terminalView === "tasks"} - terminalSession={activeSession} - viewportLayout={props.viewportLayout} - /> - )} - - - ) + return props.terminalSessions.length === 0 + ? null + : ( + + ) } diff --git a/packages/app/src/web/app-ready-terminal-tabs.tsx b/packages/app/src/web/app-ready-terminal-tabs.tsx new file mode 100644 index 00000000..a252c65a --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-tabs.tsx @@ -0,0 +1,133 @@ +import type { CSSProperties, JSX } from "react" + +import type { TerminalScreenProps } from "./app-ready-terminal-types.js" +import { Box, Text } from "./elements.js" +import { terminalSessionId } from "./terminal-state.js" +import { type ActiveTerminalSession, terminalTitleById } from "./terminal.js" + +type TerminalTabsProps = + & Pick + & { + readonly activeSessionId: string | null + readonly compactMobile: boolean + } + +const mobileTabsStyle: CSSProperties = { + display: "flex", + flexShrink: 0, + gap: "6px", + minWidth: 0, + overflowX: "auto", + overflowY: "hidden", + paddingBottom: "4px" +} + +const fallbackTerminalTabLabel = (session: ActiveTerminalSession): string => + session.browserProjectName ?? session.header + +const terminalTabLabel = ( + session: ActiveTerminalSession, + labels: ReadonlyMap +): string => + session.browserProjectId === undefined + ? fallbackTerminalTabLabel(session) + : labels.get(terminalSessionId(session)) ?? fallbackTerminalTabLabel(session) + +const activeTerminalProject = ( + sessions: ReadonlyArray, + activeSessionId: string | null +): { readonly projectId: string; readonly projectKey?: string | undefined } | null => { + const active = sessions.find((session) => terminalSessionId(session) === activeSessionId) ?? sessions.at(-1) + return active?.browserProjectId === undefined + ? null + : { projectId: active.browserProjectId, projectKey: active.browserProjectKey } +} + +const TerminalTab = ( + props: { + readonly active: boolean + readonly compactMobile: boolean + readonly onSelect: () => void + readonly session: ActiveTerminalSession + readonly terminalLabels: ReadonlyMap + } +): JSX.Element => ( + + + {terminalTabLabel(props.session, props.terminalLabels)} + + +) + +const NewTerminalButton = ( + props: Pick +): JSX.Element | null => { + const activeProject = activeTerminalProject(props.terminalSessions, props.activeSessionId) + if (activeProject === null) { + return null + } + return ( + { + props.onOpenProjectTerminalById(activeProject.projectId, activeProject.projectKey) + }} + padding="6px" + width="auto" + > + {props.compactMobile ? "+ New" : "+ New terminal"} + + ) +} + +const TerminalTabItems = (props: TerminalTabsProps): JSX.Element => { + const terminalLabels = terminalTitleById(props.terminalSessions.map((session) => session.session)) + return ( + <> + {props.terminalSessions.map((session) => { + const sessionId = terminalSessionId(session) + return ( + { + props.onSelectTerminal(sessionId) + }} + session={session} + terminalLabels={terminalLabels} + /> + ) + })} + + + ) +} + +export const TerminalTabs = (props: TerminalTabsProps): JSX.Element => + props.compactMobile + ? ( +
+ +
+ ) + : ( + + + + ) diff --git a/packages/app/src/web/app-ready-terminal-task-manager.tsx b/packages/app/src/web/app-ready-terminal-task-manager.tsx new file mode 100644 index 00000000..a16fd310 --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-task-manager.tsx @@ -0,0 +1,68 @@ +import type { CSSProperties, JSX } from "react" + +import type { TerminalScreenProps } from "./app-ready-terminal-types.js" +import { TaskPanel } from "./panel-tasks.js" + +const taskManagerBodyStyle: CSSProperties = { + background: "#080a0d", + boxSizing: "border-box", + color: "#d6e5f7", + height: "100%", + overflow: "auto", + padding: "10px" +} + +const taskManagerToolbarStyle: CSSProperties = { + alignItems: "center", + display: "flex", + justifyContent: "flex-end", + marginBottom: "10px" +} + +const taskManagerReturnButtonStyle: CSSProperties = { + background: "#171d24", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#d6e5f7", + cursor: "pointer", + font: "inherit", + padding: "6px 10px" +} + +export const TerminalTaskManagerBody = ( + props: + & Pick< + TerminalScreenProps, + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" + | "onStopProjectTask" + | "project" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" + > + & { + readonly onClose: () => void + } +): JSX.Element => ( +
+
+ +
+ +
+) diff --git a/packages/app/src/web/app-ready-terminal-types.ts b/packages/app/src/web/app-ready-terminal-types.ts new file mode 100644 index 00000000..cb62984e --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-types.ts @@ -0,0 +1,62 @@ +import type { ReadyLayoutProps } from "./app-ready-layout.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export type TerminalWorkspaceView = "terminal" | "tasks" + +export type TerminalScreenProps = Pick< + ReadyLayoutProps, + | "activeTerminalSessionId" + | "onApplyProjectById" + | "onOpenProjectBrowserById" + | "onOpenProjectTaskManagerById" + | "onOpenProjectTerminalById" + | "onOpenSkiller" + | "onAuthTerminalExitSuccess" + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" + | "onSelectTerminal" + | "onSetActiveScreen" + | "onStopProjectTask" + | "onTerminalClose" + | "onTerminalMessage" + | "project" + | "projectBrowser" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" + | "terminalSessions" + | "viewportLayout" +> + +export type TerminalPaneProps = + & Pick< + TerminalScreenProps, + | "onApplyProjectById" + | "onOpenProjectBrowserById" + | "onOpenProjectTaskManagerById" + | "onOpenProjectTerminalById" + | "onOpenSkiller" + | "onAuthTerminalExitSuccess" + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" + | "onSetActiveScreen" + | "onStopProjectTask" + | "onTerminalClose" + | "onTerminalMessage" + | "project" + | "projectBrowser" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" + | "viewportLayout" + > + & { + readonly taskManagerOpen: boolean + readonly onCloseTaskManager: () => void + readonly singleSession: boolean + readonly terminalSession: ActiveTerminalSession + } diff --git a/packages/app/src/web/panel-content-renderers.tsx b/packages/app/src/web/panel-content-renderers.tsx new file mode 100644 index 00000000..7b2dd384 --- /dev/null +++ b/packages/app/src/web/panel-content-renderers.tsx @@ -0,0 +1,151 @@ +import { Match } from "effect" +import type { JSX } from "react" + +import { Box, Text } from "./elements.js" +import type { BrowserMenuTag } from "./menu.js" +import { AuthPanel } from "./panel-auth.js" +import type { ContentPanelProps } from "./panel-content-types.js" +import { CreatePanel } from "./panel-create-select.js" +import { ProjectAuthPanel } from "./panel-project-auth.js" +import { ProjectDetailsPanel, SelectPanel } from "./panel-project-details.js" + +type StaticMenuTag = Exclude< + BrowserMenuTag, + | "Auth" + | "Browser" + | "Create" + | "Databases" + | "Delete" + | "Down" + | "Info" + | "Ports" + | "ProjectAuth" + | "Prompts" + | "Select" + | "Share" + | "Skills" + | "Tasks" +> + +const StaticActionPanel = ( + { description, title }: { readonly description: string; readonly title: string } +): JSX.Element => ( + + {title} + {description} + +) + +const staticPanels: Record = { + DownAll: { + description: "Press Enter to stop all projects.", + title: "docker compose down (ALL projects)" + }, + Logs: { + description: "Press Enter to load docker compose logs --tail=200.", + title: "docker compose logs" + }, + Quit: { + description: "Browser mode maps Quit to closing the tab.", + title: "Quit" + }, + Status: { + description: "Press Enter to load docker compose ps.", + title: "docker compose ps" + } +} + +const renderStaticMenuPanel = (currentMenu: StaticMenuTag): JSX.Element => { + const panel = staticPanels[currentMenu] + return +} + +const renderCreateContent = (props: ContentPanelProps): JSX.Element => ( + +) + +const renderSelectContent = (props: ContentPanelProps): JSX.Element => ( + +) + +const renderProjectDetailsContent = ( + currentMenu: Extract, + props: ContentPanelProps +): JSX.Element => ( + +) + +const renderProjectPickerHandledContent = (currentMenu: BrowserMenuTag): JSX.Element => ( + +) + +export const renderContentBody = (props: ContentPanelProps): JSX.Element => + Match.value(props.currentMenu).pipe( + Match.when("Auth", () => renderAuthContent(props)), + Match.when("ProjectAuth", () => renderProjectAuthContent(props)), + Match.when("Create", () => renderCreateContent(props)), + Match.when("Select", () => renderSelectContent(props)), + Match.when("Delete", () => renderProjectDetailsContent("Delete", props)), + Match.when("Down", () => renderProjectDetailsContent("Down", props)), + Match.when("Info", () => renderProjectDetailsContent("Info", props)), + Match.when("DownAll", () => renderStaticMenuPanel("DownAll")), + Match.when("Logs", () => renderStaticMenuPanel("Logs")), + Match.when("Quit", () => renderStaticMenuPanel("Quit")), + Match.when("Status", () => renderStaticMenuPanel("Status")), + Match.when("Browser", () => renderProjectPickerHandledContent("Browser")), + Match.when("Databases", () => renderProjectPickerHandledContent("Databases")), + Match.when("Ports", () => renderProjectPickerHandledContent("Ports")), + Match.when("Prompts", () => renderProjectPickerHandledContent("Prompts")), + Match.when("Share", () => renderProjectPickerHandledContent("Share")), + Match.when("Skills", () => renderProjectPickerHandledContent("Skills")), + Match.when("Tasks", () => renderProjectPickerHandledContent("Tasks")), + Match.exhaustive + ) + +export const renderAuthContent = (props: ContentPanelProps): JSX.Element => ( + +) + +export const renderProjectAuthContent = (props: ContentPanelProps): JSX.Element => ( + +) diff --git a/packages/app/src/web/panel-content-types.ts b/packages/app/src/web/panel-content-types.ts new file mode 100644 index 00000000..57de5f3a --- /dev/null +++ b/packages/app/src/web/panel-content-types.ts @@ -0,0 +1,37 @@ +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 type { BrowserMenuTag } from "./menu.js" + +export type ContentPanelProps = { + readonly actionPrompt: ActionPromptState | null + readonly authSnapshot: AuthSnapshot | null + readonly compact: boolean + readonly controllerCwd: string + readonly dashboardRefreshTick: number + readonly projectsRoot: string + readonly createView: CreateFlowView + readonly currentMenu: BrowserMenuTag + readonly githubStatus: GithubAuthStatus | null + readonly onActionPromptCancel: () => void + readonly onActionPromptChange: (key: string, value: string) => void + readonly onActionPromptSubmit: () => void + readonly onAttachProjectTerminalSession: ( + projectId: string, + projectKey: string, + projectDisplayName: string, + sessionId: string + ) => void + readonly onCreateBufferChange: (buffer: string) => void + readonly onCreateCancel: () => 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 + readonly onRunProjectAuthAction: (index: number) => void + readonly project: ProjectDetails | null + readonly projectNavigationArmed: boolean + readonly projectAuthSnapshot: ProjectAuthSnapshot | null + readonly selectedProjectSummary: ProjectSummary | undefined +} diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index 0d0ddebd..72c8f9f0 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -1,334 +1,14 @@ -import { Match } from "effect" 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" -import { CreatePanel } from "./panel-create-select.js" -import { ProjectAuthPanel } from "./panel-project-auth.js" -import { ProjectDetailsPanel, SelectPanel } from "./panel-project-details.js" +import { renderAuthContent, renderContentBody, renderProjectAuthContent } from "./panel-content-renderers.js" +import type { ContentPanelProps } from "./panel-content-types.js" -type ContentPanelProps = { - readonly actionPrompt: ActionPromptState | null - readonly authSnapshot: AuthSnapshot | null - readonly compact: boolean - readonly controllerCwd: string - readonly dashboardRefreshTick: number - readonly projectsRoot: string - readonly createView: CreateFlowView - readonly currentMenu: BrowserMenuTag - readonly githubStatus: GithubAuthStatus | null - readonly onActionPromptCancel: () => void - readonly onActionPromptChange: (key: string, value: string) => void - readonly onActionPromptSubmit: () => void - readonly onAttachProjectTerminalSession: ( - projectId: string, - projectKey: string, - projectDisplayName: string, - sessionId: string - ) => void - readonly onCreateBufferChange: (buffer: string) => void - readonly onCreateCancel: () => 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 - readonly onRunProjectAuthAction: (index: number) => void - readonly project: ProjectDetails | null - readonly projectNavigationArmed: boolean - readonly projectAuthSnapshot: ProjectAuthSnapshot | null - readonly selectedProjectSummary: ProjectSummary | undefined -} - -type StaticMenuTag = Exclude< - BrowserMenuTag, - | "Auth" - | "Browser" - | "Create" - | "Databases" - | "Delete" - | "Down" - | "Info" - | "Ports" - | "ProjectAuth" - | "Prompts" - | "Select" - | "Share" - | "Skills" - | "Tasks" -> - -const StaticActionPanel = ( - { description, title }: { readonly description: string; readonly title: string } -): JSX.Element => ( - - {title} - {description} - -) - -const staticPanels: Record = { - DownAll: { - description: "Press Enter to stop all projects.", - title: "docker compose down (ALL projects)" - }, - Logs: { - description: "Press Enter to load docker compose logs --tail=200.", - title: "docker compose logs" - }, - Quit: { - description: "Browser mode maps Quit to closing the tab.", - title: "Quit" - }, - Status: { - description: "Press Enter to load docker compose ps.", - title: "docker compose ps" - } -} - -const renderStaticMenuPanel = (currentMenu: StaticMenuTag): JSX.Element => { - const panel = staticPanels[currentMenu] - return -} - -const renderSelectContent = ( - { - currentMenu, - dashboardRefreshTick, - onAttachProjectTerminalSession, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - project, - projectNavigationArmed, - selectedProjectSummary - }: Pick< - ContentPanelProps, - | "currentMenu" - | "dashboardRefreshTick" - | "onAttachProjectTerminalSession" - | "onKillProjectTerminalSession" - | "onOpenProjectTerminalById" - | "project" - | "projectNavigationArmed" - | "selectedProjectSummary" - > -): JSX.Element => ( - -) - -const renderProjectDetailsContent = ( - currentMenu: Extract, - project: ProjectDetails | null, - selectedProjectSummary: ProjectSummary | undefined -): JSX.Element => ( - -) - -const renderContentBody = ( - { - compact, - controllerCwd, - createView, - currentMenu, - dashboardRefreshTick, - onAttachProjectTerminalSession, - onCreateBufferChange, - onCreateCancel, - onCreateSubmit, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - project, - projectNavigationArmed, - projectsRoot, - selectedProjectSummary - }: Pick< - ContentPanelProps, - | "compact" - | "controllerCwd" - | "dashboardRefreshTick" - | "projectsRoot" - | "createView" - | "currentMenu" - | "onAttachProjectTerminalSession" - | "onCreateBufferChange" - | "onCreateCancel" - | "onCreateSubmit" - | "onKillProjectTerminalSession" - | "onOpenProjectTerminalById" - | "project" - | "projectNavigationArmed" - | "selectedProjectSummary" - > -): JSX.Element => - Match.value(currentMenu).pipe( - Match.when( - "Create", - () => ( - - ) - ), - Match.when( - "Select", - () => - renderSelectContent({ - currentMenu: "Select", - dashboardRefreshTick, - onAttachProjectTerminalSession, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - project, - projectNavigationArmed, - selectedProjectSummary - }) - ), - Match.when("Delete", () => renderProjectDetailsContent("Delete", project, selectedProjectSummary)), - Match.when("Down", () => renderProjectDetailsContent("Down", project, selectedProjectSummary)), - Match.when("Info", () => renderProjectDetailsContent("Info", project, selectedProjectSummary)), - Match.orElse((menu) => renderStaticMenuPanel(menu as StaticMenuTag)) - ) - -const renderAuthContent = ( - { - actionPrompt, - authSnapshot, - githubStatus, - onActionPromptCancel, - onActionPromptChange, - onActionPromptSubmit, - onRunAuthAction - }: Pick< - ContentPanelProps, - | "actionPrompt" - | "authSnapshot" - | "githubStatus" - | "onActionPromptCancel" - | "onActionPromptChange" - | "onActionPromptSubmit" - | "onRunAuthAction" - > -): JSX.Element => ( - -) - -const renderProjectAuthContent = ( - { - actionPrompt, - onActionPromptCancel, - onActionPromptChange, - onActionPromptSubmit, - onRunProjectAuthAction, - projectAuthSnapshot, - selectedProjectSummary - }: Pick< - ContentPanelProps, - | "actionPrompt" - | "onActionPromptCancel" - | "onActionPromptChange" - | "onActionPromptSubmit" - | "onRunProjectAuthAction" - | "selectedProjectSummary" - | "projectAuthSnapshot" - > -): JSX.Element => ( - -) - -export const ContentPanel = ( - { - actionPrompt, - authSnapshot, - currentMenu, - dashboardRefreshTick, - githubStatus, - onActionPromptCancel, - onActionPromptChange, - onActionPromptSubmit, - onAttachProjectTerminalSession, - onCreateBufferChange, - onCreateCancel, - onCreateSubmit, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - onRunAuthAction, - onRunProjectAuthAction, - projectAuthSnapshot, - selectedProjectSummary, - ...props - }: ContentPanelProps -): JSX.Element => { - if (currentMenu === "Auth") { - return renderAuthContent({ - actionPrompt, - authSnapshot, - githubStatus, - onActionPromptCancel, - onActionPromptChange, - onActionPromptSubmit, - onRunAuthAction - }) +export const ContentPanel = (props: ContentPanelProps): JSX.Element => { + if (props.currentMenu === "Auth") { + return renderAuthContent(props) } - if (currentMenu === "ProjectAuth") { - return renderProjectAuthContent({ - actionPrompt, - onActionPromptCancel, - onActionPromptChange, - onActionPromptSubmit, - onRunProjectAuthAction, - selectedProjectSummary, - projectAuthSnapshot - }) + if (props.currentMenu === "ProjectAuth") { + return renderProjectAuthContent(props) } - return renderContentBody({ - ...props, - currentMenu, - dashboardRefreshTick, - onAttachProjectTerminalSession, - onCreateBufferChange, - onCreateCancel, - onCreateSubmit, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - selectedProjectSummary - }) + return renderContentBody(props) } diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 02a29211..17b1249b 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -17,6 +17,25 @@ import { Box, Button, Text, TextInput } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" import type { CreateSubmitMode } from "./app-ready-create.js" +type CreatePanelProps = { + readonly compact: boolean + readonly controllerCwd: string + readonly createView: CreateFlowView + readonly projectsRoot: string + readonly onBufferChange: (buffer: string) => void + readonly onCancel: () => void + readonly onSubmit: (mode: CreateSubmitMode) => void +} + +type CreatePanelModel = { + readonly activeStep: CreateStep + readonly isRepoStep: boolean + readonly leftChoiceBuffer: string | null + readonly prompt: ReturnType + readonly rightChoiceBuffer: string | null + readonly visibleSteps: ReadonlyArray +} + const renderStepColor = (active: boolean): string => active ? "#56f39a" : "#8fa6c4" const webCreateSettingsNavigationHint = "↑ - up, ↓ - down, Enter - apply + down" @@ -78,107 +97,120 @@ const CreatePromptInput = ( ) -export const CreatePanel = ( - { - compact, - controllerCwd, - createView, - onBufferChange, - onCancel, - onSubmit, - projectsRoot - }: { - readonly compact: boolean - readonly controllerCwd: string - readonly createView: CreateFlowView - readonly projectsRoot: string - readonly onBufferChange: (buffer: string) => void - readonly onCancel: () => void - readonly onSubmit: (mode: CreateSubmitMode) => void - } -): JSX.Element => { +const resolveCreatePanelModel = ( + { compact, controllerCwd, createView, projectsRoot }: Pick< + CreatePanelProps, + "compact" | "controllerCwd" | "createView" | "projectsRoot" + > +): CreatePanelModel => { const prompt = createPrompt({ cwd: controllerCwd, projectsRoot }, createView) const steps = resolveCreateDisplaySteps() const activeStep = isDisplayModeFlowView(createView) ? steps[createView.step] ?? "repoUrl" : "repoUrl" const isRepoStep = isCreateFlowRepoStep(createView) - const visibleSteps = compact && isRepoStep ? [activeStep] : steps - const leftChoiceBuffer = isDisplayModeFlowView(createView) - ? resolveCreateSettingsChoiceBuffer(createView, "left") - : null - const rightChoiceBuffer = isDisplayModeFlowView(createView) - ? resolveCreateSettingsChoiceBuffer(createView, "right") - : null - const chooseSettingsBuffer = (direction: CreateSettingsChoiceDirection): void => { - if (isDisplayModeFlowView(createView)) { - const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) - if (nextBuffer !== null) { - onBufferChange(nextBuffer) - } - } + + return { + activeStep, + isRepoStep, + leftChoiceBuffer: isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "left") + : null, + prompt, + rightChoiceBuffer: isDisplayModeFlowView(createView) + ? resolveCreateSettingsChoiceBuffer(createView, "right") + : null, + visibleSteps: compact && isRepoStep ? [activeStep] : steps + } +} + +const createChoiceHandler = ( + createView: CreateFlowView, + direction: CreateSettingsChoiceDirection, + onBufferChange: (buffer: string) => void +): () => void => +() => { + if (!isDisplayModeFlowView(createView)) { + return + } + const nextBuffer = resolveCreateSettingsChoiceBuffer(createView, direction) + if (nextBuffer !== null) { + onBufferChange(nextBuffer) } +} + +const CreateSubmitButtons = ( + { + isRepoStep, + onSubmit + }: { + readonly isRepoStep: boolean + readonly onSubmit: (mode: CreateSubmitMode) => void + } +): JSX.Element => ( + isRepoStep + ? ( + + +) + +const optionalProjectActions = ( + props: TerminalHeaderProps +): ReadonlyArray => { + if (props.session.browserProjectId === undefined) { + return [] + } + return [ + { compactLabel: "Browser", label: "Open browser", onClick: props.onOpenBrowser }, + { compactLabel: "Skiller", label: "Skiller", onClick: props.onOpenSkiller }, + { compactLabel: "Apply", label: "Apply", onClick: props.onApplyProject }, + { compactLabel: "Tasks", label: "Task manager", onClick: props.onOpenTaskManager }, + { compactLabel: "New", label: "New terminal", onClick: props.onOpenTerminal } + ] +} + +const TerminalProjectActionButtons = ( + { + actions, + compactHeaderMode + }: { + readonly actions: ReadonlyArray + readonly compactHeaderMode: boolean + } +): JSX.Element => ( + <> + {actions.map((action) => + action.onClick === undefined + ? null + : ( + + {compactHeaderMode ? action.compactLabel : action.label} + + ) + )} + +) + +const TerminalImageToggleButton = ( + { + compactHeaderMode, + inlineImagePreviewsEnabled, + onToggleInlineImagePreviews + }: Pick +): JSX.Element => { + const label = inlineImagePreviewsEnabled ? "Images on" : "Images off" + const compactLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off" + const title = inlineImagePreviewsEnabled + ? "Automatic image previews enabled" + : "Automatic image previews disabled" + + return ( + + {compactHeaderMode ? compactLabel : label} + + ) +} + +const TerminalHeaderActions = (props: TerminalHeaderProps): JSX.Element => ( +
+ + + + Detach + + + Kill + +
+) + +export const TerminalHeader = (props: TerminalHeaderProps): JSX.Element => ( +
+ + +
+) diff --git a/packages/app/src/web/panel-terminal-mobile-controls.tsx b/packages/app/src/web/panel-terminal-mobile-controls.tsx new file mode 100644 index 00000000..8f38bab8 --- /dev/null +++ b/packages/app/src/web/panel-terminal-mobile-controls.tsx @@ -0,0 +1,177 @@ +import type { JSX } from "react" + +import { + mobileArrowRowStyle, + mobileControlButtonStyle, + mobileControlsCollapsedStyle, + mobileControlsRowStyle, + mobileControlsStyle +} from "./panel-terminal-styles.js" +import { + isModifierOnlyTerminalKey, + type MobileTerminalKey, + mobileTerminalKeyInput, + terminalControlCharacterForKey +} from "./terminal-mobile-controls.js" +import type { TerminalInputController } from "./terminal-panel-runtime.js" + +type MobileTerminalControlsProps = { + readonly collapsed: boolean + readonly compactTypingMode: boolean + readonly ctrlArmed: boolean + readonly onKeyPress: (key: MobileTerminalKey) => void + readonly onToggleCollapsed: () => void + readonly onToggleCtrl: () => void +} + +type MobileTerminalArrowKey = Extract + +const mobileTerminalArrowKeys: ReadonlyArray = ["left", "up", "down", "right"] + +const mobileTerminalArrowLabels: Readonly> = { + down: "↓", + left: "←", + right: "→", + up: "↑" +} + +export const retainTerminalFocus = (controller: TerminalInputController | null): void => { + controller?.focus() +} + +export const sendTerminalMobileInput = ( + controller: TerminalInputController | null, + key: MobileTerminalKey +): void => { + controller?.sendInput(mobileTerminalKeyInput(key)) + retainTerminalFocus(controller) +} + +export const shouldKeepMobileCtrlArmed = (event: KeyboardEvent): boolean => + event.metaKey || event.altKey || event.ctrlKey || event.isComposing || isModifierOnlyTerminalKey(event.key) + +export const sendMobileCtrlEventInput = ( + controller: TerminalInputController | null, + event: KeyboardEvent +): void => { + const controlCharacter = terminalControlCharacterForKey(event.key) + if (controlCharacter === null) { + return + } + event.preventDefault() + event.stopPropagation() + controller?.sendInput(controlCharacter) + retainTerminalFocus(controller) +} + +const MobileTerminalControlButton = ( + { + active = false, + label, + onClick + }: { + readonly active?: boolean + readonly label: string + readonly onClick: () => void + } +): JSX.Element => ( + +) + +const MobileCommandControlsRow = ( + { + ctrlArmed, + onKeyPress, + onToggleCollapsed, + onToggleCtrl + }: Pick +): JSX.Element => ( +
+ { + onKeyPress("escape") + }} + /> + { + onKeyPress("tab") + }} + /> + + { + onKeyPress("ctrl-c") + }} + /> + +
+) + +const MobileArrowControlsRow = ( + { onKeyPress }: Pick +): JSX.Element => ( +
+ {mobileTerminalArrowKeys.map((key) => ( + { + onKeyPress(key) + }} + /> + ))} +
+) + +const CollapsedMobileTerminalControls = ( + { compactTypingMode, onToggleCollapsed }: Pick< + MobileTerminalControlsProps, + "compactTypingMode" | "onToggleCollapsed" + > +): JSX.Element => ( +
+ +
+) + +const ExpandedMobileTerminalControls = (props: Omit): JSX.Element => ( +
+ + +
+) + +export const MobileTerminalControls = (props: MobileTerminalControlsProps): JSX.Element => + props.collapsed + ? ( + + ) + : ( + + ) diff --git a/packages/app/src/web/panel-terminal-styles.ts b/packages/app/src/web/panel-terminal-styles.ts new file mode 100644 index 00000000..4cef64d7 --- /dev/null +++ b/packages/app/src/web/panel-terminal-styles.ts @@ -0,0 +1,218 @@ +import type { CSSProperties } from "react" + +import type { TerminalStatus } from "./terminal-panel-runtime.js" + +const panelStyle: CSSProperties = { + border: "1px solid #3a4652", + borderRadius: "8px", + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden" +} + +export const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProperties => ({ + ...panelStyle, + marginTop: mobileMode || keyboardOpen ? 0 : "8px" +}) + +export const headerStyle: CSSProperties = { + alignItems: "stretch", + background: "#101419", + borderBottom: "1px solid #3a4652", + display: "flex", + flexDirection: "column", + gap: "8px", + justifyContent: "flex-start", + padding: "10px 12px" +} + +export const compactHeaderStyle: CSSProperties = { + ...headerStyle, + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: "6px", + overflow: "visible", + padding: "5px 6px" +} + +const bodyStyle: CSSProperties = { + background: "#080a0d", + flex: 1, + minHeight: 0, + padding: "8px" +} + +const bodyStyleMobile: CSSProperties = { + ...bodyStyle, + padding: "2px" +} + +const bodyStyleKeyboardOpen: CSSProperties = { + ...bodyStyle, + padding: 0 +} + +const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => { + if (compactTypingMode) { + return bodyStyleKeyboardOpen + } + return mobileMode ? bodyStyleMobile : bodyStyle +} + +export const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ + ...terminalBodyStyle(compactTypingMode, mobileMode), + boxSizing: "border-box", + overflow: "hidden", + position: "relative" +}) + +export const terminalHostStyle: CSSProperties = { + height: "100%", + minHeight: 0, + overflow: "hidden" +} + +export const terminalBodyContentStyle: CSSProperties = { + bottom: 0, + height: "100%", + left: 0, + minHeight: 0, + overflow: "auto", + position: "absolute", + right: 0, + top: 0, + zIndex: 1 +} + +export const closeButtonStyle: CSSProperties = { + background: "#171d24", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#d6e5f7", + cursor: "pointer", + font: "inherit", + padding: "6px 10px" +} + +export const compactCloseButtonStyle: CSSProperties = { + ...closeButtonStyle, + fontSize: "11px", + padding: "4px 6px" +} + +export const headerActionsStyle: CSSProperties = { + alignItems: "center", + display: "flex", + flexShrink: 0, + flexWrap: "wrap", + gap: "8px", + justifyContent: "flex-start", + width: "100%" +} + +export const compactHeaderActionsStyle: CSSProperties = { + ...headerActionsStyle, + flexWrap: "wrap", + gap: "4px", + justifyContent: "flex-end", + marginLeft: "auto", + width: "auto" +} + +export const mobileControlsCollapsedStyle: CSSProperties = { + alignItems: "center", + background: "#0d1218", + borderTop: "1px solid #3a4652", + display: "flex", + flexShrink: 0, + justifyContent: "flex-end", + padding: "8px" +} + +export const mobileControlsStyle: CSSProperties = { + background: "#0d1218", + borderTop: "1px solid #3a4652", + display: "flex", + flexDirection: "column", + flexShrink: 0, + gap: "8px", + padding: "8px" +} + +export const mobileControlsRowStyle: CSSProperties = { + display: "grid", + gap: "8px", + gridTemplateColumns: "repeat(5, minmax(0, 1fr))" +} + +export const mobileArrowRowStyle: CSSProperties = { + display: "grid", + gap: "8px", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))" +} + +export const mobileControlButtonStyle = (active = false): CSSProperties => ({ + background: active ? "#1d3550" : "#121a23", + border: `1px solid ${active ? "#78f0a3" : "#3a4652"}`, + borderRadius: "8px", + color: active ? "#e8fff0" : "#d6e5f7", + cursor: "pointer", + font: "inherit", + fontWeight: 600, + minHeight: "40px", + padding: "8px 10px" +}) + +const statusColor = (status: TerminalStatus): string => { + if (status === "attached") { + return "#56f39a" + } + if (status === "error") { + return "#ff8f8f" + } + if (status === "exited") { + return "#ffd166" + } + return "#8fd3ff" +} + +export const compactHeaderTitleStyle: CSSProperties = { + color: "#f6fbff", + flex: 1, + fontWeight: 700, + lineHeight: 1.2, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +export const compactStatusStyle = (status: TerminalStatus): CSSProperties => ({ + color: statusColor(status), + flexShrink: 0, + fontSize: "11px", + whiteSpace: "nowrap" +}) + +export const headerTitleStyle: CSSProperties = { + color: "#f6fbff", + fontWeight: 700, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +export const headerStatusStyle = (status: TerminalStatus): CSSProperties => ({ + color: statusColor(status), + whiteSpace: "nowrap" +}) + +export const headerSubtitleStyle: CSSProperties = { + color: "#8fa6c4", + fontSize: "12px", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} diff --git a/packages/app/src/web/panel-terminal-types.ts b/packages/app/src/web/panel-terminal-types.ts new file mode 100644 index 00000000..6915d120 --- /dev/null +++ b/packages/app/src/web/panel-terminal-types.ts @@ -0,0 +1,21 @@ +import type { JSX } from "react" + +import type { TerminalExitInfo } from "./terminal-panel-runtime.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export type TerminalPanelProps = { + readonly keyboardOpen: boolean + readonly mobileMode: boolean + readonly onAttachFailure: () => void + readonly onApplyProject?: (() => void) | undefined + readonly onDetach: () => void + readonly onExit?: ((info: TerminalExitInfo) => void) | undefined + readonly onKill: () => void + readonly onMessage: (message: string) => void + readonly onOpenBrowser?: (() => void) | undefined + readonly onOpenSkiller?: (() => void) | undefined + readonly onOpenTaskManager?: (() => void) | undefined + readonly onOpenTerminal?: (() => void) | undefined + readonly session: ActiveTerminalSession + readonly bodyContent?: JSX.Element | undefined +} diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index 3d79f1ad..e673b885 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -1,13 +1,23 @@ import "xterm/css/xterm.css" -import { type CSSProperties, type JSX, useCallback, useEffect, useRef, useState } from "react" +import { type JSX, useCallback, useEffect, useRef, useState } from "react" +import { TerminalHeader } from "./panel-terminal-header.js" import { - isModifierOnlyTerminalKey, - type MobileTerminalKey, - mobileTerminalKeyInput, - terminalControlCharacterForKey -} from "./terminal-mobile-controls.js" + MobileTerminalControls, + retainTerminalFocus, + sendMobileCtrlEventInput, + sendTerminalMobileInput, + shouldKeepMobileCtrlArmed +} from "./panel-terminal-mobile-controls.js" +import { + terminalBodyContentStyle, + terminalBodyFrameStyle, + terminalHostStyle, + terminalPanelStyle +} from "./panel-terminal-styles.js" +import type { TerminalPanelProps } from "./panel-terminal-types.js" +import type { MobileTerminalKey } from "./terminal-mobile-controls.js" import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" import { type TerminalConnectionState, @@ -18,629 +28,58 @@ import { } from "./terminal-panel-runtime.js" import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" -type TerminalPanelProps = { - readonly keyboardOpen: boolean - readonly mobileMode: boolean - readonly onAttachFailure: () => void - readonly onApplyProject?: (() => void) | undefined - readonly onDetach: () => void - readonly onExit?: ((info: TerminalExitInfo) => void) | undefined - readonly onKill: () => void - readonly onMessage: (message: string) => void - readonly onOpenBrowser?: (() => void) | undefined - readonly onOpenSkiller?: (() => void) | undefined - readonly onOpenTaskManager?: (() => void) | undefined - readonly onOpenTerminal?: (() => void) | undefined - readonly session: ActiveTerminalSession - readonly bodyContent?: JSX.Element | undefined -} - -const panelStyle: CSSProperties = { - border: "1px solid #3a4652", - borderRadius: "8px", - display: "flex", - flex: 1, - flexDirection: "column", - minHeight: 0, - overflow: "hidden" -} - -const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProperties => ({ - ...panelStyle, - marginTop: mobileMode || keyboardOpen ? 0 : "8px" -}) - -const headerStyle: CSSProperties = { - alignItems: "stretch", - background: "#101419", - borderBottom: "1px solid #3a4652", - display: "flex", - flexDirection: "column", - gap: "8px", - justifyContent: "flex-start", - padding: "10px 12px" -} - -const compactHeaderStyle: CSSProperties = { - ...headerStyle, - alignItems: "center", - flexDirection: "row", - flexWrap: "wrap", - gap: "6px", - overflow: "visible", - padding: "5px 6px" -} - -const bodyStyle: CSSProperties = { - background: "#080a0d", - flex: 1, - minHeight: 0, - padding: "8px" -} - -const bodyStyleMobile: CSSProperties = { - ...bodyStyle, - padding: "2px" -} +type RefState = { current: T } -const bodyStyleKeyboardOpen: CSSProperties = { - ...bodyStyle, - padding: 0 +type TerminalNotificationHandlers = { + readonly notifyAttachFailure: () => void + readonly notifyExit: (info: TerminalExitInfo) => void + readonly notifyMessage: (message: string) => void } -const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => { - if (compactTypingMode) { - return bodyStyleKeyboardOpen - } - return mobileMode ? bodyStyleMobile : bodyStyle +type InlineImagePreviewState = { + readonly inlineImagePreviewsEnabled: boolean + readonly inlineImagePreviewsEnabledRef: RefState + readonly toggleInlineImagePreviews: () => void } -const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ - ...terminalBodyStyle(compactTypingMode, mobileMode), - boxSizing: "border-box", - overflow: "hidden", - position: "relative" -}) - -const terminalHostStyle: CSSProperties = { - height: "100%", - minHeight: 0, - overflow: "hidden" -} - -const terminalBodyContentStyle: CSSProperties = { - bottom: 0, - height: "100%", - left: 0, - minHeight: 0, - overflow: "auto", - position: "absolute", - right: 0, - top: 0, - zIndex: 1 -} - -const closeButtonStyle: CSSProperties = { - background: "#171d24", - border: "1px solid #3a4652", - borderRadius: "8px", - color: "#d6e5f7", - cursor: "pointer", - font: "inherit", - padding: "6px 10px" -} - -const compactCloseButtonStyle: CSSProperties = { - ...closeButtonStyle, - fontSize: "11px", - padding: "4px 6px" -} - -const headerActionsStyle: CSSProperties = { - alignItems: "center", - display: "flex", - flexShrink: 0, - flexWrap: "wrap", - gap: "8px", - justifyContent: "flex-start", - width: "100%" -} - -const compactHeaderActionsStyle: CSSProperties = { - ...headerActionsStyle, - flexWrap: "wrap", - gap: "4px", - justifyContent: "flex-end", - marginLeft: "auto", - width: "auto" -} - -const mobileControlsCollapsedStyle: CSSProperties = { - alignItems: "center", - background: "#0d1218", - borderTop: "1px solid #3a4652", - display: "flex", - flexShrink: 0, - justifyContent: "flex-end", - padding: "8px" -} - -const mobileControlsStyle: CSSProperties = { - background: "#0d1218", - borderTop: "1px solid #3a4652", - display: "flex", - flexDirection: "column", - flexShrink: 0, - gap: "8px", - padding: "8px" -} - -const mobileControlsRowStyle: CSSProperties = { - display: "grid", - gap: "8px", - gridTemplateColumns: "repeat(5, minmax(0, 1fr))" -} - -const mobileArrowRowStyle: CSSProperties = { - display: "grid", - gap: "8px", - gridTemplateColumns: "repeat(4, minmax(0, 1fr))" -} - -const mobileControlButtonStyle = ( - active = false -): CSSProperties => ({ - background: active ? "#1d3550" : "#121a23", - border: `1px solid ${active ? "#78f0a3" : "#3a4652"}`, - borderRadius: "8px", - color: active ? "#e8fff0" : "#d6e5f7", - cursor: "pointer", - font: "inherit", - fontWeight: 600, - minHeight: "40px", - padding: "8px 10px" -}) - -const statusColor = (status: TerminalStatus): string => { - if (status === "attached") { - return "#56f39a" - } - if (status === "error") { - return "#ff8f8f" - } - if (status === "exited") { - return "#ffd166" - } - return "#8fd3ff" -} - -const compactHeaderTitleStyle: CSSProperties = { - color: "#f6fbff", - flex: 1, - fontWeight: 700, - lineHeight: 1.2, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" -} - -const compactStatusStyle = (status: TerminalStatus): CSSProperties => ({ - color: statusColor(status), - flexShrink: 0, - fontSize: "11px", - whiteSpace: "nowrap" -}) - -const headerTitleStyle: CSSProperties = { - color: "#f6fbff", - fontWeight: 700, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" -} - -const headerStatusStyle = (status: TerminalStatus): CSSProperties => ({ - color: statusColor(status), - whiteSpace: "nowrap" -}) - -const headerSubtitleStyle: CSSProperties = { - color: "#8fa6c4", - fontSize: "12px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" +type MobileTerminalControlState = { + readonly handleMobileKeyPress: (key: MobileTerminalKey) => void + readonly mobileControlsCollapsed: boolean + readonly mobileCtrlArmed: boolean + readonly toggleMobileControls: () => void + readonly toggleMobileCtrl: () => void } -const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => - isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting" - -const TerminalHeaderTitle = ( - { - compactHeaderMode, - session, - status - }: Pick & { - readonly compactHeaderMode: boolean - readonly status: TerminalStatus - } -): JSX.Element => - compactHeaderMode - ? ( -
-
- {session.browserProjectName ?? session.header} -
-
{status}
-
- ) - : ( -
-
- {session.header} -
-
- {status} -
-
- {session.subtitle} -
-
- ) - -const TerminalActionButton = ( - { - children, - compactTypingMode, - onClick, - pressed, - title - }: { - readonly children: string - readonly compactTypingMode: boolean - readonly onClick: () => void - readonly pressed?: boolean - readonly title?: string - } -): JSX.Element => ( - -) - -const OptionalTerminalActionButton = ( - { - compactHeaderMode, - compactLabel, - enabled, - label, - onClick - }: { + & InlineImagePreviewState + & MobileTerminalControlState + & { readonly compactHeaderMode: boolean - readonly compactLabel: string - readonly enabled: boolean - readonly label: string - readonly onClick: (() => void) | undefined - } -): JSX.Element | null => { - if (!enabled || onClick === undefined) { - return null - } - return ( - - {compactHeaderMode ? compactLabel : label} - - ) -} - -const TerminalHeaderActions = ( - { - compactHeaderMode, - inlineImagePreviewsEnabled, - onApplyProject, - onDetach, - onKill, - onOpenBrowser, - onOpenSkiller, - onOpenTaskManager, - onOpenTerminal, - onToggleInlineImagePreviews, - session - }: - & Pick< - TerminalPanelProps, - | "onApplyProject" - | "onDetach" - | "onKill" - | "onOpenBrowser" - | "onOpenSkiller" - | "onOpenTaskManager" - | "onOpenTerminal" - | "session" - > - & { - readonly compactHeaderMode: boolean - readonly inlineImagePreviewsEnabled: boolean - readonly onToggleInlineImagePreviews: () => void - } -): JSX.Element => { - const hasProjectActions = session.browserProjectId !== undefined - const imageToggleLabel = inlineImagePreviewsEnabled ? "Images on" : "Images off" - const compactImageToggleLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off" - const imageToggleTitle = inlineImagePreviewsEnabled - ? "Automatic image previews enabled" - : "Automatic image previews disabled" - - return ( -
- - - - - - - {compactHeaderMode ? compactImageToggleLabel : imageToggleLabel} - - - Detach - - - Kill - -
- ) -} - -const TerminalHeader = ( - { - compactHeaderMode, - inlineImagePreviewsEnabled, - onApplyProject, - onDetach, - onKill, - onOpenBrowser, - onOpenSkiller, - onOpenTaskManager, - onOpenTerminal, - onToggleInlineImagePreviews, - session, - status - }: - & Pick< - TerminalPanelProps, - | "onApplyProject" - | "onDetach" - | "onKill" - | "onOpenBrowser" - | "onOpenSkiller" - | "onOpenTaskManager" - | "onOpenTerminal" - | "session" - > - & { - readonly compactHeaderMode: boolean - readonly inlineImagePreviewsEnabled: boolean - readonly onToggleInlineImagePreviews: () => void - readonly status: TerminalStatus - } -): JSX.Element => ( -
- - -
-) - -const retainTerminalFocus = (controller: TerminalInputController | null): void => { - controller?.focus() -} - -const sendTerminalMobileInput = ( - controller: TerminalInputController | null, - key: MobileTerminalKey -): void => { - controller?.sendInput(mobileTerminalKeyInput(key)) - retainTerminalFocus(controller) -} - -const shouldKeepMobileCtrlArmed = (event: KeyboardEvent): boolean => - event.metaKey || event.altKey || event.ctrlKey || event.isComposing || isModifierOnlyTerminalKey(event.key) - -const sendMobileCtrlEventInput = ( - controller: TerminalInputController | null, - event: KeyboardEvent -): void => { - const controlCharacter = terminalControlCharacterForKey(event.key) - if (controlCharacter === null) { - return - } - event.preventDefault() - event.stopPropagation() - controller?.sendInput(controlCharacter) - retainTerminalFocus(controller) -} - -const MobileTerminalControlButton = ( - { - active = false, - label, - onClick - }: { - readonly active?: boolean - readonly label: string - readonly onClick: () => void - } -): JSX.Element => ( - -) - -const MobileTerminalControls = ( - { - collapsed, - compactTypingMode, - ctrlArmed, - onKeyPress, - onToggleCollapsed, - onToggleCtrl - }: { - readonly collapsed: boolean readonly compactTypingMode: boolean - readonly ctrlArmed: boolean - readonly onKeyPress: (key: MobileTerminalKey) => void - readonly onToggleCollapsed: () => void - readonly onToggleCtrl: () => void + readonly handleDetach: () => void + readonly handleKill: () => void + readonly hostRef: RefState + readonly status: TerminalStatus } -): JSX.Element => ( - collapsed - ? ( -
- -
- ) - : ( -
-
- { - onKeyPress("escape") - }} - /> - { - onKeyPress("tab") - }} - /> - - { - onKeyPress("ctrl-c") - }} - /> - -
-
- { - onKeyPress("left") - }} - /> - { - onKeyPress("up") - }} - /> - { - onKeyPress("down") - }} - /> - { - onKeyPress("right") - }} - /> -
-
- ) -) -export const TerminalPanel = ( - { - bodyContent, - keyboardOpen, - mobileMode, - onApplyProject, - onAttachFailure, - onDetach, - onExit, - onKill, - onMessage, - onOpenBrowser, - onOpenSkiller, - onOpenTaskManager, - onOpenTerminal, - session - }: TerminalPanelProps -): JSX.Element => { - const connectionRef = useRef({ closing: false, opened: false }) - const hostRef = useRef(null) - const inlineImagePreviewsEnabledRef = useRef(true) - const runtimeRef = useRef(null) - const [status, setStatus] = useState(() => resolveInitialTerminalStatus(session)) - const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) - const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) - const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false) - const terminalSessionId = session.session.id +const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => + isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting" + +const useTerminalNotificationHandlers = ( + { onAttachFailure, onExit, onMessage }: Pick +): TerminalNotificationHandlers => { const onAttachFailureRef = useRef(onAttachFailure) const onExitRef = useRef(onExit) const onMessageRef = useRef(onMessage) @@ -653,22 +92,29 @@ export const TerminalPanel = ( useEffect(() => { onMessageRef.current = onMessage }, [onMessage]) - useEffect(() => { - setStatus(resolveInitialTerminalStatus(session)) - }, [session]) + return { + notifyAttachFailure: useCallback(() => { + onAttachFailureRef.current() + }, []), + notifyExit: useCallback((info: TerminalExitInfo) => { + onExitRef.current?.(info) + }, []), + notifyMessage: useCallback((message: string) => { + onMessageRef.current(message) + }, []) + } +} + +const useInlineImagePreviewState = ( + runtimeRef: RefState, + terminalSessionId: string +): InlineImagePreviewState => { + const inlineImagePreviewsEnabledRef = useRef(true) + const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) useEffect(() => { inlineImagePreviewsEnabledRef.current = true setInlineImagePreviewsEnabled(true) }, [terminalSessionId]) - const notifyAttachFailure = useCallback(() => { - onAttachFailureRef.current() - }, []) - const notifyExit = useCallback((info: TerminalExitInfo) => { - onExitRef.current?.(info) - }, []) - const notifyMessage = useCallback((message: string) => { - onMessageRef.current(message) - }, []) const toggleInlineImagePreviews = useCallback(() => { setInlineImagePreviewsEnabled((current) => { const next = !current @@ -676,27 +122,30 @@ export const TerminalPanel = ( return next }) retainTerminalFocus(runtimeRef.current) - }, []) - const compactHeaderMode = resolveTerminalCompactHeaderMode(mobileMode) - const compactTypingMode = resolveTerminalTypingMode(mobileMode, keyboardOpen) - const hasBodyContent = bodyContent !== undefined + }, [runtimeRef]) - useEffect(() => { - if (!mobileMode) { - setMobileControlsCollapsed(false) - setMobileCtrlArmed(false) - } - }, [mobileMode]) + return { inlineImagePreviewsEnabled, inlineImagePreviewsEnabledRef, toggleInlineImagePreviews } +} +const useMobileCtrlKeyboard = ( + { + hostRef, + mobileCtrlArmed, + mobileMode, + runtimeRef, + setMobileCtrlArmed + }: { + readonly hostRef: RefState + readonly mobileCtrlArmed: boolean + readonly mobileMode: boolean + readonly runtimeRef: RefState + readonly setMobileCtrlArmed: (armed: boolean) => void + } +): void => { useEffect(() => { - if (!mobileMode || !mobileCtrlArmed) { + if (!mobileMode || !mobileCtrlArmed || hostRef.current === null) { return } - const host = hostRef.current - if (host === null) { - return - } - const handleKeyDown = (event: KeyboardEvent): void => { if (event.key === "Escape") { setMobileCtrlArmed(false) @@ -708,77 +157,154 @@ export const TerminalPanel = ( setMobileCtrlArmed(false) sendMobileCtrlEventInput(runtimeRef.current, event) } - + const host = hostRef.current host.addEventListener("keydown", handleKeyDown, true) return () => { host.removeEventListener("keydown", handleKeyDown, true) } - }, [mobileCtrlArmed, mobileMode]) + }, [hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed]) +} +const useMobileTerminalControlState = ( + mobileMode: boolean, + hostRef: RefState, + runtimeRef: RefState +): MobileTerminalControlState => { + const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) + const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false) + useEffect(() => { + if (!mobileMode) { + setMobileControlsCollapsed(false) + setMobileCtrlArmed(false) + } + }, [mobileMode]) + useMobileCtrlKeyboard({ hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed }) const handleMobileKeyPress = useCallback((key: MobileTerminalKey) => { if (key === "ctrl-c") { setMobileCtrlArmed(false) } sendTerminalMobileInput(runtimeRef.current, key) - }, []) + }, [runtimeRef]) + const toggleMobileControls = useCallback(() => { + setMobileControlsCollapsed((current) => !current) + setMobileCtrlArmed(false) + retainTerminalFocus(runtimeRef.current) + }, [runtimeRef]) + const toggleMobileCtrl = useCallback(() => { + setMobileCtrlArmed((current) => !current) + retainTerminalFocus(runtimeRef.current) + }, [runtimeRef]) + + return { handleMobileKeyPress, mobileControlsCollapsed, mobileCtrlArmed, toggleMobileControls, toggleMobileCtrl } +} + +const useTerminalCloseActions = ( + connectionRef: RefState, + onDetach: () => void, + onKill: () => void +): { readonly handleDetach: () => void; readonly handleKill: () => void } => ({ + handleDetach: useCallback(() => { + connectionRef.current.closing = true + onDetach() + }, [connectionRef, onDetach]), + handleKill: useCallback(() => { + connectionRef.current.closing = true + onKill() + }, [connectionRef, onKill]) +}) +const TerminalPanelBody = ( + { + bodyContent, + compactTypingMode, + hostRef, + mobileMode + }: Pick +): JSX.Element => ( +
+
+ {bodyContent === undefined ? null :
{bodyContent}
} +
+) + +const TerminalPanelMobileControls = (props: TerminalPanelLayoutProps): JSX.Element | null => + props.mobileMode && props.bodyContent === undefined + ? ( + + ) + : null + +const TerminalPanelLayout = (props: TerminalPanelLayoutProps): JSX.Element => ( +
+ + + +
+) + +export const TerminalPanel = (props: TerminalPanelProps): JSX.Element => { + const connectionRef = useRef({ closing: false, opened: false }) + const hostRef = useRef(null) + const runtimeRef = useRef(null) + const [status, setStatus] = useState(() => resolveInitialTerminalStatus(props.session)) + const compactHeaderMode = resolveTerminalCompactHeaderMode(props.mobileMode) + const compactTypingMode = resolveTerminalTypingMode(props.mobileMode, props.keyboardOpen) + const terminalSessionId = props.session.session.id + const notifications = useTerminalNotificationHandlers(props) + const inlineImageState = useInlineImagePreviewState(runtimeRef, terminalSessionId) + const mobileControlState = useMobileTerminalControlState(props.mobileMode, hostRef, runtimeRef) + const closeActions = useTerminalCloseActions(connectionRef, props.onDetach, props.onKill) + + useEffect(() => { + setStatus(resolveInitialTerminalStatus(props.session)) + }, [props.session]) useTerminalSessionLifecycle({ connectionRef, hostRef, - inlineImagePreviewsEnabledRef, - notifyExit, - notifyMessage, - onAttachFailure: notifyAttachFailure, + inlineImagePreviewsEnabledRef: inlineImageState.inlineImagePreviewsEnabledRef, + notifyExit: notifications.notifyExit, + notifyMessage: notifications.notifyMessage, + onAttachFailure: notifications.notifyAttachFailure, runtimeRef, - session, + session: props.session, setStatus }) return ( -
- { - connectionRef.current.closing = true - onDetach() - }} - onKill={() => { - connectionRef.current.closing = true - onKill() - }} - onOpenBrowser={onOpenBrowser} - onOpenSkiller={onOpenSkiller} - onOpenTaskManager={onOpenTaskManager} - onOpenTerminal={onOpenTerminal} - onToggleInlineImagePreviews={toggleInlineImagePreviews} - session={session} - status={status} - /> -
-
- {hasBodyContent ?
{bodyContent}
: null} -
- {mobileMode && !hasBodyContent - ? ( - { - setMobileControlsCollapsed((current) => !current) - setMobileCtrlArmed(false) - retainTerminalFocus(runtimeRef.current) - }} - onToggleCtrl={() => { - setMobileCtrlArmed((current) => !current) - retainTerminalFocus(runtimeRef.current) - }} - /> - ) - : null} -
+ ) } diff --git a/packages/app/src/web/terminal-copy-interaction.ts b/packages/app/src/web/terminal-copy-interaction.ts index 0f704976..ce69d0af 100644 --- a/packages/app/src/web/terminal-copy-interaction.ts +++ b/packages/app/src/web/terminal-copy-interaction.ts @@ -1,3 +1,15 @@ +import { + createTerminalSelectionDragController, + forceTerminalSelectionModifier, + suppressTerminalMouseReport, + type TerminalCopyMouseEvent, + type TerminalCopyMouseEventType, + type TerminalMouseButtonEvent, + type TerminalSelectionDragTarget +} from "./terminal-copy-selection-drag.js" + +export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" + export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" type TerminalSelectionTarget = { @@ -11,15 +23,6 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { } } -type TerminalMouseButtonEvent = { - readonly button: number -} - -type TerminalSelectionModifierEvent = { - readonly altKey: boolean - readonly shiftKey: boolean -} - type TerminalCopyClipboardData = { readonly setData: (format: string, data: string) => void } @@ -30,35 +33,6 @@ type TerminalCopyClipboardEvent = { readonly stopPropagation: () => void } -type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & { - readonly buttons?: number | undefined - readonly clientX?: number | undefined - readonly clientY?: number | undefined - readonly ctrlKey?: boolean | undefined - readonly detail?: number | undefined - readonly metaKey?: boolean | undefined - readonly preventDefault?: (() => void) | undefined - readonly screenX?: number | undefined - readonly screenY?: number | undefined - readonly stopImmediatePropagation?: (() => void) | undefined - readonly stopPropagation?: (() => void) | undefined -} - -type TerminalSelectionDragEventType = "mousemove" | "mouseup" -type TerminalCopyMouseEventType = "mousedown" | TerminalSelectionDragEventType - -type TerminalSelectionDragListenerRegistration = ( - type: TerminalSelectionDragEventType, - listener: (event: TerminalCopyMouseEvent) => void, - options: true -) => void - -type TerminalSelectionDragTarget = { - readonly addEventListener: TerminalSelectionDragListenerRegistration - readonly dispatchEvent?: ((event: Event) => boolean) | undefined - readonly removeEventListener: TerminalSelectionDragListenerRegistration -} - type TerminalCopyListenerRegistration = { (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void @@ -75,22 +49,9 @@ type TerminalCopyInteractionArgs = { readonly terminal: TerminalCopyInteractionTerminal } -type TerminalSelectionDragController = { - readonly dispose: () => void - readonly start: () => void -} - const primaryMouseButton = 0 const secondaryMouseButton = 2 - -const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) - -const currentNavigatorPlatform = (): string => { - if (typeof navigator === "undefined") { - return "" - } - return navigator.platform -} +const terminalSelectionContextSnapshotTtlMs = 10_000 const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton @@ -104,22 +65,34 @@ export const shouldForceBrowserTerminalSelection = ( terminal: TerminalCopyInteractionTerminal ): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) +/** + * Decides whether a secondary-button event must preserve the terminal selection context. + * + * @param event - Mouse button event captured before xterm/tmux handlers can clear the selection. + * @param terminal - Terminal selection and mouse-tracking facade. + * @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists. + * @pure true + * @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection(). + * @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal). + * @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing. + * @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting. + * @complexity O(1) + * @throws Never + */ +// CHANGE: document the guarded right-click selection preservation predicate +// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events +// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal" +// REF: issue-340 +// SOURCE: n/a +// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t) +// PURITY: CORE +// EFFECT: reads terminal.hasSelection through the injected terminal facade +// INVARIANT: mouseTrackingMode = none always yields false +// COMPLEXITY: O(1) export const shouldForceTerminalSelectionContext = ( event: TerminalMouseButtonEvent, terminal: TerminalCopyInteractionTerminal -): boolean => isSecondaryMouseButton(event) && terminal.hasSelection() - -const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => - macPlatformNames.has(platform) ? "altKey" : "shiftKey" - -export const forceTerminalSelectionModifier = ( - event: TerminalSelectionModifierEvent, - platform: string = currentNavigatorPlatform() -): boolean => - Reflect.defineProperty(event, terminalSelectionModifier(platform), { - configurable: true, - value: true - }) +): boolean => isSecondaryMouseButton(event) && hasActiveMouseTracking(terminal) && terminal.hasSelection() export const writeTerminalSelectionToClipboardData = ( terminal: TerminalSelectionTarget, @@ -136,173 +109,133 @@ export const writeTerminalSelectionToClipboardData = ( return true } -const resolveTerminalSelectionDragTarget = ( - host: TerminalCopyInteractionHost -): TerminalSelectionDragTarget => host.ownerDocument ?? host - -const optionalNumber = (value: number | undefined): number => value ?? 0 - -const optionalBoolean = (value: boolean | undefined): boolean => value ?? false - -const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { - const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) - return { - altKey: selectionModifier === "altKey" ? true : event.altKey, - bubbles: true, - button: event.button, - buttons: 0, - cancelable: true, - clientX: optionalNumber(event.clientX), - clientY: optionalNumber(event.clientY), - ctrlKey: optionalBoolean(event.ctrlKey), - detail: optionalNumber(event.detail), - metaKey: optionalBoolean(event.metaKey), - screenX: optionalNumber(event.screenX), - screenY: optionalNumber(event.screenY), - shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey - } -} +class TerminalSelectionContextSnapshot { + private selection = "" + private timer: ReturnType | null = null -const defineMouseEventProperty = ( - event: Event, - property: string, - value: boolean | number -): void => { - Reflect.defineProperty(event, property, { - configurable: true, - value - }) -} + constructor(private readonly terminal: TerminalSelectionTarget) {} -const copyMouseEventInitProperties = ( - event: Event, - init: MouseEventInit -): void => { - defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) - defineMouseEventProperty(event, "button", optionalNumber(init.button)) - defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) - defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) - defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) - defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) - defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) - defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) - defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) - defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) - defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) -} + readonly clear = (): void => { + this.selection = "" + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } -const createForcedTerminalMouseUpEvent = ( - sourceEvent: TerminalCopyMouseEvent -): Event => { - const init = forcedTerminalMouseUpInit(sourceEvent) - const event = typeof MouseEvent === "function" - ? new MouseEvent("mouseup", init) - : new Event("mouseup", { bubbles: true, cancelable: true }) - copyMouseEventInitProperties(event, init) - return event -} + readonly has = (): boolean => this.selection.length > 0 -const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => { - event.preventDefault?.() - event.stopPropagation?.() - event.stopImmediatePropagation?.() -} + readonly refresh = (): boolean => { + const selection = this.terminal.getSelection() + if (selection.length === 0) { + this.clear() + return false + } + this.selection = selection + if (this.timer !== null) { + clearTimeout(this.timer) + } + this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs) + return true + } -const replayForcedTerminalMouseUp = ( - target: TerminalSelectionDragTarget, - event: TerminalCopyMouseEvent -): void => { - target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event)) + readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { + if (clipboardData === null || this.selection.length === 0) { + return false + } + clipboardData.setData("text/plain", this.selection) + return true + } } -const createTerminalSelectionDragController = ( - host: TerminalCopyInteractionHost -): TerminalSelectionDragController => { - let forcedSelectionDrag = false - let selectionDragTarget: TerminalSelectionDragTarget | null = null +class TerminalCopyInteractionController { + private readonly selectionContext: TerminalSelectionContextSnapshot + private readonly selectionDrag: ReturnType - const clearSelectionDrag = (): void => { - if (selectionDragTarget === null) { - forcedSelectionDrag = false - return - } - selectionDragTarget.removeEventListener("mousemove", onMouseMove, true) - selectionDragTarget.removeEventListener("mouseup", onMouseUp, true) - selectionDragTarget = null - forcedSelectionDrag = false + constructor(private readonly args: TerminalCopyInteractionArgs) { + this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) + this.selectionDrag = createTerminalSelectionDragController(args.host) } - const onMouseMove = (event: TerminalCopyMouseEvent): void => { - if (!forcedSelectionDrag) { - return + readonly attach = (): { readonly dispose: () => void } => { + this.args.host.addEventListener("mousedown", this.onMouseDown, true) + this.args.host.addEventListener("mouseup", this.onMouseUp, true) + this.args.host.addEventListener("contextmenu", this.onContextMenu, true) + this.args.host.addEventListener("copy", this.onCopy, true) + return { dispose: this.dispose } + } + + private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => + isSecondaryMouseButton(event) && + hasActiveMouseTracking(this.args.terminal) && + (this.selectionContext.has() || this.args.terminal.hasSelection()) + + private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => { + if (!this.shouldProtectSelectionContext(event)) { + return false } forceTerminalSelectionModifier(event) + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } + return true } - const onMouseUp = (event: TerminalCopyMouseEvent): void => { - if (!forcedSelectionDrag) { + private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => { + if (isPrimaryMouseButton(event)) { + this.selectionContext.clear() + } + const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) + const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal) + if (!forceBrowserSelection && !forceSelectionContext) { + if (isSecondaryMouseButton(event)) { + this.selectionContext.clear() + } return } - const target = selectionDragTarget forceTerminalSelectionModifier(event) - if (target?.dispatchEvent !== undefined) { - // CHANGE: replay a clean document mouseup for xterm selection finalization. - // WHY: xterm's mouse-report mouseup treats the original release as pty input, - // which triggers onUserInput and clears the just-created selection. - suppressOriginalTerminalMouseUp(event) - clearSelectionDrag() - replayForcedTerminalMouseUp(target, event) + if (forceSelectionContext) { + this.selectionContext.refresh() + suppressTerminalMouseReport(event) return } - clearSelectionDrag() + if (forceBrowserSelection) { + this.selectionDrag.start() + } } - const startSelectionDrag = (): void => { - clearSelectionDrag() - forcedSelectionDrag = true - selectionDragTarget = resolveTerminalSelectionDragTarget(host) - selectionDragTarget.addEventListener("mousemove", onMouseMove, true) - selectionDragTarget.addEventListener("mouseup", onMouseUp, true) + private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { + if (!this.onSelectionContextMouseEvent(event)) { + return + } + suppressTerminalMouseReport(event) } - return { - dispose: clearSelectionDrag, - start: startSelectionDrag + private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => { + this.onSelectionContextMouseEvent(event) } -} -export const attachTerminalCopyInteraction = ( - args: TerminalCopyInteractionArgs -): { readonly dispose: () => void } => { - const selectionDrag = createTerminalSelectionDragController(args.host) - - const onMouseDown = (event: TerminalCopyMouseEvent): void => { - const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, args.terminal) - const forceSelectionContext = shouldForceTerminalSelectionContext(event, args.terminal) - if (!forceBrowserSelection && !forceSelectionContext) { - return - } - forceTerminalSelectionModifier(event) - if (forceBrowserSelection) { - selectionDrag.start() - } - } - const onCopy = (event: TerminalCopyClipboardEvent): void => { - if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) { + private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { + const wroteSelection = writeTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData) + const wroteSnapshot = wroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData) + if (!wroteSelection && !wroteSnapshot) { return } + this.selectionContext.clear() event.preventDefault() event.stopPropagation() } - args.host.addEventListener("mousedown", onMouseDown, true) - args.host.addEventListener("copy", onCopy, true) - - return { - dispose: () => { - selectionDrag.dispose() - args.host.removeEventListener("mousedown", onMouseDown, true) - args.host.removeEventListener("copy", onCopy, true) - } + private readonly dispose = (): void => { + this.selectionDrag.dispose() + this.selectionContext.clear() + this.args.host.removeEventListener("mousedown", this.onMouseDown, true) + this.args.host.removeEventListener("mouseup", this.onMouseUp, true) + this.args.host.removeEventListener("contextmenu", this.onContextMenu, true) + this.args.host.removeEventListener("copy", this.onCopy, true) } } + +export const attachTerminalCopyInteraction = ( + args: TerminalCopyInteractionArgs +): { readonly dispose: () => void } => new TerminalCopyInteractionController(args).attach() diff --git a/packages/app/src/web/terminal-copy-selection-drag.ts b/packages/app/src/web/terminal-copy-selection-drag.ts new file mode 100644 index 00000000..d6e4c85c --- /dev/null +++ b/packages/app/src/web/terminal-copy-selection-drag.ts @@ -0,0 +1,202 @@ +export type TerminalMouseButtonEvent = { + readonly button: number +} + +export type TerminalSelectionModifierEvent = { + readonly altKey: boolean + readonly shiftKey: boolean +} + +export type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & { + readonly buttons?: number | undefined + readonly clientX?: number | undefined + readonly clientY?: number | undefined + readonly ctrlKey?: boolean | undefined + readonly detail?: number | undefined + readonly metaKey?: boolean | undefined + readonly preventDefault?: (() => void) | undefined + readonly screenX?: number | undefined + readonly screenY?: number | undefined + readonly stopImmediatePropagation?: (() => void) | undefined + readonly stopPropagation?: (() => void) | undefined +} + +export type TerminalSelectionDragEventType = "mousemove" | "mouseup" +export type TerminalCopyMouseEventType = "contextmenu" | "mousedown" | TerminalSelectionDragEventType + +type TerminalSelectionDragListenerRegistration = ( + type: TerminalSelectionDragEventType, + listener: (event: TerminalCopyMouseEvent) => void, + options: true +) => void + +export type TerminalSelectionDragTarget = { + readonly addEventListener: TerminalSelectionDragListenerRegistration + readonly dispatchEvent?: ((event: Event) => boolean) | undefined + readonly removeEventListener: TerminalSelectionDragListenerRegistration +} + +export type TerminalSelectionDragHost = TerminalSelectionDragTarget & { + readonly ownerDocument?: TerminalSelectionDragTarget | null +} + +type TerminalSelectionDragController = { + readonly dispose: () => void + readonly start: () => void +} + +const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) + +const currentNavigatorPlatform = (): string => { + if (typeof navigator === "undefined") { + return "" + } + return navigator.platform +} + +const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => + macPlatformNames.has(platform) ? "altKey" : "shiftKey" + +export const forceTerminalSelectionModifier = ( + event: TerminalSelectionModifierEvent, + platform: string = currentNavigatorPlatform() +): boolean => + Reflect.defineProperty(event, terminalSelectionModifier(platform), { + configurable: true, + value: true + }) + +const optionalNumber = (value: number | undefined): number => value ?? 0 + +const optionalBoolean = (value: boolean | undefined): boolean => value ?? false + +const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { + const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) + return { + altKey: selectionModifier === "altKey" ? true : event.altKey, + bubbles: true, + button: event.button, + buttons: 0, + cancelable: true, + clientX: optionalNumber(event.clientX), + clientY: optionalNumber(event.clientY), + ctrlKey: optionalBoolean(event.ctrlKey), + detail: optionalNumber(event.detail), + metaKey: optionalBoolean(event.metaKey), + screenX: optionalNumber(event.screenX), + screenY: optionalNumber(event.screenY), + shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey + } +} + +const defineMouseEventProperty = ( + event: Event, + property: string, + value: boolean | number +): void => { + Reflect.defineProperty(event, property, { + configurable: true, + value + }) +} + +const copyMouseEventInitProperties = ( + event: Event, + init: MouseEventInit +): void => { + defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) + defineMouseEventProperty(event, "button", optionalNumber(init.button)) + defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) + defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) + defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) + defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) + defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) + defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) + defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) + defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) + defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) +} + +const createForcedTerminalMouseUpEvent = ( + sourceEvent: TerminalCopyMouseEvent +): Event => { + const init = forcedTerminalMouseUpInit(sourceEvent) + const event = typeof MouseEvent === "function" + ? new MouseEvent("mouseup", init) + : new Event("mouseup", { bubbles: true, cancelable: true }) + copyMouseEventInitProperties(event, init) + return event +} + +const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => { + event.preventDefault?.() + event.stopPropagation?.() + event.stopImmediatePropagation?.() +} + +export const suppressTerminalMouseReport = (event: TerminalCopyMouseEvent): void => { + event.stopPropagation?.() + event.stopImmediatePropagation?.() +} + +const replayForcedTerminalMouseUp = ( + target: TerminalSelectionDragTarget, + event: TerminalCopyMouseEvent +): void => { + target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event)) +} + +const resolveTerminalSelectionDragTarget = ( + host: TerminalSelectionDragHost +): TerminalSelectionDragTarget => host.ownerDocument ?? host + +class TerminalSelectionDragControllerImpl implements TerminalSelectionDragController { + private forcedSelectionDrag = false + private selectionDragTarget: TerminalSelectionDragTarget | null = null + + constructor(private readonly host: TerminalSelectionDragHost) {} + + readonly dispose = (): void => { + if (this.selectionDragTarget === null) { + this.forcedSelectionDrag = false + return + } + this.selectionDragTarget.removeEventListener("mousemove", this.onMouseMove, true) + this.selectionDragTarget.removeEventListener("mouseup", this.onMouseUp, true) + this.selectionDragTarget = null + this.forcedSelectionDrag = false + } + + readonly start = (): void => { + this.dispose() + this.forcedSelectionDrag = true + this.selectionDragTarget = resolveTerminalSelectionDragTarget(this.host) + this.selectionDragTarget.addEventListener("mousemove", this.onMouseMove, true) + this.selectionDragTarget.addEventListener("mouseup", this.onMouseUp, true) + } + + private readonly onMouseMove = (event: TerminalCopyMouseEvent): void => { + if (this.forcedSelectionDrag) { + forceTerminalSelectionModifier(event) + } + } + + private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { + if (!this.forcedSelectionDrag) { + return + } + const target = this.selectionDragTarget + forceTerminalSelectionModifier(event) + if (target?.dispatchEvent === undefined) { + this.dispose() + return + } + suppressOriginalTerminalMouseUp(event) + this.dispose() + replayForcedTerminalMouseUp(target, event) + } +} + +export const createTerminalSelectionDragController = ( + host: TerminalSelectionDragHost +): TerminalSelectionDragController => new TerminalSelectionDragControllerImpl(host) diff --git a/packages/app/src/web/terminal-panel-cleanup-runtime.ts b/packages/app/src/web/terminal-panel-cleanup-runtime.ts new file mode 100644 index 00000000..75ed285f --- /dev/null +++ b/packages/app/src/web/terminal-panel-cleanup-runtime.ts @@ -0,0 +1,42 @@ +import { revokeTerminalInlineImageObjectUrlCache } from "./terminal-inline-images.js" +import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" +import type { TerminalCleanupArgs } from "./terminal-panel-runtime-types.js" + +const closeSocket = (socket: WebSocket | null): void => { + if (socket === null || socket.readyState === WebSocket.CLOSED) { + return + } + runOptionalTerminalOperation(() => { + socket.close() + }) +} + +const clearReconnectTimer = (args: TerminalCleanupArgs): void => { + if (args.lifecycle.reconnectTimer !== null) { + clearTimeout(args.lifecycle.reconnectTimer) + args.lifecycle.reconnectTimer = null + } +} + +export const cleanupTerminalResources = ( + args: TerminalCleanupArgs +): void => { + args.lifecycle.disposed = true + clearReconnectTimer(args) + for (const disposable of args.lifecycle.inlineImageDisposables) { + disposable.dispose() + } + args.lifecycle.inlineImageDisposables = [] + revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) + args.lifecycle.outputQueue = [] + args.lifecycle.outputWriting = false + args.removeImageLinks() + args.removeImagePaste() + args.removeInput() + args.resizeObserver?.disconnect() + args.removeResize() + closeSocket(args.socketRef.current) + args.socketRef.current = null + args.runtimeRef.current = null + args.terminal.dispose() +} diff --git a/packages/app/src/web/terminal-panel-inline-images-runtime.ts b/packages/app/src/web/terminal-panel-inline-images-runtime.ts new file mode 100644 index 00000000..d0deb6c2 --- /dev/null +++ b/packages/app/src/web/terminal-panel-inline-images-runtime.ts @@ -0,0 +1,299 @@ +import { FetchHttpClient, HttpClient, HttpClientResponse } from "@effect/platform" +import { Duration, Effect } from "effect" +import * as Stream from "effect/Stream" + +import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" +import { + splitTerminalInlineImageOutput, + type TerminalInlineImageOutputSegment, + writeTerminalOutputSegment +} from "./terminal-inline-images-core.js" +import { + appendTerminalInlineImagePreview, + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "./terminal-inline-images.js" +import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" +import type { TerminalMessageHandlers } from "./terminal-panel-runtime-types.js" + +type TerminalInlineImageFetchError = { + readonly _tag: "TerminalInlineImageFetchError" + readonly message: string +} + +type TerminalInlineImageBufferState = { + readonly chunks: ReadonlyArray + readonly size: number +} + +const terminalInlineImageFetchTimeout = Duration.seconds(10) +const terminalInlineImageMaxBytes = 10 * 1024 * 1024 + +const emptyTerminalInlineImageBufferState: TerminalInlineImageBufferState = { + chunks: [], + size: 0 +} + +const terminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string +): TerminalInlineImageEntry | null => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) +} + +const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ + _tag: "TerminalInlineImageFetchError", + message +}) + +const terminalInlineImageFetchHeaders: Readonly> = { + accept: "image/*", + "cache-control": "no-cache, no-store, max-age=0", + pragma: "no-cache" +} + +const readContentLength = (headers: Readonly>): number | null => { + const value = headers["content-length"] + if (value === undefined) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} + +const validateTerminalInlineImageSize = (size: number): Effect.Effect => + size > terminalInlineImageMaxBytes + ? Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) + : Effect.void + +const appendTerminalInlineImageChunk = ( + state: TerminalInlineImageBufferState, + chunk: Uint8Array +): Effect.Effect => { + const nextSize = state.size + chunk.byteLength + return validateTerminalInlineImageSize(nextSize).pipe( + Effect.as({ + chunks: [...state.chunks, chunk], + size: nextSize + }) + ) +} + +const copyChunkToArrayBuffer = (chunk: Uint8Array): ArrayBuffer => { + const copy = new Uint8Array(chunk.byteLength) + copy.set(chunk) + return copy.buffer +} + +const imageBlobFromChunks = ( + chunks: ReadonlyArray, + mediaType: string | undefined +): Blob => + new Blob( + chunks.map((chunk) => copyChunkToArrayBuffer(chunk)), + mediaType === undefined ? {} : { type: mediaType } + ) + +const readTerminalInlineImageBlob = ( + response: HttpClientResponse.HttpClientResponse +): Effect.Effect => { + const contentLength = readContentLength(response.headers) + if (contentLength !== null && contentLength > terminalInlineImageMaxBytes) { + return Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) + } + return HttpClientResponse.stream(Effect.succeed(response)).pipe( + Stream.runFoldEffect(emptyTerminalInlineImageBufferState, appendTerminalInlineImageChunk), + Effect.map((state) => imageBlobFromChunks(state.chunks, response.headers["content-type"])), + Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) + ) +} + +const fetchTerminalInlineImageBlob = ( + fetchUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _( + client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) + ) + ) + if (response.status >= 400) { + return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) + } + return yield* _(readTerminalInlineImageBlob(response)) + }).pipe( + Effect.timeoutFail({ + duration: terminalInlineImageFetchTimeout, + onTimeout: () => terminalInlineImageFetchError("Terminal image fetch timed out.") + }), + Effect.provide(FetchHttpClient.layer) + ) + +const loadTerminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: (entry: TerminalInlineImageEntry) => void +): void => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) + if (cached !== null) { + onComplete(cached) + return + } + Effect.runFork( + fetchTerminalInlineImageBlob(fetchUrl).pipe( + Effect.match({ + onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), + onSuccess: (blob) => + handlers.lifecycle.disposed + ? null + : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) + }), + Effect.flatMap((entry) => + Effect.sync(() => { + if (entry === null || handlers.lifecycle.disposed) { + return + } + onComplete(entry) + }) + ) + ) + ) +} + +const writePreviewSpacer = ( + handlers: TerminalMessageHandlers, + onComplete: () => void +): void => { + handlers.terminal.write(terminalInlineImageSpacer, onComplete) +} + +const writeInlineImagePreview = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: () => void +): void => { + const cached = terminalImageEntry(handlers, path) + if (cached !== null) { + writeInlineImagePreviewEntry(handlers, cached, onComplete) + return + } + loadTerminalImageEntry(handlers, path, (entry) => { + writeInlineImagePreviewEntry(handlers, entry, onComplete) + }) +} + +const writeInlineImagePreviewEntry = ( + handlers: TerminalMessageHandlers, + entry: TerminalInlineImageEntry, + onComplete: () => void +): void => { + const appended = appendTerminalInlineImagePreview( + handlers.terminal, + handlers.lifecycle, + entry + ) + if (!appended) { + onComplete() + return + } + writePreviewSpacer(handlers, onComplete) +} + +const writeInlineImagePreviews = ( + handlers: TerminalMessageHandlers, + paths: ReadonlyArray, + onComplete: () => void +): void => { + let index = 0 + const writeNext = (): void => { + const path = paths[index] + if (path === undefined) { + onComplete() + return + } + index += 1 + writeInlineImagePreview(handlers, path, writeNext) + } + writeNext() +} + +const writeLineBreakBeforePreview = ( + handlers: TerminalMessageHandlers, + segment: TerminalInlineImageOutputSegment, + onComplete: () => void +): void => { + if (segment.endedWithLineBreak) { + onComplete() + return + } + handlers.terminal.write("\r\n", onComplete) +} + +const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => { + if (handlers.lifecycle.outputWriting || handlers.lifecycle.disposed) { + return + } + const segment = handlers.lifecycle.outputQueue.shift() + if (segment === undefined) { + return + } + + handlers.lifecycle.outputWriting = true + writeTerminalOutputSegment({ + inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef, + segment, + writer: { + writePreviewLineBreak: (outputSegment, onComplete) => { + writeLineBreakBeforePreview(handlers, outputSegment, onComplete) + }, + writePreviews: (paths, onComplete) => { + writeInlineImagePreviews(handlers, paths, onComplete) + }, + writeText: (text, onComplete) => { + handlers.terminal.write(text, onComplete) + } + } + }, () => { + handlers.lifecycle.outputWriting = false + flushTerminalOutputQueue(handlers) + }) +} + +/** + * Enqueues terminal output segments and starts the sequential terminal flush loop. + * + * @param handlers - Runtime terminal handlers with mutable lifecycle queues and flags. + * @param data - Raw terminal output chunk to split into text and inline-image preview segments. + * @returns Nothing; lifecycle state is updated through `handlers`. + * @pure false + * @effect TerminalMessageHandlers.lifecycle outputQueue/outputWriting/disposed and terminal writes. + * @invariant `outputWriting` acts as a semaphore: at most one flush writes to the terminal at a time. + * @precondition `handlers.lifecycle.outputQueue` is an array, `outputWriting` is boolean, and handlers are live. + * @postcondition All split segments are appended before `flushTerminalOutputQueue(handlers)` is invoked. + * @complexity O(n) where n is the number of output segments parsed from `data`. + * @throws Never + */ +// CHANGE: document terminal output queueing as the shell boundary for inline image writes +// WHY: queue order and the outputWriting semaphore protect terminal write ordering across async previews +// QUOTE(ТЗ): "Limit inline-preview loading by timeout and size without freezing terminal output" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: enqueue(q, segments) -> flush observes q followed by segments in input order +// PURITY: SHELL +// EFFECT: TerminalMessageHandlers -> mutates lifecycle.outputQueue/outputWriting and writes to terminal +// INVARIANT: disposed handlers never start a new flush, and flush is called only after queue append +// COMPLEXITY: O(n) where n is the number of output segments parsed from `data` +export const enqueueTerminalOutput = ( + handlers: TerminalMessageHandlers, + data: string +): void => { + for (const segment of splitTerminalInlineImageOutput(data)) { + handlers.lifecycle.outputQueue.push(segment) + } + flushTerminalOutputQueue(handlers) +} diff --git a/packages/app/src/web/terminal-panel-optional-operation.ts b/packages/app/src/web/terminal-panel-optional-operation.ts new file mode 100644 index 00000000..9cd77b86 --- /dev/null +++ b/packages/app/src/web/terminal-panel-optional-operation.ts @@ -0,0 +1,21 @@ +import { Effect, type Either } from "effect" + +type OptionalTerminalOperationError = { + readonly _tag: "OptionalTerminalOperationError" + readonly message: string +} + +export type OptionalTerminalOperationResult = Either.Either + +export const runOptionalTerminalOperation = (operation: () => void): OptionalTerminalOperationResult => + Effect.runSync( + Effect.either( + Effect.try({ + try: operation, + catch: (error) => ({ + _tag: "OptionalTerminalOperationError", + message: String(error) + }) + }) + ) + ) diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index 88741103..f82267b9 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -1,26 +1,11 @@ -import { FetchHttpClient, HttpClient } from "@effect/platform" -import { Effect, Either } from "effect" +import { Either } from "effect" import { Terminal } from "xterm" import { FitAddon } from "xterm-addon-fit" -import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" -import { - splitTerminalInlineImageOutput, - type TerminalInlineImageOutputSegment, - writeTerminalOutputSegment -} from "./terminal-inline-images-core.js" -import { - appendTerminalInlineImagePreview, - cachedTerminalInlineImageEntry, - cacheTerminalInlineImageBlob, - revokeTerminalInlineImageObjectUrlCache, - terminalInlineImageSpacer, - unavailableTerminalInlineImageEntry -} from "./terminal-inline-images.js" -import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" +import { enqueueTerminalOutput } from "./terminal-panel-inline-images-runtime.js" import { sendTerminalClientMessage } from "./terminal-panel-input.js" +import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" import type { - TerminalCleanupArgs, TerminalExitInfo, TerminalInputController, TerminalLifecycleState, @@ -34,29 +19,13 @@ import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js" import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js" +export { cleanupTerminalResources } from "./terminal-panel-cleanup-runtime.js" export { attachTerminalInput, isTerminalMouseReportInput } from "./terminal-panel-input.js" type TerminalRuntimeOptions = { readonly querySuppression?: TerminalQuerySuppressionOptions } -type TerminalInlineImageFetchError = { - readonly _tag: "TerminalInlineImageFetchError" - readonly message: string -} - -const runOptionalTerminalOperation = (operation: () => void): boolean => - Either.isRight( - Effect.runSync( - Effect.either( - Effect.try({ - try: operation, - catch: (error) => error - }) - ) - ) - ) - export const createLifecycleState = (): TerminalLifecycleState => ({ attachedOnce: false, disposed: false, @@ -127,11 +96,10 @@ export const sendTerminalResize = ( socketRef: TerminalSocketRef, terminal: Terminal ): void => { - if ( - !runOptionalTerminalOperation(() => { - fitAddon.fit() - }) - ) { + const fitResult = runOptionalTerminalOperation(() => { + fitAddon.fit() + }) + if (Either.isLeft(fitResult)) { return } sendTerminalClientMessage(socketRef, { @@ -191,192 +159,6 @@ const endTerminalSession = ( } } -const terminalImageEntry = ( - handlers: TerminalMessageHandlers, - path: string -): TerminalInlineImageEntry | null => { - const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) - return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) -} - -const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ - _tag: "TerminalInlineImageFetchError", - message -}) - -const terminalInlineImageFetchHeaders: Readonly> = { - accept: "image/*", - "cache-control": "no-cache, no-store, max-age=0", - pragma: "no-cache" -} - -const imageBlobFromArrayBuffer = ( - buffer: ArrayBuffer, - mediaType: string | undefined -): Blob => new Blob([buffer], mediaType === undefined ? {} : { type: mediaType }) - -const fetchTerminalInlineImageBlob = ( - fetchUrl: string -): Effect.Effect => - Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) - const response = yield* _( - client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( - Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) - ) - ) - if (response.status >= 400) { - return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) - } - const buffer = yield* _( - response.arrayBuffer.pipe( - Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) - ) - ) - return imageBlobFromArrayBuffer(buffer, response.headers["content-type"]) - }).pipe(Effect.provide(FetchHttpClient.layer)) - -const loadTerminalImageEntry = ( - handlers: TerminalMessageHandlers, - path: string, - onComplete: (entry: TerminalInlineImageEntry) => void -): void => { - const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) - const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) - if (cached !== null) { - onComplete(cached) - return - } - Effect.runFork( - fetchTerminalInlineImageBlob(fetchUrl).pipe( - Effect.match({ - onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), - onSuccess: (blob) => - handlers.lifecycle.disposed - ? null - : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) - }), - Effect.flatMap((entry) => - Effect.sync(() => { - if (entry === null || handlers.lifecycle.disposed) { - return - } - onComplete(entry) - }) - ) - ) - ) -} - -const writePreviewSpacer = ( - handlers: TerminalMessageHandlers, - onComplete: () => void -): void => { - handlers.terminal.write(terminalInlineImageSpacer, onComplete) -} - -const writeInlineImagePreview = ( - handlers: TerminalMessageHandlers, - path: string, - onComplete: () => void -): void => { - const cached = terminalImageEntry(handlers, path) - if (cached !== null) { - writeInlineImagePreviewEntry(handlers, cached, onComplete) - return - } - loadTerminalImageEntry(handlers, path, (entry) => { - writeInlineImagePreviewEntry(handlers, entry, onComplete) - }) -} - -const writeInlineImagePreviewEntry = ( - handlers: TerminalMessageHandlers, - entry: TerminalInlineImageEntry, - onComplete: () => void -): void => { - const appended = appendTerminalInlineImagePreview( - handlers.terminal, - handlers.lifecycle, - entry - ) - if (!appended) { - onComplete() - return - } - writePreviewSpacer(handlers, onComplete) -} - -const writeInlineImagePreviews = ( - handlers: TerminalMessageHandlers, - paths: ReadonlyArray, - onComplete: () => void -): void => { - let index = 0 - const writeNext = (): void => { - const path = paths[index] - if (path === undefined) { - onComplete() - return - } - index += 1 - writeInlineImagePreview(handlers, path, writeNext) - } - writeNext() -} - -const writeLineBreakBeforePreview = ( - handlers: TerminalMessageHandlers, - segment: TerminalInlineImageOutputSegment, - onComplete: () => void -): void => { - if (segment.endedWithLineBreak) { - onComplete() - return - } - handlers.terminal.write("\r\n", onComplete) -} - -const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => { - if (handlers.lifecycle.outputWriting || handlers.lifecycle.disposed) { - return - } - const segment = handlers.lifecycle.outputQueue.shift() - if (segment === undefined) { - return - } - - handlers.lifecycle.outputWriting = true - writeTerminalOutputSegment({ - inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef, - segment, - writer: { - writePreviewLineBreak: (outputSegment, onComplete) => { - writeLineBreakBeforePreview(handlers, outputSegment, onComplete) - }, - writePreviews: (paths, onComplete) => { - writeInlineImagePreviews(handlers, paths, onComplete) - }, - writeText: (text, onComplete) => { - handlers.terminal.write(text, onComplete) - } - } - }, () => { - handlers.lifecycle.outputWriting = false - flushTerminalOutputQueue(handlers) - }) -} - -const enqueueTerminalOutput = ( - handlers: TerminalMessageHandlers, - data: string -): void => { - for (const segment of splitTerminalInlineImageOutput(data)) { - handlers.lifecycle.outputQueue.push(segment) - } - flushTerminalOutputQueue(handlers) -} - const handleTerminalServerMessage = ( handlers: TerminalMessageHandlers, payload: string @@ -421,38 +203,6 @@ const attachTerminalSocketListeners = ( }) } -const closeSocket = (socket: WebSocket | null): void => { - if (socket === null || socket.readyState === WebSocket.CLOSED) { - return - } - runOptionalTerminalOperation(() => { - socket.close() - }) -} - -export const cleanupTerminalResources = ( - args: TerminalCleanupArgs -): void => { - args.lifecycle.disposed = true - clearReconnectTimer(args.lifecycle) - for (const disposable of args.lifecycle.inlineImageDisposables) { - disposable.dispose() - } - args.lifecycle.inlineImageDisposables = [] - revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) - args.lifecycle.outputQueue = [] - args.lifecycle.outputWriting = false - args.removeImageLinks() - args.removeImagePaste() - args.removeInput() - args.resizeObserver?.disconnect() - args.removeResize() - closeSocket(args.socketRef.current) - args.socketRef.current = null - args.runtimeRef.current = null - args.terminal.dispose() -} - const failBeforeAttach = ( args: TerminalSocketConnectArgs, terminalLine: string, diff --git a/packages/app/tests/docker-git/actions-project-terminal-lifecycle.test.ts b/packages/app/tests/docker-git/actions-project-terminal-lifecycle.test.ts new file mode 100644 index 00000000..28d274ce --- /dev/null +++ b/packages/app/tests/docker-git/actions-project-terminal-lifecycle.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { afterEach, beforeEach, vi } from "vitest" + +import { connectProjectById } from "../../src/web/actions-projects.js" +import type { loadProjectTerminalSession, startProjectTerminalSession } from "../../src/web/api.js" +import type { openProjectEventStream } from "../../src/web/project-events.js" +import type { ActiveTerminalSession } from "../../src/web/terminal.js" +import { makeSelectedProjectContext, session, startTerminalAccepted } from "./actions-project-terminal-test-fixtures.js" +import { waitForAssertion } from "./browser-action-context-fixture.js" + +const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>()) +const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) +const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) +const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadProjectTerminalSession: loadProjectTerminalSessionMock, + startProjectTerminalSession: startProjectTerminalSessionMock +})) + +vi.mock("../../src/web/project-events.js", () => ({ + openProjectEventStream: openProjectEventStreamMock +})) + +const readFirstProjectEventHandler = () => { + const handlers = openProjectEventStreamMock.mock.calls[0]?.[1] + if (handlers?.onEvent === undefined) { + throw new Error("missing event handlers") + } + return handlers.onEvent +} + +const emitCreatedSession = (requestId: string): void => { + readFirstProjectEventHandler()({ + at: "2026-04-21T10:00:01.000Z", + payload: { + phase: "created", + requestId, + sessionId: requestId + }, + projectId: "project-1", + seq: 8, + type: "project.ssh.session" + }) +} + +const emitStartupFailure = (requestId: string, message: string): void => { + readFirstProjectEventHandler()({ + at: "2026-04-21T10:00:01.000Z", + payload: { + message, + phase: "ssh.failed", + requestId + }, + projectId: "project-1", + seq: 8, + type: "project.deployment.status" + }) +} + +type ProjectConnectContext = Parameters[1] +type ProjectConnectLifecycle = NonNullable[3]> + +const mockProjectStream = (): void => { + openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) +} + +const createLifecycleSpies = (): Required => ({ + onFailure: vi.fn<(error: string) => void>(), + onSuccess: vi.fn<(sessionId: string) => void>() +}) + +const connectProjectAndWaitForStream = ( + context: ProjectConnectContext, + lifecycle: ProjectConnectLifecycle = {} +) => + Effect.gen(function*(_) { + connectProjectById("project-1", context, "octocat/hello-world", lifecycle) + + yield* _(waitForAssertion(() => { + expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) + })) + }) + +const connectAndAttachSession = ( + context: ProjectConnectContext, + lifecycle: Required, + pendingSessionId: string +) => + Effect.gen(function*(_) { + yield* _(connectProjectAndWaitForStream(context, lifecycle)) + expect(lifecycle.onFailure).not.toHaveBeenCalled() + expect(lifecycle.onSuccess).not.toHaveBeenCalled() + emitCreatedSession(pendingSessionId) + yield* _(waitForAssertion(() => { + expect(lifecycle.onSuccess).toHaveBeenCalledWith(pendingSessionId) + })) + }) + +const prepareAcceptedConnect = ( + pendingSessionId: string, + overrides: Parameters[0] = {} +) => { + vi.stubGlobal("crypto", { randomUUID: () => pendingSessionId }) + startProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(startTerminalAccepted(pendingSessionId))) + mockProjectStream() + return { + lifecycle: createLifecycleSpies(), + ...makeSelectedProjectContext(overrides) + } +} + +describe("project terminal connect lifecycle", () => { + beforeEach(() => { + vi.restoreAllMocks() + eventStreamCloseMock.mockReset() + loadProjectTerminalSessionMock.mockReset() + openProjectEventStreamMock.mockReset() + startProjectTerminalSessionMock.mockReset() + vi.unstubAllGlobals() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it.effect("adds a new SSH terminal session instead of replacing terminal state", () => + Effect.gen(function*(_) { + const pendingSessionId = "00000000-0000-4000-8000-000000000002" + const acceptedSession = { ...session, id: pendingSessionId } + vi.stubGlobal("crypto", { randomUUID: () => pendingSessionId }) + startProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(startTerminalAccepted(pendingSessionId))) + loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(acceptedSession)) + mockProjectStream() + const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() + const closeTerminalSession = vi.fn<(sessionId: string) => void>() + const { context, reloadDashboard, setMessage } = makeSelectedProjectContext({ + addTerminalSession, + closeTerminalSession + }) + + yield* _(connectProjectAndWaitForStream(context)) + emitCreatedSession(pendingSessionId) + + yield* _(waitForAssertion(() => { + expect(addTerminalSession).toHaveBeenCalledTimes(2) + })) + + const pendingSession = addTerminalSession.mock.calls[0]?.[0] + if (pendingSession === undefined) { + throw new Error("missing pending terminal session") + } + expect(startProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) + expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) + expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") + expect(pendingSession).toMatchObject({ + browserProjectId: "project-1", + browserProjectKey: "octocat/hello-world", + browserProjectName: "octocat/hello-world", + header: "SSH terminal: octocat/hello-world", + pendingConnection: { + message: "Starting project and waiting for SSH...", + phase: "connecting" + } + }) + expect(closeTerminalSession).toHaveBeenCalledWith(pendingSession.session.id) + expect(addTerminalSession).toHaveBeenLastCalledWith({ + browserProjectId: "project-1", + browserProjectKey: "octocat/hello-world", + browserProjectName: "octocat/hello-world", + closePath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}`, + exitMessage: "SSH session ended.", + header: "SSH terminal: octocat/hello-world", + onExit: reloadDashboard, + onReady: reloadDashboard, + pendingDeleteMessage: "Terminal session was closed before attach: octocat/hello-world.", + readyMessage: "SSH connected: octocat/hello-world.", + session: acceptedSession, + sessionPath: `/ssh/octocat/hello-world?t=${pendingSessionId.slice(0, 8)}`, + subtitle: "ssh -p 22 dev@172.18.0.7", + websocketPath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}/ws` + }) + expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) + expect(setMessage).toHaveBeenLastCalledWith( + "Project is ready. SSH terminal is connecting for octocat/hello-world." + ) + })) + + it.effect("starts SSH terminal creation from getRandomValues when randomUUID is unavailable", () => + Effect.gen(function*(_) { + vi.stubGlobal("crypto", { + getRandomValues: (values: Uint8Array): Uint8Array => { + values.set([0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE]) + return values + } + }) + startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) => + Effect.succeed(startTerminalAccepted(requestId)) + ) + mockProjectStream() + const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() + const { context } = makeSelectedProjectContext({ + addTerminalSession + }) + + yield* _(connectProjectAndWaitForStream(context)) + expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) + const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1] + expect(requestId).toBe("10325476-98ba-4cfe-8000-000000000000") + expect(addTerminalSession).toHaveBeenCalledTimes(1) + expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) + })) + + it.effect("reports success only after the created session attaches", () => + Effect.gen(function*(_) { + const pendingSessionId = "00000000-0000-4000-8000-000000000003" + loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed({ ...session, id: pendingSessionId })) + const { context, lifecycle } = prepareAcceptedConnect(pendingSessionId) + + yield* _(connectAndAttachSession(context, lifecycle, pendingSessionId)) + expect(lifecycle.onFailure).not.toHaveBeenCalled() + })) + + it.effect("ignores late failure events after the session already attached", () => + Effect.gen(function*(_) { + const pendingSessionId = "00000000-0000-4000-8000-000000000006" + loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed({ ...session, id: pendingSessionId })) + const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() + const { context, lifecycle, setMessage } = prepareAcceptedConnect(pendingSessionId, { addTerminalSession }) + + yield* _(connectAndAttachSession(context, lifecycle, pendingSessionId)) + emitStartupFailure(pendingSessionId, "Late backend failure.") + + expect(lifecycle.onFailure).not.toHaveBeenCalled() + expect(addTerminalSession).toHaveBeenCalledTimes(2) + expect(setMessage).not.toHaveBeenCalledWith("Late backend failure.") + expect(setMessage).toHaveBeenLastCalledWith( + "Project is ready. SSH terminal is connecting for octocat/hello-world." + ) + })) + + it.effect("reports failure when startup fails", () => + Effect.gen(function*(_) { + startProjectTerminalSessionMock.mockImplementation(() => Effect.fail("SSH session startup failed.")) + const lifecycle = createLifecycleSpies() + const { context } = makeSelectedProjectContext({}) + + connectProjectById("project-1", context, "octocat/hello-world", lifecycle) + + yield* _(waitForAssertion(() => { + expect(lifecycle.onFailure).toHaveBeenCalledWith("SSH session startup failed.") + })) + expect(lifecycle.onSuccess).not.toHaveBeenCalled() + })) + + it.effect("reports failure when the created session cannot attach", () => + Effect.gen(function*(_) { + const pendingSessionId = "00000000-0000-4000-8000-000000000004" + loadProjectTerminalSessionMock.mockImplementation(() => Effect.fail("SSH session attach failed.")) + const { context, lifecycle } = prepareAcceptedConnect(pendingSessionId) + + yield* _(connectProjectAndWaitForStream(context, lifecycle)) + emitCreatedSession(pendingSessionId) + + yield* _(waitForAssertion(() => { + expect(lifecycle.onFailure).toHaveBeenCalledWith("SSH session attach failed.") + })) + expect(lifecycle.onSuccess).not.toHaveBeenCalled() + expect(eventStreamCloseMock).toHaveBeenCalled() + })) + + it.effect("reports failure from backend startup events", () => + Effect.gen(function*(_) { + const pendingSessionId = "00000000-0000-4000-8000-000000000005" + const { context, lifecycle } = prepareAcceptedConnect(pendingSessionId) + + yield* _(connectProjectAndWaitForStream(context, lifecycle)) + emitStartupFailure(pendingSessionId, "Backend SSH startup failed.") + + expect(lifecycle.onFailure).toHaveBeenCalledWith("Backend SSH startup failed.") + expect(lifecycle.onSuccess).not.toHaveBeenCalled() + expect(loadProjectTerminalSessionMock).not.toHaveBeenCalled() + expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) + })) +}) diff --git a/packages/app/tests/docker-git/actions-project-terminal-test-fixtures.ts b/packages/app/tests/docker-git/actions-project-terminal-test-fixtures.ts new file mode 100644 index 00000000..e75f1030 --- /dev/null +++ b/packages/app/tests/docker-git/actions-project-terminal-test-fixtures.ts @@ -0,0 +1,53 @@ +import type { BrowserActionContext } from "../../src/web/actions-shared.js" +import type { ProjectDetails, StartProjectTerminalSessionAccepted, TerminalSession } from "../../src/web/api.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" + +export const project: ProjectDetails = { + authorizedKeysExists: true, + authorizedKeysPath: "/home/dev/.docker-git/project/authorized_keys", + clonedOnHostname: "host", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexHome: "/home/dev/.docker-git/.orch/codex", + containerName: "docker-git-project-1", + displayName: "octocat/hello-world", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/.docker-git/project/.orch/env/project.env", + gpu: "none", + id: "project-1", + projectDir: "/home/dev/.docker-git/octocat/hello-world", + projectKey: "octocat/hello-world", + repoRef: "main", + repoUrl: "https://github.com/octocat/Hello-World.git", + serviceName: "app", + sshCommand: "ssh -p 22 dev@172.18.0.7", + sshPort: 22, + sshSessions: 1, + sshUser: "dev", + startedAtEpochMs: 1_776_775_000_000, + startedAtIso: "2026-04-21T10:00:00.000Z", + status: "running", + statusLabel: "Up", + targetDir: "/home/dev/project" +} + +export const session: TerminalSession = { + createdAt: "2026-04-21T10:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh -p 22 dev@172.18.0.7", + status: "ready" +} + +export const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAccepted => ({ + accepted: true, + cursor: 7, + projectId: "project-1", + requestId +}) + +export const makeSelectedProjectContext = (overrides: Partial) => + makeBrowserActionContext({ + ...overrides, + selectedProjectId: "project-1", + selectedProjectKey: "octocat/hello-world" + }) diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index 749414f1..347d9e69 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -2,27 +2,13 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import { afterEach, beforeEach, vi } from "vitest" -import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" -import type { BrowserActionContext } from "../../src/web/actions-shared.js" -import type { - applyAllProjects, - applyProject, - loadProjectTerminalSession, - ProjectDetails, - startProjectTerminalSession, - StartProjectTerminalSessionAccepted, - TerminalSession -} from "../../src/web/api.js" -import type { openProjectEventStream } from "../../src/web/project-events.js" -import type { ActiveTerminalSession } from "../../src/web/terminal.js" +import { applyProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" +import type { applyAllProjects, applyProject } from "../../src/web/api.js" +import { project } from "./actions-project-terminal-test-fixtures.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" const applyAllProjectsMock = vi.hoisted(() => vi.fn()) const applyProjectMock = vi.hoisted(() => vi.fn()) -const eventStreamCloseMock = vi.hoisted(() => vi.fn<() => void>()) -const loadProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) -const openProjectEventStreamMock = vi.hoisted(() => vi.fn()) -const startProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ applyAllProjects: applyAllProjectsMock, @@ -33,8 +19,8 @@ vi.mock("../../src/web/api.js", () => ({ loadProjectDetails: vi.fn(), loadProjectLogs: vi.fn(), loadProjectPs: vi.fn(), - loadProjectTerminalSession: loadProjectTerminalSessionMock, - startProjectTerminalSession: startProjectTerminalSessionMock + loadProjectTerminalSession: vi.fn(), + startProjectTerminalSession: vi.fn() })) vi.mock("../../src/web/actions-browser.js", () => ({ @@ -56,190 +42,20 @@ vi.mock("../../src/web/actions-port-forwards.js", () => ({ })) vi.mock("../../src/web/project-events.js", () => ({ - openProjectEventStream: openProjectEventStreamMock + openProjectEventStream: vi.fn() })) -const project: ProjectDetails = { - authorizedKeysExists: true, - authorizedKeysPath: "/home/dev/.docker-git/project/authorized_keys", - clonedOnHostname: "host", - codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", - codexHome: "/home/dev/.docker-git/.orch/codex", - containerName: "docker-git-project-1", - displayName: "octocat/hello-world", - envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", - envProjectPath: "/home/dev/.docker-git/project/.orch/env/project.env", - gpu: "none", - id: "project-1", - projectDir: "/home/dev/.docker-git/octocat/hello-world", - projectKey: "octocat/hello-world", - repoRef: "main", - repoUrl: "https://github.com/octocat/Hello-World.git", - serviceName: "app", - sshCommand: "ssh -p 22 dev@172.18.0.7", - sshPort: 22, - sshSessions: 1, - sshUser: "dev", - startedAtEpochMs: 1_776_775_000_000, - startedAtIso: "2026-04-21T10:00:00.000Z", - status: "running", - statusLabel: "Up", - targetDir: "/home/dev/project" -} - -const session: TerminalSession = { - createdAt: "2026-04-21T10:00:00.000Z", - id: "session-1", - projectId: "project-1", - sshCommand: "ssh -p 22 dev@172.18.0.7", - status: "ready" -} - -const startTerminalAccepted = (requestId: string): StartProjectTerminalSessionAccepted => ({ - accepted: true, - cursor: 7, - projectId: "project-1", - requestId -}) - -const makeSelectedProjectContext = (overrides: Partial) => - makeBrowserActionContext({ - ...overrides, - selectedProjectId: "project-1", - selectedProjectKey: "octocat/hello-world" - }) - -const connectProjectAndWaitForStream = (context: BrowserActionContext) => - Effect.gen(function*(_) { - connectProjectById("project-1", context, "octocat/hello-world") - - yield* _(waitForAssertion(() => { - expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - })) - }) - -const readFirstProjectEventHandler = () => { - const handlers = openProjectEventStreamMock.mock.calls[0]?.[1] - if (handlers?.onEvent === undefined) { - throw new Error("missing event handlers") - } - return handlers.onEvent -} - describe("web project actions", () => { beforeEach(() => { vi.restoreAllMocks() applyAllProjectsMock.mockReset() applyProjectMock.mockReset() - eventStreamCloseMock.mockReset() - loadProjectTerminalSessionMock.mockReset() - openProjectEventStreamMock.mockReset() - startProjectTerminalSessionMock.mockReset() - vi.unstubAllGlobals() }) afterEach(() => { - vi.restoreAllMocks() vi.unstubAllGlobals() }) - it.effect("adds a new SSH terminal session instead of replacing terminal state", () => - Effect.gen(function*(_) { - const pendingSessionId = "00000000-0000-4000-8000-000000000002" - vi.stubGlobal("crypto", { randomUUID: () => pendingSessionId }) - startProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(startTerminalAccepted(pendingSessionId))) - const acceptedSession = { ...session, id: pendingSessionId } - loadProjectTerminalSessionMock.mockImplementation(() => Effect.succeed(acceptedSession)) - openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) - const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const closeTerminalSession = vi.fn<(sessionId: string) => void>() - const { context, reloadDashboard, setMessage } = makeSelectedProjectContext({ - addTerminalSession, - closeTerminalSession - }) - - yield* _(connectProjectAndWaitForStream(context)) - readFirstProjectEventHandler()({ - at: "2026-04-21T10:00:01.000Z", - payload: { - phase: "created", - requestId: pendingSessionId, - sessionId: pendingSessionId - }, - projectId: "project-1", - seq: 8, - type: "project.ssh.session" - }) - - yield* _(waitForAssertion(() => { - expect(addTerminalSession).toHaveBeenCalledTimes(2) - })) - - const pendingSession = addTerminalSession.mock.calls[0]?.[0] - if (pendingSession === undefined) { - throw new Error("missing pending terminal session") - } - expect(startProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) - expect(loadProjectTerminalSessionMock).toHaveBeenCalledWith("octocat/hello-world", pendingSessionId) - expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") - expect(pendingSession).toMatchObject({ - browserProjectId: "project-1", - browserProjectKey: "octocat/hello-world", - browserProjectName: "octocat/hello-world", - header: "SSH terminal: octocat/hello-world", - pendingConnection: { - message: "Starting project and waiting for SSH...", - phase: "connecting" - } - }) - expect(closeTerminalSession).toHaveBeenCalledWith(pendingSession.session.id) - expect(addTerminalSession).toHaveBeenLastCalledWith({ - browserProjectId: "project-1", - browserProjectKey: "octocat/hello-world", - browserProjectName: "octocat/hello-world", - closePath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}`, - exitMessage: "SSH session ended.", - header: "SSH terminal: octocat/hello-world", - onExit: reloadDashboard, - onReady: reloadDashboard, - pendingDeleteMessage: "Terminal session was closed before attach: octocat/hello-world.", - readyMessage: "SSH connected: octocat/hello-world.", - session: acceptedSession, - sessionPath: `/ssh/octocat/hello-world?t=${pendingSessionId.slice(0, 8)}`, - subtitle: "ssh -p 22 dev@172.18.0.7", - websocketPath: `/projects/by-key/octocat%2Fhello-world/terminal-sessions/${pendingSessionId}/ws` - }) - expect(eventStreamCloseMock).toHaveBeenCalledTimes(1) - expect(setMessage).toHaveBeenLastCalledWith( - "Project is ready. SSH terminal is connecting for octocat/hello-world." - ) - })) - - it.effect("starts SSH terminal creation from getRandomValues when randomUUID is unavailable", () => - Effect.gen(function*(_) { - vi.stubGlobal("crypto", { - getRandomValues: (values: Uint8Array): Uint8Array => { - values.set([0x10, 0x32, 0x54, 0x76, 0x98, 0xBA, 0xDC, 0xFE]) - return values - } - }) - startProjectTerminalSessionMock.mockImplementation((_projectKey, requestId: string) => - Effect.succeed(startTerminalAccepted(requestId)) - ) - openProjectEventStreamMock.mockImplementation(() => ({ close: eventStreamCloseMock })) - const addTerminalSession = vi.fn<(session: ActiveTerminalSession) => void>() - const { context } = makeSelectedProjectContext({ - addTerminalSession - }) - - yield* _(connectProjectAndWaitForStream(context)) - expect(startProjectTerminalSessionMock).toHaveBeenCalledTimes(1) - const requestId = startProjectTerminalSessionMock.mock.calls[0]?.[1] - expect(requestId).toBe("10325476-98ba-4cfe-8000-000000000000") - expect(addTerminalSession).toHaveBeenCalledTimes(1) - expect(openProjectEventStreamMock).toHaveBeenCalledTimes(1) - })) - it.effect("applies a selected project through the project apply endpoint", () => Effect.gen(function*(_) { const confirmMock = vi.fn(() => true) diff --git a/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts b/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts index 4bb122ba..9ddb9bb6 100644 --- a/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts +++ b/packages/app/tests/docker-git/app-ready-ssh-link-hook.test.ts @@ -60,6 +60,11 @@ describe("app-ready ssh link hook", () => { }) }) + it("ignores malformed percent-encoded SSH paths", () => { + expect(readSshLinkRequestFromHref("https://docker-git.local/ssh/%E0%A4%A")).toBeNull() + expect(readSshLinkRequestFromHref("https://docker-git.local/ssh/session/%E0%A4%A")).toBeNull() + }) + it("selects the requested workspace terminal before the active one", () => { expect(selectWorkspaceTerminalSession(makeSessionPair(), "session-1", "session-2")?.id).toBe("session-2") }) diff --git a/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts b/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts index 2e0fbf5e..ba5449d6 100644 --- a/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts +++ b/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts @@ -21,7 +21,7 @@ export type TerminalCopyTestMouseEvent = Event & { readonly shiftKey: boolean } -type TerminalCopyTestMouseType = "mousedown" | "mousemove" | "mouseup" +type TerminalCopyTestMouseType = "contextmenu" | "mousedown" | "mousemove" | "mouseup" type TerminalCopyTestEventType = "copy" | TerminalCopyTestMouseType type TerminalCopyTestCopyListener = (event: TerminalCopyTestClipboardEvent) => void type TerminalCopyTestMouseListener = (event: TerminalCopyTestMouseEvent) => void @@ -37,6 +37,7 @@ type TerminalCopyTestMouseOptions = Pick< TerminalCopyTestMouseEvent, "altKey" | "buttons" | "clientX" | "clientY" | "screenX" | "screenY" | "shiftKey" > +const mouseTestEventTypes = new Set(["contextmenu", "mousedown", "mousemove", "mouseup"]) const isCopyTestListener = ( type: TerminalCopyTestEventType, @@ -50,7 +51,7 @@ const isMouseTestListener = ( const isMouseTestEventType = ( type: string -): type is TerminalCopyTestMouseType => type === "mousedown" || type === "mousemove" || type === "mouseup" +): type is TerminalCopyTestMouseType => mouseTestEventTypes.has(type) const isMouseTestListenerEntry = ( entry: TerminalCopyTestListener @@ -74,14 +75,22 @@ const optionalBoolean = (value: boolean | undefined): boolean => value ?? false const optionalNumber = (value: number | undefined): number => value ?? 0 -const defaultButtons = (type: TerminalCopyTestMouseType): number => type === "mouseup" ? 0 : 1 +const pressedButtonsByMouseButton: ReadonlyArray = [1, 4, 2] + +const defaultButtons = (type: TerminalCopyTestMouseType, button: number): number => { + if (type === "contextmenu" || type === "mouseup") { + return 0 + } + return pressedButtonsByMouseButton[button] ?? 0 +} const resolveMouseOptions = ( type: TerminalCopyTestMouseType, + button: number, options: Partial ): TerminalCopyTestMouseOptions => ({ altKey: optionalBoolean(options.altKey), - buttons: options.buttons ?? defaultButtons(type), + buttons: options.buttons ?? defaultButtons(type, button), clientX: optionalNumber(options.clientX), clientY: optionalNumber(options.clientY), screenX: optionalNumber(options.screenX), @@ -108,7 +117,7 @@ export class FakeTerminalCopyMouseEvent extends Event { options: Partial = {} ) { super(type, { bubbles: true, cancelable: true }) - const resolved = resolveMouseOptions(type, options) + const resolved = resolveMouseOptions(type, button, options) this.altKey = resolved.altKey this.button = button this.buttons = resolved.buttons @@ -135,6 +144,24 @@ export class FakeTerminalCopyMouseEvent extends Event { } } +export class FakeTerminalCopyClipboardEvent { + readonly clipboardData: TerminalCopyTestClipboardData | null + preventDefaultCalls = 0 + stopPropagationCalls = 0 + + constructor(clipboardData: TerminalCopyTestClipboardData | null) { + this.clipboardData = clipboardData + } + + preventDefault(): void { + this.preventDefaultCalls += 1 + } + + stopPropagation(): void { + this.stopPropagationCalls += 1 + } +} + const isPropagationStopped = (event: TerminalCopyTestMouseEvent): boolean => event instanceof FakeTerminalCopyMouseEvent && (event.stopPropagationCalls > 0 || event.stopImmediatePropagationCalls > 0) @@ -199,6 +226,14 @@ export class FakeTerminalCopyEventTarget { this.dispatchMousePhase(type, event, "bubble") } + dispatchCopy(event: TerminalCopyTestClipboardEvent): void { + for (const entry of this.listeners) { + if (entry.type === "copy") { + entry.listener(event) + } + } + } + dispatchEvent(event: Event): boolean { this.dispatchedEvents.push(event) if (isMouseTestEventType(event.type) && isTerminalCopyTestMouseEvent(event)) { @@ -247,6 +282,9 @@ export const mouseEvent = ( > ): FakeTerminalCopyMouseEvent => new FakeTerminalCopyMouseEvent(type, button, options) +export const copyEvent = (clipboardData: TerminalCopyTestClipboardData | null): FakeTerminalCopyClipboardEvent => + new FakeTerminalCopyClipboardEvent(clipboardData) + export const expectNoDragListeners = (target: FakeTerminalCopyEventTarget): void => { expect(target.captureListenerCount("mousemove")).toBe(0) expect(target.captureListenerCount("mouseup")).toBe(0) @@ -256,9 +294,5 @@ export const expectSingleMouseEvent = ( events: ReadonlyArray ): TerminalCopyTestMouseEvent => { expect(events).toHaveLength(1) - const event = events[0] - if (event === undefined) { - throw new Error("Expected one mouse event.") - } - return event + return events[0] ?? expect.fail("Expected one mouse event.") } 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 9821edb2..f030dad8 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -10,6 +10,7 @@ import { resolveCreateFlowSteps, resolveCreateSettingsChoiceBuffer } from "../../src/docker-git/menu-create-shared.js" +import type { CreateInputs } from "../../src/docker-git/menu-types.js" import { createFeatureRepoDisplaySettingsView, createFeatureRepoSettingsView, @@ -88,6 +89,21 @@ describe("menu-create-shared", () => { ]) }) + it("preserves generated long out-dir buffers without recursion depth failures", () => { + fc.assert( + fc.property(fc.integer({ min: 1, max: 2500 }), (repeatCount) => { + const longOutDir = `/tmp/${"nested-".repeat(repeatCount)}repo` + const view = expectCreateContinueView(advanceCreateFlow( + cwd, + createInitialFlowView(`${featureCreateRepoUrl} --out-dir "${longOutDir}"`) + )) + + expect(view.values.outDir).toBe(longOutDir) + }), + { numRuns: 25 } + ) + }) + it("completes immediately when every remaining prompt was passed inline", () => { const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, @@ -169,6 +185,20 @@ describe("menu-create-shared", () => { ) }) + it("advances by one settings index after applying the current setting", () => { + const next = expectCreateContinueView(advanceCreateFlow( + cwd, + { + ...createFeatureRepoSettingsView(cwd), + buffer: "45%" + } + )) + + expect(next.values.cpuLimit).toBe("45%") + expect(next.step).toBe(2) + expect(resolveCreateFlowSteps(next.values)[next.step]).toBe("gpu") + }) + it("maps create-mode steps to the matching display row when opening browser Settings", () => { const createView = { ...createFeatureRepoSettingsView(cwd), @@ -225,7 +255,7 @@ describe("menu-create-shared", () => { expect(resolveCreateSettingsChoiceBuffer(unknownStepView, "left")).toBeNull() }) - it("continues after applying a navigated setting while earlier settings remain unresolved", () => { + it("completes after applying a navigated final setting with defaults", () => { const view = createFeatureRepoSettingsView(cwd) const forceView = moveCreateSettingsStep(view, "up") @@ -233,7 +263,7 @@ describe("menu-create-shared", () => { throw new TypeError("expected settings navigation result") } - const next = expectCreateContinueView(advanceCreateFlow( + const inputs = expectCreateCompleteInputs(advanceCreateFlow( cwd, { ...forceView, @@ -241,15 +271,46 @@ describe("menu-create-shared", () => { } )) - expect(next.values.force).toBe(true) - expect(next.step).toBe(resolveCreateFlowSteps(next.values).length - 1) - expect(resolveCreateFlowSteps(next.values)).toEqual([ - "repoUrl", - "cpuLimit", - "ramLimit", - "gpu", - "runUp", - "mcpPlaywright" - ]) + expect(inputs.force).toBe(true) + expect(inputs.cpuLimit).toBe("") + expect(inputs.ramLimit).toBe("") + }) + + it("completes after applying generated only remaining create settings", () => { + fc.assert( + fc.property( + fc.record({ + cpuLimit: fc.constantFrom("", "25%", "50%"), + enableMcpPlaywright: fc.boolean(), + force: fc.boolean(), + gpu: fc.constantFrom("none", "all"), + ramLimit: fc.constantFrom("", "2g", "4g"), + runUp: fc.boolean() + }), + ({ force, ...generatedValues }) => { + const values = { + outDir: defaultRoot, + repoRef: "feature-x", + repoUrl: featureCreateRepoUrl, + ...generatedValues + } satisfies Partial + expect(resolveCreateFlowSteps(values)).toEqual(["repoUrl", "force"]) + + const inputs = expectCreateCompleteInputs(advanceCreateFlow( + cwd, + { + buffer: force ? "y" : "n", + inputError: null, + mode: "create", + step: 1, + values + } + )) + + expect(inputs.force).toBe(force) + } + ), + { numRuns: 50 } + ) }) }) diff --git a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts index bced3bb3..669d9f82 100644 --- a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts +++ b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts @@ -38,6 +38,7 @@ describe("terminal copy interaction", () => { it("forces context-menu clicks into selection mode only when selected terminal text exists", () => { expect(shouldForceTerminalSelectionContext({ button: 2 }, terminalWithSelection("any", "selected"))).toBe(true) expect(shouldForceTerminalSelectionContext({ button: 2 }, terminalWithSelection("any", ""))).toBe(false) + expect(shouldForceTerminalSelectionContext({ button: 2 }, terminalWithSelection("none", "selected"))).toBe(false) expect(shouldForceTerminalSelectionContext({ button: 0 }, terminalWithSelection("any", "selected"))).toBe(false) }) @@ -186,24 +187,6 @@ describe("terminal copy interaction", () => { disposable.dispose() }) - it("keeps right-click selection handling one-shot", () => { - const documentTarget = new FakeTerminalCopyEventTarget() - const host = new FakeTerminalCopyHost(documentTarget) - const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") }) - const down = mouseEvent(2) - const move = mouseEvent(0) - - host.dispatchMouse("mousedown", down) - documentTarget.dispatchMouse("mousemove", move) - - expect(down.shiftKey).toBe(true) - expect(move.shiftKey).toBe(false) - expect(documentTarget.dispatchedEvents).toEqual([]) - expectNoDragListeners(documentTarget) - - disposable.dispose() - }) - it("falls back to host drag listeners when ownerDocument is unavailable", () => { const host = new FakeTerminalCopyHost(null) const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("drag", "") }) @@ -231,6 +214,8 @@ describe("terminal copy interaction", () => { expect(move.shiftKey).toBe(false) expect(host.listenerCount("mousedown")).toBe(0) + expect(host.listenerCount("mouseup")).toBe(0) + expect(host.listenerCount("contextmenu")).toBe(0) expect(host.listenerCount("copy")).toBe(0) expectNoDragListeners(documentTarget) }) diff --git a/packages/app/tests/docker-git/terminal-copy-right-click-interaction.test.ts b/packages/app/tests/docker-git/terminal-copy-right-click-interaction.test.ts new file mode 100644 index 00000000..b1ceb7df --- /dev/null +++ b/packages/app/tests/docker-git/terminal-copy-right-click-interaction.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from "@effect/vitest" +import * as fc from "fast-check" + +import { + attachTerminalCopyInteraction, + type TerminalCopyInteractionTerminal +} from "../../src/web/terminal-copy-interaction.js" +import { + copyEvent, + expectNoDragListeners, + FakeTerminalCopyEventTarget, + FakeTerminalCopyHost, + mouseEvent, + type TerminalCopyTestMouseEvent +} from "./fixtures/terminal-copy-interaction.js" + +type RightClickCopyHarness = { + readonly clipboardWrites: Array<{ readonly data: string; readonly format: string }> + readonly contextMenu: ReturnType + readonly contextMenuEvents: Array + readonly copy: ReturnType + readonly disposable: { readonly dispose: () => void } + readonly documentTarget: FakeTerminalCopyEventTarget + readonly host: FakeTerminalCopyHost + readonly rightClick: ReturnType + readonly rightRelease: ReturnType + readonly terminalMouseReports: Array +} + +type MutableSelectionFlow = { + readonly flow: RightClickCopyHarness + readonly readSelection: () => string +} + +const createRightClickCopyHarness = ( + terminal: TerminalCopyInteractionTerminal, + onTerminalMouseReport?: (event: TerminalCopyTestMouseEvent) => void, + onContextMenu?: (event: TerminalCopyTestMouseEvent) => void +): RightClickCopyHarness => { + const documentTarget = new FakeTerminalCopyEventTarget() + const host = new FakeTerminalCopyHost(documentTarget) + const terminalMouseReports: Array = [] + const contextMenuEvents: Array = [] + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const disposable = attachTerminalCopyInteraction({ host, terminal }) + const recordTerminalMouseReport = (event: TerminalCopyTestMouseEvent): void => { + terminalMouseReports.push(event) + onTerminalMouseReport?.(event) + } + host.addBubbleMouseListener("mousedown", recordTerminalMouseReport) + host.addBubbleMouseListener("mouseup", recordTerminalMouseReport) + host.addBubbleMouseListener("contextmenu", (event) => { + contextMenuEvents.push(event) + onContextMenu?.(event) + }) + return { + clipboardWrites, + contextMenu: mouseEvent(2, "contextmenu"), + contextMenuEvents, + copy: copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + }), + disposable, + documentTarget, + host, + rightClick: mouseEvent(2), + rightRelease: mouseEvent(2, "mouseup"), + terminalMouseReports + } +} + +const dispatchRightClickCopyFlow = (flow: RightClickCopyHarness): void => { + flow.host.dispatchMouse("mousedown", flow.rightClick) + flow.host.dispatchBubblingMouse("mouseup", flow.rightRelease) + flow.host.dispatchMouse("contextmenu", flow.contextMenu) + flow.host.dispatchCopy(flow.copy) +} + +const createMutableSelectionFlow = (selectedText: string): MutableSelectionFlow => { + let terminalSelection = selectedText + const terminal: TerminalCopyInteractionTerminal = { + getSelection: () => terminalSelection, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" } + } + const flow = createRightClickCopyHarness( + terminal, + () => { + terminalSelection = "" + }, + () => { + terminalSelection = "" + } + ) + return { + flow, + readSelection: () => terminalSelection + } +} + +const createStaticSelectionFlow = (selectedText: string): RightClickCopyHarness => + createRightClickCopyHarness({ + getSelection: () => selectedText, + hasSelection: () => selectedText.length > 0, + modes: { mouseTrackingMode: "any" } + }) + +const expectCopiedSelectionInvariant = (flow: RightClickCopyHarness, selectedText: string): void => { + expect(flow.clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) + expect(flow.copy.preventDefaultCalls).toBe(1) + expect(flow.copy.stopPropagationCalls).toBe(1) + expect(flow.terminalMouseReports).toEqual([]) +} + +const expectEmptySelectionPassthroughInvariant = (flow: RightClickCopyHarness): void => { + expect(flow.clipboardWrites).toEqual([]) + expect(flow.rightClick.stopImmediatePropagationCalls).toBe(0) + expect(flow.rightRelease.stopImmediatePropagationCalls).toBe(0) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(0) + expect(flow.copy.preventDefaultCalls).toBe(0) + expect(flow.terminalMouseReports).toEqual([flow.rightClick, flow.rightRelease]) +} + +describe("terminal copy right-click interaction", () => { + it("preserves generated right-click copy selections while mouse tracking is active", () => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (selectedText) => { + const { flow } = createMutableSelectionFlow(selectedText) + + dispatchRightClickCopyFlow(flow) + expectCopiedSelectionInvariant(flow, selectedText) + flow.disposable.dispose() + }), + { numRuns: 100 } + ) + }) + + it("preserves generated empty-selection right-click passthrough", () => { + fc.assert( + fc.property(fc.constant(""), (selectedText) => { + const flow = createStaticSelectionFlow(selectedText) + + dispatchRightClickCopyFlow(flow) + expectEmptySelectionPassthroughInvariant(flow) + flow.disposable.dispose() + }), + { numRuns: 10 } + ) + }) + + it("keeps right-click selection handling one-shot", () => { + const documentTarget = new FakeTerminalCopyEventTarget() + const host = new FakeTerminalCopyHost(documentTarget) + const terminal: TerminalCopyInteractionTerminal = { + getSelection: () => "selected", + hasSelection: () => true, + modes: { mouseTrackingMode: "any" } + } + const disposable = attachTerminalCopyInteraction({ host, terminal }) + const down = mouseEvent(2) + const move = mouseEvent(0) + + host.dispatchMouse("mousedown", down) + documentTarget.dispatchMouse("mousemove", move) + + expect(down.shiftKey).toBe(true) + expect(move.shiftKey).toBe(false) + expect(documentTarget.dispatchedEvents).toEqual([]) + expectNoDragListeners(documentTarget) + + disposable.dispose() + }) + + it("keeps selected terminal text copyable after right-click while mouse tracking is active", () => { + const selectedText = "line one\nline two" + const { flow, readSelection } = createMutableSelectionFlow(selectedText) + + flow.host.dispatchMouse("mousedown", flow.rightClick) + expect(readSelection()).toBe(selectedText) + flow.host.dispatchBubblingMouse("mouseup", flow.rightRelease) + expect(readSelection()).toBe(selectedText) + flow.host.dispatchMouse("contextmenu", flow.contextMenu) + flow.host.dispatchCopy(flow.copy) + + expect(flow.rightClick.shiftKey).toBe(true) + expect(flow.rightClick.preventDefaultCalls).toBe(0) + expect(flow.rightClick.stopImmediatePropagationCalls).toBe(1) + expect(flow.rightClick.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(flow.rightRelease.shiftKey).toBe(true) + expect(flow.rightRelease.preventDefaultCalls).toBe(0) + expect(flow.rightRelease.stopImmediatePropagationCalls).toBe(1) + expect(flow.rightRelease.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(flow.contextMenu.shiftKey).toBe(true) + expect(flow.contextMenu.preventDefaultCalls).toBe(0) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(0) + expect(flow.contextMenu.stopPropagationCalls).toBe(0) + expect(flow.terminalMouseReports).toEqual([]) + expect(flow.contextMenuEvents).toEqual([flow.contextMenu]) + expect(readSelection()).toBe("") + expectNoDragListeners(flow.documentTarget) + expectCopiedSelectionInvariant(flow, selectedText) + + flow.disposable.dispose() + }) + + it("does not suppress right-click release events without a terminal selection", () => { + const flow = createStaticSelectionFlow("") + + dispatchRightClickCopyFlow(flow) + + expect(flow.rightClick.shiftKey).toBe(false) + expect(flow.rightClick.stopImmediatePropagationCalls).toBe(0) + expect(flow.rightClick.stopPropagationCalls).toBe(0) + expect(flow.rightRelease.shiftKey).toBe(false) + expect(flow.rightRelease.stopImmediatePropagationCalls).toBe(0) + expect(flow.rightRelease.stopPropagationCalls).toBe(0) + expect(flow.contextMenu.shiftKey).toBe(false) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(0) + expect(flow.contextMenu.stopPropagationCalls).toBe(0) + expect(flow.terminalMouseReports).toEqual([flow.rightClick, flow.rightRelease]) + expect(flow.contextMenuEvents).toEqual([flow.contextMenu]) + expectEmptySelectionPassthroughInvariant(flow) + expectNoDragListeners(flow.documentTarget) + + flow.disposable.dispose() + }) +}) diff --git a/scripts/e2e/browser-command.sh b/scripts/e2e/browser-command.sh index 61e7e91e..424e3b87 100755 --- a/scripts/e2e/browser-command.sh +++ b/scripts/e2e/browser-command.sh @@ -116,7 +116,8 @@ wait_for_http_contains() { local body="" for _ in $(seq 1 "$attempts"); do - if body="$(curl -fsS "$url" 2>/dev/null)" && grep -Fq -- "$needle" <<<"$body"; then + if body="$(curl -fsS --connect-timeout 2 --max-time 5 "$url" 2>/dev/null)" \ + && grep -Fq -- "$needle" <<<"$body"; then return 0 fi if ! browser_alive; then