From 0e9def293bce3ccd69750fc334cc586371412b72 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 14 Apr 2026 13:59:40 -0500 Subject: [PATCH 1/4] phase 6 - mutations pass 1, not really working well --- .gitignore | 2 +- .../app/trees-dev/_components/ExampleCard.tsx | 2 +- .../PathStorePoweredRenderDemoClient.tsx | 447 ++++++++++++++++-- .../path-store-powered/capabilityMatrix.ts | 6 +- .../app/trees-dev/path-store-powered/page.tsx | 4 +- packages/trees/src/path-store/controller.ts | 317 ++++++++++++- packages/trees/src/path-store/file-tree.ts | 45 +- packages/trees/src/path-store/index.ts | 12 + packages/trees/src/path-store/types.ts | 85 +++- .../e2e/fixtures/path-store-mutations.html | 220 +++++++++ .../trees/test/e2e/path-store-mutations.pw.ts | 94 ++++ .../path-store-composition-surfaces.test.ts | 2 +- ...h-store-dynamic-files-mutation-api.test.ts | 287 +++++++++++ .../test/path-store-render-scroll.test.ts | 26 +- 14 files changed, 1464 insertions(+), 85 deletions(-) create mode 100644 packages/trees/test/e2e/fixtures/path-store-mutations.html create mode 100644 packages/trees/test/e2e/path-store-mutations.pw.ts create mode 100644 packages/trees/test/path-store-dynamic-files-mutation-api.test.ts diff --git a/.gitignore b/.gitignore index f8da83ce7..b090aafd7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ tsconfig.tsbuildinfo .vercel .claude/* .codemogger/* -.omx/* +.omx/ diff --git a/apps/docs/app/trees-dev/_components/ExampleCard.tsx b/apps/docs/app/trees-dev/_components/ExampleCard.tsx index 13aab5e91..b07030410 100644 --- a/apps/docs/app/trees-dev/_components/ExampleCard.tsx +++ b/apps/docs/app/trees-dev/_components/ExampleCard.tsx @@ -16,7 +16,7 @@ export function ExampleCard({ footer?: ReactNode; }) { return ( -
+

{title}

{description} diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 8f5b1df0b..58cb7f879 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -3,16 +3,14 @@ import { PathStoreFileTree, type PathStoreFileTreeOptions, + type PathStoreTreesContextMenuItem, + type PathStoreTreesContextMenuOpenContext, + type PathStoreTreesMutationEvent, } from '@pierre/trees/path-store'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { Root as ReactDomRoot } from 'react-dom/client'; import { ExampleCard } from '../_components/ExampleCard'; import { StateLog, useStateLog } from '../_components/StateLog'; -import { - clearVanillaContextMenuSlot, - renderVanillaContextMenuSlot, -} from '../_components/TreeDemoContextMenu'; import { pathStoreCapabilityMatrix } from './capabilityMatrix'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; import { PATH_STORE_CUSTOM_ICONS } from './pathStoreDemoIcons'; @@ -27,6 +25,162 @@ interface PathStorePoweredRenderDemoClientProps { sharedOptions: SharedDemoOptions; } +type PathStoreMutationOperation = + | { path: string; type: 'add' } + | { from: string; to: string; type: 'move' }; + +interface PathStoreMutationDemoTargets { + addFilePath: string; + addFolderPath: string; + batchOperations: readonly PathStoreMutationOperation[]; + moveFromPath: string | null; + moveToPath: string | null; +} + +function getParentPath(path: string): string { + if (path.endsWith('/')) { + const trimmedPath = path.slice(0, -1); + const lastSlashIndex = trimmedPath.lastIndexOf('/'); + return lastSlashIndex < 0 + ? '' + : `${trimmedPath.slice(0, lastSlashIndex + 1)}`; + } + + const lastSlashIndex = path.lastIndexOf('/'); + return lastSlashIndex < 0 ? '' : path.slice(0, lastSlashIndex + 1); +} + +function getPathBasename(path: string): string { + const trimmedPath = path.endsWith('/') ? path.slice(0, -1) : path; + const lastSlashIndex = trimmedPath.lastIndexOf('/'); + return lastSlashIndex < 0 + ? trimmedPath + : trimmedPath.slice(lastSlashIndex + 1); +} + +// Creates a stable suffixed path so repeated demo-target derivation can avoid collisions. +function getSuffixedPath(path: string, suffix: number): string { + if (path.endsWith('/')) { + return `${path.slice(0, -1)}-${String(suffix)}/`; + } + + const lastSlashIndex = path.lastIndexOf('/'); + const lastDotIndex = path.lastIndexOf('.'); + if (lastDotIndex > lastSlashIndex) { + return `${path.slice(0, lastDotIndex)}-${String(suffix)}${path.slice(lastDotIndex)}`; + } + + return `${path}-${String(suffix)}`; +} + +// Picks a unique demo path under the existing tree so mutation buttons can be re-used after reset. +function getUniquePath( + path: string, + existingPaths: ReadonlySet +): string { + let candidatePath = path; + let suffix = 1; + while (existingPaths.has(candidatePath)) { + candidatePath = getSuffixedPath(path, suffix); + suffix += 1; + } + return candidatePath; +} + +function renamePathSameParent(path: string, nextBasename: string): string { + const parentPath = getParentPath(path); + const trimmedBasename = nextBasename.trim(); + return path.endsWith('/') + ? `${parentPath}${trimmedBasename}/` + : `${parentPath}${trimmedBasename}`; +} + +// Derives deterministic proof paths from the current workload instead of hardcoding one repo shape. +function createMutationDemoTargets( + paths: readonly string[] +): PathStoreMutationDemoTargets { + const existingPaths = new Set(paths); + const directoryPaths = new Set(); + for (const path of paths) { + let currentParentPath = getParentPath(path); + while (currentParentPath.length > 0) { + directoryPaths.add(currentParentPath); + currentParentPath = getParentPath(currentParentPath); + } + if (path.endsWith('/')) { + directoryPaths.add(path); + } + } + + const sortedDirectoryPaths = [...directoryPaths].sort(); + const firstDirectoryPath = sortedDirectoryPaths[0] ?? ''; + const filePaths = paths.filter((path) => !path.endsWith('/')); + const addFilePath = getUniquePath( + `${firstDirectoryPath}phase-6-demo-file.ts`, + existingPaths + ); + const addFolderPath = getUniquePath( + `${firstDirectoryPath}phase-6-demo-folder/`, + existingPaths + ); + + let moveFromPath: string | null = null; + let moveToPath: string | null = null; + for (const sourcePath of filePaths) { + const sourceParentPath = getParentPath(sourcePath); + const sourceBasename = getPathBasename(sourcePath); + const siblingRenameTarget = getUniquePath( + renamePathSameParent(sourcePath, `moved-${sourceBasename}`), + existingPaths + ); + + const alternateDirectoryTarget = sortedDirectoryPaths + .filter((directoryPath) => directoryPath !== sourceParentPath) + .map((directoryPath) => `${directoryPath}${sourceBasename}`) + .find((candidatePath) => !existingPaths.has(candidatePath)); + + moveFromPath = sourcePath; + moveToPath = alternateDirectoryTarget ?? siblingRenameTarget; + break; + } + + const batchFolderPath = getUniquePath( + `${firstDirectoryPath}phase-6-batch-folder/`, + existingPaths + ); + const batchFilePath = `${batchFolderPath}batch-note.md`; + const batchOperations: PathStoreMutationOperation[] = [ + { path: batchFolderPath, type: 'add' }, + { path: batchFilePath, type: 'add' }, + ]; + if (moveFromPath != null && moveToPath != null) { + batchOperations.push({ from: moveFromPath, to: moveToPath, type: 'move' }); + } + + return { + addFilePath, + addFolderPath, + batchOperations, + moveFromPath, + moveToPath, + }; +} + +function formatMutationEvent(event: PathStoreTreesMutationEvent): string { + switch (event.operation) { + case 'add': + return `mutation:add ${event.path}`; + case 'remove': + return `mutation:remove ${event.path}${event.recursive === true ? ' (recursive)' : ''}`; + case 'move': + return `mutation:move ${event.from} -> ${event.to}`; + case 'batch': + return `mutation:batch [${event.events.map((entry) => entry.operation).join(', ')}]`; + case 'reset': + return `mutation:reset ${String(event.pathCountBefore)} -> ${String(event.pathCountAfter)} paths`; + } +} + const HydratedPathStoreExample = memo(function HydratedPathStoreExample({ containerHtml, description, @@ -81,9 +235,8 @@ export function PathStorePoweredRenderDemoClient({ sharedOptions, }: PathStorePoweredRenderDemoClientProps) { const { addLog, log } = useStateLog(); - const contextMenuRootRef = useRef(null); - const contextMenuSlotRef = useRef(null); const treeRef = useRef(null); + const mutationUnsubscribeRef = useRef<(() => void) | null>(null); const [iconMode, setIconMode] = useState< 'complete' | 'custom' | 'minimal' | 'standard' >('complete'); @@ -91,26 +244,41 @@ export function PathStorePoweredRenderDemoClient({ () => createPresortedPreparedInput(sharedOptions.paths), [sharedOptions.paths] ); + const demoTargets = useMemo( + () => createMutationDemoTargets(sharedOptions.paths), + [sharedOptions.paths] + ); const handleSelectionChange = useCallback( (selectedPaths: readonly string[]) => { addLog(`selected: [${selectedPaths.join(', ')}]`); }, [addLog] ); - useEffect( - () => () => { - if (contextMenuSlotRef.current == null) { + + useEffect(() => { + return () => { + mutationUnsubscribeRef.current?.(); + mutationUnsubscribeRef.current = null; + }; + }, []); + + const runMutation = useCallback( + (label: string, mutate: (tree: PathStoreFileTree) => void): void => { + const tree = treeRef.current; + if (tree == null) { + addLog(`error: tree not ready for ${label}`); return; } - clearVanillaContextMenuSlot({ - menuRootRef: contextMenuRootRef, - slotElement: contextMenuSlotRef.current, - unmount: true, - }); + try { + mutate(tree); + } catch (error) { + addLog(`error:${label} ${(error as Error).message ?? String(error)}`); + } }, - [] + [addLog] ); + const options = useMemo>( () => ({ ...sharedOptions, @@ -119,29 +287,78 @@ export function PathStorePoweredRenderDemoClient({ contextMenu: { enabled: true, onClose: () => { - if (contextMenuSlotRef.current != null) { - clearVanillaContextMenuSlot({ - menuRootRef: contextMenuRootRef, - slotElement: contextMenuSlotRef.current, - }); - } addLog('context menu: closed'); }, onOpen: (item) => { addLog(`context menu: opened for ${item.path}`); }, - render: (item, context) => { - contextMenuSlotRef.current ??= document.createElement('div'); - renderVanillaContextMenuSlot({ - context, - item: { - isFolder: item.kind === 'directory', - path: item.path, - }, - menuRootRef: contextMenuRootRef, - slotElement: contextMenuSlotRef.current, + render: ( + item: PathStoreTreesContextMenuItem, + context: PathStoreTreesContextMenuOpenContext + ) => { + const menu = document.createElement('div'); + menu.dataset.testPathStoreMutationMenu = item.path; + menu.dataset.testContextMenu = 'true'; + menu.style.display = 'grid'; + menu.style.gap = '8px'; + menu.style.minWidth = '220px'; + menu.style.padding = '8px'; + menu.style.border = '1px solid var(--color-border, #666)'; + menu.style.borderRadius = '8px'; + menu.style.background = 'var(--color-bg, #fff)'; + menu.style.boxShadow = '0 6px 18px rgba(0, 0, 0, 0.2)'; + + const label = document.createElement('div'); + label.textContent = `${item.kind === 'directory' ? 'Folder' : 'File'}: ${item.path}`; + menu.append(label); + + const renameButton = document.createElement('button'); + renameButton.type = 'button'; + renameButton.dataset.pathStoreMenuAction = 'rename'; + renameButton.textContent = 'Rename'; + renameButton.addEventListener('click', () => { + const nextBasename = window.prompt( + 'Rename path', + getPathBasename(item.path) + ); + if (nextBasename == null || nextBasename.trim().length === 0) { + addLog(`rename: cancelled for ${item.path}`); + context.close(); + return; + } + + runMutation(`rename ${item.path}`, (tree) => { + const nextPath = renamePathSameParent(item.path, nextBasename); + tree.move(item.path, nextPath); + }); + context.close(); + }); + menu.append(renameButton); + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.dataset.pathStoreMenuAction = 'delete'; + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', () => { + runMutation(`delete ${item.path}`, (tree) => { + tree.remove( + item.path, + item.kind === 'directory' ? { recursive: true } : undefined + ); + }); + context.close(); }); - return contextMenuSlotRef.current; + menu.append(deleteButton); + + const closeButton = document.createElement('button'); + closeButton.type = 'button'; + closeButton.dataset.pathStoreMenuAction = 'close'; + closeButton.textContent = 'Close'; + closeButton.addEventListener('click', () => { + context.close(); + }); + menu.append(closeButton); + return menu; }, }, header: { @@ -154,7 +371,7 @@ export function PathStorePoweredRenderDemoClient({ header.style.padding = '8px 12px'; const label = document.createElement('strong'); - label.textContent = 'Provisional header slot'; + label.textContent = 'Phase 6 mutation header'; header.append(label); const button = document.createElement('button'); @@ -169,26 +386,120 @@ export function PathStorePoweredRenderDemoClient({ }, }, }, - id: 'pst-phase5-icons', + id: 'pst-phase6-mutations', onSelectionChange: handleSelectionChange, preparedInput, renderRowDecoration: ({ item }) => - item.path.endsWith('.ts') + item.path.endsWith('.ts') === true ? { text: 'TS', title: 'TypeScript file' } : null, }), - [addLog, handleSelectionChange, preparedInput, sharedOptions] + [addLog, handleSelectionChange, preparedInput, runMutation, sharedOptions] ); const activeIcons = iconMode === 'custom' ? PATH_STORE_CUSTOM_ICONS : iconMode; - const handleTreeReady = useCallback((fileTree: PathStoreFileTree | null) => { - treeRef.current = fileTree; - }, []); + const handleTreeReady = useCallback( + (fileTree: PathStoreFileTree | null) => { + mutationUnsubscribeRef.current?.(); + mutationUnsubscribeRef.current = null; + treeRef.current = fileTree; + if (fileTree == null) { + return; + } + + mutationUnsubscribeRef.current = fileTree.onMutation('*', (event) => { + addLog(formatMutationEvent(event)); + }); + }, + [addLog] + ); useEffect(() => { treeRef.current?.setIcons(activeIcons); }, [activeIcons]); + const handleAddFile = useCallback(() => { + runMutation(`add ${demoTargets.addFilePath}`, (tree) => { + if (tree.getItem(demoTargets.addFilePath) != null) { + addLog(`add: ${demoTargets.addFilePath} already exists`); + return; + } + tree.add(demoTargets.addFilePath); + }); + }, [addLog, demoTargets.addFilePath, runMutation]); + + const handleAddFolder = useCallback(() => { + runMutation(`add ${demoTargets.addFolderPath}`, (tree) => { + if (tree.getItem(demoTargets.addFolderPath) != null) { + addLog(`add: ${demoTargets.addFolderPath} already exists`); + return; + } + tree.add(demoTargets.addFolderPath); + }); + }, [addLog, demoTargets.addFolderPath, runMutation]); + + const handleMove = useCallback(() => { + if (demoTargets.moveFromPath == null || demoTargets.moveToPath == null) { + addLog('move: no demo move target available'); + return; + } + + runMutation( + `move ${demoTargets.moveFromPath} -> ${demoTargets.moveToPath}`, + (tree) => { + if (tree.getItem(demoTargets.moveFromPath as string) == null) { + addLog( + `move: ${demoTargets.moveFromPath} is already gone; reset to retry` + ); + return; + } + if (tree.getItem(demoTargets.moveToPath as string) != null) { + addLog( + `move: ${demoTargets.moveToPath} already exists; reset to retry` + ); + return; + } + tree.move( + demoTargets.moveFromPath as string, + demoTargets.moveToPath as string + ); + } + ); + }, [addLog, demoTargets.moveFromPath, demoTargets.moveToPath, runMutation]); + + const handleBatch = useCallback(() => { + runMutation('batch demo', (tree) => { + const nextBatchIsBlocked = demoTargets.batchOperations.some( + (operation) => { + if (operation.type === 'add') { + return tree.getItem(operation.path) != null; + } + if (operation.type === 'move') { + return ( + tree.getItem(operation.from) == null || + tree.getItem(operation.to) != null + ); + } + return false; + } + ); + if (nextBatchIsBlocked) { + addLog( + 'batch: current tree state no longer matches the demo assumptions; reset to retry' + ); + return; + } + + tree.batch(demoTargets.batchOperations); + }); + }, [addLog, demoTargets.batchOperations, runMutation]); + + const handleReset = useCallback(() => { + runMutation('reset demo tree', (tree) => { + tree.resetPaths(sharedOptions.paths, { preparedInput }); + }); + }, [preparedInput, runMutation, sharedOptions.paths]); + return (

@@ -196,14 +507,56 @@ export function PathStorePoweredRenderDemoClient({ Path-store lane · provisional

- Focus + Selection + Header Slot + Icon Sets + Mutation API + Context Menu Proof + Icon Sets

- The path-store lane keeps the landed focus and selection model, - preserves the header slot, proves the built-in Minimal, Standard, and - Complete icon sets, and now restores the Phase 5 context-menu shell - plus simple row decorations. + Phase 6 turns the path-store lane into a mutation-first tree: use the + shared handle to add, move, batch, and reset paths, use the existing + context menu for low-cost delete and narrow rename proof, and watch + the live tree plus mutation log stay coherent under virtualization.

+
+ + + + + +
'; + '
Phase 6 mutation header
'; export default function PathStorePoweredPage() { const sharedOptions: Omit = @@ -34,7 +34,7 @@ export default function PathStorePoweredPage() { const payload = preloadPathStoreFileTree({ ...sharedOptions, icons: 'complete', - id: 'pst-phase5-icons', + id: 'pst-phase6-mutations', preparedInput: linuxKernelPreparedInput, }); diff --git a/packages/trees/src/path-store/controller.ts b/packages/trees/src/path-store/controller.ts index 0f19f80a6..45af94153 100644 --- a/packages/trees/src/path-store/controller.ts +++ b/packages/trees/src/path-store/controller.ts @@ -1,15 +1,28 @@ import { PathStore } from '@pierre/path-store'; import type { + PathStoreEvent, + PathStoreMoveOptions, + PathStoreOperation, PathStorePathInfo, + PathStorePreparedInput, + PathStoreRemoveOptions, PathStoreVisibleTreeProjectionData, } from '@pierre/path-store'; import type { + PathStoreTreesBatchEvent, PathStoreTreesControllerListener, PathStoreTreesControllerOptions, PathStoreTreesDirectoryHandle, PathStoreTreesFileHandle, PathStoreTreesItemHandle, + PathStoreTreesMutationEvent, + PathStoreTreesMutationEventForType, + PathStoreTreesMutationEventType, + PathStoreTreesMutationHandle, + PathStoreTreesMutationSemanticEvent, + PathStoreTreesResetEvent, + PathStoreTreesResetOptions, PathStoreTreesVisibleRow, } from './types'; @@ -25,6 +38,12 @@ interface PathStoreTreesVisibleProjection { visibleIndexByPathFactory: () => Map; } +type MutationListener = (event: PathStoreTreesMutationEvent) => void; +type MutationListenerByType = Map< + PathStoreTreesMutationEventType | '*', + Set +>; + // Initial render only mounts a tiny viewport slice, so controller startup can // cap its first projection build and defer the full 494k-row metadata walk // until the user actually navigates outside that initial window. @@ -92,6 +111,154 @@ function getSiblingComparisonKey( return path.startsWith(parentPath) ? path.slice(parentPath.length) : path; } +// Applies a directory/file move to a tracked public path so focus/selection can +// follow moved items instead of falling back as if they were deleted. +function remapMovedPath( + path: string, + fromPath: string, + toPath: string +): string { + if (path === fromPath) { + return toPath; + } + + return fromPath.endsWith('/') && path.startsWith(fromPath) + ? `${toPath}${path.slice(fromPath.length)}` + : path; +} + +// Determines whether a tracked public path disappeared because a remove event +// deleted that exact item or a whole removed directory subtree. +function isPathRemoved(path: string, removedPath: string): boolean { + return ( + path === removedPath || + (removedPath.endsWith('/') && path.startsWith(removedPath)) + ); +} + +// Rewrites focus/selection paths through mutation events so controller state +// stays aligned with the mutated topology before the next projection rebuild. +function remapPathThroughMutation( + path: string | null, + event: PathStoreEvent, + preserveRemovedPath: boolean = false +): string | null { + if (path == null) { + return null; + } + + switch (event.operation) { + case 'add': + case 'expand': + case 'collapse': + case 'mark-directory-unloaded': + case 'begin-child-load': + case 'apply-child-patch': + case 'complete-child-load': + case 'fail-child-load': + case 'cleanup': + return path; + case 'remove': + return isPathRemoved(path, event.path) + ? preserveRemovedPath + ? path + : null + : path; + case 'move': + return remapMovedPath(path, event.from, event.to); + case 'batch': { + let nextPath: string | null = path; + for (const childEvent of event.events) { + nextPath = remapPathThroughMutation( + nextPath, + childEvent, + preserveRemovedPath + ); + if (nextPath == null) { + return null; + } + } + return nextPath; + } + } +} + +function createMutationInvalidation(event: PathStoreEvent): { + canonicalChanged: boolean; + projectionChanged: boolean; + visibleCountDelta: number | null; +} { + return { + canonicalChanged: event.canonicalChanged, + projectionChanged: event.projectionChanged, + visibleCountDelta: event.visibleCountDelta, + }; +} + +function toTreesMutationSemanticEvent( + event: Extract +): PathStoreTreesMutationSemanticEvent { + switch (event.operation) { + case 'add': + return { + ...createMutationInvalidation(event), + operation: 'add', + path: event.path, + }; + case 'remove': + return { + ...createMutationInvalidation(event), + operation: 'remove', + path: event.path, + recursive: event.recursive, + }; + case 'move': + return { + ...createMutationInvalidation(event), + from: event.from, + operation: 'move', + to: event.to, + }; + } +} + +function toTreesBatchEvent( + event: Extract +): PathStoreTreesBatchEvent { + return { + ...createMutationInvalidation(event), + events: event.events + .filter( + ( + childEvent + ): childEvent is Extract< + PathStoreEvent, + { operation: 'add' | 'remove' | 'move' } + > => + childEvent.operation === 'add' || + childEvent.operation === 'remove' || + childEvent.operation === 'move' + ) + .map((childEvent) => toTreesMutationSemanticEvent(childEvent)), + operation: 'batch', + }; +} + +function toTreesMutationEvent( + event: PathStoreEvent +): PathStoreTreesMutationEvent | null { + switch (event.operation) { + case 'add': + case 'remove': + case 'move': + return toTreesMutationSemanticEvent(event); + case 'batch': + return toTreesBatchEvent(event); + default: + return null; + } +} + // Keeps logical focus on a visible row. When a focused descendant disappears, // this falls back to the nearest visible ancestor before defaulting to row 0. function resolveFocusedIndex( @@ -173,9 +340,13 @@ function createVisibleProjection( * Owns the live PathStore instance and exposes a small path-first boundary we * can evolve in later phases without leaking internal store IDs. */ -export class PathStoreTreesController { - readonly #baseOptions: Omit; +export class PathStoreTreesController implements PathStoreTreesMutationHandle { + readonly #baseOptions: Omit< + PathStoreTreesControllerOptions, + 'paths' | 'preparedInput' + >; readonly #listeners = new Set(); + readonly #mutationListeners: MutationListenerByType = new Map(); #ancestorPathsByIndex = new Map(); #focusedIndex = -1; #focusedPath: string | null = null; @@ -195,12 +366,9 @@ export class PathStoreTreesController { #visibleIndexByPathFactory: (() => Map) | null = null; public constructor(options: PathStoreTreesControllerOptions) { - const { paths, ...baseOptions } = options; + const { paths, preparedInput, ...baseOptions } = options; this.#baseOptions = baseOptions; - this.#store = new PathStore({ - ...baseOptions, - paths, - }); + this.#store = this.#createStore(paths, preparedInput); this.#rebuildVisibleProjection(null, false); this.#unsubscribe = this.#subscribe(); } @@ -551,14 +719,61 @@ export class PathStoreTreesController { } /** - * Replaces controller-owned paths through an explicit action so later phases - * can evolve the action model without exposing the raw PathStore instance. + * Applies one path-store-native file/directory addition through the shared + * mutation handle without exposing the raw store to tree consumers. */ - public replacePaths(paths: readonly string[]): void { - const nextStore = new PathStore({ - ...this.#baseOptions, - paths, - }); + public add(path: string): void { + this.#store.add(path); + } + + public remove(path: string, options: PathStoreRemoveOptions = {}): void { + this.#store.remove(path, options); + } + + public move( + fromPath: string, + toPath: string, + options: PathStoreMoveOptions = {} + ): void { + this.#store.move(fromPath, toPath, options); + } + + public batch(operations: readonly PathStoreOperation[]): void { + this.#store.batch(operations); + } + + public onMutation( + type: TType, + handler: (event: PathStoreTreesMutationEventForType) => void + ): () => void { + const key = type; + const typedHandler = handler as MutationListener; + let listenersForType = this.#mutationListeners.get(key); + if (listenersForType == null) { + listenersForType = new Set(); + this.#mutationListeners.set(key, listenersForType); + } + listenersForType.add(typedHandler); + return () => { + const registeredListeners = this.#mutationListeners.get(key); + registeredListeners?.delete(typedHandler); + if (registeredListeners?.size === 0) { + this.#mutationListeners.delete(key); + } + }; + } + + /** + * Rebuilds the controller around a new full path set. This is intentionally a + * coarse whole-tree reset path rather than a localized mutation fast path. + */ + public resetPaths( + paths: readonly string[], + options: PathStoreTreesResetOptions = {} + ): void { + const previousPathCount = this.#store.list().length; + const previousVisibleCount = this.#visibleCount; + const nextStore = this.#createStore(paths, options.preparedInput); const previousFocusedPath = this.#focusedPath; const previousSelectedPaths = this.getSelectedPaths(); const previousSelectionAnchorPath = this.#selectionAnchorPath; @@ -589,6 +804,15 @@ export class PathStoreTreesController { ); this.#unsubscribe = this.#subscribe(); this.#emit(); + this.#emitMutation({ + canonicalChanged: true, + operation: 'reset', + pathCountAfter: paths.length, + pathCountBefore: previousPathCount, + projectionChanged: true, + usedPreparedInput: options.preparedInput != null, + visibleCountDelta: this.#visibleCount - previousVisibleCount, + } satisfies PathStoreTreesResetEvent); } #ensureVisibleIndexByPath(): ReadonlyMap { @@ -765,12 +989,32 @@ export class PathStoreTreesController { }; } + #createStore( + paths: readonly string[], + preparedInput?: PathStorePreparedInput + ): PathStore { + return new PathStore({ + ...this.#baseOptions, + paths, + preparedInput, + }); + } + #emit(): void { for (const listener of this.#listeners) { listener(); } } + #emitMutation(event: PathStoreTreesMutationEvent): void { + this.#mutationListeners.get(event.operation)?.forEach((listener) => { + listener(event); + }); + this.#mutationListeners.get('*')?.forEach((listener) => { + listener(event); + }); + } + #expandDirectory(path: string): void { for (const ancestorPath of getAncestorDirectoryPaths(path)) { if (this.#store.isExpanded(ancestorPath)) { @@ -861,10 +1105,53 @@ export class PathStoreTreesController { this.#rebuildVisibleProjection(this.#focusedPath, true); } + #applyMutationState(event: PathStoreEvent): void { + const nextFocusedPath = remapPathThroughMutation( + this.#focusedPath, + event, + true + ); + const nextSelectedPaths = [...this.#selectedPaths] + .map((selectedPath) => remapPathThroughMutation(selectedPath, event)) + .filter((resolvedPath): resolvedPath is string => resolvedPath != null) + .map( + (resolvedPath) => this.#store.getPathInfo(resolvedPath)?.path ?? null + ) + .filter((resolvedPath): resolvedPath is string => resolvedPath != null); + const nextSelectionAnchorPath = remapPathThroughMutation( + this.#selectionAnchorPath, + event + ); + const canonicalAnchorPath = + nextSelectionAnchorPath == null + ? null + : (this.#store.getPathInfo(nextSelectionAnchorPath)?.path ?? null); + const uniqueNextSelectedPaths = [...new Set(nextSelectedPaths)]; + const selectionChanged = !arePathSetsEqual( + this.#selectedPaths, + uniqueNextSelectedPaths + ); + if (selectionChanged) { + this.#selectedPaths = new Set(uniqueNextSelectedPaths); + this.#selectionVersion += 1; + } + + this.#selectionAnchorPath = canonicalAnchorPath; + this.#focusedPath = + nextFocusedPath == null + ? null + : (this.#store.getPathInfo(nextFocusedPath)?.path ?? nextFocusedPath); + } + #subscribe(): () => void { - return this.#store.on('*', () => { + return this.#store.on('*', (event) => { + this.#applyMutationState(event); this.#rebuildVisibleProjection(this.#focusedPath, true); this.#emit(); + const mutationEvent = toTreesMutationEvent(event); + if (mutationEvent != null) { + this.#emitMutation(mutationEvent); + } }); } diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 72cb81922..f7acc6906 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -1,3 +1,8 @@ +import type { + PathStoreMoveOptions, + PathStoreOperation, + PathStoreRemoveOptions, +} from '@pierre/path-store'; import { h } from 'preact'; import { renderToString } from 'preact-render-to-string'; @@ -31,6 +36,10 @@ import type { PathStoreTreeRenderProps, PathStoreTreesCompositionOptions, PathStoreTreesItemHandle, + PathStoreTreesMutationEventForType, + PathStoreTreesMutationEventType, + PathStoreTreesMutationHandle, + PathStoreTreesResetOptions, PathStoreTreesRowDecorationRenderer, PathStoreTreesSelectionChangeListener, } from './types'; @@ -96,7 +105,7 @@ function getTopLevelSpriteSheets(shadowRoot: ShadowRoot): SVGElement[] { ); } -export class PathStoreFileTree { +export class PathStoreFileTree implements PathStoreTreesMutationHandle { static LoadedCustomComponent: boolean = FileTreeContainerLoaded; readonly #composition: PathStoreTreesCompositionOptions | undefined; @@ -180,6 +189,40 @@ export class PathStoreFileTree { return this.#controller.getSelectedPaths(); } + public add(path: string): void { + this.#controller.add(path); + } + + public batch(operations: readonly PathStoreOperation[]): void { + this.#controller.batch(operations); + } + + public move( + fromPath: string, + toPath: string, + options?: PathStoreMoveOptions + ): void { + this.#controller.move(fromPath, toPath, options); + } + + public onMutation( + type: TType, + handler: (event: PathStoreTreesMutationEventForType) => void + ): () => void { + return this.#controller.onMutation(type, handler); + } + + public remove(path: string, options?: PathStoreRemoveOptions): void { + this.#controller.remove(path, options); + } + + public resetPaths( + paths: readonly string[], + options?: PathStoreTreesResetOptions + ): void { + this.#controller.resetPaths(paths, options); + } + public setIcons(icons?: PathStoreFileTreeOptions['icons']): void { this.#icons = icons; diff --git a/packages/trees/src/path-store/index.ts b/packages/trees/src/path-store/index.ts index 3e692a8ea..fd9eb3ed7 100644 --- a/packages/trees/src/path-store/index.ts +++ b/packages/trees/src/path-store/index.ts @@ -1,6 +1,8 @@ export { PathStoreTreesController } from './controller'; export { PathStoreFileTree, preloadPathStoreFileTree } from './file-tree'; export type { + PathStoreTreesAddEvent, + PathStoreTreesBatchEvent, PathStoreTreesCompositionOptions, PathStoreTreesContextMenuItem, PathStoreTreesContextMenuOpenContext, @@ -11,7 +13,17 @@ export type { PathStoreTreesHeaderCompositionOptions, PathStoreTreeHydrationProps, PathStoreTreesItemHandle, + PathStoreTreesMoveEvent, + PathStoreTreesMutationEvent, + PathStoreTreesMutationEventForType, + PathStoreTreesMutationEventInvalidation, + PathStoreTreesMutationEventType, + PathStoreTreesMutationHandle, + PathStoreTreesMutationSemanticEvent, PathStoreTreeRenderProps, + PathStoreTreesRemoveEvent, + PathStoreTreesResetEvent, + PathStoreTreesResetOptions, PathStoreTreesControllerListener, PathStoreTreesControllerOptions, PathStoreTreesPublicId, diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index 07081c828..852f54b76 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -1,4 +1,10 @@ -import type { PathStoreConstructorOptions } from '@pierre/path-store'; +import type { + PathStoreConstructorOptions, + PathStoreMoveOptions, + PathStoreOperation, + PathStorePreparedInput, + PathStoreRemoveOptions, +} from '@pierre/path-store'; import type { FileTreeIcons, RemappedIcon } from '../iconConfig'; import type { ContextMenuAnchorRect } from '../types'; @@ -122,6 +128,83 @@ export interface PathStoreFileTreeSsrPayload { shadowHtml: string; } +export interface PathStoreTreesMutationEventInvalidation { + canonicalChanged: boolean; + projectionChanged: boolean; + visibleCountDelta: number | null; +} + +export interface PathStoreTreesAddEvent extends PathStoreTreesMutationEventInvalidation { + operation: 'add'; + path: PathStoreTreesPublicId; +} + +export interface PathStoreTreesRemoveEvent extends PathStoreTreesMutationEventInvalidation { + operation: 'remove'; + path: PathStoreTreesPublicId; + recursive: boolean; +} + +export interface PathStoreTreesMoveEvent extends PathStoreTreesMutationEventInvalidation { + from: PathStoreTreesPublicId; + operation: 'move'; + to: PathStoreTreesPublicId; +} + +export interface PathStoreTreesResetEvent extends PathStoreTreesMutationEventInvalidation { + operation: 'reset'; + pathCountAfter: number; + pathCountBefore: number; + usedPreparedInput: boolean; +} + +export type PathStoreTreesMutationSemanticEvent = + | PathStoreTreesAddEvent + | PathStoreTreesRemoveEvent + | PathStoreTreesMoveEvent + | PathStoreTreesResetEvent; + +export interface PathStoreTreesBatchEvent extends PathStoreTreesMutationEventInvalidation { + events: readonly PathStoreTreesMutationSemanticEvent[]; + operation: 'batch'; +} + +export type PathStoreTreesMutationEvent = + | PathStoreTreesMutationSemanticEvent + | PathStoreTreesBatchEvent; + +export type PathStoreTreesMutationEventType = + PathStoreTreesMutationEvent['operation']; + +export type PathStoreTreesMutationEventForType< + TType extends PathStoreTreesMutationEventType | '*', +> = TType extends '*' + ? PathStoreTreesMutationEvent + : Extract; + +export interface PathStoreTreesResetOptions { + preparedInput?: PathStorePreparedInput; +} + +export interface PathStoreTreesMutationHandle { + add(path: PathStoreTreesPublicId): void; + batch(operations: readonly PathStoreOperation[]): void; + move( + fromPath: PathStoreTreesPublicId, + toPath: PathStoreTreesPublicId, + options?: PathStoreMoveOptions + ): void; + onMutation( + type: TType, + handler: (event: PathStoreTreesMutationEventForType) => void + ): () => void; + remove(path: PathStoreTreesPublicId, options?: PathStoreRemoveOptions): void; + resetPaths( + paths: readonly PathStoreTreesPublicId[], + options?: PathStoreTreesResetOptions + ): void; +} + export type PathStoreTreesControllerListener = () => void; export type PathStoreTreesSelectionChangeListener = ( diff --git a/packages/trees/test/e2e/fixtures/path-store-mutations.html b/packages/trees/test/e2e/fixtures/path-store-mutations.html new file mode 100644 index 000000000..5bd863cbc --- /dev/null +++ b/packages/trees/test/e2e/fixtures/path-store-mutations.html @@ -0,0 +1,220 @@ + + + + + + path-store mutations fixture + + + +
+
+ + + + +
+
+
+
+ + + + diff --git a/packages/trees/test/e2e/path-store-mutations.pw.ts b/packages/trees/test/e2e/path-store-mutations.pw.ts new file mode 100644 index 000000000..6d6b18fab --- /dev/null +++ b/packages/trees/test/e2e/path-store-mutations.pw.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; + +declare global { + interface Window { + __pathStoreMutationsFixtureReady?: boolean; + } +} + +test.describe('path-store mutation proof', () => { + test('adds and batches mutation changes in the rendered tree', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/path-store-mutations.html'); + await page.waitForFunction( + () => window.__pathStoreMutationsFixtureReady === true + ); + + await page.locator('[data-path-store-mutation-action="add-file"]').click(); + await expect( + page.locator( + 'file-tree-container button[data-item-path="src/demo-added.ts"]' + ) + ).toBeVisible(); + await expect(page.locator('[data-path-store-mutations-log]')).toContainText( + 'mutation:add' + ); + + await page.locator('[data-path-store-mutation-action="batch"]').click(); + await expect( + page.locator( + 'file-tree-container button[data-item-path="src/batch-folder/notes.md"]' + ) + ).toBeVisible(); + await expect(page.locator('[data-path-store-mutations-log]')).toContainText( + 'mutation:batch' + ); + }); + + test('moves then resets the tree with the coarse reset path', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/path-store-mutations.html'); + await page.waitForFunction( + () => window.__pathStoreMutationsFixtureReady === true + ); + + await page.locator('[data-path-store-mutation-action="move"]').click(); + await expect( + page.locator('file-tree-container button[data-item-path="src/README.md"]') + ).toBeVisible(); + await expect( + page.locator('file-tree-container button[data-item-path="README.md"]') + ).toHaveCount(0); + + await page.locator('[data-path-store-mutation-action="reset"]').click(); + await expect( + page.locator('file-tree-container button[data-item-path="README.md"]') + ).toBeVisible(); + await expect( + page.locator('file-tree-container button[data-item-path="src/README.md"]') + ).toHaveCount(0); + await expect(page.locator('[data-path-store-mutations-log]')).toContainText( + 'mutation:reset' + ); + }); + + test('deletes a row through the reused context-menu shell', async ({ + page, + }) => { + await page.goto('/test/e2e/fixtures/path-store-mutations.html'); + await page.waitForFunction( + () => window.__pathStoreMutationsFixtureReady === true + ); + + const indexRow = page.locator( + 'file-tree-container button[data-item-path="src/index.ts"]' + ); + await indexRow.click(); + await indexRow.click({ button: 'right' }); + + await expect(page.locator('[data-test-context-menu="true"]')).toBeVisible(); + await page.locator('[data-test-menu-delete="src/index.ts"]').click(); + + await expect( + page.locator('file-tree-container button[data-item-path="src/index.ts"]') + ).toHaveCount(0); + await expect(page.locator('[data-test-context-menu="true"]')).toHaveCount( + 0 + ); + await expect(page.locator('[data-path-store-mutations-log]')).toContainText( + 'mutation:remove' + ); + }); +}); diff --git a/packages/trees/test/path-store-composition-surfaces.test.ts b/packages/trees/test/path-store-composition-surfaces.test.ts index cde7df237..6c408154d 100644 --- a/packages/trees/test/path-store-composition-surfaces.test.ts +++ b/packages/trees/test/path-store-composition-surfaces.test.ts @@ -893,7 +893,7 @@ describe('path-store composition surfaces', () => { paths: ['src/index.ts', 'src/other.ts'], }); - controller.replacePaths(['src/other.ts']); + controller.resetPaths(['src/other.ts']); expect(controller.resolveNearestVisiblePath('src/index.ts')).toBe('src/'); expect(controller.focusNearestPath('src/index.ts')).toBe('src/'); diff --git a/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts new file mode 100644 index 000000000..681906aba --- /dev/null +++ b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, test } from 'bun:test'; +// @ts-expect-error -- no @types/jsdom; only used in tests +import { JSDOM } from 'jsdom'; + +function installDom() { + const dom = new JSDOM('', { + url: 'http://localhost', + }); + const originalValues = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Event: Reflect.get(globalThis, 'Event'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + MutationObserver: Reflect.get(globalThis, 'MutationObserver'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + ShadowRoot: Reflect.get(globalThis, 'ShadowRoot'), + window: Reflect.get(globalThis, 'window'), + }; + + class MockStyleSheet { + replaceSync(_value: string): void {} + } + + class MockResizeObserver { + observe(_target: Element): void {} + disconnect(): void {} + } + + Object.assign(globalThis, { + CSSStyleSheet: MockStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Event: dom.window.Event, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + MutationObserver: dom.window.MutationObserver, + navigator: dom.window.navigator, + Node: dom.window.Node, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + ShadowRoot: dom.window.ShadowRoot, + window: dom.window, + }); + + return { + cleanup() { + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + dom.window.close(); + }, + dom, + }; +} + +async function flushDom(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function getItemButton( + shadowRoot: ShadowRoot | null | undefined, + dom: JSDOM, + path: string +): HTMLButtonElement { + const button = shadowRoot?.querySelector(`[data-item-path="${path}"]`); + if (!(button instanceof dom.window.HTMLButtonElement)) { + throw new Error(`missing button for ${path}`); + } + + return button as HTMLButtonElement; +} + +describe('path-store dynamic files / mutation API', () => { + test('controller emits add, move, batch, and reset mutation events', async () => { + const { PathStore } = await import('@pierre/path-store'); + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + }); + const events: Array<{ + events?: readonly { operation: string }[]; + operation: string; + }> = []; + const unsubscribe = controller.onMutation('*', (event) => { + events.push(event); + }); + + controller.add('src/utils.ts'); + controller.move('src/utils.ts', 'src/helpers.ts'); + controller.batch([ + { path: 'src/lib/', type: 'add' }, + { path: 'src/lib/theme.ts', type: 'add' }, + ]); + controller.resetPaths(['README.md'], { + preparedInput: PathStore.prepareInput(['README.md']), + }); + + expect(events.map((event) => event.operation)).toEqual([ + 'add', + 'move', + 'batch', + 'reset', + ]); + expect(events[2]).toMatchObject({ operation: 'batch' }); + expect(events[2]?.events?.map((event) => event.operation)).toEqual([ + 'add', + 'add', + ]); + expect(events[3]).toMatchObject({ + operation: 'reset', + pathCountAfter: 1, + usedPreparedInput: true, + }); + + unsubscribe(); + controller.destroy(); + }); + + test('controller keeps focus and selection aligned to moved paths', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['docs/readme.md', 'src/index.ts'], + }); + + controller.focusPath('docs/readme.md'); + controller.selectOnlyPath('docs/readme.md'); + controller.move('docs/readme.md', 'src/readme.md'); + + expect(controller.getFocusedPath()).toBe('src/readme.md'); + expect(controller.getSelectedPaths()).toEqual(['src/readme.md']); + + controller.remove('src/readme.md'); + + expect(controller.getSelectedPaths()).toEqual([]); + expect(controller.getFocusedPath()).toBe('src/'); + + controller.destroy(); + }); + + test('file-tree delegates the shared mutation handle and rerenders after resetPaths', async () => { + const { PathStore } = await import('@pierre/path-store'); + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + const events: string[] = []; + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 140, + }); + const unsubscribe = fileTree.onMutation('*', (event) => { + events.push(event.operation); + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + fileTree.add('src/utils.ts'); + await flushDom(); + + const shadowRootAfterAdd = fileTree.getFileTreeContainer()?.shadowRoot; + expect( + getItemButton(shadowRootAfterAdd, dom, 'src/utils.ts') + ).not.toBeNull(); + + fileTree.resetPaths(['README.md'], { + preparedInput: PathStore.prepareInput(['README.md']), + }); + await flushDom(); + + const shadowRootAfterReset = fileTree.getFileTreeContainer()?.shadowRoot; + expect( + getItemButton(shadowRootAfterReset, dom, 'README.md') + ).not.toBeNull(); + expect( + shadowRootAfterReset?.querySelector('[data-item-path="src/index.ts"]') + ).toBeNull(); + expect(events).toEqual(['add', 'reset']); + + unsubscribe(); + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('context-menu delete proof removes the item and restores focus coherently', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + let fileTree: InstanceType | null = null; + + fileTree = new PathStoreFileTree({ + composition: { + contextMenu: { + enabled: true, + render: (item, context): HTMLElement => { + const menu = dom.window.document.createElement('div'); + const deleteButton = dom.window.document.createElement('button'); + deleteButton.textContent = 'Delete'; + deleteButton.addEventListener('click', () => { + fileTree?.remove( + item.path, + item.kind === 'directory' ? { recursive: true } : undefined + ); + context.close(); + }); + menu.append(deleteButton); + return menu as unknown as HTMLElement; + }, + }, + }, + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const host = fileTree.getFileTreeContainer(); + const shadowRoot = host?.shadowRoot; + const readmeButton = getItemButton(shadowRoot, dom, 'README.md'); + readmeButton.focus(); + readmeButton.dispatchEvent( + new dom.window.MouseEvent('contextmenu', { + bubbles: true, + clientX: 24, + clientY: 36, + }) + ); + await flushDom(); + + const deleteButton = host?.querySelector('[slot="context-menu"] button'); + if (!(deleteButton instanceof dom.window.HTMLButtonElement)) { + throw new Error('expected slotted delete button'); + } + const menuDeleteButton = deleteButton as HTMLButtonElement; + menuDeleteButton.click(); + await flushDom(); + + expect( + shadowRoot?.querySelector('[data-item-path="README.md"]') + ).toBeNull(); + expect(host?.querySelector('[slot="context-menu"]')).toBeNull(); + expect( + shadowRoot?.querySelector( + 'button[data-type="item"][data-item-focused="true"]' + ) + ).not.toBeNull(); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/trees/test/path-store-render-scroll.test.ts b/packages/trees/test/path-store-render-scroll.test.ts index 80a87272e..9d0113f80 100644 --- a/packages/trees/test/path-store-render-scroll.test.ts +++ b/packages/trees/test/path-store-render-scroll.test.ts @@ -266,7 +266,7 @@ describe('path-store render + scroll', () => { controller.destroy(); }); - test('replacePaths prunes stale selections and resets a hidden range anchor', async () => { + test('resetPaths prunes stale selections and resets a hidden range anchor', async () => { const { PathStoreTreesController } = await import('../src/path-store'); const controller = new PathStoreTreesController({ @@ -279,7 +279,7 @@ describe('path-store render + scroll', () => { controller.selectPathRange('c.ts', false); expect(controller.getSelectedPaths()).toEqual(['a.ts', 'b.ts', 'c.ts']); - controller.replacePaths(['b.ts', 'd.ts']); + controller.resetPaths(['b.ts', 'd.ts']); expect(controller.getSelectedPaths()).toEqual(['b.ts']); controller.selectPathRange('d.ts', false); @@ -288,7 +288,7 @@ describe('path-store render + scroll', () => { controller.destroy(); }); - test('replacePaths canonicalizes selected paths when a file becomes a directory', async () => { + test('resetPaths canonicalizes selected paths when a file becomes a directory', async () => { const { PathStoreTreesController } = await import('../src/path-store'); // Start with "src/foo" as a plain file. @@ -303,9 +303,9 @@ describe('path-store render + scroll', () => { // After a refresh "src/foo" is now a directory ("src/foo/") with a child. // The old selected path "src/foo" resolves to the new canonical "src/foo/" - // via the trailing-slash fallback — replacePaths must store the resolved + // via the trailing-slash fallback — resetPaths must store the resolved // canonical form so that visible-row selection checks match. - controller.replacePaths(['src/foo/bar.ts']); + controller.resetPaths(['src/foo/bar.ts']); expect(controller.getSelectedPaths()).toEqual(['src/foo/']); expect(controller.getItem('src/foo/')?.isSelected()).toBe(true); @@ -1812,7 +1812,7 @@ describe('path-store render + scroll', () => { } }); - test('replacePaths preserves focus on surviving paths and resets focus when focused path is removed', async () => { + test('resetPaths preserves focus on surviving paths and resets focus when focused path is removed', async () => { const { PathStoreTreesController } = await import('../src/path-store'); const controller = new PathStoreTreesController({ @@ -1825,22 +1825,22 @@ describe('path-store render + scroll', () => { expect(controller.getFocusedPath()).toBe('b.ts'); // Replace paths keeping b.ts — focus should survive - controller.replacePaths(['a.ts', 'b.ts', 'd.ts']); + controller.resetPaths(['a.ts', 'b.ts', 'd.ts']); expect(controller.getFocusedPath()).toBe('b.ts'); // Replace paths removing b.ts — focus should fall back - controller.replacePaths(['a.ts', 'd.ts']); + controller.resetPaths(['a.ts', 'd.ts']); expect(controller.getFocusedPath()).not.toBe('b.ts'); expect(controller.getFocusedPath()).not.toBeNull(); // Replace with empty — focus should be null - controller.replacePaths([]); + controller.resetPaths([]); expect(controller.getFocusedPath()).toBeNull(); controller.destroy(); }); - test('controller subscribe fires when replacePaths prunes selected items', async () => { + test('controller subscribe fires when resetPaths prunes selected items', async () => { const { PathStoreTreesController } = await import('../src/path-store'); const controller = new PathStoreTreesController({ @@ -1855,7 +1855,7 @@ describe('path-store render + scroll', () => { const versionBeforeReplace = controller.getSelectionVersion(); // Remove b.ts — selection should prune it - controller.replacePaths(['a.ts', 'c.ts']); + controller.resetPaths(['a.ts', 'c.ts']); expect(controller.getSelectedPaths()).toEqual(['a.ts', 'c.ts']); expect(controller.getSelectionVersion()).toBeGreaterThan( versionBeforeReplace @@ -1863,7 +1863,7 @@ describe('path-store render + scroll', () => { // Replace with all new paths — selection fully pruned const versionBeforeFullPrune = controller.getSelectionVersion(); - controller.replacePaths(['x.ts', 'y.ts']); + controller.resetPaths(['x.ts', 'y.ts']); expect(controller.getSelectedPaths()).toEqual([]); expect(controller.getSelectionVersion()).toBeGreaterThan( versionBeforeFullPrune @@ -1872,7 +1872,7 @@ describe('path-store render + scroll', () => { // Replace that doesn't affect selection — version stays the same controller.selectOnlyPath('x.ts'); const versionBeforeNoOp = controller.getSelectionVersion(); - controller.replacePaths(['x.ts', 'z.ts']); + controller.resetPaths(['x.ts', 'z.ts']); expect(controller.getSelectedPaths()).toEqual(['x.ts']); expect(controller.getSelectionVersion()).toBe(versionBeforeNoOp); From 70ad2aafc976523ee1d49f4f9a1283849caeb1dc Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 14 Apr 2026 16:59:24 -0500 Subject: [PATCH 2/4] mostly working mutation baseline --- .../PathStorePoweredRenderDemoClient.tsx | 344 +++++++++++++----- packages/trees/src/path-store/view.tsx | 14 +- 2 files changed, 263 insertions(+), 95 deletions(-) diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 58cb7f879..08810c499 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -8,12 +8,21 @@ import { type PathStoreTreesMutationEvent, } from '@pierre/trees/path-store'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createRoot, type Root as ReactDomRoot } from 'react-dom/client'; import { ExampleCard } from '../_components/ExampleCard'; import { StateLog, useStateLog } from '../_components/StateLog'; import { pathStoreCapabilityMatrix } from './capabilityMatrix'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; import { PATH_STORE_CUSTOM_ICONS } from './pathStoreDemoIcons'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; interface SharedDemoOptions extends Omit< PathStoreFileTreeOptions, @@ -30,8 +39,6 @@ type PathStoreMutationOperation = | { from: string; to: string; type: 'move' }; interface PathStoreMutationDemoTargets { - addFilePath: string; - addFolderPath: string; batchOperations: readonly PathStoreMutationOperation[]; moveFromPath: string | null; moveToPath: string | null; @@ -97,7 +104,8 @@ function renamePathSameParent(path: string, nextBasename: string): string { // Derives deterministic proof paths from the current workload instead of hardcoding one repo shape. function createMutationDemoTargets( - paths: readonly string[] + paths: readonly string[], + initialExpandedPaths: readonly string[] | undefined ): PathStoreMutationDemoTargets { const existingPaths = new Set(paths); const directoryPaths = new Set(); @@ -113,17 +121,9 @@ function createMutationDemoTargets( } const sortedDirectoryPaths = [...directoryPaths].sort(); - const firstDirectoryPath = sortedDirectoryPaths[0] ?? ''; + const firstDirectoryPath = + initialExpandedPaths?.toSorted()[0] ?? sortedDirectoryPaths[0] ?? ''; const filePaths = paths.filter((path) => !path.endsWith('/')); - const addFilePath = getUniquePath( - `${firstDirectoryPath}phase-6-demo-file.ts`, - existingPaths - ); - const addFolderPath = getUniquePath( - `${firstDirectoryPath}phase-6-demo-folder/`, - existingPaths - ); - let moveFromPath: string | null = null; let moveToPath: string | null = null; for (const sourcePath of filePaths) { @@ -158,14 +158,55 @@ function createMutationDemoTargets( } return { - addFilePath, - addFolderPath, batchOperations, moveFromPath, moveToPath, }; } +function getFirstVisibleDirectoryPath(tree: PathStoreFileTree): string { + const firstVisiblePath = + tree + .getFileTreeContainer() + ?.shadowRoot?.querySelector('button[data-type="item"]') + ?.dataset.itemPath ?? ''; + if (firstVisiblePath.endsWith('/')) { + return firstVisiblePath; + } + + return getParentPath(firstVisiblePath); +} + +function getFirstVisibleFileParentPath(tree: PathStoreFileTree): string { + const visibleButtons = + tree + .getFileTreeContainer() + ?.shadowRoot?.querySelectorAll( + 'button[data-type="item"]' + ) ?? []; + for (const button of visibleButtons) { + const itemPath = button.dataset.itemPath; + if (itemPath != null && itemPath.endsWith('/') === false) { + return getParentPath(itemPath); + } + } + + return getFirstVisibleDirectoryPath(tree); +} + +function getAvailableMutationPath( + tree: PathStoreFileTree, + basePath: string +): string { + let candidatePath = basePath; + let suffix = 1; + while (tree.getItem(candidatePath) != null) { + candidatePath = getSuffixedPath(basePath, suffix); + suffix += 1; + } + return candidatePath; +} + function formatMutationEvent(event: PathStoreTreesMutationEvent): string { switch (event.operation) { case 'add': @@ -181,6 +222,131 @@ function formatMutationEvent(event: PathStoreTreesMutationEvent): string { } } +function PathStoreMutationContextMenu({ + item, + context, + onDelete, + onRename, +}: { + item: PathStoreTreesContextMenuItem; + context: Pick; + onDelete: () => void; + onRename: () => void; +}) { + const itemType = item.kind === 'directory' ? 'Folder' : 'File'; + + return ( + !open && context.close()} + > + +
) : null}
- {row.hasChildren ? ( + {row.kind === 'directory' ? ( ) : ( @@ -954,6 +958,10 @@ export function PathStoreTreesView({ return; } + if (isEventInContextMenu(event)) { + return; + } + if (contextMenuAnchorRef.current?.contains(target) === true) { return; } From 229c7c7a9a752ec1358ee95d847c32c731f32d7d Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 14 Apr 2026 17:17:37 -0500 Subject: [PATCH 3/4] review fixes --- packages/trees/src/path-store/controller.ts | 47 ++- ...h-store-dynamic-files-mutation-api.test.ts | 283 ++++++++++++++++++ 2 files changed, 320 insertions(+), 10 deletions(-) diff --git a/packages/trees/src/path-store/controller.ts b/packages/trees/src/path-store/controller.ts index 45af94153..44fb0c0b7 100644 --- a/packages/trees/src/path-store/controller.ts +++ b/packages/trees/src/path-store/controller.ts @@ -44,6 +44,20 @@ type MutationListenerByType = Map< Set >; +function isPathMutationEvent( + event: PathStoreEvent +): event is Extract< + PathStoreEvent, + { operation: 'add' | 'remove' | 'move' | 'batch' } +> { + return ( + event.operation === 'add' || + event.operation === 'remove' || + event.operation === 'move' || + event.operation === 'batch' + ); +} + // Initial render only mounts a tiny viewport slice, so controller startup can // cap its first projection build and defer the full 494k-row metadata walk // until the user actually navigates outside that initial window. @@ -122,9 +136,13 @@ function remapMovedPath( return toPath; } - return fromPath.endsWith('/') && path.startsWith(fromPath) - ? `${toPath}${path.slice(fromPath.length)}` - : path; + const descendantPrefix = fromPath.endsWith('/') ? fromPath : `${fromPath}/`; + if (!path.startsWith(descendantPrefix)) { + return path; + } + + const targetPrefix = toPath.endsWith('/') ? toPath : `${toPath}/`; + return `${targetPrefix}${path.slice(descendantPrefix.length)}`; } // Determines whether a tracked public path disappeared because a remove event @@ -376,7 +394,9 @@ export class PathStoreTreesController implements PathStoreTreesMutationHandle { public destroy(): void { this.#unsubscribe?.(); this.#unsubscribe = null; + this.#mutationListeners.clear(); this.#listeners.clear(); + this.#itemHandles.clear(); } public focusFirstItem(): void { @@ -1105,7 +1125,12 @@ export class PathStoreTreesController implements PathStoreTreesMutationHandle { this.#rebuildVisibleProjection(this.#focusedPath, true); } - #applyMutationState(event: PathStoreEvent): void { + #applyMutationState( + event: Extract< + PathStoreEvent, + { operation: 'add' | 'remove' | 'move' | 'batch' } + > + ): string | null { const nextFocusedPath = remapPathThroughMutation( this.#focusedPath, event, @@ -1137,16 +1162,18 @@ export class PathStoreTreesController implements PathStoreTreesMutationHandle { } this.#selectionAnchorPath = canonicalAnchorPath; - this.#focusedPath = - nextFocusedPath == null - ? null - : (this.#store.getPathInfo(nextFocusedPath)?.path ?? nextFocusedPath); + return nextFocusedPath; } #subscribe(): () => void { return this.#store.on('*', (event) => { - this.#applyMutationState(event); - this.#rebuildVisibleProjection(this.#focusedPath, true); + const focusPathCandidate = isPathMutationEvent(event) + ? this.#applyMutationState(event) + : this.#focusedPath; + if (event.canonicalChanged) { + this.#itemHandles.clear(); + } + this.#rebuildVisibleProjection(focusPathCandidate, true); this.#emit(); const mutationEvent = toTreesMutationEvent(event); if (mutationEvent != null) { diff --git a/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts index 681906aba..a566fb33f 100644 --- a/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts +++ b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts @@ -127,6 +127,7 @@ describe('path-store dynamic files / mutation API', () => { ]); expect(events[3]).toMatchObject({ operation: 'reset', + pathCountBefore: 4, pathCountAfter: 1, usedPreparedInput: true, }); @@ -135,6 +136,66 @@ describe('path-store dynamic files / mutation API', () => { controller.destroy(); }); + test('typed onMutation listeners stay filtered while subscribe still tracks non-mutation rerenders', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + }); + const subscribeNotifications: number[] = []; + const addEvents: string[] = []; + const removeEvents: Array<{ path: string; recursive: boolean }> = []; + + const unsubscribeRerender = controller.subscribe(() => { + subscribeNotifications.push(controller.getVisibleCount()); + }); + const unsubscribeAdd = controller.onMutation('add', (event) => { + addEvents.push(event.path); + }); + const unsubscribeRemove = controller.onMutation('remove', (event) => { + removeEvents.push({ + path: event.path, + recursive: event.recursive, + }); + }); + + const subscribeCountBeforeCollapse = subscribeNotifications.length; + const srcDirectory = controller.getItem('src/'); + if ( + srcDirectory == null || + srcDirectory.isDirectory() !== true || + !('collapse' in srcDirectory) + ) { + throw new Error('expected src/ directory handle'); + } + srcDirectory.collapse(); + + expect(subscribeNotifications.length).toBeGreaterThan( + subscribeCountBeforeCollapse + ); + expect(addEvents).toEqual([]); + expect(removeEvents).toEqual([]); + + controller.add('src/utils.ts'); + controller.remove('README.md'); + + expect(addEvents).toEqual(['src/utils.ts']); + expect(removeEvents).toEqual([ + { + path: 'README.md', + recursive: false, + }, + ]); + + unsubscribeRemove(); + unsubscribeAdd(); + unsubscribeRerender(); + controller.destroy(); + }); + test('controller keeps focus and selection aligned to moved paths', async () => { const { PathStoreTreesController } = await import('../src/path-store/controller'); @@ -160,6 +221,104 @@ describe('path-store dynamic files / mutation API', () => { controller.destroy(); }); + test('directory moves remap focused selections and range anchors', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/utils.ts'], + }); + + controller.focusPath('src/index.ts'); + controller.selectOnlyPath('src/index.ts'); + controller.move('src/', 'lib/'); + + expect(controller.getFocusedPath()).toBe('lib/index.ts'); + expect(controller.getSelectedPaths()).toEqual(['lib/index.ts']); + + controller.selectPathRange('lib/utils.ts', false); + + expect(controller.getSelectedPaths()).toEqual([ + 'lib/index.ts', + 'lib/utils.ts', + ]); + + controller.destroy(); + }); + + test('directory lookup-path moves still remap focused descendants', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/utils.ts'], + }); + + controller.focusPath('src/index.ts'); + controller.selectOnlyPath('src/index.ts'); + controller.selectPath('src/utils.ts'); + controller.move('src', 'lib/'); + + expect(controller.getFocusedPath()).toBe('lib/index.ts'); + expect(controller.getSelectedPaths()).toEqual([ + 'lib/index.ts', + 'lib/utils.ts', + ]); + + controller.destroy(); + }); + + test('recursive directory removal falls focus back to the nearest surviving row', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/utils.ts'], + }); + + controller.focusPath('src/index.ts'); + controller.remove('src/', { recursive: true }); + + expect(controller.getFocusedPath()).toBe('README.md'); + expect(controller.getItem('src/')).toBeNull(); + + controller.destroy(); + }); + + test('batch supports mixed add, move, and remove operations', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/old.ts'], + }); + const batchEvents: Array = []; + const unsubscribe = controller.onMutation('batch', (event) => { + batchEvents.push(event.events.map((entry) => entry.operation)); + }); + + controller.batch([ + { path: 'src/new.ts', type: 'add' }, + { from: 'src/new.ts', to: 'src/renamed.ts', type: 'move' }, + { path: 'src/old.ts', type: 'remove' }, + ]); + + expect(batchEvents).toEqual([['add', 'move', 'remove']]); + expect(controller.getItem('src/old.ts')).toBeNull(); + expect(controller.getItem('src/renamed.ts')).not.toBeNull(); + + unsubscribe(); + controller.destroy(); + }); + test('file-tree delegates the shared mutation handle and rerenders after resetPaths', async () => { const { PathStore } = await import('@pierre/path-store'); const { cleanup, dom } = installDom(); @@ -211,6 +370,130 @@ describe('path-store dynamic files / mutation API', () => { } }); + test('empty directories render as folders even before they gain children', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['docs/'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + const docsButton = getItemButton(shadowRoot, dom, 'docs/'); + + expect(docsButton.dataset.itemType).toBe('folder'); + expect(docsButton.getAttribute('aria-expanded')).toBe('true'); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('getItem returns fresh handles after move and remove mutations', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'lib/', 'src/foo.ts'], + }); + + const movedHandleBefore = controller.getItem('src/foo.ts'); + expect(movedHandleBefore).not.toBeNull(); + + controller.move('src/foo.ts', 'lib/foo.ts'); + + expect(controller.getItem('src/foo.ts')).toBeNull(); + const movedHandleAfter = controller.getItem('lib/foo.ts'); + expect(movedHandleAfter).not.toBeNull(); + expect(movedHandleAfter?.getPath()).toBe('lib/foo.ts'); + expect(movedHandleAfter?.isDirectory()).toBe(false); + + controller.remove('lib/foo.ts'); + + expect(controller.getItem('lib/foo.ts')).toBeNull(); + + controller.destroy(); + }); + + test('adding under a collapsed directory keeps the directory collapsed until re-expanded', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + }); + + const srcDirectory = controller.getItem('src/'); + if ( + srcDirectory == null || + srcDirectory.isDirectory() !== true || + !('collapse' in srcDirectory) || + !('expand' in srcDirectory) + ) { + throw new Error('expected src/ directory handle'); + } + + srcDirectory.collapse(); + controller.add('src/utils.ts'); + + expect( + controller.getVisibleRows(0, controller.getVisibleCount()) + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'src/utils.ts', + }), + ]) + ); + + srcDirectory.expand(); + + expect(controller.getVisibleRows(0, controller.getVisibleCount())).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'src/utils.ts', + }), + ]) + ); + + controller.destroy(); + }); + + test('shared mutation handle intentionally omits raw-store callback batching', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md'], + }); + + const assertBatchCallbackIsRejected = (): void => { + // @ts-expect-error raw PathStore callback batching is intentionally not exposed on the shared trees handle + controller.batch((store) => { + store.add('src/index.ts'); + }); + }; + void assertBatchCallbackIsRejected; + + controller.destroy(); + }); + test('context-menu delete proof removes the item and restores focus coherently', async () => { const { cleanup, dom } = installDom(); try { From 15e17f7c1b07409a195e8ebe8e9db622aec41850 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Tue, 14 Apr 2026 17:42:02 -0500 Subject: [PATCH 4/4] fix inconsistent trailing slash handling --- packages/trees/src/path-store/controller.ts | 12 +++++++---- ...h-store-dynamic-files-mutation-api.test.ts | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/trees/src/path-store/controller.ts b/packages/trees/src/path-store/controller.ts index 44fb0c0b7..3563b519b 100644 --- a/packages/trees/src/path-store/controller.ts +++ b/packages/trees/src/path-store/controller.ts @@ -148,10 +148,14 @@ function remapMovedPath( // Determines whether a tracked public path disappeared because a remove event // deleted that exact item or a whole removed directory subtree. function isPathRemoved(path: string, removedPath: string): boolean { - return ( - path === removedPath || - (removedPath.endsWith('/') && path.startsWith(removedPath)) - ); + if (path === removedPath) { + return true; + } + + const descendantPrefix = removedPath.endsWith('/') + ? removedPath + : `${removedPath}/`; + return path.startsWith(descendantPrefix); } // Rewrites focus/selection paths through mutation events so controller state diff --git a/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts index a566fb33f..192ad993a 100644 --- a/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts +++ b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts @@ -291,6 +291,27 @@ describe('path-store dynamic files / mutation API', () => { controller.destroy(); }); + test('directory lookup-path removals clear descendant selections and focused paths', async () => { + const { PathStoreTreesController } = + await import('../src/path-store/controller'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/utils.ts'], + }); + + controller.focusPath('src/index.ts'); + controller.selectOnlyPath('src/index.ts'); + controller.selectPath('src/utils.ts'); + controller.remove('src', { recursive: true }); + + expect(controller.getSelectedPaths()).toEqual([]); + expect(controller.getFocusedPath()).toBe('README.md'); + + controller.destroy(); + }); + test('batch supports mixed add, move, and remove operations', async () => { const { PathStoreTreesController } = await import('../src/path-store/controller');