@prover-coder-ai/docker-git 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.package.json.release.bak +1 -1
- package/CHANGELOG.md +12 -0
- package/dist/src/docker-git/menu-actions.js +6 -2
- package/dist/src/docker-git/menu-input-handler.js +67 -0
- package/dist/src/docker-git/menu-render-select.js +19 -4
- package/dist/src/docker-git/menu-render.js +4 -1
- package/dist/src/docker-git/menu-select-load.js +12 -0
- package/dist/src/docker-git/menu-select-order.js +21 -0
- package/dist/src/docker-git/menu-select-runtime.js +41 -9
- package/dist/src/docker-git/menu-select.js +3 -10
- 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 +6 -1
- package/src/docker-git/menu-input-handler.ts +107 -0
- package/src/docker-git/menu-render-select.ts +33 -4
- package/src/docker-git/menu-render.ts +13 -7
- package/src/docker-git/menu-select-load.ts +33 -0
- package/src/docker-git/menu-select-order.ts +37 -0
- package/src/docker-git/menu-select-runtime.ts +59 -10
- package/src/docker-git/menu-select.ts +3 -30
- package/src/docker-git/menu-startup.ts +83 -0
- package/src/docker-git/menu-types.ts +2 -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-select-order.test.ts +73 -0
- package/tests/docker-git/menu-startup.test.ts +51 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,11 @@ 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";
|
|
9
|
-
import { loadSelectView } from "./menu-select.js";
|
|
9
|
+
import { loadSelectView } from "./menu-select-load.js";
|
|
10
10
|
import { resumeTui, suspendTui } from "./menu-shared.js";
|
|
11
11
|
import {} from "./menu-types.js";
|
|
12
12
|
// CHANGE: keep menu actions and input parsing in a dedicated module
|
|
@@ -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
|
+
};
|
|
@@ -10,17 +10,31 @@ const formatRepoRef = (repoRef) => {
|
|
|
10
10
|
}
|
|
11
11
|
return trimmed.length > 0 ? trimmed : "main";
|
|
12
12
|
};
|
|
13
|
-
const stoppedRuntime = () => ({
|
|
13
|
+
const stoppedRuntime = () => ({
|
|
14
|
+
running: false,
|
|
15
|
+
sshSessions: 0,
|
|
16
|
+
startedAtIso: null,
|
|
17
|
+
startedAtEpochMs: null
|
|
18
|
+
});
|
|
19
|
+
const pad2 = (value) => value.toString().padStart(2, "0");
|
|
20
|
+
const formatUtcTimestamp = (epochMs, withSeconds) => {
|
|
21
|
+
const date = new Date(epochMs);
|
|
22
|
+
const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : "";
|
|
23
|
+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}${seconds} UTC`;
|
|
24
|
+
};
|
|
25
|
+
const renderStartedAtCompact = (runtime) => runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false);
|
|
26
|
+
const renderStartedAtDetailed = (runtime) => runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true);
|
|
14
27
|
const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
|
|
15
|
-
const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`;
|
|
28
|
+
const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${renderStartedAtCompact(runtime)}`;
|
|
16
29
|
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
30
|
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
31
|
export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
|
|
19
32
|
const prefix = index === selected ? ">" : " ";
|
|
20
33
|
const refLabel = formatRepoRef(item.repoRef);
|
|
34
|
+
const runtime = runtimeForProject(runtimeByProject, item);
|
|
21
35
|
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
|
|
22
|
-
? ` [${renderRuntimeLabel(
|
|
23
|
-
:
|
|
36
|
+
? ` [${renderRuntimeLabel(runtime)}]`
|
|
37
|
+
: ` [started=${renderStartedAtCompact(runtime)}]`;
|
|
24
38
|
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`;
|
|
25
39
|
});
|
|
26
40
|
const buildDetailsContext = (item, runtimeByProject) => {
|
|
@@ -40,6 +54,7 @@ const commonRows = (el, context) => [
|
|
|
40
54
|
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
|
|
41
55
|
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
|
|
42
56
|
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
|
|
57
|
+
el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
|
|
43
58
|
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
|
|
44
59
|
];
|
|
45
60
|
const renderInfoDetails = (el, context, common) => [
|
|
@@ -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,12 @@
|
|
|
1
|
+
import { Effect, pipe } from "effect";
|
|
2
|
+
import { loadRuntimeByProject } from "./menu-select-runtime.js";
|
|
3
|
+
import { startSelectView } from "./menu-select.js";
|
|
4
|
+
export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
|
|
5
|
+
if (items.length === 0) {
|
|
6
|
+
context.setMessage(purpose === "Down"
|
|
7
|
+
? "No running docker-git containers."
|
|
8
|
+
: "No docker-git projects found.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
startSelectView(items, purpose, context, runtimeByProject);
|
|
12
|
+
})))));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const defaultRuntime = () => ({
|
|
2
|
+
running: false,
|
|
3
|
+
sshSessions: 0,
|
|
4
|
+
startedAtIso: null,
|
|
5
|
+
startedAtEpochMs: null
|
|
6
|
+
});
|
|
7
|
+
const runtimeForSort = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? defaultRuntime();
|
|
8
|
+
const startedAtEpochForSort = (runtime) => runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY;
|
|
9
|
+
export const sortItemsByLaunchTime = (items, runtimeByProject) => items.toSorted((left, right) => {
|
|
10
|
+
const leftRuntime = runtimeForSort(runtimeByProject, left);
|
|
11
|
+
const rightRuntime = runtimeForSort(runtimeByProject, right);
|
|
12
|
+
const leftStartedAt = startedAtEpochForSort(leftRuntime);
|
|
13
|
+
const rightStartedAt = startedAtEpochForSort(rightRuntime);
|
|
14
|
+
if (leftStartedAt !== rightStartedAt) {
|
|
15
|
+
return rightStartedAt - leftStartedAt;
|
|
16
|
+
}
|
|
17
|
+
if (leftRuntime.running !== rightRuntime.running) {
|
|
18
|
+
return leftRuntime.running ? -1 : 1;
|
|
19
|
+
}
|
|
20
|
+
return left.displayName.localeCompare(right.displayName);
|
|
21
|
+
});
|
|
@@ -2,8 +2,14 @@ import { runCommandCapture } from "@effect-template/lib/shell/command-runner";
|
|
|
2
2
|
import { runDockerPsNames } from "@effect-template/lib/shell/docker";
|
|
3
3
|
import { Effect, pipe } from "effect";
|
|
4
4
|
const emptyRuntimeByProject = () => ({});
|
|
5
|
-
const stoppedRuntime = () => ({
|
|
5
|
+
const stoppedRuntime = () => ({
|
|
6
|
+
running: false,
|
|
7
|
+
sshSessions: 0,
|
|
8
|
+
startedAtIso: null,
|
|
9
|
+
startedAtEpochMs: null
|
|
10
|
+
});
|
|
6
11
|
const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'";
|
|
12
|
+
const dockerZeroStartedAt = "0001-01-01T00:00:00Z";
|
|
7
13
|
const parseSshSessionCount = (raw) => {
|
|
8
14
|
const parsed = Number.parseInt(raw.trim(), 10);
|
|
9
15
|
if (Number.isNaN(parsed) || parsed < 0) {
|
|
@@ -11,6 +17,20 @@ const parseSshSessionCount = (raw) => {
|
|
|
11
17
|
}
|
|
12
18
|
return parsed;
|
|
13
19
|
};
|
|
20
|
+
const parseContainerStartedAt = (raw) => {
|
|
21
|
+
const trimmed = raw.trim();
|
|
22
|
+
if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const startedAtEpochMs = Date.parse(trimmed);
|
|
26
|
+
if (Number.isNaN(startedAtEpochMs)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
startedAtIso: trimmed,
|
|
31
|
+
startedAtEpochMs
|
|
32
|
+
};
|
|
33
|
+
};
|
|
14
34
|
const toRuntimeMap = (entries) => {
|
|
15
35
|
const runtimeByProject = {};
|
|
16
36
|
for (const [projectDir, runtime] of entries) {
|
|
@@ -26,23 +46,35 @@ const countContainerSshSessions = (containerName) => pipe(runCommandCapture({
|
|
|
26
46
|
onFailure: () => 0,
|
|
27
47
|
onSuccess: (raw) => parseSshSessionCount(raw)
|
|
28
48
|
}));
|
|
49
|
+
const inspectContainerStartedAt = (containerName) => pipe(runCommandCapture({
|
|
50
|
+
cwd: process.cwd(),
|
|
51
|
+
command: "docker",
|
|
52
|
+
args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
|
|
53
|
+
}, [0], (exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })), Effect.match({
|
|
54
|
+
onFailure: () => null,
|
|
55
|
+
onSuccess: (raw) => parseContainerStartedAt(raw)
|
|
56
|
+
}));
|
|
29
57
|
// CHANGE: enrich select items with runtime state and SSH session counts
|
|
30
58
|
// WHY: prevent stopping/deleting containers that are currently used via SSH
|
|
31
59
|
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
|
|
32
60
|
// REF: issue-47
|
|
33
61
|
// SOURCE: n/a
|
|
34
|
-
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
|
|
62
|
+
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
|
|
35
63
|
// PURITY: SHELL
|
|
36
64
|
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
|
|
37
|
-
// INVARIANT:
|
|
38
|
-
// COMPLEXITY: O(n + docker_ps + docker_exec)
|
|
65
|
+
// INVARIANT: projects without a known container start have startedAt = null
|
|
66
|
+
// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
|
|
39
67
|
export const loadRuntimeByProject = (items) => pipe(runDockerPsNames(process.cwd()), Effect.flatMap((runningNames) => Effect.forEach(items, (item) => {
|
|
40
68
|
const running = runningNames.includes(item.containerName);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
const sshSessionsEffect = running
|
|
70
|
+
? countContainerSshSessions(item.containerName)
|
|
71
|
+
: Effect.succeed(0);
|
|
72
|
+
return pipe(Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]), Effect.map(([sshSessions, startedAt]) => ({
|
|
73
|
+
running,
|
|
74
|
+
sshSessions,
|
|
75
|
+
startedAtIso: startedAt?.startedAtIso ?? null,
|
|
76
|
+
startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
|
|
77
|
+
})), Effect.map((runtime) => [item.projectDir, runtime]));
|
|
46
78
|
}, { concurrency: 4 })), Effect.map((entries) => toRuntimeMap(entries)), Effect.match({
|
|
47
79
|
onFailure: () => emptyRuntimeByProject(),
|
|
48
80
|
onSuccess: (runtimeByProject) => runtimeByProject
|
|
@@ -3,15 +3,17 @@ import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright";
|
|
|
3
3
|
import { connectProjectSshWithUp, deleteDockerGitProject, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
|
|
4
4
|
import { Effect, Match, pipe } from "effect";
|
|
5
5
|
import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js";
|
|
6
|
+
import { sortItemsByLaunchTime } from "./menu-select-order.js";
|
|
6
7
|
import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js";
|
|
7
8
|
import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js";
|
|
8
9
|
const emptyRuntimeByProject = () => ({});
|
|
9
10
|
export const startSelectView = (items, purpose, context, runtimeByProject = emptyRuntimeByProject()) => {
|
|
11
|
+
const sortedItems = sortItemsByLaunchTime(items, runtimeByProject);
|
|
10
12
|
context.setMessage(null);
|
|
11
13
|
context.setView({
|
|
12
14
|
_tag: "SelectProject",
|
|
13
15
|
purpose,
|
|
14
|
-
items,
|
|
16
|
+
items: sortedItems,
|
|
15
17
|
runtimeByProject,
|
|
16
18
|
selected: 0,
|
|
17
19
|
confirmDelete: false,
|
|
@@ -158,12 +160,3 @@ const handleSelectReturn = (view, context) => {
|
|
|
158
160
|
runDeleteSelection(selected, context);
|
|
159
161
|
}), Match.exhaustive);
|
|
160
162
|
};
|
|
161
|
-
export const loadSelectView = (effect, purpose, context) => pipe(effect, Effect.flatMap((items) => pipe(loadRuntimeByProject(items), Effect.flatMap((runtimeByProject) => Effect.sync(() => {
|
|
162
|
-
if (items.length === 0) {
|
|
163
|
-
context.setMessage(purpose === "Down"
|
|
164
|
-
? "No running docker-git containers."
|
|
165
|
-
: "No docker-git projects found.");
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
startSelectView(items, purpose, context, runtimeByProject);
|
|
169
|
-
})))));
|
|
@@ -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,13 +6,14 @@ 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"
|
|
12
13
|
import { Effect, Match, pipe } from "effect"
|
|
13
14
|
|
|
14
15
|
import { startCreateView } from "./menu-create.js"
|
|
15
|
-
import { loadSelectView } from "./menu-select.js"
|
|
16
|
+
import { loadSelectView } from "./menu-select-load.js"
|
|
16
17
|
import { resumeTui, suspendTui } from "./menu-shared.js"
|
|
17
18
|
import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
|
|
18
19
|
|
|
@@ -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
|
}
|