From 5d5961952d805caf5e3fa041aa403f27de1ee1d2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:24:26 -0700 Subject: [PATCH] feat: make diff view editable (right side) for unstaged changes The modified (right) side of diff views is now editable for unstaged files, matching the behavior of VS Code and Zed. Staged diffs remain read-only. Supports Cmd+S to save, dirty state tracking, and works in both single-file and combined ("View All Changes") diff views. Co-Authored-By: Claude Opus 4.6 --- .../components/editor/CombinedDiffViewer.tsx | 71 +++++++++++++++++-- .../src/components/editor/DiffViewer.tsx | 44 ++++++++++-- .../src/components/editor/EditorPanel.tsx | 50 ++++++++++--- 3 files changed, 145 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index d60e623f9ad..3c45426ba23 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -1,6 +1,7 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import { ChevronDown, ChevronRight } from 'lucide-react' -import { DiffEditor } from '@monaco-editor/react' +import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' +import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { detectLanguage } from '@/lib/language-detect' import '@/lib/monaco-setup' @@ -13,6 +14,7 @@ type DiffSection = { modifiedContent: string collapsed: boolean loading: boolean + dirty: boolean } export default function CombinedDiffViewer({ @@ -48,7 +50,8 @@ export default function CombinedDiffViewer({ originalContent: '', modifiedContent: '', collapsed: false, - loading: true + loading: true, + dirty: false })) setSections(initialSections) @@ -90,10 +93,42 @@ export default function CombinedDiffViewer({ } }, [worktreePath]) + // Track modified editors for each section so we can read their current value on save + const modifiedEditorsRef = useRef>(new Map()) + const toggleSection = useCallback((index: number) => { setSections((prev) => prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))) }, []) + const handleSectionSave = useCallback( + async (index: number) => { + const section = sections[index] + if (!section) { + return + } + const modifiedEditor = modifiedEditorsRef.current.get(index) + if (!modifiedEditor) { + return + } + + const content = modifiedEditor.getValue() + const absolutePath = `${worktreePath}/${section.entry.path}` + try { + await window.api.fs.writeFile({ filePath: absolutePath, content }) + setSections((prev) => + prev.map((s, i) => (i === index ? { ...s, modifiedContent: content, dirty: false } : s)) + ) + } catch (err) { + console.error('Save failed:', err) + } + }, + [sections, worktreePath] + ) + + // Keep a ref so mounted editors always call the latest save + const handleSectionSaveRef = useRef(handleSectionSave) + handleSectionSaveRef.current = handleSectionSave + if (sections.length === 0) { return (
@@ -137,6 +172,27 @@ export default function CombinedDiffViewer({ const dirPath = section.entry.path.includes('/') ? section.entry.path.slice(0, section.entry.path.lastIndexOf('/')) : '' + const isEditable = section.entry.area === 'unstaged' + + const handleMount: DiffOnMount = (editor, monaco) => { + if (isEditable) { + const modifiedEditor = editor.getModifiedEditor() + modifiedEditorsRef.current.set(index, modifiedEditor) + + modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => + handleSectionSaveRef.current(index) + ) + + modifiedEditor.onDidChangeModelContent(() => { + const current = modifiedEditor.getValue() + setSections((prev) => + prev.map((s, i) => + i === index ? { ...s, dirty: current !== s.modifiedContent } : s + ) + ) + }) + } + } return (
)} - {fileName} + + {fileName} + {section.dirty && M} + {dirPath && {dirPath}} void + onSave?: (content: string) => void } export default function DiffViewer({ originalContent, modifiedContent, language, - filePath + filePath, + editable, + onContentChange, + onSave }: DiffViewerProps): React.JSX.Element { const [sideBySide, setSideBySide] = useState(true) const settings = useAppStore((s) => s.settings) @@ -23,9 +29,34 @@ export default function DiffViewer({ settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - const handleMount: DiffOnMount = useCallback((editor) => { - editor.focus() - }, []) + // Keep refs to latest callbacks so the mounted editor always calls current versions + const onSaveRef = useRef(onSave) + onSaveRef.current = onSave + const onContentChangeRef = useRef(onContentChange) + onContentChangeRef.current = onContentChange + + const handleMount: DiffOnMount = useCallback( + (editor, monaco) => { + if (editable) { + const modifiedEditor = editor.getModifiedEditor() + + // Cmd/Ctrl+S to save + modifiedEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + onSaveRef.current?.(modifiedEditor.getValue()) + }) + + // Track changes + modifiedEditor.onDidChangeModelContent(() => { + onContentChangeRef.current?.(modifiedEditor.getValue()) + }) + + modifiedEditor.focus() + } else { + editor.focus() + } + }, + [editable] + ) return (
@@ -51,7 +82,8 @@ export default function DiffViewer({ theme={isDark ? 'vs-dark' : 'vs'} onMount={handleMount} options={{ - readOnly: true, + readOnly: !editable, + originalEditable: false, renderSideBySide: sideBySide, minimap: { enabled: false }, scrollBeyondLastLine: false, diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index c1fe04512d8..a4d609d2036 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -88,11 +88,18 @@ export default function EditorPanel(): React.JSX.Element | null { return } setEditBuffers((prev) => ({ ...prev, [activeFile.id]: content })) - // Compare against saved content to determine dirty state - const saved = fileContents[activeFile.id]?.content ?? '' - markFileDirty(activeFile.id, content !== saved) + if (activeFile.mode === 'edit') { + // Compare against saved content to determine dirty state + const saved = fileContents[activeFile.id]?.content ?? '' + markFileDirty(activeFile.id, content !== saved) + } else { + // Diff mode: compare against the original modified content from git + const dc = diffContents[activeFile.id] + const original = dc?.modifiedContent ?? '' + markFileDirty(activeFile.id, content !== original) + } }, - [activeFile, markFileDirty, fileContents] + [activeFile, markFileDirty, fileContents, diffContents] ) const handleSave = useCallback( @@ -103,10 +110,30 @@ export default function EditorPanel(): React.JSX.Element | null { try { await window.api.fs.writeFile({ filePath: activeFile.filePath, content }) markFileDirty(activeFile.id, false) - setFileContents((prev) => ({ - ...prev, - [activeFile.id]: { content, isBinary: false } - })) + if (activeFile.mode === 'edit') { + setFileContents((prev) => ({ + ...prev, + [activeFile.id]: { content, isBinary: false } + })) + } else { + // Update the diff's modified content baseline so dirty tracking stays correct + setDiffContents((prev) => { + const existing = prev[activeFile.id] + if (!existing) { + return prev + } + return { + ...prev, + [activeFile.id]: { ...existing, modifiedContent: content } + } + }) + } + // Clear the edit buffer since it now matches saved state + setEditBuffers((prev) => { + const next = { ...prev } + delete next[activeFile.id] + return next + }) } catch (err) { console.error('Save failed:', err) } @@ -236,12 +263,17 @@ export default function EditorPanel(): React.JSX.Element | null {
) } + // Unstaged diffs are editable (right side = working tree file) + const isEditable = activeFile.diffStaged === false return ( ) })()