@prover-coder-ai/docker-git 1.0.5

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.
Files changed (72) hide show
  1. package/.jscpd.json +16 -0
  2. package/.package.json.release.bak +109 -0
  3. package/CHANGELOG.md +31 -0
  4. package/README.md +173 -0
  5. package/biome.json +34 -0
  6. package/dist/main.js +847 -0
  7. package/dist/main.js.map +1 -0
  8. package/dist/src/app/main.js +15 -0
  9. package/dist/src/app/program.js +61 -0
  10. package/dist/src/docker-git/cli/input.js +21 -0
  11. package/dist/src/docker-git/cli/parser-attach.js +19 -0
  12. package/dist/src/docker-git/cli/parser-auth.js +70 -0
  13. package/dist/src/docker-git/cli/parser-clone.js +40 -0
  14. package/dist/src/docker-git/cli/parser-create.js +1 -0
  15. package/dist/src/docker-git/cli/parser-options.js +101 -0
  16. package/dist/src/docker-git/cli/parser-panes.js +19 -0
  17. package/dist/src/docker-git/cli/parser-sessions.js +69 -0
  18. package/dist/src/docker-git/cli/parser-shared.js +26 -0
  19. package/dist/src/docker-git/cli/parser-state.js +62 -0
  20. package/dist/src/docker-git/cli/parser.js +42 -0
  21. package/dist/src/docker-git/cli/read-command.js +17 -0
  22. package/dist/src/docker-git/cli/usage.js +99 -0
  23. package/dist/src/docker-git/main.js +15 -0
  24. package/dist/src/docker-git/menu-actions.js +115 -0
  25. package/dist/src/docker-git/menu-create.js +203 -0
  26. package/dist/src/docker-git/menu-input.js +2 -0
  27. package/dist/src/docker-git/menu-menu.js +46 -0
  28. package/dist/src/docker-git/menu-render.js +151 -0
  29. package/dist/src/docker-git/menu-select.js +131 -0
  30. package/dist/src/docker-git/menu-shared.js +111 -0
  31. package/dist/src/docker-git/menu-types.js +19 -0
  32. package/dist/src/docker-git/menu.js +237 -0
  33. package/dist/src/docker-git/program.js +38 -0
  34. package/dist/src/docker-git/tmux.js +176 -0
  35. package/eslint.config.mts +305 -0
  36. package/eslint.effect-ts-check.config.mjs +220 -0
  37. package/linter.config.json +33 -0
  38. package/package.json +63 -0
  39. package/src/app/main.ts +18 -0
  40. package/src/app/program.ts +75 -0
  41. package/src/docker-git/cli/input.ts +29 -0
  42. package/src/docker-git/cli/parser-attach.ts +22 -0
  43. package/src/docker-git/cli/parser-auth.ts +124 -0
  44. package/src/docker-git/cli/parser-clone.ts +55 -0
  45. package/src/docker-git/cli/parser-create.ts +3 -0
  46. package/src/docker-git/cli/parser-options.ts +152 -0
  47. package/src/docker-git/cli/parser-panes.ts +22 -0
  48. package/src/docker-git/cli/parser-sessions.ts +101 -0
  49. package/src/docker-git/cli/parser-shared.ts +51 -0
  50. package/src/docker-git/cli/parser-state.ts +86 -0
  51. package/src/docker-git/cli/parser.ts +73 -0
  52. package/src/docker-git/cli/read-command.ts +26 -0
  53. package/src/docker-git/cli/usage.ts +112 -0
  54. package/src/docker-git/main.ts +18 -0
  55. package/src/docker-git/menu-actions.ts +246 -0
  56. package/src/docker-git/menu-create.ts +320 -0
  57. package/src/docker-git/menu-input.ts +2 -0
  58. package/src/docker-git/menu-menu.ts +58 -0
  59. package/src/docker-git/menu-render.ts +327 -0
  60. package/src/docker-git/menu-select.ts +250 -0
  61. package/src/docker-git/menu-shared.ts +141 -0
  62. package/src/docker-git/menu-types.ts +94 -0
  63. package/src/docker-git/menu.ts +339 -0
  64. package/src/docker-git/program.ts +134 -0
  65. package/src/docker-git/tmux.ts +292 -0
  66. package/tests/app/main.test.ts +60 -0
  67. package/tests/docker-git/entrypoint-auth.test.ts +29 -0
  68. package/tests/docker-git/parser.test.ts +172 -0
  69. package/tsconfig.build.json +8 -0
  70. package/tsconfig.json +20 -0
  71. package/vite.config.ts +32 -0
  72. package/vitest.config.ts +85 -0
@@ -0,0 +1,320 @@
1
+ import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
2
+ import { createProject } from "@effect-template/lib/usecases/actions"
3
+ import type { AppError } from "@effect-template/lib/usecases/errors"
4
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
5
+ import * as Path from "@effect/platform/Path"
6
+ import { Effect, Either, Match, pipe } from "effect"
7
+ import { parseArgs } from "./cli/parser.js"
8
+ import { formatParseError, usageText } from "./cli/usage.js"
9
+
10
+ import { resetToMenu } from "./menu-shared.js"
11
+ import {
12
+ type CreateInputs,
13
+ type CreateStep,
14
+ createSteps,
15
+ type MenuEnv,
16
+ type MenuState,
17
+ type ViewState
18
+ } from "./menu-types.js"
19
+
20
+ // CHANGE: move create-flow handling into a dedicated module
21
+ // WHY: keep TUI entry slim and satisfy lint constraints
22
+ // QUOTE(ТЗ): "TUI? Красивый, удобный"
23
+ // REF: user-request-2026-02-01-tui
24
+ // SOURCE: n/a
25
+ // FORMAT THEOREM: forall s: step(s) -> step'(s)
26
+ // PURITY: SHELL
27
+ // EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
28
+ // INVARIANT: outDir resolves to a stable repo path
29
+ // COMPLEXITY: O(1) per keypress
30
+
31
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] }
32
+
33
+ type CreateRunner = { readonly runEffect: (effect: Effect.Effect<void, AppError, MenuEnv>) => void }
34
+
35
+ type CreateContext = {
36
+ readonly state: MenuState
37
+ readonly setView: (view: ViewState) => void
38
+ readonly setMessage: (message: string | null) => void
39
+ readonly runner: CreateRunner
40
+ readonly setActiveDir: (dir: string | null) => void
41
+ }
42
+
43
+ type CreateReturnContext = CreateContext & {
44
+ readonly view: Extract<ViewState, { readonly _tag: "Create" }>
45
+ }
46
+
47
+ export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
48
+ const args: Array<string> = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot]
49
+ if (input.repoRef.length > 0) {
50
+ args.push("--repo-ref", input.repoRef)
51
+ }
52
+ args.push("--out-dir", input.outDir)
53
+ if (!input.runUp) {
54
+ args.push("--no-up")
55
+ }
56
+ if (input.enableMcpPlaywright) {
57
+ args.push("--mcp-playwright")
58
+ }
59
+ if (input.force) {
60
+ args.push("--force")
61
+ }
62
+ if (input.forceEnv) {
63
+ args.push("--force-env")
64
+ }
65
+ return args
66
+ }
67
+
68
+ const trimLeftSlash = (value: string): string => {
69
+ let start = 0
70
+ while (start < value.length && value[start] === "/") {
71
+ start += 1
72
+ }
73
+ return value.slice(start)
74
+ }
75
+
76
+ const trimRightSlash = (value: string): string => {
77
+ let end = value.length
78
+ while (end > 0 && value[end - 1] === "/") {
79
+ end -= 1
80
+ }
81
+ return value.slice(0, end)
82
+ }
83
+
84
+ const joinPath = (...parts: ReadonlyArray<string>): string => {
85
+ const cleaned = parts
86
+ .filter((part) => part.length > 0)
87
+ .map((part, index) => {
88
+ if (index === 0) {
89
+ return trimRightSlash(part)
90
+ }
91
+ return trimRightSlash(trimLeftSlash(part))
92
+ })
93
+ return cleaned.join("/")
94
+ }
95
+
96
+ const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
97
+ const resolvedRepo = resolveRepoInput(repoUrl)
98
+ const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
99
+ const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
100
+ return joinPath(defaultProjectsRoot(cwd), ...projectParts)
101
+ }
102
+
103
+ export const resolveCreateInputs = (
104
+ cwd: string,
105
+ values: Partial<CreateInputs>
106
+ ): CreateInputs => {
107
+ const repoUrl = values.repoUrl ?? ""
108
+ const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
109
+ const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
110
+ const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
111
+
112
+ return {
113
+ repoUrl,
114
+ repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
115
+ outDir,
116
+ secretsRoot,
117
+ runUp: values.runUp !== false,
118
+ enableMcpPlaywright: values.enableMcpPlaywright === true,
119
+ force: values.force === true,
120
+ forceEnv: values.forceEnv === true
121
+ }
122
+ }
123
+
124
+ const parseYesDefault = (input: string, fallback: boolean): boolean => {
125
+ const normalized = input.trim().toLowerCase()
126
+ if (normalized === "y" || normalized === "yes") {
127
+ return true
128
+ }
129
+ if (normalized === "n" || normalized === "no") {
130
+ return false
131
+ }
132
+ return fallback
133
+ }
134
+
135
+ const applyCreateCommand = (
136
+ state: MenuState,
137
+ create: CreateCommand
138
+ ): Effect.Effect<{ readonly _tag: "Continue"; readonly state: MenuState }, AppError, MenuEnv> =>
139
+ Effect.gen(function*(_) {
140
+ const path = yield* _(Path.Path)
141
+ const resolvedOutDir = path.resolve(create.outDir)
142
+ yield* _(createProject(create))
143
+ return { _tag: "Continue", state: { ...state, activeDir: resolvedOutDir } }
144
+ })
145
+
146
+ const isCreateCommand = (command: { readonly _tag: string }): command is CreateCommand => command._tag === "Create"
147
+
148
+ const buildCreateEffect = (
149
+ command: { readonly _tag: string },
150
+ state: MenuState,
151
+ setActiveDir: (dir: string | null) => void,
152
+ setMessage: (message: string | null) => void
153
+ ): Effect.Effect<void, AppError, MenuEnv> => {
154
+ if (isCreateCommand(command)) {
155
+ return pipe(
156
+ applyCreateCommand(state, command),
157
+ Effect.tap((outcome) =>
158
+ Effect.sync(() => {
159
+ setActiveDir(outcome.state.activeDir)
160
+ })
161
+ ),
162
+ Effect.asVoid
163
+ )
164
+ }
165
+ if (command._tag === "Help") {
166
+ return Effect.sync(() => {
167
+ setMessage(usageText)
168
+ })
169
+ }
170
+ return Effect.void
171
+ }
172
+
173
+ const applyCreateStep = (input: {
174
+ readonly step: CreateStep
175
+ readonly buffer: string
176
+ readonly currentDefaults: CreateInputs
177
+ readonly nextValues: Partial<Mutable<CreateInputs>>
178
+ readonly cwd: string
179
+ readonly setMessage: (message: string | null) => void
180
+ }): boolean =>
181
+ Match.value(input.step).pipe(
182
+ Match.when("repoUrl", () => {
183
+ if (input.buffer.length === 0) {
184
+ input.setMessage("Repo URL is required.")
185
+ return false
186
+ }
187
+ input.nextValues.repoUrl = input.buffer
188
+ input.nextValues.outDir = resolveDefaultOutDir(input.cwd, input.buffer)
189
+ return true
190
+ }),
191
+ Match.when("repoRef", () => {
192
+ input.nextValues.repoRef = input.buffer.length > 0 ? input.buffer : input.currentDefaults.repoRef
193
+ return true
194
+ }),
195
+ Match.when("outDir", () => {
196
+ input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir
197
+ return true
198
+ }),
199
+ Match.when("runUp", () => {
200
+ input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp)
201
+ return true
202
+ }),
203
+ Match.when("mcpPlaywright", () => {
204
+ input.nextValues.enableMcpPlaywright = parseYesDefault(
205
+ input.buffer,
206
+ input.currentDefaults.enableMcpPlaywright
207
+ )
208
+ return true
209
+ }),
210
+ Match.when("force", () => {
211
+ input.nextValues.force = parseYesDefault(input.buffer, input.currentDefaults.force)
212
+ return true
213
+ }),
214
+ Match.exhaustive
215
+ )
216
+
217
+ const finalizeCreateFlow = (input: {
218
+ readonly state: MenuState
219
+ readonly nextValues: Partial<CreateInputs>
220
+ readonly setView: (view: ViewState) => void
221
+ readonly setMessage: (message: string | null) => void
222
+ readonly runner: CreateRunner
223
+ readonly setActiveDir: (dir: string | null) => void
224
+ }) => {
225
+ const inputs = resolveCreateInputs(input.state.cwd, input.nextValues)
226
+ if (inputs.repoUrl.length === 0) {
227
+ input.setMessage("Repo URL is required.")
228
+ return
229
+ }
230
+
231
+ const parsed = parseArgs(buildCreateArgs(inputs))
232
+ if (Either.isLeft(parsed)) {
233
+ input.setMessage(formatParseError(parsed.left))
234
+ input.setView({ _tag: "Menu" })
235
+ return
236
+ }
237
+
238
+ const effect = buildCreateEffect(parsed.right, input.state, input.setActiveDir, input.setMessage)
239
+ input.runner.runEffect(effect)
240
+ input.setView({ _tag: "Menu" })
241
+ input.setMessage(null)
242
+ }
243
+
244
+ const handleCreateReturn = (context: CreateReturnContext) => {
245
+ const step = createSteps[context.view.step]
246
+ if (!step) {
247
+ context.setView({ _tag: "Menu" })
248
+ return
249
+ }
250
+
251
+ const buffer = context.view.buffer.trim()
252
+ const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values)
253
+ const nextValues: Partial<Mutable<CreateInputs>> = { ...context.view.values }
254
+ const updated = applyCreateStep({
255
+ step,
256
+ buffer,
257
+ currentDefaults,
258
+ nextValues,
259
+ cwd: context.state.cwd,
260
+ setMessage: context.setMessage
261
+ })
262
+ if (!updated) {
263
+ return
264
+ }
265
+
266
+ const nextStep = context.view.step + 1
267
+ if (nextStep < createSteps.length) {
268
+ context.setView({ _tag: "Create", step: nextStep, buffer: "", values: nextValues })
269
+ context.setMessage(null)
270
+ return
271
+ }
272
+
273
+ finalizeCreateFlow({
274
+ state: context.state,
275
+ nextValues,
276
+ setView: context.setView,
277
+ setMessage: context.setMessage,
278
+ runner: context.runner,
279
+ setActiveDir: context.setActiveDir
280
+ })
281
+ }
282
+
283
+ export const startCreateView = (
284
+ setView: (view: ViewState) => void,
285
+ setMessage: (message: string | null) => void,
286
+ buffer = ""
287
+ ) => {
288
+ setView({ _tag: "Create", step: 0, buffer, values: {} })
289
+ setMessage(null)
290
+ }
291
+
292
+ export const handleCreateInput = (
293
+ input: string,
294
+ key: {
295
+ readonly escape?: boolean
296
+ readonly return?: boolean
297
+ readonly backspace?: boolean
298
+ readonly delete?: boolean
299
+ },
300
+ view: Extract<ViewState, { readonly _tag: "Create" }>,
301
+ context: CreateContext
302
+ ) => {
303
+ if (key.escape) {
304
+ resetToMenu(context)
305
+ return
306
+ }
307
+ if (key.return) {
308
+ handleCreateReturn({ ...context, view })
309
+ return
310
+ }
311
+
312
+ if (key.backspace || key.delete) {
313
+ context.setView({ ...view, buffer: view.buffer.slice(0, -1) })
314
+ return
315
+ }
316
+
317
+ if (input.length > 0) {
318
+ context.setView({ ...view, buffer: view.buffer + input })
319
+ }
320
+ }
@@ -0,0 +1,2 @@
1
+ export { buildCreateArgs, handleCreateInput, resolveCreateInputs, startCreateView } from "./menu-create.js"
2
+ export { handleMenuInput } from "./menu-menu.js"
@@ -0,0 +1,58 @@
1
+ import { parseMenuSelection } from "@effect-template/lib/core/domain"
2
+ import { isRepoUrlInput } from "@effect-template/lib/usecases/menu-helpers"
3
+ import { Either } from "effect"
4
+
5
+ import { handleMenuActionSelection, type MenuSelectionContext } from "./menu-actions.js"
6
+ import { startCreateView } from "./menu-create.js"
7
+ import { menuItems } from "./menu-types.js"
8
+
9
+ const handleMenuNavigation = (
10
+ key: { readonly upArrow?: boolean; readonly downArrow?: boolean },
11
+ setSelected: (update: (value: number) => number) => void
12
+ ) => {
13
+ if (key.upArrow) {
14
+ setSelected((prev) => (prev === 0 ? menuItems.length - 1 : prev - 1))
15
+ return
16
+ }
17
+ if (key.downArrow) {
18
+ setSelected((prev) => (prev === menuItems.length - 1 ? 0 : prev + 1))
19
+ }
20
+ }
21
+
22
+ const handleMenuEnter = (context: MenuSelectionContext) => {
23
+ const action = menuItems[context.selected]?.id
24
+ if (!action) {
25
+ return
26
+ }
27
+ handleMenuActionSelection(action, context)
28
+ }
29
+
30
+ const handleMenuTextInput = (input: string, context: MenuSelectionContext): boolean => {
31
+ const trimmed = input.trim()
32
+ if (trimmed.length > 0 && isRepoUrlInput(trimmed)) {
33
+ startCreateView(context.setView, context.setMessage, trimmed)
34
+ return true
35
+ }
36
+ const selection = parseMenuSelection(input)
37
+ if (Either.isRight(selection)) {
38
+ handleMenuActionSelection(selection.right, context)
39
+ return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ export const handleMenuInput = (
45
+ input: string,
46
+ key: { readonly upArrow?: boolean; readonly downArrow?: boolean; readonly return?: boolean },
47
+ context: MenuSelectionContext
48
+ ) => {
49
+ if (key.upArrow || key.downArrow) {
50
+ handleMenuNavigation(key, context.setSelected)
51
+ return
52
+ }
53
+ if (key.return) {
54
+ handleMenuEnter(context)
55
+ return
56
+ }
57
+ handleMenuTextInput(input, context)
58
+ }
@@ -0,0 +1,327 @@
1
+ import { Match } from "effect"
2
+ import { Box, Text } from "ink"
3
+ import React from "react"
4
+
5
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
6
+ import type { CreateInputs, CreateStep } from "./menu-types.js"
7
+ import { createSteps, menuItems } from "./menu-types.js"
8
+
9
+ // CHANGE: render menu views with Ink without JSX
10
+ // WHY: keep UI logic separate from input/state reducers
11
+ // QUOTE(ТЗ): "TUI? Красивый, удобный"
12
+ // REF: user-request-2026-02-01-tui
13
+ // SOURCE: n/a
14
+ // FORMAT THEOREM: forall v: view(v) -> render(v)
15
+ // PURITY: SHELL
16
+ // EFFECT: n/a
17
+ // INVARIANT: menu renders all items once
18
+ // COMPLEXITY: O(n)
19
+
20
+ export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): string =>
21
+ Match.value(step).pipe(
22
+ Match.when("repoUrl", () => "Repo URL"),
23
+ Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
24
+ Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
25
+ Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),
26
+ Match.when(
27
+ "mcpPlaywright",
28
+ () => `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]`
29
+ ),
30
+ Match.when(
31
+ "force",
32
+ () => `Force recreate (overwrite files + wipe volumes)? [${defaults.force ? "y" : "N"}]`
33
+ ),
34
+ Match.exhaustive
35
+ )
36
+
37
+ const renderMessage = (message: string | null): React.ReactElement | null => {
38
+ if (!message) {
39
+ return null
40
+ }
41
+ return React.createElement(
42
+ Box,
43
+ { marginTop: 1 },
44
+ React.createElement(Text, { color: "magenta" }, message)
45
+ )
46
+ }
47
+
48
+ const renderLayout = (
49
+ title: string,
50
+ body: ReadonlyArray<React.ReactElement>,
51
+ message: string | null
52
+ ): React.ReactElement => {
53
+ const el = React.createElement
54
+ const messageView = renderMessage(message)
55
+ const tail = messageView ? [messageView] : []
56
+ return el(
57
+ Box,
58
+ { flexDirection: "column", padding: 1, borderStyle: "round" },
59
+ el(Text, { color: "cyan", bold: true }, title),
60
+ ...body,
61
+ ...tail
62
+ )
63
+ }
64
+
65
+ const compactElements = (
66
+ items: ReadonlyArray<React.ReactElement | null>
67
+ ): ReadonlyArray<React.ReactElement> => items.filter((item): item is React.ReactElement => item !== null)
68
+
69
+ const renderMenuHints = (el: typeof React.createElement): React.ReactElement =>
70
+ el(
71
+ Box,
72
+ { marginTop: 1, flexDirection: "column" },
73
+ el(Text, { color: "gray" }, "Hints:"),
74
+ el(Text, { color: "gray" }, " - Paste repo URL to create directly."),
75
+ el(
76
+ Text,
77
+ { color: "gray" },
78
+ " - Aliases: create/c, select/s, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"
79
+ ),
80
+ el(Text, { color: "gray" }, " - Use arrows and Enter to run.")
81
+ )
82
+
83
+ const renderMenuMessage = (
84
+ el: typeof React.createElement,
85
+ message: string | null
86
+ ): React.ReactElement | null => {
87
+ if (!message || message.length === 0) {
88
+ return null
89
+ }
90
+ return el(
91
+ Box,
92
+ { marginTop: 1, flexDirection: "column" },
93
+ ...message
94
+ .split("\n")
95
+ .map((line, index) => el(Text, { key: `${index}-${line}`, color: "magenta" }, line))
96
+ )
97
+ }
98
+
99
+ export const renderMenu = (
100
+ cwd: string,
101
+ activeDir: string | null,
102
+ selected: number,
103
+ busy: boolean,
104
+ message: string | null
105
+ ): React.ReactElement => {
106
+ const el = React.createElement
107
+ const activeLabel = `Active: ${activeDir ?? "(none)"}`
108
+ const cwdLabel = `CWD: ${cwd}`
109
+ const items = menuItems.map((item, index) => {
110
+ const indexLabel = `${index + 1})`
111
+ const prefix = index === selected ? ">" : " "
112
+ return el(
113
+ Text,
114
+ { key: item.label, color: index === selected ? "green" : "white" },
115
+ `${prefix} ${indexLabel} ${item.label}`
116
+ )
117
+ })
118
+
119
+ const busyView = busy
120
+ ? el(Box, { marginTop: 1 }, el(Text, { color: "yellow" }, "Running..."))
121
+ : null
122
+
123
+ const messageView = renderMenuMessage(el, message)
124
+ const hints = renderMenuHints(el)
125
+
126
+ return renderLayout(
127
+ "docker-git",
128
+ compactElements([
129
+ el(Text, null, activeLabel),
130
+ el(Text, null, cwdLabel),
131
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
132
+ hints,
133
+ busyView,
134
+ messageView
135
+ ]),
136
+ null
137
+ )
138
+ }
139
+
140
+ export const renderCreate = (
141
+ label: string,
142
+ buffer: string,
143
+ message: string | null,
144
+ stepIndex: number,
145
+ defaults: CreateInputs
146
+ ): React.ReactElement => {
147
+ const el = React.createElement
148
+ const steps = createSteps.map((step, index) =>
149
+ el(
150
+ Text,
151
+ { key: step, color: index === stepIndex ? "green" : "gray" },
152
+ `${index === stepIndex ? ">" : " "} ${renderStepLabel(step, defaults)}`
153
+ )
154
+ )
155
+ return renderLayout(
156
+ "docker-git / Create",
157
+ [
158
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...steps),
159
+ el(
160
+ Box,
161
+ { marginTop: 1 },
162
+ el(Text, null, `${label}: `),
163
+ el(Text, { color: "green" }, buffer)
164
+ ),
165
+ el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
166
+ ],
167
+ message
168
+ )
169
+ }
170
+
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
+ const computeListWidth = (labels: ReadonlyArray<string>): number => {
257
+ const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
258
+ return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
259
+ }
260
+
261
+ const renderSelectListBox = (
262
+ el: typeof React.createElement,
263
+ items: ReadonlyArray<ProjectItem>,
264
+ selected: number,
265
+ labels: ReadonlyArray<string>,
266
+ width: number
267
+ ): React.ReactElement => {
268
+ const list = labels.map((label, index) =>
269
+ el(
270
+ Text,
271
+ {
272
+ key: items[index]?.projectDir ?? String(index),
273
+ color: index === selected ? "green" : "white",
274
+ wrap: "truncate"
275
+ },
276
+ label
277
+ )
278
+ )
279
+
280
+ return el(
281
+ Box,
282
+ { flexDirection: "column", width },
283
+ ...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")])
284
+ )
285
+ }
286
+
287
+ const renderSelectDetailsBox = (
288
+ el: typeof React.createElement,
289
+ purpose: SelectPurpose,
290
+ items: ReadonlyArray<ProjectItem>,
291
+ selected: number
292
+ ): React.ReactElement => {
293
+ const details = renderSelectDetails(el, purpose, items[selected])
294
+ return el(
295
+ Box,
296
+ { flexDirection: "column", marginLeft: 2, flexGrow: 1 },
297
+ ...details
298
+ )
299
+ }
300
+
301
+ export const renderSelect = (
302
+ purpose: SelectPurpose,
303
+ items: ReadonlyArray<ProjectItem>,
304
+ selected: number,
305
+ confirmDelete: boolean,
306
+ message: string | null
307
+ ): React.ReactElement => {
308
+ const el = React.createElement
309
+ const listLabels = buildSelectLabels(items, selected)
310
+ const listWidth = computeListWidth(listLabels)
311
+ 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))
318
+
319
+ return renderLayout(
320
+ selectTitle(purpose),
321
+ [
322
+ el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
323
+ hints
324
+ ],
325
+ message
326
+ )
327
+ }