perf(renderer): scope editor draft subscriptions by panel (#8105)
* perf(renderer): scope editor draft subscriptions by panel * fix(renderer): keep conflict overview drafts live
This commit is contained in:
parent
d617bda234
commit
e0207cb360
|
|
@ -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<HTMLDivElement>(null)
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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<string, string> = {}
|
||||
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<OpenFile['conflictReview']>
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { AppState } from '@/store'
|
||||
import { joinPath } from '@/lib/path'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
|
||||
type EditorDraftState = Pick<AppState, 'editorDrafts'>
|
||||
type EditorPanelDraftSelector = (state: EditorDraftState) => Record<string, string>
|
||||
|
||||
const EMPTY_EDITOR_PANEL_DRAFTS = Object.freeze({}) as Record<string, string>
|
||||
|
||||
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<string, string> = {}
|
||||
for (const fileId of fileIds) {
|
||||
const draft = state.editorDrafts[fileId]
|
||||
if (draft !== undefined) {
|
||||
nextSelection[fileId] = draft
|
||||
}
|
||||
}
|
||||
previousSelection = nextSelection
|
||||
return previousSelection
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue