Skip to content

Commit f19b7f7

Browse files
authored
feat(app): surface active SSH usage and add connect-time Playwright toggle (#50)
* feat(app): show active SSH sessions in stop/delete selectors * feat(app): add Playwright toggle on connect and fix SSH cursor * fix(app): keep runtime fallback typed for effect lint * fix(lib-test): remove try/finally for effect lint
1 parent 4fa96bb commit f19b7f7

12 files changed

Lines changed: 660 additions & 160 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { Match } from "effect"
2+
import { Text } from "ink"
3+
import type React from "react"
4+
5+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
6+
import type { SelectProjectRuntime } from "./menu-types.js"
7+
8+
export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete"
9+
10+
const formatRepoRef = (repoRef: string): string => {
11+
const trimmed = repoRef.trim()
12+
const prPrefix = "refs/pull/"
13+
if (trimmed.startsWith(prPrefix)) {
14+
const rest = trimmed.slice(prPrefix.length)
15+
const number = rest.split("/")[0] ?? rest
16+
return `PR#${number}`
17+
}
18+
return trimmed.length > 0 ? trimmed : "main"
19+
}
20+
21+
const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
22+
23+
const runtimeForProject = (
24+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
25+
item: ProjectItem
26+
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()
27+
28+
const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
29+
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`
30+
31+
export const selectTitle = (purpose: SelectPurpose): string =>
32+
Match.value(purpose).pipe(
33+
Match.when("Connect", () => "docker-git / Select project"),
34+
Match.when("Down", () => "docker-git / Stop container"),
35+
Match.when("Info", () => "docker-git / Show connection info"),
36+
Match.when("Delete", () => "docker-git / Delete project"),
37+
Match.exhaustive
38+
)
39+
40+
export const selectHint = (
41+
purpose: SelectPurpose,
42+
connectEnableMcpPlaywright: boolean
43+
): string =>
44+
Match.value(purpose).pipe(
45+
Match.when(
46+
"Connect",
47+
() => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`
48+
),
49+
Match.when("Down", () => "Enter = stop container, Esc = back"),
50+
Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"),
51+
Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"),
52+
Match.exhaustive
53+
)
54+
55+
export const buildSelectLabels = (
56+
items: ReadonlyArray<ProjectItem>,
57+
selected: number,
58+
purpose: SelectPurpose,
59+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
60+
): ReadonlyArray<string> =>
61+
items.map((item, index) => {
62+
const prefix = index === selected ? ">" : " "
63+
const refLabel = formatRepoRef(item.repoRef)
64+
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
65+
? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
66+
: ""
67+
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
68+
})
69+
70+
type SelectDetailsContext = {
71+
readonly item: ProjectItem
72+
readonly refLabel: string
73+
readonly authSuffix: string
74+
readonly runtime: SelectProjectRuntime
75+
readonly sshSessionsLabel: string
76+
}
77+
78+
const buildDetailsContext = (
79+
item: ProjectItem,
80+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
81+
): SelectDetailsContext => {
82+
const runtime = runtimeForProject(runtimeByProject, item)
83+
return {
84+
item,
85+
refLabel: formatRepoRef(item.repoRef),
86+
authSuffix: item.authorizedKeysExists ? "" : " (missing)",
87+
runtime,
88+
sshSessionsLabel: runtime.sshSessions === 1
89+
? "1 active SSH session"
90+
: `${runtime.sshSessions} active SSH sessions`
91+
}
92+
}
93+
94+
const titleRow = (el: typeof React.createElement, value: string): React.ReactElement =>
95+
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value)
96+
97+
const commonRows = (
98+
el: typeof React.createElement,
99+
context: SelectDetailsContext
100+
): ReadonlyArray<React.ReactElement> => [
101+
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
102+
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
103+
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
104+
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
105+
]
106+
107+
const renderInfoDetails = (
108+
el: typeof React.createElement,
109+
context: SelectDetailsContext,
110+
common: ReadonlyArray<React.ReactElement>
111+
): ReadonlyArray<React.ReactElement> => [
112+
titleRow(el, "Connection info"),
113+
...common,
114+
el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`),
115+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`),
116+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
117+
el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`),
118+
el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`),
119+
el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
120+
el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
121+
el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`)
122+
]
123+
124+
const renderDefaultDetails = (
125+
el: typeof React.createElement,
126+
context: SelectDetailsContext
127+
): ReadonlyArray<React.ReactElement> => [
128+
titleRow(el, "Details"),
129+
el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`),
130+
el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`),
131+
el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`),
132+
el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`),
133+
el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`)
134+
]
135+
136+
const renderConnectDetails = (
137+
el: typeof React.createElement,
138+
context: SelectDetailsContext,
139+
common: ReadonlyArray<React.ReactElement>,
140+
connectEnableMcpPlaywright: boolean
141+
): ReadonlyArray<React.ReactElement> => [
142+
titleRow(el, "Connect + SSH"),
143+
...common,
144+
el(
145+
Text,
146+
{ color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" },
147+
connectEnableMcpPlaywright
148+
? "Playwright MCP: will be enabled before SSH (P to disable)."
149+
: "Playwright MCP: keep current project setting (P to enable before SSH)."
150+
),
151+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
152+
el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`)
153+
]
154+
155+
export const renderSelectDetails = (
156+
el: typeof React.createElement,
157+
purpose: SelectPurpose,
158+
item: ProjectItem | undefined,
159+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
160+
connectEnableMcpPlaywright: boolean
161+
): ReadonlyArray<React.ReactElement> => {
162+
if (!item) {
163+
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")]
164+
}
165+
const context = buildDetailsContext(item, runtimeByProject)
166+
const common = commonRows(el, context)
167+
168+
return Match.value(purpose).pipe(
169+
Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)),
170+
Match.when("Info", () => renderInfoDetails(el, context, common)),
171+
Match.when("Down", () => [
172+
titleRow(el, "Stop container"),
173+
...common,
174+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
175+
]),
176+
Match.when("Delete", () => [
177+
titleRow(el, "Delete project"),
178+
...common,
179+
context.runtime.sshSessions > 0
180+
? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
181+
: el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
182+
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
183+
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
184+
]),
185+
Match.orElse(() => renderDefaultDetails(el, context))
186+
)
187+
}

packages/app/src/docker-git/menu-render.ts

Lines changed: 53 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import { Box, Text } from "ink"
33
import React from "react"
44

55
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
6-
import type { CreateInputs, CreateStep } from "./menu-types.js"
6+
import {
7+
buildSelectLabels,
8+
renderSelectDetails,
9+
selectHint,
10+
type SelectPurpose,
11+
selectTitle
12+
} from "./menu-render-select.js"
13+
import type { CreateInputs, CreateStep, SelectProjectRuntime } from "./menu-types.js"
714
import { createSteps, menuItems } from "./menu-types.js"
815

916
// CHANGE: render menu views with Ink without JSX
@@ -168,91 +175,6 @@ export const renderCreate = (
168175
)
169176
}
170177

171-
const formatRepoRef = (repoRef: string): string => {
172-
const trimmed = repoRef.trim()
173-
const prPrefix = "refs/pull/"
174-
if (trimmed.startsWith(prPrefix)) {
175-
const rest = trimmed.slice(prPrefix.length)
176-
const number = rest.split("/")[0] ?? rest
177-
return `PR#${number}`
178-
}
179-
return trimmed.length > 0 ? trimmed : "main"
180-
}
181-
182-
const renderSelectDetails = (
183-
el: typeof React.createElement,
184-
purpose: SelectPurpose,
185-
item: ProjectItem | undefined
186-
): ReadonlyArray<React.ReactElement> => {
187-
if (!item) {
188-
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")]
189-
}
190-
191-
const refLabel = formatRepoRef(item.repoRef)
192-
const authSuffix = item.authorizedKeysExists ? "" : " (missing)"
193-
194-
return Match.value(purpose).pipe(
195-
Match.when("Info", () => [
196-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Connection info"),
197-
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
198-
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
199-
el(Text, { wrap: "wrap" }, `Service: ${item.serviceName}`),
200-
el(Text, { wrap: "wrap" }, `SSH command: ${item.sshCommand}`),
201-
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
202-
el(Text, { wrap: "wrap" }, `Workspace: ${item.targetDir}`),
203-
el(Text, { wrap: "wrap" }, `Authorized keys: ${item.authorizedKeysPath}${authSuffix}`),
204-
el(Text, { wrap: "wrap" }, `Env global: ${item.envGlobalPath}`),
205-
el(Text, { wrap: "wrap" }, `Env project: ${item.envProjectPath}`),
206-
el(Text, { wrap: "wrap" }, `Codex auth: ${item.codexAuthPath} -> ${item.codexHome}`)
207-
]),
208-
Match.when("Delete", () => [
209-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Delete project"),
210-
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
211-
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
212-
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
213-
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
214-
]),
215-
Match.orElse(() => [
216-
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Details"),
217-
el(Text, { wrap: "truncate" }, `Repo: ${item.repoUrl}`),
218-
el(Text, { wrap: "truncate" }, `Ref: ${item.repoRef}`),
219-
el(Text, { wrap: "truncate" }, `Project dir: ${item.projectDir}`),
220-
el(Text, { wrap: "truncate" }, `Workspace: ${item.targetDir}`),
221-
el(Text, { wrap: "truncate" }, `SSH: ${item.sshCommand}`)
222-
])
223-
)
224-
}
225-
226-
type SelectPurpose = "Connect" | "Down" | "Info" | "Delete"
227-
228-
const selectTitle = (purpose: SelectPurpose): string =>
229-
Match.value(purpose).pipe(
230-
Match.when("Connect", () => "docker-git / Select project"),
231-
Match.when("Down", () => "docker-git / Stop container"),
232-
Match.when("Info", () => "docker-git / Show connection info"),
233-
Match.when("Delete", () => "docker-git / Delete project"),
234-
Match.exhaustive
235-
)
236-
237-
const selectHint = (purpose: SelectPurpose): string =>
238-
Match.value(purpose).pipe(
239-
Match.when("Connect", () => "Enter = select + SSH, Esc = back"),
240-
Match.when("Down", () => "Enter = stop container, Esc = back"),
241-
Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"),
242-
Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"),
243-
Match.exhaustive
244-
)
245-
246-
const buildSelectLabels = (
247-
items: ReadonlyArray<ProjectItem>,
248-
selected: number
249-
): ReadonlyArray<string> =>
250-
items.map((item, index) => {
251-
const prefix = index === selected ? ">" : " "
252-
const refLabel = formatRepoRef(item.repoRef)
253-
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})`
254-
})
255-
256178
const computeListWidth = (labels: ReadonlyArray<string>): number => {
257179
const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
258180
return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
@@ -284,13 +206,25 @@ const renderSelectListBox = (
284206
)
285207
}
286208

209+
type SelectDetailsBoxInput = {
210+
readonly purpose: SelectPurpose
211+
readonly items: ReadonlyArray<ProjectItem>
212+
readonly selected: number
213+
readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
214+
readonly connectEnableMcpPlaywright: boolean
215+
}
216+
287217
const renderSelectDetailsBox = (
288218
el: typeof React.createElement,
289-
purpose: SelectPurpose,
290-
items: ReadonlyArray<ProjectItem>,
291-
selected: number
219+
input: SelectDetailsBoxInput
292220
): React.ReactElement => {
293-
const details = renderSelectDetails(el, purpose, items[selected])
221+
const details = renderSelectDetails(
222+
el,
223+
input.purpose,
224+
input.items[input.selected],
225+
input.runtimeByProject,
226+
input.connectEnableMcpPlaywright
227+
)
294228
return el(
295229
Box,
296230
{ flexDirection: "column", marginLeft: 2, flexGrow: 1 },
@@ -299,22 +233,39 @@ const renderSelectDetailsBox = (
299233
}
300234

301235
export const renderSelect = (
302-
purpose: SelectPurpose,
303-
items: ReadonlyArray<ProjectItem>,
304-
selected: number,
305-
confirmDelete: boolean,
306-
message: string | null
236+
input: {
237+
readonly purpose: SelectPurpose
238+
readonly items: ReadonlyArray<ProjectItem>
239+
readonly selected: number
240+
readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
241+
readonly confirmDelete: boolean
242+
readonly connectEnableMcpPlaywright: boolean
243+
readonly message: string | null
244+
}
307245
): React.ReactElement => {
246+
const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input
308247
const el = React.createElement
309-
const listLabels = buildSelectLabels(items, selected)
248+
const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject)
310249
const listWidth = computeListWidth(listLabels)
311250
const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth)
312-
const detailsBox = renderSelectDetailsBox(el, purpose, items, selected)
313-
const baseHint = selectHint(purpose)
314-
const deleteHint = purpose === "Delete" && confirmDelete
315-
? "Confirm mode: Enter = delete now, Esc = cancel"
316-
: baseHint
317-
const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, deleteHint))
251+
const detailsBox = renderSelectDetailsBox(el, {
252+
purpose,
253+
items,
254+
selected,
255+
runtimeByProject,
256+
connectEnableMcpPlaywright
257+
})
258+
const baseHint = selectHint(purpose, connectEnableMcpPlaywright)
259+
const confirmHint = (() => {
260+
if (purpose === "Delete" && confirmDelete) {
261+
return "Confirm mode: Enter = delete now, Esc = cancel"
262+
}
263+
if (purpose === "Down" && confirmDelete) {
264+
return "Confirm mode: Enter = stop now, Esc = cancel"
265+
}
266+
return baseHint
267+
})()
268+
const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, confirmHint))
318269

319270
return renderLayout(
320271
selectTitle(purpose),

0 commit comments

Comments
 (0)