diff --git a/CHANGELOG.md b/CHANGELOG.md index 21eebb1..0328a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index b5f4afb..f94f825 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx index aa91262..c80a19e 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 { + persistBookmarks, + readStoredBookmarks, + toggleBookmark, +} from "@/lib/bookmarks"; import { persistWrapMode, resolveInitialWrapMode, @@ -69,18 +74,57 @@ export function LogCanvas({ const [copied, setCopied] = useState(false); const [wrapMode, setWrapMode] = useState("wrap"); const [wrapMounted, setWrapMounted] = useState(false); + const [bookmarks, setBookmarks] = useState([]); 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), @@ -209,6 +253,48 @@ export function LogCanvas({ +
+
+

+ Pins +

+ {bookmarks.length > 0 ? ( + + ) : null} +
+ {bookmarkPreviews.length === 0 ? ( +

+ Star a line to pin it (saved in this browser only). +

+ ) : ( +
    + {bookmarkPreviews.map(({ lineNumber, preview }) => ( +
  • + +
  • + ))} +
+ )} +
+
diff --git a/server/components/LogLineRow.tsx b/server/components/LogLineRow.tsx index c14419c..b2602f0 100644 --- a/server/components/LogLineRow.tsx +++ b/server/components/LogLineRow.tsx @@ -19,16 +19,20 @@ const LEVEL_BAR: Record = { 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"; @@ -43,17 +47,41 @@ export const LogLineRow = memo(function LogLineRow({
+
); diff --git a/server/lib/bookmarks.test.ts b/server/lib/bookmarks.test.ts new file mode 100644 index 0000000..182d9fb --- /dev/null +++ b/server/lib/bookmarks.test.ts @@ -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(); + 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([]); + }); +}); diff --git a/server/lib/bookmarks.ts b/server/lib/bookmarks.ts new file mode 100644 index 0000000..de93f9c --- /dev/null +++ b/server/lib/bookmarks.ts @@ -0,0 +1,64 @@ +/** + * Per-paste line bookmarks (local only). Never sent to the server. + */ + +export function bookmarksStorageKey(pasteId: string): string { + return `papercut-bookmarks:${pasteId}`; +} + +/** Normalize: unique positive ints, sorted ascending. */ +export function normalizeBookmarks(lines: Iterable): number[] { + const set = new Set(); + for (const n of lines) { + if (Number.isInteger(n) && n >= 1) set.add(n); + } + return [...set].sort((a, b) => a - b); +} + +export function toggleBookmark(lines: readonly number[], lineNumber: number): number[] { + if (!Number.isInteger(lineNumber) || lineNumber < 1) { + return normalizeBookmarks(lines); + } + const set = new Set(normalizeBookmarks(lines)); + if (set.has(lineNumber)) set.delete(lineNumber); + else set.add(lineNumber); + return [...set].sort((a, b) => a - b); +} + +export function isBookmarked( + lines: readonly number[] | ReadonlySet, + lineNumber: number, +): boolean { + if (lines instanceof Set) return lines.has(lineNumber); + return (lines as readonly number[]).includes(lineNumber); +} + +export function readStoredBookmarks(pasteId: string): number[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(bookmarksStorageKey(pasteId)); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return normalizeBookmarks( + parsed.filter((n): n is number => typeof n === "number"), + ); + } catch { + return []; + } +} + +export function persistBookmarks(pasteId: string, lines: readonly number[]): void { + if (typeof window === "undefined") return; + try { + const key = bookmarksStorageKey(pasteId); + const normalized = normalizeBookmarks(lines); + if (normalized.length === 0) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(normalized)); + } + } catch { + /* private mode / blocked storage */ + } +}