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;