mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
fix: highlight 缓存改用 LRUCache 降低内存开销
- Fallback.tsx: 手动 Map LRU 替换为 lru-cache 的 LRUCache - Markdown.tsx: tokenCache 同样替换为 LRUCache - color-diff-napi: 新增行级 hljs AST 缓存,避免终端 resize 时重复高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -502,6 +502,50 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
|
|||||||
|
|
||||||
let loggedEmitterShapeError = false
|
let loggedEmitterShapeError = false
|
||||||
|
|
||||||
|
// Per-line hljs AST cache — ColorFile.render re-highlights every line on
|
||||||
|
// width change (terminal resize). The AST is theme-independent; flattenHljs
|
||||||
|
// applies theme colors separately. Capped at 2048 entries (~1 MB typical).
|
||||||
|
const HL_LINE_CACHE_MAX = 2048
|
||||||
|
const hlLineCache = new Map<string, HljsNode | null>()
|
||||||
|
function cachedHljsAst(
|
||||||
|
lang: string,
|
||||||
|
code: string,
|
||||||
|
): HljsNode | null {
|
||||||
|
const key = lang + '\0' + code
|
||||||
|
const hit = hlLineCache.get(key)
|
||||||
|
if (hit !== undefined) return hit
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = hljsApi().highlight(code, {
|
||||||
|
language: lang,
|
||||||
|
ignoreIllegals: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
hlLineCache.set(key, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const emitter = result._emitter || {}
|
||||||
|
if (!hasRootNode(emitter)) {
|
||||||
|
if (!loggedEmitterShapeError) {
|
||||||
|
loggedEmitterShapeError = true
|
||||||
|
logError(
|
||||||
|
new Error(
|
||||||
|
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hlLineCache.set(key, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const node = emitter.rootNode
|
||||||
|
if (hlLineCache.size >= HL_LINE_CACHE_MAX) {
|
||||||
|
const first = hlLineCache.keys().next().value
|
||||||
|
if (first !== undefined) hlLineCache.delete(first)
|
||||||
|
}
|
||||||
|
hlLineCache.set(key, node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
function highlightLine(
|
function highlightLine(
|
||||||
state: { lang: string | null; stack: unknown },
|
state: { lang: string | null; stack: unknown },
|
||||||
line: string,
|
line: string,
|
||||||
@@ -512,30 +556,12 @@ function highlightLine(
|
|||||||
if (!state.lang) {
|
if (!state.lang) {
|
||||||
return [[defaultStyle(theme), code]]
|
return [[defaultStyle(theme), code]]
|
||||||
}
|
}
|
||||||
let result
|
const rootNode = cachedHljsAst(state.lang, code)
|
||||||
try {
|
if (!rootNode) {
|
||||||
result = hljsApi().highlight(code, {
|
|
||||||
language: state.lang,
|
|
||||||
ignoreIllegals: true,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// hljs throws on unknown language despite ignoreIllegals
|
|
||||||
return [[defaultStyle(theme), code]]
|
|
||||||
}
|
|
||||||
const emitter = result._emitter || {};
|
|
||||||
if (!hasRootNode(emitter)) {
|
|
||||||
if (!loggedEmitterShapeError) {
|
|
||||||
loggedEmitterShapeError = true
|
|
||||||
logError(
|
|
||||||
new Error(
|
|
||||||
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return [[defaultStyle(theme), code]]
|
return [[defaultStyle(theme), code]]
|
||||||
}
|
}
|
||||||
const blocks: Block[] = []
|
const blocks: Block[] = []
|
||||||
flattenHljs(emitter.rootNode, theme, undefined, blocks)
|
flattenHljs(rootNode, theme, undefined, blocks)
|
||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { extname } from 'path'
|
import { extname } from 'path'
|
||||||
import React, { Suspense, use, useMemo } from 'react'
|
import React, { Suspense, use, useMemo } from 'react'
|
||||||
import { Ansi, Text } from '@anthropic/ink'
|
import { Ansi, Text } from '@anthropic/ink'
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
|
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
|
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
|
||||||
@@ -16,8 +17,7 @@ type Props = {
|
|||||||
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
|
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
|
||||||
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
|
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
|
||||||
// of code+language to avoid retaining full source strings (#24180 RSS fix).
|
// of code+language to avoid retaining full source strings (#24180 RSS fix).
|
||||||
const HL_CACHE_MAX = 500
|
const hlCache = new LRUCache<string, string>({ max: 500 })
|
||||||
const hlCache = new Map<string, string>()
|
|
||||||
function cachedHighlight(
|
function cachedHighlight(
|
||||||
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
|
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -25,16 +25,8 @@ function cachedHighlight(
|
|||||||
): string {
|
): string {
|
||||||
const key = hashPair(language, code)
|
const key = hashPair(language, code)
|
||||||
const hit = hlCache.get(key)
|
const hit = hlCache.get(key)
|
||||||
if (hit !== undefined) {
|
if (hit !== undefined) return hit
|
||||||
hlCache.delete(key)
|
|
||||||
hlCache.set(key, hit)
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
const out = hl.highlight(code, { language })
|
const out = hl.highlight(code, { language })
|
||||||
if (hlCache.size >= HL_CACHE_MAX) {
|
|
||||||
const first = hlCache.keys().next().value
|
|
||||||
if (first !== undefined) hlCache.delete(first)
|
|
||||||
}
|
|
||||||
hlCache.set(key, out)
|
hlCache.set(key, out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { marked, type Token, type Tokens } from 'marked'
|
import { marked, type Token, type Tokens } from 'marked'
|
||||||
import React, { Suspense, use, useMemo, useRef } from 'react'
|
import React, { Suspense, use, useMemo, useRef } from 'react'
|
||||||
|
import { LRUCache } from 'lru-cache'
|
||||||
import { useSettings } from '../hooks/useSettings.js'
|
import { useSettings } from '../hooks/useSettings.js'
|
||||||
import { Ansi, Box, useTheme } from '@anthropic/ink'
|
import { Ansi, Box, useTheme } from '@anthropic/ink'
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +23,7 @@ type Props = {
|
|||||||
// scrolling back to a previously-visible message re-parses. Messages are
|
// scrolling back to a previously-visible message re-parses. Messages are
|
||||||
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
||||||
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
||||||
const TOKEN_CACHE_MAX = 500
|
const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
|
||||||
const tokenCache = new Map<string, Token[]>()
|
|
||||||
|
|
||||||
// Characters that indicate markdown syntax. If none are present, skip the
|
// Characters that indicate markdown syntax. If none are present, skip the
|
||||||
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
||||||
@@ -55,19 +55,8 @@ function cachedLexer(content: string): Token[] {
|
|||||||
}
|
}
|
||||||
const key = hashContent(content)
|
const key = hashContent(content)
|
||||||
const hit = tokenCache.get(key)
|
const hit = tokenCache.get(key)
|
||||||
if (hit) {
|
if (hit) return hit
|
||||||
// Promote to MRU — without this the eviction is FIFO (scrolling back to
|
|
||||||
// an early message evicts the very item you're looking at).
|
|
||||||
tokenCache.delete(key)
|
|
||||||
tokenCache.set(key, hit)
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
const tokens = marked.lexer(content)
|
const tokens = marked.lexer(content)
|
||||||
if (tokenCache.size >= TOKEN_CACHE_MAX) {
|
|
||||||
// LRU-ish: drop oldest. Map preserves insertion order.
|
|
||||||
const first = tokenCache.keys().next().value
|
|
||||||
if (first !== undefined) tokenCache.delete(first)
|
|
||||||
}
|
|
||||||
tokenCache.set(key, tokens)
|
tokenCache.set(key, tokens)
|
||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user