SLOPSHOPPER

diff-explorer

A VS Code-style side panel for the files Claude changes: a file tree with Material-style icons and a GitHub-dark diff preview. /diffs opens it.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-explorer
│ ┃ Changes ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ▣ client module ./controls.tsx │ diff-explorer │ │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ diff-explorer: /diffs shows the files │ │ ┃ ▣ client module ./controls.tsx ⎿ Read 6 lines │ Claude changed │ │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ 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 │ ┃ │ ┃ › /diffs │ ┃ ⎿ diff-explorer: 3 changed files. n/p walk them, v swaps diff and │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
▣ client module ./controls.tsx ──────────────────────────────────────────────────────── ▣ client module ./controls.tsx ──────────────────────────────────────────────────────── ▣ cli ▣ client module ./controls.tsx
README

🗂️ diff-explorer

한국어 · English

Claude가 이번 세션에서 바꾼 파일을 채팅 옆 패널에 VS Code처럼 보여줍니다. 왼쪽은 바뀐 파일만 모은 탐색 트리, 오른쪽은 고른 파일의 diff를 GitHub 다크 톤으로. 터미널을 떠나지 않고 "방금 뭐가 바뀌었지?"에 답할 수 있어요.

<img src="../../images/diff-explorer.png" alt="diff-explorer" width="100%">

기능

  • 바뀐 파일 트리: Material Icon Theme 스타일 아이콘과 폴더 색, VS Code의 git 상태 색(M 수정 · A 새 파일 · D 삭제). 하나뿐인 하위 폴더는 src/hooks처럼 한 줄로 합칩니다.
  • GitHub 다크 diff: 옛/새 줄 번호, 추가·삭제 배경, 짝지어진 줄에서 실제로 바뀐 글자만 진하게. 3줄 문맥과 ⋯ N unchanged lines.
  • 누적 diff: 기준점은 Claude가 그 파일을 처음 건드리기 직전 내용이라, 여러 번 고쳐도 한 번에 보입니다. 원래대로 되돌린 파일은 흐리게 남아요.
  • 새 파일 / 삭제: NEW, DELETED 배지와 취소선.
  • 파일 보기: v로 diff와 구문 강조된 파일 전체 보기를 전환.
  • 스크롤: 트리와 미리보기가 각자 스크롤되고, 줄바꿈(w)을 끄면 가로 스크롤바와 ◀ ▶로 옆으로 이동.
  • 메인·서브에이전트 모두: Write Edit MultiEdit NotebookEdit로 바뀐 파일을 잡습니다.

설치

/plugin install diff-explorer --marketplace speardragon/ray-claude-code-mods

사용법

입력클릭할 곳동작
/diffs패널 켜기 / 끄기 (토글)
/diffs on · /diffs off패널 켜기 · 끄기
/diffs clear추적 기록 비우기 (지금 내용이 새 기준점)
t툴바 Explorer탐색기 접기 / 펴기
c탐색기 제목 줄 오른쪽폴더 모두 접기 / 모두 펴기
[ / ]탐색기와 미리보기 사이 경계선탐색기 폭 좁히기 / 넓히기. 경계선은 드래그로 조절하고, 더블클릭하면 기본 폭으로 돌아갑니다 (조절한 폭은 다음 세션에도 유지)
p / n파일 헤더 ‹ ›이전 / 다음 파일
v상태줄 Diff │ Filediff ↔ 파일 전체
w상태줄 Wrap긴 줄 줄바꿈 켜기 / 끄기
k / j상태줄 ▲ ▼한 화면 위 / 아래
h / l상태줄 ◀ ▶가로 스크롤 (줄바꿈을 껐을 때)
트리 줄파일 고르기, 폴더 접기 / 펴기
휠 · 스크롤바커서가 올라간 컬럼만 스크롤, 스크롤바는 드래그 · 클릭

단축키는 패널 안(툴바, 트리, 헤더, 상태줄, 스크롤바)을 한 번 클릭한 뒤 동작합니다. 패널이 72칸보다 좁으면 트리가 자동으로 숨고, 파일 헤더의 ‹ ›와 현재/전체로 이동해요.

설정

/config의 플러그인 항목에서 바꿉니다.

키기본값설명
iconsnerdnerd: Nerd Font 글리프 아이콘. plain: 색 점, 어떤 폰트에서나 보임
autoOpentrue세션의 첫 수정 때 패널을 자동으로 열기 (전체 화면 터미널에서 옆에 붙을 때만)

알아두면 좋은 것

  • Bash로 바꾼 파일(sed -i, rm 등)은 아직 추적하지 않아요. 단, 추적 중인 파일이 Bash로 지워지면 DELETED로 보입니다.
  • 1 MB가 넘는 파일은 diff 대신 안내만 보이고, v로 파일 내용은 볼 수 있어요.
  • 마우스 휠의 가로 이동은 Claude Code가 mod에 전달하지 않아서, 가로 스크롤은 스크롤바와 h l로 합니다.
Source 14 files
hooks/register.tsx 445 lines
1// diff-explorer: the files Claude changes this session, in a VS Code-style pane beside the chat.
2//   Left, an explorer tree of the changed files with Material-style icons and git-style status;
3//   right, the selected file's diff against how it was before Claude first touched it, in GitHub dark.
4//   /diffs opens it; n/p walk the files, v swaps diff and file view, w wraps, t shows or hides the tree;
5//   the wheel scrolls whichever column it is over, the arrow keys the preview.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { Scroll, TreeMode, Tracked, View } from '../types'
10import { unified } from './diff'
11import type { Diff } from './diff'
12import type { IconStyle } from './icons'
13import { buildTree, fileOrder, relative, treeRows } from './tree'
14import { contentWidth, diffRows, lineRoom } from './rows'
15import type { VRow } from './rows'
16import { HOTKEYS } from './chrome'
17import type { Action } from './chrome'
18import { dropCells, expandTabs, textWidth } from './text'
19import { layout, Pane, TREE_MIN, treeMax } from './view'
20import type { Entry, Handlers, Layout } from './view'
21
22const PANE = 'diff-explorer'
23const TITLE = 'Changes'
24const EDIT_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
25const BEFORE_MAX = 1_000_000 // longer earlier texts are not kept: such a file shows no diff
26const MAX_FILES = 200
27const FOLLOW_AFTER_PICK_MS = 60_000 // a file the person picked holds the preview this long against new edits
28const DOCK_COLUMNS = 96
29const READ_TTL_MS = 5_000 // a file read for drawing is read again after this, or once Claude edits it
30
31// Held by the host, so a hot reload keeps them.
32const files = atom({ plugin: 'diff-explorer', key: 'files' } as const, [] as Tracked[])
33const selected = atom({ plugin: 'diff-explorer', key: 'selected' } as const, null as string | null)
34const view = atom({ plugin: 'diff-explorer', key: 'view' } as const, 'diff' as View)
35const wrap = atom({ plugin: 'diff-explorer', key: 'wrap' } as const, true)
36const tree = atom({ plugin: 'diff-explorer', key: 'tree' } as const, 'auto' as TreeMode)
37const collapsed = atom({ plugin: 'diff-explorer', key: 'collapsed' } as const, [] as string[])
38const scroll = atom({ plugin: 'diff-explorer', key: 'scroll' } as const, { tree: 0, preview: 0, x: 0 } as Scroll)
39const cwd = atom({ plugin: 'diff-explorer', key: 'cwd' } as const, '')
40const treeWidth = atom({ plugin: 'diff-explorer', key: 'treeWidth' } as const, null as number | null)
41
42// The explorer width the person dragged to, kept across sessions.
43const WIDTH_KEY = 'treeWidth'
44
45// The module's own bookkeeping; a reload starts it over.
46const session = { home: '', command: 'diffs', isFullscreen: undefined as boolean | undefined, hasAutoOpened: false, pickedAt: 0 }
47// The last drawing's measures, for the scroll and the hotkeys to clamp against.
48const drawn = { layout: null as Layout | null, treeRows: 0, previewRows: 0, maxX: 0, wideView: 0, order: [] as string[], dirs: [] as string[] }
49// Each file as last read for drawing, so scrolling and pressing read nothing from disk.
50const reads = new Map<string, { text: string | null; readAt: number }>()
51// Diffs by file, reused while neither side changed.
52const diffs = new Map<string, { before: string; after: string; diff: Diff }>()
53const vrows = new Map<string, { diff: Diff; width: number; isWrapped: boolean; rows: VRow[] }>()
54
55// Which window each scrollbar moves, by its Client's key.
56const BARS: Record<string, 'tree' | 'preview' | 'x'> = { tbar: 'tree', vbar: 'preview', hbar: 'x' }
57
58const base = (path: string) => path.slice(path.lastIndexOf('/') + 1)
59const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
60
61async function readText($: EngineInterface, path: string): Promise<string | null> {
62  const text = await $.fs.read(path).catch(() => null)
63  return typeof text === 'string' ? text : null
64}
65
66async function currentText($: EngineInterface, t: Tracked): Promise<string | null> {
67  const kept = reads.get(t.path)
68  const now = Date.now()
69  if (kept && kept.readAt >= t.at && now - kept.readAt < READ_TTL_MS) return kept.text
70  const text = await readText($, t.path)
71  reads.set(t.path, { text, readAt: now })
72  return text
73}
74
75function diffOf(path: string, before: string, after: string): Diff {
76  const kept = diffs.get(path)
77  if (kept && kept.before === before && kept.after === after) return kept.diff
78  const diff = unified(before, after)
79  diffs.set(path, { before, after, diff })
80  return diff
81}
82
83function rowsOf(path: string, diff: Diff, width: number, isWrapped: boolean): VRow[] {
84  const kept = vrows.get(path)
85  if (kept && kept.diff === diff && kept.width === width && kept.isWrapped === isWrapped) return kept.rows
86  const rows = diffRows(diff, width, isWrapped)
87  vrows.set(path, { diff, width, isWrapped, rows })
88  return rows
89}
90
91// What the tree and the header say of each file, read from disk as it is now.
92async function entriesOf($: EngineInterface, list: Tracked[], root: string) {
93  return Promise.all(
94    list.map(async t => {
95      const after = await currentText($, t)
96      const status = t.before === null ? 'A' : after === null ? 'D' : 'M'
97      const diff = t.isTooBig ? null : diffOf(t.path, t.before ?? '', after ?? '')
98      const entry: Entry = {
99        path: t.path,
100        rel: relative(t.path, root, session.home),
101        name: base(t.path),
102        status,
103        adds: diff?.adds ?? 0,
104        dels: diff?.dels ?? 0,
105        isSame: diff !== null && !diff.isBinary && diff.rows.length === 0 && status === 'M',
106        isTooBig: t.isTooBig,
107      }
108      return { entry, diff, after }
109    }),
110  )
111}
112
113async function openPane($: EngineInterface, focus: boolean) {
114  return $.ui.open({ id: PANE, title: TITLE, columns: DOCK_COLUMNS, ...(focus ? { focus: true as const } : {}) })
115}
116
117// A file Claude is about to change: keep how it was, the first time only.
118async function track($: EngineInterface, path: string, before: string | null) {
119  const isTooBig = before !== null && before.length > BEFORE_MAX
120  const now = Date.now()
121  await update($, files, list => {
122    const known = list.find(t => t.path === path)
123    if (known) return list.map(t => (t === known ? { ...t, at: now, edits: t.edits + 1 } : t))
124    const fresh: Tracked = { path, before: isTooBig ? null : before, isTooBig, at: now, edits: 1 }
125    return [...list, fresh].slice(-MAX_FILES)
126  })
127  if (now - session.pickedAt > FOLLOW_AFTER_PICK_MS) {
128    await update($, selected, () => path)
129    await update($, scroll, s => ({ ...s, preview: 0 }))
130  }
131}
132
133async function maybeAutoOpen($: EngineInterface, isOn: boolean) {
134  if (!isOn || session.hasAutoOpened) return
135  session.hasAutoOpened = true
136  const panes = await $.ui.panes().catch(() => [])
137  if (panes.some(p => p.id === PANE)) return
138  // Unasked, only where it would dock as a sidebar; elsewhere a toast says how to open it.
139  if (session.isFullscreen === true) await openPane($, false).catch(() => undefined)
140  else $.ui.toast(`diff-explorer: /${session.command} shows the files Claude changed`)
141}
142
143async function move($: EngineInterface, by: number) {
144  const order = drawn.order
145  if (order.length === 0) return
146  session.pickedAt = Date.now()
147  const now = await read($, selected)
148  const at = now ? order.indexOf(now) : -1
149  const next = order[clamp(at < 0 ? 0 : at + by, 0, order.length - 1)]!
150  await update($, selected, () => next)
151  await update($, scroll, s => ({ ...s, preview: 0, x: 0 }))
152}
153
154// The furthest each window can go, from the last drawing.
155function maxOf(region: 'tree' | 'preview' | 'x') {
156  const at = drawn.layout
157  if (!at) return 0
158  if (region === 'tree') return Math.max(0, drawn.treeRows - at.treeBody)
159  if (region === 'preview') return Math.max(0, drawn.previewRows - at.previewBody)
160  return drawn.maxX
161}
162
163async function scrollBy($: EngineInterface, region: 'tree' | 'preview' | 'x', by: number) {
164  const max = maxOf(region)
165  await update($, scroll, s => ({ ...s, [region]: clamp((s[region] ?? 0) + by, 0, max) }))
166}
167
168async function scrollTo($: EngineInterface, region: 'tree' | 'preview' | 'x', offset: number) {
169  const max = maxOf(region)
170  await update($, scroll, s => ({ ...s, [region]: clamp(offset, 0, max) }))
171}
172
173async function pickFile($: EngineInterface, path: string) {
174  session.pickedAt = Date.now()
175  await update($, selected, () => path)
176  await update($, scroll, s => ({ ...s, preview: 0, x: 0 }))
177}
178
179const toggleDir = ($: EngineInterface, key: string) =>
180  update($, collapsed, list => (list.includes(key) ? list.filter(k => k !== key) : [...list, key]))
181
182// Collapse every folder while any is open; expand them all once none is.
183const collapseAll = ($: EngineInterface) =>
184  update($, collapsed, list => (drawn.dirs.some(d => !list.includes(d)) ? [...drawn.dirs] : []))
185
186// What each control and its key does.
187function actions($: EngineInterface): Record<Action, () => Promise<unknown>> {
188  const page = () => Math.max(1, (drawn.layout?.previewBody ?? 10) - 2)
189  const half = () => Math.max(4, Math.round(drawn.wideView / 2))
190  return {
191    collapseAll: () => collapseAll($).then(() => update($, scroll, s => ({ ...s, tree: 0 }))),
192    next: () => move($, 1),
193    prev: () => move($, -1),
194    view: () => update($, view, v => (v === 'diff' ? 'file' : 'diff')).then(() => update($, scroll, s => ({ ...s, preview: 0, x: 0 }))),
195    wrap: () => update($, wrap, w => !w).then(() => update($, scroll, s => ({ ...s, x: 0 }))),
196    tree: () => update($, tree, () => (drawn.layout?.showTree ? 'hidden' : 'shown')),
197    down: () => scrollBy($, 'preview', page()),
198    up: () => scrollBy($, 'preview', -page()),
199    left: () => scrollBy($, 'x', -half()),
200    right: () => scrollBy($, 'x', half()),
201  }
202}
203
204function handlers($: EngineInterface): Handlers {
205  const run = actions($)
206  const press = (action: Action) => () => void run[action]()
207  return {
208    pick: path => void pickFile($, path),
209    toggle: key => void toggleDir($, key),
210    collapseAll: press('collapseAll'),
211    next: press('next'),
212    prev: press('prev'),
213    view: press('view'),
214    wrap: press('wrap'),
215    tree: press('tree'),
216    down: press('down'),
217    up: press('up'),
218    left: press('left'),
219    right: press('right'),
220  }
221}
222
223const BY_KEY = new Map(Object.entries(HOTKEYS).map(([action, key]) => [key, action as Action]))
224
225// A key a surface module passed on: the pane's letters anywhere; in the explorer the arrows walk
226// the files, elsewhere they scroll the preview a row or a few columns at a time.
227async function pressKey($: EngineInterface, element: string, key: string) {
228  const action = BY_KEY.get(key.toLowerCase())
229  if (action) return actions($)[action]()
230  if (element === 'explorer' && (key === 'up' || key === 'down')) return move($, key === 'up' ? -1 : 1)
231  if (key === 'up' || key === 'down') return scrollBy($, 'preview', key === 'up' ? -1 : 1)
232  if (key === 'pageup') return actions($).up()
233  if (key === 'pagedown') return actions($).down()
234  if (key === 'left' || key === 'right') return scrollBy($, 'x', key === 'left' ? -4 : 4)
235  if (key === '[' || key === ']') return resizeBy($, key === '[' ? -2 : 2)
236  return undefined
237}
238
239// The explorer's width: a number the person chose (kept in the store once settled), or null for the default.
240async function resizeTo($: EngineInterface, width: number | null, isDone: boolean) {
241  const at = drawn.layout
242  const next = width === null || !at ? width : Math.max(TREE_MIN, Math.min(treeMax(at.width), Math.round(width)))
243  await update($, treeWidth, () => next)
244  if (!isDone) return
245  if (next === null) await $.store.delete(WIDTH_KEY).catch(() => undefined)
246  else await $.store.set(WIDTH_KEY, next).catch(() => undefined)
247}
248
249const resizeBy = ($: EngineInterface, by: number) => resizeTo($, (drawn.layout?.treeWidth ?? 28) + by, true)
250
251const isOpen = async ($: EngineInterface) => (await $.ui.panes().catch(() => [])).some(p => p.id === PANE)
252
253// /diffs toggles the pane; on and off say which; clear forgets what was tracked.
254async function command($: EngineInterface, args: string) {
255  const arg = args.trim().toLowerCase()
256  if (arg === 'clear') {
257    await update($, files, () => [])
258    await update($, selected, () => null)
259    await update($, scroll, () => ({ tree: 0, preview: 0, x: 0 }))
260    diffs.clear()
261    vrows.clear()
262    reads.clear()
263    return { text: 'Cleared. Files Claude changes from now on show up again.' }
264  }
265  if (arg === 'off' || arg === 'close' || (arg === '' && (await isOpen($)))) {
266    await $.ui.close({ id: PANE }).catch(() => undefined)
267    return { text: `Closed. /${session.command} opens it again.` }
268  }
269  const opened = await openPane($, true)
270  const count = (await read($, files)).length
271  if (!opened.isPlaced) return { text: `The pane could not be shown (${opened.reason}).` }
272  return { text: `${count} changed file${count === 1 ? '' : 's'}. n/p walk them, v swaps diff and file, w wraps, t toggles the tree.` }
273}
274
275export const register: Register = (on, options) => {
276  const icons: IconStyle = options.icons === 'plain' ? 'plain' : 'nerd'
277  const autoOpen = options.autoOpen !== false
278
279  on('session.start', async ($, e, next) => {
280    const r = await next(e)
281    session.home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
282    await update($, cwd, () => e.cwd)
283    const kept = await $.store.get(WIDTH_KEY).catch(() => undefined)
284    if (typeof kept === 'number') await update($, treeWidth, () => kept)
285    // /diffs, or /diff-explorer if a built-in or another plugin ever takes /diffs.
286    for (const name of ['diffs', 'diff-explorer']) {
287      const ok = await $.command
288        .register({ name, description: 'Show the files Claude changed, as a tree with their diffs', argumentHint: '[on | off | clear]' })
289        .then(() => true)
290        .catch(() => false)
291      if (ok) {
292        session.command = name
293        break
294      }
295    }
296    return r
297  })
298
299  // Reads what Claude writes, the main agent's and every subagent's; never blocks or changes the call.
300  on('tool.call', async ($, e, next) => {
301    if (!EDIT_TOOLS.has(e.tool)) return next(e)
302    const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
303    const path = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
304    if (typeof path !== 'string' || !path.startsWith('/')) return next(e)
305    const before = await readText($, path) // before the write lands
306    const r = await next(e)
307    if (r.deny !== undefined || r.isError === true) return r
308    await track($, path, before).catch(() => undefined)
309    await maybeAutoOpen($, autoOpen).catch(() => undefined)
310    return r
311  })
312
313  on('command.run', { command: 'diffs' }, async ($, e) => command($, e.args))
314  on('command.run', { command: 'diff-explorer' }, async ($, e) => command($, e.args))
315
316  // Only to learn whether the terminal docks panes, so an unasked open lands as a sidebar.
317  on('ui.render', { component: 'AbovePrompt' }, async (_$, e, next) => {
318    if (e.viewport?.isFullscreen !== undefined) session.isFullscreen = e.viewport.isFullscreen
319    return next(e)
320  })
321
322  // What the pane's surface modules post: a click on an explorer row, or where a scrollbar's drag
323  // or click wants its window to stand.
324  on('ui.message', { requestId: PANE }, async ($, e) => {
325    const data = (e.data ?? {}) as { press?: unknown; action?: unknown }
326    if (typeof data.press === 'string') {
327      await pressKey($, e.element, data.press)
328      return {}
329    }
330    if (typeof data.action === 'string' && data.action in HOTKEYS) {
331      await actions($)[data.action as Action]()
332      return {}
333    }
334    if (e.element === 'sash') {
335      const message = e.data as { width?: unknown; isDone?: unknown } | null
336      if (typeof message?.width === 'number' || message?.width === null) await resizeTo($, message.width, message.isDone === true)
337      return {}
338    }
339    if (e.element === 'explorer') {
340      const message = e.data as { type?: unknown; key?: unknown } | null
341      if (typeof message?.key !== 'string') return {}
342      if (message.type === 'pick') await pickFile($, message.key)
343      else if (message.type === 'toggle') await toggleDir($, message.key)
344      return {}
345    }
346    const region = BARS[e.element]
347    const message = e.data as { offset?: unknown } | null
348    if (region !== undefined && typeof message?.offset === 'number') await scrollTo($, region, message.offset)
349    return {}
350  })
351
352  // The wheel scrolls the column under it; the keys (no pointer) scroll the preview.
353  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
354    const at = drawn.layout
355    const isTree = at !== null && at.showTree && e.pointer !== undefined && e.pointer.column < at.treeWidth
356    await scrollBy($, isTree ? 'tree' : 'preview', e.by)
357    return {}
358  })
359
360  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
361    const els = $.ui.resolve(e)
362    if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') session.isFullscreen ??= true
363    const list = await read($, files)
364    const root = (await read($, cwd)) || '/'
365    const isWrapped = await read($, wrap)
366    const mode = await read($, view)
367    const at = layout(e.props.bodyColumns, e.props.scroll.bodyRows, await read($, tree), mode === 'file' || !isWrapped, await read($, treeWidth))
368    const offsets = await read($, scroll)
369
370    const known = await entriesOf($, list, root)
371    const nodes = buildTree(known.map(k => ({ rel: k.entry.rel, path: k.entry.path })))
372    const shut = new Set(await read($, collapsed))
373    const rows = treeRows(nodes, shut)
374    const dirs = treeRows(nodes, new Set()).flatMap(row => (row.kind === 'dir' ? [row.key] : []))
375    const order = fileOrder(nodes)
376    const want = await read($, selected)
377    const pick = want && order.includes(want) ? want : (order[order.length - 1] ?? null)
378    const chosen = known.find(k => k.entry.path === pick) ?? null
379
380    let preview: VRow[] = []
381    let code: { source: string; startLine: number } | null = null
382    let wide = { total: 0, view: at.previewInner }
383    if (chosen) {
384      const { entry, diff, after } = chosen
385      if (mode === 'file' && after !== null) {
386        const lines = after.split('\n').map(line => expandTabs(line))
387        const start = clamp(offsets.preview, 0, Math.max(0, lines.length - at.previewBody))
388        // The engine's gutter is the last line number and some room: an estimate is enough for the bar.
389        wide = { total: lines.reduce((n, l) => Math.max(n, textWidth(l)), 0), view: Math.max(1, at.previewInner - String(lines.length).length - 3) }
390        const x = clamp(offsets.x ?? 0, 0, Math.max(0, wide.total - wide.view))
391        const window = lines.slice(start, start + at.previewBody).map(line => dropCells(line, x))
392        code = { source: window.join('\n'), startLine: start + 1 }
393        drawn.previewRows = lines.length
394      } else if (entry.isTooBig) {
395        preview = [{ kind: 'note', text: '  Too large to diff (over 1 MB). v shows the file.', color: '#7d8590' }]
396      } else if (diff?.isBinary) {
397        preview = [{ kind: 'note', text: '  Binary file: no text diff.', color: '#7d8590' }]
398      } else if (entry.isSame) {
399        preview = [{ kind: 'note', text: '  No net changes: the file is back to how it was.', color: '#7d8590' }]
400      } else if (diff) {
401        preview = rowsOf(entry.path, diff, at.previewInner, isWrapped)
402        wide = { total: contentWidth(preview), view: lineRoom(preview, at.previewInner) }
403      }
404      if (code === null) drawn.previewRows = preview.length
405    } else {
406      drawn.previewRows = 0
407    }
408    drawn.layout = at
409    drawn.treeRows = rows.length
410    drawn.order = order
411    drawn.dirs = dirs
412    drawn.maxX = at.hasHbar ? Math.max(0, wide.total - wide.view) : 0
413    drawn.wideView = wide.view
414
415    const clamped = {
416      tree: clamp(offsets.tree, 0, Math.max(0, rows.length - at.treeBody)),
417      preview: clamp(offsets.preview, 0, Math.max(0, drawn.previewRows - at.previewBody)),
418      x: clamp(offsets.x ?? 0, 0, drawn.maxX),
419    }
420    return (
421      <Pane
422        m={{
423          els,
424          at,
425          icons,
426          entries: known.map(k => k.entry),
427          tree: rows,
428          canCollapse: dirs.some(d => !shut.has(d)),
429          previewTotal: drawn.previewRows,
430          wideTotal: wide.total,
431          wideView: wide.view,
432          selected: chosen?.entry ?? null,
433          position: pick ? order.indexOf(pick) + 1 : 0,
434          view: mode,
435          isWrapped,
436          rows: preview,
437          code,
438          scroll: clamped,
439          on: handlers($),
440        }}
441      />
442    )
443  })
444}
445
hooks/diff.ts 188 lines
1// Line diff (Myers), unified hunks with context, and the changed part of each paired line.
2
3export type Op = { kind: 'eq' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number }
4
5export type Row =
6  | { kind: 'hunk'; oldStart: number; oldLines: number; newStart: number; newLines: number; skipped: number }
7  | { kind: 'eq' | 'add' | 'del'; text: string; oldNo?: number; newNo?: number; mark?: [number, number] }
8
9export type Diff = { rows: Row[]; adds: number; dels: number; isBinary: boolean }
10
11const CONTEXT = 3
12const MAX_EDIT = 1000 // past this many differing lines, the middle is shown as all removed then all added
13
14export const splitLines = (text: string) => {
15  if (text === '') return []
16  const lines = text.split(/\r?\n/)
17  if (lines[lines.length - 1] === '') lines.pop()
18  return lines
19}
20
21// Myers' O(ND) shortest edit script over the lines between a common head and tail.
22export function diffLines(a: string[], b: string[]): Op[] {
23  let head = 0
24  while (head < a.length && head < b.length && a[head] === b[head]) head++
25  let tail = 0
26  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
27
28  const midA = a.slice(head, a.length - tail)
29  const midB = b.slice(head, b.length - tail)
30  const mid = middle(midA, midB)
31
32  const ops: Op[] = []
33  for (let i = 0; i < head; i++) ops.push({ kind: 'eq', text: a[i]!, oldNo: i + 1, newNo: i + 1 })
34  let oldNo = head
35  let newNo = head
36  for (const op of mid) {
37    if (op.kind === 'eq') ops.push({ kind: 'eq', text: op.text, oldNo: ++oldNo, newNo: ++newNo })
38    else if (op.kind === 'del') ops.push({ kind: 'del', text: op.text, oldNo: ++oldNo })
39    else ops.push({ kind: 'add', text: op.text, newNo: ++newNo })
40  }
41  for (let i = a.length - tail; i < a.length; i++) ops.push({ kind: 'eq', text: a[i]!, oldNo: ++oldNo, newNo: ++newNo })
42  return ops
43}
44
45function middle(a: string[], b: string[]): Op[] {
46  const n = a.length
47  const m = b.length
48  if (n === 0) return b.map(text => ({ kind: 'add' as const, text }))
49  if (m === 0) return a.map(text => ({ kind: 'del' as const, text }))
50
51  const max = Math.min(n + m, MAX_EDIT)
52  const offset = max + 1
53  let v = new Int32Array(2 * max + 3)
54  const trace: Int32Array[] = []
55  let found = -1
56  for (let d = 0; d <= max && found < 0; d++) {
57    trace.push(v.slice())
58    for (let k = -d; k <= d; k += 2) {
59      const down = k === -d || (k !== d && v[offset + k - 1]! < v[offset + k + 1]!)
60      let x = down ? v[offset + k + 1]! : v[offset + k - 1]! + 1
61      let y = x - k
62      while (x < n && y < m && a[x] === b[y]) {
63        x++
64        y++
65      }
66      v[offset + k] = x
67      if (x >= n && y >= m) {
68        found = d
69        break
70      }
71    }
72  }
73  if (found < 0) {
74    return [...a.map(text => ({ kind: 'del' as const, text })), ...b.map(text => ({ kind: 'add' as const, text }))]
75  }
76
77  // Walk the trace back from (n, m), collecting the script in reverse.
78  const out: Op[] = []
79  let x = n
80  let y = m
81  for (let d = found; d > 0; d--) {
82    const prev = trace[d]!
83    const k = x - y
84    const down = k === -d || (k !== d && prev[offset + k - 1]! < prev[offset + k + 1]!)
85    const prevK = down ? k + 1 : k - 1
86    const prevX = prev[offset + prevK]!
87    const prevY = prevX - prevK
88    while (x > prevX && y > prevY) out.push({ kind: 'eq', text: a[--x]! }), y--
89    if (down) out.push({ kind: 'add', text: b[--y]! })
90    else out.push({ kind: 'del', text: a[--x]! })
91  }
92  while (x > 0 && y > 0) out.push({ kind: 'eq', text: a[--x]! }), y--
93  return out.reverse()
94}
95
96// The changed middle of a removed and an added line: what the two do not share at either end.
97export function changedRange(a: string, b: string): { a: [number, number]; b: [number, number] } | null {
98  if (a === b) return null
99  let head = 0
100  while (head < a.length && head < b.length && a[head] === b[head]) head++
101  let tail = 0
102  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
103  const shared = head + tail
104  // Lines that share almost nothing are told apart better without a mark.
105  if (shared < Math.min(a.length, b.length) * 0.3) return null
106  return { a: [head, a.length - tail], b: [head, b.length - tail] }
107}
108
109// Pairs each run of removed lines with the added run after it, line by line, and marks what changed.
110function markPairs(ops: Op[]): (Op & { mark?: [number, number] })[] {
111  const out: (Op & { mark?: [number, number] })[] = ops.map(op => ({ ...op }))
112  let i = 0
113  while (i < out.length) {
114    if (out[i]!.kind !== 'del') {
115      i++
116      continue
117    }
118    const delStart = i
119    while (i < out.length && out[i]!.kind === 'del') i++
120    const addStart = i
121    while (i < out.length && out[i]!.kind === 'add') i++
122    const pairs = Math.min(addStart - delStart, i - addStart)
123    for (let p = 0; p < pairs; p++) {
124      const del = out[delStart + p]!
125      const add = out[addStart + p]!
126      const range = changedRange(del.text, add.text)
127      if (range) {
128        out[delStart + p] = { ...del, mark: range.a }
129        out[addStart + p] = { ...add, mark: range.b }
130      }
131    }
132  }
133  return out
134}
135
136const looksBinary = (text: string) => text.slice(0, 8000).includes('\u0000')
137
138// Unified hunks of `before` → `after`, each with up to CONTEXT unchanged lines around it.
139export function unified(before: string, after: string): Diff {
140  if (looksBinary(before) || looksBinary(after)) return { rows: [], adds: 0, dels: 0, isBinary: true }
141  const ops = markPairs(diffLines(splitLines(before), splitLines(after)))
142  const adds = ops.filter(op => op.kind === 'add').length
143  const dels = ops.filter(op => op.kind === 'del').length
144
145  const rows: Row[] = []
146  const changed = ops.map((op, i) => (op.kind === 'eq' ? -1 : i)).filter(i => i >= 0)
147  if (changed.length === 0) return { rows, adds, dels, isBinary: false }
148
149  // Merge each change's window [i - CONTEXT, i + CONTEXT] into runs.
150  const spans: [number, number][] = []
151  for (const i of changed) {
152    const lo = Math.max(0, i - CONTEXT)
153    const hi = Math.min(ops.length - 1, i + CONTEXT)
154    const last = spans[spans.length - 1]
155    if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi)
156    else spans.push([lo, hi])
157  }
158
159  let shownTo = 0
160  for (const [lo, hi] of spans) {
161    const slice = ops.slice(lo, hi + 1)
162    const firstOld = slice.find(op => op.oldNo !== undefined)?.oldNo
163    const firstNew = slice.find(op => op.newNo !== undefined)?.newNo
164    const oldLines = slice.filter(op => op.kind !== 'add').length
165    const newLines = slice.filter(op => op.kind !== 'del').length
166    rows.push({
167      kind: 'hunk',
168      oldStart: firstOld ?? lineBefore(ops, lo, 'oldNo'),
169      oldLines,
170      newStart: firstNew ?? lineBefore(ops, lo, 'newNo'),
171      newLines,
172      skipped: lo - shownTo,
173    })
174    for (const op of slice) rows.push(op)
175    shownTo = hi + 1
176  }
177  return { rows, adds, dels, isBinary: false }
178}
179
180// For a hunk with no line on one side (a pure insertion), the line it follows there.
181function lineBefore(ops: Op[], at: number, side: 'oldNo' | 'newNo') {
182  for (let i = at - 1; i >= 0; i--) {
183    const no = ops[i]![side]
184    if (no !== undefined) return no
185  }
186  return 0
187}
188
hooks/icons.ts 147 lines
1// File and folder icons in the manner of VS Code's Material Icon Theme: one glyph and color per kind.
2// `nerd` draws Nerd Font glyphs (seti, devicons, codicons); `plain` draws colored dots any font has.
3
4export type IconStyle = 'nerd' | 'plain'
5export type Icon = { glyph: string; color: string }
6
7type Kind = { nerd: string; color: string }
8
9const K = {
10  ts: { nerd: '\ue628', color: '#3178c6' },
11  tsx: { nerd: '\ue7ba', color: '#4fc3f7' },
12  js: { nerd: '\ue60c', color: '#ffca28' },
13  jsx: { nerd: '\ue7ba', color: '#ffca28' },
14  dts: { nerd: '\ue628', color: '#0288d1' },
15  json: { nerd: '\ue60b', color: '#f9a825' },
16  md: { nerd: '\ue609', color: '#42a5f5' },
17  py: { nerd: '\ue606', color: '#ffd43b' },
18  html: { nerd: '\ue60e', color: '#e44d26' },
19  css: { nerd: '\ue614', color: '#42a5f5' },
20  sass: { nerd: '\ue603', color: '#ec407a' },
21  go: { nerd: '\ue627', color: '#00acc1' },
22  rust: { nerd: '\ue68b', color: '#ff7043' },
23  java: { nerd: '\ue66d', color: '#f44336' },
24  kotlin: { nerd: '\ue634', color: '#7f52ff' },
25  swift: { nerd: '\ue699', color: '#fe5722' },
26  c: { nerd: '\ue649', color: '#0288d1' },
27  cpp: { nerd: '\ue646', color: '#0288d1' },
28  ruby: { nerd: '\ue605', color: '#e53935' },
29  php: { nerd: '\ue608', color: '#8892bf' },
30  lua: { nerd: '\ue620', color: '#42a5f5' },
31  dart: { nerd: '\ue64c', color: '#29b6f6' },
32  shell: { nerd: '\ue691', color: '#4caf50' },
33  yaml: { nerd: '\ue6a8', color: '#ff5252' },
34  toml: { nerd: '\ue615', color: '#9e9e9e' },
35  xml: { nerd: '\ue619', color: '#8bc34a' },
36  sql: { nerd: '\ue64d', color: '#ffca28' },
37  csv: { nerd: '\ue64a', color: '#8bc34a' },
38  graphql: { nerd: '\ue662', color: '#e535ab' },
39  prisma: { nerd: '\ue684', color: '#5c6bc0' },
40  vue: { nerd: '\ue6a0', color: '#41b883' },
41  svelte: { nerd: '\ue697', color: '#ff5722' },
42  image: { nerd: '\ue60d', color: '#26a69a' },
43  svg: { nerd: '\ue698', color: '#ffb300' },
44  pdf: { nerd: '\ue67d', color: '#ef5350' },
45  zip: { nerd: '\ue6aa', color: '#afb42b' },
46  font: { nerd: '\ue659', color: '#f44336' },
47  lock: { nerd: '\ue672', color: '#ffd54f' },
48  git: { nerd: '\ue65d', color: '#e64a19' },
49  docker: { nerd: '\ue650', color: '#0288d1' },
50  npm: { nerd: '\ue616', color: '#e53935' },
51  yarn: { nerd: '\ue6a7', color: '#2c8ebb' },
52  tsconfig: { nerd: '\ue69d', color: '#0288d1' },
53  eslint: { nerd: '\ue655', color: '#7e57c2' },
54  babel: { nerd: '\ue639', color: '#fdd835' },
55  webpack: { nerd: '\ue6a3', color: '#8ed6fb' },
56  make: { nerd: '\ue673', color: '#ef5350' },
57  license: { nerd: '\ue60a', color: '#ef5350' },
58  env: { nerd: '\ue615', color: '#fbc02d' },
59  config: { nerd: '\ue615', color: '#90a4ae' },
60  test: { nerd: '\u{f0668}', color: '#00bfa5' },
61  text: { nerd: '\ue64e', color: '#90a4ae' },
62} satisfies Record<string, Kind>
63
64const BY_EXT: Record<string, Kind> = {
65  ts: K.ts, mts: K.ts, cts: K.ts, tsx: K.tsx, js: K.js, mjs: K.js, cjs: K.js, jsx: K.jsx,
66  json: K.json, jsonc: K.json, json5: K.json, md: K.md, mdx: K.md, markdown: K.md,
67  py: K.py, pyi: K.py, html: K.html, htm: K.html, css: K.css, scss: K.sass, sass: K.sass, less: K.css,
68  go: K.go, rs: K.rust, java: K.java, kt: K.kotlin, kts: K.kotlin, swift: K.swift,
69  c: K.c, h: K.c, cc: K.cpp, cpp: K.cpp, cxx: K.cpp, hpp: K.cpp, rb: K.ruby, php: K.php,
70  lua: K.lua, dart: K.dart, sh: K.shell, bash: K.shell, zsh: K.shell, fish: K.shell,
71  yml: K.yaml, yaml: K.yaml, toml: K.toml, ini: K.config, conf: K.config, xml: K.xml,
72  sql: K.sql, csv: K.csv, tsv: K.csv, graphql: K.graphql, gql: K.graphql, prisma: K.prisma,
73  vue: K.vue, svelte: K.svelte, png: K.image, jpg: K.image, jpeg: K.image, gif: K.image,
74  webp: K.image, ico: K.image, svg: K.svg, pdf: K.pdf, zip: K.zip, gz: K.zip, tar: K.zip,
75  woff: K.font, woff2: K.font, ttf: K.font, otf: K.font, lock: K.lock, txt: K.text, log: K.text,
76}
77
78const BY_NAME: Record<string, Kind> = {
79  'package.json': K.npm, 'package-lock.json': K.npm, '.npmrc': K.npm,
80  'yarn.lock': K.yarn, 'pnpm-lock.yaml': K.lock, 'bun.lockb': K.lock,
81  'dockerfile': K.docker, 'docker-compose.yml': K.docker, 'docker-compose.yaml': K.docker,
82  '.gitignore': K.git, '.gitattributes': K.git, '.gitmodules': K.git,
83  'makefile': K.make, 'license': K.license, 'license.md': K.license,
84  '.babelrc': K.babel, 'babel.config.js': K.babel,
85}
86
87const FOLDER_COLORS: Record<string, string> = {
88  src: '#4caf50', lib: '#c0ca33', app: '#ef5350', components: '#ffc107', hooks: '#7e57c2',
89  test: '#00bfa5', tests: '#00bfa5', __tests__: '#00bfa5', spec: '#00bfa5', docs: '#0288d1',
90  scripts: '#ffa000', config: '#00bcd4', public: '#29b6f6', assets: '#ffb300', images: '#26a69a',
91  styles: '#ec407a', utils: '#ffca28', types: '#0288d1', api: '#ffb300', server: '#ff7043',
92  '.github': '#78909c', '.claude': '#d97757', '.claude-plugin': '#d97757', node_modules: '#8bc34a',
93}
94
95const FOLDER = '#90a4ae'
96
97function kindOf(name: string): Kind {
98  const low = name.toLowerCase()
99  if (BY_NAME[low]) return BY_NAME[low]!
100  if (/^tsconfig.*\.json$/.test(low)) return K.tsconfig
101  if (/^\.?eslint/.test(low)) return K.eslint
102  if (/^webpack\./.test(low)) return K.webpack
103  if (/^\.env(\.|$)/.test(low)) return K.env
104  if (/\.d\.ts$/.test(low)) return K.dts
105  if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(low)) return K.test
106  if (/(^|\.)(config|rc)\.[cm]?[jt]s$/.test(low) || /^\.\w+rc$/.test(low)) return K.config
107  const ext = low.includes('.') ? low.slice(low.lastIndexOf('.') + 1) : ''
108  return BY_EXT[ext] ?? K.text
109}
110
111export function fileIcon(name: string, style: IconStyle): Icon {
112  const kind = kindOf(name)
113  return { glyph: style === 'nerd' ? kind.nerd : '●', color: kind.color }
114}
115
116// The last part of a compacted name (`src/hooks`) picks the color.
117export function folderIcon(name: string, isOpen: boolean, style: IconStyle): Icon {
118  const last = name.slice(name.lastIndexOf('/') + 1).toLowerCase()
119  const color = FOLDER_COLORS[last] ?? FOLDER
120  if (style === 'plain') return { glyph: '■', color }
121  return { glyph: isOpen ? '\ue5fe' : '\ue5ff', color }
122}
123
124// Codicon chevrons in nerd mode, plain triangles otherwise.
125export const chevron = (isOpen: boolean, style: IconStyle) =>
126  style === 'nerd' ? (isOpen ? '\ueab4' : '\ueab6') : isOpen ? '▾' : '▸'
127
128// The pane's own controls, as codicons in nerd mode and plain symbols otherwise.
129const UI = {
130  sidebar: ['\uebf3', '◧'],
131  collapseAll: ['\ueac5', '⊟'],
132  expandAll: ['\ueb95', '⊞'],
133  prev: ['\ueab5', '‹'],
134  next: ['\ueab6', '›'],
135  up: ['\ueab7', '▲'],
136  down: ['\ueab4', '▼'],
137  left: ['\ueab5', '◀'],
138  right: ['\ueab6', '▶'],
139  wrap: ['\ueb80', '↩'],
140  diff: ['\ueae1', '±'],
141  file: ['\ueae9', '≡'],
142} as const
143
144export type UiGlyph = keyof typeof UI
145
146export const uiGlyph = (name: UiGlyph, style: IconStyle) => UI[name][style === 'nerd' ? 0 : 1]
147
hooks/tree.ts 70 lines
1// The changed files as a VS Code explorer tree: folders first, chains of lone folders compacted
2// into one row (`src/hooks`), each folder open unless the person collapsed it.
3
4export type TreeRow =
5  | { kind: 'dir'; key: string; name: string; depth: number; isOpen: boolean }
6  | { kind: 'file'; key: string; name: string; depth: number; path: string }
7
8type Node = { name: string; key: string; dirs: Map<string, Node>; files: { name: string; path: string }[] }
9
10const node = (name: string, key: string): Node => ({ name, key, dirs: new Map(), files: [] })
11
12// The path as the tree shows it: under the session's folder, under home as `~`, else from the root.
13export function relative(path: string, cwd: string, home: string): string {
14  const under = (root: string) => root && path.startsWith(root.replace(/\/$/, '') + '/')
15  if (under(cwd)) return path.slice(cwd.replace(/\/$/, '').length + 1)
16  if (under(home)) return '~/' + path.slice(home.replace(/\/$/, '').length + 1)
17  return path.replace(/^\/+/, '')
18}
19
20const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
21
22export function buildTree(entries: { rel: string; path: string }[]): Node {
23  const root = node('', '')
24  for (const { rel, path } of entries) {
25    const parts = rel.split('/').filter(Boolean)
26    const name = parts.pop() ?? rel
27    let at = root
28    for (const part of parts) {
29      const key = at.key ? `${at.key}/${part}` : part
30      if (!at.dirs.has(part)) at.dirs.set(part, node(part, key))
31      at = at.dirs.get(part)!
32    }
33    at.files.push({ name, path })
34  }
35  return root
36}
37
38// A folder holding one folder and no files takes its child's place, named `a/b`.
39function compact(dir: Node): Node {
40  let at = dir
41  let name = dir.name
42  while (at.files.length === 0 && at.dirs.size === 1) {
43    const [only] = at.dirs.values()
44    at = only!
45    name = `${name}/${at.name}`
46  }
47  return at === dir ? dir : { ...at, name }
48}
49
50export function treeRows(root: Node, collapsed: ReadonlySet<string>): TreeRow[] {
51  const rows: TreeRow[] = []
52  const walk = (dir: Node, depth: number) => {
53    for (const child of [...dir.dirs.values()].map(compact).sort(byName)) {
54      const isOpen = !collapsed.has(child.key)
55      rows.push({ kind: 'dir', key: child.key, name: child.name, depth, isOpen })
56      if (isOpen) walk(child, depth + 1)
57    }
58    for (const file of [...dir.files].sort(byName)) {
59      rows.push({ kind: 'file', key: file.path, name: file.name, depth, path: file.path })
60    }
61  }
62  walk(root, 0)
63  return rows
64}
65
66// The files in the order the tree lists them, collapsed folders included: what next and previous walk.
67export function fileOrder(root: Node): string[] {
68  return treeRows(root, new Set()).flatMap(row => (row.kind === 'file' ? [row.path] : []))
69}
70
hooks/rows.tsx 132 lines
1// The preview's rows in diff view: each diff line wrapped (or cut) to the room right of its gutter,
2// painted in GitHub's diff colors, the changed part of a paired line marked.
3import type { Elements } from 'claude-code'
4
5import type { Diff } from './diff'
6import { expandTabs, pad, padStart, segsWidth, sliceSegs, textWidth, truncate, wrapSegs } from './text'
7import type { Seg } from './text'
8import { C } from './theme'
9
10type Els = Pick<Elements['terminal'], 'Text'>
11
12// One row of the preview: a diff line (or its wrapped continuation), a hunk header, or a note.
13export type VRow =
14  | { kind: 'line'; type: 'eq' | 'add' | 'del'; nums: string; marker: string; segs: Seg[] }
15  | { kind: 'hunk'; text: string }
16  | { kind: 'note'; text: string; color: string }
17
18const TAB = 4
19
20
21// Tabs expanded across segments with one running column, so tab stops stay where they were.
22function expandSegs(segs: Seg[]): Seg[] {
23  let col = 0
24  return segs.map(seg => {
25    let text = ''
26    for (const ch of seg.text) {
27      if (ch === '\t') {
28        const n = TAB - (col % TAB)
29        text += ' '.repeat(n)
30        col += n
31      } else {
32        const one = expandTabs(ch)
33        text += one
34        col += textWidth(one)
35      }
36    }
37    return { text, isMarked: seg.isMarked }
38  })
39}
40
41function segsOf(text: string, mark?: [number, number]): Seg[] {
42  if (!mark) return expandSegs([{ text, isMarked: false }])
43  const [lo, hi] = mark
44  return expandSegs(
45    [
46      { text: text.slice(0, lo), isMarked: false },
47      { text: text.slice(lo, hi), isMarked: true },
48      { text: text.slice(hi), isMarked: false },
49    ].filter(s => s.text !== ''),
50  )
51}
52
53// The diff as rows `width` cells wide: each line wrapped to the room right of its gutter, or whole
54// (one row each) for the window to show a sideways slice of.
55export function diffRows(diff: Diff, width: number, isWrapped: boolean): VRow[] {
56  const maxNo = diff.rows.reduce((n, r) => (r.kind === 'hunk' ? n : Math.max(n, r.oldNo ?? 0, r.newNo ?? 0)), 0)
57  const gw = Math.max(2, String(maxNo).length)
58  const gutter = gw * 2 + 2
59  const room = Math.max(4, width - gutter - 2)
60  const out: VRow[] = []
61  for (const row of diff.rows) {
62    if (row.kind === 'hunk') {
63      const head = `@@ -${row.oldStart},${row.oldLines} +${row.newStart},${row.newLines} @@`
64      const skipped = row.skipped > 0 ? `   ⋯ ${row.skipped} unchanged line${row.skipped === 1 ? '' : 's'}` : ''
65      out.push({ kind: 'hunk', text: head + skipped })
66      continue
67    }
68    const nums = `${padStart(row.oldNo ? String(row.oldNo) : '', gw)} ${padStart(row.newNo ? String(row.newNo) : '', gw)} `
69    const marker = row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '
70    const segs = segsOf(row.text, row.mark)
71    const parts = isWrapped ? wrapSegs(segs, room) : [segs]
72    parts.forEach((part, i) => {
73      out.push({ kind: 'line', type: row.kind, nums: i === 0 ? nums : ' '.repeat(gutter), marker: i === 0 ? marker : ' ', segs: part })
74    })
75  }
76  return out
77}
78
79// The widest line's cells, right of the gutter: how far the window can scroll sideways.
80export const contentWidth = (rows: VRow[]) =>
81  rows.reduce((n, row) => (row.kind === 'line' ? Math.max(n, segsWidth(row.segs)) : n), 0)
82
83// The room right of the gutter a line row has in `width` cells.
84export const lineRoom = (rows: VRow[], width: number) => {
85  const line = rows.find(row => row.kind === 'line')
86  return line && line.kind === 'line' ? Math.max(4, width - textWidth(line.nums) - 2) : width
87}
88
89// One row, the window `x` cells into its line; a hunk header and a note never move.
90export function PreviewRow({ els, row, width, x = 0 }: { els: Els; row: VRow; width: number; x?: number }) {
91  const { Text } = els
92  if (row.kind === 'hunk') {
93    return (
94      <Text backgroundColor={C.hunkLine} color={C.hunkFg} wrap="truncate-end">
95        {pad(truncate(` ${row.text}`, width), width)}
96      </Text>
97    )
98  }
99  if (row.kind === 'note') {
100    return (
101      <Text backgroundColor={C.canvas} color={row.color} wrap="truncate-end">
102        {pad(truncate(row.text, width), width)}
103      </Text>
104    )
105  }
106  const lineBg = row.type === 'add' ? C.addLine : row.type === 'del' ? C.delLine : C.canvas
107  const numBg = row.type === 'add' ? C.addNum : row.type === 'del' ? C.delNum : C.canvas
108  const wordBg = row.type === 'add' ? C.addWord : C.delWord
109  const markerFg = row.type === 'add' ? C.addFg : row.type === 'del' ? C.delFg : C.muted
110  const numFg = row.type === 'eq' ? C.subtle : C.muted
111  const room = Math.max(0, width - textWidth(row.nums) - 2)
112  const segs = sliceSegs(row.segs, x, room)
113  const used = textWidth(row.nums) + 2 + segsWidth(segs)
114  return (
115    <Text wrap="truncate-end">
116      <Text backgroundColor={numBg} color={numFg}>
117        {row.nums}
118      </Text>
119      <Text backgroundColor={lineBg} color={markerFg}>
120        {row.marker + ' '}
121      </Text>
122      {segs.map(seg => (
123        <Text backgroundColor={seg.isMarked ? wordBg : lineBg} color={C.fg}>
124          {seg.text}
125        </Text>
126      ))}
127      <Text backgroundColor={lineBg}>{' '.repeat(Math.max(0, width - used))}</Text>
128    </Text>
129  )
130}
131
132
hooks/chrome.tsx 201 lines
1// The pane's chrome rows: the toolbar, the explorer's title, the file's header and the status row.
2// Each is one row of items (text, controls, spacers), drawn by the controls surface module where
3// the surface draws a Client, else as Text and plain Buttons (which keep their hotkeys).
4import { cellsOf, spans } from './controls'
5import type { ControlsProps, Item, Part } from './controls'
6import { fileIcon, uiGlyph } from './icons'
7import type { UiGlyph } from './icons'
8import { truncate, truncateStart } from './text'
9import { C } from './theme'
10import type { Handlers, PaneModel } from './view'
11
12export type Action = Exclude<keyof Handlers, 'pick' | 'toggle'>
13
14// The pane's keys, for a Button's hotkey and for keys a surface module passes on.
15export const HOTKEYS: Record<Action, string> = {
16  tree: 't',
17  collapseAll: 'c',
18  prev: 'p',
19  next: 'n',
20  view: 'v',
21  wrap: 'w',
22  up: 'k',
23  down: 'j',
24  left: 'h',
25  right: 'l',
26}
27
28const HINT = '#6e7681' // a control's key, drawn quiet beside its label
29
30const text = (parts: Part[]): Item => ({ parts })
31const spacer: Item = { spacer: true }
32
33// A control, padded a cell each side so the pointer's background reads as a button.
34function control(m: PaneModel, action: Action, glyph: UiGlyph, label?: string, isOn = false): Item {
35  const hint: Part[] = label ? [{ text: ` ${HOTKEYS[action]}`, color: HINT }] : []
36  return { action, isOn, parts: [{ text: ` ${uiGlyph(glyph, m.icons)}${label ? ` ${label}` : ''}` }, ...hint, { text: ' ' }] }
37}
38
39const width = (items: Item[]) => items.reduce((n, item) => n + ('spacer' in item ? 0 : item.parts.reduce((k, p) => k + cellsOf(p.text), 0)), 0)
40
41function ChromeRow({ m, id, w, bg, items }: { m: PaneModel; id: string; w: number; bg: string; items: Item[] }) {
42  const { Box, Text, Button, Client } = m.els
43  const props: ControlsProps = {
44    width: w,
45    bg,
46    items,
47    colors: { hover: C.controlHover, pressed: C.controlPressed, on: C.accent, fg: C.fg },
48  }
49  if (Client) return <Client key={id} module="./controls.tsx" props={props} width={w} height={1} />
50  const layout = spans(items, w)
51  const paint = (parts: Part[]) =>
52    parts.map(part => (
53      <Text color={part.color ?? C.fg} backgroundColor={part.bg ?? bg} weight={part.bold ? 'bold' : undefined} strikethrough={part.strike}>
54        {part.text}
55      </Text>
56    ))
57  return (
58    <Box flexDirection="row" width={w} height={1} overflow="hidden" backgroundColor={bg}>
59      {items.map((item, i) => {
60        const [a, b] = layout[i]!
61        if ('spacer' in item) return <Text backgroundColor={bg}>{' '.repeat(b - a)}</Text>
62        if (!item.action) return <Text backgroundColor={bg}>{paint(item.parts)}</Text>
63        const action = item.action as Action
64        return (
65          <Button key={action} plain hotkey={HOTKEYS[action]} onPress={() => m.on[action]()}>
66            {paint(item.parts)}
67          </Button>
68        )
69      })}
70    </Box>
71  )
72}
73
74export function Toolbar({ m }: { m: PaneModel }) {
75  const { Box, Text } = m.els
76  const adds = m.entries.reduce((n, e) => n + e.adds, 0)
77  const dels = m.entries.reduce((n, e) => n + e.dels, 0)
78  const count = `${m.entries.length} file${m.entries.length === 1 ? '' : 's'}`
79  const isTight = m.at.width < 56
80  const items: Item[] = [
81    text([{ text: ' ' }]),
82    control(m, 'tree', 'sidebar', isTight ? undefined : 'Explorer', m.at.showTree),
83    text([
84      { text: '  │  ', color: C.subtle },
85      { text: 'CHANGES', color: C.fg, bold: true },
86      ...(isTight
87        ? []
88        : [
89            { text: `  ${count}  `, color: C.muted },
90            { text: `+${adds} `, color: C.addFg },
91            { text: `−${dels}`, color: C.delFg },
92          ]),
93    ]),
94    spacer,
95  ]
96  return (
97    <Box flexDirection="column" width={m.at.width}>
98      <ChromeRow m={m} id="toolbar" w={m.at.width} bg={C.header} items={items} />
99      <Text color={C.border} backgroundColor={C.canvas}>
100        {'─'.repeat(m.at.width)}
101      </Text>
102    </Box>
103  )
104}
105
106export function ExplorerHead({ m }: { m: PaneModel }) {
107  const items: Item[] = [
108    text([{ text: ' EXPLORER', color: C.muted, bold: true }]),
109    spacer,
110    control(m, 'collapseAll', m.canCollapse ? 'collapseAll' : 'expandAll'),
111  ]
112  return <ChromeRow m={m} id="explorer-head" w={m.at.treeWidth} bg={C.sidebar} items={items} />
113}
114
115// A GitHub diffstat: five blocks split between added and removed.
116function diffstat(adds: number, dels: number) {
117  const total = adds + dels
118  if (total === 0) return { green: 0, red: 0, grey: 5 }
119  const green = Math.round((adds / total) * 5)
120  return { green, red: 5 - green, grey: 0 }
121}
122
123export function FileHead({ m }: { m: PaneModel }) {
124  const { Box, Text } = m.els
125  const w = m.at.previewWidth
126  const sel = m.selected!
127  const icon = fileIcon(sel.name, m.icons)
128  const stat = diffstat(sel.adds, sel.dels)
129  const right: Item[] = [
130    text([
131      { text: `+${sel.adds} `, color: C.addFg },
132      { text: `−${sel.dels} `, color: C.delFg },
133      { text: '■'.repeat(stat.green), color: C.addFg },
134      { text: '■'.repeat(stat.red), color: C.delFg },
135      { text: '■'.repeat(stat.grey), color: C.subtle },
136      { text: '  ' },
137    ]),
138    control(m, 'prev', 'prev'),
139    control(m, 'next', 'next'),
140    text([{ text: ` ${m.position}/${m.entries.length} `, color: C.muted }]),
141  ]
142  const badge: Part[] =
143    sel.status === 'A'
144      ? [{ text: ' ' }, { text: ' NEW ', color: C.canvas, bg: C.addFg, bold: true }]
145      : sel.status === 'D'
146        ? [{ text: ' ' }, { text: ' DELETED ', color: C.canvas, bg: C.delFg, bold: true }]
147        : []
148  const lead = ` ${icon.glyph} `
149  const room = w - width(right) - cellsOf(lead) - badge.reduce((n, p) => n + cellsOf(p.text), 0) - 1
150  const crumbs = truncateStart(sel.rel.split('/').join(' › '), Math.max(4, room))
151  const dir = crumbs.includes(' › ') ? crumbs.slice(0, crumbs.lastIndexOf(' › ') + 3) : ''
152  const items: Item[] = [
153    text([
154      { text: lead, color: icon.color },
155      { text: dir, color: C.muted },
156      { text: crumbs.slice(dir.length), color: C.fg, bold: true },
157      ...badge,
158    ]),
159    spacer,
160    ...right,
161  ]
162  return (
163    <Box flexDirection="column" width={w}>
164      <ChromeRow m={m} id="file-head" w={w} bg={C.canvas} items={items} />
165      <Text color={C.border} backgroundColor={C.canvas}>
166        {'─'.repeat(w)}
167      </Text>
168    </Box>
169  )
170}
171
172// The status row: where the window stands, and the view, wrap and scroll controls.
173export function StatusRow({ m }: { m: PaneModel }) {
174  const w = m.at.previewWidth
175  const isTight = w < 72
176  const isDiff = m.view === 'diff'
177  const segment = (glyph: UiGlyph, label: string, isOn: boolean): Part =>
178    isOn
179      ? { text: ` ${uiGlyph(glyph, m.icons)} ${label} `, color: C.fg, bg: C.segment, bold: true }
180      : { text: ` ${uiGlyph(glyph, m.icons)} ${label} `, color: C.muted }
181  const controls: Item[] = [
182    { action: 'view', parts: [{ text: ' ' }, segment('diff', 'Diff', isDiff), segment('file', 'File', !isDiff), { text: ' v', color: HINT }, { text: ' ' }] },
183    control(m, 'wrap', 'wrap', isTight ? undefined : 'Wrap', m.isWrapped),
184    text([{ text: ' ' }]),
185    control(m, 'up', 'up'),
186    control(m, 'down', 'down'),
187    ...(m.at.hasHbar ? [control(m, 'left', 'left'), control(m, 'right', 'right')] : []),
188    text([{ text: ' ' }]),
189  ]
190  const first = m.previewTotal === 0 ? 0 : m.scroll.preview + 1
191  const last = Math.min(m.previewTotal, m.scroll.preview + m.at.previewBody)
192  const unit = isDiff ? 'rows' : 'lines'
193  const cols =
194    m.at.hasHbar && m.wideTotal > m.wideView
195      ? ` · col ${m.scroll.x + 1}–${Math.min(m.wideTotal, m.scroll.x + m.wideView)}/${m.wideTotal}`
196      : ''
197  const where = isTight ? ` ${first}–${last}/${m.previewTotal}${cols}` : ` ${unit} ${first}–${last}/${m.previewTotal}${cols}`
198  const items: Item[] = [text([{ text: truncate(where, Math.max(0, w - width(controls))), color: C.muted }]), spacer, ...controls]
199  return <ChromeRow m={m} id="status" w={w} bg={C.header} items={items} />
200}
201
hooks/text.ts 146 lines
1// Terminal cell widths: Hangul, CJK and most emoji take two cells, combining marks none.
2// Rows are padded to the column's width so their background fills it, so widths must be right.
3
4const WIDE: [number, number][] = [
5  [0x1100, 0x115f], // Hangul Jamo
6  [0x2e80, 0x303e], // CJK radicals, punctuation
7  [0x3041, 0x33ff], // kana, Hangul compatibility jamo, CJK symbols
8  [0x3400, 0x4dbf], // CJK extension A
9  [0x4e00, 0x9fff], // CJK unified
10  [0xa000, 0xa4cf], // Yi
11  [0xa960, 0xa97f], // Hangul Jamo extended A
12  [0xac00, 0xd7a3], // Hangul syllables
13  [0xf900, 0xfaff], // CJK compatibility
14  [0xfe30, 0xfe4f], // CJK compatibility forms
15  [0xff00, 0xff60], // fullwidth forms
16  [0xffe0, 0xffe6],
17  [0x1f300, 0x1f64f], // emoji
18  [0x1f900, 0x1f9ff],
19  [0x1fa70, 0x1faff],
20  [0x20000, 0x3fffd], // CJK extensions B and later
21]
22
23const ZERO: [number, number][] = [
24  [0x0300, 0x036f], // combining diacritics
25  [0x200b, 0x200f], // zero-width space, joiners, marks
26  [0xfe00, 0xfe0f], // variation selectors
27  [0x1160, 0x11ff], // Hangul medial and final jamo
28]
29
30const within = (cp: number, ranges: [number, number][]) => ranges.some(([lo, hi]) => cp >= lo && cp <= hi)
31
32export function charWidth(ch: string): number {
33  const cp = ch.codePointAt(0) ?? 0
34  if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0
35  if (within(cp, ZERO)) return 0
36  return within(cp, WIDE) ? 2 : 1
37}
38
39export const textWidth = (s: string) => [...s].reduce((n, ch) => n + charWidth(ch), 0)
40
41// Tabs to spaces at `size` stops, and every other control character dropped.
42export function expandTabs(s: string, size = 4): string {
43  let out = ''
44  let col = 0
45  for (const ch of s) {
46    const cp = ch.codePointAt(0) ?? 0
47    if (ch === '\t') {
48      const n = size - (col % size)
49      out += ' '.repeat(n)
50      col += n
51    } else if (cp >= 0x20 && !(cp >= 0x7f && cp < 0xa0)) {
52      out += ch
53      col += charWidth(ch)
54    }
55  }
56  return out
57}
58
59export const pad = (s: string, width: number) => s + ' '.repeat(Math.max(0, width - textWidth(s)))
60
61export const padStart = (s: string, width: number) => ' '.repeat(Math.max(0, width - textWidth(s))) + s
62
63// Cut to `width` cells, an ellipsis standing for what was cut.
64export function truncate(s: string, width: number): string {
65  if (width <= 0) return ''
66  if (textWidth(s) <= width) return s
67  let out = ''
68  let used = 0
69  for (const ch of s) {
70    const w = charWidth(ch)
71    if (used + w > width - 1) break
72    out += ch
73    used += w
74  }
75  return out + '…'
76}
77
78// Cut from the left, keeping the end (a path's file name).
79export function truncateStart(s: string, width: number): string {
80  if (width <= 0) return ''
81  if (textWidth(s) <= width) return s
82  const chars = [...s]
83  let out = ''
84  let used = 0
85  for (let i = chars.length - 1; i >= 0; i--) {
86    const w = charWidth(chars[i]!)
87    if (used + w > width - 1) break
88    out = chars[i] + out
89    used += w
90  }
91  return '…' + out
92}
93
94// A run of text, marked when it is the changed part of a line.
95export type Seg = { text: string; isMarked: boolean }
96
97// Lays segments out in rows of at most `width` cells, splitting a segment where a row ends.
98export function wrapSegs(segs: Seg[], width: number): Seg[][] {
99  const rows: Seg[][] = [[]]
100  let used = 0
101  for (const seg of segs) {
102    let run = ''
103    for (const ch of seg.text) {
104      const w = charWidth(ch)
105      if (used + w > width && used > 0) {
106        if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
107        rows.push([])
108        run = ''
109        used = 0
110      }
111      run += ch
112      used += w
113    }
114    if (run) rows[rows.length - 1]!.push({ text: run, isMarked: seg.isMarked })
115  }
116  return rows
117}
118
119export const segsWidth = (segs: Seg[]) => segs.reduce((n, s) => n + textWidth(s.text), 0)
120
121// The cells [from, from + width) of a row of segments: what a window scrolled sideways shows.
122// A wide character cut by either edge becomes a space, so the columns stay where they were.
123export function sliceSegs(segs: Seg[], from: number, width: number): Seg[] {
124  const out: Seg[] = []
125  let col = 0
126  const end = from + width
127  for (const seg of segs) {
128    let text = ''
129    for (const ch of seg.text) {
130      const w = charWidth(ch)
131      const next = col + w
132      if (col >= from && next <= end) text += ch
133      else if (next > from && col < end) text += ' '.repeat(Math.min(next, end) - Math.max(col, from))
134      col = next
135      if (col >= end) break
136    }
137    if (text) out.push({ text, isMarked: seg.isMarked })
138    if (col >= end) break
139  }
140  return out
141}
142
143// A line with its first `n` cells dropped.
144export const dropCells = (s: string, n: number) =>
145  n <= 0 ? s : sliceSegs([{ text: s, isMarked: false }], n, Number.MAX_SAFE_INTEGER).map(seg => seg.text).join('')
146
hooks/view.tsx 349 lines
1// What the pane draws: a toolbar, the explorer on the left and the preview on the right, each
2// column a window of its own over its rows, so the two scroll apart as in VS Code. The chrome rows
3// and their controls are in chrome.tsx; this file lays the columns out.
4import type { Elements } from 'claude-code'
5
6import { ExplorerHead, FileHead, StatusRow, Toolbar } from './chrome'
7import { chevron, fileIcon, folderIcon } from './icons'
8import type { IconStyle } from './icons'
9import { PreviewRow } from './rows'
10import type { VRow } from './rows'
11import { thumbOf } from './scrollbar'
12import type { BarProps } from './scrollbar'
13import type { SashProps } from './sash'
14import { pad, textWidth, truncate } from './text'
15import { C } from './theme'
16import type { TreeRow } from './tree'
17import type { TreeItem, TreeProps } from './tree-client'
18
19export type Els = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Code'> & {
20  Client?: Elements['terminal']['Client'] // the terminal and desktop draw one; the others fall back to Buttons
21}
22
23export type Status = 'A' | 'M' | 'D'
24
25export type Entry = {
26  path: string
27  rel: string
28  name: string
29  status: Status
30  adds: number
31  dels: number
32  isSame: boolean // back to how it was
33  isTooBig: boolean
34}
35
36export type Layout = {
37  width: number
38  height: number
39  showTree: boolean
40  treeWidth: number
41  previewWidth: number
42  treeBody: number // rows the tree's window shows
43  previewBody: number // rows the preview's window shows
44  treeInner: number // the tree's columns left of its scrollbar
45  previewInner: number // the preview's columns left of its scrollbar
46  hasHbar: boolean // lines are not wrapped, so the preview scrolls sideways and shows a bar for it
47}
48
49export type Handlers = {
50  pick: (path: string) => void
51  toggle: (dirKey: string) => void
52  collapseAll: () => void
53  next: () => void
54  prev: () => void
55  view: () => void
56  wrap: () => void
57  tree: () => void
58  down: () => void
59  up: () => void
60  left: () => void
61  right: () => void
62}
63
64const TOOLBAR_ROWS = 2 // the toolbar and the rule under it
65const TREE_HEAD = 1 // the explorer's title row
66const PREVIEW_HEAD = 2 // the file's header and the rule under it
67const PREVIEW_FOOT = 1 // the status row under the preview
68const TREE_MIN_PANE = 72 // narrower panes hide the tree unless it is forced on
69export const TREE_MIN = 16 // the narrowest the explorer can be dragged
70const PREVIEW_MIN = 32 // the room a drag leaves the preview
71
72// The widest the explorer may be in a pane `width` cells across.
73export const treeMax = (width: number) => Math.max(TREE_MIN, width - 1 - PREVIEW_MIN)
74
75// `pref` is the width the person dragged the explorer to; null sizes it to the pane.
76export function layout(width: number, height: number, tree: 'auto' | 'shown' | 'hidden', hasHbar: boolean, pref: number | null = null): Layout {
77  const showTree = tree === 'shown' || (tree === 'auto' && width >= TREE_MIN_PANE)
78  const auto = Math.max(20, Math.min(36, Math.round(width * 0.32)))
79  const treeWidth = showTree ? Math.max(TREE_MIN, Math.min(treeMax(width), pref ?? auto)) : 0
80  const previewWidth = showTree ? width - treeWidth - 1 : width
81  const body = Math.max(1, height - TOOLBAR_ROWS)
82  return {
83    width,
84    height,
85    showTree,
86    treeWidth,
87    previewWidth,
88    treeBody: Math.max(1, body - TREE_HEAD),
89    previewBody: Math.max(1, body - PREVIEW_HEAD - PREVIEW_FOOT - (hasHbar ? 1 : 0)),
90    treeInner: Math.max(1, treeWidth - 1),
91    previewInner: Math.max(1, previewWidth - 1),
92    hasHbar,
93  }
94}
95
96export const statusColor = (s: Status) => (s === 'A' ? C.gitAdded : s === 'D' ? C.gitDeleted : C.gitModified)
97
98export type PaneModel = {
99  els: Els
100  at: Layout
101  icons: IconStyle
102  entries: Entry[]
103  tree: TreeRow[]
104  canCollapse: boolean // some folder is open, so collapse all would close it
105  selected: Entry | null
106  position: number // the selected file's place among the files, 1-based
107  view: 'diff' | 'file'
108  isWrapped: boolean
109  rows: VRow[] // the preview's rows in diff view
110  code: { source: string; startLine: number } | null // the preview's window in file view
111  previewTotal: number // rows (diff) or lines (file) the preview holds
112  wideTotal: number // the widest line's cells, for the sideways bar
113  wideView: number // the cells of a line the window shows
114  scroll: { tree: number; preview: number; x: number }
115  on: Handlers
116}
117
118const BAR_COLORS = { track: C.track, thumb: C.thumb, active: C.thumbActive, bg: C.canvas }
119
120// A scrollbar: a Client the pointer drags where the surface draws one, else a picture of one.
121function Bar({ m, id, axis, length, total, view, offset, bg }: {
122  m: PaneModel
123  id: 'vbar' | 'hbar' | 'tbar'
124  axis: 'v' | 'h'
125  length: number
126  total: number
127  view: number
128  offset: number
129  bg: string
130}) {
131  const { Box, Text, Client } = m.els
132  const props: BarProps = { axis, length, total, view, offset, colors: { ...BAR_COLORS, bg } }
133  if (Client) {
134    return axis === 'v' ? (
135      <Client key={id} module="./scrollbar.tsx" props={props} width={1} height={length} />
136    ) : (
137      <Client key={id} module="./scrollbar.tsx" props={props} width={length} height={1} />
138    )
139  }
140  const { start, size, max } = thumbOf(props)
141  const isThumb = (i: number) => max > 0 && i >= start && i < start + size
142  if (axis === 'v') {
143    return (
144      <Box flexDirection="column" width={1}>
145        {Array.from({ length }, (_, i) => (
146          <Text color={isThumb(i) ? C.thumb : C.track} backgroundColor={bg}>
147            {isThumb(i) ? '┃' : '│'}
148          </Text>
149        ))}
150      </Box>
151    )
152  }
153  return (
154    <Text backgroundColor={bg}>
155      {Array.from({ length }, (_, i) => (
156        <Text color={isThumb(i) ? C.thumb : C.track}>{isThumb(i) ? '━' : '─'}</Text>
157      ))}
158    </Text>
159  )
160}
161
162function treeItems(m: PaneModel, rows: TreeRow[], width: number): TreeItem[] {
163  const byPath = new Map(m.entries.map(e => [e.path, e]))
164  return rows.map(row => {
165    const lead = ' '.repeat(1 + row.depth * 2) + (row.kind === 'dir' ? `${chevron(row.isOpen, m.icons)} ` : '  ')
166    if (row.kind === 'dir') {
167      const icon = folderIcon(row.name, row.isOpen, m.icons)
168      return {
169        key: row.key,
170        kind: 'dir',
171        lead,
172        glyph: icon.glyph,
173        glyphColor: icon.color,
174        name: truncate(row.name, width - textWidth(lead) - 3),
175        nameColor: C.fg,
176        status: '',
177        statusColor: C.muted,
178        isSelected: false,
179        isStruck: false,
180      }
181    }
182    const entry = byPath.get(row.path)
183    const status = entry?.status ?? 'M'
184    const icon = fileIcon(row.name, m.icons)
185    const color = entry?.isSame ? C.subtle : statusColor(status)
186    return {
187      key: row.path,
188      kind: 'file',
189      lead,
190      glyph: icon.glyph,
191      glyphColor: icon.color,
192      name: truncate(row.name, width - textWidth(lead) - 7),
193      nameColor: color,
194      status: ` ${status} `,
195      statusColor: color,
196      isSelected: m.selected?.path === row.path,
197      isStruck: status === 'D',
198    }
199  })
200}
201
202// Where no Client is drawn (VS Code, mobile): each row a plain Button.
203function TreeButtons({ m, items, width }: { m: PaneModel; items: TreeItem[]; width: number }) {
204  const { Button, Text } = m.els
205  return (
206    <>
207      {items.map(item => {
208        const bg = item.isSelected ? C.selected : C.sidebar
209        const used = textWidth(item.lead) + 1 + textWidth(item.name) + textWidth(item.status)
210        const press = () => (item.kind === 'dir' ? m.on.toggle(item.key) : m.on.pick(item.key))
211        return (
212          <Button key={`${item.kind}:${item.key}`} plain onPress={press}>
213            <Text backgroundColor={bg} color={C.muted}>
214              {item.lead}
215            </Text>
216            <Text backgroundColor={bg} color={item.glyphColor}>
217              {item.glyph}
218            </Text>
219            <Text backgroundColor={bg} color={item.nameColor} strikethrough={item.isStruck}>
220              {` ${item.name}${' '.repeat(Math.max(1, width - used - 1))}`}
221            </Text>
222            <Text backgroundColor={bg} color={item.statusColor}>
223              {item.status}
224            </Text>
225          </Button>
226        )
227      })}
228    </>
229  )
230}
231
232function TreeColumn({ m }: { m: PaneModel }) {
233  const { Box, Text, Client } = m.els
234  const w = m.at.treeWidth
235  const shown = m.tree.slice(m.scroll.tree, m.scroll.tree + m.at.treeBody)
236  const items = treeItems(m, shown, m.at.treeInner)
237  const props: TreeProps = {
238    width: m.at.treeInner,
239    rows: items,
240    colors: { bg: C.sidebar, hover: C.hover, selected: C.selected, accent: C.accent },
241  }
242  return (
243    <Box flexDirection="column" width={w} height={m.at.treeBody + TREE_HEAD} overflow="hidden" backgroundColor={C.sidebar}>
244      <ExplorerHead m={m} />
245      <Box flexDirection="row" width={w} height={m.at.treeBody} backgroundColor={C.sidebar}>
246        <Box flexDirection="column" width={m.at.treeInner} height={m.at.treeBody} backgroundColor={C.sidebar}>
247          {Client ? (
248            <Client key="explorer" module="./tree-client.tsx" props={props} width={m.at.treeInner} height={m.at.treeBody} />
249          ) : (
250            <TreeButtons m={m} items={items} width={m.at.treeInner} />
251          )}
252        </Box>
253        <Bar m={m} id="tbar" axis="v" length={m.at.treeBody} total={m.tree.length} view={m.at.treeBody} offset={m.scroll.tree} bg={C.sidebar} />
254      </Box>
255    </Box>
256  )
257}
258
259// The sash between the columns: a Client the pointer drags where the surface draws one, else a line.
260function Divider({ m }: { m: PaneModel }) {
261  const { Box, Text, Client } = m.els
262  const height = m.at.treeBody + TREE_HEAD
263  if (Client) {
264    const props: SashProps = {
265      height,
266      treeWidth: m.at.treeWidth,
267      min: TREE_MIN,
268      max: treeMax(m.at.width),
269      colors: { line: C.border, hot: C.accent, bg: C.canvas },
270    }
271    return <Client key="sash" module="./sash.tsx" props={props} width={1} height={height} />
272  }
273  return (
274    <Box flexDirection="column" width={1}>
275      {Array.from({ length: height }, () => (
276        <Text color={C.border} backgroundColor={C.canvas}>
277          │
278        </Text>
279      ))}
280    </Box>
281  )
282}
283
284function PreviewColumn({ m }: { m: PaneModel }) {
285  const { Box, Text, Code } = m.els
286  const w = m.at.previewWidth
287  const tall = m.at.previewBody + PREVIEW_HEAD + PREVIEW_FOOT + (m.at.hasHbar ? 1 : 0)
288  if (!m.selected) {
289    const lines = ['', '  No changes yet.', '', '  Files Claude edits this session', '  show up here, with their diff.']
290    return (
291      <Box flexDirection="column" width={w} height={tall} backgroundColor={C.canvas}>
292        {lines.map((line, i) => (
293          <Text backgroundColor={C.canvas} color={i === 1 ? C.fg : C.muted}>
294            {pad(line, w)}
295          </Text>
296        ))}
297      </Box>
298    )
299  }
300  const inner = m.at.previewInner
301  const content =
302    m.view === 'file' && m.code ? (
303      <Box width={inner} height={m.at.previewBody} overflow="hidden" backgroundColor={C.canvas}>
304        <Code source={m.code.source || ' '} path={m.selected.path} startLine={m.code.startLine} wrap="truncate-end" />
305      </Box>
306    ) : (
307      <Box flexDirection="column" width={inner} height={m.at.previewBody} overflow="hidden" backgroundColor={C.canvas}>
308        {m.rows.slice(m.scroll.preview, m.scroll.preview + m.at.previewBody).map(row => (
309          <PreviewRow els={m.els} row={row} width={inner} x={m.at.hasHbar ? m.scroll.x : 0} />
310        ))}
311      </Box>
312    )
313  const body = (
314    <Box flexDirection="column" width={w} backgroundColor={C.canvas}>
315      <Box flexDirection="row" width={w} height={m.at.previewBody} backgroundColor={C.canvas}>
316        {content}
317        <Bar m={m} id="vbar" axis="v" length={m.at.previewBody} total={m.previewTotal} view={m.at.previewBody} offset={m.scroll.preview} bg={C.canvas} />
318      </Box>
319      {m.at.hasHbar && (
320        <Box flexDirection="row" width={w} height={1} backgroundColor={C.canvas}>
321          <Bar m={m} id="hbar" axis="h" length={inner} total={m.wideTotal} view={m.wideView} offset={m.scroll.x} bg={C.canvas} />
322          <Text backgroundColor={C.canvas}> </Text>
323        </Box>
324      )}
325    </Box>
326  )
327  return (
328    <Box flexDirection="column" width={w} height={tall} overflow="hidden" backgroundColor={C.canvas}>
329      <FileHead m={m} />
330      {body}
331      <StatusRow m={m} />
332    </Box>
333  )
334}
335
336export function Pane({ m }: { m: PaneModel }) {
337  const { Box } = m.els
338  return (
339    <Box flexDirection="column" width={m.at.width} height={m.at.height} overflow="hidden" backgroundColor={C.canvas}>
340      <Toolbar m={m} />
341      <Box flexDirection="row" width={m.at.width} overflow="hidden" backgroundColor={C.canvas}>
342        {m.at.showTree && <TreeColumn m={m} />}
343        {m.at.showTree && <Divider m={m} />}
344        <PreviewColumn m={m} />
345      </Box>
346    </Box>
347  )
348}
349
hooks/theme.ts 37 lines
1// GitHub's dark theme (Primer dark default). Translucent diff colors are flattened onto the canvas,
2// since a terminal cell takes one solid color.
3export const C = {
4  canvas: '#0d1117',
5  sidebar: '#010409',
6  border: '#30363d',
7  fg: '#e6edf3',
8  muted: '#7d8590',
9  subtle: '#484f58',
10  accent: '#2f81f7',
11  selected: '#21262d', // a row picked in the explorer, beside the accent bar
12  hover: '#161b22', // the row under the pointer
13  segment: '#1c3a5e', // the active half of the Diff | File switch
14  header: '#161b22',
15  controlHover: '#262c36', // a control under the pointer
16  controlPressed: '#30363d', // a control while pressed
17  track: '#21262d', // a scrollbar's rail
18  thumb: '#484f58', // its thumb at rest
19  thumbActive: '#8b949e', // under the pointer or dragged
20
21  addLine: '#12261e',
22  addNum: '#1c4428',
23  addWord: '#1f6034',
24  addFg: '#3fb950',
25  delLine: '#25171c',
26  delNum: '#542426',
27  delWord: '#7d2a2e',
28  delFg: '#f85149',
29  hunkLine: '#111d2e',
30  hunkFg: '#8b949e',
31
32  // VS Code's git decorations, for names in the tree.
33  gitModified: '#e2c08d',
34  gitAdded: '#73c991',
35  gitDeleted: '#c74e39',
36} as const
37
hooks/controls.tsx 111 lines
1// One row of the pane's chrome (the toolbar, a header, the status row), drawn by a surface module
2// so a control under the pointer takes a quiet background (a Button would invert), a press darkens
3// it, and a click posts its action. Once clicked, the row takes the pane's keys and posts those.
4import type { ClientModule } from 'claude-code'
5
6export type Part = { text: string; color?: string; bg?: string; bold?: boolean; strike?: boolean }
7
8// A run of text, a control (with an `action`), or a spacer that takes the room left over.
9export type Item = { parts: Part[]; action?: string; isOn?: boolean } | { spacer: true }
10
11export type ControlsProps = {
12  width: number
13  bg: string
14  items: Item[]
15  colors: { hover: string; pressed: string; on: string; fg: string }
16}
17
18// A click on a control, or a key pressed while the row holds the focus.
19export type ControlsMessage = { action: string } | { press: string }
20
21type State = { hover: number | null; down: number | null }
22
23export const cellsOf = (s: string) =>
24  [...s].reduce((n, ch) => {
25    const cp = ch.codePointAt(0) ?? 0
26    if (cp < 0x20 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f)) return n
27    const isWide =
28      (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
29      (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
30      (cp >= 0x1f300 && cp <= 0x1faff)
31    return n + (isWide ? 2 : 1)
32  }, 0)
33
34const widthOf = (item: Item) => ('spacer' in item ? 0 : item.parts.reduce((n, p) => n + cellsOf(p.text), 0))
35
36// Each item's [start, end) columns, the spacers sharing what the rest leave.
37export function spans(items: Item[], width: number): [number, number][] {
38  const fixed = items.reduce((n, item) => n + widthOf(item), 0)
39  const spacers = items.filter(item => 'spacer' in item).length
40  const spare = Math.max(0, width - fixed)
41  let at = 0
42  let given = 0
43  return items.map(item => {
44    let w = widthOf(item)
45    if ('spacer' in item) {
46      given++
47      w = given === spacers ? spare - Math.floor(spare / spacers) * (spacers - 1) : Math.floor(spare / spacers)
48    }
49    const span: [number, number] = [at, at + w]
50    at += w
51    return span
52  })
53}
54
55const ControlRow: ClientModule<ControlsProps, State> = (props, surface) => {
56  const { Text } = surface.elements
57  const state = surface.state ?? { hover: null, down: null }
58  const layout = spans(props.items, props.width)
59  const hit = (x: number, y: number) => {
60    if (y !== 0) return null
61    const i = layout.findIndex(([a, b]) => x >= a && x < b)
62    const item = props.items[i]
63    return item && !('spacer' in item) && item.action ? i : null
64  }
65  const set = (next: State) => {
66    if (next.hover !== state.hover || next.down !== state.down) surface.setState(next)
67  }
68
69  surface.onPointer(event => {
70    const at = hit(event.x, event.y)
71    if (event.type === 'leave') set({ hover: null, down: null })
72    else if (event.type === 'move' || event.type === 'enter') set({ ...state, hover: at })
73    else if (event.type === 'down' && event.button === 'left') set({ hover: at, down: at })
74    else if (event.type === 'up' && event.button === 'left') {
75      const item = at !== null && at === state.down ? props.items[at] : undefined
76      set({ hover: at, down: null })
77      if (item && !('spacer' in item) && item.action) surface.post({ action: item.action } satisfies ControlsMessage)
78    }
79  })
80  surface.onKey(event => {
81    if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies ControlsMessage)
82  })
83
84  return (
85    <Text backgroundColor={props.bg} wrap="truncate-end">
86      {props.items.map((item, i) => {
87        const [a, b] = layout[i]!
88        if ('spacer' in item) return <Text backgroundColor={props.bg}>{' '.repeat(b - a)}</Text>
89        const isActive = item.action !== undefined && (state.down === i || state.hover === i)
90        const lit = state.down === i ? props.colors.pressed : props.colors.hover
91        return (
92          <Text>
93            {item.parts.map(part => (
94              <Text
95                color={part.color ?? (item.isOn ? props.colors.on : props.colors.fg)}
96                backgroundColor={isActive && !part.bg ? lit : (part.bg ?? props.bg)}
97                weight={part.bold ? 'bold' : undefined}
98                strikethrough={part.strike}
99              >
100                {part.text}
101              </Text>
102            ))}
103          </Text>
104        )
105      })}
106    </Text>
107  )
108}
109
110export default ControlRow
111
hooks/scrollbar.tsx 102 lines
1// A thin scrollbar, vertical or horizontal, drawn by a surface module: the thumb follows a drag,
2// a click on the track jumps there, and once clicked it takes the arrow, page, Home and End keys.
3// It posts where the window should stand; the hooks module owns the offset.
4import type { ClientModule } from 'claude-code'
5
6export type BarProps = {
7  axis: 'v' | 'h'
8  length: number // cells along the bar
9  total: number // rows or columns of content
10  view: number // rows or columns the window shows
11  offset: number // where the window starts
12  colors: { track: string; thumb: string; active: string; bg: string }
13}
14
15// Where the window should stand, or a key the bar does not use, for the pane's keymap.
16export type BarMessage = { offset: number } | { press: string }
17
18type State = { isHot: boolean; grab: number | null } // grab: where on the thumb the drag took it
19
20export function thumbOf(p: Pick<BarProps, 'length' | 'total' | 'view' | 'offset'>) {
21  const max = Math.max(0, p.total - p.view)
22  if (max === 0 || p.length <= 0) return { start: 0, size: p.length, max }
23  const size = Math.max(1, Math.min(p.length, Math.round((p.length * p.view) / p.total)))
24  const start = Math.round(((p.length - size) * Math.min(p.offset, max)) / max)
25  return { start, size, max }
26}
27
28const Scrollbar: ClientModule<BarProps, State> = (props, surface) => {
29  const { Box, Text } = surface.elements
30  const state = surface.state ?? { isHot: false, grab: null }
31  const { start, size, max } = thumbOf(props)
32  const isV = props.axis === 'v'
33  const page = Math.max(1, props.view - 2)
34
35  // The offset whose thumb starts at `cell`, clamped to the content.
36  const offsetAt = (cell: number) => {
37    const room = props.length - size
38    return room <= 0 ? 0 : Math.max(0, Math.min(max, Math.round((cell / room) * max)))
39  }
40  const send = (offset: number) => {
41    const clamped = Math.max(0, Math.min(max, offset))
42    if (clamped !== props.offset) surface.post({ offset: clamped } satisfies BarMessage)
43  }
44
45  surface.onPointer(event => {
46    const at = isV ? event.y : event.x
47    if (event.type === 'enter' && !state.isHot) surface.setState({ ...state, isHot: true })
48    else if (event.type === 'leave' && state.grab === null && state.isHot) surface.setState({ ...state, isHot: false })
49    else if (event.type === 'down' && event.button === 'left' && max > 0) {
50      const onThumb = at >= start && at < start + size
51      const grab = onThumb ? at - start : Math.floor(size / 2)
52      surface.setState({ isHot: true, grab })
53      if (!onThumb) send(offsetAt(at - grab))
54    } else if (event.type === 'move' && state.grab !== null) send(offsetAt(at - state.grab))
55    else if (event.type === 'up' && state.grab !== null) surface.setState({ ...state, grab: null })
56  })
57
58  surface.onKey(event => {
59    const back = isV ? 'up' : 'left'
60    const fwd = isV ? 'down' : 'right'
61    const step = isV ? 1 : 4
62    if (event.key === back) send(props.offset - step)
63    else if (event.key === fwd) send(props.offset + step)
64    else if (event.key === 'pageup') send(props.offset - page)
65    else if (event.key === 'pagedown') send(props.offset + page)
66    else if (event.key === 'home') send(0)
67    else if (event.key === 'end') send(max)
68    else if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies BarMessage)
69  })
70
71  const thumbColor = state.grab !== null || state.isHot ? props.colors.active : props.colors.thumb
72  const cell = (i: number) => {
73    const isThumb = max > 0 && i >= start && i < start + size
74    if (isV) return isThumb ? '┃' : '│'
75    return isThumb ? '━' : '─'
76  }
77  const colorOf = (i: number) => (max > 0 && i >= start && i < start + size ? thumbColor : props.colors.track)
78
79  if (isV) {
80    return (
81      <Box flexDirection="column" width={1}>
82        {Array.from({ length: props.length }, (_, i) => (
83          <Text color={colorOf(i)} backgroundColor={props.colors.bg}>
84            {cell(i)}
85          </Text>
86        ))}
87      </Box>
88    )
89  }
90  const before = max > 0 ? start : 0
91  const thumb = max > 0 ? size : 0
92  return (
93    <Text backgroundColor={props.colors.bg}>
94      <Text color={props.colors.track}>{'─'.repeat(before)}</Text>
95      <Text color={thumbColor}>{'━'.repeat(thumb)}</Text>
96      <Text color={props.colors.track}>{'─'.repeat(Math.max(0, props.length - before - thumb))}</Text>
97    </Text>
98  )
99}
100
101export default Scrollbar
102
hooks/sash.tsx 69 lines
1// The line between the explorer and the preview, drawn by a surface module so it can be dragged
2// as VS Code's sash is: it lights in the accent color under the pointer, a drag posts the explorer's
3// new width, a double click puts it back to the default, and once clicked ← → resize it too.
4import type { ClientModule } from 'claude-code'
5
6export type SashProps = {
7  height: number
8  treeWidth: number // the explorer's width now, the sash standing just right of it
9  min: number
10  max: number
11  colors: { line: string; hot: string; bg: string }
12}
13
14// `width: null` puts the default back; `isDone` marks the end of a drag, the width to keep.
15export type SashMessage = { width: number | null; isDone: boolean } | { press: string }
16
17// `lastClick`: when the last press that did not move ended, for telling a double click.
18type State = { isHot: boolean; isDragging: boolean; hasMoved: boolean; lastClick: number }
19
20const DOUBLE_CLICK_MS = 400
21const KEY_STEP = 2
22
23const Sash: ClientModule<SashProps, State> = (props, surface) => {
24  const { Box, Text } = surface.elements
25  const state = surface.state ?? { isHot: false, isDragging: false, hasMoved: false, lastClick: 0 }
26  const clamp = (n: number) => Math.max(props.min, Math.min(props.max, n))
27  const send = (width: number | null, isDone: boolean) => surface.post({ width, isDone } satisfies SashMessage)
28
29  surface.onPointer(event => {
30    if (event.type === 'enter' && !state.isHot) surface.setState({ ...state, isHot: true })
31    else if (event.type === 'leave' && !state.isDragging && state.isHot) surface.setState({ ...state, isHot: false })
32    else if (event.type === 'down' && event.button === 'left') surface.setState({ ...state, isHot: true, isDragging: true, hasMoved: false })
33    else if (event.type === 'move' && state.isDragging) {
34      // The region sits where the explorer ends, so the pointer's column is how far to move it.
35      const next = clamp(props.treeWidth + event.x)
36      if (next !== props.treeWidth) {
37        if (!state.hasMoved) surface.setState({ ...state, hasMoved: true })
38        send(next, false)
39      }
40    } else if (event.type === 'up' && state.isDragging) {
41      const now = Date.now()
42      const isClick = !state.hasMoved
43      const isDouble = isClick && now - state.lastClick < DOUBLE_CLICK_MS
44      surface.setState({ isHot: event.x === 0, isDragging: false, hasMoved: false, lastClick: isClick && !isDouble ? now : 0 })
45      if (isDouble) send(null, true)
46      else if (!isClick) send(props.treeWidth, true)
47    }
48  })
49
50  surface.onKey(event => {
51    if (event.key === 'left') send(clamp(props.treeWidth - KEY_STEP), true)
52    else if (event.key === 'right') send(clamp(props.treeWidth + KEY_STEP), true)
53    else if (!event.ctrl && !event.meta) surface.post({ press: event.key } satisfies SashMessage)
54  })
55
56  const isLit = state.isHot || state.isDragging
57  return (
58    <Box flexDirection="column" width={1}>
59      {Array.from({ length: props.height }, () => (
60        <Text color={isLit ? props.colors.hot : props.colors.line} backgroundColor={props.colors.bg}>
61          {isLit ? '┃' : '│'}
62        </Text>
63      ))}
64    </Box>
65  )
66}
67
68export default Sash
69