From 7a11d7014365ff50d995084a2012a2e3cceb8b4f Mon Sep 17 00:00:00 2001 From: ArianAr Date: Thu, 16 Jul 2026 00:54:49 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20custom=20highlight=20rules=20(r?= =?UTF-8?q?egex=20=E2=86=92=20color)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser-local highlight rules with regex patterns and color swatches. Matching lines render plain text with escaped spans; non-matching lines keep ANSI. Rules persist in localStorage (never server-side). Closes #30 --- CHANGELOG.md | 1 + ROADMAP.md | 2 +- server/components/LogCanvas.tsx | 230 ++++++++++++++++--- server/components/LogLineRow.tsx | 16 +- server/components/VirtualLogList.tsx | 4 + server/lib/highlight-rules.test.ts | 150 +++++++++++++ server/lib/highlight-rules.ts | 323 +++++++++++++++++++++++++++ 7 files changed, 687 insertions(+), 39 deletions(-) create mode 100644 server/lib/highlight-rules.test.ts create mode 100644 server/lib/highlight-rules.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a265cc6..cf0cfa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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--filtered.log`) +- Log canvas **custom highlight rules** (regex → color, per-browser `localStorage`) ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index e8155c6..07b7789 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/server/components/LogCanvas.tsx b/server/components/LogCanvas.tsx index e35ddbb..cdfb0f9 100644 --- a/server/components/LogCanvas.tsx +++ b/server/components/LogCanvas.tsx @@ -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, @@ -76,9 +87,14 @@ export function LogCanvas({ const [wrapMode, setWrapMode] = useState("wrap"); const [wrapMounted, setWrapMounted] = useState(false); const [bookmarks, setBookmarks] = useState([]); + const [highlightRules, setHighlightRules] = useState([]); + const [draftPattern, setDraftPattern] = useState(""); + const [draftColor, setDraftColor] = useState("accent"); + const [draftError, setDraftError] = useState(null); useEffect(() => { setWrapMode(resolveInitialWrapMode()); + setHighlightRules(readStoredHighlightRules()); setWrapMounted(true); }, []); @@ -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); @@ -264,46 +324,145 @@ export function LogCanvas({ -
-
-

- Pins -

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

+ Pins +

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

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

+ ) : ( +
    + {bookmarkPreviews.map(({ lineNumber, preview }) => ( +
  • + +
  • + ))} +
+ )}
- {bookmarkPreviews.length === 0 ? ( -

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

+

+ Highlights +

+

+ Regex → color (this browser only).

- ) : ( -
    - {bookmarkPreviews.map(({ lineNumber, preview }) => ( -
  • - -
  • - ))} + + + ); + })}
- )} +
+ { + 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" + /> +
+ + +
+ {draftError ? ( +

+ {draftError} +

+ ) : null} +
+
@@ -384,6 +543,7 @@ export function LogCanvas({ lines={visibleLines} selection={selection} bookmarks={bookmarks} + highlightRules={compiledHighlights} wrapMode={wrapMode} scrollToLineNumber={scrollToLine} onLineNumberClick={onLineNumberClick} diff --git a/server/components/LogLineRow.tsx b/server/components/LogLineRow.tsx index b2602f0..908ae59 100644 --- a/server/components/LogLineRow.tsx +++ b/server/components/LogLineRow.tsx @@ -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"; @@ -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; @@ -31,6 +35,7 @@ export const LogLineRow = memo(function LogLineRow({ selected, bookmarked, wrapMode, + highlightRules, onLineNumberClick, onToggleBookmark, style, @@ -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 (
)}
diff --git a/server/components/VirtualLogList.tsx b/server/components/VirtualLogList.tsx index 79cb252..6877631 100644 --- a/server/components/VirtualLogList.tsx +++ b/server/components/VirtualLogList.tsx @@ -5,6 +5,7 @@ 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"; @@ -12,6 +13,7 @@ interface VirtualLogListProps { lines: ParsedLogLine[]; selection: LineSelection | null; bookmarks: readonly number[]; + highlightRules: readonly CompiledHighlightRule[]; wrapMode: WrapMode; scrollToLineNumber: number | null; onLineNumberClick: (lineNumber: number, shiftKey: boolean) => void; @@ -24,6 +26,7 @@ export function VirtualLogList({ lines, selection, bookmarks, + highlightRules, wrapMode, scrollToLineNumber, onLineNumberClick, @@ -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} /> diff --git a/server/lib/highlight-rules.test.ts b/server/lib/highlight-rules.test.ts new file mode 100644 index 0000000..2c404ae --- /dev/null +++ b/server/lib/highlight-rules.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + HIGHLIGHT_RULES_STORAGE_KEY, + compileHighlightRules, + createHighlightRule, + escapeHtml, + highlightPlainText, + normalizeHighlightRules, + persistHighlightRules, + readStoredHighlightRules, + renderLineHtml, + sanitizeHighlightFlags, + validateHighlightPattern, +} from "./highlight-rules"; +import { clearAnsiCache } from "./ansi-html"; + +describe("sanitizeHighlightFlags", () => { + it("defaults and forces global", () => { + expect(sanitizeHighlightFlags(undefined)).toBe("ig"); + expect(sanitizeHighlightFlags("i")).toBe("ig"); + expect(sanitizeHighlightFlags("gimsuy")).toBe("gimsuy"); + // Only valid flag chars kept (y = sticky); invalid stripped + expect(sanitizeHighlightFlags("xyz")).toBe("yg"); + expect(sanitizeHighlightFlags("!!!")).toBe("ig"); + }); +}); + +describe("validateHighlightPattern", () => { + it("accepts valid patterns", () => { + expect(validateHighlightPattern("error")).toEqual({ ok: true }); + expect(validateHighlightPattern("a+b", "i")).toEqual({ ok: true }); + }); + + it("rejects empty and invalid", () => { + expect(validateHighlightPattern("").ok).toBe(false); + expect(validateHighlightPattern(" ").ok).toBe(false); + expect(validateHighlightPattern("(").ok).toBe(false); + }); +}); + +describe("compileHighlightRules", () => { + it("skips disabled and invalid patterns", () => { + const rules = [ + createHighlightRule({ id: "a", pattern: "ok", color: "error" }), + createHighlightRule({ id: "b", pattern: "nope", enabled: false }), + createHighlightRule({ id: "c", pattern: "(" }), + ]; + const compiled = compileHighlightRules(rules); + expect(compiled).toHaveLength(1); + expect(compiled[0]!.id).toBe("a"); + }); +}); + +describe("escapeHtml / highlightPlainText", () => { + it("escapes HTML entities", () => { + expect(escapeHtml(``)).toBe("<a & "b">"); + }); + + it("wraps matches with mark spans", () => { + const rules = compileHighlightRules([ + createHighlightRule({ + id: "1", + pattern: "error", + color: "error", + flags: "i", + }), + ]); + const { html, matched } = highlightPlainText("got ERROR here", rules); + expect(matched).toBe(true); + expect(html).toContain("]*>ERROR<\/mark>/); + expect(html).toContain("got "); + expect(html).toContain(" here"); + }); + + it("earlier rules win on overlap", () => { + const rules = compileHighlightRules([ + createHighlightRule({ id: "1", pattern: "foobar", color: "error" }), + createHighlightRule({ id: "2", pattern: "foo", color: "info" }), + ]); + const { html } = highlightPlainText("foobar", rules); + expect(html).toContain("bg-vscode-error"); + expect(html).not.toContain("bg-vscode-info"); + }); + + it("escapes match content", () => { + const rules = compileHighlightRules([ + createHighlightRule({ id: "1", pattern: "