SLOPSHOPPER

diff-review

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…

newpaneguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-review
│ ┃ Review ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ The review pane needs at least 60 columns ⏺ Read(src/auth.ts) │ ┃ (now 56). ⎿ Read 6 lines │ ┃ Widen it by dragging the divider, or widen ⏺ Update(src/auth.ts) │ ┃ the terminal. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diff-review │ ⎿ diff-review: Review explorer opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review
The review pane needs at least 60 columns (now 56). Widen it by dragging the divider, or widen the terminal.
README

diff-review

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.

Asking Claude about a line, the answer under 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.

Install

/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.

Ask Claude about a line

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.

  • Reply in the thread, or dictate in the prompt instead of typing in the pane.
  • Threads are kept per branch, across /clear and across sessions.
  • A thread whose line left the diff, or whose part of the pull request description was rewritten, shows on top of its file or of the overview, still readable and closable.
  • The file list marks the files holding threads: ◆ for Claude's, 💬 for GitHub review comments.

Three views

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.

The uncommitted changes, a new file

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

The pull request 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 branch commits, one not pushed, and the push button

GitHub review comments

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.

A GitHub review comment under its line

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.

Claude addressing a review comment, the change it made listed under the comment

Review bookkeeping

  • The "viewed" box of GitHub on each file, ticked from the file list.
  • ○ unviewed hides the files already viewed; a file you just marked stays a moment before it goes.
  • A file GitHub does not list yet, because it changed in a commit not pushed, shows ↑ instead of a box.
  • A file changed since you ticked it shows ! and comes back under ○ unviewed. Ticked from the pane, it opens on the first line changed since.
  • The header sums the view up in tiles: files, lines, a viewed bar, the lines still to view and the open threads. The lines still to view go down with each file ticked.

A readable diff

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.

The diff side by side

Search all changes

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.

A method name selected and searched for, its occurrences walked from file to file

Keyboard too

Everything works with the mouse. Every action also has a key; the help button of the pane lists them all.

Settings

In /config, under diff-review:

  • Theme: 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.

The light theme

What it sends, and where

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.

Requirements

  • Claude Code with mods support (tested with 2.1.294), in the terminal. The Desktop app, VS Code and mobile show a notice instead of the pane.
  • git; the GitHub CLI gh for the pull request features; xdg-open (Linux) or open (macOS) to open GitHub pages.
  • Over SSH, a terminal Claude Code cannot recognize gets each link as its text followed by the whole URL: the commit shas, the pull request. export FORCE_HYPERLINK=1 before claude keeps them as clickable text when the terminal you type in supports links, as most do.

Development

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.

License

MIT

Source 35 files
hooks/register.tsx 2191 lines
1import { 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 lines
1import 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
91
hooks/git-parsing.ts 191 lines
1import 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}
191
hooks/github-review.ts 320 lines
1import 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}
320
hooks/cursor.ts 71 lines
1import 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}
71
hooks/watch.ts 78 lines
1import 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}
78
hooks/file-filter.ts 48 lines
1import 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}
48
hooks/keyboard.ts 19 lines
1import 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}
19
hooks/keymap.ts 98 lines
1import 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']
98
hooks/layout.ts 184 lines
1import 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}
184
hooks/view/help.tsx 91 lines
1import { 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}
91
hooks/markdown.ts 115 lines
1// 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 ![alt](url); 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