diff --git a/src/components/ResultGrid/ResultGrid.tsx b/src/components/ResultGrid/ResultGrid.tsx index be129adae..e9170ed2e 100644 --- a/src/components/ResultGrid/ResultGrid.tsx +++ b/src/components/ResultGrid/ResultGrid.tsx @@ -19,7 +19,6 @@ import { } from "@tanstack/react-table" import type { ColumnDefinition } from "../../utils/questdb/types" -import { unescapeHtml } from "../../utils/escapeHtml" import type { CellValue, ResultGridDataSource, ResultGridRow } from "./types" import { clampColumnWidths, @@ -27,6 +26,7 @@ import { isLeftAligned, formatCellValue, formatColumnType, + toSingleLineDisplay, } from "./inlineGridUtils" import { useGridKeyboardNav } from "./useGridKeyboardNav" import { @@ -107,7 +107,7 @@ const GridCell = React.memo(function GridCell({ const colType = col?.type ?? "" const align = isLeftAligned(colType) ? "left" : "right" const displayValue = loaded - ? unescapeHtml(formatCellValue(rawValue, col, colWidth)) + ? toSingleLineDisplay(formatCellValue(rawValue, col, colWidth)) : "" return ( { expect(out).toBe("ARRAY[1.0,2.0]") }) - it("unescapes HTML entities so copy matches the displayed text", () => { - // Given a string value carrying HTML entities, as the grid displays it - // unescaped + it("preserves HTML entity-looking text", () => { + // Given a string value containing entity-looking text // When formatting it for copy const out = formatCellValueForCopy("a&b<c>d", col("x", "VARCHAR")) - // Then the copied text is unescaped, matching the cell - expect(out).toBe("a&bd") + // Then the copied text is preserved exactly as returned by the server + expect(out).toBe("a&b<c>d") + }) + + it("preserves embedded line breaks so copy keeps the raw value", () => { + const out = formatCellValueForCopy( + "line1\nline2\r\nline3", + col("x", "VARCHAR"), + ) + + expect(out).toBe("line1\nline2\r\nline3") + }) +}) + +describe("toSingleLineDisplay", () => { + it("collapses line breaks to a single space so cells render on one line", () => { + expect(toSingleLineDisplay("line1\nline2")).toBe("line1 line2") + expect(toSingleLineDisplay("line1\r\nline2")).toBe("line1 line2") + expect(toSingleLineDisplay("line1\rline2")).toBe("line1 line2") + expect(toSingleLineDisplay("a\n\n\nb")).toBe("a b") + }) + + it("preserves leading and interior spaces (rendered via white-space: pre)", () => { + expect(toSingleLineDisplay(" indented")).toBe(" indented") + expect(toSingleLineDisplay("a b")).toBe("a b") + }) + + it("leaves values without line breaks untouched", () => { + expect(toSingleLineDisplay("a&bd")).toBe("a&bd") + expect(toSingleLineDisplay("")).toBe("") }) }) diff --git a/src/components/ResultGrid/inlineGridUtils.ts b/src/components/ResultGrid/inlineGridUtils.ts index 7ed7e4ebe..b44c2b0d9 100644 --- a/src/components/ResultGrid/inlineGridUtils.ts +++ b/src/components/ResultGrid/inlineGridUtils.ts @@ -1,5 +1,4 @@ import type { ColumnDefinition } from "../../utils/questdb/types" -import { unescapeHtml } from "../../utils/escapeHtml" import type { CellValue, ResultGridRow } from "./types" const CELL_WIDTH_MULTIPLIER = 9.6 @@ -170,8 +169,11 @@ export const formatCellValueForCopy = ( if (value === null) return "null" if (col && isArrayColumn(col)) { - return unescapeHtml(formatArrayFull(value, col)) + return formatArrayFull(value, col) } - return unescapeHtml(formatCellValue(value, col)) + return formatCellValue(value, col) } + +export const toSingleLineDisplay = (text: string): string => + text.replace(/[\r\n]+/g, " ") diff --git a/src/components/ResultGrid/resultPageMarkdown.test.ts b/src/components/ResultGrid/resultPageMarkdown.test.ts index 9ec29c03c..788517784 100644 --- a/src/components/ResultGrid/resultPageMarkdown.test.ts +++ b/src/components/ResultGrid/resultPageMarkdown.test.ts @@ -44,16 +44,27 @@ describe("buildResultPageMarkdown", () => { }) it("renders a QUERY PLAN result as a fenced code block", () => { - // Given a single QUERY PLAN column with two plan lines + // Given a query plan containing structural indentation, raw operators, and + // entity-looking SQL literals const columns = [col("QUERY PLAN", "STRING")] - const rows = [["Async JIT Filter"], [" Row forward scan"]] + const rows = [ + ["Async JIT Filter"], + [" filter: x < y"], + [" functions: [' ','<','>']"], + ] // When building the markdown const md = buildResultPageMarkdown(columns, rows) - // Then it is a fenced block, one line per row, no table pipes + // Then every plan line is preserved verbatim in the fenced block expect(md).toBe( - ["```", "Async JIT Filter", " Row forward scan", "```"].join("\n"), + [ + "```", + "Async JIT Filter", + " filter: x < y", + " functions: [' ','<','>']", + "```", + ].join("\n"), ) }) @@ -69,17 +80,17 @@ describe("buildResultPageMarkdown", () => { expect(md).toBe(["| a | b |", "| - | - |"].join("\n")) }) - it("unescapes HTML entities so exported text matches the grid", () => { - // Given a string column whose value carries HTML entities + it("preserves HTML entity-looking text", () => { + // Given a string column whose value contains entity-looking text const columns = [col("note", "VARCHAR")] const rows = [["price>100"]] // When building the markdown const md = buildResultPageMarkdown(columns, rows) - // Then the cell is unescaped, matching what the grid displays + // Then the cell is preserved exactly as returned by the server expect(md).toBe( - ["| note |", "| --------- |", "| price>100 |"].join("\n"), + ["| note |", "| ------------ |", "| price>100 |"].join("\n"), ) }) diff --git a/src/components/ResultGrid/resultPageMarkdown.ts b/src/components/ResultGrid/resultPageMarkdown.ts index ca0aebb42..8796cc666 100644 --- a/src/components/ResultGrid/resultPageMarkdown.ts +++ b/src/components/ResultGrid/resultPageMarkdown.ts @@ -1,5 +1,4 @@ import type { ColumnDefinition } from "../../utils/questdb/types" -import { unescapeHtml } from "../../utils/escapeHtml" import type { ResultGridRow } from "./types" import { formatCellValueForCopy } from "./inlineGridUtils" @@ -11,7 +10,7 @@ const buildQueryPlanMarkdown = (rows: ResultGridRow[]): string => { for (const row of rows) { const cell = row[0] if (cell === null || cell === undefined) continue - lines.push(unescapeHtml(String(cell))) + lines.push(String(cell)) } lines.push("```") return lines.join("\n") diff --git a/src/components/ResultGrid/styles.ts b/src/components/ResultGrid/styles.ts index 3aecc8ade..b9dcb2458 100644 --- a/src/components/ResultGrid/styles.ts +++ b/src/components/ResultGrid/styles.ts @@ -229,7 +229,7 @@ export const CellText = styled.div` width: 100%; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + white-space: pre; ` export const FrozenShadow = styled.div` diff --git a/src/js/console/grid.js b/src/js/console/grid.js index 1f84ab9a1..99e82a362 100644 --- a/src/js/console/grid.js +++ b/src/js/console/grid.js @@ -22,11 +22,11 @@ * ******************************************************************************/ import { copyToClipboard } from "../../utils/copyToClipboard" -import { unescapeHtml } from "../../utils/escapeHtml" import { toast } from "../../components" import { trackEvent } from "../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events" import { buildResultPageMarkdown } from "../../components/ResultGrid/resultPageMarkdown" +import { toSingleLineDisplay } from "../../components/ResultGrid/inlineGridUtils" const hashString = (str) => { let hash = 0 @@ -1107,7 +1107,7 @@ export function grid(rootElement, _paginationFn, id) { if (cellData !== null) { const layoutEntry = getLayoutEntry() const columnWidth = layoutEntry.deviants[column.name] ?? null - cell.textContent = unescapeHtml( + cell.textContent = toSingleLineDisplay( getDisplayedCellValue(column, cellData, columnWidth), ) diff --git a/src/styles/_grid.scss b/src/styles/_grid.scss index c8e4365e2..35a80a049 100644 --- a/src/styles/_grid.scss +++ b/src/styles/_grid.scss @@ -56,12 +56,12 @@ $col-resize-ghost-z-index: ($panel-left-z-index + 1); color: #939393; } -.qg-header-row, -.qg-r { +.qg-header-row { white-space: nowrap; } .qg-r { + white-space: pre; display: flex; border-bottom: 1px solid #44475a; position: absolute; diff --git a/src/utils/escapeHtml.ts b/src/utils/escapeHtml.ts index 20131c381..b98771587 100644 --- a/src/utils/escapeHtml.ts +++ b/src/utils/escapeHtml.ts @@ -9,16 +9,3 @@ export const escapeHtml = (text: string): string => { return text.replace(/[&<>"']/g, (m) => map[m]) } - -export const unescapeHtml = (text: string): string => { - const map: Record = { - "&": "&", - "<": "<", - ">": ">", - """: '"', - "'": "'", - " ": "\u00A0", - } - - return text.replace(/&(amp|lt|gt|quot|#039|nbsp);/g, (m) => map[m]) -}