test(editor): cover context menu routing payloads

This commit is contained in:
LRcoding authored and GitHub committed 2026-07-15 20:53:22 +08:00
1 parent d3118256a4
commit 711d0ec57b
13 files changed
+484 -197

No files matched your search

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
import { CaseLower, CaseUpper, FileCode, PencilRuler, Play, Copy, Sparkles, Table2, TextSelect } from "@lucide/vue";
import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, Sparkles, Table2, TextSelect } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
@@ -40,7 +40,7 @@ import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget }
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
import { resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics";
import {
DBX_TABLE_REFERENCE_MIME,
@@ -65,7 +65,7 @@ import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorP
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
import { isSchemaAware, isSingleDatabase, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy";
import { qualifiedTableNameAtSqlPosition } from "@/lib/sql/queryCursorTableTarget";
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { areSqlSemanticDiagnosticsEqual, buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, tableReferenceKey, type SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
@@ -105,9 +105,10 @@ const emit = defineEmits<{
execute: [source: SqlExecutionOverride];
save: [];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [tableName: string];
viewTableDdl: [tableName: string];
editTableStructure: [tableName: string];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
clickColumn: [columns: Array<{ name: string; table: string; schema?: string }>, error?: string | undefined];
closeColumnPanel: [];
viewportChange: [viewport: { scrollTop: number; scrollLeft: number }];
@@ -188,7 +189,7 @@ const isGestureZooming = ref(false);
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const selectedSql = ref("");
const executableSql = ref("");
const contextTableName = ref<string | null>(null);
const contextObjectTarget = ref<SqlObjectNavigationTarget | null>(null);
const hasSelectedSql = computed(() => selectedSql.value.trim().length > 0);
const canCopySelectedSql = computed(() => selectedSql.value.length > 0);
@@ -285,7 +286,7 @@ function editorThemeAppearance() {
}
// Completion cache
let cachedTables: Array<{ name: string; schema?: string; type?: "table" | "view" }> = [];
let cachedTables: SqlCompletionTable[] = [];
let cachedCompletionObjects: SqlCompletionObject[] = [];
// Persistent column cache keyed by "schema.table" or "table"
const cachedColumnsByTable = new Map<string, SqlCompletionColumn[]>();
@@ -554,7 +555,28 @@ function syncContextMenuState(currentView: EditorViewType) {
function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEvent) {
syncContextMenuState(currentView);
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
contextTableName.value = pos == null ? null : qualifiedTableNameAtSqlPosition(currentView.state.doc.toString(), pos);
if (pos == null) {
contextObjectTarget.value = null;
return;
}
const sql = currentView.state.doc.toString();
if (!props.connectionId || props.database == null) {
const candidate = queryTableCandidateAtSqlPosition({ connectionId: "", database: props.database ?? "", schema: props.schema, databaseType: props.databaseType, sql, position: pos });
contextObjectTarget.value = candidate ? { name: candidate.tableName, database: candidate.database, schema: candidate.schema } : null;
return;
}
const parsedCandidate = queryTableCandidateAtSqlPosition({ connectionId: props.connectionId, database: props.database, schema: props.schema, databaseType: props.databaseType, sql, position: pos });
if (!parsedCandidate) {
contextObjectTarget.value = null;
return;
}
// Right-click must stay instant: resolve from completion/tree caches and keep the legacy table fallback when metadata is unavailable.
const candidate = resolveQueryContextCandidateDatabase(parsedCandidate, connectionStore.lookupLocalCompletionDatabases(parsedCandidate.connectionId, parsedCandidate.database, MAX_COMPLETION_TABLES));
const tables = connectionStore.lookupLocalCompletionTables(candidate.connectionId, candidate.database, candidate.tableName, MAX_COMPLETION_TABLES, candidate.schema);
contextObjectTarget.value = resolveQueryContextObjectTarget(candidate, tables);
}
function focusEditor() {
@@ -849,22 +871,40 @@ async function pasteClipboardAsSqlInCondition(): Promise<boolean> {
return true;
}
function openTableFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableData", contextTableName.value);
function emitContextObjectAction(action: QueryContextObjectAction) {
if (!contextObjectTarget.value) return;
const route = queryContextObjectRoute(action, contextObjectTarget.value);
switch (route.event) {
case "viewTableData":
emit("viewTableData", route.payload[0]);
break;
case "editTableStructure":
emit("editTableStructure", route.payload[0]);
break;
case "openObjectSource":
emit("openObjectSource", route.payload[0], route.payload[1]);
break;
case "viewTableDdl":
emit("viewTableDdl", route.payload[0]);
break;
}
focusEditor();
}
function editTableStructureFromContextMenu() {
if (!contextTableName.value) return;
emit("editTableStructure", contextTableName.value);
focusEditor();
}
function openTableDdlFromContextMenu() {
if (!contextTableName.value) return;
emit("viewTableDdl", contextTableName.value);
focusEditor();
function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuItem {
const disabled = !contextObjectTarget.value;
switch (action) {
case "view-data":
return { label: t("contextMenu.viewData"), action: () => emitContextObjectAction(action), disabled, icon: Table2 };
case "edit-table-structure":
return { label: t("contextMenu.editStructure"), action: () => emitContextObjectAction(action), disabled, icon: PencilRuler };
case "edit-view":
return { label: t("contextMenu.editView"), action: () => emitContextObjectAction(action), disabled, icon: Pencil };
case "view-source":
return { label: t("contextMenu.viewSource"), action: () => emitContextObjectAction(action), disabled, icon: Code2 };
case "view-ddl":
return { label: t("contextMenu.viewDdl"), action: () => emitContextObjectAction(action), disabled, icon: FileCode };
}
}
function executableStatementRangeStartingAt(currentView: EditorViewType, lineFrom: number) {
@@ -917,24 +957,7 @@ const contextMenuItems = computed<ContextMenuItem[]>(() => {
shortcut: shortcuts.executeSql,
},
]),
{
label: t("contextMenu.viewData"),
action: openTableFromContextMenu,
disabled: !contextTableName.value,
icon: Table2,
},
{
label: t("contextMenu.editStructure"),
action: editTableStructureFromContextMenu,
disabled: !contextTableName.value,
icon: PencilRuler,
},
{
label: t("contextMenu.viewDdl"),
action: openTableDdlFromContextMenu,
disabled: !contextTableName.value,
icon: FileCode,
},
...queryContextObjectActions(contextObjectTarget.value?.type).map(contextObjectMenuItem),
{ label: "", separator: true },
{
label: t("editor.contextMenu.copySelection"),
@@ -1230,7 +1253,7 @@ function completionTablesMatch(left: { name: string; schema?: string | null }, r
return left.schema.toLowerCase() === right.schema.toLowerCase();
}
async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promise<{ name: string; schema?: string; type?: "table" | "view" } | null> {
async function findExactSemanticDiagnosticTable(table: SqlTableReference): Promise<SqlCompletionTable | null> {
if (!props.connectionId || props.database == null) return null;
const target = completionMetadataTarget(table);
if (!target) return null;
@@ -1373,7 +1396,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number)
let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables);
if (!table && !usesLocalOnlyCompletionMetadata()) {
const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema);
cachedTables = [...cachedTables, ...hoverTables];
cachedTables = mergeCompletionTables(cachedTables, hoverTables);
table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables);
}
if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) {
@@ -2332,7 +2355,9 @@ function mergeCompletionTables(existing: SqlCompletionTable[], incoming: SqlComp
indexes.set(key, merged.length);
merged.push(table);
} else {
merged[index] = { ...merged[index], ...table };
const existing = merged[index];
// Preserve the more specific tree type if an older metadata endpoint reports a materialized view as VIEW.
merged[index] = { ...existing, ...table, type: mergeSqlObjectNavigationType(existing.type, table.type) };
}
}
return merged;
@@ -2644,7 +2669,7 @@ function mergeCompletionObjects(existing: SqlCompletionObject[], incoming: SqlCo
return merged;
}
async function refreshCompletionCache() {
function refreshCompletionCache() {
cachedTables = [];
cachedCompletionObjects = [];
cachedColumnsByTable.clear();
@@ -3673,7 +3698,7 @@ function scrollCursorIntoView() {
});
}
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange });
defineExpose({ openSearch, openReplace, scrollCursorIntoView, requestExecute, pasteClipboardAsSqlInCondition, previewStatementRange, refreshCompletionCache });
</script>
<template>
@@ -150,9 +150,10 @@ const emit = defineEmits<{
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string, mode?: DataGridSortMode];
executeSql: [sql: string];
clickTable: [target: SqlObjectNavigationTarget];
viewTableData: [tableName: string];
viewTableDdl: [tableName: string];
editTableStructure: [tableName: string];
viewTableData: [target: SqlObjectNavigationTarget];
viewTableDdl: [target: SqlObjectNavigationTarget];
editTableStructure: [target: SqlObjectNavigationTarget];
openObjectSource: [target: SqlObjectNavigationTarget, initialEditing: boolean];
openObjectTable: [target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
objectSchemaChange: [schema: string | undefined];
objectBrowserViewportChange: [tabId: string, viewport: ObjectBrowserViewport];
@@ -656,16 +657,20 @@ function onHandleClickTable(target: SqlObjectNavigationTarget) {
emit("clickTable", target);
}
function onHandleViewTableData(tableName: string) {
emit("viewTableData", tableName);
function onHandleViewTableData(target: SqlObjectNavigationTarget) {
emit("viewTableData", target);
}
function onHandleViewTableDdl(tableName: string) {
emit("viewTableDdl", tableName);
function onHandleViewTableDdl(target: SqlObjectNavigationTarget) {
emit("viewTableDdl", target);
}
function onHandleEditTableStructure(tableName: string) {
emit("editTableStructure", tableName);
function onHandleEditTableStructure(target: SqlObjectNavigationTarget) {
emit("editTableStructure", target);
}
function onHandleOpenObjectSource(target: SqlObjectNavigationTarget, initialEditing: boolean) {
emit("openObjectSource", target, initialEditing);
}
function onHandleCloseColumnPanel() {
@@ -683,6 +688,12 @@ function focusSearch(): boolean {
return dataGridRef.value?.focusSearch() ?? false;
}
function refreshQueryEditorCompletionCache(): boolean {
if (props.activeTab.mode !== "query" || !queryEditorRef.value) return false;
queryEditorRef.value.refreshCompletionCache();
return true;
}
function refreshData(): boolean {
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
@@ -773,7 +784,7 @@ function applyTableStructureChanges() {
return tableStructureEditorRef.value?.applyChanges() ?? Promise.resolve(false);
}
defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges });
defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, handleModRTarget, requestQueryEditorExecute, pasteClipboardAsSqlInCondition, applyTableStructureChanges });
</script>
<template>
@@ -821,6 +832,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
@view-table-data="onHandleViewTableData"
@edit-table-structure="onHandleEditTableStructure"
@view-table-ddl="onHandleViewTableDdl"
@open-object-source="onHandleOpenObjectSource"
@click-column="onHandleClickColumn"
@close-column-panel="onHandleCloseColumnPanel"
/>