From c085f09a71808d31f86c53795ef0124db8e1e434 Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 01:05:19 +0300 Subject: [PATCH] feat(ui): multi-paste side-by-side compare Load a second paste by id (?compare= or sidebar) and show A|B panels with shared filters. Client-only fetch; locked pastes need prior unlock. Closes #60 --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- server/app/paste/[id]/page.tsx | 17 ++- server/components/LogCanvas.tsx | 218 ++++++++++++++++++++++++++++++-- server/lib/compare.test.ts | 49 +++++++ server/lib/compare.ts | 80 ++++++++++++ 6 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 server/lib/compare.test.ts create mode 100644 server/lib/compare.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a4a7f9..209ce16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Log canvas **download filtered / visible lines** (`paste--filtered.log`) - Log canvas **custom highlight rules** (regex → color, per-browser `localStorage`) - Log canvas **timeline scrubber** for timestamped logs (ISO / date-time / syslog / epoch) +- Log canvas **multi-paste compare** (side-by-side via `?compare=` or sidebar) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index beaa614..3106871 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ Focus: log analysis depth. |---|---------|------|--------| | 1.2.1 | **Column / wrap mode**, sticky header | ui | ✅ Done (wrap/nowrap + sticky gutter, `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.3 | **Multi-file or multi-paste compare** (side-by-side) | ui, api | ✅ Done (`?compare=` + sidebar) | | 1.2.4 | **Custom highlight rules** (user regex → color) | ui | ✅ Done (localStorage, sidebar regex → color) | | 1.2.5 | **Timeline scrubber** for timestamped logs | ui | ✅ Done (ISO/syslog/epoch, scrub to jump) | | 1.2.6 | **Download filtered view** (not only raw) | ui | ✅ Done (export visible lines) | diff --git a/server/app/paste/[id]/page.tsx b/server/app/paste/[id]/page.tsx index 05f8bf0..a77fd1c 100644 --- a/server/app/paste/[id]/page.tsx +++ b/server/app/paste/[id]/page.tsx @@ -9,12 +9,24 @@ import { getPaste, getPasteLockInfo } from "@/lib/paste"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -type PageProps = { params: Promise<{ id: string }> }; +type PageProps = { + params: Promise<{ id: string }>; + searchParams: Promise<{ compare?: string | string[] }>; +}; -export default async function PastePage({ params }: PageProps) { +export default async function PastePage({ params, searchParams }: PageProps) { const { id } = await params; if (!id || id.length > 64) notFound(); + const sp = await searchParams; + const compareRaw = sp.compare; + const initialCompareId = + typeof compareRaw === "string" + ? compareRaw + : Array.isArray(compareRaw) + ? compareRaw[0] + : undefined; + const cookieStore = await cookies(); const token = cookieStore.get(authCookieName(id))?.value; const unlocked = verifyUnlockToken(id, token); @@ -38,6 +50,7 @@ export default async function PastePage({ params }: PageProps) { metadata={paste.metadata} createdAt={paste.createdAt} expiresAt={paste.expiresAt} + initialCompareId={initialCompareId} /> ); } diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx index 4424f71..bdcdb0c 100644 --- a/server/components/LogCanvas.tsx +++ b/server/components/LogCanvas.tsx @@ -31,6 +31,11 @@ import { type HighlightColorId, type HighlightRule, } from "@/lib/highlight-rules"; +import { + buildCompareSearch, + normalizeCompareId, + parseComparePasteResponse, +} from "@/lib/compare"; import { buildTimelineIndex, formatTimelineTime, @@ -71,6 +76,8 @@ interface LogCanvasProps { metadata: PasteMetadata; createdAt: number; expiresAt: number | null; + /** Optional second paste id from `?compare=` */ + initialCompareId?: string; } export function LogCanvas({ @@ -79,6 +86,7 @@ export function LogCanvas({ metadata, createdAt, expiresAt, + initialCompareId, }: LogCanvasProps) { const allLines = useMemo(() => parseLogLines(rawContent), [rawContent]); const levelCounts = useMemo(() => countLevels(allLines), [allLines]); @@ -99,6 +107,15 @@ export function LogCanvas({ const [draftError, setDraftError] = useState(null); const [scrubRatio, setScrubRatio] = useState(0); const [scrubLabel, setScrubLabel] = useState(null); + const [compareId, setCompareId] = useState(() => + normalizeCompareId(initialCompareId ?? null), + ); + const [compareDraft, setCompareDraft] = useState( + () => normalizeCompareId(initialCompareId ?? null) ?? "", + ); + const [compareRaw, setCompareRaw] = useState(null); + const [compareError, setCompareError] = useState(null); + const [compareLoading, setCompareLoading] = useState(false); useEffect(() => { setWrapMode(resolveInitialWrapMode()); @@ -111,6 +128,87 @@ export function LogCanvas({ setBookmarks(readStoredBookmarks(id)); }, [id]); + // Load compare paste client-side (public unlock cookie if already unlocked) + useEffect(() => { + if (!compareId || compareId === id) { + setCompareRaw(null); + setCompareError( + compareId === id ? "Cannot compare a paste with itself." : null, + ); + setCompareLoading(false); + return; + } + + let cancelled = false; + setCompareLoading(true); + setCompareError(null); + setCompareRaw(null); + + (async () => { + try { + const res = await fetch(`/api/pastes/${encodeURIComponent(compareId)}`, { + credentials: "same-origin", + }); + let body: unknown = null; + try { + body = await res.json(); + } catch { + body = null; + } + if (cancelled) return; + const parsed = parseComparePasteResponse(res.status, body, compareId); + if (!parsed.ok) { + setCompareError(parsed.error); + setCompareRaw(null); + } else { + setCompareRaw(parsed.rawContent); + setCompareError(null); + } + } catch { + if (!cancelled) { + setCompareError("Network error loading second paste."); + setCompareRaw(null); + } + } finally { + if (!cancelled) setCompareLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [compareId, id]); + + function applyCompare(rawId: string) { + const next = normalizeCompareId(rawId); + if (!next) { + setCompareError("Invalid paste id."); + return; + } + if (next === id) { + setCompareError("Cannot compare a paste with itself."); + return; + } + setCompareId(next); + setCompareDraft(next); + const url = `${window.location.pathname}${buildCompareSearch(window.location.search, next)}${window.location.hash}`; + window.history.replaceState(null, "", url); + } + + function clearCompare() { + setCompareId(null); + setCompareDraft(""); + setCompareRaw(null); + setCompareError(null); + const url = `${window.location.pathname}${buildCompareSearch(window.location.search, null)}${window.location.hash}`; + window.history.replaceState(null, "", url); + } + + const compareLines = useMemo( + () => (compareRaw != null ? parseLogLines(compareRaw) : []), + [compareRaw], + ); + const compiledHighlights = useMemo( () => compileHighlightRules(highlightRules), [highlightRules], @@ -200,6 +298,16 @@ export function LogCanvas({ [allLines, levels, query], ); + const visibleCompareLines = useMemo( + () => + compareRaw != null + ? filterLogLines(compareLines, levels, query) + : [], + [compareRaw, compareLines, levels, query], + ); + + const comparing = compareId != null && compareId !== id; + // Timeline from currently visible lines so jumps always land in the list const timeline = useMemo( () => buildTimelineIndex(visibleLines), @@ -494,6 +602,54 @@ export function LogCanvas({ +
+

+ Compare +

+
+ setCompareDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyCompare(compareDraft); + } + }} + placeholder="paste id" + className="min-w-0 flex-1 rounded border border-vscode-border bg-vscode-bg px-1.5 py-1 font-mono text-[11px] outline-none focus:border-vscode-accent" + spellCheck={false} + autoComplete="off" + aria-label="Compare paste id" + /> + +
+ {comparing ? ( + + ) : null} + {compareLoading ? ( +

Loading…

+ ) : null} + {compareError ? ( +

+ {compareError} +

+ ) : null} +
+
) : null} -
- +
+
+ {comparing && compareRaw != null ? ( +

+ A · paste/{id} +

+ ) : null} +
+ +
+
+ {comparing && compareRaw != null ? ( +
+

+ B · paste/{compareId} +

+
+ { + /* selection only for primary pane */ + }} + onToggleBookmark={() => { + /* pins only for primary paste */ + }} + /> +
+
+ ) : null}
diff --git a/server/lib/compare.test.ts b/server/lib/compare.test.ts new file mode 100644 index 0000000..789eb5c --- /dev/null +++ b/server/lib/compare.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + buildCompareSearch, + isValidPasteId, + normalizeCompareId, + parseComparePasteResponse, +} from "./compare"; + +describe("paste id validation", () => { + it("accepts nanoid-like ids", () => { + expect(isValidPasteId("V1StGXR8_Z5a")).toBe(true); + expect(normalizeCompareId(" abcdefghijkl ")).toBe("abcdefghijkl"); + }); + + it("rejects short or weird ids", () => { + expect(isValidPasteId("ab")).toBe(false); + expect(normalizeCompareId("../x")).toBeNull(); + expect(normalizeCompareId("")).toBeNull(); + }); +}); + +describe("buildCompareSearch", () => { + it("sets and clears compare param", () => { + expect(buildCompareSearch("", "abc123def456")).toBe("?compare=abc123def456"); + expect(buildCompareSearch("?foo=1", "abc123def456")).toBe( + "?foo=1&compare=abc123def456", + ); + expect(buildCompareSearch("?compare=old&x=1", null)).toBe("?x=1"); + }); +}); + +describe("parseComparePasteResponse", () => { + it("parses success", () => { + const r = parseComparePasteResponse( + 200, + { id: "abc123def456", rawContent: "hi", createdAt: 1, expiresAt: null }, + "abc123def456", + ); + expect(r.ok).toBe(true); + if (r.ok) expect(r.rawContent).toBe("hi"); + }); + + it("handles locked and missing", () => { + const locked = parseComparePasteResponse(401, {}, "x"); + expect(locked.ok).toBe(false); + if (!locked.ok) expect(locked.locked).toBe(true); + expect(parseComparePasteResponse(404, {}, "x").ok).toBe(false); + }); +}); diff --git a/server/lib/compare.ts b/server/lib/compare.ts new file mode 100644 index 0000000..7dc83bd --- /dev/null +++ b/server/lib/compare.ts @@ -0,0 +1,80 @@ +/** Helpers for multi-paste compare (client-side). */ + +const ID_RE = /^[A-Za-z0-9_-]{6,64}$/; + +export function isValidPasteId(id: string): boolean { + return ID_RE.test(id.trim()); +} + +export function normalizeCompareId(raw: string | null | undefined): string | null { + if (!raw) return null; + const id = raw.trim(); + if (!isValidPasteId(id)) return null; + return id; +} + +export function buildCompareSearch( + currentSearch: string, + compareId: string | null, +): string { + const params = new URLSearchParams( + currentSearch.startsWith("?") ? currentSearch.slice(1) : currentSearch, + ); + if (compareId) params.set("compare", compareId); + else params.delete("compare"); + const s = params.toString(); + return s ? `?${s}` : ""; +} + +export type CompareFetchResult = + | { + ok: true; + id: string; + rawContent: string; + createdAt: number; + expiresAt: number | null; + } + | { ok: false; status: number; error: string; locked?: boolean }; + +/** + * Parse JSON body from GET /api/pastes/:id for compare pane. + * Pure helper for tests; browser uses fetch + this parser. + */ +export function parseComparePasteResponse( + status: number, + body: unknown, + expectedId: string, +): CompareFetchResult { + if (status === 401) { + return { + ok: false, + status, + error: "Second paste is password-protected. Unlock it in another tab first.", + locked: true, + }; + } + if (status === 404) { + return { ok: false, status, error: "Second paste not found or expired." }; + } + if (status < 200 || status >= 300) { + return { + ok: false, + status, + error: `Failed to load second paste (HTTP ${status}).`, + }; + } + if (!body || typeof body !== "object") { + return { ok: false, status, error: "Invalid response for second paste." }; + } + const b = body as Record; + if (typeof b.rawContent !== "string") { + return { ok: false, status, error: "Invalid response for second paste." }; + } + return { + ok: true, + id: typeof b.id === "string" ? b.id : expectedId, + rawContent: b.rawContent, + createdAt: typeof b.createdAt === "number" ? b.createdAt : 0, + expiresAt: typeof b.expiresAt === "number" ? b.expiresAt : null, + }; +}