feat: add JSON view mode toggle for query results
Add a Table/JSON segmented toggle to both the query workspace and table data viewer, allowing users to switch between tabular and pretty-printed JSON display of results without exporting to a file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
src/components/results/ResultsJsonView.tsx
Normal file
60
src/components/results/ResultsJsonView.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
columns: string[];
|
||||||
|
rows: unknown[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntaxHighlight(json: string): string {
|
||||||
|
return json.replace(
|
||||||
|
/("(\\u[\da-fA-F]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
|
||||||
|
(match) => {
|
||||||
|
let cls = "text-blue-500 dark:text-blue-400"; // number
|
||||||
|
if (match.startsWith('"')) {
|
||||||
|
if (match.endsWith(":")) {
|
||||||
|
cls = "text-foreground"; // key
|
||||||
|
} else {
|
||||||
|
cls = "text-green-600 dark:text-green-400"; // string
|
||||||
|
}
|
||||||
|
} else if (/true|false/.test(match)) {
|
||||||
|
cls = "text-purple-500 dark:text-purple-400"; // boolean
|
||||||
|
} else if (match === "null") {
|
||||||
|
cls = "text-muted-foreground italic"; // null
|
||||||
|
}
|
||||||
|
return `<span class="${cls}">${match}</span>`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultsJsonView({ columns, rows }: Props) {
|
||||||
|
const jsonData = useMemo(() => {
|
||||||
|
return rows.map((row) => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
columns.forEach((col, i) => {
|
||||||
|
obj[col] = row[i];
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
}, [columns, rows]);
|
||||||
|
|
||||||
|
const highlighted = useMemo(
|
||||||
|
() => syntaxHighlight(JSON.stringify(jsonData, null, 2)),
|
||||||
|
[jsonData]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b px-3 py-1">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{rows.length} row{rows.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<pre
|
||||||
|
className="p-3 text-xs leading-5 font-mono"
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ResultsTable } from "./ResultsTable";
|
import { ResultsTable } from "./ResultsTable";
|
||||||
|
import { ResultsJsonView } from "./ResultsJsonView";
|
||||||
import type { QueryResult } from "@/types";
|
import type { QueryResult } from "@/types";
|
||||||
import { Loader2, AlertCircle } from "lucide-react";
|
import { Loader2, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
@@ -6,6 +7,7 @@ interface Props {
|
|||||||
result?: QueryResult | null;
|
result?: QueryResult | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
viewMode?: "table" | "json";
|
||||||
onCellDoubleClick?: (
|
onCellDoubleClick?: (
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
colIndex: number,
|
colIndex: number,
|
||||||
@@ -18,6 +20,7 @@ export function ResultsPanel({
|
|||||||
result,
|
result,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
viewMode = "table",
|
||||||
onCellDoubleClick,
|
onCellDoubleClick,
|
||||||
highlightedCells,
|
highlightedCells,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -57,6 +60,15 @@ export function ResultsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewMode === "json") {
|
||||||
|
return (
|
||||||
|
<ResultsJsonView
|
||||||
|
columns={result.columns}
|
||||||
|
rows={result.rows}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResultsTable
|
<ResultsTable
|
||||||
columns={result.columns}
|
columns={result.columns}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useTableData } from "@/hooks/use-table-data";
|
import { useTableData } from "@/hooks/use-table-data";
|
||||||
import { ResultsTable } from "@/components/results/ResultsTable";
|
import { ResultsTable } from "@/components/results/ResultsTable";
|
||||||
|
import { ResultsJsonView } from "@/components/results/ResultsJsonView";
|
||||||
import { PaginationControls } from "./PaginationControls";
|
import { PaginationControls } from "./PaginationControls";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -9,7 +10,7 @@ import { getTableColumns } from "@/lib/tauri";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus } from "lucide-react";
|
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus, Table2, Braces } from "lucide-react";
|
||||||
import { InsertRowDialog } from "./InsertRowDialog";
|
import { InsertRowDialog } from "./InsertRowDialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -41,6 +42,7 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
>(new Map());
|
>(new Map());
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [insertDialogOpen, setInsertDialogOpen] = useState(false);
|
const [insertDialogOpen, setInsertDialogOpen] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<"table" | "json">("table");
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading, error } = useTableData({
|
const { data, isLoading, error } = useTableData({
|
||||||
@@ -197,26 +199,54 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
{data && data.columns.length > 0 && (
|
{data && data.columns.length > 0 && (
|
||||||
<DropdownMenu>
|
<>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
className="h-6 gap-1 text-xs"
|
variant="ghost"
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
||||||
|
Export CSV
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleExport("json")}>
|
||||||
|
Export JSON
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<div className="flex items-center rounded-md border text-xs">
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
|
viewMode === "table"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
title="Table view"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Table2 className="h-3 w-3" />
|
||||||
Export
|
Table
|
||||||
</Button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
<button
|
||||||
<DropdownMenuContent align="start">
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
<DropdownMenuItem onClick={() => handleExport("csv")}>
|
viewMode === "json"
|
||||||
Export CSV
|
? "bg-muted text-foreground"
|
||||||
</DropdownMenuItem>
|
: "text-muted-foreground hover:text-foreground"
|
||||||
<DropdownMenuItem onClick={() => handleExport("json")}>
|
}`}
|
||||||
Export JSON
|
onClick={() => setViewMode("json")}
|
||||||
</DropdownMenuItem>
|
title="JSON view"
|
||||||
</DropdownMenuContent>
|
>
|
||||||
</DropdownMenu>
|
<Braces className="h-3 w-3" />
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<Button
|
<Button
|
||||||
@@ -269,18 +299,25 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
|||||||
{String(error)}
|
{String(error)}
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<ResultsTable
|
viewMode === "json" ? (
|
||||||
columns={data.columns}
|
<ResultsJsonView
|
||||||
types={data.types}
|
columns={data.columns}
|
||||||
rows={data.rows}
|
rows={data.rows}
|
||||||
onCellDoubleClick={handleCellDoubleClick}
|
/>
|
||||||
highlightedCells={highlightedCells}
|
) : (
|
||||||
externalSort={{
|
<ResultsTable
|
||||||
column: sortColumn,
|
columns={data.columns}
|
||||||
direction: sortDirection,
|
types={data.types}
|
||||||
onSort: handleSort,
|
rows={data.rows}
|
||||||
}}
|
onCellDoubleClick={handleCellDoubleClick}
|
||||||
/>
|
highlightedCells={highlightedCells}
|
||||||
|
externalSort={{
|
||||||
|
column: sortColumn,
|
||||||
|
direction: sortDirection,
|
||||||
|
onSort: handleSort,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
|
|||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark } from "lucide-react";
|
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces } from "lucide-react";
|
||||||
import { format as formatSql } from "sql-formatter";
|
import { format as formatSql } from "sql-formatter";
|
||||||
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
||||||
import {
|
import {
|
||||||
@@ -49,6 +49,7 @@ export function WorkspacePanel({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
|
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
|
||||||
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
||||||
|
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
|
|
||||||
const queryMutation = useQueryExecution();
|
const queryMutation = useQueryExecution();
|
||||||
@@ -290,7 +291,7 @@ export function WorkspacePanel({
|
|||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
||||||
{(explainData || result || error) && (
|
{(explainData || result || error) && (
|
||||||
<div className="flex border-b text-xs">
|
<div className="flex items-center border-b text-xs">
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`px-3 py-1 font-medium ${
|
||||||
resultView === "results"
|
resultView === "results"
|
||||||
@@ -313,6 +314,34 @@ export function WorkspacePanel({
|
|||||||
Explain
|
Explain
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{resultView === "results" && result && result.columns.length > 0 && (
|
||||||
|
<div className="ml-auto mr-2 flex items-center rounded-md border">
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
|
resultViewMode === "table"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setResultViewMode("table")}
|
||||||
|
title="Table view"
|
||||||
|
>
|
||||||
|
<Table2 className="h-3 w-3" />
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
|
resultViewMode === "json"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setResultViewMode("json")}
|
||||||
|
title="JSON view"
|
||||||
|
>
|
||||||
|
<Braces className="h-3 w-3" />
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{resultView === "explain" && explainData ? (
|
{resultView === "explain" && explainData ? (
|
||||||
@@ -322,6 +351,7 @@ export function WorkspacePanel({
|
|||||||
result={result}
|
result={result}
|
||||||
error={error}
|
error={error}
|
||||||
isLoading={queryMutation.isPending && resultView === "results"}
|
isLoading={queryMutation.isPending && resultView === "results"}
|
||||||
|
viewMode={resultViewMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
Reference in New Issue
Block a user