diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts new file mode 100644 index 00000000..50a79f39 --- /dev/null +++ b/packages/app/src/docker-git/menu-render-select.ts @@ -0,0 +1,187 @@ +import { Match } from "effect" +import { Text } from "ink" +import type React from "react" + +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import type { SelectProjectRuntime } from "./menu-types.js" + +export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete" + +const formatRepoRef = (repoRef: string): string => { + const trimmed = repoRef.trim() + const prPrefix = "refs/pull/" + if (trimmed.startsWith(prPrefix)) { + const rest = trimmed.slice(prPrefix.length) + const number = rest.split("/")[0] ?? rest + return `PR#${number}` + } + return trimmed.length > 0 ? trimmed : "main" +} + +const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 }) + +const runtimeForProject = ( + runtimeByProject: Readonly>, + item: ProjectItem +): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime() + +const renderRuntimeLabel = (runtime: SelectProjectRuntime): string => + `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}` + +export const selectTitle = (purpose: SelectPurpose): string => + Match.value(purpose).pipe( + Match.when("Connect", () => "docker-git / Select project"), + Match.when("Down", () => "docker-git / Stop container"), + Match.when("Info", () => "docker-git / Show connection info"), + Match.when("Delete", () => "docker-git / Delete project"), + Match.exhaustive + ) + +export const selectHint = ( + purpose: SelectPurpose, + connectEnableMcpPlaywright: boolean +): string => + Match.value(purpose).pipe( + Match.when( + "Connect", + () => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back` + ), + Match.when("Down", () => "Enter = stop container, Esc = back"), + Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), + Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), + Match.exhaustive + ) + +export const buildSelectLabels = ( + items: ReadonlyArray, + selected: number, + purpose: SelectPurpose, + runtimeByProject: Readonly> +): ReadonlyArray => + items.map((item, index) => { + const prefix = index === selected ? ">" : " " + const refLabel = formatRepoRef(item.repoRef) + const runtimeSuffix = purpose === "Down" || purpose === "Delete" + ? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]` + : "" + return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}` + }) + +type SelectDetailsContext = { + readonly item: ProjectItem + readonly refLabel: string + readonly authSuffix: string + readonly runtime: SelectProjectRuntime + readonly sshSessionsLabel: string +} + +const buildDetailsContext = ( + item: ProjectItem, + runtimeByProject: Readonly> +): SelectDetailsContext => { + const runtime = runtimeForProject(runtimeByProject, item) + return { + item, + refLabel: formatRepoRef(item.repoRef), + authSuffix: item.authorizedKeysExists ? "" : " (missing)", + runtime, + sshSessionsLabel: runtime.sshSessions === 1 + ? "1 active SSH session" + : `${runtime.sshSessions} active SSH sessions` + } +} + +const titleRow = (el: typeof React.createElement, value: string): React.ReactElement => + el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value) + +const commonRows = ( + el: typeof React.createElement, + context: SelectDetailsContext +): ReadonlyArray => [ + el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`), + el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`), + el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`), + el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`) +] + +const renderInfoDetails = ( + el: typeof React.createElement, + context: SelectDetailsContext, + common: ReadonlyArray +): ReadonlyArray => [ + titleRow(el, "Connection info"), + ...common, + el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`), + el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`), + el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`), + el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`), + el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`), + el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`), + el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`), + el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`) +] + +const renderDefaultDetails = ( + el: typeof React.createElement, + context: SelectDetailsContext +): ReadonlyArray => [ + titleRow(el, "Details"), + el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`), + el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`), + el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`), + el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`), + el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`) +] + +const renderConnectDetails = ( + el: typeof React.createElement, + context: SelectDetailsContext, + common: ReadonlyArray, + connectEnableMcpPlaywright: boolean +): ReadonlyArray => [ + titleRow(el, "Connect + SSH"), + ...common, + el( + Text, + { color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" }, + connectEnableMcpPlaywright + ? "Playwright MCP: will be enabled before SSH (P to disable)." + : "Playwright MCP: keep current project setting (P to enable before SSH)." + ), + el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`), + el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`) +] + +export const renderSelectDetails = ( + el: typeof React.createElement, + purpose: SelectPurpose, + item: ProjectItem | undefined, + runtimeByProject: Readonly>, + connectEnableMcpPlaywright: boolean +): ReadonlyArray => { + if (!item) { + return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")] + } + const context = buildDetailsContext(item, runtimeByProject) + const common = commonRows(el, context) + + return Match.value(purpose).pipe( + Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), + Match.when("Info", () => renderInfoDetails(el, context, common)), + Match.when("Down", () => [ + titleRow(el, "Stop container"), + ...common, + el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`) + ]), + Match.when("Delete", () => [ + titleRow(el, "Delete project"), + ...common, + context.runtime.sshSessions > 0 + ? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.") + : el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."), + el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`), + el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).") + ]), + Match.orElse(() => renderDefaultDetails(el, context)) + ) +} diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 52ab1a21..e82f900a 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -3,7 +3,14 @@ import { Box, Text } from "ink" import React from "react" import type { ProjectItem } from "@effect-template/lib/usecases/projects" -import type { CreateInputs, CreateStep } from "./menu-types.js" +import { + buildSelectLabels, + renderSelectDetails, + selectHint, + type SelectPurpose, + selectTitle +} from "./menu-render-select.js" +import type { CreateInputs, CreateStep, SelectProjectRuntime } from "./menu-types.js" import { createSteps, menuItems } from "./menu-types.js" // CHANGE: render menu views with Ink without JSX @@ -168,91 +175,6 @@ export const renderCreate = ( ) } -const formatRepoRef = (repoRef: string): string => { - const trimmed = repoRef.trim() - const prPrefix = "refs/pull/" - if (trimmed.startsWith(prPrefix)) { - const rest = trimmed.slice(prPrefix.length) - const number = rest.split("/")[0] ?? rest - return `PR#${number}` - } - return trimmed.length > 0 ? trimmed : "main" -} - -const renderSelectDetails = ( - el: typeof React.createElement, - purpose: SelectPurpose, - item: ProjectItem | undefined -): ReadonlyArray => { - if (!item) { - return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")] - } - - const refLabel = formatRepoRef(item.repoRef) - const authSuffix = item.authorizedKeysExists ? "" : " (missing)" - - return Match.value(purpose).pipe( - Match.when("Info", () => [ - el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Connection info"), - el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`), - el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`), - el(Text, { wrap: "wrap" }, `Service: ${item.serviceName}`), - el(Text, { wrap: "wrap" }, `SSH command: ${item.sshCommand}`), - el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`), - el(Text, { wrap: "wrap" }, `Workspace: ${item.targetDir}`), - el(Text, { wrap: "wrap" }, `Authorized keys: ${item.authorizedKeysPath}${authSuffix}`), - el(Text, { wrap: "wrap" }, `Env global: ${item.envGlobalPath}`), - el(Text, { wrap: "wrap" }, `Env project: ${item.envProjectPath}`), - el(Text, { wrap: "wrap" }, `Codex auth: ${item.codexAuthPath} -> ${item.codexHome}`) - ]), - Match.when("Delete", () => [ - el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Delete project"), - el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`), - el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`), - el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`), - el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).") - ]), - Match.orElse(() => [ - el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Details"), - el(Text, { wrap: "truncate" }, `Repo: ${item.repoUrl}`), - el(Text, { wrap: "truncate" }, `Ref: ${item.repoRef}`), - el(Text, { wrap: "truncate" }, `Project dir: ${item.projectDir}`), - el(Text, { wrap: "truncate" }, `Workspace: ${item.targetDir}`), - el(Text, { wrap: "truncate" }, `SSH: ${item.sshCommand}`) - ]) - ) -} - -type SelectPurpose = "Connect" | "Down" | "Info" | "Delete" - -const selectTitle = (purpose: SelectPurpose): string => - Match.value(purpose).pipe( - Match.when("Connect", () => "docker-git / Select project"), - Match.when("Down", () => "docker-git / Stop container"), - Match.when("Info", () => "docker-git / Show connection info"), - Match.when("Delete", () => "docker-git / Delete project"), - Match.exhaustive - ) - -const selectHint = (purpose: SelectPurpose): string => - Match.value(purpose).pipe( - Match.when("Connect", () => "Enter = select + SSH, Esc = back"), - Match.when("Down", () => "Enter = stop container, Esc = back"), - Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), - Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), - Match.exhaustive - ) - -const buildSelectLabels = ( - items: ReadonlyArray, - selected: number -): ReadonlyArray => - items.map((item, index) => { - const prefix = index === selected ? ">" : " " - const refLabel = formatRepoRef(item.repoRef) - return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})` - }) - const computeListWidth = (labels: ReadonlyArray): number => { const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24 return Math.min(Math.max(maxLabelWidth + 2, 28), 54) @@ -284,13 +206,25 @@ const renderSelectListBox = ( ) } +type SelectDetailsBoxInput = { + readonly purpose: SelectPurpose + readonly items: ReadonlyArray + readonly selected: number + readonly runtimeByProject: Readonly> + readonly connectEnableMcpPlaywright: boolean +} + const renderSelectDetailsBox = ( el: typeof React.createElement, - purpose: SelectPurpose, - items: ReadonlyArray, - selected: number + input: SelectDetailsBoxInput ): React.ReactElement => { - const details = renderSelectDetails(el, purpose, items[selected]) + const details = renderSelectDetails( + el, + input.purpose, + input.items[input.selected], + input.runtimeByProject, + input.connectEnableMcpPlaywright + ) return el( Box, { flexDirection: "column", marginLeft: 2, flexGrow: 1 }, @@ -299,22 +233,39 @@ const renderSelectDetailsBox = ( } export const renderSelect = ( - purpose: SelectPurpose, - items: ReadonlyArray, - selected: number, - confirmDelete: boolean, - message: string | null + input: { + readonly purpose: SelectPurpose + readonly items: ReadonlyArray + readonly selected: number + readonly runtimeByProject: Readonly> + readonly confirmDelete: boolean + readonly connectEnableMcpPlaywright: boolean + readonly message: string | null + } ): React.ReactElement => { + const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input const el = React.createElement - const listLabels = buildSelectLabels(items, selected) + const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject) const listWidth = computeListWidth(listLabels) const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth) - const detailsBox = renderSelectDetailsBox(el, purpose, items, selected) - const baseHint = selectHint(purpose) - const deleteHint = purpose === "Delete" && confirmDelete - ? "Confirm mode: Enter = delete now, Esc = cancel" - : baseHint - const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, deleteHint)) + const detailsBox = renderSelectDetailsBox(el, { + purpose, + items, + selected, + runtimeByProject, + connectEnableMcpPlaywright + }) + const baseHint = selectHint(purpose, connectEnableMcpPlaywright) + const confirmHint = (() => { + if (purpose === "Delete" && confirmDelete) { + return "Confirm mode: Enter = delete now, Esc = cancel" + } + if (purpose === "Down" && confirmDelete) { + return "Confirm mode: Enter = stop now, Esc = cancel" + } + return baseHint + })() + const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, confirmHint)) return renderLayout( selectTitle(purpose), diff --git a/packages/app/src/docker-git/menu-select-connect.ts b/packages/app/src/docker-git/menu-select-connect.ts new file mode 100644 index 00000000..9c541c23 --- /dev/null +++ b/packages/app/src/docker-git/menu-select-connect.ts @@ -0,0 +1,27 @@ +import { Effect } from "effect" + +import type { ProjectItem } from "@effect-template/lib/usecases/projects" + +type ConnectDeps = { + readonly connectWithUp: ( + item: ProjectItem + ) => Effect.Effect + readonly enableMcpPlaywright: ( + projectDir: string + ) => Effect.Effect +} + +const normalizedInput = (input: string): string => input.trim().toLowerCase() + +export const isConnectMcpToggleInput = (input: string): boolean => normalizedInput(input) === "p" + +export const buildConnectEffect = ( + selected: ProjectItem, + enableMcpPlaywright: boolean, + deps: ConnectDeps +): Effect.Effect => + enableMcpPlaywright + ? deps.enableMcpPlaywright(selected.projectDir).pipe( + Effect.zipRight(deps.connectWithUp(selected)) + ) + : deps.connectWithUp(selected) diff --git a/packages/app/src/docker-git/menu-select-runtime.ts b/packages/app/src/docker-git/menu-select-runtime.ts new file mode 100644 index 00000000..07ef0a0b --- /dev/null +++ b/packages/app/src/docker-git/menu-select-runtime.ts @@ -0,0 +1,94 @@ +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { runDockerPsNames } from "@effect-template/lib/shell/docker" +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { Effect, pipe } from "effect" + +import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js" + +const emptyRuntimeByProject = (): Readonly> => ({}) + +const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 }) + +const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'" + +const parseSshSessionCount = (raw: string): number => { + const parsed = Number.parseInt(raw.trim(), 10) + if (Number.isNaN(parsed) || parsed < 0) { + return 0 + } + return parsed +} + +const toRuntimeMap = ( + entries: ReadonlyArray +): Readonly> => { + const runtimeByProject: Record = {} + for (const [projectDir, runtime] of entries) { + runtimeByProject[projectDir] = runtime + } + return runtimeByProject +} + +const countContainerSshSessions = ( + containerName: string +): Effect.Effect => + pipe( + runCommandCapture( + { + cwd: process.cwd(), + command: "docker", + args: ["exec", containerName, "bash", "-lc", countSshSessionsScript] + }, + [0], + (exitCode) => ({ _tag: "CommandFailedError", command: "docker exec who -u", exitCode }) + ), + Effect.match({ + onFailure: () => 0, + onSuccess: (raw) => parseSshSessionCount(raw) + }) + ) + +// CHANGE: enrich select items with runtime state and SSH session counts +// WHY: prevent stopping/deleting containers that are currently used via SSH +// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас" +// REF: issue-47 +// SOURCE: n/a +// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)} +// PURITY: SHELL +// EFFECT: Effect, never, MenuEnv> +// INVARIANT: stopped containers always have sshSessions = 0 +// COMPLEXITY: O(n + docker_ps + docker_exec) +export const loadRuntimeByProject = ( + items: ReadonlyArray +): Effect.Effect>, never, MenuEnv> => + pipe( + runDockerPsNames(process.cwd()), + Effect.flatMap((runningNames) => + Effect.forEach( + items, + (item) => { + const running = runningNames.includes(item.containerName) + if (!running) { + const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()] + return Effect.succeed(entry) + } + return pipe( + countContainerSshSessions(item.containerName), + Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })), + Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime]) + ) + }, + { concurrency: 4 } + ) + ), + Effect.map((entries) => toRuntimeMap(entries)), + Effect.match({ + onFailure: () => emptyRuntimeByProject(), + onSuccess: (runtimeByProject) => runtimeByProject + }) + ) + +export const runtimeForSelection = ( + view: Extract, + selected: ProjectItem +): SelectProjectRuntime => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime() diff --git a/packages/app/src/docker-git/menu-select.ts b/packages/app/src/docker-git/menu-select.ts index 158925f3..80f9bec4 100644 --- a/packages/app/src/docker-git/menu-select.ts +++ b/packages/app/src/docker-git/menu-select.ts @@ -1,5 +1,6 @@ import { runDockerComposeDown } from "@effect-template/lib/shell/docker" import type { AppError } from "@effect-template/lib/usecases/errors" +import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" import { connectProjectSshWithUp, deleteDockerGitProject, @@ -9,19 +10,17 @@ import { import { Effect, Match, pipe } from "effect" +import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js" +import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js" import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js" -import type { MenuEnv, MenuKeyInput, MenuRunner, MenuViewContext, ViewState } from "./menu-types.js" - -// CHANGE: handle project selection flow in TUI -// WHY: allow selecting active project without manual typing -// QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?" -// REF: user-request-2026-02-02-select-project -// SOURCE: n/a -// FORMAT THEOREM: forall p: select(p) -> activeDir(p) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: selected index always within items length -// COMPLEXITY: O(1) per keypress +import type { + MenuEnv, + MenuKeyInput, + MenuRunner, + MenuViewContext, + SelectProjectRuntime, + ViewState +} from "./menu-types.js" type SelectContext = MenuViewContext & { readonly activeDir: string | null @@ -30,13 +29,24 @@ type SelectContext = MenuViewContext & { readonly setSkipInputs: (update: (value: number) => number) => void } +const emptyRuntimeByProject = (): Readonly> => ({}) + export const startSelectView = ( items: ReadonlyArray, purpose: "Connect" | "Down" | "Info" | "Delete", - context: Pick + context: Pick, + runtimeByProject: Readonly> = emptyRuntimeByProject() ) => { context.setMessage(null) - context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false }) + context.setView({ + _tag: "SelectProject", + purpose, + items, + runtimeByProject, + selected: 0, + confirmDelete: false, + connectEnableMcpPlaywright: false + }) } const clampIndex = (value: number, size: number): number => { @@ -62,6 +72,9 @@ export const handleSelectInput = ( resetToMenu(context) return } + if (handleConnectOptionToggle(input, view, context)) { + return + } if (handleSelectNavigation(key, view, context)) { return } @@ -69,7 +82,27 @@ export const handleSelectInput = ( handleSelectReturn(view, context) return } - handleSelectHint(input, context) + if (input.trim().length > 0) { + context.setMessage("Use arrows + Enter to select a project, Esc to cancel.") + } +} + +const handleConnectOptionToggle = ( + input: string, + view: Extract, + context: Pick +): boolean => { + if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) { + return false + } + const nextValue = !view.connectEnableMcpPlaywright + context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false }) + context.setMessage( + nextValue + ? "Playwright MCP will be enabled before SSH (press Enter to connect)." + : "Playwright MCP toggle is OFF (press Enter to connect without changes)." + ) + return true } const handleSelectNavigation = ( @@ -116,12 +149,30 @@ const runWithSuspendedTui = ( ) } -const runConnectSelection = (selected: ProjectItem, context: SelectContext) => { - context.setMessage(`Connecting to ${selected.displayName}...`) +const runConnectSelection = ( + selected: ProjectItem, + context: SelectContext, + enableMcpPlaywright: boolean +) => { + context.setMessage( + enableMcpPlaywright + ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...` + : `Connecting to ${selected.displayName}...` + ) context.setSshActive(true) runWithSuspendedTui( context, - connectProjectSshWithUp(selected), + buildConnectEffect(selected, enableMcpPlaywright, { + connectWithUp: (item) => + connectProjectSshWithUp(item).pipe( + Effect.mapError((error): AppError => error) + ), + enableMcpPlaywright: (projectDir) => + mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe( + Effect.asVoid, + Effect.mapError((error): AppError => error) + ) + }), () => { context.setSshActive(false) }, @@ -136,14 +187,20 @@ const runDownSelection = (selected: ProjectItem, context: SelectContext) => { Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), - Effect.tap((items) => + Effect.flatMap((items) => + pipe( + loadRuntimeByProject(items), + Effect.map((runtimeByProject) => ({ items, runtimeByProject })) + ) + ), + Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => { if (items.length === 0) { resetToMenu(context) context.setMessage("No running docker-git containers.") return } - startSelectView(items, "Down", context) + startSelectView(items, "Down", context, runtimeByProject) context.setMessage("Container stopped. Select another to stop, or Esc to return.") }) ), @@ -193,13 +250,24 @@ const handleSelectReturn = ( resetToMenu(context) return } + const selectedRuntime = runtimeForSelection(view, selected) + const sshSessionsLabel = selectedRuntime.sshSessions === 1 + ? "1 active SSH session" + : `${selectedRuntime.sshSessions} active SSH sessions` Match.value(view.purpose).pipe( Match.when("Connect", () => { context.setActiveDir(selected.projectDir) - runConnectSelection(selected, context) + runConnectSelection(selected, context, view.connectEnableMcpPlaywright) }), Match.when("Down", () => { + if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) { + context.setMessage( + `${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.` + ) + context.setView({ ...view, confirmDelete: true }) + return + } context.setActiveDir(selected.projectDir) runDownSelection(selected, context) }), @@ -209,8 +277,9 @@ const handleSelectReturn = ( }), Match.when("Delete", () => { if (!view.confirmDelete) { + const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : "" context.setMessage( - `Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.` + `Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.` ) context.setView({ ...view, confirmDelete: true }) return @@ -221,12 +290,6 @@ const handleSelectReturn = ( ) } -const handleSelectHint = (input: string, context: SelectContext) => { - if (input.trim().length > 0) { - context.setMessage("Use arrows + Enter to select a project, Esc to cancel.") - } -} - export const loadSelectView = ( effect: Effect.Effect, E, MenuEnv>, purpose: "Connect" | "Down" | "Info" | "Delete", @@ -235,16 +298,21 @@ export const loadSelectView = ( pipe( effect, Effect.flatMap((items) => - Effect.sync(() => { - if (items.length === 0) { - context.setMessage( - purpose === "Down" - ? "No running docker-git containers." - : "No docker-git projects found." - ) - return - } - startSelectView(items, purpose, context) - }) + pipe( + loadRuntimeByProject(items), + Effect.flatMap((runtimeByProject) => + Effect.sync(() => { + if (items.length === 0) { + context.setMessage( + purpose === "Down" + ? "No running docker-git containers." + : "No docker-git projects found." + ) + return + } + startSelectView(items, purpose, context, runtimeByProject) + }) + ) + ) ) ) diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts index 8d3a21f4..755e8206 100644 --- a/packages/app/src/docker-git/menu-types.ts +++ b/packages/app/src/docker-git/menu-types.ts @@ -77,10 +77,17 @@ export type ViewState = readonly _tag: "SelectProject" readonly purpose: "Connect" | "Down" | "Info" | "Delete" readonly items: ReadonlyArray + readonly runtimeByProject: Readonly> readonly selected: number readonly confirmDelete: boolean + readonly connectEnableMcpPlaywright: boolean } +export type SelectProjectRuntime = { + readonly running: boolean + readonly sshSessions: number +} + export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [ { id: { _tag: "Create" }, label: "Create project" }, { id: { _tag: "Select" }, label: "Select project" }, diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index b436b9b4..f15f8579 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -185,13 +185,15 @@ const renderView = (context: RenderContext) => { return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults) } - return renderSelect( - context.view.purpose, - context.view.items, - context.view.selected, - context.view.confirmDelete, - context.message - ) + return renderSelect({ + purpose: context.view.purpose, + items: context.view.items, + selected: context.view.selected, + runtimeByProject: context.view.runtimeByProject, + confirmDelete: context.view.confirmDelete, + connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright, + message: context.message + }) } const useMenuState = () => { diff --git a/packages/app/tests/docker-git/menu-select-connect.test.ts b/packages/app/tests/docker-git/menu-select-connect.test.ts new file mode 100644 index 00000000..902a0942 --- /dev/null +++ b/packages/app/tests/docker-git/menu-select-connect.test.ts @@ -0,0 +1,64 @@ +import { Effect } from "effect" +import { describe, expect, it } from "vitest" + +import type { ProjectItem } from "@effect-template/lib/usecases/projects" +import { selectHint } from "../../src/docker-git/menu-render-select.js" +import { buildConnectEffect, isConnectMcpToggleInput } from "../../src/docker-git/menu-select-connect.js" + +const makeProjectItem = (): ProjectItem => ({ + projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo", + displayName: "org/repo", + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + containerName: "dg-repo", + serviceName: "dg-repo", + sshUser: "dev", + sshPort: 2222, + targetDir: "/home/dev/org/repo", + sshCommand: "ssh -p 2222 dev@localhost", + sshKeyPath: null, + authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys", + authorizedKeysExists: true, + envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env", + codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex", + codexHome: "/home/dev/.codex" +}) + +const record = (events: Array, entry: string): Effect.Effect => + Effect.sync(() => { + events.push(entry) + }) + +const makeConnectDeps = (events: Array) => ({ + connectWithUp: (selected: ProjectItem) => record(events, `connect:${selected.projectDir}`), + enableMcpPlaywright: (projectDir: string) => record(events, `enable:${projectDir}`) +}) + +describe("menu-select-connect", () => { + it("runs Playwright enable before SSH when toggle is ON", () => { + const item = makeProjectItem() + const events: Array = [] + Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events))) + expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`]) + }) + + it("skips Playwright enable when toggle is OFF", () => { + const item = makeProjectItem() + const events: Array = [] + Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events))) + expect(events).toEqual([`connect:${item.projectDir}`]) + }) + + it("parses connect toggle key from user input", () => { + expect(isConnectMcpToggleInput("p")).toBe(true) + expect(isConnectMcpToggleInput(" P ")).toBe(true) + expect(isConnectMcpToggleInput("x")).toBe(false) + expect(isConnectMcpToggleInput("")).toBe(false) + }) + + it("renders connect hint with current Playwright toggle state", () => { + expect(selectHint("Connect", true)).toContain("toggle Playwright MCP (on)") + expect(selectHint("Connect", false)).toContain("toggle Playwright MCP (off)") + }) +}) diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 6fd09e50..3562a852 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -23,6 +23,7 @@ import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" import { buildSshCommand } from "../projects-core.js" import { autoSyncState } from "../state-repo.js" +import { ensureTerminalCursorVisible } from "../terminal-cursor.js" import { runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" import { resolveSshPort } from "./ports.js" @@ -85,7 +86,7 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } -const isInteractiveTty = (): boolean => process.stdin.isTTY === true && process.stdout.isTTY === true +const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY const buildSshArgs = ( config: CreateCommand["config"], @@ -132,6 +133,7 @@ const openSshBestEffort = ( const sshCommand = buildSshCommand(template, sshKey) yield* _(Effect.log(`Opening SSH: ${sshCommand}`)) + yield* _(ensureTerminalCursorVisible()) yield* _( runCommandWithExitCodes( { @@ -193,10 +195,10 @@ const runCreateProject = ( if (command.openSsh) { if (!command.runUp) { yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up).")) - } else if (!isInteractiveTty()) { - yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY.")) - } else { + } else if (isInteractiveTty()) { yield* _(openSshBestEffort(projectConfig)) + } else { + yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY.")) } } }).pipe(Effect.asVoid) diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index fd5f3c7d..4b785cce 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -25,6 +25,7 @@ import { withProjectIndexAndSsh } from "./projects-core.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" +import { ensureTerminalCursorVisible } from "./terminal-cursor.js" const buildSshArgs = (item: ProjectItem): ReadonlyArray => { const args: Array = [] @@ -118,14 +119,19 @@ const waitForSshReady = ( export const connectProjectSsh = ( item: ProjectItem ): Effect.Effect => - runCommandWithExitCodes( - { - cwd: process.cwd(), - command: "ssh", - args: buildSshArgs(item) - }, - [0, 130], - (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) + pipe( + ensureTerminalCursorVisible(), + Effect.zipRight( + runCommandWithExitCodes( + { + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(item) + }, + [0, 130], + (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) + ) + ) ) // CHANGE: ensure docker compose is up before SSH connection diff --git a/packages/lib/src/usecases/terminal-cursor.ts b/packages/lib/src/usecases/terminal-cursor.ts new file mode 100644 index 00000000..8dd93d45 --- /dev/null +++ b/packages/lib/src/usecases/terminal-cursor.ts @@ -0,0 +1,23 @@ +import { Effect } from "effect" + +const cursorVisibleEscape = "\u001B[?25h" + +const hasInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY + +// CHANGE: ensure the terminal cursor is visible before handing control to interactive SSH +// WHY: Ink/TTY transitions can leave cursor hidden, which makes SSH shells look frozen +// QUOTE(ТЗ): "не виден курсор в SSH терминале" +// REF: issue-3 +// SOURCE: n/a +// FORMAT THEOREM: forall t: interactive(t) -> cursor_visible(t) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: escape sequence is emitted only in interactive tty mode +// COMPLEXITY: O(1) +export const ensureTerminalCursorVisible = (): Effect.Effect => + Effect.sync(() => { + if (!hasInteractiveTty()) { + return + } + process.stdout.write(cursorVisibleEscape) + }) diff --git a/packages/lib/tests/usecases/terminal-cursor.test.ts b/packages/lib/tests/usecases/terminal-cursor.test.ts new file mode 100644 index 00000000..9e09a656 --- /dev/null +++ b/packages/lib/tests/usecases/terminal-cursor.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { vi } from "vitest" + +import { ensureTerminalCursorVisible } from "../../src/usecases/terminal-cursor.js" + +type TtyPatch = { + readonly prevStdinTty: boolean | undefined + readonly prevStdoutTty: boolean | undefined +} + +const patchTty = (stdinTty: boolean, stdoutTty: boolean): Effect.Effect => + Effect.sync(() => { + const prevStdinTty = process.stdin.isTTY + const prevStdoutTty = process.stdout.isTTY + Object.defineProperty(process.stdin, "isTTY", { value: stdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: stdoutTty, configurable: true }) + return { prevStdinTty, prevStdoutTty } + }) + +const restoreTty = (patch: TtyPatch): Effect.Effect => + Effect.sync(() => { + Object.defineProperty(process.stdin, "isTTY", { value: patch.prevStdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: patch.prevStdoutTty, configurable: true }) + }) + +const withPatchedTty = ( + stdinTty: boolean, + stdoutTty: boolean, + use: Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease(patchTty(stdinTty, stdoutTty), restoreTty).pipe( + Effect.flatMap(() => use) + ) + ) + +const withWriteSpy = ( + use: (writeSpy: ReturnType) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease( + Effect.sync(() => vi.spyOn(process.stdout, "write").mockImplementation(() => true)), + (writeSpy) => + Effect.sync(() => { + writeSpy.mockRestore() + }) + ).pipe( + Effect.flatMap((writeSpy) => use(writeSpy)) + ) + ) + +describe("ensureTerminalCursorVisible", () => { + it.effect("emits show-cursor escape in interactive tty", () => + withWriteSpy((writeSpy) => + Effect.gen(function*(_) { + yield* _(withPatchedTty(true, true, ensureTerminalCursorVisible())) + expect(writeSpy).toHaveBeenCalledWith("\u001B[?25h") + }) + )) + + it.effect("does nothing in non-interactive mode", () => + withWriteSpy((writeSpy) => + Effect.gen(function*(_) { + yield* _(withPatchedTty(false, true, ensureTerminalCursorVisible())) + expect(writeSpy).not.toHaveBeenCalled() + }) + )) +})