Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/app/src/docker-git/menu-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { renderError } from "@effect-template/lib/usecases/errors"
import {
downAllDockerGitProjects,
listProjectItems,
listProjectStatus,
listRunningProjectItems
} from "@effect-template/lib/usecases/projects"
import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
Expand Down Expand Up @@ -195,6 +196,10 @@ const runDeleteAction = (context: MenuContext) => {
}

const runComposeAction = (action: MenuAction, context: MenuContext) => {
if (action._tag === "Status" && context.state.activeDir === null) {
runWithSuspendedTui(listProjectStatus, context, "docker compose ps (all projects)")
return
}
if (!requireActiveProject(context)) {
return
}
Expand Down
107 changes: 107 additions & 0 deletions packages/app/src/docker-git/menu-input-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { handleCreateInput } from "./menu-create.js"
import { handleMenuInput } from "./menu-menu.js"
import { handleSelectInput } from "./menu-select.js"
import type { MenuKeyInput, MenuRunner, MenuState, MenuViewContext, ViewState } from "./menu-types.js"

export type InputStage = "cold" | "active"

export type MenuInputContext = MenuViewContext & {
readonly busy: boolean
readonly view: ViewState
readonly inputStage: InputStage
readonly setInputStage: (stage: InputStage) => void
readonly selected: number
readonly setSelected: (update: (value: number) => number) => void
readonly setSkipInputs: (update: (value: number) => number) => void
readonly sshActive: boolean
readonly setSshActive: (active: boolean) => void
readonly state: MenuState
readonly runner: MenuRunner
readonly exit: () => void
}

const activateInput = (
input: string,
key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
context: Pick<MenuInputContext, "inputStage" | "setInputStage">
): { readonly activated: boolean; readonly allowProcessing: boolean } => {
if (context.inputStage === "active") {
return { activated: false, allowProcessing: true }
}

if (input.trim().length > 0) {
context.setInputStage("active")
return { activated: true, allowProcessing: true }
}

if (key.upArrow || key.downArrow || key.return) {
context.setInputStage("active")
return { activated: true, allowProcessing: false }
}

if (input.length > 0) {
context.setInputStage("active")
return { activated: true, allowProcessing: true }
}

return { activated: false, allowProcessing: false }
}

const shouldHandleMenuInput = (
input: string,
key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
context: Pick<MenuInputContext, "inputStage" | "setInputStage">
): boolean => {
const activation = activateInput(input, key, context)
if (activation.activated && !activation.allowProcessing) {
return false
}
return activation.allowProcessing
}

export const handleUserInput = (
input: string,
key: MenuKeyInput,
context: MenuInputContext
) => {
if (context.busy || context.sshActive) {
return
}

if (context.view._tag === "Menu") {
if (!shouldHandleMenuInput(input, key, context)) {
return
}
handleMenuInput(input, key, {
selected: context.selected,
setSelected: context.setSelected,
state: context.state,
runner: context.runner,
exit: context.exit,
setView: context.setView,
setMessage: context.setMessage
})
return
}

if (context.view._tag === "Create") {
handleCreateInput(input, key, context.view, {
state: context.state,
setView: context.setView,
setMessage: context.setMessage,
runner: context.runner,
setActiveDir: context.setActiveDir
})
return
}

handleSelectInput(input, key, context.view, {
setView: context.setView,
setMessage: context.setMessage,
setActiveDir: context.setActiveDir,
activeDir: context.state.activeDir,
runner: context.runner,
setSshActive: context.setSshActive,
setSkipInputs: context.setSkipInputs
})
}
20 changes: 13 additions & 7 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,20 @@ const renderMenuMessage = (
)
}

export const renderMenu = (
cwd: string,
activeDir: string | null,
selected: number,
busy: boolean,
message: string | null
): React.ReactElement => {
type MenuRenderInput = {
readonly cwd: string
readonly activeDir: string | null
readonly runningDockerGitContainers: number
readonly selected: number
readonly busy: boolean
readonly message: string | null
}

export const renderMenu = (input: MenuRenderInput): React.ReactElement => {
const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input
const el = React.createElement
const activeLabel = `Active: ${activeDir ?? "(none)"}`
const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}`
const cwdLabel = `CWD: ${cwd}`
const items = menuItems.map((item, index) => {
const indexLabel = `${index + 1})`
Expand All @@ -134,6 +139,7 @@ export const renderMenu = (
"docker-git",
compactElements([
el(Text, null, activeLabel),
el(Text, null, runningLabel),
el(Text, null, cwdLabel),
el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
hints,
Expand Down
83 changes: 83 additions & 0 deletions packages/app/src/docker-git/menu-startup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { ProjectItem } from "@effect-template/lib/usecases/projects"

export type MenuStartupSnapshot = {
readonly activeDir: string | null
readonly runningDockerGitContainers: number
readonly message: string | null
}

const dockerGitContainerPrefix = "dg-"

const emptySnapshot = (): MenuStartupSnapshot => ({
activeDir: null,
runningDockerGitContainers: 0,
message: null
})

const uniqueDockerGitContainerNames = (
runningContainerNames: ReadonlyArray<string>
): ReadonlyArray<string> => [
...new Set(runningContainerNames.filter((name) => name.startsWith(dockerGitContainerPrefix)))
]

const detectKnownRunningProjects = (
items: ReadonlyArray<ProjectItem>,
runningDockerGitNames: ReadonlyArray<string>
): ReadonlyArray<ProjectItem> => {
const runningSet = new Set(runningDockerGitNames)
return items.filter((item) => runningSet.has(item.containerName))
}

const renderRunningHint = (runningCount: number): string =>
runningCount === 1
? "Detected 1 running docker-git container."
: `Detected ${runningCount} running docker-git containers.`

// CHANGE: infer initial menu state from currently running docker-git containers
// WHY: avoid "(none)" confusion when containers are already up outside this TUI session
// QUOTE(ISSUE): "У меня запущены контейнеры от docker-git но он говорит что они не запущены"
// REF: issue-13
// SOURCE: n/a
// FORMAT THEOREM: forall startupState: snapshot(startupState) -> deterministic(menuState)
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: activeDir is set only when exactly one known project is running
// COMPLEXITY: O(|containers| + |projects|)
export const resolveMenuStartupSnapshot = (
items: ReadonlyArray<ProjectItem>,
runningContainerNames: ReadonlyArray<string>
): MenuStartupSnapshot => {
const runningDockerGitNames = uniqueDockerGitContainerNames(runningContainerNames)
if (runningDockerGitNames.length === 0) {
return emptySnapshot()
}

const knownRunningProjects = detectKnownRunningProjects(items, runningDockerGitNames)
if (knownRunningProjects.length === 1 && runningDockerGitNames.length === 1) {
const selected = knownRunningProjects[0]
if (!selected) {
return emptySnapshot()
}
return {
activeDir: selected.projectDir,
runningDockerGitContainers: 1,
message: `Auto-selected active project: ${selected.displayName}.`
}
}

if (knownRunningProjects.length === 0) {
return {
activeDir: null,
runningDockerGitContainers: runningDockerGitNames.length,
message: `${renderRunningHint(runningDockerGitNames.length)} No matching project config found.`
}
}

return {
activeDir: null,
runningDockerGitContainers: runningDockerGitNames.length,
message: `${renderRunningHint(runningDockerGitNames.length)} Use Select project to choose active.`
}
}

export const defaultMenuStartupSnapshot = emptySnapshot
Loading