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
92 changes: 72 additions & 20 deletions src/browser/components/shared/DiffRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
highlightDiffChunk,
type HighlightedChunk,
} from "@/browser/utils/highlighting/highlightDiffChunk";
import { LRUCache } from "lru-cache";
import {
highlightSearchMatches,
type SearchHighlightConfig,
Expand Down Expand Up @@ -315,11 +316,49 @@ interface DiffRendererProps {
}

/**
* Hook to pre-process and highlight diff content in chunks
* Runs once when content/language changes (NOT search - that's applied post-process)
* Module-level cache for fully-highlighted diff results.
* Key: `${content.length}:${oldStart}:${newStart}:${language}:${themeMode}`
* (Using content.length instead of full content as a fast differentiator - collisions are rare
* and just cause re-highlighting, not incorrect rendering)
*
* CACHING: Once highlighted with real language, result is cached even if enableHighlighting
* becomes false later. This prevents re-highlighting during scroll when hunks leave viewport.
* This allows synchronous cache hits, eliminating the "Processing" flash when
* re-rendering the same diff content (e.g., scrolling back to a previously-viewed message).
*/
const highlightedDiffCache = new LRUCache<string, HighlightedChunk[]>({
max: 10000, // High limit - rely on maxSize for eviction
maxSize: 4 * 1024 * 1024, // 4MB total
sizeCalculation: (chunks) =>
chunks.reduce(
(total, chunk) =>
total + chunk.lines.reduce((lineTotal, line) => lineTotal + line.html.length * 2, 0),
0
),
});

function getDiffCacheKey(
content: string,
language: string,
oldStart: number,
newStart: number,
themeMode: ThemeMode
): string {
// Use content hash for more reliable cache hits
// Simple hash: length + first/last 100 chars (fast, unique enough for this use case)
const contentHash =
content.length <= 200
? content
: `${content.length}:${content.slice(0, 100)}:${content.slice(-100)}`;
return `${contentHash}:${oldStart}:${newStart}:${language}:${themeMode}`;
}

/**
* Hook to pre-process and highlight diff content in chunks.
* Results are cached at the module level for synchronous cache hits,
* eliminating "Processing" flash when re-rendering the same diff.
*
* When language="text" (highlighting disabled), keeps existing highlighted
* chunks rather than downgrading to plain text. This prevents flicker when
* hunks scroll out of viewport (enableHighlighting=false).
*/
function useHighlightedDiff(
content: string,
Expand All @@ -328,47 +367,60 @@ function useHighlightedDiff(
newStart: number,
themeMode: ThemeMode
): HighlightedChunk[] | null {
const [chunks, setChunks] = useState<HighlightedChunk[] | null>(null);
// Track if we've already highlighted with real syntax (to prevent downgrading)
const hasHighlightedRef = React.useRef(false);
const cacheKey = getDiffCacheKey(content, language, oldStart, newStart, themeMode);
const cachedResult = highlightedDiffCache.get(cacheKey);

// State for async highlighting results (initialized from cache if available)
const [chunks, setChunks] = useState<HighlightedChunk[] | null>(cachedResult ?? null);
// Track if we've highlighted this content with real syntax (not plain text)
const hasRealHighlightRef = React.useRef(false);

useEffect(() => {
// If already highlighted and trying to switch to plain text, keep the highlighted version
if (hasHighlightedRef.current && language === "text") {
return; // Keep cached highlighted chunks
// Already in cache - sync state and skip async work
const cached = highlightedDiffCache.get(cacheKey);
if (cached) {
setChunks(cached);
if (language !== "text") {
hasRealHighlightRef.current = true;
}
return;
}

// When highlighting is disabled (language="text") but we've already
// highlighted with real syntax, keep showing that version
if (language === "text" && hasRealHighlightRef.current) {
return;
}

// Reset to loading state for new uncached content
setChunks(null);

let cancelled = false;

async function highlight() {
// Split into lines (preserve indices for selection + rendering)
const lines = splitDiffLines(content);

// Group into chunks
const diffChunks = groupDiffLines(lines, oldStart, newStart);

// Highlight each chunk (without search decorations - those are applied later)
const highlighted = await Promise.all(
diffChunks.map((chunk) => highlightDiffChunk(chunk, language, themeMode))
);

if (!cancelled) {
highlightedDiffCache.set(cacheKey, highlighted);
setChunks(highlighted);
// Mark as highlighted if using real language (not plain text)
if (language !== "text") {
hasHighlightedRef.current = true;
hasRealHighlightRef.current = true;
}
}
}

void highlight();

return () => {
cancelled = true;
};
}, [content, language, oldStart, newStart, themeMode]);
}, [cacheKey, content, language, oldStart, newStart, themeMode]);

return chunks;
// Return cached result directly if available (sync path), else state (async path)
return cachedResult ?? chunks;
}

/**
Expand Down
48 changes: 7 additions & 41 deletions src/browser/utils/highlighting/highlightWorkerClient.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,19 @@
/**
* Syntax highlighting client with LRU caching
* Syntax highlighting client
*
* Provides async API for off-main-thread syntax highlighting via Web Worker.
* Results are cached to avoid redundant highlighting of identical code.
*
* Falls back to main-thread highlighting in test environments where
* Web Workers aren't available.
*
* Note: Caching happens at the caller level (DiffRenderer's highlightedDiffCache)
* to enable synchronous cache hits and avoid "Processing" flash.
*/

import { LRUCache } from "lru-cache";
import * as Comlink from "comlink";
import type { Highlighter } from "shiki";
import type { HighlightWorkerAPI } from "@/browser/workers/highlightWorker";
import { mapToShikiLang, SHIKI_DARK_THEME, SHIKI_LIGHT_THEME } from "./shiki-shared";

// ─────────────────────────────────────────────────────────────────────────────
// LRU Cache with SHA-256 hashing
// ─────────────────────────────────────────────────────────────────────────────

/**
* Cache for highlighted HTML results
* Key: First 64 bits of SHA-256 hash (hex string)
* Value: Shiki HTML output
*/
const highlightCache = new LRUCache<string, string>({
max: 10000, // High limit β€” rely on maxSize for eviction
maxSize: 8 * 1024 * 1024, // 8MB total
sizeCalculation: (html) => html.length * 2, // Rough bytes for JS strings
});

async function getCacheKey(code: string, language: string, theme: string): Promise<string> {
const { hashKey } = await import("@/common/lib/hashKey");
return hashKey(`${language}:${theme}:${code}`);
}

// ─────────────────────────────────────────────────────────────────────────────
// Main-thread Shiki (fallback only)
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -133,9 +113,8 @@ async function highlightMainThread(
// ─────────────────────────────────────────────────────────────────────────────

/**
* Highlight code with syntax highlighting (cached, off-main-thread)
* Highlight code with syntax highlighting (off-main-thread)
*
* Results are cached by (code, language, theme) to avoid redundant work.
* Highlighting runs in a Web Worker to avoid blocking the main thread.
*
* @param code - Source code to highlight
Expand All @@ -149,22 +128,9 @@ export async function highlightCode(
language: string,
theme: "dark" | "light"
): Promise<string> {
// Check cache first
const cacheKey = await getCacheKey(code, language, theme);
const cached = highlightCache.get(cacheKey);
if (cached) return cached;

// Dispatch to worker or main-thread fallback
const api = getWorkerAPI();
let html: string;

if (!api) {
html = await highlightMainThread(code, language, theme);
} else {
html = await api.highlight(code, language, theme);
return highlightMainThread(code, language, theme);
}

// Cache result
highlightCache.set(cacheKey, html);
return html;
return api.highlight(code, language, theme);
}