Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions packages/app/src/docker-git/menu-render-select.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, SelectProjectRuntime>>,
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<ProjectItem>,
selected: number,
purpose: SelectPurpose,
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
): ReadonlyArray<string> =>
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<Record<string, SelectProjectRuntime>>
): 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<React.ReactElement> => [
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<React.ReactElement>
): ReadonlyArray<React.ReactElement> => [
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<React.ReactElement> => [
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<React.ReactElement>,
connectEnableMcpPlaywright: boolean
): ReadonlyArray<React.ReactElement> => [
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<Record<string, SelectProjectRuntime>>,
connectEnableMcpPlaywright: boolean
): ReadonlyArray<React.ReactElement> => {
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))
)
}
155 changes: 53 additions & 102 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<React.ReactElement> => {
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<ProjectItem>,
selected: number
): ReadonlyArray<string> =>
items.map((item, index) => {
const prefix = index === selected ? ">" : " "
const refLabel = formatRepoRef(item.repoRef)
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})`
})

const computeListWidth = (labels: ReadonlyArray<string>): number => {
const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
Expand Down Expand Up @@ -284,13 +206,25 @@ const renderSelectListBox = (
)
}

type SelectDetailsBoxInput = {
readonly purpose: SelectPurpose
readonly items: ReadonlyArray<ProjectItem>
readonly selected: number
readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
readonly connectEnableMcpPlaywright: boolean
}

const renderSelectDetailsBox = (
el: typeof React.createElement,
purpose: SelectPurpose,
items: ReadonlyArray<ProjectItem>,
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 },
Expand All @@ -299,22 +233,39 @@ const renderSelectDetailsBox = (
}

export const renderSelect = (
purpose: SelectPurpose,
items: ReadonlyArray<ProjectItem>,
selected: number,
confirmDelete: boolean,
message: string | null
input: {
readonly purpose: SelectPurpose
readonly items: ReadonlyArray<ProjectItem>
readonly selected: number
readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
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),
Expand Down
Loading