From 764a5c6b7fbcc1891c6a5f69157c5e01f1948c6d Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 31 Mar 2026 08:28:59 -0500 Subject: [PATCH 1/7] oh god, every file --- .../_components/ReactClientRendered.tsx | 19 +- .../_components/ReactServerRendered.tsx | 19 +- .../_components/TreesDevSettingsProvider.tsx | 16 +- .../_components/VanillaClientRendered.tsx | 14 +- .../_components/VanillaServerRendered.tsx | 14 +- .../context-menu/ContextMenuDemoClient.tsx | 31 +- apps/docs/app/trees-dev/context-menu/page.tsx | 19 +- .../custom-icons/CustomIconsDemoClient.tsx | 57 +- apps/docs/app/trees-dev/custom-icons/page.tsx | 5 +- apps/docs/app/trees-dev/demo-data.ts | 35 +- .../drag-and-drop/DragAndDropDemoClient.tsx | 49 +- .../docs/app/trees-dev/drag-and-drop/page.tsx | 11 +- .../dynamic-files/DynamicFilesDemoClient.tsx | 68 +- .../docs/app/trees-dev/dynamic-files/page.tsx | 11 +- .../git-status/GitStatusDemoClient.tsx | 49 +- apps/docs/app/trees-dev/git-status/page.tsx | 6 +- .../header-slot/HeaderSlotDemoClient.tsx | 31 +- apps/docs/app/trees-dev/header-slot/page.tsx | 11 +- apps/docs/app/trees-dev/page.tsx | 11 +- .../app/trees-dev/state/StateDemoClient.tsx | 44 +- apps/docs/app/trees-dev/state/page.tsx | 11 +- .../app/trees-dev/themes/ThemesGridClient.tsx | 3 +- .../app/trees-dev/virtualization/page.tsx | 8 +- apps/docs/app/trees/demo-data.ts | 33 +- .../docs/app/trees/docs/Overview/constants.ts | 4 +- .../app/trees/tree-examples/A11ySection.tsx | 19 +- .../tree-examples/CustomIconsSection.tsx | 56 +- .../trees/tree-examples/DragDropSection.tsx | 12 +- .../tree-examples/DragDropSectionClient.tsx | 20 +- .../trees/tree-examples/FlatteningSection.tsx | 38 +- .../trees/tree-examples/GitStatusSection.tsx | 17 +- .../tree-examples/GitStatusSectionClient.tsx | 25 +- .../app/trees/tree-examples/SearchSection.tsx | 53 +- .../trees/tree-examples/StylingSection.tsx | 48 +- .../trees/tree-examples/ThemingSection.tsx | 13 +- .../tree-examples/ThemingSectionClient.tsx | 12 +- .../tree-examples/VirtualizationSection.tsx | 20 +- .../docs/app/trees/tree-examples/demo-data.ts | 32 +- package.json | 1 + packages/trees/package.json | 2 + .../scripts/benchmarkIncrementalTreeIndex.ts | 721 ++++++++++++ ...benchmarkVirtualizedFileTreeClientMount.ts | 5 +- .../benchmarkVirtualizedFileTreeRender.ts | 5 +- .../lib/benchmarkVirtualizedRenderRuntime.tsx | 16 +- .../scripts/profileTreesDevVirtualization.ts | 345 ++++-- packages/trees/src/FileTree.ts | 175 ++- packages/trees/src/components/Root.tsx | 175 +-- .../hooks/useContextMenuController.ts | 19 +- .../components/hooks/useExpansionMigration.ts | 22 +- .../trees/src/components/hooks/useTree.ts | 2 +- .../trees/src/core/INCREMENTAL_TREE_INDEX.md | 80 ++ packages/trees/src/core/create-tree.ts | 185 +-- .../trees/src/core/incremental-tree-index.ts | 1015 +++++++++++++++++ .../core/utilities/insert-items-at-target.ts | 27 +- .../utilities/remove-items-from-parents.ts | 17 +- .../src/features/async-data-loader/feature.ts | 5 +- packages/trees/src/features/main/types.ts | 17 + packages/trees/src/features/tree/feature.ts | 12 +- packages/trees/src/index.ts | 1 + packages/trees/src/model/FileTreeModel.ts | 907 +++++++++++++++ packages/trees/src/model/index.ts | 1 + packages/trees/src/react/FileTree.tsx | 15 +- .../src/react/utils/useFileTreeInstance.ts | 104 +- packages/trees/src/utils/pathLookups.ts | 17 + packages/trees/test/context-menu.test.ts | 89 +- .../trees/test/core/incremental-index.test.ts | 372 ++++++ .../e2e/fixtures/benchmarkInstrumentation.ts | 124 +- .../trees/test/e2e/fixtures/context-menu.html | 10 +- .../trees/test/e2e/fixtures/git-status.html | 6 +- .../test/e2e/fixtures/style-isolation.html | 4 +- .../trees/test/e2e/fixtures/touch-dnd.html | 6 +- .../test/e2e/fixtures/virtualization.html | 448 ++++++-- packages/trees/test/file-tree-model.test.ts | 102 ++ .../trees/test/guide-line-ancestors.test.ts | 3 +- packages/trees/test/react-controlled.test.tsx | 125 +- .../test/root-memoization-regressions.test.ts | 3 +- .../test/ssr-declarative-shadow-dom.test.ts | 174 ++- 77 files changed, 5367 insertions(+), 934 deletions(-) create mode 100644 packages/trees/scripts/benchmarkIncrementalTreeIndex.ts create mode 100644 packages/trees/src/core/INCREMENTAL_TREE_INDEX.md create mode 100644 packages/trees/src/core/incremental-tree-index.ts create mode 100644 packages/trees/src/model/FileTreeModel.ts create mode 100644 packages/trees/src/model/index.ts create mode 100644 packages/trees/test/core/incremental-index.test.ts create mode 100644 packages/trees/test/file-tree-model.test.ts diff --git a/apps/docs/app/trees-dev/_components/ReactClientRendered.tsx b/apps/docs/app/trees-dev/_components/ReactClientRendered.tsx index 3b1eebd21..22573bf9a 100644 --- a/apps/docs/app/trees-dev/_components/ReactClientRendered.tsx +++ b/apps/docs/app/trees-dev/_components/ReactClientRendered.tsx @@ -1,8 +1,13 @@ 'use client'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import { FileTree as FileTreeReact } from '@pierre/trees/react'; +import { + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; + /** * React FileTree - Client-Side Rendered * No prerendered HTML, renders entirely on client @@ -12,14 +17,20 @@ export function ReactClientRendered({ initialFiles, stateConfig, }: { - options: Omit; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; }) { + const runtimeOptions = toRuntimeFileTreeOptions({ + ...options, + initialFiles: initialFiles ?? [], + }); + const { model, ...reactOptions } = runtimeOptions; + return ( ; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; prerenderedHTML: string; }) { + const runtimeOptions = toRuntimeFileTreeOptions({ + ...options, + initialFiles: initialFiles ?? [], + }); + const { model, ...reactOptions } = runtimeOptions; + return ( void; setUseLazyDataLoader: (val: boolean) => void; handleResetControls: () => void; - fileTreeOptions: FileTreeOptions; - reactOptions: Omit; + fileTreeOptions: TreesDevFileTreeOptions; + reactOptions: Omit; reactFiles: string[] | undefined; } @@ -102,7 +106,7 @@ export function TreesDevSettingsProvider({ }${cookieSuffix}`; }, [cookieMaxAge, flattenEmptyDirectories, useLazyDataLoader]); - const fileTreeOptions = useMemo( + const fileTreeOptions = useMemo( () => ({ ...sharedDemoFileTreeOptions, flattenEmptyDirectories, @@ -111,7 +115,9 @@ export function TreesDevSettingsProvider({ [flattenEmptyDirectories, useLazyDataLoader] ); - const { initialFiles: reactFiles, ...reactOptions } = fileTreeOptions; + const reactFiles = fileTreeOptions.initialFiles; + const runtimeReactOptions = toRuntimeFileTreeOptions(fileTreeOptions); + const { model: _reactModel, ...reactOptions } = runtimeReactOptions; const value = useMemo( () => ({ diff --git a/apps/docs/app/trees-dev/_components/VanillaClientRendered.tsx b/apps/docs/app/trees-dev/_components/VanillaClientRendered.tsx index 1bbfba772..77ba6b7b9 100644 --- a/apps/docs/app/trees-dev/_components/VanillaClientRendered.tsx +++ b/apps/docs/app/trees-dev/_components/VanillaClientRendered.tsx @@ -1,9 +1,14 @@ 'use client'; import { FileTree } from '@pierre/trees'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import { useCallback, useRef } from 'react'; +import { + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; + /** * Vanilla FileTree - Client-Side Rendered * Uses ref callback to create and render FileTree instance on client mount @@ -12,7 +17,7 @@ export function VanillaClientRendered({ options, stateConfig, }: { - options: FileTreeOptions; + options: TreesDevFileTreeOptions; stateConfig?: FileTreeStateConfig; }) { const instanceRef = useRef(null); @@ -29,7 +34,10 @@ export function VanillaClientRendered({ node.innerHTML = ''; } - const fileTree = new FileTree(options, stateConfig); + const fileTree = new FileTree( + toRuntimeFileTreeOptions(options), + stateConfig + ); fileTree.render({ containerWrapper: node }); instanceRef.current = fileTree; diff --git a/apps/docs/app/trees-dev/_components/VanillaServerRendered.tsx b/apps/docs/app/trees-dev/_components/VanillaServerRendered.tsx index 1bd62cb72..a1cb9a40f 100644 --- a/apps/docs/app/trees-dev/_components/VanillaServerRendered.tsx +++ b/apps/docs/app/trees-dev/_components/VanillaServerRendered.tsx @@ -1,10 +1,15 @@ 'use client'; import { FileTree } from '@pierre/trees'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import '@pierre/trees/web-components'; import { useCallback, useRef } from 'react'; +import { + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; + /** * Vanilla FileTree - Server-Side Rendered * Uses declarative shadow DOM to prerender HTML, then hydrates with FileTree instance. @@ -16,7 +21,7 @@ export function VanillaServerRendered({ stateConfig, containerHtml, }: { - options: FileTreeOptions; + options: TreesDevFileTreeOptions; stateConfig?: FileTreeStateConfig; containerHtml: string; }) { @@ -46,7 +51,10 @@ export function VanillaServerRendered({ } } - const fileTree = new FileTree(options, stateConfig); + const fileTree = new FileTree( + toRuntimeFileTreeOptions(options), + stateConfig + ); if (!hasHydratedRef.current) { // Initial mount - hydrate the prerendered HTML diff --git a/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx b/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx index 1f8ca72de..b8aeafdf5 100644 --- a/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx +++ b/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx @@ -1,7 +1,7 @@ 'use client'; import { CONTEXT_MENU_SLOT_NAME, FileTree } from '@pierre/trees'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import { FileTree as FileTreeReact } from '@pierre/trees/react'; import '@pierre/trees/web-components'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -16,7 +16,11 @@ import { TreeDemoContextMenu, } from '../_components/TreeDemoContextMenu'; import { useTreesDevSettings } from '../_components/TreesDevSettingsProvider'; -import { sharedDemoStateConfig } from '../demo-data'; +import { + sharedDemoStateConfig, + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; interface ContextMenuDemoClientProps { preloadedContextMenuFileTreeHtml: string; @@ -64,7 +68,7 @@ function VanillaSSRContextMenu({ stateConfig, containerHtml, }: { - options: FileTreeOptions; + options: TreesDevFileTreeOptions; stateConfig?: FileTreeStateConfig; containerHtml: string; }) { @@ -106,11 +110,11 @@ function VanillaSSRContextMenu({ }; const fileTree = new FileTree( - { + toRuntimeFileTreeOptions({ ...options, initialFiles: filesRef.current, renaming: renamingOptions, - }, + }), { ...stateConfig, onFilesChange: (nextFiles) => { @@ -172,7 +176,7 @@ function ReactSSRContextMenu({ stateConfig, prerenderedHTML, }: { - options: Omit; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; prerenderedHTML: string; @@ -183,10 +187,21 @@ function ReactSSRContextMenu({ [] ); + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: files, + renaming: renamingOptions, + }), + [files, options, renamingOptions] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + return ( {}; - const contextMenuSsr = preloadFileTree(fileTreeOptions, { - ...sharedDemoStateConfig, - onContextMenuOpen: noop, - onContextMenuClose: noop, - }); + const contextMenuSsr = preloadFileTree( + toRuntimeFileTreeOptions(fileTreeOptions), + { + ...sharedDemoStateConfig, + onContextMenuOpen: noop, + onContextMenuClose: noop, + } + ); return ( ; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; }) { + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: initialFiles ?? [], + icons: { spriteSheet: customSpriteSheet, remap: CUSTOM_ICONS_REMAP }, + }), + [initialFiles, options] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + return ( @@ -127,22 +140,30 @@ function ReactSSRCustomIcons({ stateConfig, prerenderedHTML, }: { - options: Omit; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; prerenderedHTML: string; }) { + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: initialFiles ?? [], + icons: { spriteSheet: customSpriteSheet, remap: CUSTOM_ICONS_REMAP }, + }), + [initialFiles, options] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + return ( & { + initialFiles: string[]; +}; + +export const sharedDemoFileTreeOptions: TreesDevFileTreeOptions = { flattenEmptyDirectories: true, initialFiles: sampleFileList, }; +export function toRuntimeFileTreeOptions( + options: TreesDevFileTreeOptions +): FileTreeOptions { + const { initialFiles, sort, ...rest } = options; + const sortComparator = + sort === false + ? false + : sort != null && typeof sort === 'object' + ? sort.comparator + : undefined; + + return { + ...rest, + sort, + model: FileTreeModel.fromFiles(initialFiles, { sortComparator }), + }; +} + export const GIT_STATUSES_A: GitStatusEntry[] = [ { path: 'src/index.ts', status: 'modified' }, { path: 'src/components/Button.tsx', status: 'added' }, diff --git a/apps/docs/app/trees-dev/drag-and-drop/DragAndDropDemoClient.tsx b/apps/docs/app/trees-dev/drag-and-drop/DragAndDropDemoClient.tsx index bd49d74f2..852d15356 100644 --- a/apps/docs/app/trees-dev/drag-and-drop/DragAndDropDemoClient.tsx +++ b/apps/docs/app/trees-dev/drag-and-drop/DragAndDropDemoClient.tsx @@ -1,14 +1,19 @@ 'use client'; import { FileTree } from '@pierre/trees'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import { FileTree as FileTreeReact } from '@pierre/trees/react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { ExampleCard } from '../_components/ExampleCard'; import { StateLog, useStateLog } from '../_components/StateLog'; import { useTreesDevSettings } from '../_components/TreesDevSettingsProvider'; -import { sharedDemoFileTreeOptions, sharedDemoStateConfig } from '../demo-data'; +import { + sharedDemoFileTreeOptions, + sharedDemoStateConfig, + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; interface DragAndDropDemoClientProps { preloadedFileTreeHtml: string; @@ -45,7 +50,7 @@ function VanillaDnDUncontrolled({ options, stateConfig, }: { - options: FileTreeOptions; + options: TreesDevFileTreeOptions; stateConfig?: FileTreeStateConfig; }) { const instanceRef = useRef(null); @@ -73,11 +78,11 @@ function VanillaDnDUncontrolled({ } const fileTree = new FileTree( - { + toRuntimeFileTreeOptions({ ...options, dragAndDrop: true, initialFiles: sharedDemoFileTreeOptions.initialFiles, - }, + }), mergedStateConfig ); fileTree.render({ containerWrapper: node }); @@ -111,7 +116,7 @@ function ReactDnDControlled({ options, stateConfig, }: { - options: Omit; + options: Omit; stateConfig?: FileTreeStateConfig; }) { const [files, setFiles] = useState(sharedDemoFileTreeOptions.initialFiles); @@ -134,6 +139,17 @@ function ReactDnDControlled({ [lockGitignore, files, addLog] ); + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: files, + dragAndDrop: true, + }), + [files, options] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + return ( ; + options: Omit; stateConfig?: FileTreeStateConfig; prerenderedHTML: string; }) { @@ -202,6 +218,17 @@ function ReactDnDControlledSSR({ [lockGitignore, files, addLog] ); + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: files, + dragAndDrop: true, + }), + [files, options] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + return ( ; } diff --git a/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx b/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx index 67f7a372d..f1f46ea04 100644 --- a/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx +++ b/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx @@ -1,13 +1,18 @@ 'use client'; import { FileTree } from '@pierre/trees'; -import type { FileTreeOptions, FileTreeStateConfig } from '@pierre/trees'; +import type { FileTreeStateConfig } from '@pierre/trees'; import { FileTree as FileTreeReact } from '@pierre/trees/react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ExampleCard } from '../_components/ExampleCard'; import { useTreesDevSettings } from '../_components/TreesDevSettingsProvider'; -import { sharedDemoFileTreeOptions, sharedDemoStateConfig } from '../demo-data'; +import { + sharedDemoFileTreeOptions, + sharedDemoStateConfig, + toRuntimeFileTreeOptions, + type TreesDevFileTreeOptions, +} from '../demo-data'; const EXTRA_FILE = 'Build/assets/images/social/logo2.png'; @@ -46,7 +51,7 @@ function VanillaDynamicFiles({ options, stateConfig, }: { - options: FileTreeOptions; + options: TreesDevFileTreeOptions; stateConfig?: FileTreeStateConfig; }) { const instanceRef = useRef(null); @@ -64,7 +69,10 @@ function VanillaDynamicFiles({ } const fileTree = new FileTree( - { ...options, initialFiles: sharedDemoFileTreeOptions.initialFiles }, + toRuntimeFileTreeOptions({ + ...options, + initialFiles: sharedDemoFileTreeOptions.initialFiles, + }), stateConfig ); fileTree.render({ containerWrapper: node }); @@ -81,7 +89,7 @@ function VanillaDynamicFiles({ return ( } /> @@ -598,7 +591,7 @@ describe('React controlled FileTree wrapper', () => { root.render(
{item.path}
} /> ); @@ -648,7 +641,7 @@ describe('React controlled FileTree wrapper', () => { root.render( Header action} /> @@ -663,7 +656,11 @@ describe('React controlled FileTree wrapper', () => { act(() => { root.render( - + ); }); diff --git a/packages/trees/test/root-memoization-regressions.test.ts b/packages/trees/test/root-memoization-regressions.test.ts index 8bdcf268b..207d30be6 100644 --- a/packages/trees/test/root-memoization-regressions.test.ts +++ b/packages/trees/test/root-memoization-regressions.test.ts @@ -13,6 +13,7 @@ import { selectionFeature } from '../src/features/selection/feature'; import { syncDataLoaderFeature } from '../src/features/sync-data-loader/feature'; import type { FileTreeSearchConfig } from '../src/FileTree'; import { generateSyncDataLoader } from '../src/loader/sync'; +import { FileTreeModel } from '../src/model/FileTreeModel'; import type { FileTreeNode } from '../src/types'; let FileTree: typeof import('../src/FileTree').FileTree; @@ -127,7 +128,7 @@ describe('Root memoization regressions', () => { test('folder rows update aria-expanded when expansion state changes', async () => { const ft = new FileTree({ - initialFiles: ['README.md', 'src/index.ts'], + model: FileTreeModel.fromFiles(['README.md', 'src/index.ts']), }); const containerWrapper = document.createElement('div'); diff --git a/packages/trees/test/ssr-declarative-shadow-dom.test.ts b/packages/trees/test/ssr-declarative-shadow-dom.test.ts index 7e6cb0e7d..b49a627f1 100644 --- a/packages/trees/test/ssr-declarative-shadow-dom.test.ts +++ b/packages/trees/test/ssr-declarative-shadow-dom.test.ts @@ -8,6 +8,7 @@ import { } from '../src/utils/renameFileTreePaths'; let FileTree: typeof import('../src/FileTree').FileTree; +let FileTreeModel: typeof import('../src/model/FileTreeModel').FileTreeModel; let preloadFileTree: typeof import('../src/ssr/preloadFileTree').preloadFileTree; let ensureFileTreeStyles: typeof import('../src/components/web-components').ensureFileTreeStyles; let adoptDeclarativeShadowDom: typeof import('../src/components/web-components').adoptDeclarativeShadowDom; @@ -41,6 +42,7 @@ beforeAll(async () => { Object.assign(globalThis, { CSSStyleSheet: MockCSSStyleSheet }); ({ FileTree } = await import('../src/FileTree')); + ({ FileTreeModel } = await import('../src/model/FileTreeModel')); ({ preloadFileTree } = await import('../src/ssr/preloadFileTree')); ({ ensureFileTreeStyles, adoptDeclarativeShadowDom } = await import('../src/components/web-components')); @@ -63,9 +65,60 @@ const CUSTOM_SPRITE_B = ` `; +const flushRenderWork = async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +function createFileTree( + options: Omit & { + initialFiles: string[]; + }, + stateConfig?: import('../src/FileTree').FileTreeStateConfig +): import('../src/FileTree').FileTree { + const { initialFiles, sort, ...restOptions } = options; + const sortComparator = + sort === false + ? false + : sort != null && typeof sort === 'object' + ? sort.comparator + : undefined; + return new FileTree( + { + ...restOptions, + sort, + model: FileTreeModel.fromFiles(initialFiles, { sortComparator }), + }, + stateConfig + ); +} + +function preloadFileTreeWithFiles( + options: Omit & { + initialFiles: string[]; + }, + stateConfig?: import('../src/FileTree').FileTreeStateConfig +): ReturnType { + const { initialFiles, sort, ...restOptions } = options; + const sortComparator = + sort === false + ? false + : sort != null && typeof sort === 'object' + ? sort.comparator + : undefined; + return preloadFileTree( + { + ...restOptions, + sort, + model: FileTreeModel.fromFiles(initialFiles, { sortComparator }), + }, + stateConfig + ); +} + describe('SSR + declarative shadow DOM', () => { test('preloadFileTree returns an id and shadow HTML containing the expected wrapper', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md', 'src/index.ts'], }); @@ -78,7 +131,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('preloadFileTree omits the built-in search input by default', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md', 'src/index.ts'], }); @@ -87,7 +140,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('preloadFileTree includes the built-in search input when enabled', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md', 'src/index.ts'], search: true, }); @@ -97,7 +150,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('preloadFileTree includes unsafeCSS when provided', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md', 'src/index.ts'], unsafeCSS: `[data-item-section="content"] { color: hotpink; }`, }); @@ -144,7 +197,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('FileTree.hydrate uses existing SSR wrapper and calls hydrateRoot (not renderRoot)', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md', 'src/index.ts', 'src/components/Button.tsx'], }); @@ -165,7 +218,9 @@ describe('SSR + declarative shadow DOM', () => { }; try { - const ft = new FileTree({ initialFiles: ['README.md', 'src/index.ts'] }); + const ft = createFileTree({ + initialFiles: ['README.md', 'src/index.ts'], + }); ft.hydrate({ fileTreeContainer: container }); expect(ft.__id).toBe(payload.id); expect(hydrated).toBe(1); @@ -211,7 +266,7 @@ describe('SSR + declarative shadow DOM', () => { try { // Client creates FileTree WITH dragAndDrop and hydrates - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['README.md', 'src/index.ts'], dragAndDrop: true, id: ssrId, @@ -232,31 +287,72 @@ describe('SSR + declarative shadow DOM', () => { test('getFiles returns initialFiles from constructor', () => { const files = ['README.md', 'src/index.ts']; - const ft = new FileTree({ initialFiles: files }); + const ft = createFileTree({ initialFiles: files }); expect(ft.getFiles()).toEqual(files); }); - test('setFiles updates getFiles return value', () => { - const ft = new FileTree({ initialFiles: ['a.txt'] }); + test('model.replaceAll updates getFiles return value', () => { + const ft = createFileTree({ initialFiles: ['a.txt'] }); const newFiles = ['b.txt', 'c.txt']; - ft.setFiles(newFiles); + ft.model.replaceAll(newFiles); expect(ft.getFiles()).toEqual(newFiles); }); - test('setOptions with state.files delegates to setFiles', () => { - const ft = new FileTree({ initialFiles: ['a.txt'] }); - ft.setOptions({}, { files: ['b.txt'] }); - expect(ft.getFiles()).toEqual(['b.txt']); + test('renamePath same-parent leaf rename avoids an extra full rebuild', async () => { + const ft = createFileTree({ + initialFiles: ['src/a.ts', 'src/b.ts'], + flattenEmptyDirectories: true, + sort: false, + }); + const containerWrapper = document.createElement('div'); + ft.render({ containerWrapper }); + await flushRenderWork(); + + const tree = ft.handleRef.current?.tree; + expect(tree).toBeDefined(); + if (tree == null) { + throw new Error('Expected tree handle after render.'); + } + + const before = { + ...(tree.getDataRef<{ + rebuildModeCounts?: Record<'full' | 'incremental' | 'noop', number>; + }>().current.rebuildModeCounts ?? { full: 0, incremental: 0, noop: 0 }), + }; + + ft.renamePath({ + sourcePath: 'src/a.ts', + destinationPath: 'src/a-renamed.ts', + isFolder: false, + }); + await flushRenderWork(); + + const after = tree.getDataRef<{ + rebuildModeCounts?: Record<'full' | 'incremental' | 'noop', number>; + }>().current.rebuildModeCounts ?? { full: 0, incremental: 0, noop: 0 }; + + expect(after.full - before.full).toBe(0); + expect( + after.incremental - before.incremental + (after.noop - before.noop) + ).toBeGreaterThan(0); + expect(ft.getFiles()).toEqual(['src/a-renamed.ts', 'src/b.ts']); + }); + + test('setOptions cannot swap model instances', () => { + const ft = createFileTree({ initialFiles: ['a.txt'] }); + expect(() => { + ft.setOptions({ model: FileTreeModel.fromFiles(['b.txt']) }); + }).toThrow('cannot swap model instances'); }); - test('setOptions applies state.files when structural options also change', () => { - const ft = new FileTree({ initialFiles: ['a.txt'] }); - ft.setOptions({ flattenEmptyDirectories: true }, { files: ['b.txt'] }); - expect(ft.getFiles()).toEqual(['b.txt']); + test('setOptions preserves model-owned files when structural options change', () => { + const ft = createFileTree({ initialFiles: ['a.txt'] }); + ft.setOptions({ flattenEmptyDirectories: true }); + expect(ft.getFiles()).toEqual(['a.txt']); }); test('setOptions applies fileTreeSearchMode changes at runtime', () => { - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['a.txt'], fileTreeSearchMode: 'expand-matches', }); @@ -275,7 +371,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('setOptions applies icons changes at runtime', () => { - const ft = new FileTree({ initialFiles: ['a.txt'] }); + const ft = createFileTree({ initialFiles: ['a.txt'] }); let rerenders = 0; ( @@ -299,7 +395,7 @@ describe('SSR + declarative shadow DOM', () => { test('render + setOptions keep virtualized layout attributes in sync', () => { const container = document.createElement('file-tree-container'); - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['README.md'], virtualize: { threshold: 0 }, }); @@ -328,7 +424,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('hydrate applies virtualized layout attributes when enabled client-side', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md'], }); const container = document.createElement('file-tree-container'); @@ -339,7 +435,7 @@ describe('SSR + declarative shadow DOM', () => { const origHydrate = preactRenderer.hydrateRoot; preactRenderer.hydrateRoot = () => {}; try { - const ft = new FileTree({ + const ft = createFileTree({ id: payload.id, initialFiles: ['README.md'], virtualize: { threshold: 0 }, @@ -362,7 +458,7 @@ describe('SSR + declarative shadow DOM', () => { test('setOptions swaps custom sprite sheets at runtime', () => { const container = document.createElement('file-tree-container'); - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['README.md'], icons: { spriteSheet: CUSTOM_SPRITE_A, @@ -405,7 +501,7 @@ describe('SSR + declarative shadow DOM', () => { test('setOptions removes custom sprite sheet when icons are unset', () => { const container = document.createElement('file-tree-container'); - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['README.md'], icons: { spriteSheet: CUSTOM_SPRITE_A, @@ -438,7 +534,7 @@ describe('SSR + declarative shadow DOM', () => { }); test('preloadFileTree includes custom sprite sheets without requiring marker attrs', () => { - const payload = preloadFileTree({ + const payload = preloadFileTreeWithFiles({ initialFiles: ['README.md'], icons: { spriteSheet: CUSTOM_SPRITE_A, @@ -462,21 +558,21 @@ describe('SSR + declarative shadow DOM', () => { test('preloadFileTree supports virtualized empty trees', () => { expect(() => - preloadFileTree({ + preloadFileTreeWithFiles({ initialFiles: [], virtualize: { threshold: 0 }, }) ).not.toThrow(); }); - test('setFiles invokes onFilesChange callback', () => { + test('model.replaceAll invokes onFilesChange callback', () => { const calls: string[][] = []; - const ft = new FileTree( + const ft = createFileTree( { initialFiles: ['a.txt'] }, { onFilesChange: (files) => calls.push(files) } ); - ft.setFiles(['b.txt', 'c.txt']); + ft.model.replaceAll(['b.txt', 'c.txt']); expect(calls).toEqual([['b.txt', 'c.txt']]); }); @@ -505,20 +601,24 @@ describe('SSR + declarative shadow DOM', () => { expect(nextExpanded).not.toContain('src'); }); - test('setOptions with state.files invokes onFilesChange callback', () => { + test('renamePath invokes onFilesChange callback', () => { const calls: string[][] = []; - const ft = new FileTree( + const ft = createFileTree( { initialFiles: ['a.txt'] }, { onFilesChange: (files) => calls.push(files) } ); - ft.setOptions({ flattenEmptyDirectories: true }, { files: ['b.txt'] }); + ft.renamePath({ + sourcePath: 'a.txt', + destinationPath: 'b.txt', + isFolder: false, + }); expect(calls).toEqual([['b.txt']]); }); test('render injects unsafeCSS into the shadow root and keeps it in sync', () => { const container = document.createElement('file-tree-container'); - const ft = new FileTree({ + const ft = createFileTree({ initialFiles: ['README.md', 'src/index.ts'], unsafeCSS: `[data-item-section="content"] { color: hotpink; }`, }); @@ -570,7 +670,9 @@ describe('SSR + declarative shadow DOM', () => { }; try { - const ft = new FileTree({ initialFiles: ['README.md', 'src/index.ts'] }); + const ft = createFileTree({ + initialFiles: ['README.md', 'src/index.ts'], + }); ft.hydrate({ fileTreeContainer: container }); expect(hydrated).toBe(0); expect(rendered).toBe(1); From 14ebf469c71341b585fc247af224653aa5fee187 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 31 Mar 2026 09:09:27 -0500 Subject: [PATCH 2/7] stage one of even more different algo --- .../context-menu/ContextMenuDemoClient.tsx | 17 +++++++-- .../drag-and-drop/DragAndDropDemoClient.tsx | 15 ++++++-- .../dynamic-files/DynamicFilesDemoClient.tsx | 28 ++++++++++---- packages/trees/src/FileTree.ts | 23 +++++++++-- .../trees/src/core/INCREMENTAL_TREE_INDEX.md | 33 ++++++++++++++++ packages/trees/src/model/FileTreeModel.ts | 38 +++++++++++++++---- packages/trees/src/react/FileTree.tsx | 7 +++- .../src/react/utils/useFileTreeInstance.ts | 12 +++++- .../trees/test/e2e/fixtures/touch-dnd.html | 4 +- packages/trees/test/file-tree-model.test.ts | 22 +++++++++++ .../test/ssr-declarative-shadow-dom.test.ts | 32 ++++++++++++---- 11 files changed, 193 insertions(+), 38 deletions(-) diff --git a/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx b/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx index b8aeafdf5..0b773415a 100644 --- a/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx +++ b/apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx @@ -117,10 +117,11 @@ function VanillaSSRContextMenu({ }), { ...stateConfig, - onFilesChange: (nextFiles) => { + onFilesChange: (changeSet, context) => { + const nextFiles = context.getFiles(); filesRef.current = nextFiles; setFiles(nextFiles); - stateConfig?.onFilesChange?.(nextFiles); + stateConfig?.onFilesChange?.(changeSet, context); }, onContextMenuOpen: (item, context) => { renderVanillaContextMenuSlot({ @@ -198,11 +199,21 @@ function ReactSSRContextMenu({ ); const { model, ...reactTreeOptions } = runtimeOptions; + const handleFilesChange = useCallback( + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + setFiles(context.getFiles()); + }, + [] + ); + return ( ( () => ({ ...stateConfig, - onFilesChange: (files) => { + onFilesChange: (_changeSet, context) => { + const files = context.getFiles(); addLog(`files: [${files.join(', ')}]`); }, }), @@ -124,7 +125,11 @@ function ReactDnDControlled({ const { log, addLog } = useStateLog(); const handleFilesChange = useCallback( - (nextFiles: string[]) => { + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + const nextFiles = context.getFiles(); if (lockGitignore) { const oldGitignore = files.find((f) => f.endsWith('.gitignore')); const newGitignore = nextFiles.find((f) => f.endsWith('.gitignore')); @@ -203,7 +208,11 @@ function ReactDnDControlledSSR({ const { log, addLog } = useStateLog(); const handleFilesChange = useCallback( - (nextFiles: string[]) => { + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + const nextFiles = context.getFiles(); if (lockGitignore) { const oldGitignore = files.find((f) => f.endsWith('.gitignore')); const newGitignore = nextFiles.find((f) => f.endsWith('.gitignore')); diff --git a/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx b/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx index f1f46ea04..8182031fa 100644 --- a/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx +++ b/apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx @@ -144,10 +144,16 @@ function ReactControlledFiles({ ); const [onFilesChangeCalls, setOnFilesChangeCalls] = useState(0); - const handleFilesChange = useCallback((nextFiles: string[]) => { - setOnFilesChangeCalls((count) => count + 1); - setFiles(nextFiles); - }, []); + const handleFilesChange = useCallback( + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + setOnFilesChangeCalls((count) => count + 1); + setFiles(context.getFiles()); + }, + [] + ); const runtimeOptions = useMemo( () => @@ -225,10 +231,16 @@ function ReactSSRControlledFiles({ ); const [onFilesChangeCalls, setOnFilesChangeCalls] = useState(0); - const handleFilesChange = useCallback((nextFiles: string[]) => { - setOnFilesChangeCalls((count) => count + 1); - setFiles(nextFiles); - }, []); + const handleFilesChange = useCallback( + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + setOnFilesChangeCalls((count) => count + 1); + setFiles(context.getFiles()); + }, + [] + ); const runtimeOptions = useMemo( () => diff --git a/packages/trees/src/FileTree.ts b/packages/trees/src/FileTree.ts index b0b0e4b58..1ee65c083 100644 --- a/packages/trees/src/FileTree.ts +++ b/packages/trees/src/FileTree.ts @@ -104,11 +104,21 @@ export interface FileTreeHandle { closeContextMenu?: () => void; } +export type FileTreeChangeSet = FileTreeModelMutation; + +export interface FileTreeChangeContext { + model: FileTreeModel; + getFiles: () => string[]; +} + export interface FileTreeCallbacks { onExpandedItemsChange?: (items: string[]) => void; onSelectedItemsChange?: (items: string[]) => void; onSelection?: (items: FileTreeSelectionItem[]) => void; - onFilesChange?: (files: string[]) => void; + onFilesChange?: ( + changeSet: FileTreeChangeSet, + context: FileTreeChangeContext + ) => void; onContextMenuOpen?: ( item: ContextMenuItem, context: ContextMenuOpenContext @@ -173,7 +183,10 @@ export interface FileTreeStateConfig { onExpandedItemsChange?: (items: string[]) => void; onSelectedItemsChange?: (items: string[]) => void; onSelection?: (items: FileTreeSelectionItem[]) => void; - onFilesChange?: (files: string[]) => void; + onFilesChange?: ( + changeSet: FileTreeChangeSet, + context: FileTreeChangeContext + ) => void; onContextMenuOpen?: ( item: ContextMenuItem, context: ContextMenuOpenContext @@ -466,9 +479,11 @@ export class FileTree { // --- Heavier updates (re-render) --- private handleModelMutation(mutation: FileTreeModelMutation): void { - const files = this.model.getFiles(); this.options.model = this.model; - this.callbacksRef.current.onFilesChange?.(files); + this.callbacksRef.current.onFilesChange?.(mutation, { + model: this.model, + getFiles: () => this.model.getFiles(), + }); const handle = this.handleRef.current; if (handle == null) { diff --git a/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md b/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md index db0355225..2d67962fd 100644 --- a/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md +++ b/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md @@ -78,3 +78,36 @@ can refresh just the affected branch once data arrives. reindexing). 3. Optional subtree size / descendant caches for even faster range operations. 4. More aggressive dirty-root coalescing for complex batch moves. + +## v2 model direction (in progress) + +The next major step is to make the canonical model fully **ID/parent-pointer +first**, while keeping path-based APIs as a projection layer. + +Planned properties: + +- Node identity is stable and path-independent. +- After the initial model build, common edits (`add`, `delete`, `rename`, + `move`) should update local node links/child lists instead of rebuilding a + full file snapshot. +- External mutation notifications should be **changesets**, not mandatory full + `files[]` snapshots. + +### SSR-stable IDs + +Initial IDs should be deterministic from server input so SSR hydration can match +model identities. Path-derived hashing is acceptable as the bootstrap key +source, while post-hydration mutations keep IDs stable even if paths change. + +### Why we are not introducing ropes/B+ trees yet + +The current block index already provides good locality with simpler +implementation costs. A rope/B+ visible-order structure is a follow-on step for +very large range edits (especially root-level subtree moves), once the model +layer is fully local-mutation-first. + +Expected follow-on benefits when introduced: + +- lower-cost giant visible-range splices, +- better asymptotic behavior for very large trees, +- improved worst-case root-level structural edits. diff --git a/packages/trees/src/model/FileTreeModel.ts b/packages/trees/src/model/FileTreeModel.ts index 0ccd14471..b44aa54f7 100644 --- a/packages/trees/src/model/FileTreeModel.ts +++ b/packages/trees/src/model/FileTreeModel.ts @@ -17,6 +17,36 @@ import { const ROOT_ID = 'root'; const FLATTENED_PREFIX_LENGTH = FLATTENED_PREFIX.length; +function hashPathToStableSuffix(path: string): string { + let hash = 2166136261; + for (let index = 0; index < path.length; index += 1) { + hash ^= path.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function allocateDeterministicNodeId( + path: string, + usedIds: Set +): string { + const baseId = `p_${hashPathToStableSuffix(path)}`; + if (!usedIds.has(baseId)) { + usedIds.add(baseId); + return baseId; + } + + let suffix = 2; + let candidate = `${baseId}_${suffix}`; + while (usedIds.has(candidate)) { + suffix += 1; + candidate = `${baseId}_${suffix}`; + } + + usedIds.add(candidate); + return candidate; +} + function getParentPath(path: string): string { const separatorIndex = path.lastIndexOf('/'); return separatorIndex < 0 ? ROOT_ID : path.slice(0, separatorIndex); @@ -256,14 +286,8 @@ function buildStableIndexFromFiles( continue; } - let allocatedId: string; - do { - allocatedId = `n_${nextNodeId}`; - nextNodeId += 1; - } while (usedIds.has(allocatedId)); - + const allocatedId = allocateDeterministicNodeId(path, usedIds); pathToIdMap.set(path, allocatedId); - usedIds.add(allocatedId); } const idToPathMap = new Map(); diff --git a/packages/trees/src/react/FileTree.tsx b/packages/trees/src/react/FileTree.tsx index 286eaad59..689169e1c 100644 --- a/packages/trees/src/react/FileTree.tsx +++ b/packages/trees/src/react/FileTree.tsx @@ -11,6 +11,8 @@ import { HEADER_SLOT_NAME, } from '../constants'; import type { + FileTreeChangeContext, + FileTreeChangeSet, FileTreeOptions, FileTreeSelectionItem, GitStatusEntry, @@ -82,7 +84,10 @@ export interface FileTreeProps { */ containerId?: string; - onFilesChange?: (files: string[]) => void; + onFilesChange?: ( + changeSet: FileTreeChangeSet, + context: FileTreeChangeContext + ) => void; // Default (uncontrolled) state initialExpandedItems?: string[]; diff --git a/packages/trees/src/react/utils/useFileTreeInstance.ts b/packages/trees/src/react/utils/useFileTreeInstance.ts index 747e9781e..769faa951 100644 --- a/packages/trees/src/react/utils/useFileTreeInstance.ts +++ b/packages/trees/src/react/utils/useFileTreeInstance.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef } from 'react'; import { FileTree, + type FileTreeChangeContext, + type FileTreeChangeSet, type FileTreeOptions, type FileTreeSelectionItem, type FileTreeStateConfig, @@ -16,7 +18,10 @@ interface UseFileTreeInstanceProps { options: Omit; // State callbacks - onFilesChange?: (files: string[]) => void; + onFilesChange?: ( + changeSet: FileTreeChangeSet, + context: FileTreeChangeContext + ) => void; // Default (uncontrolled) state initialExpandedItems?: string[]; @@ -76,7 +81,10 @@ export function useFileTreeInstance({ ) => void; onContextMenuClose?: () => void; model: FileTreeModel; - onFilesChange?: (files: string[]) => void; + onFilesChange?: ( + changeSet: FileTreeChangeSet, + context: FileTreeChangeContext + ) => void; } >({ model, diff --git a/packages/trees/test/e2e/fixtures/touch-dnd.html b/packages/trees/test/e2e/fixtures/touch-dnd.html index 76097a8f6..2a58aa689 100644 --- a/packages/trees/test/e2e/fixtures/touch-dnd.html +++ b/packages/trees/test/e2e/fixtures/touch-dnd.html @@ -43,8 +43,8 @@ }, { initialExpandedItems: ['src', 'src/components', 'lib'], - onFilesChange: (files) => { - window.__lastFilesChange = files; + onFilesChange: (_changeSet, context) => { + window.__lastFilesChange = context.getFiles(); }, } ); diff --git a/packages/trees/test/file-tree-model.test.ts b/packages/trees/test/file-tree-model.test.ts index e50428456..486046cbb 100644 --- a/packages/trees/test/file-tree-model.test.ts +++ b/packages/trees/test/file-tree-model.test.ts @@ -3,6 +3,28 @@ import { describe, expect, test } from 'bun:test'; import { FileTreeModel } from '../src/model/FileTreeModel'; describe('FileTreeModel', () => { + test('assigns deterministic IDs across independent instances', () => { + const files = [ + 'README.md', + 'src/index.ts', + 'src/components/Button.tsx', + 'docs/guide.md', + ]; + + const modelA = FileTreeModel.fromFiles(files, { sortComparator: false }); + const modelB = FileTreeModel.fromFiles([...files].reverse(), { + sortComparator: false, + }); + + const indexA = modelA.getSyncIndex(); + const indexB = modelB.getSyncIndex(); + + for (let index = 0; index < files.length; index += 1) { + const path = files[index]; + expect(indexA.pathToId.get(path)).toBe(indexB.pathToId.get(path)); + } + }); + test('keeps file IDs stable across same-parent rename', () => { const model = FileTreeModel.fromFiles(['src/a.ts', 'src/b.ts'], { sortComparator: false, diff --git a/packages/trees/test/ssr-declarative-shadow-dom.test.ts b/packages/trees/test/ssr-declarative-shadow-dom.test.ts index b49a627f1..4052a57ae 100644 --- a/packages/trees/test/ssr-declarative-shadow-dom.test.ts +++ b/packages/trees/test/ssr-declarative-shadow-dom.test.ts @@ -565,15 +565,23 @@ describe('SSR + declarative shadow DOM', () => { ).not.toThrow(); }); - test('model.replaceAll invokes onFilesChange callback', () => { - const calls: string[][] = []; + test('model.replaceAll invokes onFilesChange callback with a changeset', () => { + const changes: import('../src/FileTree').FileTreeChangeSet[] = []; + const snapshots: string[][] = []; const ft = createFileTree( { initialFiles: ['a.txt'] }, - { onFilesChange: (files) => calls.push(files) } + { + onFilesChange: (changeSet, context) => { + changes.push(changeSet); + snapshots.push(context.getFiles()); + }, + } ); ft.model.replaceAll(['b.txt', 'c.txt']); - expect(calls).toEqual([['b.txt', 'c.txt']]); + expect(changes).toHaveLength(1); + expect(changes[0]?.kind).toBe('replace-all'); + expect(snapshots).toEqual([['b.txt', 'c.txt']]); }); test('folder rename remaps expanded subtree paths', () => { @@ -601,11 +609,17 @@ describe('SSR + declarative shadow DOM', () => { expect(nextExpanded).not.toContain('src'); }); - test('renamePath invokes onFilesChange callback', () => { - const calls: string[][] = []; + test('renamePath invokes onFilesChange callback with a changeset', () => { + const changes: import('../src/FileTree').FileTreeChangeSet[] = []; + const snapshots: string[][] = []; const ft = createFileTree( { initialFiles: ['a.txt'] }, - { onFilesChange: (files) => calls.push(files) } + { + onFilesChange: (changeSet, context) => { + changes.push(changeSet); + snapshots.push(context.getFiles()); + }, + } ); ft.renamePath({ @@ -613,7 +627,9 @@ describe('SSR + declarative shadow DOM', () => { destinationPath: 'b.txt', isFolder: false, }); - expect(calls).toEqual([['b.txt']]); + expect(changes).toHaveLength(1); + expect(changes[0]?.kind).toBe('rename-path'); + expect(snapshots).toEqual([['b.txt']]); }); test('render injects unsafeCSS into the shadow root and keeps it in sync', () => { From 11acb98963bcf631dcb18d721c2e02ff0cee647d Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 31 Mar 2026 12:49:37 -0500 Subject: [PATCH 3/7] move further towards pointer based tree structure --- .../docs/app/trees/docs/ReactAPI/constants.ts | 41 ++++++++++++------- .../app/trees/docs/VanillaAPI/content.mdx | 4 +- packages/trees/src/FileTree.ts | 30 +++++++++++++- packages/trees/src/components/Root.tsx | 27 ++++-------- packages/trees/src/model/FileTreeModel.ts | 32 +++++++++++++-- packages/trees/src/react/FileTree.tsx | 7 ++++ .../src/react/utils/useFileTreeInstance.ts | 14 +++++++ .../test/ssr-declarative-shadow-dom.test.ts | 23 +++++++++++ 8 files changed, 136 insertions(+), 42 deletions(-) diff --git a/apps/docs/app/trees/docs/ReactAPI/constants.ts b/apps/docs/app/trees/docs/ReactAPI/constants.ts index bc7821653..ed4bf7d08 100644 --- a/apps/docs/app/trees/docs/ReactAPI/constants.ts +++ b/apps/docs/app/trees/docs/ReactAPI/constants.ts @@ -11,7 +11,8 @@ const options = { export const REACT_API_FILE_TREE: PreloadFileOptions = { file: { name: 'FileExplorer.tsx', - contents: `import { FileTree } from '@pierre/trees/react'; + contents: `import { FileTreeModel } from '@pierre/trees'; +import { FileTree } from '@pierre/trees/react'; const files = [ 'src/index.ts', @@ -20,8 +21,10 @@ const files = [ 'package.json', ]; +const model = FileTreeModel.fromFiles(files); + export function FileExplorer() { - return ; + return ; }`, }, options, @@ -30,12 +33,15 @@ export function FileExplorer() { export const REACT_API_FILE_TREE_PROPS: PreloadFileOptions = { file: { name: 'file_tree_props.tsx', - contents: `import { FileTree } from '@pierre/trees/react'; + contents: `import { FileTreeModel } from '@pierre/trees'; +import { FileTree } from '@pierre/trees/react'; // FileTree accepts these props: +const model = FileTreeModel.fromFiles(['src/index.ts', 'package.json']); = { } \`, }} - initialFiles={['src/index.ts', 'package.json']} // Optional: uncontrolled state defaults initialExpandedItems={['src']} @@ -54,7 +59,6 @@ export const REACT_API_FILE_TREE_PROPS: PreloadFileOptions = { initialSearchQuery="Button" // Optional: controlled state (overrides internal state each render) - // files={controlledFiles} // expandedItems={controlledExpanded} // selectedItems={controlledSelected} @@ -62,7 +66,9 @@ export const REACT_API_FILE_TREE_PROPS: PreloadFileOptions = { onSelection={(items) => console.log(items)} onExpandedItemsChange={(items) => console.log('expanded', items)} onSelectedItemsChange={(items) => console.log('selected', items)} - onFilesChange={(files) => console.log('files', files)} + onModelChange={(changeSet, { getFiles }) => { + console.log(changeSet.kind, getFiles()); + }} // Optional: git status gitStatus={gitStatusEntries} @@ -81,7 +87,14 @@ export const REACT_API_FILE_TREE_PROPS: PreloadFileOptions = { export const REACT_API_CUSTOM_ICONS_EXAMPLE: PreloadFileOptions = { file: { name: 'custom_icons_file_tree.tsx', - contents: `import { FileTree } from '@pierre/trees/react'; + contents: `import { FileTreeModel } from '@pierre/trees'; +import { FileTree } from '@pierre/trees/react'; + +const model = FileTreeModel.fromFiles([ + 'src/index.ts', + 'src/components/Button.tsx', + 'package.json', +]); const customSpriteSheet = \`

Virtualized Linux Tree Render Fixture

return candidatePath; }; + const selectTopLevelRenameFolder = (files) => { + const firstPath = files[0]; + if (typeof firstPath !== 'string') { + throw new Error( + 'Unable to find a top-level folder path to rename in fixture data.' + ); + } + + const slashIndex = firstPath.indexOf('/'); + if (slashIndex <= 0) { + throw new Error( + 'Unable to derive a top-level folder path from fixture data.' + ); + } + + return firstPath.slice(0, slashIndex); + }; + + const buildRenamedTopLevelFolderPath = (sourcePath) => { + return `${sourcePath}__renamed`; + }; + const runInitialRender = async () => { renderButton.disabled = true; renameButton.disabled = true; @@ -593,9 +615,86 @@

Virtualized Linux Tree Render Fixture

return summary; }; + const runRenameRootFolder = async () => { + if (fixtureState.fileTree == null) { + throw new Error('Rename requested before initial render.'); + } + + renameButton.disabled = true; + resultLabel.textContent = 'Renaming top-level folder…'; + + clearMeasureState(RENAME_MEASURE_SPEC); + const benchmark = ensureBenchmark(); + const benchmarkSnapshot = benchmark?.createSnapshot() ?? null; + const rebuildModeCountsBefore = readRebuildModeCounts(); + + const sourcePath = selectTopLevelRenameFolder(fixtureState.files); + const destinationPath = buildRenamedTopLevelFolderPath(sourcePath); + + const heapBefore = benchmark?.readHeapSnapshot() ?? null; + const operationStartTime = performance.now(); + markMeasureStart(RENAME_MEASURE_SPEC); + + fixtureState.fileTree.renamePath({ + sourcePath, + destinationPath, + isFolder: true, + }); + + const { renderedItemCount } = await waitForTreeHost(); + const visibleRowsReadyTime = performance.now(); + await waitForPaint(); + const operationEndTime = performance.now(); + const heapAfter = benchmark?.readHeapSnapshot() ?? null; + + markMeasureEnd(RENAME_MEASURE_SPEC); + + const instrumentation = + benchmark != null + ? benchmarkSnapshot != null + ? benchmark.summarizeSince( + benchmarkSnapshot, + heapBefore, + heapAfter + ) + : benchmark.summarize(heapBefore, heapAfter) + : { + phases: [], + counters: {}, + heap: null, + }; + + fixtureState.files = fixtureState.fileTree.getFiles(); + fixtureState.renderedItemCount = renderedItemCount; + instrumentation.counters['workload.renameSourceLength'] = + sourcePath.length; + instrumentation.counters['workload.renameDestinationLength'] = + destinationPath.length; + + const summary = createOperationSummary({ + operationName: 'rename-root-folder', + measureSpec: RENAME_MEASURE_SPEC, + operationStartTime, + operationEndTime, + visibleRowsReadyTime, + renderedItemCount, + instrumentation, + rebuildModeCountsBefore, + resultText: `Top-level rename post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${sourcePath} → ${destinationPath}.`, + }); + + updateProfileOperation('rename-root-folder', summary); + resultLabel.textContent = summary.resultText; + renameButton.disabled = false; + return summary; + }; + window.__treesDevVirtualizationFixtureApi = { runInitialRender, runRenameFile, + runRenameRootFolder, }; renderButton.addEventListener('click', () => { diff --git a/packages/trees/test/file-tree-model.test.ts b/packages/trees/test/file-tree-model.test.ts index 61cf1cf46..418f0ec9a 100644 --- a/packages/trees/test/file-tree-model.test.ts +++ b/packages/trees/test/file-tree-model.test.ts @@ -69,6 +69,23 @@ function createInstrumentedModel( return { model, counters }; } +function createTopLevelFolderRenameFixture( + rootFolderCount: number, + filesPerRootFolder: number +): string[] { + const files: string[] = []; + + for (let rootIndex = 0; rootIndex < rootFolderCount; rootIndex += 1) { + const rootName = `root-${rootIndex}`; + for (let fileIndex = 0; fileIndex < filesPerRootFolder; fileIndex += 1) { + const bucket = Math.floor(fileIndex / 25); + files.push(`${rootName}/bucket-${bucket}/file-${fileIndex}.ts`); + } + } + + return files; +} + function getCounter(counters: Record, name: string): number { return counters[name] ?? 0; } @@ -121,6 +138,44 @@ describe('FileTreeModel', () => { expect(syncIndex.tree.get(originalId)?.path).toBe('src/a-renamed.ts'); }); + test('reports rename-path childrenOrderChanged=false when sibling order is unchanged', () => { + const model = FileTreeModel.fromFiles(['src/a.ts', 'src/b.ts'], { + sortComparator: false, + }); + + const result = model.renamePath({ + sourcePath: 'src/a.ts', + destinationPath: 'src/a-renamed.ts', + isFolder: false, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error); + } + + expect(result.mutation?.kind).toBe('rename-path'); + expect(result.mutation?.childrenOrderChanged).toBe(false); + }); + + test('reports rename-path childrenOrderChanged=true when sorting reorders siblings', () => { + const model = FileTreeModel.fromFiles(['src/a.ts', 'src/b.ts']); + + const result = model.renamePath({ + sourcePath: 'src/a.ts', + destinationPath: 'src/z.ts', + isFolder: false, + }); + + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error); + } + + expect(result.mutation?.kind).toBe('rename-path'); + expect(result.mutation?.childrenOrderChanged).toBe(true); + }); + test('reuses IDs for unchanged paths during replaceAll', () => { const model = FileTreeModel.fromFiles(['src/a.ts', 'src/b.ts'], { sortComparator: false, @@ -329,6 +384,32 @@ describe('FileTreeModel', () => { ).toBe(getCounter(large.counters, 'model.rename.folder.remapUpdatedNodes')); }); + test('renaming a top-level folder stays on the cold path-tree fast path', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + createTopLevelFolderRenameFixture(5, 200), + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + const renameResult = model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-0-renamed', + isFolder: true, + }); + + expect(renameResult.ok).toBe(true); + expect(getCounter(counters, 'model.pathTree.createdCount')).toBe(0); + + const nextFiles = model.getFiles(); + expect(nextFiles.some((path) => path.startsWith('root-0-renamed/'))).toBe( + true + ); + expect(nextFiles.some((path) => path.startsWith('root-0/'))).toBe(false); + }); + test('keeps file move remap work constant with many unrelated files', () => { const small = createInstrumentedModel(5); const large = createInstrumentedModel(500); From 857d33a6d70b632904a64dc9493f93a700f0d23e Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Wed, 1 Apr 2026 11:13:54 -0500 Subject: [PATCH 7/7] end experiment i think --- .../scripts/profileTreesDevVirtualization.ts | 63 +- packages/trees/src/FileTree.ts | 2 + packages/trees/src/components/Root.tsx | 78 +- .../trees/src/core/INCREMENTAL_TREE_INDEX.md | 26 + packages/trees/src/core/create-tree.ts | 27 +- .../trees/src/core/incremental-tree-index.ts | 251 ++++++- packages/trees/src/features/main/types.ts | 8 + packages/trees/src/features/tree/feature.ts | 25 +- packages/trees/src/model/FileTreeModel.ts | 520 ++++++++++---- .../trees/src/model/MODEL_ARCHITECTURE.md | 309 ++++++++ packages/trees/src/utils/mutablePathTree.ts | 202 ++++-- .../trees/test/core/incremental-index.test.ts | 70 ++ .../test/e2e/fixtures/virtualization.html | 669 ++++++++++++++---- packages/trees/test/file-tree-model.test.ts | 186 +++++ 14 files changed, 2039 insertions(+), 397 deletions(-) create mode 100644 packages/trees/src/model/MODEL_ARCHITECTURE.md diff --git a/packages/trees/scripts/profileTreesDevVirtualization.ts b/packages/trees/scripts/profileTreesDevVirtualization.ts index c1c52c38c..f33a1444d 100644 --- a/packages/trees/scripts/profileTreesDevVirtualization.ts +++ b/packages/trees/scripts/profileTreesDevVirtualization.ts @@ -7,7 +7,14 @@ import { fileURLToPath } from 'node:url'; type ProfileActionName = | 'initial-render' | 'rename-file' - | 'rename-root-folder'; + | 'rename-root-folder' + | 'add-file-root' + | 'add-file-deep' + | 'delete-file-deep' + | 'delete-root-folder' + | 'move-file-to-root' + | 'move-folder-to-root' + | 'move-root-folder'; interface ProfileConfig { browserUrl: string; @@ -346,6 +353,13 @@ interface VirtualizationFixtureApi { runInitialRender: () => Promise; runRenameFile: () => Promise; runRenameRootFolder: () => Promise; + runAddFileRoot: () => Promise; + runAddFileDeep: () => Promise; + runDeleteFileDeep: () => Promise; + runDeleteRootFolder: () => Promise; + runMoveFileToRoot: () => Promise; + runMoveFolderToRoot: () => Promise; + runMoveRootFolder: () => Promise; } declare global { @@ -369,6 +383,13 @@ const KNOWN_ACTION_NAMES = new Set([ 'initial-render', 'rename-file', 'rename-root-folder', + 'add-file-root', + 'add-file-deep', + 'delete-file-deep', + 'delete-root-folder', + 'move-file-to-root', + 'move-folder-to-root', + 'move-root-folder', ]); const KNOWN_WORKLOAD_NAMES = new Set([ 'pierre-snapshot', @@ -529,6 +550,9 @@ function printHelpAndExit(): never { console.log( ` --action Profile action to run (repeatable, default: ${DEFAULT_ACTIONS.join(', ')})` ); + console.log( + ' --all-actions Run every known profile action (render + all mutation workloads)' + ); console.log( ` --timeout Navigation/render timeout in milliseconds (default: ${DEFAULT_TIMEOUT_MS})` ); @@ -743,6 +767,11 @@ function parseArgs(argv: string[]): ProfileConfig { continue; } + if (rawArg === '--all-actions') { + config.actions = [...KNOWN_ACTION_NAMES]; + continue; + } + if (rawArg === '--no-build') { config.ensureBuild = false; continue; @@ -2583,6 +2612,20 @@ function getActionMethodName(actionName: ProfileActionName): string { return 'runRenameFile'; case 'rename-root-folder': return 'runRenameRootFolder'; + case 'add-file-root': + return 'runAddFileRoot'; + case 'add-file-deep': + return 'runAddFileDeep'; + case 'delete-file-deep': + return 'runDeleteFileDeep'; + case 'delete-root-folder': + return 'runDeleteRootFolder'; + case 'move-file-to-root': + return 'runMoveFileToRoot'; + case 'move-folder-to-root': + return 'runMoveFolderToRoot'; + case 'move-root-folder': + return 'runMoveRootFolder'; default: return 'runInitialRender'; } @@ -2596,6 +2639,20 @@ function getActionLabel(actionName: ProfileActionName): string { return 'Rename file'; case 'rename-root-folder': return 'Rename root folder'; + case 'add-file-root': + return 'Add file (root)'; + case 'add-file-deep': + return 'Add file (deep)'; + case 'delete-file-deep': + return 'Delete file (deep)'; + case 'delete-root-folder': + return 'Delete root folder'; + case 'move-file-to-root': + return 'Move file to root'; + case 'move-folder-to-root': + return 'Move folder to root'; + case 'move-root-folder': + return 'Move root folder'; default: return actionName; } @@ -3159,7 +3216,7 @@ function printActionTimingHumanSummary( 'right', 'right', ], - maxWidths: [28, 18, 14, 14, 18, 14, 8], + maxWidths: [28, 26, 14, 14, 18, 14, 8], } ) ); @@ -3686,7 +3743,7 @@ function printRunsHumanSummary(output: ProfileBenchmarkOutput): void { console.log('Benchmark'); console.log( createTable(['Field', 'Value'], runInfoRows, { - maxWidths: [22, 96], + maxWidths: [22, 140], }) ); diff --git a/packages/trees/src/FileTree.ts b/packages/trees/src/FileTree.ts index 75785a2af..3eafc62e1 100644 --- a/packages/trees/src/FileTree.ts +++ b/packages/trees/src/FileTree.ts @@ -290,6 +290,8 @@ export class FileTree { }, }; + this.model.prepareMutationIndexes(); + this.model.subscribe((mutation) => { this.handleModelMutation(mutation); }); diff --git a/packages/trees/src/components/Root.tsx b/packages/trees/src/components/Root.tsx index 6f10aaf65..37d6778c2 100644 --- a/packages/trees/src/components/Root.tsx +++ b/packages/trees/src/components/Root.tsx @@ -80,10 +80,30 @@ const EMPTY_ANCESTORS: string[] = []; // Reuses the last rebuild's visible ID list so virtualized rendering can size // and slice the tree without forcing core to instantiate every visible item. function getVisibleItemIds(tree: TreeInstance): string[] { - return ( - tree.getDataRef().current.visibleItemIds ?? - tree.getItems().map((item) => item.getId()) - ); + const treeDataRef = tree.getDataRef().current; + if (treeDataRef.visibleItemIds != null) { + return treeDataRef.visibleItemIds; + } + + const visibleItemCount = + treeDataRef.visibleItemCount ?? tree.getVisibleItemCount(); + const visibleItemIds = new Array(visibleItemCount); + let resolvedCount = 0; + + for (let index = 0; index < visibleItemCount; index += 1) { + const itemId = tree.getVisibleItemIdAt(index); + if (itemId == null) { + break; + } + visibleItemIds[index] = itemId; + resolvedCount += 1; + } + + if (resolvedCount !== visibleItemCount) { + visibleItemIds.length = resolvedCount; + } + + return visibleItemIds; } export function Root({ @@ -657,20 +677,25 @@ export function Root({ {(() => { const visibleIdSet = getSearchVisibleIdSet(tree); const gitStatusMap = getGitStatusMap(tree); - const allItemIds = getVisibleItemIds(tree); - const itemIds = - visibleIdSet != null - ? allItemIds.filter((itemId) => visibleIdSet.has(itemId)) - : allItemIds; + let allItemIds: string[] | null = null; + let filteredItemIds: string[] | null = null; + if (visibleIdSet != null) { + allItemIds = getVisibleItemIds(tree); + filteredItemIds = allItemIds.filter((itemId) => + visibleIdSet.has(itemId) + ); + } + const itemCount = filteredItemIds?.length ?? tree.getVisibleItemCount(); + setBenchmarkCounter( benchmarkInstrumentation, 'workload.visibleItemIds', - allItemIds.length + allItemIds?.length ?? itemCount ); setBenchmarkCounter( benchmarkInstrumentation, 'workload.renderItemIds', - itemIds.length + itemCount ); const draggedItemIdSet = isDnD ? new Set( @@ -680,8 +705,15 @@ export function Root({ ) : null; + const getItemIdAtIndex = (index: number): string | undefined => { + if (filteredItemIds != null) { + return filteredItemIds[index]; + } + return tree.getVisibleItemIdAt(index); + }; + const renderItemAtIndex = (index: number) => { - const itemId = itemIds[index]; + const itemId = getItemIdAtIndex(index); if (itemId == null) { return null; } @@ -767,15 +799,19 @@ export function Root({ if ( shouldVirtualize && - itemIds.length > 0 && - itemIds.length >= virtualizeThreshold + itemCount > 0 && + itemCount >= virtualizeThreshold ) { const focusedIndex = - focusedItemId != null ? itemIds.indexOf(focusedItemId) : null; + focusedItemId != null + ? filteredItemIds != null + ? filteredItemIds.indexOf(focusedItemId) + : tree.getItemInstance(focusedItemId).getItemMeta().index + : null; return (
= 0 @@ -789,9 +825,17 @@ export function Root({ ); } + const renderedItems: JSX.Element[] = []; + for (let index = 0; index < itemCount; index += 1) { + const renderedItem = renderItemAtIndex(index); + if (renderedItem != null) { + renderedItems.push(renderedItem); + } + } + return ( <> - {itemIds.map((_, i) => renderItemAtIndex(i))} + {renderedItems} {contextMenuTrigger} ); diff --git a/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md b/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md index 2d67962fd..1102e5fd1 100644 --- a/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md +++ b/packages/trees/src/core/INCREMENTAL_TREE_INDEX.md @@ -17,6 +17,32 @@ rebuilding flattened metadata from a DFS traversal on every `rebuildTree()`. ## Ordered-index choice +### Lock-in snapshot (current branch shape) + +The current mutation-fast shape we are converging on is: + +- **Model graph is ID/parent-pointer first** (`FileTreeModel` + + `MutablePathTree`) + - stable IDs are decoupled from path strings, + - path lookups are projection helpers (`pathToId` / `idToPath`), + - path-tree mutations (`add/delete/move/rename`) rewire pointers locally. +- **Core visible order is index-first** (`IncrementalTreeIndex`) + - `nodes` keeps structural metadata, + - `visible` (`VisibleBlockIndex`) is the mutable ordered visible sequence, + - `visibleMetaById` keeps per-visible-node ARIA/index metadata. +- **Mutation notifications are changesets** + - runtime marks only affected parents dirty, + - rebuild performs branch-local refreshes instead of full traversal. + +Important implementation detail we now rely on for large subtree moves: + +- Visible block insertion avoids argument-spread splice (`splice(...ids)`) for + large fragments, because JS argument limits can trigger stack overflows. We + build a merged block array (`head + inserted + tail`) and then split. + +This keeps root-structural edits incremental and avoids fallback-to-full rebuild +on very large insert fragments. + v1 uses a **chunked block index** (`VisibleBlockIndex`) instead of a flat array: - IDs are stored in fixed-size blocks (default 128 IDs per block). diff --git a/packages/trees/src/core/create-tree.ts b/packages/trees/src/core/create-tree.ts index 835dfeb16..246f8a7ca 100644 --- a/packages/trees/src/core/create-tree.ts +++ b/packages/trees/src/core/create-tree.ts @@ -129,7 +129,7 @@ export const createTree = ( let rebuildScheduled = false; const itemInstancesMap: Record> = {}; - let visibleItemIds: string[] = []; + let visibleItemIds: string[] | null = null; let itemInstances: ItemInstance[] | null = null; const itemElementsMap: Record = {}; // oxlint-disable-next-line typescript-eslint/no-explicit-any @@ -186,17 +186,24 @@ export const createTree = ( const invalidateVisibleCaches = () => { itemInstances = null; + visibleItemIds = null; itemMetaCache.clear(); }; + const getVisibleItemIdsSnapshot = (): string[] => { + visibleItemIds ??= treeIndex.getVisibleItemIds(); + return visibleItemIds; + }; + const syncVisibleIdsToDataRef = () => { - visibleItemIds = treeIndex.getVisibleItemIds(); + const visibleItemCount = treeIndex.getVisibleCount(); const ref = treeDataRef.current as TreeDataRef; - ref.visibleItemIds = visibleItemIds; + ref.visibleItemIds = undefined; + ref.visibleItemCount = visibleItemCount; setBenchmarkCounter( benchmarkInstrumentation, 'workload.visibleItemMeta', - visibleItemIds.length + visibleItemCount ); }; @@ -307,6 +314,7 @@ export const createTree = ( rebuildScheduled = true; const ref = treeDataRef.current as TreeDataRef; ref.visibleItemIds = undefined; + ref.visibleItemCount = undefined; }, getConfig: () => config, setConfig: (_, updater) => { @@ -329,11 +337,20 @@ export const createTree = ( getItemInstance: (_opts, itemId) => getOrCreateItemInstance(itemId), getItems: () => { if (rebuildScheduled) rebuildItemMeta(); - itemInstances ??= visibleItemIds.map((itemId) => + const currentVisibleIds = getVisibleItemIdsSnapshot(); + itemInstances ??= currentVisibleIds.map((itemId) => getOrCreateItemInstance(itemId) ); return itemInstances; }, + getVisibleItemCount: () => { + if (rebuildScheduled) rebuildItemMeta(); + return treeIndex.getVisibleCount(); + }, + getVisibleItemIdAt: (_opts, index) => { + if (rebuildScheduled) rebuildItemMeta(); + return treeIndex.getVisibleItemIdAt(index); + }, registerElement: (_opts, element) => { if (treeElement === element) { return; diff --git a/packages/trees/src/core/incremental-tree-index.ts b/packages/trees/src/core/incremental-tree-index.ts index 71aacd6e9..0a3ba7dbf 100644 --- a/packages/trees/src/core/incremental-tree-index.ts +++ b/packages/trees/src/core/incremental-tree-index.ts @@ -198,7 +198,9 @@ class VisibleBlockIndex { const { blockIndex, offset } = this.findBlockAtIndex(normalizedIndex); const initialBlock = this.blocks[blockIndex]; - initialBlock.ids.splice(offset, 0, ...ids); + const blockHead = initialBlock.ids.slice(0, offset); + const blockTail = initialBlock.ids.slice(offset); + initialBlock.ids = blockHead.concat(ids, blockTail); const changedBlocks = new Set([blockIndex]); this.splitOversizedBlocks(blockIndex, changedBlocks); @@ -438,6 +440,14 @@ export class IncrementalTreeIndex { return this.visibleIdsCache; } + getVisibleCount(): number { + return this.visible.length; + } + + getVisibleItemIdAt(index: number): string | undefined { + return this.visible.getIdAt(index); + } + getVisibleIndex(itemId: string): number | undefined { return this.visible.getIndex(itemId); } @@ -637,9 +647,20 @@ export class IncrementalTreeIndex { ); parentNode.expanded = parentId === rootId || expandedItems.has(parentId); + const previousChildren = parentNode.children.slice(); this.readAndSyncChildren(parentId, expandedItems); if (parentId === rootId) { + const rootRefreshResult = this.tryRefreshRootVisibleBranch( + rootId, + previousChildren, + parentNode.children, + expandedItems + ); + if (rootRefreshResult != null) { + return rootRefreshResult; + } + this.visible.clear(); this.visibleMetaById.clear(); @@ -692,6 +713,213 @@ export class IncrementalTreeIndex { return removedIds.length > 0 || fragment.ids.length > 0; } + private tryRefreshRootVisibleBranch( + rootId: string, + previousChildren: readonly string[], + nextChildren: readonly string[], + expandedItems: Set + ): boolean | null { + if (areIdArraysEqual(previousChildren, nextChildren)) { + return null; + } + + let prefixLength = 0; + const minLength = Math.min(previousChildren.length, nextChildren.length); + while ( + prefixLength < minLength && + previousChildren[prefixLength] === nextChildren[prefixLength] + ) { + prefixLength += 1; + } + + let previousSuffixIndex = previousChildren.length - 1; + let nextSuffixIndex = nextChildren.length - 1; + while ( + previousSuffixIndex >= prefixLength && + nextSuffixIndex >= prefixLength && + previousChildren[previousSuffixIndex] === nextChildren[nextSuffixIndex] + ) { + previousSuffixIndex -= 1; + nextSuffixIndex -= 1; + } + + const removedCount = Math.max(0, previousSuffixIndex - prefixLength + 1); + const insertedCount = Math.max(0, nextSuffixIndex - prefixLength + 1); + + let visibleChanged = false; + + if (removedCount > 0) { + const removedChildren = previousChildren.slice( + prefixLength, + prefixLength + removedCount + ); + for (let index = removedChildren.length - 1; index >= 0; index--) { + const removedChildId = removedChildren[index]; + const removedVisibleMeta = this.visibleMetaById.get(removedChildId); + if ( + removedVisibleMeta == null || + removedVisibleMeta.parentId !== rootId || + removedVisibleMeta.level !== 0 + ) { + continue; + } + + const removedVisibleIndex = this.visible.getIndex(removedChildId); + if (removedVisibleIndex == null) { + continue; + } + + const removedIds = this.removeVisibleSubtree(removedVisibleIndex, 0); + if (removedIds.length > 0) { + visibleChanged = true; + } + } + } + + if (insertedCount > 0) { + const insertionAnchorId = nextChildren[prefixLength + insertedCount]; + const insertIndex = + insertionAnchorId == null + ? this.visible.length + : (this.visible.getIndex(insertionAnchorId) ?? this.visible.length); + + const fragment = this.collectVisibleRootInsertedChildren( + rootId, + nextChildren, + prefixLength, + prefixLength + insertedCount, + expandedItems + ); + + if (fragment.ids.length > 0) { + this.visible.insertAt(insertIndex, fragment.ids); + for (let index = 0; index < fragment.ids.length; index++) { + const fragmentId = fragment.ids[index]; + this.visibleMetaById.set(fragmentId, fragment.meta[index]); + } + visibleChanged = true; + } + } + + if (!visibleChanged) { + this.updateRootChildrenVisibleMeta(rootId, nextChildren); + return false; + } + + this.updateRootChildrenVisibleMeta(rootId, nextChildren); + return true; + } + + private collectVisibleRootInsertedChildren( + rootId: string, + rootChildren: readonly string[], + startIndex: number, + endIndexExclusive: number, + expandedItems: Set + ): VisibleFragment { + const fragment: VisibleFragment = { + ids: [], + meta: [], + }; + + const ancestorSet = new Set(); + const fragmentIdSet = new Set(); + const setSize = rootChildren.length; + + for ( + let childIndex = startIndex; + childIndex < endIndexExclusive; + childIndex++ + ) { + const childId = rootChildren[childIndex]; + if (childId == null || fragmentIdSet.has(childId)) { + continue; + } + + const childNode = this.ensureNode(childId, rootId, 0); + childNode.parentId = rootId; + childNode.level = 0; + childNode.expanded = expandedItems.has(childId); + childNode.posInSet = childIndex; + childNode.setSize = setSize; + + fragment.ids.push(childId); + fragment.meta.push({ + parentId: rootId, + level: 0, + posInSet: childIndex, + setSize, + }); + fragmentIdSet.add(childId); + + if (!childNode.expanded) { + continue; + } + + ancestorSet.add(childId); + this.appendVisibleChildren( + childId, + 0, + expandedItems, + ancestorSet, + fragmentIdSet, + fragment, + false + ); + ancestorSet.delete(childId); + } + + return fragment; + } + + private updateRootChildrenVisibleMeta( + rootId: string, + rootChildren: readonly string[] + ): void { + const setSize = rootChildren.length; + + for (let childIndex = 0; childIndex < rootChildren.length; childIndex++) { + const childId = rootChildren[childIndex]; + const visibleMeta = this.visibleMetaById.get(childId); + if (visibleMeta == null) { + continue; + } + + visibleMeta.parentId = rootId; + visibleMeta.level = 0; + visibleMeta.posInSet = childIndex; + visibleMeta.setSize = setSize; + } + } + + private removeVisibleSubtree( + startIndex: number, + rootLevel: number + ): string[] { + let removeCount = 1; + + while (startIndex + removeCount < this.visible.length) { + const visibleId = this.visible.getIdAt(startIndex + removeCount); + if (visibleId == null) { + break; + } + + const visibleMeta = this.visibleMetaById.get(visibleId); + if (visibleMeta == null || visibleMeta.level <= rootLevel) { + break; + } + + removeCount += 1; + } + + const removedIds = this.visible.removeRange(startIndex, removeCount); + for (let index = 0; index < removedIds.length; index++) { + this.visibleMetaById.delete(removedIds[index]); + } + + return removedIds; + } + private removeVisibleDescendants( parentIndex: number, parentLevel: number @@ -779,8 +1007,14 @@ export class IncrementalTreeIndex { ancestorSet: Set, fragmentIdSet: Set, fragment: VisibleFragment, - checkExistingVisible: boolean + checkExistingVisible: boolean, + recursionDepth = 0 ) { + if (recursionDepth > 2048) { + throw new Error( + `appendVisibleChildren exceeded recursion depth at ${parentId}` + ); + } const children = this.readAndSyncChildren(parentId, expandedItems); const setSize = children.length; @@ -822,7 +1056,8 @@ export class IncrementalTreeIndex { ancestorSet, fragmentIdSet, fragment, - checkExistingVisible + checkExistingVisible, + recursionDepth + 1 ); ancestorSet.delete(childId); } @@ -941,6 +1176,8 @@ export class IncrementalTreeIndex { } const dirtyIds = [...this.dirtyParents]; + const hasRoot = this.dirtyParents.has(rootId); + dirtyIds.sort( (leftId, rightId) => this.getNodeLevel(leftId) - this.getNodeLevel(rightId) @@ -953,14 +1190,14 @@ export class IncrementalTreeIndex { const dirtyId = dirtyIds[index]; if (dirtyId === rootId) { - return [rootId]; + continue; } let ancestorId = this.nodes.get(dirtyId)?.parentId ?? null; let shouldSkip = false; while (ancestorId != null) { - if (selectedSet.has(ancestorId)) { + if (ancestorId !== rootId && selectedSet.has(ancestorId)) { shouldSkip = true; break; } @@ -975,6 +1212,10 @@ export class IncrementalTreeIndex { selectedIds.push(dirtyId); } + if (hasRoot) { + selectedIds.unshift(rootId); + } + return selectedIds; } diff --git a/packages/trees/src/features/main/types.ts b/packages/trees/src/features/main/types.ts index e4e089921..53bdbcf39 100644 --- a/packages/trees/src/features/main/types.ts +++ b/packages/trees/src/features/main/types.ts @@ -19,6 +19,10 @@ export interface TreeDataRef { * item instance up front. */ visibleItemIds?: string[]; + /** + * Visible row count from the latest rebuild. + */ + visibleItemCount?: number; /** Internal rebuild telemetry for profiling/debugging incremental behavior. */ lastRebuildMode?: 'full' | 'incremental' | 'noop'; rebuildModeCounts?: Record<'full' | 'incremental' | 'noop', number>; @@ -62,6 +66,10 @@ export type MainFeatureDef = { getConfig: () => TreeConfig; getItemInstance: (itemId: string) => ItemInstance; getItems: () => ItemInstance[]; + /** @internal */ + getVisibleItemCount: () => number; + /** @internal */ + getVisibleItemIdAt: (index: number) => string | undefined; registerElement: (element: HTMLElement | null) => void; getElement: () => HTMLElement | undefined | null; /** @internal */ diff --git a/packages/trees/src/features/tree/feature.ts b/packages/trees/src/features/tree/feature.ts index 1f20fd24a..0905b8d42 100644 --- a/packages/trees/src/features/tree/feature.ts +++ b/packages/trees/src/features/tree/feature.ts @@ -27,9 +27,30 @@ export const treeFeature: FeatureImplementation = { treeInstance: { getItemsMeta: ({ tree }) => { const dataRef = tree.getDataRef(); + const cachedVisibleIds = dataRef.current.visibleItemIds; const visibleIds = - dataRef.current.visibleItemIds ?? - tree.getItems().map((item) => item.getId()); + cachedVisibleIds ?? + (() => { + const visibleCount = + dataRef.current.visibleItemCount ?? tree.getVisibleItemCount(); + const ids = new Array(visibleCount); + let resolvedCount = 0; + + for (let index = 0; index < visibleCount; index += 1) { + const visibleId = tree.getVisibleItemIdAt(index); + if (visibleId == null) { + break; + } + ids[index] = visibleId; + resolvedCount += 1; + } + + if (resolvedCount !== visibleCount) { + ids.length = resolvedCount; + } + + return ids; + })(); return visibleIds.map((itemId) => tree.getItemInstance(itemId).getItemMeta() ); diff --git a/packages/trees/src/model/FileTreeModel.ts b/packages/trees/src/model/FileTreeModel.ts index dcb4910de..308407d92 100644 --- a/packages/trees/src/model/FileTreeModel.ts +++ b/packages/trees/src/model/FileTreeModel.ts @@ -385,6 +385,8 @@ export class FileTreeModel { private readonly benchmarkInstrumentation: BenchmarkInstrumentation | null; private pathTree: MutablePathTree | null = null; private pathTreeCreationCount = 0; + private pathPrefixRemapMaterializationCount = 0; + private fileIndexRebuildCount = 0; private version = 0; private nextNodeId = 1; private sortComparator: ChildrenSortOption; @@ -685,6 +687,12 @@ export class FileTreeModel { return; } + this.pathPrefixRemapMaterializationCount += 1; + this.setModelCounter( + 'model.pathPrefixRemaps.materializedCount', + this.pathPrefixRemapMaterializationCount + ); + const nextPathToId = new Map(); for (const [canonicalPath, id] of this.pathToIdMap) { @@ -733,13 +741,18 @@ export class FileTreeModel { for (let index = 0; index < files.length; index += 1) { this.fileIndexByPath.set(files[index], index); } + + this.fileIndexRebuildCount += 1; + this.setModelCounter( + 'model.snapshot.fileIndexRebuildCount', + this.fileIndexRebuildCount + ); } private adoptPathTreeFilesReference(pathTree: MutablePathTree): void { this.pathTree = pathTree; this.filesPendingPathTreeSync = true; this.pendingFileSnapshotPrefixRemaps.length = 0; - this.pendingPathPrefixRemaps.length = 0; this.fileIndexByPath.clear(); } @@ -751,7 +764,6 @@ export class FileTreeModel { this.files = this.pathTree.getFilesReference(); this.filesPendingPathTreeSync = false; this.pendingFileSnapshotPrefixRemaps.length = 0; - this.pendingPathPrefixRemaps.length = 0; this.fileIndexByPath.clear(); this.setModelCounter('model.snapshot.fileCount', this.files.length); } @@ -784,14 +796,13 @@ export class FileTreeModel { sourcePath, destinationPath, }); - this.fileIndexByPath.clear(); } /** * Applies deferred folder-prefix remaps to the dense files[] snapshot. * - * This lets folder rename/move stay cheap on the hot path and only pays the - * O(fileCount) rewrite cost when a caller actually needs the full snapshot. + * This keeps folder rename/move cheap on the hot path and only pays the + * O(fileCount) rewrite when a caller asks for a full files[] snapshot. */ private applyPendingFileSnapshotPrefixRemaps(): void { const remapRules = this.pendingFileSnapshotPrefixRemaps; @@ -799,33 +810,66 @@ export class FileTreeModel { return; } + const preparedRules = remapRules.map((rule) => ({ + sourcePath: rule.sourcePath, + sourcePathWithSlash: `${rule.sourcePath}/`, + sourcePathLength: rule.sourcePath.length, + destinationPath: rule.destinationPath, + })); + + const originalPathByTouchedIndex = new Map(); + for (let fileIndex = 0; fileIndex < this.files.length; fileIndex += 1) { - let currentPath = this.files[fileIndex]; + const originalPath = this.files[fileIndex]; + let currentPath = originalPath; - for (let ruleIndex = 0; ruleIndex < remapRules.length; ruleIndex += 1) { - const rule = remapRules[ruleIndex]; - const sourcePath = rule.sourcePath; + for ( + let ruleIndex = 0; + ruleIndex < preparedRules.length; + ruleIndex += 1 + ) { + const rule = preparedRules[ruleIndex]; if ( - currentPath !== sourcePath && - !currentPath.startsWith(`${sourcePath}/`) + currentPath !== rule.sourcePath && + !currentPath.startsWith(rule.sourcePathWithSlash) ) { continue; } - currentPath = `${rule.destinationPath}${currentPath.slice(sourcePath.length)}`; + currentPath = `${rule.destinationPath}${currentPath.slice(rule.sourcePathLength)}`; } - this.files[fileIndex] = currentPath; + if (currentPath !== originalPath) { + originalPathByTouchedIndex.set(fileIndex, originalPath); + this.files[fileIndex] = currentPath; + } } remapRules.length = 0; - this.rebuildFileIndexByPath(this.files); + + if ( + originalPathByTouchedIndex.size === 0 || + this.fileIndexByPath.size === 0 + ) { + return; + } + + if (originalPathByTouchedIndex.size > 4096) { + this.fileIndexByPath.clear(); + return; + } + + for (const originalPath of originalPathByTouchedIndex.values()) { + this.fileIndexByPath.delete(originalPath); + } + for (const fileIndex of originalPathByTouchedIndex.keys()) { + this.fileIndexByPath.set(this.files[fileIndex], fileIndex); + } } private getOrCreatePathTree(): MutablePathTree { if (this.pathTree == null) { this.applyPendingFileSnapshotPrefixRemaps(); - this.materializePendingPathPrefixRemaps(); this.pathTree = MutablePathTree.fromFiles(this.files); this.filesPendingPathTreeSync = false; this.fileIndexByPath.clear(); @@ -899,14 +943,20 @@ export class FileTreeModel { } private ensureStableIdForPath(path: string): string { - const existingId = this.pathToIdMap.get(path); + const existingId = this.resolveIdForPath(path); if (existingId != null) { return existingId; } - const allocatedId = this.allocateStableIdForPath(path); - this.pathToIdMap.set(path, allocatedId); - this.idToPathMap.set(allocatedId, path); + const canonicalPath = this.remapPathBackward(path); + const canonicalExistingId = this.pathToIdMap.get(canonicalPath); + if (canonicalExistingId != null) { + return canonicalExistingId; + } + + const allocatedId = this.allocateStableIdForPath(canonicalPath); + this.pathToIdMap.set(canonicalPath, allocatedId); + this.idToPathMap.set(allocatedId, canonicalPath); return allocatedId; } @@ -957,7 +1007,7 @@ export class FileTreeModel { } visited.add(id); - const nodePath = this.idToPathMap.get(id); + const nodePath = this.getResolvedPathForId(id); if ( shouldRemovePath != null && nodePath != null && @@ -996,7 +1046,7 @@ export class FileTreeModel { updates: Array<{ id: string; oldPath: string; newPath: string }>; scannedNodeCount: number; } { - const sourceId = this.pathToIdMap.get(sourcePath); + const sourceId = this.resolveIdForPath(sourcePath); if (sourceId == null) { return { updates: [], scannedNodeCount: 0 }; } @@ -1016,39 +1066,55 @@ export class FileTreeModel { scannedNodeCount += 1; const node = this.tree.get(id); - const oldPath = this.idToPathMap.get(id); + const canonicalPath = this.idToPathMap.get(id); + const resolvedPath = + canonicalPath == null + ? undefined + : this.remapPathForward(canonicalPath); const remappedPath = - oldPath == null + resolvedPath == null ? null - : remapPathWithPrefix(oldPath, sourcePath, destinationPath); + : remapPathWithPrefix(resolvedPath, sourcePath, destinationPath); if ( - oldPath != null && + canonicalPath != null && + resolvedPath != null && remappedPath != null && - remappedPath !== oldPath && + remappedPath !== resolvedPath && !updatedIds.has(id) ) { - updates.push({ id, oldPath, newPath: remappedPath }); + updates.push({ + id, + oldPath: canonicalPath, + newPath: this.remapPathBackward(remappedPath), + }); updatedIds.add(id); } if ( - oldPath != null && + canonicalPath != null && + resolvedPath != null && remappedPath != null && - remappedPath !== oldPath && + remappedPath !== resolvedPath && node?.children?.direct != null && - !oldPath.startsWith(FLATTENED_PREFIX) + !resolvedPath.startsWith(FLATTENED_PREFIX) ) { - const flattenedAliasPath = `${FLATTENED_PREFIX}${oldPath}`; - const flattenedAliasId = this.pathToIdMap.get(flattenedAliasPath); + const flattenedAliasPath = `${FLATTENED_PREFIX}${resolvedPath}`; + const flattenedAliasId = this.resolveIdForPath(flattenedAliasPath); if (flattenedAliasId != null && !updatedIds.has(flattenedAliasId)) { - updates.push({ - id: flattenedAliasId, - oldPath: flattenedAliasPath, - newPath: `${FLATTENED_PREFIX}${remappedPath}`, - }); - updatedIds.add(flattenedAliasId); - scannedNodeCount += 1; + const flattenedAliasCanonicalPath = + this.idToPathMap.get(flattenedAliasId); + if (flattenedAliasCanonicalPath != null) { + updates.push({ + id: flattenedAliasId, + oldPath: flattenedAliasCanonicalPath, + newPath: this.remapPathBackward( + `${FLATTENED_PREFIX}${remappedPath}` + ), + }); + updatedIds.add(flattenedAliasId); + scannedNodeCount += 1; + } } } @@ -1104,17 +1170,22 @@ export class FileTreeModel { continue; } - const id = this.pathToIdMap.get(rule.sourcePath); + const id = this.resolveIdForPath(rule.sourcePath); if (id == null || updatedIds.has(id)) { continue; } + const canonicalSourcePath = this.idToPathMap.get(id); + if (canonicalSourcePath == null) { + continue; + } + scannedNodeCount += 1; updatedIds.add(id); updates.push({ id, - oldPath: rule.sourcePath, - newPath: rule.destinationPath, + oldPath: canonicalSourcePath, + newPath: this.remapPathBackward(rule.destinationPath), }); } @@ -1290,7 +1361,7 @@ export class FileTreeModel { !pathTree.hasFolder(normalizedFolderPath) ) { let removedFolder = false; - const missingFolderId = this.pathToIdMap.get(normalizedFolderPath); + const missingFolderId = this.resolveIdForPath(normalizedFolderPath); if (missingFolderId != null) { const subtreePrefix = `${normalizedFolderPath}/`; this.removeSubtreeById(missingFolderId, (path) => { @@ -1357,7 +1428,7 @@ export class FileTreeModel { if (canReuseDirectChildren) { for (let index = 0; index < previousDirectChildren.length; index += 1) { const previousChildId = previousDirectChildren[index]; - const previousChildPath = this.idToPathMap.get(previousChildId); + const previousChildPath = this.getResolvedPathForId(previousChildId); if ( previousChildPath == null || getParentPath(previousChildPath) !== normalizedFolderPath || @@ -1415,7 +1486,8 @@ export class FileTreeModel { continue; } - const previousChildPath = this.idToPathMap.get(previousChildId); + const previousChildPath = + this.getResolvedPathForId(previousChildId); if (previousChildPath == null) { continue; } @@ -1448,7 +1520,7 @@ export class FileTreeModel { const flattenedPath = `${FLATTENED_PREFIX}${endpointPath}`; const flattenedId = this.ensureStableIdForPath(flattenedPath); - const endpointId = this.pathToIdMap.get(endpointPath); + const endpointId = this.resolveIdForPath(endpointPath); const endpointNode = endpointId != null ? this.tree.get(endpointId) : undefined; const endpointChildren = endpointNode?.children; @@ -1504,7 +1576,7 @@ export class FileTreeModel { if (dirtyChildPaths != null && dirtyChildPaths.size > 0) { for (const dirtyChildPath of dirtyChildPaths) { - const dirtyChildId = this.pathToIdMap.get(dirtyChildPath); + const dirtyChildId = this.resolveIdForPath(dirtyChildPath); if (dirtyChildId == null) { continue; } @@ -1618,7 +1690,7 @@ export class FileTreeModel { let nextHasSingleFolderChild = false; if (canReuseDirectChildren) { if (directChildIds.length === 1) { - const onlyChildPath = this.idToPathMap.get(directChildIds[0]); + const onlyChildPath = this.getResolvedPathForId(directChildIds[0]); nextHasSingleFolderChild = onlyChildPath != null && pathTree.hasFolder(onlyChildPath); } @@ -1666,6 +1738,16 @@ export class FileTreeModel { return this.version; } + /** + * Prepares mutable indexes used by structural mutations (add/delete/move). + * + * This lets callers shift one-time index construction cost out of a hot + * interaction path (for example: prime during initial mount, then mutate). + */ + prepareMutationIndexes(): void { + this.getOrCreatePathTree(); + } + getFiles(): string[] { this.syncFilesSnapshotFromPathTree(); this.applyPendingFileSnapshotPrefixRemaps(); @@ -1700,6 +1782,7 @@ export class FileTreeModel { } replaceAll(files: string[]): void { + this.materializePendingPathPrefixRemaps(); const builtIndex = buildStableIndexFromFiles( files, this.sortComparator, @@ -1711,6 +1794,205 @@ export class FileTreeModel { this.emitMutation({ kind: 'replace-all' }); } + /** + * Rebinds a single source path to a destination path while preserving its + * stable ID. Used for direct file moves where no subtree-wide remap is needed. + */ + private remapDirectPath( + sourcePath: string, + destinationPath: string + ): boolean { + const sourceId = this.resolveIdForPath(sourcePath); + if (sourceId == null) { + return false; + } + + const existingDestinationId = this.resolveIdForPath(destinationPath); + if (existingDestinationId != null && existingDestinationId !== sourceId) { + return false; + } + + const canonicalSourcePath = this.remapPathBackward(sourcePath); + const canonicalDestinationPath = this.remapPathBackward(destinationPath); + + this.pathToIdMap.delete(canonicalSourcePath); + this.pathToIdMap.set(canonicalDestinationPath, sourceId); + this.idToPathMap.set(sourceId, canonicalDestinationPath); + + const node = this.tree.get(sourceId); + if (node != null) { + node.path = canonicalDestinationPath; + node.name = getLeafName(destinationPath); + } + + return true; + } + + private hasFastMoveCollision( + pathTree: MutablePathTree, + fileRules: readonly PlannedMoveRule[], + folderRules: readonly PlannedMoveRule[] + ): boolean { + const sourcePaths = new Set(); + for (let index = 0; index < fileRules.length; index += 1) { + sourcePaths.add(fileRules[index].sourcePath); + } + for (let index = 0; index < folderRules.length; index += 1) { + sourcePaths.add(folderRules[index].sourcePath); + } + + const destinationPaths = new Set(); + const allRules = [...fileRules, ...folderRules]; + + for (let index = 0; index < allRules.length; index += 1) { + const rule = allRules[index]; + if (destinationPaths.has(rule.destinationPath)) { + return true; + } + destinationPaths.add(rule.destinationPath); + + if (sourcePaths.has(rule.destinationPath)) { + return true; + } + + if ( + pathTree.hasPath(rule.destinationPath) && + !sourcePaths.has(rule.destinationPath) + ) { + return true; + } + + const destinationParentPath = getParentPath(rule.destinationPath); + if ( + destinationParentPath !== ROOT_ID && + pathTree.hasFile(destinationParentPath) + ) { + return true; + } + } + + return false; + } + + /** + * Fast-path move application for collision-free operations. + * + * This avoids per-descendant remap scans by queuing folder prefix remaps and + * only rebinding direct file paths that actually moved. + */ + private tryApplyCollisionFreeMoveRules( + pathTree: MutablePathTree, + fileRules: readonly PlannedMoveRule[], + folderRules: readonly PlannedMoveRule[] + ): FileTreeModelMoveResult | null { + if (fileRules.length === 0 && folderRules.length === 0) { + return { ok: true, mutation: null }; + } + + if (this.hasFastMoveCollision(pathTree, fileRules, folderRules)) { + return null; + } + + const movedRules: PlannedMoveRule[] = []; + + for (let index = 0; index < fileRules.length; index += 1) { + const rule = fileRules[index]; + const { parentPath, baseName } = splitPath(rule.destinationPath); + const moveResult = pathTree.movePath( + rule.sourcePath, + parentPath === '' ? ROOT_ID : parentPath, + baseName, + 'file' + ); + if (moveResult !== 'ok') { + return null; + } + + if (!this.remapDirectPath(rule.sourcePath, rule.destinationPath)) { + return null; + } + + movedRules.push(rule); + } + + for (let index = 0; index < folderRules.length; index += 1) { + const rule = folderRules[index]; + const { parentPath, baseName } = splitPath(rule.destinationPath); + const moveResult = pathTree.movePath( + rule.sourcePath, + parentPath === '' ? ROOT_ID : parentPath, + baseName, + 'folder' + ); + if (moveResult !== 'ok') { + return null; + } + + const folderId = this.resolveIdForPath(rule.sourcePath); + if (folderId != null) { + const folderNode = this.tree.get(folderId); + if (folderNode != null) { + folderNode.name = getLeafName(rule.destinationPath); + } + } + + this.queuePathPrefixRemap(rule.sourcePath, rule.destinationPath); + movedRules.push(rule); + } + + if (movedRules.length === 0) { + return { ok: true, mutation: null }; + } + + this.setModelCounter('model.move.remapScannedNodes', movedRules.length); + this.setModelCounter('model.move.remapUpdatedNodes', movedRules.length); + + this.adoptPathTreeFilesReference(pathTree); + + const affectedFolderPaths = new Set(); + const affectedParentIdsSet = new Set(); + + for (let index = 0; index < movedRules.length; index += 1) { + const rule = movedRules[index]; + const sourceParentPath = getParentPath(rule.sourcePath); + const destinationParentPath = getParentPath(rule.destinationPath); + + affectedFolderPaths.add(sourceParentPath); + affectedFolderPaths.add(destinationParentPath); + + affectedParentIdsSet.add(this.resolveAncestorId(sourceParentPath)); + affectedParentIdsSet.add(this.resolveAncestorId(destinationParentPath)); + } + + if (affectedFolderPaths.size === 0) { + affectedFolderPaths.add(ROOT_ID); + } + + this.rebuildFolderNodesFromPathTree(pathTree, affectedFolderPaths); + this.prunePendingFlattenedNodes(); + + const movedPaths = movedRules.map((rule) => ({ + sourcePath: rule.sourcePath, + destinationPath: rule.destinationPath, + isFolder: rule.isFolder, + })); + + const mutation = { + kind: 'move-paths' as const, + movedPaths, + affectedParentIds: [...affectedParentIdsSet], + }; + this.emitMutation(mutation); + + return { + ok: true, + mutation: { + ...mutation, + version: this.version, + }, + }; + } + movePaths({ draggedPaths, targetPath, @@ -1782,7 +2064,6 @@ export class FileTreeModel { }); } - const folderDestinations = new Map(); for (const selectedFolder of selectedFolders) { if ( normalizedTarget === selectedFolder || @@ -1796,7 +2077,6 @@ export class FileTreeModel { continue; } - folderDestinations.set(selectedFolder, destinationPath); folderRules.push({ sourcePath: selectedFolder, destinationPath, @@ -1804,6 +2084,15 @@ export class FileTreeModel { }); } + const fastPathResult = this.tryApplyCollisionFreeMoveRules( + pathTree, + fileRules, + folderRules + ); + if (fastPathResult != null) { + return fastPathResult; + } + const plannedActions: Array<{ originPath: string; destinationPath: string; @@ -2297,126 +2586,49 @@ export class FileTreeModel { }; } - if (this.pathTree == null) { - this.queueFileSnapshotPrefixRemap( - normalizedSourcePath, - normalizedDestinationPath - ); - this.queuePathPrefixRemap( + if (this.pathTree != null) { + const folderRenameResult = this.pathTree.renamePath( normalizedSourcePath, - normalizedDestinationPath + normalizedDestinationPath, + 'folder' ); - - node.name = getLeafName(normalizedDestinationPath); - - this.sortChildren(parentId); - const childrenOrderChanged = this.didParentChildrenOrderChange( - parentId, - parentChildrenBeforeSort - ); - this.setModelCounter('model.rename.folder.remapScannedNodes', 1); - this.setModelCounter('model.rename.folder.remapUpdatedNodes', 1); - - const mutation = { - kind: 'rename-path' as const, - sourcePath: normalizedSourcePath, - destinationPath: normalizedDestinationPath, - isFolder: true, - parentId, - nodeId, - childrenOrderChanged, - }; - this.emitMutation(mutation); - return { - ok: true, - mutation: { ...mutation, version: this.version }, - }; - } - - const { - updates: pathUpdates, - scannedNodeCount: folderRenameScannedNodeCount, - } = this.collectSubtreePathUpdates( - normalizedSourcePath, - normalizedDestinationPath - ); - this.setModelCounter( - 'model.rename.folder.remapScannedNodes', - folderRenameScannedNodeCount - ); - this.setModelCounter( - 'model.rename.folder.remapUpdatedNodes', - pathUpdates.length - ); - - if (pathUpdates.length === 0) { - return { - ok: false, - error: 'Could not find the selected folder to rename.', - }; - } - - const updatedPathSet = new Set(pathUpdates.map((entry) => entry.oldPath)); - for (const { id, newPath } of pathUpdates) { - const existingId = this.resolveIdForPath(newPath); - if ( - existingId != null && - existingId !== id && - !updatedPathSet.has(newPath) - ) { + if (folderRenameResult === 'missing') { + return { + ok: false, + error: 'Could not find the selected folder to rename.', + }; + } + if (folderRenameResult === 'collision') { return { ok: false, error: `"${normalizedDestinationPath}" already exists.`, }; } - } - - const folderRenameResult = this.pathTree.renamePath( - normalizedSourcePath, - normalizedDestinationPath, - 'folder' - ); - if (folderRenameResult === 'missing') { - return { - ok: false, - error: 'Could not find the selected folder to rename.', - }; - } - if (folderRenameResult === 'collision') { - return { - ok: false, - error: `"${normalizedDestinationPath}" already exists.`, - }; - } - if (folderRenameResult === 'invalid') { - return { - ok: false, - error: 'Could not rename the selected folder.', - }; - } - - for (const { oldPath } of pathUpdates) { - this.pathToIdMap.delete(oldPath); - } - for (const { id, newPath } of pathUpdates) { - this.pathToIdMap.set(newPath, id); - this.idToPathMap.set(id, newPath); - const item = this.tree.get(id); - if (item != null) { - item.path = newPath; - if (id === nodeId) { - item.name = getLeafName(newPath); - } + if (folderRenameResult === 'invalid') { + return { + ok: false, + error: 'Could not rename the selected folder.', + }; } + + this.adoptPathTreeFilesReference(this.pathTree); + } else { + this.queueFileSnapshotPrefixRemap( + normalizedSourcePath, + normalizedDestinationPath + ); } - this.adoptPathTreeFilesReference(this.pathTree); + this.queuePathPrefixRemap(normalizedSourcePath, normalizedDestinationPath); + node.name = getLeafName(normalizedDestinationPath); this.sortChildren(parentId); const childrenOrderChanged = this.didParentChildrenOrderChange( parentId, parentChildrenBeforeSort ); + this.setModelCounter('model.rename.folder.remapScannedNodes', 1); + this.setModelCounter('model.rename.folder.remapUpdatedNodes', 1); const mutation = { kind: 'rename-path' as const, diff --git a/packages/trees/src/model/MODEL_ARCHITECTURE.md b/packages/trees/src/model/MODEL_ARCHITECTURE.md new file mode 100644 index 000000000..da145caf2 --- /dev/null +++ b/packages/trees/src/model/MODEL_ARCHITECTURE.md @@ -0,0 +1,309 @@ +# File Tree Model Architecture (Mutation-First) + +This document describes the internal data-structure shape we converged on for +large-tree mutation performance. + +The goal is to give a new agent enough detail to recreate the approach from +scratch, even if APIs change. + +--- + +## 1) Optimization target + +Primary target: + +- Keep **localized mutations** cheap (`rename`, `add`, `delete`, `move`) in very + large trees. + +Secondary target: + +- Keep root-structural edits (`move-root-folder`, `delete-root-folder`) stable + and incremental (no hangs, no fallback full rebuilds in normal paths). + +Non-goal during this phase: + +- Initial render speed (important, but explicitly deprioritized relative to + mutation latency). + +--- + +## 2) Architectural shape (3 layers) + +We use three cooperating structures: + +1. **Canonical model graph (ID-first)** +2. **Mutable path mutation engine (parent-pointer path tree)** +3. **Incremental visible-order index (block index)** + +This separation is deliberate: + +- the model handles semantic correctness and stable identity, +- the path tree handles path-native mutation mechanics cheaply, +- the visible index handles UI-visible order/meta updates incrementally. + +--- + +## 3) Canonical model graph (ID-first) + +Core stores: + +- `tree: Map` +- `pathToIdMap: Map` +- `idToPathMap: Map` + +Typical node shape: + +```ts +type Node = { + name: string; + path: string; // canonical path projection + children?: { + direct: string[]; // child IDs + flattened?: string[]; // child IDs in flatten projection + }; + flattens?: string[]; // IDs of folders represented by a flattened node +}; +``` + +### Why this shape + +- Stable IDs decouple identity from path churn. +- Path API remains user-facing while internals remain ID-first. +- Enables local rewiring instead of global rebuilds for most edits. + +### Key design detail: deterministic bootstrap IDs + +Initial IDs are deterministic from path hash (`p_` + collision suffix) so +SSR/hydration stays stable. + +--- + +## 4) Path mutation engine: parent-pointer tree + +`MutablePathTree` is a mutable filesystem trie with parent pointers. + +### Node shape + +- Folder nodes: `children: Map` +- File nodes: + - parent pointer + name + - linked-list pointers (`previous`, `next`) + - stable insertion token (`order`) + +### Why + +- Folder move/rename is pointer rewiring, not descendant path rewriting. +- `files[]` snapshot can be rebuilt lazily from file linked list. +- Preserves insertion order naturally without array-wide shifts. + +### Tradeoff + +- Path strings become derived state. +- Requires careful invalidation/materialization boundaries. + +--- + +## 5) Deferred path remap overlay + +Folder rename/move is often handled by queuing prefix remaps instead of eagerly +rewriting every descendant path. + +Core mechanism: + +- `pendingPathPrefixRemaps: [{ sourcePath, destinationPath }]` +- `id -> path`: apply remaps **forward** +- `path -> id`: map path **backward** to canonical path, then verify round-trip + +Related snapshot optimization: + +- `pendingFileSnapshotPrefixRemaps` lets us defer expensive `files[]` rewrite + until snapshot access is requested. + +### Why + +- Keeps hot mutation path cheap for large subtree renames/moves. + +### Hard part + +- Correctness under overlapping remaps and mixed lookup directions. + +--- + +## 6) Local folder rebuild strategy + +After mutation, we rebuild only affected folders (plus minimal propagated +ancestors), not the full model. + +Key helper concept: + +- `rebuildFolderNodesFromPathTree(affectedFolderPaths)` + +What it does: + +- Reuses previous child arrays when still valid. +- Recomputes direct children only when needed. +- Rebuilds flatten projections only where necessary. +- Tracks flattened node lifecycle via ref-count (`increment/decrement`). +- Propagates parent rebuild only when child shape changes imply parent flatten + semantics may have changed. + +Auxiliary cache: + +- `directChildIndexByFolderId` avoids repeated linear scans for child index + lookups during patching. + +--- + +## 7) Mutation protocol (changesets, not snapshots) + +Mutations emit semantic changesets: + +- `rename-path` +- `move-paths` +- `add-paths` +- `delete-paths` + +Each includes `affectedParentIds` (or equivalent localized invalidation +payload). + +Runtime/view layer consumes this and marks only those branches dirty. + +### Why + +- This is incremental view maintenance: mutate model once, update only impacted + view branches. + +### Tradeoff + +- If invalidation sets are incomplete, stale UI/meta bugs can occur. + +--- + +## 8) Incremental visible-order index (UI side) + +The model is not the visible list. +Visible order is maintained by an incremental index. + +Core pieces: + +- `nodes: Map` with parent/level/expanded metadata +- `visibleMetaById: Map` +- `visible: VisibleBlockIndex` + +`VisibleBlockIndex` stores IDs in chunks (block size ~128) and supports local +insert/remove with per-ID location map. + +This is similar to an unrolled list / B+-tree leaf layer specialized for +visible preorder mutation. + +### Critical implementation lesson + +For large inserts, avoid `splice(offset, 0, ...largeArray)`. +Use block merge (`head + inserted + tail`) to avoid JS argument-stack limits. + +That single detail prevented stack overflows and full-rebuild fallback during +large root-folder moves. + +--- + +## 9) Root-specific fast path + +Root structural changes are common and expensive in huge fixtures. + +We use a root-child diff path to avoid always rebuilding the whole visible +sequence: + +- detect changed middle segment via prefix/suffix match, +- remove only affected root-visible subtrees, +- insert only affected root-visible fragment, +- repair root child visible metadata. + +Guardrails ensure we only remove nodes still attached as root children +(`parentId === root` + `level === 0`). + +--- + +## 10) Tensions and hard problems + +### A) ID-first internals vs path-first UX + +Users express mutations in paths; internals want stable IDs. +Maintaining both with deferred remaps is inherently complex. + +### B) Flattening semantics + +Flattened folder nodes are synthetic and topology-dependent. +Small local edits can cause broad flatten identity/projection churn. + +### C) Root structural operations + +Even with good structure, moving a huge expanded subtree is still expensive +because visible preorder truly changes a lot. + +### D) Collision + batch move semantics + +Multi-source move planning must handle: + +- source/destination overlaps, +- destination collisions, +- nested folder selections, +- deterministic ordering. + +### E) Lazy/deferred state complexity + +Deferral improves performance but multiplies invalidation edges: + +- remap chains, +- snapshot materialization, +- cache reuse boundaries. + +--- + +## 11) How this maps to known structures + +We are effectively combining: + +- **Filesystem trie** (path segments) + parent-pointer rewiring +- **Bi-map projection layer** (`path <-> id`) with deferred transforms +- **Unrolled sequence blocks** for mutable visible order +- **Incremental view maintenance** via explicit mutation changesets + +Differences from textbook forms: + +- flatten projection (`flattened`, `flattens`) is domain-specific, +- path remap overlay is a practical optimization layer over the canonical graph, +- root-visible branch diffing is specialized for UI-tree workloads. + +--- + +## 12) Where theoretical gains remain + +1. **Order-statistic tree / rope / B+ tree** for visible sequence + - Better worst-case root-range splices. +2. **Subtree visible counts / interval indexing** + - Faster subtree boundary calculations. +3. **Prefix-remap compaction** (e.g. trie/transducer) + - Lower lookup cost under long remap chains. +4. **Explicit mutation transactions** + - Batch invalidation/materialization once per logical action. +5. **Stronger invariant checks in debug mode** + - Catch subtle stale-state bugs earlier. + +--- + +## 13) Rebuild-from-scratch recipe + +If implementing a standalone model library with this shape: + +1. Build deterministic stable IDs and canonical `tree + path/id` maps. +2. Add parent-pointer mutable path tree for mutation mechanics. +3. Add deferred path-prefix remap overlay (forward/backward resolution). +4. Implement local folder rebuild + flatten projection lifecycle/ref-count. +5. Emit mutation changesets with affected parent IDs. +6. Maintain a separate incremental visible-order structure (block index). +7. Add root-specialized structural diff path. +8. Add stress tests for root moves, flatten churn, collision handling, and + no-fallback incremental behavior. + +This gives the same mutation-first complexity profile we optimized for in this +branch. diff --git a/packages/trees/src/utils/mutablePathTree.ts b/packages/trees/src/utils/mutablePathTree.ts index 5fbd1c317..d090fb982 100644 --- a/packages/trees/src/utils/mutablePathTree.ts +++ b/packages/trees/src/utils/mutablePathTree.ts @@ -5,7 +5,6 @@ type MutablePathTreeMoveResult = 'ok' | 'missing' | 'collision' | 'invalid'; interface MutablePathTreeBaseNode { kind: 'folder' | 'file'; name: string; - path: string; parent: MutablePathTreeFolderNode | null; } @@ -36,15 +35,11 @@ function splitPath(path: string): { parentPath: string; baseName: string } { }; } -function joinPath(parentPath: string, baseName: string): string { - return parentPath === '' ? baseName : `${parentPath}/${baseName}`; -} - /** * Mutable parent-pointer tree for file paths. * - * Move/rename/delete logic can mutate this structure directly instead of - * repeatedly rescanning and slicing full path arrays. + * Paths are derived lazily from parent/name pointers instead of being eagerly + * rewritten through the full descendant subtree on every folder move/rename. */ export class MutablePathTree { static fromFiles(files: string[]): MutablePathTree { @@ -56,12 +51,10 @@ export class MutablePathTree { private readonly root: MutablePathTreeFolderNode = { kind: 'folder', name: 'root', - path: '', parent: null, children: new Map(), }; - private readonly pathToNode = new Map(); private headFile: MutablePathTreeFileNode | null = null; private tailFile: MutablePathTreeFileNode | null = null; private fileCount = 0; @@ -71,7 +64,6 @@ export class MutablePathTree { replaceAll(files: string[]): void { this.root.children.clear(); - this.pathToNode.clear(); this.headFile = null; this.tailFile = null; this.fileCount = 0; @@ -98,7 +90,7 @@ export class MutablePathTree { } hasPath(path: string): boolean { - return this.pathToNode.has(path); + return this.getNodeByPath(path) != null; } hasFile(path: string): boolean { @@ -131,7 +123,6 @@ export class MutablePathTree { const node: MutablePathTreeFileNode = { kind: 'file', name: baseName, - path, parent, order: this.nextFileOrder, previous: this.tailFile, @@ -140,7 +131,6 @@ export class MutablePathTree { this.nextFileOrder += 1; parent.children.set(baseName, node); - this.pathToNode.set(path, node); if (this.tailFile == null) { this.headFile = node; @@ -169,8 +159,12 @@ export class MutablePathTree { return []; } - const nodesToDelete: MutablePathTreeFileNode[] = []; const seenPaths = new Set(); + const filesToDelete: Array<{ + path: string; + node: MutablePathTreeFileNode; + }> = []; + for (let index = 0; index < paths.length; index += 1) { const path = paths[index]; if (seenPaths.has(path)) { @@ -180,31 +174,19 @@ export class MutablePathTree { const node = this.getFile(path); if (node != null) { - nodesToDelete.push(node); + filesToDelete.push({ path, node }); } } - if (nodesToDelete.length === 0) { + if (filesToDelete.length === 0) { return []; } - const deletedPathSet = new Set(nodesToDelete.map((node) => node.path)); - for (let index = 0; index < nodesToDelete.length; index += 1) { - this.removeFileNode(nodesToDelete[index]); - } - - const deletedPaths: string[] = []; - const emitted = new Set(); - for (let index = 0; index < paths.length; index += 1) { - const path = paths[index]; - if (!deletedPathSet.has(path) || emitted.has(path)) { - continue; - } - emitted.add(path); - deletedPaths.push(path); + for (let index = 0; index < filesToDelete.length; index += 1) { + this.removeFileNode(filesToDelete[index].node); } - return deletedPaths; + return filesToDelete.map((entry) => entry.path); } deleteFolderPath(path: string): boolean { @@ -241,13 +223,16 @@ export class MutablePathTree { return 'ok'; } - if (this.pathToNode.has(destinationPath)) { + if (this.getNodeByPath(destinationPath) != null) { return 'collision'; } const { parentPath, baseName } = splitPath(destinationPath); const expectedParent = node.parent; - if (expectedParent == null || expectedParent.path !== parentPath) { + if ( + expectedParent == null || + this.getNodePath(expectedParent) !== parentPath + ) { return 'invalid'; } @@ -294,7 +279,18 @@ export class MutablePathTree { const descendants = this.getDescendantFileNodes(folder); descendants.sort((left, right) => left.order - right.order); - return descendants.map((node) => node.path); + + const folderPathCache = new Map([ + [this.root, ''], + ]); + const paths = new Array(descendants.length); + for (let index = 0; index < descendants.length; index += 1) { + paths[index] = this.getFilePathCached( + descendants[index], + folderPathCache + ); + } + return paths; } getDirectChildCount(folderPath: string): number { @@ -320,21 +316,107 @@ export class MutablePathTree { const children: Array<{ path: string; kind: 'folder' | 'file' }> = []; for (const child of folder.children.values()) { - children.push({ path: child.path, kind: child.kind }); + children.push({ path: this.getNodePath(child), kind: child.kind }); } return children; } + private getNodeByPath(path: string): MutablePathTreeNode | undefined { + if (path.length === 0) { + return this.root; + } + + const segments = path.split('/'); + let current: MutablePathTreeFolderNode = this.root; + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + if (segment.length === 0) { + return undefined; + } + + const child = current.children.get(segment); + if (child == null) { + return undefined; + } + + if (index === segments.length - 1) { + return child; + } + + if (child.kind !== 'folder') { + return undefined; + } + + current = child; + } + + return undefined; + } + private getFile(path: string): MutablePathTreeFileNode | undefined { - const node = this.pathToNode.get(path); + const node = this.getNodeByPath(path); return node?.kind === 'file' ? node : undefined; } private getFolder(path: string): MutablePathTreeFolderNode | undefined { - const node = this.pathToNode.get(path); + if (path.length === 0) { + return this.root; + } + + const node = this.getNodeByPath(path); return node?.kind === 'folder' ? node : undefined; } + private getNodePath( + node: MutablePathTreeNode | MutablePathTreeFolderNode + ): string { + if (node.parent == null) { + return ''; + } + + const segments: string[] = []; + let current: MutablePathTreeNode | MutablePathTreeFolderNode | null = node; + + while (current != null && current.parent != null) { + segments.push(current.name); + current = current.parent; + } + + segments.reverse(); + return segments.join('/'); + } + + private getFolderPathCached( + folder: MutablePathTreeFolderNode, + folderPathCache: Map + ): string { + const cachedPath = folderPathCache.get(folder); + if (cachedPath != null) { + return cachedPath; + } + + const parent = folder.parent; + if (parent == null) { + folderPathCache.set(folder, ''); + return ''; + } + + const parentPath = this.getFolderPathCached(parent, folderPathCache); + const path = + parentPath === '' ? folder.name : `${parentPath}/${folder.name}`; + folderPathCache.set(folder, path); + return path; + } + + private getFilePathCached( + file: MutablePathTreeFileNode, + folderPathCache: Map + ): string { + const parentPath = this.getFolderPathCached(file.parent!, folderPathCache); + return parentPath === '' ? file.name : `${parentPath}/${file.name}`; + } + /** * Ensures every segment in a folder path exists as a folder node. */ @@ -345,7 +427,6 @@ export class MutablePathTree { const segments = path.split('/'); let current = this.root; - let currentPath = ''; for (let index = 0; index < segments.length; index += 1) { const segment = segments[index]; @@ -353,19 +434,16 @@ export class MutablePathTree { return null; } - currentPath = currentPath === '' ? segment : `${currentPath}/${segment}`; const existing = current.children.get(segment); if (existing == null) { const folder: MutablePathTreeFolderNode = { kind: 'folder', name: segment, - path: currentPath, parent: current, children: new Map(), }; current.children.set(segment, folder); - this.pathToNode.set(currentPath, folder); current = folder; continue; } @@ -389,16 +467,23 @@ export class MutablePathTree { } this.filesSnapshot.length = 0; + const folderPathCache = new Map([ + [this.root, ''], + ]); + let current = this.headFile; while (current != null) { - this.filesSnapshot.push(current.path); + this.filesSnapshot.push(this.getFilePathCached(current, folderPathCache)); current = current.next; } this.filesSnapshotDirty = false; } /** - * Moves/renames a node by rewiring parent links and rewriting subtree paths. + * Moves/renames a node by rewiring parent links only. + * + * Descendant paths are derived lazily from parent pointers, so large folder + * moves avoid eager subtree path rewrites. */ private moveNode( node: MutablePathTreeNode, @@ -429,7 +514,7 @@ export class MutablePathTree { node.name = nextBaseName; targetFolder.children.set(nextBaseName, node); - this.rewriteSubtreePaths(node); + this.filesSnapshotDirty = true; this.pruneEmptyFolders(currentParent); return 'ok'; } @@ -448,33 +533,6 @@ export class MutablePathTree { return false; } - private rewriteSubtreePaths(rootNode: MutablePathTreeNode): void { - const stack: MutablePathTreeNode[] = [rootNode]; - - while (stack.length > 0) { - const node = stack.pop()!; - const oldPath = node.path; - const parentPath = node.parent?.path ?? ''; - const nextPath = joinPath(parentPath, node.name); - - if (oldPath !== nextPath) { - this.pathToNode.delete(oldPath); - node.path = nextPath; - this.pathToNode.set(nextPath, node); - - if (node.kind === 'file') { - this.filesSnapshotDirty = true; - } - } - - if (node.kind === 'folder') { - for (const child of node.children.values()) { - stack.push(child); - } - } - } - } - private getDescendantFileNodes( folder: MutablePathTreeFolderNode ): MutablePathTreeFileNode[] { @@ -497,7 +555,6 @@ export class MutablePathTree { private removeFileNode(node: MutablePathTreeFileNode): void { node.parent?.children.delete(node.name); - this.pathToNode.delete(node.path); const previous = node.previous; const next = node.next; @@ -532,7 +589,6 @@ export class MutablePathTree { return; } parent.children.delete(current.name); - this.pathToNode.delete(current.path); current = parent; } } diff --git a/packages/trees/test/core/incremental-index.test.ts b/packages/trees/test/core/incremental-index.test.ts index e5dbdfff4..cbad37f3f 100644 --- a/packages/trees/test/core/incremental-index.test.ts +++ b/packages/trees/test/core/incremental-index.test.ts @@ -271,6 +271,47 @@ describe('core/incremental-tree-index', () => { assertVisibleInvariants(tree); }); + it('moves a root child into another expanded root child branch', () => { + const { tree, children, childCalls } = createMutableFixture(); + const dataRef = tree.getDataRef(); + const fullBefore = dataRef.current.rebuildModeCounts?.full ?? 0; + + children.root = ['b', 'c']; + children.b = ['b1', 'a']; + + childCalls.length = 0; + tree.markBranchDirty('root', 'children'); + tree.markBranchDirty('b', 'children'); + tree.rebuildTree(); + + expect(getVisibleIds(tree)).toEqual([ + 'b', + 'b1', + 'a', + 'a1', + 'a2', + 'a2x', + 'a2y', + 'a3', + 'c', + ]); + expect(tree.getItemInstance('a').getItemMeta()).toEqual({ + itemId: 'a', + parentId: 'b', + level: 1, + index: 2, + posInSet: 1, + setSize: 2, + }); + + expect(childCalls.includes('root')).toBe(true); + expect(childCalls.includes('b')).toBe(true); + expect(dataRef.current.lastRebuildMode).not.toBe('full'); + expect(dataRef.current.rebuildModeCounts?.full ?? 0).toBe(fullBefore); + + assertVisibleInvariants(tree); + }); + it('treats metadata-only rebuilds as structural no-ops', () => { const { tree, items, childCalls } = createMutableFixture(); const before = getVisibleIds(tree); @@ -333,6 +374,35 @@ describe('core/incremental-tree-index', () => { assertVisibleInvariants(tree); }); + it('handles root-level insertion without falling back to a full rebuild', () => { + const { tree, items, children } = createMutableFixture(); + const dataRef = tree.getDataRef(); + const fullBefore = dataRef.current.rebuildModeCounts?.full ?? 0; + + items.d = { id: 'd', name: 'd', isFolder: false }; + children.root = ['a', 'b', 'c', 'd']; + + tree.markBranchDirty('root', 'children'); + tree.rebuildTree(); + + expect(getVisibleIds(tree)).toEqual([ + 'a', + 'a1', + 'a2', + 'a2x', + 'a2y', + 'a3', + 'b', + 'b1', + 'c', + 'd', + ]); + + expect(dataRef.current.lastRebuildMode).not.toBe('full'); + expect(dataRef.current.rebuildModeCounts?.full ?? 0).toBe(fullBefore); + assertVisibleInvariants(tree); + }); + it('records metadata-only rename rebuilds as non-full', () => { const { tree, items } = createMutableFixture(); const dataRef = tree.getDataRef(); diff --git a/packages/trees/test/e2e/fixtures/virtualization.html b/packages/trees/test/e2e/fixtures/virtualization.html index f424dd2bc..4322315f7 100644 --- a/packages/trees/test/e2e/fixtures/virtualization.html +++ b/packages/trees/test/e2e/fixtures/virtualization.html @@ -70,7 +70,8 @@ } [data-profile-render-button], - [data-profile-rename-button] { + [data-profile-action-button], + [data-profile-action-select] { padding: 10px 16px; border: 1px solid #d1d5db; border-radius: 10px; @@ -78,11 +79,16 @@ font-weight: 600; color: #111827; background: #ffffff; + } + + [data-profile-render-button], + [data-profile-action-button] { cursor: pointer; } [data-profile-render-button]:disabled, - [data-profile-rename-button]:disabled { + [data-profile-action-button]:disabled, + [data-profile-action-select]:disabled { opacity: 0.65; cursor: progress; } @@ -117,8 +123,19 @@

Virtualized Linux Tree Render Fixture

-
@@ -155,8 +172,11 @@

Virtualized Linux Tree Render Fixture

const renderButton = document.querySelector( '[data-profile-render-button]' ); - const renameButton = document.querySelector( - '[data-profile-rename-button]' + const actionSelect = document.querySelector( + '[data-profile-action-select]' + ); + const actionButton = document.querySelector( + '[data-profile-action-button]' ); const fileCountLabel = document.querySelector( '[data-profile-file-count]' @@ -171,8 +191,12 @@

Virtualized Linux Tree Render Fixture

throw new Error('Missing [data-profile-render-button] element.'); } - if (!(renameButton instanceof HTMLButtonElement)) { - throw new Error('Missing [data-profile-rename-button] element.'); + if (!(actionSelect instanceof HTMLSelectElement)) { + throw new Error('Missing [data-profile-action-select] element.'); + } + + if (!(actionButton instanceof HTMLButtonElement)) { + throw new Error('Missing [data-profile-action-button] element.'); } if (!(fileCountLabel instanceof HTMLElement)) { @@ -447,9 +471,228 @@

Virtualized Linux Tree Render Fixture

return `${sourcePath}__renamed`; }; + const getParentPath = (path) => { + const slashIndex = path.lastIndexOf('/'); + return slashIndex < 0 ? 'root' : path.slice(0, slashIndex); + }; + + const getLeafName = (path) => { + const slashIndex = path.lastIndexOf('/'); + return slashIndex < 0 ? path : path.slice(slashIndex + 1); + }; + + const getPathDepth = (path) => { + let depth = 1; + for (let index = 0; index < path.length; index += 1) { + if (path.charCodeAt(index) === 47) { + depth += 1; + } + } + return depth; + }; + + const collectTopLevelEntries = (files) => { + const entries = new Set(); + for (const path of files) { + const slashIndex = path.indexOf('/'); + entries.add(slashIndex < 0 ? path : path.slice(0, slashIndex)); + } + return entries; + }; + + const createUniquePath = (candidatePath, existing) => { + if (!existing.has(candidatePath)) { + return candidatePath; + } + + const slashIndex = candidatePath.lastIndexOf('/'); + const directoryPrefix = + slashIndex >= 0 ? candidatePath.slice(0, slashIndex + 1) : ''; + const basename = + slashIndex >= 0 ? candidatePath.slice(slashIndex + 1) : candidatePath; + const dotIndex = basename.lastIndexOf('.'); + const stem = dotIndex > 0 ? basename.slice(0, dotIndex) : basename; + const extension = dotIndex > 0 ? basename.slice(dotIndex) : ''; + + let suffix = 2; + while (true) { + const nextPath = `${directoryPrefix}${stem}_${suffix}${extension}`; + if (!existing.has(nextPath)) { + return nextPath; + } + suffix += 1; + } + }; + + const selectDeepFilePath = (files, minimumDepth = 5) => { + for (const path of files) { + if (getPathDepth(path) >= minimumDepth) { + return path; + } + } + + const fallbackPath = files[0]; + if (typeof fallbackPath !== 'string') { + throw new Error('Unable to find a file path in fixture data.'); + } + + return fallbackPath; + }; + + const selectTopLevelFolderPair = (files) => { + let first = null; + let second = null; + const seen = new Set(); + + for (const path of files) { + const slashIndex = path.indexOf('/'); + if (slashIndex <= 0) { + continue; + } + + const topLevelFolder = path.slice(0, slashIndex); + if (seen.has(topLevelFolder)) { + continue; + } + + seen.add(topLevelFolder); + if (first == null) { + first = topLevelFolder; + continue; + } + + second = topLevelFolder; + break; + } + + if (first == null || second == null) { + throw new Error( + 'Unable to derive two top-level folders from fixture data.' + ); + } + + return { first, second }; + }; + + const selectMoveFileToRootScenario = (files) => { + const existing = new Set(files); + + for (const sourcePath of files) { + const destinationPath = getLeafName(sourcePath); + if (!existing.has(destinationPath)) { + return { + sourcePath, + targetPath: 'root', + destinationPath, + }; + } + } + + throw new Error('Unable to find a file that can be moved to root.'); + }; + + const selectMoveFolderToRootScenario = (files) => { + const topLevelEntries = collectTopLevelEntries(files); + let candidateFolderPath = getParentPath(selectDeepFilePath(files, 5)); + + while (candidateFolderPath !== 'root') { + const leafName = getLeafName(candidateFolderPath); + if (!topLevelEntries.has(leafName)) { + return { + sourcePath: candidateFolderPath, + targetPath: 'root', + destinationPath: leafName, + }; + } + + candidateFolderPath = getParentPath(candidateFolderPath); + } + + throw new Error('Unable to find a folder that can be moved to root.'); + }; + + const runMutationOperation = async ({ + operationName, + startMessage, + mutate, + buildResultText, + annotateCounters, + }) => { + if (fixtureState.fileTree == null) { + throw new Error(`${operationName} requested before initial render.`); + } + + actionButton.disabled = true; + actionSelect.disabled = true; + resultLabel.textContent = startMessage; + + try { + clearMeasureState(RENAME_MEASURE_SPEC); + const benchmark = ensureBenchmark(); + const benchmarkSnapshot = benchmark?.createSnapshot() ?? null; + const rebuildModeCountsBefore = readRebuildModeCounts(); + + const heapBefore = benchmark?.readHeapSnapshot() ?? null; + const operationStartTime = performance.now(); + markMeasureStart(RENAME_MEASURE_SPEC); + + const details = mutate(); + + const { renderedItemCount } = await waitForTreeHost(); + const visibleRowsReadyTime = performance.now(); + await waitForPaint(); + const operationEndTime = performance.now(); + const heapAfter = benchmark?.readHeapSnapshot() ?? null; + + markMeasureEnd(RENAME_MEASURE_SPEC); + + const instrumentation = + benchmark != null + ? benchmarkSnapshot != null + ? benchmark.summarizeSince( + benchmarkSnapshot, + heapBefore, + heapAfter + ) + : benchmark.summarize(heapBefore, heapAfter) + : { + phases: [], + counters: {}, + heap: null, + }; + + fixtureState.files = fixtureState.fileTree.getFiles(); + fixtureState.renderedItemCount = renderedItemCount; + + if (typeof annotateCounters === 'function') { + annotateCounters(instrumentation.counters, details); + } + + const summary = createOperationSummary({ + operationName, + measureSpec: RENAME_MEASURE_SPEC, + operationStartTime, + operationEndTime, + visibleRowsReadyTime, + renderedItemCount, + instrumentation, + rebuildModeCountsBefore, + resultText: buildResultText(details), + }); + + updateProfileOperation(operationName, summary); + resultLabel.textContent = summary.resultText; + return summary; + } finally { + actionButton.disabled = false; + actionSelect.disabled = false; + } + }; + const runInitialRender = async () => { renderButton.disabled = true; - renameButton.disabled = true; + actionButton.disabled = true; + actionSelect.disabled = true; resultLabel.textContent = 'Rendering…'; clearMeasureState(INITIAL_MEASURE_SPEC); @@ -532,177 +775,327 @@

Virtualized Linux Tree Render Fixture

updateProfileOperation('initial-render', summary); resultLabel.textContent = summary.resultText; renderButton.textContent = 'Rendered'; - renameButton.disabled = false; + actionButton.disabled = false; + actionSelect.disabled = false; return summary; }; const runRenameFile = async () => { - if (fixtureState.fileTree == null) { - throw new Error('Rename requested before initial render.'); - } + return runMutationOperation({ + operationName: 'rename-file', + startMessage: 'Renaming file…', + mutate: () => { + const sourcePath = normalizeRenameTarget(fixtureState.files); + const destinationPath = buildRenamedPath( + sourcePath, + fixtureState.files + ); - renameButton.disabled = true; - resultLabel.textContent = 'Renaming…'; + fixtureState.fileTree.renamePath({ + sourcePath, + destinationPath, + isFolder: false, + }); - clearMeasureState(RENAME_MEASURE_SPEC); - const benchmark = ensureBenchmark(); - const benchmarkSnapshot = benchmark?.createSnapshot() ?? null; - const rebuildModeCountsBefore = readRebuildModeCounts(); + return { sourcePath, destinationPath }; + }, + annotateCounters: (counters, details) => { + counters['workload.renameSourceLength'] = details.sourcePath.length; + counters['workload.renameDestinationLength'] = + details.destinationPath.length; + }, + buildResultText: (details) => + `Rename file post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${details.sourcePath} → ${details.destinationPath}.`, + }); + }; - const sourcePath = normalizeRenameTarget(fixtureState.files); - const destinationPath = buildRenamedPath( - sourcePath, - fixtureState.files - ); + const runRenameRootFolder = async () => { + return runMutationOperation({ + operationName: 'rename-root-folder', + startMessage: 'Renaming top-level folder…', + mutate: () => { + const sourcePath = selectTopLevelRenameFolder(fixtureState.files); + const destinationPath = buildRenamedTopLevelFolderPath(sourcePath); + + fixtureState.fileTree.renamePath({ + sourcePath, + destinationPath, + isFolder: true, + }); + + return { sourcePath, destinationPath }; + }, + annotateCounters: (counters, details) => { + counters['workload.renameSourceLength'] = details.sourcePath.length; + counters['workload.renameDestinationLength'] = + details.destinationPath.length; + }, + buildResultText: (details) => + `Rename root folder post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${details.sourcePath} → ${details.destinationPath}.`, + }); + }; - const heapBefore = benchmark?.readHeapSnapshot() ?? null; - const operationStartTime = performance.now(); - markMeasureStart(RENAME_MEASURE_SPEC); + const runAddFileRoot = async () => { + return runMutationOperation({ + operationName: 'add-file-root', + startMessage: 'Adding root file…', + mutate: () => { + const existingPaths = new Set(fixtureState.files); + const destinationPath = createUniquePath( + '__profile-added-root-file.ts', + existingPaths + ); + + fixtureState.fileTree.addPaths({ + paths: [destinationPath], + }); - fixtureState.fileTree.renamePath({ - sourcePath, - destinationPath, - isFolder: false, + return { destinationPath }; + }, + annotateCounters: (counters, details) => { + counters['workload.addPathLength'] = details.destinationPath.length; + counters['workload.addPathDepth'] = getPathDepth( + details.destinationPath + ); + }, + buildResultText: (details) => + `Add root file post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. added ${details.destinationPath}.`, }); + }; - const { renderedItemCount } = await waitForTreeHost(); - const visibleRowsReadyTime = performance.now(); - await waitForPaint(); - const operationEndTime = performance.now(); - const heapAfter = benchmark?.readHeapSnapshot() ?? null; + const runAddFileDeep = async () => { + return runMutationOperation({ + operationName: 'add-file-deep', + startMessage: 'Adding deep file…', + mutate: () => { + const existingPaths = new Set(fixtureState.files); + const deepParentPath = getParentPath( + selectDeepFilePath(fixtureState.files, 6) + ); + const candidatePath = `${deepParentPath}/__profile-added-deep-file.ts`; + const destinationPath = createUniquePath( + candidatePath, + existingPaths + ); - markMeasureEnd(RENAME_MEASURE_SPEC); + fixtureState.fileTree.addPaths({ + paths: [destinationPath], + }); - const instrumentation = - benchmark != null - ? benchmarkSnapshot != null - ? benchmark.summarizeSince( - benchmarkSnapshot, - heapBefore, - heapAfter - ) - : benchmark.summarize(heapBefore, heapAfter) - : { - phases: [], - counters: {}, - heap: null, - }; + return { destinationPath }; + }, + annotateCounters: (counters, details) => { + counters['workload.addPathLength'] = details.destinationPath.length; + counters['workload.addPathDepth'] = getPathDepth( + details.destinationPath + ); + }, + buildResultText: (details) => + `Add deep file post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. added ${details.destinationPath}.`, + }); + }; - fixtureState.files = fixtureState.fileTree.getFiles(); - fixtureState.renderedItemCount = renderedItemCount; - instrumentation.counters['workload.renameSourceLength'] = - sourcePath.length; - instrumentation.counters['workload.renameDestinationLength'] = - destinationPath.length; + const runDeleteFileDeep = async () => { + return runMutationOperation({ + operationName: 'delete-file-deep', + startMessage: 'Deleting deep file…', + mutate: () => { + const sourcePath = selectDeepFilePath(fixtureState.files, 6); - const summary = createOperationSummary({ - operationName: 'rename-file', - measureSpec: RENAME_MEASURE_SPEC, - operationStartTime, - operationEndTime, - visibleRowsReadyTime, - renderedItemCount, - instrumentation, - rebuildModeCountsBefore, - resultText: `Rename post-paint ready ${formatMs( - getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) - )}. ${sourcePath} → ${destinationPath}.`, - }); + fixtureState.fileTree.deletePaths({ + paths: [sourcePath], + }); - updateProfileOperation('rename-file', summary); - resultLabel.textContent = summary.resultText; - renameButton.disabled = false; - return summary; + return { sourcePath }; + }, + annotateCounters: (counters, details) => { + counters['workload.deletePathLength'] = details.sourcePath.length; + counters['workload.deletePathDepth'] = getPathDepth( + details.sourcePath + ); + }, + buildResultText: (details) => + `Delete deep file post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. deleted ${details.sourcePath}.`, + }); }; - const runRenameRootFolder = async () => { - if (fixtureState.fileTree == null) { - throw new Error('Rename requested before initial render.'); - } + const runDeleteRootFolder = async () => { + return runMutationOperation({ + operationName: 'delete-root-folder', + startMessage: 'Deleting root folder…', + mutate: () => { + const sourcePath = selectTopLevelRenameFolder(fixtureState.files); - renameButton.disabled = true; - resultLabel.textContent = 'Renaming top-level folder…'; + fixtureState.fileTree.deletePaths({ + paths: [sourcePath], + }); - clearMeasureState(RENAME_MEASURE_SPEC); - const benchmark = ensureBenchmark(); - const benchmarkSnapshot = benchmark?.createSnapshot() ?? null; - const rebuildModeCountsBefore = readRebuildModeCounts(); + return { sourcePath }; + }, + annotateCounters: (counters, details) => { + counters['workload.deletePathLength'] = details.sourcePath.length; + counters['workload.deletePathDepth'] = 1; + }, + buildResultText: (details) => + `Delete root folder post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. deleted ${details.sourcePath}.`, + }); + }; - const sourcePath = selectTopLevelRenameFolder(fixtureState.files); - const destinationPath = buildRenamedTopLevelFolderPath(sourcePath); + const runMoveFileToRoot = async () => { + return runMutationOperation({ + operationName: 'move-file-to-root', + startMessage: 'Moving file to root…', + mutate: () => { + const scenario = selectMoveFileToRootScenario(fixtureState.files); - const heapBefore = benchmark?.readHeapSnapshot() ?? null; - const operationStartTime = performance.now(); - markMeasureStart(RENAME_MEASURE_SPEC); + fixtureState.fileTree.movePaths({ + draggedPaths: [scenario.sourcePath], + targetPath: scenario.targetPath, + }); - fixtureState.fileTree.renamePath({ - sourcePath, - destinationPath, - isFolder: true, + return scenario; + }, + annotateCounters: (counters, details) => { + counters['workload.moveSourceLength'] = details.sourcePath.length; + counters['workload.moveDestinationLength'] = + details.destinationPath.length; + counters['workload.moveSourceDepth'] = getPathDepth( + details.sourcePath + ); + counters['workload.moveDestinationDepth'] = getPathDepth( + details.destinationPath + ); + }, + buildResultText: (details) => + `Move file-to-root post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${details.sourcePath} → ${details.destinationPath}.`, }); + }; - const { renderedItemCount } = await waitForTreeHost(); - const visibleRowsReadyTime = performance.now(); - await waitForPaint(); - const operationEndTime = performance.now(); - const heapAfter = benchmark?.readHeapSnapshot() ?? null; - - markMeasureEnd(RENAME_MEASURE_SPEC); + const runMoveFolderToRoot = async () => { + return runMutationOperation({ + operationName: 'move-folder-to-root', + startMessage: 'Moving deep folder to root…', + mutate: () => { + const scenario = selectMoveFolderToRootScenario(fixtureState.files); - const instrumentation = - benchmark != null - ? benchmarkSnapshot != null - ? benchmark.summarizeSince( - benchmarkSnapshot, - heapBefore, - heapAfter - ) - : benchmark.summarize(heapBefore, heapAfter) - : { - phases: [], - counters: {}, - heap: null, - }; + fixtureState.fileTree.movePaths({ + draggedPaths: [scenario.sourcePath], + targetPath: scenario.targetPath, + }); - fixtureState.files = fixtureState.fileTree.getFiles(); - fixtureState.renderedItemCount = renderedItemCount; - instrumentation.counters['workload.renameSourceLength'] = - sourcePath.length; - instrumentation.counters['workload.renameDestinationLength'] = - destinationPath.length; + return scenario; + }, + annotateCounters: (counters, details) => { + counters['workload.moveSourceLength'] = details.sourcePath.length; + counters['workload.moveDestinationLength'] = + details.destinationPath.length; + counters['workload.moveSourceDepth'] = getPathDepth( + details.sourcePath + ); + counters['workload.moveDestinationDepth'] = getPathDepth( + details.destinationPath + ); + }, + buildResultText: (details) => + `Move folder-to-root post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${details.sourcePath} → ${details.destinationPath}.`, + }); + }; - const summary = createOperationSummary({ - operationName: 'rename-root-folder', - measureSpec: RENAME_MEASURE_SPEC, - operationStartTime, - operationEndTime, - visibleRowsReadyTime, - renderedItemCount, - instrumentation, - rebuildModeCountsBefore, - resultText: `Top-level rename post-paint ready ${formatMs( - getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) - )}. ${sourcePath} → ${destinationPath}.`, + const runMoveRootFolder = async () => { + return runMutationOperation({ + operationName: 'move-root-folder', + startMessage: 'Moving root folder into another root folder…', + mutate: () => { + const { first, second } = selectTopLevelFolderPair( + fixtureState.files + ); + const sourcePath = first; + const targetPath = second; + const destinationPath = `${targetPath}/${sourcePath}`; + + fixtureState.fileTree.movePaths({ + draggedPaths: [sourcePath], + targetPath, + }); + + return { + sourcePath, + targetPath, + destinationPath, + }; + }, + annotateCounters: (counters, details) => { + counters['workload.moveSourceLength'] = details.sourcePath.length; + counters['workload.moveDestinationLength'] = + details.destinationPath.length; + counters['workload.moveSourceDepth'] = 1; + counters['workload.moveDestinationDepth'] = getPathDepth( + details.destinationPath + ); + }, + buildResultText: (details) => + `Move root-folder post-paint ready ${formatMs( + getLatestMeasureDurationMs(RENAME_MEASURE_SPEC) + )}. ${details.sourcePath} → ${details.destinationPath}.`, }); + }; - updateProfileOperation('rename-root-folder', summary); - resultLabel.textContent = summary.resultText; - renameButton.disabled = false; - return summary; + const actionRunners = { + 'rename-file': runRenameFile, + 'rename-root-folder': runRenameRootFolder, + 'add-file-root': runAddFileRoot, + 'add-file-deep': runAddFileDeep, + 'delete-file-deep': runDeleteFileDeep, + 'delete-root-folder': runDeleteRootFolder, + 'move-file-to-root': runMoveFileToRoot, + 'move-folder-to-root': runMoveFolderToRoot, + 'move-root-folder': runMoveRootFolder, + }; + + const runSelectedAction = async () => { + const actionName = actionSelect.value; + const runner = actionRunners[actionName]; + if (typeof runner !== 'function') { + throw new Error(`Unknown selected action: ${actionName}`); + } + return runner(); }; window.__treesDevVirtualizationFixtureApi = { runInitialRender, runRenameFile, runRenameRootFolder, + runAddFileRoot, + runAddFileDeep, + runDeleteFileDeep, + runDeleteRootFolder, + runMoveFileToRoot, + runMoveFolderToRoot, + runMoveRootFolder, }; renderButton.addEventListener('click', () => { void runInitialRender(); }); - renameButton.addEventListener('click', () => { - void runRenameFile(); + actionButton.addEventListener('click', () => { + void runSelectedAction(); }); window.__treesDevVirtualizationFixtureReady = true; diff --git a/packages/trees/test/file-tree-model.test.ts b/packages/trees/test/file-tree-model.test.ts index 418f0ec9a..4246385f4 100644 --- a/packages/trees/test/file-tree-model.test.ts +++ b/packages/trees/test/file-tree-model.test.ts @@ -408,6 +408,192 @@ describe('FileTreeModel', () => { true ); expect(nextFiles.some((path) => path.startsWith('root-0/'))).toBe(false); + expect(getCounter(counters, 'model.snapshot.fileIndexRebuildCount')).toBe( + 1 + ); + }); + + test('chained deferred folder remaps reuse snapshot file indices', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + ['root-0/a.ts', 'root-0/b.ts', 'docs/readme.md'], + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + expect( + model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-a', + isFolder: true, + }).ok + ).toBe(true); + + expect( + model.renamePath({ + sourcePath: 'root-a', + destinationPath: 'root-b', + isFolder: true, + }).ok + ).toBe(true); + + expect(model.getFiles()).toEqual([ + 'root-b/a.ts', + 'root-b/b.ts', + 'docs/readme.md', + ]); + expect(getCounter(counters, 'model.snapshot.fileIndexRebuildCount')).toBe( + 1 + ); + }); + + test('file rename after deferred folder remap reuses snapshot file indices', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + ['root-0/a.ts', 'root-0/b.ts', 'docs/readme.md'], + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + expect( + model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-a', + isFolder: true, + }).ok + ).toBe(true); + + expect( + model.renamePath({ + sourcePath: 'root-a/a.ts', + destinationPath: 'root-a/a-renamed.ts', + isFolder: false, + }).ok + ).toBe(true); + + expect(model.getFiles()).toEqual([ + 'root-a/a-renamed.ts', + 'root-a/b.ts', + 'docs/readme.md', + ]); + expect(getCounter(counters, 'model.snapshot.fileIndexRebuildCount')).toBe( + 1 + ); + }); + + test('keeps deferred folder remaps when addPaths creates the path tree', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + ['root-0/a.ts', 'docs/readme.md', 'other/keep.md'], + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + const syncIndex = model.getSyncIndex(); + const renamedRootId = syncIndex.pathToId.get('root-0'); + expect(renamedRootId).toBeDefined(); + if (renamedRootId == null) { + throw new Error('Expected stable ID for root-0'); + } + + expect( + model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-0-renamed', + isFolder: true, + }).ok + ).toBe(true); + + expect(model.addPaths({ paths: ['docs/new.ts'] }).ok).toBe(true); + + expect(syncIndex.pathToId.get('root-0-renamed')).toBe(renamedRootId); + expect(model.getPathForId(renamedRootId)).toBe('root-0-renamed'); + expect( + getCounter(counters, 'model.pathPrefixRemaps.materializedCount') + ).toBe(0); + }); + + test('keeps deferred folder remaps when movePaths creates the path tree', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + ['root-0/a.ts', 'docs/readme.md', 'other/keep.md'], + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + const syncIndex = model.getSyncIndex(); + const renamedRootId = syncIndex.pathToId.get('root-0'); + const movedFileId = syncIndex.pathToId.get('docs/readme.md'); + expect(renamedRootId).toBeDefined(); + expect(movedFileId).toBeDefined(); + if (renamedRootId == null || movedFileId == null) { + throw new Error('Expected stable IDs for deferred move remap test'); + } + + expect( + model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-0-renamed', + isFolder: true, + }).ok + ).toBe(true); + + expect( + model.movePaths({ + draggedPaths: ['docs/readme.md'], + targetPath: 'other', + }).ok + ).toBe(true); + + expect(syncIndex.pathToId.get('other/readme.md')).toBe(movedFileId); + expect(syncIndex.pathToId.get('root-0-renamed')).toBe(renamedRootId); + expect(model.getPathForId(renamedRootId)).toBe('root-0-renamed'); + expect( + getCounter(counters, 'model.pathPrefixRemaps.materializedCount') + ).toBe(0); + }); + + test('keeps deferred folder remaps when deletePaths creates the path tree', () => { + const { counters, instrumentation } = createCounterCollector(); + const model = FileTreeModel.fromFiles( + ['root-0/a.ts', 'docs/readme.md', 'other/keep.md'], + { + sortComparator: false, + benchmarkInstrumentation: instrumentation, + } + ); + + const syncIndex = model.getSyncIndex(); + const renamedRootId = syncIndex.pathToId.get('root-0'); + expect(renamedRootId).toBeDefined(); + if (renamedRootId == null) { + throw new Error('Expected stable ID for root-0'); + } + + expect( + model.renamePath({ + sourcePath: 'root-0', + destinationPath: 'root-0-renamed', + isFolder: true, + }).ok + ).toBe(true); + + expect(model.deletePaths({ paths: ['docs/readme.md'] }).ok).toBe(true); + + expect(syncIndex.pathToId.get('docs/readme.md')).toBeUndefined(); + expect(syncIndex.pathToId.get('root-0-renamed')).toBe(renamedRootId); + expect(model.getPathForId(renamedRootId)).toBe('root-0-renamed'); + expect( + getCounter(counters, 'model.pathPrefixRemaps.materializedCount') + ).toBe(0); }); test('keeps file move remap work constant with many unrelated files', () => {