@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.
@@ -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 => ({ running: false, sshSessions: 0 })
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(runtimeForProject(runtimeByProject, item))}]`
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
- export const renderMenu = (
107
- cwd: string,
108
- activeDir: string | null,
109
- selected: number,
110
- busy: boolean,
111
- message: string | null
112
- ): React.ReactElement => {
106
+ type MenuRenderInput = {
107
+ readonly cwd: string
108
+ readonly activeDir: string | null
109
+ readonly runningDockerGitContainers: number
110
+ readonly selected: number
111
+ readonly busy: boolean
112
+ readonly message: string | null
113
+ }
114
+
115
+ export const renderMenu = (input: MenuRenderInput): React.ReactElement => {
116
+ const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input
113
117
  const el = React.createElement
114
118
  const activeLabel = `Active: ${activeDir ?? "(none)"}`
119
+ const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`
115
120
  const cwdLabel = `CWD: ${cwd}`
116
121
  const items = menuItems.map((item, index) => {
117
122
  const indexLabel = `${index + 1})`
@@ -134,6 +139,7 @@ export const renderMenu = (
134
139
  "docker-git",
135
140
  compactElements([
136
141
  el(Text, null, activeLabel),
142
+ el(Text, null, runningLabel),
137
143
  el(Text, null, cwdLabel),
138
144
  el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
139
145
  hints,
@@ -0,0 +1,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 => ({ running: false, sshSessions: 0 })
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: stopped containers always have sshSessions = 0
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
- if (!running) {
72
- const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()]
73
- return Effect.succeed(entry)
74
- }
116
+ const sshSessionsEffect = running
117
+ ? countContainerSshSessions(item.containerName)
118
+ : Effect.succeed(0)
75
119
  return pipe(
76
- countContainerSshSessions(item.containerName),
77
- Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })),
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 }> = [