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