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

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @prover-coder-ai/docker-git
2
2
 
3
+ ## 1.0.15
4
+
5
+ ### Patch Changes
6
+
7
+ - chore: automated version bump
8
+
3
9
  ## 1.0.14
4
10
 
5
11
  ### Patch Changes
@@ -2,7 +2,7 @@ import {} from "@effect-template/lib/core/domain";
2
2
  import { readProjectConfig } from "@effect-template/lib/shell/config";
3
3
  import { runDockerComposeDown, runDockerComposeLogs, runDockerComposePs } from "@effect-template/lib/shell/docker";
4
4
  import { renderError } from "@effect-template/lib/usecases/errors";
5
- import { downAllDockerGitProjects, listProjectItems, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
5
+ import { downAllDockerGitProjects, listProjectItems, listProjectStatus, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
6
6
  import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up";
7
7
  import { Effect, Match, pipe } from "effect";
8
8
  import { startCreateView } from "./menu-create.js";
@@ -80,6 +80,10 @@ const runDeleteAction = (context) => {
80
80
  context.runner.runEffect(loadSelectView(listProjectItems, "Delete", context));
81
81
  };
82
82
  const runComposeAction = (action, context) => {
83
+ if (action._tag === "Status" && context.state.activeDir === null) {
84
+ runWithSuspendedTui(listProjectStatus, context, "docker compose ps (all projects)");
85
+ return;
86
+ }
83
87
  if (!requireActiveProject(context)) {
84
88
  return;
85
89
  }
@@ -0,0 +1,67 @@
1
+ import { handleCreateInput } from "./menu-create.js";
2
+ import { handleMenuInput } from "./menu-menu.js";
3
+ import { handleSelectInput } from "./menu-select.js";
4
+ const activateInput = (input, key, context) => {
5
+ if (context.inputStage === "active") {
6
+ return { activated: false, allowProcessing: true };
7
+ }
8
+ if (input.trim().length > 0) {
9
+ context.setInputStage("active");
10
+ return { activated: true, allowProcessing: true };
11
+ }
12
+ if (key.upArrow || key.downArrow || key.return) {
13
+ context.setInputStage("active");
14
+ return { activated: true, allowProcessing: false };
15
+ }
16
+ if (input.length > 0) {
17
+ context.setInputStage("active");
18
+ return { activated: true, allowProcessing: true };
19
+ }
20
+ return { activated: false, allowProcessing: false };
21
+ };
22
+ const shouldHandleMenuInput = (input, key, context) => {
23
+ const activation = activateInput(input, key, context);
24
+ if (activation.activated && !activation.allowProcessing) {
25
+ return false;
26
+ }
27
+ return activation.allowProcessing;
28
+ };
29
+ export const handleUserInput = (input, key, context) => {
30
+ if (context.busy || context.sshActive) {
31
+ return;
32
+ }
33
+ if (context.view._tag === "Menu") {
34
+ if (!shouldHandleMenuInput(input, key, context)) {
35
+ return;
36
+ }
37
+ handleMenuInput(input, key, {
38
+ selected: context.selected,
39
+ setSelected: context.setSelected,
40
+ state: context.state,
41
+ runner: context.runner,
42
+ exit: context.exit,
43
+ setView: context.setView,
44
+ setMessage: context.setMessage
45
+ });
46
+ return;
47
+ }
48
+ if (context.view._tag === "Create") {
49
+ handleCreateInput(input, key, context.view, {
50
+ state: context.state,
51
+ setView: context.setView,
52
+ setMessage: context.setMessage,
53
+ runner: context.runner,
54
+ setActiveDir: context.setActiveDir
55
+ });
56
+ return;
57
+ }
58
+ handleSelectInput(input, key, context.view, {
59
+ setView: context.setView,
60
+ setMessage: context.setMessage,
61
+ setActiveDir: context.setActiveDir,
62
+ activeDir: context.state.activeDir,
63
+ runner: context.runner,
64
+ setSshActive: context.setSshActive,
65
+ setSkipInputs: context.setSkipInputs
66
+ });
67
+ };
@@ -36,9 +36,11 @@ const renderMenuMessage = (el, message) => {
36
36
  .split("\n")
37
37
  .map((line, index) => el(Text, { key: `${index}-${line}`, color: "magenta" }, line)));
38
38
  };
39
- export const renderMenu = (cwd, activeDir, selected, busy, message) => {
39
+ export const renderMenu = (input) => {
40
+ const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input;
40
41
  const el = React.createElement;
41
42
  const activeLabel = `Active: ${activeDir ?? "(none)"}`;
43
+ const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`;
42
44
  const cwdLabel = `CWD: ${cwd}`;
43
45
  const items = menuItems.map((item, index) => {
44
46
  const indexLabel = `${index + 1})`;
@@ -52,6 +54,7 @@ export const renderMenu = (cwd, activeDir, selected, busy, message) => {
52
54
  const hints = renderMenuHints(el);
53
55
  return renderLayout("docker-git", compactElements([
54
56
  el(Text, null, activeLabel),
57
+ el(Text, null, runningLabel),
55
58
  el(Text, null, cwdLabel),
56
59
  el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
57
60
  hints,
@@ -0,0 +1,57 @@
1
+ const dockerGitContainerPrefix = "dg-";
2
+ const emptySnapshot = () => ({
3
+ activeDir: null,
4
+ runningDockerGitContainers: 0,
5
+ message: null
6
+ });
7
+ const uniqueDockerGitContainerNames = (runningContainerNames) => [
8
+ ...new Set(runningContainerNames.filter((name) => name.startsWith(dockerGitContainerPrefix)))
9
+ ];
10
+ const detectKnownRunningProjects = (items, runningDockerGitNames) => {
11
+ const runningSet = new Set(runningDockerGitNames);
12
+ return items.filter((item) => runningSet.has(item.containerName));
13
+ };
14
+ const renderRunningHint = (runningCount) => runningCount === 1
15
+ ? "Detected 1 running docker-git container."
16
+ : `Detected ${runningCount} running docker-git containers.`;
17
+ // CHANGE: infer initial menu state from currently running docker-git containers
18
+ // WHY: avoid "(none)" confusion when containers are already up outside this TUI session
19
+ // QUOTE(ISSUE): "У меня запущены контейнеры от docker-git но он говорит что они не запущены"
20
+ // REF: issue-13
21
+ // SOURCE: n/a
22
+ // FORMAT THEOREM: forall startupState: snapshot(startupState) -> deterministic(menuState)
23
+ // PURITY: CORE
24
+ // EFFECT: n/a
25
+ // INVARIANT: activeDir is set only when exactly one known project is running
26
+ // COMPLEXITY: O(|containers| + |projects|)
27
+ export const resolveMenuStartupSnapshot = (items, runningContainerNames) => {
28
+ const runningDockerGitNames = uniqueDockerGitContainerNames(runningContainerNames);
29
+ if (runningDockerGitNames.length === 0) {
30
+ return emptySnapshot();
31
+ }
32
+ const knownRunningProjects = detectKnownRunningProjects(items, runningDockerGitNames);
33
+ if (knownRunningProjects.length === 1 && runningDockerGitNames.length === 1) {
34
+ const selected = knownRunningProjects[0];
35
+ if (!selected) {
36
+ return emptySnapshot();
37
+ }
38
+ return {
39
+ activeDir: selected.projectDir,
40
+ runningDockerGitContainers: 1,
41
+ message: `Auto-selected active project: ${selected.displayName}.`
42
+ };
43
+ }
44
+ if (knownRunningProjects.length === 0) {
45
+ return {
46
+ activeDir: null,
47
+ runningDockerGitContainers: runningDockerGitNames.length,
48
+ message: `${renderRunningHint(runningDockerGitNames.length)} No matching project config found.`
49
+ };
50
+ }
51
+ return {
52
+ activeDir: null,
53
+ runningDockerGitContainers: runningDockerGitNames.length,
54
+ message: `${renderRunningHint(runningDockerGitNames.length)} Use Select project to choose active.`
55
+ };
56
+ };
57
+ export const defaultMenuStartupSnapshot = emptySnapshot;
@@ -1,14 +1,16 @@
1
+ import { runDockerPsNames } from "@effect-template/lib/shell/docker";
1
2
  import { InputReadError } from "@effect-template/lib/shell/errors";
2
3
  import { renderError } from "@effect-template/lib/usecases/errors";
4
+ import { listProjectItems } from "@effect-template/lib/usecases/projects";
3
5
  import { NodeContext } from "@effect/platform-node";
4
6
  import { Effect, pipe } from "effect";
5
7
  import { render, useApp, useInput } from "ink";
6
8
  import React, { useEffect, useMemo, useState } from "react";
7
- import { handleCreateInput, resolveCreateInputs } from "./menu-create.js";
8
- import { handleMenuInput } from "./menu-menu.js";
9
+ import { resolveCreateInputs } from "./menu-create.js";
10
+ import { handleUserInput } from "./menu-input-handler.js";
9
11
  import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js";
10
- import { handleSelectInput } from "./menu-select.js";
11
12
  import { leaveTui, resumeTui } from "./menu-shared.js";
13
+ import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js";
12
14
  import { createSteps } from "./menu-types.js";
13
15
  // CHANGE: keep menu state in the TUI layer
14
16
  // WHY: provide a dynamic interface with live selection and inputs
@@ -35,76 +37,16 @@ const useRunner = (setBusy, setMessage) => {
35
37
  };
36
38
  return { runEffect };
37
39
  };
38
- const activateInput = (input, key, context) => {
39
- if (context.inputStage === "active") {
40
- return { activated: false, allowProcessing: true };
41
- }
42
- if (input.trim().length > 0) {
43
- context.setInputStage("active");
44
- return { activated: true, allowProcessing: true };
45
- }
46
- if (key.upArrow || key.downArrow || key.return) {
47
- context.setInputStage("active");
48
- return { activated: true, allowProcessing: false };
49
- }
50
- if (input.length > 0) {
51
- context.setInputStage("active");
52
- return { activated: true, allowProcessing: true };
53
- }
54
- return { activated: false, allowProcessing: false };
55
- };
56
- const shouldHandleMenuInput = (input, key, context) => {
57
- const activation = activateInput(input, key, context);
58
- if (activation.activated && !activation.allowProcessing) {
59
- return false;
60
- }
61
- return activation.allowProcessing;
62
- };
63
- const handleUserInput = (input, key, context) => {
64
- if (context.busy) {
65
- return;
66
- }
67
- if (context.sshActive) {
68
- return;
69
- }
40
+ const renderView = (context) => {
70
41
  if (context.view._tag === "Menu") {
71
- if (!shouldHandleMenuInput(input, key, context)) {
72
- return;
73
- }
74
- handleMenuInput(input, key, {
42
+ return renderMenu({
43
+ cwd: context.state.cwd,
44
+ activeDir: context.activeDir,
45
+ runningDockerGitContainers: context.runningDockerGitContainers,
75
46
  selected: context.selected,
76
- setSelected: context.setSelected,
77
- state: context.state,
78
- runner: context.runner,
79
- exit: context.exit,
80
- setView: context.setView,
81
- setMessage: context.setMessage
82
- });
83
- return;
84
- }
85
- if (context.view._tag === "Create") {
86
- handleCreateInput(input, key, context.view, {
87
- state: context.state,
88
- setView: context.setView,
89
- setMessage: context.setMessage,
90
- runner: context.runner,
91
- setActiveDir: context.setActiveDir
47
+ busy: context.busy,
48
+ message: context.message
92
49
  });
93
- return;
94
- }
95
- handleSelectInput(input, key, context.view, {
96
- setView: context.setView,
97
- setMessage: context.setMessage,
98
- setActiveDir: context.setActiveDir,
99
- activeDir: context.state.activeDir,
100
- runner: context.runner,
101
- setSshActive: context.setSshActive,
102
- setSkipInputs: context.setSkipInputs
103
- });
104
- };
105
- const renderView = (context) => {
106
- if (context.view._tag === "Menu") {
107
- return renderMenu(context.state.cwd, context.activeDir, context.selected, context.busy, context.message);
108
50
  }
109
51
  if (context.view._tag === "Create") {
110
52
  const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values);
@@ -124,6 +66,7 @@ const renderView = (context) => {
124
66
  };
125
67
  const useMenuState = () => {
126
68
  const [activeDir, setActiveDir] = useState(null);
69
+ const [runningDockerGitContainers, setRunningDockerGitContainers] = useState(0);
127
70
  const [selected, setSelected] = useState(0);
128
71
  const [busy, setBusy] = useState(false);
129
72
  const [message, setMessage] = useState(null);
@@ -138,6 +81,8 @@ const useMenuState = () => {
138
81
  return {
139
82
  activeDir,
140
83
  setActiveDir,
84
+ runningDockerGitContainers,
85
+ setRunningDockerGitContainers,
141
86
  selected,
142
87
  setSelected,
143
88
  busy,
@@ -168,6 +113,28 @@ const useReadyGate = (setReady) => {
168
113
  };
169
114
  }, [setReady]);
170
115
  };
116
+ const useStartupSnapshot = (setActiveDir, setRunningDockerGitContainers, setMessage) => {
117
+ useEffect(() => {
118
+ let cancelled = false;
119
+ const startup = pipe(Effect.all([listProjectItems, runDockerPsNames(process.cwd())]), Effect.map(([items, runningNames]) => resolveMenuStartupSnapshot(items, runningNames)), Effect.match({
120
+ onFailure: () => defaultMenuStartupSnapshot(),
121
+ onSuccess: (snapshot) => snapshot
122
+ }), Effect.provide(NodeContext.layer));
123
+ void Effect.runPromise(startup).then((snapshot) => {
124
+ if (cancelled) {
125
+ return;
126
+ }
127
+ setRunningDockerGitContainers(snapshot.runningDockerGitContainers);
128
+ setMessage(snapshot.message);
129
+ if (snapshot.activeDir !== null) {
130
+ setActiveDir(snapshot.activeDir);
131
+ }
132
+ });
133
+ return () => {
134
+ cancelled = true;
135
+ };
136
+ }, [setActiveDir, setMessage, setRunningDockerGitContainers]);
137
+ };
171
138
  const useSigintGuard = (exit, sshActive) => {
172
139
  useEffect(() => {
173
140
  const handleSigint = () => {
@@ -186,6 +153,7 @@ const TuiApp = () => {
186
153
  const { exit } = useApp();
187
154
  const menu = useMenuState();
188
155
  useReadyGate(menu.setReady);
156
+ useStartupSnapshot(menu.setActiveDir, menu.setRunningDockerGitContainers, menu.setMessage);
189
157
  useSigintGuard(exit, menu.sshActive);
190
158
  useInput((input, key) => {
191
159
  if (!menu.ready) {
@@ -220,6 +188,7 @@ const TuiApp = () => {
220
188
  state: menu.state,
221
189
  view: menu.view,
222
190
  activeDir: menu.activeDir,
191
+ runningDockerGitContainers: menu.runningDockerGitContainers,
223
192
  selected: menu.selected,
224
193
  busy: menu.busy,
225
194
  message: menu.message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prover-coder-ai/docker-git",
3
- "version": "1.0.14",
3
+ "version": "1.0.15",
4
4
  "description": "Minimal Vite-powered TypeScript console starter using Effect",
5
5
  "main": "dist/src/docker-git/main.js",
6
6
  "bin": {
@@ -6,6 +6,7 @@ import { renderError } from "@effect-template/lib/usecases/errors"
6
6
  import {
7
7
  downAllDockerGitProjects,
8
8
  listProjectItems,
9
+ listProjectStatus,
9
10
  listRunningProjectItems
10
11
  } from "@effect-template/lib/usecases/projects"
11
12
  import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
@@ -195,6 +196,10 @@ const runDeleteAction = (context: MenuContext) => {
195
196
  }
196
197
 
197
198
  const runComposeAction = (action: MenuAction, context: MenuContext) => {
199
+ if (action._tag === "Status" && context.state.activeDir === null) {
200
+ runWithSuspendedTui(listProjectStatus, context, "docker compose ps (all projects)")
201
+ return
202
+ }
198
203
  if (!requireActiveProject(context)) {
199
204
  return
200
205
  }
@@ -0,0 +1,107 @@
1
+ import { handleCreateInput } from "./menu-create.js"
2
+ import { handleMenuInput } from "./menu-menu.js"
3
+ import { handleSelectInput } from "./menu-select.js"
4
+ import type { MenuKeyInput, MenuRunner, MenuState, MenuViewContext, ViewState } from "./menu-types.js"
5
+
6
+ export type InputStage = "cold" | "active"
7
+
8
+ export type MenuInputContext = MenuViewContext & {
9
+ readonly busy: boolean
10
+ readonly view: ViewState
11
+ readonly inputStage: InputStage
12
+ readonly setInputStage: (stage: InputStage) => void
13
+ readonly selected: number
14
+ readonly setSelected: (update: (value: number) => number) => void
15
+ readonly setSkipInputs: (update: (value: number) => number) => void
16
+ readonly sshActive: boolean
17
+ readonly setSshActive: (active: boolean) => void
18
+ readonly state: MenuState
19
+ readonly runner: MenuRunner
20
+ readonly exit: () => void
21
+ }
22
+
23
+ const activateInput = (
24
+ input: string,
25
+ key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
26
+ context: Pick<MenuInputContext, "inputStage" | "setInputStage">
27
+ ): { readonly activated: boolean; readonly allowProcessing: boolean } => {
28
+ if (context.inputStage === "active") {
29
+ return { activated: false, allowProcessing: true }
30
+ }
31
+
32
+ if (input.trim().length > 0) {
33
+ context.setInputStage("active")
34
+ return { activated: true, allowProcessing: true }
35
+ }
36
+
37
+ if (key.upArrow || key.downArrow || key.return) {
38
+ context.setInputStage("active")
39
+ return { activated: true, allowProcessing: false }
40
+ }
41
+
42
+ if (input.length > 0) {
43
+ context.setInputStage("active")
44
+ return { activated: true, allowProcessing: true }
45
+ }
46
+
47
+ return { activated: false, allowProcessing: false }
48
+ }
49
+
50
+ const shouldHandleMenuInput = (
51
+ input: string,
52
+ key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
53
+ context: Pick<MenuInputContext, "inputStage" | "setInputStage">
54
+ ): boolean => {
55
+ const activation = activateInput(input, key, context)
56
+ if (activation.activated && !activation.allowProcessing) {
57
+ return false
58
+ }
59
+ return activation.allowProcessing
60
+ }
61
+
62
+ export const handleUserInput = (
63
+ input: string,
64
+ key: MenuKeyInput,
65
+ context: MenuInputContext
66
+ ) => {
67
+ if (context.busy || context.sshActive) {
68
+ return
69
+ }
70
+
71
+ if (context.view._tag === "Menu") {
72
+ if (!shouldHandleMenuInput(input, key, context)) {
73
+ return
74
+ }
75
+ handleMenuInput(input, key, {
76
+ selected: context.selected,
77
+ setSelected: context.setSelected,
78
+ state: context.state,
79
+ runner: context.runner,
80
+ exit: context.exit,
81
+ setView: context.setView,
82
+ setMessage: context.setMessage
83
+ })
84
+ return
85
+ }
86
+
87
+ if (context.view._tag === "Create") {
88
+ handleCreateInput(input, key, context.view, {
89
+ state: context.state,
90
+ setView: context.setView,
91
+ setMessage: context.setMessage,
92
+ runner: context.runner,
93
+ setActiveDir: context.setActiveDir
94
+ })
95
+ return
96
+ }
97
+
98
+ handleSelectInput(input, key, context.view, {
99
+ setView: context.setView,
100
+ setMessage: context.setMessage,
101
+ setActiveDir: context.setActiveDir,
102
+ activeDir: context.state.activeDir,
103
+ runner: context.runner,
104
+ setSshActive: context.setSshActive,
105
+ setSkipInputs: context.setSkipInputs
106
+ })
107
+ }
@@ -103,15 +103,20 @@ const renderMenuMessage = (
103
103
  )
104
104
  }
105
105
 
106
- export const renderMenu = (
107
- cwd: string,
108
- activeDir: string | null,
109
- selected: number,
110
- busy: boolean,
111
- message: string | null
112
- ): React.ReactElement => {
106
+ type MenuRenderInput = {
107
+ readonly cwd: string
108
+ readonly activeDir: string | null
109
+ readonly runningDockerGitContainers: number
110
+ readonly selected: number
111
+ readonly busy: boolean
112
+ readonly message: string | null
113
+ }
114
+
115
+ export const renderMenu = (input: MenuRenderInput): React.ReactElement => {
116
+ const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input
113
117
  const el = React.createElement
114
118
  const activeLabel = `Active: ${activeDir ?? "(none)"}`
119
+ const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`
115
120
  const cwdLabel = `CWD: ${cwd}`
116
121
  const items = menuItems.map((item, index) => {
117
122
  const indexLabel = `${index + 1})`
@@ -134,6 +139,7 @@ export const renderMenu = (
134
139
  "docker-git",
135
140
  compactElements([
136
141
  el(Text, null, activeLabel),
142
+ el(Text, null, runningLabel),
137
143
  el(Text, null, cwdLabel),
138
144
  el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
139
145
  hints,
@@ -0,0 +1,83 @@
1
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
2
+
3
+ export type MenuStartupSnapshot = {
4
+ readonly activeDir: string | null
5
+ readonly runningDockerGitContainers: number
6
+ readonly message: string | null
7
+ }
8
+
9
+ const dockerGitContainerPrefix = "dg-"
10
+
11
+ const emptySnapshot = (): MenuStartupSnapshot => ({
12
+ activeDir: null,
13
+ runningDockerGitContainers: 0,
14
+ message: null
15
+ })
16
+
17
+ const uniqueDockerGitContainerNames = (
18
+ runningContainerNames: ReadonlyArray<string>
19
+ ): ReadonlyArray<string> => [
20
+ ...new Set(runningContainerNames.filter((name) => name.startsWith(dockerGitContainerPrefix)))
21
+ ]
22
+
23
+ const detectKnownRunningProjects = (
24
+ items: ReadonlyArray<ProjectItem>,
25
+ runningDockerGitNames: ReadonlyArray<string>
26
+ ): ReadonlyArray<ProjectItem> => {
27
+ const runningSet = new Set(runningDockerGitNames)
28
+ return items.filter((item) => runningSet.has(item.containerName))
29
+ }
30
+
31
+ const renderRunningHint = (runningCount: number): string =>
32
+ runningCount === 1
33
+ ? "Detected 1 running docker-git container."
34
+ : `Detected ${runningCount} running docker-git containers.`
35
+
36
+ // CHANGE: infer initial menu state from currently running docker-git containers
37
+ // WHY: avoid "(none)" confusion when containers are already up outside this TUI session
38
+ // QUOTE(ISSUE): "У меня запущены контейнеры от docker-git но он говорит что они не запущены"
39
+ // REF: issue-13
40
+ // SOURCE: n/a
41
+ // FORMAT THEOREM: forall startupState: snapshot(startupState) -> deterministic(menuState)
42
+ // PURITY: CORE
43
+ // EFFECT: n/a
44
+ // INVARIANT: activeDir is set only when exactly one known project is running
45
+ // COMPLEXITY: O(|containers| + |projects|)
46
+ export const resolveMenuStartupSnapshot = (
47
+ items: ReadonlyArray<ProjectItem>,
48
+ runningContainerNames: ReadonlyArray<string>
49
+ ): MenuStartupSnapshot => {
50
+ const runningDockerGitNames = uniqueDockerGitContainerNames(runningContainerNames)
51
+ if (runningDockerGitNames.length === 0) {
52
+ return emptySnapshot()
53
+ }
54
+
55
+ const knownRunningProjects = detectKnownRunningProjects(items, runningDockerGitNames)
56
+ if (knownRunningProjects.length === 1 && runningDockerGitNames.length === 1) {
57
+ const selected = knownRunningProjects[0]
58
+ if (!selected) {
59
+ return emptySnapshot()
60
+ }
61
+ return {
62
+ activeDir: selected.projectDir,
63
+ runningDockerGitContainers: 1,
64
+ message: `Auto-selected active project: ${selected.displayName}.`
65
+ }
66
+ }
67
+
68
+ if (knownRunningProjects.length === 0) {
69
+ return {
70
+ activeDir: null,
71
+ runningDockerGitContainers: runningDockerGitNames.length,
72
+ message: `${renderRunningHint(runningDockerGitNames.length)} No matching project config found.`
73
+ }
74
+ }
75
+
76
+ return {
77
+ activeDir: null,
78
+ runningDockerGitContainers: runningDockerGitNames.length,
79
+ message: `${renderRunningHint(runningDockerGitNames.length)} Use Select project to choose active.`
80
+ }
81
+ }
82
+
83
+ export const defaultMenuStartupSnapshot = emptySnapshot
@@ -1,24 +1,18 @@
1
+ import { runDockerPsNames } from "@effect-template/lib/shell/docker"
1
2
  import { type InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors"
2
3
  import { type AppError, renderError } from "@effect-template/lib/usecases/errors"
4
+ import { listProjectItems } from "@effect-template/lib/usecases/projects"
3
5
  import { NodeContext } from "@effect/platform-node"
4
6
  import { Effect, pipe } from "effect"
5
7
  import { render, useApp, useInput } from "ink"
6
8
  import React, { useEffect, useMemo, useState } from "react"
7
9
 
8
- import { handleCreateInput, resolveCreateInputs } from "./menu-create.js"
9
- import { handleMenuInput } from "./menu-menu.js"
10
+ import { resolveCreateInputs } from "./menu-create.js"
11
+ import { handleUserInput, type InputStage } from "./menu-input-handler.js"
10
12
  import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js"
11
- import { handleSelectInput } from "./menu-select.js"
12
13
  import { leaveTui, resumeTui } from "./menu-shared.js"
13
- import {
14
- createSteps,
15
- type MenuEnv,
16
- type MenuKeyInput,
17
- type MenuRunner,
18
- type MenuState,
19
- type MenuViewContext,
20
- type ViewState
21
- } from "./menu-types.js"
14
+ import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js"
15
+ import { createSteps, type MenuEnv, type MenuState, type ViewState } from "./menu-types.js"
22
16
 
23
17
  // CHANGE: keep menu state in the TUI layer
24
18
  // WHY: provide a dynamic interface with live selection and inputs
@@ -58,115 +52,11 @@ const useRunner = (
58
52
  return { runEffect }
59
53
  }
60
54
 
61
- type InputStage = "cold" | "active"
62
-
63
- type MenuInputContext = MenuViewContext & {
64
- readonly busy: boolean
65
- readonly view: ViewState
66
- readonly inputStage: InputStage
67
- readonly setInputStage: (stage: InputStage) => void
68
- readonly selected: number
69
- readonly setSelected: (update: (value: number) => number) => void
70
- readonly setSkipInputs: (update: (value: number) => number) => void
71
- readonly sshActive: boolean
72
- readonly setSshActive: (active: boolean) => void
73
- readonly state: MenuState
74
- readonly runner: MenuRunner
75
- readonly exit: () => void
76
- }
77
-
78
- const activateInput = (
79
- input: string,
80
- key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
81
- context: Pick<MenuInputContext, "inputStage" | "setInputStage">
82
- ): { readonly activated: boolean; readonly allowProcessing: boolean } => {
83
- if (context.inputStage === "active") {
84
- return { activated: false, allowProcessing: true }
85
- }
86
-
87
- if (input.trim().length > 0) {
88
- context.setInputStage("active")
89
- return { activated: true, allowProcessing: true }
90
- }
91
-
92
- if (key.upArrow || key.downArrow || key.return) {
93
- context.setInputStage("active")
94
- return { activated: true, allowProcessing: false }
95
- }
96
-
97
- if (input.length > 0) {
98
- context.setInputStage("active")
99
- return { activated: true, allowProcessing: true }
100
- }
101
-
102
- return { activated: false, allowProcessing: false }
103
- }
104
-
105
- const shouldHandleMenuInput = (
106
- input: string,
107
- key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
108
- context: Pick<MenuInputContext, "inputStage" | "setInputStage">
109
- ): boolean => {
110
- const activation = activateInput(input, key, context)
111
- if (activation.activated && !activation.allowProcessing) {
112
- return false
113
- }
114
- return activation.allowProcessing
115
- }
116
-
117
- const handleUserInput = (
118
- input: string,
119
- key: MenuKeyInput,
120
- context: MenuInputContext
121
- ) => {
122
- if (context.busy) {
123
- return
124
- }
125
- if (context.sshActive) {
126
- return
127
- }
128
- if (context.view._tag === "Menu") {
129
- if (!shouldHandleMenuInput(input, key, context)) {
130
- return
131
- }
132
- handleMenuInput(input, key, {
133
- selected: context.selected,
134
- setSelected: context.setSelected,
135
- state: context.state,
136
- runner: context.runner,
137
- exit: context.exit,
138
- setView: context.setView,
139
- setMessage: context.setMessage
140
- })
141
- return
142
- }
143
-
144
- if (context.view._tag === "Create") {
145
- handleCreateInput(input, key, context.view, {
146
- state: context.state,
147
- setView: context.setView,
148
- setMessage: context.setMessage,
149
- runner: context.runner,
150
- setActiveDir: context.setActiveDir
151
- })
152
- return
153
- }
154
-
155
- handleSelectInput(input, key, context.view, {
156
- setView: context.setView,
157
- setMessage: context.setMessage,
158
- setActiveDir: context.setActiveDir,
159
- activeDir: context.state.activeDir,
160
- runner: context.runner,
161
- setSshActive: context.setSshActive,
162
- setSkipInputs: context.setSkipInputs
163
- })
164
- }
165
-
166
55
  type RenderContext = {
167
56
  readonly state: MenuState
168
57
  readonly view: ViewState
169
58
  readonly activeDir: string | null
59
+ readonly runningDockerGitContainers: number
170
60
  readonly selected: number
171
61
  readonly busy: boolean
172
62
  readonly message: string | null
@@ -174,7 +64,14 @@ type RenderContext = {
174
64
 
175
65
  const renderView = (context: RenderContext) => {
176
66
  if (context.view._tag === "Menu") {
177
- return renderMenu(context.state.cwd, context.activeDir, context.selected, context.busy, context.message)
67
+ return renderMenu({
68
+ cwd: context.state.cwd,
69
+ activeDir: context.activeDir,
70
+ runningDockerGitContainers: context.runningDockerGitContainers,
71
+ selected: context.selected,
72
+ busy: context.busy,
73
+ message: context.message
74
+ })
178
75
  }
179
76
 
180
77
  if (context.view._tag === "Create") {
@@ -198,6 +95,7 @@ const renderView = (context: RenderContext) => {
198
95
 
199
96
  const useMenuState = () => {
200
97
  const [activeDir, setActiveDir] = useState<string | null>(null)
98
+ const [runningDockerGitContainers, setRunningDockerGitContainers] = useState(0)
201
99
  const [selected, setSelected] = useState(0)
202
100
  const [busy, setBusy] = useState(false)
203
101
  const [message, setMessage] = useState<string | null>(null)
@@ -213,6 +111,8 @@ const useMenuState = () => {
213
111
  return {
214
112
  activeDir,
215
113
  setActiveDir,
114
+ runningDockerGitContainers,
115
+ setRunningDockerGitContainers,
216
116
  selected,
217
117
  setSelected,
218
118
  busy,
@@ -245,6 +145,41 @@ const useReadyGate = (setReady: (ready: boolean) => void) => {
245
145
  }, [setReady])
246
146
  }
247
147
 
148
+ const useStartupSnapshot = (
149
+ setActiveDir: (value: string | null) => void,
150
+ setRunningDockerGitContainers: (value: number) => void,
151
+ setMessage: (message: string | null) => void
152
+ ) => {
153
+ useEffect(() => {
154
+ let cancelled = false
155
+
156
+ const startup = pipe(
157
+ Effect.all([listProjectItems, runDockerPsNames(process.cwd())]),
158
+ Effect.map(([items, runningNames]) => resolveMenuStartupSnapshot(items, runningNames)),
159
+ Effect.match({
160
+ onFailure: () => defaultMenuStartupSnapshot(),
161
+ onSuccess: (snapshot) => snapshot
162
+ }),
163
+ Effect.provide(NodeContext.layer)
164
+ )
165
+
166
+ void Effect.runPromise(startup).then((snapshot) => {
167
+ if (cancelled) {
168
+ return
169
+ }
170
+ setRunningDockerGitContainers(snapshot.runningDockerGitContainers)
171
+ setMessage(snapshot.message)
172
+ if (snapshot.activeDir !== null) {
173
+ setActiveDir(snapshot.activeDir)
174
+ }
175
+ })
176
+
177
+ return () => {
178
+ cancelled = true
179
+ }
180
+ }, [setActiveDir, setMessage, setRunningDockerGitContainers])
181
+ }
182
+
248
183
  const useSigintGuard = (exit: () => void, sshActive: boolean) => {
249
184
  useEffect(() => {
250
185
  const handleSigint = () => {
@@ -265,6 +200,7 @@ const TuiApp = () => {
265
200
  const menu = useMenuState()
266
201
 
267
202
  useReadyGate(menu.setReady)
203
+ useStartupSnapshot(menu.setActiveDir, menu.setRunningDockerGitContainers, menu.setMessage)
268
204
  useSigintGuard(exit, menu.sshActive)
269
205
 
270
206
  useInput(
@@ -304,6 +240,7 @@ const TuiApp = () => {
304
240
  state: menu.state,
305
241
  view: menu.view,
306
242
  activeDir: menu.activeDir,
243
+ runningDockerGitContainers: menu.runningDockerGitContainers,
307
244
  selected: menu.selected,
308
245
  busy: menu.busy,
309
246
  message: menu.message
@@ -0,0 +1,24 @@
1
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
2
+
3
+ export const makeProjectItem = (
4
+ overrides: Partial<ProjectItem> = {}
5
+ ): ProjectItem => ({
6
+ projectDir: "/home/dev/.docker-git/org-repo",
7
+ displayName: "org/repo",
8
+ repoUrl: "https://github.com/org/repo.git",
9
+ repoRef: "main",
10
+ containerName: "dg-repo",
11
+ serviceName: "dg-repo",
12
+ sshUser: "dev",
13
+ sshPort: 2222,
14
+ targetDir: "/home/dev/org/repo",
15
+ sshCommand: "ssh -p 2222 dev@localhost",
16
+ sshKeyPath: null,
17
+ authorizedKeysPath: "/home/dev/.docker-git/org-repo/.docker-git/authorized_keys",
18
+ authorizedKeysExists: true,
19
+ envGlobalPath: "/home/dev/.orch/env/global.env",
20
+ envProjectPath: "/home/dev/.docker-git/org-repo/.orch/env/project.env",
21
+ codexAuthPath: "/home/dev/.orch/auth/codex",
22
+ codexHome: "/home/dev/.codex",
23
+ ...overrides
24
+ })
@@ -2,28 +2,10 @@ import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
 
4
4
  import type { ProjectItem } from "@effect-template/lib/usecases/projects"
5
+
5
6
  import { selectHint } from "../../src/docker-git/menu-render-select.js"
6
7
  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
- })
8
+ import { makeProjectItem } from "./fixtures/project-item.js"
27
9
 
28
10
  const record = (events: Array<string>, entry: string): Effect.Effect<void> =>
29
11
  Effect.sync(() => {
@@ -35,16 +17,25 @@ const makeConnectDeps = (events: Array<string>) => ({
35
17
  enableMcpPlaywright: (projectDir: string) => record(events, `enable:${projectDir}`)
36
18
  })
37
19
 
20
+ const workspaceProject = () =>
21
+ makeProjectItem({
22
+ projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo",
23
+ authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.docker-git/authorized_keys",
24
+ envGlobalPath: "/home/dev/provercoderai/docker-git/.orch/env/global.env",
25
+ envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env",
26
+ codexAuthPath: "/home/dev/provercoderai/docker-git/.orch/auth/codex"
27
+ })
28
+
38
29
  describe("menu-select-connect", () => {
39
30
  it("runs Playwright enable before SSH when toggle is ON", () => {
40
- const item = makeProjectItem()
31
+ const item = workspaceProject()
41
32
  const events: Array<string> = []
42
33
  Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events)))
43
34
  expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`])
44
35
  })
45
36
 
46
37
  it("skips Playwright enable when toggle is OFF", () => {
47
- const item = makeProjectItem()
38
+ const item = workspaceProject()
48
39
  const events: Array<string> = []
49
40
  Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events)))
50
41
  expect(events).toEqual([`connect:${item.projectDir}`])
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { resolveMenuStartupSnapshot } from "../../src/docker-git/menu-startup.js"
4
+ import { makeProjectItem } from "./fixtures/project-item.js"
5
+
6
+ describe("menu-startup", () => {
7
+ it("returns empty snapshot when no docker-git containers are running", () => {
8
+ const snapshot = resolveMenuStartupSnapshot([makeProjectItem({})], ["postgres", "redis"])
9
+
10
+ expect(snapshot).toEqual({
11
+ activeDir: null,
12
+ runningDockerGitContainers: 0,
13
+ message: null
14
+ })
15
+ })
16
+
17
+ it("auto-selects active project when exactly one known docker-git container is running", () => {
18
+ const item = makeProjectItem({})
19
+ const snapshot = resolveMenuStartupSnapshot([item], [item.containerName])
20
+
21
+ expect(snapshot.activeDir).toBe(item.projectDir)
22
+ expect(snapshot.runningDockerGitContainers).toBe(1)
23
+ expect(snapshot.message).toContain(item.displayName)
24
+ })
25
+
26
+ it("does not auto-select when multiple docker-git containers are running", () => {
27
+ const first = makeProjectItem({
28
+ containerName: "dg-one",
29
+ displayName: "org/one",
30
+ projectDir: "/home/dev/.docker-git/org-one"
31
+ })
32
+ const second = makeProjectItem({
33
+ containerName: "dg-two",
34
+ displayName: "org/two",
35
+ projectDir: "/home/dev/.docker-git/org-two"
36
+ })
37
+ const snapshot = resolveMenuStartupSnapshot([first, second], [first.containerName, second.containerName])
38
+
39
+ expect(snapshot.activeDir).toBeNull()
40
+ expect(snapshot.runningDockerGitContainers).toBe(2)
41
+ expect(snapshot.message).toContain("Use Select project")
42
+ })
43
+
44
+ it("shows warning when running docker-git containers have no matching configs", () => {
45
+ const snapshot = resolveMenuStartupSnapshot([], ["dg-unknown", "dg-another"])
46
+
47
+ expect(snapshot.activeDir).toBeNull()
48
+ expect(snapshot.runningDockerGitContainers).toBe(2)
49
+ expect(snapshot.message).toContain("No matching project config found")
50
+ })
51
+ })