@prover-coder-ai/docker-git 1.0.15 → 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 +6 -0
- package/dist/src/docker-git/menu-actions.js +1 -1
- package/dist/src/docker-git/menu-render-select.js +19 -4
- 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/package.json +1 -1
- package/src/docker-git/menu-actions.ts +1 -1
- package/src/docker-git/menu-render-select.ts +33 -4
- 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-types.ts +2 -0
- package/tests/docker-git/menu-select-order.test.ts +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,7 @@ import { downAllDockerGitProjects, listProjectItems, listProjectStatus, listRunn
|
|
|
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
|
|
@@ -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) => [
|
|
@@ -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
|
-
})))));
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/p
|
|
|
13
13
|
import { Effect, Match, pipe } from "effect"
|
|
14
14
|
|
|
15
15
|
import { startCreateView } from "./menu-create.js"
|
|
16
|
-
import { loadSelectView } from "./menu-select.js"
|
|
16
|
+
import { loadSelectView } from "./menu-select-load.js"
|
|
17
17
|
import { resumeTui, suspendTui } from "./menu-shared.js"
|
|
18
18
|
import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
|
|
19
19
|
|
|
@@ -18,7 +18,30 @@ const formatRepoRef = (repoRef: string): string => {
|
|
|
18
18
|
return trimmed.length > 0 ? trimmed : "main"
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
21
|
+
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
22
|
+
running: false,
|
|
23
|
+
sshSessions: 0,
|
|
24
|
+
startedAtIso: null,
|
|
25
|
+
startedAtEpochMs: null
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const pad2 = (value: number): string => value.toString().padStart(2, "0")
|
|
29
|
+
|
|
30
|
+
const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => {
|
|
31
|
+
const date = new Date(epochMs)
|
|
32
|
+
const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : ""
|
|
33
|
+
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${
|
|
34
|
+
pad2(
|
|
35
|
+
date.getUTCHours()
|
|
36
|
+
)
|
|
37
|
+
}:${pad2(date.getUTCMinutes())}${seconds} UTC`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderStartedAtCompact = (runtime: SelectProjectRuntime): string =>
|
|
41
|
+
runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false)
|
|
42
|
+
|
|
43
|
+
const renderStartedAtDetailed = (runtime: SelectProjectRuntime): string =>
|
|
44
|
+
runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true)
|
|
22
45
|
|
|
23
46
|
const runtimeForProject = (
|
|
24
47
|
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
@@ -26,7 +49,11 @@ const runtimeForProject = (
|
|
|
26
49
|
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()
|
|
27
50
|
|
|
28
51
|
const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
|
|
29
|
-
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}
|
|
52
|
+
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${
|
|
53
|
+
renderStartedAtCompact(
|
|
54
|
+
runtime
|
|
55
|
+
)
|
|
56
|
+
}`
|
|
30
57
|
|
|
31
58
|
export const selectTitle = (purpose: SelectPurpose): string =>
|
|
32
59
|
Match.value(purpose).pipe(
|
|
@@ -61,9 +88,10 @@ export const buildSelectLabels = (
|
|
|
61
88
|
items.map((item, index) => {
|
|
62
89
|
const prefix = index === selected ? ">" : " "
|
|
63
90
|
const refLabel = formatRepoRef(item.repoRef)
|
|
91
|
+
const runtime = runtimeForProject(runtimeByProject, item)
|
|
64
92
|
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
|
|
65
|
-
? ` [${renderRuntimeLabel(
|
|
66
|
-
:
|
|
93
|
+
? ` [${renderRuntimeLabel(runtime)}]`
|
|
94
|
+
: ` [started=${renderStartedAtCompact(runtime)}]`
|
|
67
95
|
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
|
|
68
96
|
})
|
|
69
97
|
|
|
@@ -101,6 +129,7 @@ const commonRows = (
|
|
|
101
129
|
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
|
|
102
130
|
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
|
|
103
131
|
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
|
|
132
|
+
el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
|
|
104
133
|
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
|
|
105
134
|
]
|
|
106
135
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
2
|
+
import { Effect, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
import { loadRuntimeByProject } from "./menu-select-runtime.js"
|
|
5
|
+
import { startSelectView } from "./menu-select.js"
|
|
6
|
+
import type { MenuEnv, MenuViewContext } from "./menu-types.js"
|
|
7
|
+
|
|
8
|
+
export const loadSelectView = <E>(
|
|
9
|
+
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
|
|
10
|
+
purpose: "Connect" | "Down" | "Info" | "Delete",
|
|
11
|
+
context: Pick<MenuViewContext, "setView" | "setMessage">
|
|
12
|
+
): Effect.Effect<void, E, MenuEnv> =>
|
|
13
|
+
pipe(
|
|
14
|
+
effect,
|
|
15
|
+
Effect.flatMap((items) =>
|
|
16
|
+
pipe(
|
|
17
|
+
loadRuntimeByProject(items),
|
|
18
|
+
Effect.flatMap((runtimeByProject) =>
|
|
19
|
+
Effect.sync(() => {
|
|
20
|
+
if (items.length === 0) {
|
|
21
|
+
context.setMessage(
|
|
22
|
+
purpose === "Down"
|
|
23
|
+
? "No running docker-git containers."
|
|
24
|
+
: "No docker-git projects found."
|
|
25
|
+
)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
startSelectView(items, purpose, context, runtimeByProject)
|
|
29
|
+
})
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
2
|
+
|
|
3
|
+
import type { SelectProjectRuntime } from "./menu-types.js"
|
|
4
|
+
|
|
5
|
+
const defaultRuntime = (): SelectProjectRuntime => ({
|
|
6
|
+
running: false,
|
|
7
|
+
sshSessions: 0,
|
|
8
|
+
startedAtIso: null,
|
|
9
|
+
startedAtEpochMs: null
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const runtimeForSort = (
|
|
13
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
|
|
14
|
+
item: ProjectItem
|
|
15
|
+
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? defaultRuntime()
|
|
16
|
+
|
|
17
|
+
const startedAtEpochForSort = (runtime: SelectProjectRuntime): number =>
|
|
18
|
+
runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY
|
|
19
|
+
|
|
20
|
+
export const sortItemsByLaunchTime = (
|
|
21
|
+
items: ReadonlyArray<ProjectItem>,
|
|
22
|
+
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
|
|
23
|
+
): ReadonlyArray<ProjectItem> =>
|
|
24
|
+
items.toSorted((left, right) => {
|
|
25
|
+
const leftRuntime = runtimeForSort(runtimeByProject, left)
|
|
26
|
+
const rightRuntime = runtimeForSort(runtimeByProject, right)
|
|
27
|
+
const leftStartedAt = startedAtEpochForSort(leftRuntime)
|
|
28
|
+
const rightStartedAt = startedAtEpochForSort(rightRuntime)
|
|
29
|
+
|
|
30
|
+
if (leftStartedAt !== rightStartedAt) {
|
|
31
|
+
return rightStartedAt - leftStartedAt
|
|
32
|
+
}
|
|
33
|
+
if (leftRuntime.running !== rightRuntime.running) {
|
|
34
|
+
return leftRuntime.running ? -1 : 1
|
|
35
|
+
}
|
|
36
|
+
return left.displayName.localeCompare(right.displayName)
|
|
37
|
+
})
|
|
@@ -7,9 +7,20 @@ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js"
|
|
|
7
7
|
|
|
8
8
|
const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
|
|
9
9
|
|
|
10
|
-
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
10
|
+
const stoppedRuntime = (): SelectProjectRuntime => ({
|
|
11
|
+
running: false,
|
|
12
|
+
sshSessions: 0,
|
|
13
|
+
startedAtIso: null,
|
|
14
|
+
startedAtEpochMs: null
|
|
15
|
+
})
|
|
11
16
|
|
|
12
17
|
const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'"
|
|
18
|
+
const dockerZeroStartedAt = "0001-01-01T00:00:00Z"
|
|
19
|
+
|
|
20
|
+
type ContainerStartTime = {
|
|
21
|
+
readonly startedAtIso: string
|
|
22
|
+
readonly startedAtEpochMs: number
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
const parseSshSessionCount = (raw: string): number => {
|
|
15
26
|
const parsed = Number.parseInt(raw.trim(), 10)
|
|
@@ -19,6 +30,21 @@ const parseSshSessionCount = (raw: string): number => {
|
|
|
19
30
|
return parsed
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
const parseContainerStartedAt = (raw: string): ContainerStartTime | null => {
|
|
34
|
+
const trimmed = raw.trim()
|
|
35
|
+
if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
const startedAtEpochMs = Date.parse(trimmed)
|
|
39
|
+
if (Number.isNaN(startedAtEpochMs)) {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
startedAtIso: trimmed,
|
|
44
|
+
startedAtEpochMs
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
const toRuntimeMap = (
|
|
23
49
|
entries: ReadonlyArray<readonly [string, SelectProjectRuntime]>
|
|
24
50
|
): Readonly<Record<string, SelectProjectRuntime>> => {
|
|
@@ -48,16 +74,35 @@ const countContainerSshSessions = (
|
|
|
48
74
|
})
|
|
49
75
|
)
|
|
50
76
|
|
|
77
|
+
const inspectContainerStartedAt = (
|
|
78
|
+
containerName: string
|
|
79
|
+
): Effect.Effect<ContainerStartTime | null, never, MenuEnv> =>
|
|
80
|
+
pipe(
|
|
81
|
+
runCommandCapture(
|
|
82
|
+
{
|
|
83
|
+
cwd: process.cwd(),
|
|
84
|
+
command: "docker",
|
|
85
|
+
args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
|
|
86
|
+
},
|
|
87
|
+
[0],
|
|
88
|
+
(exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })
|
|
89
|
+
),
|
|
90
|
+
Effect.match({
|
|
91
|
+
onFailure: () => null,
|
|
92
|
+
onSuccess: (raw) => parseContainerStartedAt(raw)
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
51
96
|
// CHANGE: enrich select items with runtime state and SSH session counts
|
|
52
97
|
// WHY: prevent stopping/deleting containers that are currently used via SSH
|
|
53
98
|
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
|
|
54
99
|
// REF: issue-47
|
|
55
100
|
// SOURCE: n/a
|
|
56
|
-
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
|
|
101
|
+
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
|
|
57
102
|
// PURITY: SHELL
|
|
58
103
|
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
|
|
59
|
-
// INVARIANT:
|
|
60
|
-
// COMPLEXITY: O(n + docker_ps + docker_exec)
|
|
104
|
+
// INVARIANT: projects without a known container start have startedAt = null
|
|
105
|
+
// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
|
|
61
106
|
export const loadRuntimeByProject = (
|
|
62
107
|
items: ReadonlyArray<ProjectItem>
|
|
63
108
|
): Effect.Effect<Readonly<Record<string, SelectProjectRuntime>>, never, MenuEnv> =>
|
|
@@ -68,13 +113,17 @@ export const loadRuntimeByProject = (
|
|
|
68
113
|
items,
|
|
69
114
|
(item) => {
|
|
70
115
|
const running = runningNames.includes(item.containerName)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
116
|
+
const sshSessionsEffect = running
|
|
117
|
+
? countContainerSshSessions(item.containerName)
|
|
118
|
+
: Effect.succeed(0)
|
|
75
119
|
return pipe(
|
|
76
|
-
|
|
77
|
-
Effect.map((sshSessions): SelectProjectRuntime => ({
|
|
120
|
+
Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]),
|
|
121
|
+
Effect.map(([sshSessions, startedAt]): SelectProjectRuntime => ({
|
|
122
|
+
running,
|
|
123
|
+
sshSessions,
|
|
124
|
+
startedAtIso: startedAt?.startedAtIso ?? null,
|
|
125
|
+
startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
|
|
126
|
+
})),
|
|
78
127
|
Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime])
|
|
79
128
|
)
|
|
80
129
|
},
|
|
@@ -7,10 +7,9 @@ import {
|
|
|
7
7
|
listRunningProjectItems,
|
|
8
8
|
type ProjectItem
|
|
9
9
|
} from "@effect-template/lib/usecases/projects"
|
|
10
|
-
|
|
11
10
|
import { Effect, Match, pipe } from "effect"
|
|
12
|
-
|
|
13
11
|
import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
|
|
12
|
+
import { sortItemsByLaunchTime } from "./menu-select-order.js"
|
|
14
13
|
import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
|
|
15
14
|
import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
|
|
16
15
|
import type {
|
|
@@ -37,11 +36,12 @@ export const startSelectView = (
|
|
|
37
36
|
context: Pick<SelectContext, "setView" | "setMessage">,
|
|
38
37
|
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
|
|
39
38
|
) => {
|
|
39
|
+
const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
|
|
40
40
|
context.setMessage(null)
|
|
41
41
|
context.setView({
|
|
42
42
|
_tag: "SelectProject",
|
|
43
43
|
purpose,
|
|
44
|
-
items,
|
|
44
|
+
items: sortedItems,
|
|
45
45
|
runtimeByProject,
|
|
46
46
|
selected: 0,
|
|
47
47
|
confirmDelete: false,
|
|
@@ -289,30 +289,3 @@ const handleSelectReturn = (
|
|
|
289
289
|
Match.exhaustive
|
|
290
290
|
)
|
|
291
291
|
}
|
|
292
|
-
|
|
293
|
-
export const loadSelectView = <E>(
|
|
294
|
-
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
|
|
295
|
-
purpose: "Connect" | "Down" | "Info" | "Delete",
|
|
296
|
-
context: Pick<SelectContext, "setView" | "setMessage">
|
|
297
|
-
): Effect.Effect<void, E, MenuEnv> =>
|
|
298
|
-
pipe(
|
|
299
|
-
effect,
|
|
300
|
-
Effect.flatMap((items) =>
|
|
301
|
-
pipe(
|
|
302
|
-
loadRuntimeByProject(items),
|
|
303
|
-
Effect.flatMap((runtimeByProject) =>
|
|
304
|
-
Effect.sync(() => {
|
|
305
|
-
if (items.length === 0) {
|
|
306
|
-
context.setMessage(
|
|
307
|
-
purpose === "Down"
|
|
308
|
-
? "No running docker-git containers."
|
|
309
|
-
: "No docker-git projects found."
|
|
310
|
-
)
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
startSelectView(items, purpose, context, runtimeByProject)
|
|
314
|
-
})
|
|
315
|
-
)
|
|
316
|
-
)
|
|
317
|
-
)
|
|
318
|
-
)
|
|
@@ -86,6 +86,8 @@ export type ViewState =
|
|
|
86
86
|
export type SelectProjectRuntime = {
|
|
87
87
|
readonly running: boolean
|
|
88
88
|
readonly sshSessions: number
|
|
89
|
+
readonly startedAtIso: string | null
|
|
90
|
+
readonly startedAtEpochMs: number | null
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js"
|
|
4
|
+
import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js"
|
|
5
|
+
import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js"
|
|
6
|
+
import { makeProjectItem } from "./fixtures/project-item.js"
|
|
7
|
+
|
|
8
|
+
const makeRuntime = (
|
|
9
|
+
overrides: Partial<SelectProjectRuntime> = {}
|
|
10
|
+
): SelectProjectRuntime => ({
|
|
11
|
+
running: false,
|
|
12
|
+
sshSessions: 0,
|
|
13
|
+
startedAtIso: null,
|
|
14
|
+
startedAtEpochMs: null,
|
|
15
|
+
...overrides
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emitProof = (message: string): void => {
|
|
19
|
+
process.stdout.write(`[issue-57-proof] ${message}\n`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("menu-select order", () => {
|
|
23
|
+
it("sorts projects by last container start time (newest first)", () => {
|
|
24
|
+
const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" })
|
|
25
|
+
const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" })
|
|
26
|
+
const neverStarted = makeProjectItem({ projectDir: "/home/dev/.docker-git/never", displayName: "org/never" })
|
|
27
|
+
const startedNewest = "2026-02-17T11:30:00Z"
|
|
28
|
+
const startedOlder = "2026-02-16T07:15:00Z"
|
|
29
|
+
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
|
|
30
|
+
[newest.projectDir]: makeRuntime({
|
|
31
|
+
running: true,
|
|
32
|
+
sshSessions: 1,
|
|
33
|
+
startedAtIso: startedNewest,
|
|
34
|
+
startedAtEpochMs: Date.parse(startedNewest)
|
|
35
|
+
}),
|
|
36
|
+
[older.projectDir]: makeRuntime({
|
|
37
|
+
running: true,
|
|
38
|
+
sshSessions: 0,
|
|
39
|
+
startedAtIso: startedOlder,
|
|
40
|
+
startedAtEpochMs: Date.parse(startedOlder)
|
|
41
|
+
}),
|
|
42
|
+
[neverStarted.projectDir]: makeRuntime()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject)
|
|
46
|
+
expect(sorted.map((item) => item.projectDir)).toEqual([
|
|
47
|
+
newest.projectDir,
|
|
48
|
+
older.projectDir,
|
|
49
|
+
neverStarted.projectDir
|
|
50
|
+
])
|
|
51
|
+
emitProof("sorting by launch time works: newest container is selected first")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("shows container launch timestamp in select labels", () => {
|
|
55
|
+
const item = makeProjectItem({ projectDir: "/home/dev/.docker-git/example", displayName: "org/example" })
|
|
56
|
+
const startedAtIso = "2026-02-17T09:45:00Z"
|
|
57
|
+
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
|
|
58
|
+
[item.projectDir]: makeRuntime({
|
|
59
|
+
running: true,
|
|
60
|
+
sshSessions: 2,
|
|
61
|
+
startedAtIso,
|
|
62
|
+
startedAtEpochMs: Date.parse(startedAtIso)
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const connectLabel = buildSelectLabels([item], 0, "Connect", runtimeByProject)[0]
|
|
67
|
+
const downLabel = buildSelectLabels([item], 0, "Down", runtimeByProject)[0]
|
|
68
|
+
|
|
69
|
+
expect(connectLabel).toContain("[started=2026-02-17 09:45 UTC]")
|
|
70
|
+
expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC")
|
|
71
|
+
emitProof("UI labels show container start timestamp in Connect and Down views")
|
|
72
|
+
})
|
|
73
|
+
})
|