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
co-authored by Orca
parent a595c1f84b
commit 9b3b2a5486
4 changed files with 880 additions and 81 deletions
@@ -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)
})
})
@@ -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>
)
}
@@ -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()
})
})
@@ -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 }
}