diff --git a/README.md b/README.md index c36e3cb1..a71a385a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index a4f367f8..3444f6a3 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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. diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index b7c43c8d..084d7229 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -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; @@ -217,6 +222,25 @@ export function ResultsGrid({ setActiveFilterCol(null); }, []); + const copyRow = (row: Record, rowIndex?: number): Record => + 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>[]>(() => { return result.fields.map((field) => ({ // `id` + `accessorFn`, never `accessorKey`: TanStack reads a DOT in an @@ -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, + ), + ) + } />
{cardVirtualizer.getVirtualItems().map((virtualRow) => ( -
copyRow(result.rows[virtualRow.index])} + onCopy={copyText} > - setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })} - maskingActive={effectiveMaskingEnabled} - sensitiveColumns={sensitiveColumns} - /> -
+
+ setSelectedRow({ row: result.rows[virtualRow.index], index: virtualRow.index })} + maskingActive={effectiveMaskingEnabled} + sensitiveColumns={sensitiveColumns} + /> +
+ ))}
@@ -635,16 +673,17 @@ export function ResultsGrid({ const className = isMasked ? "text-fg-muted italic" : formatCellValue(row[field]).className; return ( -
- {displayValue} -
+ copyRow(row)} onCopy={copyText}> +
+ {displayValue} +
+
); })} @@ -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) => ( -
copyRow(row.original, row.index)} + onCopy={copyText} > - {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} ); diff --git a/src/components/results-grid/ResultContextMenu.tsx b/src/components/results-grid/ResultContextMenu.tsx new file mode 100644 index 00000000..66660090 --- /dev/null +++ b/src/components/results-grid/ResultContextMenu.tsx @@ -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; + column?: string; + onCopy: (text: string) => void; +} + +export function ResultContextMenu({ children, getRow, column, onCopy }: ResultContextMenuProps) { + return ( + + {children} + event.stopPropagation()}> + {column !== undefined && ( + onCopy(clipboardCellText(cellOf(getRow(), column)))}> + Copy Cell + + )} + onCopy(jsonText(getRow(), 2))}>Copy Row as JSON + + + ); +} diff --git a/src/components/results-grid/StatsBar.tsx b/src/components/results-grid/StatsBar.tsx index 437ef98a..2a1b768f 100644 --- a/src/components/results-grid/StatsBar.tsx +++ b/src/components/results-grid/StatsBar.tsx @@ -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..."; @@ -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; @@ -41,6 +49,7 @@ export function StatsBar({ onClearFilters, viewMode, onSetViewMode, + onCopyRows, hasSensitive, effectiveMaskingEnabled, userCanToggle, @@ -54,7 +63,7 @@ export function StatsBar({ const warningDetail = warnings.map(describeWarning).join("\n"); return ( -
+
@@ -85,6 +94,29 @@ export function StatsBar({
+ {onCopyRows && ( + + + + + + {(["json", "yaml", "csv"] as const).map((format) => ( + onCopyRows(format)}> + Copy as {format.toUpperCase()} + + ))} + + + )} {hasSensitive && (userCanToggle && onToggleMasking ? (