mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user