From aeafc9c095c6b79d66df403ad433291cdc500f82 Mon Sep 17 00:00:00 2001 From: LABCAT Date: Wed, 9 Sep 2026 20:58:47 +1200 Subject: [PATCH 1/3] 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/3] 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} -
+ ); })} From 0e8ee318168785ec432e5e96547007b60ac07105 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 9 Sep 2026 19:39:28 +0300 Subject: [PATCH 3/3] test(stream): wait for the reconnect event The fixed 35ms sleep could finish before reconnect when workspace builds and test workers ran concurrently. Poll the actual ready frame and always close the runtime. The final workspace run passed: 429 UI, 40 VS Code, 23 Electron and 4 root test files; 2154 web tests passed with one existing platform skip. --- .../server/lib/event-stream/runtime.test.js | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/web/server/lib/event-stream/runtime.test.js b/packages/web/server/lib/event-stream/runtime.test.js index c8635112..3c2aa1bb 100644 --- a/packages/web/server/lib/event-stream/runtime.test.js +++ b/packages/web/server/lib/event-stream/runtime.test.js @@ -446,18 +446,18 @@ describe('message stream websocket runtime', () => { const socket = new FakeSocket(); runtime.wsServer.emit('connection', socket, { url: '/api/global/event/ws' }); - await new Promise((resolve) => setTimeout(resolve, 35)); - - const readyFrames = socket.sent.filter((frame) => frame.type === 'ready'); - const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); - - expect(readyFrames.length).toBeGreaterThanOrEqual(2); - expect(eventFrames.length).toBeGreaterThanOrEqual(2); - expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); - expect(triggerHealthCheckCalls).toBe(0); - - socket.close(); - await runtime.close(); + try { + // Wait for the reconnect event itself; a 35ms sleep raced the event + // loop when the workspace build and test workers ran together. + await expect.poll(() => socket.sent.filter((frame) => frame.type === 'ready').length).toBeGreaterThanOrEqual(2); + const eventFrames = socket.sent.filter((frame) => frame.type === 'event' && frame.payload?.type === 'server.connected'); + expect(eventFrames.length).toBeGreaterThanOrEqual(2); + expect(fetchCalls.slice(0, 2)).toEqual([null, 'evt-1']); + expect(triggerHealthCheckCalls).toBe(0); + } finally { + socket.close(); + await runtime.close(); + } }); it('keeps synthetic event processing on forwarded upstream events', async () => {