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

This commit is contained in:
Mo-Moore
2026-09-23 14:43:24 +08:00
committed by GitHub
parent da95bd435f
commit 12ed7a49b3
19 changed files with 1021 additions and 8 deletions
@@ -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>
+8
View File
@@ -1526,6 +1526,13 @@ export default withEnglishFallback({
confirm: "Təsdiqlə",
selectFirst: "Siyahı mətnini seçin",
},
structurePeek: {
title: "Struktur: {name}",
hint: "Dəyəri redaktora daxil etmək üçün xanaya iki dəfə klikləyin",
doubleClickInsert: "Daxil etmək üçün iki dəfə klikləyin",
drag: "Yerini dəyişmək üçün sürükləyin",
empty: "Sütun yoxdur",
},
gotoLine: {
placeholder: "Sətir nömrəsi",
total: "{total} sətir",
@@ -3342,6 +3349,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "Verilənlər bazalarına bax",
viewData: "Məlumatlara bax",
openInNewDataTab: "Yeni vərəqdə bax",
peekStructure: "Struktura bax",
editStructure: "Strukturu redaktə et",
editColumn: "Sütunu redaktə et",
editIndex: "İndeksi redaktə et",
+8
View File
@@ -1741,6 +1741,13 @@ export default {
confirm: "Confirm",
selectFirst: "Please select list text",
},
structurePeek: {
title: "Structure: {name}",
hint: "Double-click a cell to insert its value into the editor",
doubleClickInsert: "Double-click to insert",
drag: "Drag to move",
empty: "No columns",
},
gotoLine: {
placeholder: "Line number",
total: "{total} lines",
@@ -3576,6 +3583,7 @@ export default {
openDatabaseBrowser: "Browse Databases",
viewData: "View Data",
openInNewDataTab: "View in New Tab",
peekStructure: "View Structure",
editStructure: "Edit Structure",
editColumn: "Edit Column",
editIndex: "Edit Index",
+8
View File
@@ -1740,6 +1740,13 @@ export default withEnglishFallback({
confirm: "Confirmar",
selectFirst: "Seleccione el texto de la lista",
},
structurePeek: {
title: "Estructura: {name}",
hint: "Haz doble clic en una celda para insertar su valor en el editor",
doubleClickInsert: "Doble clic para insertar",
drag: "Arrastrar para mover",
empty: "Sin columnas",
},
connectionOnlyTarget: "Objetivo a nivel de conexión",
previewChanges: "Previsualizar cambios",
previewChangesComment: "Previsualización de cambios (simulación): sentencia {operation}",
@@ -3470,6 +3477,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "Explorar bases de datos",
viewData: "Ver datos",
openInNewDataTab: "Ver en nueva pestaña",
peekStructure: "Ver estructura",
editStructure: "Editar estructura",
editColumn: "Editar columna",
editIndex: "Editar índice",
+8
View File
@@ -1737,6 +1737,13 @@ export default withEnglishFallback({
confirm: "Conferma",
selectFirst: "Seleziona il testo della lista",
},
structurePeek: {
title: "Struttura: {name}",
hint: "Fai doppio clic su una cella per inserirne il valore nell'editor",
doubleClickInsert: "Doppio clic per inserire",
drag: "Trascina per spostare",
empty: "Nessuna colonna",
},
connectionOnlyTarget: "Target a livello di connessione",
previewChanges: "Anteprima modifiche",
previewChangesComment: "Anteprima modifiche (prova a secco): istruzione {operation}",
@@ -3467,6 +3474,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "Esplora database",
viewData: "Visualizza Dati",
openInNewDataTab: "Visualizza in nuova scheda",
peekStructure: "Visualizza struttura",
editStructure: "Modifica Struttura",
editColumn: "Modifica Colonna",
editIndex: "Modifica Indice",
+8
View File
@@ -1758,6 +1758,13 @@ export default withEnglishFallback({
confirm: "確定",
selectFirst: "リストテキストを選択してください",
},
structurePeek: {
title: "構造: {name}",
hint: "セルをダブルクリックするとエディターに挿入されます",
doubleClickInsert: "ダブルクリックで挿入",
drag: "ドラッグして移動",
empty: "列がありません",
},
connectionOnlyTarget: "接続レベル目標",
previewChanges: "変更をプレビュー",
previewChangesComment: "変更プレビュー(ドライラン):{operation} ステートメント",
@@ -3490,6 +3497,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "データベースを参照",
viewData: "データを表示",
openInNewDataTab: "新しいタブで表示",
peekStructure: "構造を表示",
editStructure: "構造を編集",
editColumn: "列を編集",
editIndex: "インデックスを編集",
+8
View File
@@ -1622,6 +1622,13 @@ export default withEnglishFallback({
confirm: "확인",
selectFirst: "목록 텍스트를 먼저 선택하세요",
},
structurePeek: {
title: "구조: {name}",
hint: "셀을 더블클릭하면 에디터에 삽입됩니다",
doubleClickInsert: "더블클릭하여 삽입",
drag: "드래그하여 이동",
empty: "열 없음",
},
gotoLine: {
placeholder: "줄 번호",
total: "총 {total}행",
@@ -3369,6 +3376,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "데이터베이스 탐색",
viewData: "데이터 보기",
openInNewDataTab: "새 탭에서 보기",
peekStructure: "구조 보기",
editStructure: "구조 편집",
editColumn: "컬럼 편집",
editIndex: "인덱스 편집",
+8
View File
@@ -1738,6 +1738,13 @@ export default withEnglishFallback({
confirm: "Confirmar",
selectFirst: "Selecione o texto da lista primeiro",
},
structurePeek: {
title: "Estrutura: {name}",
hint: "Clique duas vezes em uma célula para inserir o valor no editor",
doubleClickInsert: "Clique duas vezes para inserir",
drag: "Arraste para mover",
empty: "Sem colunas",
},
connectionOnlyTarget: "Alvo em nível de conexão",
previewChanges: "Pré-visualizar alterações",
previewChangesComment: "Pré-visualização de alterações (simulação): instrução {operation}",
@@ -3469,6 +3476,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "Explorar bancos de dados",
viewData: "Ver Dados",
openInNewDataTab: "Ver em nova aba",
peekStructure: "Ver estrutura",
editStructure: "Editar Estrutura",
editColumn: "Editar Coluna",
editIndex: "Editar Índice",
+8
View File
@@ -1527,6 +1527,13 @@ export default withEnglishFallback({
confirm: "Onayla",
selectFirst: "Lütfen liste metnini seçin",
},
structurePeek: {
title: "Yapı: {name}",
hint: "Bir hücreye çift tıklayarak değerini düzenleyiciye ekleyin",
doubleClickInsert: "Eklemek için çift tıklayın",
drag: "Taşımak için sürükleyin",
empty: "Sütun yok",
},
gotoLine: {
placeholder: "Satır numarası",
total: "{total} satır",
@@ -3315,6 +3322,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "Veritabanlarına Gözat",
viewData: "Verileri Görüntüle",
openInNewDataTab: "Yeni Sekmede Görüntüle",
peekStructure: "Yapıyı Görüntüle",
editStructure: "Yapıyı Düzenle",
editColumn: "Sütunu Düzenle",
editIndex: "Dizini Düzenle",
+8
View File
@@ -1655,6 +1655,13 @@ export default withEnglishFallback({
confirm: "确定",
selectFirst: "请选择列表文本",
},
structurePeek: {
title: "表结构:{name}",
hint: "双击单元格可将内容插入到编辑器光标处",
doubleClickInsert: "双击插入到编辑器",
drag: "拖动面板",
empty: "暂无字段",
},
gotoLine: {
placeholder: "行号",
total: "共 {total} 行",
@@ -3488,6 +3495,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "查看资源对象",
viewData: "查看数据",
openInNewDataTab: "新标签页查看",
peekStructure: "查看表结构",
editStructure: "编辑表结构",
editColumn: "编辑字段",
editIndex: "编辑索引",
+8
View File
@@ -1739,6 +1739,13 @@ export default withEnglishFallback({
confirm: "確定",
selectFirst: "請選擇清單文字",
},
structurePeek: {
title: "資料表結構:{name}",
hint: "連按兩下儲存格可將內容插入到編輯器游標處",
doubleClickInsert: "連按兩下插入到編輯器",
drag: "拖曳面板",
empty: "暫無欄位",
},
connectionOnlyTarget: "連線級目標",
previewChanges: "預覽變更",
previewChangesComment: "變更預覽(乾跑):{operation} 語句",
@@ -3466,6 +3473,7 @@ export default withEnglishFallback({
openDatabaseBrowser: "檢視資源物件",
viewData: "檢視資料",
openInNewDataTab: "新分頁檢視",
peekStructure: "檢視資料表結構",
editStructure: "編輯資料表結構",
editColumn: "編輯欄位",
editIndex: "編輯索引",
@@ -0,0 +1,104 @@
/**
* @vitest-environment happy-dom
*/
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it } from "vitest";
import {
STRUCTURE_PEEK_PANEL_SELECTOR,
clearRememberedFocusedQueryEditorView,
focusedQueryEditorView,
queryEditorInsertContext,
registerQueryEditorInsertContext,
rememberFocusedQueryEditorView,
shouldPreserveEditorFocusOnPeekPointerDown,
unregisterQueryEditorInsertContext,
} from "@/lib/editor/focusedQueryEditorView";
function mountEditor(): { root: HTMLElement; view: EditorView } {
const root = document.createElement("div");
root.setAttribute("data-query-editor-root", "");
document.body.appendChild(root);
const view = new EditorView({
state: EditorState.create({ doc: "select 1" }),
parent: root,
});
return { root, view };
}
describe("shouldPreserveEditorFocusOnPeekPointerDown", () => {
it("preserves focus for table / chrome clicks", () => {
const td = document.createElement("td");
expect(shouldPreserveEditorFocusOnPeekPointerDown(td)).toBe(true);
});
it("allows native focus for editable controls", () => {
const input = document.createElement("input");
expect(shouldPreserveEditorFocusOnPeekPointerDown(input)).toBe(false);
const button = document.createElement("button");
expect(shouldPreserveEditorFocusOnPeekPointerDown(button)).toBe(false);
});
});
describe("focusedQueryEditorView", () => {
afterEach(() => {
clearRememberedFocusedQueryEditorView();
document.body.replaceChildren();
});
it("returns the focused query editor under activeElement", () => {
const { root, view } = mountEditor();
view.focus();
expect(focusedQueryEditorView(view.contentDOM)).toBe(view);
expect(focusedQueryEditorView(root)).toBe(view);
});
it("falls back to the last focused editor when activeElement is inside a peek panel", () => {
const { view } = mountEditor();
rememberFocusedQueryEditorView(view);
const peek = document.createElement("div");
peek.setAttribute("data-structure-peek-panel", "");
const input = document.createElement("input");
peek.appendChild(input);
document.body.appendChild(peek);
expect(input.closest(STRUCTURE_PEEK_PANEL_SELECTOR)).toBe(peek);
expect(focusedQueryEditorView(input)).toBe(view);
});
it("returns null when focus is outside editors and peek panels", () => {
const { view } = mountEditor();
rememberFocusedQueryEditorView(view);
const sidebar = document.createElement("button");
document.body.appendChild(sidebar);
expect(focusedQueryEditorView(sidebar)).toBeNull();
});
it("ignores a remembered editor that was disconnected", () => {
const { root, view } = mountEditor();
rememberFocusedQueryEditorView(view);
root.remove();
const peek = document.createElement("div");
peek.setAttribute("data-structure-peek-panel", "");
document.body.appendChild(peek);
expect(focusedQueryEditorView(peek)).toBeNull();
});
});
describe("queryEditorInsertContext", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("stores and clears per-view insert context", () => {
const { view } = mountEditor();
registerQueryEditorInsertContext(view, { connectionId: "c1", database: "db", databaseType: "postgres" });
expect(queryEditorInsertContext(view)).toEqual({ connectionId: "c1", database: "db", databaseType: "postgres" });
unregisterQueryEditorInsertContext(view);
expect(queryEditorInsertContext(view)).toBeUndefined();
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { STRUCTURE_PEEK_MIN_HEIGHT, STRUCTURE_PEEK_MIN_WIDTH, clampStructurePeekRect, resizeStructurePeekRect, structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
describe("structurePeekPanel", () => {
it("builds a stable case-insensitive identity including catalog", () => {
expect(structurePeekPanelId("Conn", "Db", "Public", "Orders", "Cat")).toBe(structurePeekPanelId("conn", "db", "public", "orders", "cat"));
expect(structurePeekPanelId("c", "db", "a", "t")).not.toBe(structurePeekPanelId("c", "db", "b", "t"));
expect(structurePeekPanelId("c", "db", "s", "t", "cat-a")).not.toBe(structurePeekPanelId("c", "db", "s", "t", "cat-b"));
});
it("clamps rects inside the viewport with a margin", () => {
expect(clampStructurePeekRect({ left: -40, top: -20, width: 500, height: 400 }, 800, 600)).toEqual({
left: 8,
top: 8,
width: 500,
height: 400,
});
expect(clampStructurePeekRect({ left: 700, top: 500, width: 500, height: 400 }, 800, 600)).toEqual({
left: 800 - 8 - 500,
top: 600 - 8 - 400,
width: 500,
height: 400,
});
});
it("enforces minimum size while resizing from the west/north edges", () => {
const origin = { left: 100, top: 100, width: 400, height: 300 };
const shrunkWest = resizeStructurePeekRect(origin, "w", 200, 0, 1200, 800);
expect(shrunkWest.width).toBe(STRUCTURE_PEEK_MIN_WIDTH);
expect(shrunkWest.left).toBe(100 + 400 - STRUCTURE_PEEK_MIN_WIDTH);
const shrunkNorth = resizeStructurePeekRect(origin, "n", 0, 200, 1200, 800);
expect(shrunkNorth.height).toBe(STRUCTURE_PEEK_MIN_HEIGHT);
expect(shrunkNorth.top).toBe(100 + 300 - STRUCTURE_PEEK_MIN_HEIGHT);
});
it("resizes northeast corners without treating n as containing e", () => {
const origin = { left: 100, top: 100, width: 400, height: 300 };
const grown = resizeStructurePeekRect(origin, "ne", 50, -40, 1200, 800);
expect(grown.width).toBe(450);
expect(grown.height).toBe(340);
expect(grown.left).toBe(100);
expect(grown.top).toBe(60);
});
});
@@ -0,0 +1,71 @@
import { EditorView } from "@codemirror/view";
import type { DatabaseType } from "@/types/database";
export const QUERY_EDITOR_ROOT_SELECTOR = "[data-query-editor-root]";
export const STRUCTURE_PEEK_PANEL_SELECTOR = "[data-structure-peek-panel]";
/** Dialect / connection metadata for inserting into a specific query editor view. */
export interface QueryEditorInsertContext {
connectionId?: string;
database?: string;
schema?: string;
databaseType?: DatabaseType;
}
let lastFocusedQueryEditorView: EditorView | null = null;
const insertContextByView = new WeakMap<EditorView, QueryEditorInsertContext>();
export function rememberFocusedQueryEditorView(view: EditorView | null | undefined): void {
if (view) lastFocusedQueryEditorView = view;
}
/** Clear the remembered view when it is destroyed (or clear unconditionally). */
export function clearRememberedFocusedQueryEditorView(view?: EditorView | null): void {
if (view == null || lastFocusedQueryEditorView === view) {
lastFocusedQueryEditorView = null;
}
}
export function registerQueryEditorInsertContext(view: EditorView, context: QueryEditorInsertContext): void {
insertContextByView.set(view, context);
}
export function unregisterQueryEditorInsertContext(view: EditorView): void {
insertContextByView.delete(view);
}
export function queryEditorInsertContext(view: EditorView): QueryEditorInsertContext | undefined {
return insertContextByView.get(view);
}
/**
* Resolve the CodeMirror view that should receive peek inserts.
* - Focus inside a query editor → that view (must still report hasFocus).
* - Focus inside a structure peek panel → most recently focused query editor.
* - Focus elsewhere (sidebar, etc.) → null.
*/
export function focusedQueryEditorView(active: Element | null = typeof document !== "undefined" ? document.activeElement : null): EditorView | null {
if (!(active instanceof Element)) return null;
const root = active.closest(QUERY_EDITOR_ROOT_SELECTOR);
if (root instanceof HTMLElement) {
const view = EditorView.findFromDOM(root);
if (!view?.hasFocus) return null;
rememberFocusedQueryEditorView(view);
return view;
}
if (active.closest(STRUCTURE_PEEK_PANEL_SELECTOR)) {
const remembered = lastFocusedQueryEditorView;
if (remembered?.dom.isConnected) return remembered;
return null;
}
return null;
}
/** Keep editor focus when interacting with non-editable peek chrome (palette-style). */
export function shouldPreserveEditorFocusOnPeekPointerDown(target: EventTarget | null): boolean {
if (!(target instanceof Element)) return false;
return !target.closest("input, textarea, button, select, a, [contenteditable='true']");
}
@@ -0,0 +1,88 @@
/** Identity + geometry helpers for in-app table-structure peek panels. */
export function structurePeekPanelId(connectionId: string, database: string, schema: string | undefined, tableName: string, catalog?: string): string {
return [connectionId, catalog ?? "", database, schema ?? "", tableName].map((part) => part.toLocaleLowerCase()).join("\u0000");
}
export const STRUCTURE_PEEK_MARGIN = 8;
export const STRUCTURE_PEEK_MIN_WIDTH = 360;
export const STRUCTURE_PEEK_MIN_HEIGHT = 240;
export const STRUCTURE_PEEK_CASCADE = 24;
export function defaultStructurePeekSize(viewportWidth: number, viewportHeight: number): { width: number; height: number } {
const maxWidth = Math.max(STRUCTURE_PEEK_MIN_WIDTH, viewportWidth - STRUCTURE_PEEK_MARGIN * 2);
const maxHeight = Math.max(STRUCTURE_PEEK_MIN_HEIGHT, viewportHeight - STRUCTURE_PEEK_MARGIN * 2);
// Default large enough to scan columns comfortably without covering most of the editor.
return {
width: Math.min(720, Math.floor(viewportWidth * 0.62), maxWidth),
height: Math.min(520, Math.floor(viewportHeight * 0.62), maxHeight),
};
}
export function centeredStructurePeekRect(width: number, height: number, viewportWidth: number, viewportHeight: number, cascadeIndex = 0): StructurePeekRect {
const stagger = cascadeIndex * STRUCTURE_PEEK_CASCADE;
return clampStructurePeekRect(
{
left: Math.round((viewportWidth - width) / 2) + stagger,
top: Math.round((viewportHeight - height) / 2) + stagger,
width,
height,
},
viewportWidth,
viewportHeight,
);
}
export interface StructurePeekRect {
left: number;
top: number;
width: number;
height: number;
}
/** Keep the panel fully inside the viewport (with margin), respecting min size. */
export function clampStructurePeekRect(rect: StructurePeekRect, viewportWidth: number, viewportHeight: number, margin = STRUCTURE_PEEK_MARGIN, minWidth = STRUCTURE_PEEK_MIN_WIDTH, minHeight = STRUCTURE_PEEK_MIN_HEIGHT): StructurePeekRect {
const maxWidth = Math.max(minWidth, viewportWidth - margin * 2);
const maxHeight = Math.max(minHeight, viewportHeight - margin * 2);
const width = Math.min(Math.max(rect.width, minWidth), maxWidth);
const height = Math.min(Math.max(rect.height, minHeight), maxHeight);
const maxLeft = Math.max(margin, viewportWidth - margin - width);
const maxTop = Math.max(margin, viewportHeight - margin - height);
const left = Math.min(Math.max(rect.left, margin), maxLeft);
const top = Math.min(Math.max(rect.top, margin), maxTop);
return { left, top, width, height };
}
export type StructurePeekResizeEdge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
const RESIZE_EAST: ReadonlySet<StructurePeekResizeEdge> = new Set(["e", "ne", "se"]);
const RESIZE_WEST: ReadonlySet<StructurePeekResizeEdge> = new Set(["w", "nw", "sw"]);
const RESIZE_NORTH: ReadonlySet<StructurePeekResizeEdge> = new Set(["n", "ne", "nw"]);
const RESIZE_SOUTH: ReadonlySet<StructurePeekResizeEdge> = new Set(["s", "se", "sw"]);
export function resizeStructurePeekRect(origin: StructurePeekRect, edge: StructurePeekResizeEdge, deltaX: number, deltaY: number, viewportWidth: number, viewportHeight: number): StructurePeekRect {
let { left, top, width, height } = origin;
if (RESIZE_EAST.has(edge)) width = origin.width + deltaX;
if (RESIZE_SOUTH.has(edge)) height = origin.height + deltaY;
if (RESIZE_WEST.has(edge)) {
width = origin.width - deltaX;
left = origin.left + deltaX;
}
if (RESIZE_NORTH.has(edge)) {
height = origin.height - deltaY;
top = origin.top + deltaY;
}
// When shrinking past min from the west/north edge, pin the opposite side.
if (RESIZE_WEST.has(edge) && width < STRUCTURE_PEEK_MIN_WIDTH) {
left = origin.left + origin.width - STRUCTURE_PEEK_MIN_WIDTH;
width = STRUCTURE_PEEK_MIN_WIDTH;
}
if (RESIZE_NORTH.has(edge) && height < STRUCTURE_PEEK_MIN_HEIGHT) {
top = origin.top + origin.height - STRUCTURE_PEEK_MIN_HEIGHT;
height = STRUCTURE_PEEK_MIN_HEIGHT;
}
return clampStructurePeekRect({ left, top, width, height }, viewportWidth, viewportHeight);
}
@@ -21,10 +21,11 @@ export interface QueryTableCandidateAtPositionInput {
position: number;
}
export type QueryContextObjectAction = "view-data" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
export type QueryContextObjectAction = "view-data" | "peek-table-structure" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
export type QueryContextObjectRoute =
| { event: "viewTableData"; payload: [target: SqlObjectNavigationTarget] }
| { event: "peekTableStructure"; payload: [target: SqlObjectNavigationTarget] }
| { event: "editTableStructure"; payload: [target: SqlObjectNavigationTarget] }
| { event: "openObjectSource"; payload: [target: SqlObjectNavigationTarget, initialEditing: boolean] }
| { event: "viewTableDdl"; payload: [target: SqlObjectNavigationTarget] };
@@ -110,16 +111,18 @@ export function resolveQueryContextObjectTarget(candidate: QueryCursorTableCandi
export function queryContextObjectActions(type?: SqlObjectNavigationType): QueryContextObjectAction[] {
if (type === "view" || type === "materialized_view") {
return ["view-data", "edit-view", "view-source", "view-ddl"];
return ["view-data", "peek-table-structure", "edit-view", "view-source", "view-ddl"];
}
// Unknown metadata preserves the historical table actions instead of disabling existing entry points.
return ["view-data", "edit-table-structure", "view-ddl"];
return ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"];
}
export function queryContextObjectRoute(action: QueryContextObjectAction, target: SqlObjectNavigationTarget): QueryContextObjectRoute {
switch (action) {
case "view-data":
return { event: "viewTableData", payload: [target] };
case "peek-table-structure":
return { event: "peekTableStructure", payload: [target] };
case "edit-table-structure":
return { event: "editTableStructure", payload: [target] };
case "edit-view":
+3 -3
View File
@@ -255,15 +255,15 @@ test("preserves table actions when context-menu metadata is unavailable", () =>
database: "app",
schema: "public",
});
assert.deepEqual(queryContextObjectActions(undefined), ["view-data", "edit-table-structure", "view-ddl"]);
assert.deepEqual(queryContextObjectActions(undefined), ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"]);
});
test("uses source actions for views and materialized views", () => {
const expected = ["view-data", "edit-view", "view-source", "view-ddl"];
const expected = ["view-data", "peek-table-structure", "edit-view", "view-source", "view-ddl"];
assert.deepEqual(queryContextObjectActions("view"), expected);
assert.deepEqual(queryContextObjectActions("materialized_view"), expected);
assert.deepEqual(queryContextObjectActions("table"), ["view-data", "edit-table-structure", "view-ddl"]);
assert.deepEqual(queryContextObjectActions("table"), ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"]);
});
test("falls back to the candidate database and schema when no table is loaded", () => {
@@ -9,6 +9,7 @@ const materializedViewTarget: SqlObjectNavigationTarget = { ...tableTarget, type
test("routes table context actions with the resolved target payload", () => {
assert.deepEqual(queryContextObjectRoute("view-data", tableTarget), { event: "viewTableData", payload: [tableTarget] });
assert.deepEqual(queryContextObjectRoute("peek-table-structure", tableTarget), { event: "peekTableStructure", payload: [tableTarget] });
assert.deepEqual(queryContextObjectRoute("edit-table-structure", tableTarget), { event: "editTableStructure", payload: [tableTarget] });
assert.deepEqual(queryContextObjectRoute("view-ddl", tableTarget), { event: "viewTableDdl", payload: [tableTarget] });
});
@@ -20,6 +21,7 @@ test("routes view source actions with editing intent and type fidelity", () => {
test("preserves materialized view type in every routed payload", () => {
assert.deepEqual(queryContextObjectRoute("view-data", materializedViewTarget), { event: "viewTableData", payload: [materializedViewTarget] });
assert.deepEqual(queryContextObjectRoute("peek-table-structure", materializedViewTarget), { event: "peekTableStructure", payload: [materializedViewTarget] });
assert.deepEqual(queryContextObjectRoute("edit-view", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, true] });
assert.deepEqual(queryContextObjectRoute("view-source", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, false] });
assert.deepEqual(queryContextObjectRoute("view-ddl", materializedViewTarget), { event: "viewTableDdl", payload: [materializedViewTarget] });