From 93006d842351e7769c81d09428c9b2d6b8b2d9c8 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Mon, 29 Jun 2026 20:00:27 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(query-editor):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BC=96=E8=BE=91=E5=99=A8=E5=86=85=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在查询编辑器工具栏新增搜索入口并直连 Monaco 查找\n- 接管 Ctrl/Cmd+F,优先打开编辑器内搜索而不是浏览器默认查找\n- 补充多语言文案与静态测试覆盖\n\nFixes #539 --- .../src/components/QueryEditor.i18n.test.ts | 6 + frontend/src/components/QueryEditor.tsx | 119 ++++++++++++++++++ .../QueryEditorToolbar.i18n.test.ts | 2 + .../QueryEditorToolbar.layout.test.tsx | 13 ++ .../src/components/QueryEditorToolbar.tsx | 19 +++ shared/i18n/de-DE.json | 2 + shared/i18n/en-US.json | 2 + shared/i18n/ja-JP.json | 2 + shared/i18n/ru-RU.json | 2 + shared/i18n/zh-CN.json | 2 + shared/i18n/zh-TW.json | 2 + 11 files changed, 171 insertions(+) diff --git a/frontend/src/components/QueryEditor.i18n.test.ts b/frontend/src/components/QueryEditor.i18n.test.ts index 2401939d..c43311e8 100644 --- a/frontend/src/components/QueryEditor.i18n.test.ts +++ b/frontend/src/components/QueryEditor.i18n.test.ts @@ -35,6 +35,12 @@ describe('QueryEditor i18n source guards', () => { expect(queryEditorSource).not.toContain('翻页未返回结果集'); }); + it('routes editor search through a localized Monaco action', () => { + expect(queryEditorSource).toContain('query_editor.action.find_in_editor'); + expect(queryEditorSource).toContain('gonavi:find-active-query'); + expect(queryEditorSource).toContain("editor.getAction?.('actions.find')"); + }); + it('uses a localized wrapper for save query failures', () => { expect(queryEditorSource).toContain('query_editor.message.save_query_failed'); expect(queryEditorSource).not.toContain('保存查询失败: '); diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 6a53cf09..c839962d 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -731,6 +731,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const runQueryActionRef = useRef(null); const selectCurrentStatementActionRef = useRef(null); const saveQueryActionRef = useRef(null); + const findInEditorActionRef = useRef(null); const formatSqlActionRef = useRef(null); const aiContextMenuActionDisposablesRef = useRef([]); const toggleQueryResultsPanelActionRef = useRef(null); @@ -880,6 +881,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc () => resolveShortcutBinding(shortcutOptions, 'toggleQueryResultsPanel', activeShortcutPlatform), [activeShortcutPlatform, shortcutOptions], ); + const findInEditorShortcutCombo = useMemo( + () => activeShortcutPlatform === 'mac' ? 'Meta+F' : 'Ctrl+F', + [activeShortcutPlatform], + ); const primaryShortcutModifierLabel = useMemo( () => getShortcutPrimaryModifierDisplayLabel(activeShortcutPlatform), [activeShortcutPlatform], @@ -898,6 +903,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc return nextVisible; }); }, [tab.id, updateQueryTabDraft]); + const handleOpenEditorFind = useCallback(() => { + const editor = editorRef.current; + if (!editor) { + return; + } + + editor.focus?.(); + try { + const findAction = editor.getAction?.('actions.find'); + if (findAction?.run) { + void findAction.run(); + return; + } + } catch { + // Fall back to Monaco's built-in command id if the action lookup fails. + } + + editor.trigger?.('keyboard', 'actions.find', null); + }, []); const handleShowSqlExecutionLog = useCallback((mode: 'open' | 'toggle' = 'toggle') => { if (!isActive) { return; @@ -2732,6 +2756,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc } } + const findInEditorKeyBinding = comboToMonacoKeyBinding( + findInEditorShortcutCombo, monaco.KeyMod, monaco.KeyCode + ); + if (findInEditorKeyBinding) { + findInEditorActionRef.current = editor.addAction({ + id: 'gonavi.findInEditor', + label: buildQueryEditorMonacoActionLabel('query_editor.action.find_in_editor'), + keybindings: [findInEditorKeyBinding.keyMod | findInEditorKeyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:find-active-query')); + }, + }); + } + const formatBinding = formatSqlShortcutBinding; if (formatBinding?.enabled && formatBinding.combo) { const keyBinding = comboToMonacoKeyBinding( @@ -4998,6 +5036,40 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [languagePreference, saveQueryShortcutBinding]); + useEffect(() => { + if (findInEditorActionRef.current) { + findInEditorActionRef.current.dispose(); + findInEditorActionRef.current = null; + } + + const editor = editorRef.current; + const monaco = monacoRef.current; + if (!editor || !monaco) return; + + const keyBinding = comboToMonacoKeyBinding( + findInEditorShortcutCombo, + monaco.KeyMod, + monaco.KeyCode, + ); + if (keyBinding) { + findInEditorActionRef.current = editor.addAction({ + id: 'gonavi.findInEditor', + label: buildQueryEditorMonacoActionLabel('query_editor.action.find_in_editor'), + keybindings: [keyBinding.keyMod | keyBinding.keyCode], + run: () => { + window.dispatchEvent(new CustomEvent('gonavi:find-active-query')); + }, + }); + } + + return () => { + if (findInEditorActionRef.current) { + findInEditorActionRef.current.dispose(); + findInEditorActionRef.current = null; + } + }; + }, [findInEditorShortcutCombo, languagePreference]); + useEffect(() => { if (formatSqlActionRef.current) { formatSqlActionRef.current.dispose(); @@ -5095,6 +5167,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }; }, [isActive, handleRun, handleRunSelectedShortcut]); + useEffect(() => { + const handleFindActiveQuery = () => { + if (!isActive) { + return; + } + handleOpenEditorFind(); + }; + + window.addEventListener('gonavi:find-active-query', handleFindActiveQuery as EventListener); + return () => { + window.removeEventListener('gonavi:find-active-query', handleFindActiveQuery as EventListener); + }; + }, [handleOpenEditorFind, isActive]); + // 监听由 TabManager 分发的专用注入事件 useEffect(() => { const handleInsertSql = (e: any) => { @@ -5339,6 +5425,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, ]; + useEffect(() => { + const handleFindShortcut = (event: KeyboardEvent) => { + if (!isActive) { + return; + } + if (!isShortcutMatch(event, findInEditorShortcutCombo)) { + return; + } + + const editor = editorRef.current; + const targetNode = resolveEventTargetNode(event.target); + const editorHasFocus = !!editor?.hasTextFocus?.(); + const inEditorPane = !!(targetNode && editorPaneRef.current?.contains(targetNode)); + const inQueryEditor = !!(targetNode && queryEditorRootRef.current?.contains(targetNode)); + if (isEditableElement(event.target) && !inEditorPane) { + return; + } + if (!editorHasFocus && !inEditorPane && !inQueryEditor && !isDocumentLevelShortcutTarget(targetNode)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + handleOpenEditorFind(); + }; + + window.addEventListener('keydown', handleFindShortcut, true); + return () => { + window.removeEventListener('keydown', handleFindShortcut, true); + }; + }, [findInEditorShortcutCombo, handleOpenEditorFind, isActive]); + useEffect(() => { const binding = saveQueryShortcutBinding; if (!binding?.enabled || !binding.combo) { @@ -5605,6 +5723,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc onRun={handleRun} onCancel={handleCancel} onQuickSave={handleQuickSave} + onFindInEditor={handleOpenEditorFind} onFormat={handleFormat} onToggleResultPanelVisibility={toggleResultPanelVisibility} onAIAction={handleAIAction} diff --git a/frontend/src/components/QueryEditorToolbar.i18n.test.ts b/frontend/src/components/QueryEditorToolbar.i18n.test.ts index fb42d57c..9d8214cd 100644 --- a/frontend/src/components/QueryEditorToolbar.i18n.test.ts +++ b/frontend/src/components/QueryEditorToolbar.i18n.test.ts @@ -42,6 +42,8 @@ const requiredKeys = [ 'query_editor.action.save', 'query_editor.action.save_with_shortcut', 'query_editor.action.more', + 'query_editor.action.find_in_editor', + 'query_editor.action.find_in_editor_with_shortcut', 'query_editor.action.format', 'query_editor.action.format_sql', 'query_editor.action.format_sql_with_shortcut', diff --git a/frontend/src/components/QueryEditorToolbar.layout.test.tsx b/frontend/src/components/QueryEditorToolbar.layout.test.tsx index 4a67f28e..90577359 100644 --- a/frontend/src/components/QueryEditorToolbar.layout.test.tsx +++ b/frontend/src/components/QueryEditorToolbar.layout.test.tsx @@ -80,6 +80,19 @@ describe('QueryEditorToolbar layout', () => { expect(transactionDelayCss).toContain('flex: 0 0 104px !important;'); }); + it('keeps editor search action grouped with formatting actions', () => { + const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); + const formatActionPairStart = toolbarSource.indexOf('SearchOutlined'); + const formatActionPairEnd = toolbarSource.indexOf('!isV2Ui && ('); + const formatActionPairSource = toolbarSource.slice(formatActionPairStart, formatActionPairEnd); + + expect(toolbarSource).toContain('SearchOutlined'); + expect(formatActionPairSource).toContain('query_editor.action.find_in_editor'); + expect(formatActionPairSource).toContain('query_editor.action.find_in_editor_with_shortcut'); + expect(formatActionPairSource).toContain('onClick={onFindInEditor}'); + expect(formatActionPairSource).toContain('FormatPainterOutlined'); + }); + it('shows delayed full-name tooltips for truncated connection and database selectors', () => { const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8'); const css = readV2ThemeCss(); diff --git a/frontend/src/components/QueryEditorToolbar.tsx b/frontend/src/components/QueryEditorToolbar.tsx index 9c7e0328..4ec83213 100644 --- a/frontend/src/components/QueryEditorToolbar.tsx +++ b/frontend/src/components/QueryEditorToolbar.tsx @@ -6,6 +6,7 @@ import { FormatPainterOutlined, PlayCircleOutlined, RobotOutlined, + SearchOutlined, SaveOutlined, SettingOutlined, StopOutlined, @@ -51,6 +52,7 @@ type QueryEditorToolbarProps = { onRun: () => void; onCancel: () => void; onQuickSave: () => void; + onFindInEditor: () => void; onFormat: () => void; onToggleResultPanelVisibility: () => void; onAIAction: (action: "generate" | "explain" | "optimize" | "schema") => void; @@ -112,6 +114,7 @@ const QueryEditorToolbar: React.FC = ({ onRun, onCancel, onQuickSave, + onFindInEditor, onFormat, onToggleResultPanelVisibility, onAIAction, @@ -161,6 +164,17 @@ const QueryEditorToolbar: React.FC = ({ ), }) : t("query_editor.action.format_sql"); + const findInEditorShortcutCombo = + activeShortcutPlatform === "mac" ? "Meta+F" : "Ctrl+F"; + const findInEditorTitle = t( + "query_editor.action.find_in_editor_with_shortcut", + { + shortcut: getShortcutDisplayLabel( + findInEditorShortcutCombo, + activeShortcutPlatform, + ), + }, + ); const aiMenuItems: MenuProps["items"] = [ { key: "ai-generate", @@ -371,6 +385,11 @@ const QueryEditorToolbar: React.FC = ({ className={isV2Ui ? "gn-v2-query-toolbar-action-pair" : undefined} style={{ display: "flex", gap: "8px", alignItems: "center" }} > + + +