diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 1a368b6a..fd356212 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -2325,6 +2325,7 @@ export const GitView: React.FC = () => { commitFilesMap={commitFilesMap} loadingCommitHashes={loadingCommitHashes} onCopyHash={handleCopyCommitHash} + directory={currentDirectory ?? undefined} showHeader={false} contentMaxHeightClassName="h-full max-h-none" branchDivider={historyBranchDivider} diff --git a/packages/ui/src/components/views/git/HistoryCommitRow.tsx b/packages/ui/src/components/views/git/HistoryCommitRow.tsx index dac47328..3993299f 100644 --- a/packages/ui/src/components/views/git/HistoryCommitRow.tsx +++ b/packages/ui/src/components/views/git/HistoryCommitRow.tsx @@ -5,6 +5,52 @@ import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { getCommitFileDiff, type CommitFileDiffResponse } from '@/lib/gitApi'; +import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; +import { getLanguageFromExtension } from '@/lib/toolHelpers'; + +const HISTORY_DIFF_REQUEST_TIMEOUT_MS = 15000; +const HISTORY_DIFF_LARGE_CHANGED_LINES = 500; +const HISTORY_DIFF_CACHE_MAX_ENTRIES = 12; +const HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 8 * 1024 * 1024; + +type HistoryDiffCacheValue = CommitFileDiffResponse | 'loading' | 'error'; + +const getHistoryDiffCacheSize = (value: HistoryDiffCacheValue): number => { + if (typeof value === 'string') { + return 0; + } + return (value.original?.length ?? 0) + (value.modified?.length ?? 0); +}; + +const trimHistoryDiffCache = (cache: Map): Map => { + if (cache.size <= HISTORY_DIFF_CACHE_MAX_ENTRIES) { + let totalSize = 0; + for (const value of cache.values()) { + totalSize += getHistoryDiffCacheSize(value); + } + if (totalSize <= HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES) { + return cache; + } + } + + const entries = Array.from(cache.entries()).reverse(); + const next = new Map(); + let totalSize = 0; + for (const [key, value] of entries) { + if (next.size >= HISTORY_DIFF_CACHE_MAX_ENTRIES) { + continue; + } + const entrySize = getHistoryDiffCacheSize(value); + if (totalSize + entrySize > HISTORY_DIFF_CACHE_MAX_TOTAL_SIZE_BYTES && next.size > 0) { + continue; + } + next.set(key, value); + totalSize += entrySize; + } + + return new Map(Array.from(next.entries()).reverse()); +}; interface HistoryCommitRowProps { entry: GitLogEntry; @@ -13,6 +59,7 @@ interface HistoryCommitRowProps { files: CommitFileEntry[]; isLoadingFiles: boolean; onCopyHash: (hash: string) => void; + directory: string | undefined; } function formatCommitDate(date: string) { @@ -53,8 +100,68 @@ export const HistoryCommitRow = React.memo(({ files, isLoadingFiles, onCopyHash, + directory, }: HistoryCommitRowProps) => { const { t } = useI18n(); + + const [openDiffPaths, setOpenDiffPaths] = React.useState>(new Set()); + const [diffCache, setDiffCache] = React.useState>(new Map()); + const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState>(new Set()); + + const loadFileDiff = React.useCallback(async (file: CommitFileEntry) => { + const key = file.path; + if (!directory) { + setDiffCache(prev => new Map(prev).set(key, 'error')); + return; + } + + setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, 'loading'))); + try { + const fetchPromise = getCommitFileDiff(directory, entry.hash, file.path, false); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out after ${HISTORY_DIFF_REQUEST_TIMEOUT_MS}ms`)), HISTORY_DIFF_REQUEST_TIMEOUT_MS); + }); + const result = await Promise.race([fetchPromise, timeoutPromise]); + setDiffCache(prev => trimHistoryDiffCache(new Map(prev).set(key, result))); + } catch { + setDiffCache(prev => new Map(prev).set(key, 'error')); + } + }, [directory, entry.hash]); + + const toggleFileDiff = React.useCallback(async (file: CommitFileEntry) => { + const key = file.path; + + if (file.changeType === 'R' || file.isBinary) { + setOpenDiffPaths(prev => { + const next = new Set(prev); + if (next.has(key)) { next.delete(key); } else { next.add(key); } + return next; + }); + return; + } + + const cached = diffCache.get(key); + const isOpen = openDiffPaths.has(key); + + if (isOpen && cached && cached !== 'error') { + // Close it + setOpenDiffPaths(prev => { const next = new Set(prev); next.delete(key); return next; }); + return; + } + + // Open it (or re-fetch on error) + setOpenDiffPaths(prev => { const next = new Set(prev); next.add(key); return next; }); + + if (cached && cached !== 'error') return; // Already loaded + + const changedLines = file.insertions + file.deletions; + if (changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES && !forceRenderLargePaths.has(key)) { + return; + } + + await loadFileDiff(file); + }, [diffCache, forceRenderLargePaths, loadFileDiff, openDiffPaths]); + return (
  • + + {openDiffPaths.has(file.path) && ( +
    + {file.changeType === 'R' ? ( +
    {t('gitView.history.renamedNoDiff')}
    + ) : file.isBinary ? ( +
    {t('gitView.history.binaryNoDiff')}
    + ) : (() => { + const changedLines = file.insertions + file.deletions; + if (!forceRenderLargePaths.has(file.path) && changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES) { + return ( +
    +
    + {t('gitView.history.largeDiffTitle', { count: changedLines })} +
    +
    + {t('gitView.history.largeDiffDescription')} +
    + +
    + ); + } + + const cached = diffCache.get(file.path); + if (cached === 'loading' || cached === undefined) { + return
    {t('gitView.history.loadingDiff')}
    ; + } + if (cached === 'error') { + return ( + + ); + } + return ( + + ); + })()} +
    )}
  • ))} diff --git a/packages/ui/src/components/views/git/HistorySection.tsx b/packages/ui/src/components/views/git/HistorySection.tsx index c73d7281..1a6ac335 100644 --- a/packages/ui/src/components/views/git/HistorySection.tsx +++ b/packages/ui/src/components/views/git/HistorySection.tsx @@ -33,6 +33,7 @@ interface HistorySectionProps { commitFilesMap: Map; loadingCommitHashes: Set; onCopyHash: (hash: string) => void; + directory: string | undefined; showHeader?: boolean; contentMaxHeightClassName?: string; branchDivider?: { @@ -52,6 +53,7 @@ export const HistorySection: React.FC = ({ commitFilesMap, loadingCommitHashes, onCopyHash, + directory, showHeader = true, contentMaxHeightClassName = 'max-h-[50vh]', branchDivider = null, @@ -92,6 +94,7 @@ export const HistorySection: React.FC = ({ files={commitFilesMap.get(entry.hash) ?? []} isLoadingFiles={loadingCommitHashes.has(entry.hash)} onCopyHash={onCopyHash} + directory={directory} /> ))} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 2c8d6fd6..49a4b67e 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -303,6 +303,12 @@ export interface GitCommitFilesResponse { files: CommitFileEntry[]; } +export interface CommitFileDiffResponse { + original: string; + modified: string; + isBinary: boolean; +} + export interface GitWorktreeInfo { head: string; name: string; @@ -447,6 +453,7 @@ export interface GitAPI { renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>; getGitLog(directory: string, options?: GitLogOptions): Promise; getCommitFiles(directory: string, hash: string): Promise; + getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise; getCurrentGitIdentity(directory: string): Promise; hasLocalIdentity?(directory: string): Promise; setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index c55ba64a..2a922ff1 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -36,6 +36,7 @@ export type { GitMergeResult, GitRebaseResult, MergeConflictDetails, + CommitFileDiffResponse, } from './api/types'; declare global { @@ -642,6 +643,17 @@ export async function getCommitFiles( return gitHttp.getCommitFiles(directory, hash); } +export async function getCommitFileDiff( + directory: string, + hash: string, + filePath: string, + isBinary: boolean +): Promise { + const runtime = getRuntimeGit(); + if (runtime?.getCommitFileDiff) return runtime.getCommitFileDiff(directory, hash, filePath, isBinary); + return gitHttp.getCommitFileDiff(directory, hash, filePath, isBinary); +} + export async function getGitIdentities(): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitIdentities(); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index b688d716..4df16c7a 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -25,6 +25,7 @@ import type { GitLogOptions, GitLogResponse, GitCommitFilesResponse, + CommitFileDiffResponse, GitIdentityProfile, GitIdentitySummary, DiscoveredGitCredential, @@ -681,6 +682,25 @@ export async function getCommitFiles( return response.json(); } +export async function getCommitFileDiff( + directory: string, + hash: string, + filePath: string, + isBinary: boolean +): Promise { + const response = await fetch( + buildUrl(`${API_BASE}/commit-file-diff`, directory, { + hash, + path: filePath, + binary: isBinary ? 'true' : undefined, + }) + ); + if (!response.ok) { + throw new Error(`Failed to get commit file diff: ${response.statusText}`); + } + return response.json(); +} + export async function getGitIdentities(): Promise { const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined)); if (!response.ok) { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 8d72bfaf..f6fc69d8 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -483,14 +483,21 @@ export const dict = { 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.history.binary': 'Binary', + 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', 'gitView.history.copySha': 'Copy SHA', + 'gitView.history.diffError': 'Failed to load diff. Click to retry.', + 'gitView.history.largeDiffDescription': 'Rendering may be slow. You can still view the diff by clicking below.', + 'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)', + 'gitView.history.loadingDiff': 'Loading diff...', 'gitView.history.loadingFiles': 'Loading files...', 'gitView.history.logSize100': 'Log Size100', 'gitView.history.logSize25': 'Log Size25', 'gitView.history.logSize50': 'Log Size50', 'gitView.history.noCommits': 'No commits found', 'gitView.history.noFiles': 'No files', + 'gitView.history.renamedNoDiff': 'Renamed file — diff not supported', + 'gitView.history.renderDiffAnyway': 'Render anyway', 'gitView.history.title': 'History', 'gitView.integrate.checking': 'Checking…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 73ce4f09..b3383007 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -484,14 +484,21 @@ export const dict: Record = { "gitView.header.removeRemoteAria": "Eliminar remoto", "gitView.header.removeRemoteTitle": "Eliminar remoto", "gitView.history.binary": "Binario", + "gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible", "gitView.history.commitsPlaceholder": "Buscar commits...", "gitView.history.copySha": "Copiar SHA", + "gitView.history.diffError": "Error al cargar el diff. Haz clic para reintentar.", + "gitView.history.largeDiffDescription": "El renderizado puede ser lento. Puedes ver el diff igualmente haciendo clic abajo.", + "gitView.history.largeDiffTitle": "Diff grande ({count} líneas cambiadas)", + "gitView.history.loadingDiff": "Cargando diff...", "gitView.history.loadingFiles": "Cargando archivos...", "gitView.history.logSize100": "100 commits", "gitView.history.logSize25": "25 commits", "gitView.history.logSize50": "50 commits", "gitView.history.noCommits": "No se encontraron commits", "gitView.history.noFiles": "No hay archivos", + "gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado", + "gitView.history.renderDiffAnyway": "Renderizar igualmente", "gitView.history.title": "Historial", "gitView.integrate.checking": "Verificando…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index fb7a4c38..6d7e1ad9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -484,14 +484,21 @@ export const dict: Record = { 'gitView.header.removeRemoteAria': '리모트 제거', 'gitView.header.removeRemoteTitle': '리모트 제거', 'gitView.history.binary': '바이너리', + 'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음', 'gitView.history.commitsPlaceholder': '커밋 검색', 'gitView.history.copySha': 'SHA 복사', + 'gitView.history.diffError': 'diff 로드 실패. 클릭하여 재시도.', + 'gitView.history.largeDiffDescription': '렌더링이 느릴 수 있습니다. 아래를 클릭해 diff를 계속 볼 수 있습니다.', + 'gitView.history.largeDiffTitle': '큰 diff({count}개 변경된 줄)', + 'gitView.history.loadingDiff': 'diff 로드 중…', 'gitView.history.loadingFiles': '파일 로드 중…', 'gitView.history.logSize100': '최근 100개', 'gitView.history.logSize25': '최근 25개', 'gitView.history.logSize50': '최근 50개', 'gitView.history.noCommits': '커밋 없음', 'gitView.history.noFiles': '파일 없음', + 'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원', + 'gitView.history.renderDiffAnyway': '그래도 렌더링', 'gitView.history.title': '히스토리', 'gitView.integrate.checking': '확인 중…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index ff34d410..c714d764 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1450,15 +1450,22 @@ export const dict: Record = { 'gitView.header.removeRemoteAria': 'Remove Remote aria label', 'gitView.header.removeRemoteTitle': 'Remove Remote Title', 'gitView.history.binary': 'Binary', + 'gitView.history.binaryNoDiff': 'Binary file — no diff available', 'gitView.history.commitsPlaceholder': 'Commits Placeholder', 'gitView.history.copySha': 'Copy SHA', 'gitView.history.dialogDescription': 'Browse recent commits and inspect changed files.', + 'gitView.history.diffError': 'Failed to load diff. Click to retry.', + 'gitView.history.largeDiffDescription': 'Rendering may be slow. You can still view the diff by clicking below.', + 'gitView.history.largeDiffTitle': 'Large diff ({count} changed lines)', + 'gitView.history.loadingDiff': 'Loading diff...', 'gitView.history.loadingFiles': 'Loading files...', 'gitView.history.logSize100': 'Log Size100', 'gitView.history.logSize25': 'Log Size25', 'gitView.history.logSize50': 'Log Size50', 'gitView.history.noCommits': 'No commits found', 'gitView.history.noFiles': 'No files', + 'gitView.history.renamedNoDiff': 'Renamed file — diff not supported', + 'gitView.history.renderDiffAnyway': 'Render anyway', 'gitView.history.title': 'History', 'gitView.integrate.checking': 'Sprawdzanie…', 'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index fe37936d..02ceb8ba 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -484,14 +484,21 @@ export const dict: Record = { "gitView.header.removeRemoteAria": "Excluir remoto", "gitView.header.removeRemoteTitle": "Excluir remoto", "gitView.history.binary": "Binario", + "gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível", "gitView.history.commitsPlaceholder": "Buscar commits...", "gitView.history.copySha": "Copiar SHA", + "gitView.history.diffError": "Falha ao carregar diff. Clique para tentar novamente.", + "gitView.history.largeDiffDescription": "A renderização pode ser lenta. Você ainda pode ver o diff clicando abaixo.", + "gitView.history.largeDiffTitle": "Diff grande ({count} linhas alteradas)", + "gitView.history.loadingDiff": "Carregando diff...", "gitView.history.loadingFiles": "Carregando arquivos...", "gitView.history.logSize100": "100 commits", "gitView.history.logSize25": "25 commits", "gitView.history.logSize50": "50 commits", "gitView.history.noCommits": "Nenhum commit encontrado", "gitView.history.noFiles": "Não há arquivos", + "gitView.history.renamedNoDiff": "Arquivo renomeado — diff não suportado", + "gitView.history.renderDiffAnyway": "Renderizar mesmo assim", "gitView.history.title": "Histórico", "gitView.integrate.checking": "Verificando…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 9a6d673b..8ad118cf 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -484,14 +484,21 @@ export const dict: Record = { "gitView.header.removeRemoteAria": "Видалити remote", "gitView.header.removeRemoteTitle": "Видалити remote", "gitView.history.binary": "Бінарний", + "gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний", "gitView.history.commitsPlaceholder": "Пошук комітів", "gitView.history.copySha": "Скопіювати SHA", + "gitView.history.diffError": "Не вдалося завантажити diff. Натисніть, щоб повторити.", + "gitView.history.largeDiffDescription": "Рендеринг може бути повільним. Diff все одно можна переглянути нижче.", + "gitView.history.largeDiffTitle": "Великий diff ({count} змінених рядків)", + "gitView.history.loadingDiff": "Завантаження diff...", "gitView.history.loadingFiles": "Завантаження файлів...", "gitView.history.logSize100": "Розмір журналу 100", "gitView.history.logSize25": "Розмір журналу 25", "gitView.history.logSize50": "Розмір журналу 50", "gitView.history.noCommits": "Комітів не знайдено", "gitView.history.noFiles": "Немає файлів", + "gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується", + "gitView.history.renderDiffAnyway": "Показати все одно", "gitView.history.title": "Історія", "gitView.integrate.checking": "Перевірка…", "gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index eef33e44..373b29b4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -484,14 +484,21 @@ export const dict: Record = { 'gitView.header.removeRemoteAria': '移除远程 {name}', 'gitView.header.removeRemoteTitle': '移除 {name}', 'gitView.history.binary': '二进制', + 'gitView.history.binaryNoDiff': '二进制文件 — 无可用 diff', 'gitView.history.commitsPlaceholder': '提交数', 'gitView.history.copySha': '复制 SHA', + 'gitView.history.diffError': '加载 diff 失败,点击重试。', + 'gitView.history.largeDiffDescription': '渲染可能较慢。你仍可点击下方查看 diff。', + 'gitView.history.largeDiffTitle': '大型 diff({count} 行变更)', + 'gitView.history.loadingDiff': '正在加载 diff...', 'gitView.history.loadingFiles': '正在加载文件...', 'gitView.history.logSize100': '100 个提交', 'gitView.history.logSize25': '25 个提交', 'gitView.history.logSize50': '50 个提交', 'gitView.history.noCommits': '未找到提交', 'gitView.history.noFiles': '没有文件', + 'gitView.history.renamedNoDiff': '已重命名文件 — 不支持 diff', + 'gitView.history.renderDiffAnyway': '仍然渲染', 'gitView.history.title': '历史', 'gitView.integrate.checking': '检查中…', 'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index adfa1f34..76798906 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -416,6 +416,23 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: true, data: result }; } + case 'api:git/commit-file-diff': { + const { directory, hash, path: filePath, binary } = (payload || {}) as { + directory?: string; + hash?: string; + path?: string; + binary?: boolean; + }; + if (!directory || !hash || !filePath) { + return { id, type, success: false, error: 'Directory, hash, and path are required' }; + } + if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) { + return { id, type, success: false, error: 'hash must be a valid commit SHA' }; + } + const result = await gitService.getCommitFileDiff(directory, hash, filePath, Boolean(binary)); + return { id, type, success: true, data: result }; + } + case 'api:git/identity': { const { directory, method, userName, userEmail, sshKey } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 4efa1d10..5d8b369c 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -279,6 +279,27 @@ async function execGit(args: string[], cwd: string): Promise<{ stdout: string; s }); } +function extractGitStatusPath(status: string, pathPart: string): string { + if ((status === 'R' || status === 'C') && pathPart.includes('\t')) { + return pathPart.split('\t').pop() || pathPart; + } + return pathPart; +} + +function extractGitNumstatDestinationPath(filePath: string): string { + if (!filePath.includes(' => ')) { + return filePath; + } + + const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/); + if (braceMatch) { + const [, prefix, , destination, suffix] = braceMatch; + return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/'); + } + + return filePath.split(' => ').pop()?.trim() || filePath; +} + // ============== Repository Operations ============== /** @@ -2631,31 +2652,84 @@ export async function getCommitFiles( directory: string, hash: string ): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> { - const result = await execGit(['show', '--numstat', '--format=', hash], directory); - - if (result.exitCode !== 0) { + const numstatResult = await execGit(['show', '--numstat', '--format=', hash], directory); + + if (numstatResult.exitCode !== 0) { return { files: [] }; } const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = []; - - for (const line of result.stdout.trim().split('\n').filter(Boolean)) { + const lines = numstatResult.stdout.trim().split('\n').filter(Boolean); + + for (const line of lines) { const parts = line.split('\t'); - if (parts.length >= 3) { - const isBinary = parts[0] === '-' && parts[1] === '-'; - files.push({ - path: parts[2] || '', - insertions: isBinary ? 0 : parseInt(parts[0] || '0', 10), - deletions: isBinary ? 0 : parseInt(parts[1] || '0', 10), - isBinary, - changeType: 'M', // Would need additional parsing for actual change type - }); + if (parts.length < 3) continue; + + const [insertionsRaw, deletionsRaw, ...pathParts] = parts; + const filePath = pathParts.join('\t'); + if (!filePath) continue; + + const isBinary = insertionsRaw === '-' && deletionsRaw === '-'; + const insertions = isBinary ? 0 : (parseInt(insertionsRaw, 10) || 0); + const deletions = isBinary ? 0 : (parseInt(deletionsRaw, 10) || 0); + + let changeType = 'M'; + if (filePath.includes(' => ')) { + changeType = 'R'; + } + + files.push({ path: filePath, insertions, deletions, isBinary, changeType }); + } + + // Get accurate change types from --name-status + const nameStatusResult = await execGit(['show', '--name-status', '--format=', hash], directory); + if (nameStatusResult.exitCode === 0) { + const statusMap = new Map(); + for (const line of nameStatusResult.stdout.trim().split('\n').filter(Boolean)) { + const match = line.match(/^([AMDRC])\d*\t(.+)$/); + if (match) { + const [, status, pathPart] = match; + statusMap.set(extractGitStatusPath(status, pathPart), status); + } + } + for (const file of files) { + const basePath = extractGitNumstatDestinationPath(file.path); + const status = statusMap.get(basePath) ?? statusMap.get(file.path); + if (status) { + file.changeType = status; + } } } return { files }; } +export async function getCommitFileDiff( + directory: string, + hash: string, + filePath: string, + isBinary: boolean +): Promise<{ original: string; modified: string; isBinary: boolean }> { + if (isBinary) { + return { original: '', modified: '', isBinary: true }; + } + + const [originalResult, modifiedResult] = await Promise.all([ + execGit(['show', `${hash}^:${filePath}`], directory), + execGit(['show', `${hash}:${filePath}`], directory), + ]); + + if (originalResult.exitCode !== 0 && modifiedResult.exitCode !== 0) { + throw new Error(`Failed to read file content at commit ${hash}`); + } + + return { + original: originalResult.exitCode === 0 ? originalResult.stdout : '', + modified: modifiedResult.exitCode === 0 ? modifiedResult.stdout : '', + isBinary: false, + }; +} + // ============== Git Identity Operations ============== export interface GitIdentitySummary { diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 20aba3bb..c94412d0 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -29,6 +29,7 @@ import type { GitLogResponse, GitLogOptions, GitCommitFilesResponse, + CommitFileDiffResponse, GitIdentitySummary, GitIdentityProfile, GitRemote, @@ -259,6 +260,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({ }); }, + getCommitFileDiff: async (directory: string, hash: string, filePath: string, isBinary: boolean): Promise => { + return sendBridgeMessage('api:git/commit-file-diff', { + directory, + hash, + path: filePath, + binary: isBinary, + }); + }, + getCurrentGitIdentity: async (directory: string): Promise => { return sendBridgeMessage('api:git/identity', { directory, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 97154d43..d05ce9ef 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -58,6 +58,7 @@ The following functions are exported and used by the web server: ### Log Operations - `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters). - `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit. +- `getCommitFileDiff(directory, hash, filePath, isBinary)`: Get before/after content for a specific file in a commit. Returns `{ original, modified, isBinary }`. Runs `git show ^:` and `git show :` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases). ### Merge and Rebase Operations - `rebase(directory, options)`: Start a rebase onto a target branch. diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 4aadeb58..1ea2dd4d 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -943,4 +943,30 @@ export function registerGitRoutes(app) { } }); + app.get('/api/git/commit-file-diff', async (req, res) => { + const { getCommitFileDiff } = await getGitLibraries(); + try { + const { directory, hash, path: filePath } = req.query; + if (!directory || typeof directory !== 'string') { + return res.status(400).json({ error: 'directory parameter is required' }); + } + if (!hash || typeof hash !== 'string') { + return res.status(400).json({ error: 'hash parameter is required' }); + } + if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) { + return res.status(400).json({ error: 'hash must be a valid commit SHA' }); + } + if (!filePath || typeof filePath !== 'string') { + return res.status(400).json({ error: 'path parameter is required' }); + } + + const isBinary = req.query.binary === 'true'; + const result = await getCommitFileDiff(directory, hash, filePath, isBinary); + res.json(result); + } catch (error) { + console.error('Failed to get commit file diff:', error); + res.status(500).json({ error: error.message || 'Failed to get commit file diff' }); + } + }); + } diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 686d7240..f8cddb62 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -1611,6 +1611,27 @@ const parseIsBinaryFromNumstat = (raw) => { return added === '-' || deleted === '-'; }; +const extractGitStatusPath = (status, pathPart) => { + if ((status === 'R' || status === 'C') && pathPart.includes('\t')) { + return pathPart.split('\t').pop() || pathPart; + } + return pathPart; +}; + +const extractGitNumstatDestinationPath = (filePath) => { + if (!filePath.includes(' => ')) { + return filePath; + } + + const braceMatch = filePath.match(/^(.*)\{([^{}]*)\s=>\s([^{}]*)\}(.*)$/); + if (braceMatch) { + const [, prefix, , destination, suffix] = braceMatch; + return `${prefix}${destination}${suffix}`.replace(/\/+/g, '/'); + } + + return filePath.split(' => ').pop()?.trim() || filePath; +}; + const looksBinaryBySniff = async (absolutePath) => { try { const handle = await fsp.open(absolutePath, 'r'); @@ -3028,15 +3049,13 @@ export async function getCommitFiles(directory, commitHash) { for (const line of statusLines) { const match = line.match(/^([AMDRC])\d*\t(.+)$/); if (match) { - const [, status, path] = match; - statusMap.set(path, status); + const [, status, pathPart] = match; + statusMap.set(extractGitStatusPath(status, pathPart), status); } } for (const file of files) { - const basePath = file.path.includes(' => ') - ? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path - : file.path; + const basePath = extractGitNumstatDestinationPath(file.path); const status = statusMap.get(basePath) || statusMap.get(file.path); if (status) { @@ -3379,3 +3398,29 @@ export async function getConflictDetails(directory) { throw error; } } + +export async function getCommitFileDiff(directory, hash, filePath, isBinary) { + if (!directory || !hash || !filePath) { + throw new Error('directory, hash, and path are required for getCommitFileDiff'); + } + + if (isBinary) { + return { original: '', modified: '', isBinary: true }; + } + + const directoryPath = normalizeDirectoryPath(directory); + + const [originalResult, modifiedResult] = await Promise.all([ + runGitCommand(directoryPath, ['show', `${hash}^:${filePath}`]), + runGitCommand(directoryPath, ['show', `${hash}:${filePath}`]), + ]); + + const original = originalResult.success ? originalResult.stdout : ''; + const modified = modifiedResult.success ? modifiedResult.stdout : ''; + + if (!originalResult.success && !modifiedResult.success) { + throw new Error(`Failed to read file content at commit ${hash}: ${originalResult.stderr || modifiedResult.stderr}`); + } + + return { original, modified, isBinary: false }; +}