diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 8cb87ea9..ac2a5e7f 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -1073,7 +1073,7 @@ describe('DataGrid DDL interactions', () => { vi.unstubAllGlobals(); }); - it('selects one row when its row number cell is clicked', async () => { + it('toggles one row when its row number cell is clicked', async () => { storeState.appearance.uiVersion = 'v2'; const rows = [ { [GONAVI_ROW_KEY]: 'row-1', id: 1 }, @@ -1107,6 +1107,14 @@ describe('DataGrid DDL interactions', () => { expect(stopPropagation).toHaveBeenCalledTimes(1); expect(testRenderState.latestTableProps.rowHoverable).toBe(false); expect(testRenderState.latestTableProps.rowSelection.selectedRowKeys).toEqual(['row-2']); + + await act(async () => { + rowNumberColumn.onCell(rows[1], 1).onClick({ stopPropagation }); + }); + await waitForEffects(); + + expect(stopPropagation).toHaveBeenCalledTimes(2); + expect(testRenderState.latestTableProps.rowSelection.selectedRowKeys).toEqual([]); renderer!.unmount(); }); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 0f4d203c..03cda76f 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -3381,7 +3381,9 @@ const DataGrid: React.FC = ({ const handleRowNumberClick = useCallback((record: Item) => { const key = record?.[GONAVI_ROW_KEY]; if (key === undefined || key === null) return; - setSelectedRowKeys([key]); + setSelectedRowKeys((previousKeys) => ( + previousKeys.length === 1 && previousKeys[0] === key ? [] : [key] + )); }, []); const handleRowNumberDoubleClick = useCallback((index: number) => { diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index 7f502993..fa3241d9 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -1,5 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Editor, { loader, type BeforeMount, type EditorProps, type OnMount } from '@monaco-editor/react'; +import { message } from 'antd'; +import { t } from '../i18n'; import { useStore } from '../store'; import { sanitizeDataTableFontSize } from '../utils/dataGridDisplay'; import { DEFAULT_MONO_FONT_FAMILY } from '../utils/fontFamilies'; @@ -7,7 +9,11 @@ import { resolveSqlEditorFontSize, resolveSqlEditorSuggestionLayout, } from '../utils/sqlEditorTypography'; -import { installWailsMonacoClipboardPasteHandler } from '../utils/monacoClipboard'; +import { + installWailsMonacoClipboardPasteHandler, + MONACO_CLIPBOARD_HANDLER_REVISION, + type MonacoClipboardReadFailure, +} from '../utils/monacoClipboard'; export type { BeforeMount, OnMount } from '@monaco-editor/react'; export type GonaviMonacoTypography = 'code' | 'data' | 'sql'; @@ -810,6 +816,8 @@ const MonacoEditor: React.FC = ({ const monoFontFamily = useStore((state) => state.appearance.customMonoFontFamily); const globalFontSize = useStore((state) => state.fontSize); const clipboardPasteCleanupRef = useRef<(() => void) | null>(null); + const clipboardEditorRef = useRef[0] | null>(null); + const clipboardMonacoRef = useRef[1] | null>(null); // Monaco theme is process-global; never fall back to "light" or other editors get polluted. const resolvedTheme = theme ?? (appTheme === 'dark' ? 'transparent-dark' : 'transparent-light'); @@ -835,26 +843,57 @@ const MonacoEditor: React.FC = ({ }; }, []); - useEffect(() => () => { - clipboardPasteCleanupRef.current?.(); - clipboardPasteCleanupRef.current = null; - }, []); - const handleBeforeMount: BeforeMount = useCallback((monaco) => { registerGonaviMonacoThemes(monaco); beforeMount?.(monaco); }, [beforeMount]); - const handleMount: OnMount = useCallback((editor, monaco) => { + const handleClipboardReadFailure = useCallback(({ source, error }: MonacoClipboardReadFailure) => { + console.warn('Failed to read clipboard text for Monaco paste', error); + void message.warning({ + key: 'gonavi-query-editor-clipboard-read-failed', + content: t(source === 'browser' + ? 'query_editor.message.clipboard_permission_required' + : 'query_editor.message.clipboard_read_failed'), + duration: 5, + }); + }, []); + + const replaceClipboardPasteHandler = useCallback((editor: Parameters[0], monaco: Parameters[1]) => { clipboardPasteCleanupRef.current?.(); clipboardPasteCleanupRef.current = gonaviTypography === 'sql' - ? installWailsMonacoClipboardPasteHandler(monaco, editor) + ? installWailsMonacoClipboardPasteHandler( + monaco, + editor, + undefined, + undefined, + handleClipboardReadFailure, + ) : null; + }, [gonaviTypography, handleClipboardReadFailure]); + + useEffect(() => { + const editor = clipboardEditorRef.current; + const monaco = clipboardMonacoRef.current; + if (editor && monaco) { + replaceClipboardPasteHandler(editor, monaco); + } + + return () => { + clipboardPasteCleanupRef.current?.(); + clipboardPasteCleanupRef.current = null; + }; + }, [MONACO_CLIPBOARD_HANDLER_REVISION, replaceClipboardPasteHandler]); + + const handleMount: OnMount = useCallback((editor, monaco) => { + clipboardEditorRef.current = editor; + clipboardMonacoRef.current = monaco; + replaceClipboardPasteHandler(editor, monaco); installOceanBaseOracleNavigationFallback(editor); installPrintableInputFallback(editor, monaco); installWebKitImeScrollStabilizer(editor); onMount?.(editor, monaco); - }, [gonaviTypography, onMount]); + }, [onMount, replaceClipboardPasteHandler]); const resolvedOptions = useMemo(() => { if (uiVersion !== 'v2') { diff --git a/frontend/src/utils/monacoClipboard.test.ts b/frontend/src/utils/monacoClipboard.test.ts index a5f33d81..7cfc7668 100644 --- a/frontend/src/utils/monacoClipboard.test.ts +++ b/frontend/src/utils/monacoClipboard.test.ts @@ -45,19 +45,37 @@ const createInternals = ( }, }); -const createEditor = (overrides: Record = {}) => ({ - getOption: vi.fn(() => true), - getRawOptions: vi.fn(() => ({ readOnly: false })), - hasModel: vi.fn(() => true), - hasTextFocus: vi.fn(() => false), - onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), - trigger: vi.fn(), - ...overrides, -}); +const createEditorDomNode = () => { + const listeners = new Map void>(); + return { + addEventListener: vi.fn((type: string, listener: (event?: unknown) => void) => { + listeners.set(type, listener); + }), + removeEventListener: vi.fn((type: string, listener: (event?: unknown) => void) => { + if (listeners.get(type) === listener) listeners.delete(type); + }), + dispatch: (type: string, event?: unknown) => listeners.get(type)?.(event), + }; +}; + +const createEditor = (overrides: Record = {}) => { + const domNode = createEditorDomNode(); + return { + getDomNode: vi.fn(() => domNode), + getOption: vi.fn(() => true), + getRawOptions: vi.fn(() => ({ readOnly: false })), + hasModel: vi.fn(() => true), + hasTextFocus: vi.fn(() => false), + hasWidgetFocus: vi.fn(() => false), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + trigger: vi.fn(), + dispatchDomEvent: domNode.dispatch, + ...overrides, + }; +}; const wailsScope = (readText = vi.fn().mockResolvedValue('native text')) => ({ window: { - WailsInvoke: vi.fn(), runtime: { ClipboardGetText: readText }, }, }); @@ -90,7 +108,53 @@ describe('Monaco clipboard fallback', () => { .resolves.toBe('SELECT * FROM users;'); }); - it('only handles paste while a registered SQL editor has text focus', async () => { + it('leaves Ctrl+V to Monaco when no context-menu paste was requested', async () => { + const pasteAction = createPasteAction(); + const readText = vi.fn().mockResolvedValue('custom clipboard text'); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const nativePaste = vi.fn(() => true); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(readText), + createInternals(pasteAction), + ); + + await runPasteAction([...pasteAction.implementations, nativePaste]); + + expect(nativePaste).toHaveBeenCalledTimes(1); + expect(readText).not.toHaveBeenCalled(); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); + + it('clears a stale context-menu owner before Ctrl+V reaches Monaco', async () => { + const pasteAction = createPasteAction(); + const editorDomNode = createEditorDomNode(); + const readText = vi.fn().mockResolvedValue('custom clipboard text'); + const editor = createEditor({ + getDomNode: vi.fn(() => editorDomNode), + hasTextFocus: vi.fn(() => true), + }); + const nativePaste = vi.fn(() => true); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(readText), + createInternals(pasteAction), + ); + + editorDomNode.dispatch('contextmenu'); + editorDomNode.dispatch('keydown'); + await runPasteAction([...pasteAction.implementations, nativePaste]); + + expect(nativePaste).toHaveBeenCalledTimes(1); + expect(readText).not.toHaveBeenCalled(); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); + + it('only handles context-menu paste for the registered SQL editor', async () => { const pasteAction = createPasteAction(); const internals = createInternals(pasteAction); const sqlEditor = createEditor({ hasTextFocus: vi.fn(() => true) }); @@ -105,6 +169,7 @@ describe('Monaco clipboard fallback', () => { ); expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1); + sqlEditor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); expect(sqlEditor.trigger).toHaveBeenCalledWith('keyboard', 'paste', { @@ -118,6 +183,277 @@ describe('Monaco clipboard fallback', () => { releaseSql(); }); + it('handles context-menu paste while the SQL editor retains widget focus', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const editor = createEditor({ hasWidgetFocus: vi.fn(() => true) }); + const scope = wailsScope(); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + internals, + ); + + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + + expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'native text' }), + ); + release(); + }); + + it('routes context-menu paste to its editor after the menu takes focus', async () => { + const pasteAction = createPasteAction(); + const editorDomNode = createEditorDomNode(); + const editor = createEditor({ getDomNode: vi.fn(() => editorDomNode) }); + const scope = wailsScope(); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + createInternals(pasteAction), + ); + + editorDomNode.dispatch('contextmenu'); + await runPasteAction(pasteAction.implementations); + + expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'native text' }), + ); + + release(); + expect(editorDomNode.removeEventListener).toHaveBeenCalledWith('contextmenu', expect.any(Function)); + }); + + it('keeps context-menu ownership while clicking Monaco menu items', async () => { + const pasteAction = createPasteAction(); + const editor = createEditor(); + const scope = wailsScope(); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + createInternals(pasteAction), + ); + + editor.dispatchDomEvent('contextmenu'); + editor.dispatchDomEvent('pointerdown', { + target: { + closest: vi.fn(() => null), + }, + composedPath: vi.fn(() => [{ + closest: vi.fn((selector: string) => ( + selector === '.monaco-menu-container' ? {} : null + )), + }]), + }); + await runPasteAction(pasteAction.implementations); + + expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'native text' }), + ); + release(); + }); + + it('routes context-menu paste only to the most recently requested editor', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const firstEditorDomNode = createEditorDomNode(); + const secondEditorDomNode = createEditorDomNode(); + const firstEditor = createEditor({ getDomNode: vi.fn(() => firstEditorDomNode) }); + const secondEditor = createEditor({ getDomNode: vi.fn(() => secondEditorDomNode) }); + const firstScope = wailsScope(vi.fn().mockResolvedValue('first editor text')); + const secondScope = wailsScope(vi.fn().mockResolvedValue('second editor text')); + const monaco = { editor: { EditorOption: { emptySelectionClipboard: 45 } } }; + + const releaseFirst = installWailsMonacoClipboardPasteHandler(monaco, firstEditor, firstScope, internals); + const releaseSecond = installWailsMonacoClipboardPasteHandler(monaco, secondEditor, secondScope, internals); + + firstEditorDomNode.dispatch('contextmenu'); + secondEditorDomNode.dispatch('contextmenu'); + await runPasteAction(pasteAction.implementations); + + releaseFirst(); + releaseSecond(); + expect(firstScope.window.runtime.ClipboardGetText).not.toHaveBeenCalled(); + expect(firstEditor.trigger).not.toHaveBeenCalled(); + expect(secondScope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(secondEditor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'second editor text' }), + ); + }); + + it('leaves Ctrl+V in another SQL editor to Monaco after a context menu is cancelled', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const firstEditor = createEditor(); + const secondEditor = createEditor(); + const firstReadText = vi.fn().mockResolvedValue('stale editor text'); + const secondReadText = vi.fn().mockResolvedValue('second editor text'); + const nativePaste = vi.fn(() => true); + const monaco = { editor: { EditorOption: { emptySelectionClipboard: 45 } } }; + + const releaseFirst = installWailsMonacoClipboardPasteHandler( + monaco, + firstEditor, + wailsScope(firstReadText), + internals, + ); + const releaseSecond = installWailsMonacoClipboardPasteHandler( + monaco, + secondEditor, + wailsScope(secondReadText), + internals, + ); + + firstEditor.dispatchDomEvent('contextmenu'); + secondEditor.dispatchDomEvent('keydown'); + await runPasteAction([...pasteAction.implementations, nativePaste]); + + expect(nativePaste).toHaveBeenCalledTimes(1); + expect(firstReadText).not.toHaveBeenCalled(); + expect(secondReadText).not.toHaveBeenCalled(); + expect(firstEditor.trigger).not.toHaveBeenCalled(); + expect(secondEditor.trigger).not.toHaveBeenCalled(); + releaseFirst(); + releaseSecond(); + }); + + it('leaves Ctrl+V outside the SQL editor to Monaco after a context menu is cancelled', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const documentNode = createEditorDomNode(); + const editorDomNode = { + ...createEditorDomNode(), + ownerDocument: documentNode, + }; + const editor = createEditor({ + getDomNode: vi.fn(() => editorDomNode), + }); + const scope = wailsScope(); + const defaultPaste = vi.fn(() => true); + pasteAction.addImplementation(10000, 'monaco-default-paste', defaultPaste); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + internals, + ); + + editorDomNode.dispatch('contextmenu'); + documentNode.dispatch('keydown'); + await runPasteAction(pasteAction.implementations); + + expect(defaultPaste).toHaveBeenCalledTimes(1); + expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled(); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); + + it('does not finish an async context-menu paste after another SQL editor is activated', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + let resolveRead: ((text: string) => void) | undefined; + const firstEditor = createEditor(); + const secondEditor = createEditor(); + const firstReadText = vi.fn(() => new Promise((resolve) => { + resolveRead = resolve; + })); + const monaco = { editor: { EditorOption: { emptySelectionClipboard: 45 } } }; + + const releaseFirst = installWailsMonacoClipboardPasteHandler( + monaco, + firstEditor, + wailsScope(firstReadText), + internals, + ); + const releaseSecond = installWailsMonacoClipboardPasteHandler( + monaco, + secondEditor, + wailsScope(), + internals, + ); + + firstEditor.dispatchDomEvent('contextmenu'); + const pastePromise = runPasteAction(pasteAction.implementations); + secondEditor.dispatchDomEvent('pointerdown'); + resolveRead?.('stale editor text'); + await pastePromise; + + expect(firstReadText).toHaveBeenCalledTimes(1); + expect(firstEditor.trigger).not.toHaveBeenCalled(); + expect(secondEditor.trigger).not.toHaveBeenCalled(); + releaseFirst(); + releaseSecond(); + }); + + it('invokes Monaco trigger with the editor as its receiver', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const editor = createEditor({ hasWidgetFocus: vi.fn(() => true) }); + editor.trigger = vi.fn(function (this: unknown) { + if (this !== editor) { + throw new Error('trigger called without editor receiver'); + } + }); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(), + internals, + ); + + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + + expect(editor.trigger).toHaveBeenCalledTimes(1); + release(); + }); + + it('does not paste after keyboard input interrupts an async context-menu read', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + let resolveRead: ((text: string) => void) | undefined; + const editor = createEditor(); + const scope = wailsScope(vi.fn(() => new Promise((resolve) => { + resolveRead = resolve; + }))); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + internals, + ); + + editor.dispatchDomEvent('contextmenu'); + const pastePromise = runPasteAction(pasteAction.implementations); + editor.dispatchDomEvent('keydown'); + resolveRead?.('stale text'); + await pastePromise; + + expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); + it('leaves the global paste action to Monaco when only a non-SQL editor is focused', async () => { const pasteAction = createPasteAction(); const internals = createInternals(pasteAction); @@ -140,23 +476,23 @@ describe('Monaco clipboard fallback', () => { release(); }); - it('routes paste to the SQL editor that currently has focus and cleans each editor independently', async () => { + it('routes context-menu paste to its requesting editor and cleans each editor independently', async () => { const pasteAction = createPasteAction(); const internals = createInternals(pasteAction); - let focusedEditor = 'first'; - const firstEditor = createEditor({ hasTextFocus: vi.fn(() => focusedEditor === 'first') }); - const secondEditor = createEditor({ hasTextFocus: vi.fn(() => focusedEditor === 'second') }); + const firstEditor = createEditor(); + const secondEditor = createEditor(); const scope = wailsScope(vi.fn().mockResolvedValue('native text')); const monaco = { editor: { EditorOption: { emptySelectionClipboard: 45 } } }; const releaseFirst = installWailsMonacoClipboardPasteHandler(monaco, firstEditor, scope, internals); const releaseSecond = installWailsMonacoClipboardPasteHandler(monaco, secondEditor, scope, internals); + firstEditor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(firstEditor.trigger).toHaveBeenCalledTimes(1); expect(secondEditor.trigger).not.toHaveBeenCalled(); - focusedEditor = 'second'; + secondEditor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(firstEditor.trigger).toHaveBeenCalledTimes(1); expect(secondEditor.trigger).toHaveBeenCalledTimes(1); @@ -208,6 +544,7 @@ describe('Monaco clipboard fallback', () => { internals, ); + editor.dispatchDomEvent('contextmenu'); const pastePromise = runPasteAction(pasteAction.implementations); release(); resolveRead?.('late text'); @@ -245,6 +582,7 @@ describe('Monaco clipboard fallback', () => { internals, ); + editor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { text: 'first value\nsecond value', @@ -253,6 +591,7 @@ describe('Monaco clipboard fallback', () => { mode: null, }); + editor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { text: 'whole line\n', @@ -261,6 +600,7 @@ describe('Monaco clipboard fallback', () => { mode: null, }); + editor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { text: 'foreign text', @@ -290,6 +630,7 @@ describe('Monaco clipboard fallback', () => { internals, ); + editor.dispatchDomEvent('contextmenu'); await runPasteAction(pasteAction.implementations); expect(wailsReadText).toHaveBeenCalledTimes(1); expect(browserReadText).toHaveBeenCalledTimes(1); @@ -297,19 +638,123 @@ describe('Monaco clipboard fallback', () => { release(); }); - it('does not register outside the Wails runtime', () => { + it('registers for the generated Wails runtime shape without a legacy WailsInvoke global', async () => { const pasteAction = createPasteAction(); + const readText = vi.fn().mockResolvedValue('bridge text'); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); const release = installWailsMonacoClipboardPasteHandler( { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, - createEditor(), + editor, { navigator: { clipboard: { readText: vi.fn().mockResolvedValue('browser text') } }, - window: { runtime: { ClipboardGetText: vi.fn().mockResolvedValue('bridge text') } }, + window: { runtime: { ClipboardGetText: readText } }, }, createInternals(pasteAction), ); - expect(pasteAction.addImplementation).not.toHaveBeenCalled(); + expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1); + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + expect(readText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'bridge text' }), + ); + release(); + }); + + it('keeps the Monaco paste action in a plain browser runtime', async () => { + const pasteAction = createPasteAction(); + const browserReadText = vi.fn().mockResolvedValue('browser text'); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + { + navigator: { clipboard: { readText: browserReadText } }, + window: {}, + }, + createInternals(pasteAction), + ); + + expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1); + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + expect(browserReadText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'browser text' }), + ); + release(); + }); + + it('falls through when no clipboard reader is available at invocation time', () => { + const pasteAction = createPasteAction(); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + { window: {} }, + createInternals(pasteAction), + ); + + expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1); + editor.dispatchDomEvent('contextmenu'); + expect(pasteAction.implementations[0]()).toBe(false); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); + + it('resolves a Wails clipboard reader injected after editor mount', async () => { + const pasteAction = createPasteAction(); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const scope: any = { window: { runtime: {} } }; + const readText = vi.fn().mockResolvedValue('late bridge text'); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + createInternals(pasteAction), + ); + scope.window.runtime.ClipboardGetText = readText; + + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + + expect(readText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith( + 'keyboard', + 'paste', + expect.objectContaining({ text: 'late bridge text' }), + ); + release(); + }); + + it('reports browser clipboard permission failures instead of swallowing them', async () => { + const pasteAction = createPasteAction(); + const error = new DOMException('Read permission denied', 'NotAllowedError'); + const onReadFailure = vi.fn(); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + { + navigator: { clipboard: { readText: vi.fn().mockRejectedValue(error) } }, + window: {}, + }, + createInternals(pasteAction), + onReadFailure, + ); + + editor.dispatchDomEvent('contextmenu'); + await runPasteAction(pasteAction.implementations); + + expect(onReadFailure).toHaveBeenCalledWith({ source: 'browser', error }); + expect(editor.trigger).not.toHaveBeenCalled(); release(); }); @@ -329,6 +774,7 @@ describe('Monaco clipboard fallback', () => { internals, ); + editor.dispatchDomEvent('contextmenu'); expect(pasteAction.implementations[0]()).toBe(false); expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled(); expect(editor.trigger).not.toHaveBeenCalled(); diff --git a/frontend/src/utils/monacoClipboard.ts b/frontend/src/utils/monacoClipboard.ts index 203a0213..0e7024d8 100644 --- a/frontend/src/utils/monacoClipboard.ts +++ b/frontend/src/utils/monacoClipboard.ts @@ -9,7 +9,6 @@ interface WailsClipboardRuntimeLike { } interface WailsWindowLike { - WailsInvoke?: unknown; runtime?: WailsClipboardRuntimeLike; } @@ -20,15 +19,47 @@ export interface MonacoClipboardScope { window?: WailsWindowLike; } +export interface MonacoClipboardReadFailure { + source: 'wails' | 'browser'; + error: unknown; +} + +// A fresh token lets mounted editors replace stale Monaco actions after a Vite/Wails HMR update. +export const MONACO_CLIPBOARD_HANDLER_REVISION = Symbol('gonavi-monaco-clipboard-handler'); + interface DisposableLike { dispose: () => void; } +interface MonacoClipboardDomEventLike { + target?: { + closest?: (selector: string) => unknown; + } | null; + composedPath?: () => unknown[]; +} + +interface MonacoClipboardEventTargetLike { + addEventListener?: ( + type: string, + listener: (event?: unknown) => void, + useCapture?: boolean, + ) => void; + removeEventListener?: ( + type: string, + listener: (event?: unknown) => void, + useCapture?: boolean, + ) => void; +} + +interface MonacoClipboardDomNodeLike extends MonacoClipboardEventTargetLike { + ownerDocument?: MonacoClipboardEventTargetLike; +} + interface MonacoClipboardEditorLike { + getDomNode?: () => MonacoClipboardDomNodeLike | null; getOption?: (option: any) => unknown; getRawOptions?: () => { readOnly?: boolean }; hasModel?: () => boolean; - hasTextFocus?: () => boolean; onDidDispose?: (listener: () => void) => DisposableLike; trigger?: (source: string, handlerId: string, payload: unknown) => void; } @@ -67,15 +98,65 @@ export interface MonacoClipboardInternals { } const MONACO_PASTE_IMPLEMENTATION_PRIORITY = 10001; +const CONTEXT_MENU_OWNERSHIP_MS = 15_000; +const CONTEXT_MENU_OWNER_KEY = Symbol.for('gonavi.monacoClipboard.contextMenuOwner'); +const INTERACTION_REVISION_KEY = Symbol.for('gonavi.monacoClipboard.interactionRevision'); const noop = () => {}; -let monacoClipboardInternalsPromise: Promise | null = null; +interface MonacoClipboardContextMenuOwner { + editor: MonacoClipboardEditorLike; + requestedAt: number; + interactionRevision: number; +} -const isWailsClipboardRuntime = (scope: MonacoClipboardScope): boolean => ( - typeof scope.window?.WailsInvoke === 'function' - && typeof scope.window.runtime?.ClipboardGetText === 'function' +interface MonacoClipboardGlobalScope { + [key: symbol]: unknown; +} + +const getContextMenuOwnerScope = (): MonacoClipboardGlobalScope => ( + globalThis as unknown as MonacoClipboardGlobalScope ); +const getContextMenuOwner = (): MonacoClipboardContextMenuOwner | undefined => { + const ownerScope = getContextMenuOwnerScope(); + const owner = ownerScope[CONTEXT_MENU_OWNER_KEY] as MonacoClipboardContextMenuOwner | undefined; + if (owner && Date.now() - owner.requestedAt > CONTEXT_MENU_OWNERSHIP_MS) { + delete ownerScope[CONTEXT_MENU_OWNER_KEY]; + return undefined; + } + return owner; +}; + +const getInteractionRevision = (): number => { + const revision = getContextMenuOwnerScope()[INTERACTION_REVISION_KEY]; + return typeof revision === 'number' ? revision : 0; +}; + +const bumpInteractionRevision = (): number => { + const ownerScope = getContextMenuOwnerScope(); + const revision = getInteractionRevision() + 1; + ownerScope[INTERACTION_REVISION_KEY] = revision; + return revision; +}; + +const setContextMenuOwner = (editor: MonacoClipboardEditorLike): void => { + getContextMenuOwnerScope()[CONTEXT_MENU_OWNER_KEY] = { + editor, + requestedAt: Date.now(), + interactionRevision: bumpInteractionRevision(), + }; +}; + +const clearContextMenuOwner = (editor?: MonacoClipboardEditorLike): void => { + const ownerScope = getContextMenuOwnerScope(); + const owner = ownerScope[CONTEXT_MENU_OWNER_KEY] as MonacoClipboardContextMenuOwner | undefined; + if (!editor || owner?.editor === editor) { + delete ownerScope[CONTEXT_MENU_OWNER_KEY]; + } +}; + +let monacoClipboardInternalsPromise: Promise | null = null; + const getBrowserClipboardReader = (scope: MonacoClipboardScope): ClipboardReadText | undefined => { try { const clipboard = scope.navigator?.clipboard; @@ -85,6 +166,26 @@ const getBrowserClipboardReader = (scope: MonacoClipboardScope): ClipboardReadTe } }; +const getClipboardReaders = (scope: MonacoClipboardScope): { + primaryReadText?: ClipboardReadText; + fallbackReadText?: ClipboardReadText; + source: 'wails' | 'browser'; +} => { + const wailsReadText = scope.window?.runtime?.ClipboardGetText; + const browserReadText = getBrowserClipboardReader(scope); + if (typeof wailsReadText === 'function') { + return { + primaryReadText: wailsReadText.bind(scope.window?.runtime), + fallbackReadText: browserReadText, + source: 'wails', + }; + } + return { + primaryReadText: browserReadText, + source: 'browser', + }; +}; + const loadMonacoClipboardInternals = (): Promise => { if (!monacoClipboardInternalsPromise) { monacoClipboardInternalsPromise = Promise.all([ @@ -136,48 +237,78 @@ const installPasteImplementation = ( editor: MonacoClipboardEditorLike, scope: MonacoClipboardScope, internals: MonacoClipboardInternals, + onReadFailure?: (failure: MonacoClipboardReadFailure) => void, ): (() => void) => { const pasteAction = internals.pasteAction; - const wailsReadText = scope.window?.runtime?.ClipboardGetText; - if (!pasteAction?.addImplementation || typeof wailsReadText !== 'function') { + if (!pasteAction?.addImplementation) { return noop; } - const browserReadText = getBrowserClipboardReader(scope); let released = false; + const editorDomNode = editor.getDomNode?.(); + const handleContextMenu = () => { + setContextMenuOwner(editor); + }; + const handleEditorInteraction = (event?: unknown) => { + const domEvent = event as MonacoClipboardDomEventLike | undefined; + const eventPath = domEvent?.composedPath?.() ?? []; + const isMonacoMenuInteraction = [domEvent?.target, ...eventPath].some((node) => { + const target = node as MonacoClipboardDomEventLike['target']; + return Boolean(target?.closest?.('.monaco-menu-container')); + }); + if (isMonacoMenuInteraction) { + return; + } + bumpInteractionRevision(); + clearContextMenuOwner(); + }; + const interactionEventTarget = editorDomNode?.ownerDocument ?? editorDomNode; + editorDomNode?.addEventListener?.('contextmenu', handleContextMenu); + interactionEventTarget?.addEventListener?.('keydown', handleEditorInteraction, true); + interactionEventTarget?.addEventListener?.('pointerdown', handleEditorInteraction, true); const implementationDisposable = pasteAction.addImplementation( MONACO_PASTE_IMPLEMENTATION_PRIORITY, 'gonavi-wails-sql-editor', () => { - const trigger = editor.trigger; + const contextMenuOwner = getContextMenuOwner(); + const ownsContextMenu = contextMenuOwner?.editor === editor; if ( - editor.hasModel?.() === false - || editor.hasTextFocus?.() !== true + !ownsContextMenu + || editor.hasModel?.() === false || editor.getRawOptions?.().readOnly === true - || typeof trigger !== 'function' + || typeof editor.trigger !== 'function' ) { - // Let Monaco's default implementation handle another focused editor. + // Keyboard paste and other editors must keep Monaco's native implementation. return false; } + clearContextMenuOwner(editor); + const { primaryReadText, fallbackReadText, source } = getClipboardReaders(scope); + if (!primaryReadText) { + return false; + } + const requestRevision = contextMenuOwner.interactionRevision; + return (async () => { let text: string; try { - text = await readClipboardTextWithFallback(wailsReadText.bind(scope.window?.runtime), browserReadText); - } catch { + text = await readClipboardTextWithFallback(primaryReadText, fallbackReadText); + } catch (error) { + onReadFailure?.({ source, error }); return; } if ( released + || requestRevision !== getInteractionRevision() || !text || editor.hasModel?.() === false - || editor.hasTextFocus?.() !== true || editor.getRawOptions?.().readOnly === true + || typeof editor.trigger !== 'function' ) { return; } - trigger('keyboard', 'paste', createPastePayload(monaco, editor, text, internals.metadataManager)); + editor.trigger('keyboard', 'paste', createPastePayload(monaco, editor, text, internals.metadataManager)); })(); }, ); @@ -186,7 +317,11 @@ const installPasteImplementation = ( const release = () => { if (released) return; released = true; + clearContextMenuOwner(editor); implementationDisposable.dispose(); + editorDomNode?.removeEventListener?.('contextmenu', handleContextMenu); + interactionEventTarget?.removeEventListener?.('keydown', handleEditorInteraction, true); + interactionEventTarget?.removeEventListener?.('pointerdown', handleEditorInteraction, true); editorDisposeDisposable?.dispose(); }; editorDisposeDisposable = editor.onDidDispose?.(release); @@ -213,20 +348,17 @@ export const installWailsMonacoClipboardPasteHandler = ( editor: MonacoClipboardEditorLike, scope: MonacoClipboardScope = globalThis as unknown as MonacoClipboardScope, internals?: MonacoClipboardInternals, + onReadFailure?: (failure: MonacoClipboardReadFailure) => void, ): (() => void) => { - if (!isWailsClipboardRuntime(scope)) { - return noop; - } - if (internals) { - return installPasteImplementation(monaco, editor, scope, internals); + return installPasteImplementation(monaco, editor, scope, internals, onReadFailure); } let released = false; let installedCleanup = noop; void loadMonacoClipboardInternals().then((loadedInternals) => { if (!released && loadedInternals) { - installedCleanup = installPasteImplementation(monaco, editor, scope, loadedInternals); + installedCleanup = installPasteImplementation(monaco, editor, scope, loadedInternals, onReadFailure); } }); diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 317dd3d9..1414ddcc 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "Abfrage konnte nicht abgebrochen werden: {{error}}", "query_editor.message.cancel_no_running": "Keine laufende Abfrage zum Abbrechen.", "query_editor.message.cancel_success": "Abfrage abgebrochen.", + "query_editor.message.clipboard_permission_required": "Der Browser kann die Zwischenablage nicht lesen. Erlauben Sie dieser Website den Zugriff auf die Zwischenablage und versuchen Sie es erneut.", + "query_editor.message.clipboard_read_failed": "Die Zwischenablage kann nicht gelesen werden. Versuchen Sie es erneut.", "query_editor.message.connection_not_found": "Verbindung nicht gefunden.", "query_editor.message.connection_readonly_blocked": "Für diese Verbindung ist der Produktionsschutz aktiv; es sind nur Abfragen erlaubt.", "query_editor.message.current_line_no_copyable_content": "Die aktuelle Zeile enthält keinen kopierbaren Inhalt.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index e473ff5b..48031940 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "Failed to cancel query: {{error}}", "query_editor.message.cancel_no_running": "No running query to cancel.", "query_editor.message.cancel_success": "Query canceled.", + "query_editor.message.clipboard_permission_required": "The browser cannot read the clipboard. Allow clipboard access for this site and try again.", + "query_editor.message.clipboard_read_failed": "Unable to read the clipboard. Try again.", "query_editor.message.connection_not_found": "Connection not found.", "query_editor.message.connection_readonly_blocked": "This connection has production guard enabled and only allows query operations.", "query_editor.message.current_line_no_copyable_content": "No copyable content on the current line.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 27be2584..9f737a1f 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "クエリのキャンセルに失敗しました: {{error}}", "query_editor.message.cancel_no_running": "キャンセルできる実行中のクエリはありません。", "query_editor.message.cancel_success": "クエリをキャンセルしました。", + "query_editor.message.clipboard_permission_required": "ブラウザーがクリップボードを読み取れません。このサイトのクリップボード権限を許可してから再試行してください。", + "query_editor.message.clipboard_read_failed": "クリップボードを読み取れません。再試行してください。", "query_editor.message.connection_not_found": "接続が見つかりません。", "query_editor.message.connection_readonly_blocked": "この接続では本番保護が有効なため、問い合わせ操作のみ実行できます。", "query_editor.message.current_line_no_copyable_content": "現在の行にコピーできる内容がありません。", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 067f530f..5595a807 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "Не удалось отменить запрос: {{error}}", "query_editor.message.cancel_no_running": "Нет выполняющегося запроса для отмены.", "query_editor.message.cancel_success": "Запрос отменен.", + "query_editor.message.clipboard_permission_required": "Браузер не может прочитать буфер обмена. Разрешите этому сайту доступ к буферу обмена и повторите попытку.", + "query_editor.message.clipboard_read_failed": "Не удалось прочитать буфер обмена. Повторите попытку.", "query_editor.message.connection_not_found": "Подключение не найдено.", "query_editor.message.connection_readonly_blocked": "Для этого подключения включена защита production, разрешены только операции запроса.", "query_editor.message.current_line_no_copyable_content": "В текущей строке нет содержимого для копирования.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 3ae0e377..680e2acd 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "取消查询失败:{{error}}", "query_editor.message.cancel_no_running": "没有正在运行的查询可取消。", "query_editor.message.cancel_success": "查询已中止。", + "query_editor.message.clipboard_permission_required": "浏览器无法读取剪贴板,请允许此站点的剪贴板权限后重试。", + "query_editor.message.clipboard_read_failed": "无法读取剪贴板,请重试。", "query_editor.message.connection_not_found": "未找到连接。", "query_editor.message.connection_readonly_blocked": "当前连接已启用生产保护,仅允许执行查询操作。", "query_editor.message.current_line_no_copyable_content": "当前行没有可复制内容。", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 18121009..20a11ec9 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -6854,6 +6854,8 @@ "query_editor.message.cancel_failed": "取消查詢失敗:{{error}}", "query_editor.message.cancel_no_running": "沒有正在執行的查詢可取消。", "query_editor.message.cancel_success": "查詢已終止。", + "query_editor.message.clipboard_permission_required": "瀏覽器無法讀取剪貼簿,請允許此網站的剪貼簿權限後重試。", + "query_editor.message.clipboard_read_failed": "無法讀取剪貼簿,請重試。", "query_editor.message.connection_not_found": "找不到連線。", "query_editor.message.connection_readonly_blocked": "目前連線已啟用正式保護,僅允許執行查詢操作。", "query_editor.message.current_line_no_copyable_content": "目前行沒有可複製內容。",