diff --git a/app/components/workbench/DiffView.tsx b/app/components/workbench/DiffView.tsx index 146076c5..7747c4a8 100644 --- a/app/components/workbench/DiffView.tsx +++ b/app/components/workbench/DiffView.tsx @@ -542,8 +542,53 @@ const FileInfo = memo( }, ); +// Create and manage a single highlighter instance at the module level +let highlighterInstance: any = null; +let highlighterPromise: Promise | null = null; + +const getSharedHighlighter = async () => { + if (highlighterInstance) { + return highlighterInstance; + } + + if (highlighterPromise) { + return highlighterPromise; + } + + highlighterPromise = getHighlighter({ + themes: ['github-dark', 'github-light'], + langs: [ + 'typescript', + 'javascript', + 'json', + 'html', + 'css', + 'jsx', + 'tsx', + 'python', + 'php', + 'java', + 'c', + 'cpp', + 'csharp', + 'go', + 'ruby', + 'rust', + 'plaintext', + ], + }); + + highlighterInstance = await highlighterPromise; + highlighterPromise = null; + + // Clear the promise once resolved + return highlighterInstance; +}; + const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language }: CodeComparisonProps) => { const [isFullscreen, setIsFullscreen] = useState(false); + + // Use state to hold the shared highlighter instance const [highlighter, setHighlighter] = useState(null); const theme = useStore(themeStore); @@ -554,34 +599,32 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language } const { unifiedBlocks, hasChanges, isBinary, error } = useProcessChanges(beforeCode, afterCode); useEffect(() => { - getHighlighter({ - themes: ['github-dark', 'github-light'], - langs: [ - 'typescript', - 'javascript', - 'json', - 'html', - 'css', - 'jsx', - 'tsx', - 'python', - 'php', - 'java', - 'c', - 'cpp', - 'csharp', - 'go', - 'ruby', - 'rust', - 'plaintext', - ], - }).then(setHighlighter); - }, []); + // Fetch the shared highlighter instance + getSharedHighlighter().then(setHighlighter); + + /* + * No cleanup needed here for the highlighter instance itself, + * as it's managed globally. Shiki instances don't typically + * need disposal unless you are dynamically loading/unloading themes/languages. + * If you were dynamically loading, you might need a more complex + * shared instance manager with reference counting or similar. + * For static themes/langs, a single instance is sufficient. + */ + }, []); // Empty dependency array ensures this runs only once on mount if (isBinary || error) { return renderContentWarning(isBinary ? 'binary' : 'error'); } + // Render a loading state or null while highlighter is not ready + if (!highlighter) { + return ( +
+
Loading diff...
+
+ ); + } + return (
@@ -602,7 +645,7 @@ const InlineDiffComparison = memo(({ beforeCode, afterCode, filename, language } lineNumber={block.lineNumber} content={block.content} type={block.type} - highlighter={highlighter} + highlighter={highlighter} // Pass the shared instance language={language} block={block} theme={theme} diff --git a/app/lib/stores/files.ts b/app/lib/stores/files.ts index a976c3a9..e2d8e40f 100644 --- a/app/lib/stores/files.ts +++ b/app/lib/stores/files.ts @@ -692,27 +692,9 @@ export class FilesStore { #processEventBuffer(events: Array<[events: PathWatcherEvent[]]>) { const watchEvents = events.flat(2); - for (const { type, path: eventPath, buffer } of watchEvents) { + for (const { type, path, buffer } of watchEvents) { // remove any trailing slashes - const sanitizedPath = eventPath.replace(/\/+$/g, ''); - - // Skip processing if this file/folder was explicitly deleted - if (this.#deletedPaths.has(sanitizedPath)) { - continue; - } - - let isInDeletedFolder = false; - - for (const deletedPath of this.#deletedPaths) { - if (sanitizedPath.startsWith(deletedPath + '/')) { - isInDeletedFolder = true; - break; - } - } - - if (isInDeletedFolder) { - continue; - } + const sanitizedPath = path.replace(/\/+$/g, ''); switch (type) { case 'add_dir': { @@ -738,38 +720,21 @@ export class FilesStore { } let content = ''; + + /** + * @note This check is purely for the editor. The way we detect this is not + * bullet-proof and it's a best guess so there might be false-positives. + * The reason we do this is because we don't want to display binary files + * in the editor nor allow to edit them. + */ const isBinary = isBinaryFile(buffer); - if (isBinary && buffer) { - // For binary files, we need to preserve the content as base64 - content = Buffer.from(buffer).toString('base64'); - } else if (!isBinary) { + if (!isBinary) { content = this.#decodeFileContent(buffer); - - /* - * If the content is a single space and this is from our empty file workaround, - * convert it back to an actual empty string - */ - if (content === ' ' && type === 'add_file') { - content = ''; - } } - const existingFile = this.files.get()[sanitizedPath]; + this.files.setKey(sanitizedPath, { type: 'file', content, isBinary }); - if (existingFile?.type === 'file' && existingFile.isBinary && existingFile.content && !content) { - content = existingFile.content; - } - - // Preserve lock state if the file already exists - const isLocked = existingFile?.type === 'file' ? existingFile.isLocked : false; - - this.files.setKey(sanitizedPath, { - type: 'file', - content, - isBinary, - isLocked, - }); break; } case 'remove_file': {