From cab52d1635932e9555a43a5f0d503ceca04a9bf0 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 01:34:31 -0700 Subject: [PATCH] perf: gate contextual copy hint polling (#4024) --- .../editor/setup-contextual-copy.test.ts | 116 ++++++++++++++++++ .../editor/setup-contextual-copy.ts | 33 +++-- 2 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/editor/setup-contextual-copy.test.ts b/src/renderer/src/components/editor/setup-contextual-copy.test.ts index ce3da798b..8141a2ff3 100644 --- a/src/renderer/src/components/editor/setup-contextual-copy.test.ts +++ b/src/renderer/src/components/editor/setup-contextual-copy.test.ts @@ -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() diff --git a/src/renderer/src/components/editor/setup-contextual-copy.ts b/src/renderer/src/components/editor/setup-contextual-copy.ts index 6f94b7a5e..b69236617 100644 --- a/src/renderer/src/components/editor/setup-contextual-copy.ts +++ b/src/renderer/src/components/editor/setup-contextual-copy.ts @@ -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() }