Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Syngnat
2026-08-11 16:00:01 +08:00
11 changed files with 694 additions and 55 deletions

View File

@@ -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();
});

View File

@@ -3381,7 +3381,9 @@ const DataGrid: React.FC<DataGridProps> = ({
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) => {

View File

@@ -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<MonacoEditorProps> = ({
const monoFontFamily = useStore((state) => state.appearance.customMonoFontFamily);
const globalFontSize = useStore((state) => state.fontSize);
const clipboardPasteCleanupRef = useRef<(() => void) | null>(null);
const clipboardEditorRef = useRef<Parameters<OnMount>[0] | null>(null);
const clipboardMonacoRef = useRef<Parameters<OnMount>[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<MonacoEditorProps> = ({
};
}, []);
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<OnMount>[0], monaco: Parameters<OnMount>[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') {

View File

@@ -45,19 +45,37 @@ const createInternals = (
},
});
const createEditor = (overrides: Record<string, unknown> = {}) => ({
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<string, (event?: unknown) => 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<string, unknown> = {}) => {
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<string>((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<string>((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();

View File

@@ -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<MonacoClipboardInternals | null> | 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<MonacoClipboardInternals | null> | 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<MonacoClipboardInternals | null> => {
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);
}
});

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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": "現在の行にコピーできる内容がありません。",

View File

@@ -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": "В текущей строке нет содержимого для копирования.",

View File

@@ -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": "当前行没有可复制内容。",

View File

@@ -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": "目前行沒有可複製內容。",