diff --git a/.changeset/good-turtles-lie.md b/.changeset/good-turtles-lie.md new file mode 100644 index 00000000..129dde54 --- /dev/null +++ b/.changeset/good-turtles-lie.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Add Query Details copy actions diff --git a/FEATURES.md b/FEATURES.md index 5564964d..e0fa7c5f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -140,7 +140,7 @@ Users can pan/zoom, inspect key and nullable markers, and jump from a node direc 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. 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. -Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations. +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. 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. 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. 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. diff --git a/ui/studio/views/queries/QueriesView.test.tsx b/ui/studio/views/queries/QueriesView.test.tsx index d8229410..54008467 100644 --- a/ui/studio/views/queries/QueriesView.test.tsx +++ b/ui/studio/views/queries/QueriesView.test.tsx @@ -1346,6 +1346,116 @@ describe("QueriesView", () => { container.remove(); }); + it("copies the SQL text and recommendation from the query details sheet", async () => { + const originalClipboard = Object.getOwnPropertyDescriptor( + navigator, + "clipboard", + ); + const writeText = vi.fn().mockResolvedValue(undefined); + + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText, + }, + }); + + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + requestLlm.mockResolvedValueOnce( + JSON.stringify({ + improvedPrisma: "await prisma.user.findMany({ select: { id: true } })", + improvedSql: "select id from users", + level: "info", + recommendations: [ + "Project only the columns the UI needs.", + "Keep pagination count queries separate from row fetches.", + ], + summary: "The query over-fetches columns.", + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const rowButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("select * from users"), + ); + + expect(rowButton).not.toBeUndefined(); + + act(() => { + click(rowButton!); + }); + + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "Project only the columns the UI needs.", + ); + }); + + const copySqlButton = document.body.querySelector( + '[aria-label="Copy SQL text"]', + ); + const copyRecommendationButton = document.body.querySelector( + '[aria-label="Copy recommendation"]', + ); + + expect(copySqlButton).not.toBeNull(); + expect(copyRecommendationButton).not.toBeNull(); + + await act(async () => { + click(copySqlButton!); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenLastCalledWith("select * from users"); + + await act(async () => { + click(copyRecommendationButton!); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenLastCalledWith( + [ + "Recommendation", + "The query over-fetches columns.", + "", + "- Project only the columns the UI needs.", + "- Keep pagination count queries separate from row fetches.", + "", + "SQL", + "select id from users", + "", + "Prisma", + "await prisma.user.findMany({ select: { id: true } })", + ].join("\n"), + ); + } finally { + act(() => { + root.unmount(); + }); + container.remove(); + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + } + }); + it("runs automatic query analysis serially, stops after five groups, and allows manual analysis", async () => { getSnapshot.mockResolvedValueOnce([ null, diff --git a/ui/studio/views/queries/QueriesView.tsx b/ui/studio/views/queries/QueriesView.tsx index 775da4dc..fb388f14 100644 --- a/ui/studio/views/queries/QueriesView.tsx +++ b/ui/studio/views/queries/QueriesView.tsx @@ -2,6 +2,7 @@ import { ChevronLeft, ChevronRight, CircleCheck, + Copy, Info, Loader2, Pause, @@ -1582,9 +1583,17 @@ function QueryDetailsSheet(props: { ))} -
-                  {query.query}
-                
+
+
+                    {query.query}
+                  
+ +
@@ -1605,20 +1614,31 @@ function QueryDetailsSheet(props: { Recommendations
- {analysis && ( - - )} - {!analysis && !isAnalysisLoading && !isAnalysisQueued && ( - - )} +
+ {analysis && ( + <> + + + + )} + {!analysis && + !isAnalysisLoading && + !isAnalysisQueued && ( + + )} +
{isAnalysisLoading ? ( @@ -1649,6 +1669,74 @@ function QueryDetailsSheet(props: { ); } +function CopyToClipboardButton(props: { + ariaLabel: string; + className?: string; + label: string; + text: string; +}) { + const { ariaLabel, className, label, text } = props; + const [hasCopied, setHasCopied] = useState(false); + const resetCopiedTimeoutRef = useRef(null); + + useEffect(() => { + return () => { + if (resetCopiedTimeoutRef.current !== null) { + window.clearTimeout(resetCopiedTimeoutRef.current); + } + }; + }, []); + + const handleCopy = useCallback(() => { + if (typeof navigator.clipboard?.writeText !== "function") { + return; + } + + void navigator.clipboard + .writeText(text) + .then(() => { + setHasCopied(true); + + if (resetCopiedTimeoutRef.current !== null) { + window.clearTimeout(resetCopiedTimeoutRef.current); + } + + resetCopiedTimeoutRef.current = window.setTimeout(() => { + setHasCopied(false); + resetCopiedTimeoutRef.current = null; + }, 1200); + }) + .catch((error) => { + console.error("Failed to copy to clipboard:", error); + }); + }, [text]); + + return ( + + + + + + + {hasCopied ? "Copied" : label} + + + + ); +} + function MetricInline(props: { label: string; value: number | string }) { return (
@@ -1676,6 +1764,28 @@ function QueryAnalysisStatusBadge(props: { level: QueryInsightAnalysisLevel }) { ); } +function formatQueryAnalysisCopyText(analysis: QueryInsightAnalysis): string { + const sections = [`Recommendation\n${analysis.summary}`]; + + if (analysis.recommendations.length > 0) { + sections.push( + analysis.recommendations + .map((recommendation) => `- ${recommendation}`) + .join("\n"), + ); + } + + if (analysis.improvedSql) { + sections.push(`SQL\n${analysis.improvedSql}`); + } + + if (analysis.improvedPrisma) { + sections.push(`Prisma\n${analysis.improvedPrisma}`); + } + + return sections.join("\n\n"); +} + function QueryAnalysis(props: { analysis: QueryInsightAnalysis }) { const { analysis } = props;