From 253fa43256a0b75c0335db0df1915604e2fb6867 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 7 Sep 2026 22:10:00 -0700 Subject: [PATCH] perf: index editor ownership and restored workspace projections (#19444) Co-authored-by: m4air --- .../slices/editor-hydration-scaling.test.ts | 44 +++++ .../editor/actions/hydrate-editor-session.ts | 99 +++------- .../file-ids/hydrated-editor-file-ids.ts | 78 +++++--- .../hydrated-editor-file-index.test.ts | 82 ++++++++ .../hydrated-editor-file-selection.ts | 40 ++++ .../file-ids/hydrated-editor-frontmatter.ts | 50 +++++ .../hydrated-editor-projections.test.ts | 175 ++++++++++++++++++ 7 files changed, 466 insertions(+), 102 deletions(-) create mode 100644 src/renderer/src/store/slices/editor-hydration-scaling.test.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-frontmatter.ts create mode 100644 src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts diff --git a/src/renderer/src/store/slices/editor-hydration-scaling.test.ts b/src/renderer/src/store/slices/editor-hydration-scaling.test.ts new file mode 100644 index 00000000000..2dd98a8ccf2 --- /dev/null +++ b/src/renderer/src/store/slices/editor-hydration-scaling.test.ts @@ -0,0 +1,44 @@ +import { expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { createTestStore } from './store-test-helpers' +import { createStoreSessionMockApi } from './store-session-test-harness' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +createStoreSessionMockApi() + +it('restores a large editor session without rescanning earlier file owners', () => { + const store = createTestStore() + const count = 2_000 + let pathReads = 0 + const files = Array.from({ length: count }, (_, index) => ({ + get filePath() { + pathReads++ + return `/project/file-${index}.ts` + }, + relativePath: `file-${index}.ts`, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + language: 'typescript', + runtimeEnvironmentId: index % 2 ? ' peer ' : null, + dirtyDraftContent: `unsaved ${index}` + })) + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + openFilesByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: files } + } + store.setState({ activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + store.getState().hydrateEditorSession(session) + const state = store.getState() + expect(state.openFiles).toHaveLength(count) + expect(pathReads).toBeLessThan(count * 20) + for (let index = 0; index < count; index++) { + const file = state.openFiles[index] + expect(file.filePath).toBe(`/project/file-${index}.ts`) + expect(state.editorDrafts[file.id]).toBe(`unsaved ${index}`) + } + expect(state.activeFileId).toBe(state.openFiles[0].id) +}) diff --git a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts index 9ccd754c970..7508ca8cfb2 100644 --- a/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts +++ b/src/renderer/src/store/slices/editor/actions/hydrate-editor-session.ts @@ -7,14 +7,14 @@ import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' import type { OpenFile } from '../types/open-file' import { buildValidWorktreeIdsForSessionHydration } from '../../degraded-repo-worktree-validity' -import { buildOwnedEditorFileId, isSameEditorOwner } from '../file-ids/editor-file-ids' +import { buildOwnedEditorFileId } from '../file-ids/editor-file-ids' +import { resolveHydratedEditorFileSelection } from '../file-ids/hydrated-editor-file-selection' +import { resolveHydratedEditorFrontmatter } from '../file-ids/hydrated-editor-frontmatter' import { addEditorFileIdMigration, - migrateEditorFileId, migrateHydratedEditorTabsAndGroups, - resolveLegacyHydratedEditorFileId, - shouldHydrateWithOwnedEditorFileId, - type LegacyHydratedEditorFile + LegacyHydratedEditorFileIndex, + shouldHydrateWithOwnedEditorFileId } from '../file-ids/hydrated-editor-file-ids' export function createHydrateEditorSession( @@ -42,7 +42,7 @@ export function createHydrateEditorSession( const openFiles: OpenFile[] = [] const editorDrafts: Record = {} const usedOpenFileIds = new Set() - const legacyHydratedOpenFiles: LegacyHydratedEditorFile[] = [] + const legacyFileIndex = new LegacyHydratedEditorFileIndex() const editorFileIdMigrationsByWorktree: Record> = {} for (const [worktreeId, files] of Object.entries(openFilesByWorktree)) { if (!validWorktreeIds.has(worktreeId)) { @@ -50,20 +50,10 @@ export function createHydrateEditorSession( } for (const pf of files) { // Split tabs share one OpenFile; repeated records for the same owner are corruption. - if ( - legacyHydratedOpenFiles.some( - (file) => - file.filePath === pf.filePath && - isSameEditorOwner(file, worktreeId, pf.runtimeEnvironmentId) - ) - ) { + if (legacyFileIndex.hasOwner(pf, worktreeId)) { continue } - const legacyId = resolveLegacyHydratedEditorFileId( - legacyHydratedOpenFiles, - pf, - worktreeId - ) + const legacyId = legacyFileIndex.resolve(pf, worktreeId) // Why: floating/runtime-owned files need IDs that survive peers disappearing between restarts; collision-based IDs drift when the path is no longer open elsewhere. const ownedId = buildOwnedEditorFileId(pf.filePath, worktreeId, pf.runtimeEnvironmentId) const id = @@ -78,7 +68,7 @@ export function createHydrateEditorSession( usedOpenFileIds.add(id) // Why: map from the collision-derived legacy id; keying by filePath would collapse same-path local/runtime tabs onto the last owner to hydrate. addEditorFileIdMigration(editorFileIdMigrationsByWorktree, worktreeId, legacyId, id) - legacyHydratedOpenFiles.push({ + legacyFileIndex.add({ id: legacyId, filePath: pf.filePath, worktreeId, @@ -117,47 +107,21 @@ export function createHydrateEditorSession( // Why: use the store's activeWorktreeId — hydrateWorkspaceSession may have nulled an invalid ID, and we must respect that. const activeWorktreeId = s.activeWorktreeId - const fallbackActiveFileId = activeWorktreeId - ? (openFiles.find((f) => f.worktreeId === activeWorktreeId)?.id ?? null) - : null - const persistedActiveFileId = activeWorktreeId - ? migrateEditorFileId( - editorFileIdMigrationsByWorktree, - activeWorktreeId, - persistedActiveFileIdByWorktree[activeWorktreeId] - ) - : null - // Why: the persisted active file may be gone (worktree validation or stale path), so verify it exists in the restored set. - const activeFileExists = persistedActiveFileId - ? openFiles.some( - (f) => f.id === persistedActiveFileId && f.worktreeId === activeWorktreeId - ) - : false - // Why: the previous active surface may have been a transient diff/conflict tab (not restored), so promote the first restored edit file. - const nextActiveFileId = activeFileExists ? persistedActiveFileId : fallbackActiveFileId + const { + activeFileId: nextActiveFileId, + activeFileIdByWorktree: filteredActiveFileIdByWorktree + } = resolveHydratedEditorFileSelection({ + openFiles, + validWorktreeIds, + activeWorktreeId, + persistedActiveFileIds: persistedActiveFileIdByWorktree, + migrations: editorFileIdMigrationsByWorktree + }) const activeTabType: WorkspaceVisibleTabType = activeWorktreeId && persistedActiveTabTypeByWorktree[activeWorktreeId] ? persistedActiveTabTypeByWorktree[activeWorktreeId] : 'terminal' - // Filter per-worktree maps to only valid worktrees with valid file references - const filteredActiveFileIdByWorktree = Object.fromEntries( - [...validWorktreeIds].flatMap((wId) => { - const persistedFileId = migrateEditorFileId( - editorFileIdMigrationsByWorktree, - wId, - persistedActiveFileIdByWorktree[wId] - ) - if ( - persistedFileId && - openFiles.some((f) => f.id === persistedFileId && f.worktreeId === wId) - ) { - return [[wId, persistedFileId]] - } - const fallbackFileId = openFiles.find((f) => f.worktreeId === wId)?.id - return fallbackFileId ? [[wId, fallbackFileId]] : [] - }) - ) const filteredActiveTabTypeByWorktree = Object.fromEntries( Object.entries(persistedActiveTabTypeByWorktree).filter(([wId, tabType]) => { if (!validWorktreeIds.has(wId)) { @@ -174,26 +138,11 @@ export function createHydrateEditorSession( // Why: transient diff/conflict surfaces aren't restored, so clear a stale "editor" marker and fall back to terminal. const nextActiveTabType = nextActiveFileId || activeTabType !== 'editor' ? activeTabType : 'terminal' - const openFileIds = new Set(openFiles.map((file) => file.id)) - // Why: visible is the default, so restore only per-file hide overrides (`false`); legacy `true` entries collapse to the default. - const hiddenFrontmatterEntries = new Map() - for (const [persistedFileId, visible] of Object.entries( - persistedMarkdownFrontmatterVisible - )) { - if (visible) { - continue - } - if (openFileIds.has(persistedFileId)) { - hiddenFrontmatterEntries.set(persistedFileId, false) - } - for (const migrations of Object.values(editorFileIdMigrationsByWorktree)) { - const migratedFileId = migrations.get(persistedFileId) - if (migratedFileId && openFileIds.has(migratedFileId)) { - hiddenFrontmatterEntries.set(migratedFileId, false) - } - } - } - const markdownFrontmatterVisible = Object.fromEntries(hiddenFrontmatterEntries) + const markdownFrontmatterVisible = resolveHydratedEditorFrontmatter( + persistedMarkdownFrontmatterVisible, + usedOpenFileIds, + editorFileIdMigrationsByWorktree + ) return { openFiles, diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts index 54bca996ef4..8bb47c3bfd8 100644 --- a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-ids.ts @@ -4,12 +4,7 @@ import type { PersistedOpenFile } from '../../../../../../shared/workspace-sessi import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' import type { OpenFile } from '../types/open-file' import { isEditorTabContentType } from '../tabs/editor-tab-content-type' -import { - buildOwnedEditorFileId, - isEditorFileIdOccupiedByOtherOwner, - isSameEditorOwner, - runtimeOwnerKey -} from './editor-file-ids' +import { buildOwnedEditorFileId, runtimeOwnerKey } from './editor-file-ids' export function shouldHydrateWithOwnedEditorFileId( worktreeId: string, @@ -39,29 +34,58 @@ export type LegacyHydratedEditorFile = Pick< 'id' | 'filePath' | 'worktreeId' | 'runtimeEnvironmentId' | 'markdownPreviewSourceFileId' > -export function resolveLegacyHydratedEditorFileId( - files: readonly LegacyHydratedEditorFile[], - persistedFile: PersistedOpenFile, - worktreeId: string -): string { - const existing = files.find( - (file) => - file.filePath === persistedFile.filePath && - isSameEditorOwner(file, worktreeId, persistedFile.runtimeEnvironmentId) - ) - if (existing) { - return existing.id +export class LegacyHydratedEditorFileIndex { + private readonly filesByPath = new Map>() + private readonly ownersById = new Map>() + + private ownerKey(worktreeId: string, runtimeEnvironmentId: string | null | undefined): string { + return JSON.stringify([worktreeId, runtimeOwnerKey(runtimeEnvironmentId)]) } - return files.some((file) => - isEditorFileIdOccupiedByOtherOwner( - file, - persistedFile.filePath, - worktreeId, - persistedFile.runtimeEnvironmentId + + hasOwner(file: PersistedOpenFile, worktreeId: string): boolean { + return ( + this.filesByPath + .get(file.filePath) + ?.has(this.ownerKey(worktreeId, file.runtimeEnvironmentId)) ?? false ) - ) - ? buildOwnedEditorFileId(persistedFile.filePath, worktreeId, persistedFile.runtimeEnvironmentId) - : persistedFile.filePath + } + + resolve(file: PersistedOpenFile, worktreeId: string): string { + const owner = this.ownerKey(worktreeId, file.runtimeEnvironmentId) + const existing = this.filesByPath.get(file.filePath)?.get(owner) + if (existing !== undefined) { + return existing + } + const occupied = this.ownersById.get(file.filePath) + return occupied && (occupied.size > 1 || !occupied.has(owner)) + ? buildOwnedEditorFileId(file.filePath, worktreeId, file.runtimeEnvironmentId) + : file.filePath + } + + add(file: LegacyHydratedEditorFile): void { + const owner = this.ownerKey(file.worktreeId, file.runtimeEnvironmentId) + let files = this.filesByPath.get(file.filePath) + if (!files) { + files = new Map() + this.filesByPath.set(file.filePath, files) + } + if (!files.has(owner)) { + files.set(owner, file.id) + } + this.addIdOwner(file.id, owner) + if (file.markdownPreviewSourceFileId !== undefined) { + this.addIdOwner(file.markdownPreviewSourceFileId, owner) + } + } + + private addIdOwner(id: string, owner: string): void { + let owners = this.ownersById.get(id) + if (!owners) { + owners = new Set() + this.ownersById.set(id, owners) + } + owners.add(owner) + } } export function migrateEditorFileId( diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts new file mode 100644 index 00000000000..d554597b905 --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-index.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import type { PersistedOpenFile } from '../../../../../../shared/workspace-session-state-types' +import { + LegacyHydratedEditorFileIndex, + type LegacyHydratedEditorFile +} from './hydrated-editor-file-ids' +import { + buildOwnedEditorFileId, + isEditorFileIdOccupiedByOtherOwner, + isSameEditorOwner +} from './editor-file-ids' + +function persisted(filePath: string, runtimeEnvironmentId: string | null): PersistedOpenFile { + return { + filePath, + runtimeEnvironmentId, + relativePath: filePath, + worktreeId: '', + language: 'text' + } +} + +function referenceId( + files: LegacyHydratedEditorFile[], + file: PersistedOpenFile, + worktreeId: string +) { + const existing = files.find( + (prior) => + prior.filePath === file.filePath && + isSameEditorOwner(prior, worktreeId, file.runtimeEnvironmentId) + ) + if (existing) { + return existing.id + } + return files.some((prior) => + isEditorFileIdOccupiedByOtherOwner(prior, file.filePath, worktreeId, file.runtimeEnvironmentId) + ) + ? buildOwnedEditorFileId(file.filePath, worktreeId, file.runtimeEnvironmentId) + : file.filePath +} + +describe('legacy hydrated editor file index', () => { + it('matches the old lookup for mixed owners, first-wins duplicates and ID reservations', () => { + const index = new LegacyHydratedEditorFileIndex() + const prior: LegacyHydratedEditorFile[] = [] + const paths = [ + '/same.ts', + 'C:\\work\\same.ts', + '/other.ts', + 'editor:folder:local:%2Fsame.ts', + '', + '/preview.md' + ] + const worktrees = ['folder:one', 'wt:two', 'floating-terminals'] + const runtimes = [null, '', ' ', 'local', 'peer', ' peer ', 'a:b', '["a","b"]'] + for (let step = 0; step < 120; step++) { + for (const filePath of paths) { + for (const worktreeId of worktrees) { + for (const runtime of runtimes) { + const file = persisted(filePath, runtime) + expect(index.hasOwner(file, worktreeId)).toBe( + prior.some( + (row) => row.filePath === filePath && isSameEditorOwner(row, worktreeId, runtime) + ) + ) + expect(index.resolve(file, worktreeId)).toBe(referenceId(prior, file, worktreeId)) + } + } + } + const row: LegacyHydratedEditorFile = { + filePath: paths[step % paths.length], + id: paths[(step * 3) % paths.length], + worktreeId: worktrees[Math.floor(step / paths.length) % worktrees.length], + runtimeEnvironmentId: runtimes[Math.floor(step / worktrees.length) % runtimes.length], + ...(step % 4 === 0 ? { markdownPreviewSourceFileId: '/preview.md' } : {}) + } + index.add(row) + prior.push(row) + } + }) +}) diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts new file mode 100644 index 00000000000..38f45353f48 --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-file-selection.ts @@ -0,0 +1,40 @@ +import type { OpenFile } from '../types/open-file' +import { migrateEditorFileId } from './hydrated-editor-file-ids' + +export function resolveHydratedEditorFileSelection(args: { + openFiles: readonly Pick[] + validWorktreeIds: ReadonlySet + activeWorktreeId: string | null + persistedActiveFileIds: Record + migrations: Record> +}): { activeFileId: string | null; activeFileIdByWorktree: Record } { + const workspaces = new Map }>() + for (const file of args.openFiles) { + let workspace = workspaces.get(file.worktreeId) + if (!workspace) { + workspace = { firstFileId: file.id, ids: new Set() } + workspaces.set(file.worktreeId, workspace) + } + workspace.ids.add(file.id) + } + const selectedId = (worktreeId: string): string | null => { + const workspace = workspaces.get(worktreeId) + const persistedId = migrateEditorFileId( + args.migrations, + worktreeId, + args.persistedActiveFileIds[worktreeId] + ) + return persistedId && workspace?.ids.has(persistedId) + ? persistedId + : (workspace?.firstFileId ?? null) + } + return { + activeFileId: args.activeWorktreeId ? selectedId(args.activeWorktreeId) : null, + activeFileIdByWorktree: Object.fromEntries( + [...args.validWorktreeIds].flatMap((worktreeId) => { + const fileId = selectedId(worktreeId) + return fileId ? [[worktreeId, fileId]] : [] + }) + ) + } +} diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-frontmatter.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-frontmatter.ts new file mode 100644 index 00000000000..0a61676489c --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-frontmatter.ts @@ -0,0 +1,50 @@ +export function resolveHydratedEditorFrontmatter( + persistedVisibility: Record, + openFileIds: ReadonlySet, + migrationsByWorktree: Record> +): Record { + const hiddenIds = new Set( + Object.entries(persistedVisibility) + .filter(([, visible]) => !visible) + .map(([id]) => id) + ) + if (hiddenIds.size === 0) { + return {} + } + const migratedIds = new Map() + const addMigration = (from: string, to: string | undefined): void => { + if (!to || !openFileIds.has(to)) { + return + } + const targets = migratedIds.get(from) + if (targets) { + targets.push(to) + } else { + migratedIds.set(from, [to]) + } + } + for (const migrations of Object.values(migrationsByWorktree)) { + // Scan the smaller side so sparse overrides never pay for a large migration map. + if (migrations.size < hiddenIds.size) { + for (const [from, to] of migrations) { + if (hiddenIds.has(from)) { + addMigration(from, to) + } + } + } else { + for (const from of hiddenIds) { + addMigration(from, migrations.get(from)) + } + } + } + const hidden = new Map() + for (const persistedId of hiddenIds) { + if (openFileIds.has(persistedId)) { + hidden.set(persistedId, false) + } + for (const migratedId of migratedIds.get(persistedId) ?? []) { + hidden.set(migratedId, false) + } + } + return Object.fromEntries(hidden) +} diff --git a/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts new file mode 100644 index 00000000000..3e0a583f9e6 --- /dev/null +++ b/src/renderer/src/store/slices/editor/file-ids/hydrated-editor-projections.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { resolveHydratedEditorFileSelection } from './hydrated-editor-file-selection' +import { resolveHydratedEditorFrontmatter } from './hydrated-editor-frontmatter' +import { migrateEditorFileId } from './hydrated-editor-file-ids' + +type SelectionInput = Parameters[0] +function referenceSelection(args: SelectionInput) { + const select = (worktreeId: string) => { + const persisted = migrateEditorFileId( + args.migrations, + worktreeId, + args.persistedActiveFileIds[worktreeId] + ) + return persisted && + args.openFiles.some((file) => file.id === persisted && file.worktreeId === worktreeId) + ? persisted + : (args.openFiles.find((file) => file.worktreeId === worktreeId)?.id ?? null) + } + return { + activeFileId: args.activeWorktreeId ? select(args.activeWorktreeId) : null, + activeFileIdByWorktree: Object.fromEntries( + [...args.validWorktreeIds].flatMap((worktreeId) => { + const fileId = select(worktreeId) + return fileId ? [[worktreeId, fileId]] : [] + }) + ) + } +} + +function referenceFrontmatter( + visibility: Record, + openIds: Set, + migrations: Record> +) { + const hidden = new Map() + for (const [id, visible] of Object.entries(visibility)) { + if (visible) { + continue + } + if (openIds.has(id)) { + hidden.set(id, false) + } + for (const migration of Object.values(migrations)) { + const target = migration.get(id) + if (target && openIds.has(target)) { + hidden.set(target, false) + } + } + } + return Object.fromEntries(hidden) +} + +class CountedMigrations extends Map { + reads = 0 + override get(key: string): string | undefined { + this.reads++ + return super.get(key) + } + override *[Symbol.iterator](): MapIterator<[string, string]> { + for (const entry of super[Symbol.iterator]()) { + this.reads++ + yield entry + } + } +} + +describe('hydrated editor selection', () => { + it('indexes files once across many workspace selections', () => { + const count = 1_000 + let reads = 0 + const files = Array.from({ length: count }, (_, index) => ({ + get id() { + reads++ + return `file-${index}` + }, + get worktreeId() { + reads++ + return `folder:${index}` + } + })) + const args: SelectionInput = { + openFiles: files, + validWorktreeIds: new Set(files.map((file) => file.worktreeId)), + activeWorktreeId: 'folder:999', + persistedActiveFileIds: Object.fromEntries(files.map((file) => [file.worktreeId, file.id])), + migrations: {} + } + reads = 0 + const expected = referenceSelection(args) + expect(reads).toBeGreaterThan((count * count) / 2) + reads = 0 + expect(resolveHydratedEditorFileSelection(args)).toEqual(expected) + expect(reads).toBeLessThan(count * 6) + }) + + it('preserves owner checks, migration, first-file fallbacks and empty IDs', () => { + for (let sample = 0; sample < 100; sample++) { + const args: SelectionInput = { + openFiles: Array.from({ length: 20 }, (_, index) => ({ + id: (index + sample) % 7 ? `file-${(index + sample) % 9}` : '', + worktreeId: `wt-${(index * 3 + sample) % 5}` + })), + activeWorktreeId: sample % 3 ? `wt-${sample % 7}` : null, + validWorktreeIds: new Set(Array.from({ length: 7 }, (_, index) => `wt-${index}`)), + persistedActiveFileIds: { + 'wt-0': 'legacy', + 'wt-1': 'file-3', + 'wt-2': 'absent', + 'wt-3': '' + }, + migrations: { 'wt-0': new Map([['legacy', 'file-1']]) } + } + expect(resolveHydratedEditorFileSelection(args)).toEqual(referenceSelection(args)) + } + }) +}) + +describe('hydrated frontmatter migration', () => { + it('avoids workspace-by-override fanout', () => { + const count = 1_000 + const visibility = Object.fromEntries( + Array.from({ length: count }, (_, i) => [`old-${i}`, false]) + ) + const openIds = new Set(Array.from({ length: count }, (_, i) => `new-${i}`)) + const migrations = Object.fromEntries( + Array.from({ length: count }, (_, i) => [ + `wt-${i}`, + new CountedMigrations([[`old-${i}`, `new-${i}`]]) + ]) + ) + const expected = referenceFrontmatter(visibility, openIds, migrations) + expect(Object.values(migrations).reduce((sum, map) => sum + map.reads, 0)).toBe(count * count) + for (const map of Object.values(migrations)) { + map.reads = 0 + } + expect(resolveHydratedEditorFrontmatter(visibility, openIds, migrations)).toEqual(expected) + expect(Object.values(migrations).reduce((sum, map) => sum + map.reads, 0)).toBe(count) + }) + + it('does no migration scan without overrides and one lookup for a sparse override', () => { + const map = new CountedMigrations( + Array.from({ length: 10_000 }, (_, i) => [`old-${i}`, `new-${i}`]) + ) + const ids = new Set(['new-9999']) + expect(resolveHydratedEditorFrontmatter({ 'old-0': true }, ids, { wt: map })).toEqual({}) + expect(map.reads).toBe(0) + expect(resolveHydratedEditorFrontmatter({ 'old-9999': false }, ids, { wt: map })).toEqual({ + 'new-9999': false + }) + expect(map.reads).toBe(1) + }) + + it('preserves insertion order, multiple owners, direct IDs and missing targets', () => { + for (let sample = 0; sample < 50; sample++) { + const visibility = Object.fromEntries( + Array.from({ length: 15 }, (_, i) => [`file-${i}`, (i + sample) % 4 === 0]) + ) + const ids = new Set(Array.from({ length: 10 }, (_, i) => `file-${(i + sample) % 16}`)) + const migrations = Object.fromEntries( + Array.from({ length: 5 }, (_, w) => [ + `wt-${w}`, + new Map( + Array.from({ length: 8 }, (_, i) => [ + `file-${(i + w) % 15}`, + `file-${(i + sample) % 17}` + ]) + ) + ]) + ) + expect(Object.entries(resolveHydratedEditorFrontmatter(visibility, ids, migrations))).toEqual( + Object.entries(referenceFrontmatter(visibility, ids, migrations)) + ) + } + }) +})