3399 lines
149 KiB
Vue
3399 lines
149 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onActivated, onBeforeUnmount, ref, watch, type Component } from "vue";
|
||
import { RecycleScroller } from "vue-virtual-scroller";
|
||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||
import {
|
||
ArrowDown,
|
||
ArrowRightLeft,
|
||
ArrowUp,
|
||
Braces,
|
||
CheckSquare,
|
||
Clipboard,
|
||
Code2,
|
||
Copy,
|
||
CopyPlus,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Download,
|
||
Eraser,
|
||
Eye,
|
||
FileCode,
|
||
GripVertical,
|
||
KeyRound,
|
||
LayoutGrid,
|
||
Link2,
|
||
List,
|
||
ListTree,
|
||
Upload,
|
||
Loader2,
|
||
Network,
|
||
Pencil,
|
||
PencilLine,
|
||
PencilRuler,
|
||
Play,
|
||
Package,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
Scissors,
|
||
Search,
|
||
ScrollText,
|
||
Square,
|
||
Table2,
|
||
TableProperties,
|
||
TerminalSquare,
|
||
Trash2,
|
||
WrapText,
|
||
X,
|
||
} from "@lucide/vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||
import * as api from "@/lib/backend/api";
|
||
import type { ColumnInfo, ConnectionConfig, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
|
||
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
|
||
import { isSchemaAware } from "@/lib/database/databaseCapabilities";
|
||
import { supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
|
||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||
import {
|
||
buildDropObjectSql,
|
||
buildDropTableSql,
|
||
buildDuplicateTableStructureSql,
|
||
buildCopyTableDataSql,
|
||
buildEmptyTableSql,
|
||
buildTruncateTableSql,
|
||
collectDuplicateTableColumnComments,
|
||
supportsDropTableCascade,
|
||
supportsTruncateTableCascade,
|
||
type TableAdminSqlOptions,
|
||
} from "@/lib/database/dbAdminSql";
|
||
import { useToast } from "@/composables/useToast";
|
||
import { buildExecutableObjectSourceStatements, buildRoutineRenameObjectSourceStatements, executeObjectSourceSave, formatObjectSourceSaveError, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
|
||
import { buildRenameObjectSql, supportsObjectRename } from "@/lib/table/objectRenameSql";
|
||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||
import { generateDatabaseExportId } from "@/lib/export/databaseExport";
|
||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/table/tableClipboard";
|
||
import { formatSqlInsert } from "@/lib/export/exportFormats";
|
||
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
|
||
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import { treeNodePinIdentity, type PinnedTreeNodeIdentity } from "@/lib/app/pinnedItems";
|
||
import { useExportTracker, type ExportTask } from "@/composables/useExportTracker";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { useQueryStore } from "@/stores/queryStore";
|
||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
|
||
import { runBatchTableDrop } from "@/lib/table/batchTableDrop";
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { filterSchemaNamesForConnection } from "@/lib/database/visibleDatabases";
|
||
import {
|
||
buildObjectBrowserRows,
|
||
countObjectBrowserRowsByFilter,
|
||
formatObjectBrowserBytes,
|
||
formatObjectBrowserCount,
|
||
formatObjectBrowserTimestamp,
|
||
canonicalizeObjectBrowserPinnedTreeNodeIdentity,
|
||
initialObjectBrowserSortDirection,
|
||
objectBrowserRowLegacyPinnedTreeNodeIds,
|
||
objectBrowserRowMatchesPinnedTreeNode,
|
||
objectBrowserRowPinnedTreeNodeIdentity,
|
||
sortObjectBrowserRows,
|
||
summarizeObjectBrowserSearch,
|
||
type ObjectBrowserFilter,
|
||
type ObjectBrowserRow,
|
||
type ObjectBrowserSortDirection,
|
||
type ObjectBrowserSortKey,
|
||
} from "@/lib/table/objectBrowserRows";
|
||
import { isSourceOnlyObjectBrowserRow, resolveRowClickAction, shouldDeferSingleClick, type ObjectBrowserRowAction } from "@/lib/table/objectBrowserRowAction";
|
||
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
|
||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRows, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
|
||
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
|
||
|
||
type ObjectFilter = ObjectBrowserFilter;
|
||
type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment";
|
||
|
||
const props = defineProps<{
|
||
connection: ConnectionConfig;
|
||
database: string;
|
||
catalog?: string;
|
||
schema?: string;
|
||
viewport?: ObjectBrowserViewport;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
openTable: [target: { tableName: string; schema?: string; tableType?: string; catalog?: string }];
|
||
schemaChange: [schema: string | undefined];
|
||
viewportChange: [viewport: ObjectBrowserViewport];
|
||
}>();
|
||
|
||
const { t } = useI18n();
|
||
const { toast } = useToast();
|
||
const { highlight } = useSqlHighlighter();
|
||
const connectionStore = useConnectionStore();
|
||
const queryStore = useQueryStore();
|
||
const settingsStore = useSettingsStore();
|
||
const refreshTooltip = computed(() => {
|
||
const shortcut = formatShortcut(settingsStore.editorSettings.shortcuts.refreshData);
|
||
return shortcut ? `${t("grid.refresh")} (${shortcut})` : t("grid.refresh");
|
||
});
|
||
|
||
const schemas = ref<string[]>([]);
|
||
const selectedSchema = ref<string | undefined>(props.schema);
|
||
const rows = ref<ObjectBrowserRow[]>([]);
|
||
const rootRef = ref<HTMLElement>();
|
||
const search = ref("");
|
||
const objectFilter = ref<ObjectFilter>("all");
|
||
const userHasSelectedFilter = ref(false);
|
||
const sortKey = ref<ObjectBrowserSortKey>("name");
|
||
const sortDirection = ref<ObjectBrowserSortDirection>("asc");
|
||
const loadingSchemas = ref(false);
|
||
const loadingObjects = ref(false);
|
||
const sourceLoading = ref(false);
|
||
const sourceContent = ref("");
|
||
const sourceError = ref("");
|
||
const sourceRow = ref<ObjectBrowserRow | null>(null);
|
||
const sourceEditing = ref(false);
|
||
const sourceCanEdit = ref(true);
|
||
// --- Right-side panel state ---
|
||
// Unified panel: either "table-info" (for tables) or "source" (for views/procedures/etc.)
|
||
const sidePanelRow = ref<ObjectBrowserRow | null>(null);
|
||
const sidePanelMode = ref<"table-info" | "source">("source");
|
||
// Table info panel state
|
||
const tableInfoTab = ref<TableInfoTab>("ddl");
|
||
const tableColumns = ref<ColumnInfo[]>([]);
|
||
const tableColumnsLoading = ref(false);
|
||
const tableDdlContent = ref("");
|
||
const tableDdlLoading = ref(false);
|
||
const tableIndexes = ref<IndexInfo[]>([]);
|
||
const tableIndexesLoading = ref(false);
|
||
const tableForeignKeys = ref<ForeignKeyInfo[]>([]);
|
||
const tableForeignKeysLoading = ref(false);
|
||
const tableTriggers = ref<TriggerInfo[]>([]);
|
||
const tableTriggersLoading = ref(false);
|
||
const tableInfoSearchQuery = ref("");
|
||
const tableInfoWrap = ref(true);
|
||
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
|
||
const SIDE_PANEL_MIN_WIDTH = 280;
|
||
const SIDE_PANEL_MAX_WIDTH = 900;
|
||
const sidePanelWidth = ref(settingsStore.editorSettings.tableInfoDrawerWidth || 420);
|
||
let sidePanelResizeStartX = 0;
|
||
let sidePanelResizeStartWidth = 0;
|
||
const isResizingSidePanel = ref(false);
|
||
const sidePanelGuard = createSidePanelRequestGuard();
|
||
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
|
||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||
const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||
const sourceEditableText = ref("");
|
||
const sourceDraft = ref("");
|
||
const sourceSaving = ref(false);
|
||
const sourceSaveError = ref("");
|
||
const error = ref("");
|
||
const showDropConfirm = ref(false);
|
||
const dropTarget = ref<ObjectBrowserRow | null>(null);
|
||
const dropPreviewSql = ref("");
|
||
const dropTableCascade = ref(false);
|
||
const batchDropCascade = ref(false);
|
||
const showRenameDialog = ref(false);
|
||
const renameTarget = ref<ObjectBrowserRow | null>(null);
|
||
const renameInput = ref("");
|
||
const renameError = ref("");
|
||
const renamePreviewSqlText = ref("");
|
||
const showTruncateConfirm = ref(false);
|
||
const truncateTarget = ref<ObjectBrowserRow | null>(null);
|
||
const truncatePreviewSql = ref("");
|
||
const truncateTableCascade = ref(false);
|
||
const showEmptyConfirm = ref(false);
|
||
const emptyTarget = ref<ObjectBrowserRow | null>(null);
|
||
const emptyPreviewSql = ref("");
|
||
const showDuplicateDialog = ref(false);
|
||
const duplicateTarget = ref<ObjectBrowserRow | null>(null);
|
||
const duplicateTableName = ref("");
|
||
const showProcedureExecutionConfirm = ref(false);
|
||
const procedureExecutionTarget = ref<ObjectBrowserRow | null>(null);
|
||
const selectedTableIds = ref<Set<string>>(new Set());
|
||
const expandedPartitionParentIds = ref<Set<string>>(new Set());
|
||
const showBatchDropConfirm = ref(false);
|
||
const batchDropExecuting = ref(false);
|
||
const batchDropProgress = ref({ completed: 0, total: 0 });
|
||
const batchDropPreviewSql = ref("");
|
||
const showBatchTruncateConfirm = ref(false);
|
||
const batchTruncatePreviewSql = ref("");
|
||
const batchTruncateCascade = ref(false);
|
||
const showBatchEmptyConfirm = ref(false);
|
||
const batchEmptyPreviewSql = ref("");
|
||
const batchEmptyPlan = ref<BatchTableEmptyPlanItem<ObjectBrowserRow>[]>([]);
|
||
// Paste table dialog state
|
||
const showPasteDialog = ref(false);
|
||
const pasteTableMode = ref<PasteTableMode>("structure-and-data");
|
||
const pasteTableEntries = ref<{ sourceName: string; targetName: string; schema?: string }[]>([]);
|
||
const pasteTableDataCopySupported = computed(() => supportsWholeRowTableDataCopy(effectiveDatabaseType.value));
|
||
const objectColumnWidths = ref<Record<ObjectBrowserColumnKey, number>>({
|
||
select: 34,
|
||
name: 260,
|
||
type: 110,
|
||
estimatedRows: 110,
|
||
totalBytes: 100,
|
||
created_at: 150,
|
||
updated_at: 150,
|
||
comment: 260,
|
||
});
|
||
const objectBrowserRowsLoadGuard = createObjectBrowserRowsLoadGuard();
|
||
let stopColumnResize: (() => void) | null = null;
|
||
let preserveObjectFilterScrollOnce = false;
|
||
|
||
// Export via background tracker
|
||
const { addTask: addExportTask } = useExportTracker();
|
||
|
||
const needsSchema = computed(() => isSchemaAware(props.connection.db_type) && !connectionUsesDatabaseObjectTreeMode(props.connection));
|
||
const canDropTargetCascade = computed(() => dropTarget.value?.type === "TABLE" && supportsDropTableCascade(effectiveDatabaseType.value));
|
||
const canTruncateTargetCascade = computed(() => !!truncateTarget.value && supportsTruncateTableCascade(effectiveDatabaseType.value));
|
||
const objectCounts = computed(() => countObjectBrowserRowsByFilter(rows.value));
|
||
// Count direct search matches once; partition parents rendered only for context must not inflate badges.
|
||
const objectSearchSummary = computed(() => summarizeObjectBrowserSearch(rows.value, search.value));
|
||
const canOpenStructureEditor = computed(() => supportsTableStructureEditing(tableStructureDatabaseType.value));
|
||
const canOpenDiagram = computed(() => !!props.database && supportsSchemaDiagram(effectiveDatabaseType.value));
|
||
const canOpenTableImport = computed(() => !!props.database && supportsTableImport(effectiveDatabaseType.value));
|
||
const supportsTruncateTable = computed(() => supportsTableTruncate(effectiveDatabaseType.value));
|
||
const sourceDialect = computed(() => codeMirrorSqlDialect(effectiveDatabaseType.value));
|
||
const sourceFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(effectiveDatabaseType.value));
|
||
const objectFilters = computed<ObjectFilter[]>(() =>
|
||
(
|
||
[
|
||
["all", objectCounts.value.all],
|
||
["tables", objectCounts.value.tables],
|
||
["views", objectCounts.value.views],
|
||
["materializedViews", objectCounts.value.materializedViews],
|
||
["procedures", objectCounts.value.procedures],
|
||
["functions", objectCounts.value.functions],
|
||
["triggers", objectCounts.value.triggers],
|
||
["sequences", objectCounts.value.sequences],
|
||
["packages", objectCounts.value.packages],
|
||
["types", objectCounts.value.types],
|
||
] as Array<[ObjectFilter, number]>
|
||
)
|
||
.filter(([filter, count]) => filter === "all" || count > 0)
|
||
.map(([filter]) => filter),
|
||
);
|
||
const showObjectFilter = computed(() => objectFilters.value.length > 2);
|
||
const hasCreatedAt = computed(() => rows.value.some((row) => row.created_at?.trim()));
|
||
const hasUpdatedAt = computed(() => rows.value.some((row) => row.updated_at?.trim()));
|
||
const hasAnyComment = computed(() => rows.value.some((row) => row.comment?.trim()));
|
||
const isListView = computed(() => settingsStore.editorSettings.objectBrowserViewMode !== "grid");
|
||
|
||
type ObjectBrowserScroller =
|
||
| HTMLElement
|
||
| {
|
||
scrollToItem?: (index: number) => void;
|
||
scrollToPosition?: (position: number) => void;
|
||
$el?: HTMLElement;
|
||
el?: HTMLElement | { value?: HTMLElement | null };
|
||
};
|
||
|
||
// RecycleScroller exposes scroll helpers on its component instance. Keep the
|
||
// type loose because vue-virtual-scroller does not ship complete ref typings.
|
||
const listScrollerRef = ref<ObjectBrowserScroller | null>(null);
|
||
const gridScrollerRef = ref<ObjectBrowserScroller | null>(null);
|
||
let viewportFrame = 0;
|
||
let restoreViewportFrame = 0;
|
||
|
||
function objectBrowserViewMode(): ObjectBrowserViewMode {
|
||
return isListView.value ? "list" : "grid";
|
||
}
|
||
|
||
function activeScroller() {
|
||
return isListView.value ? listScrollerRef.value : gridScrollerRef.value;
|
||
}
|
||
|
||
function scrollerElement(scroller: ObjectBrowserScroller | null = activeScroller()): HTMLElement | null {
|
||
if (!scroller) return null;
|
||
if (scroller instanceof HTMLElement) return scroller;
|
||
if (scroller.$el instanceof HTMLElement) return scroller.$el;
|
||
if (scroller.el instanceof HTMLElement) return scroller.el;
|
||
if (scroller.el?.value instanceof HTMLElement) return scroller.el.value;
|
||
return null;
|
||
}
|
||
|
||
function emitViewportChange(scrollTop: number) {
|
||
const viewport: ObjectBrowserViewport = {
|
||
scrollTop: Math.max(0, Math.round(scrollTop)),
|
||
viewMode: objectBrowserViewMode(),
|
||
};
|
||
if (props.viewport?.scrollTop === viewport.scrollTop && props.viewport.viewMode === viewport.viewMode) return;
|
||
emit("viewportChange", viewport);
|
||
}
|
||
|
||
function onObjectsScroll() {
|
||
if (viewportFrame) return;
|
||
viewportFrame = window.requestAnimationFrame(() => {
|
||
viewportFrame = 0;
|
||
const el = scrollerElement();
|
||
if (!el) return;
|
||
emitViewportChange(el.scrollTop);
|
||
});
|
||
}
|
||
|
||
function applyObjectBrowserScrollTop(scrollTop: number) {
|
||
const scroller = activeScroller();
|
||
if (scroller && !(scroller instanceof HTMLElement)) {
|
||
scroller.scrollToPosition?.(scrollTop);
|
||
if (scrollTop === 0) scroller.scrollToItem?.(0);
|
||
}
|
||
const el = scrollerElement(scroller);
|
||
if (el) el.scrollTop = scrollTop;
|
||
}
|
||
|
||
function restoreObjectBrowserViewport() {
|
||
const viewport = props.viewport;
|
||
if (!viewport || viewport.viewMode !== objectBrowserViewMode()) return;
|
||
if (restoreViewportFrame) window.cancelAnimationFrame(restoreViewportFrame);
|
||
const scrollTop = Math.max(0, viewport.scrollTop);
|
||
nextTick(() => {
|
||
applyObjectBrowserScrollTop(scrollTop);
|
||
restoreViewportFrame = window.requestAnimationFrame(() => {
|
||
applyObjectBrowserScrollTop(scrollTop);
|
||
restoreViewportFrame = 0;
|
||
});
|
||
});
|
||
}
|
||
|
||
function scrollObjectsToTop() {
|
||
// Read the active scroller inside nextTick so that after a list <-> grid
|
||
// switch the (re)mounted scroller is the one we reset.
|
||
emitViewportChange(0);
|
||
nextTick(() => {
|
||
applyObjectBrowserScrollTop(0);
|
||
});
|
||
}
|
||
|
||
watch(
|
||
[listScrollerRef, gridScrollerRef, isListView],
|
||
(_value, _oldValue, onCleanup) => {
|
||
const el = scrollerElement();
|
||
if (!el) return;
|
||
el.addEventListener("scroll", onObjectsScroll, { passive: true });
|
||
restoreObjectBrowserViewport();
|
||
onCleanup(() => el.removeEventListener("scroll", onObjectsScroll));
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
onActivated(() => {
|
||
restoreObjectBrowserViewport();
|
||
});
|
||
|
||
function setViewMode(mode: "list" | "grid") {
|
||
settingsStore.updateEditorSettings({ objectBrowserViewMode: mode });
|
||
scrollObjectsToTop();
|
||
}
|
||
|
||
// Re-sorting reorders the rows; jump to the top so the new head is visible
|
||
// instead of leaving the view parked at a stale mid-scroll position.
|
||
// Note: watch(sortKeyOptions) may reset sortKey during setup when the persisted key
|
||
// is no longer valid, triggering this watcher before any scroller is mounted —
|
||
// scrollObjectsToTop() handles that safely via optional chaining.
|
||
watch([sortKey, sortDirection], () => scrollObjectsToTop());
|
||
|
||
// Also jump to the top when the search query or object-type filter changes —
|
||
// filtered results bear no relation to the previous scroll position.
|
||
watch(search, () => scrollObjectsToTop());
|
||
watch(objectFilter, () => {
|
||
if (preserveObjectFilterScrollOnce) {
|
||
preserveObjectFilterScrollOnce = false;
|
||
return;
|
||
}
|
||
scrollObjectsToTop();
|
||
});
|
||
watch(
|
||
() => props.connection.show_system_schemas,
|
||
(value, oldValue) => {
|
||
if (value === oldValue) return;
|
||
void reload();
|
||
},
|
||
);
|
||
|
||
const showCheckboxColumn = computed(() => settingsStore.editorSettings.objectBrowserShowCheckbox || selectedTableCount.value > 0);
|
||
|
||
function toggleCheckboxColumn() {
|
||
const next = !settingsStore.editorSettings.objectBrowserShowCheckbox;
|
||
settingsStore.updateEditorSettings({ objectBrowserShowCheckbox: next });
|
||
if (!next) clearTableSelection();
|
||
}
|
||
|
||
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
|
||
const columns: ObjectBrowserColumnKey[] = [];
|
||
if (showCheckboxColumn.value) columns.push("select");
|
||
columns.push("name", "type", "estimatedRows", "totalBytes");
|
||
if (hasCreatedAt.value) columns.push("created_at");
|
||
if (hasUpdatedAt.value) columns.push("updated_at");
|
||
columns.push("comment");
|
||
return columns;
|
||
});
|
||
const gridTemplateColumns = computed(() => {
|
||
return objectBrowserColumns.value
|
||
.map((key, index, columns) => {
|
||
const width = objectColumnWidths.value[key];
|
||
if (key === "select") return `${width}px`;
|
||
if (index === columns.length - 1) return `minmax(${width}px,1fr)`;
|
||
return `${width}px`;
|
||
})
|
||
.join(" ");
|
||
});
|
||
const objectGridMinWidth = computed(() => {
|
||
return objectBrowserColumns.value.reduce((total, key) => total + objectColumnWidths.value[key], 0) + Math.max(0, objectBrowserColumns.value.length - 1) * 12 + 24;
|
||
});
|
||
const partitionRowsByParentId = computed(() => {
|
||
const groups = new Map<string, ObjectBrowserRow[]>();
|
||
for (const row of rows.value) {
|
||
if (!row.partitionParentId) continue;
|
||
const group = groups.get(row.partitionParentId) ?? [];
|
||
group.push(row);
|
||
groups.set(row.partitionParentId, group);
|
||
}
|
||
return groups;
|
||
});
|
||
const filteredRows = computed(() => groupedFilteredRows());
|
||
const selectableRows = computed(() => rows.value.filter((row) => row.type === "TABLE"));
|
||
|
||
// ---- Grid (tile) view virtualization ----
|
||
// The grid view chunks filteredRows into fixed-height rows and hands them to
|
||
// RecycleScroller, mirroring the list view so only visible rows are mounted.
|
||
// The previous flat `v-for` rendered every card (plus its CustomContextMenu)
|
||
// at once, which stalls the UI on schemas with thousands of objects.
|
||
const OBJECT_GRID_MIN_CARD_WIDTH = 160; // former `minmax(160px, 1fr)` floor
|
||
const OBJECT_GRID_GAP = 12; // 0.75rem, former grid gap (both axes)
|
||
// Card height is dataset-stable: if any object has timestamps/comments, every card
|
||
// reserves those slots (even when empty) so borders stay level across a row.
|
||
// objectGridRowHeight adapts to the dataset instead of always using the worst case.
|
||
// Base: p-3 top+bottom(24) + icon h-11(44) + name(18) + type/bytes(18) + gap-1×2(8) = 112
|
||
// + optional timestamp row: text-[10px](15) + gap-1(4) = 19
|
||
// + optional comment row: text-[10px](15) + gap-1(4) = 19
|
||
// If the card gains a new metadata row, add a matching constant and include it below.
|
||
const OBJECT_GRID_CARD_BASE_H = 112;
|
||
const OBJECT_GRID_CARD_TIMESTAMP_H = 19;
|
||
const OBJECT_GRID_CARD_COMMENT_H = 19;
|
||
const OBJECT_GRID_CARD_SAFETY = 6; // buffer for sub-pixel font differences
|
||
|
||
const objectGridRowHeight = computed(() => {
|
||
let cardH = OBJECT_GRID_CARD_BASE_H;
|
||
if (hasCreatedAt.value || hasUpdatedAt.value) cardH += OBJECT_GRID_CARD_TIMESTAMP_H;
|
||
if (hasAnyComment.value) cardH += OBJECT_GRID_CARD_COMMENT_H;
|
||
return cardH + OBJECT_GRID_GAP + OBJECT_GRID_CARD_SAFETY;
|
||
});
|
||
const gridContainerRef = ref<HTMLElement | null>(null);
|
||
const gridColumns = ref(1);
|
||
let gridResizeObserver: ResizeObserver | null = null;
|
||
|
||
function recomputeGridColumns(width: number) {
|
||
gridColumns.value = Math.max(1, Math.floor((width + OBJECT_GRID_GAP) / (OBJECT_GRID_MIN_CARD_WIDTH + OBJECT_GRID_GAP)));
|
||
}
|
||
|
||
// The grid container lives inside a v-else, so it mounts/unmounts when the user
|
||
// toggles list <-> grid. Watch the template ref to (re)attach the observer each
|
||
// time the node appears, instead of once in onMounted (which would miss the
|
||
// case where the browser starts in list mode).
|
||
watch(
|
||
gridContainerRef,
|
||
(el, prevEl) => {
|
||
if (prevEl) {
|
||
gridResizeObserver?.disconnect();
|
||
gridResizeObserver = null;
|
||
}
|
||
if (!el) return;
|
||
// Use the content-box width (excluding padding) to match what ResizeObserver
|
||
// delivers via entry.contentRect.width, avoiding a 1-frame column jump on mount.
|
||
const style = getComputedStyle(el);
|
||
recomputeGridColumns(el.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight));
|
||
gridResizeObserver = new ResizeObserver((entries) => {
|
||
for (const entry of entries) recomputeGridColumns(entry.contentRect.width);
|
||
});
|
||
gridResizeObserver.observe(el);
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
gridResizeObserver?.disconnect();
|
||
gridResizeObserver = null;
|
||
if (viewportFrame) window.cancelAnimationFrame(viewportFrame);
|
||
if (restoreViewportFrame) window.cancelAnimationFrame(restoreViewportFrame);
|
||
window.removeEventListener("mousemove", onSidePanelResizeMove);
|
||
window.removeEventListener("mouseup", onSidePanelResizeEnd);
|
||
if (singleClickTimer) {
|
||
clearTimeout(singleClickTimer);
|
||
singleClickTimer = null;
|
||
}
|
||
});
|
||
|
||
const gridRows = computed(() => {
|
||
const cols = gridColumns.value;
|
||
const cards = filteredRows.value;
|
||
const rows: Array<{ key: string; cards: ObjectBrowserRow[] }> = [];
|
||
for (let i = 0; i < cards.length; i += cols) {
|
||
rows.push({ key: `row-${i}`, cards: cards.slice(i, i + cols) });
|
||
}
|
||
return rows;
|
||
});
|
||
const visibleSelectableRows = computed(() => filteredRows.value.filter((row) => row.type === "TABLE"));
|
||
const selectedTableRows = computed(() => {
|
||
const ids = selectedTableIds.value;
|
||
return selectableRows.value.filter((row) => ids.has(row.id));
|
||
});
|
||
const selectedTableCount = computed(() => selectedTableRows.value.length);
|
||
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
|
||
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
|
||
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
|
||
const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0));
|
||
|
||
function iconFor(row: ObjectBrowserRow) {
|
||
if (row.type === "VIEW" || row.type === "MATERIALIZED_VIEW") return Eye;
|
||
if (row.type === "PROCEDURE") return ScrollText;
|
||
if (row.type === "FUNCTION") return Braces;
|
||
if (row.type === "TRIGGER") return RotateCcw;
|
||
if (row.type === "SEQUENCE") return ListTree;
|
||
if (row.type === "PACKAGE" || row.type === "PACKAGE_BODY") return Package;
|
||
if (row.type === "TYPE" || row.type === "TYPE_BODY") return Braces;
|
||
return Table2;
|
||
}
|
||
|
||
function typeLabel(type: ObjectBrowserRow["type"]) {
|
||
if (type === "MATERIALIZED_VIEW") return t("common.materializedView");
|
||
if (type === "VIEW") return t("objects.view");
|
||
if (type === "PROCEDURE") return t("objects.procedure");
|
||
if (type === "FUNCTION") return t("objects.function");
|
||
if (type === "TRIGGER") return t("objects.trigger");
|
||
if (type === "SEQUENCE") return t("objects.sequence");
|
||
if (type === "PACKAGE") return t("objects.package");
|
||
if (type === "PACKAGE_BODY") return t("objects.packageBody");
|
||
if (type === "TYPE") return t("objects.typeDefinition");
|
||
if (type === "TYPE_BODY") return t("objects.typeBody");
|
||
return t("objects.table");
|
||
}
|
||
|
||
function sortIconFor(key: ObjectBrowserSortKey) {
|
||
if (sortKey.value !== key) return null;
|
||
return sortDirection.value === "asc" ? ArrowUp : ArrowDown;
|
||
}
|
||
|
||
function toggleSort(key: ObjectBrowserSortKey) {
|
||
if (sortKey.value === key) {
|
||
sortDirection.value = sortDirection.value === "asc" ? "desc" : "asc";
|
||
return;
|
||
}
|
||
sortKey.value = key;
|
||
sortDirection.value = initialObjectBrowserSortDirection(key);
|
||
}
|
||
|
||
const sortKeyOptions = computed<ObjectBrowserSortKey[]>(() => {
|
||
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows", "totalBytes"];
|
||
if (hasCreatedAt.value) options.push("created_at");
|
||
if (hasUpdatedAt.value) options.push("updated_at");
|
||
options.push("comment");
|
||
return options;
|
||
});
|
||
|
||
watch(sortKeyOptions, (options) => {
|
||
if (!options.includes(sortKey.value)) {
|
||
sortKey.value = "name";
|
||
sortDirection.value = "asc";
|
||
}
|
||
});
|
||
|
||
function sortKeyLabel(key: ObjectBrowserSortKey): string {
|
||
if (key === "name") return t("objects.name");
|
||
if (key === "type") return t("objects.type");
|
||
if (key === "estimatedRows") return t("objects.rows");
|
||
if (key === "totalBytes") return t("objects.size");
|
||
if (key === "created_at") return t("objects.createdAt");
|
||
if (key === "updated_at") return t("objects.updatedAt");
|
||
if (key === "comment") return t("objects.comment");
|
||
return key;
|
||
}
|
||
|
||
function onSortKeyChange(key: ObjectBrowserSortKey) {
|
||
toggleSort(key);
|
||
}
|
||
|
||
function minimumColumnWidth(key: ObjectBrowserColumnKey) {
|
||
if (key === "select") return 34;
|
||
if (key === "name" || key === "comment") return 120;
|
||
return 72;
|
||
}
|
||
|
||
function onObjectColumnResizeStart(key: ObjectBrowserColumnKey, event: MouseEvent) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
stopColumnResize?.();
|
||
|
||
const startX = event.clientX;
|
||
const startWidth = objectColumnWidths.value[key];
|
||
const minWidth = minimumColumnWidth(key);
|
||
document.body.classList.add("select-none", "cursor-col-resize");
|
||
|
||
const onMove = (moveEvent: MouseEvent) => {
|
||
objectColumnWidths.value = {
|
||
...objectColumnWidths.value,
|
||
[key]: Math.max(minWidth, startWidth + moveEvent.clientX - startX),
|
||
};
|
||
};
|
||
const onUp = () => {
|
||
document.removeEventListener("mousemove", onMove);
|
||
document.removeEventListener("mouseup", onUp);
|
||
document.body.classList.remove("select-none", "cursor-col-resize");
|
||
stopColumnResize = null;
|
||
};
|
||
|
||
stopColumnResize = onUp;
|
||
document.addEventListener("mousemove", onMove);
|
||
document.addEventListener("mouseup", onUp);
|
||
}
|
||
|
||
function resetObjectColumnWidth(key: ObjectBrowserColumnKey, width: number, event: MouseEvent) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
objectColumnWidths.value = {
|
||
...objectColumnWidths.value,
|
||
[key]: width,
|
||
};
|
||
}
|
||
|
||
function rowMatchesFilter(row: ObjectBrowserRow, filter: ObjectFilter) {
|
||
if (filter === "tables") return row.type === "TABLE";
|
||
if (filter === "views") return row.type === "VIEW";
|
||
if (filter === "materializedViews") return row.type === "MATERIALIZED_VIEW";
|
||
if (filter === "procedures") return row.type === "PROCEDURE";
|
||
if (filter === "functions") return row.type === "FUNCTION";
|
||
if (filter === "triggers") return row.type === "TRIGGER";
|
||
if (filter === "sequences") return row.type === "SEQUENCE";
|
||
if (filter === "packages") return row.type === "PACKAGE" || row.type === "PACKAGE_BODY";
|
||
if (filter === "types") return row.type === "TYPE" || row.type === "TYPE_BODY";
|
||
return true;
|
||
}
|
||
|
||
function rowMatchesObjectFilter(row: ObjectBrowserRow) {
|
||
return rowMatchesFilter(row, objectFilter.value);
|
||
}
|
||
|
||
function objectBrowserPinnedTreeNodeContext() {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||
catalog: props.catalog,
|
||
sidebarParentId: props.catalog
|
||
? `${props.connection.id}:doris-catalog:${encodeURIComponent(props.catalog)}:${encodeURIComponent(props.database)}`
|
||
: needsSchema.value && selectedSchema.value
|
||
? `${props.connection.id}:${props.database}:${selectedSchema.value}`
|
||
: `${props.connection.id}:${props.database}`,
|
||
};
|
||
}
|
||
|
||
function canonicalizeObjectBrowserPinnedIdentity(identity: PinnedTreeNodeIdentity): PinnedTreeNodeIdentity {
|
||
return canonicalizeObjectBrowserPinnedTreeNodeIdentity(objectBrowserPinnedTreeNodeContext())(identity);
|
||
}
|
||
|
||
function sortObjectBrowserRowsWithPins(items: ObjectBrowserRow[]): ObjectBrowserRow[] {
|
||
const sorted = sortObjectBrowserRows(items, sortKey.value, sortDirection.value);
|
||
const context = objectBrowserPinnedTreeNodeContext();
|
||
return connectionStore.orderByPinnedTreeNodes(sorted, (row, identity) => objectBrowserRowMatchesPinnedTreeNode(row, identity, context));
|
||
}
|
||
|
||
function pinnedTreeNodeForObjectBrowserRow(row: ObjectBrowserRow): TreeNode {
|
||
const identity = objectBrowserRowPinnedTreeNodeIdentity(row, objectBrowserPinnedTreeNodeContext());
|
||
return {
|
||
id: identity.id,
|
||
label: identity.name,
|
||
type: identity.type,
|
||
objectName: identity.name,
|
||
signature: identity.signature || undefined,
|
||
connectionId: identity.connectionId,
|
||
database: identity.database,
|
||
schema: identity.schema || undefined,
|
||
catalog: identity.catalog || undefined,
|
||
};
|
||
}
|
||
|
||
function legacyPinnedTreeNodesForObjectBrowserRow(row: ObjectBrowserRow): TreeNode[] {
|
||
const baseNode = pinnedTreeNodeForObjectBrowserRow(row);
|
||
return objectBrowserRowLegacyPinnedTreeNodeIds(row, objectBrowserPinnedTreeNodeContext()).map((id) => ({ ...baseNode, id }));
|
||
}
|
||
|
||
function removePinnedObjectBrowserRows(rows: readonly ObjectBrowserRow[]) {
|
||
const nodes = rows.flatMap((row) => [pinnedTreeNodeForObjectBrowserRow(row), ...legacyPinnedTreeNodesForObjectBrowserRow(row)]);
|
||
connectionStore.removePinnedTreeNodes(nodes, canonicalizeObjectBrowserPinnedIdentity);
|
||
}
|
||
|
||
function groupedFilteredRows() {
|
||
const query = search.value.trim();
|
||
const candidateRows = rows.value.filter(rowMatchesObjectFilter);
|
||
const candidateIds = new Set(candidateRows.map((row) => row.id));
|
||
const matchingRows = objectSearchSummary.value.matchingRows.filter(rowMatchesObjectFilter);
|
||
const matchingIds = new Set(matchingRows.map((row) => row.id));
|
||
const parentIdsWithMatchingPartitions = new Set(matchingRows.flatMap((row) => (row.partitionParentId ? [row.partitionParentId] : [])));
|
||
const rootRows = candidateRows.filter((row) => {
|
||
if (row.partitionParentId) return false;
|
||
if (!query) return true;
|
||
return matchingIds.has(row.id) || parentIdsWithMatchingPartitions.has(row.id);
|
||
});
|
||
const sortedRoots = sortObjectBrowserRowsWithPins(rootRows);
|
||
const result: ObjectBrowserRow[] = [];
|
||
|
||
for (const row of sortedRoots) {
|
||
result.push(row);
|
||
const partitions = partitionRowsByParentId.value.get(row.id)?.filter((partition) => candidateIds.has(partition.id));
|
||
if (!partitions?.length) continue;
|
||
const parentMatches = matchingIds.has(row.id);
|
||
const shouldShowPartitions = expandedPartitionParentIds.value.has(row.id) || !!query;
|
||
if (!shouldShowPartitions) continue;
|
||
const visiblePartitions = query && !parentMatches ? partitions.filter((partition) => matchingIds.has(partition.id)) : partitions;
|
||
result.push(...sortObjectBrowserRowsWithPins(visiblePartitions));
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function iconClass(type: ObjectBrowserRow["type"]) {
|
||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "text-purple-500";
|
||
if (type === "PROCEDURE") return "text-blue-500";
|
||
if (type === "FUNCTION") return "text-amber-500";
|
||
if (type === "TRIGGER") return "text-rose-500";
|
||
if (type === "SEQUENCE") return "text-emerald-500";
|
||
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "text-cyan-500";
|
||
if (type === "TYPE" || type === "TYPE_BODY") return "text-violet-500";
|
||
return "text-green-500";
|
||
}
|
||
|
||
function iconBgClass(type: ObjectBrowserRow["type"]) {
|
||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return "object-browser-icon-bg object-browser-icon-bg-view";
|
||
if (type === "PROCEDURE") return "object-browser-icon-bg object-browser-icon-bg-procedure";
|
||
if (type === "FUNCTION") return "object-browser-icon-bg object-browser-icon-bg-function";
|
||
if (type === "TRIGGER") return "object-browser-icon-bg object-browser-icon-bg-procedure";
|
||
if (type === "SEQUENCE") return "object-browser-icon-bg object-browser-icon-bg-sequence";
|
||
if (type === "PACKAGE" || type === "PACKAGE_BODY") return "object-browser-icon-bg object-browser-icon-bg-package";
|
||
if (type === "TYPE" || type === "TYPE_BODY") return "object-browser-icon-bg object-browser-icon-bg-function";
|
||
return "object-browser-icon-bg object-browser-icon-bg-table";
|
||
}
|
||
|
||
function isPartitionParentExpanded(row: ObjectBrowserRow) {
|
||
return expandedPartitionParentIds.value.has(row.id);
|
||
}
|
||
|
||
function togglePartitionParent(row: ObjectBrowserRow) {
|
||
if (!row.partitionCount) return;
|
||
const next = new Set(expandedPartitionParentIds.value);
|
||
if (next.has(row.id)) next.delete(row.id);
|
||
else next.add(row.id);
|
||
expandedPartitionParentIds.value = next;
|
||
}
|
||
|
||
function canRename(row: ObjectBrowserRow) {
|
||
return supportsObjectRename(effectiveDatabaseType.value, row.type) || supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind);
|
||
}
|
||
|
||
function sourceTitle(row: ObjectBrowserRow | null) {
|
||
if (!row) return t("objects.source");
|
||
return `${row.name} ${t("objects.source")}`;
|
||
}
|
||
|
||
const SINGLE_CLICK_DELAY = 250;
|
||
let singleClickTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function executeRowAction(row: ObjectBrowserRow, action: ObjectBrowserRowAction) {
|
||
switch (action) {
|
||
case "table-info":
|
||
void openTableInfo(row);
|
||
break;
|
||
case "open-table":
|
||
emit("openTable", { tableName: row.name, schema: row.schema, catalog: props.catalog });
|
||
break;
|
||
case "open-source":
|
||
void openSource(row);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
|
||
const activation = settingsStore.editorSettings.sidebarActivation;
|
||
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation);
|
||
// Double click: cancel any pending single-click and fire immediately
|
||
if (isDouble) {
|
||
if (singleClickTimer) {
|
||
clearTimeout(singleClickTimer);
|
||
singleClickTimer = null;
|
||
}
|
||
executeRowAction(row, action);
|
||
return;
|
||
}
|
||
// Single click: defer when the row has a distinct double-click action so a
|
||
// following second click can cancel it (e.g. TABLE single→table-info, double→open-table).
|
||
if (shouldDeferSingleClick(row, action)) {
|
||
if (singleClickTimer) clearTimeout(singleClickTimer);
|
||
singleClickTimer = setTimeout(() => {
|
||
singleClickTimer = null;
|
||
executeRowAction(row, action);
|
||
}, SINGLE_CLICK_DELAY);
|
||
return;
|
||
}
|
||
executeRowAction(row, action);
|
||
}
|
||
|
||
// --- Table info panel (replicates DataGrid table-info-drawer) ---
|
||
|
||
type TableInfoTabItem = { id: TableInfoTab; label: string; icon: Component; count?: number };
|
||
|
||
const tableInfoTabs = computed<TableInfoTabItem[]>(() => {
|
||
const tabs: TableInfoTabItem[] = [];
|
||
if (tableMetadataCapabilities.value.ddl) {
|
||
tabs.push({ id: "ddl", label: "DDL", icon: Code2 });
|
||
}
|
||
if (tableMetadataCapabilities.value.columns) {
|
||
tabs.push({ id: "columns", label: t("grid.tableInfoColumns"), icon: ListTree, count: tableColumns.value.length });
|
||
}
|
||
if (tableMetadataCapabilities.value.indexes) {
|
||
tabs.push({ id: "indexes", label: t("grid.tableInfoIndexes"), icon: KeyRound, count: tableIndexes.value.length });
|
||
}
|
||
if (tableMetadataCapabilities.value.foreignKeys) {
|
||
tabs.push({ id: "foreignKeys", label: t("grid.tableInfoForeignKeys"), icon: Link2, count: tableForeignKeys.value.length });
|
||
}
|
||
if (tableMetadataCapabilities.value.triggers) {
|
||
tabs.push({ id: "triggers", label: t("grid.tableInfoTriggers"), icon: RotateCcw, count: tableTriggers.value.length });
|
||
}
|
||
return tabs;
|
||
});
|
||
|
||
const tableInfoTabListStyle = computed(() => ({
|
||
gridTemplateColumns: `repeat(${tableInfoTabs.value.length}, minmax(0, 1fr))`,
|
||
}));
|
||
|
||
const filteredTableColumns = computed(() => filterObjectBrowserTableColumns(tableColumns.value, tableInfoSearchQuery.value));
|
||
|
||
const filteredTableIndexes = computed(() => {
|
||
if (!tableInfoSearchQuery.value) return tableIndexes.value;
|
||
const q = tableInfoSearchQuery.value.toLowerCase();
|
||
return tableIndexes.value.filter((i) => i.name.toLowerCase().includes(q) || i.columns.some((c) => c.toLowerCase().includes(q)));
|
||
});
|
||
|
||
const filteredTableForeignKeys = computed(() => {
|
||
if (!tableInfoSearchQuery.value) return tableForeignKeys.value;
|
||
const q = tableInfoSearchQuery.value.toLowerCase();
|
||
return tableForeignKeys.value.filter((fk) => fk.name.toLowerCase().includes(q) || fk.column.toLowerCase().includes(q) || fk.ref_table.toLowerCase().includes(q) || fk.ref_column.toLowerCase().includes(q));
|
||
});
|
||
|
||
const filteredTableTriggers = computed(() => {
|
||
if (!tableInfoSearchQuery.value) return tableTriggers.value;
|
||
const q = tableInfoSearchQuery.value.toLowerCase();
|
||
return tableTriggers.value.filter((tr) => tr.name.toLowerCase().includes(q));
|
||
});
|
||
|
||
const filteredTableDdlContent = computed(() => {
|
||
if (!tableDdlContent.value) return "";
|
||
const html = highlight(tableDdlContent.value);
|
||
if (!tableInfoSearchQuery.value) return html;
|
||
const escaped = tableInfoSearchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const regex = new RegExp(`(${escaped})`, "gi");
|
||
return html.replace(/>([^<]*)</g, (_, text) => {
|
||
return `>${text.replace(regex, "<mark>$1</mark>")}<`;
|
||
});
|
||
});
|
||
|
||
async function openTableInfo(row: ObjectBrowserRow, initialTab?: TableInfoTab) {
|
||
// Toggle off if clicking the same table
|
||
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "table-info" && !initialTab) {
|
||
closeSidePanel();
|
||
return;
|
||
}
|
||
sidePanelRow.value = row;
|
||
sidePanelMode.value = "table-info";
|
||
sidePanelGuard.bump();
|
||
// Reset state
|
||
tableColumns.value = [];
|
||
tableDdlContent.value = "";
|
||
tableIndexes.value = [];
|
||
tableForeignKeys.value = [];
|
||
tableTriggers.value = [];
|
||
tableInfoSearchQuery.value = "";
|
||
// Determine initial tab: explicit request > first available > ddl
|
||
const firstTab = initialTab ?? tableInfoTabs.value[0]?.id ?? "ddl";
|
||
await selectTableInfoTab(firstTab);
|
||
}
|
||
|
||
async function selectTableInfoTab(tab: TableInfoTab) {
|
||
const nextTab = tableInfoTabs.value.some((item) => item.id === tab) ? tab : tableInfoTabs.value[0]?.id;
|
||
if (!nextTab) return;
|
||
tableInfoTab.value = nextTab;
|
||
tableInfoSearchQuery.value = "";
|
||
if (nextTab === "ddl") await fetchTableDdl();
|
||
else if (nextTab === "columns") await fetchTableColumns();
|
||
else if (nextTab === "indexes") await fetchTableIndexes();
|
||
else if (nextTab === "foreignKeys") await fetchTableForeignKeys();
|
||
else if (nextTab === "triggers") await fetchTableTriggers();
|
||
}
|
||
|
||
async function fetchTableDdl() {
|
||
const row = sidePanelRow.value;
|
||
if (!row) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableDdlLoading.value = true;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const ddl = await api.getTableDisplayDdl(props.connection.id, props.database || "", schema, row.name, tableDdlObjectType(row.type), props.catalog);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableDdlContent.value = ddl;
|
||
} catch (e: any) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableDdlContent.value = `-- Error: ${e?.message || e}`;
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) tableDdlLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTableColumns() {
|
||
const row = sidePanelRow.value;
|
||
if (!row || tableColumns.value.length > 0) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableColumnsLoading.value = true;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const columns = await api.getColumns(props.connection.id, props.database || "", schema, row.name, props.catalog);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableColumns.value = columns;
|
||
} catch {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableColumns.value = [];
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) tableColumnsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTableIndexes() {
|
||
const row = sidePanelRow.value;
|
||
if (!row || tableIndexes.value.length > 0) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableIndexesLoading.value = true;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const indexes = await api.listIndexes(props.connection.id, props.database || "", schema, row.name, props.catalog);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableIndexes.value = indexes;
|
||
} catch {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableIndexes.value = [];
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) tableIndexesLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTableForeignKeys() {
|
||
const row = sidePanelRow.value;
|
||
if (!row || tableForeignKeys.value.length > 0) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableForeignKeysLoading.value = true;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const fks = await api.listForeignKeys(props.connection.id, props.database || "", schema, row.name, props.catalog);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableForeignKeys.value = fks;
|
||
} catch {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableForeignKeys.value = [];
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) tableForeignKeysLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchTableTriggers() {
|
||
const row = sidePanelRow.value;
|
||
if (!row || tableTriggers.value.length > 0) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableTriggersLoading.value = true;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const triggers = await api.listTriggers(props.connection.id, props.database || "", schema, row.name, props.catalog);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableTriggers.value = triggers;
|
||
} catch {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableTriggers.value = [];
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) tableTriggersLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function copyTableDdl() {
|
||
void copyToClipboard(tableDdlContent.value);
|
||
toast(t("grid.copyDdl"), 2000);
|
||
}
|
||
|
||
function onTableInfoDdlKeydown(e: KeyboardEvent) {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === "a") {
|
||
e.preventDefault();
|
||
const el = tableInfoDdlPreRef.value;
|
||
if (!el) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const sel = window.getSelection();
|
||
sel?.removeAllRanges();
|
||
sel?.addRange(range);
|
||
}
|
||
}
|
||
|
||
// --- Side panel resize ---
|
||
function onSidePanelResizeStart(event: MouseEvent) {
|
||
isResizingSidePanel.value = true;
|
||
sidePanelResizeStartX = event.clientX;
|
||
sidePanelResizeStartWidth = sidePanelWidth.value;
|
||
window.addEventListener("mousemove", onSidePanelResizeMove);
|
||
window.addEventListener("mouseup", onSidePanelResizeEnd);
|
||
}
|
||
|
||
function onSidePanelResizeMove(event: MouseEvent) {
|
||
if (!isResizingSidePanel.value) return;
|
||
const delta = sidePanelResizeStartX - event.clientX;
|
||
const next = Math.min(SIDE_PANEL_MAX_WIDTH, Math.max(SIDE_PANEL_MIN_WIDTH, sidePanelResizeStartWidth + delta));
|
||
sidePanelWidth.value = next;
|
||
}
|
||
|
||
function onSidePanelResizeEnd() {
|
||
isResizingSidePanel.value = false;
|
||
window.removeEventListener("mousemove", onSidePanelResizeMove);
|
||
window.removeEventListener("mouseup", onSidePanelResizeEnd);
|
||
settingsStore.updateEditorSettings({ tableInfoDrawerWidth: sidePanelWidth.value });
|
||
}
|
||
|
||
function closeSidePanel() {
|
||
sidePanelRow.value = null;
|
||
sidePanelMode.value = "source";
|
||
sidePanelGuard.bump();
|
||
}
|
||
|
||
const canOpenTableStructureEditor = computed(() => sidePanelRow.value?.type === "TABLE" && canOpenStructureEditor.value);
|
||
|
||
function openTableStructureEditor() {
|
||
const row = sidePanelRow.value;
|
||
if (!row || row.type !== "TABLE" || !canOpenTableStructureEditor.value) return;
|
||
queryStore.openTableStructure(props.connection.id, props.database, row.schema || selectedSchema.value, row.name, tableInfoTab.value, undefined, props.catalog);
|
||
}
|
||
|
||
async function openSource(row: ObjectBrowserRow) {
|
||
// Toggle off if clicking the same source row
|
||
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "source") {
|
||
closeSidePanel();
|
||
return;
|
||
}
|
||
// Starting a different object must invalidate slower source requests before
|
||
// any state is reset, otherwise an old response can populate the new row.
|
||
const epoch = sidePanelGuard.start();
|
||
sidePanelRow.value = row;
|
||
sidePanelMode.value = "source";
|
||
sourceRow.value = row;
|
||
sourceContent.value = "";
|
||
sourceError.value = "";
|
||
sourceEditing.value = false;
|
||
sourceCanEdit.value = true;
|
||
sourceEditableText.value = "";
|
||
sourceDraft.value = "";
|
||
sourceSaveError.value = "";
|
||
sourceLoading.value = true;
|
||
const connectionId = props.connection.id;
|
||
const database = props.database;
|
||
const schema = row.schema || selectedSchema.value || database;
|
||
try {
|
||
const result = await api.getObjectSource(connectionId, database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
sourceCanEdit.value = result.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(row.type);
|
||
const editable = sourceCanEdit.value
|
||
? await api.buildEditableObjectSource({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type as ObjectSourceKind,
|
||
schema,
|
||
name: row.name,
|
||
source: result.source,
|
||
})
|
||
: result.source;
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
// Viewing database source must preserve its original whitespace and comments;
|
||
// formatting remains an explicit editor action instead of altering it on open.
|
||
sourceEditableText.value = editable;
|
||
sourceContent.value = editable;
|
||
sourceDraft.value = editable;
|
||
sourceEditing.value = sourceCanEdit.value;
|
||
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
|
||
toast(t("objects.sourceReadOnly"), 3000);
|
||
}
|
||
} catch (e: any) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
sourceError.value = e?.message || String(e);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) sourceLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function openNewQuery(row: ObjectBrowserRow) {
|
||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name);
|
||
queryStore.updateSql(
|
||
tabId,
|
||
await buildTableSelectSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
limit: 100,
|
||
}),
|
||
);
|
||
}
|
||
|
||
function openProcedureExecution(row: ObjectBrowserRow) {
|
||
if (row.type !== "PROCEDURE") return;
|
||
procedureExecutionTarget.value = row;
|
||
showProcedureExecutionConfirm.value = true;
|
||
}
|
||
|
||
function openProcedureExecutionSql(sql: string) {
|
||
const row = procedureExecutionTarget.value;
|
||
if (!row || !sql) return;
|
||
const schema = row.schema || selectedSchema.value;
|
||
const tabId = queryStore.createTab(props.connection.id, props.database, `Execute - ${row.name}`, "query", schema);
|
||
queryStore.updateSql(tabId, sql);
|
||
}
|
||
|
||
async function executeProcedureSql(sql: string) {
|
||
const row = procedureExecutionTarget.value;
|
||
if (!row || !sql) return;
|
||
const schema = row.schema || selectedSchema.value;
|
||
const tabId = queryStore.createTab(props.connection.id, props.database, `Execute - ${row.name}`, "query", schema);
|
||
queryStore.updateSql(tabId, sql);
|
||
await queryStore.executeTabSql(tabId, sql);
|
||
}
|
||
|
||
function requestDrop(row: ObjectBrowserRow) {
|
||
dropTarget.value = row;
|
||
dropPreviewSql.value = "";
|
||
dropTableCascade.value = false;
|
||
showDropConfirm.value = true;
|
||
void refreshDropPreviewSql();
|
||
}
|
||
|
||
function requestRename(row: ObjectBrowserRow) {
|
||
renameTarget.value = row;
|
||
renameInput.value = row.name;
|
||
renameError.value = "";
|
||
renamePreviewSqlText.value = "";
|
||
showRenameDialog.value = true;
|
||
}
|
||
|
||
let renamePreviewRequestId = 0;
|
||
|
||
async function refreshRenamePreviewSql() {
|
||
const requestId = ++renamePreviewRequestId;
|
||
const row = renameTarget.value;
|
||
const newName = renameInput.value.trim();
|
||
if (!showRenameDialog.value || !row || !newName || newName === row.name) {
|
||
renamePreviewSqlText.value = "";
|
||
return;
|
||
}
|
||
if (supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind)) {
|
||
renamePreviewSqlText.value = `-- Recreate ${row.type} from source, then drop the original object.`;
|
||
return;
|
||
}
|
||
try {
|
||
const sql = await buildRenameObjectSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type,
|
||
schema: row.schema || selectedSchema.value,
|
||
oldName: row.name,
|
||
newName,
|
||
});
|
||
if (requestId === renamePreviewRequestId) renamePreviewSqlText.value = sql;
|
||
} catch {
|
||
if (requestId === renamePreviewRequestId) renamePreviewSqlText.value = "";
|
||
}
|
||
}
|
||
|
||
watch([showRenameDialog, renameTarget, renameInput, selectedSchema], () => {
|
||
void refreshRenamePreviewSql();
|
||
});
|
||
|
||
async function confirmRename() {
|
||
const row = renameTarget.value;
|
||
const newName = renameInput.value.trim();
|
||
if (!row || !newName || newName === row.name) return;
|
||
renameError.value = "";
|
||
const oldPinnedNode = pinnedTreeNodeForObjectBrowserRow(row);
|
||
const oldLegacyPinnedNodes = legacyPinnedTreeNodesForObjectBrowserRow(row);
|
||
let renameApplied = false;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
if (supportsSourceBackedRoutineRename(effectiveDatabaseType.value, row.type as ObjectSourceKind)) {
|
||
const source = await api.getObjectSource(props.connection.id, props.database, schema, row.name, row.type as ObjectSourceKind, row.signature ?? undefined);
|
||
const statements = await buildRoutineRenameObjectSourceStatements({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type as ObjectSourceKind,
|
||
schema,
|
||
name: row.name,
|
||
newName,
|
||
source: source.source,
|
||
});
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.join(";\n"), async () => {
|
||
for (const sql of statements) {
|
||
await api.executeQuery(props.connection.id, props.database, sql, schema);
|
||
}
|
||
return true;
|
||
});
|
||
if (!executed) return;
|
||
} else {
|
||
const sql = await buildRenameObjectSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type,
|
||
schema,
|
||
oldName: row.name,
|
||
newName,
|
||
});
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
|
||
if (!executed) return;
|
||
}
|
||
renameApplied = true;
|
||
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
|
||
showRenameDialog.value = false;
|
||
if (sourceRow.value?.id === row.id) closeSource();
|
||
const renamedTarget = { ...oldPinnedNode, label: newName, objectName: newName, tableName: newName };
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value);
|
||
const renamedRow = rows.value.find((candidate) => objectBrowserRowMatchesPinnedTreeNode(candidate, treeNodePinIdentity(renamedTarget), objectBrowserPinnedTreeNodeContext()));
|
||
if (renamedRow) {
|
||
connectionStore.replacePinnedTreeNode(
|
||
oldPinnedNode,
|
||
pinnedTreeNodeForObjectBrowserRow(renamedRow),
|
||
canonicalizeObjectBrowserPinnedIdentity,
|
||
oldLegacyPinnedNodes.map((node) => node.id),
|
||
);
|
||
} else {
|
||
// The database mutation succeeded, so never leave the old name pinned if
|
||
// metadata refresh cannot resolve its replacement.
|
||
connectionStore.removePinnedTreeNodes([oldPinnedNode, ...oldLegacyPinnedNodes], canonicalizeObjectBrowserPinnedIdentity);
|
||
}
|
||
} catch (e: any) {
|
||
if (renameApplied) {
|
||
// The database mutation succeeded even when metadata refresh did not;
|
||
// remove the old pin instead of allowing it to revive later.
|
||
connectionStore.removePinnedTreeNodes([oldPinnedNode, ...oldLegacyPinnedNodes], canonicalizeObjectBrowserPinnedIdentity);
|
||
}
|
||
renameError.value = e?.message || String(e);
|
||
}
|
||
}
|
||
|
||
async function confirmDrop() {
|
||
if (!dropTarget.value) return;
|
||
const row = dropTarget.value;
|
||
try {
|
||
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||
if (!executed) return;
|
||
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
|
||
toast(t(successKey, { name: row.name }));
|
||
closeDroppedTableObjectTabsForRow(row);
|
||
removePinnedObjectBrowserRows([row]);
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, row.schema || selectedSchema.value);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
dropTarget.value = null;
|
||
dropPreviewSql.value = "";
|
||
dropTableCascade.value = false;
|
||
}
|
||
|
||
async function buildDropSqlForRow(row: ObjectBrowserRow, options?: { cascade?: boolean }): Promise<string> {
|
||
if (row.type === "TABLE") {
|
||
return buildDropTableSql(tableAdminSqlOptions(row, { cascade: options?.cascade && supportsDropTableCascade(effectiveDatabaseType.value) }));
|
||
}
|
||
return buildDropObjectSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type,
|
||
schema: row.schema || selectedSchema.value,
|
||
name: row.name,
|
||
});
|
||
}
|
||
|
||
let dropPreviewRequestId = 0;
|
||
|
||
async function refreshDropPreviewSql() {
|
||
const requestId = ++dropPreviewRequestId;
|
||
const row = dropTarget.value;
|
||
if (!row) {
|
||
dropPreviewSql.value = "";
|
||
return;
|
||
}
|
||
dropPreviewSql.value = "";
|
||
const sql = await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }).catch(() => "");
|
||
if (requestId === dropPreviewRequestId) dropPreviewSql.value = sql;
|
||
}
|
||
|
||
function dropConfirmTitle(): string {
|
||
if (!dropTarget.value) return "";
|
||
const type = dropTarget.value.type;
|
||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewTitle");
|
||
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureTitle");
|
||
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionTitle");
|
||
return t("contextMenu.confirmDropTableTitle");
|
||
}
|
||
|
||
function dropConfirmMessage(): string {
|
||
if (!dropTarget.value) return "";
|
||
const name = dropTarget.value.name;
|
||
const type = dropTarget.value.type;
|
||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return t("contextMenu.confirmDropViewMessage", { name });
|
||
if (type === "PROCEDURE") return t("contextMenu.confirmDropProcedureMessage", { name });
|
||
if (type === "FUNCTION") return t("contextMenu.confirmDropFunctionMessage", { name });
|
||
return t("contextMenu.confirmDropTableMessage", { name });
|
||
}
|
||
|
||
function closeSource() {
|
||
sourceRow.value = null;
|
||
sidePanelRow.value = null;
|
||
sourceContent.value = "";
|
||
sourceError.value = "";
|
||
sourceEditing.value = false;
|
||
sourceEditableText.value = "";
|
||
sourceDraft.value = "";
|
||
sourceSaveError.value = "";
|
||
}
|
||
|
||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||
if (isTauriRuntime()) {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||
const path = await save({
|
||
defaultPath: defaultFileName,
|
||
filters: [{ name: filterName, extensions: [filterExt] }],
|
||
});
|
||
if (path) await writeTextFile(path, content);
|
||
} else {
|
||
const blob = new Blob([content], { type: "text/plain" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = defaultFileName;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
function openViewData(row: ObjectBrowserRow) {
|
||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: row.type, catalog: props.catalog });
|
||
}
|
||
|
||
function openStructureEditor(row: ObjectBrowserRow) {
|
||
if (row.type !== "TABLE") return;
|
||
queryStore.openTableStructure(props.connection.id, props.database, row.schema || selectedSchema.value, row.name, undefined, undefined, props.catalog);
|
||
}
|
||
|
||
function droppedTableObjectTypeForRow(row: ObjectBrowserRow): "TABLE" | "VIEW" | "MATERIALIZED_VIEW" | null {
|
||
if (row.type === "TABLE") return "TABLE";
|
||
if (row.type === "VIEW") return "VIEW";
|
||
if (row.type === "MATERIALIZED_VIEW") return "MATERIALIZED_VIEW";
|
||
return null;
|
||
}
|
||
|
||
function closeDroppedTableObjectTabsForRow(row: ObjectBrowserRow) {
|
||
const objectType = droppedTableObjectTypeForRow(row);
|
||
if (!objectType) return;
|
||
queryStore.closeDroppedTableObjectTabs({
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
name: row.name,
|
||
objectType,
|
||
});
|
||
}
|
||
|
||
function openDiagram(row: ObjectBrowserRow) {
|
||
connectionStore.diagramSource = {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.type === "TABLE" ? row.name : undefined,
|
||
};
|
||
}
|
||
|
||
function openTableImport(row: ObjectBrowserRow) {
|
||
if (row.type !== "TABLE") return;
|
||
connectionStore.tableImportSource = {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
};
|
||
}
|
||
|
||
function openDataCompare(row: ObjectBrowserRow) {
|
||
connectionStore.dataCompareSource = {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.type === "TABLE" ? row.name : undefined,
|
||
};
|
||
}
|
||
|
||
function openDatabaseExport(row: ObjectBrowserRow) {
|
||
connectionStore.databaseExportSource = {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.type === "TABLE" || row.type === "VIEW" || row.type === "MATERIALIZED_VIEW" ? row.name : undefined,
|
||
};
|
||
}
|
||
|
||
function setSelectedTableIds(ids: Set<string>) {
|
||
selectedTableIds.value = new Set(ids);
|
||
}
|
||
|
||
function toggleTableSelection(row: ObjectBrowserRow) {
|
||
if (row.type !== "TABLE") return;
|
||
const next = new Set(selectedTableIds.value);
|
||
if (next.has(row.id)) {
|
||
next.delete(row.id);
|
||
} else {
|
||
next.add(row.id);
|
||
}
|
||
setSelectedTableIds(next);
|
||
}
|
||
|
||
function toggleVisibleTableSelection() {
|
||
const next = new Set(selectedTableIds.value);
|
||
if (allVisibleTablesSelected.value) {
|
||
for (const row of visibleSelectableRows.value) next.delete(row.id);
|
||
} else {
|
||
for (const row of visibleSelectableRows.value) next.add(row.id);
|
||
}
|
||
setSelectedTableIds(next);
|
||
}
|
||
|
||
function clearTableSelection() {
|
||
setSelectedTableIds(new Set());
|
||
}
|
||
|
||
function openBatchDatabaseExport() {
|
||
const selectedTables = selectedTableRows.value.map((row) => row.name);
|
||
if (selectedTables.length === 0) return;
|
||
connectionStore.databaseExportSource = {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: selectedTableRows.value[0]?.schema || selectedSchema.value,
|
||
tableNames: selectedTables,
|
||
};
|
||
}
|
||
|
||
async function fetchSortedTableRowsForDrop(): Promise<ObjectBrowserRow[]> {
|
||
const rows = [...selectedTableRows.value];
|
||
if (rows.length <= 1) return rows;
|
||
|
||
const fkResults = await Promise.all(rows.map((row) => api.listForeignKeys(props.connection.id, props.database, row.schema || selectedSchema.value || "", row.name, props.catalog).catch(() => [] as ForeignKeyInfo[])));
|
||
|
||
const tablesWithFk: TableWithFk[] = rows.map((row, i) => ({
|
||
name: row.name,
|
||
schema: row.schema || selectedSchema.value,
|
||
foreignKeys: fkResults[i] ?? [],
|
||
}));
|
||
|
||
const sorted = sortTablesByFkDependency(tablesWithFk);
|
||
const nameToRow = new Map(rows.map((r) => [r.name, r]));
|
||
return sorted.map((t) => nameToRow.get(t.name)!).filter(Boolean);
|
||
}
|
||
|
||
async function refreshBatchDropPreviewSql() {
|
||
const statements: string[] = [];
|
||
const sortedRows = await fetchSortedTableRowsForDrop();
|
||
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
|
||
for (const row of sortedRows) {
|
||
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })).catch(() => "");
|
||
if (sql) statements.push(sql);
|
||
}
|
||
batchDropPreviewSql.value = statements.join("\n");
|
||
}
|
||
|
||
function requestBatchDropTables() {
|
||
if (selectedTableCount.value === 0) return;
|
||
batchDropCascade.value = false;
|
||
batchDropProgress.value = { completed: 0, total: 0 };
|
||
batchDropPreviewSql.value = "";
|
||
void refreshBatchDropPreviewSql();
|
||
showBatchDropConfirm.value = true;
|
||
}
|
||
|
||
async function confirmBatchDropTables() {
|
||
if (batchDropExecuting.value) return;
|
||
batchDropExecuting.value = true;
|
||
try {
|
||
const targets = await fetchSortedTableRowsForDrop();
|
||
if (targets.length === 0) return;
|
||
batchDropProgress.value = { completed: 0, total: targets.length };
|
||
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
|
||
const plan = await Promise.all(
|
||
targets.map(async (target) => ({
|
||
target,
|
||
sql: await buildDropTableSql(tableAdminSqlOptions(target, { cascade: useCascade })),
|
||
})),
|
||
);
|
||
const batchSql = plan.map(({ sql }) => sql).join(";\n");
|
||
const result = await executeObjectBrowserSqlWithProductionGuard(batchSql, () =>
|
||
runBatchTableDrop({
|
||
databaseType: effectiveDatabaseType.value,
|
||
plan,
|
||
executeStatement: (sql) => api.executeQuery(props.connection.id, props.database, sql),
|
||
executeBatch: (sql, onProgress) => api.executeMultiWithProgress(props.connection.id, props.database, sql, onProgress),
|
||
onProgress: (progress) => {
|
||
batchDropProgress.value = { completed: Math.min(progress.completed, targets.length), total: targets.length };
|
||
},
|
||
}),
|
||
);
|
||
if (!result) return;
|
||
|
||
for (const row of result.succeeded) closeDroppedTableObjectTabsForRow(row);
|
||
if (result.succeeded.length > 0) {
|
||
removePinnedObjectBrowserRows(result.succeeded);
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||
}
|
||
|
||
if (result.failed) throw result.failed;
|
||
toast(t("objects.batchDropSuccess", { count: result.succeeded.length }));
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
} finally {
|
||
batchDropExecuting.value = false;
|
||
batchDropProgress.value = { completed: 0, total: 0 };
|
||
showBatchDropConfirm.value = false;
|
||
}
|
||
}
|
||
|
||
async function refreshBatchTruncatePreviewSql() {
|
||
const statements: string[] = [];
|
||
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
|
||
for (const row of selectedTableRows.value) {
|
||
const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })).catch(() => "");
|
||
if (sql) statements.push(sql);
|
||
}
|
||
batchTruncatePreviewSql.value = statements.join("\n");
|
||
}
|
||
|
||
function requestBatchTruncateTables() {
|
||
if (selectedTableCount.value === 0 || !supportsTruncateTable.value) return;
|
||
batchTruncateCascade.value = false;
|
||
batchTruncatePreviewSql.value = "";
|
||
void refreshBatchTruncatePreviewSql();
|
||
showBatchTruncateConfirm.value = true;
|
||
}
|
||
|
||
function tableDataRefreshTargetForRow(row: ObjectBrowserRow) {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
schemaCandidates: [row.schema, selectedSchema.value],
|
||
catalog: props.catalog,
|
||
name: row.name,
|
||
};
|
||
}
|
||
|
||
async function refreshMutatedTableDataTabsForRows(rows: readonly ObjectBrowserRow[]) {
|
||
for (const row of rows) {
|
||
const target = tableDataRefreshTargetForRow(row);
|
||
try {
|
||
await queryStore.refreshDataTabsForTable(target);
|
||
} catch (error) {
|
||
console.warn("[DBX][table-data-refresh-after-mutation:error]", { target, error });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function confirmBatchTruncateTables() {
|
||
const targets = [...selectedTableRows.value];
|
||
if (targets.length === 0) return;
|
||
try {
|
||
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
|
||
const statements = await Promise.all(
|
||
targets.map(async (row) => ({
|
||
row,
|
||
sql: await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
|
||
})),
|
||
);
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
|
||
await runBatchTableTruncate(
|
||
statements,
|
||
async ({ sql }) => {
|
||
await api.executeQuery(props.connection.id, props.database, sql);
|
||
},
|
||
async (succeeded) => refreshMutatedTableDataTabsForRows(succeeded.map(({ row }) => row)),
|
||
);
|
||
return true;
|
||
});
|
||
if (!executed) return;
|
||
toast(t("objects.batchTruncateSuccess", { count: targets.length }));
|
||
clearTableSelection();
|
||
showBatchTruncateConfirm.value = false;
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
async function refreshBatchEmptyPreviewSql(targets: ObjectBrowserRow[]) {
|
||
const plan = await buildBatchTableEmptyPlan(targets, (row) => buildEmptyTableSql(tableAdminSqlOptions(row)));
|
||
// Freeze the reviewed SQL with its target so confirmation cannot execute a different destructive statement.
|
||
batchEmptyPlan.value = plan;
|
||
batchEmptyPreviewSql.value = plan.map(({ sql }) => sql).join("\n");
|
||
}
|
||
|
||
function requestBatchEmptyTables() {
|
||
const targets = [...selectedTableRows.value];
|
||
if (targets.length === 0) return;
|
||
batchEmptyPlan.value = [];
|
||
batchEmptyPreviewSql.value = "";
|
||
void refreshBatchEmptyPreviewSql(targets)
|
||
.then(() => {
|
||
showBatchEmptyConfirm.value = true;
|
||
})
|
||
.catch((e: any) => {
|
||
batchEmptyPlan.value = [];
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
});
|
||
}
|
||
|
||
async function confirmBatchEmptyTables() {
|
||
const plan = batchEmptyPlan.value.slice();
|
||
if (plan.length === 0) return;
|
||
const asynchronousMutation = effectiveDatabaseType.value === "clickhouse";
|
||
const reviewSql = plan.map(({ sql }) => sql).join(";\n");
|
||
const result = await executeObjectBrowserSqlWithProductionGuard(reviewSql, () => {
|
||
return runBatchTableEmpty(plan, async ({ sql }) => {
|
||
await api.executeQuery(props.connection.id, props.database, sql);
|
||
});
|
||
});
|
||
if (!result) return;
|
||
for (const failure of result.failed) {
|
||
console.error(`Failed to empty table "${failure.target.target.name}":`, failure.error);
|
||
}
|
||
const feedback = batchTableEmptyFeedback(result, asynchronousMutation);
|
||
if (feedback === "success") {
|
||
toast(t("contextMenu.batchEmptySuccess", { count: result.succeeded.length }), 3000);
|
||
} else if (feedback === "submitted") {
|
||
toast(t("contextMenu.batchEmptySubmitted", { count: result.succeeded.length }), 3000);
|
||
} else if (feedback === "submitted-partial") {
|
||
toast(t("contextMenu.batchEmptySubmittedPartial", { success: result.succeeded.length, failed: result.failed.length }), 5000);
|
||
} else {
|
||
toast(t("contextMenu.batchEmptyPartialFail", { success: result.succeeded.length, failed: result.failed.length }), 5000);
|
||
}
|
||
batchEmptyPlan.value = [];
|
||
showBatchEmptyConfirm.value = false;
|
||
if (result.succeeded.length > 0) {
|
||
clearTableSelection();
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||
}
|
||
}
|
||
|
||
async function exportStructure(row: ObjectBrowserRow) {
|
||
try {
|
||
const schema = row.schema || selectedSchema.value || props.database;
|
||
const ddl = await api.getTableDdl(props.connection.id, props.database, schema, row.name, tableDdlObjectType(row.type), props.catalog);
|
||
await saveFileContent(buildSingleDdlExportFileContent(ddl), `${row.name}.sql`, "SQL", "sql");
|
||
} catch (e: any) {
|
||
console.error("Export structure failed:", e);
|
||
}
|
||
}
|
||
|
||
function tableDdlObjectType(type: ObjectBrowserRow["type"]): ObjectSourceKind | undefined {
|
||
if (type === "VIEW" || type === "MATERIALIZED_VIEW") return type;
|
||
return undefined;
|
||
}
|
||
|
||
async function exportDataLegacy(row: ObjectBrowserRow, format: "json" | "sql") {
|
||
try {
|
||
const schema = row.schema || selectedSchema.value;
|
||
const tableColumns = format === "sql" ? await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog) : undefined;
|
||
const queryColumns = props.connection.db_type === "neo4j" ? (tableColumns ?? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog))).map((column) => column.name) : undefined;
|
||
const result = await fetchTableDataForExport({
|
||
databaseType: effectiveDatabaseType.value,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
schema,
|
||
tableName: row.name,
|
||
columns: queryColumns,
|
||
executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql),
|
||
});
|
||
|
||
if (format === "json") {
|
||
let outputPath = `${row.name}.json`;
|
||
if (isTauriRuntime()) {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const path = await save({
|
||
defaultPath: outputPath,
|
||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||
});
|
||
if (!path) return;
|
||
outputPath = path as string;
|
||
}
|
||
await api.exportQueryResultJson(outputPath, result.columns, result.rows);
|
||
toast(t("grid.exported"));
|
||
return;
|
||
}
|
||
|
||
const content = await formatSqlInsert({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema,
|
||
tableName: row.name,
|
||
columns: result.columns,
|
||
columnTypes: tableColumns ? columnTypesForResultColumns(result.columns, tableColumns) : undefined,
|
||
rows: result.rows,
|
||
});
|
||
await saveFileContent(content, `${row.name}.sql`, "SQL", "sql");
|
||
toast(t("grid.exported"));
|
||
} catch (e: any) {
|
||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
function columnTypesForResultColumns(columns: string[], tableColumns: Array<{ name: string; data_type: string }>): Array<string | undefined> {
|
||
const typesByName = new Map(tableColumns.map((column) => [column.name.toLocaleLowerCase(), column.data_type]));
|
||
return columns.map((column) => typesByName.get(column.toLocaleLowerCase()));
|
||
}
|
||
|
||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||
if (format === "csv") {
|
||
await exportTableData(row, "csv");
|
||
return;
|
||
}
|
||
await exportDataLegacy(row, format);
|
||
}
|
||
|
||
async function exportDataXlsx(row: ObjectBrowserRow) {
|
||
await exportTableData(row, "xlsx");
|
||
}
|
||
|
||
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx") {
|
||
const schema = row.schema || selectedSchema.value;
|
||
|
||
// Save dialog first
|
||
let filePath = "";
|
||
const defaultName = `${row.name}.${format}`;
|
||
|
||
if (isTauriRuntime()) {
|
||
try {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : { name: "Excel", extensions: ["xlsx"] };
|
||
const path = await save({
|
||
defaultPath: defaultName,
|
||
filters: [filter],
|
||
});
|
||
if (!path) return;
|
||
filePath = path as string;
|
||
} catch (e: any) {
|
||
toast(e?.message || String(e), 5000);
|
||
return;
|
||
}
|
||
} else {
|
||
const webExportId = generateDatabaseExportId();
|
||
filePath = `__web_export_${webExportId}.${format}`;
|
||
}
|
||
|
||
let task: ExportTask | null = null;
|
||
try {
|
||
const queryColumns = props.connection.db_type === "neo4j" ? (await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog)).map((column) => column.name) : undefined;
|
||
|
||
task = addExportTask(row.name, format, filePath);
|
||
const currentTask = task;
|
||
const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null;
|
||
const request: api.TableExportRequest = {
|
||
exportId: currentTask.exportId,
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema,
|
||
tableName: row.name,
|
||
filePath,
|
||
format,
|
||
columns: queryColumns,
|
||
batchSize: settingsStore.editorSettings.exportBatchSize,
|
||
rowLimit,
|
||
};
|
||
|
||
const terminalProgress = await api.startTableExport(request, (progress) => {
|
||
currentTask.rowsExported = progress.rowsExported;
|
||
currentTask.totalRows = progress.totalRows;
|
||
currentTask.status = progress.status;
|
||
currentTask.errorMessage = progress.errorMessage || null;
|
||
});
|
||
if (terminalProgress.status === "Done") {
|
||
toast(t("grid.exported"));
|
||
}
|
||
} catch (e: any) {
|
||
if (task) {
|
||
task.status = "Error";
|
||
task.errorMessage = e?.message || String(e);
|
||
}
|
||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
function requestDuplicateStructure(row: ObjectBrowserRow) {
|
||
duplicateTarget.value = row;
|
||
duplicateTableName.value = `${row.name}_copy`;
|
||
showDuplicateDialog.value = true;
|
||
}
|
||
|
||
async function buildDuplicateStructurePlan(sourceName: string, targetName: string, schema: string | undefined, sourceColumns?: ColumnInfo[]) {
|
||
let columns = sourceColumns;
|
||
if (effectiveDatabaseType.value === "dameng" && !columns) {
|
||
try {
|
||
columns = await api.getColumns(props.connection.id, props.database, schema || "", sourceName, props.catalog);
|
||
} catch (error) {
|
||
console.warn(`Failed to load Dameng column comments for table clone: ${sourceName}`, error);
|
||
}
|
||
}
|
||
const columnComments = effectiveDatabaseType.value === "dameng" ? collectDuplicateTableColumnComments(columns ?? []) : [];
|
||
const sql = await buildDuplicateTableStructureSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema,
|
||
sourceName,
|
||
targetName,
|
||
columnComments,
|
||
});
|
||
return { sql, sourceColumns: columns, executeAsScript: columnComments.length > 0 };
|
||
}
|
||
|
||
function executeDuplicateStructurePlan(plan: { sql: string; executeAsScript: boolean }, schema: string | undefined) {
|
||
return plan.executeAsScript ? api.executeScript(props.connection.id, props.database, plan.sql, schema) : api.executeQuery(props.connection.id, props.database, plan.sql, schema);
|
||
}
|
||
|
||
async function confirmDuplicateStructure() {
|
||
const row = duplicateTarget.value;
|
||
const newName = duplicateTableName.value.trim();
|
||
if (!row || !newName) return;
|
||
showDuplicateDialog.value = false;
|
||
try {
|
||
const schema = row.schema || selectedSchema.value;
|
||
const plan = await buildDuplicateStructurePlan(row.name, newName, schema);
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
|
||
if (!executed) return;
|
||
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
function copySelectedTablesToClipboard() {
|
||
const selectedRows = selectedTableRows.value;
|
||
if (selectedRows.length === 0) return;
|
||
connectionStore.treeClipboard = {
|
||
kind: "table-copy",
|
||
tables: selectedRows.map((row) => ({
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
})),
|
||
};
|
||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||
}
|
||
|
||
function canPasteTableClipboard(): boolean {
|
||
const clipboard = connectionStore.treeClipboard;
|
||
return clipboard?.kind === "table-copy" && tableClipboardMatchesTarget(clipboard.tables, pasteTableTargetContext());
|
||
}
|
||
|
||
function pasteTableTargetContext(): TableClipboardContext {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: selectedSchema.value,
|
||
};
|
||
}
|
||
|
||
function copySingleTableToClipboard(row: ObjectBrowserRow) {
|
||
connectionStore.treeClipboard = {
|
||
kind: "table-copy",
|
||
tables: [
|
||
{
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
},
|
||
],
|
||
};
|
||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||
}
|
||
|
||
function openPasteTableDialog() {
|
||
const clipboard = connectionStore.treeClipboard;
|
||
if (!canPasteTableClipboard() || clipboard?.kind !== "table-copy") {
|
||
toast(t("contextMenu.noTableToPaste"), 2000);
|
||
return;
|
||
}
|
||
pasteTableMode.value = defaultPasteTableMode(effectiveDatabaseType.value);
|
||
pasteTableEntries.value = clipboard.tables.map((entry) => ({
|
||
sourceName: entry.tableName,
|
||
targetName: `${entry.tableName}_copy`,
|
||
schema: entry.schema,
|
||
}));
|
||
showPasteDialog.value = true;
|
||
}
|
||
|
||
function onObjectBrowserKeydown(event: KeyboardEvent) {
|
||
if (event.defaultPrevented) return;
|
||
if (eventTargetAllowsAppClipboardShortcut(event, "c")) {
|
||
if (selectedTableCount.value === 0) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
copySelectedTablesToClipboard();
|
||
return;
|
||
}
|
||
if (eventTargetAllowsAppClipboardShortcut(event, "v")) {
|
||
if (!canPasteTableClipboard()) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openPasteTableDialog();
|
||
}
|
||
}
|
||
|
||
async function confirmPasteTable() {
|
||
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
|
||
if (entries.length === 0) return;
|
||
const mode = pasteTableMode.value;
|
||
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
|
||
showPasteDialog.value = false;
|
||
let successCount = 0;
|
||
let failCount = 0;
|
||
for (const entry of entries) {
|
||
const targetName = entry.targetName.trim();
|
||
const schema = entry.schema || selectedSchema.value;
|
||
try {
|
||
let sourceColumns: ColumnInfo[] | undefined;
|
||
if (mode === "structure-and-data" || mode === "structure-only") {
|
||
const plan = await buildDuplicateStructurePlan(entry.sourceName, targetName, schema, sourceColumns);
|
||
sourceColumns = plan.sourceColumns;
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
|
||
if (!executed) return;
|
||
}
|
||
if (copyData) {
|
||
sourceColumns ??= await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName, props.catalog);
|
||
const dataCopyColumnOptions = tableDataCopyColumnOptions(effectiveDatabaseType.value, sourceColumns);
|
||
if (dataCopyColumnOptions.columns.length === 0) {
|
||
throw new Error("No writable columns available for table data copy.");
|
||
}
|
||
const dataSql = await buildCopyTableDataSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema,
|
||
sourceName: entry.sourceName,
|
||
targetName,
|
||
...dataCopyColumnOptions,
|
||
});
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
|
||
if (!executed) return;
|
||
}
|
||
successCount++;
|
||
} catch (e: any) {
|
||
failCount++;
|
||
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
|
||
}
|
||
}
|
||
if (failCount === 0) {
|
||
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
|
||
} else {
|
||
toast(t("contextMenu.batchPastePartialFail", { success: successCount, failed: failCount }), 5000);
|
||
}
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||
}
|
||
|
||
function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boolean }): TableAdminSqlOptions {
|
||
const result: TableAdminSqlOptions = {
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
};
|
||
if (options?.cascade) result.cascade = true;
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Routes Object Browser writes through the shared production gate. The SQL is
|
||
* assessed before the callback runs so generated DDL cannot bypass protection
|
||
* via executable comments, EXPLAIN ANALYZE, or qualified production targets.
|
||
*/
|
||
async function executeObjectBrowserSqlWithProductionGuard<T>(sql: string, execute: () => Promise<T>): Promise<T | undefined> {
|
||
return executeWithProductionSqlGuard({
|
||
connection: props.connection,
|
||
database: props.database,
|
||
sql,
|
||
source: t("production.sourceObjectBrowser"),
|
||
execute,
|
||
});
|
||
}
|
||
|
||
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
|
||
truncatePreviewSql.value = "";
|
||
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => "");
|
||
}
|
||
|
||
function requestTruncateTable(row: ObjectBrowserRow) {
|
||
truncateTarget.value = row;
|
||
truncateTableCascade.value = false;
|
||
void refreshTruncatePreviewSql(row);
|
||
showTruncateConfirm.value = true;
|
||
}
|
||
|
||
async function confirmTruncateTable() {
|
||
const row = truncateTarget.value;
|
||
if (!row) return;
|
||
try {
|
||
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })));
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||
if (!executed) return;
|
||
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
|
||
await refreshMutatedTableDataTabsForRows([row]);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
truncateTarget.value = null;
|
||
}
|
||
|
||
async function refreshEmptyPreviewSql(row: ObjectBrowserRow) {
|
||
emptyPreviewSql.value = "";
|
||
emptyPreviewSql.value = await buildEmptyTableSql(tableAdminSqlOptions(row)).catch(() => "");
|
||
}
|
||
|
||
function requestEmptyTable(row: ObjectBrowserRow) {
|
||
emptyTarget.value = row;
|
||
void refreshEmptyPreviewSql(row);
|
||
showEmptyConfirm.value = true;
|
||
}
|
||
|
||
async function confirmEmptyTable() {
|
||
const row = emptyTarget.value;
|
||
if (!row) return;
|
||
try {
|
||
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
|
||
if (!executed) return;
|
||
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
|
||
await refreshMutatedTableDataTabsForRows([row]);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
emptyTarget.value = null;
|
||
}
|
||
|
||
async function copyName(row: ObjectBrowserRow) {
|
||
try {
|
||
await copyToClipboard(row.name);
|
||
toast(t("connection.copied"), 2000);
|
||
} catch (e: any) {
|
||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
async function copySource() {
|
||
if (!sourceContent.value) return;
|
||
try {
|
||
await copyToClipboard(sourceContent.value);
|
||
toast(t("grid.copied"));
|
||
} catch (e: any) {
|
||
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
function editSource() {
|
||
if (!sourceRow.value || !sourceEditableText.value) return;
|
||
if (!sourceCanEdit.value) {
|
||
toast(t("objects.sourceReadOnly"), 3000);
|
||
return;
|
||
}
|
||
sourceDraft.value = sourceEditableText.value;
|
||
sourceSaveError.value = "";
|
||
sourceEditing.value = true;
|
||
}
|
||
|
||
function cancelEditSource() {
|
||
sourceEditing.value = false;
|
||
sourceDraft.value = "";
|
||
sourceSaveError.value = "";
|
||
}
|
||
|
||
async function saveSource() {
|
||
if (!sourceCanEdit.value) {
|
||
toast(t("objects.sourceReadOnly"), 3000);
|
||
return;
|
||
}
|
||
if (!sourceRow.value || !sourceDraft.value.trim()) return;
|
||
const row = sourceRow.value;
|
||
const epoch = sidePanelGuard.capture();
|
||
const connectionId = props.connection.id;
|
||
const database = props.database;
|
||
const schema = row.schema || selectedSchema.value || database;
|
||
sourceSaving.value = true;
|
||
sourceSaveError.value = "";
|
||
try {
|
||
const statements = await buildExecutableObjectSourceStatements({
|
||
databaseType: effectiveDatabaseType.value,
|
||
objectType: row.type as ObjectSourceKind,
|
||
schema,
|
||
name: row.name,
|
||
source: sourceDraft.value,
|
||
});
|
||
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
|
||
if (executableSql.trim()) {
|
||
const saved = await executeWithProductionSqlGuard({
|
||
connection: props.connection,
|
||
database,
|
||
sql: executableSql,
|
||
source: t("production.sourceObjectSource"),
|
||
execute: async () => {
|
||
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
|
||
return true;
|
||
},
|
||
});
|
||
if (!saved || sidePanelGuard.isStale(epoch)) return;
|
||
} else {
|
||
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
}
|
||
toast(t("objects.sourceSaved"));
|
||
sourceEditing.value = false;
|
||
sourceDraft.value = "";
|
||
await openSource(row);
|
||
} catch (e: unknown) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
sourceSaveError.value = formatObjectSourceSaveError(e, effectiveDatabaseType.value, row.type as ObjectSourceKind, t("objects.postgresViewColumnChangeHint"));
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) sourceSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadSchemas(epoch: number): Promise<boolean> {
|
||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||
if (!needsSchema.value) {
|
||
schemas.value = [];
|
||
selectedSchema.value = undefined;
|
||
return true;
|
||
}
|
||
loadingSchemas.value = true;
|
||
const connectionId = props.connection.id;
|
||
const database = props.database;
|
||
try {
|
||
const names = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), props.connection, database, {
|
||
showSystemSchemas: props.connection.show_system_schemas === true,
|
||
});
|
||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false;
|
||
schemas.value = names;
|
||
if (names.length === 0) {
|
||
selectedSchema.value = undefined;
|
||
return true;
|
||
}
|
||
if (!selectedSchema.value || !names.includes(selectedSchema.value)) {
|
||
selectedSchema.value = names.includes("public") ? "public" : names[0];
|
||
}
|
||
return true;
|
||
} finally {
|
||
if (objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false;
|
||
}
|
||
}
|
||
|
||
function objectBrowserRowsCacheScope(schema: string): ObjectBrowserRowsCacheScope {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema,
|
||
catalog: props.catalog,
|
||
};
|
||
}
|
||
|
||
function applyObjectBrowserRows(nextRows: ObjectBrowserRow[]) {
|
||
rows.value = nextRows;
|
||
const availableTableIds = new Set(rows.value.filter((row) => row.type === "TABLE").map((row) => row.id));
|
||
setSelectedTableIds(new Set([...selectedTableIds.value].filter((id) => availableTableIds.has(id))));
|
||
expandedPartitionParentIds.value = new Set([...expandedPartitionParentIds.value].filter((id) => rows.value.some((row) => row.id === id && row.partitionCount)));
|
||
}
|
||
|
||
function finishObjectBrowserRowsLoad() {
|
||
loadingObjects.value = false;
|
||
if (!userHasSelectedFilter.value && objectCounts.value.tables > 0) {
|
||
// The default table filter is a presentation choice, not a user query
|
||
// change, so preserve the tab's saved scroll offset across remounts.
|
||
preserveObjectFilterScrollOnce = objectFilter.value !== "tables";
|
||
objectFilter.value = "tables";
|
||
}
|
||
restoreObjectBrowserViewport();
|
||
}
|
||
|
||
async function loadObjects(options?: { allowCached?: boolean }) {
|
||
error.value = "";
|
||
const schema = needsSchema.value ? selectedSchema.value || "" : props.database;
|
||
const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema));
|
||
const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope);
|
||
if (options?.allowCached) {
|
||
const cachedRows = getCachedObjectBrowserRows(request.scope);
|
||
if (cachedRows) {
|
||
applyObjectBrowserRows(cachedRows);
|
||
finishObjectBrowserRowsLoad();
|
||
return;
|
||
}
|
||
}
|
||
|
||
loadingObjects.value = true;
|
||
rows.value = [];
|
||
try {
|
||
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
|
||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||
const nextRows = buildObjectBrowserRows({
|
||
objects,
|
||
database: request.scope.database,
|
||
fallbackSchema: request.scope.schema,
|
||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||
});
|
||
applyObjectBrowserRows(nextRows);
|
||
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
|
||
void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||
} catch (e: any) {
|
||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||
error.value = e?.message || String(e);
|
||
} finally {
|
||
if (objectBrowserRowsLoadGuard.isCurrent(request)) finishObjectBrowserRowsLoad();
|
||
}
|
||
}
|
||
|
||
async function loadObjectStatistics(request: ObjectBrowserRowsLoadHandle, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
|
||
if (!rows.value.some((row) => row.type === "TABLE")) return;
|
||
try {
|
||
const stats = await api.listObjectStatistics(request.scope.connectionId, request.scope.database, request.scope.schema);
|
||
if (!objectBrowserRowsLoadGuard.isCurrent(request) || stats.length === 0) return;
|
||
mergeObjectStatistics(stats, request.scope.schema, cacheWriteToken, cachedAt);
|
||
} catch (e) {
|
||
console.debug("[ObjectBrowser] table statistics unavailable", e);
|
||
}
|
||
}
|
||
|
||
function mergeObjectStatistics(stats: ObjectStatistics[], fallbackSchema: string, cacheWriteToken: ObjectBrowserRowsCacheWriteToken, cachedAt: number | undefined) {
|
||
const statsByKey = new Map(stats.map((stat) => [objectStatisticKey(stat.schema || fallbackSchema, stat.name), stat]));
|
||
rows.value = rows.value.map((row) => {
|
||
if (row.type !== "TABLE") return row;
|
||
const stat = statsByKey.get(objectStatisticKey(row.schema || fallbackSchema, row.name));
|
||
if (!stat) return row;
|
||
return {
|
||
...row,
|
||
estimatedRows: normalizeStatisticNumber(stat.estimated_rows),
|
||
totalBytes: normalizeStatisticNumber(stat.total_bytes),
|
||
};
|
||
});
|
||
cacheObjectBrowserRows(cacheWriteToken, rows.value, { cachedAt });
|
||
}
|
||
|
||
function objectStatisticKey(schema: string | undefined, name: string) {
|
||
return `${schema || ""}\0${name}`.toLowerCase();
|
||
}
|
||
|
||
function normalizeStatisticNumber(value: number | null | undefined): number | null {
|
||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||
}
|
||
|
||
async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number }) {
|
||
const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate();
|
||
if (!(await loadSchemas(epoch))) return;
|
||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return;
|
||
await loadObjects({ allowCached: options?.allowCachedObjects });
|
||
}
|
||
|
||
function refresh(): boolean {
|
||
void reload();
|
||
return true;
|
||
}
|
||
|
||
function onSchemaChange(value: any) {
|
||
selectedSchema.value = typeof value === "string" && value ? value : undefined;
|
||
emit("schemaChange", selectedSchema.value);
|
||
userHasSelectedFilter.value = false;
|
||
objectFilter.value = "all";
|
||
void loadObjects();
|
||
}
|
||
|
||
function filterCount(filter: ObjectFilter) {
|
||
return objectSearchSummary.value.counts[filter];
|
||
}
|
||
|
||
function filterLabel(filter: ObjectFilter) {
|
||
const key =
|
||
filter === "tables"
|
||
? "objects.tables"
|
||
: filter === "views"
|
||
? "objects.views"
|
||
: filter === "materializedViews"
|
||
? "tree.materializedViews"
|
||
: filter === "procedures"
|
||
? "objects.procedures"
|
||
: filter === "functions"
|
||
? "objects.functions"
|
||
: filter === "triggers"
|
||
? "tree.triggers"
|
||
: filter === "sequences"
|
||
? "objects.sequences"
|
||
: filter === "packages"
|
||
? "objects.packages"
|
||
: filter === "types"
|
||
? "tree.types"
|
||
: "objects.all";
|
||
return `${t(key)} ${filterCount(filter)}`;
|
||
}
|
||
|
||
function getSearchInput(): HTMLInputElement | null {
|
||
return rootRef.value?.querySelector<HTMLInputElement>("[data-object-search-input]") ?? null;
|
||
}
|
||
|
||
function focusSearch(): boolean {
|
||
const input = getSearchInput();
|
||
if (!input) return false;
|
||
input.focus();
|
||
input.select();
|
||
return true;
|
||
}
|
||
|
||
function onSearchKeydown(event: KeyboardEvent) {
|
||
if (!isCancelSearchShortcut(event)) return;
|
||
event.preventDefault();
|
||
search.value = "";
|
||
}
|
||
|
||
defineExpose({ focusSearch, refresh });
|
||
|
||
onBeforeUnmount(() => {
|
||
objectBrowserRowsLoadGuard.invalidate();
|
||
stopColumnResize?.();
|
||
});
|
||
|
||
watch(
|
||
[() => props.connection.id, () => props.database, () => props.schema],
|
||
async () => {
|
||
const contextEpoch = objectBrowserRowsLoadGuard.invalidate();
|
||
selectedSchema.value = props.schema;
|
||
userHasSelectedFilter.value = false;
|
||
objectFilter.value = "all";
|
||
clearTableSelection();
|
||
// Close side panel and invalidate any pending source/table-info requests
|
||
// so stale results from the old context don't overwrite new state.
|
||
closeSidePanel();
|
||
sourceRow.value = null;
|
||
sourceContent.value = "";
|
||
sourceError.value = "";
|
||
sourceLoading.value = false;
|
||
sourceEditing.value = false;
|
||
sourceSaving.value = false;
|
||
try {
|
||
await connectionStore.ensureConnected(props.connection.id);
|
||
} catch (e) {
|
||
console.warn("[DBX] ensureConnected failed for", props.connection.id, e);
|
||
}
|
||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(contextEpoch)) return;
|
||
void reload({ allowCachedObjects: true, contextEpoch });
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
// ---- CustomContextMenu helpers ----
|
||
|
||
function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
|
||
return {
|
||
label: t("contextMenu.exportData"),
|
||
icon: Upload,
|
||
children: [
|
||
{ label: "CSV", action: () => exportData(item, "csv") },
|
||
{ label: "JSON", action: () => exportData(item, "json") },
|
||
{ label: "SQL INSERT", action: () => exportData(item, "sql") },
|
||
{ label: "XLSX", action: () => exportDataXlsx(item) },
|
||
],
|
||
};
|
||
}
|
||
|
||
function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
|
||
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
|
||
}
|
||
|
||
function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batchEmpty"): string {
|
||
return t(`contextMenu.${key}`, { count: selectedTableCount.value });
|
||
}
|
||
|
||
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
const useBatchActions = isSelectedBatchTableContext(item);
|
||
return [
|
||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||
{
|
||
label: t("contextMenu.viewDdl"),
|
||
action: () => openTableInfo(item, "ddl"),
|
||
icon: FileCode,
|
||
},
|
||
...(canOpenStructureEditor.value ? [{ label: t("contextMenu.editStructure"), action: () => openStructureEditor(item), icon: PencilRuler }] : []),
|
||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
|
||
...(canOpenTableImport.value ? [{ label: t("contextMenu.importData"), action: () => openTableImport(item), icon: Download }] : []),
|
||
{ label: t("dataCompare.title"), action: () => openDataCompare(item), icon: ArrowRightLeft },
|
||
{ label: "", separator: true },
|
||
exportDataSubmenu(item),
|
||
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload },
|
||
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.duplicateStructure"), action: () => requestDuplicateStructure(item), icon: CopyPlus },
|
||
{ label: t("contextMenu.copyTable"), action: () => copySingleTableToClipboard(item), icon: Copy },
|
||
{ label: "", separator: true },
|
||
...(supportsTruncateTable.value
|
||
? [
|
||
{
|
||
label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"),
|
||
action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item),
|
||
icon: Scissors,
|
||
variant: "destructive" as const,
|
||
},
|
||
]
|
||
: []),
|
||
{
|
||
label: useBatchActions ? selectedBatchTableCountLabel("batchEmpty") : t("contextMenu.emptyTable"),
|
||
action: useBatchActions ? requestBatchEmptyTables : () => requestEmptyTable(item),
|
||
icon: Eraser,
|
||
variant: "destructive" as const,
|
||
},
|
||
{
|
||
label: useBatchActions ? selectedBatchTableCountLabel("batchDrop") : t("contextMenu.dropTable"),
|
||
action: useBatchActions ? requestBatchDropTables : () => requestDrop(item),
|
||
icon: Trash2,
|
||
variant: "destructive" as const,
|
||
},
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
|
||
function getViewMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
return [
|
||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||
{ label: t("contextMenu.editView"), action: () => openSource(item), icon: PencilLine },
|
||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||
{
|
||
label: t("contextMenu.viewDdl"),
|
||
action: () => openTableInfo(item, "ddl"),
|
||
icon: ScrollText,
|
||
},
|
||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||
...(canOpenDiagram.value ? [{ label: t("diagram.open"), action: () => openDiagram(item), icon: Network }] : []),
|
||
{ label: "", separator: true },
|
||
exportDataSubmenu(item),
|
||
{ label: t("contextMenu.exportDatabase"), action: () => openDatabaseExport(item), icon: Upload },
|
||
{ label: t("contextMenu.exportStructure"), action: () => exportStructure(item), icon: FileCode },
|
||
{ label: "", separator: true },
|
||
{
|
||
label: t("contextMenu.dropView"),
|
||
action: () => requestDrop(item),
|
||
icon: Trash2,
|
||
variant: "destructive" as const,
|
||
},
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
|
||
function getProcFuncMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
return [
|
||
...(item.type === "PROCEDURE" ? [{ label: t("contextMenu.executeProcedure"), action: () => openProcedureExecution(item), icon: Play }] : []),
|
||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||
...(canRename(item) ? [{ label: t("contextMenu.renameObject"), action: () => requestRename(item), icon: Pencil }] : []),
|
||
{ label: "", separator: true },
|
||
{
|
||
label: item.type === "PROCEDURE" ? t("contextMenu.dropProcedure") : t("contextMenu.dropFunction"),
|
||
action: () => requestDrop(item),
|
||
icon: Trash2,
|
||
variant: "destructive" as const,
|
||
},
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
|
||
function getPackageMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
return [
|
||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
|
||
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
if (item.type === "TABLE") return getTableMenuItems(item);
|
||
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
|
||
if (isSourceOnlyObjectBrowserRow(item)) return getPackageMenuItems(item);
|
||
return getProcFuncMenuItems(item);
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="rootRef" data-object-browser-root class="flex h-full min-h-0 min-w-0 flex-col bg-background outline-none" tabindex="0" @keydown="onObjectBrowserKeydown">
|
||
<div class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
|
||
<div class="flex min-w-0 items-center gap-2">
|
||
<span class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/50 px-2 py-0.5 text-xs font-medium truncate" :title="selectedSchema || props.database">
|
||
{{ selectedSchema || props.database }}
|
||
</span>
|
||
<span v-if="selectedSchema" class="inline-flex max-w-[14rem] min-w-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-xs text-muted-foreground truncate" :title="props.database">
|
||
{{ props.database }}
|
||
</span>
|
||
</div>
|
||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||
<div class="relative min-w-0 flex-1">
|
||
<Search class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input v-model="search" data-object-search-input class="h-7 pl-8 text-xs" :placeholder="t('objects.search')" @keydown="onSearchKeydown" />
|
||
</div>
|
||
<div v-if="showObjectFilter" class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||
<button
|
||
v-for="filter in objectFilters"
|
||
:key="filter"
|
||
type="button"
|
||
class="h-6 rounded-sm px-2 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||
:class="{ 'bg-background text-foreground shadow-sm': objectFilter === filter }"
|
||
@click="
|
||
userHasSelectedFilter = true;
|
||
objectFilter = filter;
|
||
"
|
||
>
|
||
{{ filterLabel(filter) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<SearchableSelect
|
||
v-if="needsSchema"
|
||
:model-value="selectedSchema || ''"
|
||
:options="schemas"
|
||
:placeholder="t('objects.schema')"
|
||
:search-placeholder="t('editor.searchSchema')"
|
||
:empty-text="t('grid.noSearchResults')"
|
||
:loading-text="t('objects.loadingSchemas')"
|
||
:loading="loadingSchemas"
|
||
:disabled="loadingSchemas"
|
||
trigger-variant="outline"
|
||
trigger-class="h-7 w-36 max-w-36 px-2 text-xs font-normal"
|
||
content-class="w-56"
|
||
@update:model-value="onSchemaChange"
|
||
/>
|
||
<!-- Sort selector -->
|
||
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||
<select
|
||
class="h-6 cursor-pointer appearance-none rounded-sm bg-transparent px-1.5 text-xs text-muted-foreground outline-none hover:text-foreground focus:text-foreground"
|
||
:value="sortKey"
|
||
:aria-label="t('objects.sortBy')"
|
||
@change="onSortKeyChange(($event.target as HTMLSelectElement).value as ObjectBrowserSortKey)"
|
||
>
|
||
<option v-for="key in sortKeyOptions" :key="key" :value="key" class="bg-background text-foreground">
|
||
{{ sortKeyLabel(key) }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :title="sortDirection === 'asc' ? t('objects.sortAsc') : t('objects.sortDesc')" @click="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
|
||
<ArrowUp v-if="sortDirection === 'asc'" class="h-3 w-3" />
|
||
<ArrowDown v-else class="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
<div class="flex h-7 shrink-0 items-center rounded border bg-muted/20 p-0.5">
|
||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': isListView }" :title="t('objects.viewList')" @click="setViewMode('list')">
|
||
<List class="h-3.5 w-3.5" />
|
||
</button>
|
||
<button type="button" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground" :class="{ 'bg-background text-foreground shadow-sm': !isListView }" :title="t('objects.viewGrid')" @click="setViewMode('grid')">
|
||
<LayoutGrid class="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'text-primary': settingsStore.editorSettings.objectBrowserShowCheckbox }" :title="t('objects.toggleCheckbox')" @click="toggleCheckboxColumn">
|
||
<CheckSquare v-if="settingsStore.editorSettings.objectBrowserShowCheckbox" class="h-3.5 w-3.5" />
|
||
<Square v-else class="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-7 w-7" :title="refreshTooltip" :disabled="loadingObjects" @click="reload">
|
||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects }" />
|
||
</Button>
|
||
<Button v-if="canPasteTableClipboard()" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openPasteTableDialog">
|
||
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.pasteTableSelected") }}
|
||
</Button>
|
||
</div>
|
||
<div v-if="selectedTableCount > 0" class="flex h-9 shrink-0 items-center gap-2 overflow-x-auto border-b bg-muted/30 px-3 text-xs">
|
||
<div class="min-w-0 flex-1 truncate text-muted-foreground">
|
||
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
|
||
</div>
|
||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
|
||
<Upload class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.exportSelected") }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
|
||
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.copyTableSelected") }}
|
||
</Button>
|
||
<Button v-if="supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
|
||
<Scissors class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.truncateSelected") }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
|
||
<Eraser class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
|
||
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.dropSelected") }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="clearTableSelection">
|
||
<X class="mr-1.5 h-3.5 w-3.5" />
|
||
{{ t("objects.clearSelection") }}
|
||
</Button>
|
||
</div>
|
||
|
||
<div v-if="loadingObjects" class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 class="h-4 w-4 animate-spin" />
|
||
{{ t("objects.loading") }}
|
||
</div>
|
||
<div v-else-if="error" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-destructive">
|
||
{{ error }}
|
||
</div>
|
||
<div v-else-if="filteredRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||
{{ t("objects.empty") }}
|
||
</div>
|
||
<div v-else class="flex min-h-0 min-w-0 flex-1">
|
||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||
<div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
|
||
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }">
|
||
<div v-if="showCheckboxColumn" class="relative flex min-w-0 items-center">
|
||
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
|
||
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
|
||
<Square v-else class="h-3.5 w-3.5" />
|
||
</button>
|
||
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)">
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
|
||
<span class="truncate">{{ t("objects.name") }}</span>
|
||
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 260, $event)">
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
|
||
<span class="truncate">{{ t("objects.type") }}</span>
|
||
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)">
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
|
||
<span class="truncate">{{ t("objects.rows") }}</span>
|
||
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div
|
||
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
||
@mousedown="onObjectColumnResizeStart('estimatedRows', $event)"
|
||
@dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)"
|
||
>
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
|
||
<span class="truncate">{{ t("objects.size") }}</span>
|
||
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div
|
||
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
||
@mousedown="onObjectColumnResizeStart('totalBytes', $event)"
|
||
@dblclick="resetObjectColumnWidth('totalBytes', 100, $event)"
|
||
>
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
|
||
<span class="truncate">{{ t("objects.createdAt") }}</span>
|
||
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div
|
||
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
||
@mousedown="onObjectColumnResizeStart('created_at', $event)"
|
||
@dblclick="resetObjectColumnWidth('created_at', 150, $event)"
|
||
>
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
|
||
<span class="truncate">{{ t("objects.updatedAt") }}</span>
|
||
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div
|
||
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
||
@mousedown="onObjectColumnResizeStart('updated_at', $event)"
|
||
@dblclick="resetObjectColumnWidth('updated_at', 150, $event)"
|
||
>
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
<div class="relative flex min-w-0 items-center">
|
||
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
|
||
<span class="truncate">{{ t("objects.comment") }}</span>
|
||
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
|
||
</button>
|
||
<div
|
||
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
|
||
@mousedown="onObjectColumnResizeStart('comment', $event)"
|
||
@dblclick="resetObjectColumnWidth('comment', 260, $event)"
|
||
>
|
||
<GripVertical class="h-3 w-3" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
|
||
<template #default="{ item }">
|
||
<CustomContextMenu :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||
<div
|
||
class="grid h-[34px] cursor-pointer items-center gap-3 border-b px-3 hover:bg-accent/50"
|
||
:class="{
|
||
'bg-accent/40': sourceRow?.id === item.id,
|
||
'bg-primary/10': sidePanelRow?.id === item.id && !selectedTableIds.has(item.id),
|
||
'bg-primary/5': selectedTableIds.has(item.id),
|
||
}"
|
||
:style="{ gridTemplateColumns, boxShadow: sidePanelRow?.id === item.id && !selectedTableIds.has(item.id) ? 'inset 3px 0 0 var(--primary)' : undefined }"
|
||
@click="onRowClick(item, $event)"
|
||
@contextmenu="onContextMenu"
|
||
>
|
||
<button v-if="showCheckboxColumn" class="flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
|
||
<Square v-else class="h-3.5 w-3.5" />
|
||
</button>
|
||
<div class="flex min-w-0 items-center gap-2">
|
||
<button
|
||
v-if="item.partitionCount"
|
||
type="button"
|
||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
:aria-label="t('objects.partitions', { count: item.partitionCount })"
|
||
@click.stop="togglePartitionParent(item)"
|
||
>
|
||
<ChevronDown v-if="isPartitionParentExpanded(item)" class="h-3.5 w-3.5" />
|
||
<ChevronRight v-else class="h-3.5 w-3.5" />
|
||
</button>
|
||
<span v-else-if="item.partitionParentId" class="ml-4 h-5 w-5 shrink-0" />
|
||
<component :is="iconFor(item)" class="h-3.5 w-3.5 shrink-0" :class="iconClass(item.type)" />
|
||
<span class="truncate text-[13px] font-medium text-foreground" :title="item.displayName">{{ item.displayName }}</span>
|
||
<span v-if="item.partitionCount" class="shrink-0 rounded border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
||
{{ t("objects.partitions", { count: item.partitionCount }) }}
|
||
</span>
|
||
</div>
|
||
<div class="truncate text-xs text-muted-foreground">{{ typeLabel(item.type) }}</div>
|
||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
|
||
{{ formatObjectBrowserCount(item.estimatedRows) }}
|
||
</div>
|
||
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
|
||
{{ formatObjectBrowserBytes(item.totalBytes) }}
|
||
</div>
|
||
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
|
||
{{ formatObjectBrowserTimestamp(item.created_at) }}
|
||
</div>
|
||
<div v-if="hasUpdatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.updated_at)">
|
||
{{ formatObjectBrowserTimestamp(item.updated_at) }}
|
||
</div>
|
||
<div class="truncate text-xs text-muted-foreground" :title="item.comment || ''">
|
||
{{ item.comment || "" }}
|
||
</div>
|
||
</div>
|
||
</CustomContextMenu>
|
||
</template>
|
||
</RecycleScroller>
|
||
</div>
|
||
<div v-else ref="gridContainerRef" class="object-browser-grid-wrapper min-h-0 flex-1 p-2">
|
||
<RecycleScroller ref="gridScrollerRef" v-if="gridRows.length > 0" class="object-browser-grid-scroller h-full" :items="gridRows" :item-size="objectGridRowHeight" :buffer="600" :skip-hover="true" key-field="key">
|
||
<template #default="{ item: row }">
|
||
<div class="object-browser-grid-row" :style="{ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`, height: `${objectGridRowHeight - OBJECT_GRID_GAP}px` }">
|
||
<CustomContextMenu v-for="item in row.cards" :key="item.id" :items="getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu }">
|
||
<div
|
||
class="relative flex h-full min-h-0 cursor-pointer flex-col items-center gap-1 rounded-lg border bg-card p-3 text-center transition-all hover:border-primary/40 hover:shadow-sm"
|
||
:class="{
|
||
'border-primary bg-primary/5': selectedTableIds.has(item.id),
|
||
'border-primary/60': sourceRow?.id === item.id && !selectedTableIds.has(item.id),
|
||
'border-primary bg-primary/8 shadow-sm': sidePanelRow?.id === item.id && !selectedTableIds.has(item.id),
|
||
}"
|
||
:title="item.displayName"
|
||
@click="onRowClick(item, $event)"
|
||
@contextmenu="onContextMenu"
|
||
>
|
||
<button v-if="showCheckboxColumn" class="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" type="button" :class="{ invisible: item.type !== 'TABLE' }" @click.stop="toggleTableSelection(item)">
|
||
<CheckSquare v-if="selectedTableIds.has(item.id)" class="h-3.5 w-3.5 text-primary" />
|
||
<Square v-else class="h-3.5 w-3.5" />
|
||
</button>
|
||
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full shadow-sm" :class="iconBgClass(item.type)">
|
||
<component :is="iconFor(item)" class="h-6 w-6" :class="iconClass(item.type)" />
|
||
</div>
|
||
<span class="w-full truncate text-sm font-medium leading-tight text-foreground">{{ item.displayName }}</span>
|
||
<div class="flex items-center gap-1.5">
|
||
<span class="text-xs text-muted-foreground">{{ typeLabel(item.type) }}</span>
|
||
<span v-if="item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
|
||
formatObjectBrowserCount(item.estimatedRows)
|
||
}}</span>
|
||
<span v-if="item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{ formatObjectBrowserBytes(item.totalBytes) }}</span>
|
||
</div>
|
||
<!-- Always reserve timestamp/comment slots when the dataset has them so every card shares one height. -->
|
||
<div v-if="hasCreatedAt || hasUpdatedAt" class="flex min-h-[15px] items-center gap-1 text-[10px] leading-[15px] text-muted-foreground/70">
|
||
<span v-if="item.created_at?.trim()">{{ formatObjectBrowserTimestamp(item.created_at) }}</span>
|
||
<span v-if="item.created_at?.trim() && item.updated_at?.trim()">·</span>
|
||
<span v-if="item.updated_at?.trim()">{{ formatObjectBrowserTimestamp(item.updated_at) }}</span>
|
||
</div>
|
||
<div v-if="hasAnyComment" class="w-full truncate text-[10px] leading-[15px] text-muted-foreground/60" :title="item.comment?.trim() || undefined">
|
||
{{ item.comment?.trim() || "\u00A0" }}
|
||
</div>
|
||
</div>
|
||
</CustomContextMenu>
|
||
</div>
|
||
</template>
|
||
</RecycleScroller>
|
||
</div>
|
||
</div>
|
||
<!-- Right-side panel: table info or source -->
|
||
<div v-if="sidePanelRow" class="object-browser-side-panel relative flex min-h-0 shrink-0 flex-col border-l bg-background" :class="{ 'side-panel-resizing': isResizingSidePanel }" :style="{ width: `${sidePanelWidth}px` }">
|
||
<div class="absolute left-0 top-0 bottom-0 z-20 w-1.5 -translate-x-1/2 cursor-col-resize hover:bg-primary/30" @mousedown.prevent="onSidePanelResizeStart" />
|
||
<!-- Table info mode -->
|
||
<template v-if="sidePanelMode === 'table-info'">
|
||
<div class="flex items-center gap-2 px-3 py-1.5 border-b shrink-0 bg-muted/20 h-9">
|
||
<TableProperties class="w-3.5 h-3.5 text-muted-foreground" />
|
||
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ sidePanelRow?.name }}</span>
|
||
<div v-if="tableInfoTab === 'ddl'" class="table-info-actions flex min-w-0 shrink-0 items-center gap-1">
|
||
<Button variant="ghost" size="sm" class="table-info-action-button h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyTableDdl">
|
||
<Copy class="w-3 h-3" />
|
||
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-6 w-6" :class="{ 'bg-accent': tableInfoWrap }" @click="tableInfoWrap = !tableInfoWrap">
|
||
<WrapText class="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<Button v-if="canOpenTableStructureEditor" variant="ghost" size="sm" class="table-info-action-button h-6 px-2 text-xs" :title="t('contextMenu.editStructure')" :aria-label="t('contextMenu.editStructure')" @click="openTableStructureEditor">
|
||
<PencilRuler class="w-3 h-3" />
|
||
<span class="table-info-action-label">{{ t("contextMenu.editStructure") }}</span>
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSidePanel">
|
||
<X class="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
<div class="grid border-b bg-background shrink-0" :style="tableInfoTabListStyle">
|
||
<button
|
||
v-for="tab in tableInfoTabs"
|
||
:key="tab.id"
|
||
class="h-9 min-w-0 px-1.5 text-[11px] border-b-2 transition-colors"
|
||
:class="tableInfoTab === tab.id ? 'border-primary bg-gray-300/80 text-foreground dark:bg-gray-700/80' : 'border-transparent text-muted-foreground hover:bg-gray-200 hover:text-foreground dark:hover:bg-gray-800/50'"
|
||
:title="tab.label"
|
||
@click="selectTableInfoTab(tab.id)"
|
||
>
|
||
<component :is="tab.icon" class="mx-auto h-3.5 w-3.5" />
|
||
<span class="block truncate">{{ tab.label }}</span>
|
||
</button>
|
||
</div>
|
||
<div class="px-2 py-1.5 border-b shrink-0 bg-background">
|
||
<div class="relative">
|
||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||
<input v-model="tableInfoSearchQuery" :placeholder="t('grid.tableInfoSearch')" class="w-full h-7 pl-7 pr-6 text-xs bg-muted/50 rounded border border-border focus:outline-none focus:border-primary/50" @keydown.escape="tableInfoSearchQuery = ''" />
|
||
<button v-if="tableInfoSearchQuery" class="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" @click="tableInfoSearchQuery = ''">
|
||
<X class="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="tableInfoTab === 'columns'" class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="tableColumnsLoading" class="h-full flex items-center justify-center">
|
||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<div v-else-if="tableInfoSearchQuery && filteredTableColumns.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoNoResults") }}
|
||
</div>
|
||
<table v-else class="w-full text-xs">
|
||
<thead class="sticky top-0 bg-muted text-muted-foreground">
|
||
<tr class="border-b">
|
||
<th class="text-left text-nowrap font-medium px-3 py-2 w-8">#</th>
|
||
<th class="text-left text-nowrap font-medium px-3 py-2">{{ t("grid.columnName") }}</th>
|
||
<th class="text-left text-nowrap font-medium px-3 py-2">{{ t("grid.columnType") }}</th>
|
||
<th class="text-left text-nowrap font-medium px-3 py-2">{{ t("grid.tableInfoNullable") }}</th>
|
||
<th class="text-left text-nowrap font-medium px-3 py-2">{{ t("structureEditor.defaultValue") }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(column, index) in filteredTableColumns" :key="column.name" class="border-b hover:bg-gray-200 dark:hover:bg-gray-800/30" :title="column.name">
|
||
<td class="px-3 py-2 text-muted-foreground w-8">{{ index + 1 }}</td>
|
||
<td class="px-3 py-2 font-medium">
|
||
<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>
|
||
<div v-if="column.comment" class="mt-0.5 text-[11px] text-muted-foreground truncate">
|
||
{{ column.comment }}
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2 font-mono text-[11px] text-muted-foreground">{{ column.data_type }}</td>
|
||
<td class="px-3 py-2">{{ column.is_nullable ? "YES" : "NO" }}</td>
|
||
<td data-table-info-column-default class="max-w-56 px-3 py-2 font-mono text-[11px]" :class="{ 'text-muted-foreground/70': column.column_default == null }" :title="column.column_default ?? undefined">
|
||
<span class="block max-w-56 truncate">{{ tableColumnDefaultDisplayValue(column.column_default) }}</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-else-if="tableInfoTab === 'indexes'" class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="tableIndexesLoading" class="h-full flex items-center justify-center">
|
||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<div v-else-if="tableInfoSearchQuery && filteredTableIndexes.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoNoResults") }}
|
||
</div>
|
||
<div v-else-if="tableIndexes.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoEmpty") }}
|
||
</div>
|
||
<div v-else class="divide-y">
|
||
<div v-for="index in filteredTableIndexes" :key="index.name" class="p-3 text-xs">
|
||
<div class="flex items-start gap-2">
|
||
<div class="min-w-0 flex-1">
|
||
<div class="font-medium truncate">{{ index.name }}</div>
|
||
<div class="mt-1 flex flex-wrap gap-1">
|
||
<span v-if="index.is_primary" class="rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-600">PK</span>
|
||
<span v-if="index.is_unique" class="rounded bg-emerald-500/10 px-1.5 py-0.5 text-emerald-600">UNIQUE</span>
|
||
<span v-if="index.index_type" class="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{{ index.index_type }}</span>
|
||
</div>
|
||
<div class="mt-2 font-mono text-[11px] text-muted-foreground break-all">
|
||
{{ index.columns.join(", ") }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="tableInfoTab === 'foreignKeys'" class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="tableForeignKeysLoading" class="h-full flex items-center justify-center">
|
||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<div v-else-if="tableInfoSearchQuery && filteredTableForeignKeys.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoNoResults") }}
|
||
</div>
|
||
<div v-else-if="tableForeignKeys.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoEmpty") }}
|
||
</div>
|
||
<div v-else class="divide-y">
|
||
<div v-for="fk in filteredTableForeignKeys" :key="`${fk.name}:${fk.column}`" class="p-3 text-xs">
|
||
<div class="font-medium truncate">{{ fk.name }}</div>
|
||
<div class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ fk.column }} -> {{ fk.ref_table }}.{{ fk.ref_column }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="tableInfoTab === 'triggers'" class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="tableTriggersLoading" class="h-full flex items-center justify-center">
|
||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<div v-else-if="tableInfoSearchQuery && filteredTableTriggers.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoNoResults") }}
|
||
</div>
|
||
<div v-else-if="tableTriggers.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoEmpty") }}
|
||
</div>
|
||
<div v-else class="divide-y">
|
||
<div v-for="trigger in filteredTableTriggers" :key="trigger.name" class="p-3 text-xs">
|
||
<div class="font-medium truncate">{{ trigger.name }}</div>
|
||
<div class="mt-1 text-[11px] text-muted-foreground">{{ trigger.timing }} {{ trigger.event }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<pre
|
||
v-else-if="tableInfoTab === 'ddl' && !tableDdlLoading"
|
||
ref="tableInfoDdlPreRef"
|
||
data-native-clipboard
|
||
tabindex="0"
|
||
class="flex-1 min-w-0 text-xs font-mono p-3 overflow-auto ddl-code leading-5 select-text outline-none"
|
||
:class="tableInfoWrap ? 'whitespace-pre-wrap break-words' : 'whitespace-pre'"
|
||
v-html="filteredTableDdlContent"
|
||
@keydown="onTableInfoDdlKeydown"
|
||
></pre>
|
||
<div v-else class="flex-1 flex items-center justify-center">
|
||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
</template>
|
||
<!-- Source mode (views, procedures, functions, sequences) -->
|
||
<template v-else>
|
||
<div class="flex h-8 shrink-0 items-center gap-2 border-b bg-muted/20 px-3">
|
||
<Code2 class="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span class="min-w-0 flex-1 truncate text-xs font-medium">{{ sourceTitle(sourceRow) }}</span>
|
||
<Button v-if="sourceEditing" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="sourceSaving || !sourceDraft.trim()" @click="saveSource">
|
||
<Loader2 v-if="sourceSaving" class="mr-1 h-3 w-3 animate-spin" />
|
||
{{ t("objects.saveSource") }}
|
||
</Button>
|
||
<Button v-if="sourceEditing" variant="ghost" size="sm" class="h-6 px-2 text-xs" :disabled="sourceSaving" @click="cancelEditSource">
|
||
{{ t("objects.cancelEdit") }}
|
||
</Button>
|
||
<Button v-if="!sourceEditing" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="copySource">
|
||
<Copy class="h-3 w-3" />
|
||
</Button>
|
||
<Button v-if="!sourceEditing && sourceCanEdit" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
|
||
<PencilLine class="h-3 w-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
|
||
<X class="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
<div v-if="sourceLoading" class="flex flex-1 items-center justify-center">
|
||
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
|
||
</div>
|
||
<div v-else-if="sourceError" class="flex flex-1 items-center justify-center px-4 text-sm text-destructive">
|
||
{{ sourceError }}
|
||
</div>
|
||
<div v-else-if="sourceEditing" class="flex min-h-0 flex-1 flex-col" data-object-source-editor>
|
||
<QueryEditor
|
||
v-model="sourceDraft"
|
||
class="min-h-0 flex-1"
|
||
:connection-id="props.connection.id"
|
||
:database="props.database"
|
||
:schema="selectedSchema"
|
||
:database-type="props.connection.db_type"
|
||
:dialect="sourceDialect"
|
||
:format-dialect="sourceFormatDialect"
|
||
force-word-wrap
|
||
@save="saveSource"
|
||
/>
|
||
<div v-if="sourceSaveError" class="shrink-0 whitespace-pre-wrap break-words border-t px-3 py-2 text-xs text-destructive">
|
||
{{ sourceSaveError }}
|
||
</div>
|
||
</div>
|
||
<QueryEditor
|
||
v-else
|
||
:key="`source-preview-${sourceRow?.id}`"
|
||
:model-value="sourceContent"
|
||
class="min-h-0 flex-1"
|
||
:connection-id="props.connection.id"
|
||
:database="props.database"
|
||
:schema="selectedSchema"
|
||
:database-type="props.connection.db_type"
|
||
:dialect="sourceDialect"
|
||
:format-dialect="sourceFormatDialect"
|
||
force-word-wrap
|
||
read-only
|
||
data-object-source-preview
|
||
/>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<DangerConfirmDialog v-model:open="showDropConfirm" :title="dropConfirmTitle()" :message="dropConfirmMessage()" :sql="dropPreviewSql" :confirm-label="t('dangerDialog.deleteConfirm')" @confirm="confirmDrop">
|
||
<template v-if="canDropTargetCascade" #options>
|
||
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||
<input v-model="dropTableCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshDropPreviewSql()" />
|
||
<span class="grid gap-0.5">
|
||
<span class="font-medium text-foreground">{{ t("contextMenu.dropTableCascade") }}</span>
|
||
<span class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.dropTableCascadeHint") }}</span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</DangerConfirmDialog>
|
||
|
||
<DangerConfirmDialog
|
||
v-model:open="showBatchDropConfirm"
|
||
:title="t('objects.confirmBatchDropTitle')"
|
||
:message="t('objects.confirmBatchDropMessage', { count: selectedTableCount })"
|
||
:sql="batchDropPreviewSql"
|
||
:confirm-label="t('objects.dropSelected')"
|
||
:loading="batchDropExecuting"
|
||
:close-on-confirm="false"
|
||
@confirm="confirmBatchDropTables"
|
||
>
|
||
<template #options>
|
||
<div v-if="batchDropExecuting" class="mb-3 rounded-md border bg-muted/20 px-3 py-2.5">
|
||
<div class="mb-1.5 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
|
||
<span>{{ batchDropProgress.completed }} / {{ batchDropProgress.total }}</span>
|
||
<span>{{ batchDropProgressPercent }}%</span>
|
||
</div>
|
||
<div class="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" :aria-valuemin="0" :aria-valuemax="batchDropProgress.total" :aria-valuenow="batchDropProgress.completed">
|
||
<div class="h-full bg-primary transition-[width] duration-200" :style="{ width: `${batchDropProgressPercent}%` }" />
|
||
</div>
|
||
</div>
|
||
<label v-if="canBatchDropCascade" class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||
<input v-model="batchDropCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshBatchDropPreviewSql()" />
|
||
<span class="grid gap-0.5">
|
||
<span class="font-medium text-foreground">{{ t("contextMenu.dropTableCascade") }}</span>
|
||
<span class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.dropTableCascadeHint") }}</span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</DangerConfirmDialog>
|
||
|
||
<DangerConfirmDialog
|
||
v-model:open="showBatchTruncateConfirm"
|
||
:title="t('objects.confirmBatchTruncateTitle')"
|
||
:message="t('objects.confirmBatchTruncateMessage', { count: selectedTableCount })"
|
||
:sql="batchTruncatePreviewSql"
|
||
:confirm-label="t('objects.truncateSelected')"
|
||
@confirm="confirmBatchTruncateTables"
|
||
>
|
||
<template v-if="canBatchTruncateCascade" #options>
|
||
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||
<input v-model="batchTruncateCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshBatchTruncatePreviewSql()" />
|
||
<span class="grid gap-0.5">
|
||
<span class="font-medium text-foreground">{{ t("contextMenu.truncateTableCascade") }}</span>
|
||
<span class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.truncateTableCascadeHint") }}</span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</DangerConfirmDialog>
|
||
|
||
<DangerConfirmDialog
|
||
v-model:open="showBatchEmptyConfirm"
|
||
:title="t('contextMenu.confirmBatchEmptyTitle', { count: batchEmptyPlan.length })"
|
||
:message="t('contextMenu.confirmBatchEmptyMessage', { count: batchEmptyPlan.length })"
|
||
:sql="batchEmptyPreviewSql"
|
||
:confirm-label="t('contextMenu.batchEmpty', { count: batchEmptyPlan.length })"
|
||
@confirm="confirmBatchEmptyTables"
|
||
/>
|
||
|
||
<Dialog v-model:open="showRenameDialog">
|
||
<DialogContent class="sm:max-w-[420px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{{ t("contextMenu.renameObjectTitle") }}</DialogTitle>
|
||
</DialogHeader>
|
||
<div class="grid gap-3">
|
||
<Input v-model="renameInput" :placeholder="t('contextMenu.renameObjectNamePlaceholder')" @keydown.enter.prevent="confirmRename" />
|
||
<pre v-if="renamePreviewSqlText" class="max-h-32 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
|
||
<p v-if="renameError" class="text-sm text-destructive">{{ renameError }}</p>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="showRenameDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||
<Button :disabled="!renameInput.trim() || renameInput.trim() === renameTarget?.name" @click="confirmRename">
|
||
{{ t("contextMenu.renameObject") }}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<DangerConfirmDialog
|
||
v-model:open="showTruncateConfirm"
|
||
:title="t('contextMenu.confirmTruncateTableTitle')"
|
||
:message="t('contextMenu.confirmTruncateTableMessage', { name: truncateTarget?.name ?? '' })"
|
||
:sql="truncatePreviewSql"
|
||
:confirm-label="t('contextMenu.truncateTable')"
|
||
@confirm="confirmTruncateTable"
|
||
>
|
||
<template v-if="canTruncateTargetCascade" #options>
|
||
<label class="mb-3 flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||
<input v-model="truncateTableCascade" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" @change="truncateTarget && refreshTruncatePreviewSql(truncateTarget)" />
|
||
<span class="grid gap-0.5">
|
||
<span class="font-medium text-foreground">{{ t("contextMenu.truncateTableCascade") }}</span>
|
||
<span class="text-xs leading-5 text-muted-foreground">{{ t("contextMenu.truncateTableCascadeHint") }}</span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</DangerConfirmDialog>
|
||
|
||
<DangerConfirmDialog v-model:open="showEmptyConfirm" :title="t('contextMenu.confirmEmptyTableTitle')" :message="t('contextMenu.confirmEmptyTableMessage', { name: emptyTarget?.name ?? '' })" :sql="emptyPreviewSql" :confirm-label="t('contextMenu.emptyTable')" @confirm="confirmEmptyTable" />
|
||
|
||
<ProcedureExecutionDialog
|
||
v-if="procedureExecutionTarget"
|
||
v-model:open="showProcedureExecutionConfirm"
|
||
:connection-id="props.connection.id"
|
||
:database="props.database"
|
||
:database-type="props.connection.db_type"
|
||
:schema="procedureExecutionTarget.schema || selectedSchema"
|
||
:routine-name="procedureExecutionTarget.name"
|
||
@open-sql="openProcedureExecutionSql"
|
||
@execute="executeProcedureSql"
|
||
/>
|
||
|
||
<Dialog v-model:open="showDuplicateDialog">
|
||
<DialogContent class="sm:max-w-[400px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{{ t("contextMenu.duplicateNameTitle") }}</DialogTitle>
|
||
</DialogHeader>
|
||
<Input v-model="duplicateTableName" :placeholder="t('contextMenu.duplicateNamePlaceholder')" @keydown.enter.prevent="confirmDuplicateStructure" />
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="showDuplicateDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||
<Button :disabled="!duplicateTableName.trim()" @click="confirmDuplicateStructure">
|
||
{{ t("dangerDialog.confirm") }}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog v-model:open="showPasteDialog">
|
||
<DialogContent class="sm:max-w-[500px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{{ pasteTableEntries.length > 1 ? t("contextMenu.batchPasteTitle") : t("contextMenu.pasteTableConfirmTitle") }}</DialogTitle>
|
||
</DialogHeader>
|
||
<div class="space-y-4">
|
||
<div class="flex gap-2">
|
||
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
|
||
<input v-model="pasteTableMode" type="radio" value="structure-and-data" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
|
||
{{ t("contextMenu.pasteOptionStructureAndData") }}
|
||
</label>
|
||
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
|
||
<input v-model="pasteTableMode" type="radio" value="structure-only" class="accent-primary" />
|
||
{{ t("contextMenu.pasteOptionStructureOnly") }}
|
||
</label>
|
||
<label class="flex items-center gap-1.5 text-sm cursor-pointer" :class="{ 'opacity-50 cursor-not-allowed': !pasteTableDataCopySupported }">
|
||
<input v-model="pasteTableMode" type="radio" value="data-only" class="accent-primary" :disabled="!pasteTableDataCopySupported" />
|
||
{{ t("contextMenu.pasteOptionDataOnly") }}
|
||
</label>
|
||
</div>
|
||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||
<div v-for="(entry, idx) in pasteTableEntries" :key="idx" class="flex items-center gap-2">
|
||
<span class="text-sm text-muted-foreground truncate min-w-0 flex-shrink basis-1/3" :title="entry.sourceName">{{ entry.sourceName }}</span>
|
||
<span class="text-xs text-muted-foreground flex-shrink-0">→</span>
|
||
<Input v-model="entry.targetName" class="flex-1 h-8 text-sm" :placeholder="t('contextMenu.duplicateNamePlaceholder')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="showPasteDialog = false">{{ t("dangerDialog.cancel") }}</Button>
|
||
<Button :disabled="pasteTableEntries.every((e) => !e.targetName.trim())" @click="confirmPasteTable">{{ t("dangerDialog.confirm") }}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.object-browser-table {
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.object-browser-scroller {
|
||
will-change: scroll-position;
|
||
contain: content;
|
||
}
|
||
|
||
.object-browser-scroller :deep(.vue-recycle-scroller__item-view) {
|
||
contain: layout style paint;
|
||
}
|
||
|
||
.object-browser-grid-wrapper {
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.object-browser-grid-scroller {
|
||
will-change: scroll-position;
|
||
contain: content;
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.object-browser-grid-scroller :deep(.vue-recycle-scroller__item-view) {
|
||
contain: layout style paint;
|
||
}
|
||
|
||
.object-browser-grid-row {
|
||
display: grid;
|
||
column-gap: 12px;
|
||
/* Stretch so cards with/without comment share one border height in the row. */
|
||
align-items: stretch;
|
||
}
|
||
|
||
.object-browser-icon-bg-table {
|
||
background-color: rgba(34, 197, 94, 0.1);
|
||
}
|
||
|
||
.object-browser-icon-bg-view {
|
||
background-color: rgba(168, 85, 247, 0.1);
|
||
}
|
||
|
||
.object-browser-icon-bg-procedure {
|
||
background-color: rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.object-browser-icon-bg-function {
|
||
background-color: rgba(245, 158, 11, 0.1);
|
||
}
|
||
|
||
.object-browser-icon-bg-sequence {
|
||
background-color: rgba(16, 185, 129, 0.1);
|
||
}
|
||
|
||
.object-browser-icon-bg-package {
|
||
background-color: rgba(6, 182, 212, 0.1);
|
||
}
|
||
|
||
.object-browser-stat-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
max-width: 100%;
|
||
line-height: 1rem;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.object-browser-stat-badge-rows {
|
||
color: var(--primary);
|
||
background-color: rgba(23, 23, 23, 0.1);
|
||
}
|
||
|
||
.object-browser-stat-badge-bytes {
|
||
color: var(--muted-foreground);
|
||
background-color: var(--muted);
|
||
}
|
||
|
||
:global(.dark) .object-browser-stat-badge-rows {
|
||
background-color: rgba(208, 208, 214, 0.12);
|
||
}
|
||
|
||
.side-panel-resizing {
|
||
user-select: none;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.ddl-code {
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.object-browser-side-panel {
|
||
container-type: inline-size;
|
||
}
|
||
|
||
.table-info-action-button {
|
||
gap: 0.25rem;
|
||
max-width: 8rem;
|
||
overflow: hidden;
|
||
transition:
|
||
max-width 180ms ease,
|
||
padding-inline 180ms ease;
|
||
}
|
||
|
||
.table-info-action-label {
|
||
min-width: 0;
|
||
max-width: 6rem;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
opacity: 1;
|
||
transition:
|
||
max-width 180ms ease,
|
||
opacity 120ms ease;
|
||
}
|
||
|
||
@container (max-width: 360px) {
|
||
.table-info-action-button {
|
||
width: 1.5rem;
|
||
max-width: 1.5rem;
|
||
padding-inline: 0;
|
||
}
|
||
|
||
.table-info-action-label {
|
||
max-width: 0;
|
||
opacity: 0;
|
||
}
|
||
}
|
||
</style>
|