feat(git): inline file diffs in commit history rows (#1291)
* chore: add .worktrees/ to gitignore for worktree workflow * feat(git): add getCommitFileDiff service function * docs(git): document getCommitFileDiff in module docs * feat(git): add GET /api/git/commit-file-diff route * feat(git): add CommitFileDiffResponse type and GitAPI method signature * feat(git): add getCommitFileDiff HTTP client function * feat(git): add getCommitFileDiff API facade * feat(git): add getCommitFileDiff stub to VS Code bridge * feat(git): add getCommitFileDiff to VS Code gitService and bridge handler * feat(git): add inline file diff to history commit rows * fix(git): consolidate CommitFileDiffResponse import to gitApi facade * fix(git): pass directory through history, validate hash, propagate git errors * fix(git): use exit code check for VS Code getCommitFileDiff error detection * fix(git): VS Code rename detection, hash validation parity, retry on error * fix(git): register scroll container as virtualizer root to fix empty space in history diffs * fix(git): address greptile review — rename key extraction, directory guard, language detection, isBinary cleanup * fix(git): harden history inline diffs --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
fa8fac2590
commit
631905764e
@@ -2325,6 +2325,7 @@ export const GitView: React.FC = () => {
|
|||||||
commitFilesMap={commitFilesMap}
|
commitFilesMap={commitFilesMap}
|
||||||
loadingCommitHashes={loadingCommitHashes}
|
loadingCommitHashes={loadingCommitHashes}
|
||||||
onCopyHash={handleCopyCommitHash}
|
onCopyHash={handleCopyCommitHash}
|
||||||
|
directory={currentDirectory ?? undefined}
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
contentMaxHeightClassName="h-full max-h-none"
|
contentMaxHeightClassName="h-full max-h-none"
|
||||||
branchDivider={historyBranchDivider}
|
branchDivider={historyBranchDivider}
|
||||||
|
|||||||
@@ -5,6 +5,52 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
import type { GitLogEntry, CommitFileEntry } from '@/lib/api/types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
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<string, HistoryDiffCacheValue>): Map<string, HistoryDiffCacheValue> => {
|
||||||
|
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<string, HistoryDiffCacheValue>();
|
||||||
|
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 {
|
interface HistoryCommitRowProps {
|
||||||
entry: GitLogEntry;
|
entry: GitLogEntry;
|
||||||
@@ -13,6 +59,7 @@ interface HistoryCommitRowProps {
|
|||||||
files: CommitFileEntry[];
|
files: CommitFileEntry[];
|
||||||
isLoadingFiles: boolean;
|
isLoadingFiles: boolean;
|
||||||
onCopyHash: (hash: string) => void;
|
onCopyHash: (hash: string) => void;
|
||||||
|
directory: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCommitDate(date: string) {
|
function formatCommitDate(date: string) {
|
||||||
@@ -53,8 +100,68 @@ export const HistoryCommitRow = React.memo(({
|
|||||||
files,
|
files,
|
||||||
isLoadingFiles,
|
isLoadingFiles,
|
||||||
onCopyHash,
|
onCopyHash,
|
||||||
|
directory,
|
||||||
}: HistoryCommitRowProps) => {
|
}: HistoryCommitRowProps) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const [openDiffPaths, setOpenDiffPaths] = React.useState<Set<string>>(new Set());
|
||||||
|
const [diffCache, setDiffCache] = React.useState<Map<string, HistoryDiffCacheValue>>(new Map());
|
||||||
|
const [forceRenderLargePaths, setForceRenderLargePaths] = React.useState<Set<string>>(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<never>((_, 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 (
|
return (
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -120,36 +227,108 @@ export const HistoryCommitRow = React.memo(({
|
|||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5 py-2">
|
<ul className="space-y-0.5 py-2">
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<li
|
<li key={file.path}>
|
||||||
key={file.path}
|
<button
|
||||||
className="flex items-center gap-2 typography-micro"
|
type="button"
|
||||||
>
|
onClick={() => toggleFileDiff(file)}
|
||||||
<span
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold w-3 text-center',
|
'w-full flex items-center gap-2 typography-micro text-left cursor-pointer transition-colors rounded px-1',
|
||||||
getChangeTypeColor(file.changeType)
|
openDiffPaths.has(file.path) ? 'bg-sidebar/90' : 'hover:bg-sidebar/40'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{file.changeType}
|
<span
|
||||||
</span>
|
className={cn(
|
||||||
<span className="truncate text-foreground min-w-0" title={file.path}>
|
'font-semibold w-3 text-center shrink-0',
|
||||||
{file.path}
|
getChangeTypeColor(file.changeType)
|
||||||
</span>
|
)}
|
||||||
{!file.isBinary && (
|
>
|
||||||
<span className="shrink-0">
|
{file.changeType}
|
||||||
<span style={{ color: 'var(--status-success)' }}>
|
|
||||||
+{file.insertions}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground mx-0.5">/</span>
|
|
||||||
<span style={{ color: 'var(--status-error)' }}>
|
|
||||||
-{file.deletions}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span className="truncate text-foreground min-w-0" title={file.path}>
|
||||||
{file.isBinary && (
|
{file.path}
|
||||||
<span className="typography-micro text-muted-foreground shrink-0">
|
|
||||||
{t('gitView.history.binary')}
|
|
||||||
</span>
|
</span>
|
||||||
|
{!file.isBinary && (
|
||||||
|
<span className="shrink-0">
|
||||||
|
<span style={{ color: 'var(--status-success)' }}>
|
||||||
|
+{file.insertions}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground mx-0.5">/</span>
|
||||||
|
<span style={{ color: 'var(--status-error)' }}>
|
||||||
|
-{file.deletions}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.isBinary && (
|
||||||
|
<span className="typography-micro text-muted-foreground shrink-0">
|
||||||
|
{t('gitView.history.binary')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Icon
|
||||||
|
name={openDiffPaths.has(file.path) ? 'arrow-down-s' : 'arrow-right-s'}
|
||||||
|
className="size-3 shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openDiffPaths.has(file.path) && (
|
||||||
|
<div className="max-h-[400px] overflow-y-auto rounded border border-border/40 mx-2 mb-1" data-diff-virtual-root data-diff-virtual-content>
|
||||||
|
{file.changeType === 'R' ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.renamedNoDiff')}</div>
|
||||||
|
) : file.isBinary ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.binaryNoDiff')}</div>
|
||||||
|
) : (() => {
|
||||||
|
const changedLines = file.insertions + file.deletions;
|
||||||
|
if (!forceRenderLargePaths.has(file.path) && changedLines > HISTORY_DIFF_LARGE_CHANGED_LINES) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-1 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
<div className="typography-ui-label font-semibold text-foreground">
|
||||||
|
{t('gitView.history.largeDiffTitle', { count: changedLines })}
|
||||||
|
</div>
|
||||||
|
<div className="typography-meta text-muted-foreground">
|
||||||
|
{t('gitView.history.largeDiffDescription')}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
className="h-6 px-0 text-primary hover:bg-transparent hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
setForceRenderLargePaths(prev => new Set(prev).add(file.path));
|
||||||
|
void loadFileDiff(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('gitView.history.renderDiffAnyway')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = diffCache.get(file.path);
|
||||||
|
if (cached === 'loading' || cached === undefined) {
|
||||||
|
return <div className="px-3 py-2 text-sm text-muted-foreground">{t('gitView.history.loadingDiff')}</div>;
|
||||||
|
}
|
||||||
|
if (cached === 'error') {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleFileDiff(file)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-[var(--interactive-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
{t('gitView.history.diffError')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PierreDiffViewer
|
||||||
|
original={cached.original}
|
||||||
|
modified={cached.modified}
|
||||||
|
language={getLanguageFromExtension(file.path) || ''}
|
||||||
|
fileName={file.path}
|
||||||
|
renderSideBySide={false}
|
||||||
|
layout="inline"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface HistorySectionProps {
|
|||||||
commitFilesMap: Map<string, CommitFileEntry[]>;
|
commitFilesMap: Map<string, CommitFileEntry[]>;
|
||||||
loadingCommitHashes: Set<string>;
|
loadingCommitHashes: Set<string>;
|
||||||
onCopyHash: (hash: string) => void;
|
onCopyHash: (hash: string) => void;
|
||||||
|
directory: string | undefined;
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
contentMaxHeightClassName?: string;
|
contentMaxHeightClassName?: string;
|
||||||
branchDivider?: {
|
branchDivider?: {
|
||||||
@@ -52,6 +53,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
commitFilesMap,
|
commitFilesMap,
|
||||||
loadingCommitHashes,
|
loadingCommitHashes,
|
||||||
onCopyHash,
|
onCopyHash,
|
||||||
|
directory,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
contentMaxHeightClassName = 'max-h-[50vh]',
|
contentMaxHeightClassName = 'max-h-[50vh]',
|
||||||
branchDivider = null,
|
branchDivider = null,
|
||||||
@@ -92,6 +94,7 @@ export const HistorySection: React.FC<HistorySectionProps> = ({
|
|||||||
files={commitFilesMap.get(entry.hash) ?? []}
|
files={commitFilesMap.get(entry.hash) ?? []}
|
||||||
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
isLoadingFiles={loadingCommitHashes.has(entry.hash)}
|
||||||
onCopyHash={onCopyHash}
|
onCopyHash={onCopyHash}
|
||||||
|
directory={directory}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -303,6 +303,12 @@ export interface GitCommitFilesResponse {
|
|||||||
files: CommitFileEntry[];
|
files: CommitFileEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommitFileDiffResponse {
|
||||||
|
original: string;
|
||||||
|
modified: string;
|
||||||
|
isBinary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitWorktreeInfo {
|
export interface GitWorktreeInfo {
|
||||||
head: string;
|
head: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -447,6 +453,7 @@ export interface GitAPI {
|
|||||||
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
|
renameBranch(directory: string, oldName: string, newName: string): Promise<{ success: boolean; branch: string }>;
|
||||||
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
getGitLog(directory: string, options?: GitLogOptions): Promise<GitLogResponse>;
|
||||||
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
getCommitFiles(directory: string, hash: string): Promise<GitCommitFilesResponse>;
|
||||||
|
getCommitFileDiff?(directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse>;
|
||||||
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
getCurrentGitIdentity(directory: string): Promise<GitIdentitySummary | null>;
|
||||||
hasLocalIdentity?(directory: string): Promise<boolean>;
|
hasLocalIdentity?(directory: string): Promise<boolean>;
|
||||||
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
setGitIdentity(directory: string, profileId: string): Promise<{ success: boolean; profile: GitIdentityProfile }>;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type {
|
|||||||
GitMergeResult,
|
GitMergeResult,
|
||||||
GitRebaseResult,
|
GitRebaseResult,
|
||||||
MergeConflictDetails,
|
MergeConflictDetails,
|
||||||
|
CommitFileDiffResponse,
|
||||||
} from './api/types';
|
} from './api/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -642,6 +643,17 @@ export async function getCommitFiles(
|
|||||||
return gitHttp.getCommitFiles(directory, hash);
|
return gitHttp.getCommitFiles(directory, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCommitFileDiff(
|
||||||
|
directory: string,
|
||||||
|
hash: string,
|
||||||
|
filePath: string,
|
||||||
|
isBinary: boolean
|
||||||
|
): Promise<import('./api/types').CommitFileDiffResponse> {
|
||||||
|
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<import('./api/types').GitIdentityProfile[]> {
|
export async function getGitIdentities(): Promise<import('./api/types').GitIdentityProfile[]> {
|
||||||
const runtime = getRuntimeGit();
|
const runtime = getRuntimeGit();
|
||||||
if (runtime) return runtime.getGitIdentities();
|
if (runtime) return runtime.getGitIdentities();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type {
|
|||||||
GitLogOptions,
|
GitLogOptions,
|
||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitCommitFilesResponse,
|
GitCommitFilesResponse,
|
||||||
|
CommitFileDiffResponse,
|
||||||
GitIdentityProfile,
|
GitIdentityProfile,
|
||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
DiscoveredGitCredential,
|
DiscoveredGitCredential,
|
||||||
@@ -681,6 +682,25 @@ export async function getCommitFiles(
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCommitFileDiff(
|
||||||
|
directory: string,
|
||||||
|
hash: string,
|
||||||
|
filePath: string,
|
||||||
|
isBinary: boolean
|
||||||
|
): Promise<CommitFileDiffResponse> {
|
||||||
|
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<GitIdentityProfile[]> {
|
export async function getGitIdentities(): Promise<GitIdentityProfile[]> {
|
||||||
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
|
const response = await fetch(buildUrl(`${API_BASE}/identities`, undefined));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -483,14 +483,21 @@ export const dict = {
|
|||||||
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
||||||
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
||||||
'gitView.history.binary': 'Binary',
|
'gitView.history.binary': 'Binary',
|
||||||
|
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
||||||
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
||||||
'gitView.history.copySha': 'Copy SHA',
|
'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.loadingFiles': 'Loading files...',
|
||||||
'gitView.history.logSize100': 'Log Size100',
|
'gitView.history.logSize100': 'Log Size100',
|
||||||
'gitView.history.logSize25': 'Log Size25',
|
'gitView.history.logSize25': 'Log Size25',
|
||||||
'gitView.history.logSize50': 'Log Size50',
|
'gitView.history.logSize50': 'Log Size50',
|
||||||
'gitView.history.noCommits': 'No commits found',
|
'gitView.history.noCommits': 'No commits found',
|
||||||
'gitView.history.noFiles': 'No files',
|
'gitView.history.noFiles': 'No files',
|
||||||
|
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
||||||
|
'gitView.history.renderDiffAnyway': 'Render anyway',
|
||||||
'gitView.history.title': 'History',
|
'gitView.history.title': 'History',
|
||||||
'gitView.integrate.checking': 'Checking…',
|
'gitView.integrate.checking': 'Checking…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry Pick Aborted Toast',
|
||||||
|
|||||||
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.removeRemoteAria": "Eliminar remoto",
|
"gitView.header.removeRemoteAria": "Eliminar remoto",
|
||||||
"gitView.header.removeRemoteTitle": "Eliminar remoto",
|
"gitView.header.removeRemoteTitle": "Eliminar remoto",
|
||||||
"gitView.history.binary": "Binario",
|
"gitView.history.binary": "Binario",
|
||||||
|
"gitView.history.binaryNoDiff": "Archivo binario — no hay diff disponible",
|
||||||
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
||||||
"gitView.history.copySha": "Copiar SHA",
|
"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.loadingFiles": "Cargando archivos...",
|
||||||
"gitView.history.logSize100": "100 commits",
|
"gitView.history.logSize100": "100 commits",
|
||||||
"gitView.history.logSize25": "25 commits",
|
"gitView.history.logSize25": "25 commits",
|
||||||
"gitView.history.logSize50": "50 commits",
|
"gitView.history.logSize50": "50 commits",
|
||||||
"gitView.history.noCommits": "No se encontraron commits",
|
"gitView.history.noCommits": "No se encontraron commits",
|
||||||
"gitView.history.noFiles": "No hay archivos",
|
"gitView.history.noFiles": "No hay archivos",
|
||||||
|
"gitView.history.renamedNoDiff": "Archivo renombrado — diff no soportado",
|
||||||
|
"gitView.history.renderDiffAnyway": "Renderizar igualmente",
|
||||||
"gitView.history.title": "Historial",
|
"gitView.history.title": "Historial",
|
||||||
"gitView.integrate.checking": "Verificando…",
|
"gitView.integrate.checking": "Verificando…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
||||||
|
|||||||
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.removeRemoteAria': '리모트 제거',
|
'gitView.header.removeRemoteAria': '리모트 제거',
|
||||||
'gitView.header.removeRemoteTitle': '리모트 제거',
|
'gitView.header.removeRemoteTitle': '리모트 제거',
|
||||||
'gitView.history.binary': '바이너리',
|
'gitView.history.binary': '바이너리',
|
||||||
|
'gitView.history.binaryNoDiff': '바이너리 파일 — diff 없음',
|
||||||
'gitView.history.commitsPlaceholder': '커밋 검색',
|
'gitView.history.commitsPlaceholder': '커밋 검색',
|
||||||
'gitView.history.copySha': 'SHA 복사',
|
'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.loadingFiles': '파일 로드 중…',
|
||||||
'gitView.history.logSize100': '최근 100개',
|
'gitView.history.logSize100': '최근 100개',
|
||||||
'gitView.history.logSize25': '최근 25개',
|
'gitView.history.logSize25': '최근 25개',
|
||||||
'gitView.history.logSize50': '최근 50개',
|
'gitView.history.logSize50': '최근 50개',
|
||||||
'gitView.history.noCommits': '커밋 없음',
|
'gitView.history.noCommits': '커밋 없음',
|
||||||
'gitView.history.noFiles': '파일 없음',
|
'gitView.history.noFiles': '파일 없음',
|
||||||
|
'gitView.history.renamedNoDiff': '이름 변경된 파일 — diff 미지원',
|
||||||
|
'gitView.history.renderDiffAnyway': '그래도 렌더링',
|
||||||
'gitView.history.title': '히스토리',
|
'gitView.history.title': '히스토리',
|
||||||
'gitView.integrate.checking': '확인 중…',
|
'gitView.integrate.checking': '확인 중…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick이 중단되었습니다',
|
||||||
|
|||||||
@@ -1450,15 +1450,22 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
'gitView.header.removeRemoteAria': 'Remove Remote aria label',
|
||||||
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
'gitView.header.removeRemoteTitle': 'Remove Remote Title',
|
||||||
'gitView.history.binary': 'Binary',
|
'gitView.history.binary': 'Binary',
|
||||||
|
'gitView.history.binaryNoDiff': 'Binary file — no diff available',
|
||||||
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
'gitView.history.commitsPlaceholder': 'Commits Placeholder',
|
||||||
'gitView.history.copySha': 'Copy SHA',
|
'gitView.history.copySha': 'Copy SHA',
|
||||||
'gitView.history.dialogDescription': 'Browse recent commits and inspect changed files.',
|
'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.loadingFiles': 'Loading files...',
|
||||||
'gitView.history.logSize100': 'Log Size100',
|
'gitView.history.logSize100': 'Log Size100',
|
||||||
'gitView.history.logSize25': 'Log Size25',
|
'gitView.history.logSize25': 'Log Size25',
|
||||||
'gitView.history.logSize50': 'Log Size50',
|
'gitView.history.logSize50': 'Log Size50',
|
||||||
'gitView.history.noCommits': 'No commits found',
|
'gitView.history.noCommits': 'No commits found',
|
||||||
'gitView.history.noFiles': 'No files',
|
'gitView.history.noFiles': 'No files',
|
||||||
|
'gitView.history.renamedNoDiff': 'Renamed file — diff not supported',
|
||||||
|
'gitView.history.renderDiffAnyway': 'Render anyway',
|
||||||
'gitView.history.title': 'History',
|
'gitView.history.title': 'History',
|
||||||
'gitView.integrate.checking': 'Sprawdzanie…',
|
'gitView.integrate.checking': 'Sprawdzanie…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
|
'gitView.integrate.cherryPickAbortedToast': 'Cherry-pick został przerwany',
|
||||||
|
|||||||
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.removeRemoteAria": "Excluir remoto",
|
"gitView.header.removeRemoteAria": "Excluir remoto",
|
||||||
"gitView.header.removeRemoteTitle": "Excluir remoto",
|
"gitView.header.removeRemoteTitle": "Excluir remoto",
|
||||||
"gitView.history.binary": "Binario",
|
"gitView.history.binary": "Binario",
|
||||||
|
"gitView.history.binaryNoDiff": "Arquivo binário — diff não disponível",
|
||||||
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
"gitView.history.commitsPlaceholder": "Buscar commits...",
|
||||||
"gitView.history.copySha": "Copiar SHA",
|
"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.loadingFiles": "Carregando arquivos...",
|
||||||
"gitView.history.logSize100": "100 commits",
|
"gitView.history.logSize100": "100 commits",
|
||||||
"gitView.history.logSize25": "25 commits",
|
"gitView.history.logSize25": "25 commits",
|
||||||
"gitView.history.logSize50": "50 commits",
|
"gitView.history.logSize50": "50 commits",
|
||||||
"gitView.history.noCommits": "Nenhum commit encontrado",
|
"gitView.history.noCommits": "Nenhum commit encontrado",
|
||||||
"gitView.history.noFiles": "Não há arquivos",
|
"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.history.title": "Histórico",
|
||||||
"gitView.integrate.checking": "Verificando…",
|
"gitView.integrate.checking": "Verificando…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick abortado",
|
||||||
|
|||||||
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"gitView.header.removeRemoteAria": "Видалити remote",
|
"gitView.header.removeRemoteAria": "Видалити remote",
|
||||||
"gitView.header.removeRemoteTitle": "Видалити remote",
|
"gitView.header.removeRemoteTitle": "Видалити remote",
|
||||||
"gitView.history.binary": "Бінарний",
|
"gitView.history.binary": "Бінарний",
|
||||||
|
"gitView.history.binaryNoDiff": "Бінарний файл — diff недоступний",
|
||||||
"gitView.history.commitsPlaceholder": "Пошук комітів",
|
"gitView.history.commitsPlaceholder": "Пошук комітів",
|
||||||
"gitView.history.copySha": "Скопіювати SHA",
|
"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.loadingFiles": "Завантаження файлів...",
|
||||||
"gitView.history.logSize100": "Розмір журналу 100",
|
"gitView.history.logSize100": "Розмір журналу 100",
|
||||||
"gitView.history.logSize25": "Розмір журналу 25",
|
"gitView.history.logSize25": "Розмір журналу 25",
|
||||||
"gitView.history.logSize50": "Розмір журналу 50",
|
"gitView.history.logSize50": "Розмір журналу 50",
|
||||||
"gitView.history.noCommits": "Комітів не знайдено",
|
"gitView.history.noCommits": "Комітів не знайдено",
|
||||||
"gitView.history.noFiles": "Немає файлів",
|
"gitView.history.noFiles": "Немає файлів",
|
||||||
|
"gitView.history.renamedNoDiff": "Перейменований файл — diff не підтримується",
|
||||||
|
"gitView.history.renderDiffAnyway": "Показати все одно",
|
||||||
"gitView.history.title": "Історія",
|
"gitView.history.title": "Історія",
|
||||||
"gitView.integrate.checking": "Перевірка…",
|
"gitView.integrate.checking": "Перевірка…",
|
||||||
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
|
"gitView.integrate.cherryPickAbortedToast": "Cherry-pick перервано",
|
||||||
|
|||||||
@@ -484,14 +484,21 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'gitView.header.removeRemoteAria': '移除远程 {name}',
|
'gitView.header.removeRemoteAria': '移除远程 {name}',
|
||||||
'gitView.header.removeRemoteTitle': '移除 {name}',
|
'gitView.header.removeRemoteTitle': '移除 {name}',
|
||||||
'gitView.history.binary': '二进制',
|
'gitView.history.binary': '二进制',
|
||||||
|
'gitView.history.binaryNoDiff': '二进制文件 — 无可用 diff',
|
||||||
'gitView.history.commitsPlaceholder': '提交数',
|
'gitView.history.commitsPlaceholder': '提交数',
|
||||||
'gitView.history.copySha': '复制 SHA',
|
'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.loadingFiles': '正在加载文件...',
|
||||||
'gitView.history.logSize100': '100 个提交',
|
'gitView.history.logSize100': '100 个提交',
|
||||||
'gitView.history.logSize25': '25 个提交',
|
'gitView.history.logSize25': '25 个提交',
|
||||||
'gitView.history.logSize50': '50 个提交',
|
'gitView.history.logSize50': '50 个提交',
|
||||||
'gitView.history.noCommits': '未找到提交',
|
'gitView.history.noCommits': '未找到提交',
|
||||||
'gitView.history.noFiles': '没有文件',
|
'gitView.history.noFiles': '没有文件',
|
||||||
|
'gitView.history.renamedNoDiff': '已重命名文件 — 不支持 diff',
|
||||||
|
'gitView.history.renderDiffAnyway': '仍然渲染',
|
||||||
'gitView.history.title': '历史',
|
'gitView.history.title': '历史',
|
||||||
'gitView.integrate.checking': '检查中…',
|
'gitView.integrate.checking': '检查中…',
|
||||||
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
'gitView.integrate.cherryPickAbortedToast': '已中止 cherry-pick',
|
||||||
|
|||||||
@@ -416,6 +416,23 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput
|
|||||||
return { id, type, success: true, data: result };
|
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': {
|
case 'api:git/identity': {
|
||||||
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
const { directory, method, userName, userEmail, sshKey } = (payload || {}) as {
|
||||||
directory?: string;
|
directory?: string;
|
||||||
|
|||||||
@@ -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 ==============
|
// ============== Repository Operations ==============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2631,31 +2652,84 @@ export async function getCommitFiles(
|
|||||||
directory: string,
|
directory: string,
|
||||||
hash: string
|
hash: string
|
||||||
): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> {
|
): Promise<{ files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> }> {
|
||||||
const result = await execGit(['show', '--numstat', '--format=', hash], directory);
|
const numstatResult = await execGit(['show', '--numstat', '--format=', hash], directory);
|
||||||
|
|
||||||
if (result.exitCode !== 0) {
|
if (numstatResult.exitCode !== 0) {
|
||||||
return { files: [] };
|
return { files: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = [];
|
const files: Array<{ path: string; insertions: number; deletions: number; isBinary: boolean; changeType: string }> = [];
|
||||||
|
const lines = numstatResult.stdout.trim().split('\n').filter(Boolean);
|
||||||
|
|
||||||
for (const line of result.stdout.trim().split('\n').filter(Boolean)) {
|
for (const line of lines) {
|
||||||
const parts = line.split('\t');
|
const parts = line.split('\t');
|
||||||
if (parts.length >= 3) {
|
if (parts.length < 3) continue;
|
||||||
const isBinary = parts[0] === '-' && parts[1] === '-';
|
|
||||||
files.push({
|
const [insertionsRaw, deletionsRaw, ...pathParts] = parts;
|
||||||
path: parts[2] || '',
|
const filePath = pathParts.join('\t');
|
||||||
insertions: isBinary ? 0 : parseInt(parts[0] || '0', 10),
|
if (!filePath) continue;
|
||||||
deletions: isBinary ? 0 : parseInt(parts[1] || '0', 10),
|
|
||||||
isBinary,
|
const isBinary = insertionsRaw === '-' && deletionsRaw === '-';
|
||||||
changeType: 'M', // Would need additional parsing for actual change type
|
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<string, string>();
|
||||||
|
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 };
|
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 ==============
|
// ============== Git Identity Operations ==============
|
||||||
|
|
||||||
export interface GitIdentitySummary {
|
export interface GitIdentitySummary {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
GitLogResponse,
|
GitLogResponse,
|
||||||
GitLogOptions,
|
GitLogOptions,
|
||||||
GitCommitFilesResponse,
|
GitCommitFilesResponse,
|
||||||
|
CommitFileDiffResponse,
|
||||||
GitIdentitySummary,
|
GitIdentitySummary,
|
||||||
GitIdentityProfile,
|
GitIdentityProfile,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
@@ -259,6 +260,15 @@ export const createVSCodeGitAPI = (): GitAPI => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getCommitFileDiff: async (directory: string, hash: string, filePath: string, isBinary: boolean): Promise<CommitFileDiffResponse> => {
|
||||||
|
return sendBridgeMessage<CommitFileDiffResponse>('api:git/commit-file-diff', {
|
||||||
|
directory,
|
||||||
|
hash,
|
||||||
|
path: filePath,
|
||||||
|
binary: isBinary,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
getCurrentGitIdentity: async (directory: string): Promise<GitIdentitySummary | null> => {
|
getCurrentGitIdentity: async (directory: string): Promise<GitIdentitySummary | null> => {
|
||||||
return sendBridgeMessage<GitIdentitySummary | null>('api:git/identity', {
|
return sendBridgeMessage<GitIdentitySummary | null>('api:git/identity', {
|
||||||
directory,
|
directory,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ The following functions are exported and used by the web server:
|
|||||||
### Log Operations
|
### Log Operations
|
||||||
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
|
- `getLog(directory, options)`: Get commit history with stats (supports maxCount, from, to, file filters).
|
||||||
- `getCommitFiles(directory, commitHash)`: Get file changes for a specific commit.
|
- `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 <hash>^:<path>` and `git show <hash>:<path>` in parallel; returns empty strings on failure (added/deleted/root-commit edge cases).
|
||||||
|
|
||||||
### Merge and Rebase Operations
|
### Merge and Rebase Operations
|
||||||
- `rebase(directory, options)`: Start a rebase onto a target branch.
|
- `rebase(directory, options)`: Start a rebase onto a target branch.
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1611,6 +1611,27 @@ const parseIsBinaryFromNumstat = (raw) => {
|
|||||||
return added === '-' || deleted === '-';
|
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) => {
|
const looksBinaryBySniff = async (absolutePath) => {
|
||||||
try {
|
try {
|
||||||
const handle = await fsp.open(absolutePath, 'r');
|
const handle = await fsp.open(absolutePath, 'r');
|
||||||
@@ -3028,15 +3049,13 @@ export async function getCommitFiles(directory, commitHash) {
|
|||||||
for (const line of statusLines) {
|
for (const line of statusLines) {
|
||||||
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
const match = line.match(/^([AMDRC])\d*\t(.+)$/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const [, status, path] = match;
|
const [, status, pathPart] = match;
|
||||||
statusMap.set(path, status);
|
statusMap.set(extractGitStatusPath(status, pathPart), status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const basePath = file.path.includes(' => ')
|
const basePath = extractGitNumstatDestinationPath(file.path);
|
||||||
? file.path.split(' => ').pop()?.replace(/[{}]/g, '') || file.path
|
|
||||||
: file.path;
|
|
||||||
|
|
||||||
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
const status = statusMap.get(basePath) || statusMap.get(file.path);
|
||||||
if (status) {
|
if (status) {
|
||||||
@@ -3379,3 +3398,29 @@ export async function getConflictDetails(directory) {
|
|||||||
throw error;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user