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:
LABCAT
2026-09-09 20:58:47 +12:00
parent bdda3c36bc
commit aeafc9c095
2 changed files with 194 additions and 2 deletions
+46 -2
View File
@@ -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>
);
});