feat(editor): add table structure peek and dblclick insert
This commit is contained in:
1 parent
da95bd435f
commit
12ed7a49b3
19 files changed
+1021
-8
No files matched your search
@@ -15,7 +15,7 @@ let lastHandledCompressRequestId = 0;
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, shallowRef, computed, nextTick } from "vue";
|
||||
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { AlignLeft, Camera, CaseLower, CaseSensitive, CaseUpper, ClipboardPaste, Code2, Columns3, Download, Eye, FileCode, Highlighter, MessageSquareText, Minimize2, Pencil, PencilRuler, Play, Copy, List, Scissors, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import { Transaction, StateEffect } from "@codemirror/state";
|
||||
@@ -26,6 +26,8 @@ import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||
import EditorGotoLinePanel from "./EditorGotoLinePanel.vue";
|
||||
import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue";
|
||||
import DelimitedListDialog from "./DelimitedListDialog.vue";
|
||||
import TableStructurePeekDialog from "./TableStructurePeekDialog.vue";
|
||||
import type { TableStructurePeekInsertKind } from "./TableStructurePeekDialog.vue";
|
||||
import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue";
|
||||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||||
import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot";
|
||||
@@ -145,6 +147,7 @@ import {
|
||||
DBX_TABLE_REFERENCE_DRAG_END_EVENT,
|
||||
activeTableReferencePayloadValue,
|
||||
clearActiveTableReferencePayload,
|
||||
createColumnReferencePayload,
|
||||
hasTableReferencePayloadType,
|
||||
parseTableReferencePayload,
|
||||
tableReferenceInsertText,
|
||||
@@ -180,6 +183,7 @@ import { createSqlAliasHighlights } from "@/lib/editor/codemirrorSqlAliasHighlig
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
import { clearRememberedFocusedQueryEditorView, focusedQueryEditorView, queryEditorInsertContext, registerQueryEditorInsertContext, rememberFocusedQueryEditorView, unregisterQueryEditorInsertContext } from "@/lib/editor/focusedQueryEditorView";
|
||||
import { stabilizeUnfocusedQueryEditorPointerDown } from "@/lib/editor/queryEditorUnfocusedPointer";
|
||||
import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight";
|
||||
@@ -206,6 +210,7 @@ import { analyzeIntentionActions, prepareExpandWildcardContext, buildExpandWildc
|
||||
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
|
||||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||||
import { structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
|
||||
import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, queryTableNavigationTargetAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { oracleDatabaseLinkCompletionContext, oracleDatabaseLinkCompletionItems } from "@/lib/sql/oracleDatabaseLinkCompletion";
|
||||
@@ -560,6 +565,22 @@ const delimitedListSelectedText = ref("");
|
||||
const codeSnapshotOpen = ref(false);
|
||||
const codeSnapshotSource = ref<CodeSnapshotSource | null>(null);
|
||||
|
||||
interface StructurePeekPanelState {
|
||||
id: string;
|
||||
target: SqlObjectNavigationTarget;
|
||||
columns: ColumnInfo[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
zIndex: number;
|
||||
cascadeIndex: number;
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
const structurePeekPanels = ref<StructurePeekPanelState[]>([]);
|
||||
let structurePeekRequestSeq = 0;
|
||||
let structurePeekZIndexSeq = 50;
|
||||
let structurePeekCascadeSeq = 0;
|
||||
|
||||
function openDelimitedListDialog() {
|
||||
if (props.readOnly) return;
|
||||
if (!selectedSql.value.trim()) {
|
||||
@@ -578,6 +599,134 @@ function applyDelimitedListResult(result: string) {
|
||||
focusEditor();
|
||||
}
|
||||
|
||||
function bringStructurePeekToFront(panelId: string) {
|
||||
const panel = structurePeekPanels.value.find((item) => item.id === panelId);
|
||||
if (!panel) return;
|
||||
structurePeekZIndexSeq += 1;
|
||||
panel.zIndex = structurePeekZIndexSeq;
|
||||
}
|
||||
|
||||
function closeStructurePeekPanel(panelId: string) {
|
||||
structurePeekPanels.value = structurePeekPanels.value.filter((item) => item.id !== panelId);
|
||||
}
|
||||
|
||||
async function loadStructurePeekColumns(panelId: string, requestId: number, target: SqlObjectNavigationTarget, database: string, schema: string) {
|
||||
if (!props.connectionId) return;
|
||||
try {
|
||||
const objectMetadataRequest = {
|
||||
connectionId: props.connectionId,
|
||||
database,
|
||||
schema,
|
||||
tableName: target.name,
|
||||
catalog: props.catalog,
|
||||
objectType: sqlObjectNavigationSourceKind(target),
|
||||
};
|
||||
const { value } = await loadObjectMetadataFacet(objectMetadataRequest, "columns", () => api.getColumns(props.connectionId!, database, schema, target.name, props.catalog, props.clientSessionId));
|
||||
const current = structurePeekPanels.value.find((item) => item.id === panelId && item.requestId === requestId);
|
||||
if (!current) return;
|
||||
current.columns = value;
|
||||
current.error = "";
|
||||
current.loading = false;
|
||||
} catch (error: unknown) {
|
||||
const current = structurePeekPanels.value.find((item) => item.id === panelId && item.requestId === requestId);
|
||||
if (!current) return;
|
||||
current.error = error instanceof Error ? error.message : String(error);
|
||||
current.columns = [];
|
||||
current.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openTableStructurePeek(target: SqlObjectNavigationTarget) {
|
||||
if (!props.connectionId) return;
|
||||
const database = target.database || props.database || "";
|
||||
const schema = target.schema ?? props.schema ?? "";
|
||||
const id = structurePeekPanelId(props.connectionId, database, schema, target.name, props.catalog);
|
||||
const existing = structurePeekPanels.value.find((item) => item.id === id);
|
||||
if (existing) {
|
||||
bringStructurePeekToFront(existing.id);
|
||||
// Retry when a previous open left the panel in an error state.
|
||||
if (existing.error && !existing.loading) {
|
||||
const requestId = ++structurePeekRequestSeq;
|
||||
existing.requestId = requestId;
|
||||
existing.loading = true;
|
||||
existing.error = "";
|
||||
existing.columns = [];
|
||||
await loadStructurePeekColumns(existing.id, requestId, existing.target, database, schema);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
structurePeekZIndexSeq += 1;
|
||||
const requestId = ++structurePeekRequestSeq;
|
||||
const cascadeIndex = structurePeekCascadeSeq++;
|
||||
const panel: StructurePeekPanelState = {
|
||||
id,
|
||||
target: { ...target, database, schema: schema || target.schema },
|
||||
columns: [],
|
||||
loading: true,
|
||||
error: "",
|
||||
zIndex: structurePeekZIndexSeq,
|
||||
cascadeIndex,
|
||||
requestId,
|
||||
};
|
||||
structurePeekPanels.value = [...structurePeekPanels.value, panel];
|
||||
await loadStructurePeekColumns(id, requestId, panel.target, database, schema);
|
||||
}
|
||||
|
||||
function syncQueryEditorInsertContext(currentView: EditorViewType | null = view.value) {
|
||||
if (!currentView) return;
|
||||
registerQueryEditorInsertContext(currentView, {
|
||||
connectionId: props.connectionId,
|
||||
database: props.database,
|
||||
schema: props.schema,
|
||||
databaseType: props.databaseType,
|
||||
});
|
||||
}
|
||||
|
||||
function insertStructurePeekValue(panel: StructurePeekPanelState, value: string, kind: TableStructurePeekInsertKind) {
|
||||
if (!value) return;
|
||||
// Insert into the focused query editor, or the last one if focus is inside peek chrome.
|
||||
// Focus elsewhere (sidebar, etc.) → no-op.
|
||||
const targetView = focusedQueryEditorView();
|
||||
if (!targetView || targetView.state.readOnly) return;
|
||||
|
||||
const targetCtx = queryEditorInsertContext(targetView);
|
||||
const connectionId = targetCtx?.connectionId ?? props.connectionId;
|
||||
const databaseType = targetCtx?.databaseType ?? props.databaseType;
|
||||
const database = panel.target.database || targetCtx?.database || props.database || "";
|
||||
|
||||
let insertText = value;
|
||||
if (kind === "identifier" && connectionId && database != null) {
|
||||
const payload = createColumnReferencePayload({
|
||||
connectionId,
|
||||
database,
|
||||
schema: panel.target.schema ?? targetCtx?.schema ?? props.schema,
|
||||
columnNames: [value],
|
||||
databaseType,
|
||||
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
|
||||
});
|
||||
if (payload) {
|
||||
insertText = tableReferenceInsertText(payload, databaseType, {
|
||||
columnNameSeparator: settingsStore.editorSettings.sidebarCopyTableNameSeparator,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Peek inserts at the caret (or replaces a non-empty selection). Do not use
|
||||
// replaceSelectedEditorText — that helper requires a selection and no-ops on caret.
|
||||
const selection = targetView.state.selection.main;
|
||||
const from = selection.from;
|
||||
const to = selection.empty ? from : selection.to;
|
||||
targetView.dispatch({
|
||||
changes: { from, to, insert: insertText },
|
||||
selection: { anchor: from + insertText.length },
|
||||
scrollIntoView: true,
|
||||
userEvent: "input.type",
|
||||
});
|
||||
focusEditorView(targetView);
|
||||
rememberFocusedQueryEditorView(targetView);
|
||||
}
|
||||
|
||||
// ==================== Intention Popup ====================
|
||||
|
||||
interface IntentionPopupState {
|
||||
@@ -1573,6 +1722,7 @@ function syncContextMenuStateAtEvent(currentView: EditorViewType, event: MouseEv
|
||||
|
||||
function focusEditor() {
|
||||
view.value?.focus();
|
||||
rememberFocusedQueryEditorView(view.value);
|
||||
}
|
||||
|
||||
function clearTableNavigationHover() {
|
||||
@@ -2140,6 +2290,11 @@ function emitContextObjectAction(action: QueryContextObjectAction) {
|
||||
case "viewTableData":
|
||||
emit("viewTableData", route.payload[0]);
|
||||
break;
|
||||
case "peekTableStructure":
|
||||
// Non-modal floating panel: restore editor focus so typing continues underneath.
|
||||
void openTableStructurePeek(route.payload[0]);
|
||||
focusEditor();
|
||||
return;
|
||||
case "editTableStructure":
|
||||
emit("editTableStructure", route.payload[0]);
|
||||
break;
|
||||
@@ -2163,6 +2318,13 @@ function contextObjectMenuItem(action: QueryContextObjectAction): ContextMenuIte
|
||||
disabled,
|
||||
icon: Table2,
|
||||
};
|
||||
case "peek-table-structure":
|
||||
return {
|
||||
label: t("contextMenu.peekStructure"),
|
||||
action: () => emitContextObjectAction(action),
|
||||
disabled,
|
||||
icon: Columns3,
|
||||
};
|
||||
case "edit-table-structure":
|
||||
return {
|
||||
label: t("contextMenu.editStructure"),
|
||||
@@ -7164,6 +7326,9 @@ onMounted(async () => {
|
||||
eventFilter: startsQueryEditorRectangularSelection,
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.focusChanged && update.view.hasFocus) {
|
||||
rememberFocusedQueryEditorView(update.view);
|
||||
}
|
||||
if (update.docChanged) {
|
||||
searchPanelRef.value?.scheduleDocumentSearchUpdate();
|
||||
if (isEditorComposing(update.view)) {
|
||||
@@ -7582,6 +7747,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
view.value = new EditorView({ state, parent: editorElement });
|
||||
syncQueryEditorInsertContext(view.value);
|
||||
batchColumnSelectionTooltipParents.set(view.value, tooltipParent);
|
||||
postCompositionKeyGuardCleanup = postCompositionKeyGuard.attach(view.value.contentDOM);
|
||||
registerEditorScrollbarPointerGuard(view.value);
|
||||
@@ -7793,6 +7959,11 @@ watch(
|
||||
// connection/database without remounting it.
|
||||
watch([() => props.connectionId, () => props.database, () => props.catalog, () => props.clientSessionId], () => warmActiveTabConnection());
|
||||
|
||||
watch(
|
||||
() => [props.connectionId, props.database, props.schema, props.databaseType] as const,
|
||||
() => syncQueryEditorInsertContext(),
|
||||
);
|
||||
|
||||
// Restored tabs mount before their connection is established, so the warm-up
|
||||
// above is skipped and never retried when connecting finishes later.
|
||||
watch(
|
||||
@@ -8152,7 +8323,11 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", onBatchColumnSelectionRowGuard, true);
|
||||
document.removeEventListener("click", onBatchColumnSelectionRowGuard, true);
|
||||
zoomCommitScheduler.dispose();
|
||||
view.value?.destroy();
|
||||
if (view.value) {
|
||||
unregisterQueryEditorInsertContext(view.value);
|
||||
clearRememberedFocusedQueryEditorView(view.value);
|
||||
view.value.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function readEditorViewport(currentView: EditorViewType) {
|
||||
@@ -8378,6 +8553,20 @@ defineExpose({
|
||||
<EditorGotoLinePanel ref="gotoLinePanelRef" :view="view" @open="searchPanelRef?.closeSearch()" />
|
||||
<SqlExecutionTargetPicker v-if="pickerVisible" :candidates="pickerCandidates" :active-index="pickerActiveIndex" :anchor="pickerAnchor" @update:active-index="onPickerActiveIndexChange" @confirm="onPickerConfirm" @cancel="closePicker" />
|
||||
<DelimitedListDialog v-model:open="delimitedListOpen" :selected-text="delimitedListSelectedText" @confirm="applyDelimitedListResult" />
|
||||
<TableStructurePeekDialog
|
||||
v-for="panel in structurePeekPanels"
|
||||
:key="panel.id"
|
||||
:table-name="panel.target.name"
|
||||
:schema="panel.target.schema"
|
||||
:columns="panel.columns"
|
||||
:loading="panel.loading"
|
||||
:error="panel.error"
|
||||
:z-index="panel.zIndex"
|
||||
:cascade-index="panel.cascadeIndex"
|
||||
@close="closeStructurePeekPanel(panel.id)"
|
||||
@activate="bringStructurePeekToFront(panel.id)"
|
||||
@insert-value="(value, kind) => insertStructurePeekValue(panel, value, kind)"
|
||||
/>
|
||||
<CodeSnapshotDialog v-model:open="codeSnapshotOpen" :source="codeSnapshotSource" />
|
||||
<!-- SQL 意图操作弹出菜单(参考 DataGrip Alt+Enter) -->
|
||||
<Teleport to="body">
|
||||
|
||||
Reference in new issue
Block a user