Skip to content

Commit c426534

Browse files
authored
fix(app): synchronize TUI startup status with running containers (#13) (#52)
* fix(app): sync menu startup with running docker-git containers * fix(ci): address effect lint and stabilize opencode e2e
1 parent 70cfc02 commit c426534

File tree

9 files changed

+353
-148
lines changed

9 files changed

+353
-148
lines changed

packages/app/src/docker-git/menu-actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { renderError } from "@effect-template/lib/usecases/errors"
66
import {
77
downAllDockerGitProjects,
88
listProjectItems,
9+
listProjectStatus,
910
listRunningProjectItems
1011
} from "@effect-template/lib/usecases/projects"
1112
import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
@@ -195,6 +196,10 @@ const runDeleteAction = (context: MenuContext) => {
195196
}
196197

197198
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+
}
198203
if (!requireActiveProject(context)) {
199204
return
200205
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

packages/app/src/docker-git/menu-render.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,15 +103,20 @@ const renderMenuMessage = (
103103
)
104104
}
105105

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
113117
const el = React.createElement
114118
const activeLabel = `Active: ${activeDir ?? "(none)"}`
119+
const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`
115120
const cwdLabel = `CWD: ${cwd}`
116121
const items = menuItems.map((item, index) => {
117122
const indexLabel = `${index + 1})`
@@ -134,6 +139,7 @@ export const renderMenu = (
134139
"docker-git",
135140
compactElements([
136141
el(Text, null, activeLabel),
142+
el(Text, null, runningLabel),
137143
el(Text, null, cwdLabel),
138144
el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
139145
hints,
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

0 commit comments

Comments
 (0)