mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
fix(editor): index WSL watcher aliases per batch (#14015)
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user