Merge pull request #1477 from frizynn/refactor/tree-render-shared-utils

refactor: extract shared tree rendering utilities
This commit is contained in:
TÂCHES 2026-03-19 15:40:26 -06:00 committed by GitHub
commit 66bca9c8a2
3 changed files with 111 additions and 38 deletions

View file

@ -20,6 +20,12 @@ import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { appKey, appKeyHint, keyHint } from "./keybinding-hints.js";
import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode } from "./session-selector-search.js";
import {
applyRowHighlight,
buildTreePrefix,
computeScrollWindow,
renderCursor,
} from "./tree-render-utils.js";
type SessionScope = "current" | "all";
@ -420,11 +426,11 @@ class SessionList implements Component, Focusable {
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible),
const { startIndex, endIndex } = computeScrollWindow(
this.selectedIndex,
this.filteredSessions.length,
this.maxVisible,
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);
// Render visible sessions (one line each with tree structure)
for (let i = startIndex; i < endIndex; i++) {
@ -435,7 +441,7 @@ class SessionList implements Component, Focusable {
const isCurrent = this.currentSessionFilePath === session.path;
// Build tree prefix
const prefix = this.buildTreePrefix(node);
const prefix = this.buildNodeTreePrefix(node);
// Session display text (name or first message)
const hasName = !!session.name;
@ -454,7 +460,7 @@ class SessionList implements Component, Focusable {
}
// Cursor
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const cursor = renderCursor(isSelected);
// Calculate available width for message
const prefixWidth = visibleWidth(prefix);
@ -483,11 +489,8 @@ class SessionList implements Component, Focusable {
const spacing = Math.max(1, width - leftWidth - visibleWidth(rightPart));
const styledRight = theme.fg(isConfirmingDelete ? "error" : "dim", rightPart);
let line = leftPart + " ".repeat(spacing) + styledRight;
if (isSelected) {
line = theme.bg("selectedBg", line);
}
lines.push(truncateToWidth(line, width));
const line = leftPart + " ".repeat(spacing) + styledRight;
lines.push(applyRowHighlight(line, isSelected, width));
}
// Add scroll indicator if needed
@ -500,14 +503,8 @@ class SessionList implements Component, Focusable {
return lines;
}
private buildTreePrefix(node: FlatSessionNode): string {
if (node.depth === 0) {
return "";
}
const parts = node.ancestorContinues.map((continues) => (continues ? "│ " : " "));
const branch = node.isLast ? "└─ " : "├─ ";
return parts.join("") + branch;
private buildNodeTreePrefix(node: FlatSessionNode): string {
return buildTreePrefix(node.ancestorContinues, node.isLast, node.depth);
}
handleInput(keyData: string): void {

View file

@ -0,0 +1,81 @@
import { truncateToWidth } from "@gsd/pi-tui";
import { theme } from "../theme/theme.js";
// ── Tree connector characters ────────────────────────────────────────
export const TREE_BRANCH = "\u251C\u2500 "; // "├─ "
export const TREE_LAST = "\u2514\u2500 "; // "└─ "
export const TREE_PIPE = "\u2502 "; // "│ "
export const TREE_SPACE = " "; // 3 spaces
/**
* Build a tree prefix string from ancestor-continuation flags and branch position.
*
* Each ancestor level contributes either a pipe ("│ ") or blank spacing (" ")
* depending on whether that ancestor has more siblings after it. The final segment
* is the branch connector: "├─ " (more siblings) or "└─ " (last sibling).
*
* Used by session-selector for its simpler flat tree display.
* tree-selector uses its own gutter-based char-by-char builder for richer rendering.
*/
export function buildTreePrefix(ancestorContinues: boolean[], isLast: boolean, depth: number): string {
if (depth === 0) return "";
const parts = ancestorContinues.map((continues) => (continues ? TREE_PIPE : TREE_SPACE));
const branch = isLast ? TREE_LAST : TREE_BRANCH;
return parts.join("") + branch;
}
// ── Scroll window ────────────────────────────────────────────────────
export interface ScrollWindow {
/** First visible index (inclusive) */
startIndex: number;
/** Last visible index (exclusive) */
endIndex: number;
}
/**
* Compute a centered scroll window around `selectedIndex` within a list of `totalItems`.
*
* The window tries to center the selected item. When near the beginning or end of the
* list the window clamps so it doesn't exceed bounds.
*/
export function computeScrollWindow(selectedIndex: number, totalItems: number, maxVisible: number): ScrollWindow {
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), totalItems - maxVisible));
const endIndex = Math.min(startIndex + maxVisible, totalItems);
return { startIndex, endIndex };
}
// ── Cursor & selection helpers ───────────────────────────────────────
/**
* Return the cursor indicator for a list row.
*
* Selected: " " (accent-colored)
* Unselected: " " (two spaces, matching width)
*/
export function renderCursor(isSelected: boolean): string {
return isSelected ? theme.fg("accent", "\u203A ") : " ";
}
/**
* Apply selected-row background highlight and truncate to `width`.
*/
export function applyRowHighlight(line: string, isSelected: boolean, width: number): string {
const truncated = truncateToWidth(line, width);
return isSelected ? theme.bg("selectedBg", truncated) : truncated;
}
// ── Scroll position indicator ────────────────────────────────────────
/**
* Render a muted "(current/total)" position indicator, optionally with a suffix label.
*/
export function renderScrollPosition(
selectedIndex: number,
totalItems: number,
width: number,
suffixLabel?: string,
): string {
const suffix = suffixLabel ?? "";
return truncateToWidth(theme.fg("muted", ` (${selectedIndex + 1}/${totalItems})${suffix}`), width);
}

View file

@ -14,6 +14,12 @@ import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { keyHint } from "./keybinding-hints.js";
import {
applyRowHighlight,
computeScrollWindow,
renderCursor,
renderScrollPosition,
} from "./tree-render-utils.js";
/** Gutter info: position (displayIndent where connector was) and whether to show │ */
interface GutterInfo {
@ -595,14 +601,11 @@ class TreeList implements Component {
return lines;
}
const startIndex = Math.max(
0,
Math.min(
this.selectedIndex - Math.floor(this.maxVisibleLines / 2),
this.filteredNodes.length - this.maxVisibleLines,
),
const { startIndex, endIndex } = computeScrollWindow(
this.selectedIndex,
this.filteredNodes.length,
this.maxVisibleLines,
);
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.filteredNodes.length);
for (let i = startIndex; i < endIndex; i++) {
const flatNode = this.filteredNodes[i];
@ -610,7 +613,7 @@ class TreeList implements Component {
const isSelected = i === this.selectedIndex;
// Build line: cursor + prefix + path marker + label + content
const cursor = isSelected ? theme.fg("accent", " ") : " ";
const cursor = renderCursor(isSelected);
// If multiple roots, shift display (roots at 0, not 1)
const displayIndent = this.multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
@ -664,19 +667,11 @@ class TreeList implements Component {
const label = flatNode.node.label ? theme.fg("warning", `[${flatNode.node.label}] `) : "";
const content = this.getEntryDisplayText(flatNode.node, isSelected);
let line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content;
if (isSelected) {
line = theme.bg("selectedBg", line);
}
lines.push(truncateToWidth(line, width));
const line = cursor + theme.fg("dim", prefix) + foldMarker + pathMarker + label + content;
lines.push(applyRowHighlight(line, isSelected, width));
}
lines.push(
truncateToWidth(
theme.fg("muted", ` (${this.selectedIndex + 1}/${this.filteredNodes.length})${this.getFilterLabel()}`),
width,
),
);
lines.push(renderScrollPosition(this.selectedIndex, this.filteredNodes.length, width, this.getFilterLabel()));
return lines;
}