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
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/menu-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/p
import { Effect, Match, pipe } from "effect"

import { startCreateView } from "./menu-create.js"
import { loadSelectView } from "./menu-select.js"
import { loadSelectView } from "./menu-select-load.js"
import { resumeTui, suspendTui } from "./menu-shared.js"
import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"

Expand Down
37 changes: 33 additions & 4 deletions packages/app/src/docker-git/menu-render-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,42 @@ const formatRepoRef = (repoRef: string): string => {
return trimmed.length > 0 ? trimmed : "main"
}

const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
const stoppedRuntime = (): SelectProjectRuntime => ({
running: false,
sshSessions: 0,
startedAtIso: null,
startedAtEpochMs: null
})

const pad2 = (value: number): string => value.toString().padStart(2, "0")

const formatUtcTimestamp = (epochMs: number, withSeconds: boolean): string => {
const date = new Date(epochMs)
const seconds = withSeconds ? `:${pad2(date.getUTCSeconds())}` : ""
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${
pad2(
date.getUTCHours()
)
}:${pad2(date.getUTCMinutes())}${seconds} UTC`
}

const renderStartedAtCompact = (runtime: SelectProjectRuntime): string =>
runtime.startedAtEpochMs === null ? "-" : formatUtcTimestamp(runtime.startedAtEpochMs, false)

const renderStartedAtDetailed = (runtime: SelectProjectRuntime): string =>
runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true)

const runtimeForProject = (
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
item: ProjectItem
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? stoppedRuntime()

const renderRuntimeLabel = (runtime: SelectProjectRuntime): string =>
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}`
`${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${
renderStartedAtCompact(
runtime
)
}`

export const selectTitle = (purpose: SelectPurpose): string =>
Match.value(purpose).pipe(
Expand Down Expand Up @@ -61,9 +88,10 @@ export const buildSelectLabels = (
items.map((item, index) => {
const prefix = index === selected ? ">" : " "
const refLabel = formatRepoRef(item.repoRef)
const runtime = runtimeForProject(runtimeByProject, item)
const runtimeSuffix = purpose === "Down" || purpose === "Delete"
? ` [${renderRuntimeLabel(runtimeForProject(runtimeByProject, item))}]`
: ""
? ` [${renderRuntimeLabel(runtime)}]`
: ` [started=${renderStartedAtCompact(runtime)}]`
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
})

Expand Down Expand Up @@ -101,6 +129,7 @@ const commonRows = (
el(Text, { wrap: "wrap" }, `Project directory: ${context.item.projectDir}`),
el(Text, { wrap: "wrap" }, `Container: ${context.item.containerName}`),
el(Text, { wrap: "wrap" }, `State: ${context.runtime.running ? "running" : "stopped"}`),
el(Text, { wrap: "wrap" }, `Started at: ${renderStartedAtDetailed(context.runtime)}`),
el(Text, { wrap: "wrap" }, `SSH sessions now: ${context.sshSessionsLabel}`)
]

Expand Down
33 changes: 33 additions & 0 deletions packages/app/src/docker-git/menu-select-load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
import { Effect, pipe } from "effect"

import { loadRuntimeByProject } from "./menu-select-runtime.js"
import { startSelectView } from "./menu-select.js"
import type { MenuEnv, MenuViewContext } from "./menu-types.js"

export const loadSelectView = <E>(
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
purpose: "Connect" | "Down" | "Info" | "Delete",
context: Pick<MenuViewContext, "setView" | "setMessage">
): Effect.Effect<void, E, MenuEnv> =>
pipe(
effect,
Effect.flatMap((items) =>
pipe(
loadRuntimeByProject(items),
Effect.flatMap((runtimeByProject) =>
Effect.sync(() => {
if (items.length === 0) {
context.setMessage(
purpose === "Down"
? "No running docker-git containers."
: "No docker-git projects found."
)
return
}
startSelectView(items, purpose, context, runtimeByProject)
})
)
)
)
)
37 changes: 37 additions & 0 deletions packages/app/src/docker-git/menu-select-order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ProjectItem } from "@effect-template/lib/usecases/projects"

import type { SelectProjectRuntime } from "./menu-types.js"

const defaultRuntime = (): SelectProjectRuntime => ({
running: false,
sshSessions: 0,
startedAtIso: null,
startedAtEpochMs: null
})

const runtimeForSort = (
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>,
item: ProjectItem
): SelectProjectRuntime => runtimeByProject[item.projectDir] ?? defaultRuntime()

const startedAtEpochForSort = (runtime: SelectProjectRuntime): number =>
runtime.startedAtEpochMs ?? Number.NEGATIVE_INFINITY

export const sortItemsByLaunchTime = (
items: ReadonlyArray<ProjectItem>,
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
): ReadonlyArray<ProjectItem> =>
items.toSorted((left, right) => {
const leftRuntime = runtimeForSort(runtimeByProject, left)
const rightRuntime = runtimeForSort(runtimeByProject, right)
const leftStartedAt = startedAtEpochForSort(leftRuntime)
const rightStartedAt = startedAtEpochForSort(rightRuntime)

if (leftStartedAt !== rightStartedAt) {
return rightStartedAt - leftStartedAt
}
if (leftRuntime.running !== rightRuntime.running) {
return leftRuntime.running ? -1 : 1
}
return left.displayName.localeCompare(right.displayName)
})
69 changes: 59 additions & 10 deletions packages/app/src/docker-git/menu-select-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,20 @@ import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js"

const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})

const stoppedRuntime = (): SelectProjectRuntime => ({ running: false, sshSessions: 0 })
const stoppedRuntime = (): SelectProjectRuntime => ({
running: false,
sshSessions: 0,
startedAtIso: null,
startedAtEpochMs: null
})

const countSshSessionsScript = "who -u 2>/dev/null | wc -l | tr -d '[:space:]'"
const dockerZeroStartedAt = "0001-01-01T00:00:00Z"

type ContainerStartTime = {
readonly startedAtIso: string
readonly startedAtEpochMs: number
}

const parseSshSessionCount = (raw: string): number => {
const parsed = Number.parseInt(raw.trim(), 10)
Expand All @@ -19,6 +30,21 @@ const parseSshSessionCount = (raw: string): number => {
return parsed
}

const parseContainerStartedAt = (raw: string): ContainerStartTime | null => {
const trimmed = raw.trim()
if (trimmed.length === 0 || trimmed === dockerZeroStartedAt) {
return null
}
const startedAtEpochMs = Date.parse(trimmed)
if (Number.isNaN(startedAtEpochMs)) {
return null
}
return {
startedAtIso: trimmed,
startedAtEpochMs
}
}

const toRuntimeMap = (
entries: ReadonlyArray<readonly [string, SelectProjectRuntime]>
): Readonly<Record<string, SelectProjectRuntime>> => {
Expand Down Expand Up @@ -48,16 +74,35 @@ const countContainerSshSessions = (
})
)

const inspectContainerStartedAt = (
containerName: string
): Effect.Effect<ContainerStartTime | null, never, MenuEnv> =>
pipe(
runCommandCapture(
{
cwd: process.cwd(),
command: "docker",
args: ["inspect", "--format", "{{.State.StartedAt}}", containerName]
},
[0],
(exitCode) => ({ _tag: "CommandFailedError", command: "docker inspect .State.StartedAt", exitCode })
),
Effect.match({
onFailure: () => null,
onSuccess: (raw) => parseContainerStartedAt(raw)
})
)

// CHANGE: enrich select items with runtime state and SSH session counts
// WHY: prevent stopping/deleting containers that are currently used via SSH
// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас"
// REF: issue-47
// SOURCE: n/a
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p)}
// FORMAT THEOREM: forall p: runtime(p) -> {running(p), ssh_sessions(p), started_at(p)}
// PURITY: SHELL
// EFFECT: Effect<Record<string, SelectProjectRuntime>, never, MenuEnv>
// INVARIANT: stopped containers always have sshSessions = 0
// COMPLEXITY: O(n + docker_ps + docker_exec)
// INVARIANT: projects without a known container start have startedAt = null
// COMPLEXITY: O(n + docker_ps + docker_exec + docker_inspect)
export const loadRuntimeByProject = (
items: ReadonlyArray<ProjectItem>
): Effect.Effect<Readonly<Record<string, SelectProjectRuntime>>, never, MenuEnv> =>
Expand All @@ -68,13 +113,17 @@ export const loadRuntimeByProject = (
items,
(item) => {
const running = runningNames.includes(item.containerName)
if (!running) {
const entry: readonly [string, SelectProjectRuntime] = [item.projectDir, stoppedRuntime()]
return Effect.succeed(entry)
}
const sshSessionsEffect = running
? countContainerSshSessions(item.containerName)
: Effect.succeed(0)
return pipe(
countContainerSshSessions(item.containerName),
Effect.map((sshSessions): SelectProjectRuntime => ({ running: true, sshSessions })),
Effect.all([sshSessionsEffect, inspectContainerStartedAt(item.containerName)]),
Effect.map(([sshSessions, startedAt]): SelectProjectRuntime => ({
running,
sshSessions,
startedAtIso: startedAt?.startedAtIso ?? null,
startedAtEpochMs: startedAt?.startedAtEpochMs ?? null
})),
Effect.map((runtime): readonly [string, SelectProjectRuntime] => [item.projectDir, runtime])
)
},
Expand Down
33 changes: 3 additions & 30 deletions packages/app/src/docker-git/menu-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import {
listRunningProjectItems,
type ProjectItem
} from "@effect-template/lib/usecases/projects"

import { Effect, Match, pipe } from "effect"

import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
import { sortItemsByLaunchTime } from "./menu-select-order.js"
import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
import type {
Expand All @@ -37,11 +36,12 @@ export const startSelectView = (
context: Pick<SelectContext, "setView" | "setMessage">,
runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
) => {
const sortedItems = sortItemsByLaunchTime(items, runtimeByProject)
context.setMessage(null)
context.setView({
_tag: "SelectProject",
purpose,
items,
items: sortedItems,
runtimeByProject,
selected: 0,
confirmDelete: false,
Expand Down Expand Up @@ -289,30 +289,3 @@ const handleSelectReturn = (
Match.exhaustive
)
}

export const loadSelectView = <E>(
effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
purpose: "Connect" | "Down" | "Info" | "Delete",
context: Pick<SelectContext, "setView" | "setMessage">
): Effect.Effect<void, E, MenuEnv> =>
pipe(
effect,
Effect.flatMap((items) =>
pipe(
loadRuntimeByProject(items),
Effect.flatMap((runtimeByProject) =>
Effect.sync(() => {
if (items.length === 0) {
context.setMessage(
purpose === "Down"
? "No running docker-git containers."
: "No docker-git projects found."
)
return
}
startSelectView(items, purpose, context, runtimeByProject)
})
)
)
)
)
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export type ViewState =
export type SelectProjectRuntime = {
readonly running: boolean
readonly sshSessions: number
readonly startedAtIso: string | null
readonly startedAtEpochMs: number | null
}

export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
Expand Down
73 changes: 73 additions & 0 deletions packages/app/tests/docker-git/menu-select-order.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest"

import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js"
import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js"
import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js"
import { makeProjectItem } from "./fixtures/project-item.js"

const makeRuntime = (
overrides: Partial<SelectProjectRuntime> = {}
): SelectProjectRuntime => ({
running: false,
sshSessions: 0,
startedAtIso: null,
startedAtEpochMs: null,
...overrides
})

const emitProof = (message: string): void => {
process.stdout.write(`[issue-57-proof] ${message}\n`)
}

describe("menu-select order", () => {
it("sorts projects by last container start time (newest first)", () => {
const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" })
const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" })
const neverStarted = makeProjectItem({ projectDir: "/home/dev/.docker-git/never", displayName: "org/never" })
const startedNewest = "2026-02-17T11:30:00Z"
const startedOlder = "2026-02-16T07:15:00Z"
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
[newest.projectDir]: makeRuntime({
running: true,
sshSessions: 1,
startedAtIso: startedNewest,
startedAtEpochMs: Date.parse(startedNewest)
}),
[older.projectDir]: makeRuntime({
running: true,
sshSessions: 0,
startedAtIso: startedOlder,
startedAtEpochMs: Date.parse(startedOlder)
}),
[neverStarted.projectDir]: makeRuntime()
}

const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject)
expect(sorted.map((item) => item.projectDir)).toEqual([
newest.projectDir,
older.projectDir,
neverStarted.projectDir
])
emitProof("sorting by launch time works: newest container is selected first")
})

it("shows container launch timestamp in select labels", () => {
const item = makeProjectItem({ projectDir: "/home/dev/.docker-git/example", displayName: "org/example" })
const startedAtIso = "2026-02-17T09:45:00Z"
const runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = {
[item.projectDir]: makeRuntime({
running: true,
sshSessions: 2,
startedAtIso,
startedAtEpochMs: Date.parse(startedAtIso)
})
}

const connectLabel = buildSelectLabels([item], 0, "Connect", runtimeByProject)[0]
const downLabel = buildSelectLabels([item], 0, "Down", runtimeByProject)[0]

expect(connectLabel).toContain("[started=2026-02-17 09:45 UTC]")
expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC")
emitProof("UI labels show container start timestamp in Connect and Down views")
})
})