SLOPSHOPPER

diff-review

A reviewr-style review pane: diff with file sidebar (uncommitted, branch, last-turn, last-commit scopes), the branch's MR/PR, and a live pipeline diagram. Hunk…

newpaneguardcommandtoastprocess
v0.2.0MITupdated 2026-10-06fedecor9/diff-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-review
│ ┃ diff-review ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Changes 2: MR/PR 3: Pipeline [uncommi │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │src/auth.ts · hunk 1/1 ⏺ Update(src/auth.ts) │ ┃ │@@ -3,4 +3,5 @@ ⎿ Added 2 lines, removed 1 line │ ┃ │ 3 export async function refresh(token: s ⏺ Bash(bun test) │ ┃ │ 4 const claims = await verify(token) ⎿ 3 pass, 1 fail │ ┃ │▎ 5 if (!claims) throw new Error('invali │ ┃ │▎ 5 if (!claims || claims.exp < Date.now ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │▎ 6 await audit('refresh', claims.sub) │ ┃ │ 7 return issue(claims.sub) ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ › /diff-review │ ┃ ⎿ diff-review: diff-review pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · diff-review
1: Changes 2: MR/PR 3: Pipeline [uncommitted] vs HEAD 1 ╭─────────────────────────────────────────────────────────── │src/auth.ts · hunk 1/1 │@@ -3,4 +3,5 @@ │ 3 export async function refresh(token: string) { │ 4 const claims = await verify(token) │▎ 5 if (!claims) throw new Error('invalid token') │▎ 5 if (!claims || claims.exp < Date.now() / 1000) throw │▎ 6 await audit('refresh', claims.sub) │ 7 return issue(claims.sub) ╰─────────────────────────────────────────────────────────── u: uncommitted b: branch t: turn g: commit f: focus files j:
README

diff-review

A reviewr-style review pane inside Claude Code. Read the agent's diff beside the chat, comment on hunks, and send the comments back to the prompt.

  • Changes: the diff with syntax highlighting and a Files sidebar grouped by folder. Four scopes: uncommitted, branch (vs origin/HEAD), last turn, last commit.
  • MR / PR: the branch's merge request (GitLab, via glab) or pull request (GitHub, via gh): state, checks, description and comments, read-only.
  • Pipeline: the branch's latest pipeline as a vertical diagram of stages, each listing its jobs, their times and the command a running job is on. Live while it runs.

Install

In a Claude Code terminal session:

/plugin install diff-review --marketplace fedecor9/diff-review

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Needs git. The MR/PR and Pipeline tabs need glab (GitLab) or gh (GitHub), authenticated.

Use

/diff-review opens the pane; it also opens on its own at session start when the terminal is at least 144 columns wide. ctrl+x tab gives the pane the keyboard, Esc hands it back to the chat; the top-right corner says which side has it.

KeyChanges tab
1 2 3Changes / MR / Pipeline tab
u b t gScope: uncommitted, branch, last turn, last commit
fSwitch focus between the diff and the Files sidebar
j kNext / previous hunk (diff focused) or file (Files focused)
n pNext / previous hunk, across files
lBack to the diff from the Files sidebar
cComment on the current hunk
sSend every comment to the prompt box (not submitted)
xClear comments
zHide / show the Files sidebar
rRefresh
KeyMR and Pipeline tabs
j kSelect a stage (Pipeline)
l / hZoom into the selected stage / back
oOpen in the browser
rRefresh
↑ ↓Scroll

How it reads changes

The uncommitted, branch and last-turn scopes snapshot the working tree (untracked files included, .gitignore respected) through a scratch git index, so the real index is never touched. The last-turn scope snapshots when each turn starts and diffs against it.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .
Source 5 files
hooks/register.tsx 773 lines
1// A reviewr-style diff pane: four diff scopes, file and hunk navigation,
2// comments on hunks, and Send, which puts every comment in the prompt box.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { DiffFile, FocusRegion, ForgeView, Pipeline, PipelineStage, ReviewComment, Scope, Tab } from '../types'
7import {
8  STATUS_COLOR,
9  STATUS_ICON,
10  currentCommand,
11  elapsed,
12  parseGithubRun,
13  parseGitlabPipeline,
14  stageSpan,
15} from './pipeline'
16import { GITHUB_FIELDS, checksColor, parseGithub, parseGitlab } from './forge'
17import { STATUS_LETTER, fileTree, formatComments, hunkRows, hunksInView, parseDiff, sortByFolder } from './diff'
18
19const PANE = 'diff-review'
20const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
21// Share of the terminal the docked pane asks for, like reviewr's half split.
22const DOCK_SHARE = 0.6
23const ROW_BACKGROUND = { add: '#15301f', remove: '#3a1a1d' } as const
24const ROW_ACCENT = { add: 'green', remove: 'red' } as const
25const REFRESHING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
26
27const scope = atom({ plugin: 'diff-review', key: 'scope' } as const, 'uncommitted' as Scope)
28const files = atom({ plugin: 'diff-review', key: 'files' } as const, [] as DiffFile[])
29const fileIndex = atom({ plugin: 'diff-review', key: 'fileIndex' } as const, 0)
30const hunkIndex = atom({ plugin: 'diff-review', key: 'hunkIndex' } as const, 0)
31const comments = atom({ plugin: 'diff-review', key: 'comments' } as const, [] as ReviewComment[])
32const isSidebarHidden = atom({ plugin: 'diff-review', key: 'isSidebarHidden' } as const, false)
33const focusRegion = atom({ plugin: 'diff-review', key: 'focusRegion' } as const, 'diff' as FocusRegion)
34const isComposing = atom({ plugin: 'diff-review', key: 'isComposing' } as const, false)
35const turnTree = atom({ plugin: 'diff-review', key: 'turnTree' } as const, null as string | null)
36const baseLabel = atom({ plugin: 'diff-review', key: 'baseLabel' } as const, '')
37const notice = atom({ plugin: 'diff-review', key: 'notice' } as const, '')
38const tab = atom({ plugin: 'diff-review', key: 'tab' } as const, 'changes' as Tab)
39const forge = atom({ plugin: 'diff-review', key: 'forge' } as const, null as ForgeView | null)
40const forgeNotice = atom({ plugin: 'diff-review', key: 'forgeNotice' } as const, '')
41const pipeline = atom({ plugin: 'diff-review', key: 'pipeline' } as const, null as Pipeline | null)
42const pipelineNotice = atom({ plugin: 'diff-review', key: 'pipelineNotice' } as const, '')
43const stageIndex = atom({ plugin: 'diff-review', key: 'stageIndex' } as const, 0)
44const zoomedStage = atom({ plugin: 'diff-review', key: 'zoomedStage' } as const, null as number | null)
45const now = atom({ plugin: 'diff-review', key: 'now' } as const, 0)
46const pollGeneration = atom({ plugin: 'diff-review', key: 'pollGeneration' } as const, 0)
47
48const TICK_MS = 2_000
49const TICKS_PER_FETCH = 5
50
51const SCOPE_LABELS: Record<Scope, string> = {
52  uncommitted: 'uncommitted',
53  branch: 'branch',
54  turn: 'last turn',
55  commit: 'last commit',
56}
57
58async function git($: EngineInterface, args: string[], env?: Record<string, string>): Promise<string> {
59  const result = await $.process.run(['git', ...args], { env, timeoutMs: 20_000 })
60  if (result.exitCode !== 0) throw new Error(result.stderr.trim() || `git ${args[0]} failed`)
61  return result.stdout
62}
63
64// The working tree as a tree object, untracked files included and .gitignore
65// respected, built in a scratch index seeded from the real one so unchanged
66// files are not rehashed.
67async function snapshotTree($: EngineInterface): Promise<string> {
68  const gitPath = async (name: string) =>
69    (await git($, ['rev-parse', '--path-format=absolute', '--git-path', name])).trim()
70  const scratchIndex = await gitPath('diff-review.index')
71  await $.process.run(['cp', await gitPath('index'), scratchIndex])
72  const env = { GIT_INDEX_FILE: scratchIndex }
73  await git($, ['add', '-A'], env)
74  return (await git($, ['write-tree'], env)).trim()
75}
76
77async function defaultBase($: EngineInterface): Promise<string> {
78  const remoteHead = await $.process.run(['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
79  return remoteHead.exitCode === 0 ? remoteHead.stdout.trim() : 'main'
80}
81
82async function headOrEmpty($: EngineInterface): Promise<string> {
83  const head = await $.process.run(['git', 'rev-parse', '--verify', '--quiet', 'HEAD'])
84  return head.exitCode === 0 ? head.stdout.trim() : EMPTY_TREE
85}
86
87async function diffRange($: EngineInterface, current: Scope): Promise<{ from: string; to: string; label: string } | string> {
88  switch (current) {
89    case 'uncommitted':
90      return { from: await headOrEmpty($), to: await snapshotTree($), label: 'vs HEAD' }
91    case 'branch': {
92      const base = await defaultBase($)
93      const mergeBase = (await git($, ['merge-base', base, 'HEAD'])).trim()
94      return { from: mergeBase, to: await snapshotTree($), label: `vs ${base}` }
95    }
96    case 'turn': {
97      const start = await read($, turnTree)
98      if (!start) return 'No turn has started since the pane loaded.'
99      return { from: start, to: await snapshotTree($), label: 'since the last turn started' }
100    }
101    case 'commit':
102      return { from: 'HEAD~1', to: 'HEAD', label: 'HEAD~1..HEAD' }
103  }
104}
105
106async function refresh($: EngineInterface): Promise<void> {
107  try {
108    const range = await diffRange($, await read($, scope))
109    if (typeof range === 'string') {
110      await update($, files, () => [])
111      await update($, notice, () => range)
112      return
113    }
114    const output = await git($, ['diff', '--no-color', '--no-ext-diff', '-M', range.from, range.to])
115    const parsed = sortByFolder(parseDiff(output))
116    await update($, files, () => parsed)
117    await update($, baseLabel, () => range.label)
118    await update($, notice, () => '')
119    await update($, fileIndex, index => Math.min(index, Math.max(0, parsed.length - 1)))
120    await update($, hunkIndex, () => 0)
121  } catch (error) {
122    await update($, notice, () => (error instanceof Error ? error.message : String(error)))
123  }
124}
125
126async function selectScope($: EngineInterface, next: Scope): Promise<void> {
127  await update($, scope, () => next)
128  await update($, fileIndex, () => 0)
129  await refresh($)
130}
131
132async function selectFile($: EngineInterface, index: number): Promise<void> {
133  await update($, fileIndex, () => index)
134  await update($, hunkIndex, () => 0)
135}
136
137function shortPath(path: string, width: number): string {
138  return path.length <= width ? path : `…${path.slice(path.length - width + 1)}`
139}
140
141async function isGithubRepo($: EngineInterface): Promise<boolean> {
142  const remotes = (await git($, ['remote'])).split('\n').filter(Boolean)
143  const remote = ['upstream', 'origin'].find(name => remotes.includes(name)) ?? remotes[0]
144  if (!remote) throw new Error('This repo has no remote.')
145  return (await git($, ['remote', 'get-url', remote])).trim().includes('github.com')
146}
147
148async function runJson($: EngineInterface, argv: string[]): Promise<string> {
149  const result = await $.process.run(argv, { timeoutMs: 30_000 })
150  if (result.exitCode !== 0) throw new Error(result.stderr.trim().split('\n')[0] || `${argv[0]} failed`)
151  return result.stdout
152}
153
154// The branch's latest pipeline (GitLab) or workflow run (GitHub). A GitLab
155// job's command is read from its log: running jobs on every fetch, finished
156// ones once, for the stage selected or zoomed into.
157async function loadPipeline($: EngineInterface): Promise<void> {
158  try {
159    const loaded = (await isGithubRepo($)) ? await loadGithubRun($) : await loadGitlabPipeline($)
160    await update($, pipeline, () => loaded)
161    await update($, pipelineNotice, () => '')
162    await update($, stageIndex, index => Math.min(index, Math.max(0, loaded.stages.length - 1)))
163  } catch (error) {
164    await update($, pipelineNotice, () => (error instanceof Error ? error.message : String(error)))
165  }
166}
167
168async function loadGitlabPipeline($: EngineInterface): Promise<Pipeline> {
169  const loaded = parseGitlabPipeline(await runJson($, ['glab', 'ci', 'get', '--output', 'json']))
170  const previous = await read($, pipeline)
171  const zoomed = await read($, zoomedStage)
172  const selected = await read($, stageIndex)
173  const known = new Map(previous?.stages.flatMap(stage => stage.jobs).map(job => [job.id, job]))
174
175  await Promise.all(
176    loaded.stages.flatMap((stage, stageAt) =>
177      stage.jobs.map(async job => {
178        const cached = known.get(job.id)
179        const isFinished = job.status !== 'running'
180        if (isFinished && cached?.command && cached.status === job.status) {
181          job.command = cached.command
182          job.output = cached.output
183          return
184        }
185        if (job.status !== 'running' && stageAt !== zoomed && stageAt !== selected) return
186        if (job.status === 'pending' || job.status === 'skipped' || job.status === 'manual') return
187        const trace = await $.process.run(['glab', 'api', `projects/:id/jobs/${job.id}/trace`], { timeoutMs: 30_000 })
188        if (trace.exitCode === 0) Object.assign(job, currentCommand(trace.stdout))
189      }),
190    ),
191  )
192  return loaded
193}
194
195async function loadGithubRun($: EngineInterface): Promise<Pipeline> {
196  const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
197  const runs = JSON.parse(await runJson($, ['gh', 'run', 'list', '--branch', branch, '--limit', '1', '--json', 'databaseId']))
198  const runId = runs[0]?.databaseId
199  if (!runId) throw new Error(`No workflow runs for ${branch}.`)
200  const fields = 'databaseId,workflowName,status,conclusion,url,headBranch,createdAt,updatedAt,jobs'
201  return parseGithubRun(await runJson($, ['gh', 'run', 'view', String(runId), '--json', fields]))
202}
203
204// Ticks the clock while the Pipeline tab is shown, so running times count up,
205// and refetches every few ticks while the pipeline is still going. A newer
206// call or a reload (which cancels this loop's sleep) ends it.
207async function startPolling($: EngineInterface): Promise<void> {
208  const generation = await update($, pollGeneration, value => value + 1)
209  let ticks = 0
210  try {
211    while ((await read($, tab)) === 'pipeline' && (await read($, pollGeneration)) === generation) {
212      const current = await read($, pipeline)
213      const isActive = !current || current.status === 'running' || current.status === 'pending'
214      if (ticks % TICKS_PER_FETCH === 0 && (ticks === 0 || isActive)) await loadPipeline($)
215      const time = await $.clock.now()
216      await update($, now, () => time)
217      ticks += 1
218      await $.clock.sleep(TICK_MS)
219    }
220  } catch {
221    // A reload or session end cancels the pending sleep; the loop just stops.
222  }
223}
224
225async function zoomStage($: EngineInterface, index: number | null): Promise<void> {
226  await update($, zoomedStage, () => index)
227  if (index !== null) {
228    await update($, stageIndex, () => index)
229    await loadPipeline($)
230  }
231}
232
233async function moveStage($: EngineInterface, step: number): Promise<void> {
234  const count = (await read($, pipeline))?.stages.length ?? 0
235  if (count === 0) return
236  await update($, stageIndex, index => Math.max(0, Math.min(count - 1, index + step)))
237  await loadPipeline($)
238}
239
240// The branch's open MR (GitLab, via glab) or PR (GitHub, via gh), read-only.
241async function loadForge($: EngineInterface): Promise<void> {
242  await update($, forgeNotice, () => 'Loading…')
243  try {
244    const isGithub = await isGithubRepo($)
245
246    const argv = isGithub ? ['gh', 'pr', 'view', '--json', GITHUB_FIELDS] : ['glab', 'mr', 'view', '--comments', '--output', 'json']
247    const result = await $.process.run(argv, { timeoutMs: 30_000 })
248    if (result.exitCode !== 0) {
249      const reason = result.stderr.trim().split('\n')[0] || `${argv[0]} failed`
250      throw new Error(`No ${isGithub ? 'PR' : 'MR'} for this branch: ${reason}`)
251    }
252    const view = isGithub ? parseGithub(result.stdout) : parseGitlab(result.stdout)
253    await update($, forge, () => view)
254    await update($, forgeNotice, () => '')
255  } catch (error) {
256    await update($, forge, () => null)
257    await update($, forgeNotice, () => (error instanceof Error ? error.message : String(error)))
258  }
259}
260
261async function selectTab($: EngineInterface, next: Tab): Promise<void> {
262  await update($, tab, () => next)
263  if (next === 'forge' && (await read($, forge)) === null) await loadForge($)
264  if (next === 'pipeline') void startPolling($)
265}
266
267async function openPane($: EngineInterface, presentation: { isFullscreen: boolean; columns: number }) {
268  await refresh($)
269  await $.ui.open({
270    id: PANE,
271    title: 'diff-review',
272    focus: true,
273    ...(presentation.isFullscreen ? { columns: Math.floor(presentation.columns * DOCK_SHARE) } : {}),
274  })
275  return { text: 'diff-review pane opened.' }
276}
277
278async function toggleRegion($: EngineInterface): Promise<void> {
279  const hidden = await read($, isSidebarHidden)
280  await update($, focusRegion, region => (region === 'diff' && !hidden ? 'files' : 'diff'))
281}
282
283async function moveFile($: EngineInterface, step: number): Promise<void> {
284  const count = (await read($, files)).length
285  if (count === 0) return
286  await update($, fileIndex, index => (index + step + count) % count)
287  await update($, hunkIndex, () => 0)
288}
289
290// Walks hunks across files, like reviewr's ] and [.
291async function moveHunk($: EngineInterface, step: number): Promise<void> {
292  const list = await read($, files)
293  if (list.length === 0) return
294  const fileAt = await read($, fileIndex)
295  const hunkAt = (await read($, hunkIndex)) + step
296  const hunkCount = list[fileAt]?.hunks.length ?? 0
297
298  if (hunkAt >= 0 && hunkAt < hunkCount) {
299    await update($, hunkIndex, () => hunkAt)
300    return
301  }
302  const nextFile = (fileAt + step + list.length) % list.length
303  await update($, fileIndex, () => nextFile)
304  await update($, hunkIndex, () => (step > 0 ? 0 : Math.max(0, (list[nextFile]?.hunks.length ?? 1) - 1)))
305}
306
307async function addComment($: EngineInterface, text: string): Promise<void> {
308  await update($, isComposing, () => false)
309  if (text.trim().length === 0) return
310  const file = (await read($, files))[await read($, fileIndex)]
311  const hunk = file?.hunks[await read($, hunkIndex)]
312  if (!file || !hunk) return
313  const [hunkHeader = '', ...body] = hunk.split('\n')
314  const comment: ReviewComment = { path: file.path, hunkHeader, snippet: body.join('\n'), text: text.trim() }
315  await update($, comments, list => [...list, comment])
316}
317
318async function sendComments($: EngineInterface): Promise<void> {
319  const list = await read($, comments)
320  if (list.length === 0) {
321    $.ui.toast('No comments to send.')
322    return
323  }
324  const filled = await $.prompt.fill({ text: formatComments(list), mode: 'append' })
325  if (!filled.isFilled) {
326    $.ui.toast('The prompt box is busy; comments kept.')
327    return
328  }
329  await update($, comments, () => [])
330  $.ui.toast(`${list.length} comment${list.length === 1 ? '' : 's'} added to the prompt.`)
331}
332
333export const register: Register = on => {
334  on('session.start', async ($, e, next) => {
335    await $.command.register({ name: 'diff-review', description: 'Open the diff-review pane: changes, MR and pipeline' })
336    void refresh($)
337    void $.ui.open({ id: PANE, title: 'diff-review' })
338    if ((await read($, tab)) === 'pipeline') void startPolling($)
339    return next(e)
340  })
341
342  on('command.run', { command: 'diff-review' }, async ($, e) => openPane($, e.presentation))
343
344  on('turn.start', async ($, e, next) => {
345    try {
346      const tree = await snapshotTree($)
347      await update($, turnTree, () => tree)
348    } catch {
349      // Not a git repo, or git failed: the turn scope reports it on refresh.
350    }
351    return next(e)
352  })
353
354  on('tool.call', async ($, e, next) => {
355    const result = await next(e)
356    if (REFRESHING_TOOLS.has(e.tool)) void refresh($)
357    return result
358  })
359
360  on('turn.complete', async ($, e, next) => {
361    void refresh($)
362    return next(e)
363  })
364
365  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
366    if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
367    const { Box, Text, Button, Code, Input, Link, Markdown } = $.ui.resolve(e)
368    const bodyColumns = e.props.bodyColumns
369    const bodyRows = e.props.scroll.bodyRows
370    const activeTab = await read($, tab)
371    const view = await read($, forge)
372    const hasKeys = e.props.isFocused
373
374    const keysIndicator = hasKeys ? (
375      <Text color="cyan">⌨ keys here · esc → chat</Text>
376    ) : (
377      <Text dimColor>ctrl+x tab → keys</Text>
378    )
379
380    const tabBar = (
381      <Box flexDirection="row" gap={2} marginRight={2}>
382        <Button key="tab-changes" plain hotkey="1" dimColor={activeTab !== 'changes'} label="Changes" onPress={() => selectTab($, 'changes')} />
383        <Button key="tab-forge" plain hotkey="2" dimColor={activeTab !== 'forge'} label={view?.kind ?? 'MR/PR'} onPress={() => selectTab($, 'forge')} />
384        <Button key="tab-pipeline" plain hotkey="3" dimColor={activeTab !== 'pipeline'} label="Pipeline" onPress={() => selectTab($, 'pipeline')} />
385      </Box>
386    )
387
388    if (activeTab === 'pipeline') {
389      const loaded = await read($, pipeline)
390      const pipelineMessage = await read($, pipelineNotice)
391      const selected = await read($, stageIndex)
392      const zoomed = await read($, zoomedStage)
393      const clock = (await read($, now)) || (await $.clock.now())
394      const boxWidth = Math.max(30, Math.min(bodyColumns - 2, 72))
395      const zoomedTo = zoomed === null ? undefined : loaded?.stages[zoomed]
396
397      const arrow = (key: string, label?: string) => (
398        <Box key={key} flexDirection="column" alignItems="center" width={boxWidth}>
399          <Text dimColor>│</Text>
400          <Text color="cyan">▼{label ? <Text dimColor> {label}</Text> : null}</Text>
401        </Box>
402      )
403
404      // Every box lists its jobs; the selected one also shows each job's
405      // command and latest output, which the zoomed view shows too.
406      const stageBox = (stage: PipelineStage, index: number, isSelected: boolean) => {
407        const span = stageSpan(stage)
408        const done = stage.jobs.filter(job => job.status === 'success' || job.status === 'skipped').length
409        return (
410          <Box
411            key={`stage-${index}`}
412            flexDirection="column"
413            width={boxWidth}
414            borderStyle={isSelected ? 'bold' : 'round'}
415            borderColor={isSelected ? 'cyan' : STATUS_COLOR[stage.status]}
416            paddingX={1}
417          >
418            <Box flexDirection="row">
419              <Text color={STATUS_COLOR[stage.status]}>{STATUS_ICON[stage.status]} </Text>
420              <Button key={`stage-open-${index}`} plain label={stage.name} onPress={() => zoomStage($, index)} />
421              <Text dimColor>
422                {' '}
423                · {done}/{stage.jobs.length} done
424              </Text>
425              <Box flexGrow={1} />
426              <Text dimColor>{elapsed(span.startedAt, span.finishedAt, clock)}</Text>
427            </Box>
428            {stage.jobs.map(job => {
429              const isRunning = job.status === 'running'
430              const showsCommand = (isSelected || isRunning) && job.command
431              return (
432                <Box key={`stage-job-${job.id}`} flexDirection="column">
433                  <Box flexDirection="row">
434                    <Text dimColor>{'  '}</Text>
435                    <Text color={STATUS_COLOR[job.status]}>{STATUS_ICON[job.status]} </Text>
436                    <Text bold={isRunning}>{job.name}</Text>
437                    {job.isAllowedToFail && <Text dimColor> (may fail)</Text>}
438                    <Box flexGrow={1} />
439                    <Text dimColor>{elapsed(job.startedAt, job.finishedAt, clock)}</Text>
440                  </Box>
441                  {showsCommand && (
442                    <Text color={isRunning ? 'yellow' : undefined} dimColor={!isRunning} wrap="truncate-end">
443                      {'    $ '}
444                      {job.command}
445                    </Text>
446                  )}
447                  {isSelected && job.output && (
448                    <Text dimColor wrap="truncate-end">
449                      {'      '}
450                      {job.output}
451                    </Text>
452                  )}
453                </Box>
454              )
455            })}
456          </Box>
457        )
458      }
459
460      const header = (
461        <Box flexDirection="row" gap={1}>
462          {tabBar}
463          {loaded && <Text color={STATUS_COLOR[loaded.status]}>{STATUS_ICON[loaded.status]}</Text>}
464          {loaded && <Text bold>{loaded.name}</Text>}
465          {loaded && <Text dimColor>{loaded.ref}</Text>}
466          <Box flexGrow={1} />
467          {loaded && <Text dimColor>{elapsed(loaded.startedAt, loaded.finishedAt, clock)}</Text>}
468          {keysIndicator}
469        </Box>
470      )
471
472      if (loaded && zoomedTo && zoomed !== null) {
473        const span = stageSpan(zoomedTo)
474        const nextStage = loaded.stages[zoomed + 1]
475        return (
476          <Box flexDirection="column">
477            {header}
478            <Box flexDirection="row" gap={1} marginTop={1}>
479              <Button key="zoom-back" plain hotkey="h" label="← stages" onPress={() => zoomStage($, null)} />
480              <Text color={STATUS_COLOR[zoomedTo.status]}>
481                {STATUS_ICON[zoomedTo.status]} {zoomedTo.name}
482              </Text>
483              <Text dimColor>
484                {zoomedTo.status} · {elapsed(span.startedAt, span.finishedAt, clock)} · {zoomedTo.jobs.length} in parallel
485              </Text>
486            </Box>
487            {zoomedTo.jobs.map(job => (
488              <Box
489                key={`job-${job.id}`}
490                flexDirection="column"
491                width={boxWidth}
492                borderStyle="round"
493                borderColor={STATUS_COLOR[job.status]}
494                paddingX={1}
495              >
496                <Box flexDirection="row">
497                  <Text color={STATUS_COLOR[job.status]}>{STATUS_ICON[job.status]} </Text>
498                  <Text bold>{job.name}</Text>
499                  {job.isAllowedToFail && <Text dimColor> (may fail)</Text>}
500                  <Box flexGrow={1} />
501                  <Text dimColor>{elapsed(job.startedAt, job.finishedAt, clock)}</Text>
502                </Box>
503                {job.command && (
504                  <Text color={job.status === 'running' ? 'yellow' : undefined} dimColor={job.status !== 'running'} wrap="truncate-end">
505                    $ {job.command}
506                  </Text>
507                )}
508                {job.output && (
509                  <Text dimColor wrap="truncate-end">
510                    {job.output}
511                  </Text>
512                )}
513                {!job.command && <Text dimColor>{job.status}</Text>}
514              </Box>
515            ))}
516            {nextStage && arrow('zoom-arrow', 'next')}
517            {nextStage && (
518              <Box flexDirection="row" width={boxWidth} borderStyle="round" borderDimColor paddingX={1}>
519                <Text color={STATUS_COLOR[nextStage.status]}>{STATUS_ICON[nextStage.status]} </Text>
520                <Button key="zoom-next" plain hotkey="l" label={nextStage.name} onPress={() => zoomStage($, zoomed + 1)} />
521                <Box flexGrow={1} />
522                <Text dimColor>{nextStage.status}</Text>
523              </Box>
524            )}
525            <Box gap={1} flexWrap="wrap" marginTop={1}>
526              <Button key="pipeline-refresh" plain dimColor hotkey="r" label="refresh" onPress={() => loadPipeline($)} />
527              <Button key="pipeline-open" plain dimColor hotkey="o" label="open in browser" onPress={() => void $.process.run(['open', loaded.url])} />
528              <Text dimColor>↑↓ scroll</Text>
529            </Box>
530          </Box>
531        )
532      }
533
534      return (
535        <Box flexDirection="column">
536          {header}
537          {pipelineMessage && <Text color="red">{pipelineMessage}</Text>}
538          {!loaded && !pipelineMessage && <Text dimColor>Loading the pipeline…</Text>}
539          {loaded && (
540            <Box flexDirection="column" marginTop={1}>
541              {loaded.stages.flatMap((stage, index) => [
542                ...(index > 0 ? [arrow(`arrow-${index}`)] : []),
543                stageBox(stage, index, index === selected),
544              ])}
545            </Box>
546          )}
547          <Box gap={1} flexWrap="wrap" marginTop={1}>
548            <Button key="stage-down" plain dimColor hotkey="j" label="next stage" onPress={() => moveStage($, 1)} />
549            <Button key="stage-up" plain dimColor hotkey="k" label="prev stage" onPress={() => moveStage($, -1)} />
550            <Button key="stage-zoom" plain dimColor hotkey="l" label="zoom in" onPress={() => zoomStage($, selected)} />
551            <Button key="pipeline-refresh" plain dimColor hotkey="r" label="refresh" onPress={() => loadPipeline($)} />
552            {loaded && <Button key="pipeline-open" plain dimColor hotkey="o" label="open in browser" onPress={() => void $.process.run(['open', loaded.url])} />}
553          </Box>
554        </Box>
555      )
556    }
557
558    if (activeTab === 'forge') {
559      const forgeMessage = await read($, forgeNotice)
560      const stateColor = view?.state.includes('open') ? 'green' : view?.state.includes('merged') ? 'magenta' : 'red'
561      return (
562        <Box flexDirection="column">
563          <Box flexDirection="row">
564            {tabBar}
565            <Box flexGrow={1} />
566            {view && <Text dimColor>{view.number} </Text>}
567            {keysIndicator}
568          </Box>
569          {forgeMessage && <Text color={forgeMessage === 'Loading…' ? undefined : 'red'} dimColor={forgeMessage === 'Loading…'}>{forgeMessage}</Text>}
570          {view && (
571            <Box flexDirection="column">
572              <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
573                <Text bold>{view.title}</Text>
574                <Box flexDirection="row" gap={1}>
575                  <Text color={stateColor} bold>{view.state}</Text>
576                  <Text dimColor>
577                    @{view.author} · {view.target} ← {view.source}
578                  </Text>
579                </Box>
580                <Box flexDirection="row" gap={2}>
581                  {view.checks && <Text color={checksColor(view.checks)}>● checks {view.checks}</Text>}
582                  {view.mergeStatus && <Text dimColor>merge: {view.mergeStatus.replaceAll('_', ' ')}</Text>}
583                </Box>
584                <Link href={view.url} label={view.url} />
585              </Box>
586              <Markdown text={view.description.trim() || '_No description._'} />
587              <Box marginTop={1}>
588                <Text bold>
589                  Comments <Text dimColor>({view.notes.length})</Text>
590                </Text>
591              </Box>
592              {view.notes.map(one => (
593                <Box flexDirection="column" marginTop={1} borderStyle="round" borderDimColor paddingX={1}>
594                  <Box flexDirection="row" gap={1}>
595                    <Text bold>@{one.author}</Text>
596                    {one.path && (
597                      <Text color="cyan" wrap="truncate-start">
598                        {one.path}
599                        {one.line ? `:${one.line}` : ''}
600                      </Text>
601                    )}
602                    {one.isResolved !== undefined && (
603                      <Text color={one.isResolved ? 'green' : 'yellow'}>{one.isResolved ? 'resolved' : 'unresolved'}</Text>
604                    )}
605                  </Box>
606                  <Markdown text={one.body} />
607                </Box>
608              ))}
609            </Box>
610          )}
611          <Box gap={1} flexWrap="wrap" marginTop={1}>
612            <Button key="forge-refresh" plain dimColor hotkey="r" label="refresh" onPress={() => loadForge($)} />
613            {view && <Button key="forge-open" plain dimColor hotkey="o" label="open in browser" onPress={() => void $.process.run(['open', view.url])} />}
614            <Text dimColor>↑↓ scroll</Text>
615          </Box>
616        </Box>
617      )
618    }
619
620    const list = await read($, files)
621    const current = await read($, scope)
622    const fileAt = await read($, fileIndex)
623    const hunkAt = await read($, hunkIndex)
624    const pending = await read($, comments)
625    const composing = await read($, isComposing)
626    const sidebarHidden = await read($, isSidebarHidden)
627    const region = sidebarHidden ? 'diff' : await read($, focusRegion)
628    const regionBorder = (name: FocusRegion) => (hasKeys && region === name ? 'cyan' : undefined)
629    const label = await read($, baseLabel)
630    const message = await read($, notice)
631
632    const file = list[fileAt]
633    const added = list.reduce((sum, one) => sum + one.added, 0)
634    const removed = list.reduce((sum, one) => sum + one.removed, 0)
635    const sidebarWidth = sidebarHidden ? 0 : Math.min(40, Math.max(24, Math.floor(bodyColumns * 0.28)))
636    const mainRows = Math.max(6, bodyRows - (composing ? 5 : 3) - (message ? 1 : 0))
637    // Inside a round border: two rows of frame, one for the title row.
638    const diffRows = mainRows - 3
639    const sidebarRows = mainRows - 3
640
641    const tree = fileTree(list)
642    const selectedRow = Math.max(0, tree.findIndex(row => row.kind === 'file' && row.index === fileAt))
643    const firstRow = Math.max(0, Math.min(selectedRow - Math.floor(sidebarRows / 2), tree.length - sidebarRows))
644
645    const shownRows = file ? hunksInView(file.hunks, hunkAt, diffRows).flatMap(hunkRows) : []
646    const lastLine = shownRows.reduce((max, row) => (row.kind === 'header' ? max : Math.max(max, row.oldLine ?? 0, row.newLine ?? 0)), 0)
647    const gutterWidth = Math.max(3, String(lastLine).length)
648
649    return (
650      <Box flexDirection="column">
651        <Box flexDirection="row" gap={1}>
652          {tabBar}
653          <Text color="yellow" bold>
654            [{SCOPE_LABELS[current]}]
655          </Text>
656          <Text dimColor>{label}</Text>
657          <Box flexGrow={1} />
658          {pending.length > 0 && <Text color="magenta">{pending.length} comments</Text>}
659          <Text dimColor>{list.length} changed</Text>
660          <Text color="green">+{added}</Text>
661          <Text color="red">-{removed}</Text>
662          {keysIndicator}
663        </Box>
664        {message && <Text color="red" wrap="truncate-end">{message}</Text>}
665
666        <Box flexDirection="row" height={mainRows} overflow="hidden">
667          <Box flexDirection="column" flexGrow={1} flexShrink={1} borderStyle="round" borderColor={regionBorder('diff')} borderDimColor={!regionBorder('diff')} overflow="hidden">
668            {file ? (
669              <Text dimColor wrap="truncate-start">
670                {file.path} · hunk {Math.min(hunkAt + 1, file.hunks.length)}/{file.hunks.length}
671              </Text>
672            ) : (
673              <Text dimColor>{message ? '' : 'No changes.'}</Text>
674            )}
675            {file && file.status === 'binary' && <Text dimColor>Binary file, no diff.</Text>}
676            {shownRows.map(row => {
677              if (row.kind === 'header') {
678                return (
679                  <Text color="cyan" dimColor wrap="truncate-end">
680                    {row.text}
681                  </Text>
682                )
683              }
684              const isChange = row.kind !== 'context'
685              const accent = row.kind === 'context' ? undefined : ROW_ACCENT[row.kind]
686              const background = row.kind === 'context' ? undefined : ROW_BACKGROUND[row.kind]
687              const lineNumber = row.kind === 'remove' ? row.oldLine : row.newLine
688              return (
689                <Box flexDirection="row" backgroundColor={background}>
690                  <Text color={accent}>{isChange ? '▎' : ' '}</Text>
691                  <Text color={accent} dimColor={!isChange}>
692                    {String(lineNumber ?? '').padStart(gutterWidth)}{' '}
693                  </Text>
694                  <Code source={row.text.length > 0 ? row.text : ' '} path={file?.path} wrap="truncate-end" />
695                </Box>
696              )
697            })}
698          </Box>
699
700          {!sidebarHidden && (
701            <Box flexDirection="column" width={sidebarWidth} flexShrink={0} borderStyle="round" borderColor={regionBorder('files')} borderDimColor={!regionBorder('files')} overflow="hidden">
702              <Text bold color={regionBorder('files')}>Files</Text>
703              {tree.slice(firstRow, firstRow + sidebarRows).map(row => {
704                if (row.kind === 'dir') {
705                  return (
706                    <Text dimColor wrap="truncate-start">
707                      ▾ {row.path}
708                    </Text>
709                  )
710                }
711                const one = list[row.index]
712                if (!one) return null
713                const isSelected = row.index === fileAt
714                const counts = `${one.added > 0 ? ` +${one.added}` : ''}${one.removed > 0 ? ` -${one.removed}` : ''}`
715                const nameWidth = sidebarWidth - 6 - counts.length - (row.isNested ? 2 : 0)
716                return (
717                  <Box flexDirection="row" backgroundColor={isSelected ? '#3b3f52' : undefined}>
718                    <Text color="yellow">{row.isNested ? '  ' : ''}{STATUS_LETTER[one.status]} </Text>
719                    <Box flexGrow={1}>
720                      <Button key={`file-${row.index}`} plain label={shortPath(row.name, nameWidth)} onPress={() => selectFile($, row.index)} />
721                    </Box>
722                    <Text color="green">{one.added > 0 ? ` +${one.added}` : ''}</Text>
723                    <Text color="red">{one.removed > 0 ? ` -${one.removed}` : ''}</Text>
724                  </Box>
725                )
726              })}
727            </Box>
728          )}
729        </Box>
730
731        {composing && (
732          <Input
733            key="comment"
734            label={`Comment on ${file?.path ?? ''} hunk ${hunkAt + 1}`}
735            placeholder="Empty to cancel"
736            autoFocus
737            onSubmit={text => addComment($, text)}
738          />
739        )}
740
741        <Box gap={1} flexWrap="wrap">
742          <Button key="u" plain dimColor hotkey="u" label="uncommitted" onPress={() => selectScope($, 'uncommitted')} />
743          <Button key="b" plain dimColor hotkey="b" label="branch" onPress={() => selectScope($, 'branch')} />
744          <Button key="t" plain dimColor hotkey="t" label="turn" onPress={() => selectScope($, 'turn')} />
745          <Button key="g" plain dimColor hotkey="g" label="commit" onPress={() => selectScope($, 'commit')} />
746          <Button key="f" plain dimColor hotkey="f" label={region === 'diff' ? 'focus files' : 'focus diff'} onPress={() => toggleRegion($)} />
747          {region === 'files' ? (
748            <Button key="j" plain dimColor hotkey="j" label="file↓" onPress={() => moveFile($, 1)} />
749          ) : (
750            <Button key="j" plain dimColor hotkey="j" label="hunk↓" onPress={() => moveHunk($, 1)} />
751          )}
752          {region === 'files' ? (
753            <Button key="k" plain dimColor hotkey="k" label="file↑" onPress={() => moveFile($, -1)} />
754          ) : (
755            <Button key="k" plain dimColor hotkey="k" label="hunk↑" onPress={() => moveHunk($, -1)} />
756          )}
757          {region === 'files' && <Button key="l" plain dimColor hotkey="l" label="open" onPress={() => update($, focusRegion, () => 'diff')} />}
758          <Button key="n" plain dimColor hotkey="n" label="hunk↓" onPress={() => moveHunk($, 1)} />
759          <Button key="p" plain dimColor hotkey="p" label="hunk↑" onPress={() => moveHunk($, -1)} />
760          <Button key="c" plain dimColor hotkey="c" label="comment" onPress={() => update($, isComposing, () => true)} />
761          <Button key="s" plain dimColor hotkey="s" label="send" onPress={() => sendComments($)} />
762          <Button key="x" plain dimColor hotkey="x" label="clear" onPress={() => update($, comments, () => [])} />
763          <Button key="z" plain dimColor hotkey="z" label="hide files" onPress={async () => {
764              await update($, isSidebarHidden, hidden => !hidden)
765              await update($, focusRegion, () => 'diff')
766            }} />
767          <Button key="r" plain dimColor hotkey="r" label="refresh" onPress={() => refresh($)} />
768        </Box>
769      </Box>
770    )
771  })
772}
773
hooks/pipeline.ts 178 lines
1import type { Pipeline, PipelineJob, PipelineStage, PipelineStatus } from '../types'
2
3type Json = Record<string, any>
4
5const TRACE_PREFIX = /^\S+Z [0-9a-f]+[OE]\+?\s?/
6const ANSI = /\x1b\[[0-9;?]*[a-zA-Z]/g
7
8export function normalizeStatus(raw: string | null | undefined): PipelineStatus {
9  switch ((raw ?? '').toLowerCase()) {
10    case 'success':
11    case 'passed':
12    case 'neutral':
13      return 'success'
14    case 'failed':
15    case 'failure':
16    case 'timed_out':
17    case 'startup_failure':
18      return 'failed'
19    case 'running':
20    case 'in_progress':
21      return 'running'
22    case 'canceled':
23    case 'cancelled':
24      return 'canceled'
25    case 'skipped':
26      return 'skipped'
27    case 'manual':
28    case 'action_required':
29      return 'manual'
30    default:
31      return 'pending'
32  }
33}
34
35// A stage is as far along as its least-finished job; a failure that may fail
36// does not fail the stage.
37export function stageStatus(jobs: PipelineJob[]): PipelineStatus {
38  const statuses = jobs.map(job => (job.status === 'failed' && job.isAllowedToFail ? 'success' : job.status))
39  if (statuses.includes('running')) return 'running'
40  if (statuses.includes('failed')) return 'failed'
41  if (statuses.includes('canceled')) return 'canceled'
42  if (statuses.includes('pending')) return statuses.some(status => status === 'success') ? 'running' : 'pending'
43  if (statuses.includes('manual')) return 'manual'
44  if (statuses.every(status => status === 'skipped')) return 'skipped'
45  return 'success'
46}
47
48// Reads `glab ci get --output json`: stages ordered by their first job's id,
49// which GitLab assigns in stage order.
50export function parseGitlabPipeline(raw: string): Pipeline {
51  const pipeline: Json = JSON.parse(raw)
52  const jobs: Json[] = [...(pipeline.jobs ?? [])].sort((left, right) => left.id - right.id)
53
54  const stages: PipelineStage[] = []
55  for (const job of jobs) {
56    let stage = stages.find(one => one.name === job.stage)
57    if (!stage) {
58      stage = { name: job.stage, status: 'pending', jobs: [] }
59      stages.push(stage)
60    }
61    stage.jobs.push({
62      id: String(job.id),
63      name: job.name,
64      status: normalizeStatus(job.status),
65      startedAt: job.started_at ?? undefined,
66      finishedAt: job.finished_at ?? undefined,
67      isAllowedToFail: Boolean(job.allow_failure),
68    })
69  }
70  for (const stage of stages) stage.status = stageStatus(stage.jobs)
71
72  return {
73    id: String(pipeline.id),
74    name: `Pipeline #${pipeline.id}`,
75    status: normalizeStatus(pipeline.status),
76    url: pipeline.web_url ?? '',
77    ref: String(pipeline.ref ?? '').replace(/^refs\/merge-requests\/(\d+)\/merge$/, '!$1 merge'),
78    startedAt: pipeline.started_at ?? pipeline.created_at ?? undefined,
79    finishedAt: pipeline.finished_at ?? undefined,
80    stages,
81  }
82}
83
84// Reads `gh run view --json`: GitHub has no stages, so each job is a stage
85// and its steps are the jobs inside it, the running step its command.
86export function parseGithubRun(raw: string): Pipeline {
87  const run: Json = JSON.parse(raw)
88  const stages: PipelineStage[] = (run.jobs ?? []).map((job: Json) => {
89    const steps: PipelineJob[] = (job.steps ?? []).map((step: Json) => ({
90      id: `${job.databaseId}-${step.number}`,
91      name: step.name,
92      status: normalizeStatus(step.status === 'completed' ? step.conclusion : step.status),
93      startedAt: step.startedAt ?? undefined,
94      finishedAt: step.completedAt ?? undefined,
95      isAllowedToFail: false,
96    }))
97    const current = steps.find(step => step.status === 'running')
98    return {
99      name: job.name,
100      status: normalizeStatus(job.status === 'completed' ? job.conclusion : job.status),
101      jobs: steps,
102      current: current?.name,
103      startedAt: job.startedAt ?? undefined,
104      finishedAt: job.completedAt ?? undefined,
105    }
106  })
107
108  return {
109    id: String(run.databaseId),
110    name: run.workflowName ?? `Run #${run.databaseId}`,
111    status: normalizeStatus(run.status === 'completed' ? run.conclusion : run.status),
112    url: run.url ?? '',
113    ref: run.headBranch ?? '',
114    startedAt: run.createdAt ?? undefined,
115    finishedAt: run.status === 'completed' ? run.updatedAt : undefined,
116    stages,
117  }
118}
119
120// The command a GitLab job is running: its trace's last `$ command` line, and
121// the last line of output after it.
122export function currentCommand(trace: string): { command?: string; output?: string } {
123  const lines = trace
124    .split('\n')
125    .map(line => line.replace(ANSI, '').replace(TRACE_PREFIX, '').trimEnd())
126    .filter(line => line.length > 0 && !line.startsWith('section_'))
127
128  let commandAt = -1
129  for (let index = lines.length - 1; index >= 0; index--) {
130    if (lines[index]?.startsWith('$ ')) {
131      commandAt = index
132      break
133    }
134  }
135  if (commandAt === -1) return {}
136  const output = lines.slice(commandAt + 1).at(-1)
137  return { command: lines[commandAt]?.slice(2), output }
138}
139
140// Earliest start and latest finish across a stage's jobs.
141export function stageSpan(stage: PipelineStage): { startedAt?: string; finishedAt?: string } {
142  const starts = stage.jobs.map(job => job.startedAt).filter((value): value is string => Boolean(value)).sort()
143  const finishes = stage.jobs.map(job => job.finishedAt).filter((value): value is string => Boolean(value)).sort()
144  const isDone = stage.jobs.every(job => job.finishedAt || job.status === 'skipped' || job.status === 'manual')
145  return {
146    startedAt: stage.startedAt ?? starts[0],
147    finishedAt: stage.finishedAt ?? (isDone ? finishes.at(-1) : undefined),
148  }
149}
150
151export function elapsed(startedAt: string | undefined, finishedAt: string | undefined, now: number): string {
152  if (!startedAt) return '—'
153  const end = finishedAt ? Date.parse(finishedAt) : now
154  const seconds = Math.max(0, Math.round((end - Date.parse(startedAt)) / 1000))
155  const minutes = Math.floor(seconds / 60)
156  return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`
157}
158
159export const STATUS_ICON: Record<PipelineStatus, string> = {
160  success: '✔',
161  failed: '✘',
162  running: '◐',
163  pending: '○',
164  canceled: '⊘',
165  skipped: '»',
166  manual: '▶',
167}
168
169export const STATUS_COLOR: Record<PipelineStatus, string> = {
170  success: 'green',
171  failed: 'red',
172  running: 'yellow',
173  pending: 'gray',
174  canceled: 'gray',
175  skipped: 'gray',
176  manual: 'cyan',
177}
178
hooks/forge.ts 84 lines
1import type { ForgeNote, ForgeView } from '../types'
2
3type Json = Record<string, any>
4
5// Reads `glab mr view --comments --output json`.
6export function parseGitlab(raw: string): ForgeView {
7  const mr: Json = JSON.parse(raw)
8  const pipeline: Json | null = mr.head_pipeline ?? mr.pipeline ?? null
9  const notes: ForgeNote[] = (mr.Discussions ?? [])
10    .flatMap((discussion: Json) => discussion.notes ?? [])
11    .filter((note: Json) => !note.system)
12    .map((note: Json) => ({
13      author: note.author?.username ?? 'unknown',
14      body: note.body ?? '',
15      createdAt: note.created_at ?? '',
16      path: note.position?.new_path ?? note.position?.old_path ?? undefined,
17      line: note.position?.new_line ?? note.position?.old_line ?? undefined,
18      isResolved: note.resolvable ? Boolean(note.resolved) : undefined,
19    }))
20
21  return {
22    kind: 'MR',
23    number: `!${mr.iid}`,
24    title: mr.title ?? '',
25    state: mr.draft ? `draft · ${mr.state}` : (mr.state ?? ''),
26    url: mr.web_url ?? '',
27    author: mr.author?.username ?? '',
28    source: mr.source_branch ?? '',
29    target: mr.target_branch ?? '',
30    checks: pipeline?.status,
31    mergeStatus: mr.detailed_merge_status ?? undefined,
32    description: mr.description ?? '',
33    notes,
34  }
35}
36
37// Reads `gh pr view --json` with the fields in GITHUB_FIELDS.
38export const GITHUB_FIELDS = 'number,title,state,isDraft,url,author,headRefName,baseRefName,body,comments,reviews,statusCheckRollup,mergeStateStatus'
39
40export function parseGithub(raw: string): ForgeView {
41  const pr: Json = JSON.parse(raw)
42  const checks: Json[] = pr.statusCheckRollup ?? []
43  const conclusions = checks.map(check => (check.conclusion || check.state || check.status || '').toLowerCase())
44  const checkStatus =
45    checks.length === 0
46      ? undefined
47      : conclusions.some(status => status === 'failure' || status === 'error')
48        ? 'failed'
49        : conclusions.every(status => status === 'success' || status === 'neutral' || status === 'skipped')
50          ? 'success'
51          : 'running'
52
53  const comments: ForgeNote[] = [...(pr.comments ?? []), ...(pr.reviews ?? [])]
54    .filter((note: Json) => (note.body ?? '').trim().length > 0)
55    .map((note: Json) => ({
56      author: note.author?.login ?? 'unknown',
57      body: note.body,
58      createdAt: note.createdAt ?? note.submittedAt ?? '',
59    }))
60    .sort((left, right) => left.createdAt.localeCompare(right.createdAt))
61
62  return {
63    kind: 'PR',
64    number: `#${pr.number}`,
65    title: pr.title ?? '',
66    state: pr.isDraft ? `draft · ${String(pr.state).toLowerCase()}` : String(pr.state ?? '').toLowerCase(),
67    url: pr.url ?? '',
68    author: pr.author?.login ?? '',
69    source: pr.headRefName ?? '',
70    target: pr.baseRefName ?? '',
71    checks: checkStatus,
72    mergeStatus: pr.mergeStateStatus ? String(pr.mergeStateStatus).toLowerCase() : undefined,
73    description: pr.body ?? '',
74    notes: comments,
75  }
76}
77
78export function checksColor(status: string | undefined): string | undefined {
79  if (!status) return undefined
80  if (status === 'success') return 'green'
81  if (status === 'failed' || status === 'canceled') return 'red'
82  return 'yellow'
83}
84
hooks/diff.ts 162 lines
1import type { DiffFile, ReviewComment } from '../types'
2
3const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
4
5export function parseDiff(output: string): DiffFile[] {
6  return output
7    .split(/^diff --git /m)
8    .filter(chunk => chunk.trim().length > 0)
9    .map(parseFile)
10}
11
12function parseFile(chunk: string): DiffFile {
13  const lines = chunk.split('\n')
14  const oldPath = lines.find(line => line.startsWith('--- '))?.slice(4)
15  const newPath = lines.find(line => line.startsWith('+++ '))?.slice(4)
16  const renamedTo = lines.find(line => line.startsWith('rename to '))?.slice(10)
17  const firstLine = lines[0] ?? ''
18  const headerPath = firstLine.split(' b/').pop() ?? firstLine
19
20  const path =
21    newPath && newPath !== '/dev/null'
22      ? newPath.replace(/^b\//, '')
23      : oldPath && oldPath !== '/dev/null'
24        ? oldPath.replace(/^a\//, '')
25        : (renamedTo ?? headerPath)
26
27  const hunkLines: string[][] = []
28  for (const line of lines) {
29    if (line.startsWith('@@')) {
30      hunkLines.push([line])
31    } else if (hunkLines.length > 0 && line !== '\\ No newline at end of file') {
32      hunkLines.at(-1)?.push(line)
33    }
34  }
35  const trimmed = hunkLines.map(hunk => hunk.join('\n').replace(/\n+$/, ''))
36
37  const body = trimmed.flatMap(hunk => hunk.split('\n').slice(1))
38  const added = body.filter(line => line.startsWith('+')).length
39  const removed = body.filter(line => line.startsWith('-')).length
40
41  const status: DiffFile['status'] = chunk.includes('\nBinary files ')
42    ? 'binary'
43    : oldPath === '/dev/null'
44      ? 'added'
45      : newPath === '/dev/null'
46        ? 'deleted'
47        : renamedTo
48          ? 'renamed'
49          : 'modified'
50
51  return { path, status, added, removed, hunks: trimmed }
52}
53
54// Cuts a hunk to at most `maxLines` lines (header included) and rewrites the
55// header counts, so the result still parses as a unified-diff hunk.
56export function fitHunk(hunk: string, maxLines: number): string {
57  const lines = hunk.split('\n')
58  if (lines.length <= maxLines) return hunk
59
60  const match = HUNK_HEADER.exec(lines[0] ?? '')
61  if (!match) return lines.slice(0, maxLines).join('\n')
62
63  const body = lines.slice(1, Math.max(2, maxLines))
64  const oldCount = body.filter(line => !line.startsWith('+')).length
65  const newCount = body.filter(line => !line.startsWith('-')).length
66  const header = `@@ -${match[1]},${oldCount} +${match[3]},${newCount} @@${match[5]}`
67
68  return [header, ...body].join('\n')
69}
70
71// The hunks from `start` onward that fit in `budget` lines; the first one is
72// always shown, cut to fit when it alone is taller than the budget.
73export function hunksInView(hunks: string[], start: number, budget: number): string[] {
74  const shown: string[] = []
75  let used = 0
76  for (const hunk of hunks.slice(start)) {
77    const height = hunk.split('\n').length
78    if (shown.length === 0) {
79      shown.push(fitHunk(hunk, budget))
80      used = Math.min(height, budget)
81    } else if (used + height <= budget) {
82      shown.push(hunk)
83      used += height
84    } else {
85      break
86    }
87  }
88  return shown
89}
90
91export type HunkRow =
92  | { kind: 'header'; text: string }
93  | { kind: 'context' | 'add' | 'remove'; text: string; oldLine?: number; newLine?: number }
94
95// One row per hunk line, numbered on the old and new side from the header.
96export function hunkRows(hunk: string): HunkRow[] {
97  const [header = '', ...body] = hunk.split('\n')
98  const match = HUNK_HEADER.exec(header)
99  let oldLine = Number(match?.[1] ?? 0)
100  let newLine = Number(match?.[3] ?? 0)
101
102  const rows: HunkRow[] = [{ kind: 'header', text: header }]
103  for (const line of body) {
104    const text = line.slice(1)
105    if (line.startsWith('+')) {
106      rows.push({ kind: 'add', text, newLine: newLine++ })
107    } else if (line.startsWith('-')) {
108      rows.push({ kind: 'remove', text, oldLine: oldLine++ })
109    } else {
110      rows.push({ kind: 'context', text, oldLine: oldLine++, newLine: newLine++ })
111    }
112  }
113  return rows
114}
115
116// Orders files by folder, then name, so each folder's files sit together
117// under one sidebar row (git's path order puts `a/z.md` after `a/b/c.md`).
118export function sortByFolder(files: DiffFile[]): DiffFile[] {
119  const folderOf = (path: string) => path.slice(0, path.lastIndexOf('/') + 1)
120  return [...files].sort(
121    (left, right) => folderOf(left.path).localeCompare(folderOf(right.path)) || left.path.localeCompare(right.path),
122  )
123}
124
125export type TreeRow = { kind: 'dir'; path: string } | { kind: 'file'; index: number; name: string; isNested: boolean }
126
127// The sidebar's rows: files grouped under a row per folder, in diff order
128// (git sorts by path, so each folder's files arrive together).
129export function fileTree(files: DiffFile[]): TreeRow[] {
130  const rows: TreeRow[] = []
131  let currentDir = ''
132  files.forEach((file, index) => {
133    const slash = file.path.lastIndexOf('/')
134    const dir = slash === -1 ? '' : file.path.slice(0, slash + 1)
135    if (dir !== currentDir && dir !== '') rows.push({ kind: 'dir', path: dir })
136    currentDir = dir
137    rows.push({ kind: 'file', index, name: file.path.slice(slash + 1), isNested: dir !== '' })
138  })
139  return rows
140}
141
142export const STATUS_LETTER: Record<DiffFile['status'], string> = {
143  added: 'A',
144  deleted: 'D',
145  modified: 'M',
146  renamed: 'R',
147  binary: 'B',
148}
149
150export function formatComments(comments: ReviewComment[]): string {
151  const blocks = comments.map(comment =>
152    [
153      `${comment.path} ${comment.hunkHeader}`,
154      '```diff',
155      comment.snippet,
156      '```',
157      comment.text,
158    ].join('\n'),
159  )
160  return `Review comments on the diff:\n\n${blocks.join('\n\n')}\n`
161}
162
types/index.d.ts 105 lines
1export type Scope = 'uncommitted' | 'branch' | 'turn' | 'commit'
2
3export type DiffFile = {
4  path: string
5  status: 'added' | 'deleted' | 'modified' | 'renamed' | 'binary'
6  added: number
7  removed: number
8  hunks: string[]
9}
10
11export type ReviewComment = {
12  path: string
13  hunkHeader: string
14  snippet: string
15  text: string
16}
17
18export type Tab = 'changes' | 'forge' | 'pipeline'
19
20export type FocusRegion = 'diff' | 'files'
21
22export type PipelineStatus = 'success' | 'failed' | 'running' | 'pending' | 'canceled' | 'skipped' | 'manual'
23
24export type PipelineJob = {
25  id: string
26  name: string
27  status: PipelineStatus
28  startedAt?: string
29  finishedAt?: string
30  isAllowedToFail: boolean
31  command?: string
32  output?: string
33}
34
35export type PipelineStage = {
36  name: string
37  status: PipelineStatus
38  jobs: PipelineJob[]
39  current?: string
40  startedAt?: string
41  finishedAt?: string
42}
43
44export type Pipeline = {
45  id: string
46  name: string
47  status: PipelineStatus
48  url: string
49  ref: string
50  startedAt?: string
51  finishedAt?: string
52  stages: PipelineStage[]
53}
54
55export type ForgeNote = {
56  author: string
57  body: string
58  createdAt: string
59  path?: string
60  line?: number
61  isResolved?: boolean
62}
63
64export type ForgeView = {
65  kind: 'MR' | 'PR'
66  number: string
67  title: string
68  state: string
69  url: string
70  author: string
71  source: string
72  target: string
73  checks?: string
74  mergeStatus?: string
75  description: string
76  notes: ForgeNote[]
77}
78
79declare module 'claude-code' {
80  interface PluginState {
81    'diff-review': {
82      scope: Scope
83      files: DiffFile[]
84      fileIndex: number
85      hunkIndex: number
86      comments: ReviewComment[]
87      isComposing: boolean
88      isSidebarHidden: boolean
89      focusRegion: FocusRegion
90      turnTree: string | null
91      baseLabel: string
92      notice: string
93      tab: Tab
94      forge: ForgeView | null
95      forgeNotice: string
96      pipeline: Pipeline | null
97      pipelineNotice: string
98      stageIndex: number
99      zoomedStage: number | null
100      now: number
101      pollGeneration: number
102    }
103  }
104}
105