@prover-coder-ai/docker-git 1.0.11 → 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 +12 -0
- package/README.md +5 -1
- package/dist/main.js.map +1 -1
- package/dist/src/docker-git/cli/parser-mcp-playwright.js +18 -0
- package/dist/src/docker-git/cli/parser.js +3 -2
- package/dist/src/docker-git/cli/usage.js +2 -0
- 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/dist/src/docker-git/program.js +2 -1
- package/package.json +1 -1
- package/src/docker-git/cli/parser-mcp-playwright.ts +25 -0
- package/src/docker-git/cli/parser.ts +7 -3
- package/src/docker-git/cli/usage.ts +2 -0
- 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/src/docker-git/program.ts +5 -1
- package/tests/docker-git/menu-select-connect.test.ts +64 -0
- package/tests/docker-git/parser.test.ts +28 -0
|
@@ -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
|
+
}
|
|
@@ -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()
|