@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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -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,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
|
-
)
|
|
@@ -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
|
|
@@ -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 }> = [
|