perf: gate contextual copy hint polling (#4024)

This commit is contained in:
Neil 2026-05-31 01:34:31 -07:00 committed by GitHub
parent 819998db04
commit cab52d1635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 140 additions and 9 deletions

View File

@ -34,6 +34,122 @@ describe('setupContextualCopy', () => {
vi.unstubAllGlobals()
})
it('does not poll a focused editor when no contextual copy hint is visible', () => {
const setInterval = vi.fn(() => 1)
vi.stubGlobal('window', {
clearInterval: vi.fn(),
clearTimeout: vi.fn(),
setInterval,
setTimeout: vi.fn(() => 2)
})
vi.stubGlobal('document', {
createElement: () => ({
className: '',
offsetHeight: 28,
style: { display: '' },
textContent: ''
})
})
const editorInstance = {
addContentWidget: vi.fn(),
getContainerDomNode: () => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}),
getModel: () => null,
getSelection: () => null,
hasTextFocus: () => true,
layoutContentWidget: vi.fn(),
onDidBlurEditorText: () => ({ dispose: vi.fn() }),
onDidChangeCursorSelection: () => ({ dispose: vi.fn() }),
onDidDispose: () => ({ dispose: vi.fn() }),
onDidFocusEditorText: () => ({ dispose: vi.fn() }),
onDidScrollChange: () => ({ dispose: vi.fn() }),
removeContentWidget: vi.fn()
} as unknown as editor.IStandaloneCodeEditor
setupContextualCopy({
editorInstance,
filePath: 'src/example.ts',
setCopyToast: vi.fn(),
propsRef: {
current: {
language: 'typescript',
relativePath: 'src/example.ts'
}
},
copyToastTimeoutRef: { current: null }
})
expect(setInterval).not.toHaveBeenCalled()
})
it('polls a focused editor while a contextual copy hint is visible', () => {
const setInterval = vi.fn(() => 1)
vi.stubGlobal('window', {
clearInterval: vi.fn(),
clearTimeout: vi.fn(),
setInterval,
setTimeout: vi.fn(() => 2)
})
vi.stubGlobal('document', {
createElement: () => ({
className: '',
offsetHeight: 28,
style: { display: '' },
textContent: ''
})
})
const selection = {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 2,
endColumn: 4,
isEmpty: () => false,
getStartPosition: () => ({ lineNumber: 1, column: 1 }),
getEndPosition: () => ({ lineNumber: 2, column: 4 })
}
const editorInstance = {
addContentWidget: vi.fn(),
getContainerDomNode: () => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}),
getLayoutInfo: () => ({ height: 500 }),
getModel: () => ({
getLineMaxColumn: () => 4,
getValueInRange: () => 'one\ntwo'
}),
getScrolledVisiblePosition: () => ({ top: 20, left: 8, height: 16 }),
getSelection: () => selection,
hasTextFocus: () => true,
layoutContentWidget: vi.fn(),
onDidBlurEditorText: () => ({ dispose: vi.fn() }),
onDidChangeCursorSelection: () => ({ dispose: vi.fn() }),
onDidDispose: () => ({ dispose: vi.fn() }),
onDidFocusEditorText: () => ({ dispose: vi.fn() }),
onDidScrollChange: () => ({ dispose: vi.fn() }),
removeContentWidget: vi.fn()
} as unknown as editor.IStandaloneCodeEditor
setupContextualCopy({
editorInstance,
filePath: 'src/example.ts',
setCopyToast: vi.fn(),
propsRef: {
current: {
language: 'typescript',
relativePath: 'src/example.ts'
}
},
copyToastTimeoutRef: { current: null }
})
expect(setInterval).toHaveBeenCalledTimes(1)
})
it('clears editor-scoped contextual copy cleanup on dispose', () => {
const clearTimeout = vi.fn()
const clearInterval = vi.fn()

View File

@ -160,13 +160,17 @@ export function setupContextualCopy({
editorInstance.layoutContentWidget(copyHintWidget)
}
const isCopyHintVisible = (): boolean => copyHintNode.style.display === 'block'
const startCopyHintPolling = (): void => {
updateCopyHint()
if (copyHintInterval !== null) {
return
}
copyHintInterval = window.setInterval(() => {
updateCopyHint()
if (!isCopyHintVisible()) {
stopCopyHintPolling()
}
}, 150)
}
@ -177,6 +181,17 @@ export function setupContextualCopy({
}
}
const refreshCopyHintAndPolling = (): void => {
updateCopyHint()
if (editorInstance.hasTextFocus() && isCopyHintVisible()) {
// Why: the interval only tracks a visible content widget. Keeping it
// alive while the focused editor has no selection burns idle CPU.
startCopyHintPolling()
} else {
stopCopyHintPolling()
}
}
const getContextualCopyText = (): string | null => {
const model = editorInstance.getModel()
const selection = editorInstance.getSelection()
@ -262,13 +277,13 @@ export function setupContextualCopy({
if (getSelectionKey() !== lastCopiedSelectionKey) {
lastCopiedSelectionKey = null
}
updateCopyHint()
refreshCopyHintAndPolling()
})
const scrollListener = editorInstance.onDidScrollChange(() => {
updateCopyHint()
refreshCopyHintAndPolling()
})
const focusListener = editorInstance.onDidFocusEditorText(() => {
startCopyHintPolling()
refreshCopyHintAndPolling()
})
const blurListener = editorInstance.onDidBlurEditorText(() => {
stopCopyHintPolling()
@ -291,8 +306,8 @@ export function setupContextualCopy({
void copySelectionWithContext()
}
editorDomNode.addEventListener('keydown', handleKeyDown, true)
editorDomNode.addEventListener('mouseup', updateCopyHint, true)
editorDomNode.addEventListener('keyup', updateCopyHint, true)
editorDomNode.addEventListener('mouseup', refreshCopyHintAndPolling, true)
editorDomNode.addEventListener('keyup', refreshCopyHintAndPolling, true)
editorInstance.onDidDispose(() => {
// Why: Monaco owns these emitters, but disposing explicitly keeps this
// feature's lifecycle symmetrical with the DOM listener cleanup below.
@ -312,13 +327,13 @@ export function setupContextualCopy({
primarySelectionTimer = null
}
editorDomNode.removeEventListener('keydown', handleKeyDown, true)
editorDomNode.removeEventListener('mouseup', updateCopyHint, true)
editorDomNode.removeEventListener('keyup', updateCopyHint, true)
editorDomNode.removeEventListener('mouseup', refreshCopyHintAndPolling, true)
editorDomNode.removeEventListener('keyup', refreshCopyHintAndPolling, true)
stopCopyHintPolling()
editorInstance.removeContentWidget(copyHintWidget)
})
if (editorInstance.hasTextFocus()) {
startCopyHintPolling()
refreshCopyHintAndPolling()
} else {
updateCopyHint()
}