Merge branch 'pr-739'

This commit is contained in:
Mario Zechner 2026-01-15 18:06:00 +01:00
commit bfeec88dbe
3 changed files with 268 additions and 1 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Session tree now preserves branch connectors and indentation when filters hide intermediate entries so descendants attach to the nearest visible ancestor and sibling branches align. Fixed in both TUI and HTML export ([#739](https://github.com/badlogic/pi-mono/pull/739) by [@w-winter](https://github.com/w-winter))
## [0.46.0] - 2026-01-15
### Fixed

View file

@ -321,7 +321,7 @@
function filterNodes(flatNodes, currentLeafId) {
const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean);
return flatNodes.filter(flatNode => {
const filtered = flatNodes.filter(flatNode => {
const entry = flatNode.node.entry;
const label = flatNode.node.label;
const isCurrentLeaf = entry.id === currentLeafId;
@ -369,6 +369,139 @@
return true;
});
// Recalculate visual structure based on visible tree
recalculateVisualStructure(filtered, flatNodes);
return filtered;
}
/**
* Recompute indentation/connectors for the filtered view
*
* Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor.
* Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right.
*/
function recalculateVisualStructure(filteredNodes, allFlatNodes) {
if (filteredNodes.length === 0) return;
const visibleIds = new Set(filteredNodes.map(n => n.node.entry.id));
// Build entry map for parent lookup (using full tree)
const entryMap = new Map();
for (const flatNode of allFlatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Find nearest visible ancestor for a node
function findVisibleAncestor(nodeId) {
let currentId = entryMap.get(nodeId)?.node.entry.parentId;
while (currentId != null) {
if (visibleIds.has(currentId)) {
return currentId;
}
currentId = entryMap.get(currentId)?.node.entry.parentId;
}
return null;
}
// Build visible tree structure
const visibleParent = new Map();
const visibleChildren = new Map();
visibleChildren.set(null, []); // root-level nodes
for (const flatNode of filteredNodes) {
const nodeId = flatNode.node.entry.id;
const ancestorId = findVisibleAncestor(nodeId);
visibleParent.set(nodeId, ancestorId);
if (!visibleChildren.has(ancestorId)) {
visibleChildren.set(ancestorId, []);
}
visibleChildren.get(ancestorId).push(nodeId);
}
// Update multipleRoots based on visible roots
const visibleRootIds = visibleChildren.get(null);
const multipleRoots = visibleRootIds.length > 1;
// Build a map for quick lookup: nodeId → FlatNode
const filteredNodeMap = new Map();
for (const flatNode of filteredNodes) {
filteredNodeMap.set(flatNode.node.entry.id, flatNode);
}
// DFS traversal of visible tree, applying same indentation rules as flattenTree()
// Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
const stack = [];
// Add visible roots in reverse order (to process in forward order via stack)
for (let i = visibleRootIds.length - 1; i >= 0; i--) {
const isLast = i === visibleRootIds.length - 1;
stack.push([
visibleRootIds[i],
multipleRoots ? 1 : 0,
multipleRoots,
multipleRoots,
isLast,
[],
multipleRoots
]);
}
while (stack.length > 0) {
const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop();
const flatNode = filteredNodeMap.get(nodeId);
if (!flatNode) continue;
// Update this node's visual properties
flatNode.indent = indent;
flatNode.showConnector = showConnector;
flatNode.isLast = isLast;
flatNode.gutters = gutters;
flatNode.isVirtualRootChild = isVirtualRootChild;
flatNode.multipleRoots = multipleRoots;
// Get visible children of this node
const children = visibleChildren.get(nodeId) || [];
const multipleChildren = children.length > 1;
// Calculate child indent using same rules as flattenTree():
// - Parent branches (multiple children): children get +1
// - Just branched and indent > 0: children get +1 for visual grouping
// - Single-child chain: stay flat
let childIndent;
if (multipleChildren) {
childIndent = indent + 1;
} else if (justBranched && indent > 0) {
childIndent = indent + 1;
} else {
childIndent = indent;
}
// Build gutters for children (same logic as flattenTree)
const connectorDisplayed = showConnector && !isVirtualRootChild;
const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent;
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
const childGutters = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order (to process in forward order via stack)
for (let i = children.length - 1; i >= 0; i--) {
const childIsLast = i === children.length - 1;
stack.push([
children[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false
]);
}
}
}
// ============================================================

View file

@ -302,6 +302,9 @@ class TreeList implements Component {
return true;
});
// Recalculate visual structure (indent, connectors, gutters) based on visible tree
this.recalculateVisualStructure();
// Try to preserve cursor on the same node after filtering
if (previouslySelectedId) {
const newIndex = this.filteredNodes.findIndex((n) => n.node.entry.id === previouslySelectedId);
@ -317,6 +320,133 @@ class TreeList implements Component {
}
}
/**
* Recompute indentation/connectors for the filtered view
*
* Filtering can hide intermediate entries; descendants attach to the nearest visible ancestor.
* Keep indentation semantics aligned with flattenTree() so single-child chains don't drift right.
*/
private recalculateVisualStructure(): void {
if (this.filteredNodes.length === 0) return;
const visibleIds = new Set(this.filteredNodes.map((n) => n.node.entry.id));
// Build entry map for efficient parent lookup (using full tree)
const entryMap = new Map<string, FlatNode>();
for (const flatNode of this.flatNodes) {
entryMap.set(flatNode.node.entry.id, flatNode);
}
// Find nearest visible ancestor for a node
const findVisibleAncestor = (nodeId: string): string | null => {
let currentId = entryMap.get(nodeId)?.node.entry.parentId ?? null;
while (currentId !== null) {
if (visibleIds.has(currentId)) {
return currentId;
}
currentId = entryMap.get(currentId)?.node.entry.parentId ?? null;
}
return null;
};
// Build visible tree structure:
// - visibleParent: nodeId → nearest visible ancestor (or null for roots)
// - visibleChildren: parentId → list of visible children (in filteredNodes order)
const visibleParent = new Map<string, string | null>();
const visibleChildren = new Map<string | null, string[]>();
visibleChildren.set(null, []); // root-level nodes
for (const flatNode of this.filteredNodes) {
const nodeId = flatNode.node.entry.id;
const ancestorId = findVisibleAncestor(nodeId);
visibleParent.set(nodeId, ancestorId);
if (!visibleChildren.has(ancestorId)) {
visibleChildren.set(ancestorId, []);
}
visibleChildren.get(ancestorId)!.push(nodeId);
}
// Update multipleRoots based on visible roots
const visibleRootIds = visibleChildren.get(null)!;
this.multipleRoots = visibleRootIds.length > 1;
// Build a map for quick lookup: nodeId → FlatNode
const filteredNodeMap = new Map<string, FlatNode>();
for (const flatNode of this.filteredNodes) {
filteredNodeMap.set(flatNode.node.entry.id, flatNode);
}
// DFS over the visible tree using flattenTree() indentation semantics
// Stack items: [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild]
type StackItem = [string, number, boolean, boolean, boolean, GutterInfo[], boolean];
const stack: StackItem[] = [];
// Add visible roots in reverse order (to process in forward order via stack)
for (let i = visibleRootIds.length - 1; i >= 0; i--) {
const isLast = i === visibleRootIds.length - 1;
stack.push([
visibleRootIds[i],
this.multipleRoots ? 1 : 0,
this.multipleRoots,
this.multipleRoots,
isLast,
[],
this.multipleRoots,
]);
}
while (stack.length > 0) {
const [nodeId, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop()!;
const flatNode = filteredNodeMap.get(nodeId);
if (!flatNode) continue;
// Update this node's visual properties
flatNode.indent = indent;
flatNode.showConnector = showConnector;
flatNode.isLast = isLast;
flatNode.gutters = gutters;
flatNode.isVirtualRootChild = isVirtualRootChild;
// Get visible children of this node
const children = visibleChildren.get(nodeId) || [];
const multipleChildren = children.length > 1;
// Child indent follows flattenTree(): branch points (and first generation after a branch) shift +1
let childIndent: number;
if (multipleChildren) {
childIndent = indent + 1;
} else if (justBranched && indent > 0) {
childIndent = indent + 1;
} else {
childIndent = indent;
}
// Child gutters follow flattenTree() connector/gutter rules
const connectorDisplayed = showConnector && !isVirtualRootChild;
const currentDisplayIndent = this.multipleRoots ? Math.max(0, indent - 1) : indent;
const connectorPosition = Math.max(0, currentDisplayIndent - 1);
const childGutters: GutterInfo[] = connectorDisplayed
? [...gutters, { position: connectorPosition, show: !isLast }]
: gutters;
// Add children in reverse order (to process in forward order via stack)
for (let i = children.length - 1; i >= 0; i--) {
const childIsLast = i === children.length - 1;
stack.push([
children[i],
childIndent,
multipleChildren,
multipleChildren,
childIsLast,
childGutters,
false,
]);
}
}
}
/** Get searchable text content from a node */
private getSearchableText(node: SessionTreeNode): string {
const entry = node.entry;