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:
1 parent
76625c6ac5
commit
20c5136a40
15 files changed
+323
-25
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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>
|
||||
|
||||
+132
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
Reference in new issue
Block a user