diff --git a/.gitignore b/.gitignore index d889e830..d0547755 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,6 @@ public/tree-sitter/queries/ # Bundled tree-sitter grammars src/extensions/bundled/*/grammars/*.wasm + +# Benchmark files +benchmark_*.md diff --git a/scripts/benchmark-suite.ts b/scripts/benchmark-suite.ts new file mode 100644 index 00000000..3e3065fd --- /dev/null +++ b/scripts/benchmark-suite.ts @@ -0,0 +1,28 @@ +import { writeFileSync } from "fs"; +import { join } from "path"; + +const BENCHMARKS = { + small: { lines: 100, name: "benchmark_small.md" }, + medium: { lines: 2000, name: "benchmark_medium.md" }, + large: { lines: 20000, name: "benchmark_large.md" }, +}; + +const generateContent = (lineCount: number) => { + let content = "# Benchmark File\n\n"; + for (let i = 0; i < lineCount; i++) { + content += `## Section ${i}\n`; + content += `This is line ${i} of our benchmark file. It contains some **bold**, *italic*, and \`code\` elements to test parsing.\n`; + if (i % 10 === 0) { + content += "```typescript\nconsole.log('Code block benchmark');\n```\n"; + } + } + return content; +}; + +Object.entries(BENCHMARKS).forEach(([key, config]) => { + const filePath = join(process.cwd(), config.name); + console.log(`Generating ${key} benchmark (${config.lines} lines) to ${config.name}...`); + writeFileSync(filePath, generateContent(config.lines)); +}); + +console.log("Benchmark suite generated successfully."); diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx index 0272030b..54935d54 100644 --- a/src/features/editor/components/editor.tsx +++ b/src/features/editor/components/editor.tsx @@ -9,6 +9,7 @@ import { useContextMenu } from "../hooks/use-context-menu"; import { useEditorOperations } from "../hooks/use-editor-operations"; import { useFoldTransform } from "../hooks/use-fold-transform"; import { useInlineDiff } from "../hooks/use-inline-diff"; +import { usePerformanceMonitor } from "../hooks/use-performance"; import { getLanguageId, useTokenizer } from "../hooks/use-tokenizer"; import { useViewportLines } from "../hooks/use-viewport-lines"; import { useLspStore } from "../lsp/lsp-store"; @@ -107,7 +108,14 @@ export function Editor({ const contextMenu = useContextMenu(); const inlineDiff = useInlineDiff(filePath, content); - const actualLines = useMemo(() => splitLines(content), [content]); + const { startMeasure, endMeasure } = usePerformanceMonitor("Editor"); + + const actualLines = useMemo(() => { + startMeasure(`splitLines (len: ${content.length})`); + const res = splitLines(content); + endMeasure(`splitLines (len: ${content.length})`); + return res; + }, [content, startMeasure, endMeasure]); const lines = foldTransform.hasActiveFolds ? foldTransform.virtualLines : actualLines; const displayContent = foldTransform.hasActiveFolds ? foldTransform.virtualContent : content; // Use consistent line height for both textarea and gutter diff --git a/src/features/editor/components/layers/highlight-layer.tsx b/src/features/editor/components/layers/highlight-layer.tsx index c5cfcaeb..b828acb0 100644 --- a/src/features/editor/components/layers/highlight-layer.tsx +++ b/src/features/editor/components/layers/highlight-layer.tsx @@ -110,8 +110,10 @@ const HighlightLayerComponent = forwardRef( const sortedTokens = useMemo(() => [...tokens].sort((a, b) => a.start - b.start), [tokens]); + // Calculate line offsets once when content changes, independent of viewport + const lineOffsets = useMemo(() => buildLineOffsetMap(normalizedContent), [normalizedContent]); + const lineTokensMap = useMemo(() => { - const lineOffsets = buildLineOffsetMap(normalizedContent); const map = new Map(); let tokenIndex = 0; @@ -147,21 +149,22 @@ const HighlightLayerComponent = forwardRef( } return map; - }, [lines, sortedTokens, normalizedContent, viewportRange]); + }, [lines, sortedTokens, lineOffsets, viewportRange]); const renderedLines = useMemo(() => { - const lineOffsets = buildLineOffsetMap(normalizedContent); const startLine = viewportRange?.startLine ?? 0; const endLine = viewportRange?.endLine ?? lines.length; const result: ReactNode[] = []; - // Add empty divs for lines before viewport (for correct positioning) - for (let i = 0; i < startLine; i++) { + // Add spacer for lines before viewport + if (startLine > 0) { result.push( -
- {"\u00A0"} -
, +
, ); } @@ -182,17 +185,20 @@ const HighlightLayerComponent = forwardRef( ); } - // Add empty divs for lines after viewport (for correct positioning) - for (let i = Math.min(endLine, lines.length); i < lines.length; i++) { + // Add spacer for lines after viewport + const remainingLines = lines.length - Math.min(endLine, lines.length); + if (remainingLines > 0) { result.push( -
- {"\u00A0"} -
, +
, ); } return result; - }, [lines, lineTokensMap, normalizedContent, viewportRange]); + }, [lines, lineTokensMap, lineOffsets, viewportRange, lineHeight]); return (
>(new Set()); + + const startMeasure = useCallback( + (metricName: string) => { + const markName = `${componentName}:${metricName}:start`; + performance.mark(markName); + marksRef.current.add(markName); + }, + [componentName], + ); + + const endMeasure = useCallback( + (metricName: string) => { + const startMarkName = `${componentName}:${metricName}:start`; + const endMarkName = `${componentName}:${metricName}:end`; + const measureName = `${componentName}:${metricName}`; + + if (marksRef.current.has(startMarkName)) { + performance.mark(endMarkName); + try { + performance.measure(measureName, startMarkName, endMarkName); + const entries = performance.getEntriesByName(measureName); + const lastEntry = entries[entries.length - 1]; + if (lastEntry) { + console.info( + `[Performance] [${componentName}] ${metricName}: ${lastEntry.duration.toFixed(2)}ms`, + ); + // Optional: Dispatch event for automated benchmark collection + window.dispatchEvent( + new CustomEvent("performance-metric", { + detail: { + component: componentName, + metric: metricName, + duration: lastEntry.duration, + timestamp: Date.now(), + }, + }), + ); + } + } catch (e) { + console.warn(`Failed to measure ${measureName}`, e); + } + // Cleanup marks + performance.clearMarks(startMarkName); + performance.clearMarks(endMarkName); + performance.clearMeasures(measureName); + marksRef.current.delete(startMarkName); + + return performance.getEntriesByName(measureName).pop()?.duration; + } + }, + [componentName], + ); -export function usePerformanceMonitor() { return { - startMeasure: () => {}, - endMeasure: () => {}, - getMeasurements: () => ({}), + startMeasure, + endMeasure, }; } export function useThrottledCallback any>( callback: T, - _delay: number, + delay: number, ): T { - return callback; + const lastRun = useRef(0); + const timeoutRef = useRef(null); + + return useCallback( + (...args: Parameters) => { + const now = Date.now(); + if (now - lastRun.current >= delay) { + lastRun.current = now; + callback(...args); + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout( + () => { + lastRun.current = Date.now(); + callback(...args); + }, + delay - (now - lastRun.current), + ); + } + }, + [callback, delay], + ) as T; } diff --git a/src/features/editor/hooks/use-tokenizer.ts b/src/features/editor/hooks/use-tokenizer.ts index da058994..21426b18 100644 --- a/src/features/editor/hooks/use-tokenizer.ts +++ b/src/features/editor/hooks/use-tokenizer.ts @@ -14,6 +14,7 @@ import { import type { HighlightToken } from "../lib/wasm-parser/types"; import { useTreeCacheStore } from "../stores/tree-cache-store"; import { buildLineOffsetMap, normalizeLineEndings, type Token } from "../utils/html"; +import { usePerformanceMonitor } from "./use-performance"; import type { ViewportRange } from "./use-viewport-lines"; interface TokenizerOptions { @@ -103,6 +104,10 @@ function getLocalWasmPath(languageId: string): string { if (languageId === "typescript" || languageId === "javascript") { return "/tree-sitter/parsers/tree-sitter-tsx.wasm"; } + // Special case for markdown as the file is named tree-sitter.wasm in public folder + if (languageId === "markdown") { + return "/tree-sitter/tree-sitter.wasm"; + } return `/tree-sitter/parsers/tree-sitter-${languageId}.wasm`; } @@ -144,6 +149,7 @@ export function useTokenizer({ previousContent: "", }); const treeCacheActions = useTreeCacheStore.use.actions(); + const { startMeasure, endMeasure } = usePerformanceMonitor("Tokenizer"); /** * Tokenize the full document using WASM parser @@ -166,6 +172,7 @@ export function useTokenizer({ // Normalize line endings before tokenizing const normalizedText = normalizeLineEndings(text); + startMeasure(`tokenizeFull (len: ${normalizedText.length})`); let wasmPath: string; let highlightQuery: string | undefined; @@ -245,6 +252,7 @@ export function useTokenizer({ setTokenizedContent(""); } finally { setLoading(false); + endMeasure(`tokenizeFull (len: ${normalizeLineEndings(text).length})`); } }, [enabled, filePath, bufferId, treeCacheActions], @@ -260,6 +268,8 @@ export function useTokenizer({ const languageId = getLanguageId(filePath); if (!languageId) return; + startMeasure("tokenizeRangeInternal"); + const lineCount = text.split("\n").length; // For small files, always tokenize fully @@ -370,6 +380,7 @@ export function useTokenizer({ tokenizeFull(text); } finally { setLoading(false); + endMeasure("tokenizeRangeInternal"); } }, [enabled, filePath, tokenizeFull], diff --git a/src/features/version-control/git/controllers/use-gutter.ts b/src/features/version-control/git/controllers/use-gutter.ts index e72fabd1..f2b868b3 100644 --- a/src/features/version-control/git/controllers/use-gutter.ts +++ b/src/features/version-control/git/controllers/use-gutter.ts @@ -29,9 +29,15 @@ export function useGitGutter({ filePath, content, enabled = true }: GitGutterHoo // Memoized content hash for efficient change detection const contentHash = useMemo(() => { - const encoder = new TextEncoder(); - const bytes = encoder.encode(content); - return content ? btoa(String.fromCharCode(...bytes)).slice(0, 32) : ""; + if (!content) return ""; + // Simple hash for large strings to avoid stack overflow with String.fromCharCode(...bytes) + let hash = 0; + for (let i = 0; i < content.length; i++) { + const char = content.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; // Convert to 32bit integer + } + return hash.toString(36); }, [content]); // Process git diff lines into line change information