diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e94b16..7bae99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Light/dark theme toggle (VS Code–style palettes, `localStorage` persistence) - Roadmap: **dependency currency** policy (prefer latest packages + majors plan) - Opt-in process metrics (`PAPERCUT_METRICS=1`) — `GET /api/metrics` counters only (`pastes_created`, `unlocks_ok`, `rate_limited`); disabled by default; no content/IP logging +- Log canvas **wrap / no-wrap** toggle (dense horizontal scroll), sticky line-number gutter, preference in `localStorage` ### Planned -See [ROADMAP.md](./ROADMAP.md) (dependency updates, scale features, etc.). +See [ROADMAP.md](./ROADMAP.md) (dependency updates, canvas tools, scale features, etc.). ## [1.1.0] - 2026-07-15 diff --git a/ROADMAP.md b/ROADMAP.md index 51de569..b5f4afb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,7 +76,7 @@ Focus: log analysis depth. | # | Feature | Area | Notes | |---|---------|------|--------| -| 1.2.1 | **Column / wrap mode**, sticky header | ui | Dense log reading | +| 1.2.1 | **Column / wrap mode**, sticky header | ui | ✅ Done (wrap/nowrap + sticky gutter, `localStorage`) | | 1.2.2 | **Bookmark / pin lines** (local only) | ui | Session or localStorage | | 1.2.3 | **Multi-file or multi-paste compare** (side-by-side) | ui, api | Optional second paste id | | 1.2.4 | **Custom highlight rules** (user regex → color) | ui | Per-browser config | diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx index fc5f939..aa91262 100644 --- a/server/components/LogCanvas.tsx +++ b/server/components/LogCanvas.tsx @@ -14,6 +14,11 @@ import { type LevelFilterState, type LineSelection, } from "@/lib/log-lines"; +import { + persistWrapMode, + resolveInitialWrapMode, + type WrapMode, +} from "@/lib/wrap-mode"; import { ThemeToggle } from "./ThemeToggle"; import { VirtualLogList } from "./VirtualLogList"; @@ -62,6 +67,19 @@ export function LogCanvas({ const [selection, setSelection] = useState(null); const [scrollToLine, setScrollToLine] = useState(null); const [copied, setCopied] = useState(false); + const [wrapMode, setWrapMode] = useState("wrap"); + const [wrapMounted, setWrapMounted] = useState(false); + + useEffect(() => { + setWrapMode(resolveInitialWrapMode()); + setWrapMounted(true); + }, []); + + function toggleWrapMode() { + const next: WrapMode = wrapMode === "wrap" ? "nowrap" : "wrap"; + setWrapMode(next); + persistWrapMode(next); + } const query = useMemo(() => parseSearchQuery(search), [search]); const visibleLines = useMemo( @@ -134,9 +152,16 @@ export function LogCanvas({ URL.revokeObjectURL(url); } + const wrapLabel = + wrapMode === "wrap" ? "No wrap" : "Wrap"; + const wrapTitle = + wrapMode === "wrap" + ? "Switch to no-wrap (horizontal scroll, dense columns)" + : "Switch to soft-wrap"; + return (
-
+

PaperCut

paste/{id}

@@ -212,7 +237,7 @@ export function LogCanvas({
-
+
@@ -231,11 +256,22 @@ export function LogCanvas({ bad regex ) : null} +
diff --git a/server/components/LogLineRow.tsx b/server/components/LogLineRow.tsx index 59b78cc..c14419c 100644 --- a/server/components/LogLineRow.tsx +++ b/server/components/LogLineRow.tsx @@ -3,6 +3,7 @@ import { memo } from "react"; import { ansiToHtml } from "@/lib/ansi-html"; import type { ParsedLogLine } from "@/lib/log-lines"; +import type { WrapMode } from "@/lib/wrap-mode"; import { JsonInspector } from "./JsonInspector"; const LEVEL_BAR: Record = { @@ -18,6 +19,7 @@ const LEVEL_BAR: Record = { interface LogLineRowProps { line: ParsedLogLine; selected: boolean; + wrapMode: WrapMode; onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void; style?: React.CSSProperties; } @@ -25,38 +27,53 @@ interface LogLineRowProps { export const LogLineRow = memo(function LogLineRow({ line, selected, + wrapMode, onLineNumberClick, style, }: LogLineRowProps) { + const nowrap = wrapMode === "nowrap"; + const contentClass = nowrap + ? "min-w-0 flex-1 whitespace-pre pr-3 text-vscode-fg" + : "min-w-0 flex-1 whitespace-pre-wrap break-all pr-3 text-vscode-fg"; + + // Opaque sticky gutter so content does not show through on horizontal scroll. + const gutterBg = selected ? "bg-vscode-selection" : "bg-vscode-bg"; + return (
- - + + +
{line.isJson && line.jsonValue !== null ? ( 120 ? `${line.plain.slice(0, 120)}…` : line.plain} + collapsedLabel={ + line.plain.length > 120 ? `${line.plain.slice(0, 120)}…` : line.plain + } /> ) : ( )} diff --git a/server/components/VirtualLogList.tsx b/server/components/VirtualLogList.tsx index fd5c848..73a0ebc 100644 --- a/server/components/VirtualLogList.tsx +++ b/server/components/VirtualLogList.tsx @@ -4,11 +4,13 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useEffect, useRef } from "react"; import type { LineSelection, ParsedLogLine } from "@/lib/log-lines"; import { isLineSelected } from "@/lib/log-lines"; +import type { WrapMode } from "@/lib/wrap-mode"; import { LogLineRow } from "./LogLineRow"; interface VirtualLogListProps { lines: ParsedLogLine[]; selection: LineSelection | null; + wrapMode: WrapMode; scrollToLineNumber: number | null; onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void; } @@ -18,6 +20,7 @@ const ROW_ESTIMATE = 28; export function VirtualLogList({ lines, selection, + wrapMode, scrollToLineNumber, onLineNumberClick, }: VirtualLogListProps) { @@ -38,10 +41,21 @@ export function VirtualLogList({ } }, [scrollToLineNumber, lines, virtualizer]); + // Re-measure row heights when wrap mode changes (wrap vs single-line height). + useEffect(() => { + virtualizer.measure(); + }, [wrapMode, virtualizer]); + return ( -
+
{virtualizer.getVirtualItems().map((item) => { @@ -51,12 +65,17 @@ export function VirtualLogList({ key={line.index} data-index={item.index} ref={virtualizer.measureElement} - className="absolute top-0 left-0 w-full" + className={ + wrapMode === "nowrap" + ? "absolute top-0 left-0 min-w-full w-max" + : "absolute top-0 left-0 w-full" + } style={{ transform: `translateY(${item.start}px)` }} >
diff --git a/server/lib/wrap-mode.test.ts b/server/lib/wrap-mode.test.ts new file mode 100644 index 0000000..4af38d4 --- /dev/null +++ b/server/lib/wrap-mode.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + WRAP_MODE_STORAGE_KEY, + isWrapMode, + persistWrapMode, + readStoredWrapMode, + resolveInitialWrapMode, +} from "./wrap-mode"; + +describe("isWrapMode", () => { + it("accepts only wrap and nowrap", () => { + expect(isWrapMode("wrap")).toBe(true); + expect(isWrapMode("nowrap")).toBe(true); + expect(isWrapMode("column")).toBe(false); + expect(isWrapMode("")).toBe(false); + expect(isWrapMode(null)).toBe(false); + }); +}); + +describe("wrap mode storage", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("reads and persists via localStorage", () => { + const store = new Map(); + vi.stubGlobal("window", { + localStorage: { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + }, + }); + + expect(readStoredWrapMode()).toBeNull(); + expect(resolveInitialWrapMode()).toBe("wrap"); + + persistWrapMode("nowrap"); + expect(store.get(WRAP_MODE_STORAGE_KEY)).toBe("nowrap"); + expect(readStoredWrapMode()).toBe("nowrap"); + expect(resolveInitialWrapMode()).toBe("nowrap"); + }); + + it("ignores invalid stored values", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => "nope", + setItem: () => undefined, + }, + }); + expect(readStoredWrapMode()).toBeNull(); + expect(resolveInitialWrapMode()).toBe("wrap"); + }); + + it("tolerates storage failures", () => { + vi.stubGlobal("window", { + localStorage: { + getItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + }, + }); + expect(readStoredWrapMode()).toBeNull(); + expect(() => persistWrapMode("wrap")).not.toThrow(); + }); +}); diff --git a/server/lib/wrap-mode.ts b/server/lib/wrap-mode.ts new file mode 100644 index 0000000..84842b9 --- /dev/null +++ b/server/lib/wrap-mode.ts @@ -0,0 +1,31 @@ +export type WrapMode = "wrap" | "nowrap"; + +export const WRAP_MODE_STORAGE_KEY = "papercut-wrap-mode"; + +export function isWrapMode(value: unknown): value is WrapMode { + return value === "wrap" || value === "nowrap"; +} + +export function readStoredWrapMode(): WrapMode | null { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(WRAP_MODE_STORAGE_KEY); + return isWrapMode(raw) ? raw : null; + } catch { + return null; + } +} + +export function persistWrapMode(mode: WrapMode): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WRAP_MODE_STORAGE_KEY, mode); + } catch { + /* private mode / blocked storage */ + } +} + +/** Soft-wrap is the default (matches previous canvas behavior). */ +export function resolveInitialWrapMode(): WrapMode { + return readStoredWrapMode() ?? "wrap"; +}