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).
This commit is contained in:
LABCAT
2026-09-09 21:43:18 +12:00
parent aeafc9c095
commit 7f147568ea
2 changed files with 38 additions and 37 deletions
@@ -1005,7 +1005,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
filePath={file.path}
staged={staged}
busyAction={fileAction}
disabled={fileAction !== null}
disabled={fileAction !== null || hunkAction !== null}
onAction={handleFileAction}
/>
) : null}
@@ -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 (
<span className="ml-auto pl-3 typography-micro">
{insertions > 0 ? (
<span style={{ color: 'var(--status-success)' }}>+{insertions}</span>
) : null}
{insertions > 0 && deletions > 0 ? (
<span className="mx-0.5 text-muted-foreground">/</span>
) : null}
{deletions > 0 ? (
<span style={{ color: 'var(--status-error)' }}>-{deletions}</span>
) : null}
</span>
);
});
export const HunkActions = React.memo<HunkActionsProps>(function HunkActions({
filePath,
patch,
@@ -84,51 +104,31 @@ export const HunkActions = React.memo<HunkActionsProps>(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 (
<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"
<React.Fragment key={index}>
{index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
disabled={disabled || busyDiscard}
onSelect={() => onAction(index, primaryAction)}
aria-label={primaryTitle}
title={primaryTitle}
>
{busyPrimary ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : (
<Icon name="add" className="size-3.5" />
<Icon name={staged ? 'arrow-go-back' : 'add'} className="size-3.5" />
)}
</button>
<span className="min-w-0 flex-1 truncate">{primaryTitle}</span>
<HunkCounts insertions={hunk.insertions} deletions={hunk.deletions} />
</DropdownMenuItem>
{!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"
<DropdownMenuItem
disabled={disabled || busyPrimary}
onSelect={() => onAction(index, 'discard')}
aria-label={discardTitle}
title={discardTitle}
>
@@ -137,9 +137,10 @@ export const HunkActions = React.memo<HunkActionsProps>(function HunkActions({
) : (
<Icon name="arrow-go-back" className="size-3.5" />
)}
</button>
<span className="min-w-0 flex-1 truncate">{discardTitle}</span>
</DropdownMenuItem>
) : null}
</div>
</React.Fragment>
);
})}
</DropdownMenuContent>