Skip to content

Commit 1977aaa

Browse files
committed
fix: use simple protocol for multi-statement queries; resizable results; show target server in tab
- Switch exec from sqlx::query (extended/prepared) to sqlx::raw_sql so multi-statement batches no longer fail with "cannot insert multiple commands into a prepared statement". - Add a draggable splitter between the editor and results panes; ratio is clamped 15-85% and persisted to localStorage. Double-click resets to 40%. - Show "@ {name} / {database}" next to each tab title (full user@host:port/db in tooltip) so it's clear which server a tab will execute against.
1 parent 585daed commit 1977aaa

6 files changed

Lines changed: 182 additions & 17 deletions

File tree

crates/pg-core/src/exec.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,13 @@ where
9393
// `fetch_many` yields Either<QueryResult, Row>. A statement that returns
9494
// rows emits a series of Rows followed by a QueryResult with the command
9595
// tag; a non-returning statement emits only the QueryResult.
96-
let query = sqlx::query(&sql);
97-
// `Query::fetch_many` is deprecated in sqlx 0.8 in favor of the Executor
98-
// trait approach, but the trait approach lifetimes fight the borrow
99-
// checker here. Keep the deprecated path until sqlx 0.9 stabilizes.
100-
#[allow(deprecated)]
101-
let mut stream = query.fetch_many(&mut *conn);
96+
//
97+
// We route through `raw_sql` (simple query protocol) rather than `query`
98+
// (extended protocol) because Postgres rejects multi-statement batches in
99+
// a prepared statement with `cannot insert multiple commands into a
100+
// prepared statement`. The simple protocol has no statement preparation
101+
// and happily executes `BEGIN; UPDATE ...; COMMIT;`-style scripts.
102+
let mut stream = sqlx::raw_sql(&sql).fetch_many(&mut *conn);
102103

103104
let mut columns_reported = false;
104105
let mut batch: Vec<serde_json::Value> = Vec::with_capacity(BATCH_SIZE);

src/App.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,11 @@ export default function App() {
161161
</aside>
162162
<section className="workspace">
163163
{active?.connected ? (
164-
<Workspace profileId={active.id} injectedSql={injectedSql} />
164+
<Workspace
165+
profileId={active.id}
166+
connections={connections}
167+
injectedSql={injectedSql}
168+
/>
165169
) : server ? (
166170
<div className="empty-hint">
167171
<h2>Connected</h2>

src/styles.css

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -648,10 +648,28 @@ label {
648648
}
649649

650650
.tab-title {
651-
flex: 1;
652651
overflow: hidden;
653652
text-overflow: ellipsis;
654653
white-space: nowrap;
654+
flex-shrink: 1;
655+
}
656+
657+
.tab-target {
658+
color: var(--fg-2);
659+
font-size: 10.5px;
660+
font-family: var(--font-mono);
661+
white-space: nowrap;
662+
overflow: hidden;
663+
text-overflow: ellipsis;
664+
margin-left: 4px;
665+
opacity: 0.75;
666+
flex: 1;
667+
min-width: 0;
668+
}
669+
670+
.tab.active .tab-target {
671+
opacity: 1;
672+
color: var(--fg-1, var(--fg-0));
655673
}
656674

657675
.tab-close {
@@ -713,16 +731,48 @@ label {
713731
.query-split {
714732
flex: 1;
715733
display: grid;
716-
grid-template-rows: 40% 1fr;
734+
/* Inline style overrides this with the user-chosen split. */
735+
grid-template-rows: 40% 6px 1fr;
717736
min-height: 0;
718737
}
719738

720739
.query-editor-pane {
721-
border-bottom: 1px solid var(--border);
722740
min-height: 0;
723741
overflow: hidden;
724742
}
725743

744+
.query-splitter {
745+
cursor: row-resize;
746+
background: var(--border);
747+
position: relative;
748+
user-select: none;
749+
transition: background 120ms ease;
750+
}
751+
752+
.query-splitter::after {
753+
content: "";
754+
position: absolute;
755+
left: 50%;
756+
top: 50%;
757+
width: 32px;
758+
height: 2px;
759+
border-radius: 2px;
760+
background: var(--fg-2);
761+
opacity: 0.35;
762+
transform: translate(-50%, -50%);
763+
}
764+
765+
.query-splitter:hover,
766+
.query-splitter:active {
767+
background: var(--accent, #4d9cf6);
768+
}
769+
770+
.query-splitter:hover::after,
771+
.query-splitter:active::after {
772+
opacity: 0.8;
773+
background: #fff;
774+
}
775+
726776
.query-results-pane {
727777
min-height: 0;
728778
display: flex;

src/workspace/QueryTab.tsx

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useMemo, useRef } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import QueryEditor, { type QueryEditorHandle } from "../editor/QueryEditor";
33
import CommandsPanel from "../results/CommandsPanel";
44
import ResultsGrid from "../results/ResultsGrid";
@@ -91,6 +91,38 @@ export default function QueryTab({ tab }: Props) {
9191

9292
const running = tab.runState.phase === "running";
9393

94+
const splitRef = useRef<HTMLDivElement | null>(null);
95+
const [editorPct, setEditorPct] = useState<number>(() => readSplitPct());
96+
const dragging = useRef(false);
97+
const onSplitterDown = useCallback((e: React.MouseEvent) => {
98+
e.preventDefault();
99+
dragging.current = true;
100+
document.body.style.cursor = "row-resize";
101+
document.body.style.userSelect = "none";
102+
}, []);
103+
useEffect(() => {
104+
const onMove = (e: MouseEvent) => {
105+
if (!dragging.current || !splitRef.current) return;
106+
const rect = splitRef.current.getBoundingClientRect();
107+
const pct = ((e.clientY - rect.top) / rect.height) * 100;
108+
const clamped = Math.min(85, Math.max(15, pct));
109+
setEditorPct(clamped);
110+
};
111+
const onUp = () => {
112+
if (!dragging.current) return;
113+
dragging.current = false;
114+
document.body.style.cursor = "";
115+
document.body.style.userSelect = "";
116+
writeSplitPct(editorPct);
117+
};
118+
window.addEventListener("mousemove", onMove);
119+
window.addEventListener("mouseup", onUp);
120+
return () => {
121+
window.removeEventListener("mousemove", onMove);
122+
window.removeEventListener("mouseup", onUp);
123+
};
124+
}, [editorPct]);
125+
94126
return (
95127
<div className="query-tab">
96128
<div className="query-toolbar">
@@ -124,7 +156,11 @@ export default function QueryTab({ tab }: Props) {
124156
<span className="toolbar-spacer" />
125157
<span className="toolbar-status">{statusLine}</span>
126158
</div>
127-
<div className="query-split">
159+
<div
160+
className="query-split"
161+
ref={splitRef}
162+
style={{ gridTemplateRows: `${editorPct}% 6px 1fr` }}
163+
>
128164
<div className="query-editor-pane">
129165
<QueryEditor
130166
ref={editorRef}
@@ -135,6 +171,17 @@ export default function QueryTab({ tab }: Props) {
135171
profileId={tab.profileId}
136172
/>
137173
</div>
174+
<div
175+
className="query-splitter"
176+
role="separator"
177+
aria-orientation="horizontal"
178+
title="Drag to resize · double-click to reset"
179+
onMouseDown={onSplitterDown}
180+
onDoubleClick={() => {
181+
setEditorPct(40);
182+
writeSplitPct(40);
183+
}}
184+
/>
138185
<div className="query-results-pane">
139186
{tab.runState.phase === "error" ? (
140187
<div className="query-error">{tab.runState.message}</div>
@@ -160,6 +207,28 @@ export default function QueryTab({ tab }: Props) {
160207
);
161208
}
162209

210+
const SPLIT_KEY = "pg-shell.querySplit.editorPct";
211+
212+
function readSplitPct(): number {
213+
try {
214+
const raw = localStorage.getItem(SPLIT_KEY);
215+
if (!raw) return 40;
216+
const n = Number.parseFloat(raw);
217+
if (!Number.isFinite(n)) return 40;
218+
return Math.min(85, Math.max(15, n));
219+
} catch {
220+
return 40;
221+
}
222+
}
223+
224+
function writeSplitPct(pct: number): void {
225+
try {
226+
localStorage.setItem(SPLIT_KEY, String(pct));
227+
} catch {
228+
// ignore
229+
}
230+
}
231+
163232
function statusText(tab: QueryTabState): string {
164233
const liveRows = tab.rows.length;
165234
switch (tab.runState.phase) {

src/workspace/TabStrip.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import type { QueryTabState } from "./tabs";
22

3+
export interface TabTarget {
4+
/** Short label shown in the tab, e.g. "prod-db / orders". */
5+
label: string;
6+
/** Full server description for the tooltip. */
7+
detail: string;
8+
}
9+
310
export interface TabStripProps {
411
tabs: QueryTabState[];
512
activeId: string | null;
13+
/** Resolves the connection a tab will execute against. Null means unknown. */
14+
getTarget: (tab: QueryTabState) => TabTarget | null;
615
onActivate: (id: string) => void;
716
onClose: (id: string) => void;
817
onNew: () => void;
@@ -17,6 +26,7 @@ export interface TabStripProps {
1726
export default function TabStrip({
1827
tabs,
1928
activeId,
29+
getTarget,
2030
onActivate,
2131
onClose,
2232
onNew,
@@ -29,6 +39,7 @@ export default function TabStrip({
2939
<Tab
3040
key={t.id}
3141
tab={t}
42+
target={getTarget(t)}
3243
active={t.id === activeId}
3344
onActivate={() => onActivate(t.id)}
3445
onClose={() => onClose(t.id)}
@@ -51,18 +62,22 @@ export default function TabStrip({
5162

5263
function Tab({
5364
tab,
65+
target,
5466
active,
5567
onActivate,
5668
onClose,
5769
}: {
5870
tab: QueryTabState;
71+
target: TabTarget | null;
5972
active: boolean;
6073
onActivate: () => void;
6174
onClose: () => void;
6275
}) {
6376
const running = tab.runState.phase === "running";
6477
const errored = tab.runState.phase === "error";
6578

79+
const tooltip = target ? `${tab.title}${target.detail}` : tab.title;
80+
6681
return (
6782
<div
6883
className={`tab ${active ? "active" : ""}`}
@@ -74,14 +89,15 @@ function Tab({
7489
onClose();
7590
}
7691
}}
77-
title={tab.title}
92+
title={tooltip}
7893
>
7994
<span
8095
className={`tab-status ${running ? "running" : errored ? "error" : ""}`}
8196
>
8297
{running ? "●" : tab.dirty ? "●" : " "}
8398
</span>
8499
<span className="tab-title">{tab.title}</span>
100+
{target && <span className="tab-target">@ {target.label}</span>}
85101
<button
86102
className="tab-close"
87103
onClick={(e) => {

src/workspace/Workspace.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
1-
import { useEffect, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import type { ConnectionSummary } from "../ipc/types";
23
import HistoryPanel from "./HistoryPanel";
34
import QueryTab from "./QueryTab";
4-
import TabStrip from "./TabStrip";
5-
import { useTabStore } from "./tabs";
5+
import TabStrip, { type TabTarget } from "./TabStrip";
6+
import { useTabStore, type QueryTabState } from "./tabs";
67

78
interface Props {
89
profileId: string;
10+
/**
11+
* Known connection profiles. Used to surface the target server next to each
12+
* tab title — so the user sees where the SQL will execute.
13+
*/
14+
connections: ConnectionSummary[];
915
/**
1016
* SQL to open a new tab with when the tree injects a script. Bumped via a
1117
* version id so the same script can be re-opened.
@@ -18,7 +24,7 @@ interface Props {
1824
* tab when a profile activates, open new tab for injected scripts, keyboard
1925
* shortcuts for new/close).
2026
*/
21-
export default function Workspace({ profileId, injectedSql }: Props) {
27+
export default function Workspace({ profileId, connections, injectedSql }: Props) {
2228
const tabs = useTabStore((s) => s.tabs);
2329
const activeId = useTabStore((s) => s.activeId);
2430
const openTab = useTabStore((s) => s.openTab);
@@ -81,11 +87,30 @@ export default function Workspace({ profileId, injectedSql }: Props) {
8187
const activeTab =
8288
profileTabs.find((t) => t.id === activeId) ?? profileTabs[profileTabs.length - 1] ?? null;
8389

90+
const connectionMap = useMemo(() => {
91+
const m = new Map<string, ConnectionSummary>();
92+
for (const c of connections) m.set(c.id, c);
93+
return m;
94+
}, [connections]);
95+
96+
const getTarget = useCallback(
97+
(tab: QueryTabState): TabTarget | null => {
98+
const c = connectionMap.get(tab.profileId);
99+
if (!c) return null;
100+
return {
101+
label: `${c.name} / ${c.database}`,
102+
detail: `${c.user}@${c.host}:${c.port}/${c.database}${c.connected ? "" : " (disconnected)"}`,
103+
};
104+
},
105+
[connectionMap],
106+
);
107+
84108
return (
85109
<div className="workspace-shell">
86110
<TabStrip
87111
tabs={profileTabs}
88112
activeId={activeTab?.id ?? null}
113+
getTarget={getTarget}
89114
onActivate={setActive}
90115
onClose={closeTab}
91116
onNew={() => openTab(profileId)}

0 commit comments

Comments
 (0)