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
24 changes: 24 additions & 0 deletions packages/app/src/docker-git/menu-render-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions packages/app/src/docker-git/menu-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,15 +163,36 @@ const computeListWidth = (labels: ReadonlyArray<string>): 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<ProjectItem>,
selected: number,
labels: ReadonlyArray<string>,
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),
Expand All @@ -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
)
}

Expand Down
13 changes: 12 additions & 1 deletion packages/app/tests/docker-git/menu-select-order.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 })
})
})
3 changes: 3 additions & 0 deletions packages/docker-git/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
120 changes: 116 additions & 4 deletions packages/lib/src/core/templates-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(ТЗ): "А почему у меня не работает автодополенние в терминале?"
Expand Down Expand Up @@ -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
Expand Down