diff --git a/.gitignore b/.gitignore index ee732147..54d981b3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,19 @@ # Build artifacts tsconfig.*.tsbuildinfo +# TypeScript emit artifacts next to sources (tsc produced these accidentally; +# real source lives in .ts/.tsx). Hand-authored declaration files are +# re-included below. +src/**/*.js +src/**/*.d.ts +/electron.vite.config.js +/electron.vite.config.d.ts +!src/main/types/hosted-git-info.d.ts +!src/preload/api-types.d.ts +!src/preload/index.d.ts +!src/renderer/src/env.d.ts +!src/renderer/src/mermaid.d.ts + # Dependencies node_modules/ diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 83747730..3804eb4f 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -169,7 +169,11 @@ export function mergeWorktree( isUnread: meta?.isUnread ?? false, isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, - lastActivityAt: meta?.lastActivityAt ?? 0 + lastActivityAt: meta?.lastActivityAt ?? 0, + // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in + // shared/types) and forwarded verbatim so the renderer store mirrors + // on-disk state. `undefined` here means the worktree has no comments yet. + diffComments: meta?.diffComments } } diff --git a/src/preload/api-types.d.ts b/src/preload/api-types.d.ts index 7719a66e..1a33968d 100644 --- a/src/preload/api-types.d.ts +++ b/src/preload/api-types.d.ts @@ -269,7 +269,11 @@ export type PreloadApi = { } repos: { list: () => Promise - add: (args: { path: string; kind?: 'git' | 'folder' }) => Promise + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. + add: (args: { + path: string + kind?: 'git' | 'folder' + }) => Promise<{ repo: Repo } | { error: string }> remove: (args: { repoId: string }) => Promise update: (args: { repoId: string @@ -281,12 +285,13 @@ export type PreloadApi = { pickDirectory: () => Promise clone: (args: { url: string; destination: string }) => Promise cloneAbort: () => Promise + // Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`. addRemote: (args: { connectionId: string remotePath: string displayName?: string kind?: 'git' | 'folder' - }) => Promise + }) => Promise<{ repo: Repo } | { error: string }> onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void getGitUsername: (args: { repoId: string }) => Promise getBaseRefDefault: (args: { repoId: string }) => Promise diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c9519d68..b01d8cf0 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -104,11 +104,6 @@ type GhApi = { baseSha: string }) => Promise listIssues: (args: { repoPath: string; limit?: number }) => Promise - createIssue: (args: { - repoPath: string - title: string - body: string - }) => Promise<{ ok: true; number: number; url: string } | { ok: false; error: string }> listWorkItems: (args: { repoPath: string limit?: number diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 0747110a..67f300e7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -885,3 +885,158 @@ .animate-update-card-exit { animation: update-card-exit 150ms ease-in both; } + +/* ── Diff comment decorations ────────────────────────────────────── */ + +/* Why: the "+" button is appended into Monaco's editor DOM node by + useDiffCommentDecorator. Keep it visually subtle until hovered so it does + not distract from the diff itself. Left position overlaps the glyph margin + so the affordance reads as "add comment on this line". */ +.orca-diff-comment-add-btn { + position: absolute; + left: 4px; + width: 18px; + height: 18px; + display: none; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border) 60%, transparent); + border-radius: 4px; + background: var(--background); + color: var(--muted-foreground); + cursor: pointer; + z-index: 5; + opacity: 0.7; + transition: + opacity 100ms ease, + color 100ms ease, + background-color 100ms ease; +} + +.orca-diff-comment-add-btn:hover { + opacity: 1; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 12%, var(--background)); +} + +.orca-diff-comment-add-btn:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.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; + box-sizing: border-box; +} + +.orca-diff-comment-inline > .orca-diff-comment-card { + max-width: 420px; +} + +.orca-diff-comment-card { + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--muted) 40%, var(--background)); + padding: 6px 8px; + font-family: var(--font-sans, system-ui, sans-serif); +} + +.orca-diff-comment-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} + +.orca-diff-comment-meta { + font-size: 11px; + color: var(--muted-foreground); + font-weight: 500; +} + +.orca-diff-comment-delete { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px; + border-radius: 4px; + border: 1px solid transparent; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; +} + +.orca-diff-comment-delete:hover { + color: var(--destructive); + border-color: color-mix(in srgb, var(--destructive) 40%, transparent); + background: color-mix(in srgb, var(--destructive) 10%, transparent); +} + +.orca-diff-comment-body { + font-size: 12px; + line-height: 1.4; + color: var(--foreground); + white-space: pre-wrap; + word-break: break-word; +} + +/* Popover for entering a new comment. Positioned absolutely within the + section container so it tracks the clicked line via `top` style. Anchored + near the content (past the gutter) rather than the far right, so it reads + as attached to the line it comments on instead of floating in empty space. */ +.orca-diff-comment-popover { + position: absolute; + left: 56px; + right: 24px; + max-width: 420px; + z-index: 1000; + padding: 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--popover, var(--background)); + color: var(--popover-foreground, var(--foreground)); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + gap: 6px; +} + +.orca-diff-comment-popover-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted-foreground); +} + +.orca-diff-comment-popover-textarea { + width: 100%; + min-height: 56px; + max-height: 240px; + resize: none; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--background); + color: var(--foreground); + font-family: inherit; + font-size: 12px; + line-height: 1.4; + outline: none; +} + +.orca-diff-comment-popover-textarea:focus { + border-color: color-mix(in srgb, var(--primary) 60%, var(--border)); +} + +.orca-diff-comment-popover-footer { + display: flex; + justify-content: flex-end; + gap: 6px; +} diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx new file mode 100644 index 00000000..cb8ed287 --- /dev/null +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx @@ -0,0 +1,37 @@ +import { Trash } from 'lucide-react' + +// Why: the saved-comment card lives inside a Monaco view zone's DOM node. +// useDiffCommentDecorator creates a React root per zone and renders this +// component into it so we can use normal lucide icons and JSX instead of +// hand-built DOM + inline SVG strings. + +type Props = { + lineNumber: number + body: string + onDelete: () => void +} + +export function DiffCommentCard({ lineNumber, body, onDelete }: Props): React.JSX.Element { + return ( +
+
+ Comment · line {lineNumber} + +
+
{body}
+
+ ) +} diff --git a/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx new file mode 100644 index 00000000..30db790b --- /dev/null +++ b/src/renderer/src/components/diff-comments/DiffCommentPopover.tsx @@ -0,0 +1,150 @@ +import { useEffect, useId, useRef, useState } from 'react' +import { Button } from '@/components/ui/button' + +// Why: rendered as a DOM sibling overlay inside the editor container rather +// than as a Monaco content widget because it owns a React textarea with +// auto-resize behaviour. Positioning mirrors what useDiffCommentDecorator does +// for the "+" button so scroll updates from the parent keep the popover +// aligned with its anchor line. + +type Props = { + lineNumber: number + top: number + onCancel: () => void + onSubmit: (body: string) => Promise +} + +export function DiffCommentPopover({ + lineNumber, + top, + onCancel, + onSubmit +}: Props): React.JSX.Element { + const [body, setBody] = useState('') + // Why: `submitting` prevents duplicate comment rows when the user + // double-clicks the Comment button or hits Cmd/Ctrl+Enter twice before the + // IPC round-trip resolves. Iteration 1 made submission async and keeps the + // popover open on failure (to preserve the draft); that widened the window + // between the first click and `setPopover(null)` during which a second + // trigger would call `addDiffComment` again and produce a second row with a + // fresh id/createdAt. Tracked in React state (not a ref) so the button can + // reflect the in-flight status to the user. + const [submitting, setSubmitting] = useState(false) + const textareaRef = useRef(null) + const popoverRef = useRef(null) + // Why: stash onCancel in a ref so the document mousedown listener below can + // read the freshest callback without listing `onCancel` in its dependency + // array. Parents (DiffSectionItem, DiffViewer) pass a new arrow function on + // every render and the popover re-renders frequently (scroll tracking updates + // `top`, font zoom, etc.), which would otherwise tear down and re-attach the + // document listener on every parent render. Mirrors the pattern in + // useDiffCommentDecorator.tsx. + const onCancelRef = useRef(onCancel) + onCancelRef.current = onCancel + // Why: stable id per-instance so multiple popovers (should they ever coexist) + // don't collide on aria-labelledby references. Screen readers announce the + // "Line N" label as the dialog's accessible name. + const labelId = useId() + + useEffect(() => { + textareaRef.current?.focus() + }, []) + + // Why: Monaco's editor area does not bubble a synthetic React click up to + // the popover's onClick. Without a document-level mousedown listener, the + // popover has no way to detect clicks outside its own bounds. We keep the + // `onMouseDown={ev.stopPropagation()}` on the popover root so that this + // listener sees outside-clicks only. + useEffect(() => { + const onDocumentMouseDown = (ev: MouseEvent): void => { + if (!popoverRef.current) { + return + } + if (popoverRef.current.contains(ev.target as Node)) { + return + } + // Why: read the latest onCancel from the ref rather than closing over it + // so the listener does not need to be re-registered on every parent + // render (see onCancelRef comment above). + onCancelRef.current() + } + document.addEventListener('mousedown', onDocumentMouseDown) + return () => { + document.removeEventListener('mousedown', onDocumentMouseDown) + } + }, []) + + const autoResize = (el: HTMLTextAreaElement): void => { + el.style.height = 'auto' + el.style.height = `${Math.min(el.scrollHeight, 240)}px` + } + + const handleSubmit = async (): Promise => { + if (submitting) { + return + } + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + await onSubmit(trimmed) + } finally { + setSubmitting(false) + } + } + + return ( +
ev.stopPropagation()} + onClick={(ev) => ev.stopPropagation()} + > +
+ Line {lineNumber} +
+