Add diff note bulk clear
This commit is contained in:
parent
97e6f48256
commit
46331f881f
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue