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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-03-25 22:24:26 -07:00
co-authored by Claude Opus 4.6
parent 4c0eee235b
commit 5d5961952d
3 changed files with 145 additions and 20 deletions
@@ -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<Map<number, monacoEditor.IStandaloneCodeEditor>>(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 (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
@@ -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 (
<div
@@ -153,7 +209,10 @@ export default function CombinedDiffViewer({
) : (
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="font-medium">{fileName}</span>
<span className="font-medium">
{fileName}
{section.dirty && <span className="text-muted-foreground ml-1">M</span>}
</span>
{dirPath && <span className="text-muted-foreground text-xs">{dirPath}</span>}
<span
className={cn(
@@ -188,8 +247,10 @@ export default function CombinedDiffViewer({
original={section.originalContent}
modified={section.modifiedContent}
theme={isDark ? 'vs-dark' : 'vs'}
onMount={handleMount}
options={{
readOnly: true,
readOnly: !isEditable,
originalEditable: false,
renderSideBySide: sideBySide,
minimap: { enabled: false },
scrollBeyondLastLine: false,
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react'
import React, { useState, useCallback, useRef } from 'react'
import { DiffEditor, type DiffOnMount } from '@monaco-editor/react'
import { Columns2, Rows2 } from 'lucide-react'
import { useAppStore } from '@/store'
@@ -9,13 +9,19 @@ type DiffViewerProps = {
modifiedContent: string
language: string
filePath: string
editable?: boolean
onContentChange?: (content: string) => 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 (
<div className="flex flex-col h-full">
@@ -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,
@@ -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 {
</div>
)
}
// Unstaged diffs are editable (right side = working tree file)
const isEditable = activeFile.diffStaged === false
return (
<DiffViewer
originalContent={dc.originalContent}
modifiedContent={dc.modifiedContent}
modifiedContent={editBuffers[activeFile.id] ?? dc.modifiedContent}
language={resolvedLanguage}
filePath={activeFile.relativePath}
editable={isEditable}
onContentChange={isEditable ? handleContentChange : undefined}
onSave={isEditable ? handleSave : undefined}
/>
)
})()