feat: stacked result-set panels with DML/error notices in the query workspace (Task 9)

This commit is contained in:
2026-09-09 18:31:12 +08:00
parent c85d16d0db
commit 5e8107982b
7 changed files with 205 additions and 93 deletions
@@ -281,8 +281,8 @@ describe("DbViewerScreen", () => {
it("executes a non-destructive query when Run is clicked", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
.spyOn(commands, "executeQueryMulti")
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render(
<DbViewerScreen
connectionId="c1"
@@ -306,13 +306,11 @@ describe("DbViewerScreen", () => {
it("shows a destructive-query confirmation dialog and executes on confirm", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.spyOn(commands, "executeQueryMulti")
.mockResolvedValue({
columns: [],
rows: [],
total_rows: 0,
page: 1,
page_size: 50,
result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }],
notices: [],
execution_time_ms: 42,
} as any);
render(
<DbViewerScreen
@@ -345,8 +343,8 @@ describe("DbViewerScreen", () => {
});
it("refreshes the schema tree after a DDL query runs", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
);
const getSchemas = vi
.spyOn(commands, "getSchemas")
@@ -377,8 +375,8 @@ describe("DbViewerScreen", () => {
});
it("does not refresh the schema tree after a SELECT", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
);
const getSchemas = vi
.spyOn(commands, "getSchemas")
@@ -396,7 +394,7 @@ describe("DbViewerScreen", () => {
);
fireEvent.change(textarea, { target: { value: "SELECT 1" } });
fireEvent.click(screen.getByRole("button", { name: /run query/i }));
await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled());
await waitFor(() => expect(commands.executeQueryMulti).toHaveBeenCalled());
expect(getSchemas).not.toHaveBeenCalled();
});
@@ -423,8 +421,8 @@ describe("DbViewerScreen", () => {
it("runs the current query when the Cmd+Enter action fires", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
.spyOn(commands, "executeQueryMulti")
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render(
<DbViewerScreen
connectionId="c1"
@@ -452,7 +450,7 @@ describe("DbViewerScreen", () => {
const pending = new Promise<unknown>((r) => {
resolveRun = r;
});
vi.spyOn(commands, "executeQuery").mockReturnValue(pending as any);
vi.spyOn(commands, "executeQueryMulti").mockReturnValue(pending as any);
render(
<DbViewerScreen
connectionId="c1"
@@ -469,7 +467,7 @@ describe("DbViewerScreen", () => {
await waitFor(() =>
expect(screen.getByTestId("query-run-pulse")).toBeInTheDocument(),
);
resolveRun(mockQueryResult);
resolveRun({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 });
await waitFor(() =>
expect(screen.queryByTestId("query-run-pulse")).toBeNull(),
);
@@ -477,8 +475,8 @@ describe("DbViewerScreen", () => {
it("collapses and re-expands the query results via the caret", async () => {
const executeQuery = vi
.spyOn(commands, "executeQuery")
.mockResolvedValue(mockQueryResult as any);
.spyOn(commands, "executeQueryMulti")
.mockResolvedValue({ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any);
render(
<DbViewerScreen
connectionId="c1"
@@ -505,8 +503,8 @@ describe("DbViewerScreen", () => {
});
it("resizes the results panel with a drag handle, clamped to min/max", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue(
mockQueryResult as any,
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue(
{ result_sets: [mockQueryResult], notices: [], execution_time_ms: 42 } as any,
);
render(
<DbViewerScreen
@@ -930,7 +928,7 @@ describe("DbViewerScreen", () => {
});
it("renders an objectForm tab with the Visual/SQL toggle in the objects view", async () => {
vi.spyOn(commands, "executeQuery").mockResolvedValue({ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 } as any);
vi.spyOn(commands, "executeQueryMulti").mockResolvedValue({ result_sets: [{ columns: [], rows: [], total_rows: 0, page: 1, page_size: 50 }], notices: [], execution_time_ms: 42 } as any);
vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]);
vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]);
vi.spyOn(commands, "getTables").mockResolvedValue([] as any);
@@ -5,7 +5,7 @@ import { TooltipProvider } from "../ui/Tooltip";
import { DbViewerSidebar, NAV_CAPABILITY_KEY } from "./DbViewerSidebar";
import { DbViewerToolbar } from "./DbViewerToolbar";
import { isDestructiveQuery, isSchemaModifyingQuery } from "../../lib/utils";
import { executeQuery, cancelQuery } from "../../lib/commands";
import { executeQueryMulti, cancelQuery } from "../../lib/commands";
import { getCapabilities } from "../../lib/dbCapabilities";
const QueryEditor = lazy(() => import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor })));
@@ -19,6 +19,7 @@ import { ObjectDetail, type AnyObject } from "./objects/ObjectDetail";
import { ObjectFormTab } from "./objects/ObjectFormTab";
import { TabBar } from "./TabBar";
import { VirtualDataGrid } from "../grid/VirtualDataGrid";
import { ResultSetPanels } from "./ResultSetPanels";
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
import { TableControls } from "./TableControls";
import { EditConnectionModal } from "./EditConnectionModal";
@@ -260,6 +261,7 @@ export function DbViewerScreen({
const setTabData = useDbViewerStore((s) => s.setTabData);
const setTabError = useDbViewerStore((s) => s.setTabError);
const setTabMulti = useDbViewerStore((s) => s.setTabMulti);
const setTabLoading = useDbViewerStore((s) => s.setTabLoading);
const databases = useDbViewerStore((s) => s.databases);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
@@ -311,8 +313,24 @@ export function DbViewerScreen({
if (!tab) return;
setTabLoading(tabId, true);
try {
const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize);
setTabData(tabId, result);
const multi = await executeQueryMulti(connectionId, sql, tab.page, tab.pageSize);
const first = multi.result_sets[0] ?? null;
// Stamp the run's execution time onto the first set so the toolbar
// (TableControls reads activeTab.data.execution_time_ms) still shows it.
if (first) {
setTabData(tabId, { ...first, execution_time_ms: multi.execution_time_ms });
} else {
// Lone DML/DDL run with no result set — keep the empty-state behavior.
setTabData(tabId, {
columns: [],
rows: [],
total_rows: 0,
page: tab.page,
page_size: tab.pageSize,
execution_time_ms: multi.execution_time_ms,
});
}
setTabMulti(tabId, multi);
useQueryStore.getState().invalidateHistory(connectionId);
if (isSchemaModifyingQuery(sql)) {
const st = useDbViewerStore.getState();
@@ -1017,6 +1035,48 @@ const onQueriesPanelResizeStart = useCallback(
)
: {};
// Shared VirtualDataGrid props for the query-tab results region. The
// multi-result panels reuse these (overriding rows/columns per set);
// single-set runs render the grid directly with today's props.
const gridPropsForQueryTab = {
connectionId,
schema: activeSchema,
table: activeTable,
rows: displayRows,
columns,
hiddenColumns,
selectedRows,
dbType: currentConnection?.db_type ?? "postgresql",
tabType: activeTab?.tabType ?? "table",
getLocator,
onStageEdit: readOnlyTable ? undefined : handleStageEdit,
onStageInsertCell: readOnlyTable ? undefined : handleStageInsertCell,
onOpenRowDetail: handleOpenRowDetail,
readOnly: readOnlyTable,
enumValues: editorOptions?.enums,
fkOptions: editorOptions?.fks,
fkPlaceholders: editorOptions?.fkPlaceholders,
stagedValues,
pendingKeys,
pendingInsertChangeIds,
onToggleRow: (rowIndex: number) => {
setSelectedRows((prev) => {
const next = new Set(prev);
if (next.has(rowIndex)) next.delete(rowIndex);
else next.add(rowIndex);
return next;
});
},
onToggleAll: () => {
setSelectedRows((prev) => {
if (prev.size === processedRows.length && processedRows.length > 0) {
return new Set();
}
return new Set(processedRows.map((_, i) => i));
});
},
};
return (
<div className="flex-1 w-0 flex flex-col min-w-0 overflow-hidden">
<TabBar onCommitted={handleCommitted} />
@@ -1212,71 +1272,16 @@ const onQueriesPanelResizeStart = useCallback(
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<VirtualDataGrid
connectionId={connectionId}
schema={activeSchema}
table={activeTable}
rows={displayRows}
columns={columns}
hiddenColumns={hiddenColumns}
selectedRows={selectedRows}
dbType={currentConnection?.db_type ?? "postgresql"}
tabType={activeTab?.tabType ?? "table"}
getLocator={getLocator}
onStageEdit={readOnlyTable ? undefined : handleStageEdit}
onStageInsertCell={readOnlyTable ? undefined : handleStageInsertCell}
onOpenRowDetail={handleOpenRowDetail}
readOnly={readOnlyTable}
enumValues={editorOptions?.enums}
fkOptions={editorOptions?.fks}
fkPlaceholders={editorOptions?.fkPlaceholders}
stagedValues={stagedValues}
pendingKeys={pendingKeys}
pendingInsertChangeIds={pendingInsertChangeIds}
onToggleRow={(rowIndex) => {
setSelectedRows(
(prev) => {
const next =
new Set(
prev,
);
if (
next.has(
rowIndex,
)
)
next.delete(
rowIndex,
);
else
next.add(
rowIndex,
);
return next;
},
);
}}
onToggleAll={() => {
setSelectedRows(
(prev) => {
if (
prev.size ===
processedRows.length &&
processedRows.length >
0
) {
return new Set();
}
return new Set(
processedRows.map(
(_, i) =>
i,
),
);
},
);
}}
{activeTab?.tabType === "query" && activeTab.multi && (activeTab.multi.result_sets.length > 1 || activeTab.multi.notices.length > 0) ? (
<ResultSetPanels
multi={activeTab.multi}
renderGrid={(set) => (
<VirtualDataGrid {...gridPropsForQueryTab} rows={set.rows} columns={set.columns} />
)}
/>
) : (
<VirtualDataGrid {...gridPropsForQueryTab} />
)}
</div>
</div>
</>
@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ResultSetPanels } from "./ResultSetPanels";
import type { MultiQueryResult } from "../../lib/types";
const multi: MultiQueryResult = {
result_sets: [
{ columns: [{ name: "a", data_type: "int", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [[1], [2]], total_rows: 2, page: 1, page_size: 50 },
{ columns: [{ name: "b", data_type: "text", is_nullable: true, is_pk: false, is_fk: false, fk_ref: null, default_value: null, editable: true, is_generated: false }], rows: [], total_rows: 0, page: 1, page_size: 50 },
],
notices: [
{ statement_index: 1, kind: "dml", text: "UPDATE 3", affected: 3 },
{ statement_index: 3, kind: "error", text: "relation \"nope\" does not exist", affected: null },
],
execution_time_ms: 12,
};
describe("ResultSetPanels", () => {
it("renders one panel header per result set with row counts", () => {
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
expect(screen.getByText(/Result 1/)).toBeTruthy();
expect(screen.getByText(/Result 2/)).toBeTruthy();
expect(screen.getByText(/2 rows/)).toBeTruthy();
});
it("renders notice cards for DML and errors", () => {
render(<ResultSetPanels multi={multi} renderGrid={vi.fn()} />);
expect(screen.getByText(/UPDATE 3/)).toBeTruthy();
expect(screen.getByText(/3 rows affected/)).toBeTruthy();
expect(screen.getByText(/relation "nope" does not exist/)).toBeTruthy();
});
it("delegates each set to the grid renderer with its index", () => {
const renderGrid = vi.fn(() => <div />);
render(<ResultSetPanels multi={multi} renderGrid={renderGrid} />);
expect(renderGrid).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,42 @@
import { AlertCircle, Info } from "lucide-react";
import type { MultiQueryResult, QueryResult } from "../../lib/types";
export function ResultSetPanels({
multi,
renderGrid,
}: {
multi: MultiQueryResult;
renderGrid: (set: QueryResult, index: number) => React.ReactNode;
}) {
return (
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
{multi.result_sets.map((set, i) => (
<section key={i} className="border-b border-border flex flex-col min-h-0">
<header className="px-3 py-1.5 text-xs text-text-muted bg-surface-raised border-b border-border flex items-center gap-2 shrink-0">
<span className="font-medium text-text">Result {i + 1}</span>
<span>— {set.total_rows} row{set.total_rows === 1 ? "" : "s"}</span>
</header>
<div className="min-h-0" style={{ height: Math.min(Math.max(set.rows.length * 28 + 32, 160), 480) }}>
{renderGrid(set, i)}
</div>
</section>
))}
{multi.notices.map((n, i) => (
<div
key={i}
role={n.kind === "error" ? "alert" : "status"}
className={`flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border ${
n.kind === "error" ? "text-red-400" : "text-text-muted"
}`}
>
{n.kind === "error" ? <AlertCircle size={13} /> : <Info size={13} />}
<span className="font-medium">Statement {n.statement_index + 1}</span>
<span className="truncate">{n.text}</span>
{n.affected !== null && n.affected !== undefined && (
<span>({n.affected} rows affected)</span>
)}
</div>
))}
</div>
);
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse } from "./types";
import type { Connection, ConnectionInput, ConnectionTestResult, Folder, FolderInput, Tag, TagInput, Settings, ImportResult, TableInfo, QueryResult, BackupOptions, RestoreOptions, SyncOptions, PgToolStatus, MySqlToolStatus, MySqlBackupOptions, MySqlRestoreOptions, SqliteBackupOptions, SqliteRestoreOptions, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, SchemaGraph, IndexInfo, ConstraintInfo, RecentConnection, ObjectSearchHit, DependencyInfo, RoleInfo, PrivilegeEntry, RebuildReadiness, MaintenanceResult, TablespaceInfo, ColumnInfo, HypertableListResponse, MultiQueryResult } from "./types";
import type { FilterRule, SortRule } from "../stores/dbViewerStore";
import type { ChangePayload } from "./changePayload";
import { buildObjectDdl as buildObjectDdlImpl, type ObjectKind, type DdlParams } from "./objectCrud";
@@ -286,6 +286,15 @@ export async function executeQuery(
return invoke<QueryResult>("execute_query", { connectionId, query, page, pageSize });
}
export async function executeQueryMulti(
connectionId: string,
query: string,
page: number,
pageSize: number,
): Promise<MultiQueryResult> {
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
}
export async function getQueryHistory(
connectionId: string | null,
limit: number,
+13
View File
@@ -189,6 +189,19 @@ export interface QueryResult {
error?: string | null;
}
export interface StatementNotice {
statement_index: number;
kind: "dml" | "ddl" | "error" | string;
text: string;
affected: number | null;
}
export interface MultiQueryResult {
result_sets: QueryResult[];
notices: StatementNotice[];
execution_time_ms: number;
}
export type ChangeStatus = "pending" | "applied" | "error";
export type ChangeItemType =
+9 -2
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo } from "../lib/types";
import type { QueryResult, TableInfo, ChangeItemType, FunctionInfo, TriggerInfo, SequenceInfo, EnumInfo, ExtensionInfo, HypertableInfo, IndexInfo, ConstraintInfo, ObjectType, RoleInfo, MultiQueryResult } from "../lib/types";
import { getDatabases, getSchemas, getTables } from "../lib/commands";
import type { ObjectKind, DdlParams } from "../lib/objectCrud";
@@ -58,6 +58,7 @@ export interface ViewerTab {
loading: boolean;
error: string | null;
data: QueryResult | null;
multi?: MultiQueryResult | null;
columnFilter?: { column: string; value: string };
filterRules: FilterRule[];
sortRules: SortRule[];
@@ -146,6 +147,7 @@ interface DbViewerState {
setPage: (tabId: string, page: number) => void;
setPageSize: (tabId: string, pageSize: number) => void;
setTabData: (tabId: string, data: QueryResult) => void;
setTabMulti: (tabId: string, multi: MultiQueryResult | null) => void;
setTabLoading: (tabId: string, loading: boolean) => void;
setTabError: (tabId: string, error: string) => void;
setColumnFilter: (tabId: string, column: string, value: string) => void;
@@ -449,10 +451,15 @@ export const useDbViewerStore = create<DbViewerState>((set, get) => ({
setTabData: (tabId, data) =>
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, data, loading: false, error: null } : t,
t.id === tabId ? { ...t, data, multi: null, loading: false, error: null } : t,
),
})),
setTabMulti: (tabId, multi) =>
set((s) => ({
tabs: s.tabs.map((t) => (t.id === tabId ? { ...t, multi } : t)),
})),
setTabLoading: (tabId, loading) =>
set((state) => ({
tabs: state.tabs.map((t) =>