Polish diff file actions
This commit is contained in:
@@ -1,158 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Icon } from '@/components/icon/Icon';
|
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
||||||
import { useI18n } from '@/lib/i18n';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { extractHunkPatch } from '@/lib/diff/patchFileDiff';
|
|
||||||
|
|
||||||
type HunkAction = 'stage' | 'unstage' | 'discard';
|
|
||||||
|
|
||||||
interface DiffHunkActionsProps {
|
|
||||||
patch: string;
|
|
||||||
fileDiff: FileDiffMetadata | undefined;
|
|
||||||
directory: string;
|
|
||||||
filePath: string;
|
|
||||||
staged: boolean;
|
|
||||||
onApplied: (action: HunkAction) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DiffHunkActions = React.memo<DiffHunkActionsProps>(({
|
|
||||||
patch,
|
|
||||||
fileDiff,
|
|
||||||
directory,
|
|
||||||
filePath,
|
|
||||||
staged,
|
|
||||||
onApplied,
|
|
||||||
}) => {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const { git } = useRuntimeAPIs();
|
|
||||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
|
|
||||||
const hunks = fileDiff?.hunks;
|
|
||||||
if (!hunks || hunks.length === 0 || !patch) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = async (hunkIndex: number, action: HunkAction) => {
|
|
||||||
const hunkPatch = extractHunkPatch(patch, hunkIndex);
|
|
||||||
if (!hunkPatch) {
|
|
||||||
setError(t('diffView.hunk.unavailable'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${hunkIndex}:${action}`;
|
|
||||||
setBusyKey(key);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
if (action === 'stage') {
|
|
||||||
if (!git.stageGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
|
||||||
await git.stageGitHunk(directory, filePath, hunkPatch);
|
|
||||||
} else if (action === 'unstage') {
|
|
||||||
if (!git.unstageGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
|
||||||
await git.unstageGitHunk(directory, filePath, hunkPatch);
|
|
||||||
} else {
|
|
||||||
if (!git.revertGitHunk) throw new Error(t('diffView.hunk.unsupported'));
|
|
||||||
await git.revertGitHunk(directory, filePath, hunkPatch);
|
|
||||||
}
|
|
||||||
onApplied(action);
|
|
||||||
} catch (actionError) {
|
|
||||||
setError(actionError instanceof Error ? actionError.message : String(actionError));
|
|
||||||
} finally {
|
|
||||||
setBusyKey((current) => (current === key ? null : current));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 border-b border-[var(--interactive-border)]/40 bg-[var(--surface-elevated)]/40 px-3 py-1.5">
|
|
||||||
<div className="flex items-center gap-1.5 overflow-x-auto">
|
|
||||||
<span className="typography-micro shrink-0 text-muted-foreground uppercase">
|
|
||||||
{t('diffView.hunk.label')}
|
|
||||||
</span>
|
|
||||||
{hunks.map((hunk, index) => {
|
|
||||||
const additions = hunk.additionLines;
|
|
||||||
const deletions = hunk.deletionLines;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex shrink-0 items-center gap-1 rounded-md border border-[var(--interactive-border)]/50 bg-background/60 px-1.5 py-0.5"
|
|
||||||
>
|
|
||||||
<span className="typography-micro font-semibold text-muted-foreground">
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
{additions > 0 ? (
|
|
||||||
<span className="typography-micro" style={{ color: 'var(--status-success)' }}>
|
|
||||||
+{additions}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{deletions > 0 ? (
|
|
||||||
<span className="typography-micro" style={{ color: 'var(--status-error)' }}>
|
|
||||||
−{deletions}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{staged ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
className="h-5 gap-1 px-1.5"
|
|
||||||
disabled={busyKey !== null}
|
|
||||||
onClick={() => void run(index, 'unstage')}
|
|
||||||
title={t('diffView.hunk.unstageTitle', { index: index + 1 })}
|
|
||||||
>
|
|
||||||
{busyKey === `${index}:unstage` ? (
|
|
||||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon name="arrow-go-back" className="size-3" />
|
|
||||||
)}
|
|
||||||
{t('diffView.hunk.unstage')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
className="h-5 gap-1 px-1.5"
|
|
||||||
disabled={busyKey !== null}
|
|
||||||
onClick={() => void run(index, 'stage')}
|
|
||||||
title={t('diffView.hunk.stageTitle', { index: index + 1 })}
|
|
||||||
>
|
|
||||||
{busyKey === `${index}:stage` ? (
|
|
||||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon name="add" className="size-3" />
|
|
||||||
)}
|
|
||||||
{t('diffView.hunk.stage')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
className="h-5 gap-1 px-1.5 text-muted-foreground hover:text-[var(--status-error)]"
|
|
||||||
disabled={busyKey !== null}
|
|
||||||
onClick={() => void run(index, 'discard')}
|
|
||||||
title={t('diffView.hunk.discardTitle', { index: index + 1 })}
|
|
||||||
>
|
|
||||||
{busyKey === `${index}:discard` ? (
|
|
||||||
<Icon name="loader-4" className="size-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon name="close" className="size-3" />
|
|
||||||
)}
|
|
||||||
{t('diffView.hunk.discard')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{error ? (
|
|
||||||
<div className="flex items-center gap-1.5 typography-meta" style={{ color: 'var(--status-error)' }}>
|
|
||||||
<Icon name="error-warning" className="size-3.5 shrink-0" />
|
|
||||||
<span className={cn('min-w-0')}>{error}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -26,7 +26,6 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|||||||
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
||||||
import type { DiffViewMode } from '@/components/chat/message/types';
|
import type { DiffViewMode } from '@/components/chat/message/types';
|
||||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||||
import { DiffHunkActions } from './DiffHunkActions';
|
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
@@ -394,9 +393,6 @@ interface InlineDiffViewerProps {
|
|||||||
diff: DiffData;
|
diff: DiffData;
|
||||||
renderSideBySide: boolean;
|
renderSideBySide: boolean;
|
||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
directory: string;
|
|
||||||
staged: boolean;
|
|
||||||
onHunkApplied: (action: 'stage' | 'unstage' | 'discard') => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
||||||
@@ -404,9 +400,6 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
|||||||
diff,
|
diff,
|
||||||
renderSideBySide,
|
renderSideBySide,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
directory,
|
|
||||||
staged,
|
|
||||||
onHunkApplied,
|
|
||||||
}) => {
|
}) => {
|
||||||
const language = React.useMemo(
|
const language = React.useMemo(
|
||||||
() => getLanguageFromExtension(filePath) || 'text',
|
() => getLanguageFromExtension(filePath) || 'text',
|
||||||
@@ -429,16 +422,6 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full" style={{ contain: 'layout' }}>
|
<div className="w-full" style={{ contain: 'layout' }}>
|
||||||
{diff.patch && diff.fileDiff ? (
|
|
||||||
<DiffHunkActions
|
|
||||||
patch={diff.patch}
|
|
||||||
fileDiff={diff.fileDiff}
|
|
||||||
directory={directory}
|
|
||||||
filePath={filePath}
|
|
||||||
staged={staged}
|
|
||||||
onApplied={onHunkApplied}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<PierreDiffViewer
|
<PierreDiffViewer
|
||||||
original={diff.original}
|
original={diff.original}
|
||||||
modified={diff.modified}
|
modified={diff.modified}
|
||||||
@@ -453,6 +436,99 @@ const InlineDiffViewer = React.memo<InlineDiffViewerProps>(({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type FileDiffAction = 'stage' | 'unstage' | 'discard';
|
||||||
|
|
||||||
|
interface FileDiffActionsProps {
|
||||||
|
filePath: string;
|
||||||
|
staged: boolean;
|
||||||
|
busyAction: FileDiffAction | null;
|
||||||
|
disabled: boolean;
|
||||||
|
onAction: (action: FileDiffAction) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileDiffActions = React.memo<FileDiffActionsProps>(({
|
||||||
|
filePath,
|
||||||
|
staged,
|
||||||
|
busyAction,
|
||||||
|
disabled,
|
||||||
|
onAction,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-1 py-0.5 shadow-lg backdrop-blur-md">
|
||||||
|
{staged ? (
|
||||||
|
<FileDiffActionButton
|
||||||
|
label={t('gitView.changes.unstageFileAria', { path: filePath })}
|
||||||
|
icon="arrow-go-back"
|
||||||
|
loading={busyAction === 'unstage'}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onAction('unstage')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileDiffActionButton
|
||||||
|
label={t('gitView.changes.revertFileAria', { path: filePath })}
|
||||||
|
icon="arrow-go-back"
|
||||||
|
loading={busyAction === 'discard'}
|
||||||
|
disabled={disabled}
|
||||||
|
tone="failure"
|
||||||
|
onClick={() => onAction('discard')}
|
||||||
|
/>
|
||||||
|
<FileDiffActionButton
|
||||||
|
label={t('gitView.changes.stageFileAria', { path: filePath })}
|
||||||
|
icon="add"
|
||||||
|
loading={busyAction === 'stage'}
|
||||||
|
disabled={disabled}
|
||||||
|
tone="success"
|
||||||
|
onClick={() => onAction('stage')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface FileDiffActionButtonProps {
|
||||||
|
label: string;
|
||||||
|
icon: 'add' | 'arrow-go-back';
|
||||||
|
loading: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
tone?: 'failure' | 'success';
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileDiffActionButton: React.FC<FileDiffActionButtonProps> = ({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
tone,
|
||||||
|
onClick,
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 rounded-none bg-transparent p-0 text-muted-foreground opacity-70 hover:bg-transparent hover:text-foreground hover:opacity-100',
|
||||||
|
tone === 'failure' && 'text-[var(--status-error)] hover:text-[var(--status-error)]',
|
||||||
|
tone === 'success' && 'text-[var(--status-success)] hover:text-[var(--status-success)]'
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon name={icon} className={icon === 'add' ? 'size-4' : 'size-3.5'} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
interface MultiFileDiffEntryProps {
|
interface MultiFileDiffEntryProps {
|
||||||
directory: string;
|
directory: string;
|
||||||
file: FileEntry;
|
file: FileEntry;
|
||||||
@@ -468,7 +544,6 @@ interface MultiFileDiffEntryProps {
|
|||||||
isOpeningInEditor?: boolean;
|
isOpeningInEditor?: boolean;
|
||||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||||
staged?: boolean;
|
staged?: boolean;
|
||||||
stagedRevision?: number;
|
|
||||||
loadFullFiles?: boolean;
|
loadFullFiles?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +562,6 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
isOpeningInEditor = false,
|
isOpeningInEditor = false,
|
||||||
onOpenInEditor,
|
onOpenInEditor,
|
||||||
staged = false,
|
staged = false,
|
||||||
stagedRevision = 0,
|
|
||||||
loadFullFiles = false,
|
loadFullFiles = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -504,6 +578,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
|
||||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
|
||||||
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||||
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
||||||
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
||||||
@@ -513,6 +588,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
const descriptor = React.useMemo(() => describeChange(file), [file]);
|
const descriptor = React.useMemo(() => describeChange(file), [file]);
|
||||||
const renderSideBySide = layout === 'side-by-side';
|
const renderSideBySide = layout === 'side-by-side';
|
||||||
const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch';
|
const desiredContextMode: DiffContextMode = loadFullFiles ? 'full' : 'patch';
|
||||||
|
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
|
||||||
|
|
||||||
const diffData = React.useMemo<DiffData | null>(() => {
|
const diffData = React.useMemo<DiffData | null>(() => {
|
||||||
if (staged) return stagedDiffData;
|
if (staged) return stagedDiffData;
|
||||||
@@ -545,7 +621,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
|
|
||||||
setDiffLoadError(null);
|
setDiffLoadError(null);
|
||||||
lastDiffRequestRef.current = null;
|
lastDiffRequestRef.current = null;
|
||||||
}, [staged, stagedRevision]);
|
}, [fileStatusKey, staged]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isExpanded || !isMounted) return;
|
if (!isExpanded || !isMounted) return;
|
||||||
@@ -555,7 +631,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestKey = `${directory}::${file.path}::${staged ? `staged:${stagedRevision}` : 'unstaged'}::${desiredContextMode}::${diffRetryNonce}`;
|
const requestKey = `${directory}::${file.path}::${staged ? 'staged' : 'unstaged'}::${fileStatusKey}::${desiredContextMode}::${diffRetryNonce}`;
|
||||||
if (lastDiffRequestRef.current === requestKey) {
|
if (lastDiffRequestRef.current === requestKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -612,28 +688,58 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
lastDiffRequestRef.current = null;
|
lastDiffRequestRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, git, isExpanded, isMounted, loadFullFiles, setDiff, staged, stagedRevision]);
|
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||||
|
|
||||||
const handleToggle = React.useCallback(() => {
|
const handleToggle = React.useCallback(() => {
|
||||||
handleOpenChange(!isExpanded);
|
handleOpenChange(!isExpanded);
|
||||||
handleSelect();
|
handleSelect();
|
||||||
}, [handleOpenChange, handleSelect, isExpanded]);
|
}, [handleOpenChange, handleSelect, isExpanded]);
|
||||||
|
|
||||||
const handleHunkApplied = React.useCallback(() => {
|
const handleFileAction = React.useCallback(async (action: FileDiffAction) => {
|
||||||
setDiffRetryNonce((nonce) => nonce + 1);
|
if (!directory || fileAction !== null) {
|
||||||
if (directory) {
|
return;
|
||||||
void fetchStatus(directory, git);
|
|
||||||
}
|
}
|
||||||
}, [directory, fetchStatus, git]);
|
|
||||||
|
setFileAction(action);
|
||||||
|
try {
|
||||||
|
if (action === 'stage') {
|
||||||
|
await git.stageGitFile(directory, file.path);
|
||||||
|
} else if (action === 'unstage') {
|
||||||
|
await git.unstageGitFile(directory, file.path);
|
||||||
|
} else {
|
||||||
|
await git.revertGitFile(directory, file.path, { scope: 'working' });
|
||||||
|
}
|
||||||
|
setDiffRetryNonce((nonce) => nonce + 1);
|
||||||
|
await fetchStatus(directory, git);
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackKey = action === 'unstage'
|
||||||
|
? 'gitView.toast.unstageFileFailed'
|
||||||
|
: action === 'stage'
|
||||||
|
? 'gitView.toast.stageFileFailed'
|
||||||
|
: 'gitView.toast.revertFailed';
|
||||||
|
toast.error(error instanceof Error ? error.message : t(fallbackKey));
|
||||||
|
} finally {
|
||||||
|
setFileAction((current) => (current === action ? null : current));
|
||||||
|
}
|
||||||
|
}, [directory, fetchStatus, file.path, fileAction, git, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
||||||
<div className="sticky top-0 z-10 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
<div className="sticky top-0 z-30 border-b border-[var(--interactive-border)]/35 bg-[var(--surface-elevated)]/90 backdrop-blur-md supports-[backdrop-filter]:bg-[var(--surface-elevated)]/80">
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
handleToggle();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/header relative flex min-h-9 w-full items-center gap-2 overflow-hidden px-3 py-2',
|
'cursor-pointer',
|
||||||
|
'group/header relative grid min-h-9 w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 overflow-hidden px-3 py-2',
|
||||||
'bg-transparent',
|
'bg-transparent',
|
||||||
'text-muted-foreground hover:text-foreground',
|
'text-muted-foreground hover:text-foreground',
|
||||||
isSelected ? 'bg-[var(--interactive-selection)]/35' : null
|
isSelected ? 'bg-[var(--interactive-selection)]/35' : null
|
||||||
@@ -696,7 +802,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex shrink-0 items-center justify-self-end gap-2">
|
||||||
{formatDiffTotals(file.insertions, file.deletions)}
|
{formatDiffTotals(file.insertions, file.deletions)}
|
||||||
{showOpenInEditorAction && onOpenInEditor ? (
|
{showOpenInEditorAction && onOpenInEditor ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -727,7 +833,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
className="opacity-70"
|
className="opacity-70"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="relative bg-background overflow-hidden">
|
<div className="relative bg-background overflow-hidden">
|
||||||
@@ -775,15 +881,25 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isMounted && diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
{isMounted && diffData && (forceRenderLarge || (file.insertions + file.deletions) <= LARGE_DIFF_CHANGED_LINES) ? (
|
||||||
<InlineDiffViewer
|
<>
|
||||||
filePath={file.path}
|
<InlineDiffViewer
|
||||||
diff={diffData}
|
filePath={file.path}
|
||||||
renderSideBySide={renderSideBySide}
|
diff={diffData}
|
||||||
wrapLines={wrapLines}
|
renderSideBySide={renderSideBySide}
|
||||||
directory={directory}
|
wrapLines={wrapLines}
|
||||||
staged={staged}
|
/>
|
||||||
onHunkApplied={handleHunkApplied}
|
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||||
/>
|
<div className="pointer-events-auto">
|
||||||
|
<FileDiffActions
|
||||||
|
filePath={file.path}
|
||||||
|
staged={staged}
|
||||||
|
busyAction={fileAction}
|
||||||
|
disabled={fileAction !== null}
|
||||||
|
onAction={handleFileAction}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -825,11 +941,6 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
const ensureStatus = useGitStore((state) => state.ensureStatus);
|
||||||
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
const fetchStatus = useGitStore((state) => state.fetchStatus);
|
||||||
const setDiff = useGitStore((state) => state.setDiff);
|
const setDiff = useGitStore((state) => state.setDiff);
|
||||||
const indexRevision = useGitStore(React.useCallback((state) => {
|
|
||||||
if (!effectiveDirectory) return 0;
|
|
||||||
return state.directories.get(effectiveDirectory)?.indexRevision ?? 0;
|
|
||||||
}, [effectiveDirectory]));
|
|
||||||
|
|
||||||
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
const [displayFile, setDisplayFile] = React.useState<string | null>(null);
|
||||||
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
|
const [displayFileStaged, setDisplayFileStaged] = React.useState(false);
|
||||||
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
const [pinnedStackedTarget, setPinnedStackedTarget] = React.useState<string | null>(null);
|
||||||
@@ -859,6 +970,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
const pendingScrollFrameRef = React.useRef<number | null>(null);
|
const pendingScrollFrameRef = React.useRef<number | null>(null);
|
||||||
const shouldPinAfterAlignRef = React.useRef(false);
|
const shouldPinAfterAlignRef = React.useRef(false);
|
||||||
const visibleSyncFrameRef = React.useRef<number | null>(null);
|
const visibleSyncFrameRef = React.useRef<number | null>(null);
|
||||||
|
const stackedStateScopeRef = React.useRef<string | null>(null);
|
||||||
|
|
||||||
const cancelPendingScrollAlignment = React.useCallback(() => {
|
const cancelPendingScrollAlignment = React.useCallback(() => {
|
||||||
pendingScrollTargetRef.current = null;
|
pendingScrollTargetRef.current = null;
|
||||||
@@ -918,13 +1030,48 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
||||||
const defaultExpandedCount = stackedDefaultCollapsedAll
|
const pathSet = new Set(paths);
|
||||||
? 0
|
const scopeKey = `${effectiveDirectory ?? ''}:${diffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
||||||
: getStackedViewDefaultExpandedCount(paths.length);
|
const shouldInitialize = stackedStateScopeRef.current !== scopeKey;
|
||||||
const defaultExpanded = new Set(paths.slice(0, defaultExpandedCount));
|
stackedStateScopeRef.current = scopeKey;
|
||||||
setExpandedFiles(defaultExpanded);
|
|
||||||
setMountedStackedFiles(new Set());
|
setExpandedFiles((previous) => {
|
||||||
}, [changedFilePathsKey, stackedDefaultCollapsedAll]);
|
if (shouldInitialize) {
|
||||||
|
const defaultExpandedCount = stackedDefaultCollapsedAll
|
||||||
|
? 0
|
||||||
|
: getStackedViewDefaultExpandedCount(paths.length);
|
||||||
|
return new Set(paths.slice(0, defaultExpandedCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const path of previous) {
|
||||||
|
if (!pathSet.has(path)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
return changed ? next : previous;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMountedStackedFiles((previous) => {
|
||||||
|
if (shouldInitialize) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const path of previous) {
|
||||||
|
if (!pathSet.has(path)) {
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.add(path);
|
||||||
|
}
|
||||||
|
return changed ? next : previous;
|
||||||
|
});
|
||||||
|
}, [changedFilePathsKey, diffScope, effectiveDirectory, stackedDefaultCollapsedAll]);
|
||||||
|
|
||||||
const syncVisibleStackedFiles = React.useCallback(() => {
|
const syncVisibleStackedFiles = React.useCallback(() => {
|
||||||
visibleSyncFrameRef.current = null;
|
visibleSyncFrameRef.current = null;
|
||||||
@@ -1314,41 +1461,42 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
<ScrollableOverlay
|
<div className="relative flex-1 min-h-0 h-full">
|
||||||
ref={diffScrollRef}
|
<ScrollableOverlay
|
||||||
outerClassName="flex-1 min-h-0 h-full"
|
ref={diffScrollRef}
|
||||||
className="[overflow-anchor:none]"
|
outerClassName="min-h-0 h-full"
|
||||||
disableHorizontal
|
className="[overflow-anchor:none] pb-16"
|
||||||
observeMutations={false}
|
disableHorizontal
|
||||||
preventOverscroll
|
observeMutations={false}
|
||||||
data-diff-virtual-root
|
preventOverscroll
|
||||||
>
|
data-diff-virtual-root
|
||||||
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
|
>
|
||||||
{changedFiles.map((file) => (
|
<div className="flex flex-col [overflow-anchor:none]" data-diff-virtual-content>
|
||||||
<MultiFileDiffEntry
|
{changedFiles.map((file) => (
|
||||||
key={`${getFileStaged(file.path) ? 'staged' : 'unstaged'}:${file.path}`}
|
<MultiFileDiffEntry
|
||||||
directory={effectiveDirectory}
|
key={file.path}
|
||||||
file={file}
|
directory={effectiveDirectory}
|
||||||
layout={getLayoutForFile(file)}
|
file={file}
|
||||||
wrapLines={diffWrapLines}
|
layout={getLayoutForFile(file)}
|
||||||
isSelected={false}
|
wrapLines={diffWrapLines}
|
||||||
isExpanded={expandedFiles.has(file.path)}
|
isSelected={false}
|
||||||
isMounted={mountedStackedFiles.has(file.path) || file.path === pinnedStackedTarget}
|
isExpanded={expandedFiles.has(file.path)}
|
||||||
onSelect={handleSelectFile}
|
isMounted={mountedStackedFiles.has(file.path) || file.path === pinnedStackedTarget}
|
||||||
onExpandedChange={handleStackedEntryExpandedChange}
|
onSelect={handleSelectFile}
|
||||||
registerSectionRef={registerSectionRef}
|
onExpandedChange={handleStackedEntryExpandedChange}
|
||||||
showOpenInEditorAction={showOpenInEditorAction}
|
registerSectionRef={registerSectionRef}
|
||||||
isOpeningInEditor={openingEditorFilePath === file.path}
|
showOpenInEditorAction={showOpenInEditorAction}
|
||||||
onOpenInEditor={(filePath, diffData) => {
|
isOpeningInEditor={openingEditorFilePath === file.path}
|
||||||
void openFileInEditorAtChange(filePath, diffData);
|
onOpenInEditor={(filePath, diffData) => {
|
||||||
}}
|
void openFileInEditorAtChange(filePath, diffData);
|
||||||
staged={getFileStaged(file.path)}
|
}}
|
||||||
stagedRevision={indexRevision}
|
staged={getFileStaged(file.path)}
|
||||||
loadFullFiles={loadFullFiles}
|
loadFullFiles={loadFullFiles}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1099,9 +1099,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("relative", "w-full")}>
|
<div className={cn("relative", "w-full")}>
|
||||||
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
<div ref={diffRootRef} className="pierre-diff-wrapper w-full overflow-x-auto overflow-y-visible relative">
|
||||||
<div ref={diffContainerRef} className="w-full" />
|
<div ref={diffContainerRef} className="w-full" />
|
||||||
</div>
|
|
||||||
{commentOverlays}
|
|
||||||
</div>
|
</div>
|
||||||
|
{commentOverlays}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2327,6 +2327,67 @@ const HUNK_ACTION_ARGS: Record<'stage' | 'unstage' | 'discard', string[]> = {
|
|||||||
discard: ['--reverse'],
|
discard: ['--reverse'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsePatchPathToken = (line: string): string | null => {
|
||||||
|
const value = String(line || '').replace(/^(?:-{3}|\+{3})\s+/, '');
|
||||||
|
if (!value || value === '/dev/null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('"')) {
|
||||||
|
let token = '"';
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = 1; index < value.length; index += 1) {
|
||||||
|
const char = value[index];
|
||||||
|
token += char;
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === '"') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(token) as string;
|
||||||
|
} catch {
|
||||||
|
return token.slice(1, token.endsWith('"') ? -1 : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.split('\t', 1)[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePatchTargetPath = (value: string | null): string | null => {
|
||||||
|
if (!value || value === '/dev/null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.replace(/^[ab]\//, '').replace(/\\/g, '/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractPatchTargetPath = (patch: string): string | null => {
|
||||||
|
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||||
|
const realTargets = matches
|
||||||
|
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0] ?? '')))
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
return realTargets[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRepoRelativePath = async (directory: string, filePath: string): Promise<string> => {
|
||||||
|
const normalizedFilePath = normalizePath(filePath).replace(/\\/g, '/');
|
||||||
|
if (!path.isAbsolute(normalizedFilePath)) {
|
||||||
|
return normalizedFilePath.replace(/^\.?\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootResult = await execGit(['rev-parse', '--show-toplevel'], directory);
|
||||||
|
if (rootResult.exitCode !== 0) {
|
||||||
|
throw new Error(rootResult.stderr || 'Failed to resolve repository root');
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = normalizePath(rootResult.stdout.trim());
|
||||||
|
return path.relative(repoRoot, normalizedFilePath).replace(/\\/g, '/');
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a single-hunk patch to stage, unstage, or discard it.
|
* Apply a single-hunk patch to stage, unstage, or discard it.
|
||||||
* The patch is written to a temp file and applied with `git apply`.
|
* The patch is written to a temp file and applied with `git apply`.
|
||||||
@@ -2337,6 +2398,9 @@ export async function applyGitHunk(
|
|||||||
patch: string,
|
patch: string,
|
||||||
action: 'stage' | 'unstage' | 'discard',
|
action: 'stage' | 'unstage' | 'discard',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!HUNK_ACTION_ARGS[action]) {
|
||||||
|
throw new Error('Invalid hunk action');
|
||||||
|
}
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
throw new Error('path is required');
|
throw new Error('path is required');
|
||||||
}
|
}
|
||||||
@@ -2347,6 +2411,12 @@ export async function applyGitHunk(
|
|||||||
throw new Error('patch does not contain a hunk header');
|
throw new Error('patch does not contain a hunk header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const repoRelativePath = await getRepoRelativePath(directory, filePath);
|
||||||
|
const targetPath = extractPatchTargetPath(patch);
|
||||||
|
if (targetPath && targetPath !== repoRelativePath && targetPath !== filePath.replace(/\\/g, '/')) {
|
||||||
|
throw new Error('patch target path does not match the requested file');
|
||||||
|
}
|
||||||
|
|
||||||
const flags = HUNK_ACTION_ARGS[action];
|
const flags = HUNK_ACTION_ARGS[action];
|
||||||
const tmpDir = os.tmpdir();
|
const tmpDir = os.tmpdir();
|
||||||
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
const tmpPath = path.join(tmpDir, `openchamber-hunk-${Date.now()}-${Math.random().toString(36).slice(2)}.patch`);
|
||||||
|
|||||||
@@ -2561,11 +2561,49 @@ const HUNK_ACTION_FLAGS = {
|
|||||||
discard: ['--reverse'],
|
discard: ['--reverse'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsePatchPathToken = (line) => {
|
||||||
|
const value = String(line || '').replace(/^(?:-{3}|\+{3})\s+/, '');
|
||||||
|
if (!value || value === '/dev/null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.startsWith('"')) {
|
||||||
|
let token = '"';
|
||||||
|
let escaped = false;
|
||||||
|
for (let index = 1; index < value.length; index += 1) {
|
||||||
|
const char = value[index];
|
||||||
|
token += char;
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === '\\') {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === '"') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(token);
|
||||||
|
} catch {
|
||||||
|
return token.slice(1, token.endsWith('"') ? -1 : undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.split('\t', 1)[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePatchTargetPath = (value) => {
|
||||||
|
if (!value || value === '/dev/null') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.replace(/^[ab]\//, '');
|
||||||
|
};
|
||||||
|
|
||||||
const extractPatchTargetPath = (patch) => {
|
const extractPatchTargetPath = (patch) => {
|
||||||
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+(?:[ab]\/)?([^\s\t]+)/gm)];
|
const matches = [...patch.matchAll(/^(?:-{3}|\+{3})\s+.+$/gm)];
|
||||||
const realTargets = matches
|
const realTargets = matches
|
||||||
.map((match) => match[1])
|
.map((match) => normalizePatchTargetPath(parsePatchPathToken(match[0])))
|
||||||
.filter((value) => value && value !== '/dev/null');
|
.filter(Boolean);
|
||||||
return realTargets[0] || null;
|
return realTargets[0] || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,25 @@ describe('applyHunk', () => {
|
|||||||
'patch target path does not match'
|
'patch target path does not match'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts hunk patches for files with spaces in their path', async () => {
|
||||||
|
if (!canRunGit()) return;
|
||||||
|
const { tmpDir, git } = await createTempRepo();
|
||||||
|
const filePath = 'file name.txt';
|
||||||
|
await writeFile(tmpDir, filePath, ORIGINAL_FILE);
|
||||||
|
await git.add(filePath);
|
||||||
|
await git.commit('Initial');
|
||||||
|
|
||||||
|
await writeFile(tmpDir, filePath, EDITED_FILE);
|
||||||
|
const diff = await getDiff(tmpDir, { path: filePath });
|
||||||
|
const hunks = splitHunks(diff);
|
||||||
|
expect(hunks.length).toBe(2);
|
||||||
|
|
||||||
|
await applyHunk(tmpDir, filePath, { patch: hunks[0], action: 'stage' });
|
||||||
|
|
||||||
|
const staged = (await git.raw(['show', `:${filePath}`])).replace(/\r\n/g, '\n');
|
||||||
|
expect(staged).toBe(makeFile('TOP', 'line20'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user