@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.
@@ -0,0 +1,93 @@
1
+ import { Match } from "effect";
2
+ import { Text } from "ink";
3
+ const formatRepoRef = (repoRef) => {
4
+ const trimmed = repoRef.trim();
5
+ const prPrefix = "refs/pull/";
6
+ if (trimmed.startsWith(prPrefix)) {
7
+ const rest = trimmed.slice(prPrefix.length);
8
+ const number = rest.split("/")[0] ?? rest;
9
+ return `PR#${number}`;
10
+ }
11
+ return trimmed.length > 0 ? trimmed : "main";
12
+ };
13
+ const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
14
+ const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
15
+ const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`;
16
+ export const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
17
+ export const selectHint = (purpose, connectEnableMcpPlaywright) => Match.value(purpose).pipe(Match.when("Connect", () => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`), Match.when("Down", () => "Enter = stop container, Esc = back"), Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), Match.exhaustive);
18
+ export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
19
+ const prefix = index === selected ? ">" : " ";
20
+ const refLabel = formatRepoRef(item.repoRef);
21
+ const runtimeSuffix = purpose === "Down" || purpose === "Delete"
22
+ ? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
23
+ : "";
24
+ return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`;
25
+ });
26
+ const buildDetailsContext = (item, runtimeByProject) => {
27
+ const runtime = runtimeForProject(runtimeByProject, item);
28
+ return {
29
+ item,
30
+ refLabel: formatRepoRef(item.repoRef),
31
+ authSuffix: item.authorizedKeysExists ? "" : " (missing)",
32
+ runtime,
33
+ sshSessionsLabel: runtime.sshSessions === 1
34
+ ? "1 active SSH session"
35
+ : `${runtime.sshSessions} active SSH sessions`
36
+ };
37
+ };
38
+ const titleRow = (el, value) => el(Text, { color: "cyan", bold: true, wrap: "truncate" }, value);
39
+ const commonRows = (el, context) => [
40
+ el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
41
+ el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
42
+ el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
43
+ el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
44
+ ];
45
+ const renderInfoDetails = (el, context, common) => [
46
+ titleRow(el, "Connection info"),
47
+ ...common,
48
+ el(Text, { wrap: "wrap" }, `Service: ${context.item.serviceName}`),
49
+ el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`),
50
+ el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
51
+ el(Text, { wrap: "wrap" }, `Workspace: ${context.item.targetDir}`),
52
+ el(Text, { wrap: "wrap" }, `Authorized keys: ${context.item.authorizedKeysPath}${context.authSuffix}`),
53
+ el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
54
+ el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
55
+ el(Text, { wrap: "wrap" }, `Codex auth: ${context.item.codexAuthPath} -> ${context.item.codexHome}`)
56
+ ];
57
+ const renderDefaultDetails = (el, context) => [
58
+ titleRow(el, "Details"),
59
+ el(Text, { wrap: "truncate" }, `Repo: ${context.item.repoUrl}`),
60
+ el(Text, { wrap: "truncate" }, `Ref: ${context.item.repoRef}`),
61
+ el(Text, { wrap: "truncate" }, `Project dir: ${context.item.projectDir}`),
62
+ el(Text, { wrap: "truncate" }, `Workspace: ${context.item.targetDir}`),
63
+ el(Text, { wrap: "truncate" }, `SSH: ${context.item.sshCommand}`)
64
+ ];
65
+ const renderConnectDetails = (el, context, common, connectEnableMcpPlaywright) => [
66
+ titleRow(el, "Connect + SSH"),
67
+ ...common,
68
+ el(Text, { color: connectEnableMcpPlaywright ? "green" : "gray", wrap: "wrap" }, connectEnableMcpPlaywright
69
+ ? "Playwright MCP: will be enabled before SSH (P to disable)."
70
+ : "Playwright MCP: keep current project setting (P to enable before SSH)."),
71
+ el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
72
+ el(Text, { wrap: "wrap" }, `SSH command: ${context.item.sshCommand}`)
73
+ ];
74
+ export const renderSelectDetails = (el, purpose, item, runtimeByProject, connectEnableMcpPlaywright) => {
75
+ if (!item) {
76
+ return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")];
77
+ }
78
+ const context = buildDetailsContext(item, runtimeByProject);
79
+ const common = commonRows(el, context);
80
+ return Match.value(purpose).pipe(Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), Match.when("Info", () => renderInfoDetails(el, context, common)), Match.when("Down", () => [
81
+ titleRow(el, "Stop container"),
82
+ ...common,
83
+ el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
84
+ ]), Match.when("Delete", () => [
85
+ titleRow(el, "Delete project"),
86
+ ...common,
87
+ context.runtime.sshSessions > 0
88
+ ? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
89
+ : el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
90
+ el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
91
+ el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
92
+ ]), Match.orElse(() => renderDefaultDetails(el, context)));
93
+ };
@@ -1,6 +1,7 @@
1
1
  import { Match } from "effect";
2
2
  import { Box, Text } from "ink";
3
3
  import React from "react";
4
+ import { buildSelectLabels, renderSelectDetails, selectHint, selectTitle } from "./menu-render-select.js";
4
5
  import { createSteps, menuItems } from "./menu-types.js";
5
6
  // CHANGE: render menu views with Ink without JSX
6
7
  // WHY: keep UI logic separate from input/state reducers
@@ -67,56 +68,6 @@ export const renderCreate = (label, buffer, message, stepIndex, defaults) => {
67
68
  el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
68
69
  ], message);
69
70
  };
70
- const formatRepoRef = (repoRef) => {
71
- const trimmed = repoRef.trim();
72
- const prPrefix = "refs/pull/";
73
- if (trimmed.startsWith(prPrefix)) {
74
- const rest = trimmed.slice(prPrefix.length);
75
- const number = rest.split("/")[0] ?? rest;
76
- return `PR#${number}`;
77
- }
78
- return trimmed.length > 0 ? trimmed : "main";
79
- };
80
- const renderSelectDetails = (el, purpose, item) => {
81
- if (!item) {
82
- return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")];
83
- }
84
- const refLabel = formatRepoRef(item.repoRef);
85
- const authSuffix = item.authorizedKeysExists ? "" : " (missing)";
86
- return Match.value(purpose).pipe(Match.when("Info", () => [
87
- el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Connection info"),
88
- el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
89
- el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
90
- el(Text, { wrap: "wrap" }, `Service: ${item.serviceName}`),
91
- el(Text, { wrap: "wrap" }, `SSH command: ${item.sshCommand}`),
92
- el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
93
- el(Text, { wrap: "wrap" }, `Workspace: ${item.targetDir}`),
94
- el(Text, { wrap: "wrap" }, `Authorized keys: ${item.authorizedKeysPath}${authSuffix}`),
95
- el(Text, { wrap: "wrap" }, `Env global: ${item.envGlobalPath}`),
96
- el(Text, { wrap: "wrap" }, `Env project: ${item.envProjectPath}`),
97
- el(Text, { wrap: "wrap" }, `Codex auth: ${item.codexAuthPath} -> ${item.codexHome}`)
98
- ]), Match.when("Delete", () => [
99
- el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Delete project"),
100
- el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
101
- el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
102
- el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
103
- el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
104
- ]), Match.orElse(() => [
105
- el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Details"),
106
- el(Text, { wrap: "truncate" }, `Repo: ${item.repoUrl}`),
107
- el(Text, { wrap: "truncate" }, `Ref: ${item.repoRef}`),
108
- el(Text, { wrap: "truncate" }, `Project dir: ${item.projectDir}`),
109
- el(Text, { wrap: "truncate" }, `Workspace: ${item.targetDir}`),
110
- el(Text, { wrap: "truncate" }, `SSH: ${item.sshCommand}`)
111
- ]));
112
- };
113
- const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
114
- const selectHint = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "Enter = select + SSH, Esc = back"), Match.when("Down", () => "Enter = stop container, Esc = back"), Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), Match.exhaustive);
115
- const buildSelectLabels = (items, selected) => items.map((item, index) => {
116
- const prefix = index === selected ? ">" : " ";
117
- const refLabel = formatRepoRef(item.repoRef);
118
- return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})`;
119
- });
120
71
  const computeListWidth = (labels) => {
121
72
  const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24;
122
73
  return Math.min(Math.max(maxLabelWidth + 2, 28), 54);
@@ -129,21 +80,34 @@ const renderSelectListBox = (el, items, selected, labels, width) => {
129
80
  }, label));
130
81
  return el(Box, { flexDirection: "column", width }, ...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")]));
131
82
  };
132
- const renderSelectDetailsBox = (el, purpose, items, selected) => {
133
- const details = renderSelectDetails(el, purpose, items[selected]);
83
+ const renderSelectDetailsBox = (el, input) => {
84
+ const details = renderSelectDetails(el, input.purpose, input.items[input.selected], input.runtimeByProject, input.connectEnableMcpPlaywright);
134
85
  return el(Box, { flexDirection: "column", marginLeft: 2, flexGrow: 1 }, ...details);
135
86
  };
136
- export const renderSelect = (purpose, items, selected, confirmDelete, message) => {
87
+ export const renderSelect = (input) => {
88
+ const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, runtimeByProject, selected } = input;
137
89
  const el = React.createElement;
138
- const listLabels = buildSelectLabels(items, selected);
90
+ const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject);
139
91
  const listWidth = computeListWidth(listLabels);
140
92
  const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth);
141
- const detailsBox = renderSelectDetailsBox(el, purpose, items, selected);
142
- const baseHint = selectHint(purpose);
143
- const deleteHint = purpose === "Delete" && confirmDelete
144
- ? "Confirm mode: Enter = delete now, Esc = cancel"
145
- : baseHint;
146
- const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, deleteHint));
93
+ const detailsBox = renderSelectDetailsBox(el, {
94
+ purpose,
95
+ items,
96
+ selected,
97
+ runtimeByProject,
98
+ connectEnableMcpPlaywright
99
+ });
100
+ const baseHint = selectHint(purpose, connectEnableMcpPlaywright);
101
+ const confirmHint = (() => {
102
+ if (purpose === "Delete" && confirmDelete) {
103
+ return "Confirm mode: Enter = delete now, Esc = cancel";
104
+ }
105
+ if (purpose === "Down" && confirmDelete) {
106
+ return "Confirm mode: Enter = stop now, Esc = cancel";
107
+ }
108
+ return baseHint;
109
+ })();
110
+ const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, confirmHint));
147
111
  return renderLayout(selectTitle(purpose), [
148
112
  el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
149
113
  hints
@@ -0,0 +1,6 @@
1
+ import { Effect } from "effect";
2
+ const normalizedInput = (input) => input.trim().toLowerCase();
3
+ export const isConnectMcpToggleInput = (input) => normalizedInput(input) === "p";
4
+ export const buildConnectEffect = (selected, enableMcpPlaywright, deps) => enableMcpPlaywright
5
+ ? deps.enableMcpPlaywright(selected.projectDir).pipe(Effect.zipRight(deps.connectWithUp(selected)))
6
+ : deps.connectWithUp(selected);
@@ -0,0 +1,50 @@
1
+ import { runCommandCapture } from "@effect-template/lib/shell/command-runner";
2
+ import { runDockerPsNames } from "@effect-template/lib/shell/docker";
3
+ import { Effect, pipe } from "effect";
4
+ const emptyRuntimeByProject = () => ({});
5
+ const stoppedRuntime = () => ({ running: false, sshSessions: 0 });
6
+ const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
7
+ const parseSshSessionCount = (raw) => {
8
+ const parsed = Number.parseInt(raw.trim(), 10);
9
+ if (Number.isNaN(parsed) || parsed < 0) {
10
+ return 0;
11
+ }
12
+ return parsed;
13
+ };
14
+ const toRuntimeMap = (entries) => {
15
+ const runtimeByProject = {};
16
+ for (const [projectDir, runtime] of entries) {
17
+ runtimeByProject[projectDir] = runtime;
18
+ }
19
+ return runtimeByProject;
20
+ };
21
+ const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
22
+ cwd: process.cwd(),
23
+ command: "docker",
24
+ args: ["exec", containerName, "bash", "-lc", countSshSessionsScript]
25
+ }, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker exec who -u", exitCode })), Effect.match({
26
+ onFailure: () => 0,
27
+ onSuccess: (raw) => parseSshSessionCount(raw)
28
+ }));
29
+ // CHANGE: enrich select items with runtime state and SSH session counts
30
+ // WHY: prevent stopping/deleting containers that are currently used via SSH
31
+ // QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
32
+ // REF: issue-47
33
+ // SOURCE: n/a
34
+ // FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
35
+ // PURITY: SHELL
36
+ // EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
37
+ // INVARIANT: stopped containers always have sshSessions = 0
38
+ // COMPLEXITY: O(n + docker_ps + docker_exec)
39
+ export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
40
+ const running = runningNames.includes(item.containerName);
41
+ if (!running) {
42
+ const entry = [item.projectDir, stoppedRuntime()];
43
+ return Effect.succeed(entry);
44
+ }
45
+ return pipe(countContainerSshSessions(item.containerName), Effect.map((sshSessions) => ({ running: true, sshSessions })), Effect.map((runtime) => [item.projectDir, runtime]));
46
+ }, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
47
+ onFailure: () => emptyRuntimeByProject(),
48
+ onSuccess: (runtimeByProject) => runtimeByProject
49
+ }));
50
+ export const runtimeForSelection = (view, selected) => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime();
@@ -1,10 +1,22 @@
1
1
  import { runDockerComposeDown } from "@effect-template/lib/shell/docker";
2
+ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
2
3
  import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
3
4
  import { Effect, Match, pipe } from "effect";
5
+ import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js";
6
+ import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js";
4
7
  import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
5
- export const startSelectView = (items, purpose, context) => {
8
+ const emptyRuntimeByProject = () => ({});
9
+ export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
6
10
  context.setMessage(null);
7
- context.setView({ _tag: "SelectProject", purpose, items, selected: 0, confirmDelete: false });
11
+ context.setView({
12
+ _tag: "SelectProject",
13
+ purpose,
14
+ items,
15
+ runtimeByProject,
16
+ selected: 0,
17
+ confirmDelete: false,
18
+ connectEnableMcpPlaywright: false
19
+ });
8
20
  };
9
21
  const clampIndex = (value, size) => {
10
22
  if (size <= 0) {
@@ -23,6 +35,9 @@ export const handleSelectInput = (input, key, view, context) => {
23
35
  resetToMenu(context);
24
36
  return;
25
37
  }
38
+ if (handleConnectOptionToggle(input, view, context)) {
39
+ return;
40
+ }
26
41
  if (handleSelectNavigation(key, view, context)) {
27
42
  return;
28
43
  }
@@ -30,7 +45,20 @@ export const handleSelectInput = (input, key, view, context) => {
30
45
  handleSelectReturn(view, context);
31
46
  return;
32
47
  }
33
- handleSelectHint(input, context);
48
+ if (input.trim().length > 0) {
49
+ context.setMessage("Use arrows + Enter to select a project, Esc to cancel.");
50
+ }
51
+ };
52
+ const handleConnectOptionToggle = (input, view, context) => {
53
+ if (view.purpose !== "Connect" || !isConnectMcpToggleInput(input)) {
54
+ return false;
55
+ }
56
+ const nextValue = !view.connectEnableMcpPlaywright;
57
+ context.setView({ ...view, connectEnableMcpPlaywright: nextValue, confirmDelete: false });
58
+ context.setMessage(nextValue
59
+ ? "Playwright MCP will be enabled before SSH (press Enter to connect)."
60
+ : "Playwright MCP toggle is OFF (press Enter to connect without changes).");
61
+ return true;
34
62
  };
35
63
  const handleSelectNavigation = (key, view, context) => {
36
64
  if (key.upArrow) {
@@ -54,22 +82,27 @@ const runWithSuspendedTui = (context, effect, onResume, doneMessage) => {
54
82
  context.setMessage(doneMessage);
55
83
  }))));
56
84
  };
57
- const runConnectSelection = (selected, context) => {
58
- context.setMessage(`Connecting to ${selected.displayName}...`);
85
+ const runConnectSelection = (selected, context, enableMcpPlaywright) => {
86
+ context.setMessage(enableMcpPlaywright
87
+ ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
88
+ : `Connecting to ${selected.displayName}...`);
59
89
  context.setSshActive(true);
60
- runWithSuspendedTui(context, connectProjectSshWithUp(selected), () => {
90
+ runWithSuspendedTui(context, buildConnectEffect(selected, enableMcpPlaywright, {
91
+ connectWithUp: (item) => connectProjectSshWithUp(item).pipe(Effect.mapError((error) => error)),
92
+ enableMcpPlaywright: (projectDir) => mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(Effect.asVoid, Effect.mapError((error) => error))
93
+ }), () => {
61
94
  context.setSshActive(false);
62
95
  }, "SSH session ended. Press Esc to return to the menu.");
63
96
  };
64
97
  const runDownSelection = (selected, context) => {
65
98
  context.setMessage(`Stopping ${selected.displayName}...`);
66
- context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), Effect.tap((items) => Effect.sync(() => {
99
+ context.runner.runEffect(pipe(Effect.sync(suspendTui), Effect.zipRight(runDockerComposeDown(selected.projectDir)), Effect.zipRight(listRunningProjectItems), Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.map((runtimeByProject) => ({ items, runtimeByProject })))), Effect.tap(({ items, runtimeByProject }) => Effect.sync(() => {
67
100
  if (items.length === 0) {
68
101
  resetToMenu(context);
69
102
  context.setMessage("No running docker-git containers.");
70
103
  return;
71
104
  }
72
- startSelectView(items, "Down", context);
105
+ startSelectView(items, "Down", context, runtimeByProject);
73
106
  context.setMessage("Container stopped. Select another to stop, or Esc to return.");
74
107
  })), Effect.ensuring(Effect.sync(() => {
75
108
  resumeTui();
@@ -97,10 +130,19 @@ const handleSelectReturn = (view, context) => {
97
130
  resetToMenu(context);
98
131
  return;
99
132
  }
133
+ const selectedRuntime = runtimeForSelection(view, selected);
134
+ const sshSessionsLabel = selectedRuntime.sshSessions === 1
135
+ ? "1 active SSH session"
136
+ : `${selectedRuntime.sshSessions} active SSH sessions`;
100
137
  Match.value(view.purpose).pipe(Match.when("Connect", () => {
101
138
  context.setActiveDir(selected.projectDir);
102
- runConnectSelection(selected, context);
139
+ runConnectSelection(selected, context, view.connectEnableMcpPlaywright);
103
140
  }), Match.when("Down", () => {
141
+ if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
142
+ context.setMessage(`${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.`);
143
+ context.setView({ ...view, confirmDelete: true });
144
+ return;
145
+ }
104
146
  context.setActiveDir(selected.projectDir);
105
147
  runDownSelection(selected, context);
106
148
  }), Match.when("Info", () => {
@@ -108,24 +150,20 @@ const handleSelectReturn = (view, context) => {
108
150
  runInfoSelection(selected, context);
109
151
  }), Match.when("Delete", () => {
110
152
  if (!view.confirmDelete) {
111
- context.setMessage(`Really delete ${selected.displayName}? Press Enter again to confirm, Esc to cancel.`);
153
+ const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : "";
154
+ context.setMessage(`Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.`);
112
155
  context.setView({ ...view, confirmDelete: true });
113
156
  return;
114
157
  }
115
158
  runDeleteSelection(selected, context);
116
159
  }), Match.exhaustive);
117
160
  };
118
- const handleSelectHint = (input, context) => {
119
- if (input.trim().length > 0) {
120
- context.setMessage("Use arrows + Enter to select a project, Esc to cancel.");
121
- }
122
- };
123
- export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => Effect.sync(() => {
161
+ export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
124
162
  if (items.length === 0) {
125
163
  context.setMessage(purpose === "Down"
126
164
  ? "No running docker-git containers."
127
165
  : "No docker-git projects found.");
128
166
  return;
129
167
  }
130
- startSelectView(items, purpose, context);
131
- })));
168
+ startSelectView(items, purpose, context, runtimeByProject);
169
+ })))));
@@ -112,7 +112,15 @@ const renderView = (context) => {
112
112
  const label = renderStepLabel(step, currentDefaults);
113
113
  return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults);
114
114
  }
115
- return renderSelect(context.view.purpose, context.view.items, context.view.selected, context.view.confirmDelete, context.message);
115
+ return renderSelect({
116
+ purpose: context.view.purpose,
117
+ items: context.view.items,
118
+ selected: context.view.selected,
119
+ runtimeByProject: context.view.runtimeByProject,
120
+ confirmDelete: context.view.confirmDelete,
121
+ connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright,
122
+ message: context.message
123
+ });
116
124
  };
117
125
  const useMenuState = () => {
118
126
  const [activeDir, setActiveDir] = useState(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
@@ -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
+ }