@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.
- package/.package.json.release.bak +1 -1
- package/CHANGELOG.md +6 -0
- package/dist/src/docker-git/menu-actions.js +5 -1
- package/dist/src/docker-git/menu-input-handler.js +67 -0
- package/dist/src/docker-git/menu-render.js +4 -1
- package/dist/src/docker-git/menu-startup.js +57 -0
- package/dist/src/docker-git/menu.js +39 -70
- package/package.json +1 -1
- package/src/docker-git/menu-actions.ts +5 -0
- package/src/docker-git/menu-input-handler.ts +107 -0
- package/src/docker-git/menu-render.ts +13 -7
- package/src/docker-git/menu-startup.ts +83 -0
- package/src/docker-git/menu.ts +55 -118
- package/tests/docker-git/fixtures/project-item.ts +24 -0
- package/tests/docker-git/menu-select-connect.test.ts +13 -22
- package/tests/docker-git/menu-startup.test.ts +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -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 = (
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
42
|
+
return renderMenu({
|
|
43
|
+
cwd: context.state.cwd,
|
|
44
|
+
activeDir: context.activeDir,
|
|
45
|
+
runningDockerGitContainers: context.runningDockerGitContainers,
|
|
75
46
|
selected: context.selected,
|
|
76
|
-
|
|
77
|
-
|
|
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
|
@@ -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
|
-
|
|
107
|
-
cwd: string
|
|
108
|
-
activeDir: string | null
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
package/src/docker-git/menu.ts
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
+
})
|