diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts index 5b97ba02..17bef476 100644 --- a/packages/app/src/docker-git/menu-render-select.ts +++ b/packages/app/src/docker-git/menu-render-select.ts @@ -97,6 +97,30 @@ export const buildSelectLabels = ( return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}` }) +export type SelectListWindow = { + readonly start: number + readonly end: number +} + +export const buildSelectListWindow = ( + total: number, + selected: number, + maxVisible: number +): SelectListWindow => { + if (total <= 0) { + return { start: 0, end: 0 } + } + const visible = Math.max(1, maxVisible) + if (total <= visible) { + return { start: 0, end: total } + } + const boundedSelected = Math.min(Math.max(selected, 0), total - 1) + const half = Math.floor(visible / 2) + const maxStart = total - visible + const start = Math.min(Math.max(boundedSelected - half, 0), maxStart) + return { start, end: start + visible } +} + type SelectDetailsContext = { readonly item: ProjectItem readonly refLabel: string diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 6fe4b360..d074dc53 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -6,6 +6,7 @@ import type { ProjectItem } from "@effect-template/lib/usecases/projects" import { renderLayout } from "./menu-render-layout.js" import { buildSelectLabels, + buildSelectListWindow, renderSelectDetails, selectHint, type SelectPurpose, @@ -162,6 +163,22 @@ const computeListWidth = (labels: ReadonlyArray): number => { return Math.min(Math.max(maxLabelWidth + 2, 28), 54) } +const readStdoutRows = (): number | null => { + const rows = process.stdout.rows + if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) { + return null + } + return rows +} + +const computeSelectListMaxRows = (): number => { + const rows = readStdoutRows() + if (rows === null) { + return 12 + } + return Math.max(6, rows - 14) +} + const renderSelectListBox = ( el: typeof React.createElement, items: ReadonlyArray, @@ -169,8 +186,13 @@ const renderSelectListBox = ( labels: ReadonlyArray, width: number ): React.ReactElement => { - const list = labels.map((label, index) => - el( + const window = buildSelectListWindow(labels.length, selected, computeSelectListMaxRows()) + const hiddenAbove = window.start + const hiddenBelow = labels.length - window.end + const visibleLabels = labels.slice(window.start, window.end) + const list = visibleLabels.map((label, offset) => { + const index = window.start + offset + return el( Text, { key: items[index]?.projectDir ?? String(index), @@ -179,12 +201,22 @@ const renderSelectListBox = ( }, label ) - ) + }) + + const before = hiddenAbove > 0 + ? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenAbove} more above`)] + : [] + const after = hiddenBelow > 0 + ? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenBelow} more below`)] + : [] + const listBody = list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")] return el( Box, { flexDirection: "column", width }, - ...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")]) + ...before, + ...listBody, + ...after ) } diff --git a/packages/app/tests/docker-git/menu-select-order.test.ts b/packages/app/tests/docker-git/menu-select-order.test.ts index 3c1b344f..3b821111 100644 --- a/packages/app/tests/docker-git/menu-select-order.test.ts +++ b/packages/app/tests/docker-git/menu-select-order.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js" +import { buildSelectLabels, buildSelectListWindow } 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" @@ -70,4 +70,15 @@ describe("menu-select order", () => { 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") }) + + it("keeps full list visible when projects fit into viewport", () => { + const window = buildSelectListWindow(8, 3, 12) + expect(window).toEqual({ start: 0, end: 8 }) + }) + + it("computes a scrolling window around selected project", () => { + expect(buildSelectListWindow(30, 0, 10)).toEqual({ start: 0, end: 10 }) + expect(buildSelectListWindow(30, 15, 10)).toEqual({ start: 10, end: 20 }) + expect(buildSelectListWindow(30, 29, 10)).toEqual({ start: 20, end: 30 }) + }) }) diff --git a/packages/docker-git/tests/core/templates.test.ts b/packages/docker-git/tests/core/templates.test.ts index b17bb4ad..27618821 100644 --- a/packages/docker-git/tests/core/templates.test.ts +++ b/packages/docker-git/tests/core/templates.test.ts @@ -78,6 +78,9 @@ describe("planFiles", () => { expect(entrypointSpec.contents).toContain( "push contains commit updating managed issue block in AGENTS.md" ) + expect(entrypointSpec.contents).toContain("docker_git_short_pwd()") + expect(entrypointSpec.contents).toContain("local base=\"[\\t] $short_pwd\"") + expect(entrypointSpec.contents).toContain("local base=\"[%*] $short_pwd\"") expect(entrypointSpec.contents).toContain("CACHE_ROOT=\"/home/dev/.docker-git/.cache/git-mirrors\"") expect(entrypointSpec.contents).toContain("PACKAGE_CACHE_ROOT=\"/home/dev/.docker-git/.cache/packages\"") expect(entrypointSpec.contents).toContain("npm_config_store_dir") diff --git a/packages/lib/src/core/templates-prompt.ts b/packages/lib/src/core/templates-prompt.ts index 2db68c67..09868417 100644 --- a/packages/lib/src/core/templates-prompt.ts +++ b/packages/lib/src/core/templates-prompt.ts @@ -8,12 +8,64 @@ // EFFECT: n/a // INVARIANT: script is deterministic // COMPLEXITY: O(1) -export const renderPromptScript = (): string => - `docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +const dockerGitPromptScript = `docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_short_pwd() { + local full_path + full_path="\${PWD:-}" + if [[ -z "$full_path" ]]; then + printf "%s" "?" + return + fi + + local display="$full_path" + if [[ -n "\${HOME:-}" && "$full_path" == "$HOME" ]]; then + display="~" + elif [[ -n "\${HOME:-}" && "$full_path" == "$HOME/"* ]]; then + display="~/\${full_path#$HOME/}" + fi + + if [[ "$display" == "~" || "$display" == "/" ]]; then + printf "%s" "$display" + return + fi + + local prefix="" + local body="$display" + if [[ "$body" == "~/"* ]]; then + prefix="~/" + body="\${body#~/}" + elif [[ "$body" == /* ]]; then + prefix="/" + body="\${body#/}" + fi + + local result="$prefix" + local segment="" + local rest="$body" + while [[ "$rest" == */* ]]; do + segment="\${rest%%/*}" + rest="\${rest#*/}" + if [[ -n "$segment" ]]; then + result+="\${segment:0:1}/" + fi + done + + if [[ -n "$rest" ]]; then + result+="$rest" + elif [[ "$result" == "~/" ]]; then + result="~" + elif [[ -z "$result" ]]; then + result="/" + fi + + printf "%s" "$result" +} docker_git_prompt_apply() { local b b="$(docker_git_branch)" - local base="[\\t] \\w" + local short_pwd + short_pwd="$(docker_git_short_pwd)" + local base="[\\t] $short_pwd" if [ -n "$b" ]; then PS1="\${base} (\${b})> " else @@ -26,6 +78,8 @@ else PROMPT_COMMAND="docker_git_prompt_apply" fi` +export const renderPromptScript = (): string => dockerGitPromptScript + // CHANGE: enable bash completion for interactive shells // WHY: allow tab completion for CLI tools in SSH terminals // QUOTE(ТЗ): "А почему у меня не работает автодополенние в терминале?" @@ -124,10 +178,68 @@ zstyle ':completion:*' tag-order builtins commands aliases reserved-words functi autoload -Uz add-zsh-hook docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; } +docker_git_short_pwd() { + local full_path="\${PWD:-}" + if [[ -z "$full_path" ]]; then + print -r -- "?" + return + fi + + local display="$full_path" + if [[ -n "\${HOME:-}" && "$full_path" == "$HOME" ]]; then + display="~" + elif [[ -n "\${HOME:-}" && "$full_path" == "$HOME/"* ]]; then + display="~/\${full_path#$HOME/}" + fi + + if [[ "$display" == "~" || "$display" == "/" ]]; then + print -r -- "$display" + return + fi + + local prefix="" + local body="$display" + if [[ "$body" == "~/"* ]]; then + prefix="~/" + body="\${body#~/}" + elif [[ "$body" == /* ]]; then + prefix="/" + body="\${body#/}" + fi + + local -a parts + local result="$prefix" + parts=(\${(s:/:)body}) + local total=\${#parts[@]} + local idx=1 + local part="" + for part in "\${parts[@]}"; do + if [[ -z "$part" ]]; then + ((idx++)) + continue + fi + if (( idx < total )); then + result+="\${part[1,1]}/" + else + result+="$part" + fi + ((idx++)) + done + + if [[ -z "$result" ]]; then + result="/" + elif [[ "$result" == "~/" ]]; then + result="~" + fi + + print -r -- "$result" +} docker_git_prompt_apply() { local b b="$(docker_git_branch)" - local base="[%*] %~" + local short_pwd + short_pwd="$(docker_git_short_pwd)" + local base="[%*] $short_pwd" if [[ -n "$b" ]]; then PROMPT="$base ($b)> " else diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index f26eee67..1987e7e8 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -440,3 +440,76 @@ export const runDockerPsNames = ( .filter((line) => line.length > 0) ) ) + +const publishedHostPortPattern = /:(\d+)->/g + +const parsePublishedHostPortsFromLine = (line: string): ReadonlyArray => { + const parsed: Array = [] + for (const match of line.matchAll(publishedHostPortPattern)) { + const rawPort = match[1] + if (rawPort === undefined) { + continue + } + const value = Number.parseInt(rawPort, 10) + if (Number.isInteger(value) && value > 0 && value <= 65_535) { + parsed.push(value) + } + } + return parsed +} + +// CHANGE: decode published host ports from `docker ps --format "{{.Ports}}"` output +// WHY: Docker can reserve host ports via NAT even when no host TCP socket is visible +// QUOTE(ТЗ): "должен просто новый порт брать под себя" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in parse(output): published_by_docker(p) +// PURITY: CORE +// EFFECT: Effect, never, never> +// INVARIANT: returns unique ports in encounter order +// COMPLEXITY: O(|output|) +export const parseDockerPublishedHostPorts = (output: string): ReadonlyArray => { + const unique = new Set() + const parsed: Array = [] + + for (const line of output.split(/\r?\n/)) { + const trimmed = line.trim() + if (trimmed.length === 0) { + continue + } + for (const port of parsePublishedHostPortsFromLine(trimmed)) { + if (!unique.has(port)) { + unique.add(port) + parsed.push(port) + } + } + } + + return parsed +} + +// CHANGE: read currently published Docker host ports from running containers +// WHY: avoid false "free port" results when Docker reserves ports without userland proxy sockets +// QUOTE(ТЗ): "а не сражаться за старый" +// REF: user-request-2026-02-19-port-allocation +// SOURCE: n/a +// FORMAT THEOREM: forall p in result: published_by_running_container(p) +// PURITY: SHELL +// EFFECT: Effect, CommandFailedError | PlatformError, CommandExecutor> +// INVARIANT: output ports are unique +// COMPLEXITY: O(command + |stdout|) +export const runDockerPsPublishedHostPorts = ( + cwd: string +): Effect.Effect, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => + pipe( + runCommandCapture( + { + cwd, + command: "docker", + args: ["ps", "--format", "{{.Ports}}"] + }, + [Number(ExitCode(0))], + (exitCode) => new CommandFailedError({ command: "docker ps", exitCode }) + ), + Effect.map((output) => parseDockerPublishedHostPorts(output)) + ) diff --git a/packages/lib/src/usecases/actions/ports.ts b/packages/lib/src/usecases/actions/ports.ts index 6467833f..1bcb1487 100644 --- a/packages/lib/src/usecases/actions/ports.ts +++ b/packages/lib/src/usecases/actions/ports.ts @@ -1,3 +1,4 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" @@ -15,7 +16,7 @@ export const resolveSshPort = ( ): Effect.Effect< CreateCommand["config"], PortProbeError | PlatformError, - FileSystem.FileSystem | Path.Path + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor > => Effect.gen(function*(_) { const reserved = yield* _(loadReservedPorts(outDir)) diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 8b6bb1ff..4ebe7f78 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -60,7 +60,8 @@ const renderPrimaryError = (error: NonParseError): string | null => Match.when({ _tag: "DockerCommandError" }, ({ exitCode }) => [ `docker compose failed with exit code ${exitCode}`, - "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group)." + "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).", + "Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port (for example --ssh-port 2235), or stop the conflicting project/container." ].join("\n")), Match.when({ _tag: "DockerAccessError" }, ({ details, issue }) => [ diff --git a/packages/lib/src/usecases/ports-reserve.ts b/packages/lib/src/usecases/ports-reserve.ts index 66bbc5c2..ccc7f10e 100644 --- a/packages/lib/src/usecases/ports-reserve.ts +++ b/packages/lib/src/usecases/ports-reserve.ts @@ -1,8 +1,10 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import type { FileSystem } from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" -import { Effect, Option } from "effect" +import { Effect, Either, Option } from "effect" +import { runDockerPsPublishedHostPorts } from "../shell/docker.js" import { PortProbeError } from "../shell/errors.js" import { isPortAvailable } from "../shell/ports.js" import { listProjectItems } from "./projects-list.js" @@ -12,6 +14,8 @@ export type ReservedPort = { readonly projectDir: string } +const dockerPublishedMarker = "" + const resolveExclude = ( path: Path.Path, excludeDir: string | null @@ -29,6 +33,34 @@ const filterReserved = ( return path.resolve(item.projectDir) !== resolvedExclude } +const reservePort = ( + reserved: Array, + seen: Set, + port: number, + projectDir: string +): void => { + if (seen.has(port)) { + return + } + seen.add(port) + reserved.push({ port, projectDir }) +} + +const loadPublishedDockerPorts = (): Effect.Effect, never, CommandExecutor.CommandExecutor> => + Effect.either(runDockerPsPublishedHostPorts(process.cwd())).pipe( + Effect.flatMap( + Either.match({ + onLeft: (error) => + Effect.logWarning( + `Failed to read published Docker ports; falling back to TCP probing only: ${ + error instanceof Error ? error.message : String(error) + }` + ).pipe(Effect.as(new Set())), + onRight: (ports) => Effect.succeed(new Set(ports)) + }) + ) + ) + // CHANGE: collect SSH ports currently occupied by existing docker-git projects // WHY: avoid port collisions while allowing reuse of ports from stopped projects // QUOTE(ТЗ): "для каждого докера брать должен свой порт" @@ -36,7 +68,7 @@ const filterReserved = ( // SOURCE: n/a // FORMAT THEOREM: ∀p∈Projects: reserved(port(p)) // PURITY: SHELL -// EFFECT: Effect, PlatformError | PortProbeError, FileSystem | Path.Path> +// EFFECT: Effect, PlatformError | PortProbeError, FileSystem | Path.Path | CommandExecutor> // INVARIANT: excludes the current project dir when provided // COMPLEXITY: O(n) where n = number of projects export const loadReservedPorts = ( @@ -44,23 +76,31 @@ export const loadReservedPorts = ( ): Effect.Effect< ReadonlyArray, PlatformError | PortProbeError, - FileSystem | Path.Path + FileSystem | Path.Path | CommandExecutor.CommandExecutor > => Effect.gen(function*(_) { const path = yield* _(Path.Path) const items = yield* _(listProjectItems) + const publishedByDocker = yield* _(loadPublishedDockerPorts()) const reserved: Array = [] + const seen = new Set() const filter = filterReserved(path, excludeDir) for (const item of items) { if (!filter(item)) { continue } - if (!(yield* _(isPortAvailable(item.sshPort)))) { - reserved.push({ port: item.sshPort, projectDir: item.projectDir }) + const occupiedByDocker = publishedByDocker.has(item.sshPort) + const occupiedBySocket = occupiedByDocker ? false : !(yield* _(isPortAvailable(item.sshPort))) + if (occupiedByDocker || occupiedBySocket) { + reservePort(reserved, seen, item.sshPort, item.projectDir) } } + for (const port of publishedByDocker) { + reservePort(reserved, seen, port, dockerPublishedMarker) + } + return reserved }) diff --git a/packages/lib/src/usecases/projects-up.ts b/packages/lib/src/usecases/projects-up.ts index 53cf6a58..5513362a 100644 --- a/packages/lib/src/usecases/projects-up.ts +++ b/packages/lib/src/usecases/projects-up.ts @@ -33,7 +33,7 @@ const maxPortAttempts = 25 // SOURCE: n/a // FORMAT THEOREM: ∀p: reserved(p) ∨ occupied(p) → selected(p') ∧ available(p') // PURITY: SHELL -// EFFECT: Effect +// EFFECT: Effect // INVARIANT: config is rewritten when port changes // COMPLEXITY: O(n) where n = maxPortAttempts const ensureAvailableSshPort = ( @@ -42,7 +42,7 @@ const ensureAvailableSshPort = ( ): Effect.Effect< TemplateConfig, PortProbeError | PlatformError | FileExistsError, - FileSystem | Path + FileSystem | Path | CommandExecutor > => Effect.gen(function*(_) { const reserved = yield* _(loadReservedPorts(projectDir)) diff --git a/packages/lib/tests/shell/docker.test.ts b/packages/lib/tests/shell/docker.test.ts index bc3c49fd..4f5917cd 100644 --- a/packages/lib/tests/shell/docker.test.ts +++ b/packages/lib/tests/shell/docker.test.ts @@ -1,9 +1,26 @@ import { describe, expect, it } from "@effect/vitest" -import { dockerComposeUpRecreateArgs } from "../../src/shell/docker.js" +import { dockerComposeUpRecreateArgs, parseDockerPublishedHostPorts } from "../../src/shell/docker.js" describe("docker compose args", () => { it("uses build when force-env recreates containers", () => { expect(dockerComposeUpRecreateArgs).toEqual(["up", "-d", "--build", "--force-recreate"]) }) }) + +describe("parseDockerPublishedHostPorts", () => { + it("extracts unique published host ports from docker ps output", () => { + const output = [ + "127.0.0.1:2222->22/tcp", + "0.0.0.0:5672->5672/tcp, [::]:5672->5672/tcp", + "5900/tcp, 6080/tcp, 9223/tcp", + ":::8080->80/tcp" + ].join("\n") + + expect(parseDockerPublishedHostPorts(output)).toEqual([2222, 5672, 8080]) + }) + + it("returns empty array when no host ports are published", () => { + expect(parseDockerPublishedHostPorts("5900/tcp, 6080/tcp")).toEqual([]) + }) +}) diff --git a/packages/lib/tests/usecases/errors.test.ts b/packages/lib/tests/usecases/errors.test.ts index 90355df8..3726f0b7 100644 --- a/packages/lib/tests/usecases/errors.test.ts +++ b/packages/lib/tests/usecases/errors.test.ts @@ -9,6 +9,8 @@ describe("renderError", () => { expect(message).toContain("docker compose failed with exit code 1") expect(message).toContain("/var/run/docker.sock") + expect(message).toContain("port is already allocated") + expect(message).toContain("--ssh-port") }) it("renders actionable recovery for DockerAccessError", () => {