prune-selector.ts
6673 bytes
1import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2import {
3 pruneCandidatesOverlap,
4 type BranchCandidate,
5} from "./tree-model.ts";
6import { formatPruneTreeItem, type PruneTreeItem } from "./tree-view.ts";
7
8export async function selectPruneCandidates(
9 ctx: ExtensionCommandContext,
10 items: readonly PruneTreeItem[],
11): Promise<BranchCandidate[] | undefined> {
12 if (ctx.mode === "tui") {
13 return selectWithTreeUi(ctx, items);
14 }
15
16 return selectWithFallback(ctx, items);
17}
18
19async function selectWithTreeUi(
20 ctx: ExtensionCommandContext,
21 items: readonly PruneTreeItem[],
22): Promise<BranchCandidate[] | undefined> {
23 const { Key, matchesKey, truncateToWidth } = await import("@earendil-works/pi-tui");
24 const selectedRootIds = await ctx.ui.custom<readonly string[] | undefined>((tui, theme, _keybindings, done) => {
25 let selectedIndex = 0;
26 let scrollOffset = 0;
27 let selectedRootIds = new Set<string>();
28
29 const visibleItemCount = (): number => Math.max(1, (tui.terminal.rows ?? 24) - 5);
30 const keepSelectionVisible = (): void => {
31 const visibleCount = visibleItemCount();
32 if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
33 if (selectedIndex >= scrollOffset + visibleCount) {
34 scrollOffset = selectedIndex - visibleCount + 1;
35 }
36 };
37 const moveSelection = (offset: number): void => {
38 selectedIndex = Math.max(0, Math.min(items.length - 1, selectedIndex + offset));
39 keepSelectionVisible();
40 tui.requestRender();
41 };
42
43 return {
44 handleInput(data: string): void {
45 if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
46 done(undefined);
47 return;
48 }
49 if (matchesKey(data, Key.up)) {
50 moveSelection(-1);
51 return;
52 }
53 if (matchesKey(data, Key.down)) {
54 moveSelection(1);
55 return;
56 }
57 if (matchesKey(data, Key.space)) {
58 const candidate = items[selectedIndex]?.candidate;
59 if (!candidate) return;
60 selectedRootIds = togglePruneCandidateSelection(selectedRootIds, candidate, items);
61 tui.requestRender();
62 return;
63 }
64 if (matchesKey(data, Key.enter) && selectedRootIds.size > 0) {
65 done([...selectedRootIds]);
66 }
67 },
68 render(width: number): string[] {
69 const visibleCount = visibleItemCount();
70 const visibleItems = items.slice(scrollOffset, scrollOffset + visibleCount);
71 const lines = [
72 theme.fg("accent", theme.bold("Prune inactive branches:")),
73 theme.fg(
74 "dim",
75 `↑↓ move • space toggle • enter confirm (${formatSelectedBranchCount(selectedRootIds.size)}) • esc cancel`,
76 ),
77 ];
78
79 if (scrollOffset > 0) lines.push(theme.fg("dim", `… ${scrollOffset} earlier branches`));
80
81 for (let index = 0; index < visibleItems.length; index += 1) {
82 const item = visibleItems[index];
83 if (!item) continue;
84
85 const itemIndex = scrollOffset + index;
86 const marker = selectedRootIds.has(item.candidate.rootId) ? "[x]" : "[ ]";
87 const row = `${itemIndex === selectedIndex ? "→" : " "} ${marker} ${item.treePrefix}${formatPruneTreeItem(item)}`;
88 const rendered = itemIndex === selectedIndex ? theme.fg("accent", theme.bold(row)) : row;
89 lines.push(rendered);
90 }
91
92 const hiddenAfter = items.length - scrollOffset - visibleItems.length;
93 if (hiddenAfter > 0) lines.push(theme.fg("dim", `… ${hiddenAfter} later branches`));
94
95 return lines.map((line) => truncateToWidth(line, width));
96 },
97 invalidate(): void {},
98 };
99 });
100
101 return selectedRootIds ? selectedCandidates(items, new Set(selectedRootIds)) : undefined;
102}
103
104async function selectWithFallback(
105 ctx: ExtensionCommandContext,
106 items: readonly PruneTreeItem[],
107): Promise<BranchCandidate[] | undefined> {
108 const choices = makeUniqueChoices(items);
109 let selectedRootIds = new Set<string>();
110
111 while (true) {
112 const selectableChoices = choices.filter(
113 (choice) =>
114 !selectedRootIds.has(choice.item.candidate.rootId) &&
115 ![...selectedRootIds].some((rootId) => {
116 const selectedCandidate = items.find((item) => item.candidate.rootId === rootId)?.candidate;
117 return selectedCandidate && pruneCandidatesOverlap(selectedCandidate, choice.item.candidate);
118 }),
119 );
120 const finishLabel = selectedRootIds.size > 0
121 ? `Prune ${formatSelectedBranchCount(selectedRootIds.size)}`
122 : undefined;
123 const selectedLabel = await ctx.ui.select(
124 "Prune inactive branches:",
125 [...selectableChoices.map((choice) => choice.label), ...(finishLabel ? [finishLabel] : [])],
126 );
127 if (!selectedLabel) return undefined;
128 if (selectedLabel === finishLabel) return selectedCandidates(items, selectedRootIds);
129
130 const selectedChoice = selectableChoices.find((choice) => choice.label === selectedLabel);
131 if (!selectedChoice) return undefined;
132 selectedRootIds = togglePruneCandidateSelection(
133 selectedRootIds,
134 selectedChoice.item.candidate,
135 items,
136 );
137 }
138}
139
140export function togglePruneCandidateSelection(
141 selectedRootIds: ReadonlySet<string>,
142 candidate: BranchCandidate,
143 items: readonly PruneTreeItem[],
144): Set<string> {
145 const nextSelectedRootIds = new Set(selectedRootIds);
146 if (nextSelectedRootIds.delete(candidate.rootId)) return nextSelectedRootIds;
147
148 for (const item of items) {
149 if (
150 nextSelectedRootIds.has(item.candidate.rootId) &&
151 pruneCandidatesOverlap(item.candidate, candidate)
152 ) {
153 nextSelectedRootIds.delete(item.candidate.rootId);
154 }
155 }
156
157 nextSelectedRootIds.add(candidate.rootId);
158 return nextSelectedRootIds;
159}
160
161function selectedCandidates(
162 items: readonly PruneTreeItem[],
163 selectedRootIds: ReadonlySet<string>,
164): BranchCandidate[] {
165 return items
166 .filter((item) => selectedRootIds.has(item.candidate.rootId))
167 .map((item) => item.candidate);
168}
169
170function formatSelectedBranchCount(count: number): string {
171 return `${count} selected ${count === 1 ? "branch" : "branches"}`;
172}
173
174function makeUniqueChoices(items: readonly PruneTreeItem[]): Array<{ label: string; item: PruneTreeItem }> {
175 const counts = new Map<string, number>();
176
177 return items.map((item) => {
178 const baseLabel = `${item.treePrefix}${formatPruneTreeItem(item)}`;
179 const count = (counts.get(baseLabel) ?? 0) + 1;
180 counts.set(baseLabel, count);
181 return {
182 label: count === 1 ? baseLabel : `${baseLabel} (${count})`,
183 item,
184 };
185 });
186}