diff --git a/.changeset/client-rename-removal-reconciliation.md b/.changeset/client-rename-removal-reconciliation.md new file mode 100644 index 00000000..dc011f41 --- /dev/null +++ b/.changeset/client-rename-removal-reconciliation.md @@ -0,0 +1,9 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Make client-side rename and removal reconciliation consistent across the file tree, editor tabs, and server-driven auth redirects. + +Renaming a document, folder, or asset now captures the existing editor snapshot before cleanup, clears stale providers and IndexedDB rows, and only then remaps tabs and navigation. Destination persistence is cleared only when a stale pooled document actually exists, so a destination provider that was already reopened by the server redirect cannot be torn down by a second local cleanup pass. Reusing a previous document name therefore starts from clean local state without duplicating old CRDT content, while renamed tabs retain their order and active target. + +Deleting content now closes the affected document, folder, and asset tabs through the same reconciliation controller before clearing deduplicated document persistence. Server-reported renames and removals use that controller too, keeping active documents, inactive tabs, URL hashes, and home redirects aligned regardless of which surface initiated the operation. diff --git a/packages/app/src/components/EditorTabs.dom.test.tsx b/packages/app/src/components/EditorTabs.dom.test.tsx index 914a37e4..6b2c195d 100644 --- a/packages/app/src/components/EditorTabs.dom.test.tsx +++ b/packages/app/src/components/EditorTabs.dom.test.tsx @@ -24,15 +24,13 @@ let toastErrors: string[] = []; const activateTab = vi.fn(() => {}); const activateNewTab = vi.fn(() => {}); -const closeAndClearForRename = vi.fn(() => Promise.resolve()); const closeNewTab = vi.fn(() => {}); const closeTab = vi.fn(() => {}); const closeTabs = vi.fn(() => {}); -const getPoolActiveDocName = vi.fn(() => null as string | null); const openNewTab = vi.fn(() => {}); const pinTab = vi.fn(() => {}); const reopenClosedTab = vi.fn(() => {}); -const remapTabsForRename = vi.fn(() => {}); +const reconcileLocalRename = vi.fn(() => Promise.resolve()); const reorderTabs = vi.fn(() => {}); const unpinTab = vi.fn(() => {}); @@ -186,11 +184,9 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget, activateNewTab, activateTab, - closeAndClearForRename, closeNewTab, closeTab, closeTabs, - getPoolActiveDocName, isNewTabActive, newTabIds, openNewTab, @@ -198,7 +194,7 @@ vi.doMock('@/editor/DocumentContext', () => ({ pinTab, pinnedTabIds, reopenClosedTab, - remapTabsForRename, + reconcileLocalRename, reorderTabs, unpinTab, visibleTabIds, @@ -261,23 +257,19 @@ function resetState() { for (const fn of [ activateTab, activateNewTab, - closeAndClearForRename, closeNewTab, closeTab, closeTabs, - getPoolActiveDocName, openNewTab, pinTab, reopenClosedTab, - remapTabsForRename, + reconcileLocalRename, reorderTabs, unpinTab, ]) { fn.mockClear(); } - closeAndClearForRename.mockImplementation(() => Promise.resolve()); - getPoolActiveDocName.mockImplementation(() => null); - remapTabsForRename.mockImplementation(() => {}); + reconcileLocalRename.mockImplementation(() => Promise.resolve()); Object.defineProperty(window, 'okDesktop', { configurable: true, value: undefined, @@ -621,12 +613,44 @@ describe('EditorTabs runtime behavior', () => { expect(globalThis.fetch).not.toHaveBeenCalled(); }); - test('rename post-commit reconciliation failures surface the refresh toast and skip navigation', async () => { + test('passes the successful response mapping to reconciliation before navigating', async () => { activeDocName = 'docs/team/readme'; activeTabId = 'docs/team/readme'; - remapTabsForRename.mockImplementation(() => { - throw new Error('idb clear failed'); + reconcileLocalRename.mockImplementation((input) => { + expect(input).toEqual({ + renamed: [{ fromDocName: 'docs/team/readme', toDocName: 'docs/team/renamed' }], + }); + expect(window.location.hash).toBe(''); + return Promise.resolve(); + }); + globalThis.fetch = vi.fn(() => + Promise.resolve( + new Response( + JSON.stringify({ + renamed: [{ fromDocName: 'docs/team/readme', toDocName: 'docs/team/renamed' }], + renamedAssets: [], + }), + { headers: { 'Content-Type': 'application/json' }, status: 200 }, + ), + ), + ) as never; + + await renderEditorTabs(); + fireEvent.doubleClick(tabButton('docs/team/readme.txt')); + const input = screen.getByTestId('editor-tab-rename-input') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'renamed.txt' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(reconcileLocalRename).toHaveBeenCalledTimes(1); + expect(window.location.hash).toBe('#/docs/team/renamed'); }); + }); + + test('rename post-commit reconciliation failures surface the refresh toast and skip navigation', async () => { + activeDocName = 'docs/team/readme'; + activeTabId = 'docs/team/readme'; + reconcileLocalRename.mockImplementation(() => Promise.reject(new Error('idb clear failed'))); globalThis.fetch = vi.fn(() => Promise.resolve( new Response( diff --git a/packages/app/src/components/EditorTabs.tsx b/packages/app/src/components/EditorTabs.tsx index d58a732a..6e386bef 100644 --- a/packages/app/src/components/EditorTabs.tsx +++ b/packages/app/src/components/EditorTabs.tsx @@ -32,7 +32,6 @@ import { buildRenamedNodePath, isValidNodeName, normalizeRenameValue, - planRenameCleanupCalls, remapActiveDocName, } from '@/components/file-tree-operations'; import { Button } from '@/components/ui/button'; @@ -51,7 +50,6 @@ import { } from '@/components/ui/input-group'; import { Kbd } from '@/components/ui/kbd'; import { useDocumentContext } from '@/editor/DocumentContext'; -import { captureRenameSnapshots } from '@/editor/editor-cache'; import { docTabId, filterClosableTabIds, @@ -451,12 +449,9 @@ export function EditorTabs() { activeTarget, activateTab, activateNewTab, - closeAndClearForRename, closeNewTab, closeTab, closeTabs, - getPoolActiveDocName, - poolHas, isNewTabActive, newTabIds, openNewTab, @@ -464,7 +459,7 @@ export function EditorTabs() { pinTab, pinnedTabIds, reopenClosedTab, - remapTabsForRename, + reconcileLocalRename, reorderTabs, unpinTab, visibleTabIds, @@ -630,20 +625,15 @@ export function EditorTabs() { // Split try/catch: server-side rename already committed // (`parsed.ok === true`). A failure inside the post-commit work - // (IDB clear via closeAndClearForRename, tab remap, event dispatch) + // (persistence cleanup, tab remap, event dispatch) // is a client-side reconciliation failure, NOT a network error. // Labeling it "Network error — please try again" would misdirect // the user toward a retry that POSTs against a now-nonexistent // source path and fails differently. The correct recovery is to // refresh and resync with disk truth. - captureRenameSnapshots(renamed); let reconcileOk = true; try { - // Same gate as FileTree.applyRenamedDocuments — rationale documented - // at `planRenameCleanupCalls` in file-tree-operations.ts. - const cleanupDocNames = planRenameCleanupCalls(renamed, getPoolActiveDocName(), poolHas); - await Promise.all(cleanupDocNames.map((name) => closeAndClearForRename(name))); - remapTabsForRename(renamed); + await reconcileLocalRename({ renamed }); emitDocumentsChanged(['files', 'backlinks', 'graph']); } catch (reconcileErr) { reconcileOk = false; @@ -664,8 +654,8 @@ export function EditorTabs() { commitInProgressRef.current = false; lastFailedValueRef.current = null; - // Skip navigation when reconciliation failed: remapTabsForRename never - // ran, so no tab is keyed to nextActiveDocName. Calling navigateToDoc + // Skip navigation when reconciliation failed: tab identities remain stale, + // so no tab is keyed to nextActiveDocName. Calling navigateToDoc // would silently open a new tab and contradict the "refresh to resync" // toast. Refresh recovers consistent state. if (reconcileOk && nextActiveDocName && nextActiveDocName !== currentActiveDocName) { diff --git a/packages/app/src/components/FileTree.create.dom.test.tsx b/packages/app/src/components/FileTree.create.dom.test.tsx index 155fc57d..1e32f456 100644 --- a/packages/app/src/components/FileTree.create.dom.test.tsx +++ b/packages/app/src/components/FileTree.create.dom.test.tsx @@ -55,8 +55,8 @@ const openTargetMock = vi.fn(() => {}); const notifySidebarFileSelectedMock = vi.fn(() => {}); const closeTabsMock = vi.fn(() => {}); const closeDocumentMock = vi.fn(() => {}); -const closeAndClearForRenameMock = vi.fn(async () => {}); -const remapTabsForRenameMock = vi.fn(() => {}); +const reconcileLocalRenameMock = vi.fn(async () => {}); +const reconcileLocalRemovalMock = vi.fn(async () => {}); const dispatchHandoffMock = vi.fn(async () => ({ ok: true as const })); const DOCUMENTS: FileEntry[] = [ @@ -261,15 +261,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' }, closeTabs: closeTabsMock, closeDocument: closeDocumentMock, - closeAndClearDocument: closeAndClearForRenameMock, - closeAndClearForDelete: closeAndClearForRenameMock, - closeAndClearForRename: closeAndClearForRenameMock, - getPoolActiveDocName: () => 'notes/source', - poolHas: () => false, isNewTabActive: false, openTarget: openTargetMock, prewarm: () => {}, - remapTabsForRename: remapTabsForRenameMock, + reconcileLocalRemoval: reconcileLocalRemovalMock, + reconcileLocalRename: reconcileLocalRenameMock, }), })); diff --git a/packages/app/src/components/FileTree.duplicate.dom.test.tsx b/packages/app/src/components/FileTree.duplicate.dom.test.tsx index 9765d369..afe1676a 100644 --- a/packages/app/src/components/FileTree.duplicate.dom.test.tsx +++ b/packages/app/src/components/FileTree.duplicate.dom.test.tsx @@ -67,8 +67,8 @@ const openTargetMock = vi.fn(() => {}); const notifySidebarFileSelectedMock = vi.fn(() => {}); const closeTabsMock = vi.fn(() => {}); const closeDocumentMock = vi.fn(() => {}); -const closeAndClearForRenameMock = vi.fn(async () => {}); -const remapTabsForRenameMock = vi.fn(() => {}); +const reconcileLocalRenameMock = vi.fn(async () => {}); +const reconcileLocalRemovalMock = vi.fn(async () => {}); const dispatchHandoffMock = vi.fn(async () => ({ ok: true as const })); const DOCUMENTS: FileEntry[] = [ @@ -307,15 +307,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' }, closeTabs: closeTabsMock, closeDocument: closeDocumentMock, - closeAndClearDocument: closeAndClearForRenameMock, - closeAndClearForDelete: closeAndClearForRenameMock, - closeAndClearForRename: closeAndClearForRenameMock, - getPoolActiveDocName: () => 'notes/source', - poolHas: () => true, isNewTabActive: false, openTarget: openTargetMock, prewarm: () => {}, - remapTabsForRename: remapTabsForRenameMock, + reconcileLocalRemoval: reconcileLocalRemovalMock, + reconcileLocalRename: reconcileLocalRenameMock, }), })); diff --git a/packages/app/src/components/FileTree.share.dom.test.tsx b/packages/app/src/components/FileTree.share.dom.test.tsx index b8fef3ff..65645a14 100644 --- a/packages/app/src/components/FileTree.share.dom.test.tsx +++ b/packages/app/src/components/FileTree.share.dom.test.tsx @@ -226,15 +226,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' }, closeTabs: () => {}, closeDocument: () => {}, - closeAndClearDocument: async () => {}, - closeAndClearForDelete: async () => {}, - closeAndClearForRename: async () => {}, - getPoolActiveDocName: () => 'notes/source', - poolHas: () => true, isNewTabActive: false, openTarget: () => {}, prewarm: () => {}, - remapTabsForRename: () => {}, + reconcileLocalRemoval: async () => {}, + reconcileLocalRename: async () => {}, }), })); diff --git a/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx b/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx index 72e155c6..497aaa90 100644 --- a/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx +++ b/packages/app/src/components/FileTree.showall-lazy.dom.test.tsx @@ -220,15 +220,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: null, closeTabs: vi.fn(() => {}), closeDocument: vi.fn(() => {}), - closeAndClearDocument: vi.fn(async () => {}), - closeAndClearForDelete: vi.fn(async () => {}), - closeAndClearForRename: vi.fn(async () => {}), - getPoolActiveDocName: () => null, - poolHas: () => false, isNewTabActive: false, openTarget: openTargetMock, prewarm: () => {}, - remapTabsForRename: vi.fn(() => {}), + reconcileLocalRemoval: vi.fn(async () => {}), + reconcileLocalRename: vi.fn(async () => {}), }), })); vi.doMock('@/components/PageListContext', () => ({ diff --git a/packages/app/src/components/FileTree.showall-lifecycle.dom.test.tsx b/packages/app/src/components/FileTree.showall-lifecycle.dom.test.tsx index a7b9c3ad..0952bcb4 100644 --- a/packages/app/src/components/FileTree.showall-lifecycle.dom.test.tsx +++ b/packages/app/src/components/FileTree.showall-lifecycle.dom.test.tsx @@ -194,15 +194,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: null, closeTabs: vi.fn(() => {}), closeDocument: vi.fn(() => {}), - closeAndClearDocument: vi.fn(async () => {}), - closeAndClearForDelete: vi.fn(async () => {}), - closeAndClearForRename: vi.fn(async () => {}), - getPoolActiveDocName: () => null, - poolHas: () => false, isNewTabActive: false, openTarget: vi.fn(() => {}), prewarm: () => {}, - remapTabsForRename: vi.fn(() => {}), + reconcileLocalRemoval: vi.fn(async () => {}), + reconcileLocalRename: vi.fn(async () => {}), }), })); vi.doMock('@/components/PageListContext', () => ({ diff --git a/packages/app/src/components/FileTree.showall-truncation.dom.test.tsx b/packages/app/src/components/FileTree.showall-truncation.dom.test.tsx index 958082de..4847c82e 100644 --- a/packages/app/src/components/FileTree.showall-truncation.dom.test.tsx +++ b/packages/app/src/components/FileTree.showall-truncation.dom.test.tsx @@ -170,15 +170,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: null, closeTabs: vi.fn(() => {}), closeDocument: vi.fn(() => {}), - closeAndClearDocument: vi.fn(async () => {}), - closeAndClearForDelete: vi.fn(async () => {}), - closeAndClearForRename: vi.fn(async () => {}), - getPoolActiveDocName: () => null, - poolHas: () => false, isNewTabActive: false, openTarget: vi.fn(() => {}), prewarm: () => {}, - remapTabsForRename: vi.fn(() => {}), + reconcileLocalRemoval: vi.fn(async () => {}), + reconcileLocalRename: vi.fn(async () => {}), }), })); vi.doMock('@/components/PageListContext', () => ({ diff --git a/packages/app/src/components/FileTree.superseded-refresh.dom.test.tsx b/packages/app/src/components/FileTree.superseded-refresh.dom.test.tsx index 7b8023ec..5213acee 100644 --- a/packages/app/src/components/FileTree.superseded-refresh.dom.test.tsx +++ b/packages/app/src/components/FileTree.superseded-refresh.dom.test.tsx @@ -231,15 +231,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: null, closeTabs: vi.fn(() => {}), closeDocument: vi.fn(() => {}), - closeAndClearDocument: vi.fn(async () => {}), - closeAndClearForDelete: vi.fn(async () => {}), - closeAndClearForRename: vi.fn(async () => {}), - getPoolActiveDocName: () => null, - poolHas: () => false, isNewTabActive: false, openTarget: vi.fn(() => {}), prewarm: () => {}, - remapTabsForRename: vi.fn(() => {}), + reconcileLocalRemoval: vi.fn(async () => {}), + reconcileLocalRename: vi.fn(async () => {}), }), })); vi.doMock('@/components/PageListContext', () => ({ diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index 34f0d139..e7b6efef 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -110,7 +110,6 @@ import { buildTrashAbsPath, canonicalizeAssetTargetForDelete, type FileTreeTarget, - planRenameCleanupCalls, type RenamedAssetMapping, type RenamedDocExtensionMapping, type RenamedDocMapping, @@ -192,7 +191,6 @@ import { Skeleton } from '@/components/ui/skeleton'; import { asDirectoryHandle, useSelectionMirror } from '@/components/use-selection-mirror'; import { getEditorForDoc } from '@/editor/active-editor'; import { useDocumentContext } from '@/editor/DocumentContext'; -import { captureRenameSnapshots } from '@/editor/editor-cache'; import { assetTabId, docTabId, folderTabId, remapPathForFolderRenames } from '@/editor/editor-tabs'; import { useConflicts } from '@/hooks/use-conflicts'; import { useFolderConfig } from '@/hooks/use-folder-config'; @@ -1170,13 +1168,11 @@ export function FileTree({ activeTarget, closeTabs, closeDocument, - closeAndClearForRename, - getPoolActiveDocName, - poolHas, isNewTabActive, openTarget, prewarm, - remapTabsForRename, + reconcileLocalRemoval, + reconcileLocalRename, } = useDocumentContext(); const { notifySidebarFileSelected } = useSidebar(); const { resolvedTheme } = useTheme(); @@ -2600,24 +2596,18 @@ export function FileTree({ remapPathForFolderRenames(currentActiveAssetPath, renamedFolders)) : null); - captureRenameSnapshots(renamed); - // Wipe IDB for ends that need it. `planRenameCleanupCalls` gates the - // `to` clear behind whether the server-push `onRenameRedirect` path has - // already done the close+clear+reopen. The full rationale (race shape, - // why the `from` clear stays) is documented at the helper's site in - // `file-tree-operations.ts`. - const cleanupDocNames = [ - ...planRenameCleanupCalls(renamed, getPoolActiveDocName(), poolHas), - ...docToAssetRenames.keys(), - ]; - await Promise.all(cleanupDocNames.map((docName) => closeAndClearForRename(docName))); + await reconcileLocalRename({ + renamed, + renamedFolders, + renamedAssets, + additionalRemovedDocNames: [...docToAssetRenames.keys()], + }); for (const entry of renamed) { addPage(entry.toDocName); } for (const entry of assetToDocRenames.values()) { addPage(entry); } - remapTabsForRename(renamed, renamedFolders, renamedAssets); let nextDocumentsForRename: FileEntry[] | null = null; setDocuments((current) => { @@ -3584,18 +3574,14 @@ export function FileTree({ ...tabsToClose.assetPaths, ...successfulTargets.filter((target) => target.kind === 'asset').map((target) => target.path), ]); - closeTabs( - [ + await reconcileLocalRemoval({ + tabIdsToClose: [ ...[...deleted].map((docName) => docTabId(docName)), ...[...deletedFolders].map((folderPath) => folderTabId(folderPath)), ...[...deletedAssets].map((assetPath) => assetTabId(assetPath)), ], - { force: true }, - ); - // Clear IDB for each deleted docName so a same-browser delete-then-recreate - // (or a sibling rename that lands on this docName) cannot resurrect content - // from stale IndexedDB rows. - await Promise.all([...deleted].map((docName) => closeAndClearForRename(docName))); + docNamesToClear: [...deleted], + }); for (const target of successfulTargets) { const treePath = diff --git a/packages/app/src/components/file-tree-operations.test.ts b/packages/app/src/components/file-tree-operations.test.ts index 1e398d81..79408971 100644 --- a/packages/app/src/components/file-tree-operations.test.ts +++ b/packages/app/src/components/file-tree-operations.test.ts @@ -9,7 +9,6 @@ import { type FileTreeTarget, isValidNodeName, normalizeRenameValue, - planRenameCleanupCalls, remapActiveDocName, } from './file-tree-operations'; import type { FileEntry } from './file-tree-utils'; @@ -312,94 +311,6 @@ describe('file-tree-operations', () => { ).toBe('README'); }); - describe('planRenameCleanupCalls', () => { - const poolHasAll = () => true; - - test('skips destination cleanup when redirect already reopened it', () => { - expect( - planRenameCleanupCalls( - [{ fromDocName: 'docs/notes', toDocName: 'docs/renamed' }], - 'docs/renamed', - poolHasAll, - ), - ).toEqual(['docs/notes']); - }); - - test('clears both ends when redirect has not run yet', () => { - expect( - planRenameCleanupCalls( - [{ fromDocName: 'docs/notes', toDocName: 'docs/renamed' }], - 'docs/notes', - poolHasAll, - ), - ).toEqual(['docs/notes', 'docs/renamed']); - }); - - test('clears both ends when the active doc is unrelated', () => { - expect( - planRenameCleanupCalls( - [{ fromDocName: 'docs/notes', toDocName: 'docs/renamed' }], - 'README', - poolHasAll, - ), - ).toEqual(['docs/notes', 'docs/renamed']); - }); - - test('clears both ends when active doc is unknown', () => { - expect( - planRenameCleanupCalls( - [{ fromDocName: 'docs/notes', toDocName: 'docs/renamed' }], - null, - poolHasAll, - ), - ).toEqual(['docs/notes', 'docs/renamed']); - }); - - test('applies redirect guard per rename entry', () => { - expect( - planRenameCleanupCalls( - [ - { fromDocName: 'docs/a', toDocName: 'archive/a' }, - { fromDocName: 'docs/b', toDocName: 'archive/b' }, - { fromDocName: 'docs/c', toDocName: 'archive/c' }, - ], - 'archive/a', - poolHasAll, - ), - ).toEqual(['docs/a', 'docs/b', 'archive/b', 'docs/c', 'archive/c']); - }); - - test('empty rename batch — empty result', () => { - expect(planRenameCleanupCalls([], null, poolHasAll)).toEqual([]); - expect(planRenameCleanupCalls([], 'anything', poolHasAll)).toEqual([]); - }); - - test('skips destination cleanup when the pool never opened it', () => { - expect( - planRenameCleanupCalls( - [{ fromDocName: 'Untitled', toDocName: 'dhx' }], - 'Untitled', - () => false, - ), - ).toEqual(['Untitled']); - }); - - test('applies pool presence guard per rename entry', () => { - const poolHas = (docName: string) => docName === 'archive/a' || docName === 'archive/c'; - expect( - planRenameCleanupCalls( - [ - { fromDocName: 'docs/a', toDocName: 'archive/a' }, - { fromDocName: 'docs/b', toDocName: 'archive/b' }, - { fromDocName: 'docs/c', toDocName: 'archive/c' }, - ], - 'README', - poolHas, - ), - ).toEqual(['docs/a', 'archive/a', 'docs/b', 'docs/c', 'archive/c']); - }); - }); - // `target.path` is extension-stripped for `.md` / `.mdx` files (see // `treeFilePathToDocName` in file-tree-adapter.ts), so the trash flow must // reconstruct the on-disk extension before handing the absolute path to diff --git a/packages/app/src/components/file-tree-operations.ts b/packages/app/src/components/file-tree-operations.ts index a973b16a..e2a85cda 100644 --- a/packages/app/src/components/file-tree-operations.ts +++ b/packages/app/src/components/file-tree-operations.ts @@ -255,27 +255,6 @@ export function remapActiveDocName( return renamed.find((entry) => entry.fromDocName === activeDocName)?.toDocName ?? activeDocName; } -/** - * Decide which docNames need `closeAndClearForRename` after a successful - * `/api/rename-path`. Rename cleanup can also arrive through the server-push - * redirect path, so the client response must not clear a destination provider - * that has already been reopened. It also skips never-opened destinations: - * the IDB delete would be a no-op, but the temporary `pendingClears` entry can - * race the subsequent `pool.open(toDocName)` for a brand-new inline rename. - */ -export function planRenameCleanupCalls( - renamed: readonly RenamedDocMapping[], - poolActiveDocName: string | null, - poolHas: (docName: string) => boolean, -): string[] { - return renamed.flatMap((entry) => { - const serverPushHandledTo = poolActiveDocName === entry.toDocName; - if (serverPushHandledTo) return [entry.fromDocName]; - if (!poolHas(entry.toDocName)) return [entry.fromDocName]; - return [entry.fromDocName, entry.toDocName]; - }); -} - /** * Build the OS-trash-bound absolute path for a tree target. Files reconstruct * the on-disk extension via `target.docExt`; folders and assets pass diff --git a/packages/app/src/components/file-tree-refresh.dom.test.tsx b/packages/app/src/components/file-tree-refresh.dom.test.tsx index 0d0a7164..a3ea7777 100644 --- a/packages/app/src/components/file-tree-refresh.dom.test.tsx +++ b/packages/app/src/components/file-tree-refresh.dom.test.tsx @@ -115,12 +115,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: { kind: 'doc', target: 'notes/source', docName: 'notes/source' }, closeTabs: () => {}, closeDocument: () => {}, - closeAndClearForRename: async () => {}, - getPoolActiveDocName: () => 'notes/source', isNewTabActive: false, openTarget: () => {}, prewarm: () => {}, - remapTabsForRename: () => {}, + reconcileLocalRemoval: async () => {}, + reconcileLocalRename: async () => {}, }), })); diff --git a/packages/app/src/components/file-tree-rename-focus-reconciliation.dom.test.tsx b/packages/app/src/components/file-tree-rename-focus-reconciliation.dom.test.tsx index 96661eb0..d16719b6 100644 --- a/packages/app/src/components/file-tree-rename-focus-reconciliation.dom.test.tsx +++ b/packages/app/src/components/file-tree-rename-focus-reconciliation.dom.test.tsx @@ -59,10 +59,18 @@ const openTargetMock = vi.fn(() => {}); const notifySidebarFileSelectedMock = vi.fn(() => {}); const closeTabsMock = vi.fn(() => {}); const closeDocumentMock = vi.fn(() => {}); -const closeAndClearForRenameMock = vi.fn(async () => {}); -const remapTabsForRenameMock = vi.fn(() => {}); +const reconcileLocalRenameMock = vi.fn(async () => {}); +const reconcileLocalRemovalMock = vi.fn(async () => {}); const dispatchHandoffMock = vi.fn(async () => ({ ok: true as const })); +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + // Three documents alphabetically. `foo.md` (index 1) is the rename target. // After rename: ['aaa.md', 'bar.md', 'zzz.md']. The index-0 row (`aaa.md`) // is the fallback target Pierre snaps focus onto when its `#focusedPath` @@ -313,15 +321,11 @@ vi.doMock('@/editor/DocumentContext', () => ({ activeTarget: { kind: 'doc', target: 'foo', docName: 'foo' }, closeTabs: closeTabsMock, closeDocument: closeDocumentMock, - closeAndClearDocument: closeAndClearForRenameMock, - closeAndClearForDelete: closeAndClearForRenameMock, - closeAndClearForRename: closeAndClearForRenameMock, - getPoolActiveDocName: () => 'foo', - poolHas: () => true, isNewTabActive: false, openTarget: openTargetMock, prewarm: () => {}, - remapTabsForRename: remapTabsForRenameMock, + reconcileLocalRename: reconcileLocalRenameMock, + reconcileLocalRemoval: reconcileLocalRemovalMock, }), })); @@ -487,8 +491,8 @@ describe('FileTree post-rename Pierre/React store reconciliation', () => { addPageMock.mockClear(); openTargetMock.mockClear(); notifySidebarFileSelectedMock.mockClear(); - closeAndClearForRenameMock.mockClear(); - remapTabsForRenameMock.mockClear(); + reconcileLocalRenameMock.mockClear(); + reconcileLocalRemovalMock.mockClear(); consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -574,6 +578,37 @@ describe('FileTree post-rename Pierre/React store reconciliation', () => { expect(model.getFocusedIndex()).toBe(1); }); + test('waits for local rename reconciliation before adding the destination page or resetting the tree', async () => { + const reconciliation = deferred(); + reconcileLocalRenameMock.mockImplementation(() => reconciliation.promise); + render(); + + await waitFor(() => { + expect(capturedOptions).not.toBeNull(); + expect(model.getItem('foo.md')).not.toBeNull(); + }); + + simulatePierreCommitRename('foo.md', 'bar', false); + + await waitFor(() => { + expect(reconcileLocalRenameMock).toHaveBeenCalledWith({ + renamed: [{ fromDocName: 'foo', toDocName: 'bar' }], + renamedFolders: [], + renamedAssets: [], + additionalRemovedDocNames: [], + }); + }); + expect(addPageMock).not.toHaveBeenCalled(); + expect(model.getItem('bar.md')).toBeNull(); + + reconciliation.resolve(); + + await waitFor(() => { + expect(addPageMock).toHaveBeenCalledWith('bar'); + expect(model.getItem('bar.md')).not.toBeNull(); + }); + }); + test('DRAG_DROP — Pierre store already canonical post-drop; reconciliation guard short-circuits without throwing', async () => { // Drag/drop semantic: Pierre's internal drop handler commits the move // with the CANONICAL tree path (no extensionless commit applies to drops). By the diff --git a/packages/app/src/editor/DocumentContext.dom.test.tsx b/packages/app/src/editor/DocumentContext.dom.test.tsx index 718fa0c6..44c3a26e 100644 --- a/packages/app/src/editor/DocumentContext.dom.test.tsx +++ b/packages/app/src/editor/DocumentContext.dom.test.tsx @@ -823,14 +823,29 @@ function RenameHarness({ fromDocName, toDocName }: { fromDocName: string; toDocN {ctx.openTabs.join('|')} {ctx.visibleTabIds.join('|')} {ctx.activeTabId ?? ''} - ); } -describe('DocumentContext remapTabsForRename — preserves tab position', () => { +function AuthRenameHarness() { + const ctx = useDocumentContext(); + return ( + + ); +} + +describe('DocumentContext local rename reconciliation — preserves tab position', () => { afterEach(() => { cleanup(); window.localStorage.clear(); @@ -879,7 +894,7 @@ describe('DocumentContext remapTabsForRename — preserves tab position', () => test('renaming a non-active tab leaves activeTabId untouched', async () => { // Seed: [foo, bar], active = foo. Rename `bar` → `bazz`. Active stays foo — // the `if (remappedActiveTabId && next.includes(remappedActiveTabId))` guard - // in remapTabsForRename only commits when the remapped active actually lands + // in local rename reconciliation only commits when the remapped active actually lands // in the next tab set; an unrelated rename must not perturb the active tab. seedRenameSession(); render(, { @@ -893,4 +908,42 @@ describe('DocumentContext remapTabsForRename — preserves tab position', () => expect(screen.getByTestId('active-tab').textContent).toBe(RENAME_FOO); }); + + test('auth rename navigates when the current target matches even if another pool doc is active', async () => { + mockCollabUrl = 'ws://localhost:1/collab'; + globalThis.fetch = vi.fn(() => Promise.reject(new Error('unexpected fetch'))) as never; + render(, { wrapper: ProviderHarness }); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Select source' })); + + const pool = ( + window as unknown as { + __providerPool?: { + open(docName: string): void; + setActive(docName: string): void; + onRenameRedirect?: (input: { + fromDocName: string; + toDocName: string; + hadOpenProvider: boolean; + }) => void; + }; + } + ).__providerPool; + expect(pool).toBeDefined(); + pool?.open('other.md'); + pool?.setActive('other.md'); + + act(() => { + pool?.onRenameRedirect?.({ + fromDocName: 'from.md', + toDocName: 'to.md', + hadOpenProvider: true, + }); + }); + + await waitFor(() => { + expect(window.location.hash).toBe('#/to.md'); + }); + }); }); diff --git a/packages/app/src/editor/DocumentContext.tsx b/packages/app/src/editor/DocumentContext.tsx index 2badc4e2..c8206917 100644 --- a/packages/app/src/editor/DocumentContext.tsx +++ b/packages/app/src/editor/DocumentContext.tsx @@ -24,6 +24,12 @@ import { refreshServerInfo } from '@/lib/server-info-refresh'; import { useCollabUrl } from '@/lib/use-collab-url'; import { getEditorForDoc } from './active-editor'; import { handleBranchSwitched } from './branch-invalidation'; +import { + type ClientRemovalReconciler, + createClientRemovalReconciler, + type LocalRemovalReconciliation, + type LocalRenameReconciliation, +} from './client-removal-reconciliation'; import { captureRenameSnapshots, subscribePoolEviction } from './editor-cache'; import { addOpenTab, @@ -196,49 +202,10 @@ interface DocumentContextValue { assetPaths: ReadonlySet; filePaths?: ReadonlySet; }) => void; - /** Rename visible tabs after a file/folder/asset rename without changing their order. */ - remapTabsForRename: ( - renamed: readonly { fromDocName: string; toDocName: string }[], - renamedFolders?: readonly { fromPath: string; toPath: string }[], - renamedAssets?: readonly { fromPath: string; toPath: string }[], - ) => void; - /** - * Close `docName` and synchronously delete its client-side IndexedDB. - * Used by rename flows so a future open at this name starts from a - * clean persistence. Without this, moving a doc back to a folder it - * once occupied would hydrate the new Y.Doc from the leftover IDB - * rows of the prior session at that name and then append-merge with - * the server's freshly-loaded content (no shared ancestor → CRDT - * union-merge), producing visible content duplication. Returns a - * promise so callers can await IDB deletion before triggering the - * navigation that opens the new provider. - */ - closeAndClearForRename: (docName: string) => Promise; - /** - * Live read of the pool's currently active doc. Distinct from the - * React-state `activeDocName` (and its `activeDocNameRef` snapshot in - * `FileTree`/`EditorTabs`): those reflect committed React state, which - * lags the pool when an auth-rejection-driven `onRenameRedirect` runs - * inside an awaited HTTP response — the pool is already re-pointed at - * `toDocName` before React batches the matching `setActiveTarget`. Used - * by the post-`/api/rename-path` cleanup in `FileTree.applyRenamedDocuments` - * and `EditorTabs` to detect that the server-push path already did the - * close+clear+reopen and to skip a second destructive - * `closeAndClearForRename(toDocName)` that would tear down the live - * provider `onRenameRedirect` just opened. - */ - getPoolActiveDocName: () => string | null; - /** - * Live read of whether the pool currently has an entry for `docName`. - * Parallel to `getPoolActiveDocName` (same access path, same React-state - * lag concern). Used by `applyRenamedDocuments` to skip the post-rename - * `closeAndClearForRename(toDocName)` when the destination name was never - * opened in the pool — the IDB-by-name delete would be a no-op that - * still registers a transient `pendingClears` entry which races against - * the subsequent `pool.open(toDocName)` and forces `persistence: null` + - * deferred attach on the freshly-opened provider. - */ - poolHas: (docName: string) => boolean; + /** Reconcile provider, persistence, and tab state for a local rename. The caller navigates. */ + reconcileLocalRename: (input: LocalRenameReconciliation) => Promise; + /** Reconcile forced tab closure and persistence removal after a local delete. */ + reconcileLocalRemoval: (input: LocalRemovalReconciliation) => Promise; /** * Destroy and recreate the pool entry for `docName` while preserving * `activeDocName`. Used by the "Try again" path in `DocumentErrorBoundary` @@ -502,6 +469,17 @@ function hashFromTabId(tabId: string): string { } } +function setLocationHash(hash: string) { + if (typeof window !== 'undefined') window.location.hash = hash; +} + +function requireRemovalReconciler( + reconciler: ClientRemovalReconciler | null, +): ClientRemovalReconciler { + if (!reconciler) throw new Error('removal reconciler is not initialized'); + return reconciler; +} + function tabIdFromHash(hash: string): string | null { const assetPath = assetPathFromHash(hash); if (assetPath) return assetTabId(assetPath); @@ -623,6 +601,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) { const [visibleTabIds, setVisibleTabIds] = useState(openTabs); const [activeNewTabId, setActiveNewTabId] = useState(null); const [tabSessionLoaded, setTabSessionLoaded] = useState(false); + const activeTargetRef = useRef(activeTarget); const activeTabIdRef = useRef(activeTabId); const openTabsRef = useRef(openTabs); const pinnedTabIdsRef = useRef(pinnedTabIds); @@ -631,15 +610,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { const visibleTabIdsRef = useRef(visibleTabIds); const nextNewTabOrdinalRef = useRef(1); const recentlyClosedTabsRef = useRef([]); - // Set true when the user explicitly CLOSES (or unpins/replaces) a tab during - // the async session-restore window. Bails the restore merge so a freshly- - // closed tab cannot resurrect from the about-to-arrive restored snapshot. - // - // OPENS (hash-nav, sidebar clicks, agent links) intentionally do NOT set this - // ref — the restore merge below is additive (state.openTabs ∪ openTabsRef.current), - // so an opened-during-restore tab coexists with the restored set without - // collision. Earlier code bailed on every mutation, which dropped the entire - // restore on any open and broke hash-nav-while-restore-pending paths. const tabSessionUserClosedRef = useRef(false); const [tabIdentityResolved, setTabIdentityResolved] = useState(false); const [principal, setPrincipal] = useState(null); @@ -699,6 +669,178 @@ export function DocumentProvider({ children }: { children: ReactNode }) { commitOpenTabs(updater(openTabsRef.current)); } + // Set true when the user explicitly CLOSES (or unpins/replaces) a tab during + // the async session-restore window. Bails the restore merge so a freshly- + // closed tab cannot resurrect from the about-to-arrive restored snapshot. + // + // OPENS (hash-nav, sidebar clicks, agent links) intentionally do NOT set this + // ref — the restore merge below is additive (state.openTabs ∪ openTabsRef.current), + // so an opened-during-restore tab coexists with the restored set without + // collision. Earlier code bailed on every mutation, which dropped the entire + // restore on any open and broke hash-nav-while-restore-pending paths. + // + // Closes (and close-like replace-active) during the restore window bail the + // restore merge. Other mutations (open, pin, activate) remain additive. + const markTabSessionClosedDuringRestore = () => { + if (!tabSessionLoaded) tabSessionUserClosedRef.current = true; + }; + + function remapTabsForRename( + renamed: readonly { fromDocName: string; toDocName: string }[], + renamedFolders: readonly { fromPath: string; toPath: string }[] = [], + renamedAssets: readonly { fromPath: string; toPath: string }[] = [], + ) { + markTabSessionClosedDuringRestore(); + const next = remapOpenTabs( + openTabsRef.current, + renamed, + MAX_POOL, + renamedFolders, + pinnedTabIdsRef.current, + renamedAssets, + ); + const nextPinnedTabIds = normalizePinnedTabIds( + remapOpenTabs( + pinnedTabIdsRef.current, + renamed, + Number.MAX_SAFE_INTEGER, + renamedFolders, + [], + renamedAssets, + ), + next, + ); + if (collabUrl !== null) { + const p = getPool(collabUrl); + for (const tabId of next) { + const docName = docNameForTabId(tabId); + if (docName) p.open(docName); + } + } + commitVisibleTabIds( + remapVisibleTabsForRename(visibleTabIdsRef.current, renamed, renamedFolders, renamedAssets), + ); + commitTabState(next, nextPinnedTabIds); + const currentActiveTabId = activeTabIdRef.current; + if (!currentActiveTabId) return; + const remappedActiveTabId = remapOpenTabs( + [currentActiveTabId], + renamed, + 1, + renamedFolders, + [], + renamedAssets, + )[0]; + if (remappedActiveTabId && next.includes(remappedActiveTabId)) { + commitActiveTabId(remappedActiveTabId); + } + } + + function pushRecentlyClosedTabs(tabIds: readonly string[]) { + if (tabIds.length === 0) return; + recentlyClosedTabsRef.current = [...recentlyClosedTabsRef.current, ...tabIds].slice(-50); + } + + function closeTabIds( + tabIds: readonly string[], + { rememberRecentlyClosed = false }: { rememberRecentlyClosed?: boolean } = {}, + ) { + const closingTabIds = new Set(tabIds.filter((tabId) => tabId.length > 0)); + if (closingTabIds.size === 0) return; + markTabSessionClosedDuringRestore(); + if (rememberRecentlyClosed) { + pushRecentlyClosedTabs(openTabsRef.current.filter((tabId) => closingTabIds.has(tabId))); + } + if (collabUrl !== null) { + const p = getPool(collabUrl); + const closingByDocName = new Map>(); + for (const tabId of closingTabIds) { + const docName = docNameForTabId(tabId); + if (!docName) continue; + const tabsForDoc = closingByDocName.get(docName) ?? new Set(); + tabsForDoc.add(tabId); + closingByDocName.set(docName, tabsForDoc); + } + for (const [docName, tabsForDoc] of closingByDocName) { + if (!hasOpenDocTab(openTabsRef.current, docName, tabsForDoc)) p.close(docName); + } + } + let nextActiveTabId: string | null = null; + const currentActiveTabId = + activeTabId ?? activeTabIdForTarget(activeTarget, snapshot.activeDocName); + updateOpenTabs((current) => { + nextActiveTabId = nextActiveTabAfterCloseMany(current, currentActiveTabId, closingTabIds); + return current.filter((tabId) => !closingTabIds.has(tabId)); + }); + if (!currentActiveTabId || !closingTabIds.has(currentActiveTabId)) { + if (!currentActiveTabId) { + setActiveTarget((current) => { + if (!current) return current; + const targetTabId = tabIdForNavigationTarget(current); + return targetTabId && closingTabIds.has(targetTabId) ? null : current; + }); + } + return; + } + if (nextActiveTabId) { + commitActiveTabId(nextActiveTabId); + setLocationHash(hashFromTabId(nextActiveTabId)); + return; + } + if (collabUrl !== null) getPool(collabUrl).clearActive(); + setActiveTarget(null); + commitActiveTabId(null); + setLocationHash(''); + } + + function createRemovalReconciler() { + return createClientRemovalReconciler({ + captureRenameSnapshots, + getActivePoolDocName: () => + collabUrl === null ? null : getPool(collabUrl).getActiveDocName(), + hasPooledDocument: (docName) => collabUrl !== null && getPool(collabUrl).has(docName), + closeAndClear: async (docName) => { + if (collabUrl !== null) await getPool(collabUrl).closeAndClearPersistence(docName); + }, + openAndActivate: (docName) => { + if (collabUrl === null) return; + const p = getPool(collabUrl); + p.open(docName); + p.setActive(docName); + }, + remapTabs: ({ renamed, renamedFolders, renamedAssets }) => + remapTabsForRename(renamed, renamedFolders, renamedAssets), + closeTabs: closeTabIds, + removeDocumentTab: (docName) => + commitTabState(removeOpenTab(openTabsRef.current, docName), pinnedTabIdsRef.current), + remapActiveTargetForRename: (fromDocName, toDocName) => { + const current = activeTargetRef.current; + const remapped = current !== null && docNameForNavigationTarget(current) === fromDocName; + if (!remapped) return false; + const next = { kind: 'doc' as const, target: toDocName, docName: toDocName }; + activeTargetRef.current = next; + setActiveTarget((current) => { + if (!current || docNameForNavigationTarget(current) !== fromDocName) return current; + return next; + }); + return true; + }, + clearActiveTargetForRemoval: (docName) => { + setActiveTarget((current) => + current && docNameForNavigationTarget(current) === docName ? null : current, + ); + }, + navigateToDocument: (docName) => { + setLocationHash(hashFromDocName(docName)); + }, + navigateHome: () => { + setLocationHash(''); + }, + }); + } + + const removalReconcilerRef = useRef(null); + function commitNewTabIds(nextNewTabIds: string[]) { newTabIdsRef.current = nextNewTabIds; setNewTabIds((current) => (sameTabIds(current, nextNewTabIds) ? current : nextNewTabIds)); @@ -728,6 +870,16 @@ export function DocumentProvider({ children }: { children: ReactNode }) { ); } + useEffect(() => { + activeTargetRef.current = activeTarget; + }, [activeTarget]); + + // No dependency array: auth callbacks need a reconciler with the latest + // collab URL and locally-scoped state helpers after every render. + useEffect(() => { + removalReconcilerRef.current = createRemovalReconciler(); + }); + useEffect(() => { activeTabIdRef.current = activeTabId; }, [activeTabId]); @@ -864,14 +1016,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { writeLocalTabSessionState(storage, localKey, state); }, [activeTabId, activeTarget, openTabs, pinnedTabIds, snapshot.activeDocName, tabSessionLoaded]); - // Closes (and close-like replace-active) during the restore window bail the - // restore merge. Other mutations (open, pin, activate) are no-ops here because - // the restore merge is additive — see tabSessionUserClosedRef declaration for - // the full rationale. - function markTabSessionClosedDuringRestore() { - if (!tabSessionLoaded) tabSessionUserClosedRef.current = true; - } - useEffect(() => { if (collabUrl === null) return; let cancelled = false; @@ -881,28 +1025,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { // Sync initial state setSnapshot(takeSnapshot(p)); - function commitTabsFromPoolCallback( - nextOpenTabs: string[], - nextPinnedTabIds: readonly string[], - ) { - const normalizedPinnedTabIds = normalizePinnedTabIds(nextPinnedTabIds, nextOpenTabs); - openTabsRef.current = nextOpenTabs; - pinnedTabIdsRef.current = normalizedPinnedTabIds; - setOpenTabs((current) => (sameTabIds(current, nextOpenTabs) ? current : nextOpenTabs)); - setPinnedTabIds((current) => - sameTabIds(current, normalizedPinnedTabIds) ? current : normalizedPinnedTabIds, - ); - const nextVisibleTabIds = reconcileVisibleTabOrder( - visibleTabIdsRef.current, - nextOpenTabs, - newTabIdsRef.current, - ); - visibleTabIdsRef.current = nextVisibleTabIds; - setVisibleTabIds((current) => - sameTabIds(current, nextVisibleTabIds) ? current : nextVisibleTabIds, - ); - } - // Late-join branch backstop. Auth-token `expectedBranch` claim // mismatch (server is on branch B, client claims branch A) routes // through the same handleBranchSwitched flow as the live CC1 @@ -933,53 +1055,11 @@ export function DocumentProvider({ children }: { children: ReactNode }) { // is unsupported by `BuildHIR::lowerStatement`. void (async () => { let cleanupError: unknown; - // Capture before close — closeAndClearPersistence clears the pool's - // active slot when its argument is the active doc, so we can't read - // this signal after the fact. - const wasActive = p.getActiveDocName() === fromDocName; - captureRenameSnapshots([{ fromDocName, toDocName }]); try { - await Promise.all([ - p.closeAndClearPersistence(fromDocName), - p.closeAndClearPersistence(toDocName), - ]); - // Open a fresh provider so the editor hydrates the new doc. - // The hash below already points at toDocName, so a file-tree - // re-click can't recover via `hashchange` if we skip this. - if (wasActive) { - p.open(toDocName); - p.setActive(toDocName); - } - const nextOpenTabs = remapOpenTabs( - openTabsRef.current, - [{ fromDocName, toDocName }], - MAX_POOL, - [], - pinnedTabIdsRef.current, - ); - const nextPinnedTabIds = normalizePinnedTabIds( - remapOpenTabs( - pinnedTabIdsRef.current, - [{ fromDocName, toDocName }], - Number.MAX_SAFE_INTEGER, - ), - nextOpenTabs, - ); - visibleTabIdsRef.current = remapVisibleTabsForRename(visibleTabIdsRef.current, [ - { fromDocName, toDocName }, - ]); - commitTabsFromPoolCallback(nextOpenTabs, nextPinnedTabIds); - setActiveTarget((current) => { - if (!current) return current; - const currentDocName = docNameForNavigationTarget(current); - if (currentDocName === fromDocName) { - return { kind: 'doc', target: toDocName, docName: toDocName }; - } - return current; + await requireRemovalReconciler(removalReconcilerRef.current).reconcileAuthRename({ + fromDocName, + toDocName, }); - if (wasActive) { - window.location.hash = hashFromDocName(toDocName); - } } catch (err) { cleanupError = err; console.warn( @@ -1011,16 +1091,9 @@ export function DocumentProvider({ children }: { children: ReactNode }) { void (async () => { let cleanupError: unknown; try { - await p.closeAndClearPersistence(docName); - const nextOpenTabs = removeOpenTab(openTabsRef.current, docName); - commitTabsFromPoolCallback(nextOpenTabs, pinnedTabIdsRef.current); - setActiveTarget((current) => { - if (!current) return current; - return docNameForNavigationTarget(current) === docName ? null : current; + await requireRemovalReconciler(removalReconcilerRef.current).reconcileAuthRemoval({ + docName, }); - if (p.getActiveDocName() === docName) { - window.location.hash = ''; - } } catch (err) { cleanupError = err; console.warn( @@ -1260,11 +1333,6 @@ export function DocumentProvider({ children }: { children: ReactNode }) { openTargetWithOptions(target, options); }; - function pushRecentlyClosedTabs(tabIds: readonly string[]) { - if (tabIds.length === 0) return; - recentlyClosedTabsRef.current = [...recentlyClosedTabsRef.current, ...tabIds].slice(-50); - } - const activateTabById = (tabId: string) => { const tab = parseEditorTabId(tabId); commitActiveNewTabId(null); @@ -1615,61 +1683,13 @@ export function DocumentProvider({ children }: { children: ReactNode }) { }, closeTabs: (tabIds: readonly string[], options: CloseTabsOptions = {}) => { const requestedTabIds = tabIds.filter((tabId) => tabId.length > 0); - const closingTabIds = new Set( - options.force - ? requestedTabIds - : filterClosableTabIds(requestedTabIds, pinnedTabIdsRef.current), - ); - if (closingTabIds.size === 0) return; - markTabSessionClosedDuringRestore(); - if (!options.force) { - pushRecentlyClosedTabs(openTabsRef.current.filter((tabId) => closingTabIds.has(tabId))); - } - if (collabUrl !== null) { - const p = getPool(collabUrl); - const closingByDocName = new Map>(); - for (const tabId of closingTabIds) { - const docName = docNameForTabId(tabId); - if (!docName) continue; - const tabsForDoc = closingByDocName.get(docName) ?? new Set(); - tabsForDoc.add(tabId); - closingByDocName.set(docName, tabsForDoc); - } - for (const [docName, tabsForDoc] of closingByDocName) { - if (!hasOpenDocTab(openTabsRef.current, docName, tabsForDoc)) p.close(docName); - } - } - - let nextActiveTabId: string | null = null; - const currentActiveTabId = - activeTabId ?? activeTabIdForTarget(activeTarget, snapshot.activeDocName); - updateOpenTabs((current) => { - nextActiveTabId = nextActiveTabAfterCloseMany(current, currentActiveTabId, closingTabIds); - return current.filter((tabId) => !closingTabIds.has(tabId)); - }); - - if (!currentActiveTabId || !closingTabIds.has(currentActiveTabId)) { - if (!currentActiveTabId) { - setActiveTarget((current) => { - if (!current) return current; - const targetTabId = tabIdForNavigationTarget(current); - return targetTabId && closingTabIds.has(targetTabId) ? null : current; - }); - } + if (options.force) { + closeTabIds(requestedTabIds); return; } - if (nextActiveTabId) { - commitActiveTabId(nextActiveTabId); - window.location.hash = hashFromTabId(nextActiveTabId); - return; - } - if (collabUrl !== null) { - const p = getPool(collabUrl); - p.clearActive(); - } - setActiveTarget(null); - commitActiveTabId(null); - window.location.hash = ''; + closeTabIds(filterClosableTabIds(requestedTabIds, pinnedTabIdsRef.current), { + rememberRecentlyClosed: true, + }); }, syncOpenTabsWithKnownTargets: ({ pages, folderPaths, assetPaths, filePaths }) => { const keepMissingDocName = activeTarget?.kind === 'missing' ? activeTarget.target : null; @@ -1739,75 +1759,8 @@ export function DocumentProvider({ children }: { children: ReactNode }) { commitActiveTabId(null); window.location.hash = ''; }, - remapTabsForRename: (renamed, renamedFolders = [], renamedAssets = []) => { - // Rename changes tab identity (old name closed, new name opened) — must - // mark so the restore merge doesn't resurrect the old identity. - markTabSessionClosedDuringRestore(); - const next = remapOpenTabs( - openTabsRef.current, - renamed, - MAX_POOL, - renamedFolders, - pinnedTabIdsRef.current, - renamedAssets, - ); - const nextPinnedTabIds = normalizePinnedTabIds( - remapOpenTabs( - pinnedTabIdsRef.current, - renamed, - Number.MAX_SAFE_INTEGER, - renamedFolders, - [], - renamedAssets, - ), - next, - ); - if (collabUrl !== null) { - const p = getPool(collabUrl); - for (const tabId of next) { - const docName = docNameForTabId(tabId); - if (docName) p.open(docName); - } - } - visibleTabIdsRef.current = remapVisibleTabsForRename( - visibleTabIdsRef.current, - renamed, - renamedFolders, - renamedAssets, - ); - commitTabState(next, nextPinnedTabIds); - const currentActiveTabId = activeTabIdRef.current; - if (currentActiveTabId) { - const remappedActiveTabId = remapOpenTabs( - [currentActiveTabId], - renamed, - 1, - renamedFolders, - [], - renamedAssets, - )[0]; - if (remappedActiveTabId && next.includes(remappedActiveTabId)) { - commitActiveTabId(remappedActiveTabId); - } - } - }, - closeAndClearForRename: async (docName: string) => { - if (collabUrl === null) return; - const p = getPool(collabUrl); - await p.closeAndClearPersistence(docName); - setActiveTarget((current) => { - if (!current) return current; - return docNameForNavigationTarget(current) === docName ? null : current; - }); - }, - getPoolActiveDocName: () => { - if (collabUrl === null) return null; - return getPool(collabUrl).getActiveDocName(); - }, - poolHas: (docName: string) => { - if (collabUrl === null) return false; - return getPool(collabUrl).has(docName); - }, + reconcileLocalRename: (input) => createRemovalReconciler().reconcileLocalRename(input), + reconcileLocalRemoval: (input) => createRemovalReconciler().reconcileLocalRemoval(input), recycleDocument: (docName: string) => { if (collabUrl === null) return; const p = getPool(collabUrl); diff --git a/packages/app/src/editor/client-removal-reconciliation.test.ts b/packages/app/src/editor/client-removal-reconciliation.test.ts new file mode 100644 index 00000000..5ea54b9a --- /dev/null +++ b/packages/app/src/editor/client-removal-reconciliation.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from 'vitest'; +import { + type ClientRemovalReconciliationPorts, + createClientRemovalReconciler, +} from './client-removal-reconciliation'; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createPorts( + options: { + activePoolDocName?: string | null; + pooledDocNames?: readonly string[]; + deferredClears?: boolean; + remapActiveTarget?: boolean; + clearRejects?: boolean; + } = {}, +) { + const log: string[] = []; + let activePoolDocName = options.activePoolDocName ?? null; + const clearDeferred = new Map>(); + const ports: ClientRemovalReconciliationPorts = { + captureRenameSnapshots: (renamed) => + log.push( + `capture:${renamed.map((entry) => `${entry.fromDocName}>${entry.toDocName}`).join(',')}`, + ), + getActivePoolDocName: () => activePoolDocName, + hasPooledDocument: (docName) => (options.pooledDocNames ?? []).includes(docName), + closeAndClear: (docName) => { + log.push(`clear:start:${docName}`); + if (options.clearRejects) return Promise.reject(new Error('clear failed')); + if (!options.deferredClears) { + if (activePoolDocName === docName) activePoolDocName = null; + log.push(`clear:end:${docName}`); + return Promise.resolve(); + } + const pending = deferred(); + clearDeferred.set(docName, pending); + pending.promise.then(() => { + if (activePoolDocName === docName) activePoolDocName = null; + log.push(`clear:end:${docName}`); + }); + return pending.promise; + }, + openAndActivate: (docName) => { + activePoolDocName = docName; + log.push(`open:${docName}`); + }, + remapTabs: ({ renamed }) => + log.push( + `remap:${renamed.map((entry) => `${entry.fromDocName}>${entry.toDocName}`).join(',')}`, + ), + closeTabs: (tabIds) => log.push(`close-tabs:${tabIds.join(',')}`), + removeDocumentTab: (docName) => log.push(`remove-tab:${docName}`), + remapActiveTargetForRename: (fromDocName, toDocName) => { + log.push(`remap-target:${fromDocName}>${toDocName}`); + return options.remapActiveTarget ?? false; + }, + clearActiveTargetForRemoval: (docName) => log.push(`clear-target:${docName}`), + navigateToDocument: (docName) => log.push(`navigate:${docName}`), + navigateHome: () => log.push('navigate:home'), + }; + return { clearDeferred, log, reconciler: createClientRemovalReconciler(ports) }; +} + +describe('ClientRemovalReconciler', () => { + test('captures local rename before clears and remaps after every clear settles', async () => { + const { clearDeferred, log, reconciler } = createPorts({ + pooledDocNames: ['to'], + deferredClears: true, + }); + const reconcile = reconciler.reconcileLocalRename({ + renamed: [{ fromDocName: 'from', toDocName: 'to' }], + }); + expect(log).toEqual(['capture:from>to', 'clear:start:from', 'clear:start:to']); + clearDeferred.get('from')?.resolve(); + await Promise.resolve(); + expect(log).not.toContain('remap:from>to'); + clearDeferred.get('to')?.resolve(); + await reconcile; + expect(log).toEqual([ + 'capture:from>to', + 'clear:start:from', + 'clear:start:to', + 'clear:end:from', + 'clear:end:to', + 'remap:from>to', + ]); + }); + + test('clears both local rename ends when the destination is pooled', async () => { + const { log, reconciler } = createPorts({ activePoolDocName: 'from', pooledDocNames: ['to'] }); + await reconciler.reconcileLocalRename({ renamed: [{ fromDocName: 'from', toDocName: 'to' }] }); + expect(log).toEqual([ + 'capture:from>to', + 'clear:start:from', + 'clear:end:from', + 'clear:start:to', + 'clear:end:to', + 'remap:from>to', + ]); + }); + + test('clears only the source when auth already reopened the destination', async () => { + const { log, reconciler } = createPorts({ activePoolDocName: 'to', pooledDocNames: ['to'] }); + await reconciler.reconcileLocalRename({ renamed: [{ fromDocName: 'from', toDocName: 'to' }] }); + expect(log).toEqual(['capture:from>to', 'clear:start:from', 'clear:end:from', 'remap:from>to']); + }); + + test('clears only the source when the local destination was never pooled', async () => { + const { log, reconciler } = createPorts(); + await reconciler.reconcileLocalRename({ renamed: [{ fromDocName: 'from', toDocName: 'to' }] }); + expect(log).toEqual(['capture:from>to', 'clear:start:from', 'clear:end:from', 'remap:from>to']); + }); + + test('dedupes multiple local mappings and additional removals', async () => { + const { log, reconciler } = createPorts({ pooledDocNames: ['b', 'c'] }); + await reconciler.reconcileLocalRename({ + renamed: [ + { fromDocName: 'a', toDocName: 'b' }, + { fromDocName: 'b', toDocName: 'c' }, + ], + additionalRemovedDocNames: ['a', 'c', 'asset-source'], + }); + expect(log.filter((entry) => entry.startsWith('clear:start:'))).toEqual([ + 'clear:start:a', + 'clear:start:b', + 'clear:start:c', + 'clear:start:asset-source', + ]); + }); + + test('remaps local rename tabs without provider state', async () => { + const { log, reconciler } = createPorts(); + await reconciler.reconcileLocalRename({ + renamed: [], + renamedFolders: [{ fromPath: 'a', toPath: 'b' }], + }); + expect(log).toEqual(['capture:', 'remap:']); + }); + + test('force-closes local removal tabs before deduped persistence clears', async () => { + const { log, reconciler } = createPorts(); + await reconciler.reconcileLocalRemoval({ + tabIdsToClose: ['from', 'folder:old'], + docNamesToClear: ['from', 'from', 'other'], + }); + expect(log).toEqual([ + 'close-tabs:from,folder:old', + 'clear:start:from', + 'clear:end:from', + 'clear:start:other', + 'clear:end:other', + ]); + }); + + test('auth rename snapshots, clears, reopens, remaps, and navigates in order', async () => { + const { clearDeferred, log, reconciler } = createPorts({ + activePoolDocName: 'from', + deferredClears: true, + remapActiveTarget: true, + }); + const reconcile = reconciler.reconcileAuthRename({ fromDocName: 'from', toDocName: 'to' }); + expect(log).toEqual(['capture:from>to', 'clear:start:from', 'clear:start:to']); + clearDeferred.get('from')?.resolve(); + clearDeferred.get('to')?.resolve(); + await reconcile; + expect(log.slice(-4)).toEqual([ + 'open:to', + 'remap:from>to', + 'remap-target:from>to', + 'navigate:to', + ]); + }); + + test('auth rename of an inactive source does not reopen or navigate', async () => { + const { log, reconciler } = createPorts({ activePoolDocName: 'other' }); + await reconciler.reconcileAuthRename({ fromDocName: 'from', toDocName: 'to' }); + expect(log).not.toContain('open:to'); + expect(log).not.toContain('navigate:to'); + }); + + test('auth rename navigates when only the active target is remapped', async () => { + const { log, reconciler } = createPorts({ + activePoolDocName: 'other', + remapActiveTarget: true, + }); + await reconciler.reconcileAuthRename({ fromDocName: 'from', toDocName: 'to' }); + expect(log).not.toContain('open:to'); + expect(log).toContain('remap-target:from>to'); + expect(log).toContain('navigate:to'); + }); + + test('auth removal navigates home from the pre-teardown active snapshot', async () => { + const { log, reconciler } = createPorts({ activePoolDocName: 'deleted' }); + await reconciler.reconcileAuthRemoval({ docName: 'deleted' }); + expect(log).toEqual([ + 'clear:start:deleted', + 'clear:end:deleted', + 'remove-tab:deleted', + 'clear-target:deleted', + 'navigate:home', + ]); + }); + + test('auth removal of an inactive document leaves unrelated navigation intact', async () => { + const { log, reconciler } = createPorts({ activePoolDocName: 'other' }); + await reconciler.reconcileAuthRemoval({ docName: 'deleted' }); + expect(log).not.toContain('navigate:home'); + expect(log).toContain('remove-tab:deleted'); + }); + + test('propagates adapter failures without remapping or navigating afterward', async () => { + const { log, reconciler } = createPorts({ clearRejects: true }); + await expect( + reconciler.reconcileAuthRename({ fromDocName: 'from', toDocName: 'to' }), + ).rejects.toThrow('clear failed'); + expect(log).not.toContain('remap:from>to'); + expect(log).not.toContain('navigate:to'); + }); + + test('propagates local rename failures before remapping', async () => { + const { log, reconciler } = createPorts({ + pooledDocNames: ['to'], + clearRejects: true, + }); + await expect( + reconciler.reconcileLocalRename({ + renamed: [{ fromDocName: 'from', toDocName: 'to' }], + }), + ).rejects.toThrow('clear failed'); + expect(log).not.toContain('remap:from>to'); + }); + + test('propagates local removal cleanup failures after closing tabs', async () => { + const { log, reconciler } = createPorts({ clearRejects: true }); + await expect( + reconciler.reconcileLocalRemoval({ + tabIdsToClose: ['from'], + docNamesToClear: ['from'], + }), + ).rejects.toThrow('clear failed'); + expect(log).toEqual(['close-tabs:from', 'clear:start:from']); + }); + + test('propagates auth removal failures without navigating', async () => { + const { log, reconciler } = createPorts({ + activePoolDocName: 'deleted', + clearRejects: true, + }); + await expect(reconciler.reconcileAuthRemoval({ docName: 'deleted' })).rejects.toThrow( + 'clear failed', + ); + expect(log).not.toContain('navigate:home'); + }); +}); diff --git a/packages/app/src/editor/client-removal-reconciliation.ts b/packages/app/src/editor/client-removal-reconciliation.ts new file mode 100644 index 00000000..a3b36a4b --- /dev/null +++ b/packages/app/src/editor/client-removal-reconciliation.ts @@ -0,0 +1,108 @@ +import type { RenamedAssetMapping, RenamedDocMapping } from '@inkeep/open-knowledge-core'; +import type { RenamedFolderMapping } from './editor-tabs'; + +export interface LocalRenameReconciliation { + renamed: readonly RenamedDocMapping[]; + renamedFolders?: readonly RenamedFolderMapping[]; + renamedAssets?: readonly RenamedAssetMapping[]; + /** Source doc names removed outside `renamed`, such as doc-to-asset transitions. */ + additionalRemovedDocNames?: readonly string[]; +} + +export interface LocalRemovalReconciliation { + tabIdsToClose: readonly string[]; + docNamesToClear: readonly string[]; +} + +interface AuthRenameReconciliation { + fromDocName: string; + toDocName: string; +} + +interface AuthRemovalReconciliation { + docName: string; +} + +export interface ClientRemovalReconciler { + reconcileLocalRename(input: LocalRenameReconciliation): Promise; + reconcileLocalRemoval(input: LocalRemovalReconciliation): Promise; + reconcileAuthRename(input: AuthRenameReconciliation): Promise; + reconcileAuthRemoval(input: AuthRemovalReconciliation): Promise; +} + +export interface ClientRemovalReconciliationPorts { + captureRenameSnapshots(renamed: readonly RenamedDocMapping[]): void; + getActivePoolDocName(): string | null; + hasPooledDocument(docName: string): boolean; + closeAndClear(docName: string): Promise; + openAndActivate(docName: string): void; + remapTabs(input: { + renamed: readonly RenamedDocMapping[]; + renamedFolders: readonly RenamedFolderMapping[]; + renamedAssets: readonly RenamedAssetMapping[]; + }): void; + closeTabs(tabIds: readonly string[]): void; + removeDocumentTab(docName: string): void; + remapActiveTargetForRename(fromDocName: string, toDocName: string): boolean; + clearActiveTargetForRemoval(docName: string): void; + navigateToDocument(docName: string): void; + navigateHome(): void; +} + +function uniqueDocNames(docNames: readonly string[]): string[] { + return [...new Set(docNames)]; +} + +export function createClientRemovalReconciler( + ports: ClientRemovalReconciliationPorts, +): ClientRemovalReconciler { + return { + async reconcileLocalRename({ + renamed, + renamedFolders = [], + renamedAssets = [], + additionalRemovedDocNames = [], + }) { + ports.captureRenameSnapshots(renamed); + const activePoolDocName = ports.getActivePoolDocName(); + const cleanupDocNames: string[] = []; + for (const { fromDocName, toDocName } of renamed) { + cleanupDocNames.push(fromDocName); + if (activePoolDocName !== toDocName && ports.hasPooledDocument(toDocName)) { + cleanupDocNames.push(toDocName); + } + } + cleanupDocNames.push(...additionalRemovedDocNames); + await Promise.all( + uniqueDocNames(cleanupDocNames).map((docName) => ports.closeAndClear(docName)), + ); + ports.remapTabs({ renamed, renamedFolders, renamedAssets }); + }, + + async reconcileLocalRemoval({ tabIdsToClose, docNamesToClear }) { + ports.closeTabs(tabIdsToClose); + await Promise.all( + uniqueDocNames(docNamesToClear).map((docName) => ports.closeAndClear(docName)), + ); + }, + + async reconcileAuthRename({ fromDocName, toDocName }) { + const wasActive = ports.getActivePoolDocName() === fromDocName; + const renamed = [{ fromDocName, toDocName }]; + ports.captureRenameSnapshots(renamed); + await Promise.all([ports.closeAndClear(fromDocName), ports.closeAndClear(toDocName)]); + if (wasActive) ports.openAndActivate(toDocName); + ports.remapTabs({ renamed, renamedFolders: [], renamedAssets: [] }); + const remappedActiveTarget = ports.remapActiveTargetForRename(fromDocName, toDocName); + if (wasActive || remappedActiveTarget) ports.navigateToDocument(toDocName); + }, + + async reconcileAuthRemoval({ docName }) { + const wasActive = ports.getActivePoolDocName() === docName; + await ports.closeAndClear(docName); + ports.removeDocumentTab(docName); + ports.clearActiveTargetForRemoval(docName); + if (wasActive) ports.navigateHome(); + }, + }; +} diff --git a/packages/app/src/editor/editor-tabs.ts b/packages/app/src/editor/editor-tabs.ts index 5a39ae75..75e32ed6 100644 --- a/packages/app/src/editor/editor-tabs.ts +++ b/packages/app/src/editor/editor-tabs.ts @@ -20,7 +20,7 @@ interface EditorTabSessionState { updatedAt: string | null; } -interface RenamedFolderMapping { +export interface RenamedFolderMapping { fromPath: string; toPath: string; } @@ -538,8 +538,8 @@ export function remapOpenTabs( // subsequent `reconcileVisibleTabOrder` does not drop the stale (pre-rename) // tabIds at the membership check and re-append the new tabIds at the end, // shifting the renamed tab's slot. Both rename-adjacent commit -// paths in DocumentContext — server-driven `onRenameRedirect` and -// sidebar-driven `remapTabsForRename` — MUST seed the ref through this +// paths in DocumentContext's client-removal reconciler — server-driven and +// local-response rename flows — MUST seed the ref through this // helper so the invariant is structural rather than caller-enforced. export function remapVisibleTabsForRename( currentOrder: readonly string[], diff --git a/packages/app/src/editor/provider-pool.ts b/packages/app/src/editor/provider-pool.ts index c3475f07..74fa7833 100644 --- a/packages/app/src/editor/provider-pool.ts +++ b/packages/app/src/editor/provider-pool.ts @@ -515,8 +515,8 @@ export class ProviderPool { * * Map entries are deleted via a `.then`/`.catch` epilogue when the work * settles; the public `closeAndClearPersistence` still swallows the - * rejection so legacy callers (FileTree bulk rename, EditorTabs - * cleanup) don't need to handle per-docName failures inside Promise.all + * rejection so the DocumentContext reconciliation adapter can preserve + * the existing best-effort cleanup behavior inside Promise.all * batches. The deferred-attach scheduler subscribes to this promise * directly (see `open`) and observes both resolve and reject, attaching * persistence on success and skipping on failure (entry runs without @@ -527,8 +527,8 @@ export class ProviderPool { /** * Per-docName retention of `closeAndClearPersistence` failures across the * pendingClears finalize window. The public wrapper swallows clear - * failures so legacy batch callers (FileTree bulk rename, EditorTabs - * cleanup) don't see partial-failure rejections, and the in-flight + * failures so the DocumentContext reconciliation adapter does not see + * partial-failure rejections, and the in-flight * Promise drops out of `pendingClears` once its .then/.catch finalize * epilogue runs. Without this set, a non-concurrent reopen of the same * docName afterwards (delete → time passes → recreate) observes no @@ -1441,10 +1441,10 @@ export class ProviderPool { * Auth-rejection cleanup callbacks for the rename-redirect / doc-deleted * arms of `onAuthenticationFailed`. Pool computes `hadOpenProvider` from * its own entry map (the only state it can observe synchronously); the - * React layer owns the React-state-aware cleanup (closeAndClearForRename, - * remapTabsForRename, active-tab navigation) and emits the structured - * `removal.cleanup` event after the awaited cleanup settles. Mirrors the - * `setOnBranchMismatch` shape — pool stays free of React/UI knowledge. + * React layer owns the React-state-aware reconciliation (snapshot capture, + * persistence cleanup, tab remapping, active-tab navigation) and emits the + * structured `removal.cleanup` event after the awaited cleanup settles. + * Mirrors the `setOnBranchMismatch` shape — pool stays free of React/UI knowledge. */ private onRenameRedirect: RenameRedirectHandler | null = null; private onDocDeleted: ((args: { docName: string; hadOpenProvider: boolean }) => void) | null = @@ -2649,7 +2649,7 @@ export class ProviderPool { */ async closeAndClearPersistence(docName: string): Promise { // Public API: always resolves, even if the underlying clear failed. - // FileTree's bulk-rename + EditorTabs cleanup batch many calls via + // The DocumentContext reconciliation adapter batches many calls via // `Promise.all(...)`; propagating a per-docName clear failure would // abort the batch and leave partial-rename state in the React tree. // The pendingClears tracking exposes the internal status to the @@ -2674,8 +2674,8 @@ export class ProviderPool { const inFlight = this.pendingClears.get(docName); if (inFlight !== undefined) { // Concurrent callers share the same in-flight work — preserves - // the idempotent semantics existing callers (FileTree, EditorTabs) - // already rely on when batching close-and-clears for renames. + // the idempotent semantics the DocumentContext reconciliation adapter + // relies on when batching close-and-clears for renames. return inFlight; } // Register the pendingClear via a deferred promise BEFORE invoking