Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ Standalone application only: the embedded `@libredb/studio` package carries no a
- **Column Filtering**: Per-column text filters on query results for instant data exploration.
- **Interactive Pivot Table**: Client-side pivoting with 5 aggregation functions (COUNT, SUM, AVG, MIN, MAX) and SQL generation.
- **Expert Exporter**: Instant CSV and JSON exports for reporting. CSV import and result export offer comma (default), semicolon and tab separators.
- **Result Clipboard**: Right-click a cell to copy its full value or its row as JSON. **Copy rows** copies loaded, filtered and sorted results as JSON, YAML or CSV, respecting the active display mask.

### Advanced Data Visualization
- **8 Chart Types**: Bar, Line, Pie, Area, Scatter, Histogram, Stacked Bar, and Stacked Area charts powered by Recharts.
Expand Down
1 change: 1 addition & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@

### 15. Professional Data Export
* **Format Versatility:** Instantly export query result sets to CSV, JSON, SQL `INSERT` statements, or a generated `CREATE TABLE` DDL.
* **Clipboard:** Right-click a grid cell for **Copy Cell** or **Copy Row as JSON**; mobile cards also offer row copy. **Copy rows** copies the loaded result rows in the current filter and sort order as JSON, YAML or CSV, without fetching more pages. Active display masking also applies to copied data, including temporarily revealed cells.
* **CSV Delimiters:** Choose comma (default), semicolon or tab in the import preview or result export menu. Changing the import delimiter reparses the preview and retains the header setting and column mappings. Export quoting, formula neutralization and UTF-8 encoding apply to every separator.
* **Developer-Ready:** Clean data output optimized for external analysis, reporting, or database migrations.
* **Formula-Safe CSV:** A cell whose value starts with `=`, `+`, `-`, `@`, a tab or a carriage return is written with a leading apostrophe, so a spreadsheet shows it as text instead of evaluating it when the file is opened; this is unconditional and has no setting, and a plain number such as `-12.5` is left exactly as it is.
Expand Down
117 changes: 81 additions & 36 deletions src/components/ResultsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { ResultCard } from "@/components/results-grid/ResultCard";
import { RowDetailSheet } from "@/components/results-grid/RowDetailSheet";
import { StatsBar, LoadMoreFooter } from "@/components/results-grid/StatsBar";
import { describeWarning, formatCellValue } from "@/components/results-grid/utils";
import { ResultContextMenu } from "@/components/results-grid/ResultContextMenu";
import { writeToClipboard } from "@/components/copy-button";
import { resultClipboardText } from "@/lib/export/clipboard";
import { cellOf } from "@/lib/export/csv";
import { toast } from "sonner";

export interface CellChange {
rowIndex: number;
Expand Down Expand Up @@ -217,6 +222,25 @@ export function ResultsGrid({
setActiveFilterCol(null);
}, []);

const copyRow = (row: Record<string, unknown>, rowIndex?: number): Record<string, unknown> =>
Object.fromEntries(
result.fields.map((field) => {
const change = rowIndex === undefined ? undefined : getCellChange(rowIndex, field);
const value = change === undefined ? cellOf(row, field) : change.newValue;
const pattern = sensitiveColumns.get(field);
// A temporary on-screen reveal must not bypass the export mask.
return [
field,
effectiveMaskingEnabled && pattern && value != null ? maskValueByPattern(value, pattern) : value,
];
}),
);

const copyText = async (text: string) => {
if (await writeToClipboard(text)) toast.success("Copied to clipboard");
else toast.error("Could not copy to clipboard");
};

const columns = useMemo<ColumnDef<typeof tableFeatureSet, Record<string, unknown>>[]>(() => {
return result.fields.map((field) => ({
// `id` + `accessorFn`, never `accessorKey`: TanStack reads a DOT in an
Expand Down Expand Up @@ -536,34 +560,48 @@ export function ResultsGrid({
pendingChanges={pendingChanges}
onApplyChanges={onApplyChanges}
onDiscardChanges={onDiscardChanges}
onCopyRows={(format) =>
copyText(
resultClipboardText(
format,
rows.map((row) => copyRow(row.original, row.index)),
result.fields,
),
)
}
/>

<div ref={cardContainerRef} className={cn("flex-1 overflow-auto p-4 md:hidden", viewMode !== "card" && "hidden")}>
<div style={{ height: `${cardVirtualizer.getTotalSize()}px`, position: "relative" }}>
{cardVirtualizer.getVirtualItems().map((virtualRow) => (
<div
<ResultContextMenu
key={virtualRow.index}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
padding: "4px 0",
}}
getRow={() => copyRow(result.rows[virtualRow.index])}
onCopy={copyText}
>
<ResultCard
row={result.rows[virtualRow.index]}
fields={result.fields}
primaryColumn={primaryColumn}
idColumn={idColumn}
index={virtualRow.index}
onSelect={() => setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })}
maskingActive={effectiveMaskingEnabled}
sensitiveColumns={sensitiveColumns}
/>
</div>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
padding: "4px 0",
}}
>
<ResultCard
row={result.rows[virtualRow.index]}
fields={result.fields}
primaryColumn={primaryColumn}
idColumn={idColumn}
index={virtualRow.index}
onSelect={() => setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })}
maskingActive={effectiveMaskingEnabled}
sensitiveColumns={sensitiveColumns}
/>
</div>
</ResultContextMenu>
))}
</div>
</div>
Expand Down Expand Up @@ -635,16 +673,17 @@ export function ResultsGrid({
const className = isMasked ? "text-fg-muted italic" : formatCellValue(row[field]).className;

return (
<div
key={field}
className={cn(
"h-full px-4 py-3 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden flex items-center",
idx === 0 && "sticky left-0 z-10 bg-sunken shadow-[2px_0_8px_rgba(0,0,0,0.3)]",
"min-w-[120px]",
)}
>
<span className={className}>{displayValue}</span>
</div>
<ResultContextMenu key={field} column={field} getRow={() => copyRow(row)} onCopy={copyText}>
<div
className={cn(
"h-full px-4 py-3 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden flex items-center",
idx === 0 && "sticky left-0 z-10 bg-sunken shadow-[2px_0_8px_rgba(0,0,0,0.3)]",
"min-w-[120px]",
)}
>
<span className={className}>{displayValue}</span>
</div>
</ResultContextMenu>
);
})}
</button>
Expand Down Expand Up @@ -697,13 +736,19 @@ export function ResultsGrid({
className="flex group hover:bg-brand-tint/[0.03] transition-colors border-b border-hairline"
>
{row.getVisibleCells().map((cell) => (
<div
<ResultContextMenu
key={cell.id}
style={{ width: cell.column.getSize(), minWidth: cell.column.getSize() }}
className="h-full px-4 py-2 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden group-hover:border-hairline-strong flex items-center shrink-0"
column={cell.column.id}
getRow={() => copyRow(row.original, row.index)}
onCopy={copyText}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
<div
style={{ width: cell.column.getSize(), minWidth: cell.column.getSize() }}
className="h-full px-4 py-2 border-r border-hairline text-xs font-mono whitespace-nowrap overflow-hidden group-hover:border-hairline-strong flex items-center shrink-0"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</ResultContextMenu>
))}
</div>
);
Expand Down
30 changes: 30 additions & 0 deletions src/components/results-grid/ResultContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import type { ReactElement } from "react";
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from "@/components/ui/context-menu";
import { clipboardCellText } from "@/lib/export/clipboard";
import { cellOf } from "@/lib/export/csv";
import { jsonText } from "@/lib/export/json";

interface ResultContextMenuProps {
children: ReactElement;
getRow: () => Record<string, unknown>;
column?: string;
onCopy: (text: string) => void;
}

export function ResultContextMenu({ children, getRow, column, onCopy }: ResultContextMenuProps) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent onClick={(event) => event.stopPropagation()}>
{column !== undefined && (
<ContextMenuItem onSelect={() => onCopy(clipboardCellText(cellOf(getRow(), column)))}>
Copy Cell
</ContextMenuItem>
)}
<ContextMenuItem onSelect={() => onCopy(jsonText(getRow(), 2))}>Copy Row as JSON</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
34 changes: 33 additions & 1 deletion src/components/results-grid/StatsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { ChevronDown, LayoutGrid, Table2, LoaderCircle, EyeOff, Eye, Save, X, Fu
import { Button } from "@/components/ui/button";
import type { CellChange } from "@/components/ResultsGrid";
import { describeWarning } from "@/components/results-grid/utils";
import type { ClipboardFormat } from "@/lib/export/clipboard";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";

const MASKED_LABEL = "MASKED";
const LOADING_LABEL = "Loading...";
Expand All @@ -19,6 +26,7 @@ export interface StatsBarProps {
onClearFilters: () => void;
viewMode: "card" | "table";
onSetViewMode: (mode: "card" | "table") => void;
onCopyRows?: (format: ClipboardFormat) => void;
// Masking props
hasSensitive: boolean;
effectiveMaskingEnabled: boolean;
Expand All @@ -41,6 +49,7 @@ export function StatsBar({
onClearFilters,
viewMode,
onSetViewMode,
onCopyRows,
hasSensitive,
effectiveMaskingEnabled,
userCanToggle,
Expand All @@ -54,7 +63,7 @@ export function StatsBar({
const warningDetail = warnings.map(describeWarning).join("\n");

return (
<div className="flex items-center justify-between px-4 py-2 border-b border-hairline bg-surface text-xs text-fg-muted font-mono">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-2 border-b border-hairline bg-surface text-xs text-fg-muted font-mono">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-success-tint/50" />
Expand Down Expand Up @@ -85,6 +94,29 @@ export function StatsBar({
</div>

<div className="flex items-center gap-2">
{onCopyRows && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1"
disabled={filteredRowCount === 0}
title="Copy loaded rows in the current filter and sort order"
>
Copy rows
<ChevronDown className="w-3 h-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{(["json", "yaml", "csv"] as const).map((format) => (
<DropdownMenuItem key={format} onSelect={() => onCopyRows(format)}>
Copy as {format.toUpperCase()}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
{hasSensitive &&
(userCanToggle && onToggleMasking ? (
<Button
Expand Down
26 changes: 26 additions & 0 deletions src/lib/export/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { stringify as stringifyYaml } from "yaml";
import { asBytes, binaryText } from "./binary";
import { jsonText } from "./json";
import { toCsv } from "./csv";

export type ClipboardFormat = "json" | "yaml" | "csv";

/** The full value, without the grid's truncation or display-only formatting. */
export function clipboardCellText(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (value instanceof Date) return value.toISOString();
const bytes = asBytes(value);
if (bytes !== undefined) return binaryText(bytes);
return typeof value === "object" ? jsonText(value) : String(value);
}

export function resultClipboardText(
format: ClipboardFormat,
rows: Record<string, unknown>[],
fields: string[],
): string {
if (format === "csv") return toCsv(rows, fields);
const json = jsonText(rows, 2);
// Use the same bigint/cycle/Date representation for JSON and YAML.
return format === "yaml" ? stringifyYaml(JSON.parse(json)) : json;
}
Loading
Loading