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
29 changes: 20 additions & 9 deletions src/tui/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { copyToClipboard } from '../clipboard.js';
import * as fs from 'fs';
import * as path from 'path';
import { humanFormatWorkItem, formatTitleOnlyTUI } from '../commands/helpers.js';
import { createTuiState, rebuildTreeState, buildVisibleNodes, expandAncestorsForInProgress, isClosedStatus, enterMoveMode, exitMoveMode, sortBySortIndexDateAndId } from './state.js';
import { createTuiState, rebuildTreeState, buildVisibleNodes, expandAncestorsForInProgress, isClosedStatus, enterMoveMode, exitMoveMode, sortBySortIndexDateAndId, incrementalExpand, incrementalCollapse } from './state.js';
import { createPersistence } from './persistence.js';
import { resolveWorklogDir } from '../worklog-paths.js';
import { createLayout } from './layout.js';
Expand Down Expand Up @@ -285,8 +285,9 @@ export class TuiController {
for (const r of state.roots) state.expanded.add(r.id);
}

// Flatten visible nodes for rendering (uses module-level VisibleNode type)
const buildVisible = () => buildVisibleNodes(state);
// Flatten visible nodes for rendering. Returns the cached result when
// available so scroll and detail-update events avoid a full tree traversal.
const buildVisible = () => state.cachedVisibleNodes ?? buildVisibleNodes(state);

const help = listComponent.getFooter();
const detail = detailComponent.getDetail();
Expand Down Expand Up @@ -2938,10 +2939,18 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) {
// Event handlers (named so they can be removed during cleanup)
// Centralized list selection handler to keep detail updates/rendering
// consistent across mouse and keyboard interactions.
// Uses the cached visible nodes (no tree traversal) for scroll/navigation.
const updateListSelection = (idx: number, source?: string) => {
const scrollStart = perfEnabled ? performance.now() : null;
const visible = buildVisible();
updateDetailForIndex(idx, visible);
screen.render();
if (perfEnabled && scrollStart !== null) {
const scrollEnd = performance.now();
const dur = scrollEnd - scrollStart;
perfMetrics.push({ event: 'scroll', start: scrollStart, end: scrollEnd, duration: dur });
debugLog(`scroll/select (${source ?? 'unknown'}) took ${dur.toFixed(2)} ms`);
}
};

const listSelectHandler = (_el: any, idx: number) => {
Expand Down Expand Up @@ -3095,7 +3104,7 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) {
const visible = buildVisible();
const node = visible[idx];
if (node && node.hasChildren) {
state.expanded.add(node.item.id);
incrementalExpand(state, idx);
renderListAndDetail(idx);
}
});
Expand All @@ -3107,15 +3116,14 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) {
const node = visible[idx];
if (!node) return;
if (node.hasChildren && state.expanded.has(node.item.id)) {
state.expanded.delete(node.item.id);
incrementalCollapse(state, idx);
renderListAndDetail(idx);
return;
}
// collapse parent if possible
const parentIdx = findParentIndex(idx, visible);
if (parentIdx >= 0) {
const parent = visible[parentIdx];
state.expanded.delete(parent.item.id);
incrementalCollapse(state, parentIdx);
renderListAndDetail(parentIdx);
}
});
Expand Down Expand Up @@ -3144,8 +3152,11 @@ function updateDetailForIndex(idx: number, visible?: VisibleNode[]) {
debugLog(`Expand/collapse no-op took ${durEarly.toFixed(2)} ms (start=${start.toFixed(3)}ms end=${endEarly.toFixed(3)}ms)`);
return;
}
if (state.expanded.has(node.item.id)) state.expanded.delete(node.item.id);
else state.expanded.add(node.item.id);
if (state.expanded.has(node.item.id)) {
incrementalCollapse(state, idx);
} else {
incrementalExpand(state, idx);
}
renderListAndDetail(idx);
// persist state
void persistence.savePersistedState(db.getPrefix?.() || undefined, { expanded: Array.from(state.expanded) });
Expand Down
86 changes: 86 additions & 0 deletions src/tui/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export type TuiState = {
expanded: Set<string>;
listLines: string[];
moveMode: MoveMode | null;
/** Cached result of buildVisibleNodes. Null when the cache is stale. */
cachedVisibleNodes: VisibleNode[] | null;
};

export type VisibleNode = { item: Item; depth: number; hasChildren: boolean };
Expand Down Expand Up @@ -68,6 +70,10 @@ export const rebuildTreeState = (state: TuiState): void => {
for (const id of Array.from(state.expanded)) {
if (!state.itemsById.has(id)) state.expanded.delete(id);
}

// Invalidate the visible-node cache so the next buildVisibleNodes call
// performs a full traversal and repopulates it.
state.cachedVisibleNodes = null;
// Lightweight timing to help diagnose expensive tree rebuilds in the TUI
try {
const dur = Date.now() - t0;
Expand All @@ -87,6 +93,7 @@ export const createTuiState = (items: Item[], showClosed: boolean, persistedExpa
expanded: new Set<string>(),
listLines: [],
moveMode: null,
cachedVisibleNodes: null,
};

if (persistedExpanded && Array.isArray(persistedExpanded)) {
Expand All @@ -111,9 +118,88 @@ export const buildVisibleNodes = (state: TuiState): VisibleNode[] => {

for (const r of state.roots) visit(r, 0);
try { (state as any).__lastBuildVisibleMs = Date.now() - t0; } catch (_) {}
state.cachedVisibleNodes = out;
return out;
};

/**
* Build the visible VisibleNode sub-list for the children of a given item,
* traversing only nodes that are currently expanded. Used by incremental
* expand to splice in just the new subtree rather than rebuilding everything.
*/
function buildSubtreeNodes(state: TuiState, parentId: string, depth: number): VisibleNode[] {
const out: VisibleNode[] = [];
const children = (state.childrenMap.get(parentId) || []).slice().sort(sortBySortIndexDateAndId);
for (const child of children) {
const grandchildren = state.childrenMap.get(child.id) || [];
out.push({ item: child, depth, hasChildren: grandchildren.length > 0 });
if (grandchildren.length > 0 && state.expanded.has(child.id)) {
out.push(...buildSubtreeNodes(state, child.id, depth + 1));
}
}
return out;
}

/**
* Incrementally expand the node at `nodeIdx` in the cached visible-node list.
*
* Instead of re-traversing the entire tree, this function:
* 1. Sets the node as expanded in `state.expanded`.
* 2. Builds only the newly visible subtree.
* 3. Splices the subtree into `state.cachedVisibleNodes`.
*
* Falls back to a full `buildVisibleNodes` traversal if the cache is stale.
*/
export const incrementalExpand = (state: TuiState, nodeIdx: number): VisibleNode[] => {
const cached = state.cachedVisibleNodes;
if (!cached) return buildVisibleNodes(state);

const node = cached[nodeIdx];
if (!node || !node.hasChildren) return cached;
if (state.expanded.has(node.item.id)) return cached;

state.expanded.add(node.item.id);
const subtree = buildSubtreeNodes(state, node.item.id, node.depth + 1);
const newVisible = cached.slice(0, nodeIdx + 1).concat(subtree, cached.slice(nodeIdx + 1));
state.cachedVisibleNodes = newVisible;
return newVisible;
};

/**
* Incrementally collapse the node at `nodeIdx` in the cached visible-node list.
*
* Instead of re-traversing the entire tree, this function:
* 1. Removes the node from `state.expanded`.
* 2. Finds the contiguous block of descendants that follow the node in the
* visible list (all nodes at a greater depth).
* 3. Removes that block from `state.cachedVisibleNodes`.
*
* Falls back to a full `buildVisibleNodes` traversal if the cache is stale.
*/
export const incrementalCollapse = (state: TuiState, nodeIdx: number): VisibleNode[] => {
const cached = state.cachedVisibleNodes;
if (!cached) return buildVisibleNodes(state);

const node = cached[nodeIdx];
if (!node) return cached;

state.expanded.delete(node.item.id);

// Find the end of the descendant block: all nodes with depth > node.depth
// that immediately follow the collapsed node are now hidden.
let endIdx = nodeIdx + 1;
while (endIdx < cached.length && cached[endIdx].depth > node.depth) endIdx++;

if (endIdx === nodeIdx + 1) {
// No visible descendants – nothing to remove.
return cached;
}

const newVisible = cached.slice(0, nodeIdx + 1).concat(cached.slice(endIdx));
state.cachedVisibleNodes = newVisible;
return newVisible;
};

export const expandAncestorsForInProgress = (state: TuiState): void => {
const inProgressItems = state.currentVisibleItems.filter((item) => {
return item.status === 'in-progress';
Expand Down
Loading
Loading