Skip to content

Commit a2fda75

Browse files
authored
feat(ui): add project list scrolling and shorten prompt paths (#70)
1 parent 3ec21ee commit a2fda75

File tree

5 files changed

+191
-9
lines changed

5 files changed

+191
-9
lines changed

packages/app/src/docker-git/menu-render-select.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,30 @@ export const buildSelectLabels = (
9797
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})${runtimeSuffix}`
9898
})
9999

100+
export type SelectListWindow = {
101+
readonly start: number
102+
readonly end: number
103+
}
104+
105+
export const buildSelectListWindow = (
106+
total: number,
107+
selected: number,
108+
maxVisible: number
109+
): SelectListWindow => {
110+
if (total <= 0) {
111+
return { start: 0, end: 0 }
112+
}
113+
const visible = Math.max(1, maxVisible)
114+
if (total <= visible) {
115+
return { start: 0, end: total }
116+
}
117+
const boundedSelected = Math.min(Math.max(selected, 0), total - 1)
118+
const half = Math.floor(visible / 2)
119+
const maxStart = total - visible
120+
const start = Math.min(Math.max(boundedSelected - half, 0), maxStart)
121+
return { start, end: start + visible }
122+
}
123+
100124
type SelectDetailsContext = {
101125
readonly item: ProjectItem
102126
readonly refLabel: string

packages/app/src/docker-git/menu-render.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
66
import { renderLayout } from "./menu-render-layout.js"
77
import {
88
buildSelectLabels,
9+
buildSelectListWindow,
910
renderSelectDetails,
1011
selectHint,
1112
type SelectPurpose,
@@ -162,15 +163,36 @@ const computeListWidth = (labels: ReadonlyArray<string>): number => {
162163
return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
163164
}
164165

166+
const readStdoutRows = (): number | null => {
167+
const rows = process.stdout.rows
168+
if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) {
169+
return null
170+
}
171+
return rows
172+
}
173+
174+
const computeSelectListMaxRows = (): number => {
175+
const rows = readStdoutRows()
176+
if (rows === null) {
177+
return 12
178+
}
179+
return Math.max(6, rows - 14)
180+
}
181+
165182
const renderSelectListBox = (
166183
el: typeof React.createElement,
167184
items: ReadonlyArray<ProjectItem>,
168185
selected: number,
169186
labels: ReadonlyArray<string>,
170187
width: number
171188
): React.ReactElement => {
172-
const list = labels.map((label, index) =>
173-
el(
189+
const window = buildSelectListWindow(labels.length, selected, computeSelectListMaxRows())
190+
const hiddenAbove = window.start
191+
const hiddenBelow = labels.length - window.end
192+
const visibleLabels = labels.slice(window.start, window.end)
193+
const list = visibleLabels.map((label, offset) => {
194+
const index = window.start + offset
195+
return el(
174196
Text,
175197
{
176198
key: items[index]?.projectDir ?? String(index),
@@ -179,12 +201,22 @@ const renderSelectListBox = (
179201
},
180202
label
181203
)
182-
)
204+
})
205+
206+
const before = hiddenAbove > 0
207+
? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenAbove} more above`)]
208+
: []
209+
const after = hiddenBelow > 0
210+
? [el(Text, { color: "gray", wrap: "truncate" }, `[scroll] ${hiddenBelow} more below`)]
211+
: []
212+
const listBody = list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")]
183213

184214
return el(
185215
Box,
186216
{ flexDirection: "column", width },
187-
...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")])
217+
...before,
218+
...listBody,
219+
...after
188220
)
189221
}
190222

packages/app/tests/docker-git/menu-select-order.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "vitest"
22

3-
import { buildSelectLabels } from "../../src/docker-git/menu-render-select.js"
3+
import { buildSelectLabels, buildSelectListWindow } from "../../src/docker-git/menu-render-select.js"
44
import { sortItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js"
55
import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js"
66
import { makeProjectItem } from "./fixtures/project-item.js"
@@ -70,4 +70,15 @@ describe("menu-select order", () => {
7070
expect(downLabel).toContain("running, ssh=2, started=2026-02-17 09:45 UTC")
7171
emitProof("UI labels show container start timestamp in Connect and Down views")
7272
})
73+
74+
it("keeps full list visible when projects fit into viewport", () => {
75+
const window = buildSelectListWindow(8, 3, 12)
76+
expect(window).toEqual({ start: 0, end: 8 })
77+
})
78+
79+
it("computes a scrolling window around selected project", () => {
80+
expect(buildSelectListWindow(30, 0, 10)).toEqual({ start: 0, end: 10 })
81+
expect(buildSelectListWindow(30, 15, 10)).toEqual({ start: 10, end: 20 })
82+
expect(buildSelectListWindow(30, 29, 10)).toEqual({ start: 20, end: 30 })
83+
})
7384
})

packages/docker-git/tests/core/templates.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ describe("planFiles", () => {
7878
expect(entrypointSpec.contents).toContain(
7979
"push contains commit updating managed issue block in AGENTS.md"
8080
)
81+
expect(entrypointSpec.contents).toContain("docker_git_short_pwd()")
82+
expect(entrypointSpec.contents).toContain("local base=\"[\\t] $short_pwd\"")
83+
expect(entrypointSpec.contents).toContain("local base=\"[%*] $short_pwd\"")
8184
expect(entrypointSpec.contents).toContain("CACHE_ROOT=\"/home/dev/.docker-git/.cache/git-mirrors\"")
8285
expect(entrypointSpec.contents).toContain("PACKAGE_CACHE_ROOT=\"/home/dev/.docker-git/.cache/packages\"")
8386
expect(entrypointSpec.contents).toContain("npm_config_store_dir")

packages/lib/src/core/templates-prompt.ts

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,64 @@
88
// EFFECT: n/a
99
// INVARIANT: script is deterministic
1010
// COMPLEXITY: O(1)
11-
export const renderPromptScript = (): string =>
12-
`docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
11+
const dockerGitPromptScript = `docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
12+
docker_git_short_pwd() {
13+
local full_path
14+
full_path="\${PWD:-}"
15+
if [[ -z "$full_path" ]]; then
16+
printf "%s" "?"
17+
return
18+
fi
19+
20+
local display="$full_path"
21+
if [[ -n "\${HOME:-}" && "$full_path" == "$HOME" ]]; then
22+
display="~"
23+
elif [[ -n "\${HOME:-}" && "$full_path" == "$HOME/"* ]]; then
24+
display="~/\${full_path#$HOME/}"
25+
fi
26+
27+
if [[ "$display" == "~" || "$display" == "/" ]]; then
28+
printf "%s" "$display"
29+
return
30+
fi
31+
32+
local prefix=""
33+
local body="$display"
34+
if [[ "$body" == "~/"* ]]; then
35+
prefix="~/"
36+
body="\${body#~/}"
37+
elif [[ "$body" == /* ]]; then
38+
prefix="/"
39+
body="\${body#/}"
40+
fi
41+
42+
local result="$prefix"
43+
local segment=""
44+
local rest="$body"
45+
while [[ "$rest" == */* ]]; do
46+
segment="\${rest%%/*}"
47+
rest="\${rest#*/}"
48+
if [[ -n "$segment" ]]; then
49+
result+="\${segment:0:1}/"
50+
fi
51+
done
52+
53+
if [[ -n "$rest" ]]; then
54+
result+="$rest"
55+
elif [[ "$result" == "~/" ]]; then
56+
result="~"
57+
elif [[ -z "$result" ]]; then
58+
result="/"
59+
fi
60+
61+
printf "%s" "$result"
62+
}
1363
docker_git_prompt_apply() {
1464
local b
1565
b="$(docker_git_branch)"
16-
local base="[\\t] \\w"
66+
local short_pwd
67+
short_pwd="$(docker_git_short_pwd)"
68+
local base="[\\t] $short_pwd"
1769
if [ -n "$b" ]; then
1870
PS1="\${base} (\${b})> "
1971
else
@@ -26,6 +78,8 @@ else
2678
PROMPT_COMMAND="docker_git_prompt_apply"
2779
fi`
2880

81+
export const renderPromptScript = (): string => dockerGitPromptScript
82+
2983
// CHANGE: enable bash completion for interactive shells
3084
// WHY: allow tab completion for CLI tools in SSH terminals
3185
// QUOTE(ТЗ): "А почему у меня не работает автодополенние в терминале?"
@@ -124,10 +178,68 @@ zstyle ':completion:*' tag-order builtins commands aliases reserved-words functi
124178
125179
autoload -Uz add-zsh-hook
126180
docker_git_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
181+
docker_git_short_pwd() {
182+
local full_path="\${PWD:-}"
183+
if [[ -z "$full_path" ]]; then
184+
print -r -- "?"
185+
return
186+
fi
187+
188+
local display="$full_path"
189+
if [[ -n "\${HOME:-}" && "$full_path" == "$HOME" ]]; then
190+
display="~"
191+
elif [[ -n "\${HOME:-}" && "$full_path" == "$HOME/"* ]]; then
192+
display="~/\${full_path#$HOME/}"
193+
fi
194+
195+
if [[ "$display" == "~" || "$display" == "/" ]]; then
196+
print -r -- "$display"
197+
return
198+
fi
199+
200+
local prefix=""
201+
local body="$display"
202+
if [[ "$body" == "~/"* ]]; then
203+
prefix="~/"
204+
body="\${body#~/}"
205+
elif [[ "$body" == /* ]]; then
206+
prefix="/"
207+
body="\${body#/}"
208+
fi
209+
210+
local -a parts
211+
local result="$prefix"
212+
parts=(\${(s:/:)body})
213+
local total=\${#parts[@]}
214+
local idx=1
215+
local part=""
216+
for part in "\${parts[@]}"; do
217+
if [[ -z "$part" ]]; then
218+
((idx++))
219+
continue
220+
fi
221+
if (( idx < total )); then
222+
result+="\${part[1,1]}/"
223+
else
224+
result+="$part"
225+
fi
226+
((idx++))
227+
done
228+
229+
if [[ -z "$result" ]]; then
230+
result="/"
231+
elif [[ "$result" == "~/" ]]; then
232+
result="~"
233+
fi
234+
235+
print -r -- "$result"
236+
}
127237
docker_git_prompt_apply() {
128238
local b
129239
b="$(docker_git_branch)"
130-
local base="[%*] %~"
240+
local short_pwd
241+
short_pwd="$(docker_git_short_pwd)"
242+
local base="[%*] $short_pwd"
131243
if [[ -n "$b" ]]; then
132244
PROMPT="$base ($b)> "
133245
else

0 commit comments

Comments
 (0)