fix(query): redirect to result view when live-message query returns rows

This commit is contained in:
lxk955
2026-10-18 08:34:56 +08:00
committed by GitHub
parent 95eb27dab1
commit de00ff46e4
5 changed files with 273 additions and 33 deletions
@@ -210,7 +210,7 @@ import { loadQueryResultTransferSourceDdl } from "@/components/transfer/queryRes
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryMessage, QueryResult, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
import type { QueryMessage, QueryResult, QueryTab, RedisResultViewMode, TableInfoTab, TabOutputView, 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";
@@ -871,6 +871,14 @@ function setRedisResultViewMode(mode: RedisResultViewMode) {
queryStore.updateTabUiState(props.activeTab.id, { redisResultViewMode: mode });
}
function selectOutputView(tabId: string, view: TabOutputView) {
queryStore.updateTabUiState(tabId, {
activeOutputView: view,
autoSwitchedToMessagesDuringExecution: false,
});
emit("update:activeOutputView", tabId, view);
}
function observeStandaloneResultToolbar() {
standaloneResultToolbarResizeObserver?.disconnect();
standaloneResultToolbarResizeObserver = undefined;
@@ -1106,6 +1114,10 @@ watch(
() => props.activeTab.liveQueryMessages?.length ?? 0,
(count, previousCount) => {
if (count > 0 && !previousCount && props.activeTab.isExecuting && !props.editorOnly && !hasTabularResult.value && (props.activeOutputView === "result" || props.activeOutputView === "summary")) {
queryStore.updateTabUiState(props.activeTab.id, {
activeOutputView: "messages",
autoSwitchedToMessagesDuringExecution: true,
});
emit("update:activeOutputView", props.activeTab.id, "messages");
}
},
@@ -1126,6 +1138,7 @@ watch(
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");
return;
}
},
{ immediate: true },
@@ -2539,7 +2552,7 @@ defineExpose({
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="standaloneResultToolbarCompact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-view="selectOutputView(activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<QueryResultToolbarActions
@@ -2551,8 +2564,8 @@ defineExpose({
: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')"
@select-explain="selectOutputView(activeTab.id, 'explain')"
@select-profile="selectOutputView(activeTab.id, 'profile')"
@export-archive="exportResultArchive"
@open-result-view="openPluginResultView"
/>
@@ -2809,7 +2822,7 @@ defineExpose({
:result-mode="redisResultViewMode"
:message-count="resultMessageCount"
:compact="compact"
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
@select-view="selectOutputView(activeTab.id, $event)"
@select-result-mode="setRedisResultViewMode"
/>
<template v-if="activeElasticsearchRawBody">
@@ -2831,7 +2844,7 @@ defineExpose({
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')"
@click="selectOutputView(activeTab.id, activeOutputView === 'profile' ? 'result' : 'profile')"
>
<Gauge class="h-3.5 w-3.5" />
{{ t("profile.title") }}
@@ -2847,8 +2860,8 @@ defineExpose({
: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')"
@select-explain="selectOutputView(activeTab.id, 'explain')"
@select-profile="selectOutputView(activeTab.id, 'profile')"
@export-archive="exportResultArchive"
@open-result-view="openPluginResultView"
/>
@@ -7,7 +7,7 @@ import type { QueryResult, QueryTab } from "@/types/database";
vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } }));
vi.mock("@/components/transfer/QueryResultTransferDialog.vue", () => ({ __esModule: true, default: { render: () => null } }));
vi.mock("@/components/grid/DataGrid.vue", () => ({ default: { render: () => h("div", { "data-test": "data-grid" }) } }));
vi.mock("@/components/grid/DataGrid.vue", () => ({ __esModule: true, default: { render: () => h("div", { "data-test": "data-grid" }) } }));
import ContentArea from "../ContentArea.vue";
import { useConnectionStore } from "@/stores/connectionStore";
@@ -95,4 +95,34 @@ describe("live server output", () => {
expect(host.textContent).toContain("running output");
expect(host.querySelector('[data-test="data-grid"]')).toBeNull();
});
it("marks autoSwitchedToMessagesDuringExecution in tab uiState when live messages arrive (#11822)", async () => {
const { state, tab } = await mountResults([], 0);
tab.isExecuting = true;
await nextTick();
tab.liveQueryMessages = [{ severity: "INFO", message: "function info message" }];
await nextTick();
expect(state.view).toBe("messages");
expect(useQueryStore().tabs.find((t) => t.id === tab.id)?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(true);
});
it("clears autoSwitchedToMessagesDuringExecution when user explicitly selects an output view (#11822)", async () => {
const { host, state, tab } = await mountResults([], 0);
tab.isExecuting = true;
await nextTick();
tab.liveQueryMessages = [{ severity: "INFO", message: "function info message" }];
await nextTick();
expect(state.view).toBe("messages");
expect(useQueryStore().tabs.find((t) => t.id === tab.id)?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(true);
tab.result = { columns: ["val"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 };
tab.results = [tab.result];
await nextTick();
const buttons = host.querySelectorAll<HTMLButtonElement>("[data-query-result-view-switcher] button");
buttons[0]?.click();
await nextTick();
expect(state.view).toBe("result");
expect(useQueryStore().tabs.find((t) => t.id === tab.id)?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(false);
});
});
@@ -778,7 +778,10 @@ SELECT @value AS Message;`;
// Mirrors the real order of events: the notice is surfaced live and the view moves to
// Messages, then the statement finishes with a tabular result.
activeOutputView.value = "messages";
if (activeTab.value) activeTab.value.result = { columns: ["log_and_return"], rows: [["done"]], affected_rows: 0, execution_time_ms: 1, messages: [{ severity: "NOTICE", message: "working" }] };
if (activeTab.value) {
activeTab.value.uiState = { ...activeTab.value.uiState, autoSwitchedToMessagesDuringExecution: true };
activeTab.value.result = { columns: ["log_and_return"], rows: [["done"]], affected_rows: 0, execution_time_ms: 1, messages: [{ severity: "NOTICE", message: "working" }] };
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
@@ -793,6 +796,7 @@ SELECT @value AS Message;`;
expect(activeTab.value?.result?.rows).toEqual([["done"]]);
expect(activeOutputView.value).toBe("result");
expect(activeTab.value?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(false);
});
it("keeps Messages selected when a completed run produced no tabular result", async () => {
@@ -853,6 +857,180 @@ SELECT @value AS Message;`;
expect(activeOutputView.value).toBe("messages");
});
it("redirects from messages to result when tabular data is produced after auto-switching (#11822)", async () => {
const sql = "SELECT * FROM test_func_with_info()";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
vi.spyOn(useQueryStore(), "executeCurrentSql").mockImplementation(async () => {
activeOutputView.value = "messages";
if (activeTab.value) {
activeTab.value.uiState = { ...activeTab.value.uiState, autoSwitchedToMessagesDuringExecution: true };
activeTab.value.result = {
columns: ["val"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
messages: [{ severity: "INFO", message: "function info message" }],
};
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("result");
expect(activeTab.value?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(false);
});
it("does not redirect from messages when query fails with error result (#11822)", async () => {
const sql = "SELECT * FROM test_func_with_error()";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
vi.spyOn(useQueryStore(), "executeCurrentSql").mockImplementation(async () => {
activeOutputView.value = "messages";
if (activeTab.value) {
activeTab.value.uiState = { ...activeTab.value.uiState, autoSwitchedToMessagesDuringExecution: true };
activeTab.value.result = {
columns: ["Error"],
execution_error: true,
rows: [["function exception"]],
affected_rows: 0,
execution_time_ms: 1,
messages: [{ severity: "INFO", message: "function info message" }],
};
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("messages");
expect(activeTab.value?.uiState?.autoSwitchedToMessagesDuringExecution).toBe(false);
});
it("does not redirect when user manually switched to messages mid-run (#11822)", async () => {
const sql = "SELECT * FROM test_func_with_info()";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
vi.spyOn(useQueryStore(), "executeCurrentSql").mockImplementation(async () => {
activeOutputView.value = "messages";
if (activeTab.value) {
// User manually selected messages mid-run, so autoSwitchedToMessagesDuringExecution is false
activeTab.value.uiState = { ...activeTab.value.uiState, autoSwitchedToMessagesDuringExecution: false };
activeTab.value.result = {
columns: ["val"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
};
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("messages");
});
it("honors multiStatementDefaultView when redirecting multi-statement execution (#11822)", async () => {
const sql = "SELECT 1; SELECT 2;";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("result");
const settingsStore = useSettingsStore();
settingsStore.editorSettings.multiStatementDefaultView = "summary";
vi.spyOn(useQueryStore(), "executeCurrentSql").mockImplementation(async () => {
activeOutputView.value = "messages";
if (activeTab.value) {
activeTab.value.uiState = { ...activeTab.value.uiState, autoSwitchedToMessagesDuringExecution: true };
activeTab.value.result = {
columns: ["val"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
};
activeTab.value.results = [activeTab.value.result];
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute();
expect(activeOutputView.value).toBe("summary");
});
it("redirects background tab uiState to result when query finishes while tab is inactive (#11822)", async () => {
const sql = "SELECT * FROM test_func_with_info()";
const tab1: QueryTab = { ...queryTab("app"), id: "tab-1", sql, uiState: { activeOutputView: "messages", autoSwitchedToMessagesDuringExecution: true } };
const tab2: QueryTab = { ...queryTab("app"), id: "tab-2", sql: "SELECT 2", uiState: { activeOutputView: "chart" } };
const queryStore = useQueryStore();
queryStore.tabs = [tab1, tab2];
const activeTab = ref<QueryTab | undefined>(tab2);
const activeConnection = ref<ConnectionConfig | undefined>(connection("postgres"));
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages">("chart");
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async (_sql, options) => {
const targetTab = queryStore.tabs.find((t) => t.id === options?.tabId);
if (targetTab) {
targetTab.uiState = { ...targetTab.uiState, activeOutputView: "messages", autoSwitchedToMessagesDuringExecution: true };
targetTab.result = {
columns: ["val"],
rows: [[1]],
affected_rows: 0,
execution_time_ms: 1,
};
}
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => sql),
activeOutputView,
});
await execution.tryExecute(undefined, { tabId: tab1.id });
// Active tab (tab2) output view should remain untouched
expect(activeOutputView.value).toBe("chart");
// Background tab (tab1) uiState should have redirected to "result"
expect(tab1.uiState?.activeOutputView).toBe("result");
expect(tab1.uiState?.autoSwitchedToMessagesDuringExecution).toBe(false);
});
it("forwards execute-in-new-result-tab intent to the query store", async () => {
const sql = "SELECT * FROM users";
const activeTab = ref<QueryTab | undefined>({ ...queryTab("app"), sql });
+40 -23
View File
@@ -224,6 +224,13 @@ export function useSqlExecution(deps: {
return deps.activeTab.value?.id === tabId ? deps.activeTab.value : undefined;
}
function updateExecutionTabUiState(tab: QueryTab, patch: Partial<NonNullable<QueryTab["uiState"]>>) {
queryStore.updateTabUiState(tab.id, patch);
if (!queryStore.tabs.some((candidate) => candidate.id === tab.id)) {
tab.uiState = { ...tab.uiState, ...patch };
}
}
function resolveExecutionConnection(tabId: string | undefined): ConnectionConfig | undefined {
const tab = resolveExecutionTab(tabId);
return (tab ? connectionStore.getConfig(tab.connectionId) : undefined) ?? deps.activeConnection.value;
@@ -480,6 +487,7 @@ export function useSqlExecution(deps: {
if (deps.activeTab.value?.id === executionTabId) {
deps.activeOutputView.value = redisConsoleSelected ? "result" : statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
}
updateExecutionTabUiState(tab, { autoSwitchedToMessagesDuringExecution: false });
const connName = executionConnection?.name || "";
const start = Date.now();
const isRedis = executionDatabaseType === "redis";
@@ -497,53 +505,62 @@ export function useSqlExecution(deps: {
cancelEditorViewportRequest(options.editorViewportRequestId);
return notStarted("busy", executionTabId);
}
const currentTab = resolveExecutionTab(executionTabId) ?? tab;
const executionTabStillActive = deps.activeTab.value?.id === executionTabId;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? tab.results?.findIndex((result) => result.server_message === true) : undefined;
const sqlServerMessageResultIndex = executionDatabaseType === "sqlserver" ? currentTab.results?.findIndex((result) => result.server_message === true) : undefined;
if (sqlServerMessageResultIndex !== undefined && sqlServerMessageResultIndex >= 0) {
focusSqlServerDataResult(tab.id, executionDatabaseType, tab);
focusSqlServerDataResult(currentTab.id, executionDatabaseType, currentTab);
if (executionTabStillActive) {
deps.activeOutputView.value = tab.result?.server_message === true ? "messages" : "result";
deps.activeOutputView.value = currentTab.result?.server_message === true ? "messages" : "result";
}
} else if (executionDatabaseType === "sqlserver" && tab.result?.server_message === true) {
} else if (executionDatabaseType === "sqlserver" && currentTab.result?.server_message === true) {
if (executionTabStillActive) {
deps.activeOutputView.value = "messages";
}
} else if (tab.result && !tab.result.columns.length && !tab.results?.some((result) => result.columns.length > 0)) {
} else if (currentTab.result && !currentTab.result.columns.length && !currentTab.results?.some((result) => result.columns.length > 0)) {
if (executionTabStillActive && deps.activeOutputView.value !== "messages") {
deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(tab.result) : "summary";
deps.activeOutputView.value = statementCount === 1 ? defaultViewForResult(currentTab.result) : "summary";
}
} else if (currentTab.uiState?.autoSwitchedToMessagesDuringExecution) {
const hasTabularData =
!firstQueryExecutionError(currentTab) &&
!!((currentTab.result?.columns.length && currentTab.result.server_message !== true && !isQueryExecutionErrorResult(currentTab.result)) || currentTab.results?.some((result) => result.columns.length > 0 && result.server_message !== true && !isQueryExecutionErrorResult(result)));
if (hasTabularData) {
const targetView = statementCount > 1 && settingsStore.editorSettings.multiStatementDefaultView === "summary" ? "summary" : "result";
updateExecutionTabUiState(currentTab, {
activeOutputView: targetView,
autoSwitchedToMessagesDuringExecution: false,
});
if (executionTabStillActive) {
deps.activeOutputView.value = targetView;
}
}
} else if (executionTabStillActive && deps.activeOutputView.value === "messages" && ((tab.result?.columns.length ?? 0) > 0 || tab.results?.some((result) => result.columns.length > 0))) {
// A notice can arrive before the statement produces its rows, so the live-output watcher
// in ContentArea advances to Messages while it cannot yet know that data will follow.
// Once the run finishes with a real result set, show the data again; Messages stays one
// click away in the view switcher. Message-only runs and SQL Server PRINT batches are
// handled by the branches above and keep their existing behavior.
deps.activeOutputView.value = "result";
}
updateExecutionTabUiState(currentTab, { autoSwitchedToMessagesDuringExecution: false });
const elapsed = Date.now() - start;
const failure = firstQueryExecutionError(tab);
const failure = firstQueryExecutionError(currentTab);
const success = !failure;
historyStore.add({
connection_id: tab.connectionId,
connection_id: currentTab.connectionId,
connection_name: connName,
database: tab.database,
database: currentTab.database,
sql,
execution_time_ms: elapsed,
success,
error: failure ? (failure.error ? translateBackendError(t, failure.error, failure.rows?.[0]?.[0]) : String(failure.rows?.[0]?.[0] ?? "")) : undefined,
activity_kind: classifySqlActivityKind(sql),
operation: primarySqlOperation(sql),
affected_rows: success ? tab.result?.affected_rows : undefined,
affected_rows: success ? currentTab.result?.affected_rows : undefined,
});
if (success) {
const refreshTarget = sqlMetadataRefreshTarget(sql, tab.schema);
const refreshTarget = sqlMetadataRefreshTarget(sql, currentTab.schema);
if (refreshTarget.scope === "connection") {
connectionStore.invalidateMetadataCache(tab.connectionId);
await invalidateObjectMetadataCache({ connectionId: tab.connectionId });
await connectionStore.loadDatabases(tab.connectionId, { force: true });
connectionStore.invalidateMetadataCache(currentTab.connectionId);
await invalidateObjectMetadataCache({ connectionId: currentTab.connectionId });
await connectionStore.loadDatabases(currentTab.connectionId, { force: true });
} else if (refreshTarget.scope === "database") {
await invalidateObjectMetadataCache({ connectionId: tab.connectionId, database: tab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(tab.connectionId, tab.database, refreshTarget.schema);
await invalidateObjectMetadataCache({ connectionId: currentTab.connectionId, database: currentTab.database, schema: refreshTarget.schema });
await connectionStore.refreshObjectListTreeNode(currentTab.connectionId, currentTab.database, refreshTarget.schema);
}
}
return {
+2
View File
@@ -2076,6 +2076,8 @@ export interface TabUiState {
/** Redis query results default to grid; a per-tab override selects command-line output. */
redisResultViewMode?: RedisResultViewMode;
resultPaneOpen?: boolean;
/** Tracks whether the active output view was auto-switched to "messages" by live server output during execution. */
autoSwitchedToMessagesDuringExecution?: boolean;
/** Small JSON-compatible snapshots owned by special-page components. */
page?: Record<string, TabPageUiState>;
}