From 321a8929a6181530af9a9217cdd4e49c1e405d87 Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Mon, 1 Jun 2026 04:27:19 -0400 Subject: [PATCH 01/63] feat(review): GitHub PR review-sync backend Add syncPRReview in github.ts plus the review:sync IPC handler, the reviewSync backend method, and the shared ReviewSync* types. Sync pushes local line comments as a DRAFT pending review (addPullRequestReview / addPullRequestReviewThread, anchored RIGHT-side), marks locally-reviewed files as viewed (mark-only, never unmark), then pulls the canonical comment set (published + the user's pending drafts) and unions GitHub's per-file viewerViewedState back in. A pull-only mode fetches comments without pushing (used by auto-sync on review open). Best-effort: rejected comments are counted failed and kept local; resolves token + upstream repo + PR number like pr:merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/main/github.ts | 269 +++++++++++++++++++++++++++++++++- src/main/index.ts | 34 ++++- src/renderer/build-backend.ts | 3 + src/renderer/types.ts | 5 +- src/shared/github-types.ts | 38 +++++ 5 files changed, 345 insertions(+), 4 deletions(-) diff --git a/src/main/github.ts b/src/main/github.ts index 1f6ce15f..a233bd84 100644 --- a/src/main/github.ts +++ b/src/main/github.ts @@ -4,7 +4,13 @@ import { log, formatErr } from './debug' import { getCachedToken, invalidateTokenCache, resolveGitHubToken } from './github-auth' import { trackedFetch } from './github-recorder' import type { CheckStatus, PRReview, PRStatus } from '../shared/state/prs' -import type { PRSummary, PRMetadata } from '../shared/github-types' +import type { + PRSummary, + PRMetadata, + ReviewSyncComment, + ReviewSyncInput, + ReviewSyncResult +} from '../shared/github-types' export type { CheckStatus, PRReview, PRStatus, PRSummary, PRMetadata } @@ -1173,6 +1179,267 @@ export async function getPRMetadata( } } +// --- Review sync ----------------------------------------------------------- + +async function ghRequest( + token: string, + url: string, + method: string, + body?: unknown +): Promise<{ ok: boolean; status: number; json: unknown }> { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Harness', + 'X-GitHub-Api-Version': '2022-11-28', + Authorization: `Bearer ${token}` + } + if (body !== undefined) headers['Content-Type'] = 'application/json' + const res = await trackedFetch(url, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }) + let json: unknown = null + try { + json = await res.json() + } catch { + /* empty body */ + } + return { ok: res.ok, status: res.status, json } +} + +async function ghGraphQL( + token: string, + query: string, + variables: Record +): Promise<{ ok: boolean; data?: unknown; error?: string }> { + const res = await trackedFetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': 'Harness', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ query, variables }) + }) + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` } + const json = (await res.json()) as { data?: unknown; errors?: { message: string }[] } + if (json.errors && json.errors.length > 0) { + return { ok: false, data: json.data, error: json.errors.map((e) => e.message).join('; ') } + } + return { ok: true, data: json.data } +} + +/** Push local review comments (as a DRAFT/pending review) + per-file viewed + * state to a GitHub PR, then pull the canonical comment set back. + * + * Comments are added to the viewer's pending review so they stay unpublished + * until the user submits the review on GitHub. A user can only have one + * pending review per PR, so we reuse an existing one (adding threads to it) + * or create a fresh one. Line comments use the RIGHT side; file-level + * (line 0) comments aren't supported as drafts and are left local. + * + * Best-effort: a comment GitHub rejects (e.g. a line outside the diff) is + * counted as failed and kept locally rather than aborting the sync. + * Comments already carrying a remoteId are assumed pushed and not re-sent. */ +export async function syncPRReview( + token: string, + owner: string, + repo: string, + prNumber: number, + input: ReviewSyncInput +): Promise { + const base = `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}` + const passthrough = { + comments: input.comments, + reviewedFiles: input.reviewedFiles, + pushed: 0, + failed: 0 + } + + // 1. PR node id (for the draft-review + viewed-state GraphQL APIs). + let nodeId = '' + try { + const pr = await ghRequest(token, base, 'GET') + if (!pr.ok) { + return { ok: false, error: `Could not load PR #${prNumber} (HTTP ${pr.status})`, ...passthrough } + } + nodeId = (pr.json as { node_id?: string }).node_id ?? '' + } catch (err) { + return { ok: false, error: formatErr(err), ...passthrough } + } + if (!nodeId) return { ok: false, error: 'Could not resolve PR node id', ...passthrough } + + // 2. Find the viewer's existing pending review, if any. Pending reviews are + // only returned to their author, so any PENDING entry here is ours. + let pendingReviewNodeId = '' + let pendingReviewId: number | null = null + try { + const res = await ghRequest(token, `${base}/reviews?per_page=100`, 'GET') + if (res.ok && Array.isArray(res.json)) { + const pending = (res.json as Array<{ id: number; node_id: string; state: string }>).find( + (r) => r.state === 'PENDING' + ) + if (pending) { + pendingReviewNodeId = pending.node_id + pendingReviewId = pending.id + } + } + } catch (err) { + log('github', 'list reviews failed', formatErr(err)) + } + + // 3. Push un-synced line comments as drafts on the pending review. + const toPush = input.pullOnly + ? [] + : input.comments.filter((c) => c.remoteId === undefined && c.lineNumber > 0) + let pushed = 0 + let failed = 0 + if (toPush.length > 0) { + if (pendingReviewNodeId) { + // Existing pending review — add each comment as a thread. + for (const c of toPush) { + const r = await ghGraphQL( + token, + `mutation($rid:ID!,$path:String!,$line:Int!,$body:String!){ + addPullRequestReviewThread(input:{pullRequestReviewId:$rid,path:$path,line:$line,side:RIGHT,body:$body}){ thread { id } } + }`, + { rid: pendingReviewNodeId, path: c.filePath, line: c.lineNumber, body: c.body } + ) + if (r.ok) pushed++ + else { + failed++ + log('github', `draft thread rejected ${c.filePath}:${c.lineNumber}: ${r.error}`) + } + } + } else { + // No pending review yet — create one carrying all the threads. + const threads = toPush.map((c) => ({ + path: c.filePath, + line: c.lineNumber, + side: 'RIGHT', + body: c.body + })) + const r = await ghGraphQL( + token, + `mutation($prId:ID!,$threads:[DraftPullRequestReviewThread!]){ + addPullRequestReview(input:{pullRequestId:$prId,threads:$threads}){ pullRequestReview { databaseId } } + }`, + { prId: nodeId, threads } + ) + if (r.ok) { + pushed += toPush.length + const dbId = ( + r.data as { addPullRequestReview?: { pullRequestReview?: { databaseId?: number } } } | undefined + )?.addPullRequestReview?.pullRequestReview?.databaseId + if (typeof dbId === 'number') pendingReviewId = dbId + } else { + failed += toPush.length + log('github', `create draft review failed: ${r.error}`) + } + } + } + + // 4. Push viewed state — only MARK locally-reviewed files. Never unmark: + // a file viewed on GitHub (but not locally) must not get cleared. + // Skipped on a pull-only sync. + const reviewedSet = new Set(input.reviewedFiles) + if (!input.pullOnly) { + for (const path of input.files) { + if (!reviewedSet.has(path)) continue + const r = await ghGraphQL( + token, + 'mutation($id:ID!,$p:String!){markFileAsViewed(input:{pullRequestId:$id,path:$p}){clientMutationId}}', + { id: nodeId, p: path } + ) + if (!r.ok) log('github', `mark viewed failed for ${path}: ${r.error}`) + } + } + + // 4b. Pull GitHub's viewed state and union it with the local set, so files + // viewed on GitHub stay viewed (and vice-versa). Viewed state is + // additive — un-viewing isn't propagated. + const ghViewed = new Set() + const viewedQuery = await ghGraphQL( + token, + 'query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){pullRequest(number:$num){files(first:100){nodes{path viewerViewedState}}}}}', + { o: owner, n: repo, num: prNumber } + ) + if (viewedQuery.ok) { + const nodes = + ( + viewedQuery.data as { + repository?: { + pullRequest?: { files?: { nodes?: Array<{ path: string; viewerViewedState?: string }> } } + } + } + )?.repository?.pullRequest?.files?.nodes ?? [] + for (const f of nodes) if (f.viewerViewedState === 'VIEWED') ghViewed.add(f.path) + } else { + log('github', `viewed-state query failed: ${viewedQuery.error}`) + } + const mergedReviewed = [...new Set([...input.reviewedFiles, ...ghViewed])] + + // 5. Pull the canonical comment set: published comments + our pending + // review's drafts (so freshly-pushed drafts get an id and don't re-post). + const pulled: ReviewSyncComment[] = [] + type ApiComment = { + id: number + path: string + line?: number | null + original_line?: number | null + body?: string + created_at?: string + html_url?: string + user?: { login?: string; avatar_url?: string } + } + const collect = (arr: ApiComment[]): void => { + for (const rc of arr) { + pulled.push({ + filePath: rc.path, + lineNumber: rc.line ?? rc.original_line ?? 0, + body: rc.body ?? '', + remoteId: rc.id, + author: rc.user?.login, + authorAvatarUrl: rc.user?.avatar_url, + createdAt: rc.created_at, + htmlUrl: rc.html_url + }) + } + } + try { + const published = await ghRequest(token, `${base}/comments?per_page=100`, 'GET') + if (published.ok && Array.isArray(published.json)) collect(published.json as ApiComment[]) + } catch (err) { + log('github', 'list review comments error', formatErr(err)) + } + if (pendingReviewId !== null) { + try { + const drafts = await ghRequest(token, `${base}/reviews/${pendingReviewId}/comments?per_page=100`, 'GET') + if (drafts.ok && Array.isArray(drafts.json)) collect(drafts.json as ApiComment[]) + } catch (err) { + log('github', 'list pending review comments error', formatErr(err)) + } + } + + // Keep local comments not represented in the pulled set — file-level + // comments (no draft support) and any that failed to push — so they're + // not lost and can retry next sync. + const pulledKey = new Set(pulled.map((c) => `${c.filePath}:${c.lineNumber}:${c.body}`)) + const keptLocal = input.comments.filter( + (c) => c.remoteId === undefined && !pulledKey.has(`${c.filePath}:${c.lineNumber}:${c.body}`) + ) + + return { + ok: true, + comments: [...pulled, ...keptLocal], + reviewedFiles: mergedReviewed, + pushed, + failed + } +} + /** Test a token by making an authenticated request to /user. Returns the username if valid. */ export async function testToken(token: string): Promise<{ ok: boolean; username?: string; error?: string }> { try { diff --git a/src/main/index.ts b/src/main/index.ts index 32718428..0ef12703 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,8 @@ import { getWeeklyStats } from './weekly-stats' import type { TerminalTab, PaneNode, PaneLeaf } from '../shared/state/terminals' import { getLeaves, mapLeaves } from '../shared/state/terminals' import { listWorktrees, listBranches, continueWorktree, isWorktreeDirty, defaultWorktreeDir, getChangedFiles, getFileDiff, getBranchCommits, getCommitDiff, getCommitMeta, getCommitChangedFiles, getCommitFileDiffSides, getCommitRangeChangedFiles, getCommitRangeFileDiffSides, getMainWorktreeStatus, prepareMainForMerge, mergeWorktreeLocally, getBranchSha, previewMergeConflicts, getBranchDiffStats, listAllFiles, listRecentCommitShas, readWorktreeFile, readWorktreeFileBinary, writeWorktreeFile, getFileDiffSides, getCurrentBranch, symlinkClaudeSettings, type MergeStrategy } from './worktree' -import { listOpenPRs, testToken, starRepo, unstarRepo, isRepoStarred, mergePR, approvePR, getRepoInfo, type GitHubMergeMethod, type MergePRResult } from './github' +import { listOpenPRs, testToken, starRepo, unstarRepo, isRepoStarred, mergePR, approvePR, getRepoInfo, getRepoContext, syncPRReview, type GitHubMergeMethod, type MergePRResult } from './github' +import type { ReviewSyncInput, ReviewSyncResult } from '../shared/github-types' import { AVAILABLE_EDITORS, DEFAULT_EDITOR_ID, openInEditor } from './editor' import { setSecret, getSecret, hasSecret, deleteSecret } from './secrets' import { resolveGitHubToken, getTokenSource, invalidateTokenCache, getCachedToken } from './github-auth' @@ -1589,6 +1590,37 @@ function registerIpcHandlers(): void { } ) + transport.onRequest( + 'review:sync', + async (_ctx, worktreePath: string, input: ReviewSyncInput): Promise => { + const passthrough = { + comments: input.comments, + reviewedFiles: input.reviewedFiles, + pushed: 0, + failed: 0 + } + const token = getCachedToken() + if (!token) { + return { ok: false, error: 'Connect a GitHub token in Settings to sync', ...passthrough } + } + // PRs (incl. fork PRs) live on the upstream repo — that's where the + // PR number the poller cached came from. + const ctx = await getRepoContext(worktreePath) + if (!ctx) { + return { ok: false, error: 'Could not resolve GitHub repo from worktree origin', ...passthrough } + } + let prNumber = store.getSnapshot().state.prs.byPath[worktreePath]?.number + if (typeof prNumber !== 'number') { + await prPoller.refreshOne(worktreePath) + prNumber = store.getSnapshot().state.prs.byPath[worktreePath]?.number + } + if (typeof prNumber !== 'number') { + return { ok: false, error: 'No pull request found for this worktree', ...passthrough } + } + return syncPRReview(token, ctx.upstream.owner, ctx.upstream.repo, prNumber, input) + } + ) + transport.onRequest('stats:getWeekly', async (_ctx) => { const snap = store.getSnapshot().state return getWeeklyStats(snap.prs, snap.worktrees) diff --git a/src/renderer/build-backend.ts b/src/renderer/build-backend.ts index c2ccab45..cc6c4657 100644 --- a/src/renderer/build-backend.ts +++ b/src/renderer/build-backend.ts @@ -39,6 +39,7 @@ import type { StateEventListener } from '../shared/transport/transport' import type { ElectronAPI } from './types' +import type { ReviewSyncInput } from '../shared/github-types' export type { ElectronOnlyHelpers } @@ -222,6 +223,8 @@ export function buildBackend( listRepoPRs: (repoRoot: string) => req('prs:listOpen', repoRoot), mergePR: (worktreePath: string, method: 'merge' | 'squash' | 'rebase') => req('pr:merge', worktreePath, method), + reviewSync: (worktreePath: string, input: ReviewSyncInput) => + req('review:sync', worktreePath, input), approvePR: (worktreePath: string) => req('pr:approve', worktreePath), getWeeklyStats: () => req('stats:getWeekly'), diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 0ead0aa5..28e5f103 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -129,8 +129,8 @@ export type { PerfMetrics, PerfSample } import type { CheckStatus, PRReview, PRStatus } from '../shared/state/prs' export type { CheckStatus, PRReview, PRStatus } -import type { PRSummary, PRMetadata } from '../shared/github-types' -export type { PRSummary, PRMetadata } +import type { PRSummary, PRMetadata, ReviewSyncInput, ReviewSyncResult, ReviewSyncComment } from '../shared/github-types' +export type { PRSummary, PRMetadata, ReviewSyncInput, ReviewSyncResult, ReviewSyncComment } import type { BrowserState, BrowserTabState } from '../shared/state/browser' export type { BrowserState, BrowserTabState } @@ -267,6 +267,7 @@ export interface ElectronAPI { worktreePath: string ): Promise<{ ok: true } | { ok: false; error: string }> + reviewSync(worktreePath: string, input: ReviewSyncInput): Promise getWeeklyStats(): Promise getBranchCommits(worktreePath: string): Promise getCommitDiff(worktreePath: string, hash: string): Promise diff --git a/src/shared/github-types.ts b/src/shared/github-types.ts index dca5e86b..77039aed 100644 --- a/src/shared/github-types.ts +++ b/src/shared/github-types.ts @@ -28,3 +28,41 @@ export interface PRSummary { } export type PRMetadata = PRSummary + +/** A review comment shuttled between the renderer and GitHub. lineNumber is + * the 1-based modified-side line; 0 means a file-level comment. remoteId is + * the GitHub review-comment id — present once the comment has been posted + * or fetched, absent for a local comment that still needs pushing. */ +export interface ReviewSyncComment { + filePath: string + lineNumber: number + body: string + remoteId?: number + author?: string + authorAvatarUrl?: string + /** ISO timestamp the comment was created (from GitHub). */ + createdAt?: string + /** Link to the comment on GitHub. */ + htmlUrl?: string +} + +export interface ReviewSyncInput { + comments: ReviewSyncComment[] + reviewedFiles: string[] + files: string[] + /** Pull only — fetch PR comments without pushing local comments or viewed + * state. Used by the auto-sync on review open so it can't clobber GitHub + * state from an empty local review. */ + pullOnly?: boolean +} + +export interface ReviewSyncResult { + ok: boolean + error?: string + /** The reconciled comment set: everything now on the PR, plus any local + * comments that failed to post (so they're not lost). */ + comments: ReviewSyncComment[] + reviewedFiles: string[] + pushed: number + failed: number +} From 36245ff282104689cd176e957ab1a09c5aae11cc Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Mon, 1 Jun 2026 04:28:10 -0400 Subject: [PATCH 02/63] feat(review): in-app Review tab overhaul + diff/editor tab improvements Review tab (ReviewPane/ReviewDiffPane/ReviewFileTree): - Two-row toolbar: commit selector (with diff stat), Split/Unified + whitespace toggles, Copy/Sync/Send actions on top; reviewer status, comment dropdown, and files reviewed/remaining on the second row. - Split/Unified (s/d) and whitespace toggles; honor side-by-side in narrow panes (useInlineViewWhenSpaceIsLimited). All review shortcuts (j/k/[/]/r/s/d/c/?) live in one handler, gated on the tab being active. - Inline comments: markdown bodies, author avatar + @handle + relative time linking to GitHub, prominent styling, view-zones redrawn on editor (re)mount. 'c' comments the hovered line (or file-level). Comment dropdown + jump-to-first-comment button; jump scrolls reliably. - Sync button (drafts), status dot, disabled off "All commits"; auto pull-only sync on open. Reviewer avatars show state + top-level review (markdown) on click. Refresh on git changes. - "Viewed" button moved right; ClipboardCheck tab icon. Diff/editor tabs: - Diff tab view-only with Unified/Split/Full modes; Edit opens the editor tab; "Uncommitted" label; whitespace toggle. Committed files in Changed Files open the Review tab on that file. Section counts in Changed Files headers. - Editor: reliable Cmd/Ctrl+S (onKeyDown), Save moved left with unsaved badge, FileInput/FileOutput markdown toggle, warn before closing a tab with unsaved changes (dirty-tabs registry). - Distinct Diff/File tab icons. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/App.tsx | 7 + src/renderer/components/ChangedFilesPanel.tsx | 32 +- src/renderer/components/DiffView.tsx | 215 +++++--- src/renderer/components/FileView.tsx | 46 +- src/renderer/components/MonacoDiffEditor.tsx | 23 +- src/renderer/components/MonacoEditor.tsx | 14 +- src/renderer/components/ReviewDiffPane.tsx | 277 ++++++++-- src/renderer/components/ReviewFileTree.tsx | 35 +- src/renderer/components/ReviewPane.tsx | 506 ++++++++++++++++-- src/renderer/components/RightColumn.tsx | 3 + src/renderer/components/TerminalPanel.tsx | 12 +- src/renderer/components/WorkspaceView.tsx | 10 + src/renderer/dirty-tabs.ts | 19 + src/renderer/hooks/useTabHandlers.ts | 8 + src/renderer/review-open-file.ts | 46 ++ 15 files changed, 1048 insertions(+), 205 deletions(-) create mode 100644 src/renderer/dirty-tabs.ts create mode 100644 src/renderer/review-open-file.ts diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index d36f64f7..359b2236 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -25,6 +25,7 @@ import { QuestCard } from './components/QuestCard' import { WorkspaceView } from './components/WorkspaceView' import { QuakeTerminal } from './components/QuakeTerminal' import { RightColumn } from './components/RightColumn' +import { requestReviewFile } from './review-open-file' import { CollapsedSidebar } from './components/CollapsedSidebar' import { CollapsedRightPanel } from './components/CollapsedRightPanel' import { Settings } from './components/Settings' @@ -1569,6 +1570,7 @@ const setQuestStep = useCallback((next: QuestStep) => { onReorderTabs={handleReorderTabs} onMoveTabToPane={handleMoveTabToPane} onSendToAgent={handleSendToAgent} + onOpenFile={(_wtPath, filePath) => handleOpenFile(filePath)} topBarLeadingPx={TITLE_LEADING_PX} hideAppTitle={singleScreenMode} onTitleBlockEdge={isVisible ? handleTitleBlockEdge : undefined} @@ -1725,6 +1727,11 @@ const setQuestStep = useCallback((next: QuestStep) => { onOpenReview={() => { if (activeWorktreeId) void backend.panesOpenReview(activeWorktreeId) }} + onOpenReviewFile={(filePath) => { + if (!activeWorktreeId) return + requestReviewFile(activeWorktreeId, filePath) + void backend.panesOpenReview(activeWorktreeId) + }} onCollapse={() => setRightColumnHidden(true)} /> )} diff --git a/src/renderer/components/ChangedFilesPanel.tsx b/src/renderer/components/ChangedFilesPanel.tsx index 16798073..4bd39e74 100644 --- a/src/renderer/components/ChangedFilesPanel.tsx +++ b/src/renderer/components/ChangedFilesPanel.tsx @@ -13,6 +13,8 @@ interface ChangedFilesPanelProps { onOpenDiff: (filePath: string, staged: boolean, mode: Mode) => void onSendToAgent?: (text: string) => void onOpenReview?: () => void + /** Open the worktree's Review tab focused on this committed file. */ + onOpenReviewFile?: (filePath: string) => void } const STATUS_LABEL: Record = { @@ -36,7 +38,7 @@ interface ChangedFilesData { branch: ChangedFile[] } -export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview }: ChangedFilesPanelProps): JSX.Element { +export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview, onOpenReviewFile }: ChangedFilesPanelProps): JSX.Element { const backend = useBackend() const fetcher = useCallback(async (path: string): Promise => { const [working, branch] = await Promise.all([ @@ -58,7 +60,6 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO const stagedFiles = workingFiles.filter((f) => f.staged) const unstagedFiles = workingFiles.filter((f) => !f.staged) - const totalCount = workingFiles.length + branchFiles.length const actions = ( <> @@ -100,8 +101,11 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO {worktreePath && hasLoaded && ( <> {/* Uncommitted section */} -
- Uncommitted +
+ Uncommitted + {workingFiles.length > 0 && ( + {workingFiles.length} + )}
{workingFiles.length === 0 ? (
No changes
@@ -139,8 +143,11 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO )} {/* Branch diff section */} -
- Committed +
+ Committed + {branchFiles.length > 0 && ( + {branchFiles.length} + )}
{branchFiles.length === 0 ? (
No commits on this branch yet
@@ -150,7 +157,11 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO key={`branch-${file.path}`} file={file} worktreePath={worktreePath} - onClick={() => onOpenDiff(file.path, false, 'branch')} + onClick={() => + onOpenReviewFile + ? onOpenReviewFile(file.path) + : onOpenDiff(file.path, false, 'branch') + } onSendToAgent={onSendToAgent} /> )) @@ -159,13 +170,6 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO )}
- {totalCount > 0 && ( -
- {workingFiles.length > 0 && {workingFiles.length} uncommitted} - {workingFiles.length > 0 && branchFiles.length > 0 && · } - {branchFiles.length > 0 && {branchFiles.length} committed} -
- )} ) } diff --git a/src/renderer/components/DiffView.tsx b/src/renderer/components/DiffView.tsx index b9b85cc1..4a104256 100644 --- a/src/renderer/components/DiffView.tsx +++ b/src/renderer/components/DiffView.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { ArrowRightFromLine, AtSign, Save, WrapText } from 'lucide-react' +import { useEffect, useState } from 'react' +import { ArrowRightFromLine, AtSign, Code2, Pencil, Pilcrow, WrapText } from 'lucide-react' import type { CommitDiff, FileDiffSides } from '../types' import { Tooltip } from './Tooltip' import { detectLanguage, highlightLine } from '../syntax' import { MonacoDiffEditor } from './MonacoDiffEditor' +import { MonacoEditor } from './MonacoEditor' import { useSettings } from '../store' import { useBackend } from '../backend' import { scaledEditorFontSize } from '../../shared/state/settings' @@ -14,9 +15,20 @@ interface DiffViewProps { staged?: boolean branchDiff?: boolean commitHash?: string + /** True when this diff tab is the active/visible tab in its pane. + * Gates the d/f/s view-mode keyboard shortcuts so background tabs + * don't react to them. */ + active?: boolean + /** Open the dedicated editor (file) tab for this path. The diff tab is + * view-only; the Edit action routes here so a file is only ever + * editable in one place — never two tabs writing the same file. */ + onOpenEditor?: (filePath: string) => void onSendToAgent?: (text: string) => void } +/** Diff-tab view modes for a single file. */ +type FileViewMode = 'unified' | 'split' | 'full' + export function DiffView(props: DiffViewProps): JSX.Element { if (props.commitHash) return if (props.filePath) return @@ -32,35 +44,48 @@ function FileDiffView({ filePath, staged, branchDiff, + active, + onOpenEditor, onSendToAgent }: DiffViewProps): JSX.Element { const backend = useBackend() const settings = useSettings() const [sides, setSides] = useState(null) const [loading, setLoading] = useState(true) - const [modifiedValue, setModifiedValue] = useState('') - const [savedValue, setSavedValue] = useState('') - const [saveError, setSaveError] = useState(null) + const [viewMode, setViewMode] = useState('unified') + const [showWhitespace, setShowWhitespace] = useState(false) const [wordWrap, setWordWrap] = useState(false) - const valueRef = useRef(modifiedValue) - const savedRef = useRef(savedValue) - valueRef.current = modifiedValue - savedRef.current = savedValue - - // Only unstaged working diffs have a modified side that IS the working - // tree — the only place edits can meaningfully land. Everything else - // (staged / branch) is read-only. - const editable = !staged && !branchDiff - const dirty = editable && modifiedValue !== savedValue + // The diff tab is view-only. Editing happens in the dedicated editor + // (file) tab via onOpenEditor, so a file is never writable from two + // tabs at once. d / f / s swap the view in place. Honor Monaco focus: + // a read-only editor lets these bubble, but a real form field shouldn't. + useEffect(() => { + if (!active) return + const onKey = (e: KeyboardEvent): void => { + if (e.metaKey || e.ctrlKey || e.altKey) return + const t = e.target as HTMLElement | null + const inMonaco = !!t?.closest?.('.monaco-editor') + if (!inMonaco && (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement)) return + if (e.key === 'd') { + e.preventDefault() + setViewMode('unified') + } else if (e.key === 's') { + e.preventDefault() + setViewMode('split') + } else if (e.key === 'f') { + e.preventDefault() + setViewMode('full') + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [active]) useEffect(() => { let cancelled = false setLoading(true) setSides(null) - setModifiedValue('') - setSavedValue('') - setSaveError(null) setWordWrap(false) if (!filePath) return backend @@ -68,8 +93,6 @@ function FileDiffView({ .then((r) => { if (cancelled) return setSides(r) - setModifiedValue(r.modified) - setSavedValue(r.modified) setLoading(false) }) return () => { @@ -77,30 +100,6 @@ function FileDiffView({ } }, [worktreePath, filePath, staged, branchDiff]) - const save = useCallback(async () => { - if (!filePath || !editable) return - const current = valueRef.current - if (current === savedRef.current) return - setSaveError(null) - const r = await backend.writeWorktreeFile(worktreePath, filePath, current) - if (r.ok) { - setSavedValue(current) - } else { - setSaveError(r.error || 'Save failed') - } - }, [worktreePath, filePath, editable]) - - useEffect(() => { - const handler = (e: BeforeUnloadEvent): void => { - if (valueRef.current !== savedRef.current) { - e.preventDefault() - e.returnValue = '' - } - } - window.addEventListener('beforeunload', handler) - return () => window.removeEventListener('beforeunload', handler) - }, []) - if (loading) { return (
@@ -125,7 +124,7 @@ function FileDiffView({ ) } - if (!dirty && sides.original === sides.modified) { + if (sides.original === sides.modified) { return (
No changes @@ -133,28 +132,40 @@ function FileDiffView({ ) } - const readOnlyBanner = branchDiff - ? 'Viewing branch diff (base…HEAD) — read-only.' - : staged - ? 'Viewing staged diff — read-only. Unstage the file to edit here.' - : null + const referenceLine = + onSendToAgent && filePath ? (ln: number) => onSendToAgent(`@${filePath}:${ln} `) : undefined return (
+
+ setViewMode('split')} /> + setViewMode('unified')} /> + setViewMode('full')} /> +
+ + {viewMode !== 'full' && ( + + + + )} + {filePath} - {dirty && } - {saveError && ( - - {saveError} - - )} + {!branchDiff && Uncommitted} {staged && !branchDiff && staged} {branchDiff && branch} {!sides.originalExists && new file} @@ -167,14 +178,14 @@ function FileDiffView({ {wordWrap ? : } - {editable && ( - + + {onOpenEditor && filePath && sides.modifiedExists && ( + )} @@ -188,34 +199,72 @@ function FileDiffView({ )} + {filePath && ( + + + + )}
- {readOnlyBanner && ( -
- {readOnlyBanner} -
- )}
- onSendToAgent(`@${filePath}:${ln} `) - : undefined - } - /> + {viewMode === 'full' ? ( + + ) : ( + + )}
) } +export function ModeButton({ + active, + label, + hint, + onClick +}: { + active: boolean + label: string + hint: string + onClick: () => void +}): JSX.Element { + return ( + + + + ) +} + // Commit diffs are whole-commit, multi-file text. Monaco's inline diff is // per-file, so commit view keeps the legacy parsed rendering for now. // Fold into Monaco by rendering one editor per changed file in a follow-up. diff --git a/src/renderer/components/FileView.tsx b/src/renderer/components/FileView.tsx index 00be14e4..51e9e09e 100644 --- a/src/renderer/components/FileView.tsx +++ b/src/renderer/components/FileView.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowRightFromLine, AtSign, Code2, Eye, Save, WrapText } from 'lucide-react' +import { ArrowRightFromLine, AtSign, Code2, FileInput, FileOutput, Save, WrapText } from 'lucide-react' import ReactMarkdown from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' @@ -9,9 +9,11 @@ import { MonacoEditor } from './MonacoEditor' import { useSettings } from '../store' import { useBackend } from '../backend' import { scaledEditorFontSize } from '../../shared/state/settings' +import { setTabDirty, clearTabDirty } from '../dirty-tabs' import 'highlight.js/styles/github-dark.css' interface FileViewProps { + tabId: string worktreePath: string filePath?: string onSendToAgent?: (text: string) => void @@ -48,7 +50,7 @@ function formatBytes(n: number): string { return `${(n / (1024 * 1024)).toFixed(1)} MB` } -export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProps): JSX.Element { +export function FileView({ tabId, worktreePath, filePath, onSendToAgent }: FileViewProps): JSX.Element { const backend = useBackend() const settings = useSettings() const [result, setResult] = useState(null) @@ -68,6 +70,15 @@ export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProp const dirty = value !== savedValue + // Publish unsaved state so handleCloseTab can warn before closing this + // tab; clear the entry when the tab unmounts. + useEffect(() => { + setTabDirty(tabId, dirty) + }, [tabId, dirty]) + useEffect(() => { + return () => clearTabDirty(tabId) + }, [tabId]) + const mode: ViewerMode = useMemo( () => (filePath ? detectViewerMode(filePath) : 'text'), [filePath] @@ -221,7 +232,7 @@ export function FileView({ worktreePath, filePath, onSendToAgent }: FileViewProp onClick={() => setMarkdownAsCode((v) => !v)} className="shrink-0 text-faint hover:text-fg cursor-pointer" > - {markdownAsCode ? : } + {markdownAsCode ? : } ) : null @@ -322,13 +333,29 @@ function FileHeader({ const backend = useBackend() return (
+ {showSave && ( + + + + )} {filePath} - {dirty && } {saveError && ( @@ -339,17 +366,6 @@ function FileHeader({ {truncated && truncated} {wrapToggleControl} {toggleControl} - {showSave && ( - - - - )} {onSendToAgent && ( +
+ {comment.authorAvatarUrl ? ( + {comment.author + ) : comment.author ? ( + + {comment.author.slice(0, 1)} + + ) : null} + {comment.author && ( + @{comment.author} + )} + {timeStr && + (comment.htmlUrl ? ( + + {timeStr} + + ) : ( + {timeStr} + ))} + {comment.remoteId === undefined && ( + + )} +
+
+ {comment.body} +
) } @@ -116,8 +196,8 @@ function InlineCommentInput({ }} >
- - Line {lineNumber} + + {lineNumber === 0 ? 'File comment' : `Line ${lineNumber}`} - - {file.path} @@ -421,6 +568,34 @@ export function ReviewDiffPane({ {wordWrap ? : } + + {comments.length > 0 && ( + + + + )} + + + +
{/* Diff with inline comments via view zones */} @@ -436,6 +611,8 @@ export function ReviewDiffPane({ modified={sides.modified} filePath={file.path} readOnly + renderSideBySide={sideBySide} + ignoreTrimWhitespace={ignoreTrimWhitespace} fontFamily={settings.terminalFontFamily || undefined} fontSize={scaledEditorFontSize(settings.terminalFontSize, settings.uiScale)} wordWrap={wordWrap} diff --git a/src/renderer/components/ReviewFileTree.tsx b/src/renderer/components/ReviewFileTree.tsx index 0beef026..dab69c5d 100644 --- a/src/renderer/components/ReviewFileTree.tsx +++ b/src/renderer/components/ReviewFileTree.tsx @@ -8,6 +8,16 @@ export interface ReviewComment { lineNumber: number body: string timestamp: number + /** GitHub review-comment id once pushed/fetched. Absent = local-only. */ + remoteId?: number + /** GitHub login of the author, for comments fetched from the PR. */ + author?: string + /** Author avatar URL, for comments fetched from the PR. */ + authorAvatarUrl?: string + /** ISO creation timestamp, for comments fetched from the PR. */ + createdAt?: string + /** Link to the comment on GitHub. */ + htmlUrl?: string } interface ReviewFileTreeProps { @@ -22,6 +32,14 @@ interface ReviewFileTreeProps { onSelectFile: (path: string) => void onToggleReviewed: (path: string) => void onToggleDir: (dir: string) => void + /** s = side-by-side, d = unified. Lives here so every review keyboard + * shortcut shares one handler/pattern. */ + onSetSideBySide: (sideBySide: boolean) => void + /** ? toggles the review shortcuts popup. */ + onShowShortcuts: () => void + /** True when the review tab is the active/visible tab — the keyboard + * shortcuts no-op otherwise so a background review tab doesn't react. */ + active?: boolean } const STATUS_LABEL: Record = { @@ -150,7 +168,10 @@ export function ReviewFileTree({ collapsedDirs, onSelectFile, onToggleReviewed, - onToggleDir + onToggleDir, + onSetSideBySide, + onShowShortcuts, + active }: ReviewFileTreeProps): JSX.Element { const containerRef = useRef(null) const [filter, setFilter] = useState('') @@ -207,6 +228,7 @@ export function ReviewFileTree({ useEffect(() => { const handler = (e: KeyboardEvent): void => { + if (!active) return if ( e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement @@ -225,6 +247,15 @@ export function ReviewFileTree({ } else if (e.key === '[') { e.preventDefault() navigateUnreviewed(-1) + } else if (e.key === 's' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + onSetSideBySide(true) + } else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) { + e.preventDefault() + onSetSideBySide(false) + } else if (e.key === '?') { + e.preventDefault() + onShowShortcuts() } else if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { e.preventDefault() if (selectedFile) { @@ -247,7 +278,7 @@ export function ReviewFileTree({ } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [navigateFile, navigateUnreviewed, selectedFile, navigableFiles, reviewedFiles, onSelectFile, onToggleReviewed]) + }, [active, navigateFile, navigateUnreviewed, selectedFile, navigableFiles, reviewedFiles, onSelectFile, onToggleReviewed, onSetSideBySide, onShowShortcuts]) return (
diff --git a/src/renderer/components/ReviewPane.tsx b/src/renderer/components/ReviewPane.tsx index decb0f70..a3e7becd 100644 --- a/src/renderer/components/ReviewPane.tsx +++ b/src/renderer/components/ReviewPane.tsx @@ -1,13 +1,19 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { Send, Clipboard, Check, MessageSquare, GitCommitHorizontal, ArrowUp, ChevronDown } from 'lucide-react' +import { Send, Clipboard, Check, MessageSquare, GitCommitHorizontal, ArrowUp, ChevronDown, Pilcrow, X, Keyboard, CloudSync, Loader2 } from 'lucide-react' import type { ChangedFile, BranchCommit } from '../types' +import type { PRReview } from '../../shared/state/prs' import type { ReviewComment } from './ReviewFileTree' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' import { ReviewFileTree } from './ReviewFileTree' import { ReviewDiffPane } from './ReviewDiffPane' +import { ModeButton } from './DiffView' import { ResizeHandle } from './ResizeHandle' import { Tooltip } from './Tooltip' import { useBackend } from '../backend' +import { usePrs } from '../store' import { setReviewProgress, clearReviewProgress } from '../review-progress' +import { useReviewFileRequest } from '../review-open-file' interface ReviewPaneProps { tabId: string @@ -16,6 +22,10 @@ interface ReviewPaneProps { fromCommit?: string /** Tip commit of the selection (newest selected). Undefined ⇒ "All commits". */ toCommit?: string + /** True when this review tab is the active/visible tab in its pane. + * Gates the review keyboard shortcuts so they don't fire from a + * background tab. */ + active?: boolean onSendToAgent?: (text: string) => void } @@ -26,6 +36,7 @@ export function ReviewPane({ worktreePath, fromCommit, toCommit, + active, onSendToAgent }: ReviewPaneProps): JSX.Element { const backend = useBackend() @@ -39,12 +50,39 @@ export function ReviewPane({ // Hoisted above ReviewDiffPane so the choice persists as the reviewer // clicks through files in the same review session. const [wordWrap, setWordWrap] = useState(false) + const [sideBySide, setSideBySide] = useState(false) + const [showWhitespace, setShowWhitespace] = useState(false) + const [showShortcuts, setShowShortcuts] = useState(false) + const [revealTarget, setRevealTarget] = useState<{ filePath: string; line: number; nonce: number } | null>(null) + const revealNonceRef = useRef(0) + const [refreshKey, setRefreshKey] = useState(0) + const [syncState, setSyncState] = useState<'idle' | 'syncing' | 'ok' | 'error'>('idle') + const [syncDetail, setSyncDetail] = useState(null) + const syncing = syncState === 'syncing' + + const prs = usePrs() + const pr = prs.byPath[worktreePath] + const prNumber = pr?.number // Whole-branch when both bounds are undefined. Single commit when both // are set and equal. Otherwise a contiguous range. const isWholeBranch = !fromCommit && !toCommit const isSingleCommit = !!fromCommit && fromCommit === toCommit + // Re-fetch when the worktree's git state changes (new commits, etc.). + // Same watcher signal the Changed Files / Branch Commits panels use. + // Bumps refreshKey, which the commit + file effects below depend on. + useEffect(() => { + backend.watchChangedFiles(worktreePath) + const off = backend.onChangedFilesInvalidated((path) => { + if (path === worktreePath) setRefreshKey((k) => k + 1) + }) + return () => { + off() + backend.unwatchChangedFiles(worktreePath) + } + }, [worktreePath, backend]) + useEffect(() => { let cancelled = false backend @@ -58,9 +96,18 @@ export function ReviewPane({ return () => { cancelled = true } - }, [worktreePath, backend]) + }, [worktreePath, backend, refreshKey]) + + // The reviewed set / comments belong to a specific file set, so wipe + // them when the commit selection changes. A plain refresh (new commit + // on the same selection) must NOT wipe them — that's why this is keyed + // on the selection identity only, not refreshKey. + useEffect(() => { + setReviewedFiles(new Set()) + setComments([]) + }, [worktreePath, isWholeBranch, isSingleCommit, fromCommit, toCommit]) - // Refetch the file list whenever the commit selection changes. + // Refetch the file list when the selection changes or a refresh fires. useEffect(() => { let cancelled = false const promise = isWholeBranch @@ -76,11 +123,12 @@ export function ReviewPane({ if (prev && result.some((f) => f.path === prev)) return prev return result[0]?.path ?? null }) - // The reviewed set / comments belong to the previous file set; - // wipe them when the selection changes so progress reflects the - // new file set. - setReviewedFiles(new Set()) - setComments([]) + // Drop reviewed marks for files that no longer exist so the + // progress count stays honest after a refresh. + setReviewedFiles((prev) => { + const next = new Set([...prev].filter((p) => result.some((f) => f.path === p))) + return next.size === prev.size ? prev : next + }) }) .catch(() => { if (!cancelled) setFiles([]) @@ -88,7 +136,7 @@ export function ReviewPane({ return () => { cancelled = true } - }, [worktreePath, backend, isWholeBranch, isSingleCommit, fromCommit, toCommit]) + }, [worktreePath, backend, isWholeBranch, isSingleCommit, fromCommit, toCommit, refreshKey]) // Push "(N/M)" up to the tab strip. Clear on unmount. useEffect(() => { @@ -101,6 +149,16 @@ export function ReviewPane({ } }, [tabId]) + // Honor an external "jump to this file" request (Changed Files panel + // clicking a committed file opens this tab and asks for that file). The + // file may not be in `files` yet when the tab is first created — set it + // anyway; the file-load effect above preserves a still-valid selection + // once the list arrives. + const fileRequest = useReviewFileRequest(worktreePath) + useEffect(() => { + if (fileRequest) setSelectedFile(fileRequest.filePath) + }, [fileRequest?.nonce, fileRequest?.filePath]) + const selectedFileObj = useMemo( () => files.find((f) => f.path === selectedFile) ?? null, [files, selectedFile] @@ -167,7 +225,8 @@ export function ReviewPane({ if (comments.length === 0) return '' const lines = ['Review feedback on your changes:', ''] for (const c of comments) { - lines.push(`${c.filePath}:${c.lineNumber} — ${c.body}`) + const loc = c.lineNumber === 0 ? c.filePath : `${c.filePath}:${c.lineNumber}` + lines.push(`${loc} — ${c.body}`) } return lines.join('\n') }, [comments]) @@ -194,6 +253,82 @@ export function ReviewPane({ if (text) navigator.clipboard.writeText(text) }, [formatComments]) + // Push local comments + viewed state to the PR and pull the canonical + // comment set back. Replaces the local comment list with the reconciled + // result so synced comments carry their GitHub ids (and don't re-post). + const runSync = useCallback( + async (pullOnly: boolean) => { + if (syncing || !prNumber || !isWholeBranch) return + setSyncState('syncing') + setSyncDetail(pullOnly ? 'Loading PR comments…' : 'Syncing…') + try { + const result = await backend.reviewSync(worktreePath, { + comments: comments.map((c) => ({ + filePath: c.filePath, + lineNumber: c.lineNumber, + body: c.body, + remoteId: c.remoteId, + author: c.author + })), + reviewedFiles: [...reviewedFiles], + files: files.map((f) => f.path), + pullOnly + }) + if (!result.ok) { + setSyncState('error') + setSyncDetail(result.error ?? 'Sync failed') + return + } + setComments( + result.comments.map((c) => ({ + id: c.remoteId !== undefined ? `gh-${c.remoteId}` : `comment-${++commentIdCounter}`, + filePath: c.filePath, + lineNumber: c.lineNumber, + body: c.body, + timestamp: Date.now(), + remoteId: c.remoteId, + author: c.author, + authorAvatarUrl: c.authorAvatarUrl, + createdAt: c.createdAt, + htmlUrl: c.htmlUrl + })) + ) + // Reflect the merged viewed state (local ∪ GitHub) so files viewed + // on GitHub show as reviewed here too. + setReviewedFiles(new Set(result.reviewedFiles)) + setSyncState(result.failed > 0 ? 'error' : 'ok') + setSyncDetail( + pullOnly + ? `Loaded ${result.comments.length} comment${result.comments.length === 1 ? '' : 's'}` + : `Synced${result.pushed > 0 ? ` · ${result.pushed} drafted` : ''}${ + result.failed > 0 ? ` · ${result.failed} failed` : '' + }` + ) + } catch (err) { + setSyncState('error') + setSyncDetail(err instanceof Error ? err.message : 'Sync failed') + } + }, + [syncing, prNumber, isWholeBranch, backend, worktreePath, comments, reviewedFiles, files] + ) + + const handleSync = useCallback(() => void runSync(false), [runSync]) + + // Auto-sync (pull-only) once when the review opens with a PR, so existing + // PR comments show up without a manual Sync. Pull-only can't clobber + // GitHub state from the empty local review. + const autoSyncedRef = useRef(null) + useEffect(() => { + if (!prNumber || !isWholeBranch) return + const key = `${worktreePath}:${prNumber}` + if (autoSyncedRef.current === key) return + autoSyncedRef.current = key + void runSync(true) + // runSync intentionally omitted — fire once per worktree+PR, not on every + // comments/reviewedFiles change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [prNumber, isWholeBranch, worktreePath]) + // Compute the indices of the selected commits in the (newest→oldest) // commit list. Used to highlight the active range in the picker and // for shift-click range extension. @@ -244,7 +379,8 @@ export function ReviewPane({ const progress = files.length > 0 ? reviewedFiles.size / files.length : 0 return ( -
+
+ {showShortcuts && setShowShortcuts(false)} />} {/* Top controls bar */}
@@ -256,45 +392,78 @@ export function ReviewPane({ onCommitClick={handleCommitClick} fromCommit={fromCommit} toCommit={toCommit} + totalAdditions={totalAdditions} + totalDeletions={totalDeletions} /> -
- {totalAdditions > 0 && +{totalAdditions}} - {totalDeletions > 0 && −{totalDeletions}} +
+ setSideBySide(true)} /> + setSideBySide(false)} />
+ + + +
- {allReviewed ? ( - - - All reviewed - - ) : ( - - {reviewedFiles.size}/{files.length} reviewed - - )} - - {comments.length > 0 && ( - - - {comments.length} - - )} - + + + +
+ + + + +
+ + {/* Second row — review status info */} +
+ {pr && } + + { + setSelectedFile(c.filePath) + setRevealTarget({ filePath: c.filePath, line: c.lineNumber, nonce: ++revealNonceRef.current }) + }} + /> + +
+ + {allReviewed ? ( + + + All reviewed + + ) : ( + + {reviewedFiles.size} reviewed · {Math.max(0, files.length - reviewedFiles.size)} remaining + + )}
@@ -333,6 +537,9 @@ export function ReviewPane({ onSelectFile={setSelectedFile} onToggleReviewed={handleToggleReviewed} onToggleDir={handleToggleDir} + onSetSideBySide={setSideBySide} + onShowShortcuts={() => setShowShortcuts((v) => !v)} + active={active} />
@@ -352,6 +559,10 @@ export function ReviewPane({ } reviewed={selectedFile ? reviewedFiles.has(selectedFile) : false} comments={fileComments} + sideBySide={sideBySide} + ignoreTrimWhitespace={!showWhitespace} + active={active} + revealTarget={revealTarget} onToggleReviewed={() => { if (selectedFile) handleToggleReviewed(selectedFile) }} @@ -372,6 +583,8 @@ interface CommitSelectorProps { selectionIndices: { fromIdx: number; toIdx: number } | null fromCommit?: string toCommit?: string + totalAdditions: number + totalDeletions: number onSelectAll: () => void onCommitClick: (idx: number, shift: boolean) => void } @@ -382,6 +595,8 @@ function CommitSelector({ selectionIndices, fromCommit, toCommit, + totalAdditions, + totalDeletions, onSelectAll, onCommitClick }: CommitSelectorProps): JSX.Element { @@ -424,6 +639,12 @@ function CommitSelector({ > {buttonLabel} + {(totalAdditions > 0 || totalDeletions > 0) && ( + + {totalAdditions > 0 && +{totalAdditions}} + {totalDeletions > 0 && −{totalDeletions}} + + )} {open && ( @@ -495,3 +716,222 @@ function shortOf(commits: BranchCommit[], hash?: string): string { const m = commits.find((c) => c.hash === hash) return m ? m.shortHash : hash.slice(0, 7) } + +const REVIEW_MD_PLUGINS = [remarkGfm] + +const REVIEW_STATE_META: Record = { + APPROVED: { ring: 'ring-success', label: 'approved' }, + CHANGES_REQUESTED: { ring: 'ring-danger', label: 'requested changes' }, + COMMENTED: { ring: 'ring-info', label: 'commented' }, + DISMISSED: { ring: 'ring-border', label: 'dismissed' }, + PENDING: { ring: 'ring-border', label: 'pending' } +} + +/** Compact cluster of reviewer avatars, ring-colored by their latest review + * state. Renders nothing until someone has actually reviewed. Clicking an + * avatar opens that reviewer's top-level review comment. */ +function ReviewerStatus({ reviews }: { reviews: PRReview[] }): JSX.Element | null { + const [openUser, setOpenUser] = useState(null) + const wrapRef = useRef(null) + + useEffect(() => { + if (!openUser) return + const close = (e: MouseEvent): void => { + if (wrapRef.current && e.target instanceof Node && wrapRef.current.contains(e.target)) return + setOpenUser(null) + } + window.addEventListener('mousedown', close) + return () => window.removeEventListener('mousedown', close) + }, [openUser]) + + const latest = new Map() + for (const r of reviews) { + if (r.state === 'PENDING') continue + const prev = latest.get(r.user) + if (!prev || r.submittedAt > prev.submittedAt) latest.set(r.user, r) + } + const list = [...latest.values()] + if (list.length === 0) return null + const openReview = list.find((r) => r.user === openUser) ?? null + + return ( +
+ {list.map((r) => { + const meta = REVIEW_STATE_META[r.state] ?? REVIEW_STATE_META.COMMENTED + return ( + + + + ) + })} + {openReview && ( +
e.stopPropagation()} + > +
+ @{openReview.user} + + {(REVIEW_STATE_META[openReview.state] ?? REVIEW_STATE_META.COMMENTED).label} + + {openReview.htmlUrl && ( + setOpenUser(null)} + > + Open + + )} +
+
+ {openReview.body.trim() ? ( +
+ {openReview.body} +
+ ) : ( + (no top-level comment) + )} +
+
+ )} +
+ ) +} + +function CommentDropdown({ + comments, + onSelect +}: { + comments: ReviewComment[] + onSelect: (c: ReviewComment) => void +}): JSX.Element { + const [open, setOpen] = useState(false) + const wrapRef = useRef(null) + + useEffect(() => { + if (!open) return + const close = (e: MouseEvent): void => { + if (wrapRef.current && e.target instanceof Node && wrapRef.current.contains(e.target)) return + setOpen(false) + } + window.addEventListener('mousedown', close) + return () => window.removeEventListener('mousedown', close) + }, [open]) + + const sorted = [...comments].sort( + (a, b) => a.filePath.localeCompare(b.filePath) || a.lineNumber - b.lineNumber + ) + + return ( +
+ + {open && comments.length > 0 && ( +
e.stopPropagation()} + > + {sorted.map((c) => { + const name = c.filePath.split('/').pop() || c.filePath + return ( + + ) + })} +
+ )} +
+ ) +} + +const REVIEW_SHORTCUTS: [string, string][] = [ + ['j / ↓', 'Next file'], + ['k / ↑', 'Previous file'], + ['] / [', 'Next / previous unreviewed file'], + ['r', 'Mark file viewed / unviewed'], + ['s / d', 'Side-by-side / unified diff'], + ['c', 'Comment on hovered line (or file)'], + ['?', 'Toggle this help'] +] + +function ReviewShortcutsPopup({ onClose }: { onClose: () => void }): JSX.Element { + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + return ( +
+
e.stopPropagation()} + > +
+ Review shortcuts + +
+
+ {REVIEW_SHORTCUTS.map(([keys, desc]) => ( +
+ {desc} + + {keys} + +
+ ))} +
+
+
+ ) +} diff --git a/src/renderer/components/RightColumn.tsx b/src/renderer/components/RightColumn.tsx index f43aabb9..f009c3b1 100644 --- a/src/renderer/components/RightColumn.tsx +++ b/src/renderer/components/RightColumn.tsx @@ -39,6 +39,7 @@ interface RightColumnProps { onOpenFile: AllFilesPanelProps['onOpenFile'] onSendToAgent: (worktreePath: string, text: string) => void onOpenReview: () => void + onOpenReviewFile: ChangedFilesPanelProps['onOpenReviewFile'] onCollapse: () => void } @@ -60,6 +61,7 @@ export function RightColumn({ onOpenFile, onSendToAgent, onOpenReview, + onOpenReviewFile, onCollapse }: RightColumnProps): JSX.Element { const backend = useBackend() @@ -124,6 +126,7 @@ export function RightColumn({ activeWorktreeId ? (text) => onSendToAgent(activeWorktreeId, text) : undefined } onOpenReview={onOpenReview} + onOpenReviewFile={onOpenReviewFile} /> ) case 'allFiles': diff --git a/src/renderer/components/TerminalPanel.tsx b/src/renderer/components/TerminalPanel.tsx index 57fce6c5..ca3be184 100644 --- a/src/renderer/components/TerminalPanel.tsx +++ b/src/renderer/components/TerminalPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useCallback, useState } from 'react' -import { X, SquareTerminal, Sparkles, Loader2, Globe, Users, ChevronLeft, ChevronRight } from 'lucide-react' +import { X, SquareTerminal, Sparkles, Loader2, Globe, Users, ChevronLeft, ChevronRight, Diff, File, ClipboardCheck } from 'lucide-react' import { SortableContext, horizontalListSortingStrategy, @@ -304,9 +304,15 @@ function SortableTab({ tab, isActive, status, shellActivity, showClose, onSelect ) : ( ) - ) : tab.type !== 'diff' && tab.type !== 'file' && tab.type !== 'review' ? ( + ) : tab.type === 'diff' ? ( + + ) : tab.type === 'file' ? ( + + ) : tab.type === 'review' ? ( + + ) : ( - ) : null} + )} {editing ? ( void onMoveTabToPane: (worktreePath: string, tabId: string, toPaneId: string, toIndex?: number) => void onSendToAgent?: (worktreePath: string, text: string) => void + /** Open the dedicated editor (file) tab for a path — used by the diff + * tab's Edit action so editing never happens in the diff pane. */ + onOpenFile?: (worktreePath: string, filePath: string) => void /** Leading padding for the leftmost leaf's tab bar so it clears the macOS * traffic lights when no sidebar sits to the left of the workspace. */ topBarLeadingPx?: number @@ -359,6 +362,7 @@ export function WorkspaceView({ onReorderTabs, onMoveTabToPane, onSendToAgent, + onOpenFile, repoLabel, branch, topBarLeadingPx = 0, @@ -583,6 +587,10 @@ export function WorkspaceView({ staged={tab.staged ?? false} branchDiff={tab.branchDiff ?? false} commitHash={tab.commitHash} + active={visible && isActiveInPane} + onOpenEditor={ + onOpenFile ? (filePath) => onOpenFile(worktreePath, filePath) : undefined + } onSendToAgent={ onSendToAgent ? (text) => onSendToAgent(worktreePath, text) @@ -591,6 +599,7 @@ export function WorkspaceView({ /> ) : tab.type === 'file' ? ( onSendToAgent(worktreePath, text) diff --git a/src/renderer/dirty-tabs.ts b/src/renderer/dirty-tabs.ts new file mode 100644 index 00000000..37648c24 --- /dev/null +++ b/src/renderer/dirty-tabs.ts @@ -0,0 +1,19 @@ +// Tracks which editor tabs have unsaved changes so the close handler can +// warn before discarding them. The dirty flag lives inside FileView's own +// React tree; this side channel (keyed by tabId) lets handleCloseTab read +// it imperatively at close time. Mirrors review-progress.ts. FileView sets +// its entry on every change and clears it on unmount. +const dirtyTabs = new Set() + +export function setTabDirty(tabId: string, dirty: boolean): void { + if (dirty) dirtyTabs.add(tabId) + else dirtyTabs.delete(tabId) +} + +export function clearTabDirty(tabId: string): void { + dirtyTabs.delete(tabId) +} + +export function isTabDirty(tabId: string): boolean { + return dirtyTabs.has(tabId) +} diff --git a/src/renderer/hooks/useTabHandlers.ts b/src/renderer/hooks/useTabHandlers.ts index 5447927d..fcf02c6d 100644 --- a/src/renderer/hooks/useTabHandlers.ts +++ b/src/renderer/hooks/useTabHandlers.ts @@ -4,6 +4,7 @@ import { getLeaves, findLeaf, findLeafByTabId } from '../../shared/state/termina import { agentDisplayName, getAgentInfo } from '../../shared/agent-registry' import { focusTerminalById, markTerminalClosing } from '../components/XTerminal' import { useBackend } from '../backend' +import { isTabDirty, clearTabDirty } from '../dirty-tabs' function makeTerminalId(prefix: string, worktreePath: string): string { const safe = worktreePath.replace(/[/\\]/g, '-').replace(/^-+/, '').replace(/-+/g, '-') @@ -103,6 +104,13 @@ export function useTabHandlers({ const handleCloseTab = useCallback( (worktreePath: string, tabId: string) => { + // An editor tab with unsaved edits would silently discard them on + // close — confirm first. The dirty flag is published by FileView. + if (isTabDirty(tabId)) { + const ok = window.confirm('This file has unsaved changes. Close the tab and discard them?') + if (!ok) return + clearTabDirty(tabId) + } // PanesFSM is the authoritative path for json-claude/agent/shell // teardown — its closeTab kills the subprocess via killJsonClaude // or killTabPty. The PTY-side notification here is purely an diff --git a/src/renderer/review-open-file.ts b/src/renderer/review-open-file.ts new file mode 100644 index 00000000..ceb858ea --- /dev/null +++ b/src/renderer/review-open-file.ts @@ -0,0 +1,46 @@ +// Cross-component "jump the review tab to this file" request channel. +// The Changed Files panel (committed rows) opens the worktree's Review tab +// and asks it to select a specific file; this side channel carries that +// request from the panel into the ReviewPane's own React tree. +// +// Mirrors review-progress.ts — a side channel for state that lives inside +// ReviewPane, keyed by worktree path. A monotonic nonce makes re-requesting +// the same file re-fire, so clicking an already-open file re-selects it. +import { useSyncExternalStore } from 'react' + +export interface ReviewFileRequest { + filePath: string + nonce: number +} + +const requestByWorktree = new Map() +const listeners = new Set<() => void>() +let counter = 0 + +function emit(): void { + for (const l of listeners) l() +} + +export function requestReviewFile(worktreePath: string, filePath: string): void { + requestByWorktree.set(worktreePath, { filePath, nonce: ++counter }) + emit() +} + +function subscribe(cb: () => void): () => void { + listeners.add(cb) + return () => { + listeners.delete(cb) + } +} + +function getSnapshot(worktreePath: string): ReviewFileRequest | undefined { + return requestByWorktree.get(worktreePath) +} + +export function useReviewFileRequest(worktreePath: string): ReviewFileRequest | undefined { + return useSyncExternalStore( + subscribe, + () => getSnapshot(worktreePath), + () => undefined + ) +} From 33cad711e0c60aa82060768e5b39e26e6cf9a240 Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Mon, 1 Jun 2026 07:31:02 -0400 Subject: [PATCH 03/63] =?UTF-8?q?changed=20files:=20clarify=20editor=20but?= =?UTF-8?q?ton=20=E2=80=94=20external=20by=20default,=20=E2=8C=98-click=20?= =?UTF-8?q?for=20in-app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relabel the per-file editor icon "Open file in external editor"; ⌘-click opens the file in the in-Harness editor tab instead (handleOpenFile, threaded through RightColumn → ChangedFilesPanel → FileRow). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/renderer/components/ChangedFilesPanel.tsx | 16 ++++++++++++---- src/renderer/components/RightColumn.tsx | 1 + 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/ChangedFilesPanel.tsx b/src/renderer/components/ChangedFilesPanel.tsx index 4bd39e74..17595357 100644 --- a/src/renderer/components/ChangedFilesPanel.tsx +++ b/src/renderer/components/ChangedFilesPanel.tsx @@ -15,6 +15,8 @@ interface ChangedFilesPanelProps { onOpenReview?: () => void /** Open the worktree's Review tab focused on this committed file. */ onOpenReviewFile?: (filePath: string) => void + /** Open a file in the in-Harness editor tab (⌘-click on the editor icon). */ + onOpenFile?: (filePath: string) => void } const STATUS_LABEL: Record = { @@ -38,7 +40,7 @@ interface ChangedFilesData { branch: ChangedFile[] } -export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview, onOpenReviewFile }: ChangedFilesPanelProps): JSX.Element { +export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onOpenReview, onOpenReviewFile, onOpenFile }: ChangedFilesPanelProps): JSX.Element { const backend = useBackend() const fetcher = useCallback(async (path: string): Promise => { const [working, branch] = await Promise.all([ @@ -123,6 +125,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO worktreePath={worktreePath} onClick={() => onOpenDiff(file.path, true, 'working')} onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> ))} {stagedFiles.length > 0 && unstagedFiles.length > 0 && ( @@ -137,6 +140,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO worktreePath={worktreePath} onClick={() => onOpenDiff(file.path, false, 'working')} onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> ))} @@ -163,6 +167,7 @@ export function ChangedFilesPanel({ worktreePath, onOpenDiff, onSendToAgent, onO : onOpenDiff(file.path, false, 'branch') } onSendToAgent={onSendToAgent} + onOpenFile={onOpenFile} /> )) )} @@ -178,12 +183,14 @@ function FileRow({ file, worktreePath, onClick, - onSendToAgent + onSendToAgent, + onOpenFile }: { file: ChangedFile worktreePath: string | null onClick: () => void onSendToAgent?: (text: string) => void + onOpenFile?: (filePath: string) => void }): JSX.Element { const backend = useBackend() const lastSlash = file.path.lastIndexOf('/') @@ -235,11 +242,12 @@ function FileRow({ )} {worktreePath && ( - + )}
-
- {comment.body} +
+
+ {comment.body} +
+ {clamped && ( +
+ )}
+ {collapsible && ( + + )}
) } @@ -269,9 +331,13 @@ function InlineCommentInput({ interface ViewZoneEntry { zoneId: string + /** Kept so we can mutate heightInPx and re-layout when the comment's + * rendered height changes (collapse/expand, markdown reflow). */ + zone: monaco.editor.IViewZone root: Root domNode: HTMLDivElement stickyWrapper: HTMLDivElement + resizeObserver?: ResizeObserver } export function ReviewDiffPane({ @@ -354,6 +420,7 @@ export function ReviewDiffPane({ if (zones.length === 0) return modifiedEd.changeViewZones((accessor) => { for (const z of zones) { + z.resizeObserver?.disconnect() accessor.removeZone(z.zoneId) queueMicrotask(() => z.root.unmount()) } @@ -407,13 +474,14 @@ export function ReviewDiffPane({ stickyWrapper.style.width = `${contentWidth}px` domNode.appendChild(stickyWrapper) - const heightInLines = item.type === 'input' ? 7 : 4 - const zoneId = accessor.addZone({ + const zone: monaco.editor.IViewZone = { afterLineNumber: item.lineNumber, - heightInLines, domNode, suppressMouseDown: false - }) + } + if (item.type === 'input') zone.heightInLines = 7 + else zone.heightInPx = 76 // estimate; the ResizeObserver corrects it + const zoneId = accessor.addZone(zone) const root = createRoot(stickyWrapper) if (item.type === 'comment' && item.comment) { @@ -434,7 +502,24 @@ export function ReviewDiffPane({ ) } - newZones.push({ zoneId, root, domNode, stickyWrapper }) + // Resize the comment zone to fit its rendered content so collapse / + // expand and markdown reflow don't clip. The observed height depends + // only on the comment content (not the zone height), so there's no + // feedback loop. + let resizeObserver: ResizeObserver | undefined + if (item.type === 'comment') { + resizeObserver = new ResizeObserver(() => { + const ed = editorRef.current + if (!ed) return + const h = Math.ceil(stickyWrapper.scrollHeight) + 8 + if (!h || zone.heightInPx === h) return + zone.heightInPx = h + ed.getModifiedEditor().changeViewZones((acc) => acc.layoutZone(zoneId)) + }) + resizeObserver.observe(stickyWrapper) + } + + newZones.push({ zoneId, zone, root, domNode, stickyWrapper, resizeObserver }) } }) @@ -445,7 +530,10 @@ export function ReviewDiffPane({ useEffect(() => { return () => { const zones = viewZonesRef.current - for (const z of zones) queueMicrotask(() => z.root.unmount()) + for (const z of zones) { + z.resizeObserver?.disconnect() + queueMicrotask(() => z.root.unmount()) + } viewZonesRef.current = [] } }, []) From 6f41a5b73966164a7428e584a9e25c7797dbb389 Mon Sep 17 00:00:00 2001 From: Sterling Greene Date: Mon, 1 Jun 2026 07:44:35 -0400 Subject: [PATCH 05/63] review: render sanitized HTML in comments; expand-all button Comments (and the reviewer popover) are GitHub-authored markdown that can contain raw HTML. Add rehype-raw to parse it and rehype-sanitize (GitHub schema) to strip anything unsafe, since the content comes from arbitrary PR participants. Also add an UnfoldVertical/FoldVertical toggle on the diff file header (left of Viewed) that expands/collapses all comments in the file via a forceExpanded prop. Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 198 +++++++++++++++++++++ package.json | 2 + src/renderer/components/ReviewDiffPane.tsx | 45 ++++- src/renderer/components/ReviewPane.tsx | 7 +- 4 files changed, 245 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc7ddd87..f7754069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,8 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "ssh-config": "^5.1.0", "ws": "^8.20.0" @@ -5361,6 +5363,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -6027,6 +6041,26 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-is-element": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", @@ -6040,6 +6074,59 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -6067,6 +6154,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -6096,6 +6202,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/highlight.js": { "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", @@ -6148,6 +6271,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -8769,6 +8902,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9127,6 +9272,35 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -10375,6 +10549,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -10567,6 +10755,16 @@ "defaults": "^1.0.3" } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index d455df8d..f431afb8 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,8 @@ "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "ssh-config": "^5.1.0", "ws": "^8.20.0" diff --git a/src/renderer/components/ReviewDiffPane.tsx b/src/renderer/components/ReviewDiffPane.tsx index 43fa96a4..d0865bad 100644 --- a/src/renderer/components/ReviewDiffPane.tsx +++ b/src/renderer/components/ReviewDiffPane.tsx @@ -3,7 +3,9 @@ import { createRoot, type Root } from 'react-dom/client' import * as monaco from 'monaco-editor' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' -import { ArrowRightFromLine, Check, MessagesSquare, WrapText } from 'lucide-react' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import { ArrowRightFromLine, Check, FoldVertical, MessagesSquare, UnfoldVertical, WrapText } from 'lucide-react' import type { FileDiffSides, ChangedFile } from '../types' import type { ReviewComment } from './ReviewFileTree' import { MonacoDiffEditor } from './MonacoDiffEditor' @@ -56,6 +58,9 @@ const STATUS_COLOR: Record = { } const COMMENT_REMARK_PLUGINS = [remarkGfm] +// rehype-raw parses raw HTML (so GitHub-authored tags render); rehype-sanitize +// then strips anything unsafe — comments come from arbitrary PR participants. +const COMMENT_REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize] function formatRelTime(ms: number): string { if (!ms || Number.isNaN(ms)) return '' @@ -75,14 +80,16 @@ const COLLAPSED_BODY_PX = 64 function InlineComment({ comment, - onDelete + onDelete, + forceExpanded }: { comment: ReviewComment onDelete: () => void + forceExpanded?: boolean }): JSX.Element { const ts = comment.createdAt ? Date.parse(comment.createdAt) : comment.timestamp const timeStr = formatRelTime(ts) - const [expanded, setExpanded] = useState(false) + const [expanded, setExpanded] = useState(forceExpanded ?? false) const bodyRef = useRef(null) const [collapsible, setCollapsible] = useState(false) useEffect(() => { @@ -189,7 +196,9 @@ function InlineComment({ overflow: 'hidden' }} > - {comment.body} + + {comment.body} +
{clamped && (
(null) const [loading, setLoading] = useState(false) const [commentLine, setCommentLine] = useState(null) + const [expandAll, setExpandAll] = useState(false) // Bumped each time the diff editor (re)mounts so the view-zone effect // re-runs and re-draws comments — the editor unmounts/remounts on every // file switch, and a ref alone wouldn't retrigger the effect. @@ -487,7 +497,11 @@ export function ReviewDiffPane({ if (item.type === 'comment' && item.comment) { const c = item.comment root.render( - onDeleteComment(c.id)} /> + onDeleteComment(c.id)} + forceExpanded={expandAll} + /> ) } else if (item.type === 'input') { root.render( @@ -524,7 +538,7 @@ export function ReviewDiffPane({ }) viewZonesRef.current = newZones - }, [comments, commentLine, editorNonce, clearViewZones, onAddComment, onDeleteComment]) + }, [comments, commentLine, editorNonce, expandAll, clearViewZones, onAddComment, onDeleteComment]) // Clean up view zones on unmount useEffect(() => { @@ -670,6 +684,25 @@ export function ReviewDiffPane({ )} + {comments.length > 0 && ( + + + + )} + + )} + {resolved ? ( + + Resolved + + ) : resolving ? ( + Resolving on next sync… + ) : canResolve ? ( + + ) : null} +
+ + {replying && ( +
1 ? 16 : 0, maxWidth: '760px' }}> +