Parent directory

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}