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..08810c499 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -3,19 +3,26 @@ 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 { createRoot, 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'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; interface SharedDemoOptions extends Omit< PathStoreFileTreeOptions, @@ -27,6 +34,319 @@ interface PathStorePoweredRenderDemoClientProps { sharedOptions: SharedDemoOptions; } +type PathStoreMutationOperation = + | { path: string; type: 'add' } + | { from: string; to: string; type: 'move' }; + +interface PathStoreMutationDemoTargets { + 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[], + initialExpandedPaths: readonly string[] | undefined +): 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 = + initialExpandedPaths?.toSorted()[0] ?? sortedDirectoryPaths[0] ?? ''; + const filePaths = paths.filter((path) => !path.endsWith('/')); + 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 { + 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': + 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`; + } +} + +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()} + > + + + + + + +

'; + '
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..3563b519b 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,26 @@ interface PathStoreTreesVisibleProjection { visibleIndexByPathFactory: () => Map; } +type MutationListener = (event: PathStoreTreesMutationEvent) => void; +type MutationListenerByType = Map< + PathStoreTreesMutationEventType | '*', + 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. @@ -92,6 +125,162 @@ 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; + } + + 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 +// deleted that exact item or a whole removed directory subtree. +function isPathRemoved(path: string, removedPath: string): boolean { + 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 +// 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 +362,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 +388,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(); } @@ -208,7 +398,9 @@ export class PathStoreTreesController { public destroy(): void { this.#unsubscribe?.(); this.#unsubscribe = null; + this.#mutationListeners.clear(); this.#listeners.clear(); + this.#itemHandles.clear(); } public focusFirstItem(): void { @@ -551,14 +743,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 +828,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 +1013,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 +1129,60 @@ export class PathStoreTreesController { this.#rebuildVisibleProjection(this.#focusedPath, true); } + #applyMutationState( + event: Extract< + PathStoreEvent, + { operation: 'add' | 'remove' | 'move' | 'batch' } + > + ): string | null { + 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; + return nextFocusedPath; + } + #subscribe(): () => void { - return this.#store.on('*', () => { - this.#rebuildVisibleProjection(this.#focusedPath, true); + return this.#store.on('*', (event) => { + 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) { + 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/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index d1bf0e66b..3614aebf2 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -208,6 +208,10 @@ function isEventInContextMenu(event: Event): boolean { continue; } + if (entry.dataset.pathStoreContextMenuRoot === 'true') { + return true; + } + if (entry.dataset.type === 'context-menu-anchor') { return true; } @@ -351,8 +355,8 @@ function renderStyledRow( data-type="item" data-item-path={targetPath} data-item-parked={isParked ? 'true' : undefined} - data-item-type={row.hasChildren ? 'folder' : 'file'} - aria-expanded={row.hasChildren ? row.isExpanded : undefined} + data-item-type={row.kind === 'directory' ? 'folder' : 'file'} + aria-expanded={row.kind === 'directory' ? row.isExpanded : undefined} aria-label={getPathStoreTreesRowAriaLabel(row)} aria-level={row.level + 1} aria-haspopup={contextMenuEnabled ? 'menu' : undefined} @@ -416,7 +420,7 @@ function renderStyledRow(
) : 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; } 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..192ad993a --- /dev/null +++ b/packages/trees/test/path-store-dynamic-files-mutation-api.test.ts @@ -0,0 +1,591 @@ +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', + pathCountBefore: 4, + pathCountAfter: 1, + usedPreparedInput: true, + }); + + unsubscribe(); + 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'); + + 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('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('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'); + + 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(); + 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('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 { + 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);