perf: cache editor external watch targets (#2598)

This commit is contained in:
Neil 2026-05-21 22:04:18 -07:00 committed by GitHub
parent c1dcdb41e1
commit f050fa364b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 214 additions and 43 deletions

View File

@ -0,0 +1,136 @@
import { describe, expect, it, vi } from 'vitest'
import {
getEditorExternalWatchTargets,
type EditorExternalWatchTargetState
} from './useEditorExternalWatch'
vi.mock('@/store', () => ({
useAppStore: {
getState: vi.fn()
}
}))
vi.mock('@/components/editor/editor-autosave', () => ({
notifyEditorExternalFileChange: vi.fn(),
getOpenFilesForExternalFileChange: vi.fn(() => [])
}))
describe('getEditorExternalWatchTargets', () => {
const makeRepo = (
id: string,
connectionId: string | null = null
): EditorExternalWatchTargetState['repos'][number] =>
({
id,
path: `/${id}`,
kind: 'git',
connectionId
}) as EditorExternalWatchTargetState['repos'][number]
const makeWorktree = (
repoId: string,
id = `${repoId}-wt`
): EditorExternalWatchTargetState['worktreesByRepo'][string][number] =>
({
id,
repoId,
path: `/${repoId}/worktree`
}) as EditorExternalWatchTargetState['worktreesByRepo'][string][number]
const makeOpenFile = (
worktreeId: string,
isDirty = false
): EditorExternalWatchTargetState['openFiles'][number] =>
({
id: `${worktreeId}-file`,
worktreeId,
filePath: `/repo/${worktreeId}/notes.md`,
relativePath: 'notes.md',
language: 'markdown',
mode: 'edit',
isDirty
}) as EditorExternalWatchTargetState['openFiles'][number]
const makeState = (args: {
repo: EditorExternalWatchTargetState['repos'][number]
worktree: EditorExternalWatchTargetState['worktreesByRepo'][string][number]
openFiles?: EditorExternalWatchTargetState['openFiles']
activeWorktreeId?: string | null
runtimeEnvironmentId?: string | null
}): EditorExternalWatchTargetState => ({
openFiles: args.openFiles ?? [],
worktreesByRepo: { [args.repo.id]: [args.worktree] },
repos: [args.repo],
activeWorktreeId: args.activeWorktreeId ?? null,
settings:
args.runtimeEnvironmentId === undefined
? null
: ({
activeRuntimeEnvironmentId: args.runtimeEnvironmentId
} as EditorExternalWatchTargetState['settings'])
})
it('preserves the snapshot when open-file metadata changes without changing watched roots', () => {
const repo = makeRepo('repo-1')
const worktree = makeWorktree(repo.id, 'wt-1')
const first = getEditorExternalWatchTargets(
makeState({ repo, worktree, openFiles: [makeOpenFile(worktree.id, false)] })
)
const second = getEditorExternalWatchTargets(
makeState({ repo, worktree, openFiles: [makeOpenFile(worktree.id, true)] })
)
expect(second).toBe(first)
expect(second.targets).toEqual([
{
worktreeId: 'wt-1',
worktreePath: '/repo-1/worktree',
connectionId: undefined,
runtimeEnvironmentId: undefined
}
])
})
it('keeps watching the active worktree even when it has no open editor files', () => {
const repo = makeRepo('repo-active')
const worktree = makeWorktree(repo.id, 'wt-active')
expect(
getEditorExternalWatchTargets(makeState({ repo, worktree, activeWorktreeId: worktree.id }))
.targets
).toEqual([
{
worktreeId: 'wt-active',
worktreePath: '/repo-active/worktree',
connectionId: undefined,
runtimeEnvironmentId: undefined
}
])
})
it('rebuilds targets when SSH connection or runtime environment identity changes', () => {
const localRepo = makeRepo('repo-remote', null)
const remoteRepo = makeRepo('repo-remote', 'ssh-1')
const worktree = makeWorktree(localRepo.id, 'wt-remote')
const local = getEditorExternalWatchTargets(
makeState({ repo: localRepo, worktree, openFiles: [makeOpenFile(worktree.id)] })
)
const remote = getEditorExternalWatchTargets(
makeState({
repo: remoteRepo,
worktree,
openFiles: [makeOpenFile(worktree.id)],
runtimeEnvironmentId: ' runtime-1 '
})
)
expect(remote).not.toBe(local)
expect(remote.targets).toEqual([
{
worktreeId: 'wt-remote',
worktreePath: '/repo-remote/worktree',
connectionId: 'ssh-1',
runtimeEnvironmentId: 'runtime-1'
}
])
})
})

View File

@ -2,8 +2,8 @@
target diffing, fs:changed dispatch, tombstone coalescing, and rename
correlation so the end-to-end event-to-store mutation contract stays
readable in one file. */
import { useEffect, useMemo, useRef } from 'react'
import { useAppStore } from '@/store'
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 { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path'
@ -71,6 +71,23 @@ type ExternalWatchNotification = {
relativePath: string
}
type WatchedTargetsSnapshot = {
targets: WatchedTarget[]
targetsKey: string
}
export type EditorExternalWatchTargetState = Pick<
AppState,
'openFiles' | 'worktreesByRepo' | 'repos' | 'activeWorktreeId' | 'settings'
>
let cachedOpenFiles: AppState['openFiles'] | null = null
let cachedWorktreesByRepo: AppState['worktreesByRepo'] | null = null
let cachedRepos: AppState['repos'] | null = null
let cachedActiveWorktreeId: string | null = null
let cachedRuntimeEnvironmentId: string | undefined
let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' }
export function getWatchedTargetKey(target: WatchedTarget): string {
// Why: SSH worktrees can exist in the store before their remote filesystem
// provider is ready. Include connectionId so a local/unknown placeholder
@ -78,6 +95,64 @@ export function getWatchedTargetKey(target: WatchedTarget): string {
return `${target.worktreeId}::${target.worktreePath}::${target.connectionId ?? 'local'}::${target.runtimeEnvironmentId ?? 'client'}`
}
export function getEditorExternalWatchTargets(
state: EditorExternalWatchTargetState
): WatchedTargetsSnapshot {
const runtimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || undefined
if (
cachedOpenFiles === state.openFiles &&
cachedWorktreesByRepo === state.worktreesByRepo &&
cachedRepos === state.repos &&
cachedActiveWorktreeId === state.activeWorktreeId &&
cachedRuntimeEnvironmentId === runtimeEnvironmentId
) {
return cachedWatchedTargetsSnapshot
}
const ids = new Set<string>()
// Why: only the set of worktree IDs matters for watcher ownership. Dirty
// flags and editor metadata can churn while typing/saving, but should not
// re-render App or rebuild watch subscriptions.
for (const f of state.openFiles) {
ids.add(f.worktreeId)
}
if (state.activeWorktreeId) {
ids.add(state.activeWorktreeId)
}
const nextTargets: WatchedTarget[] = []
const parts: string[] = []
for (const id of Array.from(ids).sort()) {
const wt = findWorktreeById(state.worktreesByRepo, id)
if (!wt) {
continue
}
const repo = state.repos.find((r) => r.id === wt.repoId)
const target = {
worktreeId: id,
worktreePath: wt.path,
connectionId: repo?.connectionId ?? undefined,
runtimeEnvironmentId
}
nextTargets.push(target)
parts.push(getWatchedTargetKey(target))
}
const targetsKey = parts.join('|')
cachedOpenFiles = state.openFiles
cachedWorktreesByRepo = state.worktreesByRepo
cachedRepos = state.repos
cachedActiveWorktreeId = state.activeWorktreeId
cachedRuntimeEnvironmentId = runtimeEnvironmentId
if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) {
return cachedWatchedTargetsSnapshot
}
cachedWatchedTargetsSnapshot = { targets: nextTargets, targetsKey }
return cachedWatchedTargetsSnapshot
}
// Why: macOS atomic writes (Claude Code Edit, vim :w, VSCode save) deliver a
// delete event immediately followed by a create event for the same path. When
// those two land in separate fs:changed payloads a few ms apart, the tab
@ -108,47 +183,7 @@ type PendingDeleteTimer = {
* regardless of which UI panel is visible.
*/
export function useEditorExternalWatch(): void {
const openFiles = useAppStore((s) => s.openFiles)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const repos = useAppStore((s) => s.repos)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const runtimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
// Why: unify the target computation and the dependency key into one memo so
// there's a single source of truth. The derived string key drives the
// watch-diff effect; the array itself is what the effect actually iterates.
const { targets, targetsKey } = useMemo(() => {
const ids = new Set<string>()
// Why: watch every worktree that has an editor tab open, so terminal edits
// in any of those roots reach the editor. Also watch the active worktree
// even when it has no open files — otherwise the File Explorer's tree
// reconciliation loses its event stream the moment the last tab for that
// worktree is closed.
for (const f of openFiles) {
ids.add(f.worktreeId)
}
if (activeWorktreeId) {
ids.add(activeWorktreeId)
}
const nextTargets: WatchedTarget[] = []
const parts: string[] = []
for (const id of Array.from(ids).sort()) {
const wt = findWorktreeById(worktreesByRepo, id)
if (!wt) {
continue
}
const repo = repos.find((r) => r.id === wt.repoId)
const target = {
worktreeId: id,
worktreePath: wt.path,
connectionId: repo?.connectionId ?? undefined,
runtimeEnvironmentId: runtimeEnvironmentId?.trim() || undefined
}
nextTargets.push(target)
parts.push(getWatchedTargetKey(target))
}
return { targets: nextTargets, targetsKey: parts.join('|') }
}, [openFiles, worktreesByRepo, repos, activeWorktreeId, runtimeEnvironmentId])
const { targets, targetsKey } = useAppStore(getEditorExternalWatchTargets)
const targetsRef = useRef<WatchedTarget[]>([])
const latestTargetsRef = useRef<WatchedTarget[]>(targets)