@prover-coder-ai/docker-git 1.0.12 → 1.0.13
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.
- package/.package.json.release.bak +1 -1
- package/CHANGELOG.md +6 -0
- package/dist/main.js.map +1 -1
- package/dist/src/docker-git/menu-render-select.js +93 -0
- package/dist/src/docker-git/menu-render.js +24 -60
- package/dist/src/docker-git/menu-select-connect.js +6 -0
- package/dist/src/docker-git/menu-select-runtime.js +50 -0
- package/dist/src/docker-git/menu-select.js +56 -18
- package/dist/src/docker-git/menu.js +9 -1
- package/package.json +1 -1
- package/src/docker-git/menu-render-select.ts +187 -0
- package/src/docker-git/menu-render.ts +53 -102
- package/src/docker-git/menu-select-connect.ts +27 -0
- package/src/docker-git/menu-select-runtime.ts +94 -0
- package/src/docker-git/menu-select.ts +107 -39
- package/src/docker-git/menu-types.ts +7 -0
- package/src/docker-git/menu.ts +9 -7
- package/tests/docker-git/menu-select-connect.test.ts +64 -0
|
@@ -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
|
|
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
|
-
|
|
290
|
-
items: ReadonlyArray<ProjectItem>,
|
|
291
|
-
selected: number
|
|
219
|
+
input: SelectDetailsBoxInput
|
|
292
220
|
): React.ReactElement => {
|
|
293
|
-
const details = renderSelectDetails(
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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,
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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({
|
|
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
|
-
|
|
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 = (
|
|
120
|
-
|
|
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
|
-
|
|
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.
|
|
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}
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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" },
|
package/src/docker-git/menu.ts
CHANGED
|
@@ -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.
|
|
193
|
-
context.
|
|
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
|
+
})
|