feat(source-control): full-row hover, tooltips, and staged discard-all (#1407)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-04 15:43:14 -07:00 committed by GitHub
parent a595c1f84b
commit 9b3b2a5486
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 880 additions and 81 deletions

View File

@ -0,0 +1,151 @@
import { describe, expect, it, vi } from 'vitest'
import { Plus } from 'lucide-react'
import { ActionButton } from './SourceControl'
import { Button } from '@/components/ui/button'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findInnerButton(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.type === Button) {
found = entry
}
})
if (!found) {
throw new Error('inner Button not found')
}
return found
}
function findTooltipContentText(node: unknown): string {
const texts: string[] = []
visit(node, (entry) => {
const typeName =
typeof entry.type === 'function' || typeof entry.type === 'object'
? ((entry.type as { displayName?: string; name?: string }).displayName ??
(entry.type as { displayName?: string; name?: string }).name ??
'')
: ''
if (typeName === 'TooltipContent') {
const children = entry.props?.children
if (typeof children === 'string') {
texts.push(children)
}
}
})
return texts.join(' ')
}
// Why: ActionButton's onClick handler only touches these three fields, so a
// narrow event type is both correct and avoids a forbidden `as unknown as X`
// double-cast on the event value.
type MinimalMouseEvent = Pick<
React.MouseEvent,
'preventDefault' | 'stopPropagation' | 'defaultPrevented'
>
function makeClickEvent(): {
event: MinimalMouseEvent
preventDefault: ReturnType<typeof vi.fn>
} {
const preventDefault = vi.fn()
const event: MinimalMouseEvent = {
preventDefault,
stopPropagation: vi.fn(),
defaultPrevented: false
}
return { event, preventDefault }
}
const baseProps = {
icon: Plus,
title: 'Stage all',
onClick: vi.fn()
}
describe('ActionButton', () => {
it('forwards the title as aria-label on the inner button', () => {
const element = ActionButton({ ...baseProps, onClick: vi.fn() })
const button = findInnerButton(element)
expect(button.props['aria-label']).toBe('Stage all')
})
it('renders the title as tooltip content (non-native, Radix)', () => {
const element = ActionButton({ ...baseProps, onClick: vi.fn() })
// Why: the Radix TooltipContent is what renders the visual tooltip.
// Native `title` was removed because its styling diverges from the
// rest of the sidebar chrome.
expect(findTooltipContentText(element)).toContain('Stage all')
})
it('calls the onClick handler when enabled', () => {
const onClick = vi.fn()
const element = ActionButton({ ...baseProps, onClick })
const button = findInnerButton(element)
const { event } = makeClickEvent()
;(button.props.onClick as (e: MinimalMouseEvent) => void)(event)
expect(onClick).toHaveBeenCalledWith(event)
})
it('does NOT render the native disabled prop on the inner button', () => {
// Why: Radix TooltipTrigger on a DOM-disabled <button> gets its pointer
// events blocked in Chromium, suppressing the tooltip entirely — a
// regression vs. the native `title` attribute it replaced. ActionButton
// uses aria-disabled + a click guard instead.
const element = ActionButton({ ...baseProps, onClick: vi.fn(), disabled: true })
const button = findInnerButton(element)
expect(button.props.disabled).toBeUndefined()
})
it('marks the inner button aria-disabled when the disabled prop is true', () => {
const element = ActionButton({ ...baseProps, onClick: vi.fn(), disabled: true })
const button = findInnerButton(element)
expect(button.props['aria-disabled']).toBe(true)
})
it('applies the visual-disabled class when disabled', () => {
const element = ActionButton({ ...baseProps, onClick: vi.fn(), disabled: true })
const button = findInnerButton(element)
expect(button.props.className).toContain('opacity-50')
expect(button.props.className).toContain('cursor-not-allowed')
})
it('omits the visual-disabled class when enabled', () => {
const element = ActionButton({ ...baseProps, onClick: vi.fn(), disabled: false })
const button = findInnerButton(element)
expect(button.props.className).not.toContain('opacity-50')
})
it('swallows clicks and calls preventDefault when disabled', () => {
const onClick = vi.fn()
const element = ActionButton({ ...baseProps, onClick, disabled: true })
const button = findInnerButton(element)
const { event, preventDefault } = makeClickEvent()
;(button.props.onClick as (e: MinimalMouseEvent) => void)(event)
// Why: keyboard Enter/Space also fires onClick on a non-DOM-disabled
// button. The guard must block both pointer and keyboard activation
// while `disabled` is true, even though the inner handler is trusted
// to early-return on `isExecutingBulk`.
expect(onClick).not.toHaveBeenCalled()
expect(preventDefault).toHaveBeenCalledTimes(1)
})
})

View File

@ -34,6 +34,14 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/comp
import { Button } from '@/components/ui/button'
import { BulkActionBar } from './BulkActionBar'
import { useSourceControlSelection, type FlatEntry } from './useSourceControlSelection'
import {
getDiscardAllPaths,
getStageAllPaths,
getUnstageAllPaths,
runDiscardAllForArea,
type DiscardAllArea
} from './discard-all-sequence'
import { toast } from 'sonner'
import {
ContextMenu,
ContextMenuContent,
@ -617,6 +625,49 @@ function SourceControlInner(): React.JSX.Element {
}
}, [worktreePath, bulkUnstagePaths, clearSelection, activeWorktreeId])
// Why: "Stage all" on the Changes section intentionally skips unresolved
// conflict rows. `git add` on a conflicted file silently clears the `u`
// record — the only live signal we have — before the user has reviewed it,
// which mirrors the per-row Stage suppression above.
const handleStageAllInArea = useCallback(
async (area: 'unstaged' | 'untracked') => {
if (!worktreePath || isExecutingBulk) {
return
}
const paths = getStageAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await window.api.git.bulkStage({ worktreePath, filePaths: paths, connectionId })
clearSelection()
} finally {
setIsExecutingBulk(false)
}
},
[worktreePath, grouped, activeWorktreeId, isExecutingBulk, clearSelection]
)
const handleUnstageAll = useCallback(async () => {
if (!worktreePath || isExecutingBulk) {
return
}
const paths = getUnstageAllPaths(grouped.staged)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await window.api.git.bulkUnstage({ worktreePath, filePaths: paths, connectionId })
clearSelection()
} finally {
setIsExecutingBulk(false)
}
}, [worktreePath, grouped.staged, activeWorktreeId, isExecutingBulk, clearSelection])
const refreshBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
return
@ -758,34 +809,109 @@ function SourceControlInner(): React.JSX.Element {
[worktreePath, activeWorktreeId]
)
const handleDiscard = useCallback(
// Why: split into two variants — `discardSingle` throws so bulk callers can
// aggregate failures into a single toast via `runDiscardAllForArea`'s
// onError, while `handleDiscard` swallows for the per-row fire-and-forget UI
// contract (no individual failure toast).
const discardSingle = useCallback(
async (filePath: string) => {
if (!worktreePath || !activeWorktreeId) {
return
}
try {
// Why: git discard replaces the working tree version of this file. Any
// pending editor autosave must be quiesced first so it cannot recreate
// the discarded edits after git restores the file.
await requestEditorSaveQuiesce({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath
})
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await window.api.git.discard({ worktreePath, filePath, connectionId })
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath
})
} catch {
// git operation failed silently
}
// Why: git discard replaces the working tree version of this file. Any
// pending editor autosave must be quiesced first so it cannot recreate
// the discarded edits after git restores the file.
await requestEditorSaveQuiesce({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath
})
const connectionId = getConnectionId(activeWorktreeId ?? null) ?? undefined
await window.api.git.discard({ worktreePath, filePath, connectionId })
notifyEditorExternalFileChange({
worktreeId: activeWorktreeId,
worktreePath,
relativePath: filePath
})
},
[activeWorktreeId, worktreePath]
)
const handleDiscard = useCallback(
async (filePath: string) => {
try {
await discardSingle(filePath)
} catch {
// Why: per-row discard is fire-and-forget for the UI; failures are not
// surfaced individually. Bulk callers use `discardSingle` directly so
// they can aggregate failures into a single toast.
}
},
[discardSingle]
)
// Why: "Discard all" mirrors the per-row discard rules — it skips unresolved
// and resolved_locally rows because discarding those can silently re-create
// the conflict or lose the resolution (no v1 UX to explain this clearly).
// There is no bulk discard IPC, so we serialize per-file discard calls that
// run the same editor-quiesce + external-change notification as the row action.
// The sequencing + filter rules live in discard-all-sequence.ts so they can
// be unit-tested independently of the full component (staged area needs a
// bulk-unstage first, and a failed unstage must skip the discard loop).
const handleRevertAllInArea = useCallback(
async (area: DiscardAllArea) => {
if (!worktreePath || !activeWorktreeId || isExecutingBulk) {
return
}
const paths = getDiscardAllPaths(grouped[area], area)
if (paths.length === 0) {
return
}
setIsExecutingBulk(true)
try {
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
// Why: `onError` fires once per failure — both for the bulk-unstage
// pre-step and for each per-file discard failure. Aggregate into one
// toast after the sequence completes so a partial failure across N
// files doesn't spam N error toasts.
const errors: unknown[] = []
const result = await runDiscardAllForArea(area, paths, {
bulkUnstage: (filePaths) =>
window.api.git.bulkUnstage({ worktreePath, filePaths, connectionId }),
discardOne: discardSingle,
onError: (error) => {
errors.push(error)
console.error('[SourceControl] discard-all failure', error)
}
})
if (result.aborted) {
toast.error('Discard all failed — unable to unstage files before discard', {
description: errors[0] instanceof Error ? errors[0].message : undefined
})
} else if (result.failed.length > 0) {
// Why: only include the first error message to avoid a huge toast
// body on bulk failures; a short sample of failed paths gives users
// enough context to retry or investigate.
const firstMsg = errors[0] instanceof Error ? errors[0].message : undefined
const sample = result.failed.slice(0, 3).join(', ')
const more = result.failed.length > 3 ? `, +${result.failed.length - 3} more` : ''
toast.error(
`Failed to discard ${result.failed.length} file${result.failed.length === 1 ? '' : 's'}`,
{
description: firstMsg ? `${firstMsg} (e.g. ${sample}${more})` : `${sample}${more}`
}
)
}
if (!result.aborted) {
clearSelection()
}
} finally {
setIsExecutingBulk(false)
}
},
[worktreePath, activeWorktreeId, grouped, isExecutingBulk, clearSelection, discardSingle]
)
if (!activeWorktree || !activeRepo || !worktreePath) {
return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground px-4 text-center">
@ -1038,6 +1164,26 @@ function SourceControlInner(): React.JSX.Element {
return null
}
const isCollapsed = collapsedSections.has(area)
// Why: "Stage all"/"Unstage all" operate on the *unfiltered*
// group for the area — acting on just the filter-visible subset
// would surprise users who don't realize a filter is active.
// The +/- is hidden when the filter is active to avoid that
// mismatch between what's shown and what would be staged.
// Why: visibility and execution both resolve paths through the
// same helpers (`getStageAllPaths`/`getUnstageAllPaths`/
// `getDiscardAllPaths`) so the button can never show for a set
// the handler would then filter to empty.
const stageAllPaths =
area === 'unstaged' || area === 'untracked'
? getStageAllPaths(grouped[area], area)
: []
const canStageAll = !normalizedFilter && stageAllPaths.length > 0
const canUnstageAll =
!normalizedFilter &&
area === 'staged' &&
getUnstageAllPaths(grouped.staged).length > 0
const canRevertAll =
!normalizedFilter && getDiscardAllPaths(grouped[area], area).length > 0
return (
<div key={area}>
<SectionHeader
@ -1049,37 +1195,92 @@ function SourceControlInner(): React.JSX.Element {
isCollapsed={isCollapsed}
onToggle={() => toggleSection(area)}
actions={
items.some((entry) => entry.conflictStatus === 'unresolved') ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
>
View all
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
>
View all
</Button>
)
<>
{/* Why: bulk action buttons are hover-only on
pointer devices to avoid cluttering the section
header with persistent icons. On no-hover
pointers (touch, and SSH sessions where hover
state is unreliable see AGENTS.md "SSH Use
Case"), force them visible so they're reachable
without tabbing. One outer wrapper so that
focusing any action reveals all three siblings
otherwise keyboard users tab into an invisible
next stop. */}
<div className="flex items-center opacity-0 transition-opacity group-hover/section:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">
{canRevertAll && (
<ActionButton
icon={Undo2}
// Why: for untracked files, discard deletes the file
// outright (rm -rf via git.discard's untracked branch).
// A generic "Discard all" label hides that severity —
// label explicitly for the destructive variant.
title={
area === 'untracked' ? 'Delete all untracked' : 'Discard all'
}
onClick={(event) => {
event.stopPropagation()
void handleRevertAllInArea(area)
}}
disabled={isExecutingBulk}
/>
)}
{canStageAll && (
<ActionButton
icon={Plus}
title="Stage all"
onClick={(event) => {
event.stopPropagation()
if (area === 'unstaged' || area === 'untracked') {
void handleStageAllInArea(area)
}
}}
disabled={isExecutingBulk}
/>
)}
{canUnstageAll && (
<ActionButton
icon={Minus}
title="Unstage all"
onClick={(event) => {
event.stopPropagation()
void handleUnstageAll()
}}
disabled={isExecutingBulk}
/>
)}
</div>
{items.some((entry) => entry.conflictStatus === 'unresolved') ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px] text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
>
View all
</Button>
) : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
if (activeWorktreeId && worktreePath) {
openAllDiffs(activeWorktreeId, worktreePath, undefined, area)
}
}}
>
View all
</Button>
)}
</>
}
/>
{!isCollapsed &&
@ -1420,25 +1621,31 @@ function SectionHeader({
onToggle: () => void
actions?: React.ReactNode
}): React.JSX.Element {
// Why: wrap the toggle button and actions in a shared rounded container
// so the hover background spans the entire row instead of clipping around
// the label. The outer div keeps the vertical spacing that separates
// sections; the inner wrapper owns the hover rectangle.
return (
<div className="group/section flex items-center pl-1 pr-3 pt-3 pb-1">
<button
type="button"
className="flex flex-1 items-center gap-1 rounded-md px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 hover:bg-accent hover:text-accent-foreground"
onClick={onToggle}
>
<ChevronDown
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span>{label}</span>
<span className="text-[11px] font-medium tabular-nums">{count}</span>
{conflictCount > 0 && (
<span className="text-[11px] font-medium text-destructive/80">
· {conflictCount} conflict{conflictCount === 1 ? '' : 's'}
</span>
)}
</button>
<div className="shrink-0 flex items-center">{actions}</div>
<div className="pl-1 pr-3 pt-3 pb-1">
<div className="group/section flex items-center rounded-md pr-1 hover:bg-accent hover:text-accent-foreground">
<button
type="button"
className="flex flex-1 items-center gap-1 px-0.5 py-0.5 text-left text-xs font-semibold uppercase tracking-wider text-foreground/70 group-hover/section:text-accent-foreground"
onClick={onToggle}
>
<ChevronDown
className={cn('size-3.5 shrink-0 transition-transform', isCollapsed && '-rotate-90')}
/>
<span>{label}</span>
<span className="text-[11px] font-medium tabular-nums">{count}</span>
{conflictCount > 0 && (
<span className="text-[11px] font-medium text-destructive/80">
· {conflictCount} conflict{conflictCount === 1 ? '' : 's'}
</span>
)}
</button>
<div className="shrink-0 flex items-center">{actions}</div>
</div>
</div>
)
}
@ -1928,26 +2135,61 @@ function EmptyState({
)
}
function ActionButton({
export function ActionButton({
icon: Icon,
title,
onClick
onClick,
disabled
}: {
icon: React.ComponentType<{ className?: string }>
title: string
onClick: (event: React.MouseEvent) => void
disabled?: boolean
}): React.JSX.Element {
// Why: use the Radix Tooltip instead of the native `title` attribute so the
// label matches the rest of the sidebar chrome (consistent styling, no OS
// delay quirks, dismissible on pointer leave).
//
// Why (no local TooltipProvider): the app root mounts a single
// TooltipProvider (see App.tsx); nesting another one here gives this subtree
// its own delay-timing state and breaks Radix's "skip the open delay when
// moving between adjacent tooltip triggers" handoff between sibling action
// buttons in the section header.
//
// Why (disabled handling): Radix's TooltipTrigger asChild on a disabled
// <button> gets pointer-events blocked in Chromium, which suppresses the
// tooltip entirely — a regression vs. the native `title` attribute it
// replaced. We keep the button interactive and rely on the caller's
// `isExecutingBulk` early-return to no-op the click during bulk ops;
// `aria-disabled` + visual dimming preserves the disabled affordance.
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto p-0.5 text-muted-foreground hover:text-foreground"
title={title}
onClick={onClick}
>
<Icon className="size-3.5" />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className={cn(
'h-auto w-auto p-0.5 text-muted-foreground hover:text-foreground',
disabled && 'opacity-50 cursor-not-allowed'
)}
aria-label={title}
aria-disabled={disabled}
onClick={(event) => {
if (disabled) {
event.preventDefault()
return
}
onClick(event)
}}
>
<Icon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{title}
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,284 @@
import { describe, expect, it, vi } from 'vitest'
import {
getDiscardAllPaths,
getStageAllPaths,
getUnstageAllPaths,
runDiscardAllForArea,
type DiscardAllArea
} from './discard-all-sequence'
import type { GitStatusEntry } from '../../../../shared/types'
function entry(partial: Partial<GitStatusEntry> & { path: string }): GitStatusEntry {
return {
status: 'modified',
area: 'unstaged',
...partial
}
}
describe('getDiscardAllPaths', () => {
it('returns only paths in the requested area', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'a.ts', area: 'staged' }),
entry({ path: 'b.ts', area: 'unstaged' }),
entry({ path: 'c.ts', area: 'untracked', status: 'untracked' })
]
expect(getDiscardAllPaths(entries, 'staged')).toEqual(['a.ts'])
expect(getDiscardAllPaths(entries, 'unstaged')).toEqual(['b.ts'])
expect(getDiscardAllPaths(entries, 'untracked')).toEqual(['c.ts'])
})
it('skips entries with an unresolved conflict', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'clean.ts', area: 'unstaged' }),
entry({
path: 'conflict.ts',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
})
]
// Why: `git restore --worktree --source=HEAD` on an unresolved conflict
// clears the `u` record silently before the user has reviewed it, which
// is why the per-row Stage/Discard buttons also suppress this case.
expect(getDiscardAllPaths(entries, 'unstaged')).toEqual(['clean.ts'])
})
it('skips entries resolved locally but not yet re-staged', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'clean.ts', area: 'unstaged' }),
entry({
path: 'resolved.ts',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
]
// Why: discarding a locally-resolved file loses the resolution. The user
// would have to re-resolve from scratch — treat it as too dangerous to
// include in a bulk action.
expect(getDiscardAllPaths(entries, 'unstaged')).toEqual(['clean.ts'])
})
it('returns an empty array when nothing matches', () => {
expect(getDiscardAllPaths([], 'staged')).toEqual([])
expect(getDiscardAllPaths([entry({ path: 'a.ts', area: 'staged' })], 'unstaged')).toEqual([])
})
})
describe('getStageAllPaths', () => {
it('returns only paths in the requested area', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'a.ts', area: 'staged' }),
entry({ path: 'b.ts', area: 'unstaged' }),
entry({ path: 'c.ts', area: 'untracked', status: 'untracked' })
]
expect(getStageAllPaths(entries, 'unstaged')).toEqual(['b.ts'])
expect(getStageAllPaths(entries, 'untracked')).toEqual(['c.ts'])
})
it('skips entries with an unresolved conflict', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'clean.ts', area: 'unstaged' }),
entry({
path: 'conflict.ts',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
})
]
// Why: `git add` on an unresolved conflict silently clears the `u`
// record before the user has reviewed it — same hazard the per-row
// Stage button guards against.
expect(getStageAllPaths(entries, 'unstaged')).toEqual(['clean.ts'])
})
it('includes entries that are resolved locally', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'clean.ts', area: 'unstaged' }),
entry({
path: 'resolved.ts',
area: 'unstaged',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
]
// Why: staging a locally-resolved file is the normal resolution
// workflow — it marks the conflict as finished. Unlike discard, this
// must NOT be filtered out.
expect(getStageAllPaths(entries, 'unstaged')).toEqual(['clean.ts', 'resolved.ts'])
})
it('returns an empty array when nothing matches', () => {
expect(getStageAllPaths([], 'unstaged')).toEqual([])
expect(getStageAllPaths([entry({ path: 'a.ts', area: 'staged' })], 'unstaged')).toEqual([])
})
})
describe('getUnstageAllPaths', () => {
it('returns only staged-area paths', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'a.ts', area: 'staged' }),
entry({ path: 'b.ts', area: 'unstaged' }),
entry({ path: 'c.ts', area: 'untracked', status: 'untracked' })
]
expect(getUnstageAllPaths(entries)).toEqual(['a.ts'])
})
it('includes staged conflict rows', () => {
const entries: GitStatusEntry[] = [
entry({ path: 'clean.ts', area: 'staged' }),
entry({
path: 'conflict.ts',
area: 'staged',
conflictKind: 'both_modified',
conflictStatus: 'unresolved'
}),
entry({
path: 'resolved.ts',
area: 'staged',
conflictKind: 'both_modified',
conflictStatus: 'resolved_locally'
})
]
// Why: `git reset HEAD` on a staged conflict row is safe and mirrors
// the per-row Unstage action — no conflict filter here.
expect(getUnstageAllPaths(entries)).toEqual(['clean.ts', 'conflict.ts', 'resolved.ts'])
})
it('returns an empty array when nothing is staged', () => {
expect(getUnstageAllPaths([])).toEqual([])
expect(getUnstageAllPaths([entry({ path: 'a.ts', area: 'unstaged' })])).toEqual([])
})
})
describe('runDiscardAllForArea', () => {
function makeDeps(
overrides: {
bulkUnstageError?: unknown
discardOneError?: (path: string) => unknown
} = {}
) {
const bulkUnstageCalls: string[][] = []
const discardOneCalls: string[] = []
const errors: unknown[] = []
const bulkUnstage = vi.fn(async (paths: string[]) => {
bulkUnstageCalls.push([...paths])
if (overrides.bulkUnstageError !== undefined) {
throw overrides.bulkUnstageError
}
})
const discardOne = vi.fn(async (path: string) => {
discardOneCalls.push(path)
if (overrides.discardOneError) {
const err = overrides.discardOneError(path)
if (err !== undefined) {
throw err
}
}
})
const onError = vi.fn((error: unknown) => {
errors.push(error)
})
return {
deps: { bulkUnstage, discardOne, onError },
bulkUnstageCalls,
discardOneCalls,
errors,
bulkUnstage,
discardOne,
onError
}
}
it('no-ops when the path list is empty', async () => {
const ctx = makeDeps()
const result = await runDiscardAllForArea('staged', [], ctx.deps)
expect(result).toEqual({ discarded: [], failed: [], aborted: false })
expect(ctx.bulkUnstage).not.toHaveBeenCalled()
expect(ctx.discardOne).not.toHaveBeenCalled()
})
it('discards unstaged paths one-by-one without bulk-unstaging', async () => {
const ctx = makeDeps()
const result = await runDiscardAllForArea('unstaged', ['a.ts', 'b.ts'], ctx.deps)
expect(result).toEqual({ discarded: ['a.ts', 'b.ts'], failed: [], aborted: false })
expect(ctx.bulkUnstage).not.toHaveBeenCalled()
expect(ctx.discardOneCalls).toEqual(['a.ts', 'b.ts'])
})
it('discards untracked paths one-by-one without bulk-unstaging', async () => {
const ctx = makeDeps()
const result = await runDiscardAllForArea('untracked', ['new.ts'], ctx.deps)
expect(result).toEqual({ discarded: ['new.ts'], failed: [], aborted: false })
expect(ctx.bulkUnstage).not.toHaveBeenCalled()
expect(ctx.discardOneCalls).toEqual(['new.ts'])
})
it('bulk-unstages staged paths before the per-file discard loop', async () => {
const ctx = makeDeps()
const result = await runDiscardAllForArea('staged', ['a.ts', 'b.ts'], ctx.deps)
expect(result).toEqual({ discarded: ['a.ts', 'b.ts'], failed: [], aborted: false })
expect(ctx.bulkUnstageCalls).toEqual([['a.ts', 'b.ts']])
expect(ctx.discardOneCalls).toEqual(['a.ts', 'b.ts'])
// Why: bulk unstage MUST happen strictly before any discard, otherwise
// the index would still hold the staged delta when the worktree was
// reset and the files would reappear as inverse changes.
expect(ctx.bulkUnstage.mock.invocationCallOrder[0]).toBeLessThan(
ctx.discardOne.mock.invocationCallOrder[0]
)
})
it('aborts and skips the discard loop if bulk-unstage rejects', async () => {
const error = new Error('index locked')
const ctx = makeDeps({ bulkUnstageError: error })
const result = await runDiscardAllForArea('staged', ['a.ts', 'b.ts'], ctx.deps)
expect(result).toEqual({ discarded: [], failed: [], aborted: true })
// Why: a failed unstage + successful discard would leave the index with
// the staged delta and the worktree at HEAD — a worse state than we
// started in. The discard loop must not run.
expect(ctx.discardOne).not.toHaveBeenCalled()
expect(ctx.errors).toEqual([error])
})
it('continues past a per-file discard failure and records it in `failed`', async () => {
const error = new Error('EPERM')
const ctx = makeDeps({
discardOneError: (path) => (path === 'b.ts' ? error : undefined)
})
const result = await runDiscardAllForArea('unstaged', ['a.ts', 'b.ts', 'c.ts'], ctx.deps)
// Why: best-effort continuation — one stuck file shouldn't block the
// rest of a bulk action the user explicitly triggered.
expect(result).toEqual({
discarded: ['a.ts', 'c.ts'],
failed: ['b.ts'],
aborted: false
})
expect(ctx.discardOneCalls).toEqual(['a.ts', 'b.ts', 'c.ts'])
// Why: `aborted` is reserved for the pre-step (bulk unstage) failing —
// per-file failures don't trip it, otherwise callers couldn't
// distinguish "nothing ran" from "some ran, some didn't".
expect(ctx.errors).toEqual([error])
})
it('does not invoke the error callback on a happy-path staged run', async () => {
const ctx = makeDeps()
await runDiscardAllForArea('staged', ['a.ts'], ctx.deps)
expect(ctx.onError).not.toHaveBeenCalled()
})
it('does not bulk-unstage for non-staged areas even if the dep is provided', async () => {
const ctx = makeDeps()
const areas: DiscardAllArea[] = ['unstaged', 'untracked']
for (const area of areas) {
await runDiscardAllForArea(area, ['x.ts'], ctx.deps)
}
// Why: the unstage step is specific to the staged area's two-step
// reset. Accidentally invoking it for unstaged/untracked would be a
// no-op for unstaged entries but could mask a regression where staged
// entries leak into those paths.
expect(ctx.bulkUnstage).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,122 @@
import type { GitStatusEntry } from '../../../../shared/types'
export type DiscardAllArea = 'staged' | 'unstaged' | 'untracked'
/**
* Collect the paths a "Discard all" bulk action should operate on for a given
* area. Unresolved and locally-resolved conflicts are excluded discarding
* those can silently re-create the conflict or lose the resolution.
*/
export function getDiscardAllPaths(
entries: readonly GitStatusEntry[],
area: DiscardAllArea
): string[] {
return entries
.filter(
(entry) =>
entry.area === area &&
entry.conflictStatus !== 'unresolved' &&
entry.conflictStatus !== 'resolved_locally'
)
.map((entry) => entry.path)
}
export type StageAllArea = 'unstaged' | 'untracked'
/**
* Collect the paths a "Stage all" action should operate on.
* Unresolved conflict rows are excluded `git add` on a conflicted file
* silently clears the `u` record before the user has reviewed it.
* `resolved_locally` rows are intentionally INCLUDED: staging them is how the
* user finalises the resolution and mirrors the per-row Stage button.
*/
export function getStageAllPaths(entries: readonly GitStatusEntry[], area: StageAllArea): string[] {
return entries
.filter((entry) => entry.area === area && entry.conflictStatus !== 'unresolved')
.map((entry) => entry.path)
}
/**
* Collect the paths an "Unstage all" action should operate on.
* Every staged row is eligible `git reset HEAD` on a staged conflict
* row is safe and mirrors the per-row Unstage action.
*/
export function getUnstageAllPaths(entries: readonly GitStatusEntry[]): string[] {
return entries.filter((entry) => entry.area === 'staged').map((entry) => entry.path)
}
export type DiscardAllDeps = {
/** Unstage the given paths in one IPC round-trip. Only called for 'staged'. */
bulkUnstage: (paths: string[]) => Promise<void>
/** Discard a single path (restore working-tree to HEAD, or rm if untracked). */
discardOne: (path: string) => Promise<void>
/**
* Called when either the pre-step (bulkUnstage) rejects OR an individual
* `discardOne` rejects. Invoked once per failure so callers can surface
* each error (e.g. a toast per stuck file) rather than swallowing them.
*/
onError?: (error: unknown) => void
}
export type DiscardAllResult = {
/** Paths whose `discardOne` call resolved successfully. */
discarded: string[]
/** Paths whose `discardOne` call rejected. Best-effort: the loop continues past these. */
failed: string[]
/**
* True only when the pre-step (bulk unstage for the 'staged' area) failed
* and we never entered the per-file discard loop. Per-file failures do
* NOT set this flag they are reported via `failed`.
*/
aborted: boolean
}
/**
* Run the "Discard all" sequence for a given area.
*
* For 'staged', this first bulk-unstages the paths without that step,
* `discardOne` (which maps to `git restore --worktree --source=HEAD`) would
* reset the working tree to HEAD but leave the index carrying the staged
* delta, producing phantom inverse "Changes" rows the user thought they just
* discarded. If the unstage fails we MUST skip the discard loop entirely for
* the same reason: a stale index with a clean worktree is a worse state than
* the one the user started in.
*
* Per-file `discardOne` failures are best-effort: we continue past a failed
* file so a single stuck path does not block the rest of the bulk action.
* Failed paths are reported via `failed`, `onError` is invoked once per
* failure, and `aborted` remains `false` because the loop ran end-to-end.
*/
export async function runDiscardAllForArea(
area: DiscardAllArea,
paths: readonly string[],
deps: DiscardAllDeps
): Promise<DiscardAllResult> {
if (paths.length === 0) {
return { discarded: [], failed: [], aborted: false }
}
if (area === 'staged') {
try {
await deps.bulkUnstage([...paths])
} catch (error) {
deps.onError?.(error)
return { discarded: [], failed: [], aborted: true }
}
}
const discarded: string[] = []
const failed: string[] = []
for (const path of paths) {
try {
await deps.discardOne(path)
discarded.push(path)
} catch (error) {
// Best-effort: record and continue so one stuck file doesn't block the
// rest of the bulk action.
failed.push(path)
deps.onError?.(error)
}
}
return { discarded, failed, aborted: false }
}