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 @@ -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-<id>-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=<id>` or sidebar)

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
17 changes: 15 additions & 2 deletions server/app/paste/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -38,6 +50,7 @@ export default async function PastePage({ params }: PageProps) {
metadata={paste.metadata}
createdAt={paste.createdAt}
expiresAt={paste.expiresAt}
initialCompareId={initialCompareId}
/>
);
}
218 changes: 207 additions & 11 deletions server/components/LogCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import {
type HighlightColorId,
type HighlightRule,
} from "@/lib/highlight-rules";
import {
buildCompareSearch,
normalizeCompareId,
parseComparePasteResponse,
} from "@/lib/compare";
import {
buildTimelineIndex,
formatTimelineTime,
Expand Down Expand Up @@ -71,6 +76,8 @@ interface LogCanvasProps {
metadata: PasteMetadata;
createdAt: number;
expiresAt: number | null;
/** Optional second paste id from `?compare=` */
initialCompareId?: string;
}

export function LogCanvas({
Expand All @@ -79,6 +86,7 @@ export function LogCanvas({
metadata,
createdAt,
expiresAt,
initialCompareId,
}: LogCanvasProps) {
const allLines = useMemo(() => parseLogLines(rawContent), [rawContent]);
const levelCounts = useMemo(() => countLevels(allLines), [allLines]);
Expand All @@ -99,6 +107,15 @@ export function LogCanvas({
const [draftError, setDraftError] = useState<string | null>(null);
const [scrubRatio, setScrubRatio] = useState(0);
const [scrubLabel, setScrubLabel] = useState<string | null>(null);
const [compareId, setCompareId] = useState<string | null>(() =>
normalizeCompareId(initialCompareId ?? null),
);
const [compareDraft, setCompareDraft] = useState(
() => normalizeCompareId(initialCompareId ?? null) ?? "",
);
const [compareRaw, setCompareRaw] = useState<string | null>(null);
const [compareError, setCompareError] = useState<string | null>(null);
const [compareLoading, setCompareLoading] = useState(false);

useEffect(() => {
setWrapMode(resolveInitialWrapMode());
Expand All @@ -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],
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -494,6 +602,54 @@ export function LogCanvas({
</div>
</div>

<div className="space-y-1 border-t border-vscode-border pt-3">
<p className="text-xs font-semibold uppercase tracking-wide text-vscode-muted">
Compare
</p>
<div className="flex gap-1">
<input
type="text"
value={compareDraft}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => applyCompare(compareDraft)}
className="shrink-0 rounded border border-vscode-border bg-vscode-line px-2 py-1 text-[10px] hover:border-vscode-accent"
>
Go
</button>
</div>
{comparing ? (
<button
type="button"
onClick={clearCompare}
className="text-[10px] text-vscode-muted hover:text-vscode-fg"
>
Clear compare
</button>
) : null}
{compareLoading ? (
<p className="text-[10px] text-vscode-muted">Loading…</p>
) : null}
{compareError ? (
<p className="text-[10px] text-vscode-error" role="alert">
{compareError}
</p>
) : null}
</div>

<div className="mt-auto space-y-2">
<button
type="button"
Expand Down Expand Up @@ -598,17 +754,57 @@ export function LogCanvas({
/>
</div>
) : null}
<div className="min-h-0 flex-1">
<VirtualLogList
lines={visibleLines}
selection={selection}
bookmarks={bookmarks}
highlightRules={compiledHighlights}
wrapMode={wrapMode}
scrollToLineNumber={scrollToLine}
onLineNumberClick={onLineNumberClick}
onToggleBookmark={onToggleBookmark}
/>
<div
className={`min-h-0 flex-1 ${comparing && compareRaw != null ? "flex" : ""}`}
>
<div
className={
comparing && compareRaw != null
? "flex min-h-0 min-w-0 flex-1 flex-col border-r border-vscode-border"
: "h-full min-h-0"
}
>
{comparing && compareRaw != null ? (
<p className="shrink-0 border-b border-vscode-border bg-vscode-sidebar px-2 py-1 font-mono text-[10px] text-vscode-muted">
A · paste/{id}
</p>
) : null}
<div className="min-h-0 flex-1">
<VirtualLogList
lines={visibleLines}
selection={selection}
bookmarks={bookmarks}
highlightRules={compiledHighlights}
wrapMode={wrapMode}
scrollToLineNumber={scrollToLine}
onLineNumberClick={onLineNumberClick}
onToggleBookmark={onToggleBookmark}
/>
</div>
</div>
{comparing && compareRaw != null ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<p className="shrink-0 border-b border-vscode-border bg-vscode-sidebar px-2 py-1 font-mono text-[10px] text-vscode-muted">
B · paste/{compareId}
</p>
<div className="min-h-0 flex-1">
<VirtualLogList
lines={visibleCompareLines}
selection={null}
bookmarks={[]}
highlightRules={compiledHighlights}
wrapMode={wrapMode}
scrollToLineNumber={null}
onLineNumberClick={() => {
/* selection only for primary pane */
}}
onToggleBookmark={() => {
/* pins only for primary paste */
}}
/>
</div>
</div>
) : null}
</div>
</div>
</div>
Expand Down
49 changes: 49 additions & 0 deletions server/lib/compare.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading