feat(editor): add table structure peek and dblclick insert

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-23 14:43:24 +08:00
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">
@@ -0,0 +1,431 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GripVertical, KeyRound, Loader2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatMetadataColumnTypeLabel } from "@/lib/dataGrid/dataGridColumnType";
import { STRUCTURE_PEEK_MIN_HEIGHT, STRUCTURE_PEEK_MIN_WIDTH, centeredStructurePeekRect, clampStructurePeekRect, defaultStructurePeekSize, resizeStructurePeekRect, type StructurePeekResizeEdge } from "@/lib/editor/structurePeekPanel";
import { shouldPreserveEditorFocusOnPeekPointerDown } from "@/lib/editor/focusedQueryEditorView";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
import type { ColumnInfo } from "@/types/database";
export type TableStructurePeekInsertKind = "identifier" | "raw";
defineOptions({ name: "TableStructurePeekDialog" });
const props = defineProps<{
tableName: string;
schema?: string;
columns: ColumnInfo[];
loading: boolean;
error?: string;
zIndex?: number;
/** 0-based index among open panels; used only for initial cascade placement. */
cascadeIndex?: number;
}>();
const emit = defineEmits<{
insertValue: [value: string, kind: TableStructurePeekInsertKind];
close: [];
activate: [];
}>();
const { t } = useI18n();
const searchQuery = ref("");
const panelLeft = ref(0);
const panelTop = ref(0);
const panelWidth = ref(STRUCTURE_PEEK_MIN_WIDTH);
const panelHeight = ref(STRUCTURE_PEEK_MIN_HEIGHT);
const interacting = ref(false);
type InteractionMode = "move" | "resize";
let interactionMode: InteractionMode | null = null;
let resizeEdge: StructurePeekResizeEdge | null = null;
let dragPointerId: number | null = null;
let startClientX = 0;
let startClientY = 0;
let originLeft = 0;
let originTop = 0;
let originWidth = 0;
let originHeight = 0;
const title = computed(() => {
const parts = [props.schema, props.tableName].filter((part) => typeof part === "string" && part.length > 0);
return parts.length > 0 ? parts.join(".") : props.tableName;
});
const filteredColumns = computed(() => filterObjectBrowserTableColumns(props.columns, searchQuery.value));
const panelStyle = computed(() => {
const style: Record<string, string> = {
left: `${panelLeft.value}px`,
top: `${panelTop.value}px`,
width: `${panelWidth.value}px`,
height: `${panelHeight.value}px`,
zIndex: String(props.zIndex ?? 50),
};
if (interacting.value) style.transition = "none";
return style;
});
const resizeEdges: StructurePeekResizeEdge[] = ["n", "s", "e", "w", "ne", "nw", "se", "sw"];
function viewportSize() {
return { width: window.innerWidth, height: window.innerHeight };
}
function applyRect(rect: { left: number; top: number; width: number; height: number }) {
panelLeft.value = rect.left;
panelTop.value = rect.top;
panelWidth.value = rect.width;
panelHeight.value = rect.height;
}
function initializeGeometry() {
const { width: vw, height: vh } = viewportSize();
const size = defaultStructurePeekSize(vw, vh);
applyRect(centeredStructurePeekRect(size.width, size.height, vw, vh, props.cascadeIndex ?? 0));
}
function clampToViewport() {
const { width: vw, height: vh } = viewportSize();
applyRect(clampStructurePeekRect({ left: panelLeft.value, top: panelTop.value, width: panelWidth.value, height: panelHeight.value }, vw, vh));
}
function formatColumnType(column: ColumnInfo): string {
return formatMetadataColumnTypeLabel({
dataType: column.data_type,
characterMaximumLength: column.character_maximum_length,
numericPrecision: column.numeric_precision,
numericScale: column.numeric_scale,
});
}
function insertableValue(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed || null;
}
function insertValue(value: string | null | undefined, kind: TableStructurePeekInsertKind) {
const trimmed = insertableValue(value);
if (!trimmed) return;
emit("insertValue", trimmed, kind);
}
function onCellDblClick(value: string | null | undefined, kind: TableStructurePeekInsertKind) {
insertValue(value, kind);
}
function closePanel() {
emit("close");
}
function detachInteractionListeners() {
window.removeEventListener("pointermove", onInteractionMove, true);
window.removeEventListener("pointerup", onInteractionEnd, true);
window.removeEventListener("pointercancel", onInteractionEnd, true);
}
function endInteraction() {
interacting.value = false;
interactionMode = null;
resizeEdge = null;
dragPointerId = null;
detachInteractionListeners();
}
function onInteractionMove(event: PointerEvent) {
if (!interactionMode) return;
if (dragPointerId != null && event.pointerId !== dragPointerId) return;
event.preventDefault();
const { width: vw, height: vh } = viewportSize();
const deltaX = event.clientX - startClientX;
const deltaY = event.clientY - startClientY;
if (interactionMode === "move") {
applyRect(
clampStructurePeekRect(
{
left: originLeft + deltaX,
top: originTop + deltaY,
width: originWidth,
height: originHeight,
},
vw,
vh,
),
);
return;
}
if (interactionMode === "resize" && resizeEdge) {
applyRect(resizeStructurePeekRect({ left: originLeft, top: originTop, width: originWidth, height: originHeight }, resizeEdge, deltaX, deltaY, vw, vh));
}
}
function onInteractionEnd(event: PointerEvent) {
// Always tear down an active interaction. A mismatched pointerId (or a lost
// pointerup after HMR) must not leave capture-phase preventDefault listeners
// attached to window — that freezes clicks / context menus app-wide.
if (!interactionMode && dragPointerId == null) return;
if (dragPointerId != null && event.pointerId !== dragPointerId) {
endInteraction();
clampToViewport();
return;
}
event.preventDefault();
endInteraction();
clampToViewport();
}
function beginInteraction(event: PointerEvent, mode: InteractionMode, edge: StructurePeekResizeEdge | null = null) {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
emit("activate");
// Drop any previous interaction before arming a new one (HMR / missed pointerup).
endInteraction();
interacting.value = true;
interactionMode = mode;
resizeEdge = edge;
dragPointerId = event.pointerId;
startClientX = event.clientX;
startClientY = event.clientY;
originLeft = panelLeft.value;
originTop = panelTop.value;
originWidth = panelWidth.value;
originHeight = panelHeight.value;
window.addEventListener("pointermove", onInteractionMove, true);
window.addEventListener("pointerup", onInteractionEnd, true);
window.addEventListener("pointercancel", onInteractionEnd, true);
try {
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
} catch {
// ignore
}
}
function startDrag(event: PointerEvent) {
const target = event.target;
if (target instanceof Element && target.closest("[data-no-drag]")) return;
beginInteraction(event, "move");
}
function startResize(event: PointerEvent, edge: StructurePeekResizeEdge) {
beginInteraction(event, "resize", edge);
}
function onPanelActivate(event?: PointerEvent) {
emit("activate");
// Keep the focused editor's caret so dblclick can insert across editors.
if (event && shouldPreserveEditorFocusOnPeekPointerDown(event.target)) {
event.preventDefault();
}
}
function onWindowResize() {
clampToViewport();
}
onMounted(() => {
initializeGeometry();
window.addEventListener("resize", onWindowResize);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onWindowResize);
endInteraction();
});
watch(
() => props.tableName,
() => {
searchQuery.value = "";
},
);
</script>
<template>
<Teleport to="body">
<div
data-structure-peek-panel
role="dialog"
aria-modal="false"
:aria-label="t('editor.structurePeek.title', { name: title })"
class="dbx-structure-peek-panel fixed flex flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl ring-1 ring-border/60"
:class="{ 'is-dragging': interacting }"
:style="panelStyle"
@pointerdown="onPanelActivate"
>
<div data-structure-peek-drag-chrome class="flex shrink-0 cursor-grab items-center gap-2 border-b border-border px-3 py-2 active:cursor-grabbing" @pointerdown="startDrag">
<button
type="button"
data-drag-handle
class="dbx-structure-peek-drag-handle inline-flex h-7 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
:title="t('editor.structurePeek.drag')"
:aria-label="t('editor.structurePeek.drag')"
@pointerdown="startDrag"
>
<GripVertical class="pointer-events-none h-4 w-4" />
</button>
<div class="min-w-0 flex-1 select-none">
<div class="truncate text-sm font-medium">{{ t("editor.structurePeek.title", { name: title }) }}</div>
<div class="truncate text-xs text-muted-foreground">{{ t("editor.structurePeek.hint") }}</div>
</div>
<Button type="button" variant="ghost" size="sm" data-no-drag class="h-7 w-7 shrink-0 p-0" :title="t('common.close')" :aria-label="t('common.close')" @pointerdown.stop @click="closePanel">
<X class="h-4 w-4" />
</Button>
</div>
<div data-no-drag class="shrink-0 border-b border-border px-3 py-2" @pointerdown.stop="onPanelActivate">
<Input v-model="searchQuery" class="h-8 text-xs" :placeholder="t('structureEditor.searchColumns')" :disabled="props.loading" />
</div>
<div data-no-drag class="min-h-0 flex-1 overflow-auto" @pointerdown.stop="onPanelActivate">
<div v-if="props.loading" class="flex h-40 items-center justify-center">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="props.error" class="p-4 text-xs text-destructive">{{ props.error }}</div>
<div v-else-if="filteredColumns.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ searchQuery.trim() ? t("grid.tableInfoNoResults") : t("editor.structurePeek.empty") }}
</div>
<table v-else class="w-full text-xs">
<thead class="sticky top-0 z-10 bg-muted text-muted-foreground">
<tr class="border-b">
<th class="w-8 px-3 py-2 text-left font-medium text-nowrap">#</th>
<th class="px-3 py-2 text-left font-medium text-nowrap">{{ t("grid.columnName") }}</th>
<th class="px-3 py-2 text-left font-medium text-nowrap">{{ t("grid.columnType") }}</th>
<th class="px-3 py-2 text-left font-medium text-nowrap">{{ t("grid.tableInfoNullable") }}</th>
<th class="px-3 py-2 text-left font-medium text-nowrap">{{ t("structureEditor.defaultValue") }}</th>
<th class="px-3 py-2 text-left font-medium text-nowrap">{{ t("structureEditor.comment") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(column, index) in filteredColumns" :key="column.name" class="border-b hover:bg-muted/40">
<td class="w-8 px-3 py-2 text-muted-foreground">{{ index + 1 }}</td>
<td class="cursor-pointer px-3 py-2 font-medium" :title="t('editor.structurePeek.doubleClickInsert')" @dblclick.stop="onCellDblClick(column.name, 'identifier')">
<span class="inline-flex items-center gap-1.5">
<KeyRound v-if="column.is_primary_key" class="h-3 w-3 text-amber-500" />
{{ column.name }}
</span>
</td>
<td class="cursor-pointer px-3 py-2 font-mono text-[11px] text-muted-foreground" :title="t('editor.structurePeek.doubleClickInsert')" @dblclick.stop="onCellDblClick(formatColumnType(column), 'raw')">
{{ formatColumnType(column) }}
</td>
<td class="cursor-pointer px-3 py-2" :title="t('editor.structurePeek.doubleClickInsert')" @dblclick.stop="onCellDblClick(column.is_nullable ? 'YES' : 'NO', 'raw')">
{{ column.is_nullable ? "YES" : "NO" }}
</td>
<td
class="max-w-48 px-3 py-2 font-mono text-[11px]"
:class="insertableValue(column.column_default) ? 'cursor-pointer' : 'text-muted-foreground/70'"
:title="insertableValue(column.column_default) ? t('editor.structurePeek.doubleClickInsert') : undefined"
@dblclick.stop="onCellDblClick(column.column_default, 'raw')"
>
<span class="block truncate">{{ tableColumnDefaultDisplayValue(column.column_default) }}</span>
</td>
<td class="max-w-56 px-3 py-2 text-muted-foreground" :class="{ 'cursor-pointer': !!insertableValue(column.comment) }" :title="insertableValue(column.comment) ? t('editor.structurePeek.doubleClickInsert') : undefined" @dblclick.stop="onCellDblClick(column.comment, 'raw')">
<span class="block truncate">{{ column.comment || "—" }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-for="edge in resizeEdges" :key="edge" data-no-drag class="dbx-structure-peek-resize" :class="`dbx-structure-peek-resize--${edge}`" @pointerdown.stop="startResize($event, edge)" />
</div>
</Teleport>
</template>
<style scoped>
.dbx-structure-peek-panel.is-dragging {
user-select: none;
}
.dbx-structure-peek-drag-handle {
cursor: grab;
touch-action: none;
user-select: none;
}
.dbx-structure-peek-panel.is-dragging .dbx-structure-peek-drag-handle {
cursor: grabbing;
}
.dbx-structure-peek-resize {
position: absolute;
z-index: 20;
touch-action: none;
}
.dbx-structure-peek-resize--n,
.dbx-structure-peek-resize--s {
left: 6px;
right: 6px;
height: 6px;
cursor: ns-resize;
}
.dbx-structure-peek-resize--e,
.dbx-structure-peek-resize--w {
top: 6px;
bottom: 6px;
width: 6px;
cursor: ew-resize;
}
.dbx-structure-peek-resize--n {
top: -3px;
}
.dbx-structure-peek-resize--s {
bottom: -3px;
}
.dbx-structure-peek-resize--e {
right: -3px;
}
.dbx-structure-peek-resize--w {
left: -3px;
}
.dbx-structure-peek-resize--ne,
.dbx-structure-peek-resize--nw,
.dbx-structure-peek-resize--se,
.dbx-structure-peek-resize--sw {
width: 10px;
height: 10px;
}
.dbx-structure-peek-resize--ne {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
.dbx-structure-peek-resize--nw {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
.dbx-structure-peek-resize--se {
bottom: -4px;
right: -4px;
cursor: nwse-resize;
}
.dbx-structure-peek-resize--sw {
bottom: -4px;
left: -4px;
cursor: nesw-resize;
}
</style>