perf: cache editor external watch targets (#2598)
This commit is contained in:
parent
c1dcdb41e1
commit
f050fa364b
|
|
@ -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'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue