From ece1119c318e12186b73574233ae78eb67193cac Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 30 Jun 2026 15:25:01 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E8=A1=8C=E7=BA=A7=E5=BF=AB=E6=8D=B7=E9=94=AE=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=90=9C=E7=B4=A2=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接管 Cmd/Ctrl+E,选择当前行并复制,避免落到宿主搜索 - 新增 Cmd/Ctrl+D 复制当前行到下一行,并同步快捷键冲突校验 - 增加 macOS SQL 菜单桥接,确保原生 Cmd+E 回落到编辑器 - 保持 Monaco 内置搜索并下移,避免遮挡 SQL 第一行 - 补齐多语言文案与 QueryEditor/shortcut/main 回归测试 --- frontend/src/App.tsx | 14 +- .../QueryEditor.external-sql-save.test.tsx | 372 ++++++++++++++- .../src/components/QueryEditor.i18n.test.ts | 1 + .../QueryEditor.results-and-drop.test.tsx | 3 + frontend/src/components/QueryEditor.tsx | 445 +++++++++++++++++- frontend/src/i18n/catalog.test.ts | 28 +- frontend/src/styles/v2-theme-workbench.css | 8 + frontend/src/utils/shortcuts.test.ts | 48 +- frontend/src/utils/shortcuts.ts | 64 ++- main.go | 35 ++ main_test.go | 54 ++- shared/i18n/de-DE.json | 7 +- shared/i18n/en-US.json | 7 +- shared/i18n/ja-JP.json | 7 +- shared/i18n/ru-RU.json | 7 +- shared/i18n/zh-CN.json | 7 +- shared/i18n/zh-TW.json | 7 +- 17 files changed, 1054 insertions(+), 60 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 073f7851..6052111f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -92,7 +92,7 @@ import { ShortcutAction, canRecordShortcutForAction, eventToShortcut, - findReservedConflicts, + findReservedConflictsForAction, getShortcutDisplay, getShortcutDisplayLabel, getShortcutPlatform, @@ -2203,7 +2203,11 @@ function App() { for (const action of SHORTCUT_ACTION_ORDER) { const binding = resolveShortcutBinding(shortcutOptions, action, activeShortcutPlatform); if (!binding?.enabled || !binding.combo) continue; - const conflicts = findReservedConflicts(normalizeShortcutCombo(binding.combo), activeShortcutPlatform); + const conflicts = findReservedConflictsForAction( + action, + normalizeShortcutCombo(binding.combo), + activeShortcutPlatform, + ); if (conflicts.length > 0) { map[action] = conflicts; } @@ -2935,7 +2939,11 @@ function App() { return; } - const reservedConflicts = findReservedConflicts(normalizedCombo, activeShortcutPlatform); + const reservedConflicts = findReservedConflictsForAction( + capturingShortcutAction, + normalizedCombo, + activeShortcutPlatform, + ); if (reservedConflicts.length > 0) { const { hasMonaco, hasOther, monacoLabels, otherLabels, otherContexts } = splitConflictsByContext(reservedConflicts); if (hasMonaco) { diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 075258aa..6062699d 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -76,6 +76,10 @@ const storeState = vi.hoisted(() => ({ mac: { enabled: false, combo: '' }, windows: { enabled: false, combo: '' }, }, + duplicateCurrentLine: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, saveQuery: { mac: { enabled: true, combo: 'Meta+S' }, windows: { enabled: true, combo: 'Ctrl+S' }, @@ -92,6 +96,27 @@ const storeState = vi.hoisted(() => ({ })); const storeSubscribers = vi.hoisted(() => new Set<() => void>()); +const runtimeEventListeners = vi.hoisted(() => new Map void>>()); + +const runtimeApi = vi.hoisted(() => ({ + EventsOn: vi.fn((eventName: string, handler: (...args: any[]) => void) => { + const listeners = runtimeEventListeners.get(eventName) ?? new Set<(...args: any[]) => void>(); + listeners.add(handler); + runtimeEventListeners.set(eventName, listeners); + return () => { + const current = runtimeEventListeners.get(eventName); + if (!current) { + return; + } + current.delete(handler); + if (current.size === 0) { + runtimeEventListeners.delete(eventName); + } + }; + }), + ClipboardSetText: vi.fn(async () => true), + LogInfo: vi.fn(), +})); const notifyStoreSubscribers = () => { storeSubscribers.forEach((subscriber) => subscriber()); @@ -294,6 +319,8 @@ vi.mock('../store', () => { return { useStore }; }); +vi.mock('../../wailsjs/runtime', () => runtimeApi); + vi.mock('../../wailsjs/go/app/App', () => backendApp); vi.mock('../utils/autoFetchVisibility', () => ({ @@ -308,7 +335,7 @@ vi.mock('@monaco-editor/react', () => ({ onMount?.(editorState.editor, { editor: { setTheme: vi.fn() }, KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 }, - KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83 }, + KeyCode: { KeyD: 68, KeyE: 69, KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83 }, languages: { CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 }, CompletionItemInsertTextRule: { InsertAsSnippet: 1 }, @@ -396,6 +423,7 @@ vi.mock('@ant-design/icons', () => { CloseOutlined: Icon, StopOutlined: Icon, RobotOutlined: Icon, + SearchOutlined: Icon, DatabaseOutlined: Icon, EyeOutlined: Icon, EyeInvisibleOutlined: Icon, @@ -616,6 +644,8 @@ describe('QueryEditor external SQL save', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; @@ -626,6 +656,27 @@ describe('QueryEditor external SQL save', () => { vi.stubGlobal('document', { addEventListener: vi.fn(), removeEventListener: vi.fn(), + body: { nodeName: 'BODY', appendChild: vi.fn() }, + documentElement: { nodeName: 'HTML' }, + execCommand: vi.fn(() => true), + createElement: vi.fn((tagName: string) => ({ + tagName: String(tagName || '').toUpperCase(), + className: '', + style: {}, + setAttribute: vi.fn(), + focus: vi.fn(), + select: vi.fn(), + setSelectionRange: vi.fn(), + remove: vi.fn(), + })), + }); + const currentNavigator = globalThis.navigator as any; + vi.stubGlobal('navigator', { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + platform: currentNavigator?.platform || 'MacIntel', + userAgent: currentNavigator?.userAgent || 'Vitest', }); setCurrentLanguage('zh-CN'); storeState.languagePreference = 'zh-CN'; @@ -633,8 +684,12 @@ describe('QueryEditor external SQL save', () => { storeState.shortcutOptions.runQuery.windows = { enabled: false, combo: '' }; storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: false, combo: '' }; storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: false, combo: '' }; + storeState.shortcutOptions.duplicateCurrentLine.mac = { enabled: false, combo: '' }; + storeState.shortcutOptions.duplicateCurrentLine.windows = { enabled: false, combo: '' }; storeState.shortcutOptions.saveQuery.mac = { enabled: true, combo: 'Meta+S' }; storeState.shortcutOptions.saveQuery.windows = { enabled: true, combo: 'Ctrl+S' }; + runtimeApi.EventsOn.mockClear(); + runtimeEventListeners.clear(); storeState.addTab.mockReset(); storeState.setActiveContext.mockReset(); storeState.saveQuery.mockReset(); @@ -664,6 +719,10 @@ describe('QueryEditor external SQL save', () => { mac: { enabled: false, combo: '' }, windows: { enabled: false, combo: '' }, }, + duplicateCurrentLine: { + mac: { enabled: false, combo: '' }, + windows: { enabled: false, combo: '' }, + }, saveQuery: { mac: { enabled: true, combo: 'Meta+S' }, windows: { enabled: true, combo: 'Ctrl+S' }, @@ -744,6 +803,7 @@ describe('QueryEditor external SQL save', () => { editorState.editor.updateOptions.mockClear(); editorState.editor.pushUndoStop.mockClear(); editorState.editor.addAction.mockClear(); + editorState.editor.getContribution.mockClear(); storeState.updateQueryTabDraft.mockReset(); storeSubscribers.clear(); editorState.editor.layout.mockClear(); @@ -898,6 +958,8 @@ describe('QueryEditor external SQL save', () => { }), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; @@ -961,6 +1023,8 @@ describe('QueryEditor external SQL save', () => { }), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; @@ -1033,6 +1097,8 @@ describe('QueryEditor external SQL save', () => { }), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; @@ -1087,6 +1153,8 @@ describe('QueryEditor external SQL save', () => { }), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; @@ -2713,6 +2781,9 @@ describe('QueryEditor external SQL save', () => { const initialOptions = editorState.editor.updateOptions.mock.calls[0]?.[0]; expect(initialOptions).toMatchObject({ fixedOverflowWidgets: true, + find: { + addExtraSpaceOnTop: true, + }, hover: { enabled: true, delay: 1000, @@ -2776,6 +2847,8 @@ describe('QueryEditor external SQL save', () => { storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Q' }; storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+Q' }; storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+Q' }; + storeState.shortcutOptions.duplicateCurrentLine.mac = { enabled: true, combo: 'Meta+D' }; + storeState.shortcutOptions.duplicateCurrentLine.windows = { enabled: true, combo: 'Ctrl+D' }; await act(async () => { create(); @@ -2788,7 +2861,10 @@ describe('QueryEditor external SQL save', () => { label: 'GoNavi: Run SQL', }); expect(findEditorAction('gonavi.selectCurrentStatement')).toMatchObject({ - label: 'GoNavi: Select Current Statement', + label: 'GoNavi: Select Current Line and Copy', + }); + expect(findEditorAction('gonavi.duplicateCurrentLine')).toMatchObject({ + label: 'GoNavi: Duplicate Current Line Below', }); expect(findEditorAction('gonavi.saveQuery')).toMatchObject({ label: 'GoNavi: Save Query', @@ -2800,6 +2876,8 @@ describe('QueryEditor external SQL save', () => { storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Q' }; storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+Q' }; storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+Q' }; + storeState.shortcutOptions.duplicateCurrentLine.mac = { enabled: true, combo: 'Meta+D' }; + storeState.shortcutOptions.duplicateCurrentLine.windows = { enabled: true, combo: 'Ctrl+D' }; await act(async () => { create(); @@ -2812,7 +2890,10 @@ describe('QueryEditor external SQL save', () => { label: 'GoNavi: 执行 SQL', }); expect(findEditorAction('gonavi.selectCurrentStatement')).toMatchObject({ - label: 'GoNavi: 选择当前语句', + label: 'GoNavi: 选择当前行并复制', + }); + expect(findEditorAction('gonavi.duplicateCurrentLine')).toMatchObject({ + label: 'GoNavi: 复制当前行到下一行', }); expect(findEditorAction('gonavi.saveQuery')).toMatchObject({ label: 'GoNavi: 保存查询', @@ -2826,7 +2907,8 @@ describe('QueryEditor external SQL save', () => { expect(findEditorActionLabels('gonavi.queryEditor.showObjectInfo')).toContain('GoNavi: Show Object Info'); expect(findEditorActionLabels('gonavi.runQuery')).toContain('GoNavi: Run SQL'); - expect(findEditorActionLabels('gonavi.selectCurrentStatement')).toContain('GoNavi: Select Current Statement'); + expect(findEditorActionLabels('gonavi.selectCurrentStatement')).toContain('GoNavi: Select Current Line and Copy'); + expect(findEditorActionLabels('gonavi.duplicateCurrentLine')).toContain('GoNavi: Duplicate Current Line Below'); expect(findEditorActionLabels('gonavi.saveQuery')).toContain('GoNavi: Save Query'); expect(findEditorAction('gonavi.queryEditor.showObjectInfo')).toMatchObject({ label: 'GoNavi: Show Object Info', @@ -2835,7 +2917,10 @@ describe('QueryEditor external SQL save', () => { label: 'GoNavi: Run SQL', }); expect(findEditorAction('gonavi.selectCurrentStatement')).toMatchObject({ - label: 'GoNavi: Select Current Statement', + label: 'GoNavi: Select Current Line and Copy', + }); + expect(findEditorAction('gonavi.duplicateCurrentLine')).toMatchObject({ + label: 'GoNavi: Duplicate Current Line Below', }); expect(findEditorAction('gonavi.saveQuery')).toMatchObject({ label: 'GoNavi: Save Query', @@ -2964,7 +3049,7 @@ describe('QueryEditor external SQL save', () => { } }); - it('shows "No selectable SQL statement." in English when selecting the current statement without selectable SQL', async () => { + it('shows "No copyable content on the current line." in English when selecting an empty current line', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+Q' }; @@ -2982,11 +3067,11 @@ describe('QueryEditor external SQL save', () => { await selectCurrentStatementAction.run(); }); - expect(messageApi.info).toHaveBeenCalledWith('No selectable SQL statement.'); - expect(messageApi.info).not.toHaveBeenCalledWith('没有可选择的 SQL 语句。'); + expect(messageApi.info).toHaveBeenCalledWith('No copyable content on the current line.'); + expect(messageApi.info).not.toHaveBeenCalledWith('当前行没有可复制内容。'); }); - it('selects only the current SQL statement when the editor content uses CRLF line endings', async () => { + it('selects and copies only the current line when the editor content uses CRLF line endings', async () => { storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+Q' }; storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+Q' }; const sql = [ @@ -3010,14 +3095,268 @@ describe('QueryEditor external SQL save', () => { await selectCurrentStatementAction.run(); }); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + expect(messageApi.success).toHaveBeenCalledWith('已复制到剪贴板'); expect(editorState.selection).toMatchObject({ startLineNumber: 5, startColumn: 1, endLineNumber: 5, - endColumn: 'SELECT a.id, a.name FROM third_table a ORDER BY a.id;'.length, + endColumn: 'SELECT a.id, a.name FROM third_table a ORDER BY a.id;'.length + 1, }); }); + it('falls back to the browser clipboard when the Monaco copy command is unavailable', async () => { + storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+Q' }; + storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+Q' }; + (document.execCommand as any).mockReturnValueOnce(false); + + await act(async () => { + create(); + }); + editorState.position = { lineNumber: 2, column: 8 }; + editorState.selection = null; + + const selectCurrentStatementAction = findEditorAction('gonavi.selectCurrentStatement'); + expect(selectCurrentStatementAction).toBeTruthy(); + + await act(async () => { + await selectCurrentStatementAction.run(); + }); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('SELECT 2 AS two;'); + expect(messageApi.success).toHaveBeenCalledWith('已复制到剪贴板'); + expect(messageApi.error).not.toHaveBeenCalled(); + expect(editorState.selection).toMatchObject({ + startLineNumber: 2, + startColumn: 1, + endLineNumber: 2, + endColumn: 'SELECT 2 AS two;'.length + 1, + }); + }); + + it('duplicates the current line below and keeps the caret column', async () => { + storeState.shortcutOptions.duplicateCurrentLine.mac = { enabled: true, combo: 'Meta+D' }; + storeState.shortcutOptions.duplicateCurrentLine.windows = { enabled: true, combo: 'Ctrl+D' }; + editorState.position = { lineNumber: 2, column: 6 }; + + await act(async () => { + create(); + }); + + const duplicateCurrentLineAction = findEditorAction('gonavi.duplicateCurrentLine'); + expect(duplicateCurrentLineAction).toBeTruthy(); + + await act(async () => { + duplicateCurrentLineAction.run(); + }); + + expect(editorState.value).toBe('SELECT 1;\nFROM dual\nFROM dual'); + expect(editorState.position).toEqual({ lineNumber: 3, column: 6 }); + expect(editorState.selection).toMatchObject({ + startLineNumber: 3, + startColumn: 6, + endLineNumber: 3, + endColumn: 6, + }); + expect(editorState.editor.pushUndoStop).toHaveBeenCalled(); + }); + + it('intercepts Ctrl/Cmd+E at window level and copies the current line instead of leaking to host search', async () => { + storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+E' }; + storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+E' }; + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + }); + + await act(async () => { + create(); + }); + editorState.position = { lineNumber: 2, column: 8 }; + editorState.selection = null; + (window.dispatchEvent as any).mockClear(); + (navigator.clipboard.writeText as any).mockClear(); + + const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`); + const event = { + ctrlKey: !isMacRuntime, + metaKey: isMacRuntime, + altKey: false, + shiftKey: false, + key: 'e', + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener(event)); + await Promise.resolve(); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(messageApi.success).toHaveBeenCalledWith('已复制到剪贴板'); + expect(editorState.editor.setSelections).not.toHaveBeenCalled(); + expect(editorState.selection).toMatchObject({ + startLineNumber: 2, + startColumn: 1, + endLineNumber: 2, + endColumn: 'SELECT 2 AS two;'.length + 1, + }); + expect( + (window.dispatchEvent as any).mock.calls.map((call: any[]) => call[0]?.type), + ).not.toContain('gonavi:find-active-query'); + }); + + it('intercepts Ctrl/Cmd+D at window level and duplicates the current line below', async () => { + storeState.shortcutOptions.duplicateCurrentLine.mac = { enabled: true, combo: 'Meta+D' }; + storeState.shortcutOptions.duplicateCurrentLine.windows = { enabled: true, combo: 'Ctrl+D' }; + const windowListeners: Record void)[]> = {}; + vi.stubGlobal('window', { + addEventListener: vi.fn((type: string, listener: (event?: any) => void) => { + windowListeners[type] ||= []; + windowListeners[type].push(listener); + }), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { + callback(0); + return 1; + }), + cancelAnimationFrame: vi.fn(), + innerHeight: 900, + }); + + await act(async () => { + create(); + }); + editorState.position = { lineNumber: 2, column: 8 }; + editorState.selection = null; + (window.dispatchEvent as any).mockClear(); + + const isMacRuntime = /(Mac|iPhone|iPad|iPod)/i.test(`${navigator.platform || ''} ${navigator.userAgent || ''}`); + const event = { + ctrlKey: !isMacRuntime, + metaKey: isMacRuntime, + altKey: false, + shiftKey: false, + key: 'd', + target: null, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; + + await act(async () => { + windowListeners.keydown?.forEach((listener) => listener(event)); + await Promise.resolve(); + }); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(editorState.value).toBe('SELECT 1;\nSELECT 2 AS two;\nSELECT 2 AS two;\nSELECT 3;'); + expect(editorState.position).toEqual({ lineNumber: 3, column: 8 }); + expect( + (window.dispatchEvent as any).mock.calls.map((call: any[]) => call[0]?.type), + ).not.toContain('gonavi:find-active-query'); + }); + + it('responds to the macOS native Cmd+E fallback event and copies the current line', async () => { + storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+E' }; + storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+E' }; + + await act(async () => { + create(); + }); + editorState.position = { lineNumber: 2, column: 8 }; + editorState.selection = null; + (document.execCommand as any).mockClear(); + + const nativeListeners = runtimeEventListeners.get('gonavi:native-select-current-line'); + expect(nativeListeners?.size ?? 0).toBeGreaterThan(0); + + await act(async () => { + nativeListeners?.forEach((listener) => listener()); + await Promise.resolve(); + }); + + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(messageApi.success).toHaveBeenCalledWith('已复制到剪贴板'); + expect(editorState.editor.setSelections).not.toHaveBeenCalled(); + expect(editorState.selection).toMatchObject({ + startLineNumber: 2, + startColumn: 1, + endLineNumber: 2, + endColumn: 'SELECT 2 AS two;'.length + 1, + }); + }); + + it('uses the last tracked cursor position for the macOS native Cmd+E fallback when the live cursor is unavailable', async () => { + storeState.shortcutOptions.selectCurrentStatement.mac = { enabled: true, combo: 'Meta+E' }; + storeState.shortcutOptions.selectCurrentStatement.windows = { enabled: true, combo: 'Ctrl+E' }; + + await act(async () => { + create(); + }); + + await act(async () => { + editorState.cursorPositionListeners.forEach((listener) => listener({ + position: { lineNumber: 2, column: 8 }, + })); + }); + editorState.position = null as any; + editorState.selection = null; + (document.execCommand as any).mockClear(); + + const nativeListeners = runtimeEventListeners.get('gonavi:native-select-current-line'); + expect(nativeListeners?.size ?? 0).toBeGreaterThan(0); + + await act(async () => { + nativeListeners?.forEach((listener) => listener()); + await Promise.resolve(); + }); + + expect(editorState.editor.setPosition).toHaveBeenCalledWith({ lineNumber: 2, column: 8 }); + expect(document.execCommand).toHaveBeenCalledWith('copy'); + expect(messageApi.success).toHaveBeenCalledWith('已复制到剪贴板'); + }); + it('shows the object info miss toast in English when the cursor is not on a recognized table or column', async () => { storeState.languagePreference = 'en-US'; setCurrentLanguage('en-US'); @@ -8164,6 +8503,19 @@ describe('QueryEditor external SQL save', () => { expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {'); }); + it('keeps Monaco find widget offset styles scoped to the v2 query editor shell', () => { + const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); + const css = readV2ThemeCss(); + + expect(source).toContain('QUERY_EDITOR_MONACO_FIND_WIDGET_OFFSET_PX = 10'); + expect(source).toContain("editor.contrib.findController"); + expect(source).toContain('MutationObserver'); + expect(source).toContain('gn-v2-query-monaco-shell-find-visible'); + expect(source).toContain('heightInPx: QUERY_EDITOR_MONACO_FIND_WIDGET_OFFSET_PX'); + expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-monaco-shell .monaco-editor .find-widget.visible {'); + expect(css).toContain('top: 10px !important;'); + }); + it('keeps the v2 query editor toolbar grouped and compact', () => { const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8'); const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); diff --git a/frontend/src/components/QueryEditor.i18n.test.ts b/frontend/src/components/QueryEditor.i18n.test.ts index c43311e8..6637688e 100644 --- a/frontend/src/components/QueryEditor.i18n.test.ts +++ b/frontend/src/components/QueryEditor.i18n.test.ts @@ -39,6 +39,7 @@ describe('QueryEditor i18n source guards', () => { expect(queryEditorSource).toContain('query_editor.action.find_in_editor'); expect(queryEditorSource).toContain('gonavi:find-active-query'); expect(queryEditorSource).toContain("editor.getAction?.('actions.find')"); + expect(queryEditorSource).toContain('addExtraSpaceOnTop: true'); }); it('uses a localized wrapper for save query failures', () => { diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index a6707676..9f229055 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -380,6 +380,7 @@ vi.mock('@ant-design/icons', () => { CloseOutlined: Icon, StopOutlined: Icon, RobotOutlined: Icon, + SearchOutlined: Icon, DatabaseOutlined: Icon, EyeOutlined: Icon, EyeInvisibleOutlined: Icon, @@ -587,6 +588,8 @@ describe('QueryEditor external SQL save', () => { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), + setTimeout, + clearTimeout, requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => { callback(0); return 1; diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index c839962d..16a41435 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -1,4 +1,4 @@ -import Modal from './common/ResizableDraggableModal'; +import Modal from './common/ResizableDraggableModal'; import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import Editor, { type OnMount } from './MonacoEditor'; import { message, Input, Form, MenuProps } from 'antd'; @@ -8,10 +8,11 @@ import { TabData, ColumnDefinition } from '../types'; import { useStore } from '../store'; import { DBQuery, DBQueryWithCancel, DBQueryMulti, DBQueryMultiInTransaction, DBQueryMultiTransactional, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile, ExportSQLFile } from '../../wailsjs/go/app/App'; import { GONAVI_ROW_KEY } from './DataGrid'; +import { EventsOn } from '../../wailsjs/runtime'; import { findConnectionMutatingStatements } from '../utils/connectionReadOnly'; import { getDataSourceCapabilities, shouldShowOceanBaseRowNumberColumn } from '../utils/dataSourceCapabilities'; import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb"; -import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts"; +import { getShortcutDisplayLabel, getShortcutPlatform, getShortcutPrimaryModifierDisplayLabel, isEditableElement, isImeComposingKeyEvent, isShortcutMatch, comboToMonacoKeyBinding, normalizeShortcutCombo, resolveShortcutBinding } from "../utils/shortcuts"; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isPostgresSchemaDialect } from '../utils/connectionDriverType'; @@ -26,7 +27,7 @@ import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/q import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; import { formatSqlExecutionError, hasLocalizedSqlTimeoutKeyword } from '../utils/sqlErrorSemantics'; import { canReusePendingSqlEditorTransactionForType, shouldUseSqlEditorManagedTransactionForType } from '../utils/sqlEditorTransaction'; -import { findSqlStatementRanges, resolveCurrentSqlStatementRange, resolveExecutableSql } from '../utils/sqlStatementSelection'; +import { findSqlStatementRanges, resolveExecutableSql } from '../utils/sqlStatementSelection'; import { isMacLikePlatform } from '../utils/appearance'; import { splitSidebarQualifiedName } from '../utils/sidebarLocate'; import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata'; @@ -147,12 +148,86 @@ export { const buildQueryEditorMonacoActionLabel = (key: string): string => `GoNavi: ${translate(key)}`; +const QUERY_EDITOR_MONACO_FIND_WIDGET_OFFSET_PX = 10; +const QUERY_EDITOR_MONACO_FIND_OPTIONS = { + addExtraSpaceOnTop: true, +} as const; +const QUERY_EDITOR_NATIVE_SELECT_CURRENT_LINE_EVENT = 'gonavi:native-select-current-line'; + +type QueryEditorMonacoFindStateChangeEvent = { + isRevealed?: boolean; +}; + +type QueryEditorMonacoFindState = { + isRevealed?: boolean; + onFindReplaceStateChange?: ( + listener: (event: QueryEditorMonacoFindStateChangeEvent) => void, + ) => { dispose?: () => void } | void; +}; + +type QueryEditorMonacoFindController = { + getState?: () => QueryEditorMonacoFindState | null; +}; + const QUERY_EDITOR_SQL_PROMPT_PLACEHOLDER = '{SQL}'; const escapeQueryEditorObjectEditSqlLiteral = (value: unknown): string => ( String(value || '').replace(/'/g, "''") ); +const CLIPBOARD_WRITE_TIMEOUT_MS = 2000; + +const copyQueryEditorTextToClipboard = async (text: string): Promise => { + const tryAsyncClipboardWrite = async (): Promise => { + if (typeof navigator?.clipboard?.writeText !== 'function') { + return false; + } + + try { + const written = await Promise.race([ + navigator.clipboard.writeText(text).then(() => true as const), + new Promise((resolve) => setTimeout(() => resolve(false), CLIPBOARD_WRITE_TIMEOUT_MS)), + ]); + return written; + } catch { + return false; + } + }; + + if (typeof document?.createElement === 'function' && typeof document?.execCommand === 'function') { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', 'true'); + textarea.setAttribute('aria-hidden', 'true'); + Object.assign(textarea.style, { + position: 'fixed', + top: '0', + left: '-9999px', + opacity: '0', + pointerEvents: 'none', + }); + + try { + document.body?.appendChild?.(textarea); + textarea.focus?.(); + textarea.select?.(); + textarea.setSelectionRange?.(0, text.length); + if (document.execCommand('copy')) { + return true; + } + } catch { + // Fall through to async clipboard APIs when execCommand is unavailable. + } finally { + textarea.remove?.(); + } + } + + if (await tryAsyncClipboardWrite()) { + return true; + } + return false; +}; + const getQueryEditorObjectEditRawValue = (row: Record, candidateKeys: string[]): any => { const keyMap = new Map(); Object.keys(row || {}).forEach((key) => keyMap.set(key.toLowerCase(), row[key])); @@ -728,8 +803,13 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const editorShellRef = useRef(null); const editorRef = useRef(null); const monacoRef = useRef(null); + const findWidgetOffsetZoneIdRef = useRef(null); + const findWidgetOffsetVisibleRef = useRef(false); + const findWidgetStateDisposableRef = useRef<{ dispose?: () => void } | null>(null); + const findWidgetDomObserverRef = useRef(null); const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); + const duplicateCurrentLineActionRef = useRef(null); const saveQueryActionRef = useRef(null); const findInEditorActionRef = useRef(null); const formatSqlActionRef = useRef(null); @@ -869,6 +949,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); + const duplicateCurrentLineShortcutBinding = useMemo( + () => resolveShortcutBinding(shortcutOptions, 'duplicateCurrentLine', activeShortcutPlatform), + [activeShortcutPlatform, shortcutOptions], + ); const saveQueryShortcutBinding = useMemo( () => resolveShortcutBinding(shortcutOptions, 'saveQuery', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], @@ -922,6 +1006,131 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc editor.trigger?.('keyboard', 'actions.find', null); }, []); + const disconnectQueryEditorFindWidgetObserver = useCallback(() => { + findWidgetDomObserverRef.current?.disconnect?.(); + findWidgetDomObserverRef.current = null; + }, []); + const clearQueryEditorFindWidgetOffset = useCallback((editorInstance?: any) => { + editorShellRef.current?.classList?.remove?.('gn-v2-query-monaco-shell-find-visible'); + findWidgetOffsetVisibleRef.current = false; + const editor = editorInstance || editorRef.current; + const currentZoneId = findWidgetOffsetZoneIdRef.current; + if (!currentZoneId || !editor?.changeViewZones) { + findWidgetOffsetZoneIdRef.current = null; + return; + } + editor.changeViewZones((accessor: any) => { + accessor.removeZone(currentZoneId); + }); + findWidgetOffsetZoneIdRef.current = null; + }, []); + const syncQueryEditorFindWidgetOffset = useCallback((editorInstance: any, visible: boolean) => { + const editor = editorInstance || editorRef.current; + const shouldOffset = isV2Ui && visible; + editorShellRef.current?.classList?.toggle?.('gn-v2-query-monaco-shell-find-visible', shouldOffset); + const currentVisible = findWidgetOffsetVisibleRef.current; + const hasZone = Boolean(findWidgetOffsetZoneIdRef.current); + + if (currentVisible === shouldOffset && (!shouldOffset || hasZone)) { + return; + } + findWidgetOffsetVisibleRef.current = shouldOffset; + + if (!editor?.changeViewZones) { + return; + } + + const currentZoneId = findWidgetOffsetZoneIdRef.current; + if (!shouldOffset && !currentZoneId) { + return; + } + + editor.changeViewZones((accessor: any) => { + if (currentZoneId) { + accessor.removeZone(currentZoneId); + findWidgetOffsetZoneIdRef.current = null; + } + if (!shouldOffset || typeof document === 'undefined' || typeof document.createElement !== 'function') { + return; + } + const domNode = document.createElement('div'); + domNode.className = 'gn-v2-query-find-widget-offset-zone'; + domNode.setAttribute('aria-hidden', 'true'); + findWidgetOffsetZoneIdRef.current = accessor.addZone({ + afterLineNumber: 0, + heightInPx: QUERY_EDITOR_MONACO_FIND_WIDGET_OFFSET_PX, + domNode, + suppressMouseDown: true, + }); + }); + }, [isV2Ui]); + const bindQueryEditorFindWidgetDomObserver = useCallback((editorInstance?: any) => { + const editor = editorInstance || editorRef.current; + disconnectQueryEditorFindWidgetObserver(); + if (!editor || !isV2Ui) { + return; + } + + const resolveFindWidgetVisible = () => { + const shell = editorShellRef.current; + const findWidget = shell?.querySelector?.('.monaco-editor .find-widget'); + if (!findWidget || !('classList' in findWidget)) { + return false; + } + return findWidget.classList.contains('visible') && !findWidget.classList.contains('hiddenEditor'); + }; + + const syncFromDom = () => { + syncQueryEditorFindWidgetOffset(editor, resolveFindWidgetVisible()); + }; + + syncFromDom(); + + if (typeof MutationObserver !== 'function' || !editorShellRef.current) { + return; + } + + const observer = new MutationObserver(() => { + syncFromDom(); + }); + observer.observe(editorShellRef.current, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['class'], + }); + findWidgetDomObserverRef.current = observer; + + if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(() => { + syncFromDom(); + }); + } + }, [disconnectQueryEditorFindWidgetObserver, isV2Ui, syncQueryEditorFindWidgetOffset]); + const bindQueryEditorFindWidgetOffset = useCallback((editorInstance?: any) => { + const editor = editorInstance || editorRef.current; + findWidgetStateDisposableRef.current?.dispose?.(); + findWidgetStateDisposableRef.current = null; + clearQueryEditorFindWidgetOffset(editor); + if (!editor || !isV2Ui) { + return; + } + + const findController = editor.getContribution?.('editor.contrib.findController') as QueryEditorMonacoFindController | null; + const findState = findController?.getState?.(); + bindQueryEditorFindWidgetDomObserver(editor); + if (!findState) { + return; + } + + syncQueryEditorFindWidgetOffset(editor, Boolean(findState.isRevealed)); + findWidgetStateDisposableRef.current = findState.onFindReplaceStateChange?.((event) => { + if (!event?.isRevealed) { + return; + } + syncQueryEditorFindWidgetOffset(editor, Boolean(findState.isRevealed)); + }) || null; + }, [bindQueryEditorFindWidgetDomObserver, clearQueryEditorFindWidgetOffset, isV2Ui, syncQueryEditorFindWidgetOffset]); const handleShowSqlExecutionLog = useCallback((mode: 'open' | 'toggle' = 'toggle') => { if (!isActive) { return; @@ -949,6 +1158,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); const autoFetchVisible = useAutoFetchVisibility(); + useEffect(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + bindQueryEditorFindWidgetOffset(editor); + return () => { + disconnectQueryEditorFindWidgetObserver(); + findWidgetStateDisposableRef.current?.dispose?.(); + findWidgetStateDisposableRef.current = null; + clearQueryEditorFindWidgetOffset(editor); + }; + }, [bindQueryEditorFindWidgetOffset, clearQueryEditorFindWidgetOffset, disconnectQueryEditorFindWidgetObserver]); + useEffect(() => { const nextContextKey = [ String(currentConnectionId || '').trim(), @@ -1319,34 +1542,87 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } }, [insertTextIntoEditorAtPosition, mergeSidebarDropObjectMetadata, refreshObjectDecorations]); - const handleSelectCurrentStatement = () => { + const handleSelectCurrentStatement = async () => { const editor = editorRef.current; const monaco = monacoRef.current; const model = editor?.getModel?.(); - const position = editor?.getPosition?.(); - if (!editor || !monaco || !model || !position) { + if (!editor || !monaco?.Range || !model) { return; } - const fullSQL = String(model.getValue?.() || ''); - const normalizedPosition = normalizeEditorPosition(position); + const normalizedPosition = normalizeEditorPosition(editor.getPosition?.()) + || normalizeEditorPosition(lastEditorCursorPositionRef.current); if (!normalizedPosition) { return; } - const cursorOffset = getNormalizedOffsetAtPosition(fullSQL, normalizedPosition); - const range = resolveCurrentSqlStatementRange(fullSQL, cursorOffset); - if (!range) { - void message.info(translate('query_editor.message.no_selectable_sql')); + lastEditorCursorPositionRef.current = normalizedPosition; + const lineNumber = normalizedPosition.lineNumber; + const lineText = String(model.getLineContent?.(lineNumber) || ''); + if (!lineText.trim()) { + void message.info(translate('query_editor.message.current_line_no_copyable_content')); return; } - const start = getNormalizedPositionAtOffset(fullSQL, range.start); - const end = getNormalizedPositionAtOffset(fullSQL, range.end); - const selection = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column); - editor.setSelections?.([selection]); + const maxColumn = Number(model.getLineMaxColumn?.(lineNumber) || 1); + const selection = new monaco.Range(lineNumber, 1, lineNumber, maxColumn); + editor.setPosition?.(normalizedPosition); editor.setSelection(selection); editor.revealRangeInCenterIfOutsideViewport?.(selection); + + const copied = await copyQueryEditorTextToClipboard(lineText); + editor.setSelection(selection); editor.focus?.(); + if (copied) { + void message.success(translate('data_grid.message.copied_to_clipboard')); + return; + } + + void message.error(translate('connection_modal.message.copy_failed')); + }; + + const handleDuplicateCurrentLine = () => { + const editor = editorRef.current; + const monaco = monacoRef.current; + const model = editor?.getModel?.(); + const normalizedPosition = normalizeEditorPosition(editor?.getPosition?.()); + if (!editor || !monaco?.Range || !model || !normalizedPosition) { + return; + } + + const lineNumber = normalizedPosition.lineNumber; + const lineText = String(model.getLineContent?.(lineNumber) || ''); + const maxColumn = Number(model.getLineMaxColumn?.(lineNumber) || (lineText.length + 1)); + const modelValue = String(model.getValue?.() || ''); + const lineBreak = typeof model.getEOL?.() === 'string' + ? model.getEOL() + : (modelValue.includes('\r\n') ? '\r\n' : '\n'); + const insertRange = new monaco.Range(lineNumber, maxColumn, lineNumber, maxColumn); + const nextColumn = Math.min(normalizedPosition.column, lineText.length + 1); + + editor.executeEdits?.('gonavi-duplicate-current-line', [{ + range: insertRange, + text: `${lineBreak}${lineText}`, + forceMoveMarkers: true, + }]); + editor.pushUndoStop?.(); + + const nextPosition = { lineNumber: lineNumber + 1, column: nextColumn }; + const cursorSelection = new monaco.Range( + nextPosition.lineNumber, + nextPosition.column, + nextPosition.lineNumber, + nextPosition.column, + ); + editor.setSelections?.([cursorSelection]); + editor.setSelection?.(cursorSelection); + editor.setPosition?.(nextPosition); + editor.revealLineInCenterIfOutsideViewport?.(nextPosition.lineNumber); + editor.focus?.(); + + const nextValue = editor.getValue?.(); + if (typeof nextValue === 'string') { + applyQueryState(nextValue); + } }; const buildQueryEditorAiContextMenuActions = useCallback(() => ([ @@ -2301,12 +2577,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc editor.updateOptions?.({ fixedOverflowWidgets: true, + find: QUERY_EDITOR_MONACO_FIND_OPTIONS, hover: { enabled: true, delay: QUERY_EDITOR_HOVER_DELAY_MS, above: false, }, }); + bindQueryEditorFindWidgetOffset(editor); const applyNavigationHoverStateAtPosition = (targetPosition: { lineNumber: number; column: number } | null) => { if (!ctrlMetaPressedRef.current) { @@ -2739,6 +3017,21 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } + const duplicateLineBinding = duplicateCurrentLineShortcutBinding; + if (duplicateLineBinding?.enabled && duplicateLineBinding.combo) { + const keyBinding = comboToMonacoKeyBinding( + duplicateLineBinding.combo, monaco.KeyMod, monaco.KeyCode, + ); + if (keyBinding) { + duplicateCurrentLineActionRef.current = editor.addAction({ + id: 'gonavi.duplicateCurrentLine', + label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.duplicateCurrentLine.label'), + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: handleDuplicateCurrentLine, + }); + } + } + const saveBinding = saveQueryShortcutBinding; if (saveBinding?.enabled && saveBinding.combo) { const keyBinding = comboToMonacoKeyBinding( @@ -5003,6 +5296,37 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [languagePreference, selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]); + useEffect(() => { + if (duplicateCurrentLineActionRef.current) { + duplicateCurrentLineActionRef.current.dispose(); + duplicateCurrentLineActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const binding = duplicateCurrentLineShortcutBinding; + if (!binding?.enabled || !binding.combo) return; + + const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode); + if (keyBinding) { + duplicateCurrentLineActionRef.current = editor.addAction({ + id: 'gonavi.duplicateCurrentLine', + label: buildQueryEditorMonacoActionLabel('app.shortcuts.action.duplicateCurrentLine.label'), + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: handleDuplicateCurrentLine, + }); + } + + return () => { + if (duplicateCurrentLineActionRef.current) { + duplicateCurrentLineActionRef.current.dispose(); + duplicateCurrentLineActionRef.current = null; + } + }; + }, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, languagePreference]); + useEffect(() => { if (saveQueryActionRef.current) { saveQueryActionRef.current.dispose(); @@ -5181,6 +5505,94 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [handleOpenEditorFind, isActive]); + useEffect(() => { + const binding = selectCurrentStatementShortcutBinding; + if (!binding?.enabled || !binding.combo) { + return; + } + + const handleSelectCurrentStatementShortcut = (event: KeyboardEvent) => { + if (!isActive) { + return; + } + if (!isShortcutMatch(event, binding.combo)) { + return; + } + + const editor = editorRef.current; + const targetNode = resolveEventTargetNode(event.target); + const editorHasFocus = !!editor?.hasTextFocus?.(); + const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); + if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void handleSelectCurrentStatement(); + }; + + window.addEventListener('keydown', handleSelectCurrentStatementShortcut, true); + return () => { + window.removeEventListener('keydown', handleSelectCurrentStatementShortcut, true); + }; + }, [handleSelectCurrentStatement, isActive, selectCurrentStatementShortcutBinding]); + + useEffect(() => { + const binding = selectCurrentStatementShortcutBinding; + if ( + activeShortcutPlatform !== 'mac' + || !binding?.enabled + || normalizeShortcutCombo(binding.combo) !== 'Meta+E' + ) { + return; + } + + try { + return EventsOn(QUERY_EDITOR_NATIVE_SELECT_CURRENT_LINE_EVENT, () => { + if (!isActive) { + return; + } + void handleSelectCurrentStatement(); + }); + } catch { + return; + } + }, [activeShortcutPlatform, handleSelectCurrentStatement, isActive, selectCurrentStatementShortcutBinding]); + + useEffect(() => { + const binding = duplicateCurrentLineShortcutBinding; + if (!binding?.enabled || !binding.combo) { + return; + } + + const handleDuplicateCurrentLineShortcut = (event: KeyboardEvent) => { + if (!isActive) { + return; + } + if (!isShortcutMatch(event, binding.combo)) { + return; + } + + const editor = editorRef.current; + const targetNode = resolveEventTargetNode(event.target); + const editorHasFocus = !!editor?.hasTextFocus?.(); + const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); + if (!editorHasFocus && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + handleDuplicateCurrentLine(); + }; + + window.addEventListener('keydown', handleDuplicateCurrentLineShortcut, true); + return () => { + window.removeEventListener('keydown', handleDuplicateCurrentLineShortcut, true); + }; + }, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, isActive]); + // 监听由 TabManager 分发的专用注入事件 useEffect(() => { const handleInsertSql = (e: any) => { @@ -5749,6 +6161,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc minimap: { enabled: false }, automaticLayout: true, fixedOverflowWidgets: true, + find: QUERY_EDITOR_MONACO_FIND_OPTIONS, hover: { enabled: true, delay: QUERY_EDITOR_HOVER_DELAY_MS, diff --git a/frontend/src/i18n/catalog.test.ts b/frontend/src/i18n/catalog.test.ts index f6686534..004e29e4 100644 --- a/frontend/src/i18n/catalog.test.ts +++ b/frontend/src/i18n/catalog.test.ts @@ -232,6 +232,8 @@ describe("i18n catalog", () => { "app.shortcuts.action.openShortcutManager.description", "app.shortcuts.action.openShortcutManager.label", "app.shortcuts.action.record", + "app.shortcuts.action.duplicateCurrentLine.description", + "app.shortcuts.action.duplicateCurrentLine.label", "app.shortcuts.action.resetWindowZoom.description", "app.shortcuts.action.resetWindowZoom.label", "app.shortcuts.action.restore_defaults", @@ -1006,13 +1008,15 @@ describe("i18n catalog", () => { it("keeps QueryEditor local editor interaction toasts in catalogs instead of source literals", () => { const toastKeys = [ - "query_editor.message.no_selectable_sql", + "query_editor.message.current_line_no_copyable_content", + "data_grid.message.copied_to_clipboard", + "connection_modal.message.copy_failed", "query_editor.message.object_info_target_not_found", ] as const; const source = readQueryEditorSource(); const selectStatementSource = sliceBetween( source, - "const handleSelectCurrentStatement = () => {", + "const handleSelectCurrentStatement = async () => {", " const syncQueryToEditor = (sql: string) => {", ); const objectInfoActionSource = sliceBetween( @@ -1028,13 +1032,21 @@ describe("i18n catalog", () => { } } - expect(selectStatementSource).toContain("query_editor.message.no_selectable_sql"); + expect(selectStatementSource).toContain("query_editor.message.current_line_no_copyable_content"); + expect(selectStatementSource).toContain("data_grid.message.copied_to_clipboard"); + expect(selectStatementSource).toContain("connection_modal.message.copy_failed"); expect(objectInfoActionSource).toContain("query_editor.message.object_info_target_not_found"); - expect(selectStatementSource).not.toContain("没有可选择的 SQL 语句。"); + expect(selectStatementSource).not.toContain("当前行没有可复制内容。"); + expect(selectStatementSource).not.toContain("已复制到剪贴板"); + expect(selectStatementSource).not.toContain("复制失败"); expect(objectInfoActionSource).not.toContain("当前光标未定位到可识别的表或字段。"); - assertSourceDoesNotInlineCatalogValues(selectStatementSource, ["query_editor.message.no_selectable_sql"]); + assertSourceDoesNotInlineCatalogValues(selectStatementSource, [ + "query_editor.message.current_line_no_copyable_content", + "data_grid.message.copied_to_clipboard", + "connection_modal.message.copy_failed", + ]); assertSourceDoesNotInlineCatalogValues(objectInfoActionSource, ["query_editor.message.object_info_target_not_found"]); }); @@ -2171,6 +2183,7 @@ describe("i18n catalog", () => { it("keeps QueryEditor Monaco action labels in catalogs instead of source literals", () => { const actionLabelKeys = [ + "app.shortcuts.action.duplicateCurrentLine.label", "app.shortcuts.action.runQuery.label", "app.shortcuts.action.selectCurrentStatement.label", "app.shortcuts.action.saveQuery.label", @@ -2198,6 +2211,11 @@ describe("i18n catalog", () => { " const binding = selectCurrentStatementShortcutBinding;", " }, [languagePreference, selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);", ), + sliceBetween( + source, + " const binding = duplicateCurrentLineShortcutBinding;", + " }, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, languagePreference]);", + ), sliceBetween( source, " const binding = saveQueryShortcutBinding;", diff --git a/frontend/src/styles/v2-theme-workbench.css b/frontend/src/styles/v2-theme-workbench.css index 309655c8..84cd6502 100644 --- a/frontend/src/styles/v2-theme-workbench.css +++ b/frontend/src/styles/v2-theme-workbench.css @@ -292,6 +292,14 @@ body[data-ui-version="v2"] .gn-v2-query-monaco-shell { background: var(--gn-bg-panel); } +body[data-ui-version="v2"] .gn-v2-query-monaco-shell .monaco-editor .find-widget.visible { + top: 10px !important; +} + +body[data-ui-version="v2"] .gn-v2-query-monaco-shell .gn-v2-query-find-widget-offset-zone { + pointer-events: none; +} + body[data-ui-version="v2"] .gn-v2-query-resizer { height: 7px !important; background: var(--gn-bg-panel-2) !important; diff --git a/frontend/src/utils/shortcuts.test.ts b/frontend/src/utils/shortcuts.test.ts index 240f1bdb..5903529d 100644 --- a/frontend/src/utils/shortcuts.test.ts +++ b/frontend/src/utils/shortcuts.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_SHORTCUT_OPTIONS, findReservedConflict, findReservedConflicts, + findReservedConflictsForAction, describeConflictContext, normalizeShortcutCombo, RESERVED_SHORTCUTS, @@ -110,6 +111,26 @@ describe('findReservedConflicts', () => { }); }); +describe('findReservedConflictsForAction', () => { + it('allows duplicate current line to reuse Monaco add-selection shortcut on Windows', () => { + expect(findReservedConflicts('Ctrl+D', 'windows')).toEqual([ + expect.objectContaining({ + monacoCommandId: 'editor.action.addSelectionToNextFindMatch', + }), + ]); + expect(findReservedConflictsForAction('duplicateCurrentLine', 'Ctrl+D', 'windows')).toEqual([]); + }); + + it('allows duplicate current line to reuse Monaco add-selection shortcut on macOS', () => { + expect(findReservedConflicts('Meta+D', 'mac')).toEqual([ + expect.objectContaining({ + monacoCommandId: 'editor.action.addSelectionToNextFindMatch', + }), + ]); + expect(findReservedConflictsForAction('duplicateCurrentLine', 'Meta+D', 'mac')).toEqual([]); + }); +}); + // ─── describeConflictContext ───────────────────────────────────────── describe('describeConflictContext', () => { @@ -303,11 +324,24 @@ describe('shortcut defaults', () => { windows: { combo: 'Ctrl+E', enabled: true }, }); expect(SHORTCUT_ACTION_META.selectCurrentStatement).toMatchObject({ - label: '选择当前语句', + label: '选择当前行并复制', scope: 'queryEditor', }); }); + it('registers duplicate current line as a query editor shortcut', () => { + expect(DEFAULT_SHORTCUT_OPTIONS.duplicateCurrentLine).toEqual({ + mac: { combo: 'Meta+D', enabled: true }, + windows: { combo: 'Ctrl+D', enabled: true }, + }); + expect(SHORTCUT_ACTION_META.duplicateCurrentLine).toMatchObject({ + label: '复制当前行到下一行', + scope: 'queryEditor', + allowInEditable: true, + allowedReservedMonacoCommandIds: ['editor.action.addSelectionToNextFindMatch'], + }); + }); + it('registers save query as a query editor shortcut', () => { expect(DEFAULT_SHORTCUT_OPTIONS.saveQuery).toEqual({ mac: { combo: 'Meta+S', enabled: true }, @@ -507,14 +541,14 @@ describe('comboToMonacoKeyBinding', () => { it('maps Ctrl+Enter correctly', () => { expect(comboToMonacoKeyBinding('Ctrl+Enter', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.CtrlCmd, + keyMod: mockKeyMod.WinCtrl, keyCode: mockKeyCode.Enter, }); }); it('maps Ctrl+Shift+R correctly', () => { expect(comboToMonacoKeyBinding('Ctrl+Shift+R', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Shift, + keyMod: mockKeyMod.WinCtrl | mockKeyMod.Shift, keyCode: mockKeyCode.KeyR, }); }); @@ -528,7 +562,7 @@ describe('comboToMonacoKeyBinding', () => { it('maps Meta+Enter (macOS variant)', () => { expect(comboToMonacoKeyBinding('Meta+Enter', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.WinCtrl, + keyMod: mockKeyMod.CtrlCmd, keyCode: mockKeyCode.Enter, }); }); @@ -542,7 +576,7 @@ describe('comboToMonacoKeyBinding', () => { it('maps Ctrl+, (comma)', () => { expect(comboToMonacoKeyBinding('Ctrl+,', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.CtrlCmd, + keyMod: mockKeyMod.WinCtrl, keyCode: mockKeyCode.OemComma, }); }); @@ -557,14 +591,14 @@ describe('comboToMonacoKeyBinding', () => { it('maps Ctrl+Digit1', () => { expect(comboToMonacoKeyBinding('Ctrl+1', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.CtrlCmd, + keyMod: mockKeyMod.WinCtrl, keyCode: mockKeyCode.Digit1, }); }); it('maps Ctrl+Alt+Delete', () => { expect(comboToMonacoKeyBinding('Ctrl+Alt+Delete', mockKeyMod, mockKeyCode)).toEqual({ - keyMod: mockKeyMod.CtrlCmd | mockKeyMod.Alt, + keyMod: mockKeyMod.WinCtrl | mockKeyMod.Alt, keyCode: mockKeyCode.Delete, }); }); diff --git a/frontend/src/utils/shortcuts.ts b/frontend/src/utils/shortcuts.ts index cc08c144..80046330 100644 --- a/frontend/src/utils/shortcuts.ts +++ b/frontend/src/utils/shortcuts.ts @@ -5,6 +5,7 @@ import { getCurrentLanguage, t } from '../i18n'; export type ShortcutAction = | 'runQuery' | 'selectCurrentStatement' + | 'duplicateCurrentLine' | 'saveQuery' | 'formatSql' | 'toggleQueryResultsPanel' @@ -43,6 +44,7 @@ export interface ShortcutActionMeta { requiredKey?: string; disallowShift?: boolean; platformOnly?: 'mac'; + allowedReservedMonacoCommandIds?: string[]; } interface ShortcutActionMetaDefinition extends Omit { @@ -103,6 +105,7 @@ const KEY_ALIASES: Record = { export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [ 'runQuery', 'selectCurrentStatement', + 'duplicateCurrentLine', 'saveQuery', 'formatSql', 'toggleQueryResultsPanel', @@ -139,6 +142,7 @@ const createShortcutActionMeta = ( requiredKey: definition.requiredKey, disallowShift: definition.disallowShift, platformOnly: definition.platformOnly, + allowedReservedMonacoCommandIds: definition.allowedReservedMonacoCommandIds, }); const SHORTCUT_ACTION_META_DEFINITIONS: Record = { @@ -151,6 +155,13 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record { @@ -802,6 +835,25 @@ export const findReservedConflicts = (normalizedCombo: string, platform?: Shortc .map((r) => ({ label: r.label, context: r.context, monacoCommandId: r.monacoCommandId })); }; +export const findReservedConflictsForAction = ( + action: ShortcutAction, + normalizedCombo: string, + platform?: ShortcutPlatform, +): ConflictInfo[] => { + const conflicts = findReservedConflicts(normalizedCombo, platform); + const allowedMonacoCommandIds = new Set( + SHORTCUT_ACTION_META[action].allowedReservedMonacoCommandIds || [], + ); + if (allowedMonacoCommandIds.size === 0) { + return conflicts; + } + return conflicts.filter((conflict) => ( + conflict.context !== 'monaco' + || !conflict.monacoCommandId + || !allowedMonacoCommandIds.has(conflict.monacoCommandId) + )); +}; + export interface MonacoKeyBinding { keyMod: number; keyCode: number; @@ -876,9 +928,9 @@ export const comboToMonacoKeyBinding = ( for (const piece of pieces) { if (piece === 'Ctrl') { - keyMod |= keyModEnum.CtrlCmd ?? 0; - } else if (piece === 'Meta') { keyMod |= keyModEnum.WinCtrl ?? 0; + } else if (piece === 'Meta') { + keyMod |= keyModEnum.CtrlCmd ?? 0; } else if (piece === 'Alt') { keyMod |= keyModEnum.Alt ?? 0; } else if (piece === 'Shift') { diff --git a/main.go b/main.go index 2ccb86c5..ef9edd61 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "runtime" "runtime/debug" "strings" @@ -13,12 +14,17 @@ import ( "GoNavi-Wails/internal/mcpserver" "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" "github.com/wailsapp/wails/v2/pkg/options/mac" + wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/options/windows" ) +const nativeSelectCurrentLineEvent = "gonavi:native-select-current-line" + func main() { // 大结果集导出(88W+ 行)时,JSON 编解码会产生 5-8 倍内存副本, // Go 默认 GOGC=100 下堆翻倍才触发 GC,叠加 Windows MADV_FREE 不归还 RSS, @@ -34,12 +40,22 @@ func main() { application := app.NewApp() aiService := aiservice.NewService() lowMemoryMode := isLowMemoryMode() + var runtimeCtx context.Context backgroundColour := &options.RGBA{R: 0, G: 0, B: 0, A: 0} windowsBackdrop := windows.Acrylic if lowMemoryMode { backgroundColour = &options.RGBA{R: 255, G: 255, B: 255, A: 255} windowsBackdrop = windows.None } + var appMenu *menu.Menu + if strings.EqualFold(strings.TrimSpace(runtime.GOOS), "darwin") { + appMenu = buildMacApplicationMenu(func() { + if runtimeCtx == nil { + return + } + wailsRuntime.EventsEmit(runtimeCtx, nativeSelectCurrentLineEvent) + }, true) + } // Create application with options err := wails.Run(&options.App{ @@ -53,7 +69,9 @@ func main() { Assets: assets, }, BackgroundColour: backgroundColour, + Menu: appMenu, OnStartup: func(ctx context.Context) { + runtimeCtx = ctx app.InitializeLifecycle(application, ctx) aiservice.InitializeLifecycle(aiService, ctx) }, @@ -85,6 +103,23 @@ func main() { } } +func buildMacApplicationMenu(onNativeSelectCurrentLine func(), frameless bool) *menu.Menu { + result := menu.NewMenuFromItems( + menu.AppMenu(), + menu.EditMenu(), + ) + if !frameless { + result.Append(menu.WindowMenu()) + } + queryEditorMenu := result.AddSubmenu("SQL") + queryEditorMenu.AddText("Copy Current Line", keys.CmdOrCtrl("e"), func(_ *menu.CallbackData) { + if onNativeSelectCurrentLine != nil { + onNativeSelectCurrentLine() + } + }) + return result +} + func runSpecialMode(args []string) bool { if !shouldRunMCPServerMode(args) { return false diff --git a/main_test.go b/main_test.go index 4b1d8f36..a76684e8 100644 --- a/main_test.go +++ b/main_test.go @@ -1,6 +1,11 @@ package main -import "testing" +import ( + "testing" + + "github.com/wailsapp/wails/v2/pkg/menu" + "github.com/wailsapp/wails/v2/pkg/menu/keys" +) func TestIsLowMemoryMode(t *testing.T) { tests := []struct { @@ -47,3 +52,50 @@ func TestShouldRunMCPServerMode(t *testing.T) { }) } } + +func TestBuildMacApplicationMenu(t *testing.T) { + called := 0 + appMenu := buildMacApplicationMenu(func() { + called++ + }, true) + + if appMenu == nil { + t.Fatal("buildMacApplicationMenu() returned nil") + } + if len(appMenu.Items) != 3 { + t.Fatalf("expected 3 top-level menu items, got %d", len(appMenu.Items)) + } + if appMenu.Items[0].Role != menu.AppMenuRole { + t.Fatalf("first top-level menu role = %v, want %v", appMenu.Items[0].Role, menu.AppMenuRole) + } + if appMenu.Items[1].Role != menu.EditMenuRole { + t.Fatalf("second top-level menu role = %v, want %v", appMenu.Items[1].Role, menu.EditMenuRole) + } + + queryEditorMenu := appMenu.Items[2] + if queryEditorMenu.Label != "SQL" { + t.Fatalf("query editor menu label = %q, want %q", queryEditorMenu.Label, "SQL") + } + if queryEditorMenu.SubMenu == nil || len(queryEditorMenu.SubMenu.Items) != 1 { + t.Fatalf("query editor submenu items = %d, want 1", len(queryEditorMenu.SubMenu.Items)) + } + + copyCurrentLineItem := queryEditorMenu.SubMenu.Items[0] + if copyCurrentLineItem.Label != "Copy Current Line" { + t.Fatalf("menu item label = %q, want %q", copyCurrentLineItem.Label, "Copy Current Line") + } + if copyCurrentLineItem.Accelerator == nil { + t.Fatal("menu item accelerator is nil") + } + if copyCurrentLineItem.Accelerator.Key != "e" { + t.Fatalf("menu item accelerator key = %q, want %q", copyCurrentLineItem.Accelerator.Key, "e") + } + if len(copyCurrentLineItem.Accelerator.Modifiers) != 1 || copyCurrentLineItem.Accelerator.Modifiers[0] != keys.CmdOrCtrlKey { + t.Fatalf("menu item modifiers = %v, want [%v]", copyCurrentLineItem.Accelerator.Modifiers, keys.CmdOrCtrlKey) + } + + copyCurrentLineItem.Click(&menu.CallbackData{MenuItem: copyCurrentLineItem}) + if called != 1 { + t.Fatalf("native select-current-line callback called %d times, want 1", called) + } +} diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index c8ee3515..cc838fd9 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "SQL ausführen", "app.shortcuts.action.saveQuery.description": "Aktuellen Abfrage-Tab speichern; unbenannte Abfragen öffnen den Speicherdialog", "app.shortcuts.action.saveQuery.label": "Abfrage speichern", + "app.shortcuts.action.duplicateCurrentLine.description": "Die Zeile an der Cursorposition im Abfrageeditor duplizieren und darunter einfügen", + "app.shortcuts.action.duplicateCurrentLine.label": "Aktuelle Zeile darunter duplizieren", "app.shortcuts.action.formatSql.description": "SQL im aktuellen Abfrageeditor formatieren", "app.shortcuts.action.formatSql.label": "SQL formatieren", - "app.shortcuts.action.selectCurrentStatement.description": "SQL-Anweisung an der Cursorposition im Abfrageeditor auswählen", - "app.shortcuts.action.selectCurrentStatement.label": "Aktuelle Anweisung auswählen", + "app.shortcuts.action.selectCurrentStatement.description": "Zeile an der Cursorposition im Abfrageeditor auswählen und in die Zwischenablage kopieren", + "app.shortcuts.action.selectCurrentStatement.label": "Aktuelle Zeile auswählen und kopieren", "app.shortcuts.action.sendAIChatMessage.description": "Aktuelle Nachricht im AI-Eingabefeld senden; Shift+Enter fügt immer einen Zeilenumbruch ein", "app.shortcuts.action.sendAIChatMessage.label": "AI-Chat senden", "app.shortcuts.action.showSlowQueries.description": "Verlauf langsamer SQL-Abfragen für die aktuelle Verbindung anzeigen (Standard-Schwellwert 500ms)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "Code an der aktuellen Cursorposition eingefügt.", "query_editor.message.no_executable_sql": "Kein ausführbares SQL.", "query_editor.message.no_format_restore_snapshot": "Es ist kein SQL-Stand vor der Formatierung zum Wiederherstellen verfügbar.", + "query_editor.message.current_line_no_copyable_content": "Die aktuelle Zeile enthält keinen kopierbaren Inhalt.", "query_editor.message.no_selectable_sql": "Keine auswählbare SQL-Anweisung.", "query_editor.message.object_info_target_not_found": "Der Cursor befindet sich auf keiner erkannten Tabelle oder Spalte.", "query_editor.message.page_query_empty": "Die Seitenabfrage hat keine Ergebnismenge zurückgegeben.", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 3e074063..5ba87ce2 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "Run SQL", "app.shortcuts.action.saveQuery.description": "Save the current query tab; unnamed queries open the save dialog", "app.shortcuts.action.saveQuery.label": "Save Query", + "app.shortcuts.action.duplicateCurrentLine.description": "Duplicate the line at the cursor in the query editor and insert it below", + "app.shortcuts.action.duplicateCurrentLine.label": "Duplicate Current Line Below", "app.shortcuts.action.formatSql.description": "Format SQL in the current query editor", "app.shortcuts.action.formatSql.label": "Format SQL", - "app.shortcuts.action.selectCurrentStatement.description": "Select the SQL statement at the cursor in the query editor", - "app.shortcuts.action.selectCurrentStatement.label": "Select Current Statement", + "app.shortcuts.action.selectCurrentStatement.description": "Select the line at the cursor in the query editor and copy it to the clipboard", + "app.shortcuts.action.selectCurrentStatement.label": "Select Current Line and Copy", "app.shortcuts.action.sendAIChatMessage.description": "Send the current message from the AI input; Shift+Enter always inserts a new line", "app.shortcuts.action.sendAIChatMessage.label": "Send AI Chat", "app.shortcuts.action.showSlowQueries.description": "View slow SQL history for the current connection (default threshold 500ms)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "Code inserted at the current cursor.", "query_editor.message.no_executable_sql": "No executable SQL.", "query_editor.message.no_format_restore_snapshot": "No pre-format SQL snapshot is available to restore.", + "query_editor.message.current_line_no_copyable_content": "No copyable content on the current line.", "query_editor.message.no_selectable_sql": "No selectable SQL statement.", "query_editor.message.object_info_target_not_found": "The cursor is not on a recognized table or column.", "query_editor.message.page_query_empty": "The page query returned no result set.", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 11f0d328..7e25ba24 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "SQL を実行", "app.shortcuts.action.saveQuery.description": "現在のクエリタブを保存します。名前のないクエリでは保存ダイアログを開きます", "app.shortcuts.action.saveQuery.label": "クエリを保存", + "app.shortcuts.action.duplicateCurrentLine.description": "クエリエディターでカーソル位置の行を複製して次の行へ挿入します", + "app.shortcuts.action.duplicateCurrentLine.label": "現在の行を下に複製", "app.shortcuts.action.formatSql.description": "現在のクエリエディターの SQL を整形します", "app.shortcuts.action.formatSql.label": "SQL を整形", - "app.shortcuts.action.selectCurrentStatement.description": "クエリエディターでカーソル位置の SQL 文を選択します", - "app.shortcuts.action.selectCurrentStatement.label": "現在の文を選択", + "app.shortcuts.action.selectCurrentStatement.description": "クエリエディターでカーソル位置の行を選択してクリップボードにコピーします", + "app.shortcuts.action.selectCurrentStatement.label": "現在の行を選択してコピー", "app.shortcuts.action.sendAIChatMessage.description": "AI 入力欄の現在のメッセージを送信します。Shift+Enter は常に改行します", "app.shortcuts.action.sendAIChatMessage.label": "AI チャット送信", "app.shortcuts.action.showSlowQueries.description": "現在の接続のスロー SQL 履歴を表示(デフォルト閾値 500ms)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "現在のカーソル位置にコードを挿入しました。", "query_editor.message.no_executable_sql": "実行できる SQL がありません。", "query_editor.message.no_format_restore_snapshot": "元に戻せる整形前の SQL はありません。", + "query_editor.message.current_line_no_copyable_content": "現在の行にコピーできる内容がありません。", "query_editor.message.no_selectable_sql": "選択できる SQL ステートメントがありません。", "query_editor.message.object_info_target_not_found": "現在のカーソル位置に認識できるテーブルまたはカラムがありません。", "query_editor.message.page_query_empty": "ページ取得で結果セットが返されませんでした。", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 9ed07a1b..e931f31c 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "Выполнить SQL", "app.shortcuts.action.saveQuery.description": "Сохранить текущую вкладку запроса; для безымянных запросов откроется окно сохранения", "app.shortcuts.action.saveQuery.label": "Сохранить запрос", + "app.shortcuts.action.duplicateCurrentLine.description": "Дублировать строку под курсором в редакторе запросов и вставить ее ниже", + "app.shortcuts.action.duplicateCurrentLine.label": "Дублировать текущую строку ниже", "app.shortcuts.action.formatSql.description": "Форматировать SQL в текущем редакторе запросов", "app.shortcuts.action.formatSql.label": "Форматировать SQL", - "app.shortcuts.action.selectCurrentStatement.description": "Выбрать SQL-оператор под курсором в редакторе запросов", - "app.shortcuts.action.selectCurrentStatement.label": "Выбрать текущий оператор", + "app.shortcuts.action.selectCurrentStatement.description": "Выбрать строку под курсором в редакторе запросов и скопировать ее в буфер обмена", + "app.shortcuts.action.selectCurrentStatement.label": "Выбрать текущую строку и скопировать", "app.shortcuts.action.sendAIChatMessage.description": "Отправить текущее сообщение из поля ввода AI; Shift+Enter всегда вставляет новую строку", "app.shortcuts.action.sendAIChatMessage.label": "Отправить AI-чат", "app.shortcuts.action.showSlowQueries.description": "Просмотр истории медленных SQL-запросов для текущего подключения (порог по умолчанию 500мс)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "Код вставлен в текущую позицию курсора.", "query_editor.message.no_executable_sql": "Нет SQL для выполнения.", "query_editor.message.no_format_restore_snapshot": "Нет сохранённого состояния SQL до форматирования для восстановления.", + "query_editor.message.current_line_no_copyable_content": "В текущей строке нет содержимого для копирования.", "query_editor.message.no_selectable_sql": "Нет SQL-инструкции для выбора.", "query_editor.message.object_info_target_not_found": "Текущий курсор не указывает на распознанную таблицу или колонку.", "query_editor.message.page_query_empty": "Запрос страницы не вернул набор результатов.", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 6c933980..ceb18ac9 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "执行 SQL", "app.shortcuts.action.saveQuery.description": "保存当前查询页;未命名查询会打开保存弹窗", "app.shortcuts.action.saveQuery.label": "保存查询", + "app.shortcuts.action.duplicateCurrentLine.description": "在查询编辑器中复制光标所在行并插入到下一行", + "app.shortcuts.action.duplicateCurrentLine.label": "复制当前行到下一行", "app.shortcuts.action.formatSql.description": "格式化当前查询编辑器中的 SQL", "app.shortcuts.action.formatSql.label": "美化 SQL", - "app.shortcuts.action.selectCurrentStatement.description": "在查询编辑器中选中光标所在 SQL 语句", - "app.shortcuts.action.selectCurrentStatement.label": "选择当前语句", + "app.shortcuts.action.selectCurrentStatement.description": "在查询编辑器中选中光标所在行并复制到剪贴板", + "app.shortcuts.action.selectCurrentStatement.label": "选择当前行并复制", "app.shortcuts.action.sendAIChatMessage.description": "在 AI 输入框中发送当前消息,Shift+Enter 始终换行", "app.shortcuts.action.sendAIChatMessage.label": "AI 聊天发送", "app.shortcuts.action.showSlowQueries.description": "查看当前连接的慢 SQL 历史记录(默认阈值 500ms)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "代码已在当前光标处成功插入。", "query_editor.message.no_executable_sql": "没有可执行的 SQL。", "query_editor.message.no_format_restore_snapshot": "没有可还原的美化前 SQL", + "query_editor.message.current_line_no_copyable_content": "当前行没有可复制内容。", "query_editor.message.no_selectable_sql": "没有可选择的 SQL 语句。", "query_editor.message.object_info_target_not_found": "当前光标未定位到可识别的表或字段。", "query_editor.message.page_query_empty": "翻页未返回结果集", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 1c418184..1773f155 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -2607,10 +2607,12 @@ "app.shortcuts.action.runQuery.label": "執行 SQL", "app.shortcuts.action.saveQuery.description": "儲存目前查詢頁;未命名查詢會開啟儲存彈窗", "app.shortcuts.action.saveQuery.label": "儲存查詢", + "app.shortcuts.action.duplicateCurrentLine.description": "在查詢編輯器中複製游標所在行並插入到下一行", + "app.shortcuts.action.duplicateCurrentLine.label": "複製目前行到下一行", "app.shortcuts.action.formatSql.description": "格式化目前查詢編輯器中的 SQL", "app.shortcuts.action.formatSql.label": "美化 SQL", - "app.shortcuts.action.selectCurrentStatement.description": "在查詢編輯器中選取游標所在 SQL 語句", - "app.shortcuts.action.selectCurrentStatement.label": "選取目前語句", + "app.shortcuts.action.selectCurrentStatement.description": "在查詢編輯器中選取游標所在行並複製到剪貼簿", + "app.shortcuts.action.selectCurrentStatement.label": "選取目前行並複製", "app.shortcuts.action.sendAIChatMessage.description": "在 AI 輸入框中送出目前訊息,Shift+Enter 一律換行", "app.shortcuts.action.sendAIChatMessage.label": "AI 聊天送出", "app.shortcuts.action.showSlowQueries.description": "檢視目前連線的慢 SQL 歷史記錄(預設閾值 500ms)", @@ -6164,6 +6166,7 @@ "query_editor.message.insert_success": "程式碼已插入目前游標位置。", "query_editor.message.no_executable_sql": "沒有可執行的 SQL。", "query_editor.message.no_format_restore_snapshot": "沒有可還原的美化前 SQL", + "query_editor.message.current_line_no_copyable_content": "目前行沒有可複製內容。", "query_editor.message.no_selectable_sql": "沒有可選取的 SQL 陳述式。", "query_editor.message.object_info_target_not_found": "目前游標未定位到可識別的資料表或欄位。", "query_editor.message.page_query_empty": "翻頁未傳回結果集",