Review the diff of what changed in the folders Claude works in since you last looked, write a note on any line or hunk, and get your notes in the prompt to…

Review the diff of what changed since you last looked, write a note on any line or hunk, and get your notes in the prompt to edit and send back.
difnote is a diff and a note: you read what changed, and you say what you think of it, anchored to the line it's about. Its icon says the same: a removed line, an added one, and a note on it.

When Claude has changed a few files, the feedback you want to give is usually about specific lines: "name this", "keep that guard", "why this file?". Typing that into the prompt means quoting the code by hand. /difnote opens a pane with one diff per file, everything that changed in the folders Claude works in since your last review: Claude's edits, its shell commands, its subagents, a formatter, or you. Move to a line, a hunk or a file and press Enter to write a note; press s, and the notes go into the prompt box after whatever you've typed, each with its place and the code it's about:
Notes on the changes:
src/api/client.ts:42
> const timeout = 30;
Name this and read it from config.
src/db/pool.ts:12-14
> - const pool = new Pool(opts)
> + const pool = createPool(config)
Keep the old name; callers rely on it.
You edit them, add what you want Claude to do, and send them yourself. difnote never sends anything to Claude.
/plugin install difnote --marketplace mmedum/difnote
difnote needs git on your machine, but your folders don't have to be git repositories. Tested with Claude Code 2.1.296 and git 2.55 in a terminal. Mods don't draw in the VS Code panel, or with -p.
Third-party marketplaces don't update on their own. To update, run claude plugin update difnote@difnote and restart Claude Code. To remove difnote, run claude plugin uninstall difnote@difnote.
/difnote opens its pane with the keyboard. With the pane open, behind another tab or with the keyboard in the prompt box, it gives the pane the keyboard again, where you left it; so does ctrl+x tab, also when the prompt box holds text, where /difnote can't be typed. Esc steps back one level at a time, out of a note, then out of the open file, and at the top gives the keyboard back to the prompt box, the pane staying open. q closes the pane. Claude Code puts every mod's pane in the same dock beside the transcript (in a wide fullscreen terminal; otherwise above the prompt), one at a time as tabs: ctrl+x tab switches between them. difnote asks for a dock 100 columns wide, but a width you set yourself wins; ctrl+x ← widens the dock, ctrl+x → narrows it again.
l opens a file's hunks right under its row, and closes the file open before, as one file is open at a time. Each hunk sits under a header, drawn as Claude Code's own highlighted diff, long lines wrapped after the line numbers, each note right under the line it's on. h or Esc closes the file, the cursor back on its row; n opens the next file. Scrolled down a long file, its row stays at the top.j and k, or the arrows, move it over the files, the folder headings, and in the open file its hunk headers and changed lines; the page keys move it a window at a time, Home and End to the first or last. The wheel scrolls, taking the cursor along. Enter acts on the row the cursor is on, and the first key at the bottom says what it does: open or close a file, fold or unfold a folder, write a note.w cuts every line to one row but the cursor's, which always shows whole. A line longer than twelve rows is cut, marked ….c, or Enter on a hunk or a line, writes one on the row the cursor is on: a file, a hunk or a line, its field opening right under it. Enter saves it, saving it empty removes it, and Esc cancels, leaving the note as it was. Notes you haven't put in the prompt box yet stay through /clear and restarts, kept for the folder the session runs in.| Key | Does |
|---|---|
| Enter | what the first key at the bottom says: open or close a file, fold a folder, write a note |
j / k, ↓ ↑ | the next or previous file, folder, hunk or changed line |
l / h | in or out: open a file or a folder; close the open file, go to its folder, fold it |
n | close the open file and open the next |
v | mark the file viewed, or not; it closes, and the cursor goes to the next not viewed |
c | write a note on the row the cursor is on |
w | cut lines to one row, or wrap them again |
s | put the notes into the prompt box, after your draft |
r | with no notes waiting, mark everything shown reviewed, after asking |
x | on a folder from earlier sessions, stop watching it from here |
q | close the pane |
| Esc | back one level: cancel a note, close the open file; at the top, back to the prompt box |
The keys at the bottom are the ones that work on the row the cursor is on.
cd. difnote records each such folder before Claude's first change there. A git repository inside a watched folder is watched on its own. The pane looks when it opens, after each of Claude's tool calls, and when you move in it after a pause; while it's open and in front, it also looks by itself 10 seconds after its last look, so changes you, a script or another session make show too. A look by itself first asks git whether anything is new, with two commands that only read, and records a folder only if something is. A folder whose look took over 3 seconds twice is looked at only after Claude's tool calls or when you move in the pane, and the pane says so. While you write a note, the pane waits, and looks once you save it or leave it.cded into, has nothing to review and isn't kept. Once a look finds nothing left to review in one, sessions here leave it out until it changes again. The pane names these folders under "From earlier sessions here"; Enter on one opens its newest file, and x on one stops watching it from here without marking its changes reviewed, so a session in that folder itself still shows them..gitignore leaves out..env, .env.*, *.pem, *.key, *.p12, *.pfx, id_rsa and its kin, .netrc, .pgpass) show as changed, with their sizes, but without lines: difnote keeps no copy of the last two. A large or secret file whose time changed but whose content didn't shows as changed too, since difnote compares only its size and time. When very much changed at once, a file whose lines didn't fit in what git can hand difnote in one go says so, and shows its lines once the rest is reviewed.edit_document, format_document, edit_table, insert_object, layout_document, manage_tabs, delete_tab, review_suggestion or create_document) shows as one row, by its title once Claude has read it. Opening it says how many edits were made as suggestions, directly or as comments, with its link: you review it in Google Docs, where suggestions wait for you to accept or reject them. A note on it goes into the prompt with its title and link.v marks the file the cursor is on, or the one open, viewed: its row goes quiet with a ✓, an open file closes, and the cursor goes on to the next file not yet viewed. The mark drops by itself once the file changes again, as when Claude acts on your notes, so the file shows again with what changed. v on a viewed file unmarks it. Marks stay through /clear and restarts, kept for the folder the session runs in.r and answer Mark reviewed (a review can't be undone). Sending your notes, your other prompts and slash commands leave the list as it is, so you can go on reviewing while Claude works. What the pane showed counts as reviewed, also what it picked up by itself while it was open; anything that changed after its last look shows next time. The reviewed state belongs to the folder, so it holds across sessions: another session's review in the same folder moves it for both.- and +, and one on a file gives its path. When you press s, difnote reads each file you wrote notes on and numbers each note where its code is now, so lines added or removed since the pane looked don't throw the numbers off. A note on a line the file no longer has says (since changed), numbered by the lines left around it. If a dialog holds the prompt box, the notes stay in the pane and a message says so. With the notes in the prompt box, ctrl+x tab goes back into the pane (with more than one mod's pane open, press it until difnote's tab is in front).Set these in /config (type difnote to find them) or with /plugin configure difnote@difnote. A color takes a theme role, such as success or inactive, or a color: #7aa2f7, rgb(122,162,247), ansi256(110) or ansi:cyan. A value difnote can't read leaves the default.
success by default): how many lines a file or hunk adds.error by default): how many lines it removes.suggestion by default): your notes in the pane.claude by default): the bar beside the row the cursor is on.inactive by default): folder headings, hints and line numbers.The code itself takes Claude Code's own diff colors, as in /diff.
Mods run with your permissions, so here is everything difnote touches. claude plugin validate lists the same.
For each folder it watches, difnote keeps a private git folder in Claude Code's data folder for difnote, and uses it to record the folder: once before Claude first changes it, and again each time the pane looks and finds something new. The pane shows the difference from the state you last reviewed. Your folder doesn't need to be a git repository. If it is one, difnote only reads it and never writes to your .git, index, branches, stash or hooks.
git, and only git. difnote starts no shell, and never runs a command it read in the conversation: a shell command of Claude's is only searched for the folder paths it names. Each command is written out in full in hooks/register.tsx; the paths and ids that differ from call to call go on its standard input, never on its command line. Your own git settings are switched off for difnote's commands (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1, and no settings given through the environment), except to ask for your global ignore file, so they can't change what difnote records. Each command also names the git folder it works on, its work tree and its index in git's own variables (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE and their kin), so none that Claude Code was started with can point it elsewhere.
| Command | Runs | Why |
|---|---|---|
git --version | when a session starts | to know whether git is there; without it, only Claude's Edit and Write show |
git config --global --path --get core.excludesFile | when a session starts | your own global ignore file, which difnote's records follow too |
git init --quiet | the first time difnote watches a folder | to make its private git folder |
git --no-optional-locks ls-files -z --cached --ignored --exclude-standard | each time a git repository is recorded | the files your repository tracks though its .gitignore or your own ignore file matches them, so they are recorded too; it only reads |
git ls-files -z --others --exclude-standard | each time a folder is recorded, and on each look by itself | the files that are new since the last record, and not ignored; on a look by itself, whether there are any; it only reads |
git diff-files -z --name-only | each time a folder is recorded, and on each look by itself | the files that changed since the last record; on a look by itself, whether there are any; it only reads |
git update-index -z --add --remove --stdin | each time a folder is recorded | to record those files, up to 1 MiB each |
git update-index -z --force-remove --stdin | when a file became a folder | to take it out of the record |
git fast-import --quiet --done | for a file over 1 MiB, named like a secret, or unreadable | a placeholder with its size and time, in place of its content |
git update-index -z --index-info | after fast-import | to record those placeholders |
git write-tree | each time a folder is recorded | to keep the record as one state |
git update-ref --stdin | after each record, when a review ends, for a folder unused 30 days | to point at the state shown and the state reviewed, or let them go |
git rev-parse --verify --quiet refs/difnote/base | when a session first watches a folder | whether it has a reviewed state already |
git diff-tree … refs/difnote/base refs/difnote/shown -- | when the pane looks | the difference between the reviewed state and now |
git cat-file --batch-check | for files without lines | their sizes |
git check-ignore -z --stdin | when the pane looks | which of Claude's Edit and Write files a watched folder ignores |
git read-tree --empty | for a folder unused for 30 days | to empty its record |
git gc --quiet --prune=1.hour.ago | once a day per folder, and for a folder unused for 30 days | to pack the records and let go of states no review needs |
A folder is recorded when the pane opens, after Claude's tool calls, when you move in the pane after a pause, and when a look by itself finds something new. A look by itself comes 10 seconds after the last look ended, or 20 times as long as that look took, if longer, and only while the pane is open and in front: for each folder it runs the two commands that only read, and records nothing where nothing is new.
All but version, config and the one that reads your repository work on difnote's private git folder, which git's own variables name, and run from /, never from inside a folder difnote watches, where git would read a path as that folder's file. Whether a folder is in a git repository, and where its top is, difnote finds by looking for a .git folder or file above it, without running git. Git runs with Claude Code's own rule for mods: your repository's hooks are off.
Nothing leaves your machine. difnote hands data to two places, both on your machine:
git, which it starts ($.process.run) with the commands above: the paths of the files to record, on its standard input, and the files themselves, which git reads from your folder into difnote's private git folder. None of these commands contacts a remote or the network; there is no fetch, push, clone or remote among them.s ($.prompt.fill): your notes, with the lines they quote. You send them yourself.What it reads of the conversation (the paths and results of Claude's file edits, the paths in its shell commands, a Google Doc's title and link) only decides which folders git records and what the pane shows.
$.fs.list, $.fs.stat). Of your own repositories: whether a folder holds a .git folder or file, to find where a repository starts; a .git file's and the git folder's commondir, for a work tree or a submodule; .git/info/exclude, which it copies into its private git folder so its records ignore what yours do; and, through git, the list of files the repository tracks. It writes nothing there.$.session.messages): each file's path, the file before the change and after it, and the change's hunks. They stand in for files in folders difnote doesn't watch.get_document answers: each document's link, the mode an edit was made in, and the document's title.s, only to tell whether it's empty, so the notes go after your draft with a blank line between.s ($.fs.read), to number each note where its code is in the file now.refs/difnote/base, or packed-refs once git packed it), so a look by itself notices a review made in another session.$.fs.stat); the names are in the code only so difnote can leave such files out. Of your git settings, it asks only for the path of your global ignore file.hooks/register.tsx 2033 lines1// difnote lists what changed in the folders Claude works in since you last
2// reviewed, one diff per file, lets you write a note on a line, a hunk or a
3// file, and puts the notes into the prompt box for you to edit and send.
4
5import type { EngineInterface, ProcessRunResult, Register, Timer } from 'claude-code'
6import { atom, read, update } from 'claude-code'
7import type { Change, FileDiff, Note, Place, Watch } from '../types'
8import type { Places } from './folders'
9import { isInside, parentOf, pathsIn, placesOf, refusalOf, slugOf } from './folders'
10import type { Entry } from './git'
11import {
12 ENV,
13 MOST_FILES,
14 MOST_KEPT,
15 blobsOf,
16 configOf,
17 entriesOf,
18 envOf,
19 excludeOf,
20 fileOf,
21 gitdirOf,
22 isSecret,
23 placeholderOf,
24 repoEnvOf,
25 sizesOf,
26 sizesWanted,
27 under,
28} from './git'
29import { colorOf } from './paint'
30import { ENTER, FIELD, FOLDER, groupsOf, layoutOf, paneOf } from './pane'
31import type { Actions, Colors, Item, Layout, PaneView } from './pane'
32import { anchorOf, changeOf, docOf, filesOf, fingerprintOf, isRecord, promptOf, rowsOf, titleOf } from './review'
33import type { Row } from './review'
34
35const NAME = 'difnote'
36const PANE = 'difnote'
37
38const diffs = atom({ plugin: 'difnote', key: 'diffs' } as const, [])
39const watch = atom({ plugin: 'difnote', key: 'watch' } as const, {
40 hasGit: false,
41 since: null,
42 looking: [],
43 skipped: [],
44 slow: [],
45 carried: [],
46 lost: [],
47 roots: [],
48 home: null,
49})
50const changes = atom({ plugin: 'difnote', key: 'changes' } as const, [])
51const notes = atom({ plugin: 'difnote', key: 'notes' } as const, [])
52const editing = atom({ plugin: 'difnote', key: 'editing' } as const, null)
53const place = atom({ plugin: 'difnote', key: 'place' } as const, { open: null, cursor: null, top: 0, isCompact: false, closed: [] })
54const viewed = atom({ plugin: 'difnote', key: 'viewed' } as const, {})
55
56// The room the pane asks for: columns docked beside the transcript, rows
57// seated above the prompt.
58const DOCK_COLUMNS = 100
59const INLINE_ROWS = 30
60
61// Where each session's last review of Claude's Edit and Write records ended:
62// the id of the newest change it covered, by session id, for the sessions
63// reviewed last.
64const BASELINES = 'baselines'
65const KEPT_BASELINES = 100
66
67// The notes not yet in the prompt box, kept through /clear and restarts,
68// by the folder of the session they were written in: a session in another
69// folder has its own.
70const NOTES = 'notes'
71
72// The files marked viewed, kept like the notes: by the session's folder.
73const VIEWED = 'viewed'
74
75// How long Claude's change waits for difnote to record a folder it hasn't
76// recorded before; and how old a look may be when a key is pressed in the
77// pane, as one back from behind another, before it looks again.
78const WAIT_MS = 5_000
79const STALE_MS = 2_000
80
81// An open pane looks again by itself, for changes no hook sees (yours, a
82// script's, another session's), POLL_MS after its last look ended, or PACE
83// times as long as that look took, if longer. A folder whose look took over
84// SLOW_MS SLOW_TIMES times is left to the looks Claude's changes and your
85// keys make.
86const POLL_MS = 10_000
87const PACE = 20
88const SLOW_MS = 3_000
89const SLOW_TIMES = 2
90
91// The answer to r's question that marks everything shown reviewed.
92const SURE = 'Mark reviewed'
93
94// The session's folder, which paths are written relative to; what the pane
95// has shown since the review began (the newest Edit or Write record, and
96// each folder's state); the last look and whether one is under way; the
97// rows j and k walk as last drawn (a render can't write state, so they are
98// kept here); and the element the focus ring is on.
99let cwd = ''
100let shownUpTo: string | null = null
101let shownTrees = new Map<string, string>()
102let looked: Looked | null = null
103let lookedAt = 0
104let ignored: ReadonlySet<string> = new Set()
105// The files Claude changed before difnote had their folder's record.
106let early: ReadonlySet<string> = new Set()
107// The titles of the Google Docs Claude read, by link.
108let titles: ReadonlyMap<string, string> = new Map()
109let isLooking = false
110let isAgain = false
111// Whether the pane is open; the look by itself it waits for; whether one
112// waited while a note was being written; and how long the last look took.
113let isOpen = false
114let timer: Timer | null = null
115let isHeld = false
116let lastLookMs = 0
117// How many reviews ended, or folders were let go of with x, this session,
118// so a look under way when one does knows its result is out of date.
119let reviews = 0
120// The list as last drawn: the keys and the wheel move the cursor over it.
121let layout: Layout | null = null
122
123// The changes to keep: each file's after-text only on its latest change,
124// the one the diff reads it from.
125function kept(list: readonly Change[]): Change[] {
126 const later = new Set<string>()
127 return list
128 .toReversed()
129 .map(change => {
130 const isLatest = !later.has(change.path)
131 later.add(change.path)
132 return isLatest ? change : { ...change, after: null }
133 })
134 .toReversed()
135}
136
137// The files as the pane lists them: those of the watched folders, then those
138// Claude changed elsewhere, or that a watched folder ignores, by its Edit and
139// Write records. Worked out again only when one of them changes: a redraw
140// for a key or a note costs no diff.
141let listed: {
142 diffs: readonly FileDiff[] | null
143 changes: readonly Change[] | null
144 ignored: ReadonlySet<string>
145 early: ReadonlySet<string>
146 titles: ReadonlyMap<string, string>
147 files: FileDiff[]
148} = { diffs: null, changes: null, ignored, early, titles, files: [] }
149function listedFiles(found: readonly FileDiff[], list: readonly Change[]): FileDiff[] {
150 if (listed.diffs !== found || listed.changes !== list || listed.ignored !== ignored || listed.early !== early || listed.titles !== titles) {
151 const records = filesOf(
152 list.filter(change => !isWatched(change.path) || ignored.has(change.path) || early.has(change.path)),
153 cwd,
154 titles,
155 )
156 const byRecord = new Set(records.map(one => one.path))
157 listed = { diffs: found, changes: list, ignored, early, titles, files: [...found.filter(one => !byRecord.has(one.path)), ...records] }
158 }
159 return listed.files
160}
161
162async function filesNow($: EngineInterface): Promise<FileDiff[]> {
163 return listedFiles(await read($, diffs), await read($, changes))
164}
165
166// The rows of the files listed, worked out again only when they change: a
167// key that moves the cursor redraws without them.
168let rowsFor: { files: readonly FileDiff[] | null; rows: Row[] } = { files: null, rows: [] }
169function rowsOfFiles(files: readonly FileDiff[]): Row[] {
170 if (rowsFor.files !== files) {
171 rowsFor = { files, rows: rowsOf(files) }
172 }
173 return rowsFor.rows
174}
175
176// Claude's changes since the last review, from the conversation as Claude
177// Code stored it: at the start, and after /clear, /resume or /branch.
178async function readChanges($: EngineInterface) {
179 const baselines = await $.store.get(BASELINES).catch(() => undefined)
180 const ends = new Set(Object.values(isRecord(baselines) ? baselines : {}).filter(id => typeof id === 'string'))
181 const messages = await $.session.messages().catch(() => [])
182 const uses = messages.flatMap(message => (message.role === 'assistant' ? message.toolUses : []))
183 for (const use of uses) {
184 learnTitle(use.tool, use.result)
185 }
186 const all = uses.flatMap(use => {
187 const change =
188 use.isError === true
189 ? null
190 : (changeOf(use.tool_use_id, use.tool, use.result) ?? docOf(use.tool_use_id, use.tool, use.input, use.result, titles))
191 return change === null ? [] : [change]
192 })
193 // Where a review in this conversation ended: under this session's id, or,
194 // after /branch, the session it branched from. Tool-use ids are unique,
195 // so any stored end found here is this conversation's.
196 const from = all.findLastIndex(change => ends.has(change.id)) + 1
197 await update($, changes, () => kept(all.slice(from)))
198}
199
200function learnTitle(tool: string, result: unknown) {
201 const found = titleOf(tool, result)
202 if (found !== null && titles.get(found[0]) !== found[1]) {
203 titles = new Map([...titles, found])
204 }
205}
206
207// The notes not yet sent, as the store keeps them.
208async function readNotes($: EngineInterface) {
209 const stored = await $.store.get(NOTES).catch(() => undefined)
210 const own = isRecord(stored) ? stored[cwd] : undefined
211 await update($, notes, () => (Array.isArray(own) ? own.filter(isNote) : []))
212}
213
214function isNote(value: unknown): value is Note {
215 return (
216 isRecord(value) &&
217 typeof value.target === 'string' &&
218 typeof value.path === 'string' &&
219 typeof value.anchor === 'string' &&
220 Array.isArray(value.quote) &&
221 value.quote.every(line => typeof line === 'string') &&
222 typeof value.text === 'string'
223 )
224}
225
226// The files marked viewed, as the store keeps them.
227async function readViewed($: EngineInterface) {
228 const stored = await $.store.get(VIEWED).catch(() => undefined)
229 const own = isRecord(stored) ? stored[cwd] : undefined
230 const marks = isRecord(own) ? Object.entries(own).filter((entry): entry is [string, string] => typeof entry[1] === 'string') : []
231 await update($, viewed, () => Object.fromEntries(marks))
232}
233
234// Sets the files marked viewed, in the pane and in the store.
235async function setViewed($: EngineInterface, marks: Record<string, string>) {
236 await update($, viewed, () => marks)
237 const stored = await $.store.get(VIEWED).catch(() => undefined)
238 const others = Object.entries(isRecord(stored) ? stored : {}).filter(([folder]) => folder !== cwd)
239 const isEmpty = Object.keys(marks).length === 0
240 await $.store.set(VIEWED, Object.fromEntries([...others, ...(isEmpty ? [] : [[cwd, marks] as const])])).catch(() => undefined)
241}
242
243// Changes the notes, in the pane and in the store.
244async function setNotes($: EngineInterface, change: (list: Note[]) => Note[]) {
245 let now: Note[] = []
246 await update($, notes, list => {
247 now = change(list)
248 return now
249 })
250 const stored = await $.store.get(NOTES).catch(() => undefined)
251 const others = Object.entries(isRecord(stored) ? stored : {}).filter(([folder]) => folder !== cwd)
252 await $.store.set(NOTES, Object.fromEntries([...others, ...(now.length === 0 ? [] : [[cwd, now] as const])])).catch(() => undefined)
253}
254
255// Looks at the watched folders again; a look asked for while one is under
256// way runs after it. A look by itself (`isTimer`) records only the folders
257// git says changed, and redraws only when one did. The next look by itself
258// is set from when this one ends.
259async function refresh($: EngineInterface, isTimer = false) {
260 if (isLooking) {
261 isAgain = true
262 return
263 }
264 isLooking = true
265 const from = reviews
266 const startedAt = await $.clock.now()
267 let now: Looked
268 let ignoring: ReadonlySet<string>
269 try {
270 const paths = (await read($, changes)).map(change => change.path)
271 ;[now, ignoring] = await Promise.all([look($, isTimer), isTimer ? ignored : ignoredOf($, paths)])
272 } finally {
273 isLooking = false
274 }
275 lookedAt = await $.clock.now()
276 lastLookMs = lookedAt - startedAt
277 // A review that ended during the look made it out of date, states the
278 // pane never showed: it is dropped, and the look made again.
279 if (reviews !== from) {
280 isAgain = false
281 await refresh($)
282 return
283 }
284 const isSame = looked !== null && isSameLook(looked, now)
285 looked = now
286 ignored = ignoring
287 if (!isSame) {
288 const hadFiles = (await filesNow($)).length > 0
289 await update($, diffs, () => filesOfLook(now))
290 void parkWhenListed($, hadFiles).catch(() => undefined)
291 await askEscape($)
292 }
293 const said = watchOf()
294 if (JSON.stringify(said) !== JSON.stringify(await read($, watch))) {
295 await update($, watch, () => said)
296 }
297 if (isAgain) {
298 isAgain = false
299 await refresh($)
300 return
301 }
302 schedule($)
303}
304
305// Whether a look found each folder as the last did.
306function isSameLook(was: Looked, now: Looked): boolean {
307 return (
308 was.files.size === now.files.size &&
309 [...now.files].every(([folder, files]) => was.files.get(folder) === files) &&
310 was.failed.length === now.failed.length &&
311 now.failed.every((one, i) => was.failed[i]?.folder === one.folder && was.failed[i]?.why === one.why)
312 )
313}
314
315// Sets the open pane's next look by itself, in place of any set before.
316function schedule($: EngineInterface) {
317 timer?.cancel()
318 timer = isOpen ? $.clock.after(Math.max(POLL_MS, PACE * lastLookMs), () => void tick($).catch(() => undefined)) : null
319}
320
321// The look by itself: none while one is under way (it sets the next); while
322// the pane is behind another tab, or a note is being written, the next is
323// set instead; a closed pane's ends.
324async function tick($: EngineInterface) {
325 timer = null
326 if (!isOpen || isLooking) {
327 return
328 }
329 if (!(await isShown($))) {
330 schedule($)
331 } else if ((await read($, editing)) !== null) {
332 isHeld = true
333 schedule($)
334 } else {
335 // A look that failed sets no next one: set here, the looks go on.
336 await refresh($, true).finally(() => {
337 if (timer === null) {
338 schedule($)
339 }
340 })
341 }
342}
343
344// Closes the note field; a look by itself held while it was open is made.
345async function closeField($: EngineInterface) {
346 await update($, editing, () => null)
347 await askEscape($)
348 if (isHeld) {
349 isHeld = false
350 if (isOpen) {
351 void refresh($, true).catch(() => undefined)
352 }
353 }
354}
355
356// The pane opened or closed: its looks by themselves start or stop.
357function setOpen($: EngineInterface, open: boolean) {
358 isOpen = open
359 if (!open) {
360 isHeld = false
361 }
362 schedule($)
363}
364
365async function isShown($: EngineInterface): Promise<boolean> {
366 const pane = (await $.ui.panes().catch(() => [])).find(one => one.id === PANE)
367 return pane?.isShown === true
368}
369
370// Ends the review of Claude's Edit and Write records the pane showed: the
371// changes it covered leave the list, and where it ended is kept for a later
372// /resume.
373async function endReview($: EngineInterface, upTo: string) {
374 let isEnded = false
375 await update($, changes, list => {
376 const at = list.findIndex(change => change.id === upTo)
377 isEnded = at >= 0
378 return at < 0 ? list : list.slice(at + 1)
379 })
380 const sessionId = await $.session.id().catch(() => null)
381 if (!isEnded || sessionId === null) {
382 return
383 }
384 const was = await $.store.get(BASELINES).catch(() => undefined)
385 const others = Object.entries(isRecord(was) ? was : {}).filter(([id]) => id !== sessionId)
386 await $.store.set(BASELINES, Object.fromEntries([...others.slice(-(KEPT_BASELINES - 1)), [sessionId, upTo]])).catch(() => undefined)
387}
388
389// Marks what the pane showed reviewed: each folder as it was shown, and
390// Claude's records up to the newest shown; or what it showed when r was
391// pressed (`shown`). What changed since stays.
392async function markReviewed(
393 $: EngineInterface,
394 shown: { upTo: string | null; trees: ReadonlyMap<string, string> } = { upTo: shownUpTo, trees: shownTrees },
395) {
396 const { upTo, trees } = shown
397 reviews += 1
398 shownUpTo = null
399 shownTrees = new Map()
400 early = new Set()
401 if (trees.size > 0) {
402 // Only folders whose reviewed state moved leave the list: one git
403 // couldn't move shows its changes until the next review.
404 const done = await reviewed($, trees)
405 await update($, diffs, list => list.filter(one => !done.some(folder => isInside(one.path, folder))))
406 await update($, watch, () => watchOf())
407 }
408 if (upTo !== null) {
409 await endReview($, upTo)
410 }
411 // What was marked viewed is reviewed now.
412 await setViewed($, {})
413 await askEscape($)
414 if (await isShown($)) {
415 void refresh($).catch(() => undefined)
416 }
417}
418
419// r: asks first, as a review can't be undone; the keyboard goes back to
420// the pane after.
421async function confirmReviewed($: EngineInterface) {
422 // What the question is about: what the pane shows now, not what it may
423 // show by the answer.
424 const shown = { upTo: shownUpTo, trees: shownTrees }
425 const count = (await filesNow($)).length
426 const question = count === 1 ? 'Mark the 1 file shown reviewed?' : `Mark all ${count} files shown reviewed?`
427 const answer = await $.ui.ask(question, { options: [SURE, 'Cancel'], header: 'difnote' }).catch(() => '')
428 await openPane($).catch(() => undefined)
429 if (answer === SURE) {
430 await markReviewed($, shown)
431 }
432}
433
434// x: the outside folder the cursor is on, else the only one carried from
435// earlier sessions, no longer watched from here. Its changes leave the pane
436// and this review, unreviewed, and later sessions here leave it out until
437// Claude changes it again; a session in the folder itself still shows them.
438async function dropFolder($: EngineInterface) {
439 const list = (await read($, watch)).carried.map(one => one.folder)
440 const at = layout?.cursor ?? null
441 const target = at?.startsWith(FOLDER) === true ? at.slice(FOLDER.length) : list.length === 1 ? list[0] : undefined
442 if (target === undefined || !carried.has(target)) {
443 $.ui.toast('Move to a folder under "From earlier sessions here" first.')
444 return
445 }
446 const gone = new Set([...active].filter(folder => isInside(folder, target) && isOutside(folder)))
447 // A look under way still holds the folder: it is made again without it.
448 reviews += 1
449 for (const folder of gone) {
450 active.delete(folder)
451 carried.delete(folder)
452 quiet.delete(folder)
453 const one = times[folder]
454 if (one !== undefined) {
455 times[folder] = { ...one, from: one.from.filter(from => from !== cwd) }
456 }
457 }
458 await saveTimes($)
459 const keep = <T,>(map: ReadonlyMap<string, T>) => new Map([...map].filter(([folder]) => !gone.has(folder)))
460 shownTrees = keep(shownTrees)
461 if (looked !== null) {
462 const now: Looked = { ...looked, trees: keep(looked.trees), files: keep(looked.files) }
463 looked = now
464 await update($, diffs, () => filesOfLook(now))
465 }
466 await update($, watch, () => watchOf())
467 await askEscape($)
468 await park($)
469}
470
471// Puts the notes into the prompt box after what is there, a blank line
472// between, and lets them go once the box holds them. Under a dialog the box
473// takes nothing, and the notes stay.
474async function sendNotes($: EngineInterface, rows: readonly Row[]) {
475 const list = await read($, notes)
476 if (list.length === 0) {
477 return
478 }
479 // The files the notes are on, as they are now, so each note points where
480 // its line stands; a file that can't be read keeps the numbers it had. A
481 // note on a whole file has no number, and its file isn't read: one named
482 // like a secret never is.
483 const paths = [...new Set(list.filter(note => !note.target.startsWith('file:')).map(note => note.path))]
484 const reads = await Promise.all(paths.map(async path => [path, await $.fs.read(path).catch(() => null)] as const))
485 const files = new Map(reads.flatMap(([path, text]) => (typeof text === 'string' ? [[path, text] as const] : [])))
486 const draft = await $.prompt.read()
487 const text = `${draft.text.trim() === '' ? '' : '\n\n'}${promptOf(list, rows, files)}`
488 // oxlint-disable-next-line unicorn/no-array-fill-with-reference-type -- the prompt box's fill, not an array's
489 const filled = await $.prompt.fill({ text, mode: 'append' })
490 if (filled.isFilled) {
491 await setNotes($, () => [])
492 } else {
493 $.ui.toast("The prompt box can't take the notes right now; they are kept.")
494 }
495}
496
497// Writes, rewrites or, empty, removes the note on a row; the focus goes back
498// to the row.
499async function saveNote($: EngineInterface, row: Row, text: string) {
500 const words = text.trim()
501 const { anchor, quote } = anchorOf(row)
502 const note: Note = { target: row.key, path: row.file.path, anchor, quote, text: words }
503 await setNotes($, list => {
504 const others = list.filter(one => one.target !== row.key)
505 if (words === '') {
506 return others
507 }
508 const at = list.findIndex(one => one.target === row.key)
509 return at < 0 ? [...list, note] : list.map(one => (one.target === row.key ? note : one))
510 })
511 await closeField($)
512 await park($)
513}
514
515// /difnote gives the pane the keyboard, opening it, bringing it to the
516// front of the dock or taking the keyboard back from the prompt, and looks
517// again. q closes the pane (see closePane).
518async function showPane($: EngineInterface) {
519 await openPane($)
520 setOpen($, true)
521 void refresh($).catch(() => undefined)
522}
523
524async function openField($: EngineInterface, row: Row) {
525 await update($, editing, () => row.key)
526 await askEscape($)
527 await $.ui.focus({ requestId: PANE, key: FIELD }).catch(() => undefined)
528}
529
530// Opens the pane with the keyboard, on the button Enter presses, or on a
531// note field left open: none is drawn for Enter while one is, and a park
532// would wait for it, the keyboard at the prompt meanwhile. Esc steps back
533// (see askEscape). Docked, the pane asks for room for code; a width the
534// person set the dock to wins.
535async function openPane($: EngineInterface) {
536 const steps = await hasStep($)
537 await $.ui.open({ ...paneOpen(steps), focus: true })
538 escapes = steps
539 if ((await read($, editing)) !== null) {
540 await $.ui.focus({ requestId: PANE, key: FIELD }).catch(() => undefined)
541 } else if ((await filesNow($)).length > 0) {
542 // An empty pane draws no button for Enter (see parkWhenListed).
543 await park($)
544 }
545}
546
547// Puts Claude Code's focus ring on the button Enter presses, where it stays
548// while difnote moves its own cursor: a ring on rows that come and go would
549// be kept at its place among them, not on its row.
550async function park($: EngineInterface) {
551 await $.ui.focus({ requestId: PANE, key: ENTER }).catch(() => undefined)
552}
553
554// An empty pane draws no button for Enter, so a park found nothing to put
555// the ring on: once files show, the ring goes onto the button, if the pane
556// has the keyboard (a park never takes it from the prompt).
557async function parkWhenListed($: EngineInterface, hadFiles: boolean) {
558 if (hadFiles || (await filesNow($)).length === 0) {
559 return
560 }
561 const pane = (await $.ui.panes().catch(() => [])).find(one => one.id === PANE)
562 if (pane?.isFocused === true) {
563 await park($)
564 }
565}
566
567// The pane as difnote opens it: its title and size, and whether Esc asks
568// to close it, which it does only while there is a step to take back
569// (see stepBack).
570function paneOpen(steps: boolean) {
571 return { id: PANE, title: 'difnote', columns: DOCK_COLUMNS, rows: INLINE_ROWS, ...(steps ? { closeOnEscape: true as const } : {}) }
572}
573
574// Whether the pane, as last opened, asks Esc to close it; null where
575// difnote doesn't know, as for a pane Claude Code kept open through a
576// restart.
577let escapes: boolean | null = null
578
579// The pane's size at its last drawing, columns by rows.
580let drawnSize = ''
581
582// Esc goes back one step at a time: while a note field or a file is open,
583// it asks to close the pane, a close difnote refuses to step back instead.
584// At the top it hands the keyboard back to the prompt box and the pane
585// stays, as glimt's Esc does; q closes it. An open pane takes the new
586// manners when opened again, as a title; only one in front, so another
587// tab stays in front of it. One behind, or one Claude Code kept open
588// through a restart, takes them when the person moves in it (closeField)
589// or with /difnote.
590async function askEscape($: EngineInterface) {
591 const steps = await hasStep($)
592 if (!isOpen || steps === escapes || !(await isShown($))) {
593 return
594 }
595 await $.ui.open(paneOpen(steps)).then(
596 () => {
597 escapes = steps
598 },
599 () => undefined,
600 )
601}
602
603// q: closes the pane with difnote's own close, which passes no ui.close hook
604// of difnote's; refused, the pane stays as it was.
605async function closePane($: EngineInterface) {
606 await $.ui.close({ id: PANE }).then(
607 () => whenClosed($),
608 () => undefined,
609 )
610}
611
612// The pane closed: its looks by themselves stop, and it forgets where the
613// person was.
614async function whenClosed($: EngineInterface) {
615 setOpen($, false)
616 await forget($)
617}
618
619// Esc, the close mark or another plugin's close while a note field or a file
620// is open: one step back, the way /diff's dialog goes. A note field closes,
621// leaving the note as it was; an open file closes, the cursor on its row.
622// Only then does the close mark close the pane.
623async function stepBack($: EngineInterface) {
624 if ((await read($, editing)) !== null) {
625 await closeField($)
626 await park($)
627 return
628 }
629 const { open } = await read($, place)
630 if (open !== null) {
631 await moveTo($, `file:${open}`, { open: null })
632 }
633}
634
635// Whether there is a step to take back before the pane closes: a note
636// field, or a file open that is still listed (a review or Claude's undo can
637// take it out of the list).
638async function hasStep($: EngineInterface): Promise<boolean> {
639 if ((await read($, editing)) !== null) {
640 return true
641 }
642 const { open } = await read($, place)
643 return open !== null && (await filesNow($)).some(file => file.path === open)
644}
645
646// Closed, the pane forgets the file it had open, its cursor and an open note
647// field: opened again, it starts at the top of the list.
648async function forget($: EngineInterface) {
649 await closeField($)
650 await update($, place, was => ({ ...was, open: null, cursor: null, top: 0 }))
651}
652
653// Puts the cursor on a row, with other changes to where the person is; the
654// window keeps showing what it showed, moved only as far as the cursor needs.
655async function moveTo($: EngineInterface, key: string | null, change: Partial<Place> = {}) {
656 const top = layout?.top ?? 0
657 await update($, place, was => ({ ...was, top, ...change, cursor: key }))
658 if ('open' in change) {
659 await askEscape($)
660 }
661 await park($)
662}
663
664// The item a key names in the list as last drawn.
665function itemOf(key: string | null): Item | undefined {
666 return key === null ? undefined : layout?.items.find(item => item.key === key)
667}
668
669// The stop `step` stops from the cursor, held at the ends; from none, the
670// first or the last.
671function stopFrom(step: number): string | null {
672 const stops = layout?.stops ?? []
673 if (stops.length === 0) {
674 return null
675 }
676 const at = layout?.cursor === null || layout === null ? -1 : stops.indexOf(layout.cursor)
677 const to = at < 0 ? (step > 0 ? 0 : stops.length - 1) : Math.min(stops.length - 1, Math.max(0, at + step))
678 return stops[to] ?? null
679}
680
681// The key of the folder heading a file is listed under, if any.
682async function headingOf(path: string, $: EngineInterface): Promise<string | null> {
683 const groups = groupsOf(await filesNow($), await read($, watch))
684 for (const group of groups) {
685 for (const dir of group.dirs) {
686 if (dir.dir !== '' && dir.files.some(one => one.path === path)) {
687 return dir.key
688 }
689 }
690 }
691 return null
692}
693
694// Opens a file under its row, closing the one open; its folder opens if
695// folded. The cursor goes to its first changed line, else its row.
696async function openAt($: EngineInterface, path: string) {
697 await closeField($)
698 const rows = rowsOfFiles(await filesNow($))
699 const first = rows.find(row => row.file.path === path && row.kind === 'line') ?? rows.find(row => row.file.path === path && row.kind === 'hunk')
700 const dir = await headingOf(path, $)
701 const { closed } = await read($, place)
702 await moveTo($, first?.key ?? `file:${path}`, { open: path, closed: closed.filter(one => one !== dir) })
703}
704
705// Enter, on the button the ring stays on: what the cursor's row offers. A
706// file opens or closes; a folder heading folds or unfolds its files; a
707// folder from earlier sessions opens its newest file; a hunk or a line
708// takes a note.
709async function act($: EngineInterface) {
710 const item = itemOf(layout?.cursor ?? null)
711 const was = await read($, place)
712 if (item?.kind === 'file') {
713 await (was.open === item.file.path ? moveTo($, item.key, { open: null }) : openAt($, item.file.path))
714 } else if (item?.kind === 'dir') {
715 await toggleDir($, item)
716 } else if (item?.kind === 'folder') {
717 const newest = (await filesNow($)).filter(one => isInside(one.path, item.folder)).toSorted((x, y) => y.changedAt - x.changedAt)[0]
718 if (newest !== undefined) {
719 await openAt($, newest.path)
720 }
721 } else if (item?.kind === 'hunk' || item?.kind === 'code') {
722 await noteOn($, item.key)
723 }
724}
725
726// Folds a folder's files away, closing the file open in it; or unfolds them.
727async function toggleDir($: EngineInterface, item: Extract<Item, { kind: 'dir' }>) {
728 const was = await read($, place)
729 const isClosing = !was.closed.includes(item.key)
730 const isOpenInside = isClosing && was.open !== null && (await headingOf(was.open, $)) === item.key
731 await moveTo($, item.key, {
732 closed: isClosing ? [...was.closed, item.key] : was.closed.filter(one => one !== item.key),
733 ...(isOpenInside ? { open: null } : {}),
734 })
735}
736
737// h: out one level. From inside the open file, close it, the cursor on its
738// row; on the open file's row, close it; on a file in a folder, to the
739// folder's heading; on an unfolded heading, fold it.
740async function goOut($: EngineInterface) {
741 const item = itemOf(layout?.cursor ?? null)
742 const was = await read($, place)
743 if ((item?.kind === 'hunk' || item?.kind === 'code') && was.open !== null) {
744 await moveTo($, `file:${was.open}`, { open: null })
745 } else if (item?.kind === 'file' && was.open === item.file.path) {
746 await moveTo($, item.key, { open: null })
747 } else if (item?.kind === 'file' && item.indent > 0) {
748 await moveTo($, await headingOf(item.file.path, $))
749 } else if (item?.kind === 'dir' && !item.isClosed) {
750 await toggleDir($, item)
751 }
752}
753
754// l: in one level. A file opens; on the open file's row, to its first
755// changed line; on a hunk header, to its first changed line; a folded
756// heading unfolds.
757async function goIn($: EngineInterface) {
758 const item = itemOf(layout?.cursor ?? null)
759 const was = await read($, place)
760 if (item?.kind === 'file' && was.open !== item.file.path) {
761 await openAt($, item.file.path)
762 } else if (item?.kind === 'file') {
763 const first = rowsOfFiles(await filesNow($)).find(row => row.file.path === item.file.path && row.kind === 'line')
764 await moveTo($, first?.key ?? item.key)
765 } else if (item?.kind === 'hunk') {
766 const own = rowsOfFiles(await filesNow($))
767 const at = own.findIndex(row => row.key === item.key)
768 const first = own.slice(at + 1).find(row => row.kind === 'line' || row.kind === 'hunk')
769 await moveTo($, first?.kind === 'line' ? first.key : item.key)
770 } else if (item?.kind === 'dir' && item.isClosed) {
771 await toggleDir($, item)
772 }
773}
774
775// n: the next file in the list opens, the one open closing; the last stays.
776async function nextFile($: EngineInterface) {
777 const order = layout?.order ?? []
778 const { open } = await read($, place)
779 const item = itemOf(layout?.cursor ?? null)
780 const from = open ?? (item?.kind === 'file' ? item.file.path : null)
781 const at = order.findIndex(one => one.path === from)
782 const next = order[at + 1]
783 if (next !== undefined) {
784 await openAt($, next.path)
785 }
786}
787
788// v: the cursor's file marked viewed, or no longer: its row dims, and the
789// mark drops by itself once its change changes. Marked, an open file
790// closes and the cursor goes on to the next file not yet viewed.
791async function toggleViewed($: EngineInterface) {
792 const item = itemOf(layout?.cursor ?? null)
793 const was = await read($, place)
794 const path = item?.kind === 'file' ? item.file.path : item?.kind === 'hunk' || item?.kind === 'code' ? was.open : null
795 const file = (await filesNow($)).find(one => one.path === path)
796 if (file === undefined) {
797 return
798 }
799 const marks = await read($, viewed)
800 const print = fingerprintOf(file)
801 if (marks[file.path] === print) {
802 await setViewed($, Object.fromEntries(Object.entries(marks).filter(([one]) => one !== file.path)))
803 await moveTo($, `file:${file.path}`)
804 return
805 }
806 await setViewed($, { ...marks, [file.path]: print })
807 const order = layout?.order ?? []
808 const at = order.findIndex(one => one.path === file.path)
809 const next = order.slice(at + 1).find(one => marks[one.path] !== fingerprintOf(one))
810 const dir = next === undefined ? null : await headingOf(next.path, $)
811 await moveTo($, `file:${(next ?? file).path}`, {
812 ...(was.open === file.path ? { open: null } : {}),
813 closed: was.closed.filter(one => one !== dir),
814 })
815}
816
817// w: lines other than the cursor's cut to one row, or wrapped again.
818async function toggleWrap($: EngineInterface) {
819 const was = await read($, place)
820 await moveTo($, layout?.cursor ?? was.cursor, { isCompact: !was.isCompact })
821}
822
823// c: a note on the row the cursor is on: a file, a hunk or a changed line.
824async function noteOn($: EngineInterface, key: string | null) {
825 const row = rowsOfFiles(await filesNow($)).find(one => one.key === key)
826 if (row !== undefined) {
827 await openField($, row)
828 }
829}
830
831// The person's wheel, arrows, page keys, Home and End over the pane, which
832// difnote scrolls itself. The wheel moves the window three rows a tick and
833// takes the cursor along when it would leave it; an arrow moves the cursor
834// a stop, a page key about a window, Home and End to the first or last.
835async function scrolled($: EngineInterface, by: number, isWheel: boolean, contentRows: number) {
836 if (layout === null || by === 0) {
837 return
838 }
839 const { items, stops, window } = layout
840 const step = Math.sign(by)
841 if (isWheel) {
842 let top = layout.top
843 for (let rows = 0; rows < 3 * Math.abs(by);) {
844 const next = top + step
845 if (next < 0 || next >= items.length) {
846 break
847 }
848 rows += (step > 0 ? items[top] : items[next])?.rows ?? 1
849 top = next
850 }
851 const shown: string[] = []
852 for (let i = top, rows = 0; i < items.length && rows < window; i += 1) {
853 const item = items[i]
854 if (item !== undefined) {
855 rows += item.rows
856 if (stops.includes(item.key)) {
857 shown.push(item.key)
858 }
859 }
860 }
861 const cursor = layout.cursor !== null && shown.includes(layout.cursor) ? layout.cursor : step > 0 ? shown[0] : shown.at(-1)
862 await update($, place, was => ({ ...was, top, cursor: cursor ?? was.cursor }))
863 await park($)
864 return
865 }
866 if (Math.abs(by) === 1) {
867 await moveTo($, stopFrom(step))
868 } else if (Math.abs(by) >= contentRows) {
869 await moveTo($, (step > 0 ? stops.at(-1) : stops[0]) ?? null)
870 } else {
871 const at = items.findIndex(item => item.key === layout?.cursor)
872 let i = Math.max(0, at)
873 for (let rows = 0; rows < window - 2 && i + step >= 0 && i + step < items.length;) {
874 i += step
875 rows += items[i]?.rows ?? 1
876 }
877 while (i >= 0 && i < items.length && !stops.includes(items[i]?.key ?? '')) {
878 i -= step
879 }
880 await moveTo($, items[i]?.key ?? stopFrom(step))
881 }
882}
883
884// What the pane's keys and its note field do.
885function actionsOf($: EngineInterface, rows: readonly Row[]): Actions {
886 const run = (work: () => Promise<unknown>) => () => void work().catch(() => undefined)
887 return {
888 act: run(() => act($)),
889 down: run(() => moveTo($, stopFrom(1))),
890 up: run(() => moveTo($, stopFrom(-1))),
891 out: run(() => goOut($)),
892 in: run(() => goIn($)),
893 note: run(() => noteOn($, layout?.cursor ?? null)),
894 next: run(() => nextFile($)),
895 wrap: run(() => toggleWrap($)),
896 viewed: run(() => toggleViewed($)),
897 send: run(() => sendNotes($, rows)),
898 reviewed: run(() => confirmReviewed($)),
899 drop: run(() => dropFolder($)),
900 close: run(() => closePane($)),
901 save: (row, text) => void saveNote($, row, text).catch(() => undefined),
902 }
903}
904
905// The paths a tool call is about to change, and whether a shell command
906// names them; none for a tool that changes no files.
907function namedBy(e: unknown): { paths: string[]; isShell: boolean } {
908 if (!isRecord(e)) {
909 return { paths: [], isShell: false }
910 }
911 if ((e.tool === 'Edit' || e.tool === 'Write') && typeof e.file_path === 'string') {
912 return { paths: [e.file_path], isShell: false }
913 }
914 if (e.tool === 'NotebookEdit' && typeof e.notebook_path === 'string') {
915 return { paths: [e.notebook_path], isShell: false }
916 }
917 if ((e.tool === 'Bash' || e.tool === 'PowerShell') && typeof e.command === 'string') {
918 return { paths: pathsIn(e.command, cwd, places.home), isShell: true }
919 }
920 return { paths: [], isShell: false }
921}
922
923// The files a shell command changed in a git repository, as Claude Code
924// found them.
925function changedBy(result: unknown): string[] {
926 const found = isRecord(result) && isRecord(result.bashEditDiff) ? result.bashEditDiff.changedFiles : undefined
927 return Array.isArray(found) ? found.filter(path => typeof path === 'string') : []
928}
929
930// ---------------------------------------------------------------------------
931// The folders difnote watches. Each gets a private git folder, in Claude
932// Code's data folder for difnote, which records the folder before Claude
933// first changes it and again each time the pane looks; the pane shows what
934// changed since the state the person last reviewed, whoever changed it.
935
936// A folder difnote knows of: watched, with its private git folder, what kind
937// of folder it is, and whether its first record is in (`ready` resolves
938// with that); or skipped, with why.
939type Watched = {
940 folder: string
941 state: 'watched'
942 git: string
943 kind: 'repository' | 'folder' | 'files'
944 ready: Promise<boolean>
945 hasBase: boolean
946}
947type Folder = Watched | { folder: string; state: 'skipped'; why: string }
948
949// What a look found, by folder: the state it recorded, what a review marks
950// reviewed, and the changed files; and the folders it couldn't look at,
951// with why.
952type Looked = { trees: Map<string, string>; files: Map<string, FileDiff[]>; failed: { folder: string; why: string }[] }
953
954// When each folder's reviewed state was recorded, when it was last used and
955// when its git folder was last tidied, what kind of folder it is, the
956// folders of the sessions it carries changes for, and how many changed
957// files the last look found, by folder: kept in the store, so a folder
958// unused for long can be emptied, and a later session in a folder that
959// Claude changed another from watches that one again while its changes wait
960// for review. Used means Claude changed it, a review covered it or a session
961// started in it; a look is no use.
962type Times = { baseAt: number; usedAt: number; tidiedAt: number; kind: Watched['kind']; from: string[]; pending: number }
963const FOLDERS = 'folders'
964
965// The most session folders a folder carries changes for.
966const MOST_FROM = 20
967
968// Folder times as the store keeps them; kind, from and pending may be missing.
969function timesOf(value: unknown): Times | null {
970 if (!isRecord(value) || typeof value.baseAt !== 'number' || typeof value.usedAt !== 'number' || typeof value.tidiedAt !== 'number') {
971 return null
972 }
973 const kind = value.kind === 'repository' || value.kind === 'files' ? value.kind : 'folder'
974 const from = Array.isArray(value.from) ? value.from.filter(one => typeof one === 'string') : []
975 const pending = typeof value.pending === 'number' ? value.pending : 0
976 return { baseAt: value.baseAt, usedAt: value.usedAt, tidiedAt: value.tidiedAt, kind, from, pending }
977}
978
979// A folder's times as kept, or new ones from `now`.
980function timesFor(folder: string, now: number): Times {
981 const one = folders.get(folder)
982 const kind = one?.state === 'watched' ? one.kind : 'folder'
983 return times[folder] ?? { baseAt: now, usedAt: now, tidiedAt: now, kind, from: [], pending: 0 }
984}
985
986// Whether a folder is outside the session's own: not it, nor in it.
987function isOutside(folder: string): boolean {
988 return sessionFolder !== '' && !isInside(folder, sessionFolder)
989}
990
991// After Claude changed files in the folders found: each used now, with
992// changes to review; one outside the session's folder is watched again by
993// later sessions here until a look finds nothing in it left to review.
994async function touched($: EngineInterface, found: readonly (Folder | null)[]) {
995 const used = found.filter((one): one is Watched => one?.state === 'watched')
996 if (used.length === 0) {
997 return
998 }
999 const now = await $.clock.now()
1000 for (const { folder } of used) {
1001 const was = timesFor(folder, now)
1002 const from = isOutside(folder) ? [...new Set([...was.from, cwd])].slice(-MOST_FROM) : was.from
1003 times[folder] = { ...was, usedAt: now, pending: Math.max(was.pending, 1), from }
1004 }
1005 await saveTimes($)
1006}
1007
1008const UNUSED_MS = 30 * 24 * 60 * 60 * 1000
1009const TIDY_MS = 24 * 60 * 60 * 1000
1010
1011// The most repositories inside a watched folder that difnote watches too,
1012// and how deep inside one another it looks for them; the most files git
1013// may fail to read before a folder is skipped.
1014const MOST_NESTED = 20
1015const MOST_ROUNDS = 3
1016const MOST_UNREADABLE = 10
1017
1018// Why a folder isn't watched, in the pane's words.
1019class Skip extends Error {}
1020
1021let places: Places = { data: '', config: null, home: null }
1022let hasGit = false
1023let excludesFile: string | null = null
1024const folders = new Map<string, Folder>()
1025// The folders this session works in, watched or not.
1026const active = new Set<string>()
1027// Each folder's repository top, null for none, as git answered.
1028const tops = new Map<string, string | null>()
1029// The placeholders recorded, by path: their text, git's id for it, and the
1030// size and time it was recorded with.
1031const placeholders = new Map<string, { text: string; id: string; size: number; changedAt: number }>()
1032const looking = new Set<string>()
1033let times: Record<string, Times> = {}
1034// The session's own folder: its repository's top, else the session's.
1035let sessionFolder = ''
1036// What each folder's last full look left: the repositories git lists in it,
1037// and the ids of its reviewed state and of the state it recorded. A look by
1038// itself that finds all three the same, and no file but placeholders
1039// differing from the record, keeps the result. Another session in the same
1040// folder shares the record, and moves the recorded state when it looks.
1041const quiet = new Map<string, { nested: string; base: string; shown: string }>()
1042// How many of each folder's looks took over SLOW_MS.
1043const slowness = new Map<string, number>()
1044// The outside folders earlier sessions here left changes in, watched again;
1045// and the folders emptied at the start that still held changes not reviewed.
1046const carried = new Set<string>()
1047let lost: string[] = []
1048// The folders emptied this session, for git to pack their git folders.
1049const emptied = new Set<string>()
1050
1051// A watched folder's private git folder.
1052function gitOf(folder: string): string {
1053 return `${places.data}/roots/${slugOf(folder)}/git`
1054}
1055let failed: { folder: string; why: string }[] = []
1056
1057// What difnote watches, as the pane says it: the folders refused or that a
1058// look couldn't reach, the folders being recorded for the first time, and
1059// when the oldest state it compares against was recorded.
1060function watchOf(): Watch {
1061 const mine = [...active].flatMap(folder => {
1062 const one = folders.get(folder)
1063 return one === undefined ? [] : [one]
1064 })
1065 const since = mine.flatMap(one => {
1066 const time = one.state === 'watched' && one.hasBase ? times[one.folder] : undefined
1067 return time === undefined ? [] : [time.baseAt]
1068 })
1069 const skipped = mine.flatMap(one => (one.state === 'skipped' ? [{ folder: one.folder, why: one.why }] : []))
1070 return {
1071 hasGit,
1072 since: since.length === 0 ? null : Math.min(...since),
1073 looking: [...looking],
1074 skipped: [...skipped, ...failed],
1075 slow: [...active].filter(isSlow),
1076 carried: [...carried].filter(folder => active.has(folder)).map(folder => ({ folder, usedAt: times[folder]?.usedAt ?? 0 })),
1077 lost,
1078 roots: rootsOf(mine),
1079 home: places.home,
1080 }
1081}
1082
1083// The folders the pane groups files under: the session's, its
1084// repository's top when that is above it, and each watched folder outside
1085// it that no other one holds.
1086function rootsOf(mine: readonly Folder[]): string[] {
1087 const outside = mine.flatMap(one => (one.state === 'watched' && isOutside(one.folder) ? [one.folder] : []))
1088 const outermost = outside.filter(folder => !outside.some(other => other !== folder && isInside(folder, other)))
1089 return [cwd, ...(sessionFolder !== '' && sessionFolder !== cwd ? [sessionFolder] : []), ...outermost.toSorted()]
1090}
1091
1092function isSlow(folder: string): boolean {
1093 return (slowness.get(folder) ?? 0) >= SLOW_TIMES
1094}
1095
1096function changed($: EngineInterface) {
1097 void update($, watch, () => watchOf()).catch(() => undefined)
1098}
1099
1100// At the session's start: where to keep the git folders, whether git runs,
1101// the person's own ignore file, and the session's folder, recorded in the
1102// background if it has no record yet; then old records tidied.
1103async function startWatching($: EngineInterface): Promise<void> {
1104 places = placesOf($.plugin.root, cwd)
1105 folders.clear()
1106 tops.clear()
1107 placeholders.clear()
1108 active.clear()
1109 looking.clear()
1110 quiet.clear()
1111 slowness.clear()
1112 carried.clear()
1113 lost = []
1114 sessionFolder = ''
1115 failed = []
1116 hasGit = await git($, 'version', cwd).then(
1117 ran => ran.exitCode === 0,
1118 () => false,
1119 )
1120 if (!hasGit) {
1121 return
1122 }
1123 const stored = await $.store.get(FOLDERS).catch(() => undefined)
1124 times = Object.fromEntries(
1125 Object.entries(isRecord(stored) ? stored : {}).flatMap(([folder, value]) => {
1126 const one = timesOf(value)
1127 return one === null ? [] : [[folder, one] as const]
1128 }),
1129 )
1130 const asked = await git($, 'excludes', cwd).catch(() => null)
1131 excludesFile = asked?.exitCode === 0 && asked.stdout.trim() !== '' ? asked.stdout.trim() : null
1132 // Folders unused for long are emptied first, before any can be watched
1133 // again; the rest of the tidying goes on in the background.
1134 await letGo($).catch(() => undefined)
1135 if (lost.length > 0) {
1136 $.ui.toast(`Emptied after 30 days unused, with changes you hadn't reviewed: ${lost.join(', ')}`)
1137 }
1138 const top = await topOf($, cwd)
1139 sessionFolder = top ?? cwd
1140 const mine = follow($, sessionFolder, top === null ? 'folder' : 'repository')
1141 const now = await $.clock.now()
1142 // A session starting here uses its folder and the repositories in it.
1143 for (const [folder, one] of Object.entries(times)) {
1144 if (!isOutside(folder)) {
1145 one.usedAt = now
1146 }
1147 }
1148 await saveTimes($)
1149 // The folders outside it that Claude changed from here, and whose changes
1150 // wait for review, watched again: they show, as in the session that made
1151 // the change. One that is no longer a repository is, as any folder outside
1152 // the session's, watched for Claude's own files only.
1153 const wanted = Object.entries(times).filter(([folder, one]) => isOutside(folder) && one.from.includes(cwd) && now - one.usedAt <= UNUSED_MS)
1154 const found = await Promise.all(
1155 wanted.map(async ([folder]) => ((await $.fs.exists(folder).catch(() => false)) ? { folder, top: await topOf($, folder) } : null)),
1156 )
1157 for (const one of found) {
1158 if (one !== null) {
1159 follow($, one.folder, one.top === one.folder ? 'repository' : 'files')
1160 carried.add(one.folder)
1161 }
1162 }
1163 // Tidying waits for the session's folder's first record, so their git
1164 // calls don't hold each other's locks.
1165 void (mine.state === 'watched' ? mine.ready : Promise.resolve(false)).then(() => tidy($)).catch(() => undefined)
1166}
1167
1168// Before Claude changes the files at `paths`: the folders they are in,
1169// watched from now on, each recorded first if it has no record yet, waiting
1170// up to `waitMs`. A shell command's paths count only in a git repository or
1171// a folder already watched; an Edit's or a Write's, in any folder.
1172async function beforeChange(
1173 $: EngineInterface,
1174 paths: readonly string[],
1175 isShell: boolean,
1176 waitMs: number,
1177): Promise<{ isRecorded: boolean; found: (Folder | null)[] }> {
1178 if (!hasGit) {
1179 return { isRecorded: true, found: [] }
1180 }
1181 const found = await Promise.all(paths.map(path => folderFor($, path, isShell).catch(() => null)))
1182 const waits = found.flatMap(one => (one?.state === 'watched' && !one.hasBase ? [one.ready] : []))
1183 if (waits.length > 0 && waitMs > 0) {
1184 await Promise.race([Promise.all(waits), $.clock.sleep(waitMs)])
1185 }
1186 return { isRecorded: found.every(one => one?.state !== 'watched' || one.hasBase), found }
1187}
1188
1189// Whether a file is in a folder difnote has a record of: there, the
1190// records show its changes, whoever made them.
1191function isWatched(path: string): boolean {
1192 return [...folders.values()].some(
1193 one => one.state === 'watched' && one.hasBase && (one.kind === 'files' ? parentOf(path) === one.folder : isInside(path, one.folder)),
1194 )
1195}
1196
1197// Records each folder this session works in as it is now, and compares it
1198// with its reviewed state. A look by itself (`isTimer`) keeps what the last
1199// look found for each folder git says nothing changed in, and for each slow
1200// one.hooks/folders.ts 132 lines1// Which folders difnote watches and where it keeps their records: the
2// places on this machine it goes by, the folders it refuses, and the
3// folders a shell command names. Pure: hooks/register.tsx acts on them.
4
5// Where difnote keeps its private git folders, Claude Code's own folder, and
6// the person's home folder, each found from paths alone, never from the
7// environment; null where a path doesn't tell.
8export type Places = { data: string; config: string | null; home: string | null }
9
10// An installed plugin runs from <config>/plugins/cache/<marketplace>/<name>/
11// <version>, and Claude Code keeps its data in <config>/plugins/data/<id>,
12// <id> being name@marketplace with every character but a letter, a digit, _
13// and - made a -. A plugin loaded from a folder keeps its data in that
14// folder, under .data.
15export function placesOf(pluginRoot: string, cwd: string): Places {
16 const installed = /^(.*)\/plugins\/cache\/([^/]+)\/([^/]+)\/[^/]+\/?$/.exec(pluginRoot)
17 const [, config, marketplace, name] = installed ?? []
18 const home = config?.endsWith('/.claude') === true ? config.slice(0, -'/.claude'.length) : (homeOf(cwd) ?? homeOf(pluginRoot))
19 if (config === undefined || marketplace === undefined || name === undefined) {
20 return { data: `${pluginRoot}/.data`, config: home === null ? null : `${home}/.claude`, home }
21 }
22
23 return { data: `${config}/plugins/data/${`${name}@${marketplace}`.replaceAll(/[^A-Za-z0-9_-]/g, '-')}`, config, home }
24}
25
26// The home folder a path is in, by the shapes home folders take.
27export function homeOf(path: string): string | null {
28 return /^(\/home\/[^/]+|\/Users\/[^/]+|\/var\/home\/[^/]+|\/root)(?:\/|$)/.exec(path)?.[1] ?? null
29}
30
31// Whether `path` is `folder` or inside it.
32export function isInside(path: string, folder: string): boolean {
33 return path === folder || path.startsWith(folder === '/' ? '/' : `${folder}/`)
34}
35
36const TEMPORARY = ['/tmp', '/var/tmp', '/private/tmp', '/var/folders', '/private/var/folders']
37
38// Why difnote won't watch a folder, or null where it will: a folder that
39// holds everything, Claude Code's own, or a temporary one.
40export function refusalOf(folder: string, places: Places): string | null {
41 const { home, config } = places
42 if (folder === '/') {
43 return 'the whole disk'
44 }
45 if (home !== null && folder === home) {
46 return 'your home folder'
47 }
48 if (home !== null && isInside(home, folder)) {
49 return 'holds your home folder'
50 }
51 const name = ['Desktop', 'Documents', 'Downloads'].find(one => home !== null && folder === `${home}/${one}`)
52 if (name !== undefined) {
53 return `your ${name} folder`
54 }
55 if (config !== null && isInside(folder, config)) {
56 return "Claude Code's own folder"
57 }
58 if (TEMPORARY.some(one => isInside(folder, one))) {
59 return 'a temporary folder'
60 }
61
62 return null
63}
64
65// A folder's name for its private git folder: its own name, readable, and a
66// hash of its whole path.
67export function slugOf(folder: string): string {
68 const name = folder
69 .slice(folder.lastIndexOf('/') + 1)
70 .replaceAll(/[^A-Za-z0-9_.-]/g, '-')
71 .slice(0, 40)
72 return `${name === '' ? 'root' : name}-${hashOf(folder)}`
73}
74
75// FNV-1a over the text's code points, forward and backward: 64 bits, as 16
76// hex digits.
77function hashOf(text: string): string {
78 // oxlint-disable-next-line typescript/no-misused-spread -- code points, not UTF-16 units
79 const points = [...text].map(char => char.codePointAt(0) ?? 0)
80 const fnv = (list: readonly number[]) => list.reduce((hash, point) => Math.imul(hash ^ point, 0x01000193) >>> 0, 0x811c9dc5)
81 return [fnv(points), fnv(points.toReversed())].map(hash => hash.toString(16).padStart(8, '0')).join('')
82}
83
84// A path with its `.` and `..` worked out.
85export function normal(path: string): string {
86 const parts: string[] = []
87 for (const part of path.split('/')) {
88 if (part === '..') {
89 parts.pop()
90 } else if (part !== '' && part !== '.') {
91 parts.push(part)
92 }
93 }
94
95 return `/${parts.join('/')}`
96}
97
98export function parentOf(path: string): string {
99 const cut = path.lastIndexOf('/')
100 return cut <= 0 ? '/' : path.slice(0, cut)
101}
102
103// How many of the paths a command names difnote looks at.
104export const MOST_NAMED = 20
105
106// The folders and files a shell command names: absolute paths, paths from
107// the home folder, and where it goes with cd or pushd, the session's folder
108// taking a relative one. Not the system's own folders.
109export function pathsIn(command: string, cwd: string, home: string | null): string[] {
110 const found: string[] = []
111 const add = (raw: string) => {
112 const path = raw.replace(/^["']|["']$/g, '')
113 const full =
114 path.startsWith('~/') || path === '~' ? (home === null ? null : `${home}${path.slice(1)}`) : path.startsWith('/') ? path : `${cwd}/${path}`
115 if (full === null || full.startsWith('//')) {
116 return
117 }
118 const at = normal(full)
119 if (at !== '/' && !['/dev', '/proc', '/sys'].some(one => isInside(at, one)) && !found.includes(at)) {
120 found.push(at)
121 }
122 }
123 for (const match of command.matchAll(/(?:^|[;&|({]|\bthen|\bdo|\belse)\s*(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/g)) {
124 add(match[1] ?? '')
125 }
126 for (const match of command.matchAll(/(?:^|[\s"'=(<>|;&])(~?\/[^\s"'`;&|<>()${}\\]*)/g)) {
127 add(match[1] ?? '')
128 }
129
130 return found.slice(0, MOST_NAMED)
131}
132hooks/git.ts 301 lines1// difnote's private git folders, as text: the files it writes into one, and
2// how it reads what git prints. Pure: hooks/register.tsx runs git.
3
4import type { Bare, FileDiff, Hunk } from '../types'
5import { shownPath } from './review'
6
7// Every command runs with the person's own git settings off, also any
8// given through the environment, so none of them changes what difnote
9// records; and with git's messages in English.
10export const ENV = {
11 GIT_CONFIG_GLOBAL: '/dev/null',
12 GIT_CONFIG_NOSYSTEM: '1',
13 GIT_CONFIG_PARAMETERS: '',
14 GIT_CONFIG_COUNT: '0',
15 GIT_OPTIONAL_LOCKS: '0',
16 GIT_TERMINAL_PROMPT: '0',
17 LC_ALL: 'C',
18}
19
20// A private git folder's commands also name the folder, its work tree, its
21// index and its objects, so that none of git's own variables Claude Code
22// may have been started with can point them at another repository.
23export function envOf(git: string, folder: string, common = git): Record<string, string> {
24 return {
25 ...ENV,
26 GIT_DIR: git,
27 GIT_COMMON_DIR: common,
28 GIT_WORK_TREE: folder,
29 GIT_INDEX_FILE: `${git}/index`,
30 GIT_OBJECT_DIRECTORY: `${common}/objects`,
31 GIT_ALTERNATE_OBJECT_DIRECTORIES: '',
32 GIT_NAMESPACE: '',
33 }
34}
35
36// The person's repository, as git's variables name it, with their own
37// ignore file, so what it lists is what difnote's own git folder would
38// skip: its git folder, the one it shares with its other work trees (for a
39// work tree or a submodule, whose .git is a file naming its git folder),
40// and the top of the repository.
41export function repoEnvOf(dirs: { git: string; common: string }, top: string, excludesFile: string | null): Record<string, string> {
42 return {
43 ...envOf(dirs.git, top, dirs.common),
44 ...(excludesFile === null ? {} : { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.excludesFile', GIT_CONFIG_VALUE_0: excludesFile }),
45 }
46}
47
48// Where a path names one, from a folder: absolute as given, else under it.
49export function under(folder: string, path: string): string {
50 return path.startsWith('/') ? path : `${folder}/${path}`
51}
52
53// The git folder a .git file names (`gitdir: <path>`); null for other text.
54export function gitdirOf(text: string, top: string): string | null {
55 const path = /^gitdir:\s*(.+)$/m.exec(text)?.[1]?.trim()
56 return path === undefined ? null : under(top, path)
57}
58
59// The most files a folder may have for difnote to watch it, and the largest
60// file it keeps a copy of, in bytes.
61export const MOST_FILES = 50_000
62export const MOST_KEPT = 1_048_576
63
64// What difnote never records in a folder that isn't a git repository, which
65// has its own .gitignore for these.
66export const SKIPPED = [
67 'node_modules/',
68 '.venv/',
69 'venv/',
70 '__pycache__/',
71 '.cache/',
72 '.pytest_cache/',
73 '.mypy_cache/',
74 '.tox/',
75 '.next/',
76 'dist/',
77 'build/',
78 'target/',
79 '.gradle/',
80]
81
82// Where difnote's own settings start in a private git folder's config file.
83const SETTINGS = "# difnote's settings, written again when a session starts"
84
85// A private git folder's settings: git's own, as `git init` wrote them for
86// this machine, but not bare; then difnote's, after any it wrote before:
87// the person's folder as its work tree, nothing kept of old states, nothing
88// done on its own, and the person's own ignore file, their settings off.
89// git reads whether a folder is bare before it follows an include, so it
90// all goes in the one file.
91export function configOf(own: string, folder: string, excludesFile: string | null): string {
92 const gits = (own.split(SETTINGS)[0] ?? '').trimEnd().replace(/^(\s*bare\s*=\s*)true\s*$/m, '$1false')
93 return [
94 gits,
95 SETTINGS,
96 '[core]',
97 `\tworktree = ${quoted(folder)}`,
98 '\tlogAllRefUpdates = false',
99 '\tautocrlf = false',
100 '\tfsmonitor = false',
101 '\tsplitIndex = false',
102 ...(excludesFile === null ? [] : [`\texcludesFile = ${quoted(excludesFile)}`]),
103 '[gc]',
104 '\tauto = 0',
105 '',
106 ].join('\n')
107}
108
109const quoted = (value: string) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`
110
111// What a private git folder ignores beyond the folder's .gitignore files: in
112// a git repository, what the repository's own .git/info/exclude ignores; in
113// another folder, SKIPPED; and in a folder difnote watches only for the
114// files Claude wrote into it, its subfolders.
115export function excludeOf(kind: 'repository' | 'folder' | 'files', own: string): string {
116 const lines =
117 kind === 'repository'
118 ? ["# The repository's own .git/info/exclude", own.trimEnd()]
119 : ['# What difnote never records', ...SKIPPED, ...(kind === 'files' ? ['# Only the files in the folder itself', '/*/'] : [])]
120 return `${lines.join('\n')}\n`
121}
122
123// A file difnote keeps no copy of, as it records it: why, its size and
124// when it changed, so a change to it still shows.
125export const PLACEHOLDER = 'difnote: no copy kept'
126type Kept = { why: 'large' | 'secret' | 'unreadable'; size: number }
127
128export function placeholderOf(why: Kept['why'], size: number, changedAt: number): string {
129 return `${PLACEHOLDER}\n${why} ${size} ${Math.round(changedAt)}\n`
130}
131
132function keptOf(lines: readonly string[]): Kept | null {
133 const [why, size] = lines[1]?.split(' ') ?? []
134 if (lines[0] !== PLACEHOLDER || (why !== 'large' && why !== 'secret' && why !== 'unreadable')) {
135 return null
136 }
137 return { why, size: Number(size) }
138}
139
140// The names of files that hold secrets, which difnote keeps no copy of.
141const SECRET = /^(?:\.env(?:\..+)?|.+\.(?:pem|key|p12|pfx)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\..+)?|\.netrc|\.pgpass)$/i
142
143export function isSecret(path: string): boolean {
144 return SECRET.test(path.slice(path.lastIndexOf('/') + 1))
145}
146
147// fast-import's input for the placeholders: each a blob, then its id asked
148// for, so the ids come out one to a line in order.
149export function blobsOf(texts: readonly string[]): string {
150 return `${texts.map((text, i) => `blob\nmark :${i + 1}\ndata ${text.length}\n${text}\nget-mark :${i + 1}\n`).join('')}done\n`
151}
152
153// One file in what git printed for the difference of two states: its path
154// in the folder, how it changed (A added, D deleted, M modified), the ids of
155// its contents before and after, its hunks, whether git found it binary, and
156// whether the output was cut before its hunks ended.
157export type Entry = { path: string; status: string; before: string; after: string; hunks: Hunk[]; isBinary: boolean; isCut: boolean }
158
159// What git's diff-tree printed: its list of changed files, NUL-separated, then
160// each file's patch in the same order. `isCut` where git printed more than
161// was read: the files whose patches were cut short are marked.
162export function entriesOf(out: string, isCut: boolean): Entry[] {
163 const parts = out.split('\0')
164 const entries: Entry[] = []
165 let at = 0
166 while (at + 1 < parts.length && parts[at]?.startsWith(':') === true) {
167 const [, , before = '', after = '', status = ''] = (parts[at] ?? '').slice(1).split(' ')
168 entries.push({ path: parts[at + 1] ?? '', status, before, after, hunks: [], isBinary: false, isCut: true })
169 at += 2
170 }
171 const patches = parts
172 .slice(at)
173 .join('\0')
174 .replace(/^\0+/, '')
175 .split(/^diff --git /m)
176 .slice(1)
177 patches.forEach((patch, i) => {
178 const entry = entries[i]
179 if (entry !== undefined) {
180 Object.assign(entry, patchOf(patch), isCut && i === patches.length - 1 ? { isCut: true } : {})
181 }
182 })
183
184 return entries
185}
186
187// One file's patch: binary, or its hunks, numbered as difnote numbers them
188// (a side with no lines starts at the line after); cut where a hunk ends
189// short.
190function patchOf(patch: string): Pick<Entry, 'hunks' | 'isBinary' | 'isCut'> {
191 const lines = patch.split('\n')
192 let at = lines.findIndex(line => line.startsWith('@@ ') || line.startsWith('Binary files '))
193 if (at < 0) {
194 return { hunks: [], isBinary: false, isCut: false }
195 }
196 if (lines[at]?.startsWith('Binary files ') === true) {
197 return { hunks: [], isBinary: true, isCut: false }
198 }
199
200 const hunks: Hunk[] = []
201 while (lines[at]?.startsWith('@@ ') === true) {
202 const head = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(lines[at] ?? '')
203 if (head === null) {
204 break
205 }
206 const oldLines = head[2] === undefined ? 1 : Number(head[2])
207 const newLines = head[4] === undefined ? 1 : Number(head[4])
208 const hunk: Hunk = {
209 oldStart: Number(head[1]) + (oldLines === 0 ? 1 : 0),
210 oldLines,
211 newStart: Number(head[3]) + (newLines === 0 ? 1 : 0),
212 newLines,
213 lines: [],
214 }
215 let olds = oldLines
216 let news = newLines
217 at += 1
218 while ((olds > 0 || news > 0) && at < lines.length) {
219 const line = lines[at] ?? ''
220 at += 1
221 if (line.startsWith('\\')) {
222 continue
223 }
224 const sign = line.startsWith('+') || line.startsWith('-') ? line[0] : ' '
225 olds -= sign === '+' ? 0 : 1
226 news -= sign === '-' ? 0 : 1
227 hunk.lines.push(sign === ' ' && !line.startsWith(' ') ? ` ${line}` : line)
228 }
229 if (olds > 0 || news > 0) {
230 return { hunks, isBinary: false, isCut: true }
231 }
232 hunks.push(hunk)
233 while (lines[at]?.startsWith('\\') === true) {
234 at += 1
235 }
236 }
237
238 return { hunks, isBinary: false, isCut: false }
239}
240
241// The content ids of the files below that need their sizes asked for: the
242// sides of binary files, of files cut short, and the real side of a file
243// that crossed the size difnote keeps.
244export function sizesWanted(entries: readonly Entry[]): string[] {
245 const none = /^0+$/
246 return entries.flatMap(entry => {
247 const { old, now } = keptSides(entry)
248 const sides =
249 entry.isBinary || entry.isCut
250 ? [entry.before, entry.after]
251 : old !== null || now !== null
252 ? [old === null ? entry.before : '', now === null ? entry.after : '']
253 : []
254 return sides.filter(id => id !== '' && !none.test(id))
255 })
256}
257
258// What cat-file printed for `sizesWanted`: each id's size.
259export function sizesOf(out: string): Map<string, number> {
260 return new Map(
261 out.split('\n').flatMap(line => {
262 const [id, type, size] = line.split(' ')
263 return id !== undefined && type !== 'missing' && size !== undefined ? [[id, Number(size)] as const] : []
264 }),
265 )
266}
267
268// The placeholder on each side of a file's change, where it has one.
269function keptSides(entry: Entry): { old: Kept | null; now: Kept | null } {
270 const side = (sign: string) =>
271 entry.hunks.flatMap(hunk => hunk.lines.filter(line => line.startsWith(' ') || line.startsWith(sign)).map(line => line.slice(1)))
272 return { old: keptOf(side('-')), now: keptOf(side('+')) }
273}
274
275// A file of a watched folder as the pane lists it.
276export function fileOf(entry: Entry, folder: string, cwd: string, sizes: ReadonlyMap<string, number>, changedAt: number): FileDiff {
277 const path = `${folder}/${entry.path}`
278 const isCreate = entry.status === 'A'
279 const isDelete = entry.status === 'D'
280 // A patch cut short can't be read for placeholders: git's sizes stand.
281 const { old, now } = entry.isCut ? { old: null, now: null } : keptSides(entry)
282 const sizeOf = (id: string, kept: Kept | null, isGone: boolean) => (isGone ? null : (kept?.size ?? sizes.get(id) ?? null))
283 const why = entry.isCut ? 'cut' : (now?.why ?? old?.why ?? (entry.isBinary ? 'binary' : null))
284 const bare: Bare | null = why === null ? null : { why, before: sizeOf(entry.before, old, isCreate), after: sizeOf(entry.after, now, isDelete) }
285 const hunks = bare === null ? entry.hunks : []
286 const lines = hunks.flatMap(hunk => hunk.lines)
287
288 return {
289 path,
290 shown: shownPath(path, cwd),
291 isCreate,
292 isDelete,
293 hunks,
294 added: lines.filter(line => line.startsWith('+')).length,
295 removed: lines.filter(line => line.startsWith('-')).length,
296 bare,
297 changedAt,
298 doc: null,
299 }
300}
301hooks/paint.ts 111 lines1// A color option, as Claude Code's custom themes spell a color, turned into
2// one a Text paints: a theme role by name, hex, or one of the terminal's 256
3// colors by number.
4
5// Claude Code's theme roles, which follow the person's theme: every token its
6// custom theme reference lists.
7const ROLES = new Set([
8 'text',
9 'inverseText',
10 'inactive',
11 'subtle',
12 'suggestion',
13 'remember',
14 'success',
15 'error',
16 'warning',
17 'merged',
18 'claude',
19 'permission',
20 'planMode',
21 'autoAccept',
22 'promptBorder',
23 'bashBorder',
24 'ide',
25 'diffAdded',
26 'diffRemoved',
27 'diffAddedDimmed',
28 'diffRemovedDimmed',
29 'diffAddedWord',
30 'diffRemovedWord',
31 'fastMode',
32 'effortUltra',
33 'userMessageBackground',
34 'userMessageBackgroundHover',
35 'bashMessageBackgroundColor',
36 'memoryBackgroundColor',
37 'selectionBg',
38 'rate_limit_fill',
39 'rate_limit_empty',
40 'briefLabelYou',
41 'briefLabelClaude',
42 ...['claude', 'warning', 'permission', 'promptBorder', 'inactive', 'fastMode'].map(role => `${role}Shimmer`),
43 ...['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'].map(color => `${color}_FOR_SUBAGENTS_ONLY`),
44 ...['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet'].flatMap(color => [`rainbow_${color}`, `rainbow_${color}_shimmer`]),
45])
46
47// The terminal's sixteen colors as Text names them, in ANSI order, which
48// numbers them for `ansi256(n)`; gray, in either spelling, is blackBright.
49const ANSI = [
50 'black',
51 'red',
52 'green',
53 'yellow',
54 'blue',
55 'magenta',
56 'cyan',
57 'white',
58 'blackBright',
59 'redBright',
60 'greenBright',
61 'yellowBright',
62 'blueBright',
63 'magentaBright',
64 'cyanBright',
65 'whiteBright',
66]
67const NUMBERS = new Map<string, number>([...ANSI.map((name, n) => [name, n] as const), ['gray', 8], ['grey', 8]])
68
69const hex = (...channels: number[]) => `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`
70
71// The option's color, or `fallback` for one it can't read (undefined for no
72// color at all): a theme role, a color name, `#rgb` or `#rrggbb`,
73// `rgb(r, g, b)`, `ansi256(n)` or `ansi:<name>`, as a custom theme takes
74// them. A color name, bare or after `ansi:`, comes back by its number as
75// `ansi256(n)`, which Claude Code draws in the terminal's own palette: it
76// draws a bare name as a fixed hex, and refuses a colon in a Text color. Hex,
77// short or not, and `rgb()` come back as `#rrggbb`, so nothing rests on how
78// Claude Code reads `#rgb`.
79export function colorOf<T>(value: unknown, fallback: T): string | T {
80 if (typeof value !== 'string') {
81 return fallback
82 }
83 const color = value.trim()
84 if (ROLES.has(color)) {
85 return color
86 }
87 const named = NUMBERS.get(color.startsWith('ansi:') ? color.slice('ansi:'.length) : color)
88 if (named !== undefined) {
89 return `ansi256(${named})`
90 }
91 if (/^#[0-9a-f]{6}$/i.test(color)) {
92 return color.toLowerCase()
93 }
94 const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(color)
95 if (short !== null) {
96 return hex(...short.slice(1).map(digit => Number.parseInt(digit.repeat(2), 16)))
97 }
98 const rgb = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(color)
99 if (rgb !== null) {
100 const channels = rgb.slice(1).map(Number)
101 return channels.every(channel => channel <= 255) ? hex(...channels) : fallback
102 }
103 const indexed = /^ansi256\(\s*(\d{1,3})\s*\)$/.exec(color)
104 if (indexed !== null) {
105 const n = Number(indexed[1])
106 return n <= 255 ? `ansi256(${n})` : fallback
107 }
108
109 return fallback
110}
111hooks/pane.tsx 1083 lines1// The pane: one list of the files changed since the last review. Enter
2// opens a file's hunks under its own row, drawn as Claude Code's highlighted
3// diff, and closes it again. difnote draws its own window over the list,
4// with the summary pinned above it and the keys pinned below, and its own
5// cursor, a bar beside the row it is on. The rows are planned before they
6// are drawn (layoutOf): what each is, how many rows of the pane it takes,
7// and which the cursor stops on; the window and the drawing read the same
8// plan, so what is counted is what is drawn.
9
10import type { ElementTable, RenderElement } from 'claude-code'
11import type { Bare, DocEdit, FileDiff, Hunk, Note, Place, Watch } from '../types'
12import { isInside, parentOf } from './folders'
13import { iconOf } from './icons'
14import type { Row } from './review'
15import { fingerprintOf, hunkText } from './review'
16
17export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button' | 'Code'>
18export type InputElement = ElementTable<'terminal' | 'desktop'>['Input']
19
20// The color options, each a theme role or a color (see paint.ts): the lines
21// a file or hunk adds and removes, as counted; the notes; the quiet text,
22// folders and hints; and the cursor's bar. The code itself takes Claude
23// Code's own diff colors.
24export type Colors = { added: string; removed: string; note: string; quiet: string; cursor: string }
25
26// What planning and drawing need: the elements (an Input where the surface
27// has one), the pane's width and height, whether it holds the keyboard, the
28// colors, whether files show their icons, the files and all their rows,
29// what difnote watches, whether it is looking, the time now, the notes, the
30// row whose note field is open, and where the person is.
31export type PaneView = {
32 ui: Ui
33 Input?: InputElement | undefined
34 columns: number
35 height: number
36 isFocused: boolean
37 colors: Colors
38 hasIcons: boolean
39 files: readonly FileDiff[]
40 rows: readonly Row[]
41 watch: Watch
42 isLooking: boolean
43 now: number
44 notes: readonly Note[]
45 editing: string | null
46 place: Place
47 viewed: Record<string, string>
48}
49
50// Whether the person marked a file viewed, and it hasn't changed since.
51export function isViewed(view: Pick<PaneView, 'viewed'>, file: FileDiff): boolean {
52 const mark = view.viewed[file.path]
53 return mark !== undefined && mark === fingerprintOf(file)
54}
55
56// What the person's keys and the note field do.
57export type Actions = {
58 act: () => void
59 down: () => void
60 up: () => void
61 out: () => void
62 in: () => void
63 note: () => void
64 next: () => void
65 wrap: () => void
66 viewed: () => void
67 send: () => void
68 reviewed: () => void
69 drop: () => void
70 close: () => void
71 save: (row: Row, text: string) => void
72}
73
74// The keys of the button that keeps Claude Code's focus ring, which Enter
75// presses, and of the note field, which takes the ring while it is open.
76export const ENTER = 'enter'
77export const FIELD = 'note-field'
78
79// The start of the keys of the rows naming the folders carried from earlier
80// sessions.
81export const FOLDER = 'folder:'
82
83// Rows a code line may take, wrapped; past them its text is cut, marked …
84export const MOST_ROWS = 12
85
86// A tab in code, spaced out by difnote to the next of these columns, so the
87// rows a line takes are known before Claude Code draws it.
88const TAB = 4
89
90// Blank rows past the keys: the pane clips them, and so has rows to scroll,
91// which makes the arrows reach difnote as scrolls rather than walk the
92// buttons.
93const MARGIN_ROWS = 3
94
95// Cut to `width` cells, an ellipsis marking the cut; tabs as two spaces.
96export function fit(text: string, width: number): string {
97 const chars = codePoints(text.replaceAll('\t', ' '))
98 let cells = 0
99 let end = 0
100 for (; end < chars.length; end += 1) {
101 const next = cells + cellsOf(chars[end] ?? '')
102 if (next > width) {
103 break
104 }
105 cells = next
106 }
107 if (end === chars.length) {
108 return chars.join('')
109 }
110 if (width <= 0) {
111 return ''
112 }
113 // Room for the ellipsis.
114 let kept = chars.slice(0, end)
115 while (kept.length > 0 && cellsOf(kept.join('')) > width - 1) {
116 kept = kept.slice(0, -1)
117 }
118 return `${kept.join('')}…`
119}
120
121// Cut from the start, for a path: its end is what tells files apart.
122export function fitStart(text: string, width: number): string {
123 if (cellsOf(text) <= width) {
124 return text
125 }
126 if (width <= 0) {
127 return ''
128 }
129 const chars = codePoints(text)
130 let kept: string[] = []
131 for (let i = chars.length - 1; i >= 0 && cellsOf([chars[i] ?? '', ...kept].join('')) <= width - 1; i -= 1) {
132 kept = [chars[i] ?? '', ...kept]
133 }
134 return `…${kept.join('')}`
135}
136
137// A text's characters as the terminal places them: one per code point, a
138// pair of surrogates together.
139function codePoints(text: string): string[] {
140 return Array.from(text)
141}
142
143// How many cells of the terminal a text takes: two for a wide character,
144// as East Asian scripts and most emoji are, none for a combining mark or a
145// zero-width character, one for the rest.
146export function cellsOf(text: string): number {
147 let cells = 0
148 for (const char of text) {
149 const code = char.codePointAt(0) ?? 0
150 cells += isZeroWidth(code) ? 0 : isWide(code) ? 2 : 1
151 }
152 return cells
153}
154
155function isZeroWidth(code: number): boolean {
156 return (code >= 0x0300 && code <= 0x036f) || (code >= 0x200b && code <= 0x200f) || code === 0xfe0f || code === 0x20e3
157}
158
159function isWide(code: number): boolean {
160 return (
161 (code >= 0x1100 && code <= 0x115f) ||
162 (code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
163 (code >= 0xac00 && code <= 0xd7a3) ||
164 (code >= 0xf900 && code <= 0xfaff) ||
165 (code >= 0xfe30 && code <= 0xfe4f) ||
166 (code >= 0xff00 && code <= 0xff60) ||
167 (code >= 0xffe0 && code <= 0xffe6) ||
168 (code >= 0x1f300 && code <= 0x1f64f) ||
169 (code >= 0x1f900 && code <= 0x1f9ff) ||
170 (code >= 0x1fa70 && code <= 0x1faff) ||
171 (code >= 0x20000 && code <= 0x3fffd)
172 )
173}
174
175// A line of code with its tabs spaced out to the next TAB column.
176export function untabbed(text: string): string {
177 if (!text.includes('\t')) {
178 return text
179 }
180 let out = ''
181 for (const char of text) {
182 out += char === '\t' ? ' '.repeat(TAB - (cellsOf(out) % TAB)) : char
183 }
184 return out
185}
186
187// Prose cut into lines of at most `width` cells, at spaces where it can be,
188// a word longer than a line cut where it must be.
189export function wrapped(text: string, width: number): string[] {
190 const room = Math.max(1, width)
191 const lines: string[] = []
192 for (const paragraph of text.split('\n')) {
193 let line = ''
194 for (const word of paragraph.split(' ')) {
195 const joined = line === '' ? word : `${line} ${word}`
196 if (cellsOf(joined) <= room) {
197 line = joined
198 continue
199 }
200 if (line !== '') {
201 lines.push(line)
202 }
203 line = word
204 while (cellsOf(line) > room) {
205 const chars = codePoints(line)
206 let cut = 0
207 while (cut < chars.length && cellsOf(chars.slice(0, cut + 1).join('')) <= room) {
208 cut += 1
209 }
210 lines.push(chars.slice(0, Math.max(1, cut)).join(''))
211 line = chars.slice(Math.max(1, cut)).join('')
212 }
213 }
214 lines.push(line)
215 }
216 return lines
217}
218
219const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
220
221// The line at the top: what the pane covers and what it holds.
222export function summaryOf(view: Pick<PaneView, 'files' | 'notes' | 'watch' | 'isLooking' | 'now' | 'viewed'>): string {
223 const { files, notes, isLooking } = view
224 const seen = files.filter(file => isViewed(view, file)).length
225 return [
226 `since ${sinceOf(view)}`,
227 plural(files.length, 'file', 'files'),
228 ...(seen === 0 ? [] : [`${seen} viewed`]),
229 ...(notes.length === 0 ? [] : [plural(notes.length, 'note', 'notes')]),
230 ...(isLooking ? ['looking…'] : []),
231 ].join(' · ')
232}
233
234// Since when the pane shows changes: the time the oldest state it compares
235// against was recorded, the day too when not today; else the last review.
236function sinceOf(view: Pick<PaneView, 'watch' | 'now'>): string {
237 const { since } = view.watch
238 return since === null ? 'your last review' : timeOf(since, view.now)
239}
240
241const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
242
243// A time on this machine's clock, as 14:02, or 9 Oct 14:02 when not on the
244// day of `now`.
245export function timeOf(at: number, now: number): string {
246 const then = new Date(at)
247 const clock = `${String(then.getHours()).padStart(2, '0')}:${String(then.getMinutes()).padStart(2, '0')}`
248 return then.toDateString() === new Date(now).toDateString() ? clock : `${then.getDate()} ${MONTHS[then.getMonth()] ?? ''} ${clock}`
249}
250
251// A size in bytes, as people read it.
252export function sizeOf(bytes: number): string {
253 if (bytes < 1024) {
254 return `${bytes} B`
255 }
256 return bytes < 1_048_576 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1_048_576).toFixed(1)} MB`
257}
258
259// A Google Doc's icon: Font Awesome's Google mark, in Google's blue.
260const GOOGLE_DOC = { glyph: '\u{f1a0}', color: '#4285f4' }
261
262const HOW: Record<Exclude<DocEdit['how'], 'create'>, [string, string]> = {
263 suggest: ['edit as suggestions', 'edits as suggestions'],
264 direct: ['edit made directly', 'edits made directly'],
265 comment: ['edit as comments', 'edits as comments'],
266 review: ['time it accepted or rejected suggestions', 'times it accepted or rejected suggestions'],
267}
268
269// How Claude edited a Google Doc, under its row: whether it made it, how
270// many edits of each kind, and that it is reviewed in Google Docs.
271export function docWords(hows: readonly DocEdit['how'][]): string {
272 const counts = (['suggest', 'direct', 'comment', 'review'] as const).flatMap(how => {
273 const n = hows.filter(one => one === how).length
274 return n === 0 ? [] : [`${n} ${n === 1 ? HOW[how][0] : HOW[how][1]}`]
275 })
276 const parts = [...(hows.includes('create') ? ['made by Claude'] : []), ...counts]
277 return `In Google Docs: ${parts.join(', ')}. Review it there, where suggestions wait for you and version history keeps the rest:`
278}
279
280// What a file drawn without lines is, in its row.
281const BARE: Record<Bare['why'], string> = {
282 binary: 'binary',
283 large: 'over 1 MiB',
284 secret: 'secret',
285 unreadable: 'unreadable',
286 cut: 'too much at once',
287}
288
289// Why a file has no lines, and its sizes, under its row.
290export function bareOf(bare: Bare): string {
291 const why = {
292 binary: 'A binary file',
293 large: 'Over 1 MiB, so difnote keeps no copy of it',
294 secret: 'Named like a secret, so difnote keeps no copy of it',
295 unreadable: "git couldn't read it",
296 cut: 'Too much changed at once for difnote to read its lines',
297 }[bare.why]
298 const { before, after } = bare
299 // Sizes that read the same rounded read in bytes, so the change shows.
300 const isSame = before !== null && after !== null && before !== after && sizeOf(before) === sizeOf(after)
301 const bytes = (size: number) => `${size.toLocaleString('en-US')} B`
302 const sizes =
303 before !== null && after !== null
304 ? isSame
305 ? `${bytes(before)} → ${bytes(after)}`
306 : `${sizeOf(before)} → ${sizeOf(after)}`
307 : after !== null
308 ? `now ${sizeOf(after)}`
309 : before !== null
310 ? `was ${sizeOf(before)}`
311 : ''
312 return `${why}: no lines to show.${sizes === '' ? '' : ` ${sizes}.`}`
313}
314
315// A hunk header's words: the lines it spans, what it adds and removes, and
316// its notes.
317export function hunkLabel(row: Extract<Row, { kind: 'hunk' }>, notes: number): string {
318 const lines = row.to > row.from ? `lines ${row.from}–${row.to}` : `line ${row.from}`
319 const added = row.changed.filter(line => line.startsWith('+')).length
320 const removed = row.changed.length - added
321 const counts = [added > 0 ? `+${added}` : '', removed > 0 ? `−${removed}` : ''].filter(part => part !== '').join(' ')
322 return [lines, counts, ...(notes > 0 ? [`✎${notes}`] : [])].join(' · ')
323}
324
325// The folders carried from earlier sessions that still hold changes, each
326// with how many of the files listed are in it.
327export function carriedOf(view: Pick<PaneView, 'files' | 'watch'>): { folder: string; usedAt: number; count: number }[] {
328 return view.watch.carried.flatMap(one => {
329 const count = view.files.filter(file => file.doc === null && isInside(file.path, one.folder)).length
330 return count === 0 ? [] : [{ ...one, count }]
331 })
332}
333
334// The files as the list groups them: under the folder they are watched in,
335// the session's first and the rest by path, Google Docs last; in each, by
336// their folder in it, folders by path and then the files at its top; each
337// folder's files by name. A folder's key names its root and its path in it.
338export type Group = { root: string; label: string; dirs: { key: string; dir: string; files: FileDiff[] }[] }
339
340export function groupsOf(files: readonly FileDiff[], watch: Pick<Watch, 'roots' | 'home'>): Group[] {
341 const own = watch.roots[0] ?? ''
342 const rootOf = (file: FileDiff): string => {
343 if (file.doc !== null) {
344 return DOCS
345 }
346 if (!file.shown.startsWith('/')) {
347 return own
348 }
349 const found = watch.roots.filter(root => root !== own && isInside(file.path, root)).toSorted((x, y) => y.length - x.length)[0]
350 return found ?? parentOf(file.path)
351 }
352 const inRoot = (file: FileDiff, root: string): string => {
353 if (file.doc !== null) {
354 return ''
355 }
356 const path = file.shown.startsWith('/') ? file.path.slice(root.length + 1) : file.shown
357 const cut = path.lastIndexOf('/')
358 return cut < 0 ? '' : path.slice(0, cut)
359 }
360 const byRoot = new Map<string, FileDiff[]>()
361 for (const file of files) {
362 const root = rootOf(file)
363 byRoot.set(root, [...(byRoot.get(root) ?? []), file])
364 }
365 const order = (root: string) => (root === own ? 0 : root === DOCS ? 2 : 1)
366 return [...byRoot]
367 .toSorted(([x], [y]) => order(x) - order(y) || (x < y ? -1 : x > y ? 1 : 0))
368 .map(([root, list]) => {
369 const byDir = new Map<string, FileDiff[]>()
370 for (const file of list) {
371 const dir = inRoot(file, root)
372 byDir.set(dir, [...(byDir.get(dir) ?? []), file])
373 }
374 const dirs = [...byDir]
375 .toSorted(([x], [y]) => (x === '' ? 1 : 0) - (y === '' ? 1 : 0) || (x < y ? -1 : x > y ? 1 : 0))
376 .map(([dir, inDir]) => ({
377 key: `dir:${root}|${dir}`,
378 dir,
379 files: inDir.toSorted((x, y) => (nameIn(x) < nameIn(y) ? -1 : nameIn(x) > nameIn(y) ? 1 : 0)),
380 }))
381 return { root, label: root === DOCS ? DOCS : homed(root, watch.home), dirs }
382 })
383}
384
385// The heading Google Docs are listed under.
386const DOCS = 'Google Docs'
387
388// A file's name in its row: the last part of its path; a Google Doc's title.
389function nameIn(file: FileDiff): string {
390 return file.doc === null ? file.shown.slice(file.shown.lastIndexOf('/') + 1) : file.shown
391}
392
393// A folder written from the home folder as ~.
394function homed(folder: string, home: string | null): string {
395 return home !== null && isInside(folder, home) ? `~${folder.slice(home.length)}` : folder
396}
397
398// ---------------------------------------------------------------------------
399// The plan.
400
401// The rows by their file's path, made once for each set of rows.
402let byFile: { rows: readonly Row[] | null; map: Map<string, Row[]> } = { rows: null, map: new Map() }
403function byFileOf(rows: readonly Row[]): Map<string, Row[]> {
404 if (byFile.rows !== rows) {
405 const map = new Map<string, Row[]>()
406 for (const row of rows) {
407 const list = map.get(row.file.path)
408 if (list === undefined) {
409 map.set(row.file.path, [row])
410 } else {
411 list.push(row)
412 }
413 }
414 byFile = { rows, map }
415 }
416 return byFile.map
417}
418
419// One thing the list shows, the rows of the pane it takes, and its key: a
420// file's row; the open file's hunk headers and code lines, each line with
421// its numbers on both sides and the gutter's digits; a note under its row,
422// its lines already cut; the note field; prose cut into lines; a folder
423// from earlier sessions.
424type Base = { key: string; rows: number }
425type HunkRow = Extract<Row, { kind: 'hunk' }>
426type LineRow = Extract<Row, { kind: 'line' | 'context' }>
427export type Item =
428 | (Base & { kind: 'file'; file: FileDiff; indent: number })
429 | (Base & { kind: 'hunk'; row: HunkRow })
430 | (Base & { kind: 'code'; row: LineRow; hunk: string; line: string; old: number; new: number; digits: number; isCut: boolean })
431 | (Base & { kind: 'note'; lines: string[]; indent: number })
432 | (Base & { kind: 'field'; row: Row })
433 | (Base & { kind: 'text'; lines: string[]; tone: 'quiet' | 'plain' })
434 | (Base & { kind: 'folder'; folder: string; usedAt: number; count: number })
435 | (Base & { kind: 'root'; label: string; count: number })
436 | (Base & { kind: 'dir'; dir: string; count: number; isClosed: boolean; indent: number })
437
438// The digits of a diff's gutter as Claude Code draws a hunk: those of the
439// last line number its header spans on either side.
440export function gutterDigits(hunk: Hunk): number {
441 return String(Math.max(1, hunk.oldStart + hunk.oldLines - 1, hunk.newStart + hunk.newLines - 1)).length
442}
443
444// The rows of the pane a line of code takes at `room` cells beside its
445// gutter, wrapped as Claude Code wraps code: by character.
446function codeRows(text: string, room: number): number {
447 return Math.max(1, Math.ceil(cellsOf(text) / Math.max(1, room)))
448}
449
450// Whether the cursor stops on an item: files, hunk headers, changed lines
451// and folders; context, notes and prose are only read.
452export function isStop(item: Item): boolean {
453 return (
454 item.kind === 'file' ||
455 item.kind === 'dir' ||
456 item.kind === 'hunk' ||
457 item.kind === 'folder' ||
458 (item.kind === 'code' && item.row.kind === 'line')
459 )
460}
461
462// The items of the list at a width, for where the person is.
463export function itemsOf(view: PaneView, cursor: string | null): Item[] {
464 const { files, rows, notes, editing, place, columns } = view
465 // Every row gives its first column to the cursor's bar.
466 const width = Math.max(4, columns - 1)
467 const items: Item[] = []
468 const noteOf = (key: string) => notes.find(note => note.target === key)
469 const under = (row: Row, indent: number) => {
470 if (editing === row.key) {
471 items.push({ kind: 'field', key: `field:${row.key}`, rows: width - indent < 60 ? 2 : 1, row })
472 return
473 }
474 const note = noteOf(row.key)
475 if (note !== undefined) {
476 const lines = wrapped(`✎ ${note.text}`, width - indent - 2)
477 items.push({ kind: 'note', key: `note:${row.key}`, rows: lines.length, lines, indent })
478 }
479 }
480 const prose = (key: string, text: string, tone: 'quiet' | 'plain', indent = 0) => {
481 const lines = wrapped(text, width - indent).map(line => `${' '.repeat(indent)}${line}`)
482 items.push({ kind: 'text', key, rows: lines.length, lines, tone })
483 }
484
485 // A file's row; open, its hunks, each header then its lines, with the
486 // notes under the rows they are on.
487 const planFile = (file: FileDiff, indent: number) => {
488 items.push({ kind: 'file', key: `file:${file.path}`, rows: 1, file, indent })
489 const own = byFileOf(rows).get(file.path) ?? []
490 const title = own.find(row => row.kind === 'file')
491 if (title !== undefined) {
492 under(title, indent + 2)
493 }
494 if (place.open !== file.path) {
495 return
496 }
497 if (file.doc !== null) {
498 prose(`doc:${file.path}`, docWords(file.doc), 'quiet', indent + 2)
499 prose(`link:${file.path}`, file.path, 'plain', indent + 2)
500 } else if (file.bare !== null) {
501 prose(`bare:${file.path}`, bareOf(file.bare), 'quiet', indent + 2)
502 }
503 let hunk: HunkRow | null = null
504 let digits = 1
505 let old = 0
506 let now = 0
507 for (const row of own) {
508 if (row.kind === 'hunk') {
509 hunk = row
510 digits = gutterDigits(row.hunk)
511 old = row.hunk.oldStart
512 now = row.hunk.newStart
513 items.push({ kind: 'hunk', key: row.key, rows: 1, row })
514 under(row, 2)
515 } else if ((row.kind === 'line' || row.kind === 'context') && hunk !== null) {
516 const sign = row.kind === 'context' ? ' ' : row.sign
517 const text = untabbed(row.text)
518 // The gutter: the number's digits, a space before and after them,
519 // and the sign.
520 const room = width - (digits + 3)
521 const isCompact = place.isCompact && row.key !== cursor
522 const full = codeRows(text, room)
523 const shown = isCompact ? 1 : Math.min(full, MOST_ROWS)
524 const isCut = !isCompact && full > MOST_ROWS
525 const line = `${sign}${isCut ? fit(text, MOST_ROWS * room - 1) : text}`
526 items.push({ kind: 'code', key: row.key, rows: shown, row, hunk: hunk.key, line, old, new: now, digits, isCut })
527 old += sign === '+' ? 0 : 1
528 now += sign === '-' ? 0 : 1
529 if (row.kind === 'line') {
530 under(row, 4)
531 }
532 }
533 }
534 }
535
536 const groups = groupsOf(files, view.watch)
537 // A group's heading says where its files are: shown unless the one group
538 // is the session's own folder.
539 const isHeaded = groups.length > 1 || groups.some(group => group.root !== (view.watch.roots[0] ?? ''))
540 for (const group of groups) {
541 if (isHeaded) {
542 items.push({
543 kind: 'root',
544 key: `root:${group.root}`,
545 rows: 1,
546 label: group.label,
547 count: group.dirs.reduce((sum, dir) => sum + dir.files.length, 0),
548 })
549 }
550 for (const dir of group.dirs) {
551 const isClosed = place.closed.includes(dir.key)
552 if (dir.dir !== '') {
553 items.push({ kind: 'dir', key: dir.key, rows: 1, dir: dir.dir, count: dir.files.length, isClosed, indent: 0 })
554 }
555 if (!isClosed) {
556 for (const file of dir.files) {
557 planFile(file, dir.dir === '' ? 0 : 2)
558 }
559 }
560 }
561 }
562
563 if (files.length === 0 && !view.watch.hasGit) {
564 prose('nogit', "Without git, difnote sees only Claude's Edit and Write.", 'quiet')
565 }
566 const carried = carriedOf(view)
567 if (carried.length > 0) {
568 items.push({ kind: 'text', key: 'gap:carried', rows: 1, lines: [''], tone: 'quiet' })
569 prose('heading:carried', 'From earlier sessions here, until you review them:', 'quiet')
570 for (const one of carried) {
571 items.push({ kind: 'folder', key: `${FOLDER}${one.folder}`, rows: 1, ...one })
572 }
573 }
574 const keys = new Set(rows.map(row => row.key))
575 const gone = notes.filter(note => !keys.has(note.target))
576 if (gone.length > 0) {
577 items.push({ kind: 'text', key: 'gap:gone', rows: 1, lines: [''], tone: 'quiet' })
578 prose('heading:gone', 'Notes on lines changed since:', 'quiet')
579 for (const note of gone) {
580 const lines = wrapped(`✎ ${note.anchor} · ${note.text}`, width - 4)
581 items.push({ kind: 'note', key: `gone:${note.target}`, rows: lines.length, lines, indent: 2 })
582 }
583 }
584 const blocks: [string, string, string[]][] = [
585 ['slow', "Slow to look at, so looked at only after Claude's changes or a key:", view.watch.slow],
586 ['skipped', "Not watched, so only Claude's Edit and Write show there:", view.watch.skipped.map(({ folder, why }) => `${folder} (${why})`)],
587 ['lost', "Emptied after 30 days unused, with changes you hadn't reviewed:", view.watch.lost],
588 ]
589 for (const [name, heading, lines] of blocks) {
590 if (lines.length > 0) {
591 items.push({ kind: 'text', key: `gap:${name}`, rows: 1, lines: [''], tone: 'quiet' })
592 prose(`heading:${name}`, heading, 'quiet')
593 lines.forEach((line, i) => prose(`${name}:${i}`, line, 'quiet', 2))
594 }
595 }
596 return items
597}
598
599// The keys the footer offers on the cursor's item, in the order shown, each
600// with its hotkey (null for the button Enter presses), its label and what
601// it does; while a note is being written, none.
602export type Key = { key: string; hotkey: string | null; label: string; act: keyof Omit<Actions, 'save'> }
603
604export function keysOf(view: PaneView, item: Item | undefined, order: readonly FileDiff[]): Key[] {
605 if (view.editing !== null) {
606 return []
607 }
608 const open = view.place.open
609 const hasNote = (key: string) => view.notes.some(note => note.target === key)
610 const isInOpen = item !== undefined && (item.kind === 'hunk' || item.kind === 'code')
611 const isOpenFile = item?.kind === 'file' && open === item.file.path
612 const enter: Key[] =
613 item === undefined
614 ? []
615 : item.kind === 'file'
616 ? [{ key: ENTER, hotkey: null, label: isOpenFile ? '↵ close' : '↵ open', act: 'act' }]
617 : item.kind === 'dir'
618 ? [{ key: ENTER, hotkey: null, label: item.isClosed ? '↵ open folder' : '↵ close folder', act: 'act' }]
619 : item.kind === 'folder'
620 ? [{ key: ENTER, hotkey: null, label: '↵ open newest', act: 'act' }]
621 : item.kind === 'root'
622 ? []
623 : [{ key: ENTER, hotkey: null, label: hasNote(item.key) ? '↵ edit note' : '↵ note', act: 'act' }]
624 const inLabel =
625 item?.kind === 'file' ? (isOpenFile ? 'in' : 'open') : item?.kind === 'dir' && item.isClosed ? 'open' : item?.kind === 'hunk' ? 'in' : null
626 const outLabel =
627 isInOpen || isOpenFile ? 'close' : item?.kind === 'file' && item.indent > 0 ? 'folder' : item?.kind === 'dir' && !item.isClosed ? 'close' : null
628 const current = item?.kind === 'file' ? item.file.path : open
629 const isLast = order.length === 0 || order.at(-1)?.path === current
630 const canNote = item !== undefined && (item.kind === 'file' || item.kind === 'hunk' || item.kind === 'code')
631 const marked = item?.kind === 'file' ? item.file : isInOpen ? view.files.find(one => one.path === open) : undefined
632 const viewedLabel = marked === undefined ? null : isViewed(view, marked) ? 'not viewed' : 'viewed'
633 return [
634 ...enter,
635 ...(inLabel === null ? [] : [{ key: 'key-in', hotkey: 'l', label: inLabel, act: 'in' } as const]),
636 ...(outLabel === null ? [] : [{ key: 'key-out', hotkey: 'h', label: outLabel, act: 'out' } as const]),
637 ...(canNote ? [{ key: 'key-note', hotkey: 'c', label: 'note', act: 'note' } as const] : []),
638 ...(viewedLabel === null ? [] : [{ key: 'key-viewed', hotkey: 'v', label: viewedLabel, act: 'viewed' } as const]),
639 ...(open !== null && !isLast ? [{ key: 'key-next', hotkey: 'n', label: 'next file', act: 'next' } as const] : []),
640 ...(open !== null ? [{ key: 'key-wrap', hotkey: 'w', label: view.place.isCompact ? 'wrap' : 'compact', act: 'wrap' } as const] : []),
641 ...(item?.kind === 'folder' ? [{ key: 'key-drop', hotkey: 'x', label: 'stop watching', act: 'drop' } as const] : []),
642 ...(view.notes.length > 0
643 ? [{ key: 'send', hotkey: 's', label: `send ${plural(view.notes.length, 'note', 'notes')}`, act: 'send' } as const]
644 : view.files.length > 0
645 ? [{ key: 'key-reviewed', hotkey: 'r', label: 'mark all reviewed', act: 'reviewed' } as const]
646 : []),
647 ...(item !== undefined ? [{ key: 'key-down', hotkey: 'j', label: 'down', act: 'down' } as const] : []),
648 ...(item !== undefined ? [{ key: 'key-up', hotkey: 'k', label: 'up', act: 'up' } as const] : []),
649 { key: 'key-close', hotkey: 'q', label: 'close', act: 'close' },
650 ]
651}
652
653// The text a key shows: its hotkey and label, as Claude Code draws a plain
654// button with a hotkey; Enter's button its label alone.
655function keyText(one: Key): string {
656 return one.hotkey === null ? one.label : `${one.hotkey}: ${one.label}`
657}
658
659const FIELD_HINT = '↵ save · empty removes · Esc cancels'
660
661// The rows the footer takes at a width: its keys laid out as Claude Code
662// wraps a row of them, two cells apart.
663function footerRows(keys: readonly Key[], width: number, isEditing: boolean): number {
664 if (isEditing) {
665 return wrapped(FIELD_HINT, width).length
666 }
667 let rows = 1
668 let used = 0
669 for (const one of keys) {
670 const cells = cellsOf(keyText(one))
671 if (used > 0 && used + 2 + cells > width) {
672 rows += 1
673 used = cells
674 } else {
675 used += (used > 0 ? 2 : 0) + cells
676 }
677 }
678 return rows
679}
680
681// The list planned for the pane: its items, the files in the order listed,
682// the keys of the items the cursor stops on, the cursor's key (the one
683// asked for, else the nearest stop left of it), the footer's keys, the rows
684// the window has, its first item, and the open file's row when it stands in
685// for the window's first row because it scrolled above it.
686export type Layout = {
687 items: Item[]
688 order: FileDiff[]
689 stops: string[]
690 cursor: string | null
691 keys: Key[]
692 window: number
693 top: number
694 sticky: Item | null
695}
696
697export function layoutOf(view: PaneView): Layout {
698 const asked = view.place.cursor
699 // The cursor's own rows depend on whether it is on a line, so the plan
700 // is made once for the key asked, then checked.
701 let items = itemsOf(view, asked)
702 let stops = items.filter(isStop).map(item => item.key)
703 const cursor = stops.includes(asked ?? '') ? asked : nearestStop(items, asked, view.place.open)
704 if (cursor !== asked) {
705 items = itemsOf(view, cursor)
706 stops = items.filter(isStop).map(item => item.key)
707 }
708 const at = items.findIndex(item => item.key === cursor)
709 const order = groupsOf(view.files, view.watch).flatMap(group => group.dirs.flatMap(dir => dir.files))
710 const keys = keysOf(view, items[at], order)
711 const width = Math.max(4, view.columns)
712 const window = Math.max(3, view.height - 1 - footerRows(keys, width, view.editing !== null))
713 const margin = window < 12 ? 1 : 2
714 let top = topFor(items, at, view.place.top, window, margin)
715 let sticky = stickyAt(items, top, view.place.open)
716 if (sticky !== null) {
717 top = topFor(items, at, top, window - 1, margin)
718 sticky = stickyAt(items, top, view.place.open)
719 }
720 return { items, order, stops, cursor, keys, window, top, sticky }
721}
722
723// A stop for a cursor whose row is gone, as after a review or Claude's
724// change of its line: the open file's row, else the first stop.
725function nearestStop(items: readonly Item[], asked: string | null, open: string | null): string | null {
726 if (asked !== null && open !== null) {
727 const own = items.find(item => item.kind === 'file' && item.file.path === open)
728 if (own !== undefined) {
729 return own.key
730 }
731 }
732 return items.find(isStop)?.key ?? null
733}
734
735// The first item the window shows: the one asked for, moved as little as it
736// takes to show the cursor with `margin` rows around it, and never so far
737// down that the window ends short of its height with items left above.
738export function topFor(items: readonly Item[], at: number, asked: number, window: number, margin: number): number {
739 const rowsOf = (from: number, to: number) => items.slice(from, to).reduce((sum, item) => sum + item.rows, 0)
740 let top = Math.max(0, Math.min(asked, items.length - 1))
741 if (at >= 0) {
742 if (at < top) {
743 top = at
744 }
745 while (top > 0 && rowsOf(top, at) < margin) {
746 top -= 1
747 }
748 while (top < at && rowsOf(top, at + 1) + margin > window) {
749 top += 1
750 }
751 }
752 // No empty rows at the bottom while items sit above the window.
753 while (top > 0 && rowsOf(top - 1, items.length) <= window) {
754 top -= 1
755 }
756 return top
757}
758
759// The open file's row, when the window starts inside that file below it.
760function stickyAt(items: readonly Item[], top: number, open: string | null): Item | null {
761 if (open === null) {
762 return null
763 }
764 const at = items.findIndex(item => item.kind === 'file' && item.file.path === open)
765 if (at < 0 || at >= top) {
766 return null
767 }
768 const end = items.findIndex(
769 (item, i) =>
770 i > at && (item.kind === 'file' || item.kind === 'dir' || item.kind === 'root' || item.kind === 'folder' || item.key.startsWith('gap:')),
771 )
772 return top < (end < 0 ? items.length : end) ? (items[at] ?? null) : null
773}
774
775// ---------------------------------------------------------------------------
776// The drawing.
777
778export function paneOf(view: PaneView, layout: Layout, actions: Actions): RenderElement {
779 const { ui, colors, files, notes, columns } = view
780 const { Box, Text } = ui
781 const width = Math.max(1, columns)
782 const added = files.reduce((sum, file) => sum + file.added, 0)
783 const removed = files.reduce((sum, file) => sum + file.removed, 0)
784 const totals = files.length === 0 ? '' : ` +${added} −${removed}`
785 const header =
786 files.length === 0 && notes.length === 0 ? (
787 <Text color={colors.quiet}>{fit(emptyOf(view), width)}</Text>
788 ) : (
789 <Text>
790 <Text color={colors.quiet}>{fit(summaryOf(view), width - cellsOf(totals))}</Text>
791 {files.length > 0 && <Text color={colors.added}>{` +${added}`}</Text>}
792 {files.length > 0 && <Text color={colors.removed}>{` −${removed}`}</Text>}
793 </Text>
794 )
795
796 return (
797 <Box flexDirection="column" width={width}>
798 <Box flexShrink={0}>{header}</Box>
799 <Box flexDirection="column" height={layout.window} overflow="hidden" flexShrink={0}>
800 {bodyOf(view, layout, actions)}
801 </Box>
802 <Box flexShrink={0}>{footerOf(view, layout, actions)}</Box>
803 {Array.from({ length: MARGIN_ROWS }, (_, i) => (
804 <Text key={`margin:${i}`}> </Text>
805 ))}
806 </Box>
807 )
808}
809
810// What the pane says with no files changed.
811function emptyOf(view: PaneView): string {
812 if (!view.watch.hasGit) {
813 return 'No changes from Claude since your last review.'
814 }
815 return view.isLooking ? 'Looking at the folders Claude works in…' : `No changes since ${sinceOf(view)}.`
816}
817
818// The rows the window shows: from its first item until it is full, the
819// open file's row first when it scrolled above. Lines of one hunk next to
820// one another are drawn as one piece of code; the cursor's line is a piece
821// of its own, beside its bar.
822function bodyOf(view: PaneView, layout: Layout, actions: Actions): RenderElement[] {
823 const drawn: RenderElement[] = []
824 let filled = 0
825 if (layout.sticky !== null) {
826 drawn.push(itemOf(view, layout, layout.sticky, actions, true))
827 filled += 1
828 }
829 let run: Extract<Item, { kind: 'code' }>[] = []
830 const flush = () => {
831 if (run.length > 0) {
832 drawn.push(codeOf(view, run, false))
833 run = []
834 }
835 }
836 for (let i = layout.top; i < layout.items.length && filled < layout.window; i += 1) {
837 const item = layout.items[i]
838 if (item === undefined) {
839 break
840 }
841 filled += item.rows
842 if (item.kind === 'code' && item.key !== layout.cursor) {
843 if (run.length > 0 && run[0]?.hunk !== item.hunk) {
844 flush()
845 }
846 run.push(item)
847 continue
848 }
849 flush()
850 drawn.push(item.kind === 'code' ? codeOf(view, [item], true) : itemOf(view, layout, item, actions, false))
851 }
852 flush()
853 return drawn
854}
855
856// The bar column every row starts with: the cursor's mark on its row,
857// dimmed while the pane doesn't hold the keyboard.
858function barOf(view: PaneView, isCursor: boolean): RenderElement {
859 const { ui, colors } = view
860 return isCursor ? (
861 <ui.Text color={colors.cursor} dimColor={!view.isFocused}>
862 ▌
863 </ui.Text>
864 ) : (
865 <ui.Text> </ui.Text>
866 )
867}
868
869// A piece of code: lines of one hunk, drawn as Claude Code's highlighted
870// diff of the file, numbered where its first line stands.
871function codeOf(view: PaneView, lines: readonly Extract<Item, { kind: 'code' }>[], isCursor: boolean): RenderElement {
872 const { ui, place } = view
873 const first = lines[0]
874 if (first === undefined) {
875 return <ui.Text> </ui.Text>
876 }
877 const piece: Hunk = {
878 oldStart: first.old,
879 oldLines: lines.filter(one => !one.line.startsWith('+')).length,
880 newStart: first.new,
881 newLines: lines.filter(one => !one.line.startsWith('-')).length,
882 lines: lines.map(one => one.line),
883 }
884 // Cut lines only where the person asked for it, and never the cursor's.
885 const wrap = place.isCompact && !isCursor ? 'truncate-end' : 'wrap'
886 // A piece's gutter is as wide as its own numbers: one with fewer digits
887 // than its hunk is moved over, so the numbers line up as the whole's
888 // would. The room stays uncolored: Claude Code's diff colors are its
889 // highlighter's, which no theme color matches.
890 return (
891 <ui.Box key={`code:${first.key}`} flexDirection="row" flexShrink={0}>
892 {barOf(view, isCursor)}
893 <ui.Box flexGrow={1} paddingLeft={first.digits - gutterDigits(piece)}>
894 <ui.Code source={hunkText(piece)} format="diff" path={first.row.file.path} wrap={wrap} />
895 </ui.Box>
896 </ui.Box>
897 )
898}
899
900// Every other item: a file's row, a hunk header, a note, the note field,
901// prose, or a folder from earlier sessions.
902function itemOf(view: PaneView, layout: Layout, item: Item, actions: Actions, isSticky: boolean): RenderElement {
903 const { ui, colors, notes } = view
904 const { Box, Text } = ui
905 const width = Math.max(4, view.columns - 1)
906 const isCursor = item.key === layout.cursor && !isSticky
907 const row = (content: RenderElement) => (
908 <Box key={`${isSticky ? 'sticky:' : ''}${item.key}`} flexDirection="row" flexShrink={0} {...(item.kind === 'file' ? { height: 1 } : {})}>
909 {barOf(view, isCursor)}
910 {content}
911 </Box>
912 )
913 switch (item.kind) {
914 case 'file': {
915 const { file } = item
916 const count = notes.filter(note => note.path === file.path).length
917 const isOpen = view.place.open === file.path
918 const at = layout.order.indexOf(file)
919 const place = isOpen ? ` · ${at + 1}/${layout.order.length}` : ''
920 const tail = `${factsOf(file)}${count > 0 ? ` ✎${count}` : ''}${place}`
921 const lead = `${' '.repeat(item.indent)}${isOpen ? '▾ ' : ''}`
922 // Viewed, the whole row is quiet and marked, its counts too.
923 if (isViewed(view, file)) {
924 const mark = `${lead}✓ `
925 return row(
926 <Text key={item.key} color={colors.quiet}>
927 {`${mark}${fitStart(nameIn(file), width - cellsOf(tail) - cellsOf(mark) - 1)}${tail}`}
928 </Text>,
929 )
930 }
931 return row(
932 <Text key={item.key}>
933 {lead === '' ? '' : <Text color={colors.quiet}>{lead}</Text>}
934 {nameOf(view, file, width - cellsOf(tail) - cellsOf(lead) - 1)}
935 {countsOf(view, file)}
936 {count > 0 ? <Text color={colors.note}>{` ✎${count}`}</Text> : ''}
937 {place === '' ? '' : <Text color={colors.quiet}>{place}</Text>}
938 </Text>,
939 )
940 }
941 case 'root':
942 return row(
943 <Text key={item.key} bold>
944 {fitStart(item.label, width - cellsOf(` · ${plural(item.count, 'file', 'files')}`))}
945 <Text color={colors.quiet}>{` · ${plural(item.count, 'file', 'files')}`}</Text>
946 </Text>,
947 )
948 case 'dir': {
949 const facts = ` · ${plural(item.count, 'file', 'files')}`
950 const mark = item.isClosed ? '▸ ' : ''
951 return row(
952 <Text key={item.key} color={colors.quiet}>
953 {`${mark}${fitStart(`${item.dir}/`, width - cellsOf(facts) - cellsOf(mark))}${facts}`}
954 </Text>,
955 )
956 }
957 case 'hunk': {
958 const lines = byFileOf(view.rows).get(item.row.file.path) ?? []
959 const from = lines.indexOf(item.row)
960 const end = lines.findIndex((one, i) => i > from && one.kind === 'hunk')
961 const own = lines.slice(from + 1, end < 0 ? lines.length : end)
962 const hasNote = (key: string) => notes.some(note => note.target === key)
963 const count = own.filter(one => hasNote(one.key)).length + (hasNote(item.key) ? 1 : 0)
964 return row(
965 <Text key={item.key} color={colors.quiet}>
966 {fit(` ${hunkLabel(item.row, count)}`, width)}
967 </Text>,
968 )
969 }
970 case 'code':
971 return codeOf(view, [item], isCursor)
972 case 'note':
973 return (
974 <Box key={item.key} flexDirection="column" flexShrink={0}>
975 {item.lines.map((line, i) => (
976 <Text key={`${item.key}:${i}`} color={colors.note}>
977 {`${' '.repeat(item.indent + 1)}${i === 0 ? '' : ' '}${line}`}
978 </Text>
979 ))}
980 </Box>
981 )
982 case 'field':
983 return fieldOf(view, item.row, actions)
984 case 'text':
985 return (
986 <Box key={item.key} flexDirection="column" flexShrink={0}>
987 {item.lines.map((line, i) => (
988 <Text key={`${item.key}:${i}`} {...(item.tone === 'quiet' ? { color: colors.quiet } : {})}>
989 {` ${line}`}
990 </Text>
991 ))}
992 </Box>
993 )
994 case 'folder': {
995 const facts = ` · ${plural(item.count, 'file', 'files')} · last used ${timeOf(item.usedAt, view.now)}`
996 return row(<Text key={item.key} color={colors.quiet}>{` ${fitStart(item.folder, width - cellsOf(facts) - 2)}${facts}`}</Text>)
997 }
998 default:
999 return row(<Text> </Text>)
1000 }
1001}
1002
1003// A file's name as its row shows it, under its folder's heading: its icon
1004// in its color and its name, cut from the start to `width`.
1005function nameOf(view: PaneView, file: FileDiff, width: number): (RenderElement | string)[] {
1006 const { ui, hasIcons } = view
1007 const icon = file.doc === null ? iconOf(file.path) : GOOGLE_DOC
1008 return [...(hasIcons ? [<ui.Text color={icon.color}>{`${icon.glyph} `}</ui.Text>] : []), fitStart(nameIn(file), width - (hasIcons ? 2 : 0))]
1009}
1010
1011// What a file adds and removes, in the added and removed colors; "new" for
1012// a file created, "deleted" for one gone; and, for a file without lines,
1013// what it is.
1014function countsOf(view: PaneView, file: FileDiff): RenderElement[] {
1015 const { ui, colors } = view
1016 if (file.doc !== null) {
1017 return [<ui.Text color={colors.quiet}>{factsOf(file)}</ui.Text>]
1018 }
1019 const counts = file.isCreate
1020 ? [<ui.Text color={colors.added}> new</ui.Text>]
1021 : file.isDelete
1022 ? [<ui.Text color={colors.removed}> deleted</ui.Text>]
1023 : file.bare === null
1024 ? [<ui.Text color={colors.added}>{` +${file.added}`}</ui.Text>, <ui.Text color={colors.removed}>{` −${file.removed}`}</ui.Text>]
1025 : []
1026 return file.bare === null ? counts : [...counts, <ui.Text color={colors.quiet}>{` ${BARE[file.bare.why]}`}</ui.Text>]
1027}
1028
1029// countsOf's text, for the room it takes.
1030function factsOf(file: FileDiff): string {
1031 if (file.doc !== null) {
1032 return ` Google Doc · ${plural(file.doc.length, 'edit', 'edits')}`
1033 }
1034 const counts = file.isCreate ? ' new' : file.isDelete ? ' deleted' : file.bare === null ? ` +${file.added} −${file.removed}` : ''
1035 return file.bare === null ? counts : `${counts} ${BARE[file.bare.why]}`
1036}
1037
1038// The one-line note field, under its row: Enter saves, an empty note
1039// removes it, Esc cancels. On a line, it says which.
1040function fieldOf(view: PaneView, row: Row, actions: Actions): RenderElement {
1041 const { ui, Input, colors, notes } = view
1042 if (Input === undefined) {
1043 return <ui.Text color={colors.quiet}>{fit(' Notes need the terminal or the desktop app.', view.columns)}</ui.Text>
1044 }
1045 const was = notes.find(note => note.target === row.key)?.text ?? ''
1046 const label = row.kind === 'line' ? `✎ ${row.gutter}${row.sign === '-' ? ' (removed)' : ''}` : '✎'
1047 return (
1048 <ui.Box key={`field:${row.key}`} paddingLeft={2} flexShrink={0}>
1049 <Input
1050 key={FIELD}
1051 label={label}
1052 value={was}
1053 placeholder="Enter saves · empty removes · Esc cancels"
1054 submitLabel="save"
1055 autoFocus
1056 onSubmit={text => actions.save(row, text)}
1057 />
1058 </ui.Box>
1059 )
1060}
1061
1062// The keys, pinned under the window: the button Enter presses, saying what
1063// it does on the cursor's row, then each key that works there. A key
1064// shows whenever it works, as a hotkey works only while its button is
1065// drawn; the row wraps rather than leave one out.
1066function footerOf(view: PaneView, layout: Layout, actions: Actions): RenderElement {
1067 const { ui, colors } = view
1068 if (view.editing !== null) {
1069 return <ui.Text color={colors.quiet}>{FIELD_HINT}</ui.Text>
1070 }
1071 return (
1072 <ui.Box flexDirection="row" flexWrap="wrap" columnGap={2}>
1073 {layout.keys.map(one =>
1074 one.hotkey === null ? (
1075 <ui.Button key={one.key} plain label={one.label} onPress={actions[one.act]} />
1076 ) : (
1077 <ui.Button key={one.key} plain hotkey={one.hotkey} label={one.label} onPress={actions[one.act]} />
1078 ),
1079 )}
1080 </ui.Box>
1081 )
1082}
1083hooks/review.ts 391 lines1// What the pane lists and what the prompt gets: the changes since the last
2// review as one diff per file, the rows the person moves over, and the
3// notes written on them as the prompt carries them.
4
5import type { Change, DocEdit, FileDiff, Hunk, Note } from '../types'
6import { edited, hunksOf, linesOf, patched } from './diff'
7
8// One of Claude's changes from its Edit or Write record; null for any other
9// tool, a record without the fields, or a change held for review, which
10// left the file as it was.
11export function changeOf(id: string, tool: string, record: unknown): Change | null {
12 if (!isRecord(record) || record.staged === true || typeof record.filePath !== 'string') {
13 return null
14 }
15
16 const path = record.filePath
17 const hunks = Array.isArray(record.structuredPatch) ? record.structuredPatch.filter(isHunk) : []
18 const before = typeof record.originalFile === 'string' ? record.originalFile : null
19 if (tool === 'Write' && typeof record.content === 'string') {
20 return { id, path, before, after: record.content, isCreate: record.type === 'create', hunks, doc: null }
21 }
22 if (tool === 'Edit' && typeof record.oldString === 'string' && typeof record.newString === 'string') {
23 const fromHunks = before === null || hunks.length === 0 ? null : patched(before, hunks)
24 const after = fromHunks ?? (before === null ? null : edited(before, record.oldString, record.newString, record.replaceAll === true))
25 return { id, path, before, after, isCreate: false, hunks, doc: null }
26 }
27
28 return null
29}
30
31// The Google Docs tools that change a document, by the end of their names,
32// whatever the server is called: the ones of github.com/mmedum/google-docs-mcp.
33const DOC_TOOL =
34 /^mcp__.+__(create_document|edit_document|format_document|edit_table|insert_object|layout_document|manage_tabs|delete_tab|review_suggestion)$/
35
36// A Google Doc's link, from its id or any of its links.
37export function docLink(document: string): string {
38 const id = /\/d\/([\w-]+)/.exec(document)?.[1] ?? document.trim()
39 return `https://docs.google.com/document/d/${id}/edit`
40}
41
42// One of Claude's edits of a Google Doc, from its tool call and result;
43// null for any other tool, a dry run, or a call the server refused or
44// failed, which it answers in square brackets.
45export function docOf(id: string, tool: string, input: unknown, result: unknown, titles: ReadonlyMap<string, string>): Change | null {
46 const name = DOC_TOOL.exec(tool)?.[1]
47 const text = textOf(result)
48 if (name === undefined || !isRecord(input) || input.dry_run === true || text.trimStart().startsWith('[')) {
49 return null
50 }
51 const link =
52 name === 'create_document'
53 ? (/https:\/\/docs\.google\.com\/document\/d\/[\w-]+/.exec(text)?.[0] ?? null)
54 : typeof input.document === 'string'
55 ? input.document
56 : null
57 if (link === null) {
58 return null
59 }
60 const path = docLink(link)
61 // The mode the server answered it used; else the one asked for; else the
62 // server's own default, suggestions.
63 const mode = /"mode":\s*"(\w+)"/.exec(text)?.[1] ?? (typeof input.mode === 'string' ? input.mode : 'suggest')
64 const how: DocEdit['how'] =
65 name === 'create_document'
66 ? 'create'
67 : name === 'review_suggestion'
68 ? 'review'
69 : name === 'manage_tabs' || name === 'delete_tab'
70 ? 'direct'
71 : mode === 'direct' || mode === 'comment'
72 ? mode
73 : 'suggest'
74 const title = name === 'create_document' && typeof input.title === 'string' ? input.title : (titles.get(path) ?? null)
75 return { id, path, before: null, after: null, isCreate: how === 'create', hunks: [], doc: { how, title } }
76}
77
78// A Google Doc's title, from what reading it answered: get_document's first
79// line is the title, its second the link.
80export function titleOf(tool: string, result: unknown): [string, string] | null {
81 if (!/^mcp__.+__get_document$/.test(tool)) {
82 return null
83 }
84 const [title = '', link = ''] = textOf(result).split('\n')
85 return title.trim() === '' || !link.startsWith('https://docs.google.com/document/') ? null : [docLink(link), title.trim()]
86}
87
88// A tool's result as text: as given, or its text parts.
89function textOf(result: unknown): string {
90 if (typeof result === 'string') {
91 return result
92 }
93 const parts = Array.isArray(result) ? result : isRecord(result) && Array.isArray(result.content) ? result.content : []
94 return parts.flatMap(part => (isRecord(part) && typeof part.text === 'string' ? [part.text] : [])).join('\n')
95}
96
97// The files Claude changed, by its Edit and Write records, the one changed
98// last first: each one's hunks from before Claude's first change to after
99// its latest. Where the file before the first change or after the latest is
100// unknown, or the diff too large, the latest change's own hunks stand in.
101export function filesOf(changes: readonly Change[], cwd: string, titles: ReadonlyMap<string, string> = new Map()): FileDiff[] {
102 const byPath = new Map<string, Change[]>()
103 for (const change of changes) {
104 byPath.set(change.path, [...(byPath.get(change.path) ?? []), change])
105 }
106
107 return [...byPath.entries()]
108 .map(([path, list]) => {
109 const first = list[0]
110 const latest = list.at(-1)
111 const docs = list.flatMap(change => (change.doc === null ? [] : [change.doc]))
112 if (docs.length > 0) {
113 // The title Claude read last, also after the edit.
114 const title = titles.get(path) ?? docs.findLast(doc => doc.title !== null)?.title ?? null
115 return {
116 last: changes.findLastIndex(change => change.path === path),
117 file: {
118 path,
119 shown: title ?? /\/d\/([\w-]+)/.exec(path)?.[1] ?? path,
120 isCreate: docs.some(doc => doc.how === 'create'),
121 isDelete: false,
122 hunks: [],
123 added: 0,
124 removed: 0,
125 bare: null,
126 changedAt: 0,
127 doc: docs.map(doc => doc.how),
128 },
129 }
130 }
131 const before = first?.isCreate === true ? '' : (first?.before ?? null)
132 const after = latest?.after ?? null
133 const worked = before === null || after === null ? null : hunksOf(linesOf(before), linesOf(after))
134 const hunks = worked ?? latest?.hunks ?? []
135 const lines = hunks.flatMap(hunk => hunk.lines)
136 return {
137 last: changes.findLastIndex(change => change.path === path),
138 file: {
139 path,
140 shown: shownPath(path, cwd),
141 isCreate: first?.isCreate === true,
142 isDelete: false,
143 hunks,
144 added: lines.filter(line => line.startsWith('+')).length,
145 removed: lines.filter(line => line.startsWith('-')).length,
146 bare: null,
147 changedAt: 0,
148 doc: null,
149 },
150 }
151 })
152 .toSorted((x, y) => y.last - x.last)
153 .map(({ file }) => file)
154}
155
156// A path as the person reads it: relative to the session's folder when it
157// is inside it, else as it is.
158export function shownPath(path: string, cwd: string): string {
159 const folder = cwd.endsWith('/') ? cwd : `${cwd}/`
160 return path.startsWith(folder) ? path.slice(folder.length) : path
161}
162
163// What the pane draws, top to bottom. The person moves over the file rows,
164// the hunk headers and the changed lines; context lines are only read.
165export type Row =
166 | { kind: 'file'; key: string; file: FileDiff }
167 | { kind: 'hunk'; key: string; file: FileDiff; hunk: Hunk; from: number; to: number; changed: string[] }
168 | { kind: 'line'; key: string; file: FileDiff; sign: '+' | '-'; text: string; at: number; gutter: number }
169 | { kind: 'context'; key: string; file: FileDiff; text: string; at: number }
170
171// What a row's key holds of a line's text: enough to tell a line Claude
172// changed after a note was written on it from the line the note was on.
173const KEY_TEXT = 60
174
175// The rows of the files. A line's `at` is its number in the file after
176// Claude's change, or, for a removed line, the number of the line now in
177// its place; its `gutter`, the number the diff draws beside it, a removed
178// line's in the file before. A changed line's key names its file, its side, its text and
179// which of the lines with that text it is, not its number, so lines Claude
180// adds or removes above it leave a note on it where it was; a hunk's, its
181// first changed line the same way.
182export function rowsOf(files: readonly FileDiff[]): Row[] {
183 return files.flatMap(file => {
184 const seen = new Map<string, number>()
185 const nth = (text: string) => {
186 const n = seen.get(text) ?? 0
187 seen.set(text, n + 1)
188 return `${n}:${text.slice(0, KEY_TEXT)}`
189 }
190 return [
191 { kind: 'file' as const, key: `file:${file.path}`, file },
192 ...file.hunks.flatMap(hunk => {
193 const changed = hunk.lines.filter(line => line.startsWith('+') || line.startsWith('-'))
194 const lines: Row[] = []
195 let oldLine = hunk.oldStart
196 let newLine = hunk.newStart
197 for (const line of hunk.lines) {
198 const text = line.slice(1)
199 if (line.startsWith('+')) {
200 lines.push({ kind: 'line', key: `line:${file.path}:${nth(line)}`, file, sign: '+', text, at: newLine, gutter: newLine })
201 newLine += 1
202 } else if (line.startsWith('-')) {
203 lines.push({ kind: 'line', key: `line:${file.path}:${nth(line)}`, file, sign: '-', text, at: newLine, gutter: oldLine })
204 oldLine += 1
205 } else if (line.startsWith(' ')) {
206 lines.push({ kind: 'context', key: `context:${file.path}:${newLine}`, file, text, at: newLine })
207 oldLine += 1
208 newLine += 1
209 }
210 }
211 // A hunk spans its changed lines, not the context around them.
212 const at = lines.flatMap(row => (row.kind === 'line' ? [row.at] : []))
213 const from = Math.min(...at)
214 const to = Math.max(...at)
215 const key = `hunk:${file.path}:${nth(`@${changed[0] ?? ''}`)}`
216 return [{ kind: 'hunk' as const, key, file, hunk, from, to, changed }, ...lines]
217 }),
218 ]
219 })
220}
221
222// How many changed lines of a hunk a note on its header quotes.
223export const HUNK_QUOTE = 3
224
225// A note's anchor and quote, from the row it is written on: a line's number
226// and text, marked when the line was removed; a hunk's range of lines and
227// its first changed lines, each with its - or +, so old reads apart from
228// new; a file's path alone. `shift` moves the numbers to where the lines
229// stand now (see placeOf).
230export function anchorOf(row: Row, shift = 0): { anchor: string; quote: string[] } {
231 if (row.kind === 'file') {
232 return { anchor: row.file.doc === null ? row.file.shown : `${row.file.shown} (${row.file.path})`, quote: [] }
233 }
234 if (row.kind === 'hunk') {
235 const range = row.to > row.from ? `${row.from + shift}-${row.to + shift}` : `${row.from + shift}`
236 const quote = row.changed.slice(0, HUNK_QUOTE)
237 return { anchor: `${row.file.shown}:${range}`, quote: row.changed.length > HUNK_QUOTE ? [...quote, '…'] : quote }
238 }
239
240 return { anchor: `${row.file.shown}:${row.at + shift}${row.kind === 'line' && row.sign === '-' ? ' (removed)' : ''}`, quote: [row.text] }
241}
242
243// The number of the line with this text nearest to line `near`, in the file
244// as it is now; null where the file has no such line.
245export function lineNow(file: string, text: string, near: number): number | null {
246 let best: number | null = null
247 file.split('\n').forEach((line, i) => {
248 if (line === text && (best === null || Math.abs(i + 1 - near) < Math.abs(best - near))) {
249 best = i + 1
250 }
251 })
252 return best
253}
254
255// Where a row's lines are in the file as it is now, after Claude's last Edit
256// or Write: a formatter, a shell command or the person may have changed the
257// file since. The lines of the row's hunk that are in the file, its added
258// and context lines, are found as one block, the copy nearest where they
259// were, so a line such as `}` that the file has many times goes with its own
260// hunk. Where the block was changed too, a row moves as far as the nearest
261// of the hunk's lines the file still has, an added line first by its own.
262// `changed` where the row's own line is gone, or none of its hunk is left.
263export function placeOf(row: Row, rows: readonly Row[], file: string): { shift: number; changed: boolean } {
264 const at = rows.indexOf(row)
265 let first = at
266 while (first > 0 && rows[first]?.kind !== 'hunk') {
267 first -= 1
268 }
269 let last = first + 1
270 while (last < rows.length && rows[last]?.kind !== 'hunk' && rows[last]?.kind !== 'file') {
271 last += 1
272 }
273 const stays = rows
274 .slice(first + 1, last)
275 .flatMap((one, i) => (one.kind === 'context' || (one.kind === 'line' && one.sign === '+') ? [{ ...one, index: first + 1 + i }] : []))
276 if (stays.length === 0) {
277 return { shift: 0, changed: false }
278 }
279
280 const lines = file.split('\n')
281 const from = (stays[0]?.at ?? 1) - 1
282 let block: number | null = null
283 for (let i = 0; i + stays.length <= lines.length; i += 1) {
284 if (stays.every((one, j) => lines[i + j] === one.text) && (block === null || Math.abs(i - from) < Math.abs(block - from))) {
285 block = i
286 }
287 }
288 if (block !== null) {
289 return { shift: block - from, changed: false }
290 }
291
292 // An added line comes first here, as the nearest to itself.
293 const added = row.kind === 'line' && row.sign === '+'
294 for (const one of stays.toSorted((x, y) => Math.abs(x.index - at) - Math.abs(y.index - at))) {
295 const now = lineNow(file, one.text, one.at)
296 if (now !== null) {
297 return { shift: now - one.at, changed: added && one.key !== row.key }
298 }
299 }
300 return { shift: 0, changed: true }
301}
302
303// The line that heads the notes in the prompt box; the prompt you send with
304// it ends the review.
305export const NOTES_HEADER = 'Notes on the changes:'
306
307// The notes as they go into the prompt box: one line saying what they are,
308// then each note's anchor, the code it quotes and the person's words, in
309// the order of the rows they are on. Each anchor is where its row is in the
310// file as it is now, where `files` holds it (see placeOf); a note on a line
311// the file no longer has says "(since changed)". A note whose row is gone,
312// as Claude changed it since, keeps the anchor it was written with, says
313// so, and comes last.
314export function promptOf(notes: readonly Note[], rows: readonly Row[], files: ReadonlyMap<string, string> = new Map()): string {
315 const order = new Map(rows.map((row, i) => [row.key, i]))
316 const placed = notes.map((note, i) => ({ note, at: order.get(note.target) ?? rows.length + i }))
317 const blocks = placed
318 .toSorted((x, y) => x.at - y.at)
319 .map(({ note, at }) => {
320 const row = rows[at]
321 if (row === undefined) {
322 return [`${note.anchor} (since changed)`, ...note.quote.map(line => `> ${line}`), note.text].join('\n')
323 }
324 const file = files.get(row.file.path)
325 const { shift, changed } = row.kind === 'file' || file === undefined ? { shift: 0, changed: false } : placeOf(row, rows, file)
326 const { anchor, quote } = anchorOf(row, shift)
327 return [changed ? `${anchor} (since changed)` : anchor, ...quote.map(line => `> ${line}`), note.text].join('\n')
328 })
329
330 return [NOTES_HEADER, ...blocks].join('\n\n')
331}
332
333function isHunk(value: unknown): value is Hunk {
334 return (
335 isRecord(value) &&
336 typeof value.oldStart === 'number' &&
337 typeof value.oldLines === 'number' &&
338 typeof value.newStart === 'number' &&
339 typeof value.newLines === 'number' &&
340 Array.isArray(value.lines) &&
341 value.lines.every(line => typeof line === 'string')
342 )
343}
344
345export function isRecord(value: unknown): value is Record<string, unknown> {
346 return typeof value === 'object' && value !== null && !Array.isArray(value)
347}
348
349// A file's change as a short fingerprint: the same while its change is the
350// same, another once anything in it changes, as when Claude edits it again.
351export function fingerprintOf(file: FileDiff): string {
352 const text = JSON.stringify([file.isCreate, file.isDelete, file.bare, file.doc, file.hunks])
353 let hash = 0x811c9dc5
354 for (let i = 0; i < text.length; i += 1) {
355 hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193) >>> 0
356 }
357 return `${hash.toString(16)}:${text.length}`
358}
359
360// A hunk as unified-diff text, as Claude Code's highlighter reads it: its
361// header (a side with no lines numbered from the line before it), then its
362// lines.
363export function hunkText(hunk: Hunk): string {
364 const start = (line: number, count: number) => (count === 0 ? line - 1 : line)
365 const header = `@@ -${start(hunk.oldStart, hunk.oldLines)},${hunk.oldLines} +${start(hunk.newStart, hunk.newLines)},${hunk.newLines} @@`
366 return [header, ...hunk.lines].join('\n')
367}
368
369// A hunk cut into pieces after each of the lines at `after` (indexes into
370// its lines), each piece numbered where it starts, so the code can be drawn
371// in parts with a note under a line.
372export function splitHunk(hunk: Hunk, after: ReadonlySet<number>): Hunk[] {
373 const pieces: Hunk[] = []
374 let piece: Hunk = { oldStart: hunk.oldStart, oldLines: 0, newStart: hunk.newStart, newLines: 0, lines: [] }
375 hunk.lines.forEach((line, i) => {
376 piece.lines.push(line)
377 if (!line.startsWith('+')) {
378 piece.oldLines += 1
379 }
380 if (!line.startsWith('-')) {
381 piece.newLines += 1
382 }
383 if (after.has(i) && i < hunk.lines.length - 1) {
384 pieces.push(piece)
385 piece = { oldStart: piece.oldStart + piece.oldLines, oldLines: 0, newStart: piece.newStart + piece.newLines, newLines: 0, lines: [] }
386 }
387 })
388 pieces.push(piece)
389 return pieces
390}
391hooks/icons.ts 46 lines1// A file's icon: a Nerd Font glyph and its color, as nvim-web-devicons gives
2// them (see devicons.ts), so a terminal with a Nerd Font shows the marks the
3// person's editor does. The File icons option turns them off where the
4// terminal's font has none.
5
6import { BY_EXTENSION, BY_NAME } from './devicons'
7
8export type Icon = { glyph: string; color: string }
9
10// The tables with their keys lowercased, as nvim-web-devicons reads them: a
11// key that is already lowercase keeps its entry.
12function lowered(table: Readonly<Record<string, readonly [string, string]>>): Map<string, Icon> {
13 const map = new Map<string, Icon>()
14 for (const [key, [glyph, color]] of Object.entries(table)) {
15 const lower = key.toLowerCase()
16 if (key === lower || !map.has(lower)) {
17 map.set(lower, { glyph, color })
18 }
19 }
20 return map
21}
22
23const names = lowered(BY_NAME)
24const extensions = lowered(BY_EXTENSION)
25const DEFAULT: Icon = { glyph: '\u{f0f6}', color: '#6d8086' }
26
27// The icon for a path, found as nvim-web-devicons finds it: by the file's
28// whole name, lowercased; else by its extension, the longest first
29// (`.test.ts`, then `.ts`); else the default.
30export function iconOf(path: string): Icon {
31 const name = path.slice(path.lastIndexOf('/') + 1).toLowerCase()
32 const byName = names.get(name)
33 if (byName !== undefined) {
34 return byName
35 }
36 let rest = name
37 for (let dot = rest.indexOf('.'); dot >= 0; dot = rest.indexOf('.')) {
38 rest = rest.slice(dot + 1)
39 const byExtension = extensions.get(rest)
40 if (byExtension !== undefined) {
41 return byExtension
42 }
43 }
44 return DEFAULT
45}
46hooks/diff.ts 205 lines1// Line diffs, worked out in the module: Myers' algorithm over the lines two
2// texts don't share at either end, grouped into hunks with three lines of
3// context, numbered as the files are.
4
5import type { Hunk } from '../types'
6
7export const CONTEXT = 3
8
9// Past this many differing lines a diff is not worked out (Myers' trace
10// grows with their square): the latest change's own hunks stand in.
11export const MOST_EDITS = 1_000
12
13// A text's lines, without the empty one after a final newline.
14export function linesOf(text: string): string[] {
15 const lines = text.split('\n')
16 if (lines.at(-1) === '') {
17 lines.pop()
18 }
19
20 return lines
21}
22
23type Step = ' ' | '-' | '+'
24
25// The steps from `a` to `b`, one per line kept (' '), dropped ('-') or
26// added ('+'); null past MOST_EDITS differing lines.
27export function stepsOf(a: readonly string[], b: readonly string[]): Step[] | null {
28 let head = 0
29 while (head < a.length && head < b.length && a[head] === b[head]) {
30 head += 1
31 }
32 let tail = 0
33 while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) {
34 tail += 1
35 }
36 const middle = myers(a.slice(head, a.length - tail), b.slice(head, b.length - tail))
37 if (middle === null) {
38 return null
39 }
40
41 return [...Array<Step>(head).fill(' '), ...middle, ...Array<Step>(tail).fill(' ')]
42}
43
44// Myers' O(ND) diff: for each number of edits d, the furthest point on each
45// diagonal, kept per d so the path can be walked back.
46function myers(a: readonly string[], b: readonly string[]): Step[] | null {
47 const n = a.length
48 const m = b.length
49 // Only added or only removed lines need no search, however many: a new
50 // file, a deleted block.
51 if (n === 0 || m === 0) {
52 return [...Array<Step>(n).fill('-'), ...Array<Step>(m).fill('+')]
53 }
54 const off = n + m
55 const v = new Int32Array(2 * off + 2)
56 const trace: Int32Array[] = []
57 for (let d = 0; d <= Math.min(off, MOST_EDITS); d += 1) {
58 for (let k = -d; k <= d; k += 2) {
59 const isDown = k === -d || (k !== d && (v[off + k - 1] ?? 0) < (v[off + k + 1] ?? 0))
60 let x = isDown ? (v[off + k + 1] ?? 0) : (v[off + k - 1] ?? 0) + 1
61 let y = x - k
62 while (x < n && y < m && a[x] === b[y]) {
63 x += 1
64 y += 1
65 }
66 v[off + k] = x
67 if (x >= n && y >= m) {
68 return walkBack(trace, n, m, d)
69 }
70 }
71 trace.push(v.slice(off - d, off + d + 1))
72 }
73
74 return null
75}
76
77function walkBack(trace: readonly Int32Array[], n: number, m: number, edits: number): Step[] {
78 const steps: Step[] = []
79 let x = n
80 let y = m
81 for (let d = edits; d > 0; d -= 1) {
82 const before = trace[d - 1] ?? new Int32Array(0)
83 const at = (k: number) => before[k + d - 1] ?? 0
84 const k = x - y
85 const isDown = k === -d || (k !== d && at(k - 1) < at(k + 1))
86 const from = isDown ? k + 1 : k - 1
87 const fromX = at(from)
88 const startX = isDown ? fromX : fromX + 1
89 while (x > startX) {
90 steps.push(' ')
91 x -= 1
92 y -= 1
93 }
94 steps.push(isDown ? '+' : '-')
95 x = fromX
96 y = fromX - from
97 }
98 while (x > 0) {
99 steps.push(' ')
100 x -= 1
101 }
102
103 return steps.toReversed()
104}
105
106// The hunks from `a` to `b`: each change with up to CONTEXT lines around
107// it, changes that close together in one hunk. A side's start is the number
108// of its first line in the hunk, or, where it has none, of the line that
109// follows. Null past MOST_EDITS differing lines.
110export function hunksOf(a: readonly string[], b: readonly string[]): Hunk[] | null {
111 const steps = stepsOf(a, b)
112 if (steps === null) {
113 return null
114 }
115
116 const changed = steps.flatMap((step, i) => (step === ' ' ? [] : [i]))
117 const hunks: Hunk[] = []
118 let i = 0
119 while (i < changed.length) {
120 const first = changed[i] ?? 0
121 let last = first
122 while (i + 1 < changed.length && (changed[i + 1] ?? 0) - last <= 2 * CONTEXT + 1) {
123 i += 1
124 last = changed[i] ?? last
125 }
126 i += 1
127 const from = Math.max(0, first - CONTEXT)
128 const to = Math.min(steps.length, last + CONTEXT + 1)
129 const before = steps.slice(0, from)
130 let oldLine = before.filter(step => step !== '+').length
131 let newLine = before.filter(step => step !== '-').length
132 const hunk: Hunk = { oldStart: oldLine + 1, oldLines: 0, newStart: newLine + 1, newLines: 0, lines: [] }
133 for (const step of steps.slice(from, to)) {
134 if (step === '+') {
135 hunk.lines.push(`+${b[newLine] ?? ''}`)
136 newLine += 1
137 hunk.newLines += 1
138 } else if (step === '-') {
139 hunk.lines.push(`-${a[oldLine] ?? ''}`)
140 oldLine += 1
141 hunk.oldLines += 1
142 } else {
143 hunk.lines.push(` ${a[oldLine] ?? ''}`)
144 oldLine += 1
145 newLine += 1
146 hunk.oldLines += 1
147 hunk.newLines += 1
148 }
149 }
150 hunks.push(hunk)
151 }
152
153 return hunks
154}
155
156// The text after a change, from the text before it and the hunks Claude
157// Code recorded for it: what the file became, whatever rules the tool
158// followed. A hunk's old lines must stand where it says, or one line off;
159// null where they don't. A "\ No newline at end of file" line after a side's
160// last line says that side ends without one.
161export function patched(before: string, hunks: readonly Hunk[]): string | null {
162 const old = linesOf(before)
163 const out: string[] = []
164 let at = 0
165 let hasNewline = before === '' || before.endsWith('\n')
166 for (const hunk of hunks) {
167 const olds = hunk.lines.filter(line => line.startsWith(' ') || line.startsWith('-')).map(line => line.slice(1))
168 const from = [hunk.oldStart - 1, hunk.oldStart, hunk.oldStart - 2].find(start => start >= at && olds.every((line, i) => old[start + i] === line))
169 if (from === undefined) {
170 return null
171 }
172 out.push(...old.slice(at, from))
173 at = from
174 let last = ' '
175 for (const line of hunk.lines) {
176 const sign = line[0] ?? ''
177 if (sign === '\\') {
178 hasNewline = last === '-'
179 continue
180 }
181 if (sign === '+' || sign === ' ') {
182 out.push(line.slice(1))
183 }
184 if (sign === '-' || sign === ' ') {
185 at += 1
186 }
187 last = sign
188 }
189 }
190 out.push(...old.slice(at))
191
192 return out.length === 0 ? '' : `${out.join('\n')}${hasNewline ? '\n' : ''}`
193}
194
195// The text after an Edit, from the text before it: its replacement made as
196// Claude Code makes it, every occurrence or the one. Null where the old
197// string isn't there. Only where Claude Code recorded no hunks.
198export function edited(before: string, oldString: string, newString: string, isEvery: boolean): string | null {
199 if (oldString === '' || !before.includes(oldString)) {
200 return null
201 }
202
203 return isEvery ? before.split(oldString).join(newString) : before.replace(oldString, () => newString)
204}
205hooks/devicons.ts 742 lines1// Generated from nvim-web-devicons (github.com/nvim-tree/nvim-web-devicons,
2// commit 58447c1fca35, lua/nvim-web-devicons/default/icons_by_filename.lua
3// and icons_by_file_extension.lua): each file's Nerd Font glyph and color, by
4// its whole name and by its extension. Do not edit by hand.
5//
6// MIT License, Copyright (c) 2023 nvim-tree. Permission is hereby granted,
7// free of charge, to any person obtaining a copy of this software and
8// associated documentation files (the "Software"), to deal in the Software
9// without restriction, including without limitation the rights to use, copy,
10// modify, merge, publish, distribute, sublicense, and/or sell copies of the
11// Software, and to permit persons to whom the Software is furnished to do so,
12// subject to the following conditions: The above copyright notice and this
13// permission notice shall be included in all copies or substantial portions
14// of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
15// KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
17// EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
20// USE OR OTHER DEALINGS IN THE SOFTWARE.
21
22export const BY_NAME: Readonly<Record<string, readonly [string, string]>> = {
23 '.SRCINFO': ['\u{f08c7}', '#0f94d2'],
24 '.Xauthority': ['\u{f369}', '#e54d18'],
25 '.Xresources': ['\u{f369}', '#e54d18'],
26 '.babelrc': ['\u{e639}', '#cbcb41'],
27 '.bash_profile': ['\u{e615}', '#89e051'],
28 '.bashrc': ['\u{e615}', '#89e051'],
29 '.clang-format': ['\u{e823}', '#97c3e6'],
30 '.clang-tidy': ['\u{e823}', '#97c3e6'],
31 '.clangd': ['\u{e823}', '#97c3e6'],
32 '.codespellrc': ['\u{f04c6}', '#35da60'],
33 '.condarc': ['\u{e715}', '#43b02a'],
34 '.dockerignore': ['\u{f0868}', '#458ee6'],
35 '.ds_store': ['\u{e615}', '#41535b'],
36 '.editorconfig': ['\u{e652}', '#fff2f2'],
37 '.env': ['\u{f462}', '#faf743'],
38 '.eslintignore': ['\u{e655}', '#4b32c3'],
39 '.eslintrc': ['\u{e655}', '#4b32c3'],
40 '.git-blame-ignore-revs': ['\u{e702}', '#f54d27'],
41 '.gitattributes': ['\u{e702}', '#f54d27'],
42 '.gitconfig': ['\u{e615}', '#f54d27'],
43 '.gitignore': ['\u{e702}', '#f54d27'],
44 '.gitlab-ci.yml': ['\u{f296}', '#e24329'],
45 '.gitmodules': ['\u{e702}', '#f54d27'],
46 '.gtkrc-2.0': ['\u{f362}', '#ffffff'],
47 '.gvimrc': ['\u{e62b}', '#019833'],
48 '.justfile': ['\u{f0ad}', '#6d8086'],
49 '.luacheckrc': ['\u{e615}', '#00a2ff'],
50 '.luaurc': ['\u{e615}', '#00a2ff'],
51 '.mailmap': ['\u{f02a2}', '#f54d27'],
52 '.nanorc': ['\u{e838}', '#440077'],
53 '.npmignore': ['\u{e71e}', '#e8274b'],
54 '.npmrc': ['\u{e71e}', '#e8274b'],
55 '.nuxtrc': ['\u{f1106}', '#00c58e'],
56 '.nvmrc': ['\u{e718}', '#5fa04e'],
57 '.pnpmfile.cjs': ['\u{e865}', '#f9ad02'],
58 '.pre-commit-config.yaml': ['\u{f06e2}', '#f8b424'],
59 '.prettierignore': ['\u{e6b4}', '#4285f4'],
60 '.prettierrc': ['\u{e6b4}', '#4285f4'],
61 '.prettierrc.cjs': ['\u{e6b4}', '#4285f4'],
62 '.prettierrc.js': ['\u{e6b4}', '#4285f4'],
63 '.prettierrc.json': ['\u{e6b4}', '#4285f4'],
64 '.prettierrc.json5': ['\u{e6b4}', '#4285f4'],
65 '.prettierrc.mjs': ['\u{e6b4}', '#4285f4'],
66 '.prettierrc.toml': ['\u{e6b4}', '#4285f4'],
67 '.prettierrc.yaml': ['\u{e6b4}', '#4285f4'],
68 '.prettierrc.yml': ['\u{e6b4}', '#4285f4'],
69 '.pylintrc': ['\u{e615}', '#6d8086'],
70 '.settings.json': ['\u{e70c}', '#854cc7'],
71 '.vimrc': ['\u{e62b}', '#019833'],
72 '.xinitrc': ['\u{f369}', '#e54d18'],
73 '.xsession': ['\u{f369}', '#e54d18'],
74 '.zprofile': ['\u{e615}', '#89e051'],
75 '.zshenv': ['\u{e615}', '#89e051'],
76 '.zshrc': ['\u{e615}', '#89e051'],
77 AUTHORS: ['\u{edca}', '#a172ff'],
78 'AUTHORS.txt': ['\u{edca}', '#a172ff'],
79 'Directory.Build.props': ['\u{e615}', '#00a2ff'],
80 'Directory.Build.targets': ['\u{e615}', '#00a2ff'],
81 'Directory.Packages.props': ['\u{e615}', '#00a2ff'],
82 'FreeCAD.conf': ['\u{f336}', '#cb333b'],
83 Gemfile: ['\u{e791}', '#701516'],
84 Jenkinsfile: ['\u{f2ec}', '#d33833'],
85 PKGBUILD: ['\u{f303}', '#0f94d2'],
86 'PrusaSlicer.ini': ['\u{f351}', '#ec6b23'],
87 'PrusaSlicerGcodeViewer.ini': ['\u{f351}', '#ec6b23'],
88 'QtProject.conf': ['\u{f375}', '#40cd52'],
89 _gvimrc: ['\u{e62b}', '#019833'],
90 _vimrc: ['\u{e62b}', '#019833'],
91 'bitbucket-pipelines.yml': ['\u{f00a8}', '#2684ff'],
92 brewfile: ['\u{e791}', '#701516'],
93 bspwmrc: ['\u{f355}', '#2f2f2f'],
94 build: ['\u{e63a}', '#89e051'],
95 'build.gradle': ['\u{e660}', '#005f87'],
96 'build.zig.zon': ['\u{e6a9}', '#f69a1b'],
97 'bun.lock': ['\u{e76f}', '#eadcd1'],
98 'bun.lockb': ['\u{e76f}', '#eadcd1'],
99 cantorrc: ['\u{f373}', '#1c99f3'],
100 checkhealth: ['\u{f04d9}', '#75b4fb'],
101 'cmakelists.txt': ['\u{e794}', '#dce3eb'],
102 code_of_conduct: ['\u{f4ae}', '#e41662'],
103 'code_of_conduct.md': ['\u{f4ae}', '#e41662'],
104 commit_editmsg: ['\u{e702}', '#f54d27'],
105 'commitlint.config.js': ['\u{f0718}', '#2b9689'],
106 'commitlint.config.ts': ['\u{f0718}', '#2b9689'],
107 'compose.yaml': ['\u{f0868}', '#458ee6'],
108 'compose.yml': ['\u{f0868}', '#458ee6'],
109 config: ['\u{e615}', '#6d8086'],
110 containerfile: ['\u{f0868}', '#458ee6'],
111 copying: ['\u{e60a}', '#cbcb41'],
112 'copying.lesser': ['\u{e60a}', '#cbcb41'],
113 'docker-compose.yaml': ['\u{f0868}', '#458ee6'],
114 'docker-compose.yml': ['\u{f0868}', '#458ee6'],
115 dockerfile: ['\u{f0868}', '#458ee6'],
116 dune: ['\u{e67a}', '#8a421e'],
117 'eslint.config.cjs': ['\u{e655}', '#4b32c3'],
118 'eslint.config.js': ['\u{e655}', '#4b32c3'],
119 'eslint.config.mjs': ['\u{e655}', '#4b32c3'],
120 'eslint.config.ts': ['\u{e655}', '#4b32c3'],
121 'ext_typoscript_setup.txt': ['\u{e772}', '#ff8700'],
122 'favicon.ico': ['\u{e623}', '#cbcb41'],
123 'fp-info-cache': ['\u{f34c}', '#ffffff'],
124 'fp-lib-table': ['\u{f34c}', '#ffffff'],
125 gnumakefile: ['\u{e779}', '#6d8086'],
126 'go.mod': ['\u{e627}', '#00add8'],
127 'go.sum': ['\u{e627}', '#00add8'],
128 'go.work': ['\u{e627}', '#00add8'],
129 'gradle-wrapper.properties': ['\u{e660}', '#005f87'],
130 'gradle.properties': ['\u{e660}', '#005f87'],
131 gradlew: ['\u{e660}', '#005f87'],
132 groovy: ['\u{e775}', '#4a687c'],
133 'gruntfile.babel.js': ['\u{e611}', '#e37933'],
134 'gruntfile.coffee': ['\u{e611}', '#e37933'],
135 'gruntfile.js': ['\u{e611}', '#e37933'],
136 'gruntfile.ts': ['\u{e611}', '#e37933'],
137 gtkrc: ['\u{f362}', '#ffffff'],
138 'gulpfile.babel.js': ['\u{e610}', '#cc3e44'],
139 'gulpfile.coffee': ['\u{e610}', '#cc3e44'],
140 'gulpfile.js': ['\u{e610}', '#cc3e44'],
141 'gulpfile.ts': ['\u{e610}', '#cc3e44'],
142 'hypridle.conf': ['\u{f359}', '#00aaae'],
143 'hyprland.conf': ['\u{f359}', '#00aaae'],
144 'hyprlandd.conf': ['\u{f359}', '#00aaae'],
145 'hyprlock.conf': ['\u{f359}', '#00aaae'],
146 'hyprpaper.conf': ['\u{f359}', '#00aaae'],
147 'hyprsunset.conf': ['\u{f359}', '#00aaae'],
148 'i18n.config.js': ['\u{f05ca}', '#7986cb'],
149 'i18n.config.ts': ['\u{f05ca}', '#7986cb'],
150 'i3blocks.conf': ['\u{f35a}', '#e8ebee'],
151 'i3status.conf': ['\u{f35a}', '#e8ebee'],
152 'index.theme': ['\u{ee72}', '#2db96f'],
153 'ionic.config.json': ['\u{e7a9}', '#4f8ff7'],
154 justfile: ['\u{f0ad}', '#6d8086'],
155 kalgebrarc: ['\u{f373}', '#1c99f3'],
156 kdeglobals: ['\u{f373}', '#1c99f3'],
157 'kdenlive-layoutsrc': ['\u{f33c}', '#83b8f2'],
158 kdenliverc: ['\u{f33c}', '#83b8f2'],
159 kritadisplayrc: ['\u{f33d}', '#f245fb'],
160 kritarc: ['\u{f33d}', '#f245fb'],
161 license: ['\u{e60a}', '#d0bf41'],
162 'license.md': ['\u{e60a}', '#d0bf41'],
163 'lxde-rc.xml': ['\u{f363}', '#909090'],
164 'lxqt.conf': ['\u{f364}', '#0192d3'],
165 makefile: ['\u{e779}', '#6d8086'],
166 'mix.lock': ['\u{e62d}', '#b88cd9'],
167 'mpv.conf': ['\u{f36e}', '#3b1342'],
168 'next.config.cjs': ['\u{e83e}', '#ffffff'],
169 'next.config.js': ['\u{e83e}', '#ffffff'],
170 'next.config.ts': ['\u{e83e}', '#ffffff'],
171 node_modules: ['\u{e718}', '#e8274b'],
172 'nuxt.config.cjs': ['\u{f1106}', '#00c58e'],
173 'nuxt.config.js': ['\u{f1106}', '#00c58e'],
174 'nuxt.config.mjs': ['\u{f1106}', '#00c58e'],
175 'nuxt.config.ts': ['\u{f1106}', '#00c58e'],
176 'package-lock.json': ['\u{e71e}', '#7a0d21'],
177 'package.json': ['\u{e71e}', '#e8274b'],
178 'platformio.ini': ['\u{e682}', '#f6822b'],
179 'playwright.config.cjs': ['\u{eeb6}', '#2fad33'],
180 'playwright.config.cts': ['\u{eeb6}', '#2fad33'],
181 'playwright.config.js': ['\u{eeb6}', '#2fad33'],
182 'playwright.config.mjs': ['\u{eeb6}', '#2fad33'],
183 'playwright.config.mts': ['\u{eeb6}', '#2fad33'],
184 'playwright.config.ts': ['\u{eeb6}', '#2fad33'],
185 'pnpm-lock.yaml': ['\u{e865}', '#f9ad02'],
186 'pnpm-workspace.yaml': ['\u{e865}', '#f9ad02'],
187 'pom.xml': ['\u{e674}', '#7a0d21'],
188 'prettier.config.cjs': ['\u{e6b4}', '#4285f4'],
189 'prettier.config.js': ['\u{e6b4}', '#4285f4'],
190 'prettier.config.mjs': ['\u{e6b4}', '#4285f4'],
191 'prettier.config.ts': ['\u{e6b4}', '#4285f4'],
192 'prisma.config.mts': ['\u{e684}', '#5a67d8'],
193 'prisma.config.ts': ['\u{e684}', '#5a67d8'],
194 procfile: ['\u{e607}', '#a074c4'],
195 'py.typed': ['\u{e606}', '#ffbc03'],
196 rakefile: ['\u{e791}', '#701516'],
197 readme: ['\u{f00ba}', '#ededed'],
198 'readme.md': ['\u{f00ba}', '#ededed'],
199 rmd: ['\u{e609}', '#519aba'],
200 'robots.txt': ['\u{f06a9}', '#5d7096'],
201 security: ['\u{f0483}', '#bec4c9'],
202 'security.md': ['\u{f0483}', '#bec4c9'],
203 'settings.gradle': ['\u{e660}', '#005f87'],
204 'svelte.config.js': ['\u{e697}', '#ff3e00'],
205 sxhkdrc: ['\u{f355}', '#2f2f2f'],
206 'sym-lib-table': ['\u{f34c}', '#ffffff'],
207 'tailwind.config.js': ['\u{f13ff}', '#20c2e3'],
208 'tailwind.config.mjs': ['\u{f13ff}', '#20c2e3'],
209 'tailwind.config.ts': ['\u{f13ff}', '#20c2e3'],
210 'taskfile.dist.yaml': ['\u{f01a6}', '#69d3c9'],
211 'taskfile.dist.yml': ['\u{f01a6}', '#69d3c9'],
212 'taskfile.yaml': ['\u{f01a6}', '#69d3c9'],
213 'taskfile.yml': ['\u{f01a6}', '#69d3c9'],
214 'tmux.conf': ['\u{ebc8}', '#14ba19'],
215 'tmux.conf.local': ['\u{ebc8}', '#14ba19'],
216 'tsconfig.json': ['\u{e69d}', '#519aba'],
217 unlicense: ['\u{e60a}', '#d0bf41'],
218 vagrantfile: ['\u{f2b8}', '#1563ff'],
219 'vercel.json': ['\u{e8d3}', '#ffffff'],
220 'vite.config.cjs': ['\u{e8d7}', '#8040ff'],
221 'vite.config.cts': ['\u{e8d7}', '#8040ff'],
222 'vite.config.js': ['\u{e8d7}', '#8040ff'],
223 'vite.config.mjs': ['\u{e8d7}', '#8040ff'],
224 'vite.config.mts': ['\u{e8d7}', '#8040ff'],
225 'vite.config.ts': ['\u{e8d7}', '#8040ff'],
226 'vitest.config.cjs': ['\u{e8d9}', '#739b1b'],
227 'vitest.config.cts': ['\u{e8d9}', '#739b1b'],
228 'vitest.config.js': ['\u{e8d9}', '#739b1b'],
229 'vitest.config.mjs': ['\u{e8d9}', '#739b1b'],
230 'vitest.config.mts': ['\u{e8d9}', '#739b1b'],
231 'vitest.config.ts': ['\u{e8d9}', '#739b1b'],
232 vlcrc: ['\u{f057c}', '#ee7a00'],
233 webpack: ['\u{f072b}', '#519aba'],
234 'weston.ini': ['\u{f367}', '#ffbb01'],
235 workspace: ['\u{e63a}', '#89e051'],
236 'wrangler.jsonc': ['\u{e792}', '#f48120'],
237 'wrangler.toml': ['\u{e792}', '#f48120'],
238 'xdph.conf': ['\u{f359}', '#00aaae'],
239 xmobarrc: ['\u{f35e}', '#fd4d5d'],
240 'xmobarrc.hs': ['\u{f35e}', '#fd4d5d'],
241 'xmonad.hs': ['\u{f35e}', '#fd4d5d'],
242 'xorg.conf': ['\u{f369}', '#e54d18'],
243 'xsettingsd.conf': ['\u{f369}', '#e54d18'],
244}
245
246export const BY_EXTENSION: Readonly<Record<string, readonly [string, string]>> = {
247 '3gp': ['\u{e69f}', '#fd971f'],
248 '3mf': ['\u{f01a7}', '#888888'],
249 '7z': ['\u{f410}', '#eca517'],
250 Dockerfile: ['\u{f0868}', '#458ee6'],
251 R: ['\u{f07d4}', '#2266ba'],
252 a: ['\u{eb9c}', '#dcddd6'],
253 aac: ['\u{f001}', '#00afff'],
254 ada: ['\u{e6b5}', '#599eff'],
255 adb: ['\u{e6b5}', '#599eff'],
256 ads: ['\u{e6b5}', '#a074c4'],
257 ai: ['\u{e7b4}', '#cbcb41'],
258 aif: ['\u{f001}', '#00afff'],
259 aiff: ['\u{f001}', '#00afff'],
260 android: ['\u{e70e}', '#34a853'],
261 ape: ['\u{f001}', '#00afff'],
262 apk: ['\u{e70e}', '#34a853'],
263 apl: ['\u{e730}', '#24a148'],
264 app: ['\u{eae8}', '#9f0500'],
265 applescript: ['\u{f179}', '#6d8085'],
266 asc: ['\u{f099d}', '#576d7f'],
267 asm: ['\u{e637}', '#0091bd'],
268 asmdef: ['\u{f0431}', '#407aab'],
269 ass: ['\u{f0a16}', '#ffb713'],
270 astro: ['\u{e6b3}', '#e23f67'],
271 avi: ['\u{e69f}', '#fd971f'],
272 avif: ['\u{e60d}', '#a074c4'],
273 awk: ['\u{e795}', '#4d5a5e'],
274 azcli: ['\u{ebe8}', '#0078d4'],
275 bak: ['\u{f006f}', '#6d8086'],
276 bash: ['\u{e760}', '#89e051'],
277 bat: ['\u{e615}', '#c1f12e'],
278 bazel: ['\u{e63a}', '#89e051'],
279 bib: ['\u{f125f}', '#cbcb41'],
280 bicep: ['\u{e63b}', '#519aba'],
281 bicepparam: ['\u{e63b}', '#9f74b3'],
282 bin: ['\u{eae8}', '#9f0500'],
283 'blade.php': ['\u{f2f7}', '#f05340'],
284 blend: ['\u{f00ab}', '#ea7600'],
285 blp: ['\u{f0ebe}', '#5796e2'],
286 bmp: ['\u{e60d}', '#a074c4'],
287 bqn: ['\u{e730}', '#24a148'],
288 brep: ['\u{f0eeb}', '#839463'],
289 bz: ['\u{f410}', '#eca517'],
290 bz2: ['\u{f410}', '#eca517'],
291 bz3: ['\u{f410}', '#eca517'],
292 bzl: ['\u{e63a}', '#89e051'],
293 c: ['\u{e61e}', '#599eff'],
294 'c++': ['\u{e61d}', '#f34b7d'],
295 cache: ['\u{f49b}', '#ffffff'],
296 cast: ['\u{e69f}', '#fd971f'],
297 cbl: ['\u{f423}', '#005ca5'],
298 cc: ['\u{e61d}', '#f34b7d'],
299 ccm: ['\u{e61d}', '#f34b7d'],
300 cfc: ['\u{e645}', '#01a4ba'],
301 cfg: ['\u{e615}', '#6d8086'],
302 cfm: ['\u{e645}', '#01a4ba'],
303 cjs: ['\u{e60c}', '#cbcb41'],
304 clj: ['\u{e768}', '#8dc149'],
305 cljc: ['\u{e768}', '#8dc149'],
306 cljd: ['\u{e76a}', '#519aba'],
307 cljs: ['\u{e76a}', '#519aba'],
308 cmake: ['\u{e794}', '#dce3eb'],
309 cob: ['\u{f423}', '#005ca5'],
310 cobol: ['\u{f423}', '#005ca5'],
311 coffee: ['\u{e61b}', '#cbcb41'],
312 conda: ['\u{e715}', '#43b02a'],
313 conf: ['\u{e615}', '#6d8086'],
314 'config.ru': ['\u{e791}', '#701516'],
315 cow: ['\u{f019a}', '#965824'],
316 cp: ['\u{e61d}', '#519aba'],
317 cpp: ['\u{e61d}', '#519aba'],
318 cppm: ['\u{e61d}', '#519aba'],
319 cpy: ['\u{f423}', '#005ca5'],
320 cr: ['\u{e62f}', '#c8c8c8'],
321 crdownload: ['\u{f019}', '#44cda8'],
322 cs: ['\u{f031b}', '#596706'],
323 csh: ['\u{e795}', '#4d5a5e'],
324 cshtml: ['\u{f1997}', '#512bd4'],
325 cson: ['\u{e60b}', '#cbcb41'],
326 csproj: ['\u{f0aae}', '#512bd4'],
327 css: ['\u{e6b8}', '#663399'],
328 csv: ['\u{e64a}', '#89e051'],
329 cts: ['\u{e628}', '#519aba'],
330 cu: ['\u{e64b}', '#89e051'],
331 cue: ['\u{f0cb9}', '#ed95ae'],
332 cuh: ['\u{e64b}', '#a074c4'],
333 cxx: ['\u{e61d}', '#519aba'],
334 cxxm: ['\u{e61d}', '#519aba'],
335 d: ['\u{e7af}', '#b03931'],
336 'd.ts': ['\u{e628}', '#d59855'],
337 dart: ['\u{e798}', '#03589c'],
338 db: ['\u{e706}', '#dad8d8'],
339 dconf: ['\u{f361}', '#ffffff'],
340 desktop: ['\u{f108}', '#563d7c'],
341 diff: ['\u{e728}', '#41535b'],
342 dll: ['\u{eb9c}', '#4d2c0b'],
343 doc: ['\u{f022c}', '#185abd'],
344 dockerignore: ['\u{f0868}', '#458ee6'],
345 docx: ['\u{f022c}', '#185abd'],
346 dot: ['\u{f1049}', '#30638e'],
347 download: ['\u{f019}', '#44cda8'],
348 drl: ['\u{e28c}', '#ffafaf'],
349 dropbox: ['\u{e707}', '#0061fe'],
350 dump: ['\u{e706}', '#dad8d8'],
351 dwg: ['\u{f0eeb}', '#839463'],
352 dxf: ['\u{f0eeb}', '#839463'],
353 ebook: ['\u{e28b}', '#eab16d'],
354 ebuild: ['\u{f30d}', '#4c416e'],
355 edn: ['\u{e76a}', '#519aba'],
356 eex: ['\u{e62d}', '#b88cd9'],
357 ejs: ['\u{e60e}', '#cbcb41'],
358 el: ['\u{e632}', '#8172be'],
359 elc: ['\u{e632}', '#8172be'],
360 elf: ['\u{eae8}', '#9f0500'],
361 elm: ['\u{e62c}', '#519aba'],
362 eln: ['\u{e632}', '#8172be'],
363 env: ['\u{f462}', '#faf743'],
364 eot: ['\u{f031}', '#ececec'],
365 epp: ['\u{e631}', '#ffa61a'],
366 epub: ['\u{e28b}', '#eab16d'],
367 erb: ['\u{e60e}', '#701516'],
368 erl: ['\u{e7b1}', '#b83998'],
369 ex: ['\u{e62d}', '#b88cd9'],
370 exe: ['\u{eae8}', '#9f0500'],
371 exs: ['\u{e62d}', '#b88cd9'],
372 'f#': ['\u{e7a7}', '#519aba'],
373 f3d: ['\u{f0eeb}', '#839463'],
374 f90: ['\u{f121a}', '#734f96'],
375 fbx: ['\u{f01a7}', '#888888'],
376 fcbak: ['\u{f336}', '#cb333b'],
377 fcmacro: ['\u{f336}', '#cb333b'],
378 fcmat: ['\u{f336}', '#cb333b'],
379 fcparam: ['\u{f336}', '#cb333b'],
380 fcscript: ['\u{f336}', '#cb333b'],
381 fcstd: ['\u{f336}', '#cb333b'],
382 fcstd1: ['\u{f336}', '#cb333b'],
383 fctb: ['\u{f336}', '#cb333b'],
384 fctl: ['\u{f336}', '#cb333b'],
385 fdmdownload: ['\u{f019}', '#44cda8'],
386 feature: ['\u{e7b7}', '#00a818'],
387 fish: ['\u{e795}', '#4d5a5e'],
388 flac: ['\u{f001}', '#0075aa'],
389 flc: ['\u{f031}', '#ececec'],
390 flf: ['\u{f031}', '#ececec'],
391 fnl: ['\u{e6af}', '#fff3d7'],
392 fodg: ['\u{f379}', '#fffb57'],
393 fodp: ['\u{f37a}', '#fe9c45'],
394 fods: ['\u{f378}', '#78fc4e'],
395 fodt: ['\u{f37c}', '#2dcbfd'],
396 frag: ['\u{e855}', '#5586a6'],
397 fs: ['\u{e7a7}', '#519aba'],
398 fsi: ['\u{e7a7}', '#519aba'],
399 fsscript: ['\u{e7a7}', '#519aba'],
400 fsx: ['\u{e7a7}', '#519aba'],
401 gcode: ['\u{f042b}', '#1471ad'],
402 gd: ['\u{e65f}', '#6d8086'],
403 gemspec: ['\u{e791}', '#701516'],
404 geom: ['\u{e855}', '#5586a6'],
405 gif: ['\u{e60d}', '#a074c4'],
406 git: ['\u{e702}', '#f14c28'],
407 glb: ['\u{f1b2}', '#ffb13b'],
408 gleam: ['\u{f005}', '#ffaff3'],
409 glsl: ['\u{e855}', '#5586a6'],
410 gnumakefile: ['\u{e779}', '#6d8086'],
411 go: ['\u{e627}', '#00add8'],
412 godot: ['\u{e65f}', '#6d8086'],
413 gpr: ['\u{e6b5}', '#6d8086'],
414 gql: ['\u{f20e}', '#e535ab'],
415 gradle: ['\u{e660}', '#005f87'],
416 graphql: ['\u{f20e}', '#e535ab'],
417 gresource: ['\u{f362}', '#ffffff'],
418 gv: ['\u{f1049}', '#30638e'],
419 gz: ['\u{f410}', '#eca517'],
420 h: ['\u{f0fd}', '#a074c4'],
421 haml: ['\u{e60e}', '#eaeae1'],
422 hbs: ['\u{e60f}', '#f0772b'],
423 heex: ['\u{e62d}', '#b88cd9'],
424 heic: ['\u{e60d}', '#a074c4'],
425 hex: ['\u{e667}', '#2e63ff'],
426 hh: ['\u{f0fd}', '#a074c4'],
427 hpp: ['\u{f0fd}', '#a074c4'],
428 hrl: ['\u{e7b1}', '#b83998'],
429 hs: ['\u{e61f}', '#a074c4'],
430 htm: ['\u{e60e}', '#e34c26'],
431 html: ['\u{e736}', '#e44d26'],
432 http: ['\u{f1d8}', '#008ec7'],
433 huff: ['\u{f0858}', '#4242c7'],
434 hurl: ['\u{f0ec}', '#ff0288'],
435 hx: ['\u{e666}', '#ea8220'],
436 hxx: ['\u{f0fd}', '#a074c4'],
437 ical: ['\u{f073}', '#2b2e83'],
438 icalendar: ['\u{f073}', '#2b2e83'],
439 ico: ['\u{e60d}', '#cbcb41'],
440 ics: ['\u{f073}', '#2b2e83'],
441 ifb: ['\u{f073}', '#2b2e83'],
442 ifc: ['\u{f0eeb}', '#839463'],
443 ige: ['\u{f0eeb}', '#839463'],
444 iges: ['\u{f0eeb}', '#839463'],
445 igs: ['\u{f0eeb}', '#839463'],
446 image: ['\u{e271}', '#d0bec8'],
447 img: ['\u{e271}', '#d0bec8'],
448 import: ['\u{f0c6}', '#ececec'],
449 info: ['\u{f129}', '#ffffcd'],
450 ini: ['\u{e615}', '#6d8086'],
451 ino: ['\u{f34b}', '#56b6c2'],
452 ipynb: ['\u{e80f}', '#f57d01'],
453 iso: ['\u{e271}', '#d0bec8'],
454 ixx: ['\u{e61d}', '#519aba'],
455 jar: ['\u{e738}', '#ffaf67'],
456 java: ['\u{e738}', '#cc3e44'],
457 jl: ['\u{e624}', '#a270ba'],
458 jpeg: ['\u{e60d}', '#a074c4'],
459 jpg: ['\u{e60d}', '#a074c4'],
460 js: ['\u{e60c}', '#cbcb41'],
461 json: ['\u{e60b}', '#cbcb41'],
462 json5: ['\u{e60b}', '#cbcb41'],
463 jsonc: ['\u{e60b}', '#cbcb41'],
464 jsonl: ['\u{e60b}', '#cbcb41'],
465 jsx: ['\u{e625}', '#20c2e3'],
466 jwmrc: ['\u{f35b}', '#0078cd'],
467 jxl: ['\u{e60d}', '#a074c4'],
468 kbx: ['\u{f0bc4}', '#737672'],
469 kdb: ['\u{f23e}', '#529b34'],
470 kdbx: ['\u{f23e}', '#529b34'],
471 kdenlive: ['\u{f33c}', '#83b8f2'],
472 kdenlivetitle: ['\u{f33c}', '#83b8f2'],
473 kicad_dru: ['\u{f34c}', '#ffffff'],
474 kicad_mod: ['\u{f34c}', '#ffffff'],
475 kicad_pcb: ['\u{f34c}', '#ffffff'],
476 kicad_prl: ['\u{f34c}', '#ffffff'],
477 kicad_pro: ['\u{f34c}', '#ffffff'],
478 kicad_sch: ['\u{f34c}', '#ffffff'],
479 kicad_sym: ['\u{f34c}', '#ffffff'],
480 kicad_wks: ['\u{f34c}', '#ffffff'],
481 ko: ['\u{f17c}', '#dcddd6'],
482 kpp: ['\u{f33d}', '#f245fb'],
483 kra: ['\u{f33d}', '#f245fb'],
484 krz: ['\u{f33d}', '#f245fb'],
485 ksh: ['\u{e795}', '#4d5a5e'],
486 kt: ['\u{e634}', '#7f52ff'],
487 kts: ['\u{e634}', '#7f52ff'],
488 lck: ['\u{e672}', '#bbbbbb'],
489 leex: ['\u{e62d}', '#b88cd9'],
490 less: ['\u{e614}', '#563d7c'],
491 lff: ['\u{f031}', '#ececec'],
492 lhs: ['\u{e61f}', '#a074c4'],
493 lib: ['\u{eb9c}', '#4d2c0b'],
494 license: ['\u{e60a}', '#cbcb41'],
495 liquid: ['\u{e670}', '#95bf47'],
496 lock: ['\u{e672}', '#bbbbbb'],
497 log: ['\u{f0331}', '#dddddd'],
498 lrc: ['\u{f0a16}', '#ffb713'],
499 lua: ['\u{e620}', '#51a0cf'],
500 luac: ['\u{e620}', '#51a0cf'],
501 luau: ['\u{e620}', '#00a2ff'],
502 m: ['\u{e61e}', '#599eff'],
503 m3u: ['\u{f0cb9}', '#ed95ae'],
504 m3u8: ['\u{f0cb9}', '#ed95ae'],
505 m4a: ['\u{f001}', '#00afff'],
506 m4v: ['\u{e69f}', '#fd971f'],
507 magnet: ['\u{f076}', '#a51b16'],
508 makefile: ['\u{e779}', '#6d8086'],
509 markdown: ['\u{e609}', '#dddddd'],
510 material: ['\u{e2a6}', '#b83998'],
511 md: ['\u{f48a}', '#dddddd'],
512 md5: ['\u{f0565}', '#8c86af'],
513 mdx: ['\u{f48a}', '#519aba'],
514 mint: ['\u{f032a}', '#87c095'],
515 mjs: ['\u{e60c}', '#f1e05a'],
516 mk: ['\u{e779}', '#6d8086'],
517 mkv: ['\u{e69f}', '#fd971f'],
518 ml: ['\u{e67a}', '#e37933'],
519 mli: ['\u{e67a}', '#e37933'],
520 mm: ['\u{e61d}', '#519aba'],
521 mo: ['\u{edfe}', '#9772fb'],
522 mobi: ['\u{e28b}', '#eab16d'],
523 mojo: ['\u{f06d}', '#ff4c1f'],
524 mov: ['\u{e69f}', '#fd971f'],
525 mp3: ['\u{f001}', '#00afff'],
526 mp4: ['\u{e69f}', '#fd971f'],
527 mpp: ['\u{e61d}', '#519aba'],
528 msf: ['\u{f370}', '#137be1'],
529 mts: ['\u{e628}', '#519aba'],
530 mustache: ['\u{e60f}', '#e37933'],
531 nfo: ['\u{f129}', '#ffffcd'],
532 nim: ['\u{e677}', '#f3d400'],
533 nix: ['\u{f313}', '#7ebae4'],
534 norg: ['\u{e847}', '#4878be'],
535 nswag: ['\u{e60b}', '#85ea2d'],
536 nu: ['\u{f054}', '#3aa675'],
537 o: ['\u{eae8}', '#9f0500'],
538 obj: ['\u{f01a7}', '#888888'],
539 odf: ['\u{f37b}', '#ff5a96'],
540 odg: ['\u{f379}', '#fffb57'],
541 odin: ['\u{f07e2}', '#3882d2'],
542 odp: ['\u{f37a}', '#fe9c45'],
543 ods: ['\u{f378}', '#78fc4e'],
544 odt: ['\u{f37c}', '#2dcbfd'],
545 oga: ['\u{f001}', '#0075aa'],
546 ogg: ['\u{f001}', '#0075aa'],
547 ogv: ['\u{e69f}', '#fd971f'],
548 ogx: ['\u{e69f}', '#fd971f'],
549 opus: ['\u{f001}', '#0075aa'],
550 org: ['\u{e633}', '#77aa99'],
551 otf: ['\u{f031}', '#ececec'],
552 out: ['\u{eae8}', '#9f0500'],
553 part: ['\u{f019}', '#44cda8'],
554 patch: ['\u{e728}', '#41535b'],
555 pck: ['\u{f487}', '#6d8086'],
556 pcm: ['\u{f001}', '#0075aa'],
557 pdf: ['\u{eaeb}', '#b30b00'],
558 php: ['\u{e608}', '#a074c4'],
559 pl: ['\u{e769}', '#519aba'],
560 pls: ['\u{f0cb9}', '#ed95ae'],
561 ply: ['\u{f01a7}', '#888888'],
562 pm: ['\u{e769}', '#519aba'],
563 png: ['\u{e60d}', '#a074c4'],
564 po: ['\u{f1ab}', '#2596be'],
565 pot: ['\u{f1ab}', '#2596be'],
566 pp: ['\u{e631}', '#ffa61a'],
567 ppt: ['\u{f0227}', '#cb4a32'],
568 pptx: ['\u{f0227}', '#cb4a32'],
569 prefab: ['\u{eef6}', '#94d4f9'],
570 prisma: ['\u{e684}', '#5a67d8'],
571 pro: ['\u{e7a1}', '#e4b854'],
572 ps1: ['\u{f0a0a}', '#4273ca'],
573 psb: ['\u{e7b8}', '#519aba'],
574 psd: ['\u{e7b8}', '#519aba'],
575 psd1: ['\u{f0a0a}', '#6975c4'],
576 psm1: ['\u{f0a0a}', '#6975c4'],
577 pub: ['\u{f0dd6}', '#e3c58e'],
578 pxd: ['\u{e606}', '#5aa7e4'],
579 pxi: ['\u{e606}', '#5aa7e4'],
580 py: ['\u{e606}', '#ffbc03'],
581 pyc: ['\u{e606}', '#ffe291'],
582 pyd: ['\u{e606}', '#ffe291'],
583 pyi: ['\u{e606}', '#ffbc03'],
584 pyo: ['\u{e606}', '#ffe291'],
585 pyw: ['\u{e606}', '#5aa7e4'],
586 pyx: ['\u{e606}', '#5aa7e4'],
587 qm: ['\u{f1ab}', '#2596be'],
588 qml: ['\u{f375}', '#40cd52'],
589 qrc: ['\u{f375}', '#40cd52'],
590 qss: ['\u{f375}', '#40cd52'],
591 query: ['\u{e21c}', '#90a850'],
592 r: ['\u{f07d4}', '#2266ba'],
593 rake: ['\u{e791}', '#701516'],
594 rar: ['\u{f410}', '#eca517'],
595 rasi: ['\u{ebf6}', '#cbcb41'],
596 razor: ['\u{f1998}', '#512bd4'],
597 rb: ['\u{e791}', '#701516'],
598 res: ['\u{e688}', '#cc3e44'],
599 resi: ['\u{e688}', '#f55385'],
600 rkt: ['\u{f0627}', '#9f1d20'],
601 rlib: ['\u{e7a8}', '#dea584'],
602 rmd: ['\u{e609}', '#519aba'],
603 rproj: ['\u{f05c6}', '#358a5b'],
604 rs: ['\u{e68b}', '#dea584'],
605 rss: ['\u{e619}', '#fb9d3b'],
606 s: ['\u{e637}', '#0071c5'],
607 sass: ['\u{e603}', '#f55385'],
608 sbt: ['\u{e737}', '#cc3e44'],
609 sc: ['\u{e737}', '#cc3e44'],
610 scad: ['\u{f34e}', '#f9d72c'],
611 scala: ['\u{e737}', '#cc3e44'],
612 scm: ['\u{f0627}', '#eeeeee'],
613 scss: ['\u{e603}', '#f55385'],
614 sh: ['\u{e795}', '#4d5a5e'],
615 sha1: ['\u{f0565}', '#8c86af'],
616 sha224: ['\u{f0565}', '#8c86af'],
617 sha256: ['\u{f0565}', '#8c86af'],
618 sha384: ['\u{f0565}', '#8c86af'],
619 sha512: ['\u{f0565}', '#8c86af'],
620 sig: ['\u{f0627}', '#e37933'],
621 signature: ['\u{f0627}', '#e37933'],
622 skp: ['\u{f0eeb}', '#839463'],
623 sldasm: ['\u{f0eeb}', '#839463'],
624 sldprt: ['\u{f0eeb}', '#839463'],
625 slim: ['\u{e60e}', '#e34c26'],
626 sln: ['\u{e70c}', '#854cc7'],
627 slnx: ['\u{e70c}', '#854cc7'],
628 slvs: ['\u{f0eeb}', '#839463'],
629 sml: ['\u{f0627}', '#e37933'],
630 so: ['\u{eb9c}', '#dcddd6'],
631 sol: ['\u{e656}', '#519aba'],
632 'spec.js': ['\u{f499}', '#cbcb41'],
633 'spec.jsx': ['\u{f499}', '#20c2e3'],
634 'spec.ts': ['\u{f499}', '#519aba'],
635 'spec.tsx': ['\u{f499}', '#1354bf'],
636 spx: ['\u{f001}', '#0075aa'],
637 sql: ['\u{e706}', '#dad8d8'],
638 sqlite: ['\u{e706}', '#dad8d8'],
639 sqlite3: ['\u{e706}', '#dad8d8'],
640 srt: ['\u{f0a16}', '#ffb713'],
641 ssa: ['\u{f0a16}', '#ffb713'],
642 ste: ['\u{f0eeb}', '#839463'],
643 step: ['\u{f0eeb}', '#839463'],
644 stl: ['\u{f01a7}', '#888888'],
645 'stories.js': ['\u{e8b3}', '#ff4785'],
646 'stories.jsx': ['\u{e8b3}', '#ff4785'],
647 'stories.mjs': ['\u{e8b3}', '#ff4785'],
648 'stories.svelte': ['\u{e8b3}', '#ff4785'],
649 'stories.ts': ['\u{e8b3}', '#ff4785'],
650 'stories.tsx': ['\u{e8b3}', '#ff4785'],
651 'stories.vue': ['\u{e8b3}', '#ff4785'],
652 stp: ['\u{f0eeb}', '#839463'],
653 strings: ['\u{f1ab}', '#2596be'],
654 styl: ['\u{e600}', '#8dc149'],
655 sub: ['\u{f0a16}', '#ffb713'],
656 sublime: ['\u{e7aa}', '#e37933'],
657 suo: ['\u{e70c}', '#854cc7'],
658 sv: ['\u{f035b}', '#019833'],
659 svelte: ['\u{e697}', '#ff3e00'],
660 svg: ['\u{f0721}', '#ffb13b'],
661 svgz: ['\u{f0721}', '#ffb13b'],
662 svh: ['\u{f035b}', '#019833'],
663 svx: ['\u{e697}', '#ff0042'],
664 swift: ['\u{e755}', '#e37933'],
665 t: ['\u{e769}', '#519aba'],
666 tbc: ['\u{f06d3}', '#1e5cb3'],
667 tcl: ['\u{f06d3}', '#1e5cb3'],
668 templ: ['\u{eac4}', '#dbbd30'],
669 terminal: ['\u{f489}', '#31b53e'],
670 'test.js': ['\u{f499}', '#cbcb41'],
671 'test.jsx': ['\u{f499}', '#20c2e3'],
672 'test.ts': ['\u{f499}', '#519aba'],
673 'test.tsx': ['\u{f499}', '#1354bf'],
674 tex: ['\u{e69b}', '#3d6117'],
675 tf: ['\u{e69a}', '#5f43e9'],
676 tfvars: ['\u{f15b}', '#5f43e9'],
677 tgz: ['\u{f410}', '#eca517'],
678 tmpl: ['\u{eac4}', '#dbbd30'],
679 tmux: ['\u{ebc8}', '#14ba19'],
680 toml: ['\u{e6b2}', '#9c4221'],
681 torrent: ['\u{f019}', '#44cda8'],
682 tres: ['\u{e65f}', '#6d8086'],
683 ts: ['\u{e628}', '#519aba'],
684 tscn: ['\u{e65f}', '#6d8086'],
685 tsconfig: ['\u{e772}', '#ff8700'],
686 tsx: ['\u{e7ba}', '#1354bf'],
687 ttf: ['\u{f031}', '#ececec'],
688 twig: ['\u{e61c}', '#8dc149'],
689 txt: ['\u{f0219}', '#89e051'],
690 txz: ['\u{f410}', '#eca517'],
691 typ: ['\u{f37f}', '#0dbcc0'],
692 typoscript: ['\u{e772}', '#ff8700'],
693 ui: ['\u{f2d0}', '#015bf0'],
694 unity: ['\u{e721}', '#fdfdfd'],
695 v: ['\u{f035b}', '#019833'],
696 vala: ['\u{e8d1}', '#7b3db9'],
697 vert: ['\u{e855}', '#5586a6'],
698 vh: ['\u{f035b}', '#019833'],
699 vhd: ['\u{f035b}', '#019833'],
700 vhdl: ['\u{f035b}', '#019833'],
701 vi: ['\u{e81e}', '#fec60a'],
702 vim: ['\u{e62b}', '#019833'],
703 vsh: ['\u{e6ac}', '#5d87bf'],
704 vsix: ['\u{e70c}', '#854cc7'],
705 vue: ['\u{e6a0}', '#8dc149'],
706 wasm: ['\u{e6a1}', '#5c4cdb'],
707 wav: ['\u{f001}', '#00afff'],
708 webm: ['\u{e69f}', '#fd971f'],
709 webmanifest: ['\u{e60b}', '#f1e05a'],
710 webp: ['\u{e60d}', '#a074c4'],
711 webpack: ['\u{f072b}', '#519aba'],
712 wgsl: ['\u{e955}', '#005a9c'],
713 wma: ['\u{f001}', '#00afff'],
714 wmv: ['\u{e69f}', '#fd971f'],
715 woff: ['\u{f031}', '#ececec'],
716 woff2: ['\u{f031}', '#ececec'],
717 wrl: ['\u{f01a7}', '#888888'],
718 wrz: ['\u{f01a7}', '#888888'],
719 wv: ['\u{f001}', '#00afff'],
720 wvc: ['\u{f001}', '#00afff'],
721 x: ['\u{e691}', '#599eff'],
722 xaml: ['\u{f0673}', '#512bd4'],
723 xcf: ['\u{f338}', '#635b46'],
724 xcplayground: ['\u{e755}', '#e37933'],
725 xcstrings: ['\u{f1ab}', '#2596be'],
726 xls: ['\u{f021b}', '#207245'],
727 xlsx: ['\u{f021b}', '#207245'],
728 xm: ['\u{e691}', '#519aba'],
729 xml: ['\u{f05c0}', '#e37933'],
730 xpi: ['\u{e786}', '#ff1b01'],
731 xslt: ['\u{f05c0}', '#33a9dc'],
732 xul: ['\u{e745}', '#e37933'],
733 xz: ['\u{f410}', '#eca517'],
734 yaml: ['\u{e8eb}', '#d70000'],
735 yml: ['\u{e8eb}', '#d70000'],
736 zig: ['\u{e6a9}', '#f69a1b'],
737 zip: ['\u{f410}', '#eca517'],
738 zsh: ['\u{e795}', '#89e051'],
739 zst: ['\u{f410}', '#eca517'],
740 '\u{1f525}': ['\u{f06d}', '#ff4c1f'],
741}
742types/index.d.ts 121 lines1// One hunk of a unified diff, as Claude Code's Edit and Write results
2// record theirs: where it starts and how many lines it spans on each side,
3// and its lines, each led by ' ', '-' or '+'.
4export type Hunk = {
5 oldStart: number
6 oldLines: number
7 newStart: number
8 newLines: number
9 lines: string[]
10}
11
12// One of Claude's changes to a file, as its Edit or Write result records
13// it: the call's id, the file's path, the file before the change and after
14// it (null where Claude Code kept no copy, as for a file too large), whether
15// the change created the file, and its own hunks. For an edit of a Google
16// Doc, its link as the path, no text, and how it was edited (`doc`).
17export type Change = {
18 id: string
19 path: string
20 before: string | null
21 after: string | null
22 isCreate: boolean
23 hunks: Hunk[]
24 doc: DocEdit | null
25}
26
27// How Claude edited a Google Doc: as suggestions, directly, as comments, by
28// creating it, or by accepting or rejecting suggestions; and its title,
29// where known.
30export type DocEdit = { how: 'suggest' | 'direct' | 'comment' | 'create' | 'review'; title: string | null }
31
32// A note the person wrote: what it is on (a row's key), its anchor and the
33// code it quotes as the prompt will carry them, its file, and its words.
34export type Note = {
35 target: string
36 path: string
37 anchor: string
38 quote: string[]
39 text: string
40}
41
42// A changed file as the pane lists it: its path, and as written for the
43// person; whether it is new or gone; its hunks and how many lines they add
44// and remove; why it has no lines, if so; and when it last changed, for the
45// list's order (0 where unknown, as for a file that is gone). A Google Doc
46// Claude edited is listed too, by its link, with how each edit was made
47// (`doc`), and reviewed in Google Docs.
48export type FileDiff = {
49 path: string
50 shown: string
51 isCreate: boolean
52 isDelete: boolean
53 hunks: Hunk[]
54 added: number
55 removed: number
56 bare: Bare | null
57 changedAt: number
58 doc: DocEdit['how'][] | null
59}
60
61// A file drawn without lines: binary, larger than difnote keeps, named like
62// a secret, or one git couldn't read, of which difnote keeps no copy; or one
63// whose lines didn't fit in what git can hand difnote at once. Its size in
64// bytes before and after, null on a side where it didn't exist or isn't
65// known.
66export type Bare = { why: 'binary' | 'large' | 'secret' | 'unreadable' | 'cut'; before: number | null; after: number | null }
67
68// What difnote watches: whether git runs here (without it, only Claude's
69// Edit and Write show); when the oldest state the pane compares against was
70// recorded, null before any; the folders being recorded for the first time;
71// the folders it doesn't watch, with why; the folders it looks at only
72// after Claude's changes or a key, as looking at them is slow; the folders
73// outside the session's that earlier sessions here left changes in, with
74// when one last used them; the folders emptied at the session's start
75// that still held changes not reviewed; the folders the list groups files
76// under, the session's first; and the home folder, written ~ in them.
77export type Watch = {
78 hasGit: boolean
79 since: number | null
80 looking: string[]
81 skipped: { folder: string; why: string }[]
82 slow: string[]
83 carried: { folder: string; usedAt: number }[]
84 lost: string[]
85 roots: string[]
86 home: string | null
87}
88
89// Where the person is in the pane's list: the file open under its row
90// (null for none), the row the cursor is on, by its key, the first row the
91// window shows as asked, whether lines other than the cursor's are cut to
92// one row, and the folders whose files are folded away.
93export type Place = { open: string | null; cursor: string | null; top: number; isCompact: boolean; closed: string[] }
94
95declare module 'claude-code' {
96 interface PluginState {
97 difnote: {
98 // The changed files in the folders difnote watches, as last looked at.
99 diffs: FileDiff[]
100 // What difnote watches.
101 watch: Watch
102 // Claude's Edit and Write changes since the person last reviewed,
103 // oldest first: what shows for files outside the folders watched.
104 changes: Change[]
105 // The person's notes, in the order written; kept in the store too,
106 // until they go into the prompt box.
107 notes: Note[]
108 // The row whose note field is open; null while none is.
109 editing: string | null
110 // Where the person is in the list: the file open under its row, the
111 // row the cursor is on, the window's first row, whether lines are cut,
112 // and the folders closed.
113 place: Place
114 // The files the person marked viewed, by path, each with the
115 // fingerprint of its change when marked: a file is viewed while its
116 // change is still that one.
117 viewed: Record<string, string>
118 }
119 }
120}
121