diff --git a/packages/path-store/src/builder.ts b/packages/path-store/src/builder.ts index 2d0b6691a..2e6c86f8e 100644 --- a/packages/path-store/src/builder.ts +++ b/packages/path-store/src/builder.ts @@ -141,6 +141,9 @@ export function preparePresortedInput( return { paths: presortedPaths, presortedPaths, + presortedPathsContainDirectories: presortedPaths.some((path) => + path.endsWith('/') + ), }; } @@ -165,6 +168,16 @@ export function getPreparedInputPresortedPaths( : null; } +export function getPreparedInputPresortedPathsContainDirectories( + preparedInput: import('./public-types').PathStorePreparedInput +): boolean | null { + const internalPreparedInput = preparedInput as Partial; + return typeof internalPreparedInput.presortedPathsContainDirectories === + 'boolean' + ? internalPreparedInput.presortedPathsContainDirectories + : null; +} + export function preparePathEntries( paths: readonly string[], options: PathStoreOptions = {} @@ -229,11 +242,19 @@ export class PathStoreBuilder { return this; } - public appendPresortedPaths(paths: readonly string[]): this { + public appendPresortedPaths( + paths: readonly string[], + containsDirectories: boolean | null = null + ): this { withBenchmarkPhase( this.instrumentation, 'store.builder.appendPresortedPaths', () => { + if (containsDirectories === false) { + this.appendPresortedFilePaths(paths); + return; + } + let previousPath: string | null = null; let currentDepth = 0; const nodes = this.nodes; @@ -440,6 +461,138 @@ export class PathStoreBuilder { return this; } + // File-only presorted input can skip all explicit-directory handling and the + // trailing-slash checks in the hottest builder loop. + private appendPresortedFilePaths(paths: readonly string[]): void { + let previousPath: string | null = null; + let currentDepth = 0; + const nodes = this.nodes; + const segmentTable = this.segmentTable; + const idByValue = segmentTable.idByValue; + const valueById = segmentTable.valueById; + const sortKeyById = segmentTable.sortKeyById; + const dirStack = this.directoryStack; + let stackTop = 0; + let cachedDirPrefix = ''; + let cachedDirDepth = 0; + + for (const path of paths) { + if (previousPath === path) { + throw new Error(`Duplicate path: "${path}"`); + } + + const endIndex = path.length; + let sharedDirectoryDepth = 0; + let unsharedSegmentStart = 0; + + if (previousPath != null) { + if ( + cachedDirPrefix.length > 0 && + path.length > cachedDirPrefix.length && + path.startsWith(cachedDirPrefix) + ) { + sharedDirectoryDepth = cachedDirDepth; + unsharedSegmentStart = cachedDirPrefix.length; + } else { + const compareLength = Math.min(endIndex, previousPath.length); + for (let ci = 0; ci < compareLength; ci++) { + const cc = path.charCodeAt(ci); + if (cc !== previousPath.charCodeAt(ci)) { + break; + } + if (cc === 47) { + sharedDirectoryDepth++; + unsharedSegmentStart = ci + 1; + } + } + } + } + + stackTop = sharedDirectoryDepth; + currentDepth = sharedDirectoryDepth; + + let segmentStart = unsharedSegmentStart; + let slashPos = path.indexOf('/', segmentStart); + while (slashPos >= 0) { + const parentId = dirStack[stackTop]; + if (parentId === undefined) { + throw new Error( + 'Directory stack underflow while building the path store' + ); + } + + currentDepth++; + const dirSeg = path.slice(segmentStart, slashPos); + let dirNameId = idByValue[dirSeg]; + if (dirNameId === undefined) { + dirNameId = valueById.length; + idByValue[dirSeg] = dirNameId; + valueById.push(dirSeg); + sortKeyById.push(undefined); + } + const nodeId = nodes.length; + nodes.push({ + depth: currentDepth, + flags: 0, + id: nodeId, + kind: PATH_STORE_NODE_KIND_DIRECTORY, + nameId: dirNameId, + parentId, + pathCache: null, + pathCacheVersion: 0, + subtreeNodeCount: 1, + visibleSubtreeCount: 1, + }); + stackTop++; + dirStack[stackTop] = nodeId; + segmentStart = slashPos + 1; + slashPos = path.indexOf('/', segmentStart); + } + + const parentId = dirStack[stackTop]; + if (parentId === undefined) { + throw new Error(`Unable to resolve file parent for "${path}"`); + } + + const fileSeg = path.slice(segmentStart); + let fileNameId = idByValue[fileSeg]; + if (fileNameId === undefined) { + fileNameId = valueById.length; + idByValue[fileSeg] = fileNameId; + valueById.push(fileSeg); + sortKeyById.push(undefined); + } + const nodeId = nodes.length; + nodes.push({ + depth: currentDepth + 1, + flags: 0, + id: nodeId, + kind: PATH_STORE_NODE_KIND_FILE, + nameId: fileNameId, + parentId, + pathCache: null, + pathCacheVersion: -1, + subtreeNodeCount: 1, + visibleSubtreeCount: 1, + }); + + if (segmentStart !== cachedDirPrefix.length) { + cachedDirPrefix = path.substring(0, segmentStart); + cachedDirDepth = currentDepth; + } + + previousPath = path; + } + + dirStack.length = stackTop + 1; + + if (previousPath != null) { + this.lastPreparedPath = parseInputPath(previousPath); + } + + this.hasDeferredDirectoryIndexes = true; + } + public finish(): PathStoreSnapshot { if (this.hasDeferredDirectoryIndexes) { withBenchmarkPhase( @@ -822,22 +975,6 @@ export class PathStoreBuilder { parentNode.visibleSubtreeCount += node.visibleSubtreeCount; } } - - // Eagerly build name-id lookup maps so later path lookups (e.g., - // initializeExpandedPaths) don't pay the lazy-rebuild cost per directory. - // This is O(n) total and cache-friendlier than building maps on-demand - // during random tree walks. - for (const dirIndex of directories.values()) { - if (dirIndex.childIdByNameId == null && dirIndex.childIds.length > 0) { - const map = new Map(); - const childIds = dirIndex.childIds; - for (let ci = 0; ci < childIds.length; ci++) { - const childId = childIds[ci]; - map.set(nodes[childId].nameId, childId); - } - dirIndex.childIdByNameId = map; - } - } } // Builds directory-child indexes in the same layout as buildPresortedFinish diff --git a/packages/path-store/src/index.ts b/packages/path-store/src/index.ts index c75b594fe..969610f65 100644 --- a/packages/path-store/src/index.ts +++ b/packages/path-store/src/index.ts @@ -20,10 +20,12 @@ export type { PathStoreOperation, PathStoreOptions, PathStorePathComparator, + PathStorePathInfo, PathStorePreparedInput, PathStoreRemoveOptions, PathStoreVisibleRow, PathStoreVisibleTreeProjection, + PathStoreVisibleTreeProjectionData, PathStoreVisibleTreeProjectionRow, } from './public-types'; export type { diff --git a/packages/path-store/src/internal-types.ts b/packages/path-store/src/internal-types.ts index 5fa376a91..8b14e7081 100644 --- a/packages/path-store/src/internal-types.ts +++ b/packages/path-store/src/internal-types.ts @@ -73,6 +73,7 @@ export interface PreparedPath { export type InternalPreparedInput = PathStorePreparedInput & { readonly preparedPaths?: readonly PreparedPath[]; readonly presortedPaths?: readonly string[]; + readonly presortedPathsContainDirectories?: boolean; }; export interface LookupPath { diff --git a/packages/path-store/src/projection.ts b/packages/path-store/src/projection.ts index 6c536e838..828a758d4 100644 --- a/packages/path-store/src/projection.ts +++ b/packages/path-store/src/projection.ts @@ -27,6 +27,9 @@ import type { PathStoreDirectoryLoadState, PathStoreExpandEvent, PathStoreVisibleRow, + PathStoreVisibleTreeProjection, + PathStoreVisibleTreeProjectionData, + PathStoreVisibleTreeProjectionRow, } from './public-types'; import { getSegmentValue } from './segments'; import { @@ -36,12 +39,35 @@ import { } from './state'; import type { PathStoreState } from './state'; +const INITIAL_PROJECTION_DEPTH_CAPACITY = 64; +type ProjectionDepthTable = Int32Array; + interface VisibleRowCursor { headNodeId: NodeId; terminalNodeId: NodeId; visibleDepth: number; } +function ensureProjectionDepthCapacity( + depthTable: ProjectionDepthTable, + depth: number +): ProjectionDepthTable { + const requiredLength = depth + 2; + if (requiredLength <= depthTable.length) { + return depthTable; + } + + let nextLength = depthTable.length; + while (nextLength < requiredLength) { + nextLength *= 2; + } + + const nextDepthTable = new Int32Array(nextLength); + nextDepthTable.fill(-1); + nextDepthTable.set(depthTable); + return nextDepthTable; +} + export function getVisibleCount(state: PathStoreState): number { return requireNode(state, state.snapshot.rootId).visibleSubtreeCount; } @@ -131,6 +157,30 @@ export function getVisibleSlice( return rows; } +export function getVisibleTreeProjectionData( + state: PathStoreState, + maxRows: number = getVisibleCount(state) +): PathStoreVisibleTreeProjectionData { + const instrumentation = state.instrumentation; + if (instrumentation == null) { + return buildVisibleTreeProjectionDataDFS(state, maxRows); + } + + return withBenchmarkPhase( + instrumentation, + 'store.getVisibleTreeProjection', + () => buildVisibleTreeProjectionDataDFS(state, maxRows) + ); +} + +export function getVisibleTreeProjection( + state: PathStoreState +): PathStoreVisibleTreeProjection { + return createVisibleTreeProjectionFromData( + getVisibleTreeProjectionData(state) + ); +} + export function expandPath( state: PathStoreState, path: string @@ -389,6 +439,150 @@ function getNextVisibleRowCursor( } } +function createVisibleTreeProjectionFromData( + projection: PathStoreVisibleTreeProjectionData +): PathStoreVisibleTreeProjection { + const rowCount = projection.paths.length; + const projectionRows: PathStoreVisibleTreeProjectionRow[] = new Array( + rowCount + ); + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const parentIndex = projection.getParentIndex(rowIndex); + projectionRows[rowIndex] = { + index: rowIndex, + parentPath: + parentIndex >= 0 ? (projection.paths[parentIndex] ?? null) : null, + path: projection.paths[rowIndex] ?? '', + posInSet: projection.posInSetByIndex[rowIndex] ?? 0, + setSize: projection.setSizeByIndex[rowIndex] ?? 0, + }; + } + + return { + getParentIndex: projection.getParentIndex, + rows: projectionRows, + get visibleIndexByPath(): Map { + return projection.visibleIndexByPath; + }, + }; +} + +// Walks the full visible preorder and builds the ARIA projection data directly +// into path and typed-array buffers so tree startup can avoid allocating a +// projection row object for every visible item. +function buildVisibleTreeProjectionDataDFS( + state: PathStoreState, + maxRows: number +): PathStoreVisibleTreeProjectionData { + const paths = new Array(maxRows); + const parentRowIndex = new Int32Array(maxRows); + const posInSetByIndex = new Int32Array(maxRows); + const childCount = new Int32Array(maxRows + 1); + let lastRowAtDepth: ProjectionDepthTable = new Int32Array( + INITIAL_PROJECTION_DEPTH_CAPACITY + ); + lastRowAtDepth.fill(-1); + + let rowCount = 0; + const { nodes, directories, segmentTable } = state.snapshot; + const stack: Array<[DirectoryChildIndex, number, number, string]> = [ + [directories.get(state.snapshot.rootId)!, 0, -1, ''], + ]; + const flattenEnabled = state.snapshot.options.flattenEmptyDirectories; + const pathCacheVersion = state.pathCacheVersion; + const segmentValues = segmentTable.valueById; + + while (stack.length > 0 && rowCount < maxRows) { + const frame = stack[stack.length - 1]; + const dirIndex = frame[0]; + + if (frame[1] >= dirIndex.childIds.length) { + stack.pop(); + continue; + } + + const childId = dirIndex.childIds[frame[1]++]; + const childNode = nodes[childId]; + const visibleDepth = frame[2] + 1; + const parentPath = frame[3]; + lastRowAtDepth = ensureProjectionDepthCapacity( + lastRowAtDepth, + visibleDepth + ); + + let path: string; + let terminalNodeId = childId; + if (childNode.kind !== PATH_STORE_NODE_KIND_DIRECTORY) { + path = + childNode.pathCache != null && + childNode.pathCacheVersion === pathCacheVersion + ? childNode.pathCache + : `${parentPath}${segmentValues[childNode.nameId]}`; + } else { + terminalNodeId = flattenEnabled + ? getFlattenedTerminalDirectoryId(state, childId) + : childId; + path = + terminalNodeId === childId + ? `${parentPath}${segmentValues[childNode.nameId]}/` + : materializeNodePath(state, terminalNodeId); + } + + const parentIdx = lastRowAtDepth[visibleDepth]; + parentRowIndex[rowCount] = parentIdx; + const countSlot = parentIdx + 1; + childCount[countSlot] += 1; + paths[rowCount] = path; + posInSetByIndex[rowCount] = childCount[countSlot] - 1; + lastRowAtDepth[visibleDepth + 1] = rowCount; + + rowCount += 1; + + const terminalNode = nodes[terminalNodeId]; + if ( + terminalNode != null && + terminalNode.kind === PATH_STORE_NODE_KIND_DIRECTORY && + isDirectoryExpanded(state, terminalNodeId, terminalNode) + ) { + stack.push([directories.get(terminalNodeId)!, 0, visibleDepth, path]); + } + } + + if (rowCount < maxRows) { + paths.length = rowCount; + } + + const setSizeByIndex = new Int32Array(rowCount); + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + setSizeByIndex[rowIndex] = childCount[parentRowIndex[rowIndex] + 1] ?? 0; + } + + const finalParentRowIndex = parentRowIndex.subarray(0, rowCount); + const finalPosInSetByIndex = posInSetByIndex.subarray(0, rowCount); + let cachedVisibleIndexByPath: Map | null = null; + return { + getParentIndex(index: number): number { + return index < 0 || index >= rowCount + ? -1 + : (finalParentRowIndex[index] ?? -1); + }, + paths, + posInSetByIndex: finalPosInSetByIndex, + setSizeByIndex, + get visibleIndexByPath(): Map { + if (cachedVisibleIndexByPath == null) { + cachedVisibleIndexByPath = new Map(); + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + cachedVisibleIndexByPath.set(paths[rowIndex] ?? '', rowIndex); + } + } + + return cachedVisibleIndexByPath; + }, + }; +} + // Iterative depth-first traversal that collects visible rows by walking the // child arrays directly. This is faster than the cursor-based approach for // large contiguous slices starting from index 0, because it never needs to diff --git a/packages/path-store/src/public-types.ts b/packages/path-store/src/public-types.ts index aa2b4a538..26a32fa84 100644 --- a/packages/path-store/src/public-types.ts +++ b/packages/path-store/src/public-types.ts @@ -81,6 +81,12 @@ export interface PathStoreFlattenedRowSegment { path: string; } +export interface PathStorePathInfo { + depth: number; + kind: 'directory' | 'file'; + path: string; +} + export interface PathStoreVisibleRow { depth: number; flattenedSegments?: readonly PathStoreFlattenedRowSegment[]; @@ -104,10 +110,19 @@ export interface PathStoreVisibleTreeProjectionRow { } export interface PathStoreVisibleTreeProjection { + getParentIndex(index: number): number; rows: readonly PathStoreVisibleTreeProjectionRow[]; visibleIndexByPath: Map; } +export interface PathStoreVisibleTreeProjectionData { + getParentIndex(index: number): number; + paths: readonly string[]; + posInSetByIndex: Int32Array; + setSizeByIndex: Int32Array; + visibleIndexByPath: Map; +} + export interface PathStoreEventInvalidation { affectedAncestorIds: readonly number[]; affectedNodeIds: readonly number[]; diff --git a/packages/path-store/src/store.ts b/packages/path-store/src/store.ts index 2b0cca289..0dfdbf354 100644 --- a/packages/path-store/src/store.ts +++ b/packages/path-store/src/store.ts @@ -1,6 +1,7 @@ import { getPreparedInputEntries, getPreparedInputPresortedPaths, + getPreparedInputPresortedPathsContainDirectories, PathStoreBuilder, prepareInput as prepareCanonicalInput, preparePaths as prepareCanonicalPaths, @@ -49,6 +50,8 @@ import { expandPath, getVisibleCount, getVisibleSlice, + getVisibleTreeProjectionData as getVisibleTreeProjectionDataFromState, + getVisibleTreeProjection as getVisibleTreeProjectionFromState, } from './projection'; import type { PathStoreChildPatch, @@ -62,10 +65,18 @@ import type { PathStoreMoveOptions, PathStoreOperation, PathStoreOptions, + PathStorePathInfo, PathStorePreparedInput, PathStoreRemoveOptions, PathStoreVisibleRow, + PathStoreVisibleTreeProjection, + PathStoreVisibleTreeProjectionData, } from './public-types'; +import { + compareSegmentSortKeys, + createSegmentSortKey, + getSegmentSortKey, +} from './sort'; import { beginDirectoryLoad, completeDirectoryLoad, @@ -177,7 +188,12 @@ export class PathStore { options.preparedInput ); if (presortedPaths != null) { - builder.appendPresortedPaths(presortedPaths); + builder.appendPresortedPaths( + presortedPaths, + getPreparedInputPresortedPathsContainDirectories( + options.preparedInput + ) + ); } else { // preparedInput is the caller's explicit fast path, so skip the // builder's redundant monotonic-order validation and only keep @@ -213,12 +229,22 @@ export class PathStore { instrumentation ) ); - withBenchmarkPhase( + const expandedDirectoryCount = withBenchmarkPhase( instrumentation, 'store.state.initializeExpandedPaths', () => this.initializeExpandedPaths(options.initialExpandedPaths) ); - if (canInitializeOpenVisibleCounts(options)) { + const canUseOpenVisibleCounts = + canInitializeOpenVisibleCounts(options) || + ((options.initialExpansion ?? 'closed') === 'closed' && + expandedDirectoryCount === this.#state.snapshot.directories.size - 1) || + ((options.initialExpandedPaths?.length ?? 0) > 0 && + withBenchmarkPhase( + instrumentation, + 'store.state.checkAllDirectoriesExpanded', + () => this.hasAllDirectoriesExpanded() + )); + if (canUseOpenVisibleCounts) { withBenchmarkPhase( instrumentation, 'store.state.initializeOpenVisibleCounts', @@ -348,6 +374,54 @@ export class PathStore { ); } + public getVisibleTreeProjection(): PathStoreVisibleTreeProjection { + return getVisibleTreeProjectionFromState(this.#state); + } + + public getVisibleTreeProjectionData( + maxRows?: number + ): PathStoreVisibleTreeProjectionData { + return getVisibleTreeProjectionDataFromState(this.#state, maxRows); + } + + /** + * Resolves a lookup path to the store's canonical path and item kind. + * Lets tree adapters answer path-first queries without building a second + * whole-tree metadata index alongside the store. + */ + public getPathInfo(path: string): PathStorePathInfo | null { + return withBenchmarkPhase( + this.#state.instrumentation, + 'store.getPathInfo', + () => { + const nodeId = findNodeId(this.#state, path); + if (nodeId == null) { + return null; + } + + const node = requireNode(this.#state, nodeId); + return { + depth: node.depth, + kind: + node.kind === PATH_STORE_NODE_KIND_DIRECTORY ? 'directory' : 'file', + path: materializeNodePath(this.#state, nodeId), + } satisfies PathStorePathInfo; + } + ); + } + + public isExpanded(path: string): boolean { + return withBenchmarkPhase( + this.#state.instrumentation, + 'store.isExpanded', + () => { + const directoryNodeId = this.requireDirectoryNodeId(path); + const directoryNode = requireNode(this.#state, directoryNodeId); + return isDirectoryExpanded(this.#state, directoryNodeId, directoryNode); + } + ); + } + public expand(path: string): void { withBenchmarkPhase(this.#state.instrumentation, 'store.expand', () => { const previousVisibleCount = getVisibleCount(this.#state); @@ -690,24 +764,208 @@ export class PathStore { private initializeExpandedPaths( expandedPaths: readonly string[] | undefined - ): void { + ): number { if (expandedPaths == null || expandedPaths.length === 0) { - return; + return 0; } + let expandedDirectoryCount = 0; + const previousChildOffsets: number[] = []; + const previousNodeIds: number[] = []; + let previousEndIndex = 0; + let previousPath: string | null = null; + const segmentTable = this.#state.snapshot.segmentTable; + const segmentValues = segmentTable.valueById; + const nodes = this.#state.snapshot.nodes; + const targetSegmentSortKeyCache = new Map< + string, + ReturnType + >(); + for (const path of expandedPaths) { - const directoryNodeId = findNodeId(this.#state, path); - if (directoryNodeId == null) { - throw new Error(`Path does not exist: "${path}"`); + if (previousPath != null && path < previousPath) { + previousPath = null; + previousEndIndex = 0; + previousChildOffsets.length = 0; + previousNodeIds.length = 0; } - const directoryNode = requireNode(this.#state, directoryNodeId); - if (directoryNode.kind !== PATH_STORE_NODE_KIND_DIRECTORY) { - throw new Error(`Path is not a directory: "${path}"`); + const hasTrailingSlash = + path.length > 0 && path.charCodeAt(path.length - 1) === 47; + const endIndex = hasTrailingSlash ? path.length - 1 : path.length; + if (endIndex === 0) { + previousPath = path; + previousEndIndex = endIndex; + previousChildOffsets.length = 0; + previousNodeIds.length = 0; + continue; + } + + let sharedDepth = 0; + let unsharedSegmentStart = 0; + if (previousPath != null) { + const compareLength = Math.min(endIndex, previousEndIndex); + let prefixMatched = true; + for (let charIndex = 0; charIndex < compareLength; charIndex += 1) { + const charCode = path.charCodeAt(charIndex); + if (charCode !== previousPath.charCodeAt(charIndex)) { + prefixMatched = false; + break; + } + if (charCode === 47) { + sharedDepth += 1; + unsharedSegmentStart = charIndex + 1; + } + } + + if (prefixMatched) { + if ( + compareLength === previousEndIndex && + endIndex > compareLength && + path.charCodeAt(compareLength) === 47 + ) { + sharedDepth += 1; + unsharedSegmentStart = compareLength + 1; + } else if ( + compareLength === endIndex && + previousEndIndex > compareLength && + previousPath.charCodeAt(compareLength) === 47 + ) { + sharedDepth += 1; + unsharedSegmentStart = endIndex + 1; + } + } + + sharedDepth = Math.min(sharedDepth, previousNodeIds.length); + } + + let currentDirectoryId = + sharedDepth === 0 + ? this.#state.snapshot.rootId + : (previousNodeIds[sharedDepth - 1] ?? this.#state.snapshot.rootId); + let resolvedDepth = sharedDepth; + let foundDirectory = true; + let segmentStart = unsharedSegmentStart; + + while (segmentStart <= endIndex) { + const slashIndex = path.indexOf('/', segmentStart); + const segmentEnd = + slashIndex === -1 || slashIndex > endIndex ? endIndex : slashIndex; + const segment = path.slice(segmentStart, segmentEnd); + const currentIndex = getDirectoryIndex(this.#state, currentDirectoryId); + const childIds = currentIndex.childIds; + const searchStartIndex = + resolvedDepth === sharedDepth + ? (previousChildOffsets[resolvedDepth] ?? 0) + : 0; + let nextChildOffset = searchStartIndex; + let nextNodeId: number | undefined; + const targetSegmentSortKey = + targetSegmentSortKeyCache.get(segment) ?? + createSegmentSortKey(segment); + targetSegmentSortKeyCache.set(segment, targetSegmentSortKey); + const searchForSegment = ( + startIndex: number, + endIndex: number + ): boolean => { + for ( + nextChildOffset = startIndex; + nextChildOffset < endIndex; + nextChildOffset += 1 + ) { + const candidateNodeId = childIds[nextChildOffset]; + const candidateNode = nodes[candidateNodeId]; + const candidateSegment = segmentValues[candidateNode.nameId]; + if (candidateSegment === segment) { + nextNodeId = candidateNodeId; + return true; + } + const orderComparison = compareSegmentSortKeys( + getSegmentSortKey(segmentTable, candidateNode.nameId), + targetSegmentSortKey + ); + if ( + orderComparison > 0 || + (orderComparison === 0 && candidateSegment > segment) + ) { + return false; + } + } + return false; + }; + + const foundFromStart = searchForSegment( + searchStartIndex, + childIds.length + ); + if (!foundFromStart && searchStartIndex > 0) { + searchForSegment(0, searchStartIndex); + } + if (nextNodeId === undefined) { + foundDirectory = false; + break; + } + + const nextNode = requireNode(this.#state, nextNodeId); + if (nextNode.kind !== PATH_STORE_NODE_KIND_DIRECTORY) { + foundDirectory = false; + break; + } + + previousChildOffsets[resolvedDepth] = nextChildOffset; + previousNodeIds[resolvedDepth] = nextNodeId; + currentDirectoryId = nextNodeId; + resolvedDepth += 1; + if (segmentEnd === endIndex) { + break; + } + segmentStart = segmentEnd + 1; + } + + previousPath = path; + previousEndIndex = endIndex; + previousChildOffsets.length = resolvedDepth; + previousNodeIds.length = resolvedDepth; + if (!foundDirectory) { + continue; } - setDirectoryExpanded(this.#state, directoryNodeId, true, directoryNode); + for ( + let depthIndex = sharedDepth; + depthIndex < resolvedDepth; + depthIndex += 1 + ) { + const directoryNodeId = previousNodeIds[depthIndex]; + if (directoryNodeId == null) { + continue; + } + + const directoryNode = requireNode(this.#state, directoryNodeId); + if (isDirectoryExpanded(this.#state, directoryNodeId, directoryNode)) { + continue; + } + + setDirectoryExpanded(this.#state, directoryNodeId, true, directoryNode); + expandedDirectoryCount += 1; + } } + + return expandedDirectoryCount; + } + + private hasAllDirectoriesExpanded(): boolean { + for (const directoryNodeId of this.#state.snapshot.directories.keys()) { + if (directoryNodeId === this.#state.snapshot.rootId) { + continue; + } + + const directoryNode = requireNode(this.#state, directoryNodeId); + if (!isDirectoryExpanded(this.#state, directoryNodeId, directoryNode)) { + return false; + } + } + + return true; } private requireDirectoryNodeId(path: string): number { diff --git a/packages/path-store/src/visible-tree-projection.ts b/packages/path-store/src/visible-tree-projection.ts index 8cbc3d5df..964cfc8dd 100644 --- a/packages/path-store/src/visible-tree-projection.ts +++ b/packages/path-store/src/visible-tree-projection.ts @@ -75,6 +75,11 @@ export function createVisibleTreeProjection( // only read .rows don’t pay the ~98K Map.set cost. let cachedVisibleIndexByPath: Map | null = null; return { + getParentIndex(index: number): number { + return index < 0 || index >= rowCount + ? -1 + : (parentRowIndex[index] ?? -1); + }, rows: projectionRows, get visibleIndexByPath(): Map { if (cachedVisibleIndexByPath == null) { diff --git a/packages/path-store/test/path-store.test.ts b/packages/path-store/test/path-store.test.ts index cd1059815..8f41472e9 100644 --- a/packages/path-store/test/path-store.test.ts +++ b/packages/path-store/test/path-store.test.ts @@ -2150,6 +2150,41 @@ describe('PathStore', () => { ]); }); + test('path info resolves canonical directory lookups and initialExpandedPaths expands ancestors', () => { + const store = new PathStore({ + flattenEmptyDirectories: false, + initialExpandedPaths: ['src/components'], + paths: ['README.md', 'src/index.ts', 'src/components/Button.tsx'], + }); + + expect(store.getPathInfo('src/components')).toEqual({ + depth: 2, + kind: 'directory', + path: 'src/components/', + }); + expect(store.getPathInfo('src/components/')).toEqual({ + depth: 2, + kind: 'directory', + path: 'src/components/', + }); + expect(store.getPathInfo('README.md')).toEqual({ + depth: 1, + kind: 'file', + path: 'README.md', + }); + expect(store.getPathInfo('missing.ts')).toBeNull(); + + expect(store.isExpanded('src/')).toBe(true); + expect(store.isExpanded('src/components')).toBe(true); + expect(getVisiblePaths(store, 0, 9)).toEqual([ + 'src/', + 'src/components/', + 'src/components/Button.tsx', + 'src/index.ts', + 'README.md', + ]); + }); + test('keeps sibling positions correct after removing one child and moving another', () => { const store = new PathStore({ flattenEmptyDirectories: false, diff --git a/packages/trees/scripts/lib/pathStoreProfileShared.ts b/packages/trees/scripts/lib/pathStoreProfileShared.ts index 3a52aca69..3cc70ec61 100644 --- a/packages/trees/scripts/lib/pathStoreProfileShared.ts +++ b/packages/trees/scripts/lib/pathStoreProfileShared.ts @@ -83,6 +83,7 @@ export function createPathStorePresortedPreparedInput( return { paths, presortedPaths: paths, + presortedPathsContainDirectories: false, } as PathStorePreparedInput; } diff --git a/packages/trees/src/path-store/controller.ts b/packages/trees/src/path-store/controller.ts index 3655dea47..294a2a00c 100644 --- a/packages/trees/src/path-store/controller.ts +++ b/packages/trees/src/path-store/controller.ts @@ -1,7 +1,7 @@ -import { createVisibleTreeProjection, PathStore } from '@pierre/path-store'; +import { PathStore } from '@pierre/path-store'; import type { - PathStoreVisibleRow, - PathStoreVisibleTreeProjectionRow, + PathStorePathInfo, + PathStoreVisibleTreeProjectionData, } from '@pierre/path-store'; import type { @@ -13,27 +13,23 @@ import type { PathStoreTreesVisibleRow, } from './types'; -interface PathStoreTreesItemMetadata { - depth: number; - kind: 'directory' | 'file'; - path: string; -} - -interface PathStoreTreesItemState { - expandedDirectories: Set; - initialExpandedPaths: readonly string[]; - itemHandles: Map; - itemMetadata: Map; -} +type ProjectionIndexBuffer = Int32Array; interface PathStoreTreesVisibleProjection { - focusedPath: string | null; - parentPaths: Map; - projectionRows: readonly PathStoreVisibleTreeProjectionRow[]; - visibleIndexByPath: Map; - visibleRows: readonly PathStoreVisibleRow[]; + focusedIndex: number; + getParentIndex(index: number): number; + paths: readonly string[]; + posInSetByIndex: ProjectionIndexBuffer; + setSizeByIndex: ProjectionIndexBuffer; + visibleIndexByPath: Map | null; + visibleIndexByPathFactory: () => Map; } +// Initial render only mounts a tiny viewport slice, so controller startup can +// cap its first projection build and defer the full 494k-row metadata walk +// until the user actually navigates outside that initial window. +const INITIAL_PROJECTION_ROW_LIMIT = 512; + function arePathSetsEqual( currentPaths: ReadonlySet, nextPaths: readonly string[] @@ -51,32 +47,6 @@ function arePathSetsEqual( return true; } -function getVisibleSelectionTargetPath( - row: Pick -): string { - return row.isFlattened - ? (row.flattenedSegments?.findLast((segment) => segment.isTerminal)?.path ?? - row.path) - : row.path; -} - -function resolvePathStoreTreesItemPath( - itemMetadata: ReadonlyMap, - path: string -): string | null { - const directMatch = itemMetadata.get(path); - if (directMatch != null) { - return directMatch.path; - } - - if (path.endsWith('/')) { - return null; - } - - const directoryMatch = itemMetadata.get(`${path}/`); - return directoryMatch?.kind === 'directory' ? directoryMatch.path : null; -} - // Expanding a nested directory should make that directory visible, so this // helper walks its ancestor chain in canonical path form. function getAncestorDirectoryPaths(path: string): readonly string[] { @@ -108,18 +78,19 @@ function findNearestVisibleAncestorPath( // Keeps logical focus on a visible row. When a focused descendant disappears, // this falls back to the nearest visible ancestor before defaulting to row 0. -function resolveFocusedPath( - targetPaths: readonly string[], +function resolveFocusedIndex( + rowCount: number, visibleIndexByPath: ReadonlyMap, candidatePath: string | null -): string | null { - if (targetPaths.length === 0) { - return null; +): number { + if (rowCount === 0) { + return -1; } if (candidatePath != null) { - if (visibleIndexByPath.has(candidatePath)) { - return candidatePath; + const directIndex = visibleIndexByPath.get(candidatePath); + if (directIndex != null) { + return directIndex; } const ancestorPath = findNearestVisibleAncestorPath( @@ -127,11 +98,11 @@ function resolveFocusedPath( candidatePath ); if (ancestorPath != null) { - return ancestorPath; + return visibleIndexByPath.get(ancestorPath) ?? 0; } } - return targetPaths[0] ?? null; + return 0; } // Rebuilds the visible-row projection once so focus/navigation can use @@ -139,116 +110,47 @@ function resolveFocusedPath( // Derives the row metadata that the renderer needs for roving tabindex and // treeitem ARIA attrs without exposing path-store's numeric row identities. function createVisibleProjection( - rows: readonly PathStoreVisibleRow[], + projection: PathStoreVisibleTreeProjectionData, focusedPathCandidate: string | null ): PathStoreTreesVisibleProjection { - const projection = createVisibleTreeProjection(rows); - const targetPaths = projection.rows.map((row) => row.path); + if (projection.paths.length === 0) { + return { + focusedIndex: -1, + getParentIndex: projection.getParentIndex, + paths: projection.paths, + posInSetByIndex: projection.posInSetByIndex, + setSizeByIndex: projection.setSizeByIndex, + visibleIndexByPath: null, + visibleIndexByPathFactory: () => projection.visibleIndexByPath, + }; + } - const focusedPath = resolveFocusedPath( - targetPaths, - projection.visibleIndexByPath, - focusedPathCandidate - ); - const parentPaths = new Map(); - for (const row of projection.rows) { - parentPaths.set(row.path, row.parentPath); + if (focusedPathCandidate == null) { + return { + focusedIndex: 0, + getParentIndex: projection.getParentIndex, + paths: projection.paths, + posInSetByIndex: projection.posInSetByIndex, + setSizeByIndex: projection.setSizeByIndex, + visibleIndexByPath: null, + visibleIndexByPathFactory: () => projection.visibleIndexByPath, + }; } + const visibleIndexByPath = projection.visibleIndexByPath; return { - focusedPath, - parentPaths, - projectionRows: projection.rows, - visibleIndexByPath: projection.visibleIndexByPath, - visibleRows: rows, - }; -} - -// Builds a path-first lookup table so `getItem(path)` can stay fast without -// reaching into path-store internals for every lookup. -function createPathStoreTreesItemMetadata( - paths: readonly string[] -): Map { - const itemMetadata = new Map(); - - const ensureDirectory = (path: string, depth: number): void => { - if (itemMetadata.has(path)) { - return; - } - - itemMetadata.set(path, { - depth, - kind: 'directory', - path, - }); + focusedIndex: resolveFocusedIndex( + projection.paths.length, + visibleIndexByPath, + focusedPathCandidate + ), + getParentIndex: projection.getParentIndex, + paths: projection.paths, + posInSetByIndex: projection.posInSetByIndex, + setSizeByIndex: projection.setSizeByIndex, + visibleIndexByPath, + visibleIndexByPathFactory: () => visibleIndexByPath, }; - - for (const path of paths) { - const isDirectory = path.endsWith('/'); - const normalizedPath = isDirectory ? path.slice(0, -1) : path; - if (normalizedPath.length === 0) { - continue; - } - - const segments = normalizedPath.split('/'); - const directoryCount = isDirectory ? segments.length : segments.length - 1; - - for (let index = 0; index < directoryCount; index += 1) { - const directoryPath = `${segments.slice(0, index + 1).join('/')}/`; - ensureDirectory(directoryPath, index + 1); - } - - if (!isDirectory) { - itemMetadata.set(path, { - depth: segments.length, - kind: 'file', - path, - }); - } - } - - return itemMetadata; -} - -// Mirrors path-store's initial expansion contract so item handles can answer -// `isExpanded()` without reaching into path-store private state. -function createInitialExpandedDirectories( - itemMetadata: ReadonlyMap, - options: Omit -): readonly string[] { - const expandedDirectories = new Set(); - const { initialExpansion = 'closed', initialExpandedPaths } = options; - - if (initialExpansion === 'open') { - for (const [path, metadata] of itemMetadata) { - if (metadata.kind === 'directory') { - expandedDirectories.add(path); - } - } - } else if (typeof initialExpansion === 'number') { - for (const [path, metadata] of itemMetadata) { - if (metadata.kind === 'directory' && metadata.depth <= initialExpansion) { - expandedDirectories.add(path); - } - } - } - - for (const path of initialExpandedPaths ?? []) { - const resolvedPath = resolvePathStoreTreesItemPath(itemMetadata, path); - if ( - resolvedPath == null || - itemMetadata.get(resolvedPath)?.kind !== 'directory' - ) { - continue; - } - - for (const ancestorPath of getAncestorDirectoryPaths(resolvedPath)) { - expandedDirectories.add(ancestorPath); - } - expandedDirectories.add(resolvedPath); - } - - return [...expandedDirectories]; } /** @@ -258,34 +160,32 @@ function createInitialExpandedDirectories( export class PathStoreTreesController { readonly #baseOptions: Omit; readonly #listeners = new Set(); - #ancestorPathsByPath = new Map(); - #expandedDirectories = new Set(); + #ancestorPathsByIndex = new Map(); + #focusedIndex = -1; #focusedPath: string | null = null; + #hasFullProjection = false; + #getParentIndexForVisibleRow = (_index: number): number => -1; #itemHandles = new Map(); - #itemMetadata = new Map(); - #parentPaths = new Map(); - #projectionRows: readonly PathStoreVisibleTreeProjectionRow[] = []; + #projectionPaths: readonly string[] = []; + #projectionPosInSetByIndex: ProjectionIndexBuffer = new Int32Array(0); + #projectionSetSizeByIndex: ProjectionIndexBuffer = new Int32Array(0); #selectionAnchorPath: string | null = null; #selectedPaths = new Set(); #selectionVersion = 0; #store: PathStore; + #visibleCount = 0; #unsubscribe: (() => void) | null; - #visibleIndexByPath = new Map(); - #visibleRows: readonly PathStoreVisibleRow[] = []; + #visibleIndexByPath: Map | null = null; + #visibleIndexByPathFactory: (() => Map) | null = null; public constructor(options: PathStoreTreesControllerOptions) { const { paths, ...baseOptions } = options; this.#baseOptions = baseOptions; - const itemState = this.#createItemState(paths); this.#store = new PathStore({ ...baseOptions, - initialExpandedPaths: itemState.initialExpandedPaths, paths, }); - // Item handles close over `this.#store`, so apply them only after the live - // store instance exists. - this.#applyItemState(itemState); - this.#rebuildVisibleProjection(null); + this.#rebuildVisibleProjection(null, false); this.#unsubscribe = this.#subscribe(); } @@ -296,17 +196,18 @@ export class PathStoreTreesController { } public focusFirstItem(): void { - const firstRow = this.#visibleRows[0]; - if (firstRow != null) { - this.#setFocusedPath(firstRow.path); + if (this.#projectionPaths.length > 0) { + this.#setFocusedIndex(0); } } public focusLastItem(): void { - const lastRow = this.#visibleRows[this.#visibleRows.length - 1]; - if (lastRow != null) { - this.#setFocusedPath(lastRow.path); + if (this.#visibleCount <= 0) { + return; } + + this.#ensureFullProjection(); + this.#setFocusedIndex(this.#visibleCount - 1); } public focusNextItem(): void { @@ -314,32 +215,26 @@ export class PathStoreTreesController { } public focusParentItem(): void { - if (this.#focusedPath == null) { + if (this.#focusedIndex < 0) { return; } - const parentPath = this.#parentPaths.get(this.#focusedPath) ?? null; - if (parentPath != null) { - this.#setFocusedPath(parentPath); + const parentIndex = this.#getParentIndexForVisibleRow(this.#focusedIndex); + if (parentIndex >= 0) { + this.#setFocusedIndex(parentIndex); } } public focusPath(path: string): void { - const resolvedPath = resolvePathStoreTreesItemPath( - this.#itemMetadata, - path - ); + const resolvedPath = this.#store.getPathInfo(path)?.path ?? null; if (resolvedPath == null) { return; } - const nextFocusedPath = resolveFocusedPath( - this.#visibleRows.map((row) => row.path), - this.#visibleIndexByPath, - resolvedPath - ); - if (nextFocusedPath != null) { - this.#setFocusedPath(nextFocusedPath); + this.#ensureFullProjection(); + const nextFocusedIndex = this.#resolveFocusedIndex(resolvedPath); + if (nextFocusedIndex >= 0) { + this.#setFocusedIndex(nextFocusedIndex); } } @@ -348,17 +243,13 @@ export class PathStoreTreesController { } public getFocusedIndex(): number { - if (this.#focusedPath == null) { - return -1; - } - - return this.#visibleIndexByPath.get(this.#focusedPath) ?? -1; + return this.#focusedIndex; } public getFocusedItem(): PathStoreTreesItemHandle | null { return this.#focusedPath == null ? null - : (this.#itemHandles.get(this.#focusedPath) ?? null); + : this.#getOrCreateItemHandle(this.#focusedPath); } public getFocusedPath(): string | null { @@ -374,35 +265,40 @@ export class PathStoreTreesController { } public getVisibleCount(): number { - return this.#visibleRows.length; + return this.#visibleCount; } public getVisibleRows( start: number, end: number ): readonly PathStoreTreesVisibleRow[] { - if (end < start || this.#visibleRows.length === 0) { + if (end < start || this.#visibleCount === 0) { return []; } + if (!this.#hasFullProjection && end >= this.#projectionPaths.length) { + this.#ensureFullProjection(); + } + const boundedStart = Math.max(0, start); - const boundedEnd = Math.min(this.#visibleRows.length - 1, end); + const boundedEnd = Math.min(this.#visibleCount - 1, end); if (boundedEnd < boundedStart) { return []; } - return this.#visibleRows - .slice(boundedStart, boundedEnd + 1) + return this.#store + .getVisibleSlice(boundedStart, boundedEnd) .map((row, offset) => { - const projectionRow = this.#projectionRows[boundedStart + offset]; - if (projectionRow == null) { + const index = boundedStart + offset; + const projectionPath = this.#projectionPaths[index]; + if (projectionPath == null) { throw new Error( - `Missing projection row for visible index ${String(boundedStart + offset)}` + `Missing projection path for visible index ${String(index)}` ); } return { - ancestorPaths: this.#getAncestorPaths(projectionRow.path), + ancestorPaths: this.#getAncestorPaths(index), depth: row.depth, flattenedSegments: row.flattenedSegments?.map((segment) => ({ isTerminal: segment.isTerminal, @@ -410,19 +306,17 @@ export class PathStoreTreesController { path: segment.path, })), hasChildren: row.hasChildren, - index: projectionRow.index, + index, isExpanded: row.isExpanded, isFlattened: row.isFlattened, - isFocused: projectionRow.path === this.#focusedPath, - isSelected: this.#selectedPaths.has( - getVisibleSelectionTargetPath(row) - ), + isFocused: projectionPath === this.#focusedPath, + isSelected: this.#selectedPaths.has(projectionPath), kind: row.kind, level: row.depth, name: row.name, - path: projectionRow.path, - posInSet: projectionRow.posInSet, - setSize: projectionRow.setSize, + path: projectionPath, + posInSet: this.#projectionPosInSetByIndex[index] ?? 0, + setSize: this.#projectionSetSizeByIndex[index] ?? 0, } satisfies PathStoreTreesVisibleRow; }); } @@ -434,19 +328,15 @@ export class PathStoreTreesController { * paths (`src`) so callers do not need to know the canonical slash rules. */ public getItem(path: string): PathStoreTreesItemHandle | null { - const resolvedPath = resolvePathStoreTreesItemPath( - this.#itemMetadata, - path - ); - return resolvedPath == null + const itemInfo = this.#store.getPathInfo(path); + return itemInfo == null ? null - : (this.#itemHandles.get(resolvedPath) ?? null); + : this.#getOrCreateItemHandle(itemInfo.path, itemInfo); } public selectAllVisiblePaths(): void { - const nextSelectedPaths = this.#visibleRows.map((row) => - getVisibleSelectionTargetPath(row) - ); + this.#ensureFullProjection(); + const nextSelectedPaths = [...this.#projectionPaths]; this.#applySelection( nextSelectedPaths, this.#focusedPath ?? this.#selectionAnchorPath @@ -531,12 +421,12 @@ export class PathStoreTreesController { return; } + this.#ensureFullProjection(); const anchorPath = this.#selectionAnchorPath; - if ( - anchorPath == null || - !this.#visibleIndexByPath.has(anchorPath) || - !this.#visibleIndexByPath.has(resolvedPath) - ) { + const anchorIndex = + anchorPath == null ? -1 : this.#findVisibleIndexByPath(anchorPath); + const targetIndex = this.#findVisibleIndexByPath(resolvedPath); + if (anchorIndex === -1 || targetIndex === -1) { const nextSelectedPaths = unionSelection ? [...this.#selectedPaths, resolvedPath] : [resolvedPath]; @@ -544,19 +434,11 @@ export class PathStoreTreesController { return; } - const anchorIndex = this.#visibleIndexByPath.get(anchorPath); - const targetIndex = this.#visibleIndexByPath.get(resolvedPath); - if (anchorIndex == null || targetIndex == null) { - return; - } - const [startIndex, endIndex] = anchorIndex <= targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex]; - const rangePaths = this.#visibleRows - .slice(startIndex, endIndex + 1) - .map((row) => getVisibleSelectionTargetPath(row)); + const rangePaths = this.#projectionPaths.slice(startIndex, endIndex + 1); const nextSelectedPaths = unionSelection ? [...this.#selectedPaths, ...rangePaths] : rangePaths; @@ -568,26 +450,26 @@ export class PathStoreTreesController { return; } - const focusedIndex = this.getFocusedIndex(); + const focusedIndex = this.#focusedIndex; if (focusedIndex === -1) { return; } const nextIndex = Math.min( - this.#projectionRows.length - 1, + this.#visibleCount - 1, Math.max(0, focusedIndex + offset) ); if (nextIndex === focusedIndex) { return; } - const currentRow = this.#visibleRows[focusedIndex]; - const nextRow = this.#visibleRows[nextIndex]; - const currentPath = - currentRow == null ? null : getVisibleSelectionTargetPath(currentRow); - const nextPath = - nextRow == null ? null : getVisibleSelectionTargetPath(nextRow); - if (currentPath == null || nextPath == null || nextRow == null) { + if (!this.#hasFullProjection && nextIndex >= this.#projectionPaths.length) { + this.#ensureFullProjection(); + } + + const currentPath = this.#projectionPaths[focusedIndex] ?? null; + const nextPath = this.#projectionPaths[nextIndex] ?? null; + if (currentPath == null || nextPath == null) { return; } @@ -603,7 +485,7 @@ export class PathStoreTreesController { this.#selectionAnchorPath ?? currentPath, false ); - this.#setFocusedPath(nextRow.path); + this.#setFocusedIndex(nextIndex); } public subscribe(listener: PathStoreTreesControllerListener): () => void { @@ -619,10 +501,8 @@ export class PathStoreTreesController { * can evolve the action model without exposing the raw PathStore instance. */ public replacePaths(paths: readonly string[]): void { - const nextItemState = this.#createItemState(paths); const nextStore = new PathStore({ ...this.#baseOptions, - initialExpandedPaths: nextItemState.initialExpandedPaths, paths, }); const previousFocusedPath = this.#focusedPath; @@ -631,11 +511,9 @@ export class PathStoreTreesController { this.#unsubscribe?.(); this.#store = nextStore; - this.#applyItemState(nextItemState); + this.#itemHandles.clear(); const nextSelectedPaths = previousSelectedPaths - .map((selectedPath) => - resolvePathStoreTreesItemPath(nextItemState.itemMetadata, selectedPath) - ) + .map((selectedPath) => nextStore.getPathInfo(selectedPath)?.path ?? null) .filter((resolved): resolved is string => resolved != null); const selectionChanged = !arePathSetsEqual( this.#selectedPaths, @@ -648,38 +526,84 @@ export class PathStoreTreesController { this.#selectionAnchorPath = previousSelectionAnchorPath == null ? null - : (resolvePathStoreTreesItemPath( - nextItemState.itemMetadata, - previousSelectionAnchorPath - ) ?? null); - this.#rebuildVisibleProjection(previousFocusedPath); + : (nextStore.getPathInfo(previousSelectionAnchorPath)?.path ?? null); + this.#rebuildVisibleProjection( + previousFocusedPath, + previousFocusedPath != null || + nextSelectedPaths.length > 0 || + this.#selectionAnchorPath != null + ); this.#unsubscribe = this.#subscribe(); this.#emit(); } - #applyItemState(itemState: PathStoreTreesItemState): void { - this.#expandedDirectories = itemState.expandedDirectories; - this.#itemHandles = itemState.itemHandles; - this.#itemMetadata = itemState.itemMetadata; + #ensureVisibleIndexByPath(): ReadonlyMap { + this.#visibleIndexByPath ??= + this.#visibleIndexByPathFactory?.() ?? new Map(); + + return this.#visibleIndexByPath; + } + + #findVisibleIndexByPath(path: string): number { + return this.#ensureVisibleIndexByPath().get(path) ?? -1; + } + + #resolveFocusedIndex(path: string): number { + const directIndex = this.#findVisibleIndexByPath(path); + if (directIndex !== -1) { + return directIndex; + } + + const ancestorPath = findNearestVisibleAncestorPath( + this.#ensureVisibleIndexByPath(), + path + ); + return ancestorPath == null + ? -1 + : this.#findVisibleIndexByPath(ancestorPath); + } + + #getOrCreateItemHandle( + path: string, + itemInfo?: PathStorePathInfo + ): PathStoreTreesItemHandle | null { + const cachedHandle = this.#itemHandles.get(path); + if (cachedHandle != null) { + return cachedHandle; + } + + const resolvedItemInfo = itemInfo ?? this.#store.getPathInfo(path); + if (resolvedItemInfo == null) { + return null; + } + + const handle = + resolvedItemInfo.kind === 'directory' + ? this.#createDirectoryHandle(resolvedItemInfo.path) + : this.#createFileHandle(resolvedItemInfo.path); + this.#itemHandles.set(resolvedItemInfo.path, handle); + return handle; } - #getAncestorPaths(path: string): readonly string[] { - const cached = this.#ancestorPathsByPath.get(path); + #getAncestorPaths(index: number): readonly string[] { + const cached = this.#ancestorPathsByIndex.get(index); if (cached != null) { return cached; } - const parentPath = this.#parentPaths.get(path) ?? null; + const parentIndex = this.#getParentIndexForVisibleRow(index); const ancestorPaths = - parentPath == null + parentIndex < 0 ? [] - : [...this.#getAncestorPaths(parentPath), parentPath]; - this.#ancestorPathsByPath.set(path, ancestorPaths); + : [ + ...this.#getAncestorPaths(parentIndex), + this.#projectionPaths[parentIndex] ?? '', + ].filter((path) => path !== ''); + this.#ancestorPathsByIndex.set(index, ancestorPaths); return ancestorPaths; } #collapseDirectory(path: string): void { - this.#expandedDirectories.delete(path); this.#store.collapse(path); } @@ -724,7 +648,7 @@ export class PathStoreTreesController { }, getPath: () => path, isDirectory: () => true, - isExpanded: () => this.#expandedDirectories.has(path), + isExpanded: () => this.#store.isExpanded(path), isFocused: () => this.#focusedPath === path, isSelected: () => this.#selectedPaths.has(path), select: () => { @@ -760,31 +684,6 @@ export class PathStoreTreesController { }; } - #createItemState(paths: readonly string[]): PathStoreTreesItemState { - const itemMetadata = createPathStoreTreesItemMetadata(paths); - const initialExpandedPaths = createInitialExpandedDirectories( - itemMetadata, - this.#baseOptions - ); - const expandedDirectories = new Set(initialExpandedPaths); - const itemHandles = new Map(); - - for (const metadata of itemMetadata.values()) { - const handle = - metadata.kind === 'directory' - ? this.#createDirectoryHandle(metadata.path) - : this.#createFileHandle(metadata.path); - itemHandles.set(metadata.path, handle); - } - - return { - expandedDirectories, - initialExpandedPaths, - itemHandles, - itemMetadata, - }; - } - #emit(): void { for (const listener of this.#listeners) { listener(); @@ -793,77 +692,103 @@ export class PathStoreTreesController { #expandDirectory(path: string): void { for (const ancestorPath of getAncestorDirectoryPaths(path)) { - if (this.#expandedDirectories.has(ancestorPath)) { + if (this.#store.isExpanded(ancestorPath)) { continue; } - this.#expandedDirectories.add(ancestorPath); this.#store.expand(ancestorPath); } - this.#expandedDirectories.add(path); - this.#store.expand(path); + if (!this.#store.isExpanded(path)) { + this.#store.expand(path); + } } #moveFocus(offset: -1 | 1): void { - const itemCount = this.#projectionRows.length; + const itemCount = this.#visibleCount; if (itemCount === 0) { return; } - const focusedIndex = this.getFocusedIndex(); - const currentIndex = focusedIndex === -1 ? 0 : focusedIndex; + const currentIndex = this.#focusedIndex === -1 ? 0 : this.#focusedIndex; const nextIndex = Math.min( itemCount - 1, Math.max(0, currentIndex + offset) ); - const nextRow = this.#visibleRows[nextIndex]; - if (nextRow != null) { - this.#setFocusedPath(nextRow.path); + if (!this.#hasFullProjection && nextIndex >= this.#projectionPaths.length) { + this.#ensureFullProjection(); + } + if (nextIndex !== currentIndex || this.#focusedIndex === -1) { + this.#setFocusedIndex(nextIndex); } } - #rebuildVisibleProjection(focusedPathCandidate: string | null): void { - const visibleCount = this.#store.getVisibleCount(); - const rows = - visibleCount > 0 ? this.#store.getVisibleSlice(0, visibleCount - 1) : []; - const projection = createVisibleProjection(rows, focusedPathCandidate); - this.#ancestorPathsByPath.clear(); - this.#focusedPath = projection.focusedPath; - this.#parentPaths = projection.parentPaths; - this.#projectionRows = projection.projectionRows; + #rebuildVisibleProjection( + focusedPathCandidate: string | null, + full: boolean = true + ): void { + this.#visibleCount = this.#store.getVisibleCount(); + const projection = createVisibleProjection( + this.#store.getVisibleTreeProjectionData( + full + ? undefined + : Math.min(this.#visibleCount, INITIAL_PROJECTION_ROW_LIMIT) + ), + focusedPathCandidate + ); + this.#ancestorPathsByIndex.clear(); + this.#hasFullProjection = projection.paths.length >= this.#visibleCount; + this.#focusedIndex = projection.focusedIndex; + this.#focusedPath = + projection.focusedIndex < 0 + ? null + : (projection.paths[projection.focusedIndex] ?? null); + this.#getParentIndexForVisibleRow = projection.getParentIndex; + this.#projectionPaths = projection.paths; + this.#projectionPosInSetByIndex = projection.posInSetByIndex; + this.#projectionSetSizeByIndex = projection.setSizeByIndex; this.#visibleIndexByPath = projection.visibleIndexByPath; - this.#visibleRows = projection.visibleRows; + this.#visibleIndexByPathFactory = projection.visibleIndexByPathFactory; } #resolveSelectionPath(path: string): string | null { - return resolvePathStoreTreesItemPath(this.#itemMetadata, path); + return this.#store.getPathInfo(path)?.path ?? null; } - #setFocusedPath(path: string, emit: boolean = true): void { - const currentFocusedPath = this.#focusedPath; - if (currentFocusedPath === path) { + #setFocusedIndex(index: number, emit: boolean = true): void { + const nextPath = this.#projectionPaths[index]; + if (nextPath == null) { return; } - if (!this.#visibleIndexByPath.has(path)) { + if (this.#focusedIndex === index && this.#focusedPath === nextPath) { return; } - this.#focusedPath = path; + + this.#focusedIndex = index; + this.#focusedPath = nextPath; if (emit) { this.#emit(); } } + #ensureFullProjection(): void { + if (this.#hasFullProjection) { + return; + } + + this.#rebuildVisibleProjection(this.#focusedPath, true); + } + #subscribe(): () => void { return this.#store.on('*', () => { - this.#rebuildVisibleProjection(this.#focusedPath); + this.#rebuildVisibleProjection(this.#focusedPath, true); this.#emit(); }); } #toggleDirectory(path: string): void { - if (this.#expandedDirectories.has(path)) { + if (this.#store.isExpanded(path)) { this.#collapseDirectory(path); return; }