SLOPSHOPPER

difnote

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…

newpaneguardcommandtoastprocess
v0.3.0Apache-2.0updated 2026-10-14mmedum/difnote
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · difnote
│ ┃ difnote ✕ › fix the failing auth test and add an audit log call │ ┃ No changes since your last review. │ ┃ ⏺ Read(src/auth.ts) │ ┃ Not watched, so only Claude's Edit and Writ ⎿ Read 6 lines │ ┃ there: ⏺ Update(src/auth.ts) │ ┃ /work/app (git didn't answer: ENOENT: no ⎿ Added 2 lines, removed 1 line │ ┃ /plugins/difnote/.data/roots/app-5511a9d7 ⏺ Bash(bun test) │ ┃ /config) ⎿ 3 pass, 1 fail │ ┃ /work/app/src (outside your home folder) │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /difnote │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · difnote
No changes since your last review. Not watched, so only Claude's Edit and Write show there: /work/app (git didn't answer: ENOENT: no such file /plugins/difnote/.data/roots/app-5511a9d79f534637/git /config) /work/app/src (outside your home folder) q: close
README

difnote

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.

CI Release

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.

The difnote pane: a summary line of 3 files, 1 viewed and 1 note; the files under src/api/ and src/db/; src/api/client.ts opened in place after Claude made fetchOrders take a timeoutMs parameter, its hunk drawn as Claude Code's highlighted diff with three removed lines in red and two added lines in green, long lines wrapped, the cursor's bar on the first added line and the note "Read the default from config, not a literal." right under it; pool.ts marked viewed with a check; the keys at the bottom.

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.

Install

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

Using it

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

  • One list: the files changed since your last review, under a heading for each folder they are in, folders first and each by name; with more than one place, as another repository, under a heading for each place too. Each file shows its icon, what it adds and removes, and how many notes it has. The top line says since when, and stays at the top; the keys stay at the bottom.
  • A file opens in place: Enter or 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.
  • The cursor: a bar beside the row it's on. 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.
  • Long lines wrap; w cuts every line to one row but the cursor's, which always shows whole. A line longer than twelve rows is cut, marked ….
  • Notes: 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.
KeyDoes
Enterwhat 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 / hin or out: open a file or a folder; close the open file, go to its folder, fold it
nclose the open file and open the next
vmark the file viewed, or not; it closes, and the cursor goes to the next not viewed
cwrite a note on the row the cursor is on
wcut lines to one row, or wrap them again
sput the notes into the prompt box, after your draft
rwith no notes waiting, mark everything shown reviewed, after asking
xon a folder from earlier sessions, stop watching it from here
qclose the pane
Escback 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.

  • What it lists: every change in the folders Claude works in, whoever made it: Claude's Edit and Write, its shell commands, its subagents, a formatter or you. A folder is watched from the moment it comes up: the session's folder when the session starts, a folder Claude edits or writes a file in, and a git repository a shell command names by its path or goes into with 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.
  • Folders from earlier sessions: a later session in the same folder watches again the folders outside it that this session's looks found changes in, while those changes wait for review: a folder Claude edited or wrote a file in, or a repository a shell command changed. A folder a command only read in, or 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.
  • What it doesn't watch: your home folder, its Desktop, Documents and Downloads folders, temporary folders, Claude Code's own folder, and any folder with more than 50,000 files. For a folder outside your home folder and outside the session's, it watches only a git repository. The pane lists each folder it doesn't watch, and why. There, only the changes Claude made with its Edit and Write tools show, as they do for a file that the folder's .gitignore leaves out.
  • Files without lines: a binary file, a file over 1 MiB, and a file named like a secret (.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.
  • Google Docs: a Google Doc Claude edited through a Google Docs server's tools (google-docs-mcp's 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.
  • Viewed files: 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.
  • When a review ends: only when you press 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.
  • The notes in the prompt: a note on a line quotes it, one on a removed line says so and takes the number of the line now in its place (in the pane, its field names it by its old number, as the diff's gutter does), one on a hunk quotes its first changed lines with their - 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).

Settings

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.

  • File icons (on by default): a Nerd Font icon before each file, in its language's color: nvim-web-devicons' whole set, found the way it finds them (by the file's name, then its longest extension). Turn it off if your terminal's font has no Nerd Font glyphs.
  • Added color (success by default): how many lines a file or hunk adds.
  • Removed color (error by default): how many lines it removes.
  • Note color (suggestion by default): your notes in the pane.
  • Cursor color (claude by default): the bar beside the row the cursor is on.
  • Quiet color (inactive by default): folder headings, hints and line numbers.

The code itself takes Claude Code's own diff colors, as in /diff.

How it works

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.

What it runs

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.

CommandRunsWhy
git --versionwhen a session startsto know whether git is there; without it, only Claude's Edit and Write show
git config --global --path --get core.excludesFilewhen a session startsyour own global ignore file, which difnote's records follow too
git init --quietthe first time difnote watches a folderto make its private git folder
git --no-optional-locks ls-files -z --cached --ignored --exclude-standardeach time a git repository is recordedthe 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-standardeach time a folder is recorded, and on each look by itselfthe 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-onlyeach time a folder is recorded, and on each look by itselfthe 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 --stdineach time a folder is recordedto record those files, up to 1 MiB each
git update-index -z --force-remove --stdinwhen a file became a folderto take it out of the record
git fast-import --quiet --donefor a file over 1 MiB, named like a secret, or unreadablea placeholder with its size and time, in place of its content
git update-index -z --index-infoafter fast-importto record those placeholders
git write-treeeach time a folder is recordedto keep the record as one state
git update-ref --stdinafter each record, when a review ends, for a folder unused 30 daysto point at the state shown and the state reviewed, or let them go
git rev-parse --verify --quiet refs/difnote/basewhen a session first watches a folderwhether it has a reviewed state already
git diff-tree … refs/difnote/base refs/difnote/shown --when the pane looksthe difference between the reviewed state and now
git cat-file --batch-checkfor files without linestheir sizes
git check-ignore -z --stdinwhen the pane lookswhich of Claude's Edit and Write files a watched folder ignores
git read-tree --emptyfor a folder unused for 30 daysto empty its record
git gc --quiet --prune=1.hour.agoonce a day per folder, and for a folder unused for 30 daysto 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.

What it sends, and where

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.
  • The prompt box, when you press 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.

What it reads

  • The folders it watches, through git, as above, and each changed file's size and time ($.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.
  • The results of Claude's Edit and Write calls in this conversation, as they happen and, when a session starts or restarts, from the conversation Claude Code stored ($.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.
  • The commands of Claude's shell calls, for the paths they name, and the list of files Claude Code reports a command changed in a git repository.
  • The calls Claude makes to a Google Docs server's editing tools, and what get_document answers: each document's link, the mode an edit was made in, and the document's title.
  • The prompt box when you press s, only to tell whether it's empty, so the notes go after your draft with a blank line between.
  • The files you wrote notes on, when you press s ($.fs.read), to number each note where its code is in the file now.
  • Of its own private git folders, the file that holds the id of the state you last reviewed (refs/difnote/base, or packed-refs once git packed it), so a look by itself notices a review made in another session.
  • The session's id and folder, and the color options.
  • No credentials: no token, key or password, and no environment variable. A file named like a secret is never read, only its size and time ($.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.

What it keeps, and where

  • Its records of your folders, under Claude Code's data folder for difnote: `~/
Source 10 files
hooks/register.tsx 2033 lines
1// 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 lines
1// 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}
132
hooks/git.ts 301 lines
1// 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}
301
hooks/paint.ts 111 lines
1// 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}
111
hooks/pane.tsx 1083 lines
1// 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}
1083
hooks/review.ts 391 lines
1// 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}
391
hooks/icons.ts 46 lines
1// 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}
46
hooks/diff.ts 205 lines
1// 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}
205
hooks/devicons.ts 742 lines
1// 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}
742
types/index.d.ts 121 lines
1// 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