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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,6 @@ public/tree-sitter/queries/

# Bundled tree-sitter grammars
src/extensions/bundled/*/grammars/*.wasm

# Benchmark files
benchmark_*.md
28 changes: 28 additions & 0 deletions scripts/benchmark-suite.ts
Original file line number Diff line number Diff line change
@@ -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.");
10 changes: 9 additions & 1 deletion src/features/editor/components/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
34 changes: 20 additions & 14 deletions src/features/editor/components/layers/highlight-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ const HighlightLayerComponent = forwardRef<HTMLDivElement, HighlightLayerProps>(

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<number, Token[]>();
let tokenIndex = 0;

Expand Down Expand Up @@ -147,21 +149,22 @@ const HighlightLayerComponent = forwardRef<HTMLDivElement, HighlightLayerProps>(
}

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(
<div key={i} className="highlight-layer-line">
{"\u00A0"}
</div>,
<div
key="spacer-top"
style={{ height: `${startLine * lineHeight}px` }}
className="highlight-layer-spacer"
/>,
);
}

Expand All @@ -182,17 +185,20 @@ const HighlightLayerComponent = forwardRef<HTMLDivElement, HighlightLayerProps>(
);
}

// 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(
<div key={i} className="highlight-layer-line">
{"\u00A0"}
</div>,
<div
key="spacer-bottom"
style={{ height: `${remainingLines * lineHeight}px` }}
className="highlight-layer-spacer"
/>,
);
}

return result;
}, [lines, lineTokensMap, normalizedContent, viewportRange]);
}, [lines, lineTokensMap, lineOffsets, viewportRange, lineHeight]);

return (
<div
Expand Down
97 changes: 90 additions & 7 deletions src/features/editor/hooks/use-performance.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,101 @@
/**
* Performance hook stub for backward compatibility
* Performance hook for tracking core editor metrics
*/
import { useCallback, useRef } from "react";

export interface PerformanceMetric {
name: string;
startTime: number;
duration: number;
}

export function usePerformanceMonitor(componentName: string) {
const marksRef = useRef<Set<string>>(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<T extends (...args: any[]) => any>(
callback: T,
_delay: number,
delay: number,
): T {
return callback;
const lastRun = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

return useCallback(
(...args: Parameters<T>) => {
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;
}
11 changes: 11 additions & 0 deletions src/features/editor/hooks/use-tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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`;
}

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -245,6 +252,7 @@ export function useTokenizer({
setTokenizedContent("");
} finally {
setLoading(false);
endMeasure(`tokenizeFull (len: ${normalizeLineEndings(text).length})`);
}
},
[enabled, filePath, bufferId, treeCacheActions],
Expand All @@ -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
Expand Down Expand Up @@ -370,6 +380,7 @@ export function useTokenizer({
tokenizeFull(text);
} finally {
setLoading(false);
endMeasure("tokenizeRangeInternal");
}
},
[enabled, filePath, tokenizeFull],
Expand Down
12 changes: 9 additions & 3 deletions src/features/version-control/git/controllers/use-gutter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down