diff --git a/frontend/src/components/MonacoEditor.tsx b/frontend/src/components/MonacoEditor.tsx index 0ff6bbc0..7f502993 100644 --- a/frontend/src/components/MonacoEditor.tsx +++ b/frontend/src/components/MonacoEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Editor, { loader, type BeforeMount, type EditorProps, type OnMount } from '@monaco-editor/react'; import { useStore } from '../store'; import { sanitizeDataTableFontSize } from '../utils/dataGridDisplay'; @@ -7,6 +7,7 @@ import { resolveSqlEditorFontSize, resolveSqlEditorSuggestionLayout, } from '../utils/sqlEditorTypography'; +import { installWailsMonacoClipboardPasteHandler } from '../utils/monacoClipboard'; export type { BeforeMount, OnMount } from '@monaco-editor/react'; export type GonaviMonacoTypography = 'code' | 'data' | 'sql'; @@ -808,6 +809,7 @@ const MonacoEditor: React.FC = ({ const sqlEditorFontSizeFollowGlobal = useStore((state) => state.appearance.sqlEditorFontSizeFollowGlobal); const monoFontFamily = useStore((state) => state.appearance.customMonoFontFamily); const globalFontSize = useStore((state) => state.fontSize); + const clipboardPasteCleanupRef = useRef<(() => void) | 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'); @@ -833,17 +835,26 @@ 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) => { + clipboardPasteCleanupRef.current?.(); + clipboardPasteCleanupRef.current = gonaviTypography === 'sql' + ? installWailsMonacoClipboardPasteHandler(monaco, editor) + : null; installOceanBaseOracleNavigationFallback(editor); installPrintableInputFallback(editor, monaco); installWebKitImeScrollStabilizer(editor); onMount?.(editor, monaco); - }, [onMount]); + }, [gonaviTypography, onMount]); const resolvedOptions = useMemo(() => { if (uiVersion !== 'v2') { diff --git a/frontend/src/utils/monacoClipboard.test.ts b/frontend/src/utils/monacoClipboard.test.ts new file mode 100644 index 00000000..a5f33d81 --- /dev/null +++ b/frontend/src/utils/monacoClipboard.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + installWailsMonacoClipboardPasteHandler, + readClipboardTextWithFallback, + type MonacoClipboardInternals, +} from './monacoClipboard'; + +type PasteImplementation = () => boolean | Promise; + +const createPasteAction = () => { + const implementations: Array<{ priority: number; implementation: PasteImplementation }> = []; + + return { + addImplementation: vi.fn((priority: number, _name: string, implementation: PasteImplementation) => { + const entry = { priority, implementation }; + implementations.push(entry); + implementations.sort((left, right) => right.priority - left.priority); + return { + dispose: () => { + const index = implementations.indexOf(entry); + if (index >= 0) { + implementations.splice(index, 1); + } + }, + }; + }), + get implementations() { + return implementations.map((entry) => entry.implementation); + }, + }; +}; + +const createInternals = ( + pasteAction: ReturnType, + metadataByText = new Map(), +): MonacoClipboardInternals => ({ + pasteAction, + metadataManager: { + get: vi.fn((text: string) => metadataByText.get(text) ?? null), + }, +}); + +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 wailsScope = (readText = vi.fn().mockResolvedValue('native text')) => ({ + window: { + WailsInvoke: vi.fn(), + runtime: { ClipboardGetText: readText }, + }, +}); + +const runPasteAction = async (implementations: PasteImplementation[]) => { + for (const implementation of implementations) { + const result = implementation(); + if (result !== false) { + await result; + return; + } + } +}; + +describe('Monaco clipboard fallback', () => { + it('uses the primary clipboard reader when it can read text', async () => { + const primaryReadText = vi.fn().mockResolvedValue('native text'); + const fallbackReadText = vi.fn().mockResolvedValue('browser text'); + + await expect(readClipboardTextWithFallback(primaryReadText, fallbackReadText)) + .resolves.toBe('native text'); + expect(fallbackReadText).not.toHaveBeenCalled(); + }); + + it('falls back when the primary clipboard reader rejects the read', async () => { + const primaryReadText = vi.fn().mockRejectedValue(new Error('native clipboard unavailable')); + const fallbackReadText = vi.fn().mockResolvedValue('SELECT * FROM users;'); + + await expect(readClipboardTextWithFallback(primaryReadText, fallbackReadText)) + .resolves.toBe('SELECT * FROM users;'); + }); + + it('only handles paste while a registered SQL editor has text focus', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const sqlEditor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const nonSqlEditor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const scope = wailsScope(); + + const releaseSql = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + sqlEditor, + scope, + internals, + ); + + expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1); + await runPasteAction(pasteAction.implementations); + expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1); + expect(sqlEditor.trigger).toHaveBeenCalledWith('keyboard', 'paste', { + text: 'native text', + pasteOnNewLine: false, + multicursorText: null, + mode: null, + }); + expect(nonSqlEditor.trigger).not.toHaveBeenCalled(); + + releaseSql(); + }); + + it('leaves the global paste action to Monaco when only a non-SQL editor is focused', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const sqlEditor = createEditor({ hasTextFocus: vi.fn(() => false) }); + const scope = wailsScope(); + const defaultPaste = vi.fn(() => true); + pasteAction.addImplementation(10000, 'monaco-default-paste', defaultPaste); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + sqlEditor, + scope, + internals, + ); + + await runPasteAction(pasteAction.implementations); + expect(defaultPaste).toHaveBeenCalledTimes(1); + expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled(); + expect(sqlEditor.trigger).not.toHaveBeenCalled(); + release(); + }); + + it('routes paste to the SQL editor that currently has focus 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 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); + + await runPasteAction(pasteAction.implementations); + expect(firstEditor.trigger).toHaveBeenCalledTimes(1); + expect(secondEditor.trigger).not.toHaveBeenCalled(); + + focusedEditor = 'second'; + await runPasteAction(pasteAction.implementations); + expect(firstEditor.trigger).toHaveBeenCalledTimes(1); + expect(secondEditor.trigger).toHaveBeenCalledTimes(1); + + releaseSecond(); + expect(pasteAction.implementations).toHaveLength(1); + expect(pasteAction.implementations[0]()).toBe(false); + + releaseFirst(); + expect(pasteAction.implementations).toHaveLength(0); + }); + + it('automatically unregisters a disposed SQL editor', () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + let onDispose: (() => void) | undefined; + const editor = createEditor({ + onDidDispose: vi.fn((listener: () => void) => { + onDispose = listener; + return { dispose: vi.fn() }; + }), + }); + + installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(), + internals, + ); + + expect(pasteAction.implementations).toHaveLength(1); + onDispose?.(); + expect(pasteAction.implementations).toHaveLength(0); + }); + + it('does not paste after the SQL editor is released during an async clipboard read', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + let resolveRead: ((text: string) => void) | undefined; + const wailsReadText = vi.fn(() => new Promise((resolve) => { + resolveRead = resolve; + })); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(wailsReadText), + internals, + ); + + const pastePromise = runPasteAction(pasteAction.implementations); + release(); + resolveRead?.('late text'); + await pastePromise; + + expect(editor.trigger).not.toHaveBeenCalled(); + expect(pasteAction.implementations).toHaveLength(0); + }); + + it('reuses Monaco metadata for matching multi-cursor and whole-line copies only', async () => { + const pasteAction = createPasteAction(); + const metadata = new Map([ + ['first value\nsecond value', { + isFromEmptySelection: false, + multicursorText: ['first value', 'second value'], + mode: null, + }], + ['whole line\n', { + isFromEmptySelection: true, + multicursorText: null, + mode: null, + }], + ]); + const internals = createInternals(pasteAction, metadata); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + const wailsReadText = vi.fn() + .mockResolvedValueOnce('first value\nsecond value') + .mockResolvedValueOnce('whole line\n') + .mockResolvedValueOnce('foreign text'); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + wailsScope(wailsReadText), + internals, + ); + + await runPasteAction(pasteAction.implementations); + expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { + text: 'first value\nsecond value', + pasteOnNewLine: false, + multicursorText: ['first value', 'second value'], + mode: null, + }); + + await runPasteAction(pasteAction.implementations); + expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { + text: 'whole line\n', + pasteOnNewLine: true, + multicursorText: null, + mode: null, + }); + + await runPasteAction(pasteAction.implementations); + expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', { + text: 'foreign text', + pasteOnNewLine: false, + multicursorText: null, + mode: null, + }); + expect(internals.metadataManager.get).toHaveBeenCalledWith('foreign text'); + + release(); + }); + + it('uses the browser reader if the Wails clipboard is temporarily unavailable', async () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const browserReadText = vi.fn().mockResolvedValue('browser text'); + const wailsReadText = vi.fn().mockRejectedValue(new Error('native clipboard unavailable')); + const editor = createEditor({ hasTextFocus: vi.fn(() => true) }); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + { + navigator: { clipboard: { readText: browserReadText } }, + ...wailsScope(wailsReadText), + }, + internals, + ); + + await runPasteAction(pasteAction.implementations); + expect(wailsReadText).toHaveBeenCalledTimes(1); + expect(browserReadText).toHaveBeenCalledTimes(1); + expect(editor.trigger).toHaveBeenCalledWith('keyboard', 'paste', expect.objectContaining({ text: 'browser text' })); + release(); + }); + + it('does not register outside the Wails runtime', () => { + const pasteAction = createPasteAction(); + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + createEditor(), + { + navigator: { clipboard: { readText: vi.fn().mockResolvedValue('browser text') } }, + window: { runtime: { ClipboardGetText: vi.fn().mockResolvedValue('bridge text') } }, + }, + createInternals(pasteAction), + ); + + expect(pasteAction.addImplementation).not.toHaveBeenCalled(); + release(); + }); + + it('does not read or paste into a read-only SQL editor', () => { + const pasteAction = createPasteAction(); + const internals = createInternals(pasteAction); + const editor = createEditor({ + getRawOptions: vi.fn(() => ({ readOnly: true })), + hasTextFocus: vi.fn(() => true), + }); + const scope = wailsScope(); + + const release = installWailsMonacoClipboardPasteHandler( + { editor: { EditorOption: { emptySelectionClipboard: 45 } } }, + editor, + scope, + internals, + ); + + expect(pasteAction.implementations[0]()).toBe(false); + expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled(); + expect(editor.trigger).not.toHaveBeenCalled(); + release(); + }); +}); diff --git a/frontend/src/utils/monacoClipboard.ts b/frontend/src/utils/monacoClipboard.ts new file mode 100644 index 00000000..203a0213 --- /dev/null +++ b/frontend/src/utils/monacoClipboard.ts @@ -0,0 +1,238 @@ +type ClipboardReadText = () => string | Promise; + +interface ClipboardLike { + readText: ClipboardReadText; +} + +interface WailsClipboardRuntimeLike { + ClipboardGetText?: ClipboardReadText; +} + +interface WailsWindowLike { + WailsInvoke?: unknown; + runtime?: WailsClipboardRuntimeLike; +} + +export interface MonacoClipboardScope { + navigator?: { + clipboard?: ClipboardLike; + }; + window?: WailsWindowLike; +} + +interface DisposableLike { + dispose: () => void; +} + +interface MonacoClipboardEditorLike { + getOption?: (option: any) => unknown; + getRawOptions?: () => { readOnly?: boolean }; + hasModel?: () => boolean; + hasTextFocus?: () => boolean; + onDidDispose?: (listener: () => void) => DisposableLike; + trigger?: (source: string, handlerId: string, payload: unknown) => void; +} + +interface MonacoEditorApiLike { + EditorOption?: { + emptySelectionClipboard?: unknown; + }; +} + +export interface MonacoClipboardApiLike { + editor?: MonacoEditorApiLike; +} + +interface MonacoClipboardMetadata { + isFromEmptySelection?: boolean; + multicursorText?: string[] | null; + mode?: unknown; +} + +interface MonacoClipboardMetadataManagerLike { + get: (text: string) => MonacoClipboardMetadata | null; +} + +export interface MonacoClipboardPasteActionLike { + addImplementation?: ( + priority: number, + name: string, + implementation: () => boolean | Promise, + ) => DisposableLike; +} + +export interface MonacoClipboardInternals { + metadataManager: MonacoClipboardMetadataManagerLike; + pasteAction?: MonacoClipboardPasteActionLike; +} + +const MONACO_PASTE_IMPLEMENTATION_PRIORITY = 10001; +const noop = () => {}; + +let monacoClipboardInternalsPromise: Promise | null = null; + +const isWailsClipboardRuntime = (scope: MonacoClipboardScope): boolean => ( + typeof scope.window?.WailsInvoke === 'function' + && typeof scope.window.runtime?.ClipboardGetText === 'function' +); + +const getBrowserClipboardReader = (scope: MonacoClipboardScope): ClipboardReadText | undefined => { + try { + const clipboard = scope.navigator?.clipboard; + return typeof clipboard?.readText === 'function' ? clipboard.readText.bind(clipboard) : undefined; + } catch { + return undefined; + } +}; + +const loadMonacoClipboardInternals = (): Promise => { + if (!monacoClipboardInternalsPromise) { + monacoClipboardInternalsPromise = Promise.all([ + import('monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'), + import('monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js'), + ]).then(([clipboardModule, clipboardUtilsModule]) => { + // Monaco 0.55.1 ships these symbols without public declarations. The main editor bundle + // imports both modules, so these are the same instances used by Monaco's default action. + const pasteAction = (clipboardModule as unknown as { + PasteAction?: MonacoClipboardPasteActionLike; + }).PasteAction; + const metadataManager = (clipboardUtilsModule as unknown as { + InMemoryClipboardMetadataManager?: { INSTANCE?: MonacoClipboardMetadataManagerLike }; + }).InMemoryClipboardMetadataManager?.INSTANCE; + + return metadataManager ? { pasteAction, metadataManager } : null; + }).catch(() => null); + } + + return monacoClipboardInternalsPromise; +}; + +const createPastePayload = ( + monaco: MonacoClipboardApiLike, + editor: MonacoClipboardEditorLike, + text: string, + metadataManager: MonacoClipboardMetadataManagerLike, +) => { + // This is Monaco's own text-keyed fallback for data that cannot carry custom clipboard MIME types. + const metadata = metadataManager.get(text); + const emptySelectionClipboard = monaco.editor?.EditorOption?.emptySelectionClipboard; + const pasteOnNewLine = emptySelectionClipboard !== undefined + && editor.getOption?.(emptySelectionClipboard) === true + && metadata?.isFromEmptySelection === true; + + return { + text, + pasteOnNewLine, + // Wails only exposes text. Never reconstruct multicursorText from arbitrary line breaks. + multicursorText: metadata && typeof metadata.multicursorText !== 'undefined' + ? metadata.multicursorText + : null, + mode: metadata?.mode ?? null, + }; +}; + +const installPasteImplementation = ( + monaco: MonacoClipboardApiLike, + editor: MonacoClipboardEditorLike, + scope: MonacoClipboardScope, + internals: MonacoClipboardInternals, +): (() => void) => { + const pasteAction = internals.pasteAction; + const wailsReadText = scope.window?.runtime?.ClipboardGetText; + if (!pasteAction?.addImplementation || typeof wailsReadText !== 'function') { + return noop; + } + + const browserReadText = getBrowserClipboardReader(scope); + let released = false; + const implementationDisposable = pasteAction.addImplementation( + MONACO_PASTE_IMPLEMENTATION_PRIORITY, + 'gonavi-wails-sql-editor', + () => { + const trigger = editor.trigger; + if ( + editor.hasModel?.() === false + || editor.hasTextFocus?.() !== true + || editor.getRawOptions?.().readOnly === true + || typeof trigger !== 'function' + ) { + // Let Monaco's default implementation handle another focused editor. + return false; + } + + return (async () => { + let text: string; + try { + text = await readClipboardTextWithFallback(wailsReadText.bind(scope.window?.runtime), browserReadText); + } catch { + return; + } + if ( + released + || !text + || editor.hasModel?.() === false + || editor.hasTextFocus?.() !== true + || editor.getRawOptions?.().readOnly === true + ) { + return; + } + + trigger('keyboard', 'paste', createPastePayload(monaco, editor, text, internals.metadataManager)); + })(); + }, + ); + + let editorDisposeDisposable: DisposableLike | undefined; + const release = () => { + if (released) return; + released = true; + implementationDisposable.dispose(); + editorDisposeDisposable?.dispose(); + }; + editorDisposeDisposable = editor.onDidDispose?.(release); + + return release; +}; + +export const readClipboardTextWithFallback = async ( + primaryReadText: ClipboardReadText, + fallbackReadText?: ClipboardReadText, +): Promise => { + try { + return String(await primaryReadText() ?? ''); + } catch (primaryError) { + if (!fallbackReadText) { + throw primaryError; + } + return String(await fallbackReadText() ?? ''); + } +}; + +export const installWailsMonacoClipboardPasteHandler = ( + monaco: MonacoClipboardApiLike, + editor: MonacoClipboardEditorLike, + scope: MonacoClipboardScope = globalThis as unknown as MonacoClipboardScope, + internals?: MonacoClipboardInternals, +): (() => void) => { + if (!isWailsClipboardRuntime(scope)) { + return noop; + } + + if (internals) { + return installPasteImplementation(monaco, editor, scope, internals); + } + + let released = false; + let installedCleanup = noop; + void loadMonacoClipboardInternals().then((loadedInternals) => { + if (!released && loadedInternals) { + installedCleanup = installPasteImplementation(monaco, editor, scope, loadedInternals); + } + }); + + return () => { + if (released) return; + released = true; + installedCleanup(); + }; +}; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 688f65a0..f2bfff58 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -5,6 +5,30 @@ declare module 'monaco-editor/esm/nls.messages.zh-cn' { export default messages; } +declare module 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js' { + export const PasteAction: { + addImplementation( + priority: number, + name: string, + implementation: () => boolean | Promise, + ): { dispose(): void }; + } | undefined; +} + +declare module 'monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js' { + interface ClipboardMetadata { + isFromEmptySelection?: boolean; + multicursorText?: string[] | null; + mode?: unknown; + } + + export const InMemoryClipboardMetadataManager: { + INSTANCE: { + get(text: string): ClipboardMetadata | null; + }; + }; +} + interface ImportMetaEnv { readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string; }