feat(editor): add ExPaste paste-as-IN-condition helper (#2746)

* feat: add ExPaste SQL IN condition helper

* fix: restrict ExPaste to SQL-like editors

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-07-07 12:33:49 +08:00
1 parent 29495a7220
commit 705bacba04
17 files changed
+491 -8

No files matched your search

@@ -8,12 +8,13 @@ import { search as cmSearch } from "@codemirror/search";
import EditorSearchPanel from "./EditorSearchPanel.vue";
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { copyToClipboard } from "@/lib/common/clipboard";
import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import { currentStatementFrameRangeTo, visualSqlColumns } from "@/lib/sql/currentStatementFrame";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildSqlInConditionFromPasteSource, insertTextForSqlInCondition } from "@/lib/sql/sqlInListPaste";
import { formatMongoShellText } from "@/lib/mongo/mongoFormatter";
import { useConnectionStore, COMPLETION_METADATA_CONCURRENCY } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -54,7 +55,7 @@ import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
@@ -738,6 +739,50 @@ function convertSelectedSqlCase(mode: SelectionCaseMode): boolean {
return false;
}
async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
if (!supportsSqlInListPaste(props.databaseType)) return false;
if (props.readOnly) return false;
const currentView = view.value;
if (!currentView) return false;
const selection = currentView.state.selection.main;
const selectedSource = selection.empty ? "" : currentView.state.sliceDoc(selection.from, selection.to);
let source = selectedSource;
if (!source) {
try {
source = await readTextFromClipboard();
} catch (e: any) {
toast(t("editor.exPasteClipboardReadFailed", { message: e?.message || String(e) }), 5000);
focusEditor();
return false;
}
}
const result = buildSqlInConditionFromPasteSource(source);
if (!result.ok) {
const key = result.reason === "too-large" ? "editor.exPasteTooLarge" : result.reason === "too-many-values" ? "editor.exPasteTooManyValues" : result.reason === "not-list" ? "editor.exPasteNotList" : "editor.exPasteNoValues";
toast(t(key, { limit: result.limit ?? 0 }), 5000);
focusEditor();
return false;
}
if (view.value !== currentView || props.readOnly) return false;
const state = currentView.state;
const line = state.doc.lineAt(selection.from);
const prefix = state.sliceDoc(line.from, selection.from);
const insertText = insertTextForSqlInCondition(result.sql, prefix);
currentView.dispatch({
changes: { from: selection.from, to: selection.to, insert: insertText },
selection: { anchor: selection.from + insertText.length },
scrollIntoView: true,
userEvent: "input.paste",
});
currentView.focus();
toast(t("editor.exPastePasted", { count: result.valueCount }), 2000);
return true;
}
function openTableFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableData", contextTableName.value);
@@ -887,6 +932,11 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
...binding(shortcuts.selectAll, (view) => codeMirrorSelectAll?.(view) ?? false),
...binding(shortcuts.uppercaseSelection, () => convertSelectedSqlCase("upper")),
...binding(shortcuts.lowercaseSelection, () => convertSelectedSqlCase("lower")),
...binding(shortcuts.exPasteSqlInCondition, () => {
if (!supportsSqlInListPaste(props.databaseType)) return false;
void pasteClipboardAsSqlInCondition();
return true;
}),
]),
) ?? [],
codeMirrorKeymap.of(
@@ -3254,7 +3304,7 @@ function scrollCursorIntoView() {
});
}
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute });
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition });
</script>
<template>
@@ -727,7 +727,11 @@ function requestQueryEditorExecute() {
return queryEditorRef.value?.requestExecute();
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute });
function pasteClipboardAsSqlInCondition() {
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition });
</script>
<template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Upload, RotateCcw, AlertTriangle } from "@lucide/vue";
import { Play, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, Layers, X, Shield, Upload, RotateCcw, AlertTriangle, ClipboardPaste } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -13,7 +13,7 @@ import { useSchemaOptions } from "@/composables/useSchemaOptions";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDatabase";
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { isSingleDatabase, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { hexToRgba } from "@/lib/common/color";
import type { QueryTab, ConnectionConfig } from "@/types/database";
@@ -40,6 +40,7 @@ const emit = defineEmits<{
saveSql: [];
openSql: [];
importResultArchive: [];
pasteSqlInCondition: [];
changeConnection: [connectionId: string];
changeDatabase: [database: string];
changeSchema: [schema: string | undefined];
@@ -71,6 +72,7 @@ const supportsExplain = computed(() => {
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "weaviate" && dbType !== "chromadb" && dbType !== "etcd" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "nacos";
});
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const supportsExPaste = computed(() => supportsSqlInListPaste(props.activeConnection?.db_type));
const supportsTransaction = computed(() => supportsTransactionFeature(props.activeConnection?.db_type));
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : ""));
@@ -288,6 +290,14 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
</TooltipTrigger>
<TooltipContent>{{ t("tabs.importResultArchive") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="supportsExPaste">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-teal-600 hover:bg-teal-500/10 hover:text-teal-700 dark:text-teal-300 dark:hover:text-teal-200" @click="emit('pasteSqlInCondition')">
<ClipboardPaste class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.exPasteSqlInCondition") }}</TooltipContent>
</Tooltip>
</div>
<span class="flex-1 min-w-0" />
<div class="flex items-center gap-2 shrink-0">