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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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`
- Log canvas **line pins / bookmarks** (per-paste `localStorage`, gutter star + sidebar jump list)

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Focus: log analysis depth.
| # | Feature | Area | Notes |
|---|---------|------|--------|
| 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.2 | **Bookmark / pin lines** (local only) | ui | ✅ Done (per-paste `localStorage`, sidebar pins) |
| 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 |
| 1.2.5 | **Timeline scrubber** for timestamped logs | ui | Detect common timestamp formats |
Expand Down
88 changes: 88 additions & 0 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 {
persistBookmarks,
readStoredBookmarks,
toggleBookmark,
} from "@/lib/bookmarks";
import {
persistWrapMode,
resolveInitialWrapMode,
Expand Down Expand Up @@ -69,18 +74,57 @@ export function LogCanvas({
const [copied, setCopied] = useState(false);
const [wrapMode, setWrapMode] = useState<WrapMode>("wrap");
const [wrapMounted, setWrapMounted] = useState(false);
const [bookmarks, setBookmarks] = useState<number[]>([]);

useEffect(() => {
setWrapMode(resolveInitialWrapMode());
setWrapMounted(true);
}, []);

// Load pins for this paste (local only)
useEffect(() => {
setBookmarks(readStoredBookmarks(id));
}, [id]);

function toggleWrapMode() {
const next: WrapMode = wrapMode === "wrap" ? "nowrap" : "wrap";
setWrapMode(next);
persistWrapMode(next);
}

const onToggleBookmark = useCallback(
(lineNumber: number) => {
setBookmarks((prev) => {
const next = toggleBookmark(prev, lineNumber);
persistBookmarks(id, next);
return next;
});
},
[id],
);

function clearBookmarks() {
setBookmarks([]);
persistBookmarks(id, []);
}

function jumpToBookmark(lineNumber: number) {
// Clear first so re-clicking the same pin still scrolls
setScrollToLine(null);
requestAnimationFrame(() => setScrollToLine(lineNumber));
}

const bookmarkPreviews = useMemo(() => {
const byNum = new Map(allLines.map((l) => [l.lineNumber, l] as const));
return bookmarks.map((n) => {
const line = byNum.get(n);
const plain = line?.plain ?? "";
const preview =
plain.length > 48 ? `${plain.slice(0, 48)}…` : plain || "(empty)";
return { lineNumber: n, preview };
});
}, [allLines, bookmarks]);

const query = useMemo(() => parseSearchQuery(search), [search]);
const visibleLines = useMemo(
() => filterLogLines(allLines, levels, query),
Expand Down Expand Up @@ -209,6 +253,48 @@ export function LogCanvas({
</ul>
</div>

<div className="min-h-0 flex-1">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-vscode-muted">
Pins
</p>
{bookmarks.length > 0 ? (
<button
type="button"
onClick={clearBookmarks}
className="text-[10px] text-vscode-muted hover:text-vscode-fg"
>
Clear
</button>
) : null}
</div>
{bookmarkPreviews.length === 0 ? (
<p className="text-[11px] leading-snug text-vscode-muted">
Star a line to pin it (saved in this browser only).
</p>
) : (
<ul className="max-h-48 space-y-1 overflow-y-auto">
{bookmarkPreviews.map(({ lineNumber, preview }) => (
<li key={lineNumber}>
<button
type="button"
onClick={() => jumpToBookmark(lineNumber)}
className="flex w-full flex-col rounded px-1 py-0.5 text-left hover:bg-vscode-line"
title={preview}
>
<span className="font-mono text-[11px] text-vscode-accent">
L{lineNumber}
</span>
<span className="truncate font-mono text-[10px] text-vscode-muted">
{preview}
</span>
</button>
</li>
))}
</ul>
)}
</div>

<div className="mt-auto space-y-2">
<button
type="button"
Expand Down Expand Up @@ -271,9 +357,11 @@ export function LogCanvas({
<VirtualLogList
lines={visibleLines}
selection={selection}
bookmarks={bookmarks}
wrapMode={wrapMode}
scrollToLineNumber={scrollToLine}
onLineNumberClick={onLineNumberClick}
onToggleBookmark={onToggleBookmark}
/>
</div>
</div>
Expand Down
32 changes: 30 additions & 2 deletions server/components/LogLineRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,20 @@ const LEVEL_BAR: Record<string, string> = {
interface LogLineRowProps {
line: ParsedLogLine;
selected: boolean;
bookmarked: boolean;
wrapMode: WrapMode;
onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void;
onToggleBookmark: (lineNumber: number) => void;
style?: React.CSSProperties;
}

export const LogLineRow = memo(function LogLineRow({
line,
selected,
bookmarked,
wrapMode,
onLineNumberClick,
onToggleBookmark,
style,
}: LogLineRowProps) {
const nowrap = wrapMode === "nowrap";
Expand All @@ -43,17 +47,41 @@ export const LogLineRow = memo(function LogLineRow({
<div
style={style}
data-line={line.lineNumber}
data-bookmarked={bookmarked ? "true" : undefined}
className={`flex min-w-0 items-start border-b border-vscode-line/40 font-mono text-[13px] leading-6 ${
nowrap ? "w-max min-w-full" : "w-full"
} ${selected ? "bg-vscode-selection/50" : "hover:bg-vscode-line/30"}`}
} ${selected ? "bg-vscode-selection/50" : "hover:bg-vscode-line/30"} ${
bookmarked && !selected ? "bg-vscode-accent/5" : ""
}`}
>
<div
className={`sticky left-0 z-10 flex shrink-0 items-start ${gutterBg}`}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleBookmark(line.lineNumber);
}}
className={`w-5 shrink-0 select-none text-center text-[11px] leading-6 ${
bookmarked
? "text-vscode-accent"
: "text-vscode-gutter/50 hover:text-vscode-accent"
}`}
aria-label={
bookmarked
? `Unpin line ${line.lineNumber}`
: `Pin line ${line.lineNumber}`
}
aria-pressed={bookmarked}
title={bookmarked ? "Unpin line" : "Pin line"}
>
{bookmarked ? "★" : "☆"}
</button>
<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"
className="w-12 select-none pr-1 text-right text-vscode-gutter hover:text-vscode-fg"
aria-label={`Line ${line.lineNumber}`}
>
{line.lineNumber}
Expand Down
8 changes: 8 additions & 0 deletions server/components/VirtualLogList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,33 @@ 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 { isBookmarked } from "@/lib/bookmarks";
import type { WrapMode } from "@/lib/wrap-mode";
import { LogLineRow } from "./LogLineRow";

interface VirtualLogListProps {
lines: ParsedLogLine[];
selection: LineSelection | null;
bookmarks: readonly number[];
wrapMode: WrapMode;
scrollToLineNumber: number | null;
onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void;
onToggleBookmark: (lineNumber: number) => void;
}

const ROW_ESTIMATE = 28;

export function VirtualLogList({
lines,
selection,
bookmarks,
wrapMode,
scrollToLineNumber,
onLineNumberClick,
onToggleBookmark,
}: VirtualLogListProps) {
const parentRef = useRef<HTMLDivElement>(null);
const bookmarkSet = new Set(bookmarks);

const virtualizer = useVirtualizer({
count: lines.length,
Expand Down Expand Up @@ -75,8 +81,10 @@ export function VirtualLogList({
<LogLineRow
line={line}
selected={isLineSelected(line.lineNumber, selection)}
bookmarked={isBookmarked(bookmarkSet, line.lineNumber)}
wrapMode={wrapMode}
onLineNumberClick={onLineNumberClick}
onToggleBookmark={onToggleBookmark}
/>
</div>
);
Expand Down
93 changes: 93 additions & 0 deletions server/lib/bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
bookmarksStorageKey,
isBookmarked,
normalizeBookmarks,
persistBookmarks,
readStoredBookmarks,
toggleBookmark,
} from "./bookmarks";

describe("normalizeBookmarks", () => {
it("dedupes, drops invalid, sorts", () => {
expect(normalizeBookmarks([3, 1, 2, 1, 0, -1, 2.5, 4])).toEqual([1, 2, 3, 4]);
expect(normalizeBookmarks([])).toEqual([]);
});
});

describe("toggleBookmark", () => {
it("adds and removes lines", () => {
expect(toggleBookmark([], 5)).toEqual([5]);
expect(toggleBookmark([1, 5], 5)).toEqual([1]);
expect(toggleBookmark([1, 5], 3)).toEqual([1, 3, 5]);
});

it("ignores invalid line numbers", () => {
expect(toggleBookmark([1], 0)).toEqual([1]);
expect(toggleBookmark([1], 1.5)).toEqual([1]);
});
});

describe("isBookmarked", () => {
it("works with arrays and sets", () => {
expect(isBookmarked([1, 3], 3)).toBe(true);
expect(isBookmarked([1, 3], 2)).toBe(false);
expect(isBookmarked(new Set([1, 3]), 1)).toBe(true);
});
});

describe("bookmarks storage", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("reads and persists per paste id", () => {
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);
},
removeItem: (k: string) => {
store.delete(k);
},
},
});

expect(bookmarksStorageKey("abc")).toBe("papercut-bookmarks:abc");
expect(readStoredBookmarks("abc")).toEqual([]);

persistBookmarks("abc", [10, 2, 10]);
expect(store.get("papercut-bookmarks:abc")).toBe("[2,10]");
expect(readStoredBookmarks("abc")).toEqual([2, 10]);

persistBookmarks("abc", []);
expect(store.has("papercut-bookmarks:abc")).toBe(false);
});

it("tolerates corrupt storage", () => {
vi.stubGlobal("window", {
localStorage: {
getItem: () => "not-json",
setItem: () => {
throw new Error("blocked");
},
removeItem: () => undefined,
},
});
expect(readStoredBookmarks("x")).toEqual([]);
expect(() => persistBookmarks("x", [1])).not.toThrow();
});

it("ignores non-array JSON", () => {
vi.stubGlobal("window", {
localStorage: {
getItem: () => '{"a":1}',
setItem: () => undefined,
removeItem: () => undefined,
},
});
expect(readStoredBookmarks("x")).toEqual([]);
});
});
Loading
Loading