feat: add query editor context menu

This commit is contained in:
t8y2 2026-05-28 00:29:02 +08:00
parent 3ba3a644fe
commit bd78436ab1
5 changed files with 102 additions and 1 deletions

View File

@ -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<InstanceType<typeof EditorSearchPanel>>();
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"
>
<div ref="editorRef" data-query-editor-root class="h-full w-full overflow-hidden" />
<ContextMenu>
<ContextMenuTrigger as-child>
<div ref="editorRef" data-query-editor-root data-context-menu class="h-full w-full overflow-hidden" />
</ContextMenuTrigger>
<ContextMenuContent class="w-52">
<ContextMenuItem :disabled="!canExecuteContextSql" @click="executeFromContextMenu">
{{ executeContextMenuLabel }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem :disabled="!canCopySelectedSql" @click="copySelectedSqlFromContextMenu">
{{ t("editor.contextMenu.copySelection") }}
</ContextMenuItem>
<ContextMenuItem @click="selectAllSqlFromContextMenu">
{{ t("editor.contextMenu.selectAll") }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<EditorSearchPanel ref="searchPanelRef" :view="view" />
</div>
</template>

View File

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

View File

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

View File

@ -306,6 +306,12 @@ export default {
JSON_ARRAY: "创建 JSON 数组",
},
},
contextMenu: {
executeSelection: "执行选中 SQL",
executeCurrent: "执行 SQL",
copySelection: "复制选中内容",
selectAll: "全选",
},
search: {
find: "查找",
replace: "替换",

View File

@ -25,6 +25,16 @@ test("query editor localizes the replace all button", () => {
assert.doesNotMatch(searchPanelSource, />\s*全部\s*</);
});
test("query editor exposes a context menu for executing selected SQL", () => {
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"/);