diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index dd89fd7ab..2181dfc13 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react' +import React, { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' @@ -22,6 +22,7 @@ import { selectEditorPanelGitBranchEntries, selectEditorPanelGitStatusEntries } from './editor-panel-git-entry-selector' +import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' function EditorPanelInner({ activeFileId: activeFileIdProp, @@ -60,7 +61,11 @@ function EditorPanelInner({ const setMarkdownTableOfContentsVisible = useAppStore((s) => s.setMarkdownTableOfContentsVisible) const closeFile = useAppStore((s) => s.closeFile) const clearUntitled = useAppStore((s) => s.clearUntitled) - const editorDrafts = useAppStore((s) => s.editorDrafts) + const editorDraftSelector = useMemo( + () => createEditorPanelDraftSelector(activeFile), + [activeFile] + ) + const editorDrafts = useAppStore(editorDraftSelector) const setEditorDraft = useAppStore((s) => s.setEditorDraft) const settings = useAppStore((s) => s.settings) const panelRef = useRef(null) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts new file mode 100644 index 000000000..d8b4747a9 --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import { createEditorPanelDraftSelector } from './editor-panel-draft-selector' + +function makeFile(id: string, overrides: Partial = {}): OpenFile { + return { + id, + filePath: `/repo/${id}.ts`, + relativePath: `${id}.ts`, + worktreeId: 'worktree-1', + language: 'typescript', + mode: 'edit', + isDirty: false, + ...overrides + } +} + +describe('createEditorPanelDraftSelector', () => { + it('limits draft invalidations to the panel that owns each keystroke', () => { + const files = Array.from({ length: 200 }, (_, index) => makeFile(`file-${index}`)) + let editorDrafts: Record = {} + let wholeMapInvalidations = 0 + let scopedInvalidations = 0 + let unrelatedPanelInvalidations = 0 + const selectors = files.map(createEditorPanelDraftSelector) + const previousSelections = selectors.map((selector) => selector({ editorDrafts })) + + for (let edit = 0; edit < 200; edit += 1) { + const previousDrafts = editorDrafts + editorDrafts = { ...editorDrafts, 'file-0': `edit-${edit}` } + for (let panelIndex = 0; panelIndex < files.length; panelIndex += 1) { + if (previousDrafts !== editorDrafts) { + wholeMapInvalidations += 1 + } + const nextSelection = selectors[panelIndex]({ editorDrafts }) + if (previousSelections[panelIndex] !== nextSelection) { + scopedInvalidations += 1 + if (panelIndex !== 0) { + unrelatedPanelInvalidations += 1 + } + previousSelections[panelIndex] = nextSelection + } + } + } + + expect(wholeMapInvalidations).toBe(40_000) + expect(scopedInvalidations).toBe(200) + expect(unrelatedPanelInvalidations).toBe(0) + }) + + it('includes preview and selected conflict-review drafts but excludes unrelated files', () => { + const preview = makeFile('preview', { markdownPreviewSourceFileId: 'source' }) + const conflictReview = makeFile('review', { + mode: 'conflict-review', + conflictReview: { selectedFileId: 'selected' } as NonNullable + }) + const editorDrafts = { + preview: 'preview draft', + source: '', + review: 'review draft', + selected: 'selected draft', + unrelated: 'other draft' + } + const selectPreviewDrafts = createEditorPanelDraftSelector(preview) + const selectConflictDrafts = createEditorPanelDraftSelector(conflictReview) + const selectNoDrafts = createEditorPanelDraftSelector(null) + + expect(selectPreviewDrafts({ editorDrafts })).toEqual({ + preview: 'preview draft', + source: '' + }) + expect(selectConflictDrafts({ editorDrafts })).toEqual({ + review: 'review draft', + selected: 'selected draft' + }) + expect(selectNoDrafts({ editorDrafts })).toEqual({}) + }) + + it('includes overview conflict drafts and ignores unrelated draft replacements', () => { + const conflictReview = makeFile('review', { + filePath: 'C:\\repo', + mode: 'conflict-review', + conflictReview: { + source: 'live-summary', + snapshotTimestamp: 1, + entries: [ + { path: 'src/a.ts', conflictKind: 'both_modified' }, + { path: 'src\\b.ts', conflictKind: 'both_modified' } + ] + } + }) + const selectDrafts = createEditorPanelDraftSelector(conflictReview) + const editorDrafts = { + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '', + unrelated: 'other draft' + } + + const selection = selectDrafts({ editorDrafts }) + expect(selection).toEqual({ + 'C:\\repo\\src\\a.ts': 'draft a', + 'C:\\repo\\src\\b.ts': '' + }) + + expect( + selectDrafts({ editorDrafts: { ...editorDrafts, unrelated: 'changed elsewhere' } }) + ).toBe(selection) + }) +}) diff --git a/src/renderer/src/components/editor/editor-panel-draft-selector.ts b/src/renderer/src/components/editor/editor-panel-draft-selector.ts new file mode 100644 index 000000000..8cc7e07fb --- /dev/null +++ b/src/renderer/src/components/editor/editor-panel-draft-selector.ts @@ -0,0 +1,63 @@ +import type { AppState } from '@/store' +import { joinPath } from '@/lib/path' +import type { OpenFile } from '@/store/slices/editor' + +type EditorDraftState = Pick +type EditorPanelDraftSelector = (state: EditorDraftState) => Record + +const EMPTY_EDITOR_PANEL_DRAFTS = Object.freeze({}) as Record + +export function createEditorPanelDraftSelector( + activeFile: OpenFile | null +): EditorPanelDraftSelector { + // Why: previews and conflict review can render a related file, but drafts + // from every other panel must not wake this editor on each keystroke. + const fileIds = activeFile + ? Array.from( + new Set( + [ + activeFile.id, + activeFile.markdownPreviewSourceFileId, + activeFile.conflictReview?.selectedFileId, + ...(activeFile.mode === 'conflict-review' && !activeFile.conflictReview?.selectedFileId + ? (activeFile.conflictReview?.entries ?? []).map((entry) => + joinPath(activeFile.filePath, entry.path) + ) + : []) + ].filter((fileId): fileId is string => Boolean(fileId)) + ) + ) + : [] + let previousDrafts: AppState['editorDrafts'] | null = null + let previousSelection = EMPTY_EDITOR_PANEL_DRAFTS + + return (state) => { + // Why: every Zustand write reruns the selector. The slice identity guard + // keeps unrelated terminal/status traffic allocation-free. + if (previousDrafts === state.editorDrafts) { + return previousSelection + } + previousDrafts = state.editorDrafts + + const changed = fileIds.some((fileId) => { + const draft = state.editorDrafts[fileId] + return ( + draft !== previousSelection[fileId] || + (draft === undefined && Object.prototype.hasOwnProperty.call(previousSelection, fileId)) + ) + }) + if (!changed) { + return previousSelection + } + + const nextSelection: Record = {} + for (const fileId of fileIds) { + const draft = state.editorDrafts[fileId] + if (draft !== undefined) { + nextSelection[fileId] = draft + } + } + previousSelection = nextSelection + return previousSelection + } +}