diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 110f358dd99..c647fbfb779 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -23,6 +23,9 @@ export type EditorPathMutationTarget = { relativePath: string runtimeEnvironmentId?: string | null allowLocalWindowsWslAliases?: true + indexedOpenFiles?: { + matches: (openFiles: OpenFile[]) => OpenFile[] + } } export type EditorSaveQuiesceTarget = { fileId: string } | EditorPathMutationTarget @@ -127,6 +130,9 @@ export function getOpenFilesForExternalFileChange( openFiles: OpenFile[], target: EditorPathMutationTarget ): OpenFile[] { + if (target.indexedOpenFiles) { + return target.indexedOpenFiles.matches(openFiles) + } const absolutePath = joinPath(target.worktreePath, target.relativePath) const hasRuntimeOwnerFilter = Object.hasOwn(target, 'runtimeEnvironmentId') const targetRuntimeOwner = target.runtimeEnvironmentId?.trim() || null diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts new file mode 100644 index 00000000000..68e33bc6baf --- /dev/null +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { indexEditorExternalWatchBatchPaths } from './editor-external-watch-path-index' + +const wslScope = { + worktreeId: 'wt-wsl', + worktreePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo', + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true as const +} + +function file(overrides: Partial & Pick): OpenFile { + return { + relativePath: 'file.ts', + worktreeId: 'wt-wsl', + mode: 'edit', + isDirty: false, + content: '', + language: 'typescript', + ...overrides + } as OpenFile +} + +describe('editor external watch path batch index', () => { + it('matches UNC aliases for updates, deletes, and restored tombstones', () => { + const restored = file({ + id: 'restored', + filePath: '//wsl.localhost/Ubuntu/workspace/repo/file.ts', + externalMutation: 'deleted' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: wslScope.worktreePath, + events: [ + { + kind: 'delete', + absolutePath: '\\\\wsl$\\Ubuntu\\workspace\\repo\\file.ts' + }, + { + kind: 'create', + absolutePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo\\file.ts' + } + ] + }, + [restored], + wslScope + ) + + expect(index.deletedOpenEditors).toEqual([ + { + file: restored, + normalizedDeletePath: '//wsl/ubuntu/workspace/repo/file.ts' + } + ]) + expect(index.matchesCreateOrUpdate(restored)).toBe(true) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([restored]) + }) + + it('matches /mnt drive aliases without folding WSL filesystem case', () => { + const mounted = file({ + id: 'mounted', + filePath: '//wsl.localhost/Ubuntu/mnt/c/Repo/File.ts' + }) + const nativeScope = { ...wslScope, worktreePath: 'C:\\Repo' } + const matching = indexEditorExternalWatchBatchPaths( + { + worktreePath: nativeScope.worktreePath, + events: [{ kind: 'update', absolutePath: 'c:\\repo\\file.ts' }] + }, + [mounted], + nativeScope + ) + const wrongLinuxCase = indexEditorExternalWatchBatchPaths( + { + worktreePath: nativeScope.worktreePath, + events: [{ kind: 'update', absolutePath: 'C:\\Repo\\File.ts' }] + }, + [ + file({ + id: 'linux-case', + filePath: '//wsl.localhost/Ubuntu/home/Alice/File.ts' + }) + ], + nativeScope + ) + + expect(matching.matchingOpenFiles(matching.changes[0])).toEqual([mounted]) + expect(wrongLinuxCase.matchingOpenFiles(wrongLinuxCase.changes[0])).toEqual([]) + }) + + it('keeps aliases literal for SSH, runtimes, and POSIX scopes', () => { + const restored = file({ + id: 'restored', + filePath: '//wsl.localhost/Ubuntu/workspace/repo/file.ts' + }) + const payload = { + worktreePath: wslScope.worktreePath, + events: [ + { + kind: 'update' as const, + absolutePath: '\\\\wsl.localhost\\Ubuntu\\workspace\\repo\\file.ts' + } + ] + } + + for (const scope of [ + { ...wslScope, allowLocalWindowsWslAliases: undefined }, + { ...wslScope, runtimeEnvironmentId: 'env-1', allowLocalWindowsWslAliases: undefined } + ]) { + const index = indexEditorExternalWatchBatchPaths(payload, [restored], scope) + expect(index.matchingOpenFiles(index.changes[0])).toEqual([]) + } + + const posix = indexEditorExternalWatchBatchPaths( + { + worktreePath: '/srv/repo', + events: [{ kind: 'update', absolutePath: '/srv/repo/file.ts' }] + }, + [restored], + { + ...wslScope, + worktreePath: '/srv/repo', + allowLocalWindowsWslAliases: undefined + } + ) + expect(posix.matchingOpenFiles(posix.changes[0])).toEqual([]) + }) + + it('filters owners and preserves open-file ordering across edit and diff matches', () => { + const edit = file({ id: 'edit', filePath: 'C:\\Repo\\file.ts' }) + const runtime = file({ + id: 'runtime', + filePath: 'C:\\Repo\\file.ts', + runtimeEnvironmentId: 'env-1' + }) + const diff = file({ + id: 'diff', + filePath: 'C:\\Repo\\file.ts', + mode: 'diff', + diffSource: 'unstaged' + }) + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: 'C:\\Repo', + events: [{ kind: 'update', absolutePath: 'c:\\repo\\file.ts' }] + }, + [diff, runtime, edit], + { ...wslScope, worktreePath: 'C:\\Repo' } + ) + + expect(index.matchingOpenFiles(index.changes[0]).map(({ id }) => id)).toEqual(['diff', 'edit']) + }) + + it('deduplicates repeated events and detects only working-tree combined diffs', () => { + const index = indexEditorExternalWatchBatchPaths( + { + worktreePath: 'C:\\Repo', + events: [ + { kind: 'create', absolutePath: 'C:\\Repo\\file.ts' }, + { kind: 'update', absolutePath: 'C:\\Repo\\file.ts' }, + { kind: 'update', absolutePath: 'C:\\Repo\\folder', isDirectory: true } + ] + }, + [ + file({ + id: 'combined', + filePath: 'C:\\Repo', + mode: 'diff', + diffSource: 'combined-uncommitted' + }), + file({ + id: 'branch', + filePath: 'C:\\Repo', + mode: 'diff', + diffSource: 'combined-branch' + }) + ], + { ...wslScope, worktreePath: 'C:\\Repo' } + ) + + expect(index.changes.map(({ relativePath }) => relativePath)).toEqual(['file.ts']) + expect(index.createOrUpdatePaths.size).toBe(1) + expect(index.hasCombinedDiffConsumer).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/editor-external-watch-path-index.ts b/src/renderer/src/components/editor/editor-external-watch-path-index.ts new file mode 100644 index 00000000000..f1cb7054c67 --- /dev/null +++ b/src/renderer/src/components/editor/editor-external-watch-path-index.ts @@ -0,0 +1,256 @@ +import { joinPath } from '@/lib/path' +import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' +import type { OpenFile } from '@/store/slices/editor' +import type { FsChangedPayload } from '../../../../shared/types' +import { + getLocalWindowsWslPathIdentity, + normalizeRuntimePathForComparison, + type LocalWindowsWslPathIdentity +} from '../../../../shared/cross-platform-path' + +type WatchScope = { + worktreeId: string + worktreePath: string + runtimeEnvironmentId: string | null + allowLocalWindowsWslAliases?: true +} + +type IndexedPath = { + absolutePath: string + identity: LocalWindowsWslPathIdentity +} + +type IndexedOpenFile = { + file: OpenFile + index: number + identity: LocalWindowsWslPathIdentity | null +} + +export type IndexedExternalWatchChange = IndexedPath & { + relativePath: string +} + +export type EditorExternalWatchBatchPathIndex = { + createOrUpdatePaths: ReadonlyMap + changes: readonly IndexedExternalWatchChange[] + deletedOpenEditors: readonly { file: OpenFile; normalizedDeletePath: string }[] + hasCombinedDiffConsumer: boolean + matchesCreateOrUpdate: (file: OpenFile) => boolean + matchingOpenFiles: ( + change: IndexedExternalWatchChange, + currentOpenFiles?: OpenFile[] + ) => OpenFile[] +} + +function openFileRuntimeOwner(file: Pick): string | null { + return file.runtimeEnvironmentId?.trim() || null +} + +function addToListMap(map: Map, key: string, value: T): void { + const existing = map.get(key) + if (existing) { + existing.push(value) + } else { + map.set(key, [value]) + } +} + +class IndexedPathLookup { + private readonly direct = new Map() + private readonly aliases = new Map() + private readonly wslAliases = new Map() + + constructor(private readonly allowAliases: boolean) {} + + add(path: IndexedPath, value: T): void { + this.direct.set(path.identity.normalizedPath, value) + if (!this.allowAliases) { + return + } + this.aliases.set(path.identity.aliasComparisonPath, value) + if (path.identity.isWslUnc) { + this.wslAliases.set(path.identity.aliasComparisonPath, value) + } + } + + get(identity: LocalWindowsWslPathIdentity): T | undefined { + const direct = this.direct.get(identity.normalizedPath) + if (direct !== undefined || !this.allowAliases) { + return direct + } + return identity.isWslUnc + ? this.aliases.get(identity.aliasComparisonPath) + : this.wslAliases.get(identity.aliasComparisonPath) + } +} + +function pathIdentity(value: string, allowAliases: boolean): LocalWindowsWslPathIdentity { + if (allowAliases) { + return getLocalWindowsWslPathIdentity(value) + } + const normalizedPath = normalizeRuntimePathForComparison(value) + return { normalizedPath, aliasComparisonPath: normalizedPath, isWslUnc: false } +} + +function collectMatchingFiles( + direct: readonly IndexedOpenFile[], + aliases: readonly IndexedOpenFile[], + diffs: readonly IndexedOpenFile[] +): OpenFile[] { + const byIndex = new Map() + for (const entry of [...direct, ...aliases, ...diffs]) { + byIndex.set(entry.index, entry.file) + } + return [...byIndex.entries()].sort(([left], [right]) => left - right).map(([, file]) => file) +} + +class IndexedOpenFileLookup { + private readonly directEditors = new Map() + private readonly aliasEditors = new Map() + private readonly wslAliasEditors = new Map() + private readonly diffsByRelativePath = new Map() + readonly indexedOpenFiles = new Map() + readonly hasCombinedDiffConsumer: boolean + + constructor( + openFiles: OpenFile[], + scope: WatchScope, + private readonly allowAliases: boolean + ) { + let hasCombinedDiffConsumer = false + for (const [index, file] of openFiles.entries()) { + if ( + file.worktreeId !== scope.worktreeId || + openFileRuntimeOwner(file) !== scope.runtimeEnvironmentId + ) { + continue + } + if ( + file.mode === 'diff' && + (file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-all') + ) { + hasCombinedDiffConsumer = true + continue + } + if (file.mode === 'diff') { + if (file.diffSource === 'unstaged' || file.diffSource === 'staged') { + addToListMap(this.diffsByRelativePath, file.relativePath, { + file, + index, + identity: null + }) + } + continue + } + if (file.mode !== 'edit' && file.mode !== 'markdown-preview') { + continue + } + const identity = pathIdentity(file.filePath, allowAliases) + const indexedFile = { file, index, identity } + this.indexedOpenFiles.set(file.id, indexedFile) + addToListMap(this.directEditors, file.filePath, indexedFile) + if (allowAliases) { + addToListMap(this.aliasEditors, identity.aliasComparisonPath, indexedFile) + if (identity.isWslUnc) { + addToListMap(this.wslAliasEditors, identity.aliasComparisonPath, indexedFile) + } + } + } + this.hasCombinedDiffConsumer = hasCombinedDiffConsumer + } + + matchingOpenFiles(change: IndexedExternalWatchChange): OpenFile[] { + const aliases = !this.allowAliases + ? [] + : change.identity.isWslUnc + ? (this.aliasEditors.get(change.identity.aliasComparisonPath) ?? []) + : (this.wslAliasEditors.get(change.identity.aliasComparisonPath) ?? []) + return collectMatchingFiles( + this.directEditors.get(change.absolutePath) ?? [], + aliases, + this.diffsByRelativePath.get(change.relativePath) ?? [] + ) + } +} + +export function indexEditorExternalWatchBatchPaths( + payload: FsChangedPayload, + openFiles: OpenFile[], + scope: WatchScope +): EditorExternalWatchBatchPathIndex { + const allowAliases = scope.allowLocalWindowsWslAliases === true + const createOrUpdateLookup = new IndexedPathLookup(allowAliases) + const deleteLookup = new IndexedPathLookup(allowAliases) + const createOrUpdatePaths = new Map() + const changesByRelativePath = new Map() + + for (const event of payload.events) { + if (event.kind === 'overflow') { + continue + } + const eventPath: IndexedPath = { + absolutePath: event.absolutePath, + identity: pathIdentity(event.absolutePath, allowAliases) + } + if (event.kind === 'delete') { + deleteLookup.add(eventPath, eventPath) + continue + } + if (event.isDirectory !== true) { + createOrUpdatePaths.set(eventPath.identity.normalizedPath, event.absolutePath) + createOrUpdateLookup.add(eventPath, eventPath) + } + const relativePath = getExternalFileChangeRelativePath( + scope.worktreePath, + event.absolutePath, + event.isDirectory + ) + if (relativePath && !changesByRelativePath.has(relativePath)) { + const absolutePath = joinPath(scope.worktreePath, relativePath) + changesByRelativePath.set(relativePath, { + relativePath, + absolutePath, + identity: eventPath.identity + }) + } + } + + const initialOpenFileLookup = new IndexedOpenFileLookup(openFiles, scope, allowAliases) + const openFileLookups = new WeakMap() + openFileLookups.set(openFiles, initialOpenFileLookup) + const getOpenFileLookup = (currentOpenFiles: OpenFile[]): IndexedOpenFileLookup => { + const existing = openFileLookups.get(currentOpenFiles) + if (existing) { + return existing + } + const indexed = new IndexedOpenFileLookup(currentOpenFiles, scope, allowAliases) + openFileLookups.set(currentOpenFiles, indexed) + return indexed + } + const matchesCreateOrUpdate = (file: OpenFile): boolean => { + const identity = + initialOpenFileLookup.indexedOpenFiles.get(file.id)?.identity ?? + pathIdentity(file.filePath, allowAliases) + return createOrUpdateLookup.get(identity) !== undefined + } + const deletedOpenEditors: { file: OpenFile; normalizedDeletePath: string }[] = [] + for (const indexedFile of initialOpenFileLookup.indexedOpenFiles.values()) { + const deletedPath = deleteLookup.get(indexedFile.identity!) + if (deletedPath) { + deletedOpenEditors.push({ + file: indexedFile.file, + normalizedDeletePath: deletedPath.identity.normalizedPath + }) + } + } + + return { + createOrUpdatePaths, + changes: [...changesByRelativePath.values()], + deletedOpenEditors, + hasCombinedDiffConsumer: initialOpenFileLookup.hasCombinedDiffConsumer, + matchesCreateOrUpdate, + matchingOpenFiles: (change, currentOpenFiles = openFiles) => + getOpenFileLookup(currentOpenFiles).matchingOpenFiles(change) + } +} diff --git a/src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts new file mode 100644 index 00000000000..cfcb973d968 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-complexity.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as EditorAutosaveModule from '@/components/editor/editor-autosave' +import type * as CrossPlatformPathModule from '../../../shared/cross-platform-path' +import type { FsChangedPayload } from '../../../shared/types' + +const pathOperationCounts = vi.hoisted(() => ({ + aliasComparisons: 0, + normalizations: 0, + identities: 0 +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('@/components/editor/editor-autosave', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, notifyEditorExternalFileChange: vi.fn() } +}) +vi.mock('../../../shared/cross-platform-path', async (importOriginal) => { + type PathModuleWithIdentity = typeof CrossPlatformPathModule & { + getLocalWindowsWslPathIdentity?: (value: string) => unknown + } + const actual = await importOriginal() + return { + ...actual, + normalizeRuntimePathForComparison: (value: string) => { + pathOperationCounts.normalizations++ + return actual.normalizeRuntimePathForComparison(value) + }, + areLocalWindowsWslPathAliases: (left: string, right: string) => { + pathOperationCounts.aliasComparisons++ + return actual.areLocalWindowsWslPathAliases(left, right) + }, + ...(actual.getLocalWindowsWslPathIdentity + ? { + getLocalWindowsWslPathIdentity: (value: string) => { + pathOperationCounts.identities++ + return actual.getLocalWindowsWslPathIdentity!(value) + } + } + : {}) + } +}) + +import { useAppStore } from '@/store' +import { + getOpenFilesForExternalFileChange, + notifyEditorExternalFileChange +} from '@/components/editor/editor-autosave' +import { createExternalWatchEventHandler } from './useEditorExternalWatch' + +const EVENT_COUNT = 5_000 +const OPEN_FILE_COUNT = 100 +const payloadWorktreePath = '\\\\wsl.localhost\\Ubuntu\\workspace\\repo' + +describe('external watcher path matching complexity', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + vi.clearAllMocks() + pathOperationCounts.aliasComparisons = 0 + pathOperationCounts.normalizations = 0 + pathOperationCounts.identities = 0 + vi.stubGlobal('window', { dispatchEvent: vi.fn() }) + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('bounds a maximum-sized opposite-alias batch by events plus open files', () => { + const openFiles = Array.from({ length: OPEN_FILE_COUNT }, (_, index) => ({ + id: `//wsl.localhost/Ubuntu/workspace/repo/file-${index}.ts`, + filePath: `//wsl.localhost/Ubuntu/workspace/repo/file-${index}.ts`, + relativePath: `file-${index}.ts`, + worktreeId: 'wt-wsl', + mode: 'edit' as const, + isDirty: false + })) + const initialOpenFiles = [ + ...openFiles, + { + id: 'combined-diff', + filePath: payloadWorktreePath, + relativePath: '', + worktreeId: 'wt-wsl', + mode: 'diff' as const, + diffSource: 'combined-uncommitted' as const, + isDirty: false + } + ] + vi.mocked(useAppStore.getState).mockReturnValue({ + openFiles: initialOpenFiles, + setExternalMutation: vi.fn() + } as never) + const payload: FsChangedPayload = { + worktreePath: payloadWorktreePath, + events: Array.from({ length: EVENT_COUNT }, (_, index) => ({ + kind: 'update' as const, + absolutePath: `\\\\wsl.localhost\\Ubuntu\\workspace\\repo\\file-${index}.ts` + })) + } + const { handleFsChanged, dispose } = createExternalWatchEventHandler(() => ({ + worktreeId: 'wt-wsl', + worktreePath: payload.worktreePath, + connectionId: undefined, + runtimeEnvironmentId: null, + allowLocalWindowsWslAliases: true + })) + + const startedAt = process.hrtime.bigint() + handleFsChanged(payload) + const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000 + vi.advanceTimersByTime(100) + expect(notifyEditorExternalFileChange).toHaveBeenCalledTimes(EVENT_COUNT) + // Why: a tab/store update during debounce must rebuild the index once, not rescan per event. + const currentOpenFiles = initialOpenFiles.map((file) => ({ ...file })) + for (const [notification] of vi.mocked(notifyEditorExternalFileChange).mock.calls) { + getOpenFilesForExternalFileChange(currentOpenFiles as never, notification) + } + const pathOperations = + pathOperationCounts.aliasComparisons + + pathOperationCounts.normalizations + + pathOperationCounts.identities + + console.info('STA-3942 watcher oracle', { ...pathOperationCounts, pathOperations, elapsedMs }) + expect(pathOperations).toBeLessThanOrEqual(3 * (EVENT_COUNT + initialOpenFiles.length)) + dispose() + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.test.ts b/src/renderer/src/hooks/useEditorExternalWatch.test.ts index 747babef0e1..891bbc35e4e 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.test.ts @@ -287,7 +287,8 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { filePath: 'C:\\Repo\\notes.md', relativePath: 'notes.md', mode: 'edit' as const, - isDirty: false + isDirty: false, + runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ openFiles: [file], @@ -324,7 +325,8 @@ describe('createExternalWatchEventHandler tombstone coalescing', () => { filePath: '//Server/Share/Repo/notes.md', relativePath: 'notes.md', mode: 'edit' as const, - isDirty: false + isDirty: false, + runtimeEnvironmentId: 'env-1' } vi.mocked(useAppStore.getState).mockReturnValue({ openFiles: [file], diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 5b0236d82f7..c07f9bd25c1 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -2,9 +2,7 @@ import { useEffect, useRef } from 'react' import { useAppStore, type AppState } from '@/store' import { basename, joinPath } from '@/lib/path' -import { getExternalFileChangeRelativePath } from '@/components/right-sidebar/useFileExplorerWatch' import { - areLocalWindowsWslPathAliases, isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' @@ -12,9 +10,9 @@ import { canAutoSaveOpenFile, getOpenFilesForExternalFileChange, isExternalReloadableEditorTab, - isWorkingTreeCombinedDiffTab, notifyEditorExternalFileChange } from '@/components/editor/editor-autosave' +import { indexEditorExternalWatchBatchPaths } from '@/components/editor/editor-external-watch-path-index' import { clearSelfWrite, getRecentSelfWrite, @@ -87,6 +85,9 @@ type ExternalWatchNotification = { relativePath: string runtimeEnvironmentId: string | null allowLocalWindowsWslAliases?: true + indexedOpenFiles?: { + matches: (openFiles: OpenFile[]) => OpenFile[] + } } function localWslAliasOption( @@ -97,26 +98,6 @@ function localWslAliasOption( : {} } -function findMatchingWatchedPath( - watchedPaths: ReadonlyMap, - filePath: string, - allowLocalWslAliases?: true -): string | undefined { - const directMatch = watchedPaths.get(normalizeRuntimePathForComparison(filePath)) - if (directMatch !== undefined) { - return directMatch - } - if (allowLocalWslAliases !== true || !isLocalWindowsDesktopClient()) { - return undefined - } - for (const watchedPath of watchedPaths.values()) { - if (areLocalWindowsWslPathAliases(filePath, watchedPath)) { - return watchedPath - } - } - return undefined -} - function isLocalHostStamp(value: string | null | undefined): boolean { return parseExecutionHostId(value)?.kind === 'local' } @@ -525,19 +506,15 @@ export function createExternalWatchEventHandler( ) } - // Why: collect create/update paths first to cancel any pending same-path delete — this absorbs the macOS atomic-write delete→create split across two payloads. - const createOrUpdatePaths = new Map() - for (const evt of payload.events) { - if (evt.isDirectory === true) { - continue - } - if (evt.kind === 'create' || evt.kind === 'update') { - createOrUpdatePaths.set( - normalizeRuntimePathForComparison(evt.absolutePath), - evt.absolutePath - ) - } - } + // Why: one batch index keeps local WSL alias normalization out of event×tab loops. + const openFilesAtStart = useAppStore.getState().openFiles + const batchPaths = indexEditorExternalWatchBatchPaths(payload, openFilesAtStart, { + worktreeId: target.worktreeId, + worktreePath: target.worktreePath, + runtimeEnvironmentId: target.runtimeEnvironmentId, + ...localWslAliasOption(target) + }) + const createOrUpdatePaths = batchPaths.createOrUpdatePaths for (const createdPath of createOrUpdatePaths.keys()) { const key = pendingKey(target.worktreeId, target.runtimeEnvironmentId, createdPath) const existing = pendingDeletes.get(key) @@ -548,25 +525,16 @@ export function createExternalWatchEventHandler( } // Why: mark editor tabs deleted/renamed instead of closing them so the user keeps in-memory content; a paired create means rename, a lone delete is hard. - // Why: snapshot openFiles once so the delete/rename helpers share a consistent view without N store reads per payload. - const openFilesAtStart = useAppStore.getState().openFiles - const deletedOpenEditorIdsRaw = collectDeletedOpenEditorIds( - payload, - target.worktreeId, - target.runtimeEnvironmentId, - openFilesAtStart, - target.allowLocalWindowsWslAliases - ) + // Why: snapshot openFiles once so delete, rename, and update matching share one indexed view. + const deletedOpenEditorsRaw = batchPaths.deletedOpenEditors // Only pay the per-id lookup to suppress a move's own source-delete while a move is live; else the batch stays O(deletes). - const deletedOpenEditorIds = hasActiveEditorPathMoves() - ? deletedOpenEditorIdsRaw.filter((fileId) => { - const file = openFilesAtStart.find((f) => f.id === fileId) - return ( - !file || + const deletedOpenEditors = hasActiveEditorPathMoves() + ? deletedOpenEditorsRaw.filter( + ({ file }) => !isActiveMoveSourcePath(target.worktreeId, target.runtimeEnvironmentId, file.filePath) - ) - }) - : deletedOpenEditorIdsRaw + ) + : deletedOpenEditorsRaw + const deletedOpenEditorIds = deletedOpenEditors.map(({ file }) => file.id) // Why: correlate creates to deletes by basename to avoid mislabelling unrelated create+delete pairs as "renamed"; default to 'deleted' when we can't correlate. const hasPairedCreate = deletedOpenEditorIds.length > 0 && @@ -580,19 +548,9 @@ export function createExternalWatchEventHandler( } } else { // Why: defer the 'deleted' tombstone so a follow-up same-path create in the next payload can cancel it (macOS atomic write). - const deletePathByFileId = buildDeletePathByFileId( - payload, - target.worktreeId, - target.runtimeEnvironmentId, - deletedOpenEditorIds, - openFilesAtStart, - target.allowLocalWindowsWslAliases - ) - for (const fileId of deletedOpenEditorIds) { - const absolutePath = deletePathByFileId.get(fileId) - if (!absolutePath) { - continue - } + for (const { file, normalizedDeletePath } of deletedOpenEditors) { + const fileId = file.id + const absolutePath = normalizedDeletePath const key = pendingKey(target.worktreeId, target.runtimeEnvironmentId, absolutePath) const existing = pendingDeletes.get(key) if (existing) { @@ -622,61 +580,32 @@ export function createExternalWatchEventHandler( openFileRuntimeOwner(file) === target.runtimeEnvironmentId && (file.mode === 'edit' || file.mode === 'markdown-preview') && (file.externalMutation === 'deleted' || file.externalMutation === 'renamed') && - findMatchingWatchedPath( - createOrUpdatePaths, - file.filePath, - target.allowLocalWindowsWslAliases - ) !== undefined + batchPaths.matchesCreateOrUpdate(file) ) { state.setExternalMutation(file.id, null) } } } - const changedFiles = new Set() + let overflowed = false for (const evt of payload.events) { if (evt.kind === 'overflow') { // Why: overflow omits per-path info, so conservatively clear stale tombstones or a file that reappeared during the overrun stays struck through. for (const notification of getOverflowExternalReloadTargets(target)) { scheduleDebouncedExternalReload(notification) } - // Why: `break` not `return` — changedFiles is empty so the rest early-returns anyway, and this is more robust to code added after the loop. + overflowed = true break } - - if (evt.kind === 'update' && evt.isDirectory === true) { - continue - } - - if (evt.kind === 'delete') { - // Why: deletes are tombstoned above; feeding them into reload would read the ENOENT path and replace in-memory content with an error, losing the user's view. - continue - } - - const relativePath = getExternalFileChangeRelativePath( - target.worktreePath, - evt.absolutePath, - evt.isDirectory - ) - if (relativePath) { - changedFiles.add(relativePath) - } } - if (changedFiles.size === 0) { + if (overflowed || batchPaths.changes.length === 0) { return } - // Why: read openFiles once per payload to avoid N store reads on large batches; consumers skip dirty tabs so external writes don't destroy unsaved work. - const openFilesSnapshot = useAppStore.getState().openFiles - // Why: the combined "Changes" tab is per-worktree not per-path, so compute it once instead of rescanning openFiles per changed file in a large batched payload. - const hasCombinedDiffConsumer = openFilesSnapshot.some( - (f) => - f.worktreeId === target.worktreeId && - openFileRuntimeOwner(f) === target.runtimeEnvironmentId && - isWorkingTreeCombinedDiffTab(f) - ) - for (const relativePath of changedFiles) { + for (const change of batchPaths.changes) { + const relativePath = change.relativePath + const matching = batchPaths.matchingOpenFiles(change) const notification = { worktreeId: target.worktreeId, worktreePath: target.worktreePath, @@ -684,11 +613,15 @@ export function createExternalWatchEventHandler( runtimeEnvironmentId: target.runtimeEnvironmentId, ...localWslAliasOption(target) } - const absolutePath = joinPath(notification.worktreePath, notification.relativePath) - const matching = getOpenFilesForExternalFileChange(openFilesSnapshot, notification) + Object.defineProperty(notification, 'indexedOpenFiles', { + value: { + matches: (openFiles: OpenFile[]) => batchPaths.matchingOpenFiles(change, openFiles) + } + }) + const absolutePath = change.absolutePath if (matching.length === 0) { // Why: combined-diff tab has no in-memory content to clobber and guards its own reload, so notify it directly without self-write suppression. - if (hasCombinedDiffConsumer) { + if (batchPaths.hasCombinedDiffConsumer) { scheduleDebouncedExternalReload(notification) } continue @@ -717,7 +650,7 @@ export function createExternalWatchEventHandler( scheduleChangedOnDiskMark(target, notification, dirtyIds) } if (dirtyMatches.length === matching.length) { - if (hasCombinedDiffConsumer) { + if (batchPaths.hasCombinedDiffConsumer) { scheduleDebouncedExternalReload(notification) } continue @@ -1033,79 +966,6 @@ export function getOverflowExternalReloadTargets( return notifications } -function buildDeletePathByFileId( - payload: FsChangedPayload, - worktreeId: string, - runtimeEnvironmentId: string | null, - deletedOpenEditorIds: string[], - openFiles: OpenFile[], - allowLocalWindowsWslAliases?: true -): Map { - const deletePaths = new Map() - for (const evt of payload.events) { - if (evt.kind === 'delete') { - deletePaths.set(normalizeRuntimePathForComparison(evt.absolutePath), evt.absolutePath) - } - } - const result = new Map() - if (deletePaths.size === 0) { - return result - } - const deletedIdSet = new Set(deletedOpenEditorIds) - for (const file of openFiles) { - if ( - !deletedIdSet.has(file.id) || - file.worktreeId !== worktreeId || - openFileRuntimeOwner(file) !== runtimeEnvironmentId - ) { - continue - } - const deletePath = findMatchingWatchedPath( - deletePaths, - file.filePath, - allowLocalWindowsWslAliases - ) - if (deletePath) { - result.set(file.id, normalizeRuntimePathForComparison(deletePath)) - } - } - return result -} - -function collectDeletedOpenEditorIds( - payload: FsChangedPayload, - worktreeId: string, - runtimeEnvironmentId: string | null, - openFiles: OpenFile[], - allowLocalWindowsWslAliases?: true -): string[] { - const deletePaths = new Map() - for (const evt of payload.events) { - if (evt.kind === 'delete') { - deletePaths.set(normalizeRuntimePathForComparison(evt.absolutePath), evt.absolutePath) - } - } - if (deletePaths.size === 0) { - return [] - } - const result: string[] = [] - for (const file of openFiles) { - if ( - file.worktreeId !== worktreeId || - openFileRuntimeOwner(file) !== runtimeEnvironmentId || - (file.mode !== 'edit' && file.mode !== 'markdown-preview') - ) { - continue - } - if ( - findMatchingWatchedPath(deletePaths, file.filePath, allowLocalWindowsWslAliases) !== undefined - ) { - result.push(file.id) - } - } - return result -} - /** * Returns true if the batched payload contains at least one file-create event * whose basename matches a deleted open editor file. diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index becf29236bb..aad0981c3a1 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -57,18 +57,30 @@ export function normalizeRuntimePathForComparison(rawValue: string): string { } export function areLocalWindowsWslPathAliases(left: string, right: string): boolean { - const leftWslPath = parseWslUncPath(left) - const rightWslPath = parseWslUncPath(right) - if (!leftWslPath && !rightWslPath) { - return false + const leftIdentity = getLocalWindowsWslPathIdentity(left) + const rightIdentity = getLocalWindowsWslPathIdentity(right) + return ( + (leftIdentity.isWslUnc || rightIdentity.isWslUnc) && + leftIdentity.aliasComparisonPath === rightIdentity.aliasComparisonPath + ) +} + +export type LocalWindowsWslPathIdentity = { + normalizedPath: string + aliasComparisonPath: string + isWslUnc: boolean +} + +export function getLocalWindowsWslPathIdentity(value: string): LocalWindowsWslPathIdentity { + const wslPath = parseWslUncPath(value) + const normalizedPath = normalizeRuntimePathForComparison(value) + return { + normalizedPath, + aliasComparisonPath: wslPath + ? normalizeRuntimePathForComparison(toWindowsWslPath(wslPath.linuxPath, wslPath.distro)) + : normalizedPath, + isWslUnc: wslPath !== null } - const normalize = (value: string): string => { - const wslPath = parseWslUncPath(value) - return normalizeRuntimePathForComparison( - wslPath ? toWindowsWslPath(wslPath.linuxPath, wslPath.distro) : value - ) - } - return normalize(left) === normalize(right) } export function isRuntimePathAbsolute(