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 ( ) })()