From bd78436ab19fc2f430775dd81c4db6f6d2ae1ebe Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 28 May 2026 00:29:02 +0800 Subject: [PATCH] feat: add query editor context menu --- .../src/components/editor/QueryEditor.vue | 75 ++++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 6 ++ apps/desktop/src/i18n/locales/es.ts | 6 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 6 ++ .../queryEditorSearchReplace.test.ts | 10 +++ 5 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 9d6e5b05e..4e60c9552 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -5,11 +5,20 @@ import type { CompletionContext } from "@codemirror/autocomplete"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { search as cmSearch } from "@codemirror/search"; import EditorSearchPanel from "./EditorSearchPanel.vue"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { copyToClipboard } from "@/lib/clipboard"; import { resolveExecutableSql } from "@/lib/sqlExecutionTarget"; import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; +import { useToast } from "@/composables/useToast"; import { buildSqlCompletionItemsFromContext, getSqlFunctionSignatureHelp, @@ -76,6 +85,7 @@ const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); const { isDark } = useTheme(); const { t } = useI18n(); +const { toast } = useToast(); const SQL_FUNCTION_NAMES = [ "COUNT", @@ -133,6 +143,15 @@ const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize); const isGestureZooming = ref(false); const searchPanelRef = ref>(); +const selectedSql = ref(""); +const executableSql = ref(""); + +const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0); +const canCopySelectedSql = computed(() => selectedSql.value.length > 0); +const canExecuteContextSql = computed(() => executableSql.value.trim().length > 0); +const executeContextMenuLabel = computed(() => + t(hasSelectedSql.value ? "editor.contextMenu.executeSelection" : "editor.contextMenu.executeCurrent"), +); interface EditorGestureEvent extends Event { scale?: number; @@ -279,6 +298,42 @@ function executeCurrentSql() { return true; } +function syncContextMenuState(currentView: EditorViewType) { + selectedSql.value = selectedSqlFromView(currentView); + executableSql.value = executableSqlFromView(currentView); +} + +function focusEditor() { + view.value?.focus(); +} + +function executeFromContextMenu() { + if (!canExecuteContextSql.value) return; + executeCurrentSql(); + focusEditor(); +} + +async function copySelectedSqlFromContextMenu() { + if (!canCopySelectedSql.value) return; + try { + await copyToClipboard(selectedSql.value); + toast(t("grid.copied")); + focusEditor(); + } catch (e: any) { + toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000); + } +} + +function selectAllSqlFromContextMenu() { + const currentView = view.value; + if (!currentView) return; + currentView.dispatch({ + selection: { anchor: 0, head: currentView.state.doc.length }, + scrollIntoView: true, + }); + focusEditor(); +} + function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) { const shortcuts = settingsStore.editorSettings.shortcuts; const Prec = codeMirrorPrec; @@ -1182,6 +1237,7 @@ onMounted(async () => { } } if (update.selectionSet || update.docChanged) { + syncContextMenuState(update.view); emit("selectionChange", selectedSqlFromView(update.view)); emit("cursorChange", update.state.selection.main.head); } @@ -1336,6 +1392,7 @@ onMounted(async () => { }); view.value = new EditorView({ state, parent: editorRef.value }); + syncContextMenuState(view.value); syncEditorFontCssVars(liveFontSize.value, ss.fontFamily); cachedTables = []; @@ -1457,7 +1514,23 @@ defineExpose({ openSearch, openReplace }); @gesturechange="onEditorGestureChange" @gestureend="onEditorGestureEnd" > -
+ + +
+ + + + {{ executeContextMenuLabel }} + + + + {{ t("editor.contextMenu.copySelection") }} + + + {{ t("editor.contextMenu.selectAll") }} + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 32c8e47a6..1029ad42c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -310,6 +310,12 @@ export default { JSON_ARRAY: "Creates a JSON array", }, }, + contextMenu: { + executeSelection: "Execute selection", + executeCurrent: "Execute SQL", + copySelection: "Copy selection", + selectAll: "Select all", + }, search: { find: "Find", replace: "Replace", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ecaa9aa94..46deffa33 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -303,6 +303,12 @@ export default { JSON_ARRAY: "Crea un array JSON", }, }, + contextMenu: { + executeSelection: "Ejecutar seleccion", + executeCurrent: "Ejecutar SQL", + copySelection: "Copiar seleccion", + selectAll: "Seleccionar todo", + }, search: { find: "Buscar", replace: "Reemplazar", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 5762af0c0..0835a4fed 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -306,6 +306,12 @@ export default { JSON_ARRAY: "创建 JSON 数组", }, }, + contextMenu: { + executeSelection: "执行选中 SQL", + executeCurrent: "执行 SQL", + copySelection: "复制选中内容", + selectAll: "全选", + }, search: { find: "查找", replace: "替换", diff --git a/packages/app-tests/queryEditorSearchReplace.test.ts b/packages/app-tests/queryEditorSearchReplace.test.ts index 5f454181a..e788bb314 100644 --- a/packages/app-tests/queryEditorSearchReplace.test.ts +++ b/packages/app-tests/queryEditorSearchReplace.test.ts @@ -25,6 +25,16 @@ test("query editor localizes the replace all button", () => { assert.doesNotMatch(searchPanelSource, />\s*全部\s* { + assert.match(source, /ContextMenuContent/); + assert.match(source, /data-context-menu/); + assert.match(source, /syncContextMenuState\(update\.view\)/); + assert.match(source, /executeSelection/); + assert.match(source, /copySelection/); + assert.match(source, /selectAllSqlFromContextMenu/); + assert.match(appSource, /\[data-context-menu\]/); +}); + test("query editor does not apply custom search match highlight styles", () => { assert.doesNotMatch(editorThemeSource, /"\.cm-searchMatch"/); assert.doesNotMatch(editorThemeSource, /"\.cm-searchMatch-selected"/);