fix(editor): index WSL watcher aliases per batch (#14015)

This commit is contained in:
OrcaWin
2026-08-12 03:24:31 -07:00
committed by GitHub
parent ad6f8011e2
commit 09ec516ae5
7 changed files with 642 additions and 192 deletions
@@ -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
@@ -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<OpenFile> & Pick<OpenFile, 'id' | 'filePath'>): 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)
})
})
@@ -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<string, string>
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<OpenFile, 'runtimeEnvironmentId'>): string | null {
return file.runtimeEnvironmentId?.trim() || null
}
function addToListMap<T>(map: Map<string, T[]>, key: string, value: T): void {
const existing = map.get(key)
if (existing) {
existing.push(value)
} else {
map.set(key, [value])
}
}
class IndexedPathLookup<T> {
private readonly direct = new Map<string, T>()
private readonly aliases = new Map<string, T>()
private readonly wslAliases = new Map<string, T>()
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<number, OpenFile>()
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<string, IndexedOpenFile[]>()
private readonly aliasEditors = new Map<string, IndexedOpenFile[]>()
private readonly wslAliasEditors = new Map<string, IndexedOpenFile[]>()
private readonly diffsByRelativePath = new Map<string, IndexedOpenFile[]>()
readonly indexedOpenFiles = new Map<string, IndexedOpenFile>()
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<IndexedPath>(allowAliases)
const deleteLookup = new IndexedPathLookup<IndexedPath>(allowAliases)
const createOrUpdatePaths = new Map<string, string>()
const changesByRelativePath = new Map<string, IndexedExternalWatchChange>()
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<OpenFile[], IndexedOpenFileLookup>()
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)
}
}
@@ -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<typeof EditorAutosaveModule>()
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<PathModuleWithIdentity>()
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()
})
})
@@ -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],
+39 -179
View File
@@ -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<string, string>,
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<string, string>()
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<string>()
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<string, string> {
const deletePaths = new Map<string, string>()
for (const evt of payload.events) {
if (evt.kind === 'delete') {
deletePaths.set(normalizeRuntimePathForComparison(evt.absolutePath), evt.absolutePath)
}
}
const result = new Map<string, string>()
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<string, string>()
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.
+23 -11
View File
@@ -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(