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:
2026-02-13 13:05:52 +03:00
parent e02225a3b9
commit a2371f00df
4 changed files with 173 additions and 34 deletions

View 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>
);
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>