Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 38 additions & 2 deletions server/components/LogCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -62,6 +67,19 @@ export function LogCanvas({
const [selection, setSelection] = useState<LineSelection | null>(null);
const [scrollToLine, setScrollToLine] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const [wrapMode, setWrapMode] = useState<WrapMode>("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(
Expand Down Expand Up @@ -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 (
<div className="flex h-screen min-h-0 flex-col bg-vscode-bg text-vscode-fg">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-vscode-border bg-vscode-sidebar px-4 py-2">
<header className="sticky top-0 z-20 flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-vscode-border bg-vscode-sidebar px-4 py-2">
<div className="min-w-0">
<p className="font-mono text-xs text-vscode-accent">PaperCut</p>
<h1 className="truncate font-mono text-sm">paste/{id}</h1>
Expand Down Expand Up @@ -212,7 +237,7 @@ export function LogCanvas({
</aside>

<div className="flex min-w-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-vscode-border bg-vscode-sidebar px-3 py-2">
<div className="sticky top-0 z-10 flex shrink-0 items-center gap-2 border-b border-vscode-border bg-vscode-sidebar px-3 py-2">
<label className="sr-only" htmlFor="log-search">
Search
</label>
Expand All @@ -231,11 +256,22 @@ export function LogCanvas({
bad regex
</span>
) : null}
<button
type="button"
onClick={toggleWrapMode}
className="shrink-0 rounded border border-vscode-border bg-vscode-line px-2 py-1.5 font-mono text-xs text-vscode-fg hover:border-vscode-accent"
aria-pressed={wrapMode === "nowrap"}
aria-label={wrapTitle}
title={wrapTitle}
>
{!wrapMounted ? "Wrap" : wrapLabel}
</button>
</div>
<div className="min-h-0 flex-1">
<VirtualLogList
lines={visibleLines}
selection={selection}
wrapMode={wrapMode}
scrollToLineNumber={scrollToLine}
onLineNumberClick={onLineNumberClick}
/>
Expand Down
49 changes: 33 additions & 16 deletions server/components/LogLineRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand All @@ -18,45 +19,61 @@ const LEVEL_BAR: Record<string, string> = {
interface LogLineRowProps {
line: ParsedLogLine;
selected: boolean;
wrapMode: WrapMode;
onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void;
style?: React.CSSProperties;
}

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 (
<div
style={style}
data-line={line.lineNumber}
className={`flex min-w-0 items-start border-b border-vscode-line/40 font-mono text-[13px] leading-6 ${
selected ? "bg-vscode-selection/50" : "hover:bg-vscode-line/30"
}`}
nowrap ? "w-max min-w-full" : "w-full"
} ${selected ? "bg-vscode-selection/50" : "hover:bg-vscode-line/30"}`}
>
<button
type="button"
onClick={(e) => onLineNumberClick(line.lineNumber, e.shiftKey)}
className="w-14 shrink-0 select-none pr-2 text-right text-vscode-gutter hover:text-vscode-fg"
aria-label={`Line ${line.lineNumber}`}
<div
className={`sticky left-0 z-10 flex shrink-0 items-start ${gutterBg}`}
>
{line.lineNumber}
</button>
<span
className={`mt-1.5 mr-2 h-3 w-1 shrink-0 rounded-sm ${LEVEL_BAR[line.level] ?? "bg-transparent"}`}
title={line.level}
aria-hidden
/>
<button
type="button"
onClick={(e) => onLineNumberClick(line.lineNumber, e.shiftKey)}
className="w-14 select-none pr-2 text-right text-vscode-gutter hover:text-vscode-fg"
aria-label={`Line ${line.lineNumber}`}
>
{line.lineNumber}
</button>
<span
className={`mt-1.5 mr-2 h-3 w-1 rounded-sm ${LEVEL_BAR[line.level] ?? "bg-transparent"}`}
title={line.level}
aria-hidden
/>
</div>
{line.isJson && line.jsonValue !== null ? (
<JsonInspector
value={line.jsonValue}
collapsedLabel={line.plain.length > 120 ? `${line.plain.slice(0, 120)}…` : line.plain}
collapsedLabel={
line.plain.length > 120 ? `${line.plain.slice(0, 120)}…` : line.plain
}
/>
) : (
<code
className="min-w-0 flex-1 whitespace-pre-wrap break-all pr-3 text-vscode-fg"
className={contentClass}
dangerouslySetInnerHTML={{ __html: ansiToHtml(line.raw) }}
/>
)}
Expand Down
25 changes: 22 additions & 3 deletions server/components/VirtualLogList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -18,6 +20,7 @@ const ROW_ESTIMATE = 28;
export function VirtualLogList({
lines,
selection,
wrapMode,
scrollToLineNumber,
onLineNumberClick,
}: VirtualLogListProps) {
Expand All @@ -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 (
<div ref={parentRef} className="h-full overflow-auto">
<div
ref={parentRef}
className="h-full overflow-auto"
data-wrap-mode={wrapMode}
>
<div
className="relative w-full"
className={
wrapMode === "nowrap" ? "relative min-w-full w-max" : "relative w-full"
}
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((item) => {
Expand All @@ -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)` }}
>
<LogLineRow
line={line}
selected={isLineSelected(line.lineNumber, selection)}
wrapMode={wrapMode}
onLineNumberClick={onLineNumberClick}
/>
</div>
Expand Down
70 changes: 70 additions & 0 deletions server/lib/wrap-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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();
});
});
31 changes: 31 additions & 0 deletions server/lib/wrap-mode.ts
Original file line number Diff line number Diff line change
@@ -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";
}
Loading