From aeafc9c095c6b79d66df403ad433291cdc500f82 Mon Sep 17 00:00:00 2001 From: LABCAT Date: Wed, 9 Sep 2026 20:58:47 +1200 Subject: [PATCH 1/2] 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. --- packages/ui/src/components/views/DiffView.tsx | 48 +++++- .../src/components/views/git/HunkActions.tsx | 148 ++++++++++++++++++ 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/views/git/HunkActions.tsx diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index b6f47eb2..aa6b883c 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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(({ const [diffLoadError, setDiffLoadError] = React.useState(null); const [isLoading, setIsLoading] = React.useState(false); const [fileAction, setFileAction] = React.useState(null); + const [hunkAction, setHunkAction] = React.useState(null); const [forceRenderLarge, setForceRenderLarge] = React.useState(false); const [localDiffData, setLocalDiffData] = React.useState(null); const [stagedDiffData, setStagedDiffData] = React.useState(null); @@ -789,6 +791,38 @@ const MultiFileDiffEntry = React.memo(({ } }, [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 (
@@ -955,7 +989,17 @@ const MultiFileDiffEntry = React.memo(({ wrapLines={wrapLines} />
-
+
+ {!readOnlyActions && diffData?.patch ? ( + + ) : null} {!readOnlyActions ? ( 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(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 ( + + + + + + + {t('diffView.hunk.label')} · {filePath} + + + {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 ( +
+ + Hunk {displayIndex} + + {hunk.insertions > 0 ? ( + +{hunk.insertions} + ) : null} + {hunk.insertions > 0 && hunk.deletions > 0 ? ( + / + ) : null} + {hunk.deletions > 0 ? ( + -{hunk.deletions} + ) : null} + + + + {!staged ? ( + + ) : null} +
+ ); + })} +
+
+ ); +}); From 7f147568ea41a454ceca9ffedd287979456dbc9b Mon Sep 17 00:00:00 2001 From: LABCAT Date: Wed, 9 Sep 2026 21:43:18 +1200 Subject: [PATCH 2/2] fix(hunk-actions): use native menu items, drop hardcoded label - Render each hunk action as a DropdownMenuItem (Stage/Unstage/Discard hunk N with +/- counts) so Base UI keyboard navigation, highlight, Escape, and close-on-activate apply. Plain div/button rows are gone. - Row labels reuse the already-translated diffView.hunk.stageTitle / unstageTitle / discardTitle strings; no new i18n keys, no hardcoded English. - Disable the file-level actions while a hunk action is in flight for consistency (server serializes per directory regardless). --- packages/ui/src/components/views/DiffView.tsx | 2 +- .../src/components/views/git/HunkActions.tsx | 73 ++++++++++--------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index aa6b883c..e269b8cc 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1005,7 +1005,7 @@ const MultiFileDiffEntry = React.memo(({ filePath={file.path} staged={staged} busyAction={fileAction} - disabled={fileAction !== null} + disabled={fileAction !== null || hunkAction !== null} onAction={handleFileAction} /> ) : null} diff --git a/packages/ui/src/components/views/git/HunkActions.tsx b/packages/ui/src/components/views/git/HunkActions.tsx index 045062a9..612d7822 100644 --- a/packages/ui/src/components/views/git/HunkActions.tsx +++ b/packages/ui/src/components/views/git/HunkActions.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, @@ -27,7 +28,6 @@ interface HunkActionsProps { } interface HunkSummary { - patch: string; insertions: number; deletions: number; } @@ -41,9 +41,29 @@ const summarizeHunks = (patch: string): HunkSummary[] => if (line.startsWith('+')) insertions += 1; else if (line.startsWith('-')) deletions += 1; } - return { patch: hunkPatch, insertions, deletions }; + return { insertions, deletions }; }); +const HunkCounts = React.memo<{ insertions: number; deletions: number }>(function HunkCounts({ + insertions, + deletions, +}) { + if (insertions === 0 && deletions === 0) return null; + return ( + + {insertions > 0 ? ( + +{insertions} + ) : null} + {insertions > 0 && deletions > 0 ? ( + / + ) : null} + {deletions > 0 ? ( + -{deletions} + ) : null} + + ); +}); + export const HunkActions = React.memo(function HunkActions({ filePath, patch, @@ -84,51 +104,31 @@ export const HunkActions = React.memo(function HunkActions({ 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 ( -
- - Hunk {displayIndex} - - {hunk.insertions > 0 ? ( - +{hunk.insertions} - ) : null} - {hunk.insertions > 0 && hunk.deletions > 0 ? ( - / - ) : null} - {hunk.deletions > 0 ? ( - -{hunk.deletions} - ) : null} - - - + {primaryTitle} + + {!staged ? ( - + {discardTitle} + ) : null} -
+ ); })}