Review pane for uncommitted changes, the pull request or one commit at a time: ask Claude about any line, read and settle GitHub review threads, all from the…

A review pane inside Claude Code: browse your uncommitted changes, the pull request or one commit at a time, click any line and ask Claude about it. The answer streams in under that line, and the diff follows it.

Claude Code's built-in /diff shows the session's uncommitted changes and lets you ask about a file's hunks. diff-review goes on from there to a whole review: the pull request and each commit besides uncommitted changes, questions answered under the very line they are about, the GitHub review comments and their resolving, the viewed boxes, and pushing.
A personal tool. diff-review was built for my own reviews and is mostly tested on my own machine, under Linux. Another system, terminal or way of working may well bring out bugs I have never met. If you hit one, an issue is welcome, a pull request even more: see how to contribute.
/plugin marketplace add nicolas-codemate/claude-code-mods
/plugin install diff-review@codemate-mods
Then run /diff-review. It needs git, and for the pull request features the GitHub CLI logged in (gh auth login).
Updates. Claude Code does not update a marketplace on its own until you ask it to: turn on auto-update under Marketplaces in /plugin, or run /plugin marketplace update codemate-mods now and then. Each release is listed on the releases page.
Screen width. The pane sits beside the conversation. From a Full HD screen (1920 pixels) it is comfortable with the file list collapsed behind its ≡ Files bar, as in the animation above; on a wider screen the list stays pinned beside the code, as in the pictures below.
Click a line number and type the question. It goes to the conversation with the code around the line, and Claude's answer streams into a thread under that line, its mark blinking until the answer is in. While it grows, the diff scrolls to keep its end in sight, without pushing the line itself off the top; scrolling or moving yourself stops the follow.
/clear and across sessions.◆ for Claude's, 💬 for GitHub review comments.The pane opens on the uncommitted changes, or on the pull request when everything is committed. The tabs count the uncommitted files and the branch commits, and count again after each edit or command of Claude, and within a few seconds of a change made elsewhere, in an editor or a terminal.
Uncommitted: what git diff shows, new untracked files included. In the file list, + stages a file, ± the rest of a file partly staged, ✓ marks one staged whole, and add all stages everything. The tab counts the files (Uncommitted 3) and dims when there is nothing uncommitted.
Once files are staged, a bar under the header offers ✓ commit, which hands the commit to Claude as if you typed it: your commit skill writes the message when you have one, else Claude follows the style of the recent commits, and only what is staged goes in. Claude's answer streams into the bar, and a skill that asks first is answered there, or dictated in the prompt. When the commit is made the pane moves to the commits view, ready to push.

Pull request: the whole branch against the base the pull request targets, opening on its description.

Commits: the branch commits with their graph, one commit's diff at a time, under its whole message. Commits not pushed yet carry ↑ not pushed, their count shows on the tab after the branch's (Commits 5 ↑2), and a button pushes them: a second press confirms, a plain git push is used, never from main or master. When the remote refuses the push (after a rebase, say), the button and the notice that reports it offer a force push protected by --force-with-lease --force-if-includes, again on a second press; the notice goes once the force push starts.

The inline comments of the pull request appear under their lines, outdated ones on top of their file, resolved ones folded to one line. Resolve or unresolve a thread, open it on GitHub, or have Claude change the code to settle it, from the buttons of the thread. The header walks every thread of the view, Claude's and GitHub's alike.

Address with Claude. One click hands a review comment to Claude, with the code around its line: Claude changes the code to settle it, or explains why it should not and suggests a reply. The answer lands under the comment as a thread you can reply to. Nothing is posted on GitHub: resolving the comment and pushing stay yours.

○ unviewed hides the files already viewed; a file you just marked stays a moment before it goes.↑ instead of a box.! and comes back under ○ unviewed. Ticked from the pane, it opens on the first line changed since.Syntax highlighting (each block of a Vue component in its own language, Behat features as Gherkin), more context around each change or the whole file, side by side when the pane is wide enough, long lines wrapped and scrolled through line by line. The file list groups files by directory, each folding away, with a filter; lockfiles and generated files stay folded until asked.

Select a variable name or any text with the mouse and press 8, or the header's 🔍 button: every change of the view is searched for it. A bar opens under the header, over the file list and the code alike, every occurrence is lit, and each file shows how many it holds (🔍 3). The bar's ↑ and ↓ (or 6 and 7) walk them back and forth, from file to file, its field changes the term, and 🧹 clear ends the search. The counts follow Claude's edits. Where the selection cannot be read (fullscreen off), the bar opens empty, to type the term in. A line holding the term is drawn without its syntax colors, so that the term itself can be lit.

Everything works with the mouse. Every action also has a key; the help button of the pane lists them all.
In /config, under diff-review:
auto follows the theme of Claude Code (/theme), dark or light force one. The pane is fully light when Claude Code is: forced to the other mode, it leaves its backgrounds to the terminal so that text stays readable. A change applies at once. Claude Code's light theme in a dark terminal leaves some labels pale on the light backgrounds, a mod not choosing the color of a button: set Theme to dark there.
A question sends the line, the diff around it and the view it was asked from; a GitHub thread sends its comments. The context goes in as a part of the conversation the transcript does not show, the question as your message. On GitHub, nothing is posted but what you change yourself: the viewed boxes, the resolved state of a thread, and a push from the commits view. A failure shows in a red frame on top of the pane until you dismiss it; nothing else interrupts you.
git; the GitHub CLI gh for the pull request features; xdg-open (Linux) or open (macOS) to open GitHub pages.export FORCE_HYPERLINK=1 before claude keeps them as clickable text when the terminal you type in supports links, as most do.claude plugin validate .
claude plugin test .
The hooks module is hooks/register.tsx; pure logic sits beside it, the drawing under hooks/view/. The pictures come from a fictional demo shop.
MIT
hooks/register.tsx 2191 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangedFile, CommitRequest, DiffRow, ErrorNotice, ReviewThread, GitHubReviewThread, KeyboardLayout, ReviewView, ViewedState } from '../types'
5import {
6 ANSWER_PULSE_MS,
7 ASK_BUTTON_KEY_PREFIX,
8 ASK_ON_LINE_KEY,
9 CURSOR_LINE_FOCUS_KEY,
10 BASE_REF_CANDIDATES,
11 CANCEL_QUESTION_KEY,
12 DICTATE_QUESTION_KEY,
13 CONTEXT_RADIUS_ROWS,
14 DEFAULT_DIFF_CONTEXT_LINES,
15 DIFF_CONTEXT_STEP_LINES,
16 DEFAULT_FILE_LIST_WIDTH,
17 DISMISS_THREAD_KEY_PREFIX,
18 DIVIDER_KEY,
19 FILE_FILTER_KEY,
20 WATCH_FIELD_KEY,
21 EMOJI_CACHE_DURATION_MS,
22 EMOJI_STORE_KEY,
23 BROWSER_OPENERS,
24 FILE_BUTTON_KEY_PREFIX,
25 FILE_LIST_COLLAPSED_STORE_KEY,
26 FILE_LIST_WIDTH_STORE_KEY,
27 SPLIT_VIEW_STORE_KEY,
28 COMMIT_PANEL_MAX_LINES,
29 GH_TIMEOUT_MS,
30 GIT_PUSH_TIMEOUT_MS,
31 GIT_TIMEOUT_MS,
32 PUSH_CONFIRM_DELAY_MS,
33 PUSHED_NOTICE_MS,
34 LINE_NUMBER_BUTTON_KEY_PREFIX,
35 MAX_SAVED_THREADS_PER_BRANCH,
36 MIN_PANE_COLUMNS,
37 PANE_ID,
38 PANE_SHARE_OF_TERMINAL,
39 PANE_TITLE,
40 PERSON_PROMPT_ORIGINS,
41 PULL_REQUEST_OVERVIEW_KEY,
42 QUESTION_FOCUS_RETRY_MS,
43 VIEWED_FILE_HIDE_DELAY_MS,
44 QUESTION_INPUT_KEY_PREFIX,
45 REPLY_INPUT_KEY_PREFIX,
46 THREADS_STORE_KEY_PREFIX,
47 VIEWED_COMMITS_STORE_KEY_PREFIX,
48 VIEW_ORDER,
49 WHOLE_FILE_CONTEXT_LINES,
50 WHEEL_TICK_ROWS,
51 WORKING_TREE_POLL_MS,
52 COMMIT_THREAD_ANCHOR_KEY,
53} from './constants'
54import {
55 changedNewLinesOf,
56 diffRangeArguments,
57 directoryOf,
58 openingViewOf,
59 groupFilesByDirectory,
60 LOG_FORMAT,
61 commitsOfGraph,
62 parseCommitGraph,
63 repositoryWebUrlOf,
64 parseNumstat,
65 parseUnifiedDiff,
66 parseUntrackedFiles,
67 stageStatesOf,
68} from './git-parsing'
69import {
70 addressRequestOf,
71 dismissChangedFiles,
72 gitHubThreadNavigationEntries,
73 isGitHubThreadOnRow,
74 MARK_FILE_AS_VIEWED_MUTATION,
75 parsePullRequest,
76 parseReviewThreadsPage,
77 parseRenamedPaths,
78 parseViewedStatesPage,
79 pullRequestRenamesArguments,
80 PULL_REQUEST_VIEW_FIELDS,
81 RESOLVE_REVIEW_THREAD_MUTATION,
82 REVIEW_THREADS_QUERY,
83 UNRESOLVE_REVIEW_THREAD_MUTATION,
84 UNMARK_FILE_AS_VIEWED_MUTATION,
85 VIEWED_STATES_QUERY,
86 withPreviousPathsOfRenames,
87} from './github-review'
88import { cursorAtHunk, cursorStopsOf, firstChangeStop, movedCursor, offsetShowingCursor, stopOnNewLines } from './cursor'
89import { fileWithMatchesFrom, matchCountByPathOf, matchStopFrom, rowsByPathOfViewDiff, watchedTermOf } from './watch'
90import { viewedPathsToHide, visibleFilesOf } from './file-filter'
91import { KEYBOARD_LAYOUT_SOURCES, keyboardLayoutOf } from './keyboard'
92import { keymapOf } from './keymap'
93import { buildFileLines, clampFileListWidth, clampOffset, scrolledByLines } from './layout'
94import { helpContentLineCount, helpVisibleLineCount } from './view/help'
95import { parseEmojiCharacters, splitMarkdownBlocks } from './markdown'
96import {
97 answerWithText,
98 isLastExchangeDone,
99 newThread,
100 overviewAnchorKeyOf,
101 overviewQuestionTarget,
102 openThreadsByPath,
103 parseAnchorKey,
104 queuedExchange,
105 replyContextOf,
106 restoredThreads,
107 rowQuestionTarget,
108 threadsInReadingOrder,
109 withLastExchange,
110} from './questions'
111import { anchorKeyOf } from './questions'
112import type { QuestionTarget } from './questions'
113import { pairRowsSideBySide, splitIndexOfRow } from './split-rows'
114import { isVueFile, withVueLanguages } from './syntax-language'
115import { buildPaneModel } from './view/model'
116import type { ExplorerSnapshot } from './view/model'
117import { renderNarrowPane, renderPane } from './view/pane'
118import { themeOf } from './view/theme'
119import type { ExplorerActions, ViewElements } from './view/types'
120
121// --- Session state ----------------------------------------------------------------------------------------------
122// The scan that validates a hooks module reads an atom only in the file that declares it: they all live here.
123
124// What the explorer shows.
125const viewAtom = atom({ plugin: 'diff-review', key: 'view' } as const, 'uncommitted')
126const repositoryRootAtom = atom({ plugin: 'diff-review', key: 'repositoryRoot' } as const, null)
127const repositoryWebUrlAtom = atom({ plugin: 'diff-review', key: 'repositoryWebUrl' } as const, null)
128const branchAtom = atom({ plugin: 'diff-review', key: 'branch' } as const, null)
129const baseRefAtom = atom({ plugin: 'diff-review', key: 'baseRef' } as const, null)
130const baseBranchNameAtom = atom({ plugin: 'diff-review', key: 'baseBranchName' } as const, null)
131const uncommittedFileCountAtom = atom({ plugin: 'diff-review', key: 'uncommittedFileCount' } as const, 0)
132const stagedFileCountAtom = atom({ plugin: 'diff-review', key: 'stagedFileCount' } as const, 0)
133const stageStateByPathAtom = atom({ plugin: 'diff-review', key: 'stageStateByPath' } as const, {})
134const workingTreeStatusAtom = atom({ plugin: 'diff-review', key: 'workingTreeStatus' } as const, '')
135const commitRequestAtom = atom({ plugin: 'diff-review', key: 'commitRequest' } as const, { state: 'idle' })
136const commitThreadAtom = atom({ plugin: 'diff-review', key: 'commitThread' } as const, null)
137const branchCommitCountAtom = atom({ plugin: 'diff-review', key: 'branchCommitCount' } as const, 0)
138const unpushedCommitShasAtom = atom({ plugin: 'diff-review', key: 'unpushedCommitShas' } as const, [])
139const pushStateAtom = atom({ plugin: 'diff-review', key: 'pushState' } as const, 'idle')
140const commitListOffsetAtom = atom({ plugin: 'diff-review', key: 'commitListOffset' } as const, null)
141const errorNoticesAtom = atom({ plugin: 'diff-review', key: 'errorNotices' } as const, [])
142const helpScrolledLinesAtom = atom({ plugin: 'diff-review', key: 'helpScrolledLines' } as const, 0)
143const followedThreadIdAtom = atom({ plugin: 'diff-review', key: 'followedThreadId' } as const, null)
144const commitsAtom = atom({ plugin: 'diff-review', key: 'commits' } as const, [])
145const commitGraphAtom = atom({ plugin: 'diff-review', key: 'commitGraph' } as const, [])
146const gitHubReviewThreadsAtom = atom({ plugin: 'diff-review', key: 'gitHubReviewThreads' } as const, [])
147const expandedGitHubThreadIdsAtom = atom({ plugin: 'diff-review', key: 'expandedGitHubThreadIds' } as const, [])
148const selectedCommitShaAtom = atom({ plugin: 'diff-review', key: 'selectedCommitSha' } as const, null)
149const selectedCommitMessageAtom = atom({ plugin: 'diff-review', key: 'selectedCommitMessage' } as const, null)
150const filesAtom = atom({ plugin: 'diff-review', key: 'files' } as const, [])
151const selectedFilePathAtom = atom({ plugin: 'diff-review', key: 'selectedFilePath' } as const, null)
152const isOverviewShownAtom = atom({ plugin: 'diff-review', key: 'isOverviewShown' } as const, true)
153const diffRowsAtom = atom({ plugin: 'diff-review', key: 'diffRows' } as const, [])
154const loadErrorAtom = atom({ plugin: 'diff-review', key: 'loadError' } as const, null)
155const isLoadingAtom = atom({ plugin: 'diff-review', key: 'isLoading' } as const, false)
156
157// The pull request of the branch, and GitHub's viewed box per file.
158const pullRequestAtom = atom({ plugin: 'diff-review', key: 'pullRequest' } as const, null)
159const viewedStateByPathAtom = atom({ plugin: 'diff-review', key: 'viewedStateByPath' } as const, {})
160const pushDismissedPathsAtom = atom({ plugin: 'diff-review', key: 'pushDismissedPaths' } as const, [])
161const renamedPathByPreviousPathAtom = atom({ plugin: 'diff-review', key: 'renamedPathByPreviousPath' } as const, {})
162
163// Layout and scrolling.
164const requestedPaneColumnsAtom = atom({ plugin: 'diff-review', key: 'requestedPaneColumns' } as const, 0)
165const fileListWidthAtom = atom({ plugin: 'diff-review', key: 'fileListWidth' } as const, DEFAULT_FILE_LIST_WIDTH)
166const fileListOffsetAtom = atom({ plugin: 'diff-review', key: 'fileListOffset' } as const, 0)
167const diffOffsetAtom = atom({ plugin: 'diff-review', key: 'diffOffset' } as const, 0)
168const diffSkippedLinesAtom = atom({ plugin: 'diff-review', key: 'diffSkippedLines' } as const, null)
169
170// Keyboard.
171const keyboardLayoutAtom = atom({ plugin: 'diff-review', key: 'keyboardLayout' } as const, 'unknown')
172const focusedElementKeyAtom = atom({ plugin: 'diff-review', key: 'focusedElementKey' } as const, null)
173
174// The file list's filter, and whether lockfiles and generated files are listed.
175const fileFilterAtom = atom({ plugin: 'diff-review', key: 'fileFilter' } as const, '')
176const watchedTermAtom = atom({ plugin: 'diff-review', key: 'watchedTerm' } as const, '')
177const isWatchFieldOpenAtom = atom({ plugin: 'diff-review', key: 'isWatchFieldOpen' } as const, false)
178const watchedSourceRowsByPathAtom = atom({ plugin: 'diff-review', key: 'watchedSourceRowsByPath' } as const, {})
179const watchedMatchCountByPathAtom = atom({ plugin: 'diff-review', key: 'watchedMatchCountByPath' } as const, {})
180const contextLinesByPathAtom = atom({ plugin: 'diff-review', key: 'contextLinesByPath' } as const, {})
181const isNoiseShownAtom = atom({ plugin: 'diff-review', key: 'isNoiseShown' } as const, false)
182const isViewedHiddenAtom = atom({ plugin: 'diff-review', key: 'isViewedHidden' } as const, false)
183const recentlyViewedPathsAtom = atom({ plugin: 'diff-review', key: 'recentlyViewedPaths' } as const, [])
184const foldedDirectoriesAtom = atom({ plugin: 'diff-review', key: 'foldedDirectories' } as const, [])
185const isFileDrawerOpenAtom = atom({ plugin: 'diff-review', key: 'isFileDrawerOpen' } as const, false)
186const isFileListCollapsedAtom = atom({ plugin: 'diff-review', key: 'isFileListCollapsed' } as const, false)
187const cursorRowIndexAtom = atom({ plugin: 'diff-review', key: 'cursorRowIndex' } as const, null)
188const claudeCodeThemeAtom = atom({ plugin: 'diff-review', key: 'claudeCodeTheme' } as const, null)
189const configuredThemeAtom = atom({ plugin: 'diff-review', key: 'configuredTheme' } as const, null)
190const isHelpShownAtom = atom({ plugin: 'diff-review', key: 'isHelpShown' } as const, false)
191const answerPulseFrameAtom = atom({ plugin: 'diff-review', key: 'answerPulseFrame' } as const, 0)
192const isSplitViewPreferredAtom = atom({ plugin: 'diff-review', key: 'isSplitViewPreferred' } as const, false)
193
194// Questions and their threads.
195const threadsAtom = atom({ plugin: 'diff-review', key: 'threads' } as const, [])
196const anchorWithOpenInputAtom = atom({ plugin: 'diff-review', key: 'anchorWithOpenInput' } as const, null)
197const questionFieldNumberAtom = atom({ plugin: 'diff-review', key: 'questionFieldNumber' } as const, 0)
198const questionDraftAtom = atom({ plugin: 'diff-review', key: 'questionDraft' } as const, '')
199const hasQuestionHadKeyboardAtom = atom({ plugin: 'diff-review', key: 'hasQuestionHadKeyboard' } as const, false)
200const armedQuestionAtom = atom({ plugin: 'diff-review', key: 'armedQuestion' } as const, null)
201const threadIdAwaitingTurnAtom = atom({ plugin: 'diff-review', key: 'threadIdAwaitingTurn' } as const, null)
202const currentThreadIdAtom = atom({ plugin: 'diff-review', key: 'currentThreadId' } as const, null)
203const threadIdByTurnIdAtom = atom({ plugin: 'diff-review', key: 'threadIdByTurnId' } as const, {})
204
205type CommandResult = { isSuccess: boolean; output: string }
206
207// The geometry of the last drawing, kept so a wheel tick can tell the file list from the diff, clamp the scrolling and
208// resize the file list from where a drag of the divider started.
209const lastDrawnLayout = {
210 isTwoColumns: true,
211 isFileDrawerOpen: false,
212 panelsTop: 0,
213 bodyColumns: 0,
214 fileListWidth: 0,
215 fileListBoxHeight: 0,
216 visibleFileLines: 1,
217 fileLineCount: 0,
218 fileLineIndexByPath: {} as Record<string, number>,
219 visibleDiffRows: 1,
220 scrolledRowCount: 0,
221 isOverviewShown: false,
222 isSplitView: false,
223 cursorRowIndex: null as number | null,
224 isScrollingPastEnd: false,
225 commitPanelRows: 0,
226 panelsHeight: 0,
227 firstShownCommitLineIndex: 0,
228 commitLineCount: 0,
229 diffRowHeights: [] as number[],
230 fileListWidthAtDragStart: 0,
231}
232
233// Stays on top of the pane until dismissed: a failure must not slip by while the eyes are on the code.
234async function reportError($: EngineInterface, text: string, action: ErrorNotice['action'] = null): Promise<void> {
235 const noticeId = `${await $.clock.now()}-${text.length}`
236 await update($, errorNoticesAtom, notices => [...notices.filter(notice => notice.text !== text), { id: noticeId, text, action }])
237}
238
239// --- Commands on the host -------------------------------------------------------------------------------------------
240
241async function runGit($: EngineInterface, gitArguments: string[], timeoutMs = GIT_TIMEOUT_MS): Promise<CommandResult> {
242 const repositoryRoot = await read($, repositoryRootAtom)
243 try {
244 const { exitCode, stdout, stderr } = await $.process.run(
245 ['git', ...(repositoryRoot ? ['-C', repositoryRoot] : []), ...gitArguments],
246 { timeoutMs },
247 )
248
249 return { isSuccess: exitCode === 0, output: exitCode === 0 ? stdout : stderr }
250 } catch (error) {
251 return { isSuccess: false, output: String(error) }
252 }
253}
254
255// xdg-open on Linux, open on macOS: whichever the host has.
256async function openInBrowser($: EngineInterface, url: string): Promise<CommandResult> {
257 let lastResult: CommandResult = { isSuccess: false, output: 'no browser opener found' }
258 for (const opener of BROWSER_OPENERS) {
259 try {
260 const { exitCode, stdout, stderr } = await $.process.run([opener, url], { timeoutMs: GIT_TIMEOUT_MS })
261 lastResult = { isSuccess: exitCode === 0, output: exitCode === 0 ? stdout : stderr }
262 } catch (error) {
263 lastResult = { isSuccess: false, output: String(error) }
264 }
265 if (lastResult.isSuccess) {
266 break
267 }
268 }
269
270 return lastResult
271}
272
273async function runGh($: EngineInterface, ghArguments: string[]): Promise<CommandResult> {
274 const repositoryRoot = await read($, repositoryRootAtom)
275 try {
276 const { exitCode, stdout, stderr } = await $.process.run(['gh', ...ghArguments], {
277 ...(repositoryRoot ? { cwd: repositoryRoot } : {}),
278 timeoutMs: GH_TIMEOUT_MS,
279 })
280
281 return { isSuccess: exitCode === 0, output: exitCode === 0 ? stdout : stderr }
282 } catch (error) {
283 return { isSuccess: false, output: String(error) }
284 }
285}
286
287function shortOutput(result: CommandResult): string {
288 return result.output.trim().slice(0, 120)
289}
290
291async function detectKeyboardLayout($: EngineInterface): Promise<KeyboardLayout> {
292 for (const layoutSource of KEYBOARD_LAYOUT_SOURCES) {
293 try {
294 const { exitCode, stdout } = await $.process.run(layoutSource.argv, { timeoutMs: 3000 })
295 const keyboardLayout = exitCode === 0 ? keyboardLayoutOf(stdout, layoutSource.layoutPattern) : null
296 if (keyboardLayout) {
297 return keyboardLayout
298 }
299 } catch {
300 continue
301 }
302 }
303
304 return 'unknown'
305}
306
307// --- GitHub ---------------------------------------------------------------------------------------------------------
308
309async function loadEmojiCharacters($: EngineInterface): Promise<Record<string, string>> {
310 const cached = (await $.store.get(EMOJI_STORE_KEY).catch(() => undefined)) as
311 | { fetchedAt: number; characters: Record<string, string> }
312 | undefined
313 const nowMs = await $.clock.now()
314 if (cached && nowMs - cached.fetchedAt < EMOJI_CACHE_DURATION_MS) {
315 return cached.characters
316 }
317 const emojis = await runGh($, ['api', 'emojis'])
318 if (!emojis.isSuccess) {
319 return cached?.characters ?? {}
320 }
321 const characters = parseEmojiCharacters(emojis.output)
322 await $.store.set(EMOJI_STORE_KEY, { fetchedAt: nowMs, characters }).catch(() => undefined)
323
324 return characters
325}
326
327// GitHub is asked only when the person opens the explorer, refreshes it or switches to the PR view, never on every edit.
328async function loadPullRequest($: EngineInterface): Promise<void> {
329 const pullRequestView = await runGh($, ['pr', 'view', '--json', PULL_REQUEST_VIEW_FIELDS])
330 const pullRequest = pullRequestView.isSuccess ? parsePullRequest(pullRequestView.output, await loadEmojiCharacters($)) : null
331 await update($, pullRequestAtom, () => pullRequest)
332 if (pullRequest === null) {
333 await update($, viewedStateByPathAtom, () => ({}))
334 await update($, renamedPathByPreviousPathAtom, () => ({}))
335 await update($, gitHubReviewThreadsAtom, () => [])
336 return
337 }
338 await loadGitHubReviewThreads($, pullRequest.id)
339 const viewedStateByPath: Record<string, ViewedState> = {}
340 let cursor: string | null = null
341 do {
342 const page = await runGh($, [
343 'api',
344 'graphql',
345 '-f',
346 `query=${VIEWED_STATES_QUERY}`,
347 '-F',
348 `pullRequestId=${pullRequest.id}`,
349 ...(cursor ? ['-f', `cursor=${cursor}`] : []),
350 ])
351 if (!page.isSuccess) {
352 await reportError($, `Could not read the viewed state from GitHub (${shortOutput(page)})`)
353 break
354 }
355 const parsedPage = parseViewedStatesPage(page.output)
356 Object.assign(viewedStateByPath, parsedPage.viewedStateByPath)
357 cursor = parsedPage.nextCursor
358 } while (cursor !== null)
359 const renames = await runGh($, pullRequestRenamesArguments(pullRequest.number))
360 const renamedPathByPreviousPath = renames.isSuccess ? parseRenamedPaths(renames.output) : {}
361 await update($, renamedPathByPreviousPathAtom, () => renamedPathByPreviousPath)
362 const gitHubViewedStateByPath = withPreviousPathsOfRenames(viewedStateByPath, renamedPathByPreviousPath)
363 await update($, pushDismissedPathsAtom, paths => paths.filter(path => gitHubViewedStateByPath[path] === 'VIEWED'))
364 const pushDismissedPaths = await read($, pushDismissedPathsAtom)
365 await update($, viewedStateByPathAtom, () => dismissChangedFiles(gitHubViewedStateByPath, pushDismissedPaths))
366}
367
368async function loadGitHubReviewThreads($: EngineInterface, pullRequestId: string): Promise<void> {
369 const emojiCharacterByShortcode = await loadEmojiCharacters($)
370 const threads: GitHubReviewThread[] = []
371 let cursor: string | null = null
372 do {
373 const page = await runGh($, [
374 'api',
375 'graphql',
376 '-f',
377 `query=${REVIEW_THREADS_QUERY}`,
378 '-F',
379 `pullRequestId=${pullRequestId}`,
380 ...(cursor ? ['-f', `cursor=${cursor}`] : []),
381 ])
382 if (!page.isSuccess) {
383 await reportError($, `Could not read the review comments from GitHub (${shortOutput(page)})`)
384 break
385 }
386 const parsedPage = parseReviewThreadsPage(page.output, emojiCharacterByShortcode)
387 threads.push(...parsedPage.threads)
388 cursor = parsedPage.nextCursor
389 } while (cursor !== null)
390 await update($, gitHubReviewThreadsAtom, () => threads)
391}
392
393// The thread folds or unfolds at once, as GitHub draws it; GitHub refusing puts it back as it was.
394async function toggleGitHubThreadResolved($: EngineInterface, threadId: string): Promise<void> {
395 const thread = (await read($, gitHubReviewThreadsAtom)).find(candidate => candidate.id === threadId)
396 if (!thread) {
397 return
398 }
399 const setResolved = async (isResolved: boolean) => {
400 await update($, gitHubReviewThreadsAtom, threads =>
401 threads.map(candidate => (candidate.id === threadId ? { ...candidate, isResolved } : candidate)),
402 )
403 }
404 await setResolved(!thread.isResolved)
405 await update($, expandedGitHubThreadIdsAtom, threadIds => threadIds.filter(id => id !== threadId))
406 const mutation = await runGh($, [
407 'api',
408 'graphql',
409 '-f',
410 `query=${thread.isResolved ? UNRESOLVE_REVIEW_THREAD_MUTATION : RESOLVE_REVIEW_THREAD_MUTATION}`,
411 '-F',
412 `threadId=${threadId}`,
413 ])
414 if (!mutation.isSuccess) {
415 await setResolved(thread.isResolved)
416 await reportError($, `GitHub refused to ${thread.isResolved ? 'unresolve' : 'resolve'} the thread (${shortOutput(mutation)})`)
417 }
418}
419
420async function toggleViewed($: EngineInterface, path: string): Promise<void> {
421 const pullRequest = await read($, pullRequestAtom)
422 const previousState = (await read($, viewedStateByPathAtom))[path]
423 if (pullRequest === null || previousState === undefined) {
424 return
425 }
426 const renamedPathByPreviousPath = await read($, renamedPathByPreviousPathAtom)
427 const gitHubPath = renamedPathByPreviousPath[path] ?? path
428 const pathsSharingTheBox = [
429 gitHubPath,
430 ...Object.keys(renamedPathByPreviousPath).filter(previousPath => renamedPathByPreviousPath[previousPath] === gitHubPath),
431 ]
432 const setStateOfPathsSharingTheBox = async (viewedState: ViewedState) => {
433 await update($, viewedStateByPathAtom, states => ({
434 ...states,
435 ...Object.fromEntries(pathsSharingTheBox.map(sharingPath => [sharingPath, viewedState])),
436 }))
437 }
438 const isMarkingViewed = previousState !== 'VIEWED'
439 await update($, pushDismissedPathsAtom, paths => paths.filter(dismissedPath => !pathsSharingTheBox.includes(dismissedPath)))
440 if (isMarkingViewed && (await read($, isViewedHiddenAtom))) {
441 await update($, recentlyViewedPathsAtom, paths => [...paths, ...pathsSharingTheBox])
442 await $.clock.after(VIEWED_FILE_HIDE_DELAY_MS, () =>
443 update($, recentlyViewedPathsAtom, paths => paths.filter(recentPath => !pathsSharingTheBox.includes(recentPath))),
444 )
445 }
446 await setStateOfPathsSharingTheBox(isMarkingViewed ? 'VIEWED' : 'UNVIEWED')
447 const mutation = await runGh($, [
448 'api',
449 'graphql',
450 '-f',
451 `query=${isMarkingViewed ? MARK_FILE_AS_VIEWED_MUTATION : UNMARK_FILE_AS_VIEWED_MUTATION}`,
452 '-F',
453 `pullRequestId=${pullRequest.id}`,
454 '-f',
455 `path=${gitHubPath}`,
456 ])
457 if (!mutation.isSuccess) {
458 await setStateOfPathsSharingTheBox(previousState)
459 await reportError($, `GitHub refused to change the viewed state of ${path}`)
460 return
461 }
462 await rememberViewedCommit($, pathsSharingTheBox, isMarkingViewed)
463}
464
465async function viewedCommitsKey($: EngineInterface): Promise<string | null> {
466 const repositoryRoot = await read($, repositoryRootAtom)
467 const branch = await read($, branchAtom)
468
469 return repositoryRoot && branch ? `${VIEWED_COMMITS_STORE_KEY_PREFIX}|${repositoryRoot}|${branch}` : null
470}
471
472async function savedViewedCommitByPath($: EngineInterface, key: string): Promise<Record<string, string>> {
473 const saved = await $.store.get(key).catch(() => undefined)
474
475 return saved !== null && typeof saved === 'object' ? { ...(saved as Record<string, string>) } : {}
476}
477
478async function rememberViewedCommit($: EngineInterface, paths: string[], isViewed: boolean): Promise<void> {
479 const key = await viewedCommitsKey($)
480 const head = isViewed ? await runGit($, ['rev-parse', 'HEAD']) : null
481 if (key === null || (head !== null && !head.isSuccess)) {
482 return
483 }
484 const viewedCommitByPath = await savedViewedCommitByPath($, key)
485 for (const path of paths) {
486 if (head === null) {
487 delete viewedCommitByPath[path]
488 } else {
489 viewedCommitByPath[path] = head.output.trim()
490 }
491 }
492 await $.store.set(key, viewedCommitByPath).catch(() => undefined)
493}
494
495// A file changed since it was ticked in the pane opens on what changed since; ticked on GitHub, the pane cannot tell.
496async function placeCursorOnChangesSinceViewed($: EngineInterface, path: string): Promise<boolean> {
497 const key = await viewedCommitsKey($)
498 const isChangedSinceViewed = (await read($, viewAtom)) === 'pr' && (await read($, viewedStateByPathAtom))[path] === 'DISMISSED'
499 const viewedCommit = isChangedSinceViewed && key !== null ? (await savedViewedCommitByPath($, key))[path] : undefined
500 if (viewedCommit === undefined) {
501 return false
502 }
503 const changesSinceViewed = await runGit($, ['diff', '--no-color', '--no-renames', '-U0', viewedCommit, 'HEAD', '--', path])
504 if (!changesSinceViewed.isSuccess) {
505 return false
506 }
507 const diffRows = await read($, diffRowsAtom)
508 const splitRows = lastDrawnLayout.isSplitView ? pairRowsSideBySide(diffRows) : null
509 const target = stopOnNewLines(diffRows, cursorStopsOf(diffRows, splitRows), changedNewLinesOf(changesSinceViewed.output))
510 if (target === null) {
511 return false
512 }
513 await placeCursorOnRow($, target)
514
515 return true
516}
517
518// Reviewing a PR file by file: once one is viewed, the next one still to view comes up, in the order of the list.
519async function markViewedAndNext($: EngineInterface): Promise<void> {
520 const path = await read($, selectedFilePathAtom)
521 const viewedStateByPath = await read($, viewedStateByPathAtom)
522 if (path === null || viewedStateByPath[path] === undefined) {
523 return
524 }
525 const wasViewed = viewedStateByPath[path] === 'VIEWED'
526 await toggleViewed($, path)
527 if (wasViewed) {
528 return
529 }
530 const orderedPaths = groupFilesByDirectory(await shownFilesOf($, await read($, filesAtom))).flatMap(group =>
531 group.files.map(file => file.path),
532 )
533 const currentIndex = orderedPaths.indexOf(path)
534 const nextPath = [...orderedPaths.slice(currentIndex + 1), ...orderedPaths.slice(0, currentIndex)].find(
535 candidate => viewedStateByPath[candidate] !== undefined && viewedStateByPath[candidate] !== 'VIEWED',
536 )
537 if (nextPath) {
538 await selectFile($, nextPath)
539 }
540}
541
542// --- Loading what a view shows --------------------------------------------------------------------------------------
543
544// A PR may target another branch than the repository's default one (a release branch such as 2026-09): its own
545// target comes first, or the PR view would list the commits of that branch too.
546async function findBaseRef($: EngineInterface, branch: string | null): Promise<{ sha: string; branchName: string } | null> {
547 const pullRequestBase = (await read($, pullRequestAtom))?.baseRefName
548 for (const candidate of pullRequestBase ? [`origin/${pullRequestBase}`, pullRequestBase] : []) {
549 const mergeBase = await runGit($, ['merge-base', 'HEAD', candidate])
550 if (mergeBase.isSuccess) {
551 return { sha: mergeBase.output.trim(), branchName: candidate.replace(/^origin\//, '') }
552 }
553 }
554 // Without a pull request, the branch the work most likely left is the one it is fewest commits ahead of: develop for a
555 // branch taken from develop, though GitHub's default branch is main. A branch is never its own base.
556 const originHead = await runGit($, ['rev-parse', '--abbrev-ref', 'origin/HEAD'])
557 const candidates = [...(originHead.isSuccess ? [originHead.output.trim()] : []), ...BASE_REF_CANDIDATES].filter(
558 candidate => candidate.replace(/^origin\//, '') !== branch,
559 )
560 let closestBase: { sha: string; branchName: string; commitsAhead: number } | null = null
561 for (const candidate of candidates) {
562 const mergeBase = await runGit($, ['merge-base', 'HEAD', candidate])
563 if (!mergeBase.isSuccess) {
564 continue
565 }
566 const sha = mergeBase.output.trim()
567 const commitsAhead = await runGit($, ['rev-list', '--count', `${sha}..HEAD`])
568 const count = commitsAhead.isSuccess ? Number(commitsAhead.output.trim()) : Number.POSITIVE_INFINITY
569 if (closestBase === null || count < closestBase.commitsAhead) {
570 closestBase = { sha, branchName: candidate.replace(/^origin\//, ''), commitsAhead: count }
571 }
572 }
573
574 return closestBase && { sha: closestBase.sha, branchName: closestBase.branchName }
575}
576
577async function readWorkingTreeFile($: EngineInterface, path: string): Promise<string> {
578 const repositoryRoot = await read($, repositoryRootAtom)
579
580 return $.fs.read(repositoryRoot ? `${repositoryRoot}/${path}` : path)
581}
582
583// A file missing at that revision, created or deleted by the change, reads as empty.
584async function fileContentAt($: EngineInterface, revision: string | null, path: string): Promise<string> {
585 if (revision === null) {
586 try {
587 return await readWorkingTreeFile($, path)
588 } catch {
589 return ''
590 }
591 }
592 const show = await runGit($, ['show', `${revision}:${path}`])
593
594 return show.isSuccess ? show.output : ''
595}
596
597async function readUntrackedFileRows($: EngineInterface, path: string): Promise<DiffRow[]> {
598 let content = ''
599 try {
600 content = await readWorkingTreeFile($, path)
601 } catch (error) {
602 await update($, loadErrorAtom, () => `Cannot read ${path}: ${String(error)}`)
603 }
604
605 return [
606 { kind: 'hunk', oldNumber: null, newNumber: null, text: 'new file, untracked' },
607 ...content
608 .replace(/\n$/, '')
609 .split('\n')
610 .map((text, index) => ({ kind: 'added' as const, oldNumber: null, newNumber: index + 1, text })),
611 ]
612}
613
614async function loadSelectedFileDiff($: EngineInterface): Promise<void> {
615 const path = await read($, selectedFilePathAtom)
616 const file = (await read($, filesAtom)).find(changedFile => changedFile.path === path)
617 if (!path || !file) {
618 await update($, diffRowsAtom, () => [])
619 return
620 }
621 if (file.isUntracked) {
622 const rows = await readUntrackedFileRows($, path)
623 const content = rows.flatMap(row => (row.kind === 'added' ? [row.text] : [])).join('\n')
624 await update($, diffRowsAtom, () => (isVueFile(path) ? withVueLanguages(rows, '', content) : rows))
625 return
626 }
627 const range = diffRangeArguments(await read($, viewAtom), await read($, baseRefAtom), await read($, selectedCommitShaAtom))
628 if (!range) {
629 await update($, diffRowsAtom, () => [])
630 return
631 }
632 const contextLines = (await read($, contextLinesByPathAtom))[path] ?? DEFAULT_DIFF_CONTEXT_LINES
633 const diff = await runGit($, ['diff', '--no-color', '--no-renames', `-U${contextLines}`, ...range, '--', path])
634 const rows = diff.isSuccess ? parseUnifiedDiff(diff.output) : []
635 if (isVueFile(path) && rows.length > 0) {
636 const [oldRevision = null, newRevision = null] = range
637 const oldContent = await fileContentAt($, oldRevision, path)
638 const newContent = await fileContentAt($, newRevision, path)
639 await update($, diffRowsAtom, () => withVueLanguages(rows, oldContent, newContent))
640 } else {
641 await update($, diffRowsAtom, () => rows)
642 }
643 if (!diff.isSuccess) {
644 await update($, loadErrorAtom, () => diff.output.trim())
645 }
646}
647
648async function loadBranchCommits($: EngineInterface, baseRef: string | null): Promise<void> {
649 const log = baseRef ? await runGit($, ['log', '--graph', '--boundary', `--format=${LOG_FORMAT}`, `${baseRef}..HEAD`]) : null
650 const commitGraph = log?.isSuccess ? parseCommitGraph(log.output) : []
651 const commits = commitsOfGraph(commitGraph)
652 await update($, commitGraphAtom, () => commitGraph)
653 await update($, commitsAtom, () => commits)
654 const selectedCommitSha = await read($, selectedCommitShaAtom)
655 if (!commits.some(commit => commit.sha === selectedCommitSha)) {
656 await update($, selectedCommitShaAtom, () => commits[0]?.sha ?? null)
657 }
658 const shownCommitSha = await read($, selectedCommitShaAtom)
659 const message = shownCommitSha ? await runGit($, ['log', '-1', '--format=%B', shownCommitSha]) : null
660 await update($, selectedCommitMessageAtom, () => (message?.isSuccess ? message.output.trimEnd() : null))
661}
662
663const WORKING_TREE_STATUS_ARGUMENTS = ['status', '--porcelain', '--untracked-files=all']
664
665// Files change outside Claude too, in an editor or a terminal: while the pane is open, git status is read again now and
666// then, and the view reloads only when it says something else.
667async function reloadWhenWorkingTreeChanged($: EngineInterface): Promise<void> {
668 if (!(await $.ui.panes()).some(pane => pane.id === PANE_ID)) {
669 return
670 }
671 const status = await runGit($, WORKING_TREE_STATUS_ARGUMENTS)
672 if (status.isSuccess && status.output !== (await read($, workingTreeStatusAtom))) {
673 await loadView($)
674 }
675}
676
677async function loadBranchState($: EngineInterface, branch: string | null, baseRef: string | null): Promise<void> {
678 const status = await runGit($, WORKING_TREE_STATUS_ARGUMENTS)
679 const statusOutput = status.isSuccess ? status.output : ''
680 const stageStateByPath = stageStatesOf(statusOutput)
681 await update($, workingTreeStatusAtom, () => statusOutput)
682 await update($, stageStateByPathAtom, () => stageStateByPath)
683 await update($, uncommittedFileCountAtom, () => Object.keys(stageStateByPath).length)
684 await update($, stagedFileCountAtom, () => Object.values(stageStateByPath).filter(stageState => stageState !== 'unstaged').length)
685 const branchCommits = baseRef ? await runGit($, ['rev-list', '--count', `${baseRef}..HEAD`]) : null
686 await update($, branchCommitCountAtom, () => (branchCommits?.isSuccess ? Number(branchCommits.output.trim()) : 0))
687 // A branch never pushed has no remote of its own: its commits already on any remote branch, the ones it took from
688 // develop say, are not its to push.
689 const remoteRanges = [['@{upstream}..HEAD'], ...(branch ? [[`origin/${branch}..HEAD`]] : []), ['HEAD', '--not', '--remotes']]
690 let unpushedCommitShas: string[] = []
691 for (const remoteRange of remoteRanges) {
692 const revList = await runGit($, ['rev-list', ...remoteRange])
693 if (revList.isSuccess) {
694 unpushedCommitShas = revList.output.split('\n').filter(sha => sha !== '')
695 break
696 }
697 }
698 await update($, unpushedCommitShasAtom, () => unpushedCommitShas)
699}
700
701async function loadChangedFiles($: EngineInterface, view: ReviewView, baseRef: string | null): Promise<ChangedFile[]> {
702 const range = diffRangeArguments(view, baseRef, await read($, selectedCommitShaAtom))
703 let files: ChangedFile[] = []
704 if (range) {
705 const numstat = await runGit($, ['diff', '--numstat', '--summary', '--no-renames', ...range])
706 files = numstat.isSuccess ? parseNumstat(numstat.output) : []
707 if (!numstat.isSuccess) {
708 await update($, loadErrorAtom, () => numstat.output.trim())
709 }
710 } else if (view === 'pr') {
711 await update($, loadErrorAtom, () => 'Base branch not found (origin/HEAD, main, master).')
712 }
713 if (view === 'uncommitted') {
714 const untracked = await runGit($, ['ls-files', '--others', '--exclude-standard'])
715 files = [...files, ...(untracked.isSuccess ? parseUntrackedFiles(untracked.output) : [])]
716 }
717
718 return files
719}
720
721async function shownFilesOf($: EngineInterface, files: ChangedFile[]): Promise<ChangedFile[]> {
722 const isViewedHidden = (await read($, viewAtom)) === 'pr' && (await read($, isViewedHiddenAtom))
723 const hiddenPaths = isViewedHidden
724 ? viewedPathsToHide(await read($, viewedStateByPathAtom), await read($, recentlyViewedPathsAtom))
725 : undefined
726
727 return visibleFilesOf(files, { filter: await read($, fileFilterAtom), isNoiseShown: await read($, isNoiseShownAtom), hiddenPaths }).files
728}
729
730async function widenContext($: EngineInterface, path: string, contextLines: (current: number) => number): Promise<void> {
731 await update($, contextLinesByPathAtom, contextLinesByPath => ({
732 ...contextLinesByPath,
733 [path]: contextLines(contextLinesByPath[path] ?? DEFAULT_DIFF_CONTEXT_LINES),
734 }))
735 const cursorRow = (await read($, diffRowsAtom))[(await read($, cursorRowIndexAtom)) ?? -1]
736 await loadSelectedFileDiff($)
737 // More context shifts every row: the cursor follows its line.
738 const diffRows = await read($, diffRowsAtom)
739 const cursorRowIndex = cursorRow
740 ? diffRows.findIndex(row => row.kind === cursorRow.kind && row.oldNumber === cursorRow.oldNumber && row.newNumber === cursorRow.newNumber)
741 : -1
742 await update($, cursorRowIndexAtom, () => (cursorRowIndex === -1 ? null : cursorRowIndex))
743 await focusFirstChange($)
744}
745
746async function loadView($: EngineInterface): Promise<void> {
747 await update($, loadErrorAtom, () => null)
748 const topLevel = await runGit($, ['rev-parse', '--show-toplevel'])
749 if (!topLevel.isSuccess) {
750 await update($, loadErrorAtom, () => 'Not a git repository.')
751 return
752 }
753 await update($, repositoryRootAtom, () => topLevel.output.trim())
754 const remote = await runGit($, ['remote', 'get-url', 'origin'])
755 await update($, repositoryWebUrlAtom, () => (remote.isSuccess ? repositoryWebUrlOf(remote.output) : null))
756 const branch = await runGit($, ['rev-parse', '--abbrev-ref', 'HEAD'])
757 await update($, branchAtom, () => (branch.isSuccess ? branch.output.trim() : null))
758 const view = await read($, viewAtom)
759 const base = await findBaseRef($, branch.isSuccess ? branch.output.trim() : null)
760 const baseRef = base?.sha ?? null
761 await update($, baseRefAtom, () => baseRef)
762 await update($, baseBranchNameAtom, () => base?.branchName ?? null)
763 if (view === 'commits') {
764 await loadBranchCommits($, baseRef)
765 }
766 await loadBranchState($, branch.isSuccess ? branch.output.trim() : null, baseRef)
767 const files = await loadChangedFiles($, view, baseRef)
768 await update($, filesAtom, () => files)
769 const selectedFilePath = await read($, selectedFilePathAtom)
770 const shownFiles = await shownFilesOf($, files)
771 if (!shownFiles.some(file => file.path === selectedFilePath)) {
772 await update($, selectedFilePathAtom, () => groupFilesByDirectory(shownFiles)[0]?.files[0]?.path ?? null)
773 }
774 await loadSelectedFileDiff($)
775 await loadWatchedSource($)
776}
777
778// The first press arms, the second pushes; the push runs on its own so the pane shows it under way and ignores presses.
779async function pushBranch($: EngineInterface): Promise<void> {
780 const pushState = await read($, pushStateAtom)
781 switch (pushState) {
782 case 'pushing':
783 case 'pushed':
784 return
785 case 'idle':
786 case 'rejected':
787 await armPush($, pushState === 'idle' ? 'armed' : 'forceArmed', pushState)
788 return
789 case 'armed':
790 case 'forceArmed':
791 break
792 }
793 const branch = await read($, branchAtom)
794 if (branch === null || branch === 'HEAD' || ['main', 'master'].includes(branch)) {
795 await update($, pushStateAtom, () => 'idle')
796 await reportError($, `No push from ${branch ?? 'a detached HEAD'}`)
797 return
798 }
799 if (pushState === 'forceArmed') {
800 await update($, errorNoticesAtom, notices => notices.filter(notice => notice.action !== 'forcePush'))
801 }
802 await update($, pushStateAtom, () => 'pushing')
803 void runPush($, branch, pushState === 'forceArmed').catch(async error => {
804 await update($, pushStateAtom, () => 'idle')
805 await reportError($, `Push failed: ${String(error)}`)
806 })
807}
808
809async function armPush($: EngineInterface, armedState: 'armed' | 'forceArmed', stateToComeBackTo: 'idle' | 'rejected'): Promise<void> {
810 await update($, pushStateAtom, () => armedState)
811 await $.clock.after(PUSH_CONFIRM_DELAY_MS, () => update($, pushStateAtom, state => (state === armedState ? stateToComeBackTo : state)))
812}
813
814// A plain push never overwrites the remote. A force push only replaces what this clone last fetched of it
815// (--force-with-lease) and only once that is part of the local history (--force-if-includes): commits pushed
816// meanwhile from elsewhere make it fail instead of vanishing.
817async function runPush($: EngineInterface, branch: string, isForced: boolean): Promise<void> {
818 const hasUpstream = (await runGit($, ['rev-parse', '--abbrev-ref', '@{upstream}'])).isSuccess
819 const pushArguments = isForced
820 ? ['push', '--force-with-lease', '--force-if-includes']
821 : hasUpstream
822 ? ['push']
823 : ['push', '--set-upstream', 'origin', branch]
824 const pushedChanges = hasUpstream ? await runGit($, ['diff', '--name-only', '--no-renames', '@{upstream}', 'HEAD']) : null
825 const pushedPaths = pushedChanges?.isSuccess ? pushedChanges.output.split('\n').filter(path => path !== '') : []
826 const push = await runGit($, pushArguments, GIT_PUSH_TIMEOUT_MS)
827 if (push.isSuccess) {
828 await update($, pushDismissedPathsAtom, paths => [...new Set([...paths, ...pushedPaths])])
829 await update($, pushStateAtom, () => 'pushed')
830 await $.clock.after(PUSHED_NOTICE_MS, () => update($, pushStateAtom, state => (state === 'pushed' ? 'idle' : state)))
831 } else if (!isForced && /\[rejected\]|\[remote rejected\]|non-fast-forward|fetch first/.test(push.output)) {
832 await update($, pushStateAtom, () => 'rejected')
833 await reportError($, 'Push rejected: the remote branch has commits the local one lacks, after a rebase for instance.', 'forcePush')
834 } else {
835 await update($, pushStateAtom, () => 'idle')
836 await reportError($, `${isForced ? 'Force push' : 'Push'} failed: ${push.output.trim().split('\n').at(-1) ?? ''}`)
837 }
838 await refreshExplorer($)
839}
840
841async function refreshExplorer($: EngineInterface): Promise<void> {
842 await update($, isLoadingAtom, () => true)
843 await loadPullRequest($)
844 await loadView($)
845 await restoreSavedThreads($)
846 await update($, isLoadingAtom, () => false)
847}
848
849// --- Navigation -----------------------------------------------------------------------------------------------------
850
851async function switchView($: EngineInterface, view: ReviewView): Promise<void> {
852 await update($, followedThreadIdAtom, () => null)
853 await update($, viewAtom, () => view)
854 await update($, fileListOffsetAtom, () => 0)
855 await update($, diffOffsetAtom, () => 0)
856 await update($, isOverviewShownAtom, () => true)
857 await closeQuestion($)
858 await loadView($)
859 if (view === 'pr') {
860 await loadPullRequest($)
861 }
862}
863
864async function moveView($: EngineInterface, offset: number): Promise<void> {
865 const currentView = await read($, viewAtom)
866 const currentIndex = VIEW_ORDER.indexOf(currentView)
867 const targetView = VIEW_ORDER[Math.min(VIEW_ORDER.length - 1, Math.max(0, currentIndex + offset))]
868 if (targetView !== undefined && targetView !== currentView) {
869 await switchView($, targetView)
870 }
871}
872
873async function selectCommit($: EngineInterface, commitSha: string): Promise<void> {
874 await update($, selectedCommitShaAtom, () => commitSha)
875 // A commit picked among the lines the wheel brought keeps them in place; one out of sight brings the list back to it.
876 const lineIndex = (await read($, commitGraphAtom)).findIndex(line => !line.isBoundary && line.commit?.sha === commitSha)
877 const firstShownLineIndex = lastDrawnLayout.firstShownCommitLineIndex
878 if (lineIndex < firstShownLineIndex || lineIndex >= firstShownLineIndex + COMMIT_PANEL_MAX_LINES) {
879 await update($, commitListOffsetAtom, () => null)
880 }
881 await loadView($)
882}
883
884async function showOverview($: EngineInterface): Promise<void> {
885 await update($, isOverviewShownAtom, () => true)
886 await update($, diffOffsetAtom, () => 0)
887 await update($, fileListOffsetAtom, () => 0)
888 await closeQuestion($)
889}
890
891// The keyboard goes to the new file's first change, unless the caller puts it somewhere else on the same drawing: two
892// requests for one drawing leave the later one unanswered.
893async function selectFile($: EngineInterface, path: string, focus: 'first-change' | 'caller' = 'first-change'): Promise<void> {
894 await update($, followedThreadIdAtom, () => null)
895 await update($, isOverviewShownAtom, () => false)
896 await update($, selectedFilePathAtom, () => path)
897 // A file reached from a thread or from the next one to view opens its folded directory.
898 await update($, foldedDirectoriesAtom, folded => folded.filter(directory => directory !== directoryOf(path)))
899 await update($, diffOffsetAtom, () => 0)
900 await update($, cursorRowIndexAtom, () => null)
901 await scrollFileListTo($, path)
902 await closeQuestion($)
903 await loadSelectedFileDiff($)
904 if (focus === 'first-change' && !(await placeCursorOnChangesSinceViewed($, path))) {
905 await focusFirstChange($)
906 }
907}
908
909// With the keyboard on the cursor's hidden button, Enter asks about the cursor's line: a new file puts the cursor on
910// its first change.
911// The PR overview has no line: the keyboard then goes to the entry selected in the file list.
912async function focusFirstChange($: EngineInterface): Promise<void> {
913 const diffRows = await read($, diffRowsAtom)
914 const splitRows = lastDrawnLayout.isSplitView ? pairRowsSideBySide(diffRows) : null
915 // The overview only draws in the PR view of a branch that has a PR, whatever the flag says elsewhere.
916 const isOverviewDrawn =
917 (await read($, isOverviewShownAtom)) && (await read($, viewAtom)) === 'pr' && (await read($, pullRequestAtom)) !== null
918 const rowIndex = isOverviewDrawn ? null : firstChangeStop(diffRows, cursorStopsOf(diffRows, splitRows))
919 const selectedFilePath = await read($, selectedFilePathAtom)
920 const key =
921 rowIndex !== null
922 ? CURSOR_LINE_FOCUS_KEY
923 : isOverviewDrawn || selectedFilePath === null
924 ? PULL_REQUEST_OVERVIEW_KEY
925 : `${FILE_BUTTON_KEY_PREFIX}${selectedFilePath}`
926 focusAfterNextDrawing($, key)
927}
928
929async function scrollDiffByLines(
930 $: EngineInterface,
931 path: string,
932 linesToMove: number,
933 rowHeights: readonly number[],
934 lastTopLine: number,
935): Promise<void> {
936 const rowIndex = await read($, diffOffsetAtom)
937 const saved = await read($, diffSkippedLinesAtom)
938 const skippedLines = saved !== null && saved.path === path && saved.rowIndex === rowIndex ? saved.lines : 0
939 const position = scrolledByLines({ rowIndex, skippedLines }, linesToMove, rowHeights, lastTopLine)
940 await update($, diffOffsetAtom, () => position.rowIndex)
941 await update($, diffSkippedLinesAtom, () => ({ path, rowIndex: position.rowIndex, lines: position.skippedLines }))
942}
943
944async function scrollFileListTo($: EngineInterface, path: string): Promise<void> {
945 // The list as it is about to be drawn, the directory of the file maybe just unfolded.
946 const hasOverview = (await read($, viewAtom)) === 'pr' && (await read($, pullRequestAtom)) !== null
947 const fileLines = buildFileLines(await shownFilesOf($, await read($, filesAtom)), hasOverview, await read($, foldedDirectoriesAtom))
948 const fileLineIndex = fileLines.findIndex(fileLine => fileLine.kind === 'file' && fileLine.file.path === path)
949 if (fileLineIndex === -1) {
950 return
951 }
952 const visibleFileLines = lastDrawnLayout.visibleFileLines
953 await update($, fileListOffsetAtom, offset => {
954 if (fileLineIndex < offset) {
955 return fileLineIndex
956 }
957 return fileLineIndex >= offset + visibleFileLines ? fileLineIndex - visibleFileLines + 1 : offset
958 })
959}
960
961async function toggleDirectoryFold($: EngineInterface, directory: string): Promise<void> {
962 await update($, foldedDirectoriesAtom, folded =>
963 folded.includes(directory) ? folded.filter(candidate => candidate !== directory) : [...folded, directory],
964 )
965}
966
967// In the PR view the overview stands first, before the files.
968async function moveFileSelection($: EngineInterface, offset: number): Promise<void> {
969 const overviewEntry = '\u0000overview'
970 const hasOverview = (await read($, viewAtom)) === 'pr' && (await read($, pullRequestAtom)) !== null
971 const shownFiles = await shownFilesOf($, await read($, filesAtom))
972 const filePaths = groupFilesByDirectory(shownFiles).flatMap(group => group.files.map(file => file.path))
973 const orderedEntries = hasOverview ? [overviewEntry, ...filePaths] : filePaths
974 const currentEntry =
975 hasOverview && (await read($, isOverviewShownAtom)) ? overviewEntry : ((await read($, selectedFilePathAtom)) ?? '')
976 // The files of folded directories are stepped over; the selected one may sit in a directory folded since.
977 const foldedDirectories = await read($, foldedDirectoriesAtom)
978 const isListed = (entry: string) => entry === overviewEntry || !foldedDirectories.includes(directoryOf(entry))
979 const currentIndex = orderedEntries.indexOf(currentEntry)
980 const candidates = offset > 0 ? orderedEntries.slice(currentIndex + 1) : orderedEntries.slice(0, Math.max(0, currentIndex)).reverse()
981 const listedCandidates = candidates.filter(isListed)
982 const targetEntry = currentIndex === -1 ? orderedEntries.find(isListed) : (listedCandidates[Math.abs(offset) - 1] ?? listedCandidates.at(-1))
983 if (targetEntry === overviewEntry) {
984 await showOverview($)
985 } else if (targetEntry !== undefined) {
986 await selectFile($, targetEntry)
987 }
988}
989
990// --- Staging --------------------------------------------------------------------------------------------------------
991
992async function stageFile($: EngineInterface, path: string): Promise<void> {
993 if ((await read($, viewAtom)) !== 'uncommitted') {
994 return
995 }
996 const gitAdd = await runGit($, ['add', '--', path])
997 if (!gitAdd.isSuccess) {
998 await reportError($, `Git add failed (${shortOutput(gitAdd)})`)
999 return
1000 }
1001 await loadView($)
1002}
1003
1004async function stageCurrentFile($: EngineInterface): Promise<void> {
1005 const selectedFilePath = await read($, selectedFilePathAtom)
1006 if (selectedFilePath !== null && !(await read($, isOverviewShownAtom))) {
1007 await stageFile($, selectedFilePath)
1008 }
1009}
1010
1011// Everything the Uncommitted view lists goes to the index: modified, deleted and new files alike.
1012async function stageAllChanges($: EngineInterface): Promise<void> {
1013 const gitAdd = await runGit($, ['add', '--all'])
1014 if (!gitAdd.isSuccess) {
1015 await reportError($, `Git add --all failed (${shortOutput(gitAdd)})`)
1016 return
1017 }
1018 await loadView($)
1019}
1020
1021// The commit is Claude's to make, as when the person types it, so that a commit skill writes the message when there is
1022// one. Only what is staged goes in: the person chose it file by file.
1023async function requestCommit($: EngineInterface): Promise<void> {
1024 const commitRequest = await read($, commitRequestAtom)
1025 switch (commitRequest.state) {
1026 case 'requested':
1027 return
1028 case 'idle':
1029 case 'awaitingReply':
1030 break
1031 }
1032 if ((await read($, stagedFileCountAtom)) === 0) {
1033 return
1034 }
1035 const head = await runGit($, ['rev-parse', 'HEAD'])
1036 await update($, commitRequestAtom, (): CommitRequest => ({
1037 state: 'requested',
1038 headBeforeRequest: head.isSuccess ? head.output.trim() : null,
1039 turnId: null,
1040 }))
1041 const thread = newThread(`${COMMIT_THREAD_ANCHOR_KEY}-${await $.clock.now()}`, COMMIT_THREAD_ANCHOR_KEY, 'the commit of the staged changes', COMMIT_REQUEST_TEXT)
1042 await update($, commitThreadAtom, () => thread)
1043 await update($, threadIdAwaitingTurnAtom, () => thread.id)
1044 try {
1045 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: COMMIT_REQUEST_CONTEXT }] } })
1046 } catch (error) {
1047 await reportError($, `The commit request lost its instructions (${String(error)})`)
1048 }
1049 await $.prompt.submit({ text: COMMIT_REQUEST_TEXT, asUser: true })
1050}
1051
1052const COMMIT_REQUEST_TEXT = 'commit the staged changes'
1053
1054const COMMIT_REQUEST_CONTEXT = [
1055 'The person pressed the commit button of the diff-review pane.',
1056 'Commit what is staged in the git index, and only that: do not stage any other change.',
1057 'Use a commit skill when one is available; otherwise follow the conventions of the recent commit messages.',
1058].join(' ')
1059
1060// HEAD moving means Claude committed: the pane goes to the commits, where the new one waits to be pushed. A turn of the
1061// request that ends with HEAD in place leaves Claude waiting for the person's reply, whose turn may commit.
1062async function followCommitRequest($: EngineInterface, completedTurnId: string): Promise<void> {
1063 const commitRequest = await read($, commitRequestAtom)
1064 switch (commitRequest.state) {
1065 case 'idle':
1066 return
1067 case 'requested':
1068 case 'awaitingReply':
1069 break
1070 }
1071 const head = await runGit($, ['rev-parse', 'HEAD'])
1072 const headNow = head.isSuccess ? head.output.trim() : null
1073 if (headNow !== commitRequest.headBeforeRequest) {
1074 await update($, commitRequestAtom, (): CommitRequest => ({ state: 'idle' }))
1075 await update($, commitThreadAtom, () => null)
1076 await switchView($, 'commits')
1077 return
1078 }
1079 if (commitRequest.state === 'requested' && commitRequest.turnId === completedTurnId) {
1080 await update($, commitRequestAtom, (): CommitRequest => ({ state: 'awaitingReply', headBeforeRequest: commitRequest.headBeforeRequest }))
1081 }
1082}
1083
1084// --- The pane and its keyboard --------------------------------------------------------------------------------------
1085
1086async function paneOpenArguments($: EngineInterface) {
1087 const requestedPaneColumns = await read($, requestedPaneColumnsAtom)
1088
1089 return {
1090 id: PANE_ID,
1091 title: PANE_TITLE,
1092 focus: true as const,
1093 closeOnEscape: true as const,
1094 ...(requestedPaneColumns > 0 ? { columns: requestedPaneColumns } : {}),
1095 }
1096}
1097
1098// A key press is handled before the pane is drawn again, and the engine waits for an element still to be drawn: awaiting
1099// the focus there would hold the press until the wait gives up. It settles once the next drawing is done.
1100function focusAfterNextDrawing($: EngineInterface, key: string): void {
1101 void $.ui.focus({ requestId: PANE_ID, key }).catch(() => undefined)
1102}
1103
1104// Refused while the prompt below holds the keyboard: the field then waits for the person's own click.
1105async function focusQuestionField($: EngineInterface, questionFieldNumber: number): Promise<void> {
1106 const isStillWaitingForKeyboard =
1107 (await read($, questionFieldNumberAtom)) === questionFieldNumber &&
1108 (await read($, anchorWithOpenInputAtom)) !== null &&
1109 !(await read($, hasQuestionHadKeyboardAtom))
1110 if (isStillWaitingForKeyboard) {
1111 await $.ui.focus({ requestId: PANE_ID, key: `${QUESTION_INPUT_KEY_PREFIX}${questionFieldNumber}` }).catch(() => undefined)
1112 }
1113}
1114
1115// --- Questions ------------------------------------------------------------------------------------------------------
1116
1117// Each opened question gets a field of its own key: reusing one would hand the keyboard to the field of the previous
1118// line, still on screen for a moment and gone right after.
1119async function openQuestion($: EngineInterface, anchorKey: string): Promise<void> {
1120 await update($, armedQuestionAtom, () => null)
1121 await update($, questionDraftAtom, () => '')
1122 await update($, hasQuestionHadKeyboardAtom, () => false)
1123 const questionFieldNumber = await update($, questionFieldNumberAtom, fieldNumber => fieldNumber + 1)
1124 await update($, anchorWithOpenInputAtom, () => anchorKey)
1125 // A click on a line does not hand the keyboard to the pane, and a field can only be focused inside a pane that holds
1126 // it: opening the pane again with focus gives it the keyboard first. Without the width this explorer asked for,
1127 // opening again would bring the pane back to the default share, so it is only done once that width is known.
1128 if ((await read($, requestedPaneColumnsAtom)) > 0) {
1129 await $.ui.open(await paneOpenArguments($)).catch(() => undefined)
1130 }
1131 await focusQuestionField($, questionFieldNumber)
1132 // A click while another field held the keyboard moves the focus onto the clicked button once this press is handled,
1133 // taking it back from the new field: the keyboard is asked for again just after.
1134 await $.clock.after(QUESTION_FOCUS_RETRY_MS, () => focusQuestionField($, questionFieldNumber))
1135}
1136
1137async function closeQuestion($: EngineInterface): Promise<void> {
1138 await update($, focusedElementKeyAtom, key => (key?.startsWith(QUESTION_INPUT_KEY_PREFIX) ? null : key))
1139 await update($, questionDraftAtom, () => '')
1140 await update($, hasQuestionHadKeyboardAtom, () => false)
1141 await update($, anchorWithOpenInputAtom, () => null)
1142}
1143
1144async function describeViewForPrompt($: EngineInterface): Promise<string> {
1145 const view = await read($, viewAtom)
1146 if (view === 'uncommitted') {
1147 return 'the uncommitted changes (working tree against HEAD)'
1148 }
1149 if (view === 'pr') {
1150 return `the whole branch against its base (${(await read($, baseRefAtom))?.slice(0, 10) ?? '?'}..HEAD)`
1151 }
1152 const commitSha = await read($, selectedCommitShaAtom)
1153 const commit = (await read($, commitsAtom)).find(summary => summary.sha === commitSha)
1154
1155 return `commit ${commit?.shortSha ?? '?'} "${commit?.subject ?? ''}"`
1156}
1157
1158async function findRowQuestionTarget($: EngineInterface, rowIndex: number): Promise<QuestionTarget | null> {
1159 const path = await read($, selectedFilePathAtom)
1160 if (!path) {
1161 return null
1162 }
1163
1164 return rowQuestionTarget({
1165 viewDescription: await describeViewForPrompt($),
1166 repositoryRoot: await read($, repositoryRootAtom),
1167 path,
1168 rows: await read($, diffRowsAtom),
1169 rowIndex,
1170 contextRadius: CONTEXT_RADIUS_ROWS,
1171 })
1172}
1173
1174async function findOverviewQuestionTarget($: EngineInterface, blockIndex: number): Promise<QuestionTarget | null> {
1175 const pullRequest = await read($, pullRequestAtom)
1176 const block = pullRequest ? splitMarkdownBlocks(pullRequest.overviewMarkdown)[blockIndex] : undefined
1177 if (!pullRequest || block === undefined) {
1178 return null
1179 }
1180
1181 return overviewQuestionTarget({
1182 pullRequestHeadline: pullRequest.overviewHeadline,
1183 pullRequestUrl: pullRequest.url,
1184 repositoryRoot: await read($, repositoryRootAtom),
1185 block,
1186 })
1187}
1188
1189async function sendToConversation($: EngineInterface, threadId: string, question: string, context: string): Promise<void> {
1190 await update($, threadIdAwaitingTurnAtom, () => threadId)
1191 await update($, followedThreadIdAtom, () => threadId)
1192 // The location and its surroundings go in as a row only the model reads, so the transcript shows the question alone.
1193 try {
1194 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: context }] } })
1195 } catch (error) {
1196 await reportError($, `The context of the question was not sent (${String(error)})`)
1197 }
1198 await $.prompt.submit({ text: question })
1199 // The field that held the keyboard is gone once the question leaves: the keyboard would fall back to the prompt, so
1200 // the pane takes it again, on the line the cursor is on, for the shortcuts to keep working.hooks/constants.ts 91 lines1import type { ReviewView } from '../types'
2import type { ShortcutAction } from './keymap'
3
4export const PANE_ID = 'diff-review'
5export const PANE_TITLE = 'Review'
6
7// The transcript and the prompt keep the left third, the review takes the rest.
8export const PANE_SHARE_OF_TERMINAL = 2 / 3
9// Under this width the gutter (ask button, line numbers, marker) leaves too little room to read code.
10export const MIN_PANE_COLUMNS = 60
11// Narrower, the file list leaves the code the whole width and opens over it on demand.
12export const TWO_COLUMNS_MIN_WIDTH = 120
13export const DEFAULT_FILE_LIST_WIDTH = 50
14export const MIN_FILE_LIST_WIDTH = 24
15export const MIN_DIFF_WIDTH = 40
16// Each half of the side-by-side view needs about as much room as the narrowest unified diff.
17export const SPLIT_VIEW_MIN_DIFF_WIDTH = 140
18export const COMMIT_PANEL_MAX_LINES = 8
19// What the header holds besides its tiles: the view tabs, the buttons and a little of the location.
20export const HEADER_COLUMNS_WITHOUT_TILES = 142
21export const WHEEL_TICK_ROWS = 3
22export const ANSWER_PULSE_MS = 600
23export const WORKING_TREE_POLL_MS = 5000
24export const CONTEXT_RADIUS_ROWS = 6
25export const DEFAULT_DIFF_CONTEXT_LINES = 3
26export const DIFF_CONTEXT_STEP_LINES = 20
27// A context wider than any file shows the whole file, its changes in place.
28export const WHOLE_FILE_CONTEXT_LINES = 1000000
29// Only the characters of a Button's label take a click: a row's label is padded with spaces past any pane width, and
30// its clipped box cuts it, so the whole row answers a click and not just the name in it.
31export const ROW_CLICK_TARGET_LENGTH = 400
32
33export const VIEW_ORDER: readonly ReviewView[] = ['uncommitted', 'pr', 'commits']
34export const VIEW_LABELS: Record<ReviewView, string> = { uncommitted: 'Uncommitted', pr: 'Pull Request', commits: 'Commits' }
35export const VIEW_SHORTCUTS: Record<ReviewView, ShortcutAction> = {
36 uncommitted: 'uncommittedView',
37 pr: 'pullRequestView',
38 commits: 'commitsView',
39}
40
41// Element keys: the focus, close and redirect rules recognise elements by these prefixes.
42export const QUESTION_INPUT_KEY_PREFIX = 'question-'
43export const REPLY_INPUT_KEY_PREFIX = 'reply-'
44export const ASK_BUTTON_KEY_PREFIX = 'ask-'
45export const LINE_NUMBER_BUTTON_KEY_PREFIX = 'line-'
46export const DISMISS_THREAD_KEY_PREFIX = 'dismiss-'
47export const CANCEL_QUESTION_KEY = 'cancel-question'
48export const DICTATE_QUESTION_KEY = 'dictate-question'
49export const FILE_FILTER_KEY = 'file-filter'
50export const WATCH_FIELD_KEY = 'watch-term'
51// The search bar frames its field, so that it reads as the place to type.
52export const WATCH_BAR_ROWS = 3
53// The anchor of the thread the commit button opens: no line of any diff carries it.
54export const COMMIT_THREAD_ANCHOR_KEY = 'commit'
55export const DIVIDER_KEY = 'file-divider'
56export const FILE_BUTTON_KEY_PREFIX = 'file-'
57export const DIRECTORY_BUTTON_KEY_PREFIX = 'directory-'
58export const ASK_ON_LINE_KEY = 'ask-on-line'
59// The keyboard rests on a hidden button drawn first in the pane: the engine keeps the focus by position, so a button
60// drawn after threads or files would drift as they come and go. Enter on it asks about the cursor's line.
61export const CURSOR_LINE_FOCUS_KEY = 'cursor-line'
62export const ADDRESS_GITHUB_THREAD_KEY_PREFIX = 'address-github-thread-'
63export const PULL_REQUEST_OVERVIEW_KEY = 'pr-overview'
64export const HELP_CLOSE_KEY = 'help-close'
65
66export const QUESTION_FOCUS_RETRY_MS = 120
67// A file just marked viewed stays listed this long while viewed files are hidden, so the click does not make it vanish.
68export const VIEWED_FILE_HIDE_DELAY_MS = 1500
69// A session attached from another terminal (claude attach) or reached through Remote Control sends the person's
70// prompts unstamped or as the bridge's: they are the person's own all the same.
71export const PERSON_PROMPT_ORIGINS: readonly string[] = ['composer', 'unclassified', 'bridge']
72
73export const BASE_REF_CANDIDATES = ['origin/main', 'origin/master', 'origin/develop', 'main', 'master']
74export const GIT_TIMEOUT_MS = 15000
75// A push may run the repository's pre-push hook, tests included.
76export const GIT_PUSH_TIMEOUT_MS = 180000
77// The push button asks for a second press within this delay, so a stray click pushes nothing.
78export const PUSH_CONFIRM_DELAY_MS = 4000
79export const PUSHED_NOTICE_MS = 4000
80export const BROWSER_OPENERS = ['xdg-open', 'open']
81export const GH_TIMEOUT_MS = 20000
82
83export const FILE_LIST_WIDTH_STORE_KEY = 'fileListWidth'
84export const SPLIT_VIEW_STORE_KEY = 'isSplitViewPreferred'
85export const FILE_LIST_COLLAPSED_STORE_KEY = 'isFileListCollapsed'
86export const THREADS_STORE_KEY_PREFIX = 'threads'
87export const MAX_SAVED_THREADS_PER_BRANCH = 100
88export const VIEWED_COMMITS_STORE_KEY_PREFIX = 'viewedCommits'
89export const EMOJI_STORE_KEY = 'githubEmojiCharacters'
90export const EMOJI_CACHE_DURATION_MS = 7 * 24 * 3600 * 1000
91hooks/git-parsing.ts 191 lines1import type { ChangedFile, CommitGraphLine, CommitSummary, DiffRow, ReviewView, StageState } from '../types'
2
3const LOG_FIELD_SEPARATOR = '\u001f'
4export const LOG_FORMAT = ['%H', '%h', '%s', '%an', '%ar', '%D'].join('%x1f')
5
6// git diff --numstat --summary: one tab-separated count line per file, then a " create mode" or " delete mode" line
7// for each file the change creates or deletes.
8export function parseNumstat(numstatOutput: string): ChangedFile[] {
9 const lines = numstatOutput.split('\n')
10 const creationByPath = new Map<string, 'created' | 'deleted'>()
11 for (const line of lines) {
12 const summary = line.match(/^ (create|delete) mode \d+ (.+)$/)
13 if (summary?.[2]) {
14 creationByPath.set(summary[2], summary[1] === 'create' ? 'created' : 'deleted')
15 }
16 }
17
18 return lines
19 .filter(line => line.includes('\t'))
20 .map(line => {
21 const [added = '-', removed = '-', ...pathParts] = line.split('\t')
22 const path = pathParts.join('\t')
23 const creation = creationByPath.get(path)
24 return {
25 path,
26 addedLines: added === '-' ? null : Number(added),
27 removedLines: removed === '-' ? null : Number(removed),
28 isUntracked: false,
29 ...(creation ? { creation } : {}),
30 }
31 })
32}
33
34// git status --porcelain: X is the index column, Y the working tree one; ?? marks a file git does not track yet.
35export function stageStatesOf(statusOutput: string): Record<string, StageState> {
36 const stageStateByPath: Record<string, StageState> = {}
37 for (const line of statusOutput.split('\n')) {
38 if (line.length < 4) {
39 continue
40 }
41 const [indexColumn, workingTreeColumn] = [line[0], line[1]]
42 const path = line.slice(3).split(' -> ').at(-1)?.replace(/^"(.*)"$/, '$1') ?? ''
43 stageStateByPath[path] =
44 indexColumn === ' ' || indexColumn === '?' ? 'unstaged' : workingTreeColumn === ' ' ? 'staged' : 'partlyStaged'
45 }
46
47 return stageStateByPath
48}
49
50export function parseUntrackedFiles(lsFilesOutput: string): ChangedFile[] {
51 return lsFilesOutput
52 .split('\n')
53 .filter(path => path.trim() !== '')
54 .map(path => ({ path, addedLines: null, removedLines: null, isUntracked: true }))
55}
56
57// git log --graph draws the lanes before the first field; --boundary adds the base commit, its lane marked with an o.
58export function parseCommitGraph(logOutput: string): CommitGraphLine[] {
59 return logOutput
60 .split('\n')
61 .filter(line => line.trim() !== '')
62 .map(line => {
63 if (!line.includes(LOG_FIELD_SEPARATOR)) {
64 return { graph: line.trimEnd(), commit: null, isBoundary: false }
65 }
66 const [graphAndSha = '', shortSha = '', subject = '', author = '', relativeDate = '', decoration = ''] =
67 line.split(LOG_FIELD_SEPARATOR)
68 const shaMatch = graphAndSha.match(/^(.*\s)?(\S+)$/)
69 const graph = shaMatch?.[1] ?? ''
70 const refs = decoration
71 .split(',')
72 .map(ref => ref.trim().replace(/^HEAD -> /, ''))
73 .filter(ref => ref !== '' && ref !== 'HEAD')
74
75 return {
76 graph: graph.trimEnd(),
77 commit: { sha: shaMatch?.[2] ?? graphAndSha, shortSha, subject, author, relativeDate, refs },
78 isBoundary: graph.trimEnd().endsWith('o'),
79 }
80 })
81}
82
83export function commitsOfGraph(graph: CommitGraphLine[]): CommitSummary[] {
84 return graph.flatMap(line => (line.commit && !line.isBoundary ? [line.commit] : []))
85}
86
87// The web page of the repository behind a remote, from its SSH (git@host:owner/name.git) or HTTPS spelling.
88export function repositoryWebUrlOf(remoteUrl: string): string | null {
89 const match = remoteUrl.trim().match(/^(?:https:\/\/(?:[^@/]+@)?|ssh:\/\/git@|git@)([^/:]+)[/:](.+?)(?:\.git)?\/?$/)
90
91 return match ? `https://${match[1]}/${match[2]}` : null
92}
93
94// Only the hunks are kept: the header lines git prints before the first @@ (diff --git, index, ---, +++) carry nothing to review.
95export function parseUnifiedDiff(diffOutput: string): DiffRow[] {
96 const rows: DiffRow[] = []
97 let oldNumber = 0
98 let newNumber = 0
99 let isInsideHunk = false
100
101 for (const line of diffOutput.split('\n')) {
102 const hunkHeader = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/)
103 if (hunkHeader) {
104 oldNumber = Number(hunkHeader[1])
105 newNumber = Number(hunkHeader[2])
106 isInsideHunk = true
107 rows.push({ kind: 'hunk', oldNumber: null, newNumber: null, text: (hunkHeader[3] ?? '').trim() })
108 continue
109 }
110 if (!isInsideHunk || line.startsWith('\\')) {
111 continue
112 }
113 if (line.startsWith('+')) {
114 rows.push({ kind: 'added', oldNumber: null, newNumber, text: line.slice(1) })
115 newNumber += 1
116 } else if (line.startsWith('-')) {
117 rows.push({ kind: 'removed', oldNumber, newNumber: null, text: line.slice(1) })
118 oldNumber += 1
119 } else if (line.startsWith(' ')) {
120 rows.push({ kind: 'context', oldNumber, newNumber, text: line.slice(1) })
121 oldNumber += 1
122 newNumber += 1
123 }
124 }
125
126 return rows
127}
128
129// Without context lines each hunk header says where its change sits in the new file; a hunk that only removes lines
130// gives the line just above the removal.
131export function changedNewLinesOf(zeroContextDiffOutput: string): number[] {
132 return zeroContextDiffOutput.split('\n').flatMap(line => {
133 const hunkHeader = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
134 if (!hunkHeader) {
135 return []
136 }
137 const start = Number(hunkHeader[1])
138 const lineCount = hunkHeader[2] === undefined ? 1 : Number(hunkHeader[2])
139
140 return lineCount === 0 ? [Math.max(1, start)] : Array.from({ length: lineCount }, (_, offset) => start + offset)
141 })
142}
143
144// Opening on what is still to be done: the uncommitted changes, else the branch's pull request; on a branch with
145// neither, the uncommitted view says there is nothing.
146export function openingViewOf(uncommittedFileCount: number, branchCommitCount: number): ReviewView {
147 if (uncommittedFileCount === 0 && branchCommitCount > 0) {
148 return 'pr'
149 }
150
151 return 'uncommitted'
152}
153
154export function diffRangeArguments(view: ReviewView, baseRef: string | null, commitSha: string | null): string[] | null {
155 if (view === 'uncommitted') {
156 return ['HEAD']
157 }
158 if (view === 'pr') {
159 return baseRef ? [baseRef, 'HEAD'] : null
160 }
161
162 return commitSha ? [`${commitSha}~1`, commitSha] : null
163}
164
165
166export type FileGroup = { directory: string; files: ChangedFile[] }
167
168export function directoryOf(path: string): string {
169 const slashIndex = path.lastIndexOf('/')
170 return slashIndex === -1 ? '' : path.slice(0, slashIndex)
171}
172
173export function groupFilesByDirectory(files: ChangedFile[]): FileGroup[] {
174 const groups = new Map<string, ChangedFile[]>()
175 for (const file of files) {
176 const directory = directoryOf(file.path)
177 groups.set(directory, [...(groups.get(directory) ?? []), file])
178 }
179
180 return [...groups.entries()]
181 .sort(([firstDirectory], [secondDirectory]) => firstDirectory.localeCompare(secondDirectory))
182 .map(([directory, groupFiles]) => ({
183 directory,
184 files: [...groupFiles].sort((first, second) => first.path.localeCompare(second.path)),
185 }))
186}
187
188export function fileNameOf(path: string): string {
189 return path.slice(path.lastIndexOf('/') + 1)
190}
191hooks/github-review.ts 320 lines1import type { ChangedFile, DiffRow, GitHubComment, GitHubReviewThread, PullRequestSummary, ViewedState } from '../types'
2import { replaceEmojiShortcodes } from './markdown'
3import { gitHubThreadAnchorKeyOf } from './questions'
4
5export const PULL_REQUEST_FILES_PAGE_SIZE = 100
6
7export const VIEWED_STATES_QUERY = `query($pullRequestId: ID!, $cursor: String) {
8 node(id: $pullRequestId) {
9 ... on PullRequest {
10 files(first: ${PULL_REQUEST_FILES_PAGE_SIZE}, after: $cursor) {
11 nodes { path viewerViewedState }
12 pageInfo { hasNextPage endCursor }
13 }
14 }
15 }
16}`
17
18export const MARK_FILE_AS_VIEWED_MUTATION = `mutation($pullRequestId: ID!, $path: String!) {
19 markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId }
20}`
21
22export const UNMARK_FILE_AS_VIEWED_MUTATION = `mutation($pullRequestId: ID!, $path: String!) {
23 unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path }) { clientMutationId }
24}`
25
26export const REVIEW_THREADS_PAGE_SIZE = 100
27export const COMMENTS_PER_REVIEW_THREAD = 50
28
29export const REVIEW_THREADS_QUERY = `query($pullRequestId: ID!, $cursor: String) {
30 node(id: $pullRequestId) {
31 ... on PullRequest {
32 reviewThreads(first: ${REVIEW_THREADS_PAGE_SIZE}, after: $cursor) {
33 nodes {
34 id isResolved isOutdated path line originalLine diffSide
35 comments(first: ${COMMENTS_PER_REVIEW_THREAD}) { nodes { author { login } body createdAt url } }
36 }
37 pageInfo { hasNextPage endCursor }
38 }
39 }
40 }
41}`
42
43export const RESOLVE_REVIEW_THREAD_MUTATION = `mutation($threadId: ID!) {
44 resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
45}`
46
47export const UNRESOLVE_REVIEW_THREAD_MUTATION = `mutation($threadId: ID!) {
48 unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
49}`
50
51export function addressRequestOf(thread: GitHubReviewThread, pullRequest: PullRequestSummary): { question: string; context: string } {
52 const firstComment = thread.comments[0]
53 const place = thread.line === null ? `${thread.path} (outdated, was line ${thread.originalLine ?? '?'})` : `${thread.path}, line ${thread.line}`
54
55 return {
56 question: `Address the review comment of @${firstComment?.author ?? 'ghost'} on ${place}.`,
57 context: [
58 `The review thread on pull request ${pullRequest.overviewHeadline} (${pullRequest.url}), ${place}:`,
59 '````markdown',
60 ...thread.comments.map(comment => `@${comment.author}: ${comment.body}`),
61 '````',
62 'Change the code so the thread can be resolved, or say why it should not be. Do not post anything on GitHub.',
63 ].join('\n'),
64 }
65}
66
67export type ReviewThreadsPage = { threads: GitHubReviewThread[]; nextCursor: string | null }
68
69export function parseReviewThreadsPage(
70 graphqlOutput: string,
71 emojiCharacterByShortcode: Record<string, string> = {},
72): ReviewThreadsPage {
73 const reviewThreads = JSON.parse(graphqlOutput)?.data?.node?.reviewThreads
74 const numberOrNull = (value: unknown) => (typeof value === 'number' ? value : null)
75 const nodesOf = (connection: unknown): Array<Record<string, unknown>> => {
76 const nodes = (connection as { nodes?: unknown } | null)?.nodes
77 return Array.isArray(nodes) ? nodes : []
78 }
79 const threads: GitHubReviewThread[] = nodesOf(reviewThreads).flatMap(node => {
80 if (typeof node.id !== 'string' || typeof node.path !== 'string') {
81 return []
82 }
83 const comments: GitHubComment[] = nodesOf(node.comments).map(comment => ({
84 author: String((comment.author as { login?: unknown } | null)?.login ?? 'ghost'),
85 body: replaceEmojiShortcodes(String(comment.body ?? ''), emojiCharacterByShortcode),
86 createdAt: String(comment.createdAt ?? ''),
87 url: String(comment.url ?? ''),
88 }))
89 const side: GitHubReviewThread['side'] = node.diffSide === 'LEFT' ? 'LEFT' : 'RIGHT'
90
91 return [
92 {
93 id: node.id,
94 path: node.path,
95 side,
96 line: numberOrNull(node.line),
97 originalLine: numberOrNull(node.originalLine),
98 isResolved: node.isResolved === true,
99 isOutdated: node.isOutdated === true,
100 comments,
101 },
102 ]
103 })
104
105 return {
106 threads,
107 nextCursor: reviewThreads?.pageInfo?.hasNextPage ? (reviewThreads.pageInfo.endCursor ?? null) : null,
108 }
109}
110
111// GitHub places a comment on the old side for a removed line and on the new side otherwise; an unchanged line has
112// both numbers, so a comment on either side lands on it.
113export function isGitHubThreadOnRow(thread: GitHubReviewThread, path: string, row: DiffRow): boolean {
114 if (thread.path !== path || thread.line === null || row.kind === 'hunk') {
115 return false
116 }
117
118 return thread.side === 'LEFT'
119 ? row.kind !== 'added' && row.oldNumber === thread.line
120 : row.kind !== 'removed' && row.newNumber === thread.line
121}
122
123// The threads of a file that no line of its diff carries: outdated ones, or lines outside the context shown.
124export function unplacedGitHubThreadsOf(threads: GitHubReviewThread[], path: string, rows: DiffRow[]): GitHubReviewThread[] {
125 return threads.filter(thread => thread.path === path && !rows.some(row => isGitHubThreadOnRow(thread, path, row)))
126}
127
128export function gitHubThreadNavigationEntries(threads: GitHubReviewThread[]): Array<{ id: string; anchorKey: string }> {
129 return threads.map(thread => ({ id: thread.id, anchorKey: gitHubThreadAnchorKeyOf(thread) }))
130}
131
132export type ViewedStatesPage = {
133 viewedStateByPath: Record<string, ViewedState>
134 nextCursor: string | null
135}
136
137export function parseViewedStatesPage(graphqlOutput: string): ViewedStatesPage {
138 const files = JSON.parse(graphqlOutput)?.data?.node?.files
139 const viewedStateByPath: Record<string, ViewedState> = {}
140 for (const file of files?.nodes ?? []) {
141 if (typeof file?.path === 'string' && ['VIEWED', 'UNVIEWED', 'DISMISSED'].includes(file?.viewerViewedState)) {
142 viewedStateByPath[file.path] = file.viewerViewedState
143 }
144 }
145
146 return {
147 viewedStateByPath,
148 nextCursor: files?.pageInfo?.hasNextPage ? (files.pageInfo.endCursor ?? null) : null,
149 }
150}
151
152// GitHub lists a renamed file once, under its new path, and gives the old one only through the REST API.
153export function pullRequestRenamesArguments(pullRequestNumber: number): string[] {
154 return [
155 'api',
156 '--paginate',
157 `repos/{owner}/{repo}/pulls/${pullRequestNumber}/files`,
158 '--jq',
159 '.[] | select(.status == "renamed") | [.previous_filename, .filename] | @tsv',
160 ]
161}
162
163export function parseRenamedPaths(renamesOutput: string): Record<string, string> {
164 const renamedPathByPreviousPath: Record<string, string> = {}
165 for (const line of renamesOutput.split('\n')) {
166 const [previousPath, path] = line.split('\t')
167 if (previousPath && path) {
168 renamedPathByPreviousPath[previousPath] = path
169 }
170 }
171
172 return renamedPathByPreviousPath
173}
174
175// The diff lists a renamed file as one path deleted and one created: the deleted one shares the box of the new one.
176export function withPreviousPathsOfRenames(
177 viewedStateByPath: Record<string, ViewedState>,
178 renamedPathByPreviousPath: Record<string, string>,
179): Record<string, ViewedState> {
180 const viewedStateWithPreviousPaths = { ...viewedStateByPath }
181 for (const [previousPath, path] of Object.entries(renamedPathByPreviousPath)) {
182 const viewedState = viewedStateByPath[path]
183 if (viewedState !== undefined) {
184 viewedStateWithPreviousPaths[previousPath] = viewedState
185 }
186 }
187
188 return viewedStateWithPreviousPaths
189}
190
191export function parsePullRequest(
192 prViewOutput: string,
193 emojiCharacterByShortcode: Record<string, string> = {},
194): PullRequestSummary | null {
195 const pullRequest = JSON.parse(prViewOutput)
196 if (typeof pullRequest?.id !== 'string' || typeof pullRequest?.number !== 'number') {
197 return null
198 }
199 const overview = buildPullRequestOverview(pullRequest, emojiCharacterByShortcode)
200
201 return {
202 id: pullRequest.id,
203 number: pullRequest.number,
204 url: String(pullRequest.url ?? ''),
205 baseRefName: typeof pullRequest.baseRefName === 'string' ? pullRequest.baseRefName : null,
206 additions: Number(pullRequest.additions ?? 0),
207 deletions: Number(pullRequest.deletions ?? 0),
208 changedFiles: Number(pullRequest.changedFiles ?? 0),
209 overviewHeadline: overview.headline,
210 overviewMarkdown: overview.markdown,
211 }
212}
213
214// GitHub keeps a file viewed until it changes again on the branch; it then reports it as DISMISSED, to be looked at anew.
215export const VIEWED_MARKS: Record<ViewedState, string> = { VIEWED: ' ✓ ', UNVIEWED: ' ○ ', DISMISSED: ' ! ' }
216
217// GitHub unticks a viewed file once a pushed commit changes it, but only after it has processed the push.
218export function dismissChangedFiles(viewedStateByPath: Record<string, ViewedState>, changedPaths: readonly string[]): Record<string, ViewedState> {
219 return Object.fromEntries(
220 Object.entries(viewedStateByPath).map(([path, viewedState]) => [path, changedPaths.includes(path) ? viewedStateAfterChange(viewedState) : viewedState]),
221 )
222}
223
224function viewedStateAfterChange(viewedState: ViewedState): ViewedState {
225 switch (viewedState) {
226 case 'VIEWED':
227 return 'DISMISSED'
228 case 'UNVIEWED':
229 case 'DISMISSED':
230 return viewedState
231 }
232}
233
234export function countViewedFiles(paths: string[], viewedStateByPath: Record<string, ViewedState>): { viewed: number; total: number } {
235 const pathsInPullRequest = paths.filter(path => viewedStateByPath[path] !== undefined)
236
237 return {
238 viewed: pathsInPullRequest.filter(path => viewedStateByPath[path] === 'VIEWED').length,
239 total: pathsInPullRequest.length,
240 }
241}
242
243// The lines of the PR files whose box is not ticked yet; a file changed since it was viewed counts again.
244export function countLinesToView(
245 files: ChangedFile[],
246 viewedStateByPath: Record<string, ViewedState>,
247): { addedLines: number; removedLines: number } {
248 return files
249 .filter(file => viewedStateByPath[file.path] !== undefined && viewedStateByPath[file.path] !== 'VIEWED')
250 .reduce(
251 (totals, file) => ({ addedLines: totals.addedLines + (file.addedLines ?? 0), removedLines: totals.removedLines + (file.removedLines ?? 0) }),
252 { addedLines: 0, removedLines: 0 },
253 )
254}
255
256export const PULL_REQUEST_VIEW_FIELDS =
257 'id,number,url,title,body,author,state,isDraft,baseRefName,headRefName,comments,reviews,additions,deletions,changedFiles'
258
259type ConversationEntry = { author: string; body: string; createdAt: string; reviewState: string | null }
260
261// A review that only carries inline comments has an empty body and the COMMENTED state: its comments belong under
262// their lines, not in the conversation.
263function conversationEntriesOf(pullRequest: Record<string, unknown>): ConversationEntry[] {
264 const authorLogin = (entry: Record<string, unknown>) => String((entry.author as { login?: unknown } | null)?.login ?? 'ghost')
265 const comments = (Array.isArray(pullRequest.comments) ? pullRequest.comments : []).map(comment => ({
266 author: authorLogin(comment),
267 body: String(comment?.body ?? ''),
268 createdAt: String(comment?.createdAt ?? ''),
269 reviewState: null,
270 }))
271 const reviews = (Array.isArray(pullRequest.reviews) ? pullRequest.reviews : [])
272 .filter(review => String(review?.body ?? '').trim() !== '' || review?.state !== 'COMMENTED')
273 .map(review => ({
274 author: authorLogin(review),
275 body: String(review?.body ?? ''),
276 createdAt: String(review?.submittedAt ?? ''),
277 reviewState: String(review?.state ?? ''),
278 }))
279
280 return [...comments, ...reviews].sort((first, second) => first.createdAt.localeCompare(second.createdAt))
281}
282
283const REVIEW_STATE_LABELS: Record<string, string> = {
284 APPROVED: '✓ approved',
285 CHANGES_REQUESTED: '✗ changes requested',
286 COMMENTED: 'reviewed',
287 DISMISSED: 'review dismissed',
288}
289
290export function buildPullRequestOverview(
291 pullRequest: Record<string, unknown>,
292 emojiCharacterByShortcode: Record<string, string> = {},
293): { headline: string; markdown: string } {
294 const withEmoji = (text: string) => replaceEmojiShortcodes(text, emojiCharacterByShortcode)
295 const author = String((pullRequest.author as { login?: unknown } | null)?.login ?? 'ghost')
296 const state = pullRequest.isDraft === true ? 'draft' : String(pullRequest.state ?? '').toLowerCase()
297 const description = withEmoji(String(pullRequest.body ?? '').trim())
298 const conversation = conversationEntriesOf(pullRequest)
299 const markdown = [
300 `**@${author}** · ${state} · \`${String(pullRequest.baseRefName ?? '?')}\` ← \`${String(pullRequest.headRefName ?? '?')}\``,
301 '',
302 '## Description',
303 '',
304 description === '' ? '_No description._' : description,
305 '',
306 `## Conversation (${conversation.length})`,
307 ...(conversation.length === 0 ? ['', '_No comment outside the code._'] : []),
308 ...conversation.flatMap(entry => [
309 '',
310 `**@${entry.author}** · ${entry.createdAt.slice(0, 16).replace('T', ' ')}${entry.reviewState ? ` · ${REVIEW_STATE_LABELS[entry.reviewState] ?? entry.reviewState}` : ''}`,
311 '',
312 entry.body.trim() === '' ? '_(no message)_' : withEmoji(entry.body.trim()),
313 '',
314 '---',
315 ]),
316 ].join('\n')
317
318 return { headline: `#${String(pullRequest.number ?? '?')} ${withEmoji(String(pullRequest.title ?? ''))}`, markdown }
319}
320hooks/cursor.ts 71 lines1import type { DiffRow } from '../types'
2import type { SplitRow } from './split-rows'
3
4// The rows the line cursor stops on, in the order drawn. Side by side, a pair is one stop: its new line, or the
5// removed one when nothing faces it.
6export function cursorStopsOf(diffRows: DiffRow[], splitRows: SplitRow[] | null): number[] {
7 if (splitRows) {
8 return splitRows.flatMap(splitRow => {
9 const side = splitRow.kind === 'pair' ? (splitRow.right ?? splitRow.left) : null
10 return side ? [side.rowIndex] : []
11 })
12 }
13
14 return diffRows.flatMap((row, rowIndex) => (row.kind === 'hunk' ? [] : [rowIndex]))
15}
16
17export function firstChangeStop(diffRows: DiffRow[], stops: number[]): number | null {
18 return stops.find(rowIndex => diffRows[rowIndex]?.kind !== 'context') ?? stops[0] ?? null
19}
20
21// The first stop on one of these lines of the new file, else the first one below the earliest of them.
22export function stopOnNewLines(diffRows: DiffRow[], stops: number[], newLines: number[]): number | null {
23 if (newLines.length === 0) {
24 return null
25 }
26 const earliestLine = Math.min(...newLines)
27 const newNumberOf = (rowIndex: number) => diffRows[rowIndex]?.newNumber ?? null
28
29 return (
30 stops.find(rowIndex => newLines.includes(newNumberOf(rowIndex) ?? 0)) ??
31 stops.find(rowIndex => (newNumberOf(rowIndex) ?? 0) >= earliestLine) ??
32 null
33 )
34}
35
36export function movedCursor(stops: number[], cursorRowIndex: number, offset: number): number {
37 const currentStop = stops.indexOf(cursorRowIndex)
38 if (currentStop === -1) {
39 return stops[0] ?? cursorRowIndex
40 }
41
42 return stops[Math.max(0, Math.min(stops.length - 1, currentStop + offset))] ?? cursorRowIndex
43}
44
45// The first line of the next hunk below the cursor; upward, the first line of its own hunk, then of the one above.
46export function cursorAtHunk(diffRows: DiffRow[], stops: number[], cursorRowIndex: number, direction: 1 | -1): number {
47 const hunkStarts = stops.filter((rowIndex, stopIndex) => {
48 const previousStop = stops[stopIndex - 1]
49 return previousStop === undefined || diffRows.slice(previousStop, rowIndex).some(row => row.kind === 'hunk')
50 })
51 const target =
52 direction === 1
53 ? hunkStarts.find(rowIndex => rowIndex > cursorRowIndex)
54 : [...hunkStarts].reverse().find(rowIndex => rowIndex < cursorRowIndex)
55
56 return target ?? cursorRowIndex
57}
58
59// The scroll offset that keeps the cursor's drawn line in view, a few lines from the edge it moves toward.
60export function offsetShowingCursor(drawnIndex: number, offset: number, visibleRows: number): number {
61 const margin = Math.min(3, Math.floor(visibleRows / 4))
62 if (drawnIndex < offset + margin) {
63 return Math.max(0, drawnIndex - margin)
64 }
65 if (drawnIndex > offset + visibleRows - 1 - margin) {
66 return Math.max(0, drawnIndex - visibleRows + 1 + margin)
67 }
68
69 return offset
70}
71hooks/watch.ts 78 lines1import type { DiffRow } from '../types'
2import { parseUnifiedDiff } from './git-parsing'
3
4// A selection spanning several lines watches its first one: a term is searched within a line.
5export function watchedTermOf(selectedText: string): string {
6 return selectedText.split('\n').map(line => line.trim()).find(line => line !== '') ?? ''
7}
8
9export function matchCountOf(text: string, term: string): number {
10 return term === '' ? 0 : text.split(term).length - 1
11}
12
13export function splitByTerm(text: string, term: string): Array<{ text: string; isMatch: boolean }> {
14 if (term === '') {
15 return [{ text, isMatch: false }]
16 }
17
18 return text
19 .split(term)
20 .flatMap((part, index) => (index === 0 ? [{ text: part, isMatch: false }] : [{ text: term, isMatch: true }, { text: part, isMatch: false }]))
21 .filter(segment => segment.text !== '')
22}
23
24// The diff of a whole view, file by file: a deleted file is named by its old path, any other by its new one.
25export function rowsByPathOfViewDiff(diffOutput: string): Record<string, DiffRow[]> {
26 const rowsByPath: Record<string, DiffRow[]> = {}
27 for (const fileDiff of diffOutput.split(/^(?=diff --git )/m)) {
28 const oldPath = fileDiff.match(/^--- a\/(.+)$/m)?.[1]
29 const newPath = fileDiff.match(/^\+\+\+ b\/(.+)$/m)?.[1]
30 const path = newPath ?? oldPath
31 if (path !== undefined) {
32 rowsByPath[path] = parseUnifiedDiff(fileDiff)
33 }
34 }
35
36 return rowsByPath
37}
38
39export function matchCountByPathOf(rowsByPath: Record<string, DiffRow[]>, term: string): Record<string, number> {
40 return Object.fromEntries(
41 Object.entries(rowsByPath)
42 .map(([path, rows]) => [path, rows.reduce((count, row) => count + (row.kind === 'hunk' ? 0 : matchCountOf(row.text, term)), 0)] as const)
43 .filter(([, count]) => count > 0),
44 )
45}
46
47// The next stop holding the term past the cursor, in the direction asked; with no cursor, the first or the last one.
48export function matchStopFrom(diffRows: DiffRow[], stops: number[], cursorRowIndex: number | null, term: string, direction: 1 | -1): number | null {
49 const matchStops = stops.filter(rowIndex => matchCountOf(diffRows[rowIndex]?.text ?? '', term) > 0)
50 const orderedMatchStops = direction === 1 ? matchStops : [...matchStops].reverse()
51 const cursorStop = cursorRowIndex === null ? -1 : stops.indexOf(cursorRowIndex)
52 if (cursorStop === -1) {
53 return orderedMatchStops[0] ?? null
54 }
55
56 return orderedMatchStops.find(rowIndex => (stops.indexOf(rowIndex) - cursorStop) * direction > 0) ?? null
57}
58
59// The next file holding the term in the order of the list, going round past its end; the current one comes last.
60export function fileWithMatchesFrom(
61 orderedPaths: string[],
62 currentPath: string | null,
63 matchCountByPath: Record<string, number>,
64 direction: 1 | -1,
65): string | null {
66 const currentIndex = currentPath === null ? -1 : orderedPaths.indexOf(currentPath)
67 const startIndex = currentIndex === -1 && direction === -1 ? orderedPaths.length : currentIndex
68 for (let step = 1; step <= orderedPaths.length; step += 1) {
69 const index = (((startIndex + step * direction) % orderedPaths.length) + orderedPaths.length) % orderedPaths.length
70 const path = orderedPaths[index]
71 if (path !== undefined && (matchCountByPath[path] ?? 0) > 0) {
72 return path
73 }
74 }
75
76 return null
77}
78hooks/file-filter.ts 48 lines1import type { ChangedFile, ViewedState } from '../types'
2
3// Files a reviewer rarely reads line by line: lockfiles, minified or generated output, snapshots, vendored code.
4const NOISE_PATTERNS: readonly RegExp[] = [
5 /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|Pipfile\.lock|go\.sum)$/,
6 /\.lock$/,
7 /\.min\.(js|css)$/,
8 /\.(js|css)\.map$/,
9 /\.snap$/,
10 /(^|\/)(dist|build|vendor|node_modules|__generated__)\//,
11]
12
13export function isNoiseFile(path: string): boolean {
14 return NOISE_PATTERNS.some(pattern => pattern.test(path))
15}
16
17// Every word of the filter must appear in the path, in any order and case: "ctrl user" finds src/Controller/UserController.php.
18export function matchesFileFilter(path: string, filter: string): boolean {
19 const lowerCasePath = path.toLowerCase()
20
21 return filter
22 .toLowerCase()
23 .split(/\s+/)
24 .filter(word => word !== '')
25 .every(word => lowerCasePath.includes(word))
26}
27
28export function visibleFilesOf(
29 files: ChangedFile[],
30 options: { filter: string; isNoiseShown: boolean; hiddenPaths?: ReadonlySet<string> },
31): { files: ChangedFile[]; hiddenNoiseCount: number } {
32 const matchingFiles = files.filter(file => matchesFileFilter(file.path, options.filter) && !options.hiddenPaths?.has(file.path))
33 if (options.isNoiseShown) {
34 return { files: matchingFiles, hiddenNoiseCount: 0 }
35 }
36 const shownFiles = matchingFiles.filter(file => !isNoiseFile(file.path))
37
38 return { files: shownFiles, hiddenNoiseCount: matchingFiles.length - shownFiles.length }
39}
40
41export function viewedPathsToHide(viewedStateByPath: Record<string, ViewedState>, recentlyViewedPaths: readonly string[]): Set<string> {
42 return new Set(
43 Object.entries(viewedStateByPath)
44 .filter(([path, viewedState]) => viewedState === 'VIEWED' && !recentlyViewedPaths.includes(path))
45 .map(([path]) => path),
46 )
47}
48hooks/keyboard.ts 19 lines1import type { KeyboardLayout } from '../types'
2
3const AZERTY_LAYOUT_CODES = ['fr', 'be']
4
5// Where the desktop says which keyboard layout is in use: GNOME's input sources first, then X11's.
6export const KEYBOARD_LAYOUT_SOURCES: ReadonlyArray<{ argv: string[]; layoutPattern: RegExp }> = [
7 { argv: ['gsettings', 'get', 'org.gnome.desktop.input-sources', 'sources'], layoutPattern: /\('xkb', '([a-z]+)/ },
8 { argv: ['setxkbmap', '-query'], layoutPattern: /layout:\s*([a-z]+)/ },
9]
10
11export function keyboardLayoutOf(layoutSourceOutput: string, layoutPattern: RegExp): KeyboardLayout | null {
12 const layoutCode = layoutSourceOutput.match(layoutPattern)?.[1]
13 if (!layoutCode) {
14 return null
15 }
16
17 return AZERTY_LAYOUT_CODES.includes(layoutCode) ? 'azerty' : 'qwerty'
18}
19hooks/keymap.ts 98 lines1import type { KeyboardLayout } from '../types'
2
3export type ShortcutAction =
4 | 'uncommittedView'
5 | 'pullRequestView'
6 | 'commitsView'
7 | 'previousView'
8 | 'nextView'
9 | 'previousFile'
10 | 'nextFile'
11 | 'markViewedAndNext'
12 | 'focusFileFilter'
13 | 'clearFileFilters'
14 | 'toggleDirectoryFold'
15 | 'toggleFileListCollapse'
16 | 'previousLine'
17 | 'nextLine'
18 | 'previousHunk'
19 | 'nextHunk'
20 | 'moreContext'
21 | 'wholeFile'
22 | 'toggleSplitView'
23 | 'askOnLine'
24 | 'replyOnLine'
25 | 'previousThread'
26 | 'nextThread'
27 | 'toggleThreadResolved'
28 | 'openOnGitHub'
29 | 'stageCurrentFile'
30 | 'stageAllChanges'
31 | 'commitStaged'
32 | 'toggleHelp'
33 | 'watchSelection'
34 | 'nextWatchedMatch'
35 | 'previousWatchedMatch'
36
37export type ShortcutGroup = 'Views' | 'Files' | 'Lines' | 'Watch' | 'Threads' | 'git' | 'Other'
38
39type ShortcutDefinition = {
40 action: ShortcutAction
41 group: ShortcutGroup
42 description: string
43 defaultKeys: (layout: KeyboardLayout) => readonly string[]
44}
45
46// Up, left, down and right sit on z q s d on an AZERTY keyboard and on w a s d on a QWERTY one. A layout that could
47// not be read keeps both bound.
48const upKeys = (layout: KeyboardLayout) => ({ azerty: ['z'], qwerty: ['w'], unknown: ['z', 'w'] })[layout]
49const leftKeys = (layout: KeyboardLayout) => ({ azerty: ['q'], qwerty: ['a'], unknown: ['q', 'a'] })[layout]
50const fixed = (key: string) => () => [key]
51
52export const SHORTCUTS: readonly ShortcutDefinition[] = [
53 { action: 'uncommittedView', group: 'Views', description: 'Uncommitted changes', defaultKeys: fixed('1') },
54 { action: 'pullRequestView', group: 'Views', description: 'Pull request', defaultKeys: fixed('2') },
55 { action: 'commitsView', group: 'Views', description: 'Commits', defaultKeys: fixed('3') },
56 { action: 'previousView', group: 'Views', description: 'Previous view', defaultKeys: leftKeys },
57 { action: 'nextView', group: 'Views', description: 'Next view', defaultKeys: fixed('d') },
58 { action: 'previousFile', group: 'Files', description: 'Previous file', defaultKeys: upKeys },
59 { action: 'nextFile', group: 'Files', description: 'Next file', defaultKeys: fixed('s') },
60 { action: 'markViewedAndNext', group: 'Files', description: 'Mark viewed on GitHub, go to the next unviewed', defaultKeys: fixed('y') },
61 { action: 'focusFileFilter', group: 'Files', description: 'Filter the files (Enter goes back to the diff)', defaultKeys: fixed('i') },
62 { action: 'clearFileFilters', group: 'Files', description: 'Clear the filters, every file listed again', defaultKeys: fixed('5') },
63 { action: 'toggleDirectoryFold', group: 'Files', description: "Fold or unfold the file's directory", defaultKeys: fixed('0') },
64 { action: 'toggleFileListCollapse', group: 'Files', description: 'Collapse the file list behind a bar, or pin it beside the code', defaultKeys: fixed('9') },
65 { action: 'previousLine', group: 'Lines', description: 'Line above', defaultKeys: fixed('k') },
66 { action: 'nextLine', group: 'Lines', description: 'Line below', defaultKeys: fixed('j') },
67 { action: 'previousHunk', group: 'Lines', description: 'Previous hunk (@@ block)', defaultKeys: fixed('b') },
68 { action: 'nextHunk', group: 'Lines', description: 'Next hunk (@@ block)', defaultKeys: fixed('f') },
69 { action: 'moreContext', group: 'Lines', description: 'More context around the changes', defaultKeys: fixed('u') },
70 { action: 'wholeFile', group: 'Lines', description: 'The whole file, changes in place', defaultKeys: fixed('l') },
71 { action: 'toggleSplitView', group: 'Lines', description: 'Side by side or unified (wide diff)', defaultKeys: fixed('t') },
72 { action: 'askOnLine', group: 'Lines', description: 'Ask about the line (Enter too), address a GitHub thread', defaultKeys: fixed('c') },
73 { action: 'replyOnLine', group: 'Lines', description: 'Reply in the thread of the line', defaultKeys: fixed('r') },
74 { action: 'watchSelection', group: 'Watch', description: 'Search all changes for the text selected with the mouse, or type it', defaultKeys: fixed('8') },
75 { action: 'nextWatchedMatch', group: 'Watch', description: 'Next occurrence of the watched term, across files', defaultKeys: fixed('7') },
76 { action: 'previousWatchedMatch', group: 'Watch', description: 'Previous occurrence of the watched term', defaultKeys: fixed('6') },
77 { action: 'previousThread', group: 'Threads', description: 'Previous thread', defaultKeys: fixed('p') },
78 { action: 'nextThread', group: 'Threads', description: 'Next thread', defaultKeys: fixed('n') },
79 { action: 'toggleThreadResolved', group: 'Threads', description: 'Resolve or unresolve the GitHub thread', defaultKeys: fixed('x') },
80 { action: 'openOnGitHub', group: 'Other', description: 'Open the comment, commit or PR on GitHub', defaultKeys: fixed('o') },
81 { action: 'stageCurrentFile', group: 'git', description: 'git add the file', defaultKeys: fixed('g') },
82 { action: 'stageAllChanges', group: 'git', description: 'git add everything', defaultKeys: fixed('e') },
83 { action: 'commitStaged', group: 'git', description: 'Have Claude commit what git add staged', defaultKeys: fixed('4') },
84 { action: 'toggleHelp', group: 'Other', description: 'This help', defaultKeys: fixed('h') },
85]
86
87export type Keymap = Record<ShortcutAction, readonly string[]>
88
89export function keymapOf(layout: KeyboardLayout): Keymap {
90 return Object.fromEntries(SHORTCUTS.map(shortcut => [shortcut.action, shortcut.defaultKeys(layout)])) as Keymap
91}
92
93export function shortcutsOfGroup(group: ShortcutGroup): readonly ShortcutDefinition[] {
94 return SHORTCUTS.filter(shortcut => shortcut.group === group)
95}
96
97export const SHORTCUT_GROUPS: readonly ShortcutGroup[] = ['Views', 'Files', 'Lines', 'Watch', 'Threads', 'git', 'Other']
98hooks/layout.ts 184 lines1import type { ChangedFile, ReviewView } from '../types'
2import {
3 COMMIT_PANEL_MAX_LINES,
4 MIN_DIFF_WIDTH,
5 WATCH_BAR_ROWS,
6 MIN_FILE_LIST_WIDTH,
7 TWO_COLUMNS_MIN_WIDTH,
8} from './constants'
9import { groupFilesByDirectory } from './git-parsing'
10
11export type FileLine =
12 | { kind: 'overview' }
13 | { kind: 'spacer' }
14 | { kind: 'directory'; directory: string; fileCount: number; isFolded: boolean }
15 | { kind: 'file'; file: ChangedFile }
16
17export function clampOffset(offset: number, itemCount: number, visibleCount: number): number {
18 return Math.max(0, Math.min(offset, itemCount - visibleCount))
19}
20
21// The diff's first shown row, and how many of its drawn lines are already above the top: a wrapped line or a thread
22// taller than the panel is read a few lines at a time instead of leaving at once with its row.
23export type DiffScrollPosition = { rowIndex: number; skippedLines: number }
24
25export function scrolledByLines(
26 position: DiffScrollPosition,
27 linesToMove: number,
28 rowHeights: readonly number[],
29 lastTopLine: number,
30): DiffScrollPosition {
31 const linesAbove = rowHeights.slice(0, position.rowIndex).reduce((total, height) => total + height, 0)
32 let remainingLines = Math.max(0, Math.min(linesAbove + position.skippedLines + linesToMove, lastTopLine))
33 for (const [rowIndex, height] of rowHeights.entries()) {
34 if (remainingLines < height) {
35 return { rowIndex, skippedLines: remainingLines }
36 }
37 remainingLines -= height
38 }
39
40 return { rowIndex: 0, skippedLines: 0 }
41}
42
43// The top line that keeps a growing thread's end in sight: the diff only moves down, and never past the line the thread
44// is about, so a thread taller than the panel shows its question first.
45export function topLineFollowingRow(rowHeights: readonly number[], rowIndex: number, currentTopLine: number, visibleLines: number): number {
46 const linesAbove = rowHeights.slice(0, rowIndex).reduce((total, height) => total + height, 0)
47 const rowBottom = linesAbove + (rowHeights[rowIndex] ?? 0)
48
49 return Math.max(currentTopLine, Math.min(rowBottom - visibleLines, linesAbove))
50}
51
52export function clampFileListWidth(width: number, bodyColumns: number): number {
53 return Math.max(MIN_FILE_LIST_WIDTH, Math.min(width, bodyColumns - MIN_DIFF_WIDTH))
54}
55
56// A folded directory keeps its own line, its files go out of the list.
57export function buildFileLines(files: ChangedFile[], hasOverview: boolean, foldedDirectories: readonly string[] = []): FileLine[] {
58 return [
59 ...(hasOverview ? [{ kind: 'overview' as const }] : []),
60 ...groupFilesByDirectory(files).flatMap(group => {
61 const isFolded = foldedDirectories.includes(group.directory)
62 return [
63 { kind: 'spacer' as const },
64 { kind: 'directory' as const, directory: group.directory, fileCount: group.files.length, isFolded },
65 ...(isFolded ? [] : group.files.map(file => ({ kind: 'file' as const, file }))),
66 ]
67 }),
68 ]
69}
70
71export type GeometryInput = {
72 bodyRows: number
73 bodyColumns: number
74 view: ReviewView
75 hasLoadError: boolean
76 errorNoticeCount: number
77 hasPullRequestLine: boolean
78 commitLineCount: number
79 selectedCommitLineIndex: number
80 // Set by the wheel over the commit list; absent, the list follows the selected commit.
81 requestedCommitListOffset: number | null
82 fileLineCount: number
83 hasStageActions: boolean
84 commitBarRows: number
85 commitMessageRows: number
86 hasNoiseNotice: boolean
87 // The bar of the searched term, under the header.
88 hasWatchBar: boolean
89 requestedFileListWidth: number
90 requestedFileListOffset: number
91 scrolledRowCount: number
92 // A thread or a question under the last rows would stay cut off: the diff then scrolls until its last row is on top.
93 isScrollingPastEnd: boolean
94 // The diff of a file scrolls by drawn lines and clamps itself, its long lines wrapping on several.
95 isScrollingByLines: boolean
96 requestedDiffOffset: number
97 // The row of line shortcuts under a file's diff.
98 hasLineShortcuts: boolean
99 isFileDrawerOpen: boolean
100 isFileListCollapsed: boolean
101}
102
103export type PaneGeometry = {
104 isTwoColumns: boolean
105 // Narrow or collapsed, the file list is a drawer over the code, opened from a bar above it.
106 isFileDrawerOpen: boolean
107 panelsTop: number
108 panelsHeight: number
109 firstShownCommitLineIndex: number
110 commitPanelRows: number
111 fileListWidth: number
112 fileListBoxHeight: number
113 visibleFileLines: number
114 fileListOffset: number
115 diffBoxHeight: number
116 visibleDiffRows: number
117 diffOffset: number
118}
119
120const TAB_ROWS = 3
121const LOCATION_LINE_ROWS = 1
122const HEADER_BOTTOM_PADDING_ROWS = 1
123const BORDER_ROWS = 2
124const PANEL_TITLE_ROWS = 1
125const NAVIGATION_HINT_ROWS = 2
126const FILTER_ROWS = 1
127const MIN_PANELS_HEIGHT = 8
128const FILE_BAR_ROWS = 1
129// One line of text in a frame.
130const ERROR_NOTICE_ROWS = 3
131
132// The pane is drawn to exactly its height so the engine never scrolls it; each panel scrolls on its own instead.
133export function computePaneGeometry(input: GeometryInput): PaneGeometry {
134 const isTwoColumns = !input.isFileListCollapsed && input.bodyColumns >= TWO_COLUMNS_MIN_WIDTH
135 const firstShownCommitLineIndex = clampOffset(
136 input.requestedCommitListOffset ?? input.selectedCommitLineIndex - 2,
137 input.commitLineCount,
138 COMMIT_PANEL_MAX_LINES,
139 )
140 const shownCommitLineCount = Math.min(COMMIT_PANEL_MAX_LINES, input.commitLineCount)
141 const commitPanelRows = input.view === 'commits' ? BORDER_ROWS + PANEL_TITLE_ROWS + Math.max(1, shownCommitLineCount) + 1 : 0
142 const panelsTop =
143 TAB_ROWS +
144 LOCATION_LINE_ROWS +
145 (input.hasPullRequestLine ? 1 : 0) +
146 HEADER_BOTTOM_PADDING_ROWS +
147 (input.hasLoadError ? 1 : 0) +
148 (input.hasWatchBar ? WATCH_BAR_ROWS : 0) +
149 input.commitBarRows +
150 input.commitMessageRows +
151 input.errorNoticeCount * ERROR_NOTICE_ROWS +
152 commitPanelRows
153 const panelsHeight = Math.max(MIN_PANELS_HEIGHT, input.bodyRows - panelsTop)
154
155 const fileListChromeRows =
156 BORDER_ROWS +
157 PANEL_TITLE_ROWS +
158 FILTER_ROWS +
159 NAVIGATION_HINT_ROWS +
160 (input.hasStageActions ? 1 : 0) +
161 (input.hasNoiseNotice ? 1 : 0)
162 const fileListBoxHeight = isTwoColumns ? panelsHeight : panelsHeight - FILE_BAR_ROWS
163 const visibleFileLines = Math.max(1, fileListBoxHeight - fileListChromeRows)
164
165 const diffBoxHeight = isTwoColumns ? panelsHeight : panelsHeight - FILE_BAR_ROWS
166 const visibleDiffRows = Math.max(1, diffBoxHeight - BORDER_ROWS - PANEL_TITLE_ROWS - (input.hasLineShortcuts ? 1 : 0))
167
168 return {
169 isTwoColumns,
170 isFileDrawerOpen: !isTwoColumns && input.isFileDrawerOpen,
171 panelsTop,
172 panelsHeight,
173 firstShownCommitLineIndex,
174 commitPanelRows,
175 fileListWidth: clampFileListWidth(input.requestedFileListWidth, input.bodyColumns),
176 fileListBoxHeight,
177 visibleFileLines,
178 fileListOffset: clampOffset(input.requestedFileListOffset, input.fileLineCount, visibleFileLines),
179 diffBoxHeight,
180 visibleDiffRows,
181 diffOffset: clampOffset(input.requestedDiffOffset, input.scrolledRowCount, input.isScrollingPastEnd || input.isScrollingByLines ? 1 : visibleDiffRows),
182 }
183}
184hooks/view/help.tsx 91 lines1import { HELP_CLOSE_KEY } from '../constants'
2import { SHORTCUT_GROUPS, shortcutsOfGroup } from '../keymap'
3import type { Keymap } from '../keymap'
4import { wrappedLineCount } from './row-height'
5import type { ExplorerActions, ViewElements } from './types'
6
7export type HelpInput = { keymap: Keymap; scrolledLines: number }
8
9const GROUP_WIDTH = 46
10const GROUP_GAP = 2
11const KEY_COLUMN_WIDTH = 8
12const PADDING_COLUMNS = 2 * 2 + 2
13const FOOTER = [
14 "Enter on the cursor's line asks about it; on a GitHub thread reached with n/p, c has Claude address it. Escape closes a question, then this help, then the pane.",
15]
16// The title, the gap under it and the frame.
17const HELP_CHROME_ROWS = 1 + 1 + 2
18
19// The engine measures nothing drawn inside a pane: the groups' height is counted from their text, as the flex wrap lays
20// them, a row of groups as tall as its tallest.
21export function helpContentLineCount(bodyColumns: number): number {
22 const contentColumns = bodyColumns - PADDING_COLUMNS
23 const groupsPerRow = Math.max(1, Math.floor((contentColumns + GROUP_GAP) / (GROUP_WIDTH + GROUP_GAP)))
24 const groupHeights = SHORTCUT_GROUPS.map(
25 group =>
26 1 + shortcutsOfGroup(group).reduce((height, shortcut) => height + wrappedLineCount(shortcut.description, Math.floor((GROUP_WIDTH - KEY_COLUMN_WIDTH) * 0.9)), 0),
27 )
28 let lineCount = 0
29 for (let rowStart = 0; rowStart < groupHeights.length; rowStart += groupsPerRow) {
30 lineCount += Math.max(...groupHeights.slice(rowStart, rowStart + groupsPerRow)) + 1
31 }
32
33 return lineCount
34}
35
36export function helpVisibleLineCount(height: number, bodyColumns: number): number {
37 const footerRows = FOOTER.reduce((rows, line) => rows + wrappedLineCount(line, bodyColumns - PADDING_COLUMNS), 0)
38
39 return Math.max(1, height - HELP_CHROME_ROWS - footerRows)
40}
41
42export function renderHelp(ui: ViewElements, input: HelpInput, height: number, bodyColumns: number, actions: ExplorerActions) {
43 const { Box, Button, Text } = ui
44 const isMoreBelow = input.scrolledLines + helpVisibleLineCount(height, bodyColumns) < helpContentLineCount(bodyColumns)
45
46 return (
47 <Box flexDirection="column" height={height} overflow="hidden" borderStyle="round" borderColor={ui.theme.accentBorder} backgroundColor={ui.theme.panelBackground} paddingX={2}>
48 <Box flexDirection="row" flexShrink={0}>
49 <Box flexGrow={1}>
50 <Text bold color={ui.theme.heading}>
51 Keyboard shortcuts
52 </Text>
53 </Box>
54 <Box flexShrink={0} marginRight={2}>
55 <Text color={ui.theme.muted}>
56 {input.scrolledLines > 0 ? '▲' : ' '}
57 {isMoreBelow ? '▼ scroll for more' : ' '}
58 </Text>
59 </Box>
60 <Button key={HELP_CLOSE_KEY} label="× close" onPress={() => actions.toggleHelp()} />
61 </Box>
62 <Box flexDirection="column" flexGrow={1} flexShrink={1} marginTop={1} overflow="hidden">
63 <Box flexDirection="row" flexWrap="wrap" flexShrink={0} marginTop={-input.scrolledLines}>
64 {SHORTCUT_GROUPS.map(group => (
65 <Box key={`help-${group}`} flexDirection="column" width={GROUP_WIDTH} marginBottom={1} marginRight={GROUP_GAP}>
66 <Text bold color={ui.theme.accent}>
67 {group}
68 </Text>
69 {shortcutsOfGroup(group).map(shortcut => (
70 <Box key={`help-${shortcut.action}`} flexDirection="row">
71 <Box width={KEY_COLUMN_WIDTH} flexShrink={0}>
72 <Text color={ui.theme.commitSha} bold>
73 {input.keymap[shortcut.action].join(' ')}
74 </Text>
75 </Box>
76 <Text>{shortcut.description}</Text>
77 </Box>
78 ))}
79 </Box>
80 ))}
81 </Box>
82 </Box>
83 {FOOTER.map(line => (
84 <Box key={`help-footer-${line.slice(0, 12)}`} flexShrink={0}>
85 <Text color={ui.theme.muted}>{line}</Text>
86 </Box>
87 ))}
88 </Box>
89 )
90}
91hooks/markdown.ts 115 lines1// GitHub names each emoji image after its code points (`unicode/1f68b.png`, `unicode/1f1eb-1f1f7.png`): that file
2// name gives the character. Emoji GitHub draws as an image of its own (`:octocat:`) have none and keep their code.
3export function parseEmojiCharacters(emojisOutput: string): Record<string, string> {
4 const emojiCharacterByShortcode: Record<string, string> = {}
5 let emojis: unknown
6 try {
7 emojis = JSON.parse(emojisOutput)
8 } catch {
9 return emojiCharacterByShortcode
10 }
11 for (const [shortcode, imageUrl] of Object.entries(typeof emojis === 'object' && emojis !== null ? emojis : {})) {
12 const codePoints = typeof imageUrl === 'string' ? imageUrl.match(/\/unicode\/([0-9a-f-]+)\.png/)?.[1] : undefined
13 if (codePoints) {
14 emojiCharacterByShortcode[shortcode] = String.fromCodePoint(...codePoints.split('-').map(codePoint => parseInt(codePoint, 16)))
15 }
16 }
17
18 return emojiCharacterByShortcode
19}
20
21export function replaceEmojiShortcodes(text: string, emojiCharacterByShortcode: Record<string, string>): string {
22 return text.replace(/:([a-z0-9_+-]+):/g, (shortcode, name: string) => emojiCharacterByShortcode[name] ?? shortcode)
23}
24
25const IMAGE_MARK = '\u{1F5BC}'
26// The alt may hold brackets of its own, one level deep.
27const MARKDOWN_IMAGE = /!\[((?:[^[\]]|\[[^[\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g
28
29// GitHub keeps a pasted screenshot as an <img> tag and a written one as ; the terminal draws neither, so
30// each becomes a link to the picture.
31export function imagesAsLinks(markdown: string): { markdown: string; imageUrls: string[] } {
32 const imageUrls: string[] = []
33 const linkTo = (url: string, description: string) => {
34 imageUrls.push(url)
35 return `[${IMAGE_MARK} ${description.replace(/[[\]]/g, '')} ↗](${url})`
36 }
37 const withHtmlImages = markdown.replace(/<img\b[^>]*>/gi, tag => {
38 const url = htmlAttributeOf(tag, 'src')
39 if (url === null) {
40 return tag
41 }
42 const width = htmlAttributeOf(tag, 'width')
43 const height = htmlAttributeOf(tag, 'height')
44 return linkTo(url, `${imageDescriptionOf(htmlAttributeOf(tag, 'alt'))}${width && height ? ` ${width}×${height}` : ''}`)
45 })
46
47 return {
48 markdown: withHtmlImages.replace(MARKDOWN_IMAGE, (_image, alt: string, url: string) => linkTo(url, imageDescriptionOf(alt))),
49 imageUrls,
50 }
51}
52
53function htmlAttributeOf(tag: string, name: string): string | null {
54 const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i'))
55 return match ? (match[1] ?? match[2] ?? match[3] ?? null) : null
56}
57
58// GitHub names every pasted picture "image": only an alt someone wrote says more.
59function imageDescriptionOf(alt: string | null): string {
60 const trimmedAlt = alt?.trim() ?? ''
61 return trimmedAlt === '' || trimmedAlt.toLowerCase() === 'image' ? 'image' : trimmedAlt
62}
63
64const LIST_ITEM = /^(\s*)(?:[-*+]|\d+[.)])\s/
65
66export function isListItemBlock(block: string): boolean {
67 return LIST_ITEM.test(block)
68}
69
70// A nested item drawn alone would read as a top-level one: its indentation is kept apart, to be drawn as a margin.
71export function splitListItemIndentation(block: string): { indentation: number; text: string } {
72 const indentation = block.match(LIST_ITEM)?.[1]?.length ?? 0
73
74 return { indentation, text: block.split('\n').map(line => line.slice(Math.min(indentation, line.search(/\S|$/)))).join('\n') }
75}
76
77// A paragraph, a list item, a fenced code block or a heading each make one block, so a question can point at any of them.
78export function splitMarkdownBlocks(markdown: string): string[] {
79 const blocks: string[] = []
80 let currentLines: string[] = []
81 let isInsideFence = false
82 const closeBlock = () => {
83 if (currentLines.some(line => line.trim() !== '')) {
84 blocks.push(currentLines.join('\n'))
85 }
86 currentLines = []
87 }
88 for (const line of markdown.split('\n')) {
89 if (line.trimStart().startsWith('```')) {
90 isInsideFence = !isInsideFence
91 currentLines.push(line)
92 if (!isInsideFence) {
93 closeBlock()
94 }
95 continue
96 }
97 if (isInsideFence) {
98 currentLines.push(line)
99 } else if (line.trim() === '') {
100 closeBlock()
101 } else if (/^#{1,6} /.test(line)) {
102 closeBlock()
103 blocks.push(line)
104 } else if (LIST_ITEM.test(line)) {
105 closeBlock()
106 currentLines.push(line)
107 } else {
108 currentLines.push(line)
109 }
110 }
111 closeBlock()
112
113 return blocks
114}
115