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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- Log canvas **download filtered / visible lines** (`paste-<id>-filtered.log`)
- Log canvas **custom highlight rules** (regex → color, per-browser `localStorage`)

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,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.4 | **Custom highlight rules** (user regex → color) | ui | Per-browser config |
| 1.2.4 | **Custom highlight rules** (user regex → color) | ui | ✅ Done (localStorage, sidebar regex → color) |
| 1.2.5 | **Timeline scrubber** for timestamped logs | ui | Detect common timestamp formats |
| 1.2.6 | **Download filtered view** (not only raw) | ui | ✅ Done (export visible lines) |

Expand Down
230 changes: 195 additions & 35 deletions server/components/LogCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ import {
readStoredBookmarks,
toggleBookmark,
} from "@/lib/bookmarks";
import {
HIGHLIGHT_COLOR_OPTIONS,
MAX_HIGHLIGHT_RULES,
compileHighlightRules,
createHighlightRule,
persistHighlightRules,
readStoredHighlightRules,
validateHighlightPattern,
type HighlightColorId,
type HighlightRule,
} from "@/lib/highlight-rules";
import {
persistWrapMode,
resolveInitialWrapMode,
Expand Down Expand Up @@ -76,9 +87,14 @@ export function LogCanvas({
const [wrapMode, setWrapMode] = useState<WrapMode>("wrap");
const [wrapMounted, setWrapMounted] = useState(false);
const [bookmarks, setBookmarks] = useState<number[]>([]);
const [highlightRules, setHighlightRules] = useState<HighlightRule[]>([]);
const [draftPattern, setDraftPattern] = useState("");
const [draftColor, setDraftColor] = useState<HighlightColorId>("accent");
const [draftError, setDraftError] = useState<string | null>(null);

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

Expand All @@ -87,6 +103,50 @@ export function LogCanvas({
setBookmarks(readStoredBookmarks(id));
}, [id]);

const compiledHighlights = useMemo(
() => compileHighlightRules(highlightRules),
[highlightRules],
);

function updateHighlightRules(next: HighlightRule[]) {
setHighlightRules(next);
persistHighlightRules(next);
}

function addHighlightRule() {
const check = validateHighlightPattern(draftPattern, "i");
if (!check.ok) {
setDraftError(check.error);
return;
}
if (highlightRules.length >= MAX_HIGHLIGHT_RULES) {
setDraftError(`Max ${MAX_HIGHLIGHT_RULES} rules`);
return;
}
setDraftError(null);
updateHighlightRules([
...highlightRules,
createHighlightRule({
pattern: draftPattern.trim(),
flags: "i",
color: draftColor,
}),
]);
setDraftPattern("");
}

function removeHighlightRule(ruleId: string) {
updateHighlightRules(highlightRules.filter((r) => r.id !== ruleId));
}

function toggleHighlightRule(ruleId: string) {
updateHighlightRules(
highlightRules.map((r) =>
r.id === ruleId ? { ...r, enabled: !r.enabled } : r,
),
);
}

function toggleWrapMode() {
const next: WrapMode = wrapMode === "wrap" ? "nowrap" : "wrap";
setWrapMode(next);
Expand Down Expand Up @@ -264,46 +324,145 @@ 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 className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div>
<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-32 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>
{bookmarkPreviews.length === 0 ? (
<p className="text-[11px] leading-snug text-vscode-muted">
Star a line to pin it (saved in this browser only).

<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-vscode-muted">
Highlights
</p>
<p className="mb-2 text-[10px] leading-snug text-vscode-muted">
Regex → color (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}
<ul className="mb-2 max-h-36 space-y-1 overflow-y-auto">
{highlightRules.map((rule) => {
const swatch =
HIGHLIGHT_COLOR_OPTIONS.find((c) => c.id === rule.color)
?.swatchClass ?? "bg-vscode-accent";
return (
<li
key={rule.id}
className="flex items-start gap-1 rounded px-0.5 py-0.5 hover:bg-vscode-line"
>
<span className="font-mono text-[11px] text-vscode-accent">
L{lineNumber}
<input
type="checkbox"
checked={rule.enabled}
onChange={() => toggleHighlightRule(rule.id)}
className="mt-0.5 accent-vscode-accent"
aria-label={`Enable highlight ${rule.pattern}`}
/>
<span
className={`mt-1 h-2 w-2 shrink-0 rounded-sm ${swatch}`}
aria-hidden
/>
<span
className="min-w-0 flex-1 truncate font-mono text-[10px] text-vscode-fg"
title={`/${rule.pattern}/${rule.flags}`}
>
/{rule.pattern}/
</span>
<span className="truncate font-mono text-[10px] text-vscode-muted">
{preview}
</span>
</button>
</li>
))}
<button
type="button"
onClick={() => removeHighlightRule(rule.id)}
className="shrink-0 text-[10px] text-vscode-muted hover:text-vscode-error"
aria-label={`Remove highlight ${rule.pattern}`}
>
×
</button>
</li>
);
})}
</ul>
)}
<div className="space-y-1">
<input
type="text"
value={draftPattern}
onChange={(e) => {
setDraftPattern(e.target.value);
setDraftError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addHighlightRule();
}
}}
placeholder="regex pattern"
className="w-full 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="New highlight pattern"
/>
<div className="flex items-center gap-1">
<select
value={draftColor}
onChange={(e) =>
setDraftColor(e.target.value as HighlightColorId)
}
className="min-w-0 flex-1 rounded border border-vscode-border bg-vscode-bg px-1 py-1 font-mono text-[10px] text-vscode-fg"
aria-label="Highlight color"
>
{HIGHLIGHT_COLOR_OPTIONS.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
<button
type="button"
onClick={addHighlightRule}
className="shrink-0 rounded border border-vscode-border bg-vscode-line px-2 py-1 text-[10px] hover:border-vscode-accent"
>
Add
</button>
</div>
{draftError ? (
<p className="text-[10px] text-vscode-error" role="alert">
{draftError}
</p>
) : null}
</div>
</div>
</div>

<div className="mt-auto space-y-2">
Expand Down Expand Up @@ -384,6 +543,7 @@ export function LogCanvas({
lines={visibleLines}
selection={selection}
bookmarks={bookmarks}
highlightRules={compiledHighlights}
wrapMode={wrapMode}
scrollToLineNumber={scrollToLine}
onLineNumberClick={onLineNumberClick}
Expand Down
16 changes: 13 additions & 3 deletions server/components/LogLineRow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"use client";

import { memo } from "react";
import { ansiToHtml } from "@/lib/ansi-html";
import { memo, useMemo } from "react";
import type { ParsedLogLine } from "@/lib/log-lines";
import {
renderLineHtml,
type CompiledHighlightRule,
} from "@/lib/highlight-rules";
import type { WrapMode } from "@/lib/wrap-mode";
import { JsonInspector } from "./JsonInspector";

Expand All @@ -21,6 +24,7 @@ interface LogLineRowProps {
selected: boolean;
bookmarked: boolean;
wrapMode: WrapMode;
highlightRules: readonly CompiledHighlightRule[];
onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void;
onToggleBookmark: (lineNumber: number) => void;
style?: React.CSSProperties;
Expand All @@ -31,6 +35,7 @@ export const LogLineRow = memo(function LogLineRow({
selected,
bookmarked,
wrapMode,
highlightRules,
onLineNumberClick,
onToggleBookmark,
style,
Expand All @@ -43,6 +48,11 @@ export const LogLineRow = memo(function LogLineRow({
// Opaque sticky gutter so content does not show through on horizontal scroll.
const gutterBg = selected ? "bg-vscode-selection" : "bg-vscode-bg";

const lineHtml = useMemo(
() => renderLineHtml(line.raw, line.plain, highlightRules),
[line.raw, line.plain, highlightRules],
);

return (
<div
style={style}
Expand Down Expand Up @@ -102,7 +112,7 @@ export const LogLineRow = memo(function LogLineRow({
) : (
<code
className={contentClass}
dangerouslySetInnerHTML={{ __html: ansiToHtml(line.raw) }}
dangerouslySetInnerHTML={{ __html: lineHtml }}
/>
)}
</div>
Expand Down
4 changes: 4 additions & 0 deletions server/components/VirtualLogList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ 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 { CompiledHighlightRule } from "@/lib/highlight-rules";
import type { WrapMode } from "@/lib/wrap-mode";
import { LogLineRow } from "./LogLineRow";

interface VirtualLogListProps {
lines: ParsedLogLine[];
selection: LineSelection | null;
bookmarks: readonly number[];
highlightRules: readonly CompiledHighlightRule[];
wrapMode: WrapMode;
scrollToLineNumber: number | null;
onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void;
Expand All @@ -24,6 +26,7 @@ export function VirtualLogList({
lines,
selection,
bookmarks,
highlightRules,
wrapMode,
scrollToLineNumber,
onLineNumberClick,
Expand Down Expand Up @@ -83,6 +86,7 @@ export function VirtualLogList({
selected={isLineSelected(line.lineNumber, selection)}
bookmarked={isBookmarked(bookmarkSet, line.lineNumber)}
wrapMode={wrapMode}
highlightRules={highlightRules}
onLineNumberClick={onLineNumberClick}
onToggleBookmark={onToggleBookmark}
/>
Expand Down
Loading
Loading