diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 08df80501..dffa6e369 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; } diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index f94822f20..4617e368a 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -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(null) + const [isClearingDiffComments, setIsClearingDiffComments] = useState(false) const handleCopyDiffComments = useCallback(async (): Promise => { 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 => { + 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('all') const [collapsedSections, setCollapsedSections] = useState>(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 { )} + + + + + + + + + + More note actions + + + + + { + if (!activeWorktreeId || diffCommentCount === 0) { + return + } + setPendingDiffCommentsClear({ kind: 'all', worktreeId: activeWorktreeId }) + }} + > + + Clear all notes... + + + {diffCommentsExpanded && ( void deleteDiffComment(activeWorktreeId, id)} onOpen={(comment) => handleOpenComment(comment)} + onClearFile={(filePath) => + setPendingDiffCommentsClear({ + kind: 'file', + worktreeId: activeWorktreeId, + filePath + }) + } /> )} @@ -2443,6 +2564,43 @@ function SourceControlInner(): React.JSX.Element { )} + { + if (!open && !isClearingDiffComments) { + setPendingDiffCommentsClear(null) + } + }} + > + + + Clear Notes + + {pendingDiffCommentsClearDescription} + + + + + + + + + { @@ -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({
{groups.map(([filePath, list]) => (
- +
+ + +
    {list.map((c) => (
  • & Pick): 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() + }) +}) diff --git a/src/renderer/src/store/slices/diffComments.ts b/src/renderer/src/store/slices/diffComments.ts index 410d0d7fb..a8f89625c 100644 --- a/src/renderer/src/store/slices/diffComments.ts +++ b/src/renderer/src/store/slices/diffComments.ts @@ -10,6 +10,8 @@ export type DiffCommentsSlice = { addDiffComment: (input: Omit) => Promise updateDiffComment: (worktreeId: string, commentId: string, body: string) => Promise deleteDiffComment: (worktreeId: string, commentId: string) => Promise + clearDiffComments: (worktreeId: string) => Promise + clearDiffCommentsForFile: (worktreeId: string, filePath: string) => Promise } function generateId(): string { @@ -315,5 +317,40 @@ export const createDiffCommentsSlice: StateCreator { + 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 + } } })