feat(git): per-hunk stage, unstage, and discard actions in Changes view
Implements the hunk-level actions promised by the 1.13.0 changelog entry. Each expanded file diff with 2+ hunks gets a Hunks menu (next to the file actions) listing every hunk with its +/- counts and Stage/Unstage + Discard buttons, wired to the existing stageGitHunk/unstageGitHunk/ revertGitHunk API and POST /api/git/apply-hunk backend.
This commit is contained in:
@@ -34,6 +34,7 @@ import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle';
|
||||
import type { DiffViewMode } from '@/components/chat/message/types';
|
||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
@@ -43,7 +44,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { I18nKey } from '@/lib/i18n/store';
|
||||
import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { fileDiffFromPatch, extractHunkPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
@@ -643,6 +644,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
|
||||
const [hunkAction, setHunkAction] = React.useState<HunkBusyState>(null);
|
||||
const [forceRenderLarge, setForceRenderLarge] = React.useState(false);
|
||||
const [localDiffData, setLocalDiffData] = React.useState<DiffData | null>(null);
|
||||
const [stagedDiffData, setStagedDiffData] = React.useState<DiffData | null>(null);
|
||||
@@ -789,6 +791,38 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
}
|
||||
}, [directory, fetchStatus, file.path, fileAction, git, t]);
|
||||
|
||||
const handleHunkAction = React.useCallback(async (hunkIndex: number, action: HunkDiffAction) => {
|
||||
if (!directory || hunkAction !== null || fileAction !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hunkPatch = diffData?.patch ? extractHunkPatch(diffData.patch, hunkIndex) : null;
|
||||
if (!hunkPatch) {
|
||||
toast.error(t('diffView.hunk.unavailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
setHunkAction({ index: hunkIndex, action });
|
||||
try {
|
||||
const hunkMutation = action === 'stage'
|
||||
? git.stageGitHunk
|
||||
: action === 'unstage'
|
||||
? git.unstageGitHunk
|
||||
: git.revertGitHunk;
|
||||
if (!hunkMutation) {
|
||||
toast.error(t('diffView.hunk.unsupported'));
|
||||
return;
|
||||
}
|
||||
await hunkMutation(directory, file.path, hunkPatch);
|
||||
setDiffRetryNonce((nonce) => nonce + 1);
|
||||
await fetchStatus(directory, git);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error && error.message ? error.message : t('diffView.hunk.unavailable'));
|
||||
} finally {
|
||||
setHunkAction((current) => (current?.index === hunkIndex && current.action === action ? null : current));
|
||||
}
|
||||
}, [directory, diffData, fetchStatus, file.path, fileAction, git, hunkAction, t]);
|
||||
|
||||
return (
|
||||
<div ref={setSectionRef} className="scroll-mt-9 border-b border-[var(--interactive-border)]/40 last:border-b-0">
|
||||
<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">
|
||||
@@ -955,7 +989,17 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
wrapLines={wrapLines}
|
||||
/>
|
||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
||||
<div className="pointer-events-auto">
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
{!readOnlyActions && diffData?.patch ? (
|
||||
<HunkActions
|
||||
filePath={file.path}
|
||||
patch={diffData.patch}
|
||||
staged={staged}
|
||||
busyHunk={hunkAction}
|
||||
disabled={fileAction !== null || hunkAction !== null}
|
||||
onAction={handleHunkAction}
|
||||
/>
|
||||
) : null}
|
||||
{!readOnlyActions ? (
|
||||
<FileDiffActions
|
||||
filePath={file.path}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { splitPatchIntoHunks } from '@/lib/diff/patchFileDiff';
|
||||
|
||||
export type HunkDiffAction = 'stage' | 'unstage' | 'discard';
|
||||
|
||||
export type HunkBusyState = {
|
||||
index: number;
|
||||
action: HunkDiffAction;
|
||||
} | null;
|
||||
|
||||
interface HunkActionsProps {
|
||||
filePath: string;
|
||||
patch: string;
|
||||
staged: boolean;
|
||||
busyHunk: HunkBusyState;
|
||||
disabled: boolean;
|
||||
onAction: (hunkIndex: number, action: HunkDiffAction) => void;
|
||||
}
|
||||
|
||||
interface HunkSummary {
|
||||
patch: string;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
const summarizeHunks = (patch: string): HunkSummary[] =>
|
||||
splitPatchIntoHunks(patch).map((hunkPatch) => {
|
||||
let insertions = 0;
|
||||
let deletions = 0;
|
||||
for (const line of hunkPatch.split('\n')) {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
||||
if (line.startsWith('+')) insertions += 1;
|
||||
else if (line.startsWith('-')) deletions += 1;
|
||||
}
|
||||
return { patch: hunkPatch, insertions, deletions };
|
||||
});
|
||||
|
||||
export const HunkActions = React.memo<HunkActionsProps>(function HunkActions({
|
||||
filePath,
|
||||
patch,
|
||||
staged,
|
||||
busyHunk,
|
||||
disabled,
|
||||
onAction,
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const hunks = useMemo(() => summarizeHunks(patch), [patch]);
|
||||
|
||||
if (hunks.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryAction: HunkDiffAction = staged ? 'unstage' : 'stage';
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="flex h-6 shrink-0 items-center gap-1 rounded-full border border-[var(--interactive-border)]/45 bg-[var(--surface-background)]/95 px-2 typography-micro font-semibold text-muted-foreground shadow-sm backdrop-blur-md hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={t('diffView.hunk.label')}
|
||||
title={t('diffView.hunk.label')}
|
||||
>
|
||||
<Icon name="stack" className="size-3.5" />
|
||||
<span>{t('diffView.hunk.label')} · {hunks.length}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={6} className="w-64">
|
||||
<DropdownMenuLabel className="max-w-full truncate" title={filePath}>
|
||||
{t('diffView.hunk.label')} · {filePath}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{hunks.map((hunk, index) => {
|
||||
const displayIndex = index + 1;
|
||||
const busyPrimary = busyHunk?.index === index && busyHunk.action === primaryAction;
|
||||
const busyDiscard = busyHunk?.index === index && busyHunk.action === 'discard';
|
||||
const rowBusy = busyPrimary || busyDiscard;
|
||||
const primaryTitle = staged
|
||||
? t('diffView.hunk.unstageTitle', { index: displayIndex })
|
||||
: t('diffView.hunk.stageTitle', { index: displayIndex });
|
||||
const discardTitle = t('diffView.hunk.discardTitle', { index: displayIndex });
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 px-2 py-1.5"
|
||||
aria-label={primaryTitle}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
Hunk {displayIndex}
|
||||
<span className="ml-1.5 typography-micro">
|
||||
{hunk.insertions > 0 ? (
|
||||
<span style={{ color: 'var(--status-success)' }}>+{hunk.insertions}</span>
|
||||
) : null}
|
||||
{hunk.insertions > 0 && hunk.deletions > 0 ? (
|
||||
<span className="mx-0.5 text-muted-foreground">/</span>
|
||||
) : null}
|
||||
{hunk.deletions > 0 ? (
|
||||
<span style={{ color: 'var(--status-error)' }}>-{hunk.deletions}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || rowBusy}
|
||||
onClick={() => onAction(index, primaryAction)}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={primaryTitle}
|
||||
title={primaryTitle}
|
||||
>
|
||||
{busyPrimary ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="add" className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{!staged ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || rowBusy}
|
||||
onClick={() => onAction(index, 'discard')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={discardTitle}
|
||||
title={discardTitle}
|
||||
>
|
||||
{busyDiscard ? (
|
||||
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Icon name="arrow-go-back" className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user