diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts new file mode 100644 index 000000000..372d245a4 --- /dev/null +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -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' + } + ]) + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 5db3dbe3e..dbb246cc1 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -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() + // 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() - // 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([]) const latestTargetsRef = useRef(targets)