Add diff note bulk clear

This commit is contained in:
Jinjing 2026-05-15 17:53:15 -07:00 committed by GitHub
parent 97e6f48256
commit 46331f881f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 381 additions and 25 deletions

View File

@ -1011,23 +1011,26 @@
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border: 1px solid color-mix(in srgb, var(--foreground) 22%, var(--border));
border-radius: 4px;
background: var(--background);
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 5%, var(--editor-surface));
color: color-mix(in srgb, var(--foreground) 78%, var(--muted-foreground));
cursor: pointer;
z-index: 5;
opacity: 0.7;
opacity: 1;
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
transition:
opacity 100ms ease,
color 100ms ease,
background-color 100ms ease;
background-color 100ms ease,
border-color 100ms ease,
box-shadow 100ms ease;
}
.orca-diff-comment-add-btn:hover {
opacity: 1;
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 52%, var(--border));
background: color-mix(in srgb, var(--primary) 12%, var(--background));
box-shadow: 0 1px 4px color-mix(in srgb, var(--foreground) 16%, transparent);
}
.orca-diff-comment-add-btn:focus-visible {
@ -1045,10 +1048,9 @@
.orca-diff-comment-inline {
width: 100%;
/* Why: match the popover's horizontal inset so the saved card lines up with
the new-comment popover that preceded it. Both anchor at `left: 56px` and
cap at 420px wide. */
padding: 4px 24px 6px 56px;
/* Why: Monaco's view-zone node already starts after the diff gutter. Avoid
adding a second left inset so saved notes sit flush with the code column. */
padding: 4px 24px 6px 0;
box-sizing: border-box;
}

View File

@ -25,10 +25,12 @@ import {
MessageSquare,
Send,
Trash,
Trash2,
TriangleAlert,
CircleCheck,
Search,
X
X,
MoreHorizontal
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
@ -181,6 +183,10 @@ type PendingDiscardConfirmation =
| { kind: 'entry'; entry: GitStatusEntry }
| { kind: 'area'; area: DiscardAllArea; paths: readonly string[] }
type PendingDiffCommentsClear =
| { kind: 'all'; worktreeId: string }
| { kind: 'file'; worktreeId: string; filePath: string }
export function readCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string | null | undefined
@ -283,6 +289,8 @@ function SourceControlInner(): React.JSX.Element {
const openAllDiffs = useAppStore((s) => s.openAllDiffs)
const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs)
const deleteDiffComment = useAppStore((s) => s.deleteDiffComment)
const clearDiffComments = useAppStore((s) => s.clearDiffComments)
const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile)
const setScrollToDiffCommentId = useAppStore((s) => s.setScrollToDiffCommentId)
const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen)
const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab)
@ -309,6 +317,9 @@ function SourceControlInner(): React.JSX.Element {
)
const [diffCommentsExpanded, setDiffCommentsExpanded] = useState(false)
const [diffCommentsCopied, setDiffCommentsCopied] = useState(false)
const [pendingDiffCommentsClear, setPendingDiffCommentsClear] =
useState<PendingDiffCommentsClear | null>(null)
const [isClearingDiffComments, setIsClearingDiffComments] = useState(false)
const handleCopyDiffComments = useCallback(async (): Promise<void> => {
if (diffCommentsForActive.length === 0) {
@ -333,6 +344,72 @@ function SourceControlInner(): React.JSX.Element {
return () => window.clearTimeout(handle)
}, [diffCommentsCopied])
const pendingDiffCommentsClearCount = useMemo(() => {
if (!pendingDiffCommentsClear || pendingDiffCommentsClear.worktreeId !== activeWorktreeId) {
return 0
}
if (pendingDiffCommentsClear.kind === 'all') {
return diffCommentsForActive.length
}
return diffCommentsForActive.filter((c) => c.filePath === pendingDiffCommentsClear.filePath)
.length
}, [activeWorktreeId, diffCommentsForActive, pendingDiffCommentsClear])
const pendingDiffCommentsClearDescription = pendingDiffCommentsClear
? pendingDiffCommentsClear.kind === 'all'
? `Clear ${pendingDiffCommentsClearCount} ${pendingDiffCommentsClearCount === 1 ? 'note' : 'notes'} from this worktree?`
: `Clear ${pendingDiffCommentsClearCount} ${pendingDiffCommentsClearCount === 1 ? 'note' : 'notes'} from ${pendingDiffCommentsClear.filePath}?`
: ''
useEffect(() => {
if (!pendingDiffCommentsClear || isClearingDiffComments) {
return
}
if (
pendingDiffCommentsClear.worktreeId !== activeWorktreeId ||
pendingDiffCommentsClearCount === 0
) {
setPendingDiffCommentsClear(null)
}
}, [
activeWorktreeId,
isClearingDiffComments,
pendingDiffCommentsClear,
pendingDiffCommentsClearCount
])
const handleConfirmDiffCommentsClear = useCallback(async (): Promise<void> => {
const pending = pendingDiffCommentsClear
if (!pending || isClearingDiffComments || pending.worktreeId !== activeWorktreeId) {
return
}
if (pendingDiffCommentsClearCount === 0) {
setPendingDiffCommentsClear(null)
return
}
setIsClearingDiffComments(true)
try {
const ok =
pending.kind === 'all'
? await clearDiffComments(pending.worktreeId)
: await clearDiffCommentsForFile(pending.worktreeId, pending.filePath)
if (ok) {
setPendingDiffCommentsClear(null)
} else {
toast.error('Failed to clear notes.')
}
} finally {
setIsClearingDiffComments(false)
}
}, [
activeWorktreeId,
clearDiffComments,
clearDiffCommentsForFile,
isClearingDiffComments,
pendingDiffCommentsClear,
pendingDiffCommentsClearCount
])
const [scope, setScope] = useState<SourceControlScope>('all')
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set())
const [baseRefDialogOpen, setBaseRefDialogOpen] = useState(false)
@ -687,6 +764,8 @@ function SourceControlInner(): React.JSX.Element {
setCollapsedSections(new Set())
setBaseRefDialogOpen(false)
setPendingDiscard(null)
setPendingDiffCommentsClear(null)
setIsClearingDiffComments(false)
// Why: do NOT reset defaultBaseRef here. It is repo-scoped, not
// worktree-scoped, and is resolved by the effect above on activeRepo
// change. Resetting it to a hard-coded 'origin/main' on every worktree
@ -2084,12 +2163,54 @@ function SourceControlInner(): React.JSX.Element {
</Tooltip>
</TooltipProvider>
)}
<DropdownMenu>
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label="More note actions"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
More note actions
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end" className="min-w-[180px]">
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={diffCommentCount === 0}
onSelect={() => {
if (!activeWorktreeId || diffCommentCount === 0) {
return
}
setPendingDiffCommentsClear({ kind: 'all', worktreeId: activeWorktreeId })
}}
>
<Trash2 className="size-3.5" />
Clear all notes...
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{diffCommentsExpanded && (
<DiffCommentsInlineList
comments={diffCommentsForActive}
onDelete={(id) => void deleteDiffComment(activeWorktreeId, id)}
onOpen={(comment) => handleOpenComment(comment)}
onClearFile={(filePath) =>
setPendingDiffCommentsClear({
kind: 'file',
worktreeId: activeWorktreeId,
filePath
})
}
/>
)}
</div>
@ -2443,6 +2564,43 @@ function SourceControlInner(): React.JSX.Element {
)}
</div>
<Dialog
open={pendingDiffCommentsClear !== null}
onOpenChange={(open) => {
if (!open && !isClearingDiffComments) {
setPendingDiffCommentsClear(null)
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">Clear Notes</DialogTitle>
<DialogDescription className="text-xs">
{pendingDiffCommentsClearDescription}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setPendingDiffCommentsClear(null)}
disabled={isClearingDiffComments}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void handleConfirmDiffCommentsClear()}
disabled={isClearingDiffComments || pendingDiffCommentsClearCount === 0}
>
<Trash2 className="size-4" />
Clear Notes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={pendingDiscard !== null}
onOpenChange={(open) => {
@ -2938,10 +3096,12 @@ function SectionHeader({
function DiffCommentsInlineList({
comments,
onDelete,
onClearFile,
onOpen
}: {
comments: DiffComment[]
onDelete: (commentId: string) => void
onClearFile: (filePath: string) => void
// Why: clicking the note row navigates the user to that file's diff (or
// editor as a fallback) and, when a `commentId` is supplied, scrolls the
// diff to that specific note via the scrollToDiffCommentId UI slice.
@ -2996,19 +3156,30 @@ function DiffCommentsInlineList({
<div className="bg-muted/20">
{groups.map(([filePath, list]) => (
<div key={filePath} className="px-3 py-1.5">
<button
type="button"
className="block w-full truncate text-left text-[10px] font-medium text-muted-foreground hover:text-foreground"
onClick={() => {
const first = list[0]
if (first) {
onOpen(first)
}
}}
title={`Open ${filePath}`}
>
{filePath}
</button>
<div className="group/file flex items-center gap-1">
<button
type="button"
className="block min-w-0 flex-1 truncate text-left text-[10px] font-medium text-muted-foreground hover:text-foreground"
onClick={() => {
const first = list[0]
if (first) {
onOpen(first)
}
}}
title={`Open ${filePath}`}
>
{filePath}
</button>
<button
type="button"
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/file:opacity-100"
onClick={() => onClearFile(filePath)}
title={`Clear notes for ${filePath}`}
aria-label={`Clear notes for ${filePath}`}
>
<Trash2 className="size-3" />
</button>
</div>
<ul className="mt-1 space-y-1">
{list.map((c) => (
<li

View File

@ -164,6 +164,18 @@ function createTestStore() {
const REPO = 'repo1'
const WT = 'repo1::/path/wt'
function makeComment(overrides: Partial<DiffComment> & Pick<DiffComment, 'id'>): DiffComment {
return {
worktreeId: WT,
filePath: 'src/foo.ts',
lineNumber: 10,
body: 'body',
createdAt: 1000,
side: 'modified',
...overrides
}
}
function makeWorktree(diffComments: DiffComment[]): Worktree {
return {
id: WT,
@ -396,3 +408,137 @@ describe('updateDiffComment', () => {
errSpy.mockRestore()
})
})
describe('bulk clear diff comments', () => {
beforeEach(() => {
vi.clearAllMocks()
clearRuntimeCompatibilityCacheForTests()
runtimeEnvironmentTransportCall.mockReset()
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
updateMeta.mockResolvedValue({})
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { ok: true },
_meta: { runtimeId: 'remote-runtime' }
})
})
it('clears all notes and persists once', async () => {
const store = createTestStore()
seed(store, [
makeComment({ id: 'c1', filePath: 'src/foo.ts' }),
makeComment({ id: 'c2', filePath: 'src/bar.ts' })
])
const ok = await store.getState().clearDiffComments(WT)
expect(ok).toBe(true)
expect(store.getState().getDiffComments(WT)).toEqual([])
expect(updateMeta).toHaveBeenCalledTimes(1)
expect(updateMeta).toHaveBeenCalledWith({
worktreeId: WT,
updates: { diffComments: [] }
})
})
it('clears notes for one file and persists once', async () => {
const store = createTestStore()
seed(store, [
makeComment({ id: 'c1', filePath: 'src/foo.ts' }),
makeComment({ id: 'c2', filePath: 'src/bar.ts' }),
makeComment({ id: 'c3', filePath: 'src/foo.ts', lineNumber: 20 })
])
const ok = await store.getState().clearDiffCommentsForFile(WT, 'src/foo.ts')
expect(ok).toBe(true)
expect(
store
.getState()
.getDiffComments(WT)
.map((c) => c.id)
).toEqual(['c2'])
expect(updateMeta).toHaveBeenCalledTimes(1)
expect(updateMeta).toHaveBeenCalledWith({
worktreeId: WT,
updates: { diffComments: [expect.objectContaining({ id: 'c2' })] }
})
})
it('returns success without persisting when no file notes match', async () => {
const store = createTestStore()
const comments = [makeComment({ id: 'c1', filePath: 'src/foo.ts' })]
seed(store, comments)
const ok = await store.getState().clearDiffCommentsForFile(WT, 'src/missing.ts')
expect(ok).toBe(true)
expect(store.getState().getDiffComments(WT)).toBe(comments)
expect(updateMeta).not.toHaveBeenCalled()
})
it('persists clear through the selected runtime environment', async () => {
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never
})
seed(store, [makeComment({ id: 'c1' })])
const ok = await store.getState().clearDiffComments(WT)
expect(ok).toBe(true)
expect(updateMeta).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'worktree.set',
params: {
worktree: WT,
diffComments: []
},
timeoutMs: 15_000
})
})
it('rolls back to the previous note array on persist failure', async () => {
const store = createTestStore()
const comments = [makeComment({ id: 'c1' }), makeComment({ id: 'c2' })]
seed(store, comments)
updateMeta.mockRejectedValueOnce(new Error('disk full'))
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const ok = await store.getState().clearDiffComments(WT)
expect(ok).toBe(false)
expect(store.getState().getDiffComments(WT)).toBe(comments)
errSpy.mockRestore()
})
it('does not clobber a later comment array identity when rollback runs', async () => {
const store = createTestStore()
const comments = [makeComment({ id: 'c1' })]
const laterComments = [makeComment({ id: 'c2', body: 'later' })]
seed(store, comments)
let rejectPersist: (err: Error) => void = () => {}
updateMeta.mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectPersist = reject
})
)
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const clearPromise = store.getState().clearDiffComments(WT)
await Promise.resolve()
seed(store, laterComments)
rejectPersist(new Error('disk full'))
const ok = await clearPromise
expect(ok).toBe(false)
expect(store.getState().getDiffComments(WT)).toBe(laterComments)
errSpy.mockRestore()
})
})

View File

@ -10,6 +10,8 @@ export type DiffCommentsSlice = {
addDiffComment: (input: Omit<DiffComment, 'id' | 'createdAt'>) => Promise<DiffComment | null>
updateDiffComment: (worktreeId: string, commentId: string, body: string) => Promise<boolean>
deleteDiffComment: (worktreeId: string, commentId: string) => Promise<void>
clearDiffComments: (worktreeId: string) => Promise<boolean>
clearDiffCommentsForFile: (worktreeId: string, filePath: string) => Promise<boolean>
}
function generateId(): string {
@ -315,5 +317,40 @@ export const createDiffCommentsSlice: StateCreator<AppState, [], [], DiffComment
console.error('Failed to persist diff comments:', err)
rollback(set, worktreeId, result.previous, result.next)
}
},
clearDiffComments: async (worktreeId) => {
const result = mutateComments(set, worktreeId, (existing) =>
existing.length === 0 ? null : []
)
if (!result) {
return true
}
try {
await enqueuePersist(worktreeId, get)
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
rollback(set, worktreeId, result.previous, result.next)
return false
}
},
clearDiffCommentsForFile: async (worktreeId, filePath) => {
const result = mutateComments(set, worktreeId, (existing) => {
const next = existing.filter((c) => c.filePath !== filePath)
return next.length === existing.length ? null : next
})
if (!result) {
return true
}
try {
await enqueuePersist(worktreeId, get)
return true
} catch (err) {
console.error('Failed to persist diff comments:', err)
rollback(set, worktreeId, result.previous, result.next)
return false
}
}
})