From c9b0d47de86ed384786d558c2cfe4023b3d95664 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:50:40 +0000 Subject: [PATCH] feat(grid): copy a long cell value without opening the viewer A long value, such as the statement SHOW CREATE TABLE returns, could be copied only by opening the full-content viewer and using its Copy (#739). Cells that offer the expand button now offer Copy beside it, with the same hover visibility, and a check mark for a moment once the value is on the clipboard. It copies the value as stored, not the viewer's reformatted text, and copies bytes as hex, as the viewer does. That hex encoding moves into lib/blob.ts as toHex(), and the viewer uses it too. The click does not reach the row, so copying does not change the selection. The viewer already wraps long lines, which covers the issue's expand/wrap ask. Closes #739. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SCNjpC4VgppHSFs7P6rkZg --- src/components/grid/CellViewerModal.tsx | 10 +--- src/components/grid/TruncatedCell.tsx | 41 ++++++++++++- .../grid/__tests__/TruncatedCell.test.tsx | 59 +++++++++++++++++++ src/lib/blob.ts | 8 +++ 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/src/components/grid/CellViewerModal.tsx b/src/components/grid/CellViewerModal.tsx index 1cfa50a..f0b90d0 100644 --- a/src/components/grid/CellViewerModal.tsx +++ b/src/components/grid/CellViewerModal.tsx @@ -3,7 +3,7 @@ import { Check, Copy, Download, Lock, Pencil, X } from "lucide-react"; import type { editor } from "monaco-editor"; import { useEffect, useMemo, useRef, useState } from "react"; import { format } from "sql-formatter"; -import { detectMime, formatBytes } from "../../lib/blob"; +import { detectMime, formatBytes, toHex } from "../../lib/blob"; import { useThemeStore } from "../../stores/themeStore"; import { BlobViewer } from "./BlobViewer"; @@ -162,12 +162,8 @@ export function CellViewerModal({ if (!isOpen) return null; const handleCopy = async () => { - // Bytes go to the clipboard as hex rather than as `137,80,78,71`. Pasting - // a byte array into anything is useless; hex at least round-trips through - // UNHEX() (#401). - await navigator.clipboard.writeText( - bytes ? bytes.map((b) => b.toString(16).padStart(2, "0")).join("") : viewerContent, - ); + // Bytes go to the clipboard as hex rather than as `137,80,78,71` (#401). + await navigator.clipboard.writeText(bytes ? toHex(bytes) : viewerContent); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/src/components/grid/TruncatedCell.tsx b/src/components/grid/TruncatedCell.tsx index d50f93f..4c81d33 100644 --- a/src/components/grid/TruncatedCell.tsx +++ b/src/components/grid/TruncatedCell.tsx @@ -1,6 +1,6 @@ -import { Maximize2 } from "lucide-react"; +import { Check, Copy, Maximize2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { describeBlob, isBytes } from "../../lib/blob"; +import { describeBlob, isBytes, toHex } from "../../lib/blob"; import { isLongTextSqlType } from "../../lib/sql-types"; interface Props { @@ -101,6 +101,27 @@ export function TruncatedCell({ onViewFull(value === null || value === undefined ? null : formatted, columnName); }; + // Copy sits beside the expand button, so a long value — a SHOW CREATE TABLE + // statement, say — can be taken without opening the viewer first (#739). + // It copies the stored text as-is, not the viewer's reformatted version; + // bytes go as hex, the same as the viewer's Copy. + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + useEffect(() => () => { + if (copyTimer.current) clearTimeout(copyTimer.current); + }, []); + const copyValue = () => { + const text = binary ? toHex(binary) : formatted; + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + if (copyTimer.current) clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 1500); + }, () => {}); + }; + + const iconButton = + "shrink-0 rounded p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-[var(--color-bg-tertiary)] hover:text-[var(--color-text-primary)] focus-visible:opacity-100 group-hover:opacity-100"; + return (
{formatted}
+ {showIcon && !isNull && ( + + )} {showIcon && (