feat(query): 查询结果显示来源连接颜色 (#10823)

* feat(query): show source connection colors in results

* fix(query): omit absent result source connection metadata

* ci: retrigger checks after runner timeout

* fix(ci): increase heap budget for frontend type checking

* fix(ci): retry transient Windows installer bundling failures

* test(query): await lazy component imports before teardown

* test: adapt result color and CI coverage to latest main
This commit is contained in:
CSXFanMeng authored and GitHub committed 2026-10-20 00:19:22 +08:00
1 parent 76625c6ac5
commit 20c5136a40
15 files changed
+323 -25

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
param(
[ValidateRange(1, 5)][int]$MaxAttempts = 3,
[ValidateRange(0, 120)][int]$RetryDelaySeconds = 10
)
$ErrorActionPreference = 'Stop'
# Native failures are checked explicitly so transient downloads can be retried.
$PSNativeCommandUseErrorActionPreference = $false
$bundleDir = 'target/x86_64-win7-windows-msvc/release/bundle/nsis'
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json --config .github/fixtures/win7-nsis-zlib-config.json
$bundleExitCode = $LASTEXITCODE
if ($bundleExitCode -eq 0) { break }
if ($attempt -eq $MaxAttempts) {
throw "Windows 7 installer bundling failed after $MaxAttempts attempts (exit code $bundleExitCode)."
}
Write-Warning "Installer bundling attempt $attempt failed (exit code $bundleExitCode); retrying in $RetryDelaySeconds seconds."
Start-Sleep -Seconds $RetryDelaySeconds
}
$installer = Get-ChildItem -LiteralPath $bundleDir -Filter '*.exe' -File |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
throw "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
}
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
@@ -0,0 +1,65 @@
$ErrorActionPreference = 'Stop'
$bundleScript = Join-Path $PSScriptRoot 'bundle-win7-installer.ps1'
$tempBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
$testRoot = Join-Path $tempBase ('dbx-win7-bundle-test-' + [Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($testRoot) | Out-Null
$originalLocation = Get-Location
function Assert-True($Condition, [string]$Message) { if (!$Condition) { throw $Message } }
function pnpm {
$global:DbxBundleTestState.Attempts++
$global:DbxBundleTestState.Commands += ($args -join ' ')
$global:LASTEXITCODE = if ($global:DbxBundleTestState.Attempts -le $global:DbxBundleTestState.Failures) { 1 } else { 0 }
if ($LASTEXITCODE -eq 0 -and $global:DbxBundleTestState.EmitInstaller) {
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
[IO.File]::WriteAllText((Join-Path $global:DbxBundleTestState.BundleDir 'dbx-test.exe'), 'fixture installer')
}
}
function New-Fixture([int]$Failures, [bool]$EmitInstaller = $true) {
$directory = Join-Path $testRoot ([Guid]::NewGuid().ToString('N'))
[IO.Directory]::CreateDirectory($directory) | Out-Null
Set-Location -LiteralPath $directory
$global:DbxBundleTestState = @{
BundleDir = Join-Path $directory 'target/x86_64-win7-windows-msvc/release/bundle/nsis'
Attempts = 0
Commands = @()
Failures = $Failures
EmitInstaller = $EmitInstaller
}
$global:LASTEXITCODE = 0
}
try {
foreach ($failures in @(0, 2)) {
New-Fixture $failures
$hash = & $bundleScript -RetryDelaySeconds 0
Assert-True ($global:DbxBundleTestState.Attempts -eq ($failures + 1)) 'Unexpected retry count'
Assert-True ($hash.Algorithm -eq 'SHA256' -and $hash.Hash.Length -eq 64) 'Installer hash missing'
foreach ($command in $global:DbxBundleTestState.Commands) {
Assert-True ($command -eq 'tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json --config .github/fixtures/win7-nsis-zlib-config.json') 'Bundle command changed'
}
}
New-Fixture 99
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
[IO.File]::WriteAllText((Join-Path $global:DbxBundleTestState.BundleDir 'old.exe'), 'stale installer')
$failure = $null
try { & $bundleScript -RetryDelaySeconds 0 } catch { $failure = $_ }
Assert-True ($global:DbxBundleTestState.Attempts -eq 3) 'Persistent failure was not bounded'
Assert-True ($failure -and $failure.ToString().Contains('failed after 3 attempts')) 'A stale installer hid the failed bundle'
New-Fixture 0 $false
[IO.Directory]::CreateDirectory($global:DbxBundleTestState.BundleDir) | Out-Null
$failure = $null
try { & $bundleScript -RetryDelaySeconds 0 } catch { $failure = $_ }
Assert-True ($failure -and $failure.ToString().Contains('Missing Windows 7 fixed-runtime installer')) 'Missing installer was accepted'
Assert-True ($global:DbxBundleTestState.Attempts -eq 1) 'Successful bundling was retried'
Write-Host 'Windows 7 bundling retry tests passed (4 scenarios).'
} finally {
Set-Location -LiteralPath $originalLocation.Path
$resolvedRoot = (Resolve-Path -LiteralPath $testRoot).ProviderPath
if ($resolvedRoot -ne $testRoot -or !$resolvedRoot.StartsWith($tempBase, [StringComparison]::OrdinalIgnoreCase)) {
throw 'Refusing to remove a fixture outside the temporary directory.'
}
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
}
+15
View File
@@ -147,6 +147,21 @@ test("frontend tests use two shards on separate runners", () => {
assert.doesNotMatch(job("frontend-typecheck"), /vitest|oxfmt|oxlint/);
});
test("frontend type checking retains its configured heap budget", () => {
const content = job("frontend-typecheck");
assert.match(content, /NODE_OPTIONS: --max-old-space-size=8192/);
assert.match(content, /name: Type-check frontend\s+run: pnpm exec vue-tsc --noEmit --project apps\/desktop\/tsconfig\.json/);
});
test("Windows 7 bundling uses the tested retry script and still audits the installer", () => {
const content = job("windows-win7-bundle");
assert.ok(content.includes("run: ./.github/scripts/bundle-win7-installer.ps1"));
assert.doesNotMatch(content, /bundle-win7-installer\.test\.ps1/);
assert.ok(content.includes("assert-win7-installer-content.ps1"));
assert.ok(content.indexOf("bundle-win7-installer.ps1") < content.indexOf("assert-win7-installer-content.ps1"));
assert.ok(job("github-scripts").includes("run: ./.github/scripts/bundle-win7-installer.test.ps1"));
});
test("every old Agent stage has an independent owner and Java packaging remains strict", () => {
assert.ok(job("agent-checks").includes("python3 -m unittest discover"));
assert.ok(job("agent-checks").includes("python3 scripts/validate_agents.py"));
+5 -13
View File
@@ -178,6 +178,10 @@ jobs:
shell: pwsh
run: ./.github/scripts/install-mcp-powershell.test.ps1
- name: Windows installer bundling tests
shell: pwsh
run: ./.github/scripts/bundle-win7-installer.test.ps1
packages:
needs: changes
if: needs.changes.outputs.packages == 'true'
@@ -660,19 +664,7 @@ jobs:
- name: Bundle Windows 7 fixed-runtime test installer with zlib
shell: pwsh
run: |
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc `
--config src-tauri/tauri.webview2-win7-fixed.conf.json `
--config .github/fixtures/win7-nsis-zlib-config.json
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (!$installer) {
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
exit 1
}
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
run: ./.github/scripts/bundle-win7-installer.ps1
- name: Audit Windows 7 installer contents
shell: pwsh
@@ -14,6 +14,7 @@ import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
import { batchResultInsertRequest } from "@/lib/query/queryResultBatchInsert";
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import type { CSSProperties } from "vue";
import { hexToRgba } from "@/lib/common/color";
import { useI18n } from "vue-i18n";
import { provideTabUiState } from "@/lib/tabs/tabUiState";
import { shouldPinResultRunOnClick } from "@/lib/tabs/resultRunPin";
@@ -530,6 +531,21 @@ const activeQueryError = computed(() => {
return String(result.rows[0]?.[0] ?? "");
});
const hasQueryOutput = computed(() => tabHasQueryOutput(props.activeTab));
function resultRunConnectionId(runId?: string) {
const run = props.activeTab.resultRuns?.find((item) => item.id === runId);
return run?.multiDbExecution?.kind === "multi-db" ? run.multiDbExecution.target.connectionId : (run?.connectionId ?? props.activeTab.connectionId);
}
function resultRunColor(runId?: string) {
return connectionStore.getConfig(resultRunConnectionId(runId))?.color || "";
}
function resultRunColorStyle(runId: string, active: boolean): CSSProperties | undefined {
const color = resultRunColor(runId);
return color ? { borderColor: hexToRgba(color, active ? 0.72 : 0.35), backgroundColor: hexToRgba(color, active ? 0.14 : 0.05) } : undefined;
}
const activeResultColor = computed(() => (hasQueryOutput.value ? resultRunColor(props.activeTab.activeResultRunId) : ""));
// 结果集页签/列表的名称是否带库名,由编辑器设置控制(默认带库名)
const includeResultSourceDatabase = computed(() => settingsStore.editorSettings.showResultSourceDatabase);
const resultTabNamingMode = computed(() => settingsStore.editorSettings.resultTabNamingMode);
@@ -2089,7 +2105,7 @@ defineExpose({
</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">
<div data-query-result-surface class="h-full flex flex-col border border-transparent" :style="activeResultColor ? { borderColor: activeResultColor } : undefined">
<!--
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
@@ -2127,6 +2143,7 @@ defineExpose({
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'"
:style="resultRunColorStyle(run.id, run.active)"
@contextmenu="onContextMenu"
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
>
@@ -2142,6 +2159,7 @@ defineExpose({
@keydown="onResultRunTabKeydown($event, runIndex)"
@mousedown.middle.stop.prevent="removeResultRun(run.id)"
>
<span v-if="resultRunColor(run.id)" data-result-connection-color class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: resultRunColor(run.id) }" aria-hidden="true" />
<Pin v-if="run.pinned" class="h-3 w-3 shrink-0 fill-current text-primary" />
{{ run.title || run.sourceLabel || resultRunFallbackLabel(run.sequence) }}
</button>
@@ -2163,6 +2181,7 @@ defineExpose({
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 max-w-48 gap-1 px-2 text-xs">
<span v-if="activeResultColor" data-result-connection-color class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: activeResultColor }" aria-hidden="true" />
<span class="min-w-0 truncate">{{ activeResultRunItem ? activeResultRunItem.title || activeResultRunItem.sourceLabel || resultRunFallbackLabel(activeResultRunItem.sequence) : t("tabs.resultRuns") }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
</Button>
@@ -2170,6 +2189,7 @@ defineExpose({
<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" @mousedown.middle.stop.prevent="removeResultRun(run.id)">
<span v-if="resultRunColor(run.id)" data-result-connection-color class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: resultRunColor(run.id) }" aria-hidden="true" />
<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" />
@@ -2198,6 +2218,7 @@ defineExpose({
:can-export-xlsx="activeOutputView === 'result' && redisResultViewMode === 'grid' && !!activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse"
:active-index="activeTab.activeResultIndex ?? 0"
:active="activeOutputView === 'result'"
:connection-color="activeResultColor"
:display-mode="resultRunDisplayMode"
:has-sibling-run-tabs="resultRuns.length > 0"
@select="selectResultItem"
@@ -18,6 +18,7 @@ const props = withDefaults(
canExportXlsx?: boolean;
displayMode?: "tabs" | "list";
hasSiblingRunTabs?: boolean;
connectionColor?: string;
}>(),
{
displayMode: "tabs",
@@ -132,6 +133,7 @@ watch(
:title="activeItem?.label || activeItem?.title || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
:aria-label="activeItem?.displayLabel || activeItem?.label || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
>
<span v-if="connectionColor" data-result-connection-color class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: connectionColor }" aria-hidden="true" />
<span class="truncate">{{ activeItem?.displayLabel || activeItem?.label || (activeItem ? t("tabs.resultN", { n: activeItem.n }) : t("tabs.resultSets")) }}</span>
<ChevronDown class="h-3.5 w-3.5 shrink-0 opacity-70" />
</Button>
@@ -148,6 +150,7 @@ watch(
:title="activeItem?.label || activeItem?.title || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
:aria-label="activeItem?.displayLabel || activeItem?.label || (activeItem ? t('tabs.resultN', { n: activeItem.n }) : t('tabs.resultSets'))"
>
<span v-if="connectionColor" data-result-connection-color class="mr-1 h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: connectionColor }" aria-hidden="true" />
<span class="truncate">{{ activeItem?.displayLabel || activeItem?.label || (activeItem ? t("tabs.resultN", { n: activeItem.n }) : t("tabs.resultSets")) }}</span>
</Button>
</div>
@@ -157,6 +160,7 @@ watch(
<div ref="scroller" class="result-set-scroll flex h-full items-center gap-1 overflow-x-auto overflow-y-hidden px-1" @scroll="updateScrollButtons" @wheel="onTabsWheel">
<LightTooltip v-for="item in items" :key="item.index" :text="item.label || item.title || t('tabs.resultN', { n: item.n })" :delay="150" :close-delay="0" nowrap>
<Button size="sm" :variant="active && activeIndex === item.index ? 'default' : 'ghost'" class="h-6 max-w-48 shrink-0 px-2 text-xs" :data-active="active && activeIndex === item.index ? 'true' : undefined" :aria-pressed="active && activeIndex === item.index" @click="select(item)">
<span v-if="connectionColor" data-result-connection-color class="mr-1 h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: connectionColor }" aria-hidden="true" />
<span class="truncate">{{ item.displayLabel || item.label || t("tabs.resultN", { n: item.n }) }}</span>
</Button>
</LightTooltip>
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { createApp, h, nextTick, reactive } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { QueryResult, QueryTab } from "@/types/database";
vi.mock("@/components/editor/QueryEditor.vue", () => ({ __esModule: true, default: { render: () => null } }));
vi.mock("@/composables/useToolbarOverflow", async () => {
const { ref } = await import("vue");
return { useToolbarOverflow: () => ({ tier: ref(0) }) };
});
vi.mock("@/lib/tabs/tabResultCache", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/tabs/tabResultCache")>()),
writeTabResultSnapshot: vi.fn().mockResolvedValue(false),
}));
vi.mock("@/components/grid/DataGrid.vue", () => ({ __esModule: true, default: { render: () => h("div", { "data-test": "grid" }) } }));
import ContentArea from "../ContentArea.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
const cleanups: Array<() => void> = [];
afterEach(async () => {
// Keep happy-dom alive until ContentArea's lazy child imports have settled.
await vi.dynamicImportSettled();
cleanups.splice(0).forEach((cleanup) => cleanup());
document.body.replaceChildren();
window.localStorage?.clear();
});
async function mountResults(withRuns = true) {
const pinia = createPinia();
setActivePinia(pinia);
const connections = useConnectionStore();
connections.connections = [
{ id: "test", name: "Test", db_type: "sqlite", color: "#22c55e" },
{ id: "production", name: "Production", db_type: "sqlite", color: "#ef4444" },
];
const result: QueryResult = { columns: ["id"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
const tab = reactive<QueryTab>({
id: "query",
title: "Query",
connectionId: "test",
database: "app",
mode: "query",
sql: "SELECT 1",
isExecuting: false,
result,
results: [result],
activeResultRunId: withRuns ? "test-run" : undefined,
resultRuns: withRuns
? [
{ id: "test-run", connectionId: "test", title: "Test result", customTitle: true, sequence: 1, sql: "SELECT 1", createdAt: 1, result, results: [result] },
{ id: "production-run", title: "Production result", customTitle: true, sequence: 2, sql: "SELECT 1", createdAt: 2, result, results: [result], multiDbExecution: { kind: "multi-db", batchId: "batch", target: { connectionId: "production", database: "app" }, status: "success" } },
]
: undefined,
});
useQueryStore().tabs.push(tab);
const host = document.createElement("div");
document.body.append(host);
const app = createApp({
render: () =>
h(ContentArea, {
activeTab: tab,
activeConnection: connections.getConfig(tab.connectionId),
activeOutputView: "result",
executableSql: "",
formatSqlRequest: null,
compressSqlRequest: null,
selectedSql: "",
cursorPos: 0,
resultOnly: true,
blockDangerousRedisCommands: false,
}),
});
app.use(pinia);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
cleanups.push(() => app.unmount());
await nextTick();
await nextTick();
return { host, tab, connections, settings: useSettingsStore() };
}
describe("query result connection colors", () => {
it("captures the source connection when retaining an ordinary query result", async () => {
const { tab } = await mountResults(false);
useQueryStore().toggleResultAutoSave(tab.id);
expect(tab.resultRuns?.[0]?.connectionId).toBe("test");
tab.connectionId = "production";
await nextTick();
expect(tab.resultRuns?.[0]?.connectionId).toBe("test");
});
it("colors each run from its source and keeps the selected result color after changing the editor connection", async () => {
const { host, tab, connections } = await mountResults();
const markers = [...host.querySelectorAll<HTMLElement>("[data-result-run-tab] [data-result-connection-color]")];
expect(markers.map((marker) => marker.style.backgroundColor)).toEqual(["#22c55e", "#ef4444"]);
const surface = host.querySelector<HTMLElement>("[data-query-result-surface]")!;
expect(surface.style.borderColor).toBe("#22c55e");
tab.connectionId = "production";
await nextTick();
expect(surface.style.borderColor).toBe("#22c55e");
tab.activeResultRunId = "production-run";
await nextTick();
expect(surface.style.borderColor).toBe("#ef4444");
connections.connections[1]!.color = "#3b82f6";
await nextTick();
expect(surface.style.borderColor).toBe("#3b82f6");
connections.connections[1]!.color = "";
await nextTick();
expect(surface.style.borderColor).toBe("");
});
it("colors ordinary result sets without retained runs and removes markers for an uncolored connection", async () => {
const { host, connections } = await mountResults(false);
expect(host.querySelector<HTMLElement>(".result-set-scroll [data-result-connection-color]")?.style.backgroundColor).toBe("#22c55e");
connections.connections[0]!.color = "";
await nextTick();
expect(host.querySelector("[data-result-connection-color]")).toBeNull();
expect(host.querySelector<HTMLElement>("[data-query-result-surface]")!.style.borderColor).toBe("");
});
it("shows the selected source color in list mode", async () => {
const { host, tab, settings } = await mountResults();
settings.editorSettings.resultRunDisplayMode = "list";
tab.activeResultRunId = "production-run";
await nextTick();
expect(host.querySelector<HTMLElement>("[data-query-result-surface] [data-result-connection-color]")?.style.backgroundColor).toBe("#ef4444");
});
});
@@ -12,7 +12,7 @@ afterEach(() => {
document.body.replaceChildren();
});
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1, hasSiblingRunTabs = false) {
async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true, displayMode: "tabs" | "list" = "tabs", activeIndex = 1, hasSiblingRunTabs = false, connectionColor?: string) {
// Include a non-tabular result so ordinal and storage index are different.
const results = inputResults ?? ([{ columns: [], rows: [] }, ...Array.from({ length: 100 }, (_, i) => ({ columns: ["value"], rows: [[i + 1]], sourceStatement: `SELECT ${i + 1} AS value` }))] as QueryResult[]);
const select = vi.fn();
@@ -21,7 +21,7 @@ async function mountNavigator(inputResults?: QueryResult[], canExportXlsx = true
const exportXlsx = vi.fn();
const container = document.createElement("div");
document.body.append(container);
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, hasSiblingRunTabs, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app = createApp(ResultSetNavigator, { items: tabularResultItems(results), activeIndex, active: true, canExportXlsx, displayMode, hasSiblingRunTabs, connectionColor, onSelect: select, onCopySql: copySql, onCopyQuerySql: copyQuerySql, onExportXlsx: exportXlsx });
app.use(
createI18n({
legacy: false,
@@ -133,12 +133,21 @@ describe("large result-set navigation", () => {
["Copy as SQL", "copySql"],
["Copy queries", "copyQuerySql"],
["Export XLSX", "exportXlsx"],
] as const)("%s only includes selected results matching the batch search", async (action, event) => {
const mounted = await mountNavigator([
{ columns: [], rows: [] },
{ columns: ["id"], rows: [[1]], sourceLabel: "apis", sourceStatement: "SELECT * FROM apis" },
{ columns: ["id"], rows: [[2]], sourceLabel: "menus", sourceStatement: "SELECT * FROM menus WHERE enabled = true" },
]);
] as const)("%s preserves connection colors and only includes selected results matching the batch search", async (action, event) => {
const mounted = await mountNavigator(
[
{ columns: [], rows: [] },
{ columns: ["id"], rows: [[1]], sourceLabel: "apis", sourceStatement: "SELECT * FROM apis" },
{ columns: ["id"], rows: [[2]], sourceLabel: "menus", sourceStatement: "SELECT * FROM menus WHERE enabled = true" },
],
true,
"tabs",
1,
false,
"#ef4444",
);
const markers = [...mounted.container.querySelectorAll<HTMLElement>("[data-result-connection-color]")];
expect(markers.map((marker) => marker.style.backgroundColor)).toEqual(["#ef4444", "#ef4444"]);
const { popup, filter } = await openBatchMenu(mounted.container);
await filter("APIS");
expect(popup.querySelectorAll('input[type="checkbox"]')).toHaveLength(1);
@@ -152,6 +161,7 @@ describe("large result-set navigation", () => {
expect(mounted[event]).toHaveBeenCalledOnce();
expect(mounted[event].mock.calls[0]?.[0].map((item: { index: number }) => item.index)).toEqual([2]);
expect(mounted.select).not.toHaveBeenCalled();
expect(markers.every((marker) => marker.isConnected && marker.style.backgroundColor === "#ef4444")).toBe(true);
});
it("disables XLSX export when the result view is inactive", async () => {
@@ -191,11 +201,12 @@ describe("large result-set navigation", () => {
});
it("renders a dropdown selector instead of horizontal tabs when displayMode is list", async () => {
const { container, select } = await mountNavigator(undefined, true, "list");
const { container, select } = await mountNavigator(undefined, true, "list", 1, false, "#ef4444");
expect(container.querySelector(".result-set-scroll")).toBeNull();
const trigger = [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Result 1"))!;
expect(trigger).toBeTruthy();
expect(trigger.querySelector("svg")).toBeTruthy();
expect(trigger.querySelector<HTMLElement>("[data-result-connection-color]")?.style.backgroundColor).toBe("#ef4444");
// No separate "All results (100)" button in list mode
expect([...container.querySelectorAll("button")].some((button) => button.textContent?.includes("All results"))).toBe(false);
@@ -213,10 +224,11 @@ describe("large result-set navigation", () => {
it("renders a single result item without dropdown chevron in list mode when there is only one result", async () => {
const single: QueryResult = { columns: ["id"], rows: [[1]], sourceStatement: "SELECT 1" };
const { container } = await mountNavigator([single], true, "list", 0);
const { container } = await mountNavigator([single], true, "list", 0, false, "#ef4444");
expect(container.querySelector(".result-set-scroll")).toBeNull();
const button = container.querySelector<HTMLButtonElement>("button")!;
expect(button.textContent?.trim()).toBe("Result 1");
expect(button.querySelector<HTMLElement>("[data-result-connection-color]")?.style.backgroundColor).toBe("#ef4444");
// No dropdown chevron
expect(button.querySelector("svg")).toBeNull();
});
@@ -248,8 +260,11 @@ describe("large result-set navigation", () => {
});
it("constrains width with shrink-0 max-w-[50%] when hasSiblingRunTabs is true", async () => {
const { container } = await mountNavigator(undefined, true, "tabs", 1, true);
const { container } = await mountNavigator(undefined, true, "tabs", 1, true, "#ef4444");
const region = container.querySelector("[data-result-set-tabs-region]")!;
const markers = [...region.querySelectorAll<HTMLElement>("[data-result-connection-color]")];
expect(markers).toHaveLength(100);
expect(markers.every((marker) => marker.style.backgroundColor === "#ef4444")).toBe(true);
expect(region.classList.contains("shrink-0")).toBe(true);
expect(region.classList.contains("max-w-[50%]")).toBe(true);
expect(region.classList.contains("flex-1")).toBe(false);
@@ -21,6 +21,25 @@ function roundTrip(tabs: QueryTab[]) {
}
describe("openTabsPersistence originalSql round-trip", () => {
it("preserves retained result source connections independently of the editor connection", () => {
const base = { title: "Result", sequence: 1, sql: "SELECT 1", createdAt: 1 };
const [restored] = roundTrip([
queryTab({
connectionId: "editor",
resultRuns: [
{ ...base, id: "ordinary", connectionId: "test" },
{ ...base, id: "multi", connectionId: "editor", multiDbExecution: { kind: "multi-db", batchId: "batch", target: { connectionId: "production", database: "app" }, status: "success" } },
{ ...base, id: "legacy" },
{ ...base, id: "explicit-undefined", connectionId: undefined },
],
}),
]);
expect(restored.connectionId).toBe("editor");
expect(restored.resultRuns?.map((run) => run.connectionId)).toEqual(["test", "production", undefined, undefined]);
expect(restored.resultRuns?.[2]).not.toHaveProperty("connectionId");
expect(restored.resultRuns?.[3]).not.toHaveProperty("connectionId");
});
it("preserves per-tab output view state across a round-trip", () => {
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false } })]);
@@ -6,6 +6,7 @@ export const ACTIVE_TAB_STORAGE_KEY = "dbx-active-tab";
export interface SavedQueryResultRun {
id: string;
connectionId?: string;
title: string;
sequence: number;
sql: string;
@@ -232,6 +233,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
? {
resultRuns: tab.resultRuns.map((run) => ({
id: run.id,
...(run.multiDbExecution?.kind === "multi-db" ? { connectionId: run.multiDbExecution.target.connectionId } : run.connectionId !== undefined ? { connectionId: run.connectionId } : {}),
title: run.title,
sequence: run.sequence,
sql: run.sql,
+3
View File
@@ -2369,6 +2369,7 @@ export const useQueryStore = defineStore("query", () => {
const primaryResult = tab.results?.[0] ?? tab.result;
const run: NonNullable<QueryTab["resultRuns"]>[number] = {
id: uuid(),
connectionId: tab.connectionId,
title: options.title ?? `Run ${sequence}`,
customTitle: !!options.title,
sequence,
@@ -2488,6 +2489,7 @@ export const useQueryStore = defineStore("query", () => {
const run = {
...tab.resultRuns[index],
...(sql ? { sql } : {}),
connectionId: sql ? tab.connectionId : tab.resultRuns[index].connectionId,
result: tab.result,
results: tab.results,
activeResultIndex: tab.activeResultIndex,
@@ -2814,6 +2816,7 @@ export const useQueryStore = defineStore("query", () => {
// for each result run, without tracking the potentially large payload.
resultRuns: t.resultRuns?.map((run) => ({
id: run.id,
connectionId: run.connectionId,
title: run.title,
sequence: run.sequence,
sql: run.sql,
+2
View File
@@ -1598,6 +1598,8 @@ export interface QueryResultSourceColumnRef {
export interface QueryResultRun {
id: string;
/** Source connection retained when the editor switches to another connection. */
connectionId?: string;
title: string;
sequence: number;
sql: string;
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB