mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(editor): add table structure peek and dblclick insert
This commit is contained in:
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "インデックスを編集",
|
||||
|
||||
@@ -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: "인덱스 편집",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "编辑索引",
|
||||
|
||||
@@ -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
@@ -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] });
|
||||
|
||||
Reference in New Issue
Block a user