diff --git a/src/renderer/src/components/editor/EditorContent.diff-load-error.test.tsx b/src/renderer/src/components/editor/EditorContent.diff-load-error.test.tsx new file mode 100644 index 00000000000..0301cf711b3 --- /dev/null +++ b/src/renderer/src/components/editor/EditorContent.diff-load-error.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom +// Why: a failed diff load renders the error text as the modified body. If that pane stays editable +// the save path writes it over the real file, because attemptEditorFileSave falls back to the body +// shown when no draft exists. +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import type { DiffContent } from './editor-panel-content-types' + +const captured = vi.hoisted(() => ({ + diffProps: [] as { editable?: boolean; onSave?: unknown; onContentChange?: unknown }[] +})) + +vi.mock('@/lib/lazy-with-retry', () => ({ + lazyWithRetry: (factory: () => Promise) => { + if (factory.toString().includes('/DiffViewer.tsx')) { + return function MockDiffViewer(props: { + editable?: boolean + onSave?: unknown + onContentChange?: unknown + }) { + captured.diffProps.push(props) + return null + } + } + return () => null + } +})) + +vi.mock('@/store', () => { + const state = { + worktreesByRepo: {}, + openFile: vi.fn(), + openMarkdownPreview: vi.fn(), + openConflictReviewFile: vi.fn(), + openConflictReview: vi.fn(), + closeFile: vi.fn(), + setRightSidebarTab: vi.fn(), + setPendingEditorReveal: vi.fn(), + reloadOpenCheckRunDetailsTab: vi.fn() + } + return { + useAppStore: Object.assign( + (selector: (s: Record) => unknown) => selector(state), + { getState: () => ({ ...state, folderWorkspaces: [], projectGroups: [], repos: [] }) } + ) + } +}) + +import { EditorContent } from './EditorContent' + +const ACTIVE_FILE: OpenFile = { + id: '/repo/notes.ts', + filePath: '/repo/notes.ts', + relativePath: 'notes.ts', + worktreeId: 'repo::/repo', + language: 'typescript', + isDirty: false, + mode: 'diff', + diffSource: 'unstaged' +} + +function renderDiff(diff: DiffContent): void { + render( + + ) +} + +function textDiff(modifiedContent: string, loadError?: boolean): DiffContent { + return { + kind: 'text', + originalContent: '', + modifiedContent, + originalIsBinary: false, + modifiedIsBinary: false, + ...(loadError === undefined ? {} : { loadError }) + } +} + +afterEach(() => { + cleanup() + captured.diffProps.length = 0 +}) + +describe('EditorContent diff load failures', () => { + it('keeps a failed diff load read-only so its message cannot be saved over the file', () => { + renderDiff(textDiff('Error loading diff: RuntimeRpcCallError: too large', true)) + + const props = captured.diffProps.at(-1) + expect(props?.editable).toBe(false) + expect(props?.onSave).toBeUndefined() + // Why: no draft can be minted either, or the next save would write the typed-over message. + expect(props?.onContentChange).toBeUndefined() + }) + + it('leaves a normally loaded unstaged diff editable', () => { + renderDiff(textDiff('export const a = 1\n')) + + const props = captured.diffProps.at(-1) + expect(props?.editable).toBe(true) + expect(props?.onSave).toBeTypeOf('function') + }) +}) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 01be65d3465..97ec1de18c0 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -14,7 +14,7 @@ import { getNextConflictNavigationIndex } from './ConflictComponents' import type { MarkdownViewMode, OpenFile, PendingEditorReveal } from '@/store/slices/editor' -import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' +import type { DiffContent } from './editor-panel-content-types' import type { GitStatusEntry } from '../../../../shared/git-status-types' import { getMarkdownRenderMode } from './markdown-render-mode' import { getMarkdownRichModeUnsupportedMessage } from './markdown-rich-mode' @@ -147,7 +147,7 @@ export function EditorContent({ activeFile: OpenFile viewStateScopeId: string fileContents: Record - diffContents: Record + diffContents: Record editBuffers: Record openFiles: OpenFile[] worktreeEntries: GitStatusEntry[] @@ -895,6 +895,9 @@ export function EditorContent({ modifiedDiffBuffer === undefined && dc.modifiedContent.length === 0 ) + // Why: the body is a load-failure message, not file content. Editing must stay off so no draft + // is minted and no save shortcut is installed — either would write that text over the real file. + const diffEditable = isEditable && dc.loadError !== true // Why: shared by both diff sub-branches (preview and source) so preview mode surfaces the external change too. const diffExternalChangeBanner = activeFile.externalMutation === 'changed' ? ( @@ -952,10 +955,10 @@ export function EditorContent({ filePath={activeFile.filePath} relativePath={activeFile.relativePath} sideBySide={sideBySide} - editable={isEditable} + editable={diffEditable} worktreeId={activeFile.worktreeId} - onContentChange={isEditable ? handleContentChange : undefined} - onSave={isEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined} + onContentChange={diffEditable ? handleContentChange : undefined} + onSave={diffEditable ? (isMarkdown ? md.mdSave : handleSave) : undefined} /> ) // Why: editable diffs get the changed-on-disk banner; its reload refetches the diff body, not plain file content. diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts index 7b3a9fb902b..574aa04ee3b 100644 --- a/src/renderer/src/components/editor/editor-panel-content-types.ts +++ b/src/renderer/src/components/editor/editor-panel-content-types.ts @@ -32,6 +32,8 @@ export type FileContent = { export type DiffContent = GitDiffResult & { /** Superseded by an external change; still rendered until the lazy reload lands. */ isStale?: boolean + /** The body is a load-failure message, not file content — saving it would overwrite the file. */ + loadError?: boolean } export type InFlightContentRead = { diff --git a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts index 4c9f62f9b75..057165b83f5 100644 --- a/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts +++ b/src/renderer/src/components/editor/useEditorPanelDiffContentLoader.ts @@ -167,7 +167,8 @@ export function useEditorPanelDiffContentLoader({ originalContent: '', modifiedContent: `Error loading diff: ${String(err)}`, originalIsBinary: false, - modifiedIsBinary: false + modifiedIsBinary: false, + loadError: true } })) } finally {