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..0b773415a 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,17 +110,18 @@ function VanillaSSRContextMenu({ }; const fileTree = new FileTree( - { + toRuntimeFileTreeOptions({ ...options, initialFiles: filesRef.current, renaming: renamingOptions, - }, + }), { ...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({ @@ -172,7 +177,7 @@ function ReactSSRContextMenu({ stateConfig, prerenderedHTML, }: { - options: Omit; + options: Omit; initialFiles?: string[]; stateConfig?: FileTreeStateConfig; prerenderedHTML: string; @@ -183,11 +188,32 @@ function ReactSSRContextMenu({ [] ); + const runtimeOptions = useMemo( + () => + toRuntimeFileTreeOptions({ + ...options, + initialFiles: files, + renaming: renamingOptions, + }), + [files, options, renamingOptions] + ); + const { model, ...reactTreeOptions } = runtimeOptions; + + const handleFilesChange = useCallback( + ( + _changeSet: import('@pierre/trees').FileTreeChangeSet, + context: import('@pierre/trees').FileTreeChangeContext + ) => { + setFiles(context.getFiles()); + }, + [] + ); + 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..cf55c94a6 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); @@ -54,7 +59,8 @@ function VanillaDnDUncontrolled({ const mergedStateConfig = useMemo( () => ({ ...stateConfig, - onFilesChange: (files) => { + onFilesChange: (_changeSet, context) => { + const files = context.getFiles(); addLog(`files: [${files.join(', ')}]`); }, }), @@ -73,11 +79,11 @@ function VanillaDnDUncontrolled({ } const fileTree = new FileTree( - { + toRuntimeFileTreeOptions({ ...options, dragAndDrop: true, initialFiles: sharedDemoFileTreeOptions.initialFiles, - }, + }), mergedStateConfig ); fileTree.render({ containerWrapper: node }); @@ -111,7 +117,7 @@ function ReactDnDControlled({ options, stateConfig, }: { - options: Omit; + options: Omit; stateConfig?: FileTreeStateConfig; }) { const [files, setFiles] = useState(sharedDemoFileTreeOptions.initialFiles); @@ -119,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')); @@ -134,6 +144,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; }) { @@ -187,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')); @@ -202,6 +227,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..8182031fa 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/rename-file-tree-paths.test.ts b/packages/trees/test/rename-file-tree-paths.test.ts index ed2ceef74..c993f373c 100644 --- a/packages/trees/test/rename-file-tree-paths.test.ts +++ b/packages/trees/test/rename-file-tree-paths.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; +import { MutablePathTree } from '../src/utils/mutablePathTree'; import { remapExpandedPathsForFolderRename, renameFileTreePaths, @@ -159,6 +160,47 @@ describe('renameFileTreePaths', () => { error: 'Could not find the selected folder to rename.', }); }); + + test('can mutate a persistent path tree in place', () => { + const files = ['src/index.ts', 'src/utils/helpers.ts']; + const pathTree = MutablePathTree.fromFiles(files); + + const firstResult = renameFileTreePaths({ + files, + path: 'src', + isFolder: true, + nextBasename: 'lib', + pathTree, + mutatePathTree: true, + }); + + expect(firstResult).toEqual({ + nextFiles: ['lib/index.ts', 'lib/utils/helpers.ts'], + sourcePath: 'src', + destinationPath: 'lib', + isFolder: true, + }); + + if ('error' in firstResult) { + throw new Error(firstResult.error); + } + + const secondResult = renameFileTreePaths({ + files: firstResult.nextFiles, + path: 'lib/utils/helpers.ts', + isFolder: false, + nextBasename: 'format.ts', + pathTree, + mutatePathTree: true, + }); + + expect(secondResult).toEqual({ + nextFiles: ['lib/index.ts', 'lib/utils/format.ts'], + sourcePath: 'lib/utils/helpers.ts', + destinationPath: 'lib/utils/format.ts', + isFolder: false, + }); + }); }); describe('remapExpandedPathsForFolderRename', () => { 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..e382de824 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 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 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 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,22 +558,30 @@ 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', () => { - const calls: string[][] = []; - const ft = new FileTree( + 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.setFiles(['b.txt', 'c.txt']); - expect(calls).toEqual([['b.txt', 'c.txt']]); + ft.model.replaceAll(['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', () => { @@ -505,20 +609,55 @@ describe('SSR + declarative shadow DOM', () => { expect(nextExpanded).not.toContain('src'); }); - test('setOptions with state.files invokes onFilesChange callback', () => { - const calls: string[][] = []; - const ft = new FileTree( + test('renamePath invokes onFilesChange callback with a changeset', () => { + const changes: import('../src/FileTree').FileTreeChangeSet[] = []; + const snapshots: string[][] = []; + const ft = createFileTree( + { initialFiles: ['a.txt'] }, + { + onFilesChange: (changeSet, context) => { + changes.push(changeSet); + snapshots.push(context.getFiles()); + }, + } + ); + + ft.renamePath({ + sourcePath: 'a.txt', + destinationPath: 'b.txt', + isFolder: false, + }); + expect(changes).toHaveLength(1); + expect(changes[0]?.kind).toBe('rename-path'); + expect(snapshots).toEqual([['b.txt']]); + }); + + test('renamePath invokes onModelChange callback with a changeset', () => { + const changes: import('../src/FileTree').FileTreeChangeSet[] = []; + const snapshots: string[][] = []; + const ft = createFileTree( { initialFiles: ['a.txt'] }, - { onFilesChange: (files) => calls.push(files) } + { + onModelChange: (changeSet, context) => { + changes.push(changeSet); + snapshots.push(context.getFiles()); + }, + } ); - ft.setOptions({ flattenEmptyDirectories: true }, { files: ['b.txt'] }); - expect(calls).toEqual([['b.txt']]); + ft.renamePath({ + sourcePath: 'a.txt', + destinationPath: 'b.txt', + isFolder: false, + }); + 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', () => { 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 +709,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);