From ce7eaed3c9c12ba862e59a47a8943fe6eb62b244 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 12 Nov 2025 10:11:52 -0800 Subject: [PATCH 1/2] diffs --- apps/desktop/src/main/lib/worktree-manager.ts | 32 +++++++++++- .../main/components/DiffView/DiffContent.tsx | 30 ++---------- .../components/DiffView/DiffContentArea.tsx | 49 ++++++++++++++++--- .../components/Layout/TaskTabs/ModeToggle.tsx | 12 ++--- 4 files changed, 81 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/lib/worktree-manager.ts b/apps/desktop/src/main/lib/worktree-manager.ts index 5c46ad9a6ad..be8facb1668 100644 --- a/apps/desktop/src/main/lib/worktree-manager.ts +++ b/apps/desktop/src/main/lib/worktree-manager.ts @@ -847,6 +847,16 @@ class WorktreeManager { let newLineNum = 0; const diffLines = diffOutput.split("\n"); + // Only include 3 lines of context around changes to reduce data size + // This dramatically reduces memory usage for large files with few changes + const CONTEXT_LINES = 3; + let contextBuffer: Array<{ + type: "unchanged"; + oldLineNumber: number; + newLineNumber: number; + content: string; + }> = []; + for (let i = 0; i < diffLines.length; i++) { const line = diffLines[i]; @@ -857,6 +867,8 @@ class WorktreeManager { oldLineNum = parseInt(match[1], 10); newLineNum = parseInt(match[2], 10); } + // Flush context buffer when starting a new hunk + contextBuffer = []; continue; } @@ -872,6 +884,13 @@ class WorktreeManager { // Parse actual changes if (line.startsWith("+")) { + // Flush context buffer before adding change + if (contextBuffer.length > 0) { + // Only include up to CONTEXT_LINES from buffer + const contextToInclude = contextBuffer.slice(-CONTEXT_LINES); + changes.push(...contextToInclude); + contextBuffer = []; + } changes.push({ type: "added", oldLineNumber: null, @@ -880,6 +899,12 @@ class WorktreeManager { }); newLineNum++; } else if (line.startsWith("-")) { + // Flush context buffer before removing change + if (contextBuffer.length > 0) { + const contextToInclude = contextBuffer.slice(-CONTEXT_LINES); + changes.push(...contextToInclude); + contextBuffer = []; + } changes.push({ type: "removed", oldLineNumber: oldLineNum, @@ -888,12 +913,17 @@ class WorktreeManager { }); oldLineNum++; } else if (line.startsWith(" ")) { - changes.push({ + // Store unchanged lines in buffer (only keep last CONTEXT_LINES) + contextBuffer.push({ type: "unchanged", oldLineNumber: oldLineNum, newLineNumber: newLineNum, content: line.substring(1), }); + // Keep buffer size limited + if (contextBuffer.length > CONTEXT_LINES * 2) { + contextBuffer.shift(); + } oldLineNum++; newLineNum++; } diff --git a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx index 14b23b1c289..017ac26add6 100644 --- a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx @@ -1,8 +1,5 @@ import { Loader2 } from "lucide-react"; import { memo, useEffect } from "react"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; -import { detectLanguage } from "./languageDetector"; import type { DiffLine, FileDiff } from "./types"; interface DiffContentProps { @@ -16,14 +13,13 @@ export const DiffContent = memo(function DiffContent({ isLoading = false, onLoad, }: DiffContentProps) { - const language = detectLanguage(file.fileName); - // Trigger load when component mounts if content is not loaded useEffect(() => { if (file.changes.length === 0 && !isLoading && onLoad) { onLoad(); } }, [file.changes.length, isLoading, onLoad]); + const renderDiffLine = (line: DiffLine, index: number) => { const getBgColor = () => { switch (line.type) { @@ -104,28 +100,8 @@ export const DiffContent = memo(function DiffContent({ > {getLinePrefix()} -
- - {line.content || " "} - +
+ {line.content || " "}
); diff --git a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContentArea.tsx b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContentArea.tsx index a0f08dc09ea..56c41c6749f 100644 --- a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContentArea.tsx +++ b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContentArea.tsx @@ -36,6 +36,16 @@ export function DiffContentArea({ }: DiffContentAreaProps) { const [viewMode, setViewMode] = useState("files"); const scrollContainerRef = useRef(null); + const loadedFilesRef = useRef(loadedFiles); + const loadingFilesRef = useRef(loadingFiles); + const loadFileContentRef = useRef(loadFileContent); + + // Keep refs in sync + useEffect(() => { + loadedFilesRef.current = loadedFiles; + loadingFilesRef.current = loadingFiles; + loadFileContentRef.current = loadFileContent; + }, [loadedFiles, loadingFiles, loadFileContent]); // Load selected file immediately useEffect(() => { @@ -53,8 +63,8 @@ export function DiffContentArea({ for (const entry of entries) { if (entry.isIntersecting) { const fileId = entry.target.id.replace("file-diff-", ""); - if (fileId && !loadedFiles.has(fileId) && !loadingFiles.has(fileId)) { - loadFileContent(fileId); + if (fileId && !loadedFilesRef.current.has(fileId) && !loadingFilesRef.current.has(fileId)) { + loadFileContentRef.current?.(fileId); } } } @@ -66,15 +76,40 @@ export function DiffContentArea({ }, ); - // Observe all file diff elements - const elements = - scrollContainerRef.current.querySelectorAll('[id^="file-diff-"]'); - elements.forEach((el) => observer.observe(el)); + // Function to observe all file elements + const observeFiles = () => { + if (!scrollContainerRef.current) return; + const elements = + scrollContainerRef.current.querySelectorAll('[id^="file-diff-"]'); + elements.forEach((el) => { + // Only observe if not already observed + if (!observer.takeRecords().some((record) => record.target === el)) { + observer.observe(el); + } + }); + }; + + // Initial observation - use a timeout to batch DOM queries + const timeoutId = setTimeout(observeFiles, 0); + + // Use MutationObserver to watch for new file elements being added + const mutationObserver = new MutationObserver(() => { + observeFiles(); + }); + + if (scrollContainerRef.current) { + mutationObserver.observe(scrollContainerRef.current, { + childList: true, + subtree: true, + }); + } return () => { + clearTimeout(timeoutId); + mutationObserver.disconnect(); observer.disconnect(); }; - }, [viewMode, data.files, loadFileContent, loadedFiles, loadingFiles]); + }, [viewMode]); // Only recreate when viewMode changes const getFileIcon = (status: FileDiff["status"]) => { switch (status) { diff --git a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/ModeToggle.tsx b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/ModeToggle.tsx index 11d7081f8e8..a49dde75c5d 100644 --- a/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/ModeToggle.tsx +++ b/apps/desktop/src/renderer/screens/main/components/Layout/TaskTabs/ModeToggle.tsx @@ -6,27 +6,25 @@ interface ModeToggleProps { } export const ModeToggle: React.FC = ({ mode, onChange }) => ( -
+
From 3ffe0e585c6a6c8162080089473d400ac0ac5e82 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 12 Nov 2025 10:20:05 -0800 Subject: [PATCH 2/2] clean up --- apps/desktop/src/main/lib/workspace-ipcs.ts | 6 +- apps/desktop/src/main/lib/worktree-manager.ts | 143 +++++++++++++++++- .../main/components/DiffView/DiffContent.tsx | 52 +++++-- 3 files changed, 180 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/main/lib/workspace-ipcs.ts b/apps/desktop/src/main/lib/workspace-ipcs.ts index e23a78de169..4f44ca803f8 100644 --- a/apps/desktop/src/main/lib/workspace-ipcs.ts +++ b/apps/desktop/src/main/lib/workspace-ipcs.ts @@ -625,9 +625,13 @@ export function registerWorkspaceIPCs() { }; } + // Detect main branch instead of using workspace.branch + // This ensures we compare against main/master, not a feature branch + const mainBranch = await worktreeManager.detectMainBranch(workspace.repoPath); + return await worktreeManager.getGitDiffFile( worktree.path, - workspace.branch, + mainBranch, input.filePath, input.oldPath, input.status, diff --git a/apps/desktop/src/main/lib/worktree-manager.ts b/apps/desktop/src/main/lib/worktree-manager.ts index be8facb1668..1df6166e1a3 100644 --- a/apps/desktop/src/main/lib/worktree-manager.ts +++ b/apps/desktop/src/main/lib/worktree-manager.ts @@ -690,11 +690,99 @@ class WorktreeManager { } /** - * Get file list with stats (without full diff content) - non-blocking + * Detect the main branch of a repository + * Tries: main, master, then git's default branch */ + async detectMainBranch(repoPath: string): Promise { + // Try common main branch names + const candidates = ["main", "master"]; + + for (const candidate of candidates) { + try { + await execAsync(`git rev-parse --verify ${candidate}`, { + cwd: repoPath, + }); + return candidate; + } catch { + // Try remote version + try { + await execAsync(`git rev-parse --verify origin/${candidate}`, { + cwd: repoPath, + }); + return `origin/${candidate}`; + } catch { + // Continue to next candidate + } + } + } + + // Fallback: try to get git's default branch + try { + const result = await execAsync( + "git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", + { cwd: repoPath }, + ); + const defaultBranch = result.stdout.trim(); + if (defaultBranch) { + return defaultBranch; + } + } catch { + // Ignore errors + } + + // Last resort: return "main" and let caller handle the error + return "main"; + } + + /** + * Check if a file should be excluded from diff (build artifacts, dependencies, etc.) + */ + private shouldExcludeFile(filePath: string): boolean { + const excludePatterns = [ + // Dependencies + /node_modules/, + /\.pnp\./, + /\.yarn\//, + // Build outputs + /\/dist\//, + /\/dist-ssr\//, + /\/dist-electron\//, + /\/build\//, + /\/\.next\//, + /\/out\//, + /\/release\//, + /\/coverage\//, + // Lock files + /package-lock\.json$/, + /yarn\.lock$/, + /pnpm-lock\.yaml$/, + // Environment files + /\.env$/, + /\.env\./, + // Editor files + /\.DS_Store$/, + /\.vscode\//, + /\.idea\//, + // Cache files + /\.turbo\//, + /\.eslintcache$/, + /\.tsbuildinfo$/, + // Test results + /\/test-results\//, + /\/playwright-report\//, + /\/playwright\/\.cache\//, + // Temporary files + /\.tmp\//, + // Electron build artifacts + /electron\.vite\.config\.\d+\.mjs$/, + ]; + + return excludePatterns.some((pattern) => pattern.test(filePath)); + } + async getGitDiffFileList( worktreePath: string, - mainBranch: string, + _configuredBranch: string, ): Promise<{ success: boolean; files?: Array<{ @@ -709,9 +797,45 @@ class WorktreeManager { error?: string; }> { try { + // Detect the actual main branch (main/master) instead of using configured branch + // This ensures we always compare against the main branch, not a feature branch + // Get the actual git repository root (works for both regular repos and worktrees) + let repoPath = worktreePath; + try { + const gitDirResult = await execAsync("git rev-parse --show-toplevel", { + cwd: worktreePath, + }); + repoPath = gitDirResult.stdout.trim(); + } catch { + // Fallback: try to find .git directory + let currentPath = worktreePath; + while (currentPath !== "/" && currentPath !== "") { + if (existsSync(path.join(currentPath, ".git"))) { + repoPath = currentPath; + break; + } + currentPath = path.dirname(currentPath); + } + } + + const mainBranch = await this.detectMainBranch(repoPath); + + // Verify mainBranch exists + try { + await execAsync(`git rev-parse --verify ${mainBranch}`, { + cwd: worktreePath, + }); + } catch { + return { + success: false, + error: `Main branch "${mainBranch}" not found in repository`, + }; + } + // Get list of changed files with status + // Using three-dot syntax to show only committed changes (HEAD vs mainBranch) const diffFilesResult = await execAsync( - `git diff ${mainBranch} --name-status`, + `git diff ${mainBranch}...HEAD --name-status`, { cwd: worktreePath }, ); @@ -719,6 +843,7 @@ class WorktreeManager { .trim() .split("\n") .filter(Boolean); + const files = []; for (let i = 0; i < fileLines.length; i++) { @@ -728,6 +853,11 @@ class WorktreeManager { const filePath = parts[1]; const oldPath = parts[2]; // For renamed files + // Check if file should be excluded + if (this.shouldExcludeFile(filePath) || (oldPath && this.shouldExcludeFile(oldPath))) { + continue; + } + // Determine status let status: "added" | "deleted" | "modified" | "renamed" = "modified"; if (statusCode.startsWith("A")) status = "added"; @@ -756,7 +886,7 @@ class WorktreeManager { deletions = parseInt(statParts[1], 10) || 0; } } - } catch (statError) { + } catch { // If stat fails, try to get from shortstat try { const shortStatResult = await execAsync( @@ -823,10 +953,11 @@ class WorktreeManager { }> { try { // Get detailed diff for this file + // Use three-dot syntax to show only committed changes (HEAD vs mainBranch) const diffCommand = status === "deleted" - ? `git diff ${mainBranch} -- "${filePath}"` - : `git diff ${mainBranch} -- "${oldPath || filePath}"`; + ? `git diff ${mainBranch}...HEAD -- "${filePath}"` + : `git diff ${mainBranch}...HEAD -- "${oldPath || filePath}"`; const fileDiffResult = await execAsync(diffCommand, { cwd: worktreePath, diff --git a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx index 017ac26add6..83efbc0a4e9 100644 --- a/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx +++ b/apps/desktop/src/renderer/screens/main/components/DiffView/DiffContent.tsx @@ -1,5 +1,8 @@ import { Loader2 } from "lucide-react"; import { memo, useEffect } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { detectLanguage } from "./languageDetector"; import type { DiffLine, FileDiff } from "./types"; interface DiffContentProps { @@ -13,6 +16,8 @@ export const DiffContent = memo(function DiffContent({ isLoading = false, onLoad, }: DiffContentProps) { + const language = detectLanguage(file.fileName); + // Trigger load when component mounts if content is not loaded useEffect(() => { if (file.changes.length === 0 && !isLoading && onLoad) { @@ -89,19 +94,39 @@ export const DiffContent = memo(function DiffContent({ key={index} className={`flex font-mono text-[13px] leading-relaxed border-l-2 transition-colors ${getBorderColor()} ${getBgColor()}`} > -
+
{line.oldLineNumber || ""}
-
+
{line.newLineNumber || ""}
{getLinePrefix()}
-
- {line.content || " "} +
+ + {line.content || " "} +
); @@ -130,15 +155,14 @@ export const DiffContent = memo(function DiffContent({
)} {file.status}