From 0d91a062c0284073d2ff1dbc1146b8e21b05b205 Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Sun, 27 Sep 2026 00:23:15 -0300 Subject: [PATCH] feat(native): optional header badge on the diff viewer (web too) A pull-request diff is neither STAGED nor WORKING TREE; callers can now pass their own badge (e.g. "PR #212") or null to hide it. Omitted = unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../native/src/components/DiffViewer.test.tsx | 47 +++++++++++++++++++ packages/native/src/components/DiffViewer.tsx | 17 +++++-- .../web/src/components/DiffViewer.test.tsx | 47 +++++++++++++++++++ packages/web/src/components/DiffViewer.tsx | 11 ++++- 4 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 packages/native/src/components/DiffViewer.test.tsx create mode 100644 packages/web/src/components/DiffViewer.test.tsx diff --git a/packages/native/src/components/DiffViewer.test.tsx b/packages/native/src/components/DiffViewer.test.tsx new file mode 100644 index 0000000..d3faee1 --- /dev/null +++ b/packages/native/src/components/DiffViewer.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, type ReactElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import type { FileFetcher, GitProvider } from '@codeam/ide-core'; + +vi.mock('./ResilientWebView', () => ({ ResilientWebView: () => null })); +vi.mock('@expo/vector-icons', () => ({ Ionicons: () => null })); + +import { DiffViewer } from './DiffViewer'; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +async function render(element: ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root!.render(element)); +} + +const git = { diff: vi.fn().mockResolvedValue(null) } as unknown as GitProvider; +const fetcher = { readFile: vi.fn().mockResolvedValue('x') } as unknown as FileFetcher; + +describe('DiffViewer badge (native)', () => { + it('defaults to WORKING TREE', async () => { + await render(); + expect(container!.textContent).toContain('WORKING TREE'); + }); + + it('shows a caller badge instead — a PR diff is not the working tree', async () => { + await render(); + expect(container!.textContent).toContain('PR #212'); + expect(container!.textContent).not.toContain('WORKING TREE'); + }); + + it('hides the badge on null', async () => { + await render(); + expect(container!.textContent).not.toContain('WORKING TREE'); + }); +}); diff --git a/packages/native/src/components/DiffViewer.tsx b/packages/native/src/components/DiffViewer.tsx index 3232a64..4fe10f5 100644 --- a/packages/native/src/components/DiffViewer.tsx +++ b/packages/native/src/components/DiffViewer.tsx @@ -20,6 +20,12 @@ interface Props { git: GitProvider; fetcher: FileFetcher; staged?: boolean; + /** + * Header badge. Omitted → "STAGED" / "WORKING TREE" from `staged`; a string + * replaces it (e.g. "PR #212" — a pull-request diff is neither); `null` + * hides it. + */ + badge?: string | null; onClose?: () => void; } @@ -40,7 +46,8 @@ interface DiffState { * viewports because Monaco's DiffEditor handles the rebreak * automatically when its renderer detects the available width. */ -export function DiffViewer({ path, git, fetcher, staged, onClose }: Props) { +export function DiffViewer({ path, git, fetcher, staged, badge, onClose }: Props) { + const badgeText = badge === undefined ? (staged ? 'STAGED' : 'WORKING TREE') : badge; const theme = useIDETheme(); const generationRef = useRef(0); const [readyGeneration, setReadyGeneration] = useState(undefined); @@ -119,9 +126,11 @@ export function DiffViewer({ path, git, fetcher, staged, onClose }: Props) { {path} - - {staged ? 'STAGED' : 'WORKING TREE'} - + {badgeText ? ( + + {badgeText} + + ) : null} {onClose ? ( ({ DiffEditor: () => null })); + +import { DiffViewer } from './DiffViewer'; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +async function render(element: ReactElement) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => root!.render(element)); +} + +const git = { diff: vi.fn().mockResolvedValue(null) } as unknown as GitProvider; +const fetcher = { readFile: vi.fn().mockResolvedValue('x') } as unknown as FileFetcher; + +describe('DiffViewer badge (web)', () => { + it('defaults to WORKING TREE / STAGED', async () => { + await render(); + expect(container!.textContent).toContain('WORKING TREE'); + }); + + it('shows a caller badge instead — a PR diff is not the working tree', async () => { + await render(); + expect(container!.textContent).toContain('PR #212'); + expect(container!.textContent).not.toContain('WORKING TREE'); + }); + + it('hides the badge on null', async () => { + await render(); + expect(container!.textContent).not.toContain('WORKING TREE'); + expect(container!.textContent).not.toContain('STAGED'); + }); +}); diff --git a/packages/web/src/components/DiffViewer.tsx b/packages/web/src/components/DiffViewer.tsx index 6ee3387..7e1fafe 100644 --- a/packages/web/src/components/DiffViewer.tsx +++ b/packages/web/src/components/DiffViewer.tsx @@ -18,6 +18,12 @@ interface Props { /** When `true`, diff the staged version vs HEAD instead of * working-tree vs HEAD. */ staged?: boolean; + /** + * Header badge. Omitted → "STAGED" / "WORKING TREE" from `staged`; a string + * replaces it (e.g. "PR #212" — a pull-request diff is neither); `null` + * hides it. + */ + badge?: string | null; /** Close button handler (rendered top-right when supplied). */ onClose?: () => void; } @@ -47,7 +53,8 @@ interface DiffState { * specially; for those the viewer renders the raw diff text in a * read-only Monaco buffer. */ -export function DiffViewer({ path, git, fetcher, staged, onClose }: Props) { +export function DiffViewer({ path, git, fetcher, staged, badge, onClose }: Props) { + const badgeText = badge === undefined ? (staged ? 'STAGED' : 'WORKING TREE') : badge; const [state, setState] = useState({ loading: true, error: null, @@ -98,7 +105,7 @@ export function DiffViewer({ path, git, fetcher, staged, onClose }: Props) {
- {path} {staged ? 'STAGED' : 'WORKING TREE'} + {path} {badgeText ? {badgeText} : null} {onClose ? (