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} +
+ ); + })} +
+
+ ); +});