diff --git a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx index d6c0dfca2..a07df064c 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx +++ b/apps/emdash-desktop/src/renderer/features/tasks/diff-view/main-panel/diff-file-renderer.tsx @@ -11,11 +11,24 @@ import { useWorkspaceId, useWorkspaceViewModel, } from '@renderer/features/tasks/task-view-context'; +import { getFileKind } from '@renderer/lib/editor/fileKind'; +import { PreviewSourceToggle } from '@renderer/lib/editor/preview-source-toggle'; +import { useDelayedBoolean } from '@renderer/lib/hooks/use-delay-boolean'; +import { rpc } from '@renderer/lib/ipc'; import { modelRegistry } from '@renderer/lib/monaco/monaco-model-registry'; import { buildMonacoModelPath } from '@renderer/lib/monaco/monacoModelPath'; import { StickyDiffEditor } from '@renderer/lib/monaco/sticky-diff-editor'; +import { MarkdownRenderer } from '@renderer/lib/ui/markdown-renderer'; +import { ShowHide } from '@renderer/lib/ui/show-hide'; +import { Spinner } from '@renderer/lib/ui/spinner'; import { getLanguageFromPath } from '@renderer/utils/languageUtils'; -import { gitRefToString, HEAD_REF, STAGED_REF, type GitObjectRef } from '@shared/core/git/git'; +import { + gitRefToString, + HEAD_REF, + STAGED_REF, + type GitObjectRef, + type GitRef, +} from '@shared/core/git/git'; import { getDraftCommentTargetKey, type DraftCommentTarget } from '@shared/lineComments'; import type { ActiveFile } from '@shared/view-state'; @@ -32,8 +45,15 @@ export const DiffFileRenderer = observer(function DiffFileRenderer({ tab }: Diff const workspaceId = useWorkspaceId(); switch (tab.renderer.kind) { - case 'text': + case 'text': { + // Markdown files get a rendered-preview toggle (Eye) alongside the source + // diff (Pencil), mirroring the file-tab markdown renderer. Deleted files + // have no "after" content to render, so they stay diff-only. + if (getFileKind(tab.path) === 'markdown' && tab.status !== 'deleted') { + return ; + } return ; + } case 'image': { const activeFile = tabToActiveFile(tab); return ( @@ -105,30 +125,8 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil onDeleteComment: handleDeleteComment, }); - const root = `workspace:${workspaceId}`; - const uri = buildMonacoModelPath(root, tab.path); const language = getLanguageFromPath(tab.path); - - const originalUri = (() => { - if (tab.diffGroup === 'disk') { - return modelRegistry.toGitUri(uri, STAGED_REF); - } - if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') { - return modelRegistry.toGitUri(uri, tab.originalRef); - } - return modelRegistry.toGitUri(uri, HEAD_REF); - })(); - - const modifiedUri = (() => { - if (tab.diffGroup === 'staged') return modelRegistry.toGitUri(uri, STAGED_REF); - if (tab.diffGroup === 'pr') { - return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); - } - if (tab.diffGroup === 'git') { - return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); - } - return uri; - })(); + const { root, uri, originalUri, modifiedUri } = computeDiffUris(tab, workspaceId); useEffect(() => { let disposed = false; @@ -233,6 +231,221 @@ const MonacoDiffRenderer = observer(function MonacoDiffRenderer({ tab }: DiffFil ); }); +/** + * Computes the original/modified Monaco model URIs for a diff tab. Shared by the + * Monaco diff editor and the markdown preview so both read identical content. + */ +function computeDiffUris( + tab: DiffTabStore, + workspaceId: string +): { root: string; uri: string; originalUri: string; modifiedUri: string } { + const root = `workspace:${workspaceId}`; + const uri = buildMonacoModelPath(root, tab.path); + + const originalUri = (() => { + if (tab.diffGroup === 'disk') return modelRegistry.toGitUri(uri, STAGED_REF); + if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') { + return modelRegistry.toGitUri(uri, tab.originalRef); + } + return modelRegistry.toGitUri(uri, HEAD_REF); + })(); + + const modifiedUri = (() => { + if (tab.diffGroup === 'staged') return modelRegistry.toGitUri(uri, STAGED_REF); + if (tab.diffGroup === 'pr') return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); + if (tab.diffGroup === 'git') return modelRegistry.toGitUri(uri, tab.modifiedRef ?? HEAD_REF); + return uri; + })(); + + return { root, uri, originalUri, modifiedUri }; +} + +/** + * Renders a markdown diff tab with a toggle between the source diff (Pencil) and + * a rendered markdown preview (Eye), mirroring the file-tab MarkdownEditorRenderer. + * + * The Monaco diff editor is kept mounted via ShowHide while the preview is shown, + * so its models stay registered and the preview can read their content. + */ +const MarkdownDiffRenderer = observer(function MarkdownDiffRenderer({ + tab, +}: DiffFileRendererProps) { + return ( +
+ + + + {tab.showRendered && } + tab.setShowRendered(mode === 'preview')} + sourceLabel="View diff" + /> +
+ ); +}); + +/** Where a rendered side's linked images come from, matching its diff source. */ +type ImageSource = { kind: 'disk' } | { kind: 'index' } | { kind: 'ref'; ref: GitRef }; + +/** + * Resolves which source a rendered side's images come from, mirroring the + * original/modified ref selection in computeDiffUris so images stay consistent + * with the text being shown: working tree, index, or a specific git ref. + */ +function imageSourceForSide(tab: DiffTabStore, side: 'original' | 'modified'): ImageSource { + if (side === 'original') { + if (tab.diffGroup === 'disk') return { kind: 'index' }; + if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') { + return { kind: 'ref', ref: tab.originalRef }; + } + return { kind: 'ref', ref: HEAD_REF }; + } + if (tab.diffGroup === 'staged') return { kind: 'index' }; + if (tab.diffGroup === 'git' || tab.diffGroup === 'pr') { + return { kind: 'ref', ref: tab.modifiedRef ?? HEAD_REF }; + } + return { kind: 'disk' }; +} + +/** Resolves a relative markdown image against the correct side/source of the diff. */ +async function resolveSideImage( + tab: DiffTabStore, + side: 'original' | 'modified', + projectId: string, + workspaceId: string, + fileDir: string, + src: string +): Promise { + const imagePath = fileDir ? `${fileDir}/${src}` : src; + const source = imageSourceForSide(tab, side); + if (source.kind === 'disk') { + const res = await rpc.workspace.fs.readImage(projectId, workspaceId, imagePath); + return res.success ? (res.data?.dataUrl ?? null) : null; + } + const res = + source.kind === 'index' + ? await rpc.workspace.git.getImageAtIndex(projectId, workspaceId, imagePath) + : await rpc.workspace.git.getImageAtRef( + projectId, + workspaceId, + imagePath, + gitRefToString(source.ref) + ); + if (!res.success) return null; + return res.data.result.kind === 'image' ? res.data.result.image.dataUrl : null; +} + +/** + * Renders the modified ("after") markdown content as a formatted preview. In + * split mode it shows the original (left) and modified (right) side by side, + * reusing the diff toolbar's unified/split toggle for consistency. + * + * Content is read from the Monaco diff models and kept in sync via + * onDidChangeContent, so the preview tracks model refreshes (e.g. index/disk + * reloads) instead of going stale. Linked images are resolved from the same + * source as the side being rendered. + */ +const MarkdownDiffPreview = observer(function MarkdownDiffPreview({ tab }: DiffFileRendererProps) { + const { projectId } = useTaskViewContext(); + const workspaceId = useWorkspaceId(); + const diffStyle = useWorkspaceViewModel().diffView?.diffStyle ?? 'unified'; + const { originalUri, modifiedUri } = computeDiffUris(tab, workspaceId); + + // Model load status drives the loading spinner and triggers the content + // listeners below to (re)attach once a model becomes available. + const originalStatus = modelRegistry.modelStatus.get(originalUri); + const modifiedStatus = modelRegistry.modelStatus.get(modifiedUri); + + const [newContent, setNewContent] = useState(''); + const [oldContent, setOldContent] = useState(''); + + // Read content imperatively and keep it in sync via onDidChangeContent rather + // than the file-tab markdown renderer's bufferVersions MobX dependency: the + // registry only bumps bufferVersions for the editable buffer model, never for + // git/index models, so a bufferVersions dependency would go stale when a + // staged/ref/PR diff reloads. onDidChangeContent also fires for those in-place + // setValue() refreshes, so it covers every side. + useEffect(() => { + const model = modelRegistry.getModelByUri(modifiedUri); + if (!model) { + setNewContent(''); + return; + } + setNewContent(model.getValue()); + const sub = model.onDidChangeContent(() => setNewContent(model.getValue())); + return () => sub.dispose(); + }, [modifiedUri, modifiedStatus]); + + useEffect(() => { + const model = modelRegistry.getModelByUri(originalUri); + if (!model) { + setOldContent(''); + return; + } + setOldContent(model.getValue()); + const sub = model.onDidChangeContent(() => setOldContent(model.getValue())); + return () => sub.dispose(); + }, [originalUri, originalStatus]); + + const fileDir = tab.path.includes('/') ? tab.path.substring(0, tab.path.lastIndexOf('/')) : ''; + const resolveModifiedImage = useCallback( + (src: string) => resolveSideImage(tab, 'modified', projectId, workspaceId, fileDir, src), + [tab, projectId, workspaceId, fileDir] + ); + const resolveOriginalImage = useCallback( + (src: string) => resolveSideImage(tab, 'original', projectId, workspaceId, fileDir, src), + [tab, projectId, workspaceId, fileDir] + ); + + const modifiedLoading = !modifiedStatus || modifiedStatus === 'loading'; + const originalLoading = !originalStatus || originalStatus === 'loading'; + const waiting = diffStyle === 'split' ? modifiedLoading || originalLoading : modifiedLoading; + const showSpinner = useDelayedBoolean(waiting, 200); + + if (showSpinner) { + return ( +
+ +
+ ); + } + + if (diffStyle === 'split') { + return ( +
+
+ +
+
+ +
+
+ ); + } + + return ( +
+ +
+ ); +}); + function refShaOrString(ref: GitObjectRef | undefined): string { if (!ref) return gitRefToString(HEAD_REF); return ref.kind === 'commit' ? ref.sha : gitRefToString(ref); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.test.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.test.ts new file mode 100644 index 000000000..1efafb17a --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.test.ts @@ -0,0 +1,36 @@ +import { reaction } from 'mobx'; +import { describe, expect, it } from 'vitest'; +import type { ActiveFile } from '@shared/view-state'; +import { DiffTabStore } from './diff-tab-store'; + +function makeActiveFile(path: string): ActiveFile { + return { + path, + type: 'disk', + group: 'disk', + originalRef: { kind: 'commit', sha: 'deadbeef' }, + }; +} + +describe('DiffTabStore markdown preview toggle', () => { + it('defaults to the source diff (showRendered = false)', () => { + const tab = new DiffTabStore(makeActiveFile('docs/readme.md'), false); + expect(tab.renderer.kind).toBe('text'); + expect(tab.showRendered).toBe(false); + }); + + it('setShowRendered toggles the rendered-preview state observably', () => { + const tab = new DiffTabStore(makeActiveFile('docs/readme.md'), false); + const seen: boolean[] = []; + const dispose = reaction( + () => tab.showRendered, + (value) => seen.push(value) + ); + + tab.setShowRendered(true); + tab.setShowRendered(false); + dispose(); + + expect(seen).toEqual([true, false]); + }); +}); diff --git a/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts index 3b2388ad4..dd18d7a35 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/tabs/diff-tab-store.ts @@ -15,6 +15,12 @@ export class DiffTabStore { path: string; isPreview: boolean; renderer: DiffRendererData; + /** + * For markdown diff tabs: whether to show the rendered markdown preview + * (Eye) instead of the source diff (Pencil). Defaults to the source diff. + * Ignored for non-markdown files. + */ + showRendered = false; diffGroup: 'disk' | 'staged' | 'git' | 'pr'; originalRef: GitObjectRef; modifiedRef: GitObjectRef | undefined; @@ -57,8 +63,10 @@ export class DiffTabStore { commitOriginalSha: observable, commitModifiedSha: observable, status: observable, + showRendered: observable, transition: action, pin: action, + setShowRendered: action, }); } @@ -86,6 +94,10 @@ export class DiffTabStore { pin(): void { this.isPreview = false; } + + setShowRendered(showRendered: boolean): void { + this.showRendered = showRendered; + } } function resolveDiffRenderer(path: string): DiffRendererData { diff --git a/apps/emdash-desktop/src/renderer/lib/editor/preview-source-toggle.tsx b/apps/emdash-desktop/src/renderer/lib/editor/preview-source-toggle.tsx index 7b8a13526..9c3dc484a 100644 --- a/apps/emdash-desktop/src/renderer/lib/editor/preview-source-toggle.tsx +++ b/apps/emdash-desktop/src/renderer/lib/editor/preview-source-toggle.tsx @@ -5,18 +5,25 @@ interface PreviewSourceToggleProps { activeMode: 'preview' | 'source'; onSwitch: (mode: 'preview' | 'source') => void; className?: string; + /** Accessibility label for the Eye (preview) item. */ + previewLabel?: string; + /** Accessibility label for the Pencil (source) item. */ + sourceLabel?: string; } const DEFAULT_CLASSNAME = 'absolute right-3 top-3 z-10'; /** - * Floating Eye/Pencil toggle for switching between a rendered preview and - * Monaco source view. Used by the HTML renderer pair (preview iframe ↔ Monaco). + * Floating Eye/Pencil toggle for switching between a rendered preview and a + * Monaco source view. Used by the HTML renderer pair (preview iframe ↔ Monaco) + * and the markdown diff renderer (rendered preview ↔ source diff). */ export function PreviewSourceToggle({ activeMode, onSwitch, className = DEFAULT_CLASSNAME, + previewLabel = 'View rendered', + sourceLabel = 'Edit source', }: PreviewSourceToggleProps) { return ( - + - +