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 type { DiffViewMode } from '@/components/chat/message/types';
|
||||||
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
|
||||||
import { PierreDiffViewer } from './PierreDiffViewer';
|
import { PierreDiffViewer } from './PierreDiffViewer';
|
||||||
|
import { HunkActions, type HunkBusyState, type HunkDiffAction } from './git/HunkActions';
|
||||||
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";
|
||||||
@@ -43,7 +44,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
|
|||||||
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
import { findDiffScrollAnchor, getRestoredDiffScrollTop, type DiffScrollAnchor } from './diffScrollAnchor';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { I18nKey } from '@/lib/i18n/store';
|
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 { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
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 [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 [fileAction, setFileAction] = React.useState<FileDiffAction | null>(null);
|
||||||
|
const [hunkAction, setHunkAction] = React.useState<HunkBusyState>(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);
|
||||||
@@ -789,6 +791,38 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
}
|
}
|
||||||
}, [directory, fetchStatus, file.path, fileAction, git, t]);
|
}, [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 (
|
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-30 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">
|
||||||
@@ -955,7 +989,17 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
|||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
/>
|
/>
|
||||||
<div className="pointer-events-none absolute bottom-3 right-3 z-20">
|
<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 ? (
|
{!readOnlyActions ? (
|
||||||
<FileDiffActions
|
<FileDiffActions
|
||||||
filePath={file.path}
|
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