3181 lines
173 KiB
Vue
3181 lines
173 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref, defineAsyncComponent, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||
import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||
import { canReloadUnavailableDataTab, restoredDataTabReloadFilters } from "@/lib/table/tableDataRefresh";
|
||
import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
|
||
import { queryResultMessages } from "@/lib/query/queryResultMessages";
|
||
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||
import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
|
||
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
|
||
import type { CSSProperties } from "vue";
|
||
import { useI18n } from "vue-i18n";
|
||
import { provideTabUiState } from "@/lib/tabs/tabUiState";
|
||
import {
|
||
Check,
|
||
CheckSquare2,
|
||
Columns3Cog,
|
||
Copy,
|
||
EyeOff,
|
||
Gauge,
|
||
Loader2,
|
||
Search,
|
||
TableProperties,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Inbox,
|
||
RefreshCcw,
|
||
Wrench,
|
||
Toolbox,
|
||
Database,
|
||
Download,
|
||
Upload,
|
||
X,
|
||
Pin,
|
||
Pencil,
|
||
Rows3,
|
||
Hash,
|
||
SquareDashed,
|
||
Minus,
|
||
Plus,
|
||
ShieldAlert,
|
||
AlignLeft,
|
||
AlignRight,
|
||
PanelsTopLeft,
|
||
Palette,
|
||
CircleAlert,
|
||
CircleStop,
|
||
RotateCcw,
|
||
SkipForward,
|
||
ListX,
|
||
LocateFixed,
|
||
} from "@lucide/vue";
|
||
import { Splitpanes, Pane } from "splitpanes";
|
||
import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
|
||
import "splitpanes/dist/splitpanes.css";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuPortal } from "@/components/ui/dropdown-menu";
|
||
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||
import QueryEditor from "@/components/editor/QueryEditor.vue";
|
||
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
|
||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
|
||
import QueryMessagesView from "@/components/layout/QueryMessagesView.vue";
|
||
import QueryResultToolbarActions from "@/components/layout/QueryResultToolbarActions.vue";
|
||
import ResultSetNavigator from "@/components/layout/ResultSetNavigator.vue";
|
||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||
import DataGridCopyFormatControl from "@/components/grid/DataGridCopyFormatControl.vue";
|
||
import DataGridFontFamilyControl from "@/components/grid/DataGridFontFamilyControl.vue";
|
||
import DataGridColumnLayoutPopover from "@/components/grid/DataGridColumnLayoutPopover.vue";
|
||
import type { DataGridColumnLayoutHandle } from "@/components/grid/dataGridColumnLayoutPopover";
|
||
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
|
||
let dataGridComponentPromise: Promise<typeof import("@/components/grid/DataGrid.vue")> | undefined;
|
||
function loadDataGridComponent() {
|
||
if (!dataGridComponentPromise) {
|
||
dataGridComponentPromise = (async () => {
|
||
const shouldLogTiming = isDebugLoggingEnabled();
|
||
const startedAt = shouldLogTiming ? performance.now() : 0;
|
||
if (shouldLogTiming) appendDebugLog("info", "[DBX][DataGrid:load:start]");
|
||
const component = await import("@/components/grid/DataGrid.vue");
|
||
if (shouldLogTiming) appendDebugLog("info", "[DBX][DataGrid:load:done]", { elapsed: `${Math.round(performance.now() - startedAt)}ms` });
|
||
return component;
|
||
})();
|
||
}
|
||
return dataGridComponentPromise;
|
||
}
|
||
|
||
function preloadDataGridComponent() {
|
||
void loadDataGridComponent();
|
||
}
|
||
|
||
const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
|
||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
|
||
const EtcdAccessControl = defineAsyncComponent(() => import("@/components/etcd/EtcdAccessControl.vue"));
|
||
const ZooKeeperKeyBrowser = defineAsyncComponent(() => import("@/components/zookeeper/ZooKeeperKeyBrowser.vue"));
|
||
const ConsulOverview = defineAsyncComponent(() => import("@/components/consul/ConsulOverview.vue"));
|
||
const ConsulWorkspace = defineAsyncComponent(() => import("@/components/consul/ConsulWorkspace.vue"));
|
||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||
const MeilisearchIndexView = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchIndexView.vue"));
|
||
const MeilisearchSystemWorkspace = defineAsyncComponent(() => import("@/components/meilisearch/MeilisearchSystemWorkspace.vue"));
|
||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||
const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue"));
|
||
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
|
||
const ElasticsearchProfilePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchProfilePanel.vue"));
|
||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||
const MqttAdminConsole = defineAsyncComponent(() => import("@/components/mqtt/MqttAdminConsole.vue"));
|
||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||
const NacosAccessControlConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAccessControlConsole.vue"));
|
||
const NacosDashboard = defineAsyncComponent(() => import("@/components/nacos/NacosDashboard.vue"));
|
||
const DoltVersionControl = defineAsyncComponent(() => import("@/components/dolt/DoltVersionControl.vue"));
|
||
const DatabaseBrowser = defineAsyncComponent(() => import("@/components/objects/DatabaseBrowser.vue"));
|
||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||
const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue"));
|
||
const ProcessListPanel = defineAsyncComponent(() => import("@/components/admin/ProcessListPanel.vue"));
|
||
const SqlServerActivityTracePanel = defineAsyncComponent(() => import("@/components/admin/SqlServerActivityTracePanel.vue"));
|
||
const MySqlDashboard = defineAsyncComponent(() => import("@/components/admin/MySqlDashboard.vue"));
|
||
const PostgresDashboard = defineAsyncComponent(() => import("@/components/admin/PostgresDashboard.vue"));
|
||
const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admin/XuguServerDashboard.vue"));
|
||
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
|
||
|
||
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
|
||
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
|
||
const SolrAdmin = defineAsyncComponent(() => import("@/components/solr/SolrAdmin.vue"));
|
||
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
|
||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||
import { useQueryStore } from "@/stores/queryStore";
|
||
import { useConnectionStore } from "@/stores/connectionStore";
|
||
import type { ContentAreaSurfaceEmits, ContentAreaSurfaceProps } from "@/components/layout/querySurfaces";
|
||
import { TABLE_FONT_SIZE_MAX, TABLE_FONT_SIZE_MIN, useSettingsStore, type DataGridRowNumberMode, type DataGridSearchMode, type ResultRunDisplayMode } from "@/stores/settingsStore";
|
||
import { useToast } from "@/composables/useToast";
|
||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||
import { canCancelQueryExecution, isActiveResultLoading, queryExecutionLabelKey, shouldShowCancelAction } from "@/lib/sql/queryExecutionState";
|
||
import { sqlErrorDisplayPosition, sqlErrorEditorOffset, logSqlErrorPosition } from "@/lib/sql/errorPosition";
|
||
import {
|
||
databaseDisplayNameForTab,
|
||
executionSummaryItems,
|
||
isPreviewTab,
|
||
queryResultExecutionSql,
|
||
resultGridCacheKey,
|
||
resultGridColumnWidthCacheKey,
|
||
resultGridInstanceKey,
|
||
resultRunItems,
|
||
resultSourceRange,
|
||
resultSqlForGrid,
|
||
statementExecutionMarkers,
|
||
tabularResultItems,
|
||
type ExecutionSummaryItem,
|
||
} from "@/lib/tabs/tabPresentation";
|
||
import { beginPanelResize, endPanelResize } from "@/lib/app/panelResizeState";
|
||
import { defaultQueryResultArchiveFileName } from "@/lib/query/queryResultArchive";
|
||
import { saveQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||
import { isTableDataEditable } from "@/lib/table/tableEditing";
|
||
import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta";
|
||
import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase";
|
||
import { formatShortcut } from "@/lib/editor/shortcutRegistry";
|
||
import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect";
|
||
import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
|
||
import { supportsTableImport } from "@/lib/database/databaseFeatureSupport";
|
||
import { chartableColumnIndexes } from "@/lib/dataGrid/chartData";
|
||
import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse";
|
||
import { elasticsearchProfileBodyForResult, parseElasticsearchProfile } from "@/lib/elasticsearch/elasticsearchProfile";
|
||
import * as api from "@/lib/backend/api";
|
||
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
|
||
import { queryResultExportBaseName } from "@/lib/export/saveTextFile";
|
||
import { applyMongoGridChangesToDocument, applyMongoGridChangesToDocumentBaseline, serializeMongoDocumentId, type MongoInputValue } from "@/lib/mongo/mongoDocumentValues";
|
||
import { buildMongoQueryResultOperations, formatMongoQueryResultOperationPreview } from "@/lib/mongo/mongoQueryResultEditing";
|
||
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||
import { isDataGridToolbarCompact, type DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
|
||
import { useTabScroll } from "@/composables/useTabScroll";
|
||
import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
|
||
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
|
||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
|
||
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
|
||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||
import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
|
||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||
import { sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges";
|
||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||
import { isAiRedisConsoleTarget, type AiConversationBinding } from "@/lib/ai/aiConversationBinding";
|
||
|
||
type DataGridHandle = DataGridColumnLayoutHandle & {
|
||
onToolbarRefresh: () => Promise<void> | void;
|
||
focusSearch: (target?: Element | null) => boolean;
|
||
openGoToColumn: () => boolean;
|
||
openCellDetailSearch: () => boolean;
|
||
nullColumnsHidden: boolean;
|
||
allNullColumnCount: number;
|
||
canToggleAllNullColumns: boolean;
|
||
toggleAllNullColumns: () => void;
|
||
defaultCopyPreference: string;
|
||
defaultCopyPreferenceLabel: string;
|
||
copyPreferenceMenuItems: Array<{ value: string; label: string; disabled?: boolean; separatorBefore?: boolean }>;
|
||
setDefaultCopyPreference: (value: string) => void;
|
||
openExtractorConfiguration: () => void;
|
||
showDdl: boolean;
|
||
toggleDdl: (tab?: TableInfoTab) => void;
|
||
multiRowTranspose: boolean;
|
||
setMultiRowTranspose: (value: boolean) => void;
|
||
exportCsv: () => Promise<void>;
|
||
exportJson: () => Promise<void>;
|
||
exportSql: () => Promise<void>;
|
||
exportXlsx: () => Promise<void>;
|
||
};
|
||
|
||
type SearchableBrowserHandle = {
|
||
focusSearch: (target?: Element | null) => boolean;
|
||
refresh?: () => boolean;
|
||
matchesRefreshScope?: (scope: ObjectBrowserRefreshScope) => boolean;
|
||
insertCommand?: (command: string) => Promise<boolean>;
|
||
executeCommand?: (command: string) => Promise<boolean>;
|
||
};
|
||
|
||
type ObjectBrowserRefreshScope = {
|
||
schema?: string;
|
||
catalog?: string;
|
||
};
|
||
|
||
type ElasticsearchJsonResponsePanelHandle = {
|
||
focusSearch: () => boolean;
|
||
};
|
||
|
||
const props = defineProps<
|
||
ContentAreaSurfaceProps & {
|
||
/** Render only the query editor pane (used by QueryEditorSurface). */
|
||
editorOnly?: boolean;
|
||
/** Render only the shared query result pane (used by QueryResultSurface). */
|
||
resultOnly?: boolean;
|
||
/** Whether the query editor should request focus on mount. */
|
||
autoFocus?: boolean;
|
||
}
|
||
>();
|
||
|
||
const emit = defineEmits<ContentAreaSurfaceEmits>();
|
||
|
||
const { t, locale } = useI18n();
|
||
const queryStore = useQueryStore();
|
||
const connectionStore = useConnectionStore();
|
||
/** Clear a consumed editor reveal request so a later normal tab re-visit doesn't re-jump. */
|
||
function clearEditorRevealRequest(tab: { editorRevealRequest?: unknown }): void {
|
||
if (tab.editorRevealRequest !== undefined) {
|
||
tab.editorRevealRequest = undefined;
|
||
}
|
||
}
|
||
provideTabUiState(() => {
|
||
const tab = props.activeTab;
|
||
const mode = tab.mode;
|
||
return { snapshot: tab.uiState?.page?.[mode] ?? {}, update: (patch) => queryStore.updateTabPageUiState(tab.id, mode, patch, tab) };
|
||
});
|
||
const canAccessEtcdAdmin = computed(() => connectionStore.getEtcdAccessCapabilities(props.activeTab.connectionId).admin);
|
||
watch(
|
||
() => [props.activeTab.connectionId, props.activeTab.mode] as const,
|
||
([connectionId, mode]) => {
|
||
if (mode !== "etcd-dashboard" && mode !== "etcd-access-control") return;
|
||
void connectionStore.ensureEtcdAccessCapabilities(connectionId, { force: true, verifyHealth: false }).catch(() => undefined);
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function groupedQueryReadonlyColumnIndexes(tab: QueryTab): number[] | undefined {
|
||
if (!tab.queryAnalysis?.groupByColumns?.length || !tab.querySourceColumns || !tab.tableMeta?.primaryKeys.length) return undefined;
|
||
const primaryKeys = new Set(tab.tableMeta.primaryKeys);
|
||
return tab.querySourceColumns.flatMap((column, index) => (column && primaryKeys.has(column) ? [index] : []));
|
||
}
|
||
const settingsStore = useSettingsStore();
|
||
const booleanDisplayMode = computed(() => settingsStore.editorSettings.dataGridBooleanDisplayMode);
|
||
const setBooleanDisplayMode = (mode: "checkbox" | "dropdown") => settingsStore.updateEditorSettings({ dataGridBooleanDisplayMode: mode });
|
||
const colorizeDataGridCellTypes = computed(() => settingsStore.editorSettings.colorizeDataGridCellTypes);
|
||
const setColorizeDataGridCellTypes = (value: boolean) => settingsStore.updateEditorSettings({ colorizeDataGridCellTypes: value });
|
||
const { toast } = useToast();
|
||
const DEFAULT_QUERY_RESULTS_PANE_SIZE = 68;
|
||
const ddlRefreshInProgress = ref(false);
|
||
|
||
onMounted(() => {
|
||
// The watcher below warms the grid for query/data tabs. Keep source-only
|
||
// tabs out of that path: loading the grid there caused freezes (#8103).
|
||
window.addEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
|
||
window.addEventListener("dbx-refresh-object-browser", onRefreshObjectBrowser);
|
||
window.addEventListener("resize", updateStandaloneResultToolbarDimensions);
|
||
window.visualViewport?.addEventListener("resize", updateStandaloneResultToolbarDimensions);
|
||
window.addEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions);
|
||
revealActiveResultRunAfterRender();
|
||
});
|
||
|
||
watch(
|
||
() => {
|
||
const tab = props.activeTab;
|
||
const gridMode = tab.mode === "query" || tab.mode === "data";
|
||
// Object source and preview tabs also use query mode, but often never run SQL.
|
||
return !!tab.result || (gridMode && (tab.isExecuting || tab.isExplaining || (!tab.sourceView && !tab.objectSource && !isPreviewTab(tab))));
|
||
},
|
||
(shouldPreload) => {
|
||
if (shouldPreload) preloadDataGridComponent();
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
// Column info panel state
|
||
const showColumnInfo = ref(false);
|
||
const columnInfoColumns = ref<ColumnInfo[]>([]);
|
||
const columnInfoLoading = ref(false);
|
||
const columnInfoError = ref<string | undefined>(undefined);
|
||
const dataGridRef = ref<DataGridHandle>();
|
||
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
|
||
const elasticsearchJsonResponsePanelRef = ref<ElasticsearchJsonResponsePanelHandle>();
|
||
const tableStructureEditorRef = ref<{ applyChanges: () => Promise<boolean>; focusSearch: () => boolean }>();
|
||
const standaloneResultToolbarRef = ref<HTMLElement | null>(null);
|
||
const standaloneResultToolbarWidth = ref(0);
|
||
const standaloneResultToolbarViewportWidth = ref(0);
|
||
const resultTabsScrollerRef = ref<HTMLElement | null>(null);
|
||
const dataGridViewOptionsOpen = ref(false);
|
||
// Data-mode header condensation (same measured-tier mechanism as the SQL
|
||
// editor toolbar). The chips keep a min-width floor so scrollWidth reports
|
||
// real overflow; tiers then drop button labels and move secondary actions
|
||
// into the overflow menu instead of crushing the chips away.
|
||
const dataToolbarRef = ref<HTMLElement | null>(null);
|
||
const { tier: dataToolbarTier } = useToolbarOverflow(dataToolbarRef, [() => props.activeTab.id, () => !!props.activeTab.result]);
|
||
const dataToolbarCompact = computed(() => dataToolbarTier.value >= 1);
|
||
const showDataColumnsChip = computed(() => dataToolbarTier.value < 2);
|
||
const dataGridRenderMode = computed(() => settingsStore.editorSettings.dataGridRenderMode);
|
||
const dataGridSearchMode = computed(() => settingsStore.editorSettings.dataGridSearchMode);
|
||
const dataGridRowNumberMode = computed(() => settingsStore.editorSettings.dataGridRowNumberMode);
|
||
const resultRunDisplayMode = computed(() => settingsStore.editorSettings.resultRunDisplayMode);
|
||
const columnWidthDensity = computed(() => settingsStore.editorSettings.columnWidthDensity);
|
||
const tableFontSize = computed(() => settingsStore.editorSettings.tableFontSize);
|
||
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||
const documentBrowserRef = ref<SearchableBrowserHandle>();
|
||
|
||
function openDataGridExtractorConfiguration() {
|
||
dataGridViewOptionsOpen.value = false;
|
||
void nextTick(() => dataGridRef.value?.openExtractorConfiguration());
|
||
}
|
||
|
||
const etcdKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||
const etcdDashboardRef = ref<{ refresh?: () => boolean }>();
|
||
const zookeeperKeyBrowserRef = ref<SearchableBrowserHandle>();
|
||
const consulOverviewRef = ref<{ refresh?: () => boolean }>();
|
||
const consulWorkspaceRef = ref<SearchableBrowserHandle>();
|
||
const databaseBrowserRef = ref<SearchableBrowserHandle>();
|
||
const objectBrowserRef = ref<SearchableBrowserHandle>();
|
||
const pluginFilesystemTabRef = ref<{ refresh: () => Promise<unknown> }>();
|
||
const activeTableMeta = computed(() => props.activeTab.tableMeta);
|
||
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
|
||
const activeResultExecutionTarget = computed(() => queryStore.activeResultExecutionTarget(props.activeTab.id));
|
||
const activeResultConnection = computed(() => (activeResultExecutionTarget.value ? connectionStore.getConfig(activeResultExecutionTarget.value.connectionId) : props.activeConnection));
|
||
const activeResultConnectionId = computed(() => activeResultExecutionTarget.value?.connectionId ?? props.activeTab.connectionId);
|
||
// Row/column locate only makes sense for SQL editor tabs: data/preview tabs have
|
||
// no user statement to map the backend position onto. Engines without a typed
|
||
// position (Oracle) report it in the error text, so the label falls back to the
|
||
// same resolver the jump uses — the button never appears when clicking it could
|
||
// not move the caret.
|
||
const activeResultErrorPosition = computed(() => {
|
||
if (props.activeTab.mode !== "query") return undefined;
|
||
return sqlErrorDisplayPosition(activeResultErrorOffsetOptions());
|
||
});
|
||
const activeResultDatabase = computed(() => activeResultExecutionTarget.value?.database ?? props.activeTab.database);
|
||
const activeResultSchema = computed(() => activeResultExecutionTarget.value?.schema ?? props.activeTab.schema);
|
||
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(activeResultConnection.value));
|
||
// 表数据工具箱的「导入数据」与侧边栏、对象浏览器共用同一条能力判断:未适配导入的引擎(如 HANA)不出现入口。
|
||
const canOpenTableImport = computed(() => supportsTableImport(activeEffectiveDatabaseType.value));
|
||
const activeVectorConnection = computed(() => connectionStore.getConfig(props.activeTab.connectionId) ?? props.activeConnection);
|
||
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
|
||
const activeSqlStatementParameterOptions = computed(() =>
|
||
sqlStatementParameterOptionsForCompatibility(activeEffectiveDatabaseType.value, activeEffectiveDatabaseType.value === "opengauss" ? connectionStore.databaseCompatibilityMode(activeResultConnectionId.value, activeResultDatabase.value) : undefined),
|
||
);
|
||
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
|
||
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
|
||
const productionSessionDetail = computed(() => {
|
||
if (!activeProductionContext.value.active) return "";
|
||
if (activeProductionContext.value.reason === "connection") return t("production.connection");
|
||
return activeProductionContext.value.databases.join(", ") || t("production.databases");
|
||
});
|
||
|
||
function findNodeInTree(nodes: TreeNode[], id: string): TreeNode | undefined {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return node;
|
||
if (node.children) {
|
||
const found = findNodeInTree(node.children, id);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function setDataGridRenderMode(value: "canvas" | "dom") {
|
||
settingsStore.updateEditorSettings({ dataGridRenderMode: value });
|
||
}
|
||
|
||
function setDataGridSearchMode(value: DataGridSearchMode) {
|
||
settingsStore.updateEditorSettings({ dataGridSearchMode: value });
|
||
}
|
||
|
||
function setDataGridRowNumberMode(value: DataGridRowNumberMode) {
|
||
settingsStore.updateEditorSettings({ dataGridRowNumberMode: value });
|
||
}
|
||
|
||
function setResultRunDisplayMode(value: ResultRunDisplayMode) {
|
||
settingsStore.updateEditorSettings({ resultRunDisplayMode: value });
|
||
}
|
||
|
||
function setColumnWidthDensity(value: "compact" | "standard" | "comfortable") {
|
||
settingsStore.updateEditorSettings({ columnWidthDensity: value });
|
||
}
|
||
|
||
function setTableFontSize(value: number) {
|
||
settingsStore.updateEditorSettings({ tableFontSize: value });
|
||
}
|
||
|
||
const numericColumnRightAlign = computed(() => settingsStore.editorSettings.numericColumnRightAlign ?? true);
|
||
|
||
function setNumericColumnRightAlign(value: boolean) {
|
||
settingsStore.updateEditorSettings({ numericColumnRightAlign: value });
|
||
}
|
||
|
||
function decreaseTableFontSize() {
|
||
setTableFontSize(tableFontSize.value - 1);
|
||
}
|
||
|
||
function increaseTableFontSize() {
|
||
setTableFontSize(tableFontSize.value + 1);
|
||
}
|
||
|
||
/** Export file base name for the query result grid: the result's label (nearby
|
||
* comment or `schema.table`) names exports, matching the result tab (#9894). */
|
||
const activeQueryResultExportBaseName = computed(() => queryResultExportBaseName(props.activeTab.result?.sourceLabel, props.activeTab.title));
|
||
|
||
const activeTabDimension = computed(() => {
|
||
const tab = props.activeTab;
|
||
if (!tab.connectionId || tab.mode !== "vector") return undefined;
|
||
const isMilvus = connectionStore.getConfig(tab.connectionId)?.db_type === "milvus";
|
||
const suffix = isMilvus && tab.database ? `${tab.database}:${tab.sql}` : tab.sql;
|
||
const nodeId = `${tab.connectionId}:__vector_collection:${suffix}`;
|
||
const meta = findNodeInTree(connectionStore.treeNodes, nodeId)?.meta;
|
||
return meta && "dimension" in meta ? (meta as VectorCollectionMeta).dimension : undefined;
|
||
});
|
||
|
||
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => sqlFormatDialectForDbType(activeEffectiveDatabaseType.value));
|
||
|
||
const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialect(activeEffectiveDatabaseType.value));
|
||
const editorSyntaxDialect = computed<CodeMirrorSqlDialectName>(() => codeMirrorSqlDialectForConnection(props.activeConnection));
|
||
|
||
const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl"));
|
||
|
||
const modRKeys = computed(() =>
|
||
formatShortcut("Mod+R")
|
||
.split("+")
|
||
.map((key) => (key === "Cmd" ? "⌘" : key)),
|
||
);
|
||
|
||
const {
|
||
hasTabOverflow: hasResultTabOverflow,
|
||
scrollThumbLeftPercent: resultTabsThumbLeftPercent,
|
||
scrollThumbWidthPercent: resultTabsThumbWidthPercent,
|
||
isScrollbarDragging: isResultTabsScrollbarDragging,
|
||
updateScrollButtons: updateResultTabsScrollbar,
|
||
onTabsWheel: onResultTabsWheel,
|
||
startScrollbarDrag: startResultTabsScrollbarDrag,
|
||
} = useTabScroll(resultTabsScrollerRef);
|
||
|
||
const resultTabsScrollerStyle: CSSProperties = {
|
||
msOverflowStyle: "none",
|
||
scrollbarWidth: "none",
|
||
WebkitOverflowScrolling: "touch",
|
||
};
|
||
|
||
const resultTabsScrollbarThumbStyle = computed<CSSProperties>(() => ({
|
||
insetInlineStart: `${resultTabsThumbLeftPercent.value}%`,
|
||
width: `${resultTabsThumbWidthPercent.value}%`,
|
||
}));
|
||
|
||
const hasNumericData = computed(() => {
|
||
const r = props.activeTab.result;
|
||
if (!r || r.rows.length === 0) return false;
|
||
return chartableColumnIndexes(r).length > 0;
|
||
});
|
||
|
||
const activeQueryError = computed(() => {
|
||
const result = props.activeTab.result;
|
||
if (!result || !isQueryExecutionErrorResult(result)) return "";
|
||
return String(result.rows[0]?.[0] ?? "");
|
||
});
|
||
const hasQueryOutput = computed(() => tabHasQueryOutput(props.activeTab));
|
||
// 结果集页签/列表的名称是否带库名,由编辑器设置控制(默认带库名)
|
||
const includeResultSourceDatabase = computed(() => settingsStore.editorSettings.showResultSourceDatabase);
|
||
const visibleResultItems = computed(() => tabularResultItems(props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined), { includeSourceDatabase: includeResultSourceDatabase.value }));
|
||
const tabularResults = computed(() => tabularResultItems(props.activeTab.results, { includeSourceDatabase: includeResultSourceDatabase.value }));
|
||
const allResultExportSheets = computed(() =>
|
||
tabularResults.value.map((item) => ({
|
||
sheetName: item.label || t("tabs.resultN", { n: item.n }),
|
||
result: item.result,
|
||
sql: item.index === props.activeTab.activeResultIndex ? queryResultExecutionSql(props.activeTab) : item.result.sourceStatement,
|
||
})),
|
||
);
|
||
// 结果标签优先显示来源(表名),历史批次缺少来源信息时按批次 SQL 重新解析
|
||
const resultRuns = computed(() =>
|
||
resultRunItems(props.activeTab, {
|
||
includeSourceDatabase: includeResultSourceDatabase.value,
|
||
database: props.activeTab.database,
|
||
databaseType: activeEffectiveDatabaseType.value,
|
||
}),
|
||
);
|
||
const activeResultGridCacheKey = computed(() => resultGridCacheKey(props.activeTab));
|
||
const activeResultGridColumnWidthCacheKey = computed(() => resultGridColumnWidthCacheKey(props.activeTab));
|
||
const activeResultGridInstanceKey = computed(() => resultGridInstanceKey(props.activeTab));
|
||
const activeResultSql = computed(() => resultSqlForGrid(props.activeTab));
|
||
const activeResultExportSql = computed(() => queryResultExecutionSql(props.activeTab));
|
||
const activeStatementExecutionMarkers = computed(() =>
|
||
statementExecutionMarkers(
|
||
props.activeTab.sql,
|
||
props.activeTab.results ?? (props.activeTab.result ? [props.activeTab.result] : undefined),
|
||
activeEffectiveDatabaseType.value,
|
||
props.activeTab.resultBaseSql || props.activeTab.lastExecutedSql || props.activeTab.sql,
|
||
props.activeTab.resultEditorFingerprint ?? "",
|
||
props.activeTab.batchSqlExecution,
|
||
activeSqlStatementParameterOptions.value,
|
||
),
|
||
);
|
||
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
|
||
/** Whether the active result is an Elasticsearch _source table that also has a raw JSON toggle. */
|
||
const activeElasticsearchRawBody = computed(() => {
|
||
if (activeEffectiveDatabaseType.value !== "elasticsearch" && activeEffectiveDatabaseType.value !== "easysearch" && activeEffectiveDatabaseType.value !== "solr") return undefined;
|
||
return props.activeTab.result?.elasticsearch_raw_body;
|
||
});
|
||
/** ES `_search?profile=true` body extracted from the active result, when present. */
|
||
const activeElasticsearchProfileBody = computed(() => elasticsearchProfileBodyForResult(activeEffectiveDatabaseType.value, props.activeTab.result));
|
||
/** Only surfaces the "Profile" entry when the response actually carries a profile section. */
|
||
const canShowProfile = computed(() => {
|
||
const body = activeElasticsearchProfileBody.value;
|
||
return body !== null && parseElasticsearchProfile(body) !== null;
|
||
});
|
||
/** Toggle between the _source table and the raw JSON panel for Elasticsearch REST results. */
|
||
const showElasticsearchRawJson = ref(false);
|
||
watch(
|
||
() => props.activeTab.result?.elasticsearch_raw_body,
|
||
() => {
|
||
showElasticsearchRawJson.value = false;
|
||
},
|
||
);
|
||
const resultArchiveExporting = ref(false);
|
||
const canExportResultArchive = computed(() => props.activeTab.mode === "query" && (!!props.activeTab.result || !!props.activeTab.results?.length || !!props.activeTab.resultRuns?.length));
|
||
const resultAutoSave = computed(() => props.activeTab.resultAutoSave === true);
|
||
const activeResultRunItem = computed(() => resultRuns.value.find((run) => run.active));
|
||
const resultRunRenameOpen = ref(false);
|
||
const resultRunRenameId = ref<string | null>(null);
|
||
const resultRunRenameTitle = ref("");
|
||
const activeResultIsLoading = computed(() => !props.activeTab.redisMonitorActive && isActiveResultLoading(props.activeTab));
|
||
const showResultRunTabs = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "tabs");
|
||
const showResultRunSelector = computed(() => resultRuns.value.length > 0 && resultRunDisplayMode.value === "list");
|
||
const canCloseQueryResult = computed(() => props.activeTab.mode === "query" && !props.activeTab.isExecuting && !props.activeTab.activeResultRunId && (!!props.activeTab.result || !!props.activeTab.results?.length || props.activeTab.resultEvicted === true));
|
||
|
||
function updateResultTabsAfterRender() {
|
||
nextTick(() => updateResultTabsScrollbar());
|
||
}
|
||
|
||
function revealActiveResultRunAfterRender() {
|
||
nextTick(() => {
|
||
if (!showResultRunTabs.value) return;
|
||
updateResultTabsScrollbar();
|
||
resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||
});
|
||
}
|
||
|
||
function resultRunIdsWereAppended(previous: string[], current: string[]) {
|
||
return current.length > previous.length && previous.every((id, index) => current[index] === id);
|
||
}
|
||
|
||
watch(
|
||
() => ({
|
||
tabId: props.activeTab.id,
|
||
displayMode: resultRunDisplayMode.value,
|
||
runIds: resultRuns.value.map((run) => run.id),
|
||
activeRunId: props.activeTab.activeResultRunId,
|
||
}),
|
||
(current, previous) => {
|
||
const switchedTab = current.tabId !== previous.tabId;
|
||
const switchedDisplayMode = current.displayMode !== previous.displayMode;
|
||
const activeRunChanged = current.activeRunId !== previous.activeRunId;
|
||
|
||
// Appending a fresh result must keep the user's horizontal position stable.
|
||
// Reused result slots, tab/display-mode changes, and keyboard/close flows
|
||
// still reveal the active run when it may be outside the visible strip.
|
||
if (switchedTab || switchedDisplayMode || (activeRunChanged && !resultRunIdsWereAppended(previous.runIds, current.runIds))) {
|
||
revealActiveResultRunAfterRender();
|
||
return;
|
||
}
|
||
updateResultTabsAfterRender();
|
||
},
|
||
);
|
||
const summaryItems = computed(() => executionSummaryItems(props.activeTab));
|
||
const hasExecutionSummary = computed(() => summaryItems.value.length > 0 || props.activeTab.isExecuting);
|
||
const batchExecutionProgress = computed(() => props.activeTab.batchSqlExecution);
|
||
const batchRecovery = computed(() => batchSqlRecoveryState(props.activeTab));
|
||
const batchExecutionPercent = computed(() => {
|
||
const progress = batchExecutionProgress.value;
|
||
return progress?.total ? Math.round((progress.completed / progress.total) * 100) : 0;
|
||
});
|
||
const hasTabularResult = computed(() => {
|
||
if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true;
|
||
return visibleResultItems.value.length > 0;
|
||
});
|
||
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
|
||
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
|
||
// A batch can attach server messages to more than one statement result (for
|
||
// example a `DO $$ RAISE NOTICE $$` block followed by a SELECT). The messages
|
||
// view is scoped to the whole run, so collect every result's messages instead
|
||
// of only the active result's — otherwise a statement with no result set
|
||
// silently loses its notices once a later statement owns the active result.
|
||
const resultMessages = computed<QueryMessage[]>(() => {
|
||
const results = props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : [];
|
||
return results.flatMap(queryResultMessages);
|
||
});
|
||
const resultMessageCount = computed(() => resultMessages.value.length);
|
||
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
|
||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
|
||
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
|
||
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
|
||
|
||
function updateStandaloneResultToolbarDimensions() {
|
||
standaloneResultToolbarWidth.value = standaloneResultToolbarRef.value?.clientWidth ?? 0;
|
||
standaloneResultToolbarViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
|
||
}
|
||
|
||
function observeStandaloneResultToolbar() {
|
||
standaloneResultToolbarResizeObserver?.disconnect();
|
||
standaloneResultToolbarResizeObserver = undefined;
|
||
const toolbar = standaloneResultToolbarRef.value;
|
||
updateStandaloneResultToolbarDimensions();
|
||
if (toolbar && typeof ResizeObserver !== "undefined") {
|
||
standaloneResultToolbarResizeObserver = new ResizeObserver(updateStandaloneResultToolbarDimensions);
|
||
standaloneResultToolbarResizeObserver.observe(toolbar);
|
||
}
|
||
}
|
||
|
||
watch(standaloneResultToolbarRef, observeStandaloneResultToolbar, { flush: "post" });
|
||
type MongoQueryGridChanges = {
|
||
dirtyRows: Map<number, Map<number, MongoInputValue>>;
|
||
deletedRows: Set<number>;
|
||
newRows: MongoInputValue[][];
|
||
columns: string[];
|
||
rows: MongoInputValue[][];
|
||
};
|
||
const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() => {
|
||
const tab = props.activeTab;
|
||
const target = tab.mongoEditTarget;
|
||
if (tab.mode !== "query" || activeEffectiveDatabaseType.value !== "mongodb" || !target || !activeResultConnectionId.value || !activeResultDatabase.value || !tab.result) return undefined;
|
||
if (!tab.result.columns.includes(target.idColumn)) return undefined;
|
||
|
||
const editTarget = { idColumn: target.idColumn, documentAt: (rowIdx: number) => tab.result?.mongo_documents?.[rowIdx] };
|
||
|
||
const save: CustomSaveHandler["save"] = async (changes: MongoQueryGridChanges) => {
|
||
if (changes.newRows.length > 0) {
|
||
throw new Error("MongoDB query result editing does not support inserting rows.");
|
||
}
|
||
if (!changes.columns.includes(target.idColumn)) throw new Error("No _id column");
|
||
for (const operation of buildMongoQueryResultOperations(changes, editTarget)) {
|
||
const id = serializeMongoDocumentId(operation.id);
|
||
if (operation.kind === "delete") {
|
||
await api.mongoDeleteDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id);
|
||
} else {
|
||
await api.mongoUpdateDocument(activeResultConnectionId.value, activeResultDatabase.value, target.collection, id, JSON.stringify(operation.update));
|
||
}
|
||
}
|
||
};
|
||
|
||
const preview: CustomSaveHandler["preview"] = async (changes: MongoQueryGridChanges) => buildMongoQueryResultOperations(changes, editTarget).map((operation) => formatMongoQueryResultOperationPreview(target.collection, operation));
|
||
|
||
const applySavedChanges: NonNullable<CustomSaveHandler["applySavedChanges"]> = ({ dirtyRows, columns }) => {
|
||
const documents = tab.result?.mongo_documents;
|
||
if (!documents) return;
|
||
|
||
// Replace the raw array only after every backend update succeeds, keeping
|
||
// the grid and JSON preview atomic when a multi-row save partially fails.
|
||
if (tab.resultLocalSortOriginalMongoDocuments) {
|
||
tab.resultLocalSortOriginalMongoDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoDocuments, documents, dirtyRows, columns);
|
||
}
|
||
tab.result!.mongo_documents = documents.map((document, rowIdx) => {
|
||
const changes = dirtyRows.get(rowIdx);
|
||
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
|
||
});
|
||
const copyDocuments = tab.result!.mongo_copy_documents;
|
||
if (copyDocuments) {
|
||
if (tab.resultLocalSortOriginalMongoCopyDocuments) {
|
||
tab.resultLocalSortOriginalMongoCopyDocuments = applyMongoGridChangesToDocumentBaseline(tab.resultLocalSortOriginalMongoCopyDocuments, copyDocuments, dirtyRows, columns);
|
||
}
|
||
tab.result!.mongo_copy_documents = copyDocuments.map((document, rowIdx) => {
|
||
const changes = dirtyRows.get(rowIdx);
|
||
return changes ? applyMongoGridChangesToDocument(document, changes, columns) : document;
|
||
});
|
||
}
|
||
};
|
||
|
||
return { save, preview, applySavedChanges, canInsert: false, canDelete: true, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
|
||
});
|
||
const resultsPaneOpen = ref(false);
|
||
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);
|
||
// In editor-only mode the results pane is never mounted in this splitpanes,
|
||
// so the editor pane must stay at 100%: a reactive size update alone does not
|
||
// re-normalize a single pane, and shrinking it would leave a blank dead zone.
|
||
const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value));
|
||
const queryRunningElapsed = ref(0);
|
||
const sourceLoadElapsed = ref(0);
|
||
const ddlLoadElapsed = ref(0);
|
||
|
||
function toggleResultsPane(): boolean {
|
||
if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false;
|
||
resultsPaneOpen.value = !resultsPaneOpen.value;
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* The "hide results" chevron. In the shared result surface (result-only) the
|
||
* local resultsPaneOpen ref is inert — the pane is always rendered there and
|
||
* the real collapse state lives in SqlEditorWorkspace.showResultPane — so the
|
||
* chevron bubbles a toggle event up instead. Per-tab surfaces (data tabs)
|
||
* keep the local collapse behavior.
|
||
*/
|
||
function handleHideResultsPane() {
|
||
if (props.resultOnly) {
|
||
emit("toggleResultsPane");
|
||
} else {
|
||
resultsPaneOpen.value = false;
|
||
}
|
||
}
|
||
|
||
function onResultsSplitResize() {
|
||
beginPanelResize();
|
||
}
|
||
function onResultsResized(payload: { panes: { size: number }[] }) {
|
||
endPanelResize();
|
||
const resultsPane = payload.panes[1];
|
||
if (resultsPane?.size != null && resultsPane.size >= 20 && resultsPane.size <= 85) {
|
||
resultsPaneSize.value = resultsPane.size;
|
||
safeLocalStorageSet("dbx-results-pane-size", String(resultsPane.size));
|
||
}
|
||
}
|
||
let queryRunningElapsedFrame: number | undefined;
|
||
|
||
function stopRunningElapsedTimer() {
|
||
if (queryRunningElapsedFrame !== undefined) {
|
||
window.cancelAnimationFrame(queryRunningElapsedFrame);
|
||
queryRunningElapsedFrame = undefined;
|
||
}
|
||
}
|
||
|
||
/** 源码仍在加载(未失败)时的开始时间;用于耗时显示与 rAF 循环的续期判断。 */
|
||
function pendingSourceLoadStartedAt(): number | undefined {
|
||
const load = props.activeTab.sourceLoad;
|
||
return load && !load.error ? load.startedAt : undefined;
|
||
}
|
||
|
||
/** DDL 新标签仍在加载(未失败)时的开始时间;与源码加载共用同一个 rAF 循环。 */
|
||
function pendingDdlLoadStartedAt(): number | undefined {
|
||
const load = props.activeTab.ddlLoad;
|
||
return load && !load.error ? load.startedAt : undefined;
|
||
}
|
||
|
||
// 一个 rAF 循环同时驱动「查询执行中」「对象源码加载中」「DDL 加载中」三个耗时显示:
|
||
// issue #9035 的核心体感就是「不知道要等多久」,所以源码加载也要显示已耗时。
|
||
function updateRunningElapsed() {
|
||
const startedAt = props.activeTab.queryExecutionStartedAt;
|
||
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0;
|
||
const sourceStartedAt = pendingSourceLoadStartedAt();
|
||
sourceLoadElapsed.value = sourceStartedAt ? Math.max(0, Date.now() - sourceStartedAt) : 0;
|
||
const ddlStartedAt = pendingDdlLoadStartedAt();
|
||
ddlLoadElapsed.value = ddlStartedAt ? Math.max(0, Date.now() - ddlStartedAt) : 0;
|
||
}
|
||
|
||
function startRunningElapsedTimer() {
|
||
stopRunningElapsedTimer();
|
||
updateRunningElapsed();
|
||
const isTicking = () => (props.activeTab.isExecuting && !!props.activeTab.queryExecutionStartedAt) || pendingSourceLoadStartedAt() !== undefined || pendingDdlLoadStartedAt() !== undefined;
|
||
if (!isTicking()) return;
|
||
const updateOnNextFrame = () => {
|
||
updateRunningElapsed();
|
||
if (isTicking()) {
|
||
queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||
}
|
||
};
|
||
queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame);
|
||
}
|
||
|
||
const queryRunningElapsedSeconds = computed(() => formatElapsedSeconds(queryRunningElapsed.value));
|
||
const sourceLoadElapsedSeconds = computed(() => formatElapsedSeconds(sourceLoadElapsed.value));
|
||
const ddlLoadElapsedSeconds = computed(() => formatElapsedSeconds(ddlLoadElapsed.value));
|
||
|
||
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt, props.activeTab.sourceLoad?.startedAt, props.activeTab.sourceLoad?.error, props.activeTab.ddlLoad?.startedAt, props.activeTab.ddlLoad?.error] as const, startRunningElapsedTimer, {
|
||
immediate: true,
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopRunningElapsedTimer();
|
||
standaloneResultToolbarResizeObserver?.disconnect();
|
||
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
|
||
window.removeEventListener("dbx-refresh-object-browser", onRefreshObjectBrowser);
|
||
window.removeEventListener("resize", updateStandaloneResultToolbarDimensions);
|
||
window.visualViewport?.removeEventListener("resize", updateStandaloneResultToolbarDimensions);
|
||
window.removeEventListener("dbx:ui-scale-applied", updateStandaloneResultToolbarDimensions);
|
||
});
|
||
|
||
watch(
|
||
hasQueryOutput,
|
||
(hasOutput) => {
|
||
resultsPaneOpen.value = hasOutput ? true : false;
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
() => props.activeTab.id,
|
||
() => {
|
||
resultsPaneOpen.value = hasQueryOutput.value;
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => [props.activeTab.id, props.activeTab.result, props.activeTab.results, props.activeTab.isExecuting, props.activeOutputView] as const,
|
||
() => {
|
||
// The output view belongs to the shared result surface. An editor-only
|
||
// surface (a background group's editor) must never redirect the global
|
||
// view when its own tab finishes executing.
|
||
if (props.editorOnly) return;
|
||
if (props.activeTab.isExecuting) return;
|
||
if (hasExecutionSummary.value && (!hasTabularResult.value || props.activeTab.result?.server_message === true) && props.activeOutputView === "result") {
|
||
const result = props.activeTab.result;
|
||
emit("update:activeOutputView", props.activeTab.id, result ? defaultViewForResult(result) : "summary");
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
watch(
|
||
() => [props.activeTab.isExecuting, props.activeTab.isExplaining],
|
||
([isExecuting, isExplaining]) => {
|
||
if (isExecuting || isExplaining) resultsPaneOpen.value = true;
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => props.activeTab.result,
|
||
(result) => {
|
||
if (!result) return;
|
||
if (!isDebugLoggingEnabled()) return;
|
||
const startedAt = performance.now();
|
||
appendDebugLog("info", "[DBX][ContentArea:result:observed]", {
|
||
tabId: props.activeTab.id,
|
||
rowCount: result.rows.length,
|
||
columnCount: result.columns.length,
|
||
backendMs: result.execution_time_ms,
|
||
isExecuting: props.activeTab.isExecuting,
|
||
});
|
||
nextTick(() => {
|
||
appendDebugLog("info", "[DBX][ContentArea:result:nextTick]", {
|
||
tabId: props.activeTab.id,
|
||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||
isExecuting: props.activeTab.isExecuting,
|
||
});
|
||
requestAnimationFrame(() => {
|
||
appendDebugLog("info", "[DBX][ContentArea:result:first-frame]", {
|
||
tabId: props.activeTab.id,
|
||
elapsed: `${Math.round(performance.now() - startedAt)}ms`,
|
||
isExecuting: props.activeTab.isExecuting,
|
||
});
|
||
});
|
||
});
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => props.activeTab.isExecuting,
|
||
(isExecuting, wasExecuting) => {
|
||
if (isExecuting && !wasExecuting) {
|
||
queryEditorRef.value?.beginExecutionViewportTracking();
|
||
}
|
||
if (!isExecuting && wasExecuting) {
|
||
nextTick(() => {
|
||
requestAnimationFrame(() => {
|
||
queryEditorRef.value?.scrollCursorIntoView();
|
||
});
|
||
});
|
||
}
|
||
},
|
||
);
|
||
|
||
// Table toolbox handlers
|
||
function handleTableImport() {
|
||
const tab = props.activeTab;
|
||
const tableMeta = activeDataTabTableMeta.value;
|
||
if (!tableMeta || !tab.connectionId) return;
|
||
connectionStore.tableImportSource = {
|
||
connectionId: tab.connectionId,
|
||
database: tab.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
};
|
||
}
|
||
|
||
function handleTableDataGenerate() {
|
||
const tab = props.activeTab;
|
||
const tableMeta = activeDataTabTableMeta.value;
|
||
if (!tableMeta || !tab.connectionId) return;
|
||
connectionStore.tableDataGenerateSource = {
|
||
connectionId: tab.connectionId,
|
||
database: tab.database,
|
||
schema: tableMeta.schema,
|
||
tableName: tableMeta.tableName,
|
||
};
|
||
}
|
||
|
||
// Column info panel handlers
|
||
async function onHandleClickColumn(matchedCols: Array<{ name: string; table: string; schema?: string }>, errorMsg?: string) {
|
||
if (!props.activeTab.connectionId || !props.activeTab.database) return;
|
||
|
||
// If error or no columns, silently ignore — don't show the panel
|
||
if (errorMsg || matchedCols.length === 0) return;
|
||
|
||
columnInfoLoading.value = true;
|
||
columnInfoError.value = undefined;
|
||
|
||
try {
|
||
// Fetch full column details from API
|
||
const apiModule = await import("@/lib/backend/api");
|
||
const results: ColumnInfo[] = [];
|
||
|
||
for (const matchedCol of matchedCols) {
|
||
const querySchema = matchedCol.schema || props.activeTab.database || "";
|
||
try {
|
||
const fullColumns = await apiModule.getColumns(props.activeTab.connectionId, props.activeTab.database, querySchema, matchedCol.table);
|
||
for (const col of fullColumns) {
|
||
if (col.name === matchedCol.name) {
|
||
results.push({
|
||
name: col.name,
|
||
table: matchedCol.table,
|
||
dataType: col.data_type,
|
||
isNullable: col.is_nullable,
|
||
columnDefault: col.column_default,
|
||
isPrimaryKey: col.is_primary_key,
|
||
comment: col.comment,
|
||
extra: col.extra,
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
// Skip tables that fail
|
||
}
|
||
}
|
||
|
||
columnInfoColumns.value = results;
|
||
} catch (e: any) {
|
||
// Silently ignore errors
|
||
console.error("[DBX] Failed to fetch column info:", e);
|
||
return;
|
||
} finally {
|
||
columnInfoLoading.value = false;
|
||
showColumnInfo.value = true;
|
||
}
|
||
}
|
||
|
||
function closeColumnInfo() {
|
||
showColumnInfo.value = false;
|
||
columnInfoColumns.value = [];
|
||
columnInfoError.value = undefined;
|
||
}
|
||
|
||
function onHandleClickTable(target: SqlObjectNavigationTarget) {
|
||
emit("clickTable", props.activeTab.id, target);
|
||
}
|
||
|
||
function onHandleViewTableData(target: SqlObjectNavigationTarget) {
|
||
emit("viewTableData", props.activeTab.id, target);
|
||
}
|
||
|
||
/**
|
||
* The structure/DDL editor only owns the object identity, so build the
|
||
* navigation target from the active tab and reuse the same "view data" path as
|
||
* the SQL editor context menu (issue #6724).
|
||
*/
|
||
function onHandleStructureViewData() {
|
||
const tab = props.activeTab;
|
||
const meta = tab.tableMeta;
|
||
const tableName = tab.structureTableName || meta?.tableName;
|
||
if (!tableName) return;
|
||
emit("viewTableData", tab.id, {
|
||
name: tableName,
|
||
database: meta?.database || tab.database,
|
||
schema: meta?.schema || tab.schema,
|
||
type: tab.structureTableType === "view" ? "view" : "table",
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Loads the DDL of the active DDL viewer tab. Serves the toolbar refresh, the
|
||
* in-place retry, and the initial pending load of a freshly created tab (which
|
||
* renders the loading state instead of an editor): a pending load that fails
|
||
* lands in the tab itself with an in-place retry, while a refresh of
|
||
* already-loaded content only reports through a toast.
|
||
*/
|
||
async function loadActiveDdlViewer(force: boolean) {
|
||
const tab = props.activeTab;
|
||
const ddlViewer = tab.ddlViewer;
|
||
if (!ddlViewer || ddlRefreshInProgress.value) return;
|
||
ddlRefreshInProgress.value = true;
|
||
const pendingLoad = !!tab.ddlLoad;
|
||
if (pendingLoad) tab.ddlLoad = { startedAt: Date.now() };
|
||
try {
|
||
const { ddl } = await loadObjectDdl(
|
||
{
|
||
connectionId: tab.connectionId,
|
||
database: tab.database,
|
||
schema: ddlViewer.schema || tab.database,
|
||
tableName: ddlViewer.tableName,
|
||
objectType: ddlViewer.objectType,
|
||
catalog: tab.catalog,
|
||
},
|
||
{ force },
|
||
);
|
||
const dialect = ddlViewer.formatDialect ?? activeSqlFormatDialect.value;
|
||
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId) ?? props.activeConnection);
|
||
const displayed = await formatDdlForDisplay(
|
||
ddl,
|
||
{
|
||
dialect,
|
||
databaseType,
|
||
database: tab.database,
|
||
catalog: tab.catalog,
|
||
includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName,
|
||
quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers,
|
||
excludeDdlStorage: settingsStore.editorSettings.excludeDdlStorage,
|
||
},
|
||
settingsStore.editorSettings.sqlFormatter,
|
||
);
|
||
queryStore.updateSql(tab.id, displayed);
|
||
tab.originalSql = displayed;
|
||
if (pendingLoad) tab.ddlLoad = undefined;
|
||
} catch (error: any) {
|
||
if (pendingLoad) {
|
||
tab.ddlLoad = { startedAt: Date.now(), error: error?.message || String(error) };
|
||
} else {
|
||
toast(t("contextMenu.ddlRefreshFailed", { message: error?.message || String(error) }), 5000);
|
||
}
|
||
} finally {
|
||
ddlRefreshInProgress.value = false;
|
||
}
|
||
}
|
||
|
||
function refreshDdlViewer() {
|
||
void loadActiveDdlViewer(true);
|
||
}
|
||
|
||
function viewDdlTableData() {
|
||
const tab = props.activeTab;
|
||
const ddlViewer = tab.ddlViewer;
|
||
if (!ddlViewer) return;
|
||
emit("viewTableData", tab.id, {
|
||
name: ddlViewer.tableName,
|
||
database: tab.database,
|
||
schema: ddlViewer.schema,
|
||
type: ddlViewer.objectType ? sqlObjectNavigationTypeFromTableType(ddlViewer.objectType) : "table",
|
||
});
|
||
}
|
||
|
||
function onHandleViewTableDdl(target: SqlObjectNavigationTarget) {
|
||
emit("viewTableDdl", props.activeTab.id, target);
|
||
}
|
||
|
||
function onHandleEditTableStructure(target: SqlObjectNavigationTarget) {
|
||
emit("editTableStructure", props.activeTab.id, target);
|
||
}
|
||
|
||
function onHandleOpenObjectSource(target: SqlObjectNavigationTarget, initialEditing: boolean) {
|
||
emit("openObjectSource", props.activeTab.id, target, initialEditing);
|
||
}
|
||
|
||
function onHandleCloseColumnPanel() {
|
||
showColumnInfo.value = false;
|
||
columnInfoColumns.value = [];
|
||
columnInfoError.value = undefined;
|
||
}
|
||
|
||
function focusSearch(target: Element | null = null): boolean {
|
||
if (elasticsearchJsonResponsePanelRef.value?.focusSearch()) return true;
|
||
if (props.activeTab.mode === "mongo") return documentBrowserRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch(target) ?? false;
|
||
if (props.activeTab.mode === "structure") return tableStructureEditorRef.value?.focusSearch() ?? false;
|
||
if (props.activeTab.mode === "query") {
|
||
// The shared result surface (resultOnly) owns the grid, not the editor;
|
||
// route its search to the DataGrid instead of the missing QueryEditor.
|
||
if (props.resultOnly) return dataGridRef.value?.focusSearch(target) ?? false;
|
||
return queryEditorRef.value?.openSearch() ?? false;
|
||
}
|
||
return dataGridRef.value?.focusSearch(target) ?? false;
|
||
}
|
||
|
||
function openGoToColumn(): boolean {
|
||
if (props.activeTab.mode !== "data") return false;
|
||
return dataGridRef.value?.openGoToColumn() ?? false;
|
||
}
|
||
|
||
function refreshQueryEditorCompletionCache(): boolean {
|
||
if (props.activeTab.mode !== "query" || !queryEditorRef.value) return false;
|
||
queryEditorRef.value.refreshCompletionCache();
|
||
return true;
|
||
}
|
||
|
||
function reloadUnavailableDataTab() {
|
||
const { whereInput, orderBy } = restoredDataTabReloadFilters(props.activeTab);
|
||
emit("reload", props.activeTab.id, undefined, undefined, whereInput, orderBy);
|
||
}
|
||
|
||
function refreshData(): boolean {
|
||
// Reuse ObjectBrowser's reload path so schema reloads and stale object-response guards stay intact.
|
||
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "etcd") return etcdKeyBrowserRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "etcd-dashboard") return etcdDashboardRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "zookeeper") return zookeeperKeyBrowserRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "consul-overview") return consulOverviewRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "consul") return consulWorkspaceRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "databases") return databaseBrowserRef.value?.refresh?.() ?? false;
|
||
if (props.activeTab.mode === "plugin-filesystem") {
|
||
void pluginFilesystemTabRef.value?.refresh();
|
||
return true;
|
||
}
|
||
if (props.activeTab.objectSource || props.activeTab.sourceLoad) {
|
||
if (queryStore.isTabDirty(props.activeTab) && !window.confirm(t("objects.refreshDiscardConfirm"))) return false;
|
||
return queryStore.refreshObjectSourceTab(props.activeTab.id);
|
||
}
|
||
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
|
||
if (canReloadUnavailableDataTab(props.activeTab)) {
|
||
reloadUnavailableDataTab();
|
||
return true;
|
||
}
|
||
if (activeElasticsearchJsonResponse.value) {
|
||
// Match DataGrid's toolbar refresh intent so multi-result runs are
|
||
// refreshed as a group instead of replacing them with the active result.
|
||
emit("reload", props.activeTab.id, activeResultSql.value, undefined, undefined, undefined, undefined, undefined, "refresh");
|
||
return true;
|
||
}
|
||
if (!dataGridRef.value) return false;
|
||
void dataGridRef.value.onToolbarRefresh();
|
||
return true;
|
||
}
|
||
|
||
function onRefreshActiveKvBrowser(event: Event) {
|
||
const detail = (event as CustomEvent<{ mode?: string; connectionId?: string }>).detail;
|
||
if (!detail || props.activeTab.mode !== detail.mode || props.activeTab.connectionId !== detail.connectionId) return;
|
||
void nextTick(() => refreshData());
|
||
}
|
||
|
||
function matchesActiveObjectBrowserRefreshScope(detail: { connectionId?: string; database?: string } & ObjectBrowserRefreshScope): boolean {
|
||
if (props.activeTab.mode !== "objects" || props.activeTab.connectionId !== detail.connectionId || props.activeTab.database !== detail.database) return false;
|
||
const matchesRefreshScope = objectBrowserRef.value?.matchesRefreshScope;
|
||
if (matchesRefreshScope) return matchesRefreshScope(detail);
|
||
const objectBrowser = props.activeTab.objectBrowser;
|
||
return (objectBrowser?.schema || props.activeTab.schema || "") === (detail.schema || "") && (objectBrowser?.catalog || props.activeTab.catalog || "") === (detail.catalog || "");
|
||
}
|
||
|
||
function onRefreshObjectBrowser(event: Event) {
|
||
const detail = (event as CustomEvent<{ connectionId?: string; database?: string; schema?: string; catalog?: string }>).detail;
|
||
if (!detail || !matchesActiveObjectBrowserRefreshScope(detail)) return;
|
||
void nextTick(() => {
|
||
if (matchesActiveObjectBrowserRefreshScope(detail)) refreshData();
|
||
});
|
||
}
|
||
|
||
function openPluginResultView(pluginId: string, contributionId: string, label: string) {
|
||
const result = props.activeTab.result;
|
||
if (!result) return;
|
||
// The tab carries the result-view contribution id, not a workbench id: the
|
||
// plugin UI is told which declared surface the user picked, and it receives a
|
||
// bounded snapshot — plugins that need more rows re-run the statement through
|
||
// `host.queryData` (host.data:read, with the user's consent).
|
||
const cappedRows = result.rows.slice(0, 500);
|
||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||
title: label,
|
||
connectionId: props.activeTab.connectionId || "",
|
||
database: props.activeTab.database || "",
|
||
context: {
|
||
connectionId: props.activeTab.connectionId || "",
|
||
database: props.activeTab.database || "",
|
||
sql: resultSqlForGrid(props.activeTab),
|
||
result: { columns: result.columns, rows: cappedRows, truncated: result.rows.length > cappedRows.length },
|
||
},
|
||
refreshContextOnReuse: true,
|
||
});
|
||
}
|
||
|
||
async function exportResultArchive() {
|
||
if (resultArchiveExporting.value) return;
|
||
resultArchiveExporting.value = true;
|
||
try {
|
||
const bytes = await queryStore.exportResultArchive(props.activeTab.id);
|
||
if (!bytes) {
|
||
toast(t("tabs.resultArchiveUnavailable"), 4000);
|
||
return;
|
||
}
|
||
const saved = await saveQueryResultArchiveFile(defaultQueryResultArchiveFileName(props.activeTab.title), bytes);
|
||
if (saved) toast(t("tabs.resultArchiveExported"), 2500);
|
||
} catch (error: any) {
|
||
toast(t("tabs.resultArchiveExportFailed", { message: error?.message || String(error) }), 5000);
|
||
} finally {
|
||
resultArchiveExporting.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeResultRun(runId: string) {
|
||
const removedActiveRun = props.activeTab.activeResultRunId === runId;
|
||
const removed = await queryStore.removeResultRun(props.activeTab.id, runId);
|
||
if (!removed) return;
|
||
if (removedActiveRun) emit("update:activeOutputView", props.activeTab.id, "result");
|
||
await nextTick();
|
||
const activeRunTab = resultTabsScrollerRef.value?.querySelector<HTMLElement>('[data-active-result-run="true"]');
|
||
activeRunTab?.focus({ preventScroll: true });
|
||
activeRunTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||
}
|
||
|
||
function toggleResultRunPinned(runId: string) {
|
||
queryStore.toggleResultRunPinned(props.activeTab.id, runId);
|
||
}
|
||
|
||
async function closeOtherResultRuns(runId: string) {
|
||
if (!(await queryStore.closeOtherResultRuns(props.activeTab.id, runId))) return;
|
||
await selectResultRun(runId);
|
||
}
|
||
|
||
async function closeResultRunsToLeft(runId: string) {
|
||
if (!(await queryStore.closeResultRunsToLeft(props.activeTab.id, runId))) return;
|
||
await selectResultRun(runId);
|
||
}
|
||
|
||
async function closeResultRunsToRight(runId: string) {
|
||
if (!(await queryStore.closeResultRunsToRight(props.activeTab.id, runId))) return;
|
||
await selectResultRun(runId);
|
||
}
|
||
|
||
function openResultRunRename(run: (typeof resultRuns.value)[number]) {
|
||
resultRunRenameId.value = run.id;
|
||
resultRunRenameTitle.value = run.title || run.sourceLabel || t("tabs.runN", { n: run.sequence });
|
||
resultRunRenameOpen.value = true;
|
||
nextTick(() => {
|
||
const input = document.querySelector<HTMLInputElement>("[data-result-run-name-input]");
|
||
input?.focus();
|
||
input?.select();
|
||
});
|
||
}
|
||
|
||
function saveResultRunRename() {
|
||
if (!resultRunRenameId.value) return;
|
||
if (queryStore.renameResultRun(props.activeTab.id, resultRunRenameId.value, resultRunRenameTitle.value)) {
|
||
resultRunRenameOpen.value = false;
|
||
}
|
||
}
|
||
|
||
function resultRunContextMenuItems(run: (typeof resultRuns.value)[number]): ContextMenuItem[] {
|
||
return [
|
||
{
|
||
label: t("tabs.renameResultRun"),
|
||
action: () => openResultRunRename(run),
|
||
icon: Pencil,
|
||
},
|
||
{
|
||
label: t(run.pinned ? "tabs.unpinResultRun" : "tabs.pinResultRun"),
|
||
action: () => toggleResultRunPinned(run.id),
|
||
icon: Pin,
|
||
iconClass: run.pinned ? "fill-current" : "",
|
||
},
|
||
{
|
||
label: t("tabs.unpinAllResultRuns"),
|
||
action: () => queryStore.unpinAllResultRuns(props.activeTab.id),
|
||
disabled: !resultRuns.value.some((item) => item.pinned),
|
||
icon: Pin,
|
||
},
|
||
{ label: "", separator: true },
|
||
{
|
||
label: t("tabs.closeOtherResultRuns"),
|
||
action: () => void closeOtherResultRuns(run.id),
|
||
disabled: resultRuns.value.length <= 1,
|
||
icon: X,
|
||
},
|
||
{
|
||
label: t("tabs.closeResultRunsToLeft"),
|
||
action: () => void closeResultRunsToLeft(run.id),
|
||
disabled: resultRuns.value.findIndex((item) => item.id === run.id) <= 0,
|
||
icon: X,
|
||
},
|
||
{
|
||
label: t("tabs.closeResultRunsToRight"),
|
||
action: () => void closeResultRunsToRight(run.id),
|
||
disabled: resultRuns.value.findIndex((item) => item.id === run.id) >= resultRuns.value.length - 1,
|
||
icon: X,
|
||
},
|
||
];
|
||
}
|
||
|
||
async function closeCurrentQueryResult() {
|
||
if (!(await queryStore.closeQueryResult(props.activeTab.id))) return;
|
||
emit("update:activeOutputView", props.activeTab.id, "result");
|
||
}
|
||
|
||
async function selectResultRun(runId: string) {
|
||
if (!(await queryStore.setActiveResultRun(props.activeTab.id, runId))) {
|
||
toast(t("tabs.missingResultRun"), 4000);
|
||
return false;
|
||
}
|
||
emit("update:activeOutputView", props.activeTab.id, "result");
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 点击结果标签:切换显示的同时固定该结果(issue #9975)。
|
||
* 未固定的结果会被下一次普通查询复用/覆盖,点击后固定即可保留下来。
|
||
*/
|
||
async function selectResultRunFromTab(runId: string) {
|
||
if (!(await selectResultRun(runId))) return;
|
||
const run = resultRuns.value.find((item) => item.id === runId);
|
||
if (run && !run.pinned) toggleResultRunPinned(runId);
|
||
}
|
||
|
||
async function focusResultRunByIndex(index: number) {
|
||
const run = resultRuns.value[index];
|
||
if (!run) return;
|
||
if (!(await selectResultRun(run.id))) return;
|
||
await nextTick();
|
||
const runTabs = resultTabsScrollerRef.value?.querySelectorAll<HTMLElement>("[data-result-run-tab]");
|
||
const runTab = runTabs?.[index];
|
||
runTab?.focus({ preventScroll: true });
|
||
runTab?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||
}
|
||
|
||
function onResultRunTabKeydown(event: KeyboardEvent, currentIndex: number) {
|
||
const lastIndex = resultRuns.value.length - 1;
|
||
let targetIndex: number | undefined;
|
||
if (event.key === "ArrowLeft") targetIndex = currentIndex > 0 ? currentIndex - 1 : lastIndex;
|
||
if (event.key === "ArrowRight") targetIndex = currentIndex < lastIndex ? currentIndex + 1 : 0;
|
||
if (event.key === "Home") targetIndex = 0;
|
||
if (event.key === "End") targetIndex = lastIndex;
|
||
if (targetIndex === undefined || targetIndex < 0) return;
|
||
event.preventDefault();
|
||
void focusResultRunByIndex(targetIndex);
|
||
}
|
||
|
||
function toggleResultAutoSave() {
|
||
const enabled = queryStore.toggleResultAutoSave(props.activeTab.id);
|
||
toast(t(enabled ? "tabs.autoKeepResultsEnabled" : "tabs.autoKeepResultsDisabled"), 2500);
|
||
}
|
||
|
||
function selectResultItem(item: (typeof visibleResultItems.value)[number]) {
|
||
queryStore.setActiveResultIndex(props.activeTab.id, item.index);
|
||
emit("update:activeOutputView", props.activeTab.id, "result");
|
||
const range = resultSourceRange(props.activeTab.sql, item.result, item.index, activeEffectiveDatabaseType.value, activeSqlStatementParameterOptions.value) ?? null;
|
||
if (queryEditorRef.value) {
|
||
nextTick(() => queryEditorRef.value?.previewStatementRange(range));
|
||
} else {
|
||
emit("previewStatement", props.activeTab.id, range);
|
||
}
|
||
}
|
||
|
||
function executionSummaryItemRange(item: ExecutionSummaryItem) {
|
||
if (typeof item.sourceFrom === "number" && typeof item.sourceTo === "number" && item.sql && props.activeTab.sql.slice(item.sourceFrom, item.sourceTo) === item.sql) {
|
||
return { from: item.sourceFrom, to: item.sourceTo };
|
||
}
|
||
return item.result ? resultSourceRange(props.activeTab.sql, item.result, item.statementIndex, activeEffectiveDatabaseType.value, activeSqlStatementParameterOptions.value) : undefined;
|
||
}
|
||
|
||
function previewExecutionSummaryItem(item: ExecutionSummaryItem) {
|
||
const range = executionSummaryItemRange(item) ?? null;
|
||
if (queryEditorRef.value) {
|
||
queryEditorRef.value?.previewStatementRange(range);
|
||
} else {
|
||
emit("previewStatement", props.activeTab.id, range);
|
||
}
|
||
}
|
||
|
||
function focusExecutionSummaryItem(item: ExecutionSummaryItem) {
|
||
const range = executionSummaryItemRange(item) ?? null;
|
||
if (queryEditorRef.value) {
|
||
queryEditorRef.value?.focusStatementRange(range);
|
||
} else {
|
||
emit("focusStatement", props.activeTab.id, range);
|
||
}
|
||
}
|
||
|
||
async function copyExecutionSummaryError(error: string) {
|
||
try {
|
||
await copyToClipboard(error);
|
||
toast(t("grid.copied"));
|
||
} catch (copyError: any) {
|
||
toast(t("grid.copyFailed", { message: copyError?.message || String(copyError) }), 5000);
|
||
}
|
||
}
|
||
|
||
function dismissBatchRecovery() {
|
||
queryStore.dismissBatchSqlRecovery(props.activeTab.id);
|
||
}
|
||
|
||
function resumeBatchExecution(action: BatchSqlRecoveryAction) {
|
||
void queryStore.resumeBatchSql(props.activeTab.id, action);
|
||
}
|
||
|
||
function handleModRTarget(target: Element): boolean {
|
||
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
|
||
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
|
||
if (target.closest("[data-grid-root], [data-elasticsearch-json-response-root]")) return refreshData();
|
||
if (canReloadUnavailableDataTab(props.activeTab)) return refreshData();
|
||
return false;
|
||
}
|
||
|
||
function requestQueryEditorExecute() {
|
||
return queryEditorRef.value?.requestExecute();
|
||
}
|
||
|
||
function captureQueryEditorExecutionSnapshot() {
|
||
return queryEditorRef.value?.captureExecutionSnapshot();
|
||
}
|
||
|
||
function requestQueryEditorExecuteInNewResultTab() {
|
||
return queryEditorRef.value?.requestExecuteInNewResultTab();
|
||
}
|
||
|
||
function requestQueryEditorPreviewChanges(stackSql?: string) {
|
||
return queryEditorRef.value?.requestPreviewChanges?.(stackSql);
|
||
}
|
||
|
||
function shouldBlockQueryEditorExecutionShortcut(event: KeyboardEvent) {
|
||
return queryEditorRef.value?.shouldBlockExecutionShortcut?.(event) ?? false;
|
||
}
|
||
|
||
function acceptQueryEditorExecutionViewport(requestId: number) {
|
||
return queryEditorRef.value?.acceptGutterExecutionViewport(requestId) ?? false;
|
||
}
|
||
|
||
function cancelQueryEditorExecutionViewport(requestId: number) {
|
||
return queryEditorRef.value?.cancelGutterExecutionViewport(requestId) ?? false;
|
||
}
|
||
|
||
async function handleExportQuery(payload: { sql: string; format: "csv" | "xlsx" | "txt"; columnComments?: (string | null)[] }) {
|
||
const tab = props.activeTab;
|
||
if (!tab || tab.mode !== "query") return;
|
||
let filePath = `query-result.${payload.format}`;
|
||
if (isTauriRuntime()) {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const filterName = payload.format === "csv" ? "CSV" : payload.format === "xlsx" ? "Excel" : "Text";
|
||
const picked = await save({ defaultPath: filePath, filters: [{ name: filterName, extensions: [payload.format] }] });
|
||
if (!picked) return;
|
||
filePath = picked as string;
|
||
}
|
||
await queryStore.exportQuerySqlDirect(tab.id, payload.sql, payload.format, filePath, payload.columnComments);
|
||
}
|
||
|
||
function pasteClipboardAsSqlInCondition() {
|
||
return queryEditorRef.value?.pasteClipboardAsSqlInCondition();
|
||
}
|
||
|
||
function applyTableStructureChanges() {
|
||
return tableStructureEditorRef.value?.applyChanges() ?? Promise.resolve(false);
|
||
}
|
||
|
||
// The Redis console is bound to the visible tab and logical database. Refuse
|
||
// any other AI target before passing a command to the key browser (#9902).
|
||
function isRedisConsoleReady(target: AiConversationBinding): boolean {
|
||
return isAiRedisConsoleTarget(props.activeTab, target) && !!redisKeyBrowserRef.value;
|
||
}
|
||
|
||
async function insertRedisCommand(command: string, target: AiConversationBinding): Promise<boolean> {
|
||
if (!isRedisConsoleReady(target)) return false;
|
||
return (await redisKeyBrowserRef.value?.insertCommand?.(command)) ?? false;
|
||
}
|
||
|
||
async function executeRedisCommand(command: string, target: AiConversationBinding): Promise<boolean> {
|
||
if (!isRedisConsoleReady(target)) return false;
|
||
return (await redisKeyBrowserRef.value?.executeCommand?.(command)) ?? false;
|
||
}
|
||
|
||
function previewStatementRange(range: { from: number; to: number } | null): boolean {
|
||
if (!queryEditorRef.value) return false;
|
||
queryEditorRef.value.previewStatementRange(range);
|
||
return true;
|
||
}
|
||
|
||
function focusStatementRange(range: { from: number; to: number } | null): boolean {
|
||
if (!queryEditorRef.value) return false;
|
||
queryEditorRef.value.focusStatementRange(range);
|
||
return true;
|
||
}
|
||
|
||
function focusErrorPosition(offset: number): boolean {
|
||
if (!queryEditorRef.value) return false;
|
||
queryEditorRef.value.focusErrorPosition(offset);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Jump to the driver-reported row/column of the active result's error. Falls back
|
||
* to a cross-surface event when this surface only renders the shared result pane
|
||
* (the editor lives in another group).
|
||
*/
|
||
function activeResultErrorOffsetOptions() {
|
||
const result = props.activeTab.result;
|
||
return {
|
||
editorSql: props.activeTab.sql,
|
||
result,
|
||
resultIndex: result?.statement_index ?? props.activeTab.activeResultIndex,
|
||
databaseType: activeEffectiveDatabaseType.value,
|
||
parameterOptions: activeSqlStatementParameterOptions.value,
|
||
};
|
||
}
|
||
|
||
function locateActiveResultError() {
|
||
const result = props.activeTab.result;
|
||
logSqlErrorPosition("locate:invoke", {
|
||
tabId: props.activeTab.id,
|
||
mode: props.activeTab.mode,
|
||
hasEditorRef: Boolean(queryEditorRef.value),
|
||
activeResultIndex: props.activeTab.activeResultIndex,
|
||
statementIndex: result?.statement_index,
|
||
hasBackendError: Boolean(result?.error),
|
||
errorPosition: result?.error?.errorPosition ?? null,
|
||
editorLength: props.activeTab.sql.length,
|
||
resultIsError: Boolean(result && isQueryExecutionErrorResult(result)),
|
||
});
|
||
const mapped = sqlErrorEditorOffset(activeResultErrorOffsetOptions());
|
||
if (!mapped) {
|
||
logSqlErrorPosition("locate:unavailable", {
|
||
tabId: props.activeTab.id,
|
||
reason: "sqlErrorEditorOffset returned undefined (see the preceding 'unresolved:result-source-range' log)",
|
||
});
|
||
toast(t("editor.errorPositionUnavailable"), 3000);
|
||
return;
|
||
}
|
||
logSqlErrorPosition("locate:focus", { tabId: props.activeTab.id, offset: mapped.offset, line: mapped.line, column: mapped.column });
|
||
if (queryEditorRef.value) {
|
||
queryEditorRef.value.focusErrorPosition(mapped.offset);
|
||
} else {
|
||
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
|
||
}
|
||
}
|
||
|
||
function locateExecutionSummaryError(item: ExecutionSummaryItem) {
|
||
const mapped = item.result
|
||
? sqlErrorEditorOffset({
|
||
editorSql: props.activeTab.sql,
|
||
result: item.result,
|
||
resultIndex: item.statementIndex,
|
||
databaseType: activeEffectiveDatabaseType.value,
|
||
parameterOptions: activeSqlStatementParameterOptions.value,
|
||
})
|
||
: undefined;
|
||
if (mapped) {
|
||
if (!focusErrorPosition(mapped.offset)) {
|
||
emit("focusErrorOffset", props.activeTab.id, mapped.offset);
|
||
}
|
||
return;
|
||
}
|
||
focusExecutionSummaryItem(item);
|
||
}
|
||
|
||
defineExpose({
|
||
focusSearch,
|
||
openGoToColumn,
|
||
refreshData,
|
||
toggleResultsPane,
|
||
refreshQueryEditorCompletionCache,
|
||
handleModRTarget,
|
||
requestQueryEditorExecute,
|
||
captureQueryEditorExecutionSnapshot,
|
||
requestQueryEditorExecuteInNewResultTab,
|
||
requestQueryEditorPreviewChanges,
|
||
shouldBlockQueryEditorExecutionShortcut,
|
||
acceptQueryEditorExecutionViewport,
|
||
cancelQueryEditorExecutionViewport,
|
||
pasteClipboardAsSqlInCondition,
|
||
applyTableStructureChanges,
|
||
insertRedisCommand,
|
||
executeRedisCommand,
|
||
isRedisConsoleReady,
|
||
previewStatementRange,
|
||
focusStatementRange,
|
||
focusErrorPosition,
|
||
locateActiveResultError,
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="production-session-shell flex flex-col min-h-0" :class="[resultOnly ? 'h-full' : 'flex-1', { 'production-session-shell--active': activeProductionContext.active }]">
|
||
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
|
||
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
|
||
<span v-if="productionSessionDetail" class="min-w-0 truncate rounded-md border border-red-500/25 bg-background/65 px-1.5 py-0.5 font-medium text-red-700 dark:text-red-200">{{ productionSessionDetail }}</span>
|
||
</div>
|
||
<!-- Query mode: editor + results -->
|
||
<template v-if="activeTab.mode === 'query'">
|
||
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resize="onResultsSplitResize" @resized="onResultsResized">
|
||
<Pane v-if="!resultOnly" class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
|
||
<div class="h-full flex flex-col relative">
|
||
<div v-if="activeTab.ddlViewer" class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
|
||
<Button variant="outline" size="sm" :disabled="ddlRefreshInProgress" @click="refreshDdlViewer">
|
||
<RefreshCcw class="h-4 w-4" :class="ddlRefreshInProgress ? 'animate-spin' : ''" />
|
||
{{ t("structureEditor.refresh") }}
|
||
</Button>
|
||
<Button variant="outline" size="sm" @click="viewDdlTableData">
|
||
<TableProperties class="h-4 w-4" />
|
||
{{ t("contextMenu.viewData") }}
|
||
</Button>
|
||
</div>
|
||
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
|
||
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
|
||
</div>
|
||
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
|
||
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。
|
||
issue #9387:DDL 新标签同样先出 tab 再加载,失败就地显示错误。 -->
|
||
<QueryLoadingState v-if="activeTab.sourceLoad && !activeTab.sourceLoad.error" class="relative z-0 flex-1" :elapsed-seconds="sourceLoadElapsedSeconds" />
|
||
<div v-else-if="activeTab.sourceLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-object-source-load-error>
|
||
<p class="max-w-[80%] text-center break-words text-destructive">{{ activeTab.sourceLoad.error }}</p>
|
||
<Button variant="outline" size="sm" class="gap-1.5" @click="queryStore.retryObjectSourceTab(activeTab.id)">
|
||
<RotateCcw class="h-4 w-4" />
|
||
{{ t("common.retry") }}
|
||
</Button>
|
||
</div>
|
||
<QueryLoadingState v-else-if="activeTab.ddlLoad && !activeTab.ddlLoad.error" class="relative z-0 flex-1" :elapsed-seconds="ddlLoadElapsedSeconds" />
|
||
<div v-else-if="activeTab.ddlLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-ddl-load-error>
|
||
<p class="max-w-[80%] text-center break-words text-destructive">{{ t("contextMenu.ddlRefreshFailed", { message: activeTab.ddlLoad.error }) }}</p>
|
||
<Button variant="outline" size="sm" class="gap-1.5" @click="queryStore.retryDdlViewerTab(activeTab.id)">
|
||
<RotateCcw class="h-4 w-4" />
|
||
{{ t("common.retry") }}
|
||
</Button>
|
||
</div>
|
||
<QueryEditor
|
||
v-else
|
||
ref="queryEditorRef"
|
||
class="relative z-0 flex-1"
|
||
:auto-focus="autoFocus !== false"
|
||
:model-value="activeTab.sql"
|
||
:tab-id="activeTab.id"
|
||
:connection-id="activeTab.connectionId"
|
||
:catalog="activeTab.catalog"
|
||
:database="activeTab.database"
|
||
:schema="activeTab.schema"
|
||
:client-session-id="activeTab.id"
|
||
:completion-context-version="activeTab.completionContextVersion"
|
||
:database-type="activeEffectiveDatabaseType"
|
||
:dialect="editorDialect"
|
||
:syntax-dialect="editorSyntaxDialect"
|
||
:format-dialect="activeSqlFormatDialect"
|
||
:format-request-id="formatSqlRequest?.tabId === activeTab.id ? formatSqlRequest.id : undefined"
|
||
:compress-request-id="compressSqlRequest?.tabId === activeTab.id ? compressSqlRequest.id : undefined"
|
||
:execution-error="activeQueryError"
|
||
:execution-error-sql="activeTab.lastExecutedSql"
|
||
:result-columns="activeTab.result?.columns"
|
||
:result-source-statement="activeTab.result?.sourceStatement"
|
||
:result-source-from="activeTab.result?.sourceFrom"
|
||
:result-source-to="activeTab.result?.sourceTo"
|
||
:statement-execution-markers="activeStatementExecutionMarkers"
|
||
:initial-viewport="activeTab.editorViewport"
|
||
:initial-selection="activeTab.editorSelection"
|
||
:reveal-request="activeTab.editorRevealRequest"
|
||
:force-word-wrap="activeTab.forceWordWrap"
|
||
:read-only="!!activeTab.ddlViewer"
|
||
:hide-execution-controls="!!activeTab.ddlViewer"
|
||
enable-explain-shortcut
|
||
:can-explain="!activeTab.isExecuting && !activeTab.isExplaining && !!executableSql.trim()"
|
||
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
|
||
@selection-change="emit('editorSelectionChange', activeTab.id, $event)"
|
||
@send-selection-to-ai="emit('sendSelectionToAi', activeTab.id, $event)"
|
||
@cursor-change="emit('editorCursorChange', activeTab.id, $event)"
|
||
@preview-changes-available="emit('previewChangesAvailable', activeTab.id, $event)"
|
||
@viewport-change="(viewport, tabId) => emit('editorViewportChange', tabId ?? activeTab.id, viewport)"
|
||
@selection-state-change="emit('editorSelectionStateChange', activeTab.id, $event)"
|
||
@editor-state-flushed="emit('editorStateFlushed', activeTab.id)"
|
||
@editor-reveal-consumed="clearEditorRevealRequest(activeTab)"
|
||
@format-error="emit('formatError', activeTab.id)"
|
||
@execute="emit('execute', activeTab.id, $event)"
|
||
@execute-in-new-result-tab="emit('executeInNewResultTab', activeTab.id, $event)"
|
||
@explain="emit('explain', activeTab.id)"
|
||
@export-query="handleExportQuery"
|
||
@save="emit('saveSql', props.activeTab.id)"
|
||
@click-table="onHandleClickTable"
|
||
@view-table-data="onHandleViewTableData"
|
||
@edit-table-structure="onHandleEditTableStructure"
|
||
@view-table-ddl="onHandleViewTableDdl"
|
||
@open-object-source="onHandleOpenObjectSource"
|
||
@click-column="onHandleClickColumn"
|
||
@close-column-panel="onHandleCloseColumnPanel"
|
||
/>
|
||
<ColumnInfoPanel
|
||
v-if="showColumnInfo"
|
||
:columns="columnInfoColumns"
|
||
:loading="columnInfoLoading"
|
||
:error="columnInfoError"
|
||
:database-type="activeEffectiveDatabaseType"
|
||
:is-gaussdb-m="activeEffectiveDatabaseType === 'gaussdb' && activeResultConnection?.driver_profile?.toLowerCase() === 'gaussdb-m'"
|
||
@close="closeColumnInfo"
|
||
/>
|
||
<Button v-if="!editorOnly && hasQueryOutput && !resultsPaneOpen" variant="secondary" size="sm" class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg hover:bg-accent" @click="resultsPaneOpen = true">
|
||
<ChevronUp class="h-3.5 w-3.5" />
|
||
{{ t("editor.showResultsPane") }}
|
||
</Button>
|
||
</div>
|
||
</Pane>
|
||
<Pane v-if="(resultsPaneOpen || resultOnly) && !editorOnly" class="min-h-0" :size="resultOnly ? 100 : resultsPaneSize" :min-size="resultOnly ? 100 : 20">
|
||
<div class="h-full flex flex-col">
|
||
<!--
|
||
The shared result surface (resultOnly) is always mounted regardless of
|
||
whether the active tab has run a query yet, and this toolbar is the only
|
||
place that renders the "hide results" chevron (see handleHideResultsPane).
|
||
Gating it on hasQueryOutput alone left users with an always-open, empty
|
||
shared pane and no way to collapse it (#8233).
|
||
-->
|
||
<div v-if="hasQueryOutput || resultOnly" class="flex h-10 shrink-0 items-center gap-1 border-b bg-muted/20 px-2">
|
||
<Button
|
||
v-if="activeTab.mode === 'query' && activeTab.result"
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-6 w-7 shrink-0"
|
||
:class="resultAutoSave ? 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
|
||
:title="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
|
||
:aria-label="resultAutoSave ? t('tabs.autoKeepResultsEnabled') : t('tabs.autoKeepResults')"
|
||
:aria-pressed="resultAutoSave"
|
||
@click="toggleResultAutoSave"
|
||
>
|
||
<Pin class="h-3.5 w-3.5" :class="{ 'fill-current': resultAutoSave }" />
|
||
</Button>
|
||
<Button v-if="canCloseQueryResult" variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('tabs.closeResult')" :aria-label="t('tabs.closeResult')" @click="closeCurrentQueryResult">
|
||
<X class="h-3.5 w-3.5" />
|
||
</Button>
|
||
<template v-if="resultRuns.length > 0 || visibleResultItems.length > 0">
|
||
<span class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||
<div v-if="showResultRunTabs" data-result-run-tabs-region class="relative min-w-0 flex-1 self-stretch">
|
||
<div v-if="hasResultTabOverflow" class="result-tab-scrollbar" :class="{ 'result-tab-scrollbar--dragging': isResultTabsScrollbarDragging }" @pointerdown="startResultTabsScrollbarDrag">
|
||
<div class="result-tab-scrollbar__thumb" :style="resultTabsScrollbarThumbStyle" />
|
||
</div>
|
||
<div ref="resultTabsScrollerRef" class="result-tab-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" :style="resultTabsScrollerStyle" @scroll="updateResultTabsScrollbar" @wheel="onResultTabsWheel">
|
||
<div role="tablist" :aria-label="t('tabs.resultRuns')" class="flex h-full shrink-0 items-center gap-1">
|
||
<CustomContextMenu v-for="(run, runIndex) in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
|
||
<div
|
||
role="presentation"
|
||
class="group/result-run inline-flex h-7 shrink-0 select-none items-center overflow-hidden rounded-md border transition-colors"
|
||
:class="run.active ? 'border-border bg-background text-foreground shadow-sm' : 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-background/70 hover:text-foreground'"
|
||
@contextmenu="onContextMenu"
|
||
>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
data-result-run-tab
|
||
:tabindex="run.active ? 0 : -1"
|
||
:aria-selected="run.active"
|
||
:data-active-result-run="run.active ? 'true' : undefined"
|
||
class="flex h-full select-none items-center gap-1 whitespace-nowrap pl-2.5 pr-1 text-xs font-medium outline-none focus-visible:bg-accent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
|
||
@click="selectResultRunFromTab(run.id)"
|
||
@keydown="onResultRunTabKeydown($event, runIndex)"
|
||
>
|
||
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||
{{ run.title || run.sourceLabel || t("tabs.runN", { n: run.sequence }) }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="mr-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/70 outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
|
||
:title="t('tabs.removeRun', { n: run.sequence })"
|
||
:aria-label="t('tabs.removeRun', { n: run.sequence })"
|
||
@click.stop.prevent="removeResultRun(run.id)"
|
||
>
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
</CustomContextMenu>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="showResultRunSelector" class="min-w-0 flex-1">
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger as-child>
|
||
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
|
||
<span class="min-w-0 truncate">{{ activeResultRunItem ? activeResultRunItem.title || activeResultRunItem.sourceLabel || t("tabs.runN", { n: activeResultRunItem.sequence }) : t("tabs.resultRuns") }}</span>
|
||
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="start" class="w-48">
|
||
<CustomContextMenu v-for="run in resultRuns" :key="run.id" :items="() => resultRunContextMenuItems(run)" v-slot="{ onContextMenu }">
|
||
<DropdownMenuItem class="flex items-center gap-2 pr-1" @select="selectResultRunFromTab(run.id)" @contextmenu="onContextMenu">
|
||
<Check v-if="run.active" class="h-3.5 w-3.5 shrink-0" />
|
||
<span v-else class="h-3.5 w-3.5 shrink-0" />
|
||
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
|
||
<span class="min-w-0 flex-1 truncate">{{ run.title || run.sourceLabel || t("tabs.runN", { n: run.sequence }) }}</span>
|
||
<button
|
||
type="button"
|
||
class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||
:title="t('tabs.removeRun', { n: run.sequence })"
|
||
:aria-label="t('tabs.removeRun', { n: run.sequence })"
|
||
@click.stop.prevent="removeResultRun(run.id)"
|
||
>
|
||
<X class="h-3 w-3" />
|
||
</button>
|
||
</DropdownMenuItem>
|
||
</CustomContextMenu>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
<div v-else-if="resultRuns.length > 0" class="min-w-0 flex-1" />
|
||
<span v-if="resultRuns.length > 0 && visibleResultItems.length > 0" class="mx-1 h-4 w-px shrink-0 bg-border" />
|
||
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
|
||
</template>
|
||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||
<PopoverTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||
<Wrench class="h-4 w-4" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent
|
||
align="end"
|
||
:collision-padding="8"
|
||
class="w-max min-w-44 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-0 overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-0 text-popover-foreground shadow-xl"
|
||
@click.stop
|
||
@keydown.stop
|
||
>
|
||
<div class="border-b bg-muted/40 px-3 py-2">
|
||
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<PanelsTopLeft class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.resultRunDisplayMode") }}</span>
|
||
</div>
|
||
<div role="group" :aria-label="t('grid.resultRunDisplayMode')" class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="resultRunDisplayMode === 'list' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setResultRunDisplayMode('list')"
|
||
>
|
||
{{ t("grid.resultRunDisplayList") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="resultRunDisplayMode === 'tabs' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setResultRunDisplayMode('tabs')"
|
||
>
|
||
{{ t("grid.resultRunDisplayTabs") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<SquareDashed class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.renderMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.renderModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRenderMode === 'canvas' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRenderMode('canvas')"
|
||
>
|
||
{{ t("grid.canvasRenderMode") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRenderMode === 'dom' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRenderMode('dom')"
|
||
>
|
||
{{ t("grid.domRenderMode") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Columns3Cog class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.columnWidth") }}</span>
|
||
</div>
|
||
<div class="grid w-48 grid-cols-3 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
v-for="density in ['compact', 'standard', 'comfortable'] as const"
|
||
:key="density"
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-1.5 text-xs transition-colors"
|
||
:class="columnWidthDensity === density ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setColumnWidthDensity(density)"
|
||
>
|
||
{{ t(`grid.columnWidth${density.charAt(0).toUpperCase()}${density.slice(1)}`) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<DataGridFontFamilyControl />
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-[11px] font-semibold text-muted-foreground">A</span>
|
||
<span>{{ t("grid.tableFontSize") }}</span>
|
||
</div>
|
||
<div class="flex h-6 w-32 items-center rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
|
||
:disabled="tableFontSize <= TABLE_FONT_SIZE_MIN"
|
||
:aria-label="t('common.decrease')"
|
||
@click="decreaseTableFontSize"
|
||
>
|
||
<Minus class="h-3.5 w-3.5" />
|
||
</button>
|
||
<span class="flex-1 text-center text-xs font-semibold tabular-nums">{{ tableFontSize }}</span>
|
||
<button
|
||
type="button"
|
||
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
|
||
:disabled="tableFontSize >= TABLE_FONT_SIZE_MAX"
|
||
:aria-label="t('common.increase')"
|
||
@click="increaseTableFontSize"
|
||
>
|
||
<Plus class="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.searchMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.searchModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridSearchMode === 'filter' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridSearchMode('filter')"
|
||
>
|
||
{{ t("grid.searchModeFilter") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridSearchMode === 'highlight' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridSearchMode('highlight')"
|
||
>
|
||
{{ t("grid.searchModeHighlight") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Hash class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.rowNumberMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.rowNumberModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRowNumberMode === 'view' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRowNumberMode('view')"
|
||
>
|
||
{{ t("grid.rowNumberModeView") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRowNumberMode === 'source' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRowNumberMode('source')"
|
||
>
|
||
{{ t("grid.rowNumberModeSource") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Rows3 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.transposeMultiRowToggle") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="!dataGridRef?.multiRowTranspose ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="dataGridRef?.setMultiRowTranspose(false)"
|
||
>
|
||
{{ t("grid.transposeSingleRow") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRef?.multiRowTranspose ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="dataGridRef?.setMultiRowTranspose(true)"
|
||
>
|
||
{{ t("grid.transposeMultiRow") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<component :is="numericColumnRightAlign ? AlignRight : AlignLeft" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.numericColumnAlign") }}</span>
|
||
</div>
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="!numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setNumericColumnRightAlign(false)"
|
||
>
|
||
{{ t("grid.numericColumnAlignLeft") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setNumericColumnRightAlign(true)"
|
||
>
|
||
{{ t("grid.numericColumnAlignRight") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<CheckSquare2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.booleanDisplayMode") }}</span>
|
||
</div>
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="booleanDisplayMode === 'dropdown' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setBooleanDisplayMode('dropdown')"
|
||
>
|
||
{{ t("grid.booleanDisplayDropdown") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="booleanDisplayMode === 'checkbox' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setBooleanDisplayMode('checkbox')"
|
||
>
|
||
{{ t("grid.booleanDisplayCheckbox") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Palette class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.colorizeDataTypes") }}</span>
|
||
</div>
|
||
<Switch size="sm" :model-value="colorizeDataGridCellTypes" :aria-label="t('grid.colorizeDataTypes')" @update:model-value="setColorizeDataGridCellTypes" />
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
||
<span class="min-w-0 flex items-center gap-2 font-medium">
|
||
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
{{ t("grid.hideNullColumns") }}
|
||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
||
</span>
|
||
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
|
||
</div>
|
||
<DataGridCopyFormatControl
|
||
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
|
||
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
|
||
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
|
||
@select="dataGridRef?.setDefaultCopyPreference($event)"
|
||
@configure="openDataGridExtractorConfiguration"
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<LightTooltip :text="t('editor.hideResultsPane')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-muted-foreground hover:text-foreground" :title="t('editor.hideResultsPane')" :aria-label="t('editor.hideResultsPane')" @click="handleHideResultsPane">
|
||
<ChevronDown class="h-3.5 w-3.5" />
|
||
</Button>
|
||
</LightTooltip>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Keep this height in sync with the embedded result toolbar. -->
|
||
<div v-if="hasQueryOutput && showStandaloneResultToolbar" ref="standaloneResultToolbarRef" class="flex h-8 shrink-0 items-center border-b bg-muted/20">
|
||
<QueryResultViewSwitcher
|
||
:active-view="activeOutputView"
|
||
:can-show-result="canShowResultOutput"
|
||
:can-show-summary="hasExecutionSummary"
|
||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||
:can-show-messages="canShowMessagesOutput"
|
||
:message-count="resultMessageCount"
|
||
:compact="standaloneResultToolbarCompact"
|
||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||
/>
|
||
<QueryResultToolbarActions
|
||
class="ml-auto"
|
||
:active-view="activeOutputView"
|
||
:can-show-explain="canShowExplainOutput"
|
||
:can-show-profile="canShowProfile"
|
||
:can-export-archive="canExportResultArchive"
|
||
:archive-exporting="resultArchiveExporting"
|
||
:compact="standaloneResultToolbarCompact"
|
||
:has-result="!!activeTab.result"
|
||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||
@export-archive="exportResultArchive"
|
||
@open-result-view="openPluginResultView"
|
||
/>
|
||
</div>
|
||
|
||
<div v-if="activeTab.redisMonitorActive" class="flex shrink-0 items-center justify-between border-b px-3 py-1 text-xs text-muted-foreground">
|
||
<span>{{ t("redis.monitorListening") }}</span>
|
||
<Button variant="ghost" size="sm" :disabled="activeTab.isCancelling" @click="emit('cancel', activeTab.id)">{{ t("redis.monitorStop") }}</Button>
|
||
</div>
|
||
|
||
<ExplainPlanViewer
|
||
v-if="activeOutputView === 'explain'"
|
||
class="flex-1 min-h-0"
|
||
:plan="activeTab.explainPlan"
|
||
:error="activeTab.explainError"
|
||
:loading="activeTab.isExplaining"
|
||
:source-sql="activeTab.lastExplainedSql"
|
||
:explain-sql="activeTab.explainSql"
|
||
:table-result="activeTab.explainTableResult"
|
||
:table-error="activeTab.explainTableError"
|
||
/>
|
||
|
||
<ElasticsearchProfilePanel v-else-if="activeOutputView === 'profile' && canShowProfile" class="flex-1 min-h-0" :body="activeElasticsearchProfileBody ?? ''" />
|
||
|
||
<QueryChart v-else-if="activeOutputView === 'chart' && activeTab.result && !activeElasticsearchJsonResponse" class="flex-1 min-h-0" :result="activeTab.result" />
|
||
|
||
<div v-else-if="activeOutputView === 'summary'" class="flex flex-1 min-h-0 min-w-0 overflow-auto bg-background">
|
||
<div v-if="summaryItems.length === 0" class="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||
<Loader2 v-if="activeTab.isExecuting" class="mr-2 h-4 w-4 animate-spin" />
|
||
<template v-if="activeTab.isExecuting">{{ t("executionSummary.executing") }}</template>
|
||
<template v-else>{{ t("executionSummary.empty") }}</template>
|
||
</div>
|
||
<div v-else class="flex min-h-full min-w-[46rem] flex-col">
|
||
<div v-if="batchExecutionProgress" class="z-10 shrink-0 border-b bg-background/95 px-3 py-2 backdrop-blur">
|
||
<div class="mb-1.5 flex items-center gap-3 text-xs">
|
||
<span class="font-medium">{{ activeTab.isExecuting ? t("executionSummary.executing") : t("executionSummary.finished") }}</span>
|
||
<span class="tabular-nums text-muted-foreground">{{ batchExecutionProgress.completed }} / {{ batchExecutionProgress.total }}</span>
|
||
<span class="ml-auto tabular-nums text-muted-foreground">{{ batchExecutionPercent }}%</span>
|
||
</div>
|
||
<div class="h-1.5 overflow-hidden rounded-full bg-muted">
|
||
<div class="h-full rounded-full bg-primary transition-[width] duration-200" :style="{ width: `${batchExecutionPercent}%` }" />
|
||
</div>
|
||
</div>
|
||
<div v-if="batchRecovery" class="flex shrink-0 items-center gap-3 border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||
<CircleAlert class="h-4 w-4 shrink-0" />
|
||
<span class="min-w-0 flex-1 truncate">
|
||
{{ t("executionSummary.recoveryPrompt", { statement: batchRecovery.failedStatementIndex + 1, count: batchRecovery.remainingStatementCount }) }}
|
||
</span>
|
||
<div class="flex shrink-0 items-center gap-1">
|
||
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="dismissBatchRecovery">
|
||
<CircleStop class="h-3.5 w-3.5" />
|
||
{{ t("executionSummary.stop") }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('retry')">
|
||
<RotateCcw class="h-3.5 w-3.5" />
|
||
{{ t("executionSummary.retry") }}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('skip')">
|
||
<SkipForward class="h-3.5 w-3.5" />
|
||
{{ t("executionSummary.skipAndContinue") }}
|
||
</Button>
|
||
<LightTooltip :text="t('executionSummary.skipAllHint')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||
<Button variant="ghost" size="sm" class="h-6 gap-1 px-2 text-xs text-foreground hover:bg-background/70" @click="resumeBatchExecution('skip-all')">
|
||
<ListX class="h-3.5 w-3.5" />
|
||
{{ t("executionSummary.skipAll") }}
|
||
</Button>
|
||
</LightTooltip>
|
||
</div>
|
||
</div>
|
||
<div class="grid shrink-0 grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] border-b bg-muted/30 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||
<div>{{ t("executionSummary.statement") }}</div>
|
||
<div>{{ t("executionSummary.sql") }}</div>
|
||
<div>{{ t("executionSummary.status") }}</div>
|
||
<div class="text-right">{{ t("executionSummary.rows") }}</div>
|
||
<div class="text-right">{{ t("executionSummary.time") }}</div>
|
||
</div>
|
||
<DynamicScroller v-slot="{ item, index, active }" class="min-h-0 flex-1 border-b" :items="summaryItems" :min-item-size="37" :buffer="600" :skip-hover="true" key-field="statementIndex">
|
||
<DynamicScrollerItem :item="item" :active="active" :data-index="index" :size-dependencies="[item.error]">
|
||
<div class="relative grid w-full grid-cols-[4rem_minmax(14rem,1fr)_7rem_7rem_6rem] items-center border-b px-3 py-2 text-left text-xs last:border-b-0">
|
||
<button
|
||
type="button"
|
||
class="absolute inset-0 z-0 cursor-pointer text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||
:title="item.error || item.sql"
|
||
:aria-label="item.error || item.sql || t('executionSummary.noSql')"
|
||
@click="previewExecutionSummaryItem(item)"
|
||
@dblclick="locateExecutionSummaryError(item)"
|
||
@keydown.enter.prevent="focusExecutionSummaryItem(item)"
|
||
/>
|
||
<div class="pointer-events-none relative z-[1] font-mono text-muted-foreground">#{{ item.statementIndex + 1 }}</div>
|
||
<div class="relative z-[1] min-w-0 cursor-pointer" @click="previewExecutionSummaryItem(item)" @dblclick="locateExecutionSummaryError(item)">
|
||
<div class="truncate font-mono text-[11px] text-foreground">{{ item.sql || t("executionSummary.noSql") }}</div>
|
||
<div v-if="item.error" class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-destructive">
|
||
<span v-if="item.errorPosition" class="shrink-0 rounded border border-destructive/30 bg-destructive/5 px-1 tabular-nums">{{ t("executionSummary.lineColumn", { line: item.errorPosition.line, column: item.errorPosition.column }) }}</span>
|
||
<span data-native-clipboard class="min-w-0 flex-1 cursor-text select-text truncate" :title="item.error" @mousedown.stop @click.stop @dblclick.stop>{{ item.error }}</span>
|
||
<LightTooltip v-if="item.errorPosition" :text="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||
<button
|
||
type="button"
|
||
class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive"
|
||
:aria-label="t('editor.locateError', { line: item.errorPosition.line, column: item.errorPosition.column })"
|
||
@mousedown.stop
|
||
@click.stop="locateExecutionSummaryError(item)"
|
||
>
|
||
<LocateFixed class="h-3 w-3" />
|
||
</button>
|
||
</LightTooltip>
|
||
<LightTooltip :text="t('grid.copy')" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||
<button type="button" class="pointer-events-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-destructive/70 hover:bg-destructive/10 hover:text-destructive" :aria-label="t('grid.copy')" @mousedown.stop @click.stop="copyExecutionSummaryError(item.error)">
|
||
<Copy class="h-3 w-3" />
|
||
</button>
|
||
</LightTooltip>
|
||
</div>
|
||
</div>
|
||
<div class="pointer-events-none relative z-[1]">
|
||
<span
|
||
class="inline-flex h-5 items-center gap-1 rounded-full border px-2 text-[10px]"
|
||
:class="{
|
||
'border-primary/35 bg-primary/10 text-primary': item.status === 'running',
|
||
'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300': item.status === 'success',
|
||
'border-destructive/40 bg-destructive/10 text-destructive': item.status === 'error',
|
||
'border-border bg-muted/40 text-muted-foreground': item.status === 'pending' || item.status === 'skipped',
|
||
'border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300': item.status === 'cancelled',
|
||
}"
|
||
>
|
||
<Loader2 v-if="item.status === 'running'" class="h-3 w-3 animate-spin" />
|
||
<Check v-else-if="item.status === 'success'" class="h-3 w-3" />
|
||
<X v-else-if="item.status === 'error'" class="h-3 w-3" />
|
||
<SquareDashed v-else class="h-3 w-3" />
|
||
{{ t(`executionSummary.statuses.${item.status}`) }}
|
||
</span>
|
||
</div>
|
||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.status === "pending" || item.status === "running" || item.status === "skipped" ? "—" : item.rowCount.toLocaleString() }}</div>
|
||
<div class="pointer-events-none relative z-[1] text-right tabular-nums">{{ item.executionTimeMs > 0 || item.status === "success" || item.status === "error" ? `${item.executionTimeMs}ms` : "—" }}</div>
|
||
</div>
|
||
</DynamicScrollerItem>
|
||
</DynamicScroller>
|
||
<div class="shrink-0 px-3 py-2 text-[11px] text-muted-foreground">{{ t("executionSummary.navigationHint") }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="resultMessages" />
|
||
|
||
<template v-else>
|
||
<ElasticsearchJsonResponsePanel v-if="activeElasticsearchJsonResponse" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="activeElasticsearchJsonResponse.status" :body="activeElasticsearchJsonResponse.body" />
|
||
<ElasticsearchJsonResponsePanel v-else-if="showElasticsearchRawJson && activeElasticsearchRawBody" ref="elasticsearchJsonResponsePanelRef" class="flex-1 min-h-0" :status="200" :body="activeElasticsearchRawBody" can-show-table @show-table="showElasticsearchRawJson = false" />
|
||
<DataGrid
|
||
v-else-if="activeTab.result && hasTabularResult"
|
||
ref="dataGridRef"
|
||
:key="activeResultGridInstanceKey"
|
||
:cache-key="activeResultGridCacheKey"
|
||
:column-width-cache-key="activeResultGridColumnWidthCacheKey"
|
||
:pending-state-key="activeResultGridInstanceKey"
|
||
:view-generation="activeTab.resultViewGeneration"
|
||
class="flex-1 min-h-0"
|
||
:result="activeTab.result"
|
||
:sort-column="activeTab.resultSortColumn"
|
||
:sort-column-index="activeTab.resultSortColumnIndex"
|
||
:sort-direction="activeTab.resultSortDirection"
|
||
:sort-mode="activeTab.resultSortMode"
|
||
:initial-order-by-input="activeTab.orderByInput"
|
||
:sql="activeResultSql"
|
||
:export-sql="activeResultExportSql"
|
||
:loading="activeResultIsLoading"
|
||
:editable="!!activeTab.queryAnalysis || !!mongoQueryResultSaveHandler"
|
||
:source-columns="activeTab.querySourceColumns"
|
||
:joined-write-targets="activeTab.queryWriteTargets"
|
||
:readonly-column-indexes="groupedQueryReadonlyColumnIndexes(activeTab)"
|
||
:result-column-comments="activeTab.resultColumnComments"
|
||
:query-display-source-columns="activeTab.queryDisplaySourceColumns"
|
||
:custom-save-handler="mongoQueryResultSaveHandler"
|
||
:mongo-update-target="mongoQueryResultSaveHandler && activeTab.result.mongo_copy_documents?.length === activeTab.result.rows.length ? activeTab.mongoEditTarget : undefined"
|
||
:query-editability-reason="activeTab.queryEditabilityReason"
|
||
:manual-transaction-session-id="activeTab.txnSessionId"
|
||
:ensure-manual-transaction-session="activeTab.autoCommit === false ? () => queryStore.ensureManualTransactionSession(activeTab.id, activeResultDatabase, activeResultSchema) : undefined"
|
||
:on-manual-transaction-mutation="() => queryStore.markManualTransactionDirty(activeTab.id)"
|
||
:allow-insert-rows="activeTab.queryAnalysis?.allowInsert ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
|
||
:allow-delete-rows="activeTab.queryAnalysis?.allowDelete ?? activeTab.queryAnalysis?.allowInsertDelete !== false"
|
||
context="results"
|
||
:auto-transpose-single-row="settingsStore.editorSettings.dataGridAutoTransposeSingleRow"
|
||
:database-type="activeEffectiveDatabaseType"
|
||
:connection-id="activeResultConnectionId"
|
||
:database="activeResultDatabase"
|
||
:schema="activeResultSchema"
|
||
:table-meta="activeTab.tableMeta"
|
||
:table-info-tab="activeTab.tableInfoTab"
|
||
:page-offset="activeTab.resultPageOffset"
|
||
:page-limit="activeTab.resultPageLimit"
|
||
:count-sql="activeTab.resultCountSql"
|
||
:count-total-rows="activeTab.resultCountSql ? () => queryStore.countTabResultRows(activeTab.id) : undefined"
|
||
:total-row-count="activeTab.resultTotalRowCount"
|
||
:total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false"
|
||
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
|
||
:page-jump-progress="activeTab.resultPageJumpProgress"
|
||
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
|
||
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
|
||
:query-result-export-request="
|
||
(options: {
|
||
exportId: string;
|
||
filePath: string;
|
||
format: 'csv' | 'xlsx' | 'json' | 'txt' | 'sql';
|
||
includeSqlSheet?: boolean;
|
||
exportTableName?: string;
|
||
exportColumnTypes?: Array<string | null | undefined>;
|
||
exportColumnExtras?: Array<string | null | undefined>;
|
||
insertMode?: SqlInsertMode;
|
||
}) => queryStore.buildQueryResultExportRequest(activeTab.id, options)
|
||
"
|
||
:all-export-results="allResultExportSheets"
|
||
:export-file-base-name="activeQueryResultExportBaseName"
|
||
:show-cancel="shouldShowCancelAction(activeTab)"
|
||
:cancelling="activeTab.isCancelling"
|
||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||
@cancel="emit('cancel', activeTab.id)"
|
||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
|
||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
|
||
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
|
||
>
|
||
<template #result-toolbar-leading="{ compact }">
|
||
<QueryResultViewSwitcher
|
||
:active-view="activeOutputView"
|
||
:can-show-result="canShowResultOutput"
|
||
:can-show-summary="hasExecutionSummary"
|
||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||
:can-show-messages="canShowMessagesOutput"
|
||
:message-count="resultMessageCount"
|
||
:compact="compact"
|
||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||
/>
|
||
<template v-if="activeElasticsearchRawBody">
|
||
<div class="mx-1 h-4 w-px bg-border" />
|
||
<button
|
||
type="button"
|
||
class="inline-flex h-5 shrink-0 items-center rounded-sm border border-transparent px-2 text-xs leading-none transition-colors"
|
||
:class="showElasticsearchRawJson ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||
:aria-pressed="showElasticsearchRawJson"
|
||
@click="showElasticsearchRawJson = !showElasticsearchRawJson"
|
||
>
|
||
{{ showElasticsearchRawJson ? t("tabs.tableData") : t("redis.jsonView") }}
|
||
</button>
|
||
</template>
|
||
<template v-else-if="canShowProfile">
|
||
<div class="mx-1 h-4 w-px bg-border" />
|
||
<button
|
||
type="button"
|
||
class="inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-transparent px-2 text-xs leading-none transition-colors"
|
||
:class="activeOutputView === 'profile' ? 'bg-secondary text-secondary-foreground' : 'text-muted-foreground hover:text-foreground'"
|
||
:aria-pressed="activeOutputView === 'profile'"
|
||
@click="emit('update:activeOutputView', activeTab.id, activeOutputView === 'profile' ? 'result' : 'profile')"
|
||
>
|
||
<Gauge class="h-3.5 w-3.5" />
|
||
{{ t("profile.title") }}
|
||
</button>
|
||
</template>
|
||
</template>
|
||
<template #result-toolbar-actions="{ compact }">
|
||
<DataGridColumnLayoutPopover :grid="dataGridRef" :compact="compact" />
|
||
<QueryResultToolbarActions
|
||
:active-view="activeOutputView"
|
||
:can-show-explain="canShowExplainOutput"
|
||
:can-show-profile="canShowProfile"
|
||
:can-export-archive="canExportResultArchive"
|
||
:archive-exporting="resultArchiveExporting"
|
||
:compact="compact"
|
||
:has-result="!!activeTab.result"
|
||
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
|
||
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
|
||
@export-archive="exportResultArchive"
|
||
@open-result-view="openPluginResultView"
|
||
/>
|
||
</template>
|
||
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
|
||
<QueryErrorActions
|
||
:error-message="String(errorMessage)"
|
||
:backend-error="activeTab.result.error"
|
||
:connection-id="activeResultConnectionId"
|
||
:error-position="activeResultErrorPosition"
|
||
@locate-error="locateActiveResultError"
|
||
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
|
||
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
|
||
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
|
||
/>
|
||
</template>
|
||
</DataGrid>
|
||
<QueryLoadingState
|
||
v-else-if="!activeTab.result && activeTab.isExecuting"
|
||
class="flex-1 min-h-0"
|
||
:label-key="queryExecutionLabelKey(activeTab)"
|
||
:elapsed-seconds="queryRunningElapsedSeconds"
|
||
show-cancel
|
||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||
:cancelling="activeTab.isCancelling"
|
||
@cancel="emit('cancel', activeTab.id)"
|
||
/>
|
||
<div v-else-if="activeTab.resultEvicted && activeTab.resultCacheState === 'missing'" class="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||
<div>{{ t("grid.cachedResultUnavailable") }}</div>
|
||
<Button v-if="(activeTab.lastExecutedSql ?? activeTab.sql)?.trim()" variant="secondary" size="sm" @click="queryStore.reloadEvictedTab(activeTab.id, { reexecuteOnMissing: true })">
|
||
{{ t("grid.reexecuteQuery") }}
|
||
</Button>
|
||
</div>
|
||
<div v-else-if="!activeTab.result" class="flex-1 min-h-0 flex flex-col items-center justify-center gap-1 text-muted-foreground text-sm">
|
||
<div>{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}</div>
|
||
<div>{{ t("editor.pressToSaveSql", { mod: shortcutModifier }) }}</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</Pane>
|
||
</Splitpanes>
|
||
</template>
|
||
|
||
<!-- Data mode: full-height grid -->
|
||
<template v-else-if="activeTab.mode === 'data'">
|
||
<div class="flex-1 min-h-0 flex flex-col">
|
||
<div ref="dataToolbarRef" class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs overflow-hidden">
|
||
<!-- No fixed max-w cap on these chips: they must flex (truncate)
|
||
so long names only clip when the header row itself runs out
|
||
(#7880). The min-w floor keeps scrollWidth reporting real
|
||
overflow so the measured tiers condense the row before the
|
||
chips collapse. -->
|
||
<span v-if="activeConnection?.name?.trim()" data-data-header-connection class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="activeConnection.name">
|
||
{{ activeConnection.name }}
|
||
</span>
|
||
<span class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/50 px-2 py-0.5 font-medium" :title="activeDataTabTableMeta?.tableName || activeTab.title">
|
||
{{ activeDataTabTableMeta?.tableName || activeTab.title }}
|
||
</span>
|
||
<span class="inline-flex min-w-12 items-center truncate rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground" :title="[activeDataTabTableMeta?.schema, databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t)].filter(Boolean).join('@')">
|
||
<template v-if="activeDataTabTableMeta?.schema">{{ activeDataTabTableMeta.schema }}@</template>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database, t) }}
|
||
</span>
|
||
<span v-if="showDataColumnsChip && activeDataTabTableMeta" class="inline-flex shrink-0 items-center rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground tabular-nums"> {{ activeDataTabTableMeta.columns.length }} {{ t("tree.columns") }} </span>
|
||
<span class="ml-auto" />
|
||
<DataGridColumnLayoutPopover v-if="activeTab.result?.columns.length" :grid="dataGridRef" trigger-class="px-1.5" />
|
||
<Button v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId" variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :class="{ 'bg-accent': dataGridRef?.showDdl }" :title="dataToolbarCompact ? t('grid.tableInfo') : undefined" @click="dataGridRef?.toggleDdl()"
|
||
><TableProperties class="h-3.5 w-3.5" /><span v-if="!dataToolbarCompact">{{ t("grid.tableInfo") }}</span></Button
|
||
>
|
||
<DropdownMenu v-if="activeTab.result && activeDataTabTableMeta && activeTab.connectionId">
|
||
<DropdownMenuTrigger as-child>
|
||
<Button variant="ghost" size="sm" class="h-5 text-xs px-1.5 shrink-0" :title="t('tableToolbox.title')"
|
||
><Toolbox class="h-3.5 w-3.5" /><span v-if="!dataToolbarCompact">{{ t("tableToolbox.title") }}</span></Button
|
||
>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" class="w-max min-w-44 gap-0 overflow-hidden rounded-md border bg-popover p-0 text-popover-foreground shadow-xl">
|
||
<div class="border-b bg-muted/40 px-3 py-2">
|
||
<div class="text-xs font-semibold">{{ t("tableToolbox.title") }}</div>
|
||
</div>
|
||
<div class="p-1">
|
||
<DropdownMenuItem class="gap-2" @click="handleTableDataGenerate">
|
||
<Database class="h-4 w-4" />
|
||
{{ t("tableToolbox.generateData") }}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem v-if="canOpenTableImport" class="gap-2" @click="handleTableImport">
|
||
<Download class="h-4 w-4" />
|
||
{{ t("tableToolbox.importData") }}
|
||
</DropdownMenuItem>
|
||
<DropdownMenuSub>
|
||
<DropdownMenuSubTrigger class="gap-2">
|
||
<Upload class="h-4 w-4" />
|
||
{{ t("tableToolbox.exportData") }}
|
||
</DropdownMenuSubTrigger>
|
||
<DropdownMenuPortal>
|
||
<DropdownMenuSubContent>
|
||
<DropdownMenuItem @click="dataGridRef?.exportCsv()"> CSV </DropdownMenuItem>
|
||
<DropdownMenuItem @click="dataGridRef?.exportJson()"> JSON </DropdownMenuItem>
|
||
<DropdownMenuItem @click="dataGridRef?.exportSql()"> SQL INSERT </DropdownMenuItem>
|
||
<DropdownMenuItem @click="dataGridRef?.exportXlsx()"> XLSX </DropdownMenuItem>
|
||
</DropdownMenuSubContent>
|
||
</DropdownMenuPortal>
|
||
</DropdownMenuSub>
|
||
</div>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
<Popover v-if="activeTab.result?.columns.length" v-model:open="dataGridViewOptionsOpen">
|
||
<PopoverTrigger as-child>
|
||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||
<Wrench class="h-4 w-4" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent align="end" :collision-padding="8" class="w-max min-w-44 max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-2rem)] gap-0 overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-0 text-popover-foreground shadow-xl" @click.stop @keydown.stop>
|
||
<div class="border-b bg-muted/40 px-3 py-2">
|
||
<div class="text-xs font-semibold">{{ t("grid.viewOptions") }}</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<SquareDashed class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.renderMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.renderModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRenderMode === 'canvas' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRenderMode('canvas')"
|
||
>
|
||
{{ t("grid.canvasRenderMode") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRenderMode === 'dom' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRenderMode('dom')"
|
||
>
|
||
{{ t("grid.domRenderMode") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Columns3Cog class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.columnWidth") }}</span>
|
||
</div>
|
||
<div class="grid w-48 grid-cols-3 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
v-for="density in ['compact', 'standard', 'comfortable'] as const"
|
||
:key="density"
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-1.5 text-xs transition-colors"
|
||
:class="columnWidthDensity === density ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setColumnWidthDensity(density)"
|
||
>
|
||
{{ t(`grid.columnWidth${density.charAt(0).toUpperCase()}${density.slice(1)}`) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<DataGridFontFamilyControl />
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-[11px] font-semibold text-muted-foreground">A</span>
|
||
<span>{{ t("grid.tableFontSize") }}</span>
|
||
</div>
|
||
<div class="flex h-6 w-32 items-center rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
|
||
:disabled="tableFontSize <= TABLE_FONT_SIZE_MIN"
|
||
:aria-label="t('common.decrease')"
|
||
@click="decreaseTableFontSize"
|
||
>
|
||
<Minus class="h-3.5 w-3.5" />
|
||
</button>
|
||
<span class="flex-1 text-center text-xs font-semibold tabular-nums">{{ tableFontSize }}</span>
|
||
<button
|
||
type="button"
|
||
class="flex h-5 w-8 items-center justify-center rounded-[5px] bg-background text-foreground shadow-sm transition-colors hover:text-foreground disabled:pointer-events-none disabled:bg-muted/40 disabled:text-muted-foreground disabled:opacity-50 disabled:shadow-none"
|
||
:disabled="tableFontSize >= TABLE_FONT_SIZE_MAX"
|
||
:aria-label="t('common.increase')"
|
||
@click="increaseTableFontSize"
|
||
>
|
||
<Plus class="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.searchMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.searchModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridSearchMode === 'filter' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridSearchMode('filter')"
|
||
>
|
||
{{ t("grid.searchModeFilter") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridSearchMode === 'highlight' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridSearchMode('highlight')"
|
||
>
|
||
{{ t("grid.searchModeHighlight") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Hash class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.rowNumberMode") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.rowNumberModeHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRowNumberMode === 'view' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRowNumberMode('view')"
|
||
>
|
||
{{ t("grid.rowNumberModeView") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRowNumberMode === 'source' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setDataGridRowNumberMode('source')"
|
||
>
|
||
{{ t("grid.rowNumberModeSource") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Rows3 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.transposeMultiRowToggle") }}</span>
|
||
</div>
|
||
<LightTooltip :text="t('grid.transposeMultiRowHint')" side="left" :side-offset="6" :delay="0" :open-on-focus="false">
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="!dataGridRef?.multiRowTranspose ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="dataGridRef?.setMultiRowTranspose(false)"
|
||
>
|
||
{{ t("grid.transposeSingleRow") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="dataGridRef?.multiRowTranspose ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="dataGridRef?.setMultiRowTranspose(true)"
|
||
>
|
||
{{ t("grid.transposeMultiRow") }}
|
||
</button>
|
||
</div>
|
||
</LightTooltip>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<component :is="numericColumnRightAlign ? AlignRight : AlignLeft" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.numericColumnAlign") }}</span>
|
||
</div>
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="!numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setNumericColumnRightAlign(false)"
|
||
>
|
||
{{ t("grid.numericColumnAlignLeft") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="numericColumnRightAlign ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setNumericColumnRightAlign(true)"
|
||
>
|
||
{{ t("grid.numericColumnAlignRight") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<CheckSquare2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.booleanDisplayMode") }}</span>
|
||
</div>
|
||
<div class="grid w-32 grid-cols-2 rounded-md border bg-muted/40 p-0.5">
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="booleanDisplayMode === 'dropdown' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setBooleanDisplayMode('dropdown')"
|
||
>
|
||
{{ t("grid.booleanDisplayDropdown") }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="h-5 min-w-0 truncate whitespace-nowrap rounded-[5px] px-2 text-xs transition-colors"
|
||
:class="booleanDisplayMode === 'checkbox' ? 'bg-background font-semibold text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||
@click="setBooleanDisplayMode('checkbox')"
|
||
>
|
||
{{ t("grid.booleanDisplayCheckbox") }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs">
|
||
<div class="min-w-0 flex items-center gap-2 font-medium">
|
||
<Palette class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
<span>{{ t("grid.colorizeDataTypes") }}</span>
|
||
</div>
|
||
<Switch size="sm" :model-value="colorizeDataGridCellTypes" :aria-label="t('grid.colorizeDataTypes')" @update:model-value="setColorizeDataGridCellTypes" />
|
||
</div>
|
||
<div class="flex items-center justify-between gap-3 px-3 py-1.5 text-xs" :class="{ 'opacity-60': !dataGridRef?.canToggleAllNullColumns }">
|
||
<span class="min-w-0 flex items-center gap-2 font-medium">
|
||
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||
{{ t("grid.hideNullColumns") }}
|
||
<span v-if="(dataGridRef?.allNullColumnCount ?? 0) > 0" class="text-muted-foreground tabular-nums"> ({{ dataGridRef?.allNullColumnCount }}) </span>
|
||
</span>
|
||
<Switch size="sm" :model-value="!!dataGridRef?.nullColumnsHidden" :disabled="!dataGridRef?.canToggleAllNullColumns" :aria-label="t('grid.hideNullColumns')" @update:model-value="dataGridRef?.toggleAllNullColumns()" />
|
||
</div>
|
||
<DataGridCopyFormatControl
|
||
:current-label="dataGridRef?.defaultCopyPreferenceLabel ?? '-'"
|
||
:current-value="dataGridRef?.defaultCopyPreference ?? ''"
|
||
:items="dataGridRef?.copyPreferenceMenuItems ?? []"
|
||
@select="dataGridRef?.setDefaultCopyPreference($event)"
|
||
@configure="openDataGridExtractorConfiguration"
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
<DataGrid
|
||
v-if="activeTab.result"
|
||
ref="dataGridRef"
|
||
class="flex-1 min-h-0"
|
||
:key="activeTab.id"
|
||
:cache-key="activeTab.id"
|
||
:view-generation="activeTab.resultViewGeneration"
|
||
:result="activeTab.result"
|
||
:sort-column="activeTab.resultSortColumn"
|
||
:sort-column-index="activeTab.resultSortColumnIndex"
|
||
:sort-direction="activeTab.resultSortDirection"
|
||
:sort-mode="activeTab.resultSortMode"
|
||
:initial-order-by-input="activeTab.orderByInput"
|
||
:sql="activeTab.sql"
|
||
:loading="activeTab.isExecuting"
|
||
:editable="!activeTab.tableMetaPending && isTableDataEditable(activeEffectiveDatabaseType, activeTableMeta?.primaryKeys ?? [], activeTableMeta?.tableType)"
|
||
context="table-data"
|
||
:initial-where-input="activeTab.whereInput"
|
||
:database-type="activeEffectiveDatabaseType"
|
||
:connection-id="activeTab.connectionId"
|
||
:database="activeTab.database"
|
||
:execution-database="activeDataTabExecutionDatabase"
|
||
:table-meta="activeDataTabTableMeta"
|
||
:table-info-tab="activeTab.tableInfoTab"
|
||
:auto-show-table-info="settingsStore.editorSettings.tableInfoDrawerPinned"
|
||
:page-offset="activeTab.resultPageOffset"
|
||
:page-limit="activeTab.resultPageLimit"
|
||
:total-row-count="activeTab.resultTotalRowCount"
|
||
:total-row-count-is-exact="activeTab.resultTotalRowCount !== undefined || activeTab.result.total_is_exact !== false"
|
||
:total-row-count-loading="activeTab.resultTotalRowCountLoading"
|
||
:on-execute-sql="async (sql: string) => emit('executeSql', activeTab.id, sql)"
|
||
:full-export-result="(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => queryStore.fetchTabResultForExport(activeTab.id, onProgress)"
|
||
:export-file-base-name="activeTab.title"
|
||
:show-cancel="shouldShowCancelAction(activeTab)"
|
||
:cancelling="activeTab.isCancelling"
|
||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||
@cancel="emit('cancel', activeTab.id)"
|
||
@update:where-input="(v: string) => (activeTab.whereInput = v)"
|
||
@update:order-by-input="(v: string) => (activeTab.orderByInput = v)"
|
||
@local-column-filters-change="(filters: Record<string, string[]>) => queryStore.updateDataGridLocalColumnFilters(activeTab.id, filters)"
|
||
@reload="(sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number, intent?: DataGridReloadIntent) => emit('reload', activeTab.id, sql, searchText, whereInput, orderBy, limit, offset, intent)"
|
||
@paginate="(offset: number, limit: number, whereInput?: string, orderBy?: string, appendResult?: boolean) => emit('paginate', activeTab.id, offset, limit, whereInput, orderBy, appendResult)"
|
||
@sort="(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string, mode?: DataGridSortMode) => emit('sort', activeTab.id, column, columnIndex, direction, whereInput, mode)"
|
||
@change-query-timeout="(connectionId: string) => emit('openConnectionSettings', connectionId, 'advanced')"
|
||
>
|
||
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
|
||
<QueryErrorActions
|
||
:error-message="String(errorMessage)"
|
||
:backend-error="activeTab.result.error"
|
||
:connection-id="activeResultConnectionId"
|
||
:error-position="activeResultErrorPosition"
|
||
@locate-error="locateActiveResultError"
|
||
@change-connection-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
|
||
@change-query-timeout="activeResultConnectionId && emit('openConnectionSettings', activeResultConnectionId, 'advanced')"
|
||
@fix-with-ai="(message) => emit('fixWithAi', activeTab.id, message)"
|
||
/>
|
||
</template>
|
||
</DataGrid>
|
||
<QueryLoadingState
|
||
v-else-if="activeTab.isExecuting"
|
||
class="h-full"
|
||
:label-key="queryExecutionLabelKey(activeTab)"
|
||
:elapsed-seconds="queryRunningElapsedSeconds"
|
||
show-cancel
|
||
:cancel-disabled="!canCancelQueryExecution(activeTab)"
|
||
:cancelling="activeTab.isCancelling"
|
||
@cancel="emit('cancel', activeTab.id)"
|
||
/>
|
||
<div v-else class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
|
||
<Inbox class="h-8 w-8 opacity-60" />
|
||
<div>{{ t("grid.dataUnavailable") }}</div>
|
||
<div class="text-xs text-muted-foreground/70 inline-flex items-center gap-1">
|
||
<span>{{ t("grid.dataUnavailableHintPrefix") }}</span>
|
||
<kbd v-for="key in modRKeys" :key="key" class="min-w-5 rounded border border-border/60 bg-muted/50 px-1.5 py-0.5 text-center font-mono text-[12px] leading-none text-muted-foreground shadow-xs">{{ key }}</kbd>
|
||
<span>{{ t("grid.dataUnavailableHintSuffix") }}</span>
|
||
</div>
|
||
<Button variant="outline" size="sm" class="h-7 gap-1.5" @click="reloadUnavailableDataTab()">
|
||
<RefreshCcw class="h-3.5 w-3.5" />
|
||
{{ t("grid.refresh") }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Redis mode: key browser -->
|
||
<template v-else-if="activeTab.mode === 'redis'">
|
||
<div class="flex-1 min-h-0">
|
||
<RedisKeyBrowser ref="redisKeyBrowserRef" :key="`${activeTab.id}:${activeTab.connectionId}:${activeTab.database}`" :connection-id="activeTab.connectionId" :db="Number(activeTab.database)" :block-dangerous-redis-commands="props.blockDangerousRedisCommands" :state-key="activeTab.id" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Redis Dashboard: instance info -->
|
||
<template v-else-if="activeTab.mode === 'redis-dashboard'">
|
||
<div class="flex-1 min-h-0">
|
||
<RedisDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- etcd mode: key browser -->
|
||
<template v-else-if="activeTab.mode === 'etcd'">
|
||
<div class="flex-1 min-h-0">
|
||
<EtcdKeyBrowser ref="etcdKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- etcd Dashboard: cluster observation -->
|
||
<template v-else-if="activeTab.mode === 'etcd-dashboard'">
|
||
<div class="flex-1 min-h-0">
|
||
<EtcdDashboard v-if="canAccessEtcdAdmin" ref="etcdDashboardRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'etcd-access-control'">
|
||
<div class="flex-1 min-h-0">
|
||
<EtcdAccessControl v-if="canAccessEtcdAdmin" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
<div v-else class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground"><ShieldAlert class="h-4 w-4" />{{ t("etcd.adminPermissionRequired") }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'nacos-access-control'">
|
||
<div class="flex-1 min-h-0">
|
||
<NacosAccessControlConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ZooKeeper mode: znode browser -->
|
||
<template v-else-if="activeTab.mode === 'zookeeper'">
|
||
<div class="flex-1 min-h-0">
|
||
<ZooKeeperKeyBrowser ref="zookeeperKeyBrowserRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'consul-overview'">
|
||
<div class="flex-1 min-h-0">
|
||
<ConsulOverview ref="consulOverviewRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Consul management workspace -->
|
||
<template v-else-if="activeTab.mode === 'consul'">
|
||
<div class="flex-1 min-h-0">
|
||
<ConsulWorkspace ref="consulWorkspaceRef" :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Document mode: MongoDB collections and Elasticsearch indices -->
|
||
<template v-else-if="activeTab.mode === 'mongo'">
|
||
<div class="flex-1 min-h-0">
|
||
<DocumentBrowser ref="documentBrowserRef" :key="`${activeTab.id}:${activeTab.sql}`" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" :table-meta="activeTab.tableMeta" :state-key="activeTab.id" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Meilisearch index detail -->
|
||
<template v-else-if="activeTab.mode === 'meilisearch'">
|
||
<div class="flex-1 min-h-0">
|
||
<MeilisearchIndexView :key="activeTab.id" :connection-id="activeTab.connectionId" :index="activeTab.sql" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'meilisearch-system'">
|
||
<div class="flex-1 min-h-0">
|
||
<MeilisearchSystemWorkspace :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
|
||
<div class="flex-1 min-h-0">
|
||
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :state-key="activeTab.id" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'mongo-bucket'">
|
||
<div class="flex-1 min-h-0">
|
||
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" :state-key="activeTab.id" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Vector mode: Qdrant and Milvus collections -->
|
||
<template v-else-if="activeTab.mode === 'vector'">
|
||
<div class="flex-1 min-h-0">
|
||
<VectorBrowser
|
||
:key="activeTab.id"
|
||
:connection-id="activeTab.connectionId"
|
||
:database="activeTab.database"
|
||
:collection="activeTab.sql"
|
||
:collection-label="activeTab.title"
|
||
:database-type="activeEffectiveDatabaseType"
|
||
:dimension="activeTabDimension"
|
||
:tenant="activeVectorConnection?.username"
|
||
:result="activeTab.result"
|
||
@update:result="queryStore.updateTabPageResult(activeTab.id, activeTab.mode, $event, activeTab)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'hbase'">
|
||
<div class="flex-1 min-h-0">
|
||
<HBaseBrowser :key="activeTab.id" :tab-id="activeTab.id" :connection-id="activeTab.connectionId" :namespace="activeTab.database" :table="activeTab.sql" :create-table-on-open="activeTab.hbaseCreateTableOnOpen" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'mq'">
|
||
<div class="flex-1 min-h-0">
|
||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="connectionIsEffectivelyReadOnly(activeConnection)" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'mqtt'">
|
||
<div class="flex-1 min-h-0">
|
||
<MqttAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-topic="activeTab.mqttInitialTopic" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'nacos'">
|
||
<div class="flex-1 min-h-0">
|
||
<NacosAdminConsole
|
||
:key="activeTab.id"
|
||
:connection-id="activeTab.connectionId"
|
||
:namespace="activeTab.nacosNamespace"
|
||
:namespace-name="activeTab.nacosNamespaceName"
|
||
:target-data-id="activeTab.nacosTargetDataId"
|
||
:target-group="activeTab.nacosTargetGroup"
|
||
:target-keyword="activeTab.nacosTargetKeyword"
|
||
:target-request-id="activeTab.nacosTargetRequestId"
|
||
:read-only="connectionIsEffectivelyReadOnly(activeConnection)"
|
||
:zen-mode="props.zenMode"
|
||
@toggle-zen-mode="emit('toggleZenMode')"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- plugin-workbench tabs render in the always-mounted layer in App.vue
|
||
(v-show visibility): remounting the webview per tab switch would
|
||
reload the plugin iframe and drop its live session state. -->
|
||
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
|
||
<div class="flex h-full min-h-0 flex-col">
|
||
<PluginFilesystemTab
|
||
ref="pluginFilesystemTabRef"
|
||
:key="activeTab.id"
|
||
:plugin-id="activeTab.pluginFilesystem.pluginId"
|
||
:provider-id="activeTab.pluginFilesystem.providerId"
|
||
:connection-id="activeTab.connectionId || undefined"
|
||
:root-uri="activeTab.pluginFilesystem.rootUri"
|
||
:initial-uri="activeTab.pluginFilesystem.currentUri"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
|
||
<div class="min-w-0 flex-1 min-h-0">
|
||
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Objects mode: virtualized database object browser -->
|
||
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
|
||
<div class="min-w-0 flex-1 min-h-0">
|
||
<ObjectBrowser
|
||
ref="objectBrowserRef"
|
||
:key="`${activeTab.id}-${activeTab.objectBrowser?.schema || ''}`"
|
||
:connection="activeConnection"
|
||
:database="activeTab.database"
|
||
:catalog="activeTab.objectBrowser?.catalog"
|
||
:schema="activeTab.objectBrowser?.schema"
|
||
:initial-event-name="activeTab.objectBrowser?.eventName"
|
||
:initial-event-read-only="activeTab.objectBrowser?.eventReadOnly"
|
||
:initial-event-open-request-id="activeTab.objectBrowser?.eventOpenRequestId"
|
||
:initial-event-create-request-id="activeTab.objectBrowser?.eventCreateRequestId"
|
||
:initial-object-filter="activeTab.objectBrowser?.initialObjectFilter"
|
||
:selected-object-filter="activeTab.objectBrowser?.filter"
|
||
:initial-search-query="activeTab.objectBrowser?.searchQuery"
|
||
:viewport="activeTab.objectBrowser?.viewport"
|
||
@open-table="emit('openObjectTable', activeTab.id, $event)"
|
||
@schema-change="emit('objectSchemaChange', activeTab.id, $event)"
|
||
@viewport-change="emit('objectBrowserViewportChange', activeTab.id, $event)"
|
||
@search-change="emit('objectBrowserSearchChange', activeTab.id, $event)"
|
||
@filter-change="emit('objectBrowserFilterChange', activeTab.id, $event)"
|
||
@add-to-ai="emit('addObjectTableToAi', activeTab.id, $event)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Structure mode: table structure editor -->
|
||
<template v-else-if="activeTab.mode === 'structure'">
|
||
<div class="flex-1 min-h-0">
|
||
<TableStructureEditor
|
||
ref="tableStructureEditorRef"
|
||
:key="`${activeTab.id}-${activeTab.structureTableName || 'new'}`"
|
||
:connection-id="activeTab.connectionId"
|
||
:database="activeTab.database"
|
||
:catalog="activeTab.catalog"
|
||
:schema="activeTab.schema"
|
||
:table-name="activeTab.structureTableName || ''"
|
||
:initial-tab="activeTab.structureInitialTab"
|
||
:initial-tab-request-id="activeTab.structureInitialTabRequestId"
|
||
:initial-target="activeTab.structureInitialTarget"
|
||
:draft="activeTab.structureDraft"
|
||
@update:draft="(draft) => (activeTab.structureDraft = draft)"
|
||
@saved="(commentChanged, createdTableName) => emit('structureEditorSaved', activeTab.id, commentChanged, createdTableName)"
|
||
@close="emit('structureEditorClose', activeTab.id)"
|
||
@open-settings="(initialTab, initialSection) => emit('openSettings', initialTab, initialSection)"
|
||
@view-data="onHandleStructureViewData"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'users' && activeConnection">
|
||
<DatabaseUserAdmin :key="activeTab.id" :connection="activeConnection" />
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'processlist' && activeConnection">
|
||
<ProcessListPanel :key="activeTab.id" :connection="activeConnection" />
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'sqlserver-trace' && activeConnection">
|
||
<SqlServerActivityTracePanel :key="activeTab.id" :connection="activeConnection" :tab-id="activeTab.id" />
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'mysql-dashboard'">
|
||
<div class="min-h-0 flex-1">
|
||
<MySqlDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" :client-session-id="activeTab.id" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'postgres-dashboard'">
|
||
<div class="min-h-0 flex-1">
|
||
<PostgresDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'xugu-dashboard'">
|
||
<div class="min-h-0 flex-1">
|
||
<XuguServerDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'nacos-dashboard'">
|
||
<div class="min-h-0 flex-1">
|
||
<NacosDashboard :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'solr-admin'">
|
||
<div class="min-h-0 flex-1">
|
||
<SolrAdmin :key="activeTab.id" :connection-id="activeTab.connectionId" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'dolt-version-control'">
|
||
<div class="min-h-0 flex-1">
|
||
<DoltVersionControl :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :initial-branch="activeTab.workspaceBranch" />
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
|
||
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'dameng-users' && activeConnection">
|
||
<DamengUserAdmin :key="activeTab.id" :connection="activeConnection" />
|
||
</template>
|
||
|
||
<template v-else-if="activeTab.mode === 'dameng-roles' && activeConnection">
|
||
<DamengRoleAdmin :key="activeTab.id" :connection="activeConnection" />
|
||
</template>
|
||
|
||
<Dialog v-model:open="resultRunRenameOpen">
|
||
<DialogContent class="sm:max-w-[400px]">
|
||
<DialogHeader>
|
||
<DialogTitle>{{ t("tabs.renameResultRun") }}</DialogTitle>
|
||
</DialogHeader>
|
||
<Input v-model="resultRunRenameTitle" data-result-run-name-input :aria-label="t('tabs.resultRunName')" maxlength="120" @keydown.enter.prevent="saveResultRunRename" />
|
||
<DialogFooter>
|
||
<Button variant="outline" @click="resultRunRenameOpen = false">{{ t("common.cancel") }}</Button>
|
||
<Button :disabled="!resultRunRenameTitle.trim()" @click="saveResultRunRename">{{ t("common.save") }}</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.query-output-splitpanes {
|
||
isolation: isolate;
|
||
}
|
||
|
||
.production-session-shell--active {
|
||
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--destructive) 78%, transparent);
|
||
}
|
||
|
||
.production-session-strip {
|
||
background-image: linear-gradient(90deg, color-mix(in oklch, var(--destructive) 14%, transparent), color-mix(in oklch, var(--destructive) 7%, transparent));
|
||
}
|
||
|
||
.query-output-splitpanes :deep(> .splitpanes__splitter) {
|
||
z-index: 1;
|
||
flex: 0 0 3px;
|
||
}
|
||
|
||
.production-watermark {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||
gap: 3rem;
|
||
overflow: hidden;
|
||
padding: 3rem 2.5rem;
|
||
}
|
||
|
||
.production-watermark__label {
|
||
align-self: center;
|
||
justify-self: center;
|
||
transform: rotate(-22deg);
|
||
}
|
||
|
||
@media (max-width: 700px) {
|
||
.production-watermark {
|
||
grid-template-columns: 1fr;
|
||
gap: 1.5rem;
|
||
padding-inline: 1rem;
|
||
}
|
||
}
|
||
|
||
.result-tab-scroll::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
.result-tab-scrollbar {
|
||
position: absolute;
|
||
inset-inline: 0.5rem;
|
||
bottom: 2px;
|
||
z-index: 20;
|
||
height: 8px;
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
}
|
||
|
||
.result-tab-scrollbar::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset-inline: 0;
|
||
top: 3px;
|
||
height: 2px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 10%, transparent);
|
||
}
|
||
|
||
.result-tab-scrollbar__thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
height: 4px;
|
||
min-width: 20px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklch, var(--foreground) 38%, transparent);
|
||
transition:
|
||
height 120ms ease,
|
||
background-color 120ms ease,
|
||
top 120ms ease;
|
||
}
|
||
|
||
.result-tab-scrollbar:hover .result-tab-scrollbar__thumb,
|
||
.result-tab-scrollbar--dragging .result-tab-scrollbar__thumb {
|
||
top: 1px;
|
||
height: 6px;
|
||
background: color-mix(in oklch, var(--foreground) 58%, transparent);
|
||
}
|
||
</style>
|