Skip to content

Commit 9d45420

Browse files
authored
Add Query Details copy actions
Adds compact copy actions for Query Details SQL and AI recommendations, updates Query Insights docs, and includes a minor changeset for @prisma/studio-core.
1 parent ca42edf commit 9d45420

4 files changed

Lines changed: 243 additions & 18 deletions

File tree

.changeset/good-turtles-lie.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": minor
3+
---
4+
5+
Add Query Details copy actions

FEATURES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Users can pan/zoom, inspect key and nullable markers, and jump from a node direc
140140

141141
Embedders can optionally provide live query snapshots through Studio's BFF bridge, and Studio shows them in a dedicated `Queries` view directly under the schema visualizer.
142142
The view plots live query throughput and average latency from recent snapshot rows and successive snapshot updates, defaulting to the most recent 5 minutes with quick switches for 1 minute, 15 minutes, and 1 hour. The chart summary and query list follow the selected time window, including row execution and rows-returned counters, while historical first-snapshot rows render as latency context points instead of fake throughput spikes or cumulative totals. Live throughput points use one-second buckets at the query's observed time, live lines stay connected across short bursts, long unmeasured gaps break into separate segments, isolated samples render as points, and hovering the plot shows exact readings.
143-
Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations.
143+
Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations. The detail sheet includes compact copy actions for the original SQL and the full recommendation payload, so users can move either into an editor, issue, or follow-up prompt without manually selecting long text.
144144
The table and recommendation text label returned-row volume as `Rows Returned`; optional provider read-work estimates stay separate so rows returned are not described as reads.
145145
When Studio's shared `llm` hook is available, the query table adds an Analysis column that analyzes newly observed query groups in the background, one at a time, and stops automatic work after the first five groups. Rows show a running indicator, a manual Analyze action, and a completed all-good, info, or warning icon; the detail sheet uses the same analysis queue for manual recommendations. Without that hook, the AI analysis UI is hidden.
146146
If an embedder does not provide query insights, Studio hides the `Queries` menu item and stale `view=queries` URLs fall back to the normal default view.

ui/studio/views/queries/QueriesView.test.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,116 @@ describe("QueriesView", () => {
13461346
container.remove();
13471347
});
13481348

1349+
it("copies the SQL text and recommendation from the query details sheet", async () => {
1350+
const originalClipboard = Object.getOwnPropertyDescriptor(
1351+
navigator,
1352+
"clipboard",
1353+
);
1354+
const writeText = vi.fn().mockResolvedValue(undefined);
1355+
1356+
Object.defineProperty(navigator, "clipboard", {
1357+
configurable: true,
1358+
value: {
1359+
writeText,
1360+
},
1361+
});
1362+
1363+
useStudioMock.mockReturnValue({
1364+
hasAiQueryRecommendations: true,
1365+
queryInsights: {
1366+
getSnapshot,
1367+
},
1368+
requestLlm,
1369+
});
1370+
requestLlm.mockResolvedValueOnce(
1371+
JSON.stringify({
1372+
improvedPrisma: "await prisma.user.findMany({ select: { id: true } })",
1373+
improvedSql: "select id from users",
1374+
level: "info",
1375+
recommendations: [
1376+
"Project only the columns the UI needs.",
1377+
"Keep pagination count queries separate from row fetches.",
1378+
],
1379+
summary: "The query over-fetches columns.",
1380+
}),
1381+
);
1382+
1383+
const container = document.createElement("div");
1384+
document.body.appendChild(container);
1385+
const root = createRoot(container);
1386+
1387+
try {
1388+
act(() => {
1389+
root.render(<QueriesView />);
1390+
});
1391+
await flushMicrotasks();
1392+
1393+
const rowButton = [...container.querySelectorAll("button")].find(
1394+
(button) => button.textContent?.includes("select * from users"),
1395+
);
1396+
1397+
expect(rowButton).not.toBeUndefined();
1398+
1399+
act(() => {
1400+
click(rowButton!);
1401+
});
1402+
1403+
await vi.waitFor(() => {
1404+
expect(document.body.textContent).toContain(
1405+
"Project only the columns the UI needs.",
1406+
);
1407+
});
1408+
1409+
const copySqlButton = document.body.querySelector(
1410+
'[aria-label="Copy SQL text"]',
1411+
);
1412+
const copyRecommendationButton = document.body.querySelector(
1413+
'[aria-label="Copy recommendation"]',
1414+
);
1415+
1416+
expect(copySqlButton).not.toBeNull();
1417+
expect(copyRecommendationButton).not.toBeNull();
1418+
1419+
await act(async () => {
1420+
click(copySqlButton!);
1421+
await Promise.resolve();
1422+
});
1423+
1424+
expect(writeText).toHaveBeenLastCalledWith("select * from users");
1425+
1426+
await act(async () => {
1427+
click(copyRecommendationButton!);
1428+
await Promise.resolve();
1429+
});
1430+
1431+
expect(writeText).toHaveBeenLastCalledWith(
1432+
[
1433+
"Recommendation",
1434+
"The query over-fetches columns.",
1435+
"",
1436+
"- Project only the columns the UI needs.",
1437+
"- Keep pagination count queries separate from row fetches.",
1438+
"",
1439+
"SQL",
1440+
"select id from users",
1441+
"",
1442+
"Prisma",
1443+
"await prisma.user.findMany({ select: { id: true } })",
1444+
].join("\n"),
1445+
);
1446+
} finally {
1447+
act(() => {
1448+
root.unmount();
1449+
});
1450+
container.remove();
1451+
if (originalClipboard) {
1452+
Object.defineProperty(navigator, "clipboard", originalClipboard);
1453+
} else {
1454+
Reflect.deleteProperty(navigator, "clipboard");
1455+
}
1456+
}
1457+
});
1458+
13491459
it("runs automatic query analysis serially, stops after five groups, and allows manual analysis", async () => {
13501460
getSnapshot.mockResolvedValueOnce([
13511461
null,

ui/studio/views/queries/QueriesView.tsx

Lines changed: 127 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ChevronLeft,
33
ChevronRight,
44
CircleCheck,
5+
Copy,
56
Info,
67
Loader2,
78
Pause,
@@ -1582,9 +1583,17 @@ function QueryDetailsSheet(props: {
15821583
))}
15831584
</div>
15841585

1585-
<pre className="max-h-72 overflow-auto rounded-md border border-border/70 bg-muted/40 p-3 text-xs text-foreground">
1586-
<code>{query.query}</code>
1587-
</pre>
1586+
<div className="flex items-start gap-2">
1587+
<pre className="max-h-72 min-w-0 flex-1 overflow-auto rounded-md border border-border/70 bg-muted/40 p-3 text-xs text-foreground">
1588+
<code>{query.query}</code>
1589+
</pre>
1590+
<CopyToClipboardButton
1591+
ariaLabel="Copy SQL text"
1592+
className="mt-1 shrink-0"
1593+
label="Copy SQL"
1594+
text={query.query}
1595+
/>
1596+
</div>
15881597

15891598
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
15901599
<MetricInline label="Executions" value={query.count} />
@@ -1605,20 +1614,31 @@ function QueryDetailsSheet(props: {
16051614
<Sparkles data-icon="inline-start" />
16061615
Recommendations
16071616
</div>
1608-
{analysis && (
1609-
<QueryAnalysisStatusBadge level={analysis.level} />
1610-
)}
1611-
{!analysis && !isAnalysisLoading && !isAnalysisQueued && (
1612-
<Button
1613-
className="h-7 shadow-none"
1614-
onClick={onAnalyze}
1615-
size="xs"
1616-
type="button"
1617-
variant="outline"
1618-
>
1619-
{analysisError ? "Retry" : "Analyze"}
1620-
</Button>
1621-
)}
1617+
<div className="flex items-center gap-2">
1618+
{analysis && (
1619+
<>
1620+
<CopyToClipboardButton
1621+
ariaLabel="Copy recommendation"
1622+
label="Copy recommendation"
1623+
text={formatQueryAnalysisCopyText(analysis)}
1624+
/>
1625+
<QueryAnalysisStatusBadge level={analysis.level} />
1626+
</>
1627+
)}
1628+
{!analysis &&
1629+
!isAnalysisLoading &&
1630+
!isAnalysisQueued && (
1631+
<Button
1632+
className="h-7 shadow-none"
1633+
onClick={onAnalyze}
1634+
size="xs"
1635+
type="button"
1636+
variant="outline"
1637+
>
1638+
{analysisError ? "Retry" : "Analyze"}
1639+
</Button>
1640+
)}
1641+
</div>
16221642
</div>
16231643

16241644
{isAnalysisLoading ? (
@@ -1649,6 +1669,74 @@ function QueryDetailsSheet(props: {
16491669
);
16501670
}
16511671

1672+
function CopyToClipboardButton(props: {
1673+
ariaLabel: string;
1674+
className?: string;
1675+
label: string;
1676+
text: string;
1677+
}) {
1678+
const { ariaLabel, className, label, text } = props;
1679+
const [hasCopied, setHasCopied] = useState(false);
1680+
const resetCopiedTimeoutRef = useRef<number | null>(null);
1681+
1682+
useEffect(() => {
1683+
return () => {
1684+
if (resetCopiedTimeoutRef.current !== null) {
1685+
window.clearTimeout(resetCopiedTimeoutRef.current);
1686+
}
1687+
};
1688+
}, []);
1689+
1690+
const handleCopy = useCallback(() => {
1691+
if (typeof navigator.clipboard?.writeText !== "function") {
1692+
return;
1693+
}
1694+
1695+
void navigator.clipboard
1696+
.writeText(text)
1697+
.then(() => {
1698+
setHasCopied(true);
1699+
1700+
if (resetCopiedTimeoutRef.current !== null) {
1701+
window.clearTimeout(resetCopiedTimeoutRef.current);
1702+
}
1703+
1704+
resetCopiedTimeoutRef.current = window.setTimeout(() => {
1705+
setHasCopied(false);
1706+
resetCopiedTimeoutRef.current = null;
1707+
}, 1200);
1708+
})
1709+
.catch((error) => {
1710+
console.error("Failed to copy to clipboard:", error);
1711+
});
1712+
}, [text]);
1713+
1714+
return (
1715+
<TooltipProvider delayDuration={200}>
1716+
<Tooltip>
1717+
<TooltipTrigger asChild>
1718+
<Button
1719+
aria-label={ariaLabel}
1720+
className={cn(
1721+
"size-7 border border-border/70 bg-background/85 text-muted-foreground shadow-none hover:text-foreground",
1722+
className,
1723+
)}
1724+
onClick={handleCopy}
1725+
size="icon"
1726+
type="button"
1727+
variant="ghost"
1728+
>
1729+
{hasCopied ? <CircleCheck /> : <Copy />}
1730+
</Button>
1731+
</TooltipTrigger>
1732+
<TooltipContent side="left">
1733+
{hasCopied ? "Copied" : label}
1734+
</TooltipContent>
1735+
</Tooltip>
1736+
</TooltipProvider>
1737+
);
1738+
}
1739+
16521740
function MetricInline(props: { label: string; value: number | string }) {
16531741
return (
16541742
<div className="rounded-md border border-border/70 bg-background/60 p-2">
@@ -1676,6 +1764,28 @@ function QueryAnalysisStatusBadge(props: { level: QueryInsightAnalysisLevel }) {
16761764
);
16771765
}
16781766

1767+
function formatQueryAnalysisCopyText(analysis: QueryInsightAnalysis): string {
1768+
const sections = [`Recommendation\n${analysis.summary}`];
1769+
1770+
if (analysis.recommendations.length > 0) {
1771+
sections.push(
1772+
analysis.recommendations
1773+
.map((recommendation) => `- ${recommendation}`)
1774+
.join("\n"),
1775+
);
1776+
}
1777+
1778+
if (analysis.improvedSql) {
1779+
sections.push(`SQL\n${analysis.improvedSql}`);
1780+
}
1781+
1782+
if (analysis.improvedPrisma) {
1783+
sections.push(`Prisma\n${analysis.improvedPrisma}`);
1784+
}
1785+
1786+
return sections.join("\n\n");
1787+
}
1788+
16791789
function QueryAnalysis(props: { analysis: QueryInsightAnalysis }) {
16801790
const { analysis } = props;
16811791

0 commit comments

Comments
 (0)