From 5f407922904785861bd11b20479afb0b2fce1e59 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 2 Jun 2026 17:25:06 -0400 Subject: [PATCH] Add commit details panel Show commit details for history reviews, including metadata, signature state, trailers, stats, and changed files. Let file rows jump to their diff target while preserving instant reload restoration. --- electron/git-state/commit-metadata.cjs | 252 ++++++++++++++++ electron/git-state/commit.cjs | 5 +- src/App.css | 298 +++++++++++++++++++ src/App.tsx | 12 +- src/__tests__/App-render.test.tsx | 122 +++++++- src/__tests__/ReviewCodeView-scroll.test.tsx | 153 +++++++++- src/__tests__/git-state.test.ts | 122 ++++++++ src/app/components/CommitDetails.tsx | 239 +++++++++++++++ src/app/components/Panels.tsx | 24 +- src/app/components/ReviewCodeView.tsx | 200 ++++++++++--- src/app/components/useCopiedState.ts | 28 ++ src/lib/app-types.ts | 15 + src/lib/code-view-options.ts | 46 +++ src/types.ts | 45 +++ 14 files changed, 1494 insertions(+), 67 deletions(-) create mode 100644 electron/git-state/commit-metadata.cjs create mode 100644 src/app/components/CommitDetails.tsx create mode 100644 src/app/components/useCopiedState.ts diff --git a/electron/git-state/commit-metadata.cjs b/electron/git-state/commit-metadata.cjs new file mode 100644 index 0000000..cbcb3dc --- /dev/null +++ b/electron/git-state/commit-metadata.cjs @@ -0,0 +1,252 @@ +// @ts-check + +const { fileSort, getGravatarHash, git, gitBufferWithInput, gitOrEmpty } = require('./common.cjs'); + +/** + * @typedef {import('../../src/types.ts').CommitMetadata} CommitMetadata + * @typedef {import('../../src/types.ts').CommitMetadataFile} CommitMetadataFile + * @typedef {import('./common.cjs').StatusItem} StatusItem + * @typedef {{additions?: number; binary: boolean; deletions?: number; path: string}} NumstatItem + */ + +/** @param {string} email */ +const createGravatarUrl = (email) => + email ? `https://www.gravatar.com/avatar/${getGravatarHash(email)}?s=80&d=identicon` : undefined; + +/** + * @param {string} raw + * @returns {Map} + */ +const parseNumstat = (raw) => { + /** @type {Map} */ + const stats = new Map(); + const parts = raw.split('\0'); + + for (let index = 0; index < parts.length; ) { + const header = parts[index++]; + if (!header) { + // `-z` output ends with a NUL, which produces one empty trailing record. + continue; + } + + // Paths may contain tabs, so only the first two tabs are numstat separators. + const firstTab = header.indexOf('\t'); + const secondTab = firstTab === -1 ? -1 : header.indexOf('\t', firstTab + 1); + if (firstTab === -1 || secondTab === -1) { + throw new Error(`Unexpected git numstat record: ${header}`); + } + + const additionsText = header.slice(0, firstTab); + const deletionsText = header.slice(firstTab + 1, secondTab); + const pathField = header.slice(secondTab + 1); + const isRename = pathField === ''; + // For rename records, `--numstat -z` stores old and new paths in the next two records. + const path = isRename ? parts[index + 1] : pathField; + if (isRename) { + index += 2; + } + if (!path) { + throw new Error(`Unexpected git numstat record without a path: ${header}`); + } + + const binary = additionsText === '-' || deletionsText === '-'; + stats.set(path, { + binary, + path, + ...(binary + ? {} + : { + additions: Number(additionsText), + deletions: Number(deletionsText), + }), + }); + } + + return stats; +}; + +/** + * @param {string} repoRoot + * @param {string} commit + * @param {string | undefined} firstParent + */ +const readCommitNumstat = async (repoRoot, commit, firstParent) => + parseNumstat( + await git( + repoRoot, + firstParent + ? ['diff', '--numstat', '-z', '--find-renames', firstParent, commit] + : ['show', '--format=', '--numstat', '-z', '--find-renames', commit], + ), + ); + +/** @param {string} raw */ +const parseTrailers = (raw) => + raw.split('\n').flatMap((line) => { + const separator = line.indexOf(':'); + if (separator === -1) { + return []; + } + + const key = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + return key && value ? [{ key, value }] : []; + }); + +/** + * @param {string} repoRoot + * @param {string} subject + * @param {string} body + */ +const readCommitMessageParts = async (repoRoot, subject, body) => { + const message = `${subject}\n\n${body}`; + const [rawTrailerBlock, parsedTrailerBlock] = await Promise.all([ + gitBufferWithInput( + repoRoot, + ['interpret-trailers', '--only-trailers', '--only-input'], + message, + ), + gitBufferWithInput(repoRoot, ['interpret-trailers', '--parse'], message), + ]); + const trailerBlock = rawTrailerBlock.toString('utf8').trimEnd(); + const trimmedBody = body.trimEnd(); + + return { + body: + trailerBlock && trimmedBody.endsWith(trailerBlock) + ? trimmedBody.slice(0, -trailerBlock.length).trimEnd() + : body, + trailers: parseTrailers(parsedTrailerBlock.toString('utf8')), + }; +}; + +/** @param {string} raw */ +const parseCommitMetadataHeader = (raw) => { + const parts = raw.split('\0'); + const [ + ref, + shortRef, + parents, + authorName, + authorEmail, + authorDate, + committerName, + committerEmail, + committerDate, + subject, + body, + signatureStatus, + signatureSigner, + signatureKey, + ] = parts; + + return { + author: createCommitMetadataPerson(authorName, authorEmail, authorDate), + body: body || '', + committer: createCommitMetadataPerson(committerName, committerEmail, committerDate), + parents: parents ? parents.split(' ').filter(Boolean) : [], + ref: ref || '', + shortRef: shortRef || '', + signature: { + ...(signatureKey ? { key: signatureKey.trim() } : {}), + ...(signatureSigner ? { signer: signatureSigner } : {}), + status: signatureStatus || 'N', + }, + subject: subject || '', + }; +}; + +/** + * @param {string | undefined} name + * @param {string | undefined} email + * @param {string | undefined} date + */ +const createCommitMetadataPerson = (name, email, date) => ({ + date: date || '', + email: email || '', + gravatarUrl: createGravatarUrl(email || ''), + name: name || '', +}); + +/** + * @param {ReadonlyArray>} status + * @param {ReadonlyMap} numstat + * @returns {Array} + */ +const createCommitMetadataFiles = (status, numstat) => + status.map((item) => { + const stats = numstat.get(item.path); + return { + binary: stats?.binary ?? false, + oldPath: item.oldPath, + path: item.path, + status: item.status, + ...(stats && !stats.binary + ? { + additions: stats.additions ?? 0, + deletions: stats.deletions ?? 0, + } + : {}), + }; + }); + +/** @param {ReadonlyArray} files */ +const createCommitMetadataStats = (files) => { + const stats = { + additions: 0, + binaryFiles: 0, + deletions: 0, + files: files.length, + renamedFiles: 0, + }; + + for (const file of files) { + stats.additions += file.additions ?? 0; + stats.binaryFiles += file.binary ? 1 : 0; + stats.deletions += file.deletions ?? 0; + stats.renamedFiles += file.oldPath ? 1 : 0; + } + + return stats; +}; + +/** + * @param {string} repoRoot + * @param {string} commit + * @param {string | undefined} firstParent + * @param {ReadonlyArray>} status + * @returns {Promise} + */ +const readCommitMetadataForCommit = async (repoRoot, commit, firstParent, status) => { + const rawHeader = await git(repoRoot, [ + 'show', + '-s', + '--format=%H%x00%h%x00%P%x00%aN%x00%aE%x00%aI%x00%cN%x00%cE%x00%cI%x00%s%x00%b%x00%G?%x00%GS%x00%GK', + commit, + ]); + const header = parseCommitMetadataHeader(rawHeader); + const [numstat, refs, messageParts] = await Promise.all([ + readCommitNumstat(repoRoot, commit, firstParent), + gitOrEmpty(repoRoot, ['for-each-ref', '--points-at', commit, '--format=%(refname:short)']), + readCommitMessageParts(repoRoot, header.subject, header.body), + ]); + const files = createCommitMetadataFiles(status, numstat).sort(fileSort); + + return { + ...header, + body: messageParts.body, + files, + ref: commit, + refs: refs + .split('\n') + .map((value) => value.trim()) + .filter(Boolean) + .sort((left, right) => left.localeCompare(right)), + stats: createCommitMetadataStats(files), + trailers: messageParts.trailers, + }; +}; + +module.exports = { + readCommitMetadataForCommit, +}; diff --git a/electron/git-state/commit.cjs b/electron/git-state/commit.cjs index 6876e57..46d7fcb 100644 --- a/electron/git-state/commit.cjs +++ b/electron/git-state/commit.cjs @@ -16,6 +16,7 @@ const { summarizeContent, validateRepositoryPath, } = require('./common.cjs'); +const { readCommitMetadataForCommit } = require('./commit-metadata.cjs'); /** * @typedef {import('../../src/types.ts').ChangedFile} ChangedFile @@ -447,7 +448,8 @@ const readCommitState = async (launchPath, ref) => { const commit = (await git(repoRoot, ['rev-parse', '--verify', `${ref}^{commit}`])).trim(); const [firstParent] = await readCommitParents(repoRoot, commit); const status = await readCommitNameStatus(repoRoot, commit, firstParent, { sort: false }); - const [oldFiles, newFiles] = await Promise.all([ + const [commitMetadata, oldFiles, newFiles] = await Promise.all([ + readCommitMetadataForCommit(repoRoot, commit, firstParent, status), firstParent ? readGitFiles( repoRoot, @@ -486,6 +488,7 @@ const readCommitState = async (launchPath, ref) => { .sort(fileSort); return { + commitMetadata, files, generatedAt: Date.now(), launchPath, diff --git a/src/App.css b/src/App.css index 55fc2b3..3b3ff72 100644 --- a/src/App.css +++ b/src/App.css @@ -953,6 +953,7 @@ .review { min-height: 0; overflow: hidden; + position: relative; } .window-drag-region { @@ -973,6 +974,7 @@ .code-view { contain: strict; display: block; + flex: 1; height: 100%; min-height: 0; min-width: 0; @@ -984,6 +986,287 @@ padding-inline: 12px; } +/* Commit details */ +.commit-details-panel { + background: color-mix(in srgb, var(--code-bg) 94%, transparent); + border: 1px solid var(--file-border); + color: var(--text); + margin: 0; + padding: 14px; + white-space: normal; +} + +/* Commit details header */ +.commit-details-header { + align-items: flex-start; + display: flex; + flex-wrap: wrap; + gap: 10px 12px; + justify-content: space-between; + margin-bottom: 12px; +} + +.commit-details-header h2 { + flex: 1 1 260px; + font: 750 18px/1.2 var(--font-sans); + letter-spacing: 0; + margin: 0; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; +} + +.commit-details-header-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 7px; + max-width: 100%; + min-width: 0; + width: max-content; +} + +.commit-details-copy { + -webkit-app-region: no-drag; + align-items: center; + background: color-mix(in srgb, var(--code-bg) 86%, transparent); + border: 1px solid rgb(127 127 127 / 0.18); + border-radius: 15px; + color: var(--sidebar-ref); + corner-shape: squircle; + cursor: pointer; + display: inline-flex; + font: 700 12px/1 var(--font-mono); + gap: 7px; + justify-content: center; + min-height: 28px; + padding: 0 10px; + white-space: nowrap; +} + +.commit-details-copy:hover { + background: rgb(127 127 127 / 0.11); +} + +.commit-details-copy code { + font: inherit; +} + +.commit-details-copy.copied { + color: var(--viewed); +} + +.commit-details-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + min-width: 0; +} + +.commit-details-stats span { + background: rgb(127 127 127 / 0.11); + border-radius: 14px; + color: var(--muted); + corner-shape: squircle; + font: 700 11px/1 var(--font-mono); + padding: 6px 8px; + white-space: nowrap; +} + +.commit-details-stats .added { + color: var(--diff-addition); +} + +.commit-details-stats .deleted { + color: var(--diff-deletion); +} + +/* Commit details metadata */ +.commit-details-section { + border-top: 1px solid var(--file-border); + padding: 12px 0 0; +} + +.commit-details-section + .commit-details-section, +.commit-details-grid + .commit-details-section, +.commit-details-section + .commit-details-grid { + margin-top: 12px; +} + +.commit-details-section h3 { + color: var(--muted); + font: 800 10px/1 var(--font-sans); + letter-spacing: 0; + margin: 0 0 8px; + text-transform: uppercase; +} + +.commit-details-message { + background: var(--inline-code-bg); + border-radius: 12px; + color: var(--text); + corner-shape: squircle; + font: 12px/1.55 var(--font-mono); + margin: 0; + padding: 9px 10px; + white-space: pre-wrap; +} + +.commit-details-empty, +.commit-details-signature { + color: var(--muted); + display: block; + font: 13px/1.45 var(--font-sans); + margin: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; +} + +.commit-details-grid { + border-top: 1px solid var(--file-border); + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + padding-top: 12px; +} + +.commit-details-person { + align-items: start; + display: grid; + gap: 2px 7px; + grid-template-columns: auto minmax(0, 1fr); + min-width: 0; +} + +.commit-details-cell { + min-width: 0; +} + +.commit-details-signature-cell { + grid-column: 1 / -1; +} + +.commit-details-person > span:first-child, +.commit-details-cell h3 { + color: var(--muted); + font: 800 10px/1 var(--font-sans); + grid-column: 1 / -1; + letter-spacing: 0; + margin: 0 0 8px; + text-transform: uppercase; +} + +.commit-details-person-body { + display: grid; + gap: 2px; + min-width: 0; +} + +.commit-details-person strong { + font: 700 13px/1.25 var(--font-sans); + overflow-wrap: anywhere; +} + +.commit-details-person small, +.commit-details-person time { + color: var(--muted); + font: 12px/1.25 var(--font-sans); + overflow-wrap: anywhere; +} + +.commit-details-token-row { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.commit-details-token-row code { + background: var(--inline-code-bg); + border-radius: 8px; + color: var(--text); + corner-shape: squircle; + font: 600 11px/1 var(--font-mono); + max-width: 100%; + overflow-wrap: anywhere; + padding: 5px 7px; +} + +.commit-details-trailers { + display: grid; + gap: 6px 10px; + grid-template-columns: max-content minmax(0, 1fr); + margin: 0; +} + +.commit-details-trailers dt { + color: var(--muted); + font: 700 12px/1.35 var(--font-sans); +} + +.commit-details-trailers dd { + font: 13px/1.35 var(--font-sans); + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +/* Commit details files */ +.commit-details-files { + display: grid; + gap: 4px; +} + +.commit-details-file { + -webkit-app-region: no-drag; + align-items: center; + background: transparent; + border: 0; + border-radius: 8px; + color: var(--text); + corner-shape: squircle; + cursor: pointer; + display: grid; + gap: 8px; + grid-template-columns: auto minmax(0, 1fr) auto; + min-width: 0; + padding: 4px 6px; + text-align: left; +} + +.commit-details-file:hover { + background: rgb(127 127 127 / 0.1); +} + +.commit-details-file:disabled { + cursor: default; + opacity: 0.48; +} + +.commit-details-file:disabled:hover { + background: transparent; +} + +.commit-details-file .codiff-status-badge { + font-size: 11px; + padding: 5px 8px; +} + +.commit-details-file code { + font: 12px/1.35 var(--font-mono); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.commit-details-file > span:last-child { + color: var(--muted); + font: 700 12px/1 var(--font-mono); + white-space: nowrap; +} + .code-view diffs-container { background: var(--code-bg); border-radius: 28px; @@ -2175,6 +2458,21 @@ .codiff-viewed-button { padding-inline: 9px; } + + .commit-details-panel { + padding: 12px; + } + + .commit-details-header-actions { + flex-basis: 100%; + } +} + +@media (max-width: 620px) { + .commit-details-grid, + .commit-details-file { + grid-template-columns: minmax(0, 1fr); + } } .command-bar-overlay { diff --git a/src/App.tsx b/src/App.tsx index ccb311f..b81a556 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,8 @@ import { type DiffSearchResult, type RepositoryLoadError, type ReviewComment, + type ReviewScrollBehavior, + type ReviewScrollTarget, type SidebarMode, type SourceSession, type WalkthroughError, @@ -144,7 +146,7 @@ export default function App() { const [reloadDeltaPaths, setReloadDeltaPaths] = useState>(() => new Set()); const [pullRequestReviewSubmitting, setPullRequestReviewSubmitting] = useState(null); - const [scrollTarget, setScrollTarget] = useState<{ path: string; request: number } | null>(null); + const [scrollTarget, setScrollTarget] = useState(null); const [fileSearchQuery, setFileSearchQuery] = useState(''); const [historySearchQuery, setHistorySearchQuery] = useState(''); const [pendingSource, setPendingSource] = useState(null); @@ -282,8 +284,9 @@ export default function App() { [bumpItemVersion], ); - const scrollPathIntoReview = useCallback((path: string) => { + const scrollPathIntoReview = useCallback((path: string, behavior: ReviewScrollBehavior) => { setScrollTarget((current) => ({ + behavior, path, request: (current?.request ?? 0) + 1, })); @@ -430,7 +433,7 @@ export default function App() { const nextSelectedPath = reloadSelectedPath ?? initialFiles[0]?.path ?? null; setSelectedPath(nextSelectedPath); if (reloadSelectedPath) { - scrollPathIntoReview(reloadSelectedPath); + scrollPathIntoReview(reloadSelectedPath, 'instant'); } }; @@ -931,7 +934,7 @@ export default function App() { const activatePath = useCallback( (path: string) => { setSelectedPath(path); - scrollPathIntoReview(path); + scrollPathIntoReview(path, 'smooth'); }, [scrollPathIntoReview], ); @@ -1961,6 +1964,7 @@ export default function App() { activeSearchMatch={activeDiffSearchMatch} collapsed={collapsed} comments={visibleReviewComments} + commitMetadata={state.source.type === 'commit' ? (state.commitMetadata ?? null) : null} diffStyle={diffStyle} files={visibleFiles} focusCommentId={focusCommentId} diff --git a/src/__tests__/App-render.test.tsx b/src/__tests__/App-render.test.tsx index f1183f3..60a2f50 100644 --- a/src/__tests__/App-render.test.tsx +++ b/src/__tests__/App-render.test.tsx @@ -12,7 +12,7 @@ import { getReloadSelectionPath, writeReloadSelection, } from '../lib/reload-selection.ts'; -import type { ChangedFile, RepositoryState, ReviewSource } from '../types.ts'; +import type { ChangedFile, CommitMetadata, RepositoryState, ReviewSource } from '../types.ts'; const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -433,6 +433,126 @@ test('Mod+K does not open deleted files', async () => { } }); +test('commit details render inline in the diff view', async () => { + const changedFile = createChangedFile('src/app.ts'); + const source = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; + const writeClipboardText = vi.fn(async () => undefined); + const commitMetadata = { + author: { + date: '2026-01-01T12:00:00Z', + email: 'author@example.com', + name: 'Author', + }, + body: 'Detailed commit body.', + committer: { + date: '2026-01-01T13:00:00Z', + email: 'committer@example.com', + name: 'Committer', + }, + files: [ + { + additions: 1, + binary: false, + deletions: 1, + path: 'src/app.ts', + status: 'modified' as const, + }, + ], + parents: ['parent-sha'], + ref: 'abc1234', + refs: ['main'], + shortRef: 'abc1234', + signature: { + key: 'SHA256:abcdefghijklmnopqrstuvwxyz0123456789', + signer: 'signer@example.test', + status: 'G', + }, + stats: { + additions: 1, + binaryFiles: 0, + deletions: 1, + files: 1, + renamedFiles: 0, + }, + subject: 'Commit subject', + trailers: [ + { + key: 'Co-authored-by', + value: 'Second Author ', + }, + ], + } satisfies CommitMetadata; + + window.codiff = createCodiffMock({ + getRepositoryState: vi.fn(async () => ({ + ...repositoryState, + commitMetadata, + files: [changedFile], + source, + })), + }); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText: writeClipboardText, + }, + }); + + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = null; + + try { + await act(async () => { + root = createRoot(container); + root.render(); + }); + + await waitFor(() => { + expect(container.querySelector('.loading')).toBeNull(); + expect(container.querySelector('.commit-details-panel')).not.toBeNull(); + }); + + await waitFor(() => { + expect(container.querySelector('.commit-details-panel')?.textContent).toContain( + 'Detailed commit body.', + ); + expect(container.querySelector('.commit-details-panel')?.textContent).toContain( + 'Co-authored-by', + ); + }); + + const signature = container.querySelector('.commit-details-signature'); + if (!signature) { + throw new Error('Expected commit signature.'); + } + expect(signature.textContent).toBe( + 'Verified signature by signer@example.test (SHA256:abcd...6789)', + ); + expect(signature.textContent).not.toContain(commitMetadata.signature.key); + expect(signature.getAttribute('title')).toBe(commitMetadata.signature.key); + + const copyButton = container.querySelector('.commit-details-copy'); + if (!copyButton) { + throw new Error('Expected commit details copy button.'); + } + + await act(async () => { + copyButton.click(); + }); + + expect(writeClipboardText).toHaveBeenCalledWith(commitMetadata.ref); + expect(copyButton.getAttribute('aria-label')).toBe('Commit hash copied'); + expect(copyButton.textContent).toContain(commitMetadata.shortRef); + expect(copyButton.textContent).not.toContain('Copied'); + } finally { + if (root) { + await act(async () => root?.unmount()); + } + container.remove(); + } +}); + test('repository changes show the update banner without refreshing the working tree', async () => { let onRepositoryChanged: ((change: { root: string }) => void) | null = null; const getRepositoryState = vi.fn(async () => repositoryState); diff --git a/src/__tests__/ReviewCodeView-scroll.test.tsx b/src/__tests__/ReviewCodeView-scroll.test.tsx index 557d085..1c9fa43 100644 --- a/src/__tests__/ReviewCodeView-scroll.test.tsx +++ b/src/__tests__/ReviewCodeView-scroll.test.tsx @@ -8,7 +8,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { expect, test, vi } from 'vite-plus/test'; import { ReviewCodeView } from '../app/components/ReviewCodeView.tsx'; import { defaultKeymap } from '../config/defaults.ts'; -import type { ChangedFile, ReviewSource } from '../types.ts'; +import type { ChangedFile, CommitMetadata, ReviewSource } from '../types.ts'; const codeViewMock = vi.hoisted(() => ({ scrollTo: vi.fn(), @@ -23,6 +23,10 @@ vi.mock('@pierre/diffs/react', async () => { className?: string; items: Array>; onScroll?: (scrollTop: number, viewer: unknown) => void; + renderAnnotation?: ( + annotation: { metadata: unknown }, + item: CodeViewItem, + ) => React.ReactNode; renderCustomHeader?: (item: CodeViewItem) => React.ReactNode; }, ref: React.ForwardedRef, @@ -60,7 +64,7 @@ vi.mock('@pierre/diffs/react', async () => { () => ({ clearSelectedLines: () => {}, getInstance: () => viewer, - scrollTo: (target: { id: string; offset?: number }) => { + scrollTo: (target: { behavior?: string; id: string; offset?: number }) => { codeViewMock.scrollTo(target); const attempts = (scrollAttemptByIdRef.current.get(target.id) ?? 0) + 1; scrollAttemptByIdRef.current.set(target.id, attempts); @@ -83,6 +87,15 @@ vi.mock('@pierre/diffs/react', async () => { 'div', { key: item.id }, props.renderCustomHeader ? props.renderCustomHeader(item) : null, + 'annotations' in item && Array.isArray(item.annotations) + ? item.annotations.map((annotation, index) => + React.createElement( + React.Fragment, + { key: index }, + props.renderAnnotation?.(annotation, item), + ), + ) + : null, ), ), ); @@ -108,6 +121,52 @@ const createChangedFile = (path: string) => }) satisfies ChangedFile; const source = { type: 'working-tree' } satisfies ReviewSource; +const commitSource = { ref: 'abc1234', type: 'commit' } satisfies ReviewSource; +const commitMetadata = { + author: { + date: '2026-01-01T12:00:00Z', + email: 'author@example.com', + name: 'Author', + }, + body: '', + committer: { + date: '2026-01-01T12:00:00Z', + email: 'committer@example.com', + name: 'Committer', + }, + files: [ + { + additions: 1, + binary: false, + deletions: 1, + path: 'src/second.ts', + status: 'modified' as const, + }, + { + additions: 1, + binary: false, + deletions: 0, + path: 'src/hidden.ts', + status: 'modified' as const, + }, + ], + parents: ['parent-sha'], + ref: 'abc1234', + refs: ['main'], + shortRef: 'abc1234', + signature: { + status: 'N', + }, + stats: { + additions: 2, + binaryFiles: 0, + deletions: 1, + files: 2, + renamedFiles: 0, + }, + subject: 'Commit subject', + trailers: [], +} satisfies CommitMetadata; const waitFor = async (assertion: () => void) => { let lastError: unknown; @@ -142,6 +201,7 @@ test('reload scroll target is retried until the selected item renders', async () activeSearchMatch={null} collapsed={new Set()} comments={[]} + commitMetadata={null} diffStyle="split" files={[createChangedFile('src/first.ts'), createChangedFile('src/second.ts')]} focusCommentId={null} @@ -179,6 +239,95 @@ test('reload scroll target is retried until the selected item renders', async () }); expect(codeViewMock.scrollTo).toHaveBeenLastCalledWith( expect.objectContaining({ + behavior: 'instant', + id: 'diff:src/second.ts:unstaged', + type: 'item', + }), + ); + } finally { + if (root) { + await act(async () => root?.unmount()); + } + container.remove(); + } +}); + +test('commit metadata file rows scroll to the matching diff', async () => { + codeViewMock.scrollTo.mockClear(); + + const container = document.createElement('div'); + document.body.append(container); + let root: Root | null = null; + + try { + await act(async () => { + root = createRoot(container); + root.render( + {}} + onCreateComment={() => {}} + onDeleteComment={() => {}} + onLoadSection={() => {}} + onOpenFile={() => {}} + onSelectPathFromScroll={() => {}} + onSubmitComment={() => {}} + onToggleCollapsed={() => {}} + onToggleViewed={() => {}} + onUpdateComment={() => {}} + scrollTarget={null} + searchQuery="" + selectedPath={null} + showWhitespace={false} + source={commitSource} + viewed={{}} + walkthroughNotes={new Map()} + wordWrap={false} + />, + ); + }); + + const fileButtons = [...container.querySelectorAll('.commit-details-file')]; + const fileButton = fileButtons.find((button) => button.textContent?.includes('src/second.ts')); + if (!fileButton) { + throw new Error('Expected commit metadata file button.'); + } + const hiddenFileButton = fileButtons.find((button) => + button.textContent?.includes('src/hidden.ts'), + ); + if (!hiddenFileButton) { + throw new Error('Expected hidden commit metadata file button.'); + } + + expect(hiddenFileButton.disabled).toBe(true); + expect(hiddenFileButton.title).toContain('hidden by current filters'); + + await act(async () => { + hiddenFileButton.click(); + }); + + expect(codeViewMock.scrollTo).not.toHaveBeenCalled(); + + await act(async () => { + fileButton.click(); + }); + + expect(codeViewMock.scrollTo).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: 'smooth', id: 'diff:src/second.ts:unstaged', type: 'item', }), diff --git a/src/__tests__/git-state.test.ts b/src/__tests__/git-state.test.ts index c31b9f4..1c85ffb 100644 --- a/src/__tests__/git-state.test.ts +++ b/src/__tests__/git-state.test.ts @@ -663,6 +663,128 @@ test('readRepositoryState and history handle fresh repositories', async () => { }); }); +test('readRepositoryState reports commit metadata for root commits', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'notes/todo.txt', 'write tests\nship polish\n'); + await commitAll(repo, 'initial commit'); + const commit = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + + const state = await readRepositoryState(repo, { ref: commit, type: 'commit' }); + const metadata = state.commitMetadata; + + if (!metadata) { + throw new Error('Expected commit metadata.'); + } + expect(metadata.ref).toBe(commit); + expect(metadata.subject).toBe('initial commit'); + expect(metadata.parents).toEqual([]); + expect(metadata.stats).toEqual({ + additions: 2, + binaryFiles: 0, + deletions: 0, + files: 1, + renamedFiles: 0, + }); + expect(metadata.files).toEqual([ + { + additions: 2, + binary: false, + deletions: 0, + oldPath: undefined, + path: 'notes/todo.txt', + status: 'added', + }, + ]); + }); +}); + +test('readRepositoryState reports commit body, trailers, refs, and rename stats', async () => { + await withRepo(async (repo) => { + await writeRepoFile(repo, 'old.txt', 'one\n'); + await commitAll(repo, 'initial commit'); + await git(repo, ['mv', 'old.txt', 'new.txt']); + await writeRepoFile(repo, 'new.txt', 'one\ntwo\n'); + await git(repo, ['add', '--all']); + await git(repo, [ + 'commit', + '-m', + 'rename file', + '-m', + 'Detailed comment.', + '-m', + 'Co-authored-by: Second Author ', + ]); + const commit = (await git(repo, ['rev-parse', 'HEAD'])).trim(); + await git(repo, ['tag', 'v-test', commit]); + + const state = await readRepositoryState(repo, { ref: 'HEAD', type: 'commit' }); + const metadata = state.commitMetadata; + + if (!metadata) { + throw new Error('Expected commit metadata.'); + } + expect(metadata.ref).toBe(commit); + expect(metadata.body).toBe('Detailed comment.'); + expect(metadata.body).not.toContain('Co-authored-by'); + expect(metadata.trailers).toEqual([ + { + key: 'Co-authored-by', + value: 'Second Author ', + }, + ]); + expect(metadata.refs).toContain('v-test'); + expect(metadata.stats).toMatchObject({ + additions: 1, + binaryFiles: 0, + deletions: 0, + files: 1, + renamedFiles: 1, + }); + expect(metadata.files).toEqual([ + { + additions: 1, + binary: false, + deletions: 0, + oldPath: 'old.txt', + path: 'new.txt', + status: 'renamed', + }, + ]); + }); +}); + +test('readRepositoryState preserves numstat for committed paths with tabs', async () => { + await withRepo(async (repo) => { + const path = 'notes/with\ttab.txt'; + await writeRepoFile(repo, path, 'one\n'); + await commitAll(repo, 'initial commit'); + await writeRepoFile(repo, path, 'one\ntwo\n'); + await commitAll(repo, 'modify tab path'); + + const state = await readRepositoryState(repo, { ref: 'HEAD', type: 'commit' }); + const metadata = state.commitMetadata; + + if (!metadata) { + throw new Error('Expected commit metadata.'); + } + expect(metadata.stats).toMatchObject({ + additions: 1, + deletions: 0, + files: 1, + }); + expect(metadata.files).toEqual([ + { + additions: 1, + binary: false, + deletions: 0, + oldPath: undefined, + path, + status: 'modified', + }, + ]); + }); +}); + test('readRepositoryState opens branch refs as history-focused sources', async () => { await withRepo(async (repo) => { await writeRepoFile(repo, 'file.txt', 'base\n'); diff --git a/src/app/components/CommitDetails.tsx b/src/app/components/CommitDetails.tsx new file mode 100644 index 0000000..fe0e7ca --- /dev/null +++ b/src/app/components/CommitDetails.tsx @@ -0,0 +1,239 @@ +import { CopyIcon as Copy } from '@phosphor-icons/react/Copy'; +import { Fragment, useCallback, useLayoutEffect } from 'react'; +import { statusLabel } from '../../lib/code-view-options.ts'; +import { getShortRef } from '../../lib/source.ts'; +import type { CommitMetadata, CommitMetadataFile } from '../../types.ts'; +import { Gravatar } from './Gravatar.tsx'; +import { useCopiedState } from './useCopiedState.ts'; + +const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}); +const numberFormatter = new Intl.NumberFormat(undefined); + +const formatCommitDate = (value: string) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : dateFormatter.format(date); +}; + +const formatCommitCount = (value: number, singular: string, plural = `${singular}s`) => + `${numberFormatter.format(value)} ${value === 1 ? singular : plural}`; + +const signatureLabels: Record = { + B: 'Bad signature', + E: 'Signature cannot be checked', + G: 'Verified signature', + N: 'Unsigned commit', + R: 'Revoked signing key', + U: 'Untrusted signature', + X: 'Expired signature', + Y: 'Expired signing key', +}; + +const signatureLabel = (status: string) => signatureLabels[status] ?? 'Unknown signature status'; + +const formatSignature = (signature: CommitMetadata['signature']) => { + let key = signature.key; + if (key) { + const separator = key.indexOf(':'); + if (separator > 0 && separator <= 10) { + const prefix = key.slice(0, separator + 1); + const value = key.slice(separator + 1); + // Keep prefixes like SHA256: readable; shorten the fingerprint body. + key = value.length > 12 ? `${prefix}${value.slice(0, 4)}...${value.slice(-4)}` : key; + } else if (key.length > 24) { + key = `${key.slice(0, 8)}...${key.slice(-8)}`; + } + } + + return [ + signatureLabel(signature.status), + signature.signer ? `by ${signature.signer}` : '', + key ? `(${key})` : '', + ] + .filter(Boolean) + .join(' '); +}; + +function CommitStatsChips({ stats }: { stats: CommitMetadata['stats'] }) { + return ( +
+ {formatCommitCount(stats.files, 'file')} + +{numberFormatter.format(stats.additions)} + -{numberFormatter.format(stats.deletions)} + {stats.renamedFiles > 0 ? ( + {formatCommitCount(stats.renamedFiles, 'rename')} + ) : null} + {stats.binaryFiles > 0 ? ( + {formatCommitCount(stats.binaryFiles, 'binary file')} + ) : null} +
+ ); +} + +function CommitPersonRow({ label, person }: { label: string; person: CommitMetadata['author'] }) { + return ( +
+ {label} + +
+ {person.name || person.email} + {person.email} + +
+
+ ); +} + +// ReviewCodeView owns filters and CodeView ids; the panel only renders resolved file rows. +export type CommitDetailsFile = CommitMetadataFile & { + destinationItemId: string | null; +}; + +export function CommitDetailsPanel({ + files, + layoutKey, + metadata, + onLayoutReady, + onSelectFileDestination, +}: { + files: ReadonlyArray; + layoutKey: string; + metadata: CommitMetadata; + onLayoutReady?: (layoutKey: string) => void; + onSelectFileDestination?: (itemId: string) => void; +}) { + const [copied, markCopied] = useCopiedState(1600); + + useLayoutEffect(() => { + onLayoutReady?.(layoutKey); + }, [layoutKey, onLayoutReady]); + + const copyRef = useCallback(async () => { + try { + await navigator.clipboard.writeText(metadata.ref); + } catch { + // If copying fails, leave the button unchanged. + return; + } + markCopied(); + }, [markCopied, metadata.ref]); + const copyLabel = copied ? 'Commit hash copied' : 'Copy full commit hash'; + + return ( +
+
+

{metadata.subject || metadata.shortRef}

+
+ + +
+
+ {metadata.body.trim() ? ( +
+

Comment

+
{metadata.body.trimEnd()}
+
+ ) : null} +
+ + +
+

Refs

+ {metadata.refs.length > 0 ? ( +
+ {metadata.refs.map((ref) => ( + {ref} + ))} +
+ ) : ( +

No refs.

+ )} +
+
+

Parents

+ {metadata.parents.length > 0 ? ( +
+ {metadata.parents.map((parent) => ( + {getShortRef(parent)} + ))} +
+ ) : ( +

Root commit.

+ )} +
+
+

Signature

+

+ {formatSignature(metadata.signature)} +

+
+
+ {metadata.trailers.length > 0 ? ( +
+

Trailers

+
+ {metadata.trailers.map((trailer, index) => ( + +
{trailer.key}
+
{trailer.value}
+
+ ))} +
+
+ ) : null} +
+

Files

+
+ {files.map((file) => { + const title = file.oldPath ? `${file.oldPath} -> ${file.path}` : file.path; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/app/components/Panels.tsx b/src/app/components/Panels.tsx index 8a4ba90..6b48890 100644 --- a/src/app/components/Panels.tsx +++ b/src/app/components/Panels.tsx @@ -16,6 +16,7 @@ import type { RepositoryLoadError, ReviewComment } from '../../lib/app-types.ts' import { getReloadShortcutLabel } from '../../lib/keyboard.ts'; import { buildReviewCommentsMarkdown } from '../../lib/review-comments.ts'; import type { ChangedFile, PullRequestReviewEvent } from '../../types.ts'; +import { useCopiedState } from './useCopiedState.ts'; export function ReviewSourceLoading() { const [visible, setVisible] = useState(false); @@ -261,21 +262,11 @@ export function CopyCommentsButton({ files: ReadonlyArray; showWhitespace: boolean; }) { - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef(null); + const [copied, markCopied] = useCopiedState(2000); const pendingCommentCount = comments.filter( (comment) => !comment.isReadOnly && comment.body.trim(), ).length; - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - window.clearTimeout(copiedTimerRef.current); - } - }, - [], - ); - const copyComments = useCallback(async () => { const markdown = buildReviewCommentsMarkdown(files, comments, showWhitespace); if (!markdown) { @@ -283,15 +274,8 @@ export function CopyCommentsButton({ } await navigator.clipboard.writeText(markdown); - setCopied(true); - if (copiedTimerRef.current != null) { - window.clearTimeout(copiedTimerRef.current); - } - copiedTimerRef.current = window.setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 2000); - }, [comments, files, showWhitespace]); + markCopied(); + }, [comments, files, markCopied, showWhitespace]); if (pendingCommentCount === 0) { return null; diff --git a/src/app/components/ReviewCodeView.tsx b/src/app/components/ReviewCodeView.tsx index 67fd13f..94891ae 100644 --- a/src/app/components/ReviewCodeView.tsx +++ b/src/app/components/ReviewCodeView.tsx @@ -37,6 +37,8 @@ import type { ReviewAnnotationMetadata, ReviewComment, ReviewCommentAnnotationMetadata, + ReviewScrollBehavior, + ReviewScrollTarget, WalkthroughNote, } from '../../lib/app-types.ts'; import { @@ -72,42 +74,32 @@ import { import { applySearchHighlights } from '../../lib/search-highlights.ts'; import type { ChangedFile, + CommitMetadata, DiffImageContentResult, DiffSection, GitIdentity, PullRequestExistingReviewComment, ReviewSource, } from '../../types.ts'; +import { CommitDetailsPanel, type CommitDetailsFile } from './CommitDetails.tsx'; import { Gravatar } from './Gravatar.tsx'; import { DiffLineCountBadge } from './Sidebar.tsx'; +import { useCopiedState } from './useCopiedState.ts'; function CopyFilePathButton({ path }: { path: string }) { - const [copied, setCopied] = useState(false); - const copiedTimerRef = useRef(null); - - useEffect( - () => () => { - if (copiedTimerRef.current != null) { - window.clearTimeout(copiedTimerRef.current); - } - }, - [], - ); + const [copied, markCopied] = useCopiedState(1600); const handleClick = useCallback( async (event: ReactMouseEvent) => { event.stopPropagation(); - await navigator.clipboard.writeText(path); - setCopied(true); - if (copiedTimerRef.current != null) { - window.clearTimeout(copiedTimerRef.current); + try { + await navigator.clipboard.writeText(path); + } catch { + return; } - copiedTimerRef.current = window.setTimeout(() => { - setCopied(false); - copiedTimerRef.current = null; - }, 1600); + markCopied(); }, - [path], + [markCopied, path], ); return ( @@ -777,15 +769,42 @@ function ReviewAnnotation({ } const scrollTargetRetryFrameLimit = 90; +// Render commit details as a CodeView item so scrolling treats the panel like the diffs. +const commitDetailsFileName = '__codiff_commit_details__'; + +// Build an id from commit details that can change the panel height. When the panel first appears, +// we change only layoutPass to make CodeView measure again; this id still means "same content." +const getCommitDetailsContentKey = (metadata: CommitMetadata) => + [ + metadata.ref, + metadata.refs.join('\u0000'), + metadata.stats.files, + metadata.stats.additions, + metadata.stats.deletions, + metadata.stats.renamedFiles, + metadata.stats.binaryFiles, + ].join('\u0001'); + +const getCommitDetailsVersionKey = ( + metadata: CommitMetadata, + layoutPass: number, + navigationKey: string, +) => [getCommitDetailsContentKey(metadata), layoutPass, navigationKey].join('\u0001'); function isScrollTargetRendered(viewer: CodeViewInstance, itemId: string) { return viewer.getRenderedItems().some((item) => item.id === itemId); } +const getEffectiveScrollBehavior = (behavior: ReviewScrollBehavior) => + behavior === 'smooth' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches + ? 'instant' + : behavior; + export function ReviewCodeView({ activeSearchMatch, collapsed, comments, + commitMetadata, diffStyle, files, focusCommentId, @@ -818,6 +837,7 @@ export function ReviewCodeView({ activeSearchMatch: DiffSearchMatch | null; collapsed: ReadonlySet; comments: ReadonlyArray; + commitMetadata: CommitMetadata | null; diffStyle: CodiffDiffStyle; files: ReadonlyArray; focusCommentId: string | null; @@ -838,7 +858,7 @@ export function ReviewCodeView({ onToggleCollapsed: (file: ChangedFile, isCollapsed: boolean) => void; onToggleViewed: (file: ChangedFile, isViewed: boolean) => void; onUpdateComment: (commentId: string, body: string) => void; - scrollTarget: { path: string; request: number } | null; + scrollTarget: ReviewScrollTarget | null; searchQuery: string; selectedPath: string | null; showWhitespace: boolean; @@ -850,21 +870,25 @@ export function ReviewCodeView({ const codeViewRef = useRef>(null); const deferredTimersRef = useRef>(new Set()); const handledScrollRequestRef = useRef(null); + const measuredCommitDetailsLayoutKeyRef = useRef(null); const emptyCommentDeleteTimersRef = useRef>(new Map()); const highlightFrameRef = useRef(null); const ignoreNextLineSelectionEndRef = useRef(false); const [markdownPreviewSections, setMarkdownPreviewSections] = useState>( () => new Set(), ); - // Markdown preview content is rendered through a CodeView annotation portal. - // Bump the item version once the portal DOM exists so CodeView measures the real preview height. + // Markdown previews render inside a CodeView item. Change the item version once after the + // preview appears so CodeView measures the preview height instead of the placeholder height. const [markdownPreviewLayoutPassBySection, setMarkdownPreviewLayoutPassBySection] = useState< Readonly> >({}); const [imagePreviewLayoutPassBySection, setImagePreviewLayoutPassBySection] = useState< Readonly> >({}); + const [commitDetailsLayoutPass, setCommitDetailsLayoutPass] = useState(0); const [selectedLines, setSelectedLines] = useState(null); + const commitRef = source.type === 'commit' ? source.ref : null; + const commitDetailsItemId = commitRef ? `commit-details:${commitRef}` : null; const stickyHeaderFrameRef = useRef(null); const commentsBySection = useMemo(() => { const map = new Map>(); @@ -890,7 +914,24 @@ export function ReviewCodeView({ })); }, []); - const { firstItemByPath, itemMetadata, items } = useMemo(() => { + const markCommitDetailsLayoutReady = useCallback((layoutKey: string) => { + // After the panel appears, ask CodeView to measure it once. Repeated renders of the same + // commit details should not change the item version again. + if (measuredCommitDetailsLayoutKeyRef.current === layoutKey) { + return; + } + + measuredCommitDetailsLayoutKeyRef.current = layoutKey; + setCommitDetailsLayoutPass((current) => current + 1); + }, []); + + useEffect(() => { + if (!commitDetailsItemId || !commitMetadata) { + measuredCommitDetailsLayoutKeyRef.current = null; + } + }, [commitDetailsItemId, commitMetadata]); + + const { commitDetailsFiles, firstItemByPath, itemMetadata, items } = useMemo(() => { const nextItems: Array> = []; const nextFirstItemByPath = new Map(); const nextItemMetadata = new Map(); @@ -1037,13 +1078,55 @@ export function ReviewCodeView({ } } + // Keep all commit files visible, but only rows with rendered diff items can navigate. + const nextCommitDetailsFiles: ReadonlyArray = commitMetadata + ? commitMetadata.files.map((file) => ({ + ...file, + destinationItemId: nextFirstItemByPath.get(file.path) ?? null, + })) + : []; + + if (commitMetadata && commitDetailsItemId) { + const navigationKey = nextCommitDetailsFiles + .map( + (file) => `${file.oldPath ?? ''}\u0000${file.path}\u0000${file.destinationItemId ?? ''}`, + ) + .join('\u0001'); + nextItems.unshift({ + annotations: [ + { + lineNumber: 1, + metadata: { + metadata: commitMetadata, + type: 'commit-details', + }, + } satisfies LineAnnotation, + ], + file: { + cacheKey: `${commitDetailsItemId}:${commitMetadata.ref}`, + contents: ' ', + lang: 'text', + name: commitDetailsFileName, + }, + id: commitDetailsItemId, + type: 'file', + version: getItemVersion( + getCommitDetailsVersionKey(commitMetadata, commitDetailsLayoutPass, navigationKey), + ), + }); + } + return { + commitDetailsFiles: nextCommitDetailsFiles, firstItemByPath: nextFirstItemByPath, itemMetadata: nextItemMetadata, items: nextItems, }; }, [ collapsed, + commitDetailsItemId, + commitDetailsLayoutPass, + commitMetadata, commentsBySection, diffStyle, files, @@ -1183,6 +1266,10 @@ export function ReviewCodeView({ }, onPostRender: (node, _instance, _phase, context) => { const metadata = itemMetadata.get(context.item.id); + node.classList.toggle( + 'codiff-commit-details-item', + context.item.id === commitDetailsItemId, + ); node.classList.toggle( 'codiff-markdown-preview-item', metadata?.isMarkdownPreview === true, @@ -1214,6 +1301,7 @@ export function ReviewCodeView({ }) satisfies CodeViewOptions, [ cancelPendingEmptyCommentDeletes, + commitDetailsItemId, createCommentForRange, diffStyle, itemMetadata, @@ -1295,28 +1383,39 @@ export function ReviewCodeView({ [], ); - const requestScrollItemHeaderIntoView = useCallback((itemId: string) => { - const handle = codeViewRef.current; - const viewer = handle?.getInstance(); - if (!handle || !viewer || viewer.getTopForItem(itemId) == null) { - return false; - } + const requestScrollItemHeaderIntoView = useCallback( + (itemId: string, behavior: ReviewScrollBehavior = 'instant') => { + const handle = codeViewRef.current; + const viewer = handle?.getInstance(); + if (!handle || !viewer || viewer.getTopForItem(itemId) == null) { + return false; + } - handle.scrollTo({ - behavior: 'instant', - id: itemId, - offset: DEFAULT_PADDING, - type: 'item', - }); + handle.scrollTo({ + behavior: getEffectiveScrollBehavior(behavior), + id: itemId, + offset: DEFAULT_PADDING, + type: 'item', + }); - return true; - }, []); + return true; + }, + [], + ); + + const scrollToCommitDetailsDestination = useCallback( + (itemId: string) => { + requestScrollItemHeaderIntoView(itemId, 'smooth'); + }, + [requestScrollItemHeaderIntoView], + ); useLayoutEffect(() => { if (!scrollTarget || handledScrollRequestRef.current === scrollTarget.request) { return; } + const behavior = scrollTarget.behavior ?? 'instant'; const itemId = firstItemByPath.get(scrollTarget.path); if (!itemId) { return; @@ -1338,7 +1437,10 @@ export function ReviewCodeView({ return; } - if (requestScrollItemHeaderIntoView(itemId)) { + if ( + (behavior === 'instant' || !requested) && + requestScrollItemHeaderIntoView(itemId, behavior) + ) { requested = true; } @@ -1443,6 +1545,10 @@ export function ReviewCodeView({ const renderCustomHeader = useCallback( (item: CodeViewItem) => { + if (item.id === commitDetailsItemId) { + return null; + } + const meta = itemMetadata.get(item.id); return meta ? ( + ); + } + return item.type === 'diff' ? ( } @@ -1519,6 +1638,7 @@ export function ReviewCodeView({ [ comments, blurComment, + commitDetailsFiles, deleteComment, focusCommentId, focusCommentRequest, @@ -1527,11 +1647,13 @@ export function ReviewCodeView({ isPullRequest, itemMetadata, keymap, + markCommitDetailsLayoutReady, markMarkdownPreviewLayoutReady, markImagePreviewLayoutReady, onAskCodex, onSubmitComment, onUpdateComment, + scrollToCommitDetailsDestination, source, ], ); diff --git a/src/app/components/useCopiedState.ts b/src/app/components/useCopiedState.ts new file mode 100644 index 0000000..eb2b509 --- /dev/null +++ b/src/app/components/useCopiedState.ts @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export function useCopiedState(timeoutMs: number) { + const [copied, setCopied] = useState(false); + const copiedTimerRef = useRef(null); + + useEffect( + () => () => { + if (copiedTimerRef.current != null) { + window.clearTimeout(copiedTimerRef.current); + } + }, + [], + ); + + const markCopied = useCallback(() => { + setCopied(true); + if (copiedTimerRef.current != null) { + window.clearTimeout(copiedTimerRef.current); + } + copiedTimerRef.current = window.setTimeout(() => { + setCopied(false); + copiedTimerRef.current = null; + }, timeoutMs); + }, [timeoutMs]); + + return [copied, markCopied] as const; +} diff --git a/src/lib/app-types.ts b/src/lib/app-types.ts index 45b4f7b..ed7a4f8 100644 --- a/src/lib/app-types.ts +++ b/src/lib/app-types.ts @@ -1,6 +1,7 @@ import type { CodeViewHandle } from '@pierre/diffs/react'; import type { ChangedFile, + CommitMetadata, DiffSection, PullRequestExistingReviewComment, ReviewSource, @@ -30,7 +31,13 @@ type ImagePreviewAnnotationMetadata = { type: 'image-preview'; }; +type CommitDetailsAnnotationMetadata = { + metadata: CommitMetadata; + type: 'commit-details'; +}; + export type ReviewAnnotationMetadata = + | CommitDetailsAnnotationMetadata | ImagePreviewAnnotationMetadata | MarkdownPreviewAnnotationMetadata | ReviewCommentAnnotationMetadata; @@ -52,6 +59,14 @@ export type DiffSearchResult = { matches: ReadonlyArray; }; +export type ReviewScrollBehavior = 'instant' | 'smooth'; + +export type ReviewScrollTarget = { + behavior?: ReviewScrollBehavior; + path: string; + request: number; +}; + export type DiffLineCount = { additions: number; countable: boolean; diff --git a/src/lib/code-view-options.ts b/src/lib/code-view-options.ts index 5d2d0bd..cd3edd9 100644 --- a/src/lib/code-view-options.ts +++ b/src/lib/code-view-options.ts @@ -160,6 +160,52 @@ export const codeViewUnsafeCSS = ` border-radius: 28px 28px 0 0; } + /* Commit details render through CodeView's file layout; hide the file UI around the panel. */ + :host(.codiff-commit-details-item) { + --diffs-scrollbar-gutter-override: 0px; + background: transparent; + border: 0; + box-shadow: none; + } + + :host(.codiff-commit-details-item) [data-diffs-header="custom"][data-sticky] { + display: none; + } + + :host(.codiff-commit-details-item) [data-file] { + background: transparent; + border: 0; + box-shadow: none; + } + + :host(.codiff-commit-details-item) [data-file] [data-code] { + overflow: visible; + padding-bottom: 0; + padding-top: 0; + scrollbar-width: none; + } + + :host(.codiff-commit-details-item) [data-file] [data-code]::-webkit-scrollbar { + display: none; + } + + :host(.codiff-commit-details-item) [data-file] [data-line], + :host(.codiff-commit-details-item) [data-file] [data-column-number], + :host(.codiff-commit-details-item) [data-file] [data-gutter-buffer], + :host(.codiff-commit-details-item) [data-file] [data-gutter-gap] { + display: none; + } + + :host(.codiff-commit-details-item) [data-file] [data-code], + :host(.codiff-commit-details-item) [data-file] [data-content] { + display: block; + } + + :host(.codiff-commit-details-item) [data-file] [data-line-annotation] { + background: transparent; + grid-column: 1 / -1; + } + /* Align scrollbar with number column */ [data-code]::-webkit-scrollbar-track { margin-left: var(--diffs-column-number-width); diff --git a/src/types.ts b/src/types.ts index 41109b7..9b85e93 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,50 @@ export type HistoryEntry = { subject: string; }; +export type CommitMetadataPerson = { + date: string; + email: string; + gravatarUrl?: string; + name: string; +}; + +export type CommitMetadataFile = { + additions?: number; + binary: boolean; + deletions?: number; + oldPath?: string; + path: string; + status: GitFileStatus; +}; + +export type CommitMetadata = { + author: CommitMetadataPerson; + body: string; + committer: CommitMetadataPerson; + files: ReadonlyArray; + parents: ReadonlyArray; + ref: string; + refs: ReadonlyArray; + shortRef: string; + signature: { + key?: string; + signer?: string; + status: string; + }; + stats: { + additions: number; + binaryFiles: number; + deletions: number; + files: number; + renamedFiles: number; + }; + subject: string; + trailers: ReadonlyArray<{ + key: string; + value: string; + }>; +}; + export type RepositoryHistory = { entries: ReadonlyArray; root: string; @@ -75,6 +119,7 @@ export type RepositoryHistory = { export type RepositoryState = { branch: string | null; + commitMetadata?: CommitMetadata; files: ReadonlyArray; generatedAt: number; launchPath: string;