generated from nhcarrigan/template
perf: virtual windowing, markdown memoisation, and search debounce
- Terminal: virtual windowing renders max 150 lines, loads 50 older on scroll-up with scroll position compensation; auto-advances window forward during auto-scroll so old DOM nodes are unloaded continuously - Markdown: two-stage derived rendering separates expensive parse step (marked + hljs + spoilers, runs on content change) from cheap search highlight step (runs on query change only) - Achievements: fix double Object.keys() call in derived store - Terminal: 150ms debounce on search query to reduce redundant updates - Tests: add Markdown.test.ts for processSpoilers and highlightSearchMatches; extend Terminal.test.ts with virtual windowing helper coverage
This commit is contained in:
@@ -108,15 +108,19 @@
|
||||
return processed;
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
// Two-stage reactive rendering:
|
||||
// Stage 1 — only re-runs when `content` changes (expensive: marked + hljs + spoilers)
|
||||
let parsedHtml = $derived.by(() => {
|
||||
try {
|
||||
const html = marked.parse(text) as string;
|
||||
const withSpoilers = processSpoilers(html);
|
||||
return highlightSearchMatches(withSpoilers, searchQuery);
|
||||
const html = marked.parse(content) as string;
|
||||
return processSpoilers(html);
|
||||
} catch {
|
||||
return text;
|
||||
return content;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Stage 2 — re-runs when search changes; skips re-parsing markdown entirely
|
||||
let renderedHtml = $derived(highlightSearchMatches(parsedHtml, searchQuery));
|
||||
|
||||
function handleSpoilerClick(event: Event) {
|
||||
const target = event.target as HTMLElement;
|
||||
@@ -191,7 +195,7 @@
|
||||
role="presentation"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Markdown rendering requires @html; content is from Claude API -->
|
||||
{@html renderMarkdown(content)}
|
||||
{@html renderedHtml}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Markdown Component Tests
|
||||
*
|
||||
* Tests the pure helper functions extracted from the Markdown component:
|
||||
* - processSpoilers: wraps ||text|| syntax in spoiler spans, leaving code blocks untouched
|
||||
* - highlightSearchMatches: injects <mark> tags for search terms, skipping code blocks
|
||||
*
|
||||
* Manual testing checklist:
|
||||
* - [ ] Code blocks render with syntax highlighting and a copy button
|
||||
* - [ ] ||spoiler text|| renders as a hidden span revealed on click
|
||||
* - [ ] Search query highlights matching text in non-code content
|
||||
* - [ ] Links open in the system browser via the Tauri opener
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Mirror functions from Markdown.svelte for isolated testing
|
||||
|
||||
function processSpoilers(html: string): string {
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(
|
||||
/\|\|(.+?)\|\|/g,
|
||||
'<span class="spoiler" role="button" tabindex="0">$1</span>'
|
||||
);
|
||||
|
||||
processed = processed.replace(/__CODE_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function highlightSearchMatches(html: string, query: string): string {
|
||||
if (!query) return html;
|
||||
|
||||
const codeBlockPlaceholders: string[] = [];
|
||||
const tagPlaceholders: string[] = [];
|
||||
|
||||
let processed = html.replace(/<(pre|code)[^>]*>[\s\S]*?<\/\1>/gi, (match) => {
|
||||
codeBlockPlaceholders.push(match);
|
||||
return `__CODE_SEARCH_PLACEHOLDER_${codeBlockPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
processed = processed.replace(/<[^>]+>/g, (match) => {
|
||||
tagPlaceholders.push(match);
|
||||
return `__TAG_PLACEHOLDER_${tagPlaceholders.length - 1}__`;
|
||||
});
|
||||
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(`(${escapedQuery})`, "gi");
|
||||
processed = processed.replace(regex, '<mark class="search-highlight">$1</mark>');
|
||||
|
||||
processed = processed.replace(/__TAG_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return tagPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
processed = processed.replace(/__CODE_SEARCH_PLACEHOLDER_(\d+)__/g, (_, index) => {
|
||||
return codeBlockPlaceholders[parseInt(index)];
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("processSpoilers", () => {
|
||||
it("wraps ||text|| in a spoiler span", () => {
|
||||
const result = processSpoilers("<p>||secret||</p>");
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("secret");
|
||||
});
|
||||
|
||||
it("adds role=button and tabindex to spoiler spans", () => {
|
||||
const result = processSpoilers("<p>||hidden||</p>");
|
||||
expect(result).toContain('role="button"');
|
||||
expect(result).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
it("leaves content without spoiler markers unchanged", () => {
|
||||
const html = "<p>Normal text here</p>";
|
||||
expect(processSpoilers(html)).toBe(html);
|
||||
});
|
||||
|
||||
it("handles multiple spoilers in the same string", () => {
|
||||
const result = processSpoilers("<p>||a|| and ||b||</p>");
|
||||
const matches = result.match(/class="spoiler"/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside code blocks", () => {
|
||||
const html = "<pre><code>||not a spoiler||</code></pre>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
expect(result).toContain("||not a spoiler||");
|
||||
});
|
||||
|
||||
it("does not apply spoiler syntax inside inline code", () => {
|
||||
const html = "<p>Example: <code>||inline||</code></p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).not.toContain('class="spoiler"');
|
||||
});
|
||||
|
||||
it("handles spoilers adjacent to code blocks correctly", () => {
|
||||
const html = "<pre><code>code</code></pre><p>||revealed||</p>";
|
||||
const result = processSpoilers(html);
|
||||
expect(result).toContain('<span class="spoiler"');
|
||||
expect(result).toContain("<pre><code>code</code></pre>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("highlightSearchMatches", () => {
|
||||
it("returns unchanged html when query is empty string", () => {
|
||||
const html = "<p>hello world</p>";
|
||||
expect(highlightSearchMatches(html, "")).toBe(html);
|
||||
});
|
||||
|
||||
it("wraps matched text in a mark element", () => {
|
||||
const result = highlightSearchMatches("<p>hello world</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
const result = highlightSearchMatches("<p>Hello World</p>", "hello");
|
||||
expect(result).toContain('<mark class="search-highlight">Hello</mark>');
|
||||
});
|
||||
|
||||
it("highlights all occurrences", () => {
|
||||
const result = highlightSearchMatches("<p>cat and cat</p>", "cat");
|
||||
const matches = result.match(/<mark class="search-highlight">/g);
|
||||
expect(matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not highlight inside code blocks", () => {
|
||||
const html = "<pre><code>hello inside code</code></pre>";
|
||||
const result = highlightSearchMatches(html, "hello");
|
||||
expect(result).not.toContain('<mark class="search-highlight">');
|
||||
expect(result).toContain("hello inside code");
|
||||
});
|
||||
|
||||
it("does not corrupt HTML tags", () => {
|
||||
const result = highlightSearchMatches('<p class="foo">hello</p>', "hello");
|
||||
expect(result).toContain('<p class="foo">');
|
||||
expect(result).toContain('<mark class="search-highlight">hello</mark>');
|
||||
});
|
||||
|
||||
it("escapes regex special characters in the query", () => {
|
||||
const result = highlightSearchMatches("<p>price: $1.00</p>", "$1");
|
||||
expect(result).toContain('<mark class="search-highlight">$1</mark>');
|
||||
});
|
||||
|
||||
it("highlights text outside code blocks whilst leaving code intact", () => {
|
||||
const html = "<pre><code>match here</code></pre><p>match here too</p>";
|
||||
const result = highlightSearchMatches(html, "match");
|
||||
expect(result).toContain("<pre><code>match here</code></pre>");
|
||||
expect(result).toContain('<mark class="search-highlight">match</mark>');
|
||||
});
|
||||
});
|
||||
@@ -12,15 +12,26 @@
|
||||
import { clipboardStore } from "$lib/stores/clipboard";
|
||||
import { shouldHidePaths, maskPaths, showThinkingBlocks } from "$lib/stores/config";
|
||||
|
||||
// Virtual windowing constants — keeps the DOM lean during long sessions
|
||||
const WINDOW_SIZE = 150; // max lines rendered at once
|
||||
const LOAD_CHUNK = 50; // how many older lines to load when scrolling up
|
||||
const AVG_LINE_HEIGHT = 60; // rough px estimate per line, used for top spacer
|
||||
|
||||
let terminalElement: HTMLDivElement;
|
||||
let shouldAutoScroll = true;
|
||||
let lines: TerminalLine[] = [];
|
||||
let currentSearchQuery = "";
|
||||
let currentConversationId: string | null = null;
|
||||
let isRestoringScroll = false;
|
||||
let windowStart = 0;
|
||||
let isLoadingMore = false;
|
||||
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
searchQuery.subscribe((value) => {
|
||||
currentSearchQuery = value;
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
currentSearchQuery = value;
|
||||
}, 150);
|
||||
});
|
||||
|
||||
let hidePaths = false;
|
||||
@@ -54,12 +65,14 @@
|
||||
const savedPosition = claudeStore.getScrollPosition(newId);
|
||||
isRestoringScroll = true;
|
||||
if (savedPosition === -1) {
|
||||
// Auto-scroll to bottom
|
||||
// Auto-scroll to bottom — window reactive statement will advance windowStart
|
||||
shouldAutoScroll = true;
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
} else {
|
||||
// Restore to saved position
|
||||
// Restore to saved position — show from the beginning of history
|
||||
windowStart = 0;
|
||||
shouldAutoScroll = false;
|
||||
await tick();
|
||||
terminalElement.scrollTop = savedPosition;
|
||||
}
|
||||
// Small delay to prevent the scroll handler from overriding our restore
|
||||
@@ -69,10 +82,28 @@
|
||||
}
|
||||
});
|
||||
|
||||
function handleScroll() {
|
||||
async function handleScroll() {
|
||||
if (!terminalElement || isRestoringScroll) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = terminalElement;
|
||||
shouldAutoScroll = scrollHeight - scrollTop - clientHeight < 100;
|
||||
|
||||
// Load older lines when the user scrolls near the top
|
||||
if (scrollTop < 300 && windowStart > 0 && !isLoadingMore) {
|
||||
isLoadingMore = true;
|
||||
const prevScrollHeight = terminalElement.scrollHeight;
|
||||
const prevScrollTop = terminalElement.scrollTop;
|
||||
|
||||
windowStart = Math.max(0, windowStart - LOAD_CHUNK);
|
||||
|
||||
await tick();
|
||||
|
||||
if (terminalElement) {
|
||||
// Compensate for the new items pushing content down
|
||||
terminalElement.scrollTop =
|
||||
prevScrollTop + (terminalElement.scrollHeight - prevScrollHeight);
|
||||
}
|
||||
isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
afterUpdate(() => {
|
||||
@@ -138,6 +169,17 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Visible slice — only render lines within the current window
|
||||
$: visibleLines = lines.slice(windowStart, windowStart + WINDOW_SIZE);
|
||||
|
||||
// Height of the invisible spacer above the visible window
|
||||
$: topSpacerHeight = windowStart * AVG_LINE_HEIGHT;
|
||||
|
||||
// Advance the window forward when auto-scrolling and new lines overflow it
|
||||
$: if (shouldAutoScroll && lines.length > windowStart + WINDOW_SIZE) {
|
||||
windowStart = Math.max(0, lines.length - WINDOW_SIZE);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (currentSearchQuery && lines.length > 0) {
|
||||
const escapedQuery = currentSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -179,6 +221,7 @@
|
||||
if (terminalElement) {
|
||||
terminalElement.removeEventListener("copy", handleCopy);
|
||||
}
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer);
|
||||
});
|
||||
|
||||
// Copy message content to clipboard
|
||||
@@ -238,7 +281,13 @@
|
||||
Waiting for Claude... Type a message below to start!
|
||||
</div>
|
||||
{:else}
|
||||
{#each lines as line (line.id)}
|
||||
<div style="height: {topSpacerHeight}px" aria-hidden="true"></div>
|
||||
{#if windowStart > 0}
|
||||
<div class="terminal-older-indicator">
|
||||
↑ {windowStart} older {windowStart === 1 ? "message" : "messages"} — scroll up to load
|
||||
</div>
|
||||
{/if}
|
||||
{#each visibleLines as line (line.id)}
|
||||
{#if line.type === "thinking"}
|
||||
{#if showThinking}
|
||||
<ThinkingBlock content={line.content} timestamp={line.timestamp} />
|
||||
@@ -428,6 +477,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.terminal-older-indicator {
|
||||
color: var(--text-tertiary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.terminal-header-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,27 @@ function truncateToolContent(content: string): string {
|
||||
return content.slice(0, TOOL_COLLAPSE_THRESHOLD) + "…";
|
||||
}
|
||||
|
||||
// Virtual windowing helpers — mirror the logic in Terminal.svelte
|
||||
|
||||
const WINDOW_SIZE = 150;
|
||||
const LOAD_CHUNK = 50;
|
||||
const AVG_LINE_HEIGHT = 60;
|
||||
|
||||
/** Returns the windowStart index when auto-scrolling to the bottom. */
|
||||
function autoScrollWindowStart(linesLength: number, windowSize: number): number {
|
||||
return Math.max(0, linesLength - windowSize);
|
||||
}
|
||||
|
||||
/** Returns the new windowStart after loading LOAD_CHUNK older messages. */
|
||||
function olderWindowStart(currentStart: number, chunkSize: number): number {
|
||||
return Math.max(0, currentStart - chunkSize);
|
||||
}
|
||||
|
||||
/** Returns the height in pixels of the invisible top spacer. */
|
||||
function topSpacerHeight(windowStart: number, avgLineHeight: number): number {
|
||||
return windowStart * avgLineHeight;
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
describe("getLineClass", () => {
|
||||
@@ -262,3 +283,86 @@ describe("truncateToolContent", () => {
|
||||
expect(result.endsWith("...")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoScrollWindowStart", () => {
|
||||
it("returns 0 when lines fit within the window", () => {
|
||||
expect(autoScrollWindowStart(50, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when lines exactly fill the window", () => {
|
||||
expect(autoScrollWindowStart(WINDOW_SIZE, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("advances when lines exceed the window size", () => {
|
||||
expect(autoScrollWindowStart(200, WINDOW_SIZE)).toBe(50);
|
||||
});
|
||||
|
||||
it("never returns a negative value", () => {
|
||||
expect(autoScrollWindowStart(0, WINDOW_SIZE)).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps last WINDOW_SIZE lines visible for large collections", () => {
|
||||
expect(autoScrollWindowStart(500, WINDOW_SIZE)).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("olderWindowStart", () => {
|
||||
it("subtracts the chunk size from the current start", () => {
|
||||
expect(olderWindowStart(100, LOAD_CHUNK)).toBe(50);
|
||||
});
|
||||
|
||||
it("never returns a negative value when chunk is larger than start", () => {
|
||||
expect(olderWindowStart(20, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when current start is 0", () => {
|
||||
expect(olderWindowStart(0, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when current start exactly equals the chunk size", () => {
|
||||
expect(olderWindowStart(LOAD_CHUNK, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
|
||||
it("correctly loads a partial chunk near the beginning", () => {
|
||||
expect(olderWindowStart(30, LOAD_CHUNK)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("topSpacerHeight", () => {
|
||||
it("returns 0 when windowStart is 0", () => {
|
||||
expect(topSpacerHeight(0, AVG_LINE_HEIGHT)).toBe(0);
|
||||
});
|
||||
|
||||
it("multiplies windowStart by avgLineHeight", () => {
|
||||
expect(topSpacerHeight(10, AVG_LINE_HEIGHT)).toBe(600);
|
||||
});
|
||||
|
||||
it("scales linearly with windowStart", () => {
|
||||
expect(topSpacerHeight(50, AVG_LINE_HEIGHT)).toBe(3000);
|
||||
expect(topSpacerHeight(100, AVG_LINE_HEIGHT)).toBe(6000);
|
||||
expect(topSpacerHeight(150, AVG_LINE_HEIGHT)).toBe(9000);
|
||||
});
|
||||
|
||||
it("uses the provided avgLineHeight rather than a hard-coded value", () => {
|
||||
expect(topSpacerHeight(5, 100)).toBe(500);
|
||||
expect(topSpacerHeight(5, 80)).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("virtual windowing constants", () => {
|
||||
it("WINDOW_SIZE is 150", () => {
|
||||
expect(WINDOW_SIZE).toBe(150);
|
||||
});
|
||||
|
||||
it("LOAD_CHUNK is 50", () => {
|
||||
expect(LOAD_CHUNK).toBe(50);
|
||||
});
|
||||
|
||||
it("LOAD_CHUNK is smaller than WINDOW_SIZE", () => {
|
||||
expect(LOAD_CHUNK).toBeLessThan(WINDOW_SIZE);
|
||||
});
|
||||
|
||||
it("AVG_LINE_HEIGHT is a positive number", () => {
|
||||
expect(AVG_LINE_HEIGHT).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1471,11 +1471,14 @@ export const achievementsByRarity = derived(achievementsStore, ($store) => {
|
||||
return byRarity;
|
||||
});
|
||||
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => ({
|
||||
unlocked: $store.totalUnlocked,
|
||||
total: Object.keys($store.achievements).length,
|
||||
percentage: Math.round(($store.totalUnlocked / Object.keys($store.achievements).length) * 100),
|
||||
}));
|
||||
export const achievementProgress = derived(achievementsStore, ($store) => {
|
||||
const total = Object.keys($store.achievements).length;
|
||||
return {
|
||||
unlocked: $store.totalUnlocked,
|
||||
total,
|
||||
percentage: Math.round(($store.totalUnlocked / total) * 100),
|
||||
};
|
||||
});
|
||||
|
||||
// Initialize achievement listener
|
||||
export async function initAchievementsListener() {
|
||||
|
||||
Reference in New Issue
Block a user