@prover-coder-ai/docker-git 1.0.12 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,14 @@ import { Box, Text } from "ink"
3
3
  import React from "react"
4
4
 
5
5
  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"
7
14
  import { createSteps, menuItems } from "./menu-types.js"
8
15
 
9
16
  // CHANGE: render menu views with Ink without JSX
@@ -168,91 +175,6 @@ export const renderCreate = (
168
175
  )
169
176
  }
170
177
 
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
-
256
178
  const computeListWidth = (labels: ReadonlyArray<string>): number => {
257
179
  const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
258
180
  return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
@@ -284,13 +206,25 @@ const renderSelectListBox = (
284
206
  )
285
207
  }
286
208
 
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
+
287
217
  const renderSelectDetailsBox = (
288
218
  el: typeof React.createElement,
289
- purpose: SelectPurpose,
290
- items: ReadonlyArray<ProjectItem>,
291
- selected: number
219
+ input: SelectDetailsBoxInput
292
220
  ): 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
+ )
294
228
  return el(
295
229
  Box,
296
230
  { flexDirection: "column", marginLeft: 2, flexGrow: 1 },
@@ -299,22 +233,39 @@ const renderSelectDetailsBox = (
299
233
  }
300
234
 
301
235
  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
+ }
307
245
  ): React.ReactElement => {
246
+ const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input
308
247
  const el = React.createElement
309
- const listLabels = buildSelectLabels(items, selected)
248
+ const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject)
310
249
  const listWidth = computeListWidth(listLabels)
311
250
  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))
318
269
 
319
270
  return renderLayout(
320
271
  selectTitle(purpose),
@@ -0,0 +1,27 @@
1
+ import { Effect } from "effect"
2
+
3
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
4
+
5
+ type ConnectDeps<E, R> = {
6
+ readonly connectWithUp: (
7
+ item: ProjectItem
8
+ ) => Effect.Effect<void, E, R>
9
+ readonly enableMcpPlaywright: (
10
+ projectDir: string
11
+ ) => Effect.Effect<void, E, R>
12
+ }
13
+
14
+ const normalizedInput = (input: string): string => input.trim().toLowerCase()
15
+
16
+ export const isConnectMcpToggleInput = (input: string): boolean => normalizedInput(input) === "p"
17
+
18
+ export const buildConnectEffect = <E, R>(
19
+ selected: ProjectItem,
20
+ enableMcpPlaywright: boolean,
21
+ deps: ConnectDeps<E, R>
22
+ ): Effect.Effect<void, E, R> =>
23
+ enableMcpPlaywright
24
+ ? deps.enableMcpPlaywright(selected.projectDir).pipe(
25
+ Effect.zipRight(deps.connectWithUp(selected))
26
+ )
27
+ : deps.connectWithUp(selected)
@@ -0,0 +1,94 @@
1
+ import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
2
+ import { runDockerPsNames } from "@effect-template/lib/shell/docker"
3
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
4
+ import { Effect, pipe } from "effect"
5
+
6
+ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js"
7
+
8
+ const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
9
+
10
+ const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
11
+
12
+ const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'"
13
+
14
+ const parseSshSessionCount = (raw: string): number => {
15
+ const parsed = Number.parseInt(raw.trim(), 10)
16
+ if (Number.isNaN(parsed) || parsed < 0) {
17
+ return 0
18
+ }
19
+ return parsed
20
+ }
21
+
22
+ const toRuntimeMap = (
23
+ entries: ReadonlyArray<readonly [string, SelectProjectRuntime]>
24
+ ): Readonly<Record<string, SelectProjectRuntime>> => {
25
+ const runtimeByProject: Record<string, SelectProjectRuntime> = {}
26
+ for (const [projectDir, runtime] of entries) {
27
+ runtimeByProject[projectDir] = runtime
28
+ }
29
+ return runtimeByProject
30
+ }
31
+
32
+ const countContainerSshSessions = (
33
+ containerName: string
34
+ ): Effect.Effect<number, never, MenuEnv> =>
35
+ pipe(
36
+ runCommandCapture(
37
+ {
38
+ cwd: process.cwd(),
39
+ command: "docker",
40
+ args: ["exec", containerName, "bash", "-lc", countSshSessionsScript]
41
+ },
42
+ [0],
43
+ (exitCode) => ({ _tag: "CommandFailedError", command: "docker exec who -u", exitCode })
44
+ ),
45
+ Effect.match({
46
+ onFailure: () => 0,
47
+ onSuccess: (raw) => parseSshSessionCount(raw)
48
+ })
49
+ )
50
+
51
+ // CHANGE: enrich select items with runtime state and SSH session counts
52
+ // WHY: prevent stopping/deleting containers that are currently used via SSH
53
+ // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
54
+ // REF: issue-47
55
+ // SOURCE: n/a
56
+ // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
57
+ // PURITY: SHELL
58
+ // EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
59
+ // INVARIANT: stopped containers always have sshSessions = 0
60
+ // COMPLEXITY: O(n + docker_ps + docker_exec)
61
+ export const loadRuntimeByProject = (
62
+ items: ReadonlyArray<ProjectItem>
63
+ ): Effect.Effect<Readonly<Record<string, SelectProjectRuntime>>, never, MenuEnv> =>
64
+ pipe(
65
+ runDockerPsNames(process.cwd()),
66
+ Effect.flatMap((runningNames) =>
67
+ Effect.forEach(
68
+ items,
69
+ (item) => {
70
+ const running = runningNames.includes(item.containerName)
71
+ if (!running) {
72
+ const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()]
73
+ return Effect.succeed(entry)
74
+ }
75
+ return pipe(
76
+ countContainerSshSessions(item.containerName),
77
+ Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })),
78
+ Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime])
79
+ )
80
+ },
81
+ { concurrency: 4 }
82
+ )
83
+ ),
84
+ Effect.map((entries) => toRuntimeMap(entries)),
85
+ Effect.match({
86
+ onFailure: () => emptyRuntimeByProject(),
87
+ onSuccess: (runtimeByProject) => runtimeByProject
88
+ })
89
+ )
90
+
91
+ export const runtimeForSelection = (
92
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
93
+ selected: ProjectItem
94
+ ): SelectProjectRuntime => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime()
@@ -1,5 +1,6 @@
1
1
  import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
2
  import type { AppError } from "@effect-template/lib/usecases/errors"
3
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
3
4
  import {
4
5
  connectProjectSshWithUp,
5
6
  deleteDockerGitProject,
@@ -9,19 +10,17 @@ import {
9
10
 
10
11
  import { Effect, Match, pipe } from "effect"
11
12
 
13
+ import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
14
+ import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
12
15
  import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
13
- import type { MenuEnv, MenuKeyInput, MenuRunner, MenuViewContext, ViewState } from "./menu-types.js"
14
-
15
- // CHANGE: handle project selection flow in TUI
16
- // WHY: allow selecting active project without manual typing
17
- // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?"
18
- // REF: user-request-2026-02-02-select-project
19
- // SOURCE: n/a
20
- // FORMAT THEOREM: forall p: select(p) -> activeDir(p)
21
- // PURITY: SHELL
22
- // EFFECT: Effect<void, never, never>
23
- // INVARIANT: selected index always within items length
24
- // COMPLEXITY: O(1) per keypress
16
+ import type {
17
+ MenuEnv,
18
+ MenuKeyInput,
19
+ MenuRunner,
20
+ MenuViewContext,
21
+ SelectProjectRuntime,
22
+ ViewState
23
+ } from "./menu-types.js"
25
24
 
26
25
  type SelectContext = MenuViewContext & {
27
26
  readonly activeDir: string | null
@@ -30,13 +29,24 @@ type SelectContext = MenuViewContext & {
30
29
  readonly setSkipInputs: (update: (value: number) => number) => void
31
30
  }
32
31
 
32
+ const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
33
+
33
34
  export const startSelectView = (
34
35
  items: ReadonlyArray<ProjectItem>,
35
36
  purpose: "Connect" | "Down" | "Info" | "Delete",
36
- context: Pick<SelectContext, "setView" | "setMessage">
37
+ context: Pick<SelectContext, "setView" | "setMessage">,
38
+ runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
37
39
  ) => {
38
40
  context.setMessage(null)
39
- context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false })
41
+ context.setView({
42
+ _tag: "SelectProject",
43
+ purpose,
44
+ items,
45
+ runtimeByProject,
46
+ selected: 0,
47
+ confirmDelete: false,
48
+ connectEnableMcpPlaywright: false
49
+ })
40
50
  }
41
51
 
42
52
  const clampIndex = (value: number, size: number): number => {
@@ -62,6 +72,9 @@ export const handleSelectInput = (
62
72
  resetToMenu(context)
63
73
  return
64
74
  }
75
+ if (handleConnectOptionToggle(input, view, context)) {
76
+ return
77
+ }
65
78
  if (handleSelectNavigation(key, view, context)) {
66
79
  return
67
80
  }
@@ -69,7 +82,27 @@ export const handleSelectInput = (
69
82
  handleSelectReturn(view, context)
70
83
  return
71
84
  }
72
- handleSelectHint(input, context)
85
+ if (input.trim().length > 0) {
86
+ context.setMessage("Use arrows + Enter to select a project, Esc to cancel.")
87
+ }
88
+ }
89
+
90
+ const handleConnectOptionToggle = (
91
+ input: string,
92
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
93
+ context: Pick<SelectContext, "setView" | "setMessage">
94
+ ): boolean => {
95
+ if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) {
96
+ return false
97
+ }
98
+ const nextValue = !view.connectEnableMcpPlaywright
99
+ context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false })
100
+ context.setMessage(
101
+ nextValue
102
+ ? "Playwright MCP will be enabled before SSH (press Enter to connect)."
103
+ : "Playwright MCP toggle is OFF (press Enter to connect without changes)."
104
+ )
105
+ return true
73
106
  }
74
107
 
75
108
  const handleSelectNavigation = (
@@ -116,12 +149,30 @@ const runWithSuspendedTui = (
116
149
  )
117
150
  }
118
151
 
119
- const runConnectSelection = (selected: ProjectItem, context: SelectContext) => {
120
- context.setMessage(`Connecting to ${selected.displayName}...`)
152
+ const runConnectSelection = (
153
+ selected: ProjectItem,
154
+ context: SelectContext,
155
+ enableMcpPlaywright: boolean
156
+ ) => {
157
+ context.setMessage(
158
+ enableMcpPlaywright
159
+ ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
160
+ : `Connecting to ${selected.displayName}...`
161
+ )
121
162
  context.setSshActive(true)
122
163
  runWithSuspendedTui(
123
164
  context,
124
- connectProjectSshWithUp(selected),
165
+ buildConnectEffect(selected, enableMcpPlaywright, {
166
+ connectWithUp: (item) =>
167
+ connectProjectSshWithUp(item).pipe(
168
+ Effect.mapError((error): AppError => error)
169
+ ),
170
+ enableMcpPlaywright: (projectDir) =>
171
+ mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
172
+ Effect.asVoid,
173
+ Effect.mapError((error): AppError => error)
174
+ )
175
+ }),
125
176
  () => {
126
177
  context.setSshActive(false)
127
178
  },
@@ -136,14 +187,20 @@ const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
136
187
  Effect.sync(suspendTui),
137
188
  Effect.zipRight(runDockerComposeDown(selected.projectDir)),
138
189
  Effect.zipRight(listRunningProjectItems),
139
- Effect.tap((items) =>
190
+ Effect.flatMap((items) =>
191
+ pipe(
192
+ loadRuntimeByProject(items),
193
+ Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
194
+ )
195
+ ),
196
+ Effect.tap(({ items, runtimeByProject }) =>
140
197
  Effect.sync(() => {
141
198
  if (items.length === 0) {
142
199
  resetToMenu(context)
143
200
  context.setMessage("No running docker-git containers.")
144
201
  return
145
202
  }
146
- startSelectView(items, "Down", context)
203
+ startSelectView(items, "Down", context, runtimeByProject)
147
204
  context.setMessage("Container stopped. Select another to stop, or Esc to return.")
148
205
  })
149
206
  ),
@@ -193,13 +250,24 @@ const handleSelectReturn = (
193
250
  resetToMenu(context)
194
251
  return
195
252
  }
253
+ const selectedRuntime = runtimeForSelection(view, selected)
254
+ const sshSessionsLabel = selectedRuntime.sshSessions === 1
255
+ ? "1 active SSH session"
256
+ : `${selectedRuntime.sshSessions} active SSH sessions`
196
257
 
197
258
  Match.value(view.purpose).pipe(
198
259
  Match.when("Connect", () => {
199
260
  context.setActiveDir(selected.projectDir)
200
- runConnectSelection(selected, context)
261
+ runConnectSelection(selected, context, view.connectEnableMcpPlaywright)
201
262
  }),
202
263
  Match.when("Down", () => {
264
+ if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
265
+ context.setMessage(
266
+ `${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`
267
+ )
268
+ context.setView({ ...view, confirmDelete: true })
269
+ return
270
+ }
203
271
  context.setActiveDir(selected.projectDir)
204
272
  runDownSelection(selected, context)
205
273
  }),
@@ -209,8 +277,9 @@ const handleSelectReturn = (
209
277
  }),
210
278
  Match.when("Delete", () => {
211
279
  if (!view.confirmDelete) {
280
+ const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : ""
212
281
  context.setMessage(
213
- `Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.`
282
+ `Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.`
214
283
  )
215
284
  context.setView({ ...view, confirmDelete: true })
216
285
  return
@@ -221,12 +290,6 @@ const handleSelectReturn = (
221
290
  )
222
291
  }
223
292
 
224
- const handleSelectHint = (input: string, context: SelectContext) => {
225
- if (input.trim().length > 0) {
226
- context.setMessage("Use arrows + Enter to select a project, Esc to cancel.")
227
- }
228
- }
229
-
230
293
  export const loadSelectView = <E>(
231
294
  effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
232
295
  purpose: "Connect" | "Down" | "Info" | "Delete",
@@ -235,16 +298,21 @@ export const loadSelectView = <E>(
235
298
  pipe(
236
299
  effect,
237
300
  Effect.flatMap((items) =>
238
- Effect.sync(() => {
239
- if (items.length === 0) {
240
- context.setMessage(
241
- purpose === "Down"
242
- ? "No running docker-git containers."
243
- : "No docker-git projects found."
244
- )
245
- return
246
- }
247
- startSelectView(items, purpose, context)
248
- })
301
+ pipe(
302
+ loadRuntimeByProject(items),
303
+ Effect.flatMap((runtimeByProject) =>
304
+ Effect.sync(() => {
305
+ if (items.length === 0) {
306
+ context.setMessage(
307
+ purpose === "Down"
308
+ ? "No running docker-git containers."
309
+ : "No docker-git projects found."
310
+ )
311
+ return
312
+ }
313
+ startSelectView(items, purpose, context, runtimeByProject)
314
+ })
315
+ )
316
+ )
249
317
  )
250
318
  )
@@ -77,10 +77,17 @@ export type ViewState =
77
77
  readonly _tag: "SelectProject"
78
78
  readonly purpose: "Connect" | "Down" | "Info" | "Delete"
79
79
  readonly items: ReadonlyArray<ProjectItem>
80
+ readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
80
81
  readonly selected: number
81
82
  readonly confirmDelete: boolean
83
+ readonly connectEnableMcpPlaywright: boolean
82
84
  }
83
85
 
86
+ export type SelectProjectRuntime = {
87
+ readonly running: boolean
88
+ readonly sshSessions: number
89
+ }
90
+
84
91
  export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
85
92
  { id: { _tag: "Create" }, label: "Create project" },
86
93
  { id: { _tag: "Select" }, label: "Select project" },
@@ -185,13 +185,15 @@ const renderView = (context: RenderContext) => {
185
185
  return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults)
186
186
  }
187
187
 
188
- return renderSelect(
189
- context.view.purpose,
190
- context.view.items,
191
- context.view.selected,
192
- context.view.confirmDelete,
193
- context.message
194
- )
188
+ return renderSelect({
189
+ purpose: context.view.purpose,
190
+ items: context.view.items,
191
+ selected: context.view.selected,
192
+ runtimeByProject: context.view.runtimeByProject,
193
+ confirmDelete: context.view.confirmDelete,
194
+ connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright,
195
+ message: context.message
196
+ })
195
197
  }
196
198
 
197
199
  const useMenuState = () => {
@@ -0,0 +1,64 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+
4
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
5
+ import { selectHint } from "../../src/docker-git/menu-render-select.js"
6
+ import { buildConnectEffect, isConnectMcpToggleInput } from "../../src/docker-git/menu-select-connect.js"
7
+
8
+ const makeProjectItem = (): ProjectItem => ({
9
+ projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo",
10
+ displayName: "org/repo",
11
+ repoUrl: "https://github.com/org/repo.git",
12
+ repoRef: "main",
13
+ containerName: "dg-repo",
14
+ serviceName: "dg-repo",
15
+ sshUser: "dev",
16
+ sshPort: 2222,
17
+ targetDir: "/home/dev/org/repo",
18
+ sshCommand: "ssh -p 2222 dev@localhost",
19
+ sshKeyPath: null,
20
+ authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys",
21
+ authorizedKeysExists: true,
22
+ envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env",
23
+ envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env",
24
+ codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex",
25
+ codexHome: "/home/dev/.codex"
26
+ })
27
+
28
+ const record = (events: Array<string>, entry: string): Effect.Effect<void> =>
29
+ Effect.sync(() => {
30
+ events.push(entry)
31
+ })
32
+
33
+ const makeConnectDeps = (events: Array<string>) => ({
34
+ connectWithUp: (selected: ProjectItem) => record(events, `connect:${selected.projectDir}`),
35
+ enableMcpPlaywright: (projectDir: string) => record(events, `enable:${projectDir}`)
36
+ })
37
+
38
+ describe("menu-select-connect", () => {
39
+ it("runs Playwright enable before SSH when toggle is ON", () => {
40
+ const item = makeProjectItem()
41
+ const events: Array<string> = []
42
+ Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events)))
43
+ expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`])
44
+ })
45
+
46
+ it("skips Playwright enable when toggle is OFF", () => {
47
+ const item = makeProjectItem()
48
+ const events: Array<string> = []
49
+ Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events)))
50
+ expect(events).toEqual([`connect:${item.projectDir}`])
51
+ })
52
+
53
+ it("parses connect toggle key from user input", () => {
54
+ expect(isConnectMcpToggleInput("p")).toBe(true)
55
+ expect(isConnectMcpToggleInput(" P ")).toBe(true)
56
+ expect(isConnectMcpToggleInput("x")).toBe(false)
57
+ expect(isConnectMcpToggleInput("")).toBe(false)
58
+ })
59
+
60
+ it("renders connect hint with current Playwright toggle state", () => {
61
+ expect(selectHint("Connect", true)).toContain("toggle Playwright MCP (on)")
62
+ expect(selectHint("Connect", false)).toContain("toggle Playwright MCP (off)")
63
+ })
64
+ })