4347 lines
197 KiB
Vue
4347 lines
197 KiB
Vue
<script setup lang="ts">
|
||
import { computed, createApp, nextTick, onActivated, onBeforeUnmount, ref, watch, type Component } from "vue";
|
||
import { RecycleScroller } from "vue-virtual-scroller";
|
||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||
import {
|
||
Activity,
|
||
ArrowDown,
|
||
ArrowRightLeft,
|
||
ArrowUp,
|
||
Braces,
|
||
Check,
|
||
CheckSquare,
|
||
Clock,
|
||
Clipboard,
|
||
Code2,
|
||
Copy,
|
||
CopyPlus,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
Download,
|
||
Eraser,
|
||
Eye,
|
||
FileCode,
|
||
Info,
|
||
GripVertical,
|
||
KeyRound,
|
||
LayoutGrid,
|
||
Link2,
|
||
List,
|
||
ListTree,
|
||
Upload,
|
||
Loader2,
|
||
Network,
|
||
Pencil,
|
||
PencilLine,
|
||
PencilRuler,
|
||
Play,
|
||
Package,
|
||
RefreshCw,
|
||
RotateCcw,
|
||
Scissors,
|
||
Search,
|
||
ScrollText,
|
||
ShieldCheck,
|
||
Sparkles,
|
||
Square,
|
||
Table,
|
||
Table2,
|
||
TableProperties,
|
||
TerminalSquare,
|
||
Trash2,
|
||
WrapText,
|
||
X,
|
||
Wrench,
|
||
} from "@lucide/vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import i18n from "@/i18n";
|
||
import { translateBackendError } from "@/i18n/backend-errors";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||
import { DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "@/components/ui/dropdown-menu";
|
||
import ToolbarOverflowMenu from "@/components/ui/ToolbarOverflowMenu.vue";
|
||
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
|
||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||
import ProcedureExecutionDialog from "@/components/objects/ProcedureExecutionDialog.vue";
|
||
import CustomTypeInfoPanel from "@/components/objects/CustomTypeInfoPanel.vue";
|
||
import XlsxHeaderDialog from "@/components/export/XlsxHeaderDialog.vue";
|
||
import * as api from "@/lib/backend/api";
|
||
import type { ColumnInfo, ConnectionConfig, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectBrowserViewMode, ObjectBrowserViewport, ObjectInfo, ObjectSourceKind, ObjectStatistics, TableInfoTab, TreeNode, TriggerInfo } from "@/types/database";
|
||
import { sortTablesByFkDependency, type TableWithFk } from "@/lib/table/tableDependencySort";
|
||
import { isSchemaAware, supportsTableVacuum, supportsTransfer } from "@/lib/database/databaseCapabilities";
|
||
import { supportsAiAssistantContext, supportsSchemaDiagram, supportsTableImport, supportsTableStructureEditing, supportsTableTruncate } from "@/lib/database/databaseFeatureSupport";
|
||
import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionTableSqlSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, objectListSchemaForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||
import { getTableMetadataCapabilities, type TableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||
import { constraintsForConstraintsTab } from "@/lib/table/constraintPresentation";
|
||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||
import {
|
||
buildDropObjectSql,
|
||
buildDropTableSql,
|
||
buildDuplicateTableStructurePlan as buildSharedDuplicateTableStructurePlan,
|
||
buildCopyTableDataSql,
|
||
buildEmptyTableSql,
|
||
buildTruncateTableSql,
|
||
buildVacuumTableSql,
|
||
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 { buildXlsxHeaderOverrides, hasXlsxHeaderComments, type XlsxExportOptions, type XlsxHeaderMode } from "@/lib/export/xlsxHeader";
|
||
import { showSqlInsertModeDialog, type SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||
import { copyToClipboard, eventTargetAllowsAppClipboardShortcut } from "@/lib/common/clipboard";
|
||
import {
|
||
defaultPasteTableMode,
|
||
pasteTableModeCopiesData,
|
||
supportsWholeRowTableDataCopy,
|
||
tableClipboardMatchesTarget,
|
||
tableClipboardMenuState,
|
||
tableClipboardSourceContext,
|
||
tableDataCopyColumnOptions,
|
||
tablePasteFeedback,
|
||
type PasteTableMode,
|
||
type TableClipboardContext,
|
||
} from "@/lib/table/tableClipboard";
|
||
import { buildSingleDdlExportFileContent } from "@/lib/export/ddlExport";
|
||
import { fetchTableDataForExport } from "@/lib/table/tableDataExport";
|
||
import { forceCsvTextForTemporalColumns } from "@/lib/dataGrid/columnFormatter";
|
||
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 MySqlEventEditor from "@/components/objects/MySqlEventEditor.vue";
|
||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
|
||
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
|
||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||
import { buildXuguCompileSql } from "@/lib/database/xuguCompileSql";
|
||
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,
|
||
buildMongoObjectBrowserRows,
|
||
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 { objectBrowserTableSelectionAnchor, objectBrowserTableSelectionRange } from "@/lib/table/objectBrowserSelection";
|
||
import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities";
|
||
import { filterObjectBrowserTableColumns } from "@/lib/table/objectBrowserTableInfo";
|
||
import { visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||
import { createSidePanelRequestGuard } from "@/lib/table/sidePanelRequestGuard";
|
||
import { runBatchTableTruncate } from "@/lib/table/batchTableTruncate";
|
||
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
|
||
import { gaussdbMTypeDisplayName } from "@/lib/table/postgresDataTypeHelp";
|
||
import { cacheObjectBrowserRows, createObjectBrowserRowsCacheWriteToken, getCachedObjectBrowserRowsForScaffold, type ObjectBrowserRowsCacheScope, type ObjectBrowserRowsCacheWriteToken } from "@/lib/table/objectBrowserRowsCache";
|
||
import { createObjectBrowserRowsLoadGuard, type ObjectBrowserRowsLoadHandle } from "@/lib/table/objectBrowserRowsLoadGuard";
|
||
import { loadObjectDdl, type ObjectDdlRequest } from "@/lib/metadata/objectDdlCache";
|
||
import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache";
|
||
import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache";
|
||
import { invalidateObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||
import { eventEditorInstanceKey, resolveInitialEventEditorRequest } from "@/lib/table/eventEditorRequest";
|
||
|
||
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;
|
||
initialEventName?: string;
|
||
initialEventReadOnly?: boolean;
|
||
initialEventOpenRequestId?: number;
|
||
/** 显式"新建事件"请求号:每次菜单点击递增,用于打开/重新进入 CREATE 编辑器 */
|
||
initialEventCreateRequestId?: number;
|
||
initialObjectFilter?: "tables" | "events";
|
||
selectedObjectFilter?: ObjectFilter;
|
||
initialSearchQuery?: string;
|
||
viewport?: ObjectBrowserViewport;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
openTable: [target: { tableName: string; schema?: string; tableType?: string; catalog?: string; comment?: string | null }];
|
||
schemaChange: [schema: string | undefined];
|
||
viewportChange: [viewport: ObjectBrowserViewport];
|
||
searchChange: [query: string];
|
||
filterChange: [filter: ObjectFilter];
|
||
addToAi: [tables: Array<{ name: string; schema?: string }>];
|
||
}>();
|
||
|
||
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(props.initialSearchQuery ?? "");
|
||
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 refreshingObjects = ref(false);
|
||
const scaffoldRefreshError = ref("");
|
||
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 openedInitialEvent = ref("");
|
||
const isEventEditor = computed(() => sidePanelMode.value === "event-editor");
|
||
const sidePanelMode = ref<"table-info" | "source" | "type-info" | "event-editor">("source");
|
||
const eventEditorKey = computed(() =>
|
||
eventEditorInstanceKey({
|
||
createRequestId: props.initialEventCreateRequestId,
|
||
openRequestId: props.initialEventOpenRequestId,
|
||
rowId: sidePanelRow.value?.id,
|
||
}),
|
||
);
|
||
// Table info panel state
|
||
const tableInfoTab = ref<TableInfoTab>("ddl");
|
||
const tableColumns = ref<ColumnInfo[]>([]);
|
||
const tableColumnsLoading = ref(false);
|
||
const tableColumnsLoaded = ref(false);
|
||
const tableDdlContent = ref("");
|
||
const tableDdlLoading = ref(false);
|
||
const tableDdlLoaded = ref(false);
|
||
const tableIndexes = ref<IndexInfo[]>([]);
|
||
const tableIndexesLoading = ref(false);
|
||
const tableIndexesLoaded = ref(false);
|
||
const tableForeignKeys = ref<ForeignKeyInfo[]>([]);
|
||
const tableForeignKeysLoading = ref(false);
|
||
const tableForeignKeysLoaded = ref(false);
|
||
const tableTriggers = ref<TriggerInfo[]>([]);
|
||
const tableTriggersLoading = ref(false);
|
||
const tableTriggersLoaded = ref(false);
|
||
const tableConstraints = ref<ConstraintInfo[]>([]);
|
||
const tableConstraintsLoading = ref(false);
|
||
const tableConstraintsLoaded = ref(false);
|
||
// The Constraints tab hides foreign keys when the dedicated Foreign Keys tab
|
||
// is also shown, mirroring DataGrid/TableStructureEditor.
|
||
const tableConstraintsForTab = computed(() => constraintsForConstraintsTab(tableConstraints.value, tableMetadataCapabilities.value.foreignKeys));
|
||
const tableInfoSearchQuery = ref("");
|
||
const tableInfoDdlPreRef = ref<HTMLPreElement | null>(null);
|
||
const activeTableInfoLoading = computed(() => {
|
||
if (tableInfoTab.value === "ddl") return tableDdlLoading.value;
|
||
if (tableInfoTab.value === "columns") return tableColumnsLoading.value;
|
||
if (tableInfoTab.value === "indexes") return tableIndexesLoading.value;
|
||
if (tableInfoTab.value === "foreignKeys") return tableForeignKeysLoading.value;
|
||
return tableInfoTab.value === "triggers" && tableTriggersLoading.value;
|
||
});
|
||
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 sidePanelRef = ref<InstanceType<typeof CustomTypeInfoPanel> | null>(null);
|
||
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
|
||
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
|
||
const isGaussdbM = computed(() => effectiveDatabaseType.value === "gaussdb" && props.connection.driver_profile?.toLowerCase() === "gaussdb-m");
|
||
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
|
||
const isMongodb = computed(() => props.connection.db_type === "mongodb");
|
||
const supportsObjectRowStats = computed(() => !isMongodb.value);
|
||
const supportsObjectSizeStats = computed(() => !isVictoriaMetrics.value && !isMongodb.value);
|
||
const showTableStatistics = computed(() => objectFilter.value === "all" || objectFilter.value === "tables");
|
||
const showObjectRowStats = computed(() => supportsObjectRowStats.value && showTableStatistics.value);
|
||
const showObjectSizeStats = computed(() => supportsObjectSizeStats.value && showTableStatistics.value);
|
||
const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows"));
|
||
|
||
function toggleTableDdlWordWrap() {
|
||
settingsStore.updateEditorSettings({
|
||
tableDdlWordWrap: !settingsStore.editorSettings.tableDdlWordWrap,
|
||
});
|
||
}
|
||
|
||
function gaussdbMColumnType(dataType: string): string {
|
||
if (isGaussdbM.value) {
|
||
return gaussdbMTypeDisplayName(dataType);
|
||
}
|
||
return dataType;
|
||
}
|
||
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 showVacuumConfirm = ref(false);
|
||
const vacuumTarget = ref<ObjectBrowserRow | null>(null);
|
||
const vacuumPreviewSql = ref("");
|
||
const vacuumTableFull = ref(false);
|
||
const vacuumTableAnalyze = ref(false);
|
||
const vacuumExecuting = 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 tableSelectionAnchorId = ref<string | null>(null);
|
||
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; tableComment?: string | null }[]>([]);
|
||
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, updateTableExportTask } = 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 supportsVacuumTable = computed(() => !connectionIsEffectivelyReadOnly(props.connection) && supportsTableVacuum(effectiveDatabaseType.value));
|
||
const vacuumRiskMessage = computed(() => (vacuumExecuting.value ? t("contextMenu.vacuumTableRunningHint") : vacuumTableFull.value ? t("contextMenu.vacuumTableFullRisk") : vacuumTableAnalyze.value ? t("contextMenu.vacuumTableAnalyzeRisk") : t("contextMenu.vacuumTableDefaultRisk")));
|
||
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],
|
||
["events", objectCounts.value.events],
|
||
["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);
|
||
// Measured condensation for the header row: tier 1 moves the sort/view/checkbox
|
||
// controls into the overflow menu, tier 2 additionally moves the object type
|
||
// filter there and drops the database chip. See useToolbarOverflow for the
|
||
// tier contract.
|
||
const toolbarRef = ref<HTMLElement | null>(null);
|
||
const { tier: toolbarTier } = useToolbarOverflow(toolbarRef, [() => props.database, () => selectedSchema.value, () => needsSchema.value, () => showObjectFilter.value]);
|
||
const showToolbarOverflow = computed(() => toolbarTier.value >= 1);
|
||
const showInlineSortAndView = computed(() => toolbarTier.value < 1);
|
||
const showInlineCheckboxToggle = computed(() => toolbarTier.value < 1);
|
||
const showInlineObjectFilter = computed(() => toolbarTier.value < 2);
|
||
const showDatabaseChip = computed(() => toolbarTier.value < 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);
|
||
const objectListHeaderRef = ref<HTMLElement | 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() {
|
||
syncObjectListHeaderScroll();
|
||
if (viewportFrame) return;
|
||
viewportFrame = window.requestAnimationFrame(() => {
|
||
viewportFrame = 0;
|
||
const el = scrollerElement();
|
||
if (!el) return;
|
||
emitViewportChange(el.scrollTop);
|
||
});
|
||
}
|
||
|
||
// The list header sits outside the row scroller and is clipped (overflow:
|
||
// hidden), so keep its programmatic scrollLeft aligned with the scroller's
|
||
// horizontal position on every scroll, resize, and (re)attach.
|
||
function syncObjectListHeaderScroll() {
|
||
if (!isListView.value) return;
|
||
const header = objectListHeaderRef.value;
|
||
const el = scrollerElement(listScrollerRef.value);
|
||
if (!header || !el) return;
|
||
if (header.scrollLeft !== el.scrollLeft) header.scrollLeft = el.scrollLeft;
|
||
}
|
||
|
||
function flushObjectBrowserViewport() {
|
||
if (viewportFrame) {
|
||
window.cancelAnimationFrame(viewportFrame);
|
||
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();
|
||
nextTick(() => syncObjectListHeaderScroll());
|
||
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, (value) => {
|
||
scrollObjectsToTop();
|
||
emit("searchChange", value);
|
||
});
|
||
watch(objectFilter, () => {
|
||
if (preserveObjectFilterScrollOnce) {
|
||
preserveObjectFilterScrollOnce = false;
|
||
return;
|
||
}
|
||
scrollObjectsToTop();
|
||
});
|
||
watch([() => props.initialEventName, () => props.initialEventOpenRequestId, rows, loadingObjects], openInitialEventIfNeeded, { flush: "post" });
|
||
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");
|
||
if (showObjectRowStats.value) columns.push("estimatedRows");
|
||
if (showObjectSizeStats.value) columns.push("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));
|
||
|
||
// Column resizes change the scrollable content width, so the header's clamped
|
||
// scrollLeft has to be re-aligned right after the DOM updates. Registered here
|
||
// (after every computed it transitively reads) because watch sources are
|
||
// evaluated eagerly at registration time.
|
||
watch([objectGridMinWidth, objectColumnWidths], () => {
|
||
nextTick(() => syncObjectListHeaderScroll());
|
||
});
|
||
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 === "EVENT") return Clock;
|
||
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 Table;
|
||
}
|
||
|
||
function typeLabel(row: ObjectBrowserRow) {
|
||
if (isMongodb.value) {
|
||
if (row.collectionKind === "view" || row.type === "VIEW") return t("objects.view");
|
||
if (row.collectionKind === "timeseries") return t("objects.timeseries");
|
||
return t("objects.collection");
|
||
}
|
||
if (row.type === "MATERIALIZED_VIEW") return t("common.materializedView");
|
||
if (row.type === "VIEW") return t("objects.view");
|
||
if (row.type === "PROCEDURE") return t("objects.procedure");
|
||
if (row.type === "FUNCTION") return t("objects.function");
|
||
if (row.type === "TRIGGER") return t("objects.trigger");
|
||
if (row.type === "EVENT") return t("tree.events");
|
||
if (row.type === "SEQUENCE") return t("objects.sequence");
|
||
if (row.type === "PACKAGE") return t("objects.package");
|
||
if (row.type === "PACKAGE_BODY") return t("objects.packageBody");
|
||
if (row.type === "TYPE") return t("objects.typeDefinition");
|
||
if (row.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"];
|
||
if (showObjectRowStats.value) options.push("estimatedRows");
|
||
if (showObjectSizeStats.value) options.push("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 objectRowsLabel.value;
|
||
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 === "events") return row.type === "EVENT";
|
||
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") return "text-purple-500";
|
||
if (type === "MATERIALIZED_VIEW") return "text-indigo-500";
|
||
if (type === "PROCEDURE") return "text-blue-500";
|
||
if (type === "FUNCTION") return "text-amber-500";
|
||
if (type === "TRIGGER") return "text-rose-500";
|
||
if (type === "EVENT") return "text-orange-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 === "EVENT") 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 "type-info":
|
||
void openTypeInfo(row);
|
||
break;
|
||
case "open-table":
|
||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog, comment: row.comment });
|
||
break;
|
||
case "open-source":
|
||
void (row.type === "EVENT" ? openEventEditor(row) : openSource(row));
|
||
break;
|
||
}
|
||
}
|
||
|
||
function toggleTableSelectionWithAnchor(row: ObjectBrowserRow) {
|
||
toggleTableSelection(row);
|
||
tableSelectionAnchorId.value = row.id;
|
||
}
|
||
|
||
function selectTableRangeFromAnchor(row: ObjectBrowserRow) {
|
||
const anchorId = objectBrowserTableSelectionAnchor(filteredRows.value, tableSelectionAnchorId.value, row.id);
|
||
tableSelectionAnchorId.value = anchorId;
|
||
setSelectedTableIds(new Set(objectBrowserTableSelectionRange(filteredRows.value, anchorId, row.id)));
|
||
}
|
||
|
||
function onRowClick(row: ObjectBrowserRow, event: MouseEvent) {
|
||
if (row.type === "TABLE" && event.shiftKey) {
|
||
selectTableRangeFromAnchor(row);
|
||
return;
|
||
}
|
||
if (row.type === "TABLE" && (event.metaKey || event.ctrlKey)) {
|
||
toggleTableSelectionWithAnchor(row);
|
||
return;
|
||
}
|
||
if (row.type === "TABLE") tableSelectionAnchorId.value = row.id;
|
||
const activation = settingsStore.editorSettings.sidebarActivation;
|
||
const { action, isDouble } = resolveRowClickAction(row, event.detail, activation, effectiveDatabaseType.value);
|
||
// 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, effectiveDatabaseType.value)) {
|
||
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.constraints) {
|
||
tabs.push({ id: "constraints", label: t("grid.tableInfoConstraints"), icon: ShieldCheck, count: tableConstraintsForTab.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 filteredTableConstraints = computed(() => {
|
||
const base = tableConstraintsForTab.value;
|
||
if (!tableInfoSearchQuery.value) return base;
|
||
const q = tableInfoSearchQuery.value.toLowerCase();
|
||
return base.filter((c) => c.name.toLowerCase().includes(q) || c.constraint_type.toLowerCase().includes(q) || c.columns.some((col) => col.toLowerCase().includes(q)) || c.definition.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 = [];
|
||
tableConstraints.value = [];
|
||
tableColumnsLoaded.value = false;
|
||
tableDdlLoaded.value = false;
|
||
tableIndexesLoaded.value = false;
|
||
tableForeignKeysLoaded.value = false;
|
||
tableTriggersLoaded.value = false;
|
||
tableConstraintsLoaded.value = false;
|
||
tableInfoSearchQuery.value = "";
|
||
// Determine initial tab: explicit request > previously activated
|
||
const firstTab = initialTab ?? tableInfoTab.value;
|
||
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 === "constraints") await fetchTableConstraints();
|
||
else if (nextTab === "triggers") await fetchTableTriggers();
|
||
}
|
||
|
||
function tableMetadataRequest(row: ObjectBrowserRow): ObjectDdlRequest {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database || "",
|
||
schema: row.schema || selectedSchema.value || props.database,
|
||
tableName: row.name,
|
||
objectType: tableDdlObjectType(row.type),
|
||
catalog: props.catalog,
|
||
};
|
||
}
|
||
|
||
async function fetchTableDdl(force = settingsStore.editorSettings.refreshDdlOnOpen) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableDdlLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableDdlLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const { ddl } = await loadObjectDdl(tableMetadataRequest(row), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
const formatDialect = sqlFormatDialectForDbType(effectiveDatabaseType.value);
|
||
tableDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? ddl : omitDdlIdentifierQuotes(ddl, formatDialect);
|
||
loadedSuccessfully = true;
|
||
} catch (e: any) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableDdlContent.value = `-- Error: ${e?.message || e}`;
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableDdlLoaded.value = loadedSuccessfully;
|
||
tableDdlLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchTableColumns(force = false) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableColumnsLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableColumnsLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const request = tableMetadataRequest(row);
|
||
const { value: columns } = await loadObjectMetadataFacet(request, "columns", () => api.getColumns(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableColumns.value = columns;
|
||
loadedSuccessfully = true;
|
||
} catch (error) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableColumns.value = [];
|
||
toast(translateBackendError(t, error), 5000);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableColumnsLoaded.value = loadedSuccessfully;
|
||
tableColumnsLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchTableIndexes(force = false) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableIndexesLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableIndexesLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const request = tableMetadataRequest(row);
|
||
const { value: indexes } = await loadObjectMetadataFacet(request, "indexes", () => api.listIndexes(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableIndexes.value = indexes;
|
||
loadedSuccessfully = true;
|
||
} catch (error) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableIndexes.value = [];
|
||
toast(translateBackendError(t, error), 5000);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableIndexesLoaded.value = loadedSuccessfully;
|
||
tableIndexesLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchTableForeignKeys(force = false) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableForeignKeysLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableForeignKeysLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const request = tableMetadataRequest(row);
|
||
const { value: fks } = await loadObjectMetadataFacet(request, "foreign-keys", () => api.listForeignKeys(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableForeignKeys.value = fks;
|
||
loadedSuccessfully = true;
|
||
} catch (error) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableForeignKeys.value = [];
|
||
toast(translateBackendError(t, error), 5000);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableForeignKeysLoaded.value = loadedSuccessfully;
|
||
tableForeignKeysLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchTableTriggers(force = false) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableTriggersLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableTriggersLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const request = tableMetadataRequest(row);
|
||
const { value: triggers } = await loadObjectMetadataFacet(request, "triggers", () => api.listTriggers(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableTriggers.value = triggers;
|
||
loadedSuccessfully = true;
|
||
} catch (error) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableTriggers.value = [];
|
||
toast(translateBackendError(t, error), 5000);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableTriggersLoaded.value = loadedSuccessfully;
|
||
tableTriggersLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function fetchTableConstraints(force = false) {
|
||
const row = sidePanelRow.value;
|
||
if (!row || (tableConstraintsLoaded.value && !force)) return;
|
||
const epoch = sidePanelGuard.capture();
|
||
tableConstraintsLoading.value = true;
|
||
let loadedSuccessfully = false;
|
||
try {
|
||
const request = tableMetadataRequest(row);
|
||
const { value: constraints } = await loadObjectMetadataFacet(request, "constraints", () => api.listConstraints(request.connectionId, request.database, request.schema || request.database, request.tableName, request.catalog), { force });
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableConstraints.value = constraints;
|
||
loadedSuccessfully = true;
|
||
} catch (error) {
|
||
if (sidePanelGuard.isStale(epoch)) return;
|
||
tableConstraints.value = [];
|
||
toast(translateBackendError(t, error), 5000);
|
||
} finally {
|
||
if (sidePanelGuard.isFresh(epoch)) {
|
||
tableConstraintsLoaded.value = loadedSuccessfully;
|
||
tableConstraintsLoading.value = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshActiveTableInfo() {
|
||
if (sidePanelMode.value !== "table-info" || !sidePanelRow.value) return;
|
||
sidePanelGuard.bump();
|
||
|
||
if (tableInfoTab.value === "ddl") {
|
||
tableDdlContent.value = "";
|
||
tableDdlLoaded.value = false;
|
||
await fetchTableDdl(true);
|
||
} else if (tableInfoTab.value === "columns") {
|
||
tableColumns.value = [];
|
||
tableColumnsLoaded.value = false;
|
||
await fetchTableColumns(true);
|
||
} else if (tableInfoTab.value === "indexes") {
|
||
tableIndexes.value = [];
|
||
tableIndexesLoaded.value = false;
|
||
await fetchTableIndexes(true);
|
||
} else if (tableInfoTab.value === "foreignKeys") {
|
||
tableForeignKeys.value = [];
|
||
tableForeignKeysLoaded.value = false;
|
||
await fetchTableForeignKeys(true);
|
||
} else if (tableInfoTab.value === "constraints") {
|
||
tableConstraints.value = [];
|
||
tableConstraintsLoaded.value = false;
|
||
await fetchTableConstraints(true);
|
||
} else if (tableInfoTab.value === "triggers") {
|
||
tableTriggers.value = [];
|
||
tableTriggersLoaded.value = false;
|
||
await fetchTableTriggers(true);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function openTypeInfo(row: ObjectBrowserRow) {
|
||
if (sidePanelRow.value?.id === row.id && sidePanelMode.value === "type-info") {
|
||
closeSidePanel();
|
||
return;
|
||
}
|
||
sidePanelRow.value = row;
|
||
sidePanelMode.value = "type-info";
|
||
sidePanelGuard.bump();
|
||
}
|
||
|
||
function openTypeDdl(row: ObjectBrowserRow) {
|
||
sidePanelRow.value = row;
|
||
sidePanelMode.value = "type-info";
|
||
sidePanelGuard.bump();
|
||
// DDL tab is selected by the panel once details load; switching the tab is
|
||
// deferred via a dedicated request so the panel can focus it.
|
||
nextTick(() => {
|
||
const panel = sidePanelRef.value;
|
||
panel?.selectTab("ddl");
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
function openEventEditor(row: ObjectBrowserRow) {
|
||
sidePanelGuard.start();
|
||
sidePanelRow.value = row;
|
||
sourceRow.value = null;
|
||
sidePanelMode.value = "event-editor";
|
||
}
|
||
|
||
async function onEventSaved(savedName: string) {
|
||
const row = sidePanelRow.value;
|
||
const name = savedName.trim() || row?.name || "";
|
||
if (name) {
|
||
const cacheSchema = row?.schema || selectedSchema.value || props.database;
|
||
const cacheScope = { connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: name };
|
||
await Promise.all([invalidateObjectMetadataCache(cacheScope), invalidateObjectDdl(cacheScope)]);
|
||
invalidateObjectBrowserRowsCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema });
|
||
if (row && row.name !== name) {
|
||
sidePanelRow.value = { ...row, id: `event:${cacheSchema}:${name}`, name, displayName: name };
|
||
}
|
||
}
|
||
await loadObjects({ allowCached: false });
|
||
toast(t("objects.sourceSaved"));
|
||
}
|
||
|
||
async function openNewQuery(row: ObjectBrowserRow) {
|
||
flushObjectBrowserViewport();
|
||
const schema = row.schema || selectedSchema.value;
|
||
const tabId = queryStore.createTab(props.connection.id, props.database, row.name, "query", schema, undefined, props.catalog);
|
||
queryStore.updateSql(
|
||
tabId,
|
||
await buildTableSelectSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
driverProfile: props.connection.driver_profile,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
catalog: props.catalog,
|
||
database: props.database,
|
||
schema,
|
||
tableName: row.name,
|
||
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
|
||
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, undefined, props.catalog);
|
||
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, undefined, props.catalog);
|
||
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" : row.type === "EVENT" ? "contextMenu.dropEventSuccess" : "contextMenu.dropTableSuccess";
|
||
toast(t(successKey, { name: row.name }));
|
||
const cacheSchema = row.schema || selectedSchema.value || props.database;
|
||
await Promise.all([invalidateObjectMetadataCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: row.name }), invalidateObjectDdl({ connectionId: props.connection.id, database: props.database, schema: cacheSchema, tableName: row.name })]);
|
||
invalidateObjectBrowserRowsCache({ connectionId: props.connection.id, database: props.database, schema: cacheSchema });
|
||
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,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
});
|
||
}
|
||
|
||
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");
|
||
if (type === "EVENT") return t("contextMenu.confirmDropEventTitle");
|
||
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 });
|
||
if (type === "EVENT") return t("contextMenu.confirmDropEventMessage", { 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 objectBrowserOpenTableType(row: ObjectBrowserRow): string {
|
||
if (row.collectionKind === "view") return "VIEW";
|
||
if (row.collectionKind === "timeseries") return "TIMESERIES";
|
||
return row.type;
|
||
}
|
||
|
||
function openViewData(row: ObjectBrowserRow) {
|
||
emit("openTable", { tableName: row.name, schema: row.schema, tableType: objectBrowserOpenTableType(row), catalog: props.catalog, comment: row.comment });
|
||
}
|
||
|
||
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, true);
|
||
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") {
|
||
try {
|
||
const schema = row.schema || selectedSchema.value;
|
||
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;
|
||
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"));
|
||
}
|
||
} catch (e: any) {
|
||
toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000);
|
||
}
|
||
}
|
||
|
||
async function exportData(row: ObjectBrowserRow, format: "csv" | "json" | "sql") {
|
||
if (format === "json") {
|
||
await exportDataLegacy(row, format);
|
||
return;
|
||
}
|
||
const sqlExportOptions = format === "sql" ? await showSqlInsertModeDialog({ allowSplit: true }) : undefined;
|
||
if (format === "sql" && sqlExportOptions === null) return;
|
||
await exportTableData(row, format, undefined, "name", true, sqlExportOptions?.insertMode ?? "batch", sqlExportOptions?.splitMaxMb);
|
||
}
|
||
|
||
function showObjectBrowserXlsxHeaderDialog(hasComments: boolean): Promise<XlsxExportOptions | null> {
|
||
if (typeof document === "undefined") return Promise.resolve({ headerMode: "name", autoFilter: false });
|
||
|
||
return new Promise((resolve) => {
|
||
const container = document.createElement("div");
|
||
document.body.appendChild(container);
|
||
const app = createApp(XlsxHeaderDialog, {
|
||
open: true,
|
||
showHeaderOptions: hasComments,
|
||
onConfirm: (exportOptions: XlsxExportOptions) => {
|
||
resolve(exportOptions);
|
||
app.unmount();
|
||
document.body.removeChild(container);
|
||
},
|
||
onCancel: () => {
|
||
resolve(null);
|
||
app.unmount();
|
||
document.body.removeChild(container);
|
||
},
|
||
});
|
||
app.use(i18n);
|
||
app.mount(container);
|
||
});
|
||
}
|
||
|
||
async function exportDataXlsx(row: ObjectBrowserRow) {
|
||
const schema = row.schema || selectedSchema.value;
|
||
let columnInfos: ColumnInfo[] | undefined;
|
||
|
||
try {
|
||
columnInfos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
|
||
} catch {
|
||
// Export still works with field-name headers when column metadata is unavailable.
|
||
}
|
||
|
||
const exportOptions = await showObjectBrowserXlsxHeaderDialog(hasXlsxHeaderComments(columnInfos?.map((column) => column.comment)));
|
||
if (exportOptions === null) return;
|
||
await exportTableData(row, "xlsx", columnInfos, exportOptions.headerMode, exportOptions.autoFilter);
|
||
}
|
||
|
||
async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "sql", columnInfos?: ColumnInfo[], headerMode: XlsxHeaderMode = "name", autoFilter = true, insertMode: SqlInsertMode = "batch", splitMaxMb?: number) {
|
||
const schema = row.schema || selectedSchema.value;
|
||
const splitSqlOutput = format === "sql" && splitMaxMb !== undefined;
|
||
|
||
// Save dialog first
|
||
let filePath = "";
|
||
const defaultName = `${row.name}.${splitSqlOutput ? "zip" : format}`;
|
||
|
||
if (isTauriRuntime()) {
|
||
try {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const filter = format === "csv" ? { name: "CSV", extensions: ["csv"] } : format === "xlsx" ? { name: "Excel", extensions: ["xlsx"] } : splitSqlOutput ? { name: "ZIP", extensions: ["zip"] } : { name: "SQL", extensions: ["sql"] };
|
||
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}.${splitSqlOutput ? "zip" : format}`;
|
||
}
|
||
|
||
let task: ExportTask | null = null;
|
||
try {
|
||
if (isVictoriaMetrics.value) {
|
||
const result = await fetchTableDataForExport({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema,
|
||
tableName: row.name,
|
||
executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql),
|
||
});
|
||
if (format === "csv") {
|
||
await api.exportQueryResultCsv(filePath, result.columns, forceCsvTextForTemporalColumns(result.rows, result.column_types ?? []), settingsStore.editorSettings.csvQuoteMode);
|
||
} else {
|
||
const comments = result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment);
|
||
const headerOverrides = buildXlsxHeaderOverrides(result.columns, comments, headerMode);
|
||
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), headerOverrides, result.rows, undefined, autoFilter, settingsStore.editorSettings.globalDateTimeExportFormat || undefined);
|
||
}
|
||
toast(t("grid.exported"));
|
||
return;
|
||
}
|
||
let columns: string[] | undefined;
|
||
let columnComments: (string | null)[] | undefined;
|
||
|
||
if (columnInfos) {
|
||
columns = columnInfos.map((c) => c.name);
|
||
if (format === "xlsx") {
|
||
columnComments = buildXlsxHeaderOverrides(
|
||
columns,
|
||
columnInfos.map((column) => column.comment),
|
||
headerMode,
|
||
);
|
||
}
|
||
} else if (props.connection.db_type === "neo4j") {
|
||
const infos = await api.getColumns(props.connection.id, props.database, schema || props.database, row.name, props.catalog);
|
||
columns = infos.map((c) => c.name);
|
||
}
|
||
|
||
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,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote(props.connection.id),
|
||
tableName: row.name,
|
||
filePath,
|
||
format,
|
||
...(format === "sql" ? { insertMode, splitMaxMb } : {}),
|
||
csvQuoteMode: settingsStore.editorSettings.csvQuoteMode,
|
||
columns,
|
||
columnComments: format === "xlsx" ? columnComments : undefined,
|
||
autoFilter: format === "xlsx" ? autoFilter : undefined,
|
||
batchSize: settingsStore.editorSettings.exportBatchSize,
|
||
skipCount: format === "sql",
|
||
rowLimit,
|
||
};
|
||
|
||
const terminalProgress = await api.startTableExport(request, (progress) => {
|
||
updateTableExportTask(currentTask.exportId, progress);
|
||
});
|
||
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, tableComment?: string | null, sourceColumns?: ColumnInfo[]) {
|
||
return buildSharedDuplicateTableStructurePlan({
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
catalog: props.catalog,
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema,
|
||
sourceName,
|
||
targetName,
|
||
tableComment,
|
||
sourceColumns,
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
});
|
||
}
|
||
|
||
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, row.comment);
|
||
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: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
|
||
tableName: row.name,
|
||
tableComment: row.comment,
|
||
})),
|
||
};
|
||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||
}
|
||
|
||
function canPasteTableClipboard(): boolean {
|
||
return !isVictoriaMetrics.value && !isMongodb.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
|
||
}
|
||
|
||
function normalizedObjectBrowserTableClipboardEntries() {
|
||
const clipboard = connectionStore.treeClipboard;
|
||
if (clipboard?.kind !== "table-copy") return [];
|
||
return clipboard.tables.map((entry) => ({
|
||
...entry,
|
||
schema: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId),
|
||
}));
|
||
}
|
||
|
||
function canTransferTableClipboard(): boolean {
|
||
if (isVictoriaMetrics.value) return false;
|
||
const entries = normalizedObjectBrowserTableClipboardEntries();
|
||
const target = pasteTableTargetContext();
|
||
if (entries.length === 0 || connectionIsEffectivelyReadOnly(props.connection)) return false;
|
||
const source = tableClipboardSourceContext(entries);
|
||
const sourceConfig = source ? connectionStore.getConfig(source.connectionId) : undefined;
|
||
return !!source && !!sourceConfig && supportsTransfer(sourceConfig.db_type) && supportsTransfer(props.connection.db_type) && !tableClipboardMatchesTarget(entries, target);
|
||
}
|
||
|
||
function pasteTableTargetContext(): TableClipboardContext {
|
||
return {
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: normalizeObjectBrowserTableClipboardSchema(selectedSchema.value),
|
||
};
|
||
}
|
||
|
||
function normalizeObjectBrowserTableClipboardSchema(schema?: string, database = props.database, connectionId = props.connection.id): string | undefined {
|
||
const connection = connectionStore.getConfig(connectionId) ?? props.connection;
|
||
if (!isSchemaAware(connection.db_type) && connection.db_type !== "sqlite") return undefined;
|
||
return connectionObjectTreeNodeSchema(connection, database, schema);
|
||
}
|
||
|
||
function openTransferFromTableClipboard(): boolean {
|
||
const clipboard = connectionStore.treeClipboard;
|
||
const entries = normalizedObjectBrowserTableClipboardEntries();
|
||
const target = pasteTableTargetContext();
|
||
if (clipboard?.kind !== "table-copy") return false;
|
||
const source = tableClipboardSourceContext(entries);
|
||
if (!source || tableClipboardMatchesTarget(entries, target)) return false;
|
||
connectionStore.transferSource = {
|
||
connectionId: source.connectionId,
|
||
database: source.database,
|
||
schema: source.schema ?? undefined,
|
||
tables: clipboard.tables.map((entry) => entry.tableName),
|
||
targetConnectionId: target.connectionId,
|
||
targetDatabase: target.database,
|
||
targetSchema: target.schema ?? undefined,
|
||
};
|
||
return true;
|
||
}
|
||
|
||
function copySingleTableToClipboard(row: ObjectBrowserRow) {
|
||
connectionStore.treeClipboard = {
|
||
kind: "table-copy",
|
||
tables: [
|
||
{
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: normalizeObjectBrowserTableClipboardSchema(row.schema || selectedSchema.value),
|
||
tableName: row.name,
|
||
tableComment: row.comment,
|
||
},
|
||
],
|
||
};
|
||
toast(t("contextMenu.pasteTableClipboardUpdated"), 2000);
|
||
}
|
||
|
||
function openPasteTableDialog() {
|
||
const clipboard = connectionStore.treeClipboard;
|
||
if (canTransferTableClipboard()) {
|
||
openTransferFromTableClipboard();
|
||
return;
|
||
}
|
||
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: normalizeObjectBrowserTableClipboardSchema(entry.schema, entry.database, entry.connectionId),
|
||
tableComment: entry.tableComment,
|
||
}));
|
||
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() && !canTransferTableClipboard()) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
openPasteTableDialog();
|
||
}
|
||
}
|
||
|
||
async function confirmPasteTable() {
|
||
const entries = pasteTableEntries.value.filter((entry) => entry.targetName.trim());
|
||
if (entries.length === 0) return;
|
||
const clipboardAtPasteStart = connectionStore.treeClipboard;
|
||
const mode = pasteTableMode.value;
|
||
const copyData = pasteTableModeCopiesData(mode) && pasteTableDataCopySupported.value;
|
||
showPasteDialog.value = false;
|
||
let successCount = 0;
|
||
let pasteFailCount = 0;
|
||
let firstPasteError: unknown;
|
||
let pasteCancelled = false;
|
||
let hasMutatedTable = false;
|
||
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, entry.tableComment, sourceColumns);
|
||
sourceColumns = plan.sourceColumns;
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(plan.sql, () => executeDuplicateStructurePlan(plan, schema));
|
||
if (!executed) {
|
||
pasteCancelled = true;
|
||
break;
|
||
}
|
||
hasMutatedTable = true;
|
||
}
|
||
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,
|
||
normalizeNewTargetName: mode === "structure-and-data",
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
...dataCopyColumnOptions,
|
||
});
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
|
||
if (!executed) {
|
||
pasteCancelled = true;
|
||
break;
|
||
}
|
||
hasMutatedTable = true;
|
||
}
|
||
successCount++;
|
||
} catch (e: any) {
|
||
pasteFailCount++;
|
||
firstPasteError ??= e;
|
||
console.error(`Failed to paste table "${entry.sourceName}" -> "${targetName}":`, e);
|
||
}
|
||
}
|
||
if (pasteCancelled) {
|
||
if (hasMutatedTable) {
|
||
try {
|
||
await reload();
|
||
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, selectedSchema.value);
|
||
toast(t("contextMenu.pasteTableCancelledAfterPartial"), 5000);
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.pasteTableRefreshFailed", { message: translateBackendError(t, e) }), 5000);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const pasteFeedback = tablePasteFeedback(successCount, pasteFailCount, firstPasteError);
|
||
if (pasteFailCount === 0) {
|
||
if (connectionStore.treeClipboard === clipboardAtPasteStart) {
|
||
connectionStore.treeClipboard = null;
|
||
}
|
||
toast(t("contextMenu.batchPasteSuccess", { count: successCount }), 3000);
|
||
} else {
|
||
toast(`${t("contextMenu.batchPastePartialFail", { success: pasteFeedback.successCount, failed: pasteFeedback.failedCount })}\n${t("contextMenu.tableOperationFailed", { message: translateBackendError(t, pasteFeedback.firstError) })}`, 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: connectionTableSqlSchema(props.connection, row.schema || selectedSchema.value),
|
||
tableName: row.name,
|
||
// Cloud Spanner's dialect decides the quote; the static per-type mapping cannot.
|
||
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connection.id),
|
||
};
|
||
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 compileXuguObject(row: ObjectBrowserRow) {
|
||
if (effectiveDatabaseType.value !== "xugu") return;
|
||
const sql = buildXuguCompileSql({ objectType: row.type, schema: row.schema || selectedSchema.value, name: row.name });
|
||
if (!sql) return;
|
||
try {
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, row.schema || selectedSchema.value));
|
||
if (!executed) return;
|
||
toast(t("contextMenu.compileObjectSuccess", { name: row.name }));
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 refreshVacuumPreviewSql() {
|
||
const row = vacuumTarget.value;
|
||
vacuumPreviewSql.value = "";
|
||
if (!row) return;
|
||
vacuumPreviewSql.value = await buildVacuumTableSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
full: vacuumTableFull.value,
|
||
analyze: vacuumTableAnalyze.value,
|
||
}).catch(() => "");
|
||
}
|
||
|
||
function requestVacuumTable(row: ObjectBrowserRow) {
|
||
if (!supportsVacuumTable.value) return;
|
||
vacuumTarget.value = row;
|
||
vacuumTableFull.value = false;
|
||
vacuumTableAnalyze.value = false;
|
||
vacuumPreviewSql.value = "";
|
||
void refreshVacuumPreviewSql();
|
||
showVacuumConfirm.value = true;
|
||
}
|
||
|
||
async function confirmVacuumTable() {
|
||
const row = vacuumTarget.value;
|
||
if (!row || vacuumExecuting.value) return;
|
||
vacuumExecuting.value = true;
|
||
try {
|
||
const sql =
|
||
vacuumPreviewSql.value ||
|
||
(await buildVacuumTableSql({
|
||
databaseType: effectiveDatabaseType.value,
|
||
schema: row.schema || selectedSchema.value,
|
||
tableName: row.name,
|
||
full: vacuumTableFull.value,
|
||
analyze: vacuumTableAnalyze.value,
|
||
}));
|
||
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, row.schema || selectedSchema.value));
|
||
if (!executed) return;
|
||
toast(t("contextMenu.vacuumTableSuccess", { name: row.name }));
|
||
showVacuumConfirm.value = false;
|
||
vacuumTarget.value = null;
|
||
} catch (e: any) {
|
||
toast(t("contextMenu.tableOperationFailed", { message: e?.message || String(e) }), 5000);
|
||
} finally {
|
||
vacuumExecuting.value = false;
|
||
}
|
||
}
|
||
|
||
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 openInitialEventIfNeeded() {
|
||
const name = props.initialEventName?.trim() ?? "";
|
||
const decision = resolveInitialEventEditorRequest({
|
||
eventCreateRequestId: props.initialEventCreateRequestId,
|
||
eventName: props.initialEventName,
|
||
eventOpenRequestId: props.initialEventOpenRequestId,
|
||
openedRequestKey: openedInitialEvent.value,
|
||
hasEventRow: rows.value.some((candidate) => candidate.type === "EVENT" && candidate.name === name),
|
||
loadingObjects: loadingObjects.value,
|
||
});
|
||
if (decision.type === "ignore") return;
|
||
openedInitialEvent.value = decision.requestKey;
|
||
if (decision.type === "create") {
|
||
// 新建事件:不依赖对象列表中的 EVENT row,直接进入 CREATE 编辑器。
|
||
// MySqlEventEditor 收到空 name 时会以 CREATE 模式渲染。
|
||
sidePanelGuard.start();
|
||
sidePanelRow.value = null;
|
||
sourceRow.value = null;
|
||
sidePanelMode.value = "event-editor";
|
||
return;
|
||
}
|
||
const row = rows.value.find((candidate) => candidate.type === "EVENT" && candidate.name === name);
|
||
if (row) openEventEditor(row);
|
||
}
|
||
|
||
function finishObjectBrowserRowsLoad() {
|
||
loadingObjects.value = false;
|
||
const preferredFilter = props.initialEventName || props.initialEventCreateRequestId !== undefined ? "events" : (props.selectedObjectFilter ?? props.initialObjectFilter ?? "tables");
|
||
if (!userHasSelectedFilter.value && objectCounts.value[preferredFilter] > 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 = preferredFilter;
|
||
}
|
||
openInitialEventIfNeeded();
|
||
restoreObjectBrowserViewport();
|
||
}
|
||
|
||
watch([() => props.initialEventName, () => props.initialEventOpenRequestId, () => props.initialEventCreateRequestId], ([name, requestId, createRequestId], [previousName, previousRequestId, previousCreateRequestId]) => {
|
||
if (name !== previousName || requestId !== previousRequestId || createRequestId !== previousCreateRequestId) {
|
||
openedInitialEvent.value = "";
|
||
if (name || createRequestId !== undefined) objectFilter.value = "events";
|
||
}
|
||
openInitialEventIfNeeded();
|
||
});
|
||
|
||
// 达梦的对象列表 SQL 固定 `WHERE o.OWNER = ?`(DamengAgent),空 schema 必然
|
||
// 匹配 0 行;schema 解析失败(loadSchemas 抛错或返回空列表)时标签会整体空白
|
||
// (#8301)。经 objectListSchemaForConnection 回退到连接用户名(大写),仅限
|
||
// 达梦;oracle/oceanbase-oracle 维持空 schema 由后端解析当前 schema。
|
||
async function loadObjects(options?: { allowCached?: boolean; preserveExistingRows?: boolean }) {
|
||
error.value = "";
|
||
// A new load supersedes any in-flight one, so reset the transient refresh flags
|
||
// on entry. A superseded request's finally() can no longer run (the guard's
|
||
// epoch moved on) and would otherwise leave refreshingObjects stuck spinning
|
||
// the toolbar icon — the newest request owns the spinner state from here.
|
||
loadingObjects.value = false;
|
||
refreshingObjects.value = false;
|
||
scaffoldRefreshError.value = "";
|
||
// True when we are revalidating on top of visible rows (a stale-cache scaffold, or a
|
||
// same-instance refresh) — a failure then keeps the rows and raises a non-blocking
|
||
// banner instead of replacing the whole list with a full-area error.
|
||
let scaffoldRefresh = false;
|
||
const schema = needsSchema.value ? objectListSchemaForConnection(props.connection, selectedSchema.value) : props.database;
|
||
const request = objectBrowserRowsLoadGuard.start(objectBrowserRowsCacheScope(schema));
|
||
const cacheWriteToken = createObjectBrowserRowsCacheWriteToken(request.scope);
|
||
|
||
// finishObjectBrowserRowsLoad() is idempotent, but keep the default-filter /
|
||
// viewport restores to a single run per load so the events-preferred-filter case
|
||
// doesn't leave preserveObjectFilterScrollOnce latched across an extra call.
|
||
let finished = false;
|
||
const finishOnce = () => {
|
||
if (finished) return;
|
||
finished = true;
|
||
finishObjectBrowserRowsLoad();
|
||
};
|
||
|
||
const cached = options?.allowCached ? getCachedObjectBrowserRowsForScaffold(request.scope) : undefined;
|
||
if (cached) {
|
||
// Restore the last-known rows when remounting this tab. The cached list is
|
||
// authoritative for navigation restores, including entries older than the
|
||
// freshness TTL; re-querying here makes every tab switch look like a refresh.
|
||
// Explicit refresh and metadata invalidation still bypass this branch.
|
||
applyObjectBrowserRows(cached.rows);
|
||
finishOnce();
|
||
return;
|
||
} else {
|
||
// No scaffold: first load in this scope, cache invalidated by a DDL mutation,
|
||
// or the caller wants a true reload. If the caller explicitly asked to keep the
|
||
// current rows (same-instance refresh) and rows exist, refresh without blanking.
|
||
const keepExisting = options?.preserveExistingRows && rows.value.length > 0;
|
||
if (keepExisting) {
|
||
scaffoldRefresh = true;
|
||
refreshingObjects.value = true;
|
||
} else {
|
||
loadingObjects.value = true;
|
||
rows.value = [];
|
||
}
|
||
}
|
||
|
||
try {
|
||
const nextRows = props.connection.db_type === "mongodb" ? await loadMongoObjectBrowserRows(request.scope.connectionId, request.scope.database) : await loadSqlObjectBrowserRows(request);
|
||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||
applyObjectBrowserRows(nextRows);
|
||
const cachedAt = cacheObjectBrowserRows(cacheWriteToken, nextRows);
|
||
if (props.connection.db_type !== "mongodb") void loadObjectStatistics(request, cacheWriteToken, cachedAt);
|
||
} catch (e: any) {
|
||
if (!objectBrowserRowsLoadGuard.isCurrent(request)) return;
|
||
// Keep visible rows on a background revalidate failure — surface a lightweight
|
||
// banner rather than replacing the scaffold (or same-instance refresh) list.
|
||
if (scaffoldRefresh) {
|
||
scaffoldRefreshError.value = translateBackendError(t, e);
|
||
} else {
|
||
error.value = translateBackendError(t, e);
|
||
}
|
||
} finally {
|
||
if (objectBrowserRowsLoadGuard.isCurrent(request)) {
|
||
loadingObjects.value = false;
|
||
refreshingObjects.value = false;
|
||
finishOnce();
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadSqlObjectBrowserRows(request: ObjectBrowserRowsLoadHandle) {
|
||
const objects: ObjectInfo[] = await api.listObjects(request.scope.connectionId, request.scope.database, request.scope.schema, undefined, undefined, undefined, undefined, request.scope.catalog);
|
||
return buildObjectBrowserRows({
|
||
objects,
|
||
database: request.scope.database,
|
||
fallbackSchema: request.scope.schema,
|
||
rowSchema: connectionObjectTreeNodeSchema(props.connection, props.database, selectedSchema.value),
|
||
});
|
||
}
|
||
|
||
async function loadMongoObjectBrowserRows(connectionId: string, database: string) {
|
||
const collections = await api.mongoListCollections(connectionId, database);
|
||
return buildMongoObjectBrowserRows({ collections: visibleMongoCollections(collections), database });
|
||
}
|
||
|
||
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; preserveExistingRows?: boolean }) {
|
||
const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate();
|
||
try {
|
||
if (!(await loadSchemas(epoch))) return;
|
||
} catch (e) {
|
||
// listSchemas 失败(如共享连接上 SET SCHEMA 后的二次元数据请求)时,
|
||
// loadSchemas 不会触碰 selectedSchema,这里也不清空它:保留 props.schema
|
||
// 或用户已选值,继续尝试加载对象列表(达梦走用户名回退),避免标签静默
|
||
// 空白 (#8301)。对象列表若同样失败,loadObjects 自身的 catch 会展示错误。
|
||
console.warn("[ObjectBrowser] loadSchemas failed, keeping selected schema for", props.connection.id, e);
|
||
}
|
||
if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return;
|
||
await loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows });
|
||
}
|
||
|
||
function refresh(): boolean {
|
||
void reload({ preserveExistingRows: true });
|
||
void refreshActiveTableInfo();
|
||
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"
|
||
? isMongodb.value
|
||
? "objects.collections"
|
||
: "objects.tables"
|
||
: filter === "views"
|
||
? "objects.views"
|
||
: filter === "materializedViews"
|
||
? "tree.materializedViews"
|
||
: filter === "procedures"
|
||
? "objects.procedures"
|
||
: filter === "functions"
|
||
? "objects.functions"
|
||
: filter === "triggers"
|
||
? "tree.triggers"
|
||
: filter === "events"
|
||
? "tree.events"
|
||
: filter === "sequences"
|
||
? "objects.sequences"
|
||
: filter === "packages"
|
||
? "objects.packages"
|
||
: filter === "types"
|
||
? "tree.types"
|
||
: "objects.all";
|
||
return `${t(key)} ${filterCount(filter)}`;
|
||
}
|
||
|
||
function selectObjectFilter(filter: ObjectFilter) {
|
||
userHasSelectedFilter.value = true;
|
||
objectFilter.value = filter;
|
||
emit("filterChange", filter);
|
||
}
|
||
|
||
function getSearchInput(): HTMLInputElement | null {
|
||
return rootRef.value?.querySelector<HTMLInputElement>("[data-object-search-input]") ?? null;
|
||
}
|
||
|
||
function focusSearch(target: Element | null = null): boolean {
|
||
const tableInfoPanel = target?.closest<HTMLElement>("[data-object-table-info-panel]");
|
||
if (tableInfoPanel) {
|
||
const input = tableInfoPanel.querySelector<HTMLInputElement>("[data-table-info-search]");
|
||
if (input) {
|
||
input.focus();
|
||
input.select();
|
||
return true;
|
||
}
|
||
}
|
||
const input = getSearchInput();
|
||
if (!input) return false;
|
||
input.focus();
|
||
input.select();
|
||
return true;
|
||
}
|
||
|
||
function onSearchKeydown(event: KeyboardEvent) {
|
||
if (!isCancelSearchShortcut(event)) return;
|
||
event.preventDefault();
|
||
clearObjectSearch();
|
||
}
|
||
|
||
function clearObjectSearch() {
|
||
search.value = "";
|
||
getSearchInput()?.focus();
|
||
}
|
||
|
||
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 {
|
||
const formats: ContextMenuItem[] = [
|
||
{ label: "CSV", action: () => exportData(item, "csv") },
|
||
{ label: "JSON", action: () => exportData(item, "json") },
|
||
];
|
||
if (!isVictoriaMetrics.value) formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql") });
|
||
formats.push({ label: "XLSX", action: () => exportDataXlsx(item) });
|
||
return {
|
||
label: t("contextMenu.exportData"),
|
||
icon: Upload,
|
||
children: formats,
|
||
};
|
||
}
|
||
|
||
function objectBrowserTableClipboardMenuState(item: ObjectBrowserRow) {
|
||
return tableClipboardMenuState(
|
||
normalizedObjectBrowserTableClipboardEntries(),
|
||
{
|
||
connectionId: props.connection.id,
|
||
database: props.database,
|
||
schema: normalizeObjectBrowserTableClipboardSchema(item.schema || selectedSchema.value),
|
||
tableName: item.name,
|
||
},
|
||
canTransferTableClipboard(),
|
||
);
|
||
}
|
||
|
||
function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
if (isVictoriaMetrics.value) return [];
|
||
const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: () => copySingleTableToClipboard(item), icon: Copy };
|
||
const state = objectBrowserTableClipboardMenuState(item);
|
||
if (state === "copy") return [copyItem];
|
||
const pasteItem: ContextMenuItem = { label: t("contextMenu.pasteTable"), action: openPasteTableDialog, icon: Clipboard };
|
||
return state === "paste" ? [pasteItem] : [copyItem, pasteItem];
|
||
}
|
||
|
||
function isSelectedBatchTableContext(item: ObjectBrowserRow): boolean {
|
||
return item.type === "TABLE" && selectedTableCount.value > 1 && selectedTableIds.value.has(item.id);
|
||
}
|
||
|
||
function addToAiMenuItem(item: ObjectBrowserRow): ContextMenuItem {
|
||
const useBatch = isSelectedBatchTableContext(item);
|
||
const count = selectedTableCount.value;
|
||
// Schema stays per-row: the consumer (App.vue addToAi) resolves the final
|
||
// schema with its own fallback chain (table.schema || tab.schema — no
|
||
// database fallback, mirroring the sidebar tree path). ObjectBrowser is a
|
||
// single-schema view, but keeping row-level schemas makes the payload honest
|
||
// and future-proof if multi-schema selection ever appears.
|
||
const targets = useBatch ? selectedTableRows.value.map((row) => ({ name: row.name, schema: row.schema })) : [{ name: item.name, schema: item.schema }];
|
||
return {
|
||
label: useBatch ? t("contextMenu.addToAiMultiple", { count }) : t("contextMenu.addToAi"),
|
||
action: () => emit("addToAi", targets),
|
||
icon: Sparkles,
|
||
};
|
||
}
|
||
|
||
function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batchEmpty"): string {
|
||
return t(`contextMenu.${key}`, { count: selectedTableCount.value });
|
||
}
|
||
|
||
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
if (isVictoriaMetrics.value) {
|
||
return [
|
||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
|
||
...(supportsAiAssistantContext(effectiveDatabaseType.value) ? [addToAiMenuItem(item)] : []),
|
||
{ label: "", separator: true },
|
||
exportDataSubmenu(item),
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
const useBatchActions = isSelectedBatchTableContext(item);
|
||
const moreActions: ContextMenuItem[] = [];
|
||
if (supportsVacuumTable.value) {
|
||
moreActions.push({ label: t("contextMenu.vacuumTable"), action: () => requestVacuumTable(item), icon: Activity, variant: "destructive" as const });
|
||
}
|
||
if (supportsTruncateTable.value) {
|
||
moreActions.push({
|
||
label: useBatchActions ? selectedBatchTableCountLabel("batchTruncate") : t("contextMenu.truncateTable"),
|
||
action: useBatchActions ? requestBatchTruncateTables : () => requestTruncateTable(item),
|
||
icon: Scissors,
|
||
variant: "destructive" as const,
|
||
});
|
||
}
|
||
moreActions.push(
|
||
{
|
||
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,
|
||
},
|
||
);
|
||
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 },
|
||
...(supportsAiAssistantContext(effectiveDatabaseType.value) ? [addToAiMenuItem(item)] : []),
|
||
...(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 },
|
||
...tableClipboardMenuItems(item),
|
||
{ label: "", separator: true },
|
||
{ label: t("common.more"), icon: ListTree, children: moreActions },
|
||
{ 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 }] : []),
|
||
...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []),
|
||
{ 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 getEventMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
return [
|
||
{ label: t("contextMenu.editObject"), action: () => openEventEditor(item), icon: PencilLine },
|
||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.dropObject"), 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 [
|
||
...(effectiveDatabaseType.value === "xugu" && buildXuguCompileSql({ objectType: item.type, schema: item.schema || selectedSchema.value, name: item.name }) ? [{ label: t("contextMenu.compileObject"), action: () => compileXuguObject(item), icon: Wrench }] : []),
|
||
{ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 },
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
|
||
function getTypeMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
const items: ContextMenuItem[] = [];
|
||
const capabilities = customTypeCapabilities(effectiveDatabaseType.value);
|
||
// Verified PG-family types open the read-only details panel; Xugu keeps its
|
||
// “view source” entry for TYPE/TYPE_BODY rows.
|
||
if (supportsTypeObjectSource(effectiveDatabaseType.value)) {
|
||
items.push({ label: t("contextMenu.viewSource"), action: () => openSource(item), icon: Code2 });
|
||
}
|
||
if (capabilities.details) {
|
||
items.push({ label: t("contextMenu.viewDetails"), action: () => void openTypeInfo(item), icon: Info });
|
||
if (capabilities.ddl) {
|
||
items.push({ label: t("contextMenu.viewDdl"), action: () => openTypeDdl(item), icon: FileCode });
|
||
}
|
||
}
|
||
// Only separate when an action precedes copy-name.
|
||
if (items.length > 0) {
|
||
items.push({ label: "", separator: true });
|
||
}
|
||
items.push({ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy });
|
||
return items;
|
||
}
|
||
|
||
function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
|
||
if (isMongodb.value) {
|
||
return [
|
||
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
|
||
{ label: "", separator: true },
|
||
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
|
||
];
|
||
}
|
||
if (item.type === "TABLE") return getTableMenuItems(item);
|
||
if (item.type === "VIEW" || item.type === "MATERIALIZED_VIEW") return getViewMenuItems(item);
|
||
if (item.type === "EVENT") return getEventMenuItems(item);
|
||
if (item.type === "TYPE" || item.type === "TYPE_BODY") return getTypeMenuItems(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 v-if="!isEventEditor" ref="toolbarRef" class="flex h-10 shrink-0 items-center gap-2 overflow-hidden border-b px-3">
|
||
<div class="flex min-w-12 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 && showDatabaseChip" 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-24 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 pr-6 text-xs" :placeholder="isMongodb ? t('objects.searchCollections') : t('objects.search')" @keydown="onSearchKeydown" />
|
||
<button v-if="search" type="button" class="absolute right-1.5 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" :aria-label="t('common.clear')" @click="clearObjectSearch">
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
<div v-if="showObjectFilter && showInlineObjectFilter" 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="selectObjectFilter(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 v-if="showInlineSortAndView" 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 v-if="showInlineSortAndView" 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 v-if="showInlineCheckboxToggle" 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="refresh">
|
||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingObjects || refreshingObjects }" />
|
||
</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>
|
||
<ToolbarOverflowMenu v-if="showToolbarOverflow" :label="t('toolbar.moreActions')" button-class="h-7 w-7">
|
||
<DropdownMenuSub>
|
||
<DropdownMenuSubTrigger>
|
||
<ArrowDown class="h-3.5 w-3.5" />
|
||
{{ t("objects.sortBy") }}
|
||
</DropdownMenuSubTrigger>
|
||
<DropdownMenuSubContent>
|
||
<DropdownMenuItem v-for="key in sortKeyOptions" :key="key" @select="onSortKeyChange(key)">
|
||
<Check v-if="sortKey === key" class="h-3.5 w-3.5" />
|
||
{{ sortKeyLabel(key) }}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuSubContent>
|
||
</DropdownMenuSub>
|
||
<DropdownMenuItem @select="sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'">
|
||
<ArrowUp v-if="sortDirection === 'asc'" class="h-3.5 w-3.5" />
|
||
<ArrowDown v-else class="h-3.5 w-3.5" />
|
||
{{ sortDirection === "asc" ? t("objects.sortDesc") : t("objects.sortAsc") }}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem @select="setViewMode('list')">
|
||
<List class="h-3.5 w-3.5" />
|
||
{{ t("objects.viewList") }}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem @select="setViewMode('grid')">
|
||
<LayoutGrid class="h-3.5 w-3.5" />
|
||
{{ t("objects.viewGrid") }}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuCheckboxItem :model-value="settingsStore.editorSettings.objectBrowserShowCheckbox" @select.prevent @update:model-value="toggleCheckboxColumn()">{{ t("objects.toggleCheckbox") }}</DropdownMenuCheckboxItem>
|
||
<template v-if="showObjectFilter && toolbarTier >= 2">
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuCheckboxItem v-for="filter in objectFilters" :key="filter" :model-value="objectFilter === filter" @select.prevent @update:model-value="selectObjectFilter(filter)">{{ filterLabel(filter) }}</DropdownMenuCheckboxItem>
|
||
</template>
|
||
</ToolbarOverflowMenu>
|
||
</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 v-if="supportsObjectSizeStats" 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 v-if="supportsObjectSizeStats" 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="supportsObjectSizeStats && 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 v-if="supportsObjectSizeStats" 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 v-if="supportsObjectSizeStats" 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="scaffoldRefreshError" role="status" class="flex h-8 shrink-0 items-center gap-2 border-b border-destructive/30 bg-destructive/5 px-3 text-xs text-destructive">
|
||
<RefreshCw class="h-3 w-3 shrink-0" />
|
||
<span class="min-w-0 truncate">{{ scaffoldRefreshError }}</span>
|
||
</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" :class="{ 'event-editor-layout': isEventEditor }">
|
||
<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-hidden">
|
||
<div ref="objectListHeaderRef" class="h-7 shrink-0 overflow-hidden">
|
||
<div class="grid h-7 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 v-if="showObjectRowStats" 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">{{ objectRowsLabel }}</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 v-if="showObjectSizeStats" 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>
|
||
</div>
|
||
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ '--dbx-object-grid-min-width': `${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, isOpen }">
|
||
<div
|
||
class="grid h-[34px] cursor-default items-center gap-3 border-b px-3"
|
||
:class="{
|
||
'bg-accent text-accent-foreground': isOpen || sourceRow?.id === item.id || sidePanelRow?.id === item.id || selectedTableIds.has(item.id),
|
||
'hover:bg-accent/40': !isOpen && sourceRow?.id !== item.id && sidePanelRow?.id !== item.id && !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) }}</div>
|
||
<div v-if="showObjectRowStats" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
|
||
{{ formatObjectBrowserCount(item.estimatedRows) }}
|
||
</div>
|
||
<div v-if="showObjectSizeStats" 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, isOpen }">
|
||
<div
|
||
class="relative flex h-full min-h-0 cursor-default flex-col items-center gap-1 rounded-lg border p-3 text-center transition-all"
|
||
:class="{
|
||
'border-primary bg-accent shadow-sm': isOpen || sourceRow?.id === item.id || sidePanelRow?.id === item.id || selectedTableIds.has(item.id),
|
||
'bg-card hover:border-primary/40 hover:bg-accent/40 hover:shadow-sm': !isOpen && sourceRow?.id !== item.id && 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) }}</span>
|
||
<span v-if="showObjectRowStats && 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="showObjectSizeStats && 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 || isEventEditor"
|
||
:data-object-table-info-panel="sidePanelMode === 'table-info' ? '' : undefined"
|
||
class="object-browser-side-panel relative flex min-h-0 min-w-0 shrink-0 flex-col border-l bg-background"
|
||
:class="{ 'side-panel-resizing': isResizingSidePanel }"
|
||
:style="{ width: `min(${sidePanelWidth}px, 100%)` }"
|
||
>
|
||
<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' && tableMetadataCapabilities.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="sm" class="table-info-action-button h-6 px-2 text-xs" :class="{ 'bg-accent': settingsStore.editorSettings.tableDdlWordWrap }" :title="t('settings.wordWrap')" :aria-label="t('settings.wordWrap')" @click="toggleTableDdlWordWrap">
|
||
<WrapText class="w-3 h-3" />
|
||
<span class="table-info-action-label">{{ t("settings.wordWrap") }}</span>
|
||
</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="flex items-center gap-1 px-2 py-1.5 border-b shrink-0 bg-background">
|
||
<div class="relative min-w-0 flex-1">
|
||
<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" data-table-info-search :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>
|
||
<Button variant="ghost" size="icon" class="h-7 w-7 shrink-0" :disabled="activeTableInfoLoading" :title="t('structureEditor.refresh')" :aria-label="t('structureEditor.refresh')" @click="refreshActiveTableInfo">
|
||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': activeTableInfoLoading }" />
|
||
</Button>
|
||
</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">{{ gaussdbMColumnType(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 === 'constraints'" class="flex-1 min-h-0 overflow-auto">
|
||
<div v-if="tableConstraintsLoading" 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 && filteredTableConstraints.length === 0" class="p-6 text-center text-xs text-muted-foreground">
|
||
{{ t("grid.tableInfoNoResults") }}
|
||
</div>
|
||
<div v-else-if="tableConstraintsForTab.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="constraint in filteredTableConstraints" :key="constraint.name" class="p-3 text-xs" :class="constraint.enabled ? '' : 'opacity-60'">
|
||
<div class="flex flex-wrap items-center gap-1.5">
|
||
<span class="font-medium truncate">{{ constraint.name }}</span>
|
||
<span class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ constraint.constraint_type }}</span>
|
||
<span v-if="!constraint.enabled" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintDisabled") }}</span>
|
||
<span v-else-if="!constraint.valid" class="rounded border px-1 py-px text-[10px] text-muted-foreground">{{ t("grid.tableInfoConstraintNotValidated") }}</span>
|
||
</div>
|
||
<div v-if="constraint.columns.length" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">{{ constraint.columns.join(", ") }}</div>
|
||
<div v-if="constraint.ref_table" class="mt-1 font-mono text-[11px] text-muted-foreground break-all">-> {{ constraint.ref_schema ? `${constraint.ref_schema}.` : "" }}{{ constraint.ref_table }}{{ constraint.ref_columns.length ? `(${constraint.ref_columns.join(", ")})` : "" }}</div>
|
||
<div v-if="constraint.definition" class="mt-1 font-mono text-[11px] text-muted-foreground break-all whitespace-pre-wrap">{{ constraint.definition }}</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="settingsStore.editorSettings.tableDdlWordWrap ? '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>
|
||
<!-- Type info mode (read-only user-defined type details) -->
|
||
<template v-else-if="sidePanelMode === 'type-info'">
|
||
<CustomTypeInfoPanel ref="sidePanelRef" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name || ''" :catalog="props.catalog" @close="closeSidePanel" />
|
||
</template>
|
||
<template v-else-if="sidePanelMode === 'event-editor'">
|
||
<MySqlEventEditor :key="eventEditorKey" :connection="props.connection" :database="props.database" :schema="sidePanelRow?.schema || selectedSchema || props.database" :name="sidePanelRow?.name" :read-only="props.initialEventReadOnly" @saved="onEventSaved" @close="closeSidePanel" />
|
||
</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 min-w-0 max-w-full overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap" v-html="highlight(renamePreviewSqlText)"></pre>
|
||
<p v-if="renameError" class="min-w-0 max-w-full overflow-x-auto 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="showVacuumConfirm"
|
||
:title="t('contextMenu.vacuumTableTitle')"
|
||
:message="t('contextMenu.vacuumTableMessage', { name: vacuumTarget?.name ?? '' })"
|
||
:details-text="vacuumRiskMessage"
|
||
:sql="vacuumPreviewSql"
|
||
:confirm-label="vacuumExecuting ? t('contextMenu.vacuumTableRunning') : vacuumTableFull ? t('contextMenu.vacuumTableFullConfirm') : t('contextMenu.vacuumTable')"
|
||
:loading="vacuumExecuting"
|
||
:close-on-confirm="false"
|
||
@confirm="confirmVacuumTable"
|
||
>
|
||
<template #options>
|
||
<div class="mb-3 flex gap-3 rounded-md border bg-muted/20 px-3 py-2 text-sm">
|
||
<label class="flex items-center gap-2" :title="t('contextMenu.vacuumTableAnalyzeHint')">
|
||
<input v-model="vacuumTableAnalyze" :disabled="vacuumExecuting" type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-primary" @change="refreshVacuumPreviewSql" />
|
||
<span class="font-medium text-foreground">ANALYZE</span>
|
||
</label>
|
||
<label class="flex items-center gap-2" :title="t('contextMenu.vacuumTableFullHint')">
|
||
<input v-model="vacuumTableFull" :disabled="vacuumExecuting" type="checkbox" class="h-3.5 w-3.5 shrink-0 accent-destructive" @change="refreshVacuumPreviewSql" />
|
||
<span class="font-medium" :class="vacuumTableFull ? 'text-destructive' : 'text-foreground'">FULL</span>
|
||
</label>
|
||
</div>
|
||
</template>
|
||
</DangerConfirmDialog>
|
||
|
||
<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;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
/* Keep the horizontal track discoverable when the platform uses overlay
|
||
scrollbars, while leaving the native vertical scrollbar in place. */
|
||
.object-browser-scroller::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
.object-browser-scroller::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.object-browser-scroller::-webkit-scrollbar-thumb {
|
||
border: 1px solid transparent;
|
||
border-radius: 999px;
|
||
background: rgba(82, 82, 82, 0.28);
|
||
background: color-mix(in oklch, var(--foreground) 28%, transparent);
|
||
background-clip: padding-box;
|
||
}
|
||
|
||
.object-browser-scroller:hover::-webkit-scrollbar-thumb {
|
||
border: 0;
|
||
background: rgba(82, 82, 82, 0.45);
|
||
background: color-mix(in oklch, var(--foreground) 45%, transparent);
|
||
}
|
||
|
||
html.dbx-legacy-webview.dark .object-browser-scroller::-webkit-scrollbar-thumb {
|
||
background: rgba(212, 212, 216, 0.28);
|
||
}
|
||
|
||
html.dbx-legacy-webview.dark .object-browser-scroller:hover::-webkit-scrollbar-thumb {
|
||
background: rgba(212, 212, 216, 0.45);
|
||
}
|
||
|
||
/* The scroller itself stays viewport-width so its vertical scrollbar remains
|
||
visible at the right edge; the row content inside scrolls horizontally past
|
||
that width instead (issue #8885). */
|
||
.object-browser-scroller :deep(.vue-recycle-scroller__item-wrapper) {
|
||
min-width: var(--dbx-object-grid-min-width, 0px);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.event-editor-layout > :first-child {
|
||
display: none;
|
||
}
|
||
|
||
.event-editor-layout > .object-browser-side-panel {
|
||
width: 100% !important;
|
||
border-left: 0;
|
||
}
|
||
|
||
.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>
|