diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 7c64b79a0c..231a40b676 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -72,6 +72,7 @@ import { buildMongoUpdateDocument, formatMongoShellLiteral, type MongoInputValue import type { SqlExecutionOverride } from "@/lib/sql/sqlExecutionTarget"; import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort"; import { useTabScroll } from "@/composables/useTabScroll"; +import { nextResultToolbarLayout } from "@/lib/tabs/resultToolbarLayout"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import type { CustomSaveHandler } from "@/composables/useDataGridEditor"; import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database"; @@ -152,7 +153,7 @@ const emit = defineEmits<{ openConnectionSettings: [connectionId: string, initialTab: "advanced"]; }>(); -const { t } = useI18n(); +const { t, locale } = useI18n(); const queryStore = useQueryStore(); const connectionStore = useConnectionStore(); const settingsStore = useSettingsStore(); @@ -184,6 +185,7 @@ const columnInfoLoading = ref(false); const columnInfoError = ref(undefined); const dataGridRef = ref(); const queryEditorRef = ref>(); +const resultToolbarRef = ref(null); const resultTabsScrollerRef = ref(null); const columnVisibilitySearch = ref(""); const columnVisibilityOptions = computed(() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? []); @@ -323,6 +325,80 @@ const hasTabularResult = computed(() => { return visibleResultItems.value.length > 0; }); const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting); +const resultToolbarCompact = ref(false); +const resultToolbarExpandedRequiredWidth = ref(); +let resultToolbarResizeObserver: ResizeObserver | undefined; +let resultToolbarLayoutFrame = 0; + +function updateResultToolbarLayout() { + const toolbar = resultToolbarRef.value; + const tabs = resultTabsScrollerRef.value; + if (!toolbar || !tabs) return; + + const next = nextResultToolbarLayout({ + resultCount: visibleResultItems.value.length, + compact: resultToolbarCompact.value, + expandedRequiredWidth: resultToolbarExpandedRequiredWidth.value, + toolbarWidth: toolbar.clientWidth, + tabsViewportWidth: tabs.clientWidth, + tabsContentWidth: tabs.scrollWidth, + }); + const compactChanged = next.compact !== resultToolbarCompact.value; + resultToolbarCompact.value = next.compact; + resultToolbarExpandedRequiredWidth.value = next.expandedRequiredWidth; + if (compactChanged) { + nextTick(() => { + updateResultTabsScrollbar(); + scheduleResultToolbarLayout(); + }); + } +} + +function scheduleResultToolbarLayout() { + if (resultToolbarLayoutFrame) return; + resultToolbarLayoutFrame = window.requestAnimationFrame(() => { + resultToolbarLayoutFrame = 0; + updateResultToolbarLayout(); + }); +} + +function resetResultToolbarLayout() { + resultToolbarCompact.value = false; + resultToolbarExpandedRequiredWidth.value = undefined; + nextTick(() => { + updateResultTabsScrollbar(); + scheduleResultToolbarLayout(); + }); +} + +function observeResultToolbarLayout() { + resultToolbarResizeObserver?.disconnect(); + resultToolbarResizeObserver = undefined; + const toolbar = resultToolbarRef.value; + const tabs = resultTabsScrollerRef.value; + if (typeof ResizeObserver !== "undefined" && toolbar && tabs) { + resultToolbarResizeObserver = new ResizeObserver(scheduleResultToolbarLayout); + resultToolbarResizeObserver.observe(toolbar); + resultToolbarResizeObserver.observe(tabs); + } + scheduleResultToolbarLayout(); +} + +const resultToolbarLayoutSignature = computed(() => + JSON.stringify({ + locale: locale.value, + results: visibleResultItems.value.map((item) => [item.index, item.label, item.n]), + outputView: props.activeOutputView, + canExportResultArchive: canExportResultArchive.value, + hasResult: !!props.activeTab.result, + hasTabularResult: hasTabularResult.value, + autoRefreshEnabled: queryResultAutoRefreshEnabled.value, + autoRefreshInterval: queryResultAutoRefreshIntervalSeconds.value, + }), +); + +watch([resultToolbarRef, resultTabsScrollerRef], observeResultToolbarLayout, { flush: "post" }); +watch(resultToolbarLayoutSignature, resetResultToolbarLayout, { flush: "post" }); type MongoQueryGridChanges = { dirtyRows: Map>; deletedRows: Set; @@ -424,6 +500,8 @@ watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.qu onUnmounted(() => { stopQueryRunningElapsedTimer(); stopQueryResultAutoRefreshTimer(); + resultToolbarResizeObserver?.disconnect(); + if (resultToolbarLayoutFrame) window.cancelAnimationFrame(resultToolbarLayoutFrame); window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser); }); @@ -786,7 +864,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
-
+
-
- - +
+ + + + + + - - + + + + + + - - - - - - - - - {{ queryResultAutoRefreshEnabled ? t("tabs.stopAutoRefresh") : t("tabs.startAutoRefresh") }} - - - - - {{ t("tabs.autoRefreshEvery", { seconds }) }} - - - + + + + + + + + + + + + + {{ queryResultAutoRefreshEnabled ? t("tabs.stopAutoRefresh") : t("tabs.startAutoRefresh") }} + + + + + {{ t("tabs.autoRefreshEvery", { seconds }) }} + + + +
- + + +
diff --git a/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts b/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts new file mode 100644 index 0000000000..c72131c5b0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/queryResultSource.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { queryResultSourceLabel } from "@/lib/sql/queryResultSource"; + +describe("queryResultSourceLabel", () => { + it("uses the current database for an unqualified table", () => { + expect(queryResultSourceLabel("SELECT * FROM users", { database: "app", databaseType: "mysql" })).toBe("app.users"); + }); + + it("prefers the final explicit qualifier", () => { + expect(queryResultSourceLabel("SELECT * FROM analytics.events", { database: "app", databaseType: "mysql" })).toBe("analytics.events"); + expect(queryResultSourceLabel("SELECT * FROM [ServerOne].[AppDb].[dbo].[Users]", { database: "fallback", databaseType: "sqlserver" })).toBe("dbo.Users"); + }); + + it("preserves quoted identifier names", () => { + expect(queryResultSourceLabel('SELECT * FROM "Sales"."Orders"', { database: "app", databaseType: "postgres" })).toBe("Sales.Orders"); + expect(queryResultSourceLabel("SELECT * FROM HR.EMPLOYEES", { database: "fallback", databaseType: "oracle" })).toBe("HR.EMPLOYEES"); + }); + + it("labels aggregate joins with the first outer physical table", () => { + const sql = `SELECT + g.group_name AS GROUP_NAME, + COUNT(*) AS USER_COUNT, + SUM(CASE WHEN u.status = 1 THEN 1 ELSE 0 END) AS ACTIVE_USER_COUNT + FROM groups g + JOIN group_users gu ON gu.group_id = g.id + JOIN users u ON u.id = gu.user_id + GROUP BY g.id, g.group_name`; + + expect(queryResultSourceLabel(sql, { database: "aaa", databaseType: "mysql" })).toBe("aaa.groups"); + }); + + it("prefers mutation targets over other table sources", () => { + expect(queryResultSourceLabel("UPDATE users SET active = true FROM audit_events ae WHERE ae.user_id = users.id RETURNING users.id", { database: "app", databaseType: "postgres" })).toBe("app.users"); + expect(queryResultSourceLabel("INSERT INTO audit.events (id) VALUES (1) RETURNING id", { database: "app", databaseType: "postgres" })).toBe("audit.events"); + expect(queryResultSourceLabel("DELETE FROM users WHERE id = 1 RETURNING id", { database: "app", databaseType: "postgres" })).toBe("app.users"); + }); + + it("ignores non-physical outer sources", () => { + expect(queryResultSourceLabel("WITH recent AS (SELECT * FROM orders) SELECT * FROM recent", { database: "app", databaseType: "postgres" })).toBeUndefined(); + expect(queryResultSourceLabel("SELECT * FROM (SELECT * FROM users) nested", { database: "app", databaseType: "mysql" })).toBeUndefined(); + expect(queryResultSourceLabel("SELECT * FROM read_csv('users.csv') csv", { database: "main", databaseType: "duckdb" })).toBeUndefined(); + expect(queryResultSourceLabel("SELECT 1", { database: "app", databaseType: "mysql" })).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/resultToolbarLayout.spec.ts b/apps/desktop/src/lib/__tests__/tabs/resultToolbarLayout.spec.ts new file mode 100644 index 0000000000..c25aa7e90f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/resultToolbarLayout.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { nextResultToolbarLayout } from "@/lib/tabs/resultToolbarLayout"; + +describe("nextResultToolbarLayout", () => { + it("never compacts fewer than two results", () => { + expect(nextResultToolbarLayout({ resultCount: 1, compact: true, expandedRequiredWidth: 900, toolbarWidth: 700, tabsViewportWidth: 100, tabsContentWidth: 300 })).toEqual({ compact: false }); + }); + + it("keeps the expanded layout when tabs fit", () => { + expect(nextResultToolbarLayout({ resultCount: 2, compact: false, toolbarWidth: 800, tabsViewportWidth: 300, tabsContentWidth: 300 })).toEqual({ compact: false }); + }); + + it("compacts on overflow and records the expanded width requirement", () => { + expect(nextResultToolbarLayout({ resultCount: 3, compact: false, toolbarWidth: 800, tabsViewportWidth: 240, tabsContentWidth: 300 })).toEqual({ compact: true, expandedRequiredWidth: 860 }); + }); + + it("stays compact after compact controls remove the current overflow", () => { + expect(nextResultToolbarLayout({ resultCount: 3, compact: true, expandedRequiredWidth: 860, toolbarWidth: 850, tabsViewportWidth: 320, tabsContentWidth: 300 })).toEqual({ compact: true, expandedRequiredWidth: 860 }); + }); + + it("expands only after the required width and hysteresis are available", () => { + expect(nextResultToolbarLayout({ resultCount: 3, compact: true, expandedRequiredWidth: 860, toolbarWidth: 867, tabsViewportWidth: 337, tabsContentWidth: 300 })).toEqual({ compact: true, expandedRequiredWidth: 860 }); + expect(nextResultToolbarLayout({ resultCount: 3, compact: true, expandedRequiredWidth: 860, toolbarWidth: 868, tabsViewportWidth: 338, tabsContentWidth: 300 })).toEqual({ compact: false }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts index 5c98945a90..dec05adc45 100644 --- a/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/tabPresentation.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { queryResultBaseSql, queryResultExecutionSql } from "@/lib/tabs/tabPresentation"; +import { queryResultBaseSql, queryResultExecutionSql, tabularResultItems } from "@/lib/tabs/tabPresentation"; import type { QueryTab } from "@/types/database"; function queryTab(overrides: Partial): QueryTab { @@ -51,3 +51,36 @@ describe("query result SQL selection", () => { expect(queryResultExecutionSql(tab)).toBe("SELECT * FROM dbo.second ORDER BY id DESC"); }); }); + +describe("query result labels", () => { + it("uses source labels while keeping the full statement as the title", () => { + const [item] = tabularResultItems([ + { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + sourceLabel: "app.users", + sourceStatement: "SELECT * FROM users", + }, + ]); + + expect(item?.label).toBe("app.users"); + expect(item?.title).toBe("SELECT * FROM users"); + }); + + it("does not expose SQL text as a visible fallback label", () => { + const [item] = tabularResultItems([ + { + columns: ["value"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + sourceStatement: "SELECT 1", + }, + ]); + + expect(item?.label).toBeUndefined(); + expect(item?.title).toBe("SELECT 1"); + }); +}); diff --git a/apps/desktop/src/lib/sql/queryResultSource.ts b/apps/desktop/src/lib/sql/queryResultSource.ts new file mode 100644 index 0000000000..6cf8f03099 --- /dev/null +++ b/apps/desktop/src/lib/sql/queryResultSource.ts @@ -0,0 +1,24 @@ +import { buildSqlSemanticModel } from "@/lib/sql/semantic/model"; +import type { SqlSemanticRowSource } from "@/lib/sql/semantic/types"; +import type { DatabaseType } from "@/types/database"; + +export interface QueryResultSourceLabelOptions { + database?: string; + databaseType?: DatabaseType; +} + +function firstSourceOfKind(sources: SqlSemanticRowSource[], kind: SqlSemanticRowSource["kind"]): SqlSemanticRowSource | undefined { + return sources.filter((source) => source.kind === kind).sort((left, right) => left.sourceSpan.start - right.sourceSpan.start)[0]; +} + +export function queryResultSourceLabel(sql: string, options: QueryResultSourceLabelOptions = {}): string | undefined { + const statement = sql.trim(); + if (!statement) return undefined; + + const model = buildSqlSemanticModel(statement, statement.length, { databaseType: options.databaseType }); + const source = firstSourceOfKind(model.rowSources, "mutation_target") ?? firstSourceOfKind(model.rowSources, "table"); + if (!source?.name) return undefined; + + const qualifier = source.qualifierParts[source.qualifierParts.length - 1]?.trim() || options.database?.trim(); + return qualifier ? `${qualifier}.${source.name}` : source.name; +} diff --git a/apps/desktop/src/lib/tabs/resultToolbarLayout.ts b/apps/desktop/src/lib/tabs/resultToolbarLayout.ts new file mode 100644 index 0000000000..2b1156693e --- /dev/null +++ b/apps/desktop/src/lib/tabs/resultToolbarLayout.ts @@ -0,0 +1,35 @@ +const OVERFLOW_TOLERANCE_PX = 1; +const EXPAND_HYSTERESIS_PX = 8; + +export interface ResultToolbarLayoutState { + compact: boolean; + expandedRequiredWidth?: number; +} + +export interface ResultToolbarLayoutInput extends ResultToolbarLayoutState { + resultCount: number; + toolbarWidth: number; + tabsViewportWidth: number; + tabsContentWidth: number; +} + +export function nextResultToolbarLayout(input: ResultToolbarLayoutInput): ResultToolbarLayoutState { + if (input.resultCount < 2) return { compact: false }; + if (input.toolbarWidth <= 0 || input.tabsViewportWidth <= 0) { + return { compact: input.compact, expandedRequiredWidth: input.expandedRequiredWidth }; + } + + if (input.compact) { + if (input.expandedRequiredWidth === undefined || input.toolbarWidth >= input.expandedRequiredWidth + EXPAND_HYSTERESIS_PX) { + return { compact: false }; + } + return { compact: true, expandedRequiredWidth: input.expandedRequiredWidth }; + } + + const overflowWidth = input.tabsContentWidth - input.tabsViewportWidth; + if (overflowWidth <= OVERFLOW_TOLERANCE_PX) return { compact: false }; + return { + compact: true, + expandedRequiredWidth: input.toolbarWidth + overflowWidth, + }; +} diff --git a/apps/desktop/src/lib/tabs/tabPresentation.ts b/apps/desktop/src/lib/tabs/tabPresentation.ts index c6ad882ed1..2e5f066c89 100644 --- a/apps/desktop/src/lib/tabs/tabPresentation.ts +++ b/apps/desktop/src/lib/tabs/tabPresentation.ts @@ -138,12 +138,8 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v return lines; } -export function queryResultStatementLabel(result: Pick, maxLength = 48): string | undefined { - if (result.sourceLabel) return result.sourceLabel; - const statement = result.sourceStatement?.replace(/\s+/g, " ").trim(); - if (!statement) return undefined; - if (statement.length <= maxLength) return statement; - return `${statement.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +export function queryResultStatementLabel(result: Pick): string | undefined { + return result.sourceLabel; } export function resultSqlForGrid(tab: Pick): string { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index ee2a9c726d..90473a2fc7 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -6,7 +6,7 @@ import type { DatabaseType, ObjectBrowserViewport, QueryResult, QueryTab, TableI import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/diagram/explainPlan"; -import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; +import { allEditableColumnsWriteable, allPrimaryKeysPresent, sourceColumnsForResult, type EditableQueryInfo, type EditableQuerySource } from "@/lib/sql/sqlAnalysis"; import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence"; import { evaluateMongoAggregateSafety, @@ -36,6 +36,7 @@ import { loadTableMetadata } from "@/lib/metadata/tableMetadataCache"; import { quoteTableIdentifier } from "@/lib/table/tableSelectSql"; import { connectionUsesDatabaseObjectTreeMode, connectionUsesSchemaExecutionContext, effectiveDatabaseTypeForConnection, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; +import { queryResultSourceLabel } from "@/lib/sql/queryResultSource"; import { sortDataGridRows, type DataGridSortDirection } from "@/lib/dataGrid/dataGridSort"; import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; @@ -115,30 +116,20 @@ function markQueryResultRunsRowsRaw(resultRuns: NonNullable 1) return undefined; - const qualifier = analysis.schema || database?.trim(); - return qualifier ? `${qualifier}.${analysis.tableName}` : analysis.tableName; -} - function annotateQueryResultSources(results: QueryResult[], sql: string, database: string | undefined, databaseType?: DatabaseType): QueryResult[] { const statements = splitSqlStatementRanges(sql, databaseType); let statementIndex = 0; for (const result of results) { const statement = statements[statementIndex++]; if (!statement) continue; - annotateQueryResultSource(result, statement.sql, database); + annotateQueryResultSource(result, statement.sql, database, databaseType); } return results; } -function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string): QueryResult { +function annotateQueryResultSource(result: QueryResult, sourceStatement: string, database?: string, databaseType?: DatabaseType): QueryResult { result.sourceStatement = sourceStatement; - const label = queryResultSourceLabel(sourceStatement, database); + const label = databaseType ? queryResultSourceLabel(sourceStatement, { database, databaseType }) : undefined; if (label) result.sourceLabel = label; return result; } diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index e20de12531..8f3830b90b 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -3903,12 +3903,12 @@ test("query results keep readable table source labels with active database conte await store.executeTabSql(tabId, "select u.id from users u join orders o on o.user_id = u.id"); tab = store.tabs.find((item) => item.id === tabId); - assert.equal(tab?.result?.sourceLabel, undefined); + assert.equal(tab?.result?.sourceLabel, "db.users"); assert.equal(tab?.result?.sourceStatement, "select u.id from users u join orders o on o.user_id = u.id"); await store.executeTabSql(tabId, "update users set active = true; select * from users"); tab = store.tabs.find((item) => item.id === tabId); - assert.equal(tab?.results?.[0]?.sourceLabel, undefined); + assert.equal(tab?.results?.[0]?.sourceLabel, "db.users"); assert.equal(tab?.results?.[0]?.sourceStatement, "update users set active = true"); assert.equal(tab?.results?.[1]?.sourceLabel, "db.users"); assert.equal(tab?.results?.[1]?.sourceStatement, "select * from users"); diff --git a/packages/app-tests/tabPresentation.test.ts b/packages/app-tests/tabPresentation.test.ts index 73078b3345..27294bc233 100644 --- a/packages/app-tests/tabPresentation.test.ts +++ b/packages/app-tests/tabPresentation.test.ts @@ -185,7 +185,7 @@ test("tabular result items expose source labels when available", () => { tabularResultItems(results).map((item) => ({ index: item.index, n: item.n, label: item.label, title: item.title })), [ { index: 1, n: 1, label: "public.users", title: "select * from public.users" }, - { index: 2, n: 2, label: "select id, name, email, created_at from users...", title: "select id, name, email, created_at from users where active = true order by created_at desc" }, + { index: 2, n: 2, label: undefined, title: "select id, name, email, created_at from users where active = true order by created_at desc" }, ], ); assert.deepEqual(