diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 40c715e..ce2ed62 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "cc-split-diff-view", "source": "./", - "description": "Edit and Write diffs in two side-by-side columns instead of +/- lines" + "description": "Edit and Write diffs in two side-by-side columns instead of +/- lines, and /diffsplit to review all uncommitted changes the same way" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 67aa5b9..b9942bf 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,8 @@ { "name": "cc-split-diff-view", - "version": "1.0.0", - "description": "Draws Edit and Write diffs in two side-by-side columns: removed lines on the left, added lines on the right, in Claude Code's own diff colors", + "version": "1.1.0", + "description": "Draws Edit and Write diffs in two side-by-side columns: removed lines on the left, added lines on the right, in Claude Code's own diff colors. Adds /diffsplit, a pane that reviews all the uncommitted changes the same way", + "types": "./types/index.d.ts", "author": { "name": "oskarnrk", "url": "https://github.com/oskarnrk" }, "repository": "https://github.com/neteye-platform/cc-split-diff-view" } diff --git a/README.md b/README.md index 6de5fdb..5e0c9bb 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,32 @@ instead of one stacked `-`/`+` list: - colors are Claude Code's own diff colors (`diffRemoved` / `diffAdded` theme keys), so they follow your `/theme` - syntax highlighting comes from Claude Code's own highlighter (the `Code` - element, the one the built-in `/diff` pane uses), with the language taken - from the file's extension + element), with the language taken from the file's extension Errors, edits held for review, brand-new files and anything without a diff keep Claude Code's normal drawing. +## `/diffsplit` + +Reviews all the uncommitted changes in one pane, with every diff in two +columns. It opens a pane titled **Split Diff** with the changes of the working +directory against `HEAD`, untracked files included: + +- a list of the changed files comes first, each row ending with its `+n -n` + counts; click a file, or press `Enter` on it, to jump to its diff +- under the list, every file is open: its name sits between two lines, with + the counts at the end of the row, and its diff follows in two columns +- click a file's name, or press `Enter` on it, to fold or unfold that file +- `r` reloads the changes +- `Esc` closes the pane + +![The /diffsplit pane: the list of the changed files with their counts, then each file open under its name, between two lines, with its diff in two columns](docs/diffsplit-pane.png) + +Binary files are listed with `binary` and no diff. The pane reads the diff of +the first 100 files and draws a limited number of rows (at most 500 per file +and about 1200 in all); when a diff is cut it says `… N more rows`, and +folding other files makes room for it. + ## Requirements - Claude Code **v2.1.287 or later** (the version that introduced mods; diff --git a/docs/diffsplit-pane.png b/docs/diffsplit-pane.png new file mode 100644 index 0000000..bb8af30 Binary files /dev/null and b/docs/diffsplit-pane.png differ diff --git a/hooks/diff-command.ts b/hooks/diff-command.ts new file mode 100644 index 0000000..a14dcb6 --- /dev/null +++ b/hooks/diff-command.ts @@ -0,0 +1,315 @@ +import { atom, read, update } from 'claude-code' +import type { ElementTable, Hook, On } from 'claude-code' + +import { TEXT_BUDGET, drawFit, fitHunks } from './draw.js' +import type { Fit, Kit as DrawKit } from './draw.js' +import { parseChanges, parseHunks, parseUntracked } from './git-diff.js' +import type { Change, FileDiff, Snapshot } from '../types' + +const COMMAND = 'diffsplit' +const PANE = 'diffsplit' + +/** Rows drawn for one file before "… N more rows"; the pane scrolls. */ +const MAX_ROWS = 500 + +/** + * Rows drawn in all the files together. A row is about ten nodes of the tree + * and the engine refuses a tree of more than 20000, which leaves the pane blank. + */ +const MAX_TOTAL_ROWS = 1200 + +/** Characters a file's row takes besides its path: the folding mark, the counts and the label. */ +const FILE_ROW_CHARS = 30 + +/** Files whose diff is read, all at once on load; the rest are counted, not shown. */ +const MAX_FILES = 100 + +/** How often the open pane looks for changes made outside the tools Claude runs. */ +const POLL_MS = 3000 + +/** Tools that can change the working tree: the open pane reloads when one ends. */ +const WRITING_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'Bash'] as const + +/** Git's name for an empty tree, the base of a repository with no commit yet. */ +const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904' + +const GIT = ['git', '--no-pager', '-c', 'core.quotepath=off'] +const DIFF = ['diff', '--no-color', '--no-ext-diff', '--no-renames'] + +/** + * The same for the tracked files. Git names them from the root of the + * repository, but the untracked ones (`ls-files --others`) from the working + * directory; `--relative` names both from the working directory, so a path + * from one list works as a pathspec of the diff in the other. It also keeps the + * list to the working directory, as `ls-files` does. + */ +const TRACKED = [...DIFF, '--relative'] + +// Change the tag when the fields of a value change: it declines what an +// earlier version stored, which outlives a reload. +const snapshot = atom({ plugin: 'cc-split-diff-view', key: 'snapshot' } as const, null, { shape: 'snapshot-2' }) +const folded = atom({ plugin: 'cc-split-diff-view', key: 'folded' } as const, [], { shape: 'folded-1' }) + +/** The engine interface a hook receives as `$`. */ +type Engine = Parameters>[0] + +type Kit = DrawKit & Pick, 'Button'> + +// The module's own, so a reload of the code starts them over: what the pane +// drew last (see `fingerprint`), the timer that watches the tree, and whether +// a refresh is under way. +let seen = '' +let timer: { cancel: () => void } | undefined +let isRefreshing = false + +/** + * Registers `/diffsplit`: a pane that lists the uncommitted changes of the + * working directory, each file open under its name as a split diff. + * + * @param on the engine's registrar + */ +export function registerDiffSplit(on: On) { + on('session.start', async ($, e, next) => { + await $.command.register({ + name: COMMAND, + description: 'Show the uncommitted changes in a split diff pane', + }) + return next(e) + }) + + on('command.run', { command: COMMAND }, async $ => { + const loaded = await load($, { isReload: false }) + seen = await fingerprint($).catch(() => '') + await $.ui.open({ id: PANE, title: 'Split Diff', focus: true, closeOnEscape: true, rows: 30, columns: 120 }) + + // While the pane is open, look at the tree now and then: it ends itself when the pane closes. + timer?.cancel() + timer = $.clock.every(POLL_MS, () => void refresh($)) + if (loaded.kind === 'error') return { text: loaded.message } + const n = loaded.files.length + loaded.hiddenFiles + return { text: n === 0 ? 'No uncommitted changes.' : `Split diff opened: ${n} changed ${n === 1 ? 'file' : 'files'}.` } + }) + + on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => { + const kit: Kit = $.ui.resolve(e) + const current = await read($, snapshot) + const closed = await read($, folded) + + if (current === null) return kit.Text({ dimColor: true, children: ['Run /diffsplit to load the changes.'] }) + if (current.kind === 'error') return kit.Text({ color: 'error', children: [current.message] }) + + return drawFiles($, kit, current.files, current.hiddenFiles, closed, e.props.bodyColumns) + }) + + // A tool that may have written files: the open pane shows it at once. + on('tool.call', { tool: WRITING_TOOLS }, async ($, e, next) => { + const ran = await next(e) + void refresh($) + return ran + }) +} + +/** + * Asks git for the changes against HEAD, plus the untracked files, and keeps + * them. A reload from the pane itself keeps the files the person folded. + */ +async function load($: Engine, { isReload }: { isReload: boolean }): Promise { + const result = await ask($) + await update($, snapshot, () => result) + if (!isReload) await update($, folded, () => []) + return result +} + +/** Git's base for the diff: HEAD, or the empty tree in a repository with no commit yet. */ +async function baseOf($: Engine) { + const head = await $.process.run([...GIT, 'rev-parse', '--verify', '--quiet', 'HEAD']) + return head.exitCode === 0 ? 'HEAD' : EMPTY_TREE +} + +/** + * A stand-in for the state of the working tree: the diff of the tracked files, + * and the names and content hashes of the untracked ones. Two equal strings + * mean nothing changed, and the pane is not drawn again. + */ +async function fingerprint($: Engine): Promise { + const git = (args: string[], stdin?: string) => $.process.run([...GIT, ...args], stdin === undefined ? undefined : { stdin }) + const [diff, others] = await Promise.all([git([...TRACKED, await baseOf($)]), git(['ls-files', '--others', '--exclude-standard', '-z'])]) + const paths = others.stdout.split('\0').filter(path => path !== '') + const hashes = paths.length === 0 ? '' : (await git(['hash-object', '--stdin-paths'], paths.join('\n'))).stdout + return [diff.stdout, others.stdout, hashes].join('\u0001') +} + +/** Reloads the pane whether or not the tree changed: the person asked. */ +async function reloadNow($: Engine) { + await load($, { isReload: true }) + seen = await fingerprint($).catch(() => seen) +} + +/** + * Reloads the pane if it is open and the working tree is not what it drew. + * Never throws and never runs twice at once; a failure is tried again on the + * next tick. + */ +async function refresh($: Engine) { + if (isRefreshing) return + isRefreshing = true + try { + if (!(await $.ui.panes()).some(pane => pane.id === PANE)) { + timer?.cancel() + timer = undefined + return + } + const now = await fingerprint($) + if (now === seen) return + await load($, { isReload: true }) + seen = now + } catch { + // The next tick tries again. + } finally { + isRefreshing = false + } +} + +async function ask($: Engine): Promise { + const git = (args: string[]) => $.process.run([...GIT, ...args]) + + const inside = await git(['rev-parse', '--is-inside-work-tree']).catch(() => null) + if (inside === null || inside.exitCode !== 0) return { kind: 'error', message: 'Not inside a git repository.' } + + const base = await baseOf($) + const [nameStatus, numstat, others] = await Promise.all([ + git([...TRACKED, '--name-status', '-z', base]), + git([...TRACKED, '--numstat', '-z', base]), + git(['ls-files', '--others', '--exclude-standard', '-z']), + ]) + if (nameStatus.exitCode !== 0) return { kind: 'error', message: nameStatus.stderr.trim() || 'git diff failed.' } + + const all = [...parseChanges(nameStatus.stdout, numstat.stdout), ...parseUntracked(others.stdout)] + const files = await Promise.all(all.slice(0, MAX_FILES).map(change => diffOf($, change, base))) + return { kind: 'changes', files, hiddenFiles: Math.max(0, all.length - MAX_FILES) } +} + +/** One file with its hunks. An untracked file gets its counts from them. */ +async function diffOf($: Engine, change: Change, base: string): Promise { + if (change.isBinary) return { ...change, hunks: [] } + + const git = (args: string[]) => $.process.run([...GIT, ...args]) + // `--no-index` exits 1 when the files differ, which an untracked file always does. + const shown = + change.status === 'untracked' + ? await git([...DIFF, '--no-index', '--', '/dev/null', change.path]) + : await git([...TRACKED, base, '--', change.path]) + const hunks = parseHunks(shown.stdout) + if (change.status !== 'untracked') return { ...change, hunks } + + const added = hunks.reduce((sum, hunk) => sum + hunk.lines.filter(line => line.startsWith('+')).length, 0) + return { ...change, added, hunks } +} + +function drawFiles($: Engine, kit: Kit, files: FileDiff[], hidden: number, closed: string[], columns: number) { + const { Box, Text, Button } = kit + const added = files.reduce((sum, file) => sum + file.added, 0) + const removed = files.reduce((sum, file) => sum + file.removed, 0) + const count = files.length + hidden + + // The list, and the name and two rules of each file, come off the character budget first. + const frame = files.reduce((sum, file) => sum + 2 * (file.path.length + FILE_ROW_CHARS) + 2 * columns, 0) + + // Each open file draws what is left of the row and character budgets. + let rowsLeft = MAX_TOTAL_ROWS + let charsLeft = Math.max(0, TEXT_BUDGET - frame) + const drawn = files.map(file => { + const isFolded = closed.includes(file.path) + const isShown = !isFolded && !file.isBinary && file.hunks.length > 0 + const fit = isShown ? fitHunks(file.hunks, Math.min(MAX_ROWS, rowsLeft), charsLeft) : null + rowsLeft -= fit?.count ?? 0 + charsLeft -= fit?.chars ?? 0 + return drawFile($, kit, file, isFolded, fit, columns) + }) + + return Box({ + flexDirection: 'column', + children: [ + Box({ + flexDirection: 'row', + children: [ + Text({ bold: true, children: ['Uncommitted changes'] }), + Text({ dimColor: true, children: [` · ${count} ${count === 1 ? 'file' : 'files'} · `] }), + Text({ color: 'success', children: ['+' + added] }), + Text({ children: [' '] }), + Text({ color: 'error', children: ['-' + removed] }), + ], + }), + Text({ dimColor: true, children: ['↑↓ move · Enter jump to a file, or fold it · r refresh · Esc close'] }), + count === 0 ? Text({ dimColor: true, children: ['Working tree clean.'] }) : null, + // The list: a click takes the pane to the file. + ...files.map(file => + fileRow(kit, file, { + key: 'jump:' + file.path, + label: file.path, + // A pane that is not drawn cannot scroll; that is no error. + onPress: () => void $.ui.scroll({ to: { key: 'file:' + file.path }, in: PANE, block: 'start' }).catch(() => undefined), + }), + ), + ...drawn, + hidden > 0 ? Text({ dimColor: true, children: [`… ${hidden} more files not shown`] }) : null, + Box({ + flexDirection: 'row', + marginTop: 1, + children: [Button({ key: 'refresh', hotkey: 'r', onPress: () => void reloadNow($), children: ['Refresh'] })], + }), + ], + }) +} + +function drawFile($: Engine, kit: Kit, file: FileDiff, isFolded: boolean, fit: Fit | null, columns: number) { + const { Box, Text } = kit + const toggle = () => + void update($, folded, list => (list.includes(file.path) ? list.filter(path => path !== file.path) : [...list, file.path])) + // The name sits between two lines, the whole width of the pane. + const rule = () => Text({ dimColor: true, wrap: 'truncate-end', children: ['─'.repeat(columns)] }) + + return Box({ + flexDirection: 'column', + marginTop: 1, + children: [ + rule(), + fileRow(kit, file, { key: 'file:' + file.path, label: (isFolded ? '▸ ' : '▾ ') + file.path, onPress: toggle }), + rule(), + isFolded ? null : body(kit, file, fit), + ], + }) +} + +/** A file's row: its name is the button, and its counts sit at the end of the row. */ +function fileRow({ Box, Text, Button }: Kit, file: FileDiff, { key, label, onPress }: { key: string; label: string; onPress: () => void }) { + return Box({ + flexDirection: 'row', + children: [ + Box({ flexGrow: 1, flexShrink: 1, children: [Button({ key, plain: true, onPress, children: [label] })] }), + Box({ flexShrink: 0, flexDirection: 'row', children: counts(Text, file) }), + ], + }) +} + +function counts(Text: Kit['Text'], file: FileDiff) { + const label = file.status === 'untracked' ? [Text({ dimColor: true, children: ['untracked '] })] : [] + if (file.isBinary) return [...label, Text({ dimColor: true, children: ['binary'] })] + return [ + ...label, + Text({ color: 'success', children: ['+' + file.added] }), + Text({ children: [' '] }), + Text({ color: 'error', children: ['-' + file.removed] }), + ] +} + +function body(kit: Kit, file: FileDiff, fit: Fit | null) { + const { Text } = kit + if (file.isBinary) return Text({ dimColor: true, children: ['Binary file.'] }) + if (fit === null) { + const empty = file.status === 'deleted' ? 'Empty file deleted.' : 'No changes to show.' + return Text({ dimColor: true, children: [empty] }) + } + + return drawFit(kit, fit, file.path) +} diff --git a/hooks/draw.ts b/hooks/draw.ts new file mode 100644 index 0000000..6f6b788 --- /dev/null +++ b/hooks/draw.ts @@ -0,0 +1,286 @@ +import type { ElementTable } from 'claude-code' + +import type { Hunk } from '../types' +import { drawable, splitRows } from './split-rows.js' +import type { Cell, SplitDiff, SplitRow } from './split-rows.js' + +export type Kit = Pick, 'Box' | 'Text' | 'Code'> + +/** + * Where a split diff sits. The rows are the same everywhere; only what + * surrounds them differs. + */ +const LAYOUTS = { + /** Under a tool call in the transcript: the summary line with the built-in's response marker, and a frame. */ + 'tool-result': { lead: ' ⎿ ', indent: 5, isFramed: true }, + /** In the pane: no summary (the file's row has the counts), no frame, the rule between the columns alone. */ + pane: { lead: null, indent: 0, isFramed: false }, +} as const + +export type DiffLayout = keyof typeof LAYOUTS + +/** + * Characters of diff text drawn in one tree. The engine draws only the first + * 100000 and cuts the rest, the "… more rows" with it; the margin is for the + * text around the diff. + */ +export const TEXT_BUDGET = 80_000 + +/** Lines of the rule between the columns; a row that wraps past it shows a gap in the rule. */ +const RULE_LENGTH = 16 + +/** Characters of a one-line hunk header (`@@ -12,1 +12,1 @@` and the mark). */ +const HEADER_LENGTH = 22 + +/** + * The characters of text a row puts in the tree. The engine draws only the + * first 100000 of a tree, so a long diff has to be counted before it is drawn. + */ +function rowChars(row: SplitRow) { + if (row.kind === 'gap') return 60 + const text = (cell: Cell | null) => (cell === null ? 1 : HEADER_LENGTH + cell.text.length) + // A changed pair is drawn as one hunk in each column. + if (row.changed && row.left !== null && row.right !== null) { + return 2 * (HEADER_LENGTH + row.left.text.length + row.right.text.length) + RULE_LENGTH + } + return text(row.left) + text(row.right) + RULE_LENGTH +} + +/** + * How many of the first rows of a diff fit in `maxRows` rows and `maxChars` + * characters of text, and the characters those rows take. + */ +export function rowsWithin(diff: SplitDiff, maxRows: number, maxChars: number) { + let count = 0 + let chars = 0 + for (const row of diff.rows.slice(0, maxRows)) { + const next = chars + rowChars(row) + if (next > maxChars) break + chars = next + count++ + } + return { count, chars } +} + +export type DiffOptions = { + /** The file's path, for the language of the highlighting. */ + path: string | undefined + /** Rows drawn before "… N more rows". */ + maxRows: number + layout: DiffLayout +} + +/** + * A split diff: the removed lines in the left column and the added ones in the + * right, with line numbers, under a "Added 3 lines, removed 1 line" summary + * where the layout has one. Every place that draws a diff goes through here. + */ +export function drawDiff(kit: Kit, diff: SplitDiff, { path, maxRows, layout }: DiffOptions) { + const { Box, Text } = kit + const { lead, indent, isFramed } = LAYOUTS[layout] + + return Box({ + flexDirection: 'column', + children: [ + lead === null + ? null + : Box({ + flexDirection: 'row', + children: [Text({ dimColor: true, children: [lead] }), ...summary(Text, diff.added, diff.removed)], + }), + Box({ + flexDirection: 'column', + marginLeft: indent, + ...(isFramed ? { borderStyle: 'round', borderDimColor: true } : {}), + children: drawRows(kit, diff, path, maxRows), + }), + ], + }) +} + +/** + * What fits of a file's hunks in the rows and characters left: the split rows, + * or, for what has no split form (a final newline on one side only), the lines + * of git's own diff. Either way `count` rows and `chars` characters are spent. + */ +export type Fit = + | { kind: 'split'; diff: SplitDiff; count: number; chars: number } + | { kind: 'unified'; lines: string[]; total: number; count: number; chars: number } + +/** + * The one place that shares the row and character budgets out, for the split + * rows and the unified lines alike. Lines are made drawable as the split rows + * are: tabs laid out, control characters dropped, cut at the engine's limit. + */ +export function fitHunks(hunks: readonly Hunk[], maxRows: number, maxChars: number): Fit { + const diff = splitRows(hunks) + if (diff !== null) return { kind: 'split', diff, ...rowsWithin(diff, maxRows, maxChars) } + + const all = hunks.flatMap(hunk => [`@@ -${hunk.oldStart} +${hunk.newStart} @@`, ...hunk.lines].map(drawable)) + const lines: string[] = [] + let chars = 0 + for (const line of all.slice(0, maxRows)) { + // The line break is a character of the source too. + if (chars + line.length + 1 > maxChars) break + chars += line.length + 1 + lines.push(line) + } + return { kind: 'unified', lines, total: all.length, count: lines.length, chars } +} + +/** + * Draws a fit: the split diff in the pane's layout, or git's own diff, in the + * engine's drawing, with "… N more rows" after what was cut. + */ +export function drawFit(kit: Kit, fit: Fit, path: string | undefined) { + if (fit.kind === 'split') return drawDiff(kit, fit.diff, { path, maxRows: fit.count, layout: 'pane' }) + + const { Box, Text, Code } = kit + const hidden = fit.total - fit.count + return Box({ + flexDirection: 'column', + children: [ + fit.count === 0 + ? null + : Code({ source: fit.lines.join('\n'), format: 'diff', wrap: 'wrap', ...(path === undefined ? {} : { path }) }), + hidden > 0 ? Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null, + ], + }) +} + +/** + * The rows of a split diff, ready to sit in a column, with "… N more rows" + * after the first `maxRows`. + */ +function drawRows(kit: Kit, diff: SplitDiff, path: string | undefined, maxRows: number) { + const shown = diff.rows.slice(0, maxRows) + const hidden = diff.rows.length - shown.length + return [ + ...shown.map(row => drawRow(kit, row, path)), + hidden > 0 ? kit.Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null, + ] +} + +function drawRow(kit: Kit, row: SplitRow, path: string | undefined) { + const { Box, Text } = kit + if (row.kind === 'gap') { + // The label sits in both columns, so a wide screen doesn't leave the right one blank. + const label = () => Text({ dimColor: true, children: ['⋯ ' + row.hidden + ' unchanged lines'] }) + // A blank line above and below sets the label apart; an empty 1-wide Box takes the separator's place so the columns line up. + const column = { width: '50%', flexShrink: 1, paddingY: 1 } as const + return Box({ + flexDirection: 'row', + children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })], + }) + } + if (row.changed && row.left !== null && row.right !== null) { + return drawPair(kit, row.left, row.right, path) + } + return Box({ + flexDirection: 'row', + alignItems: 'stretch', + children: [ + drawCell(kit, row.left, row.changed ? 'removed' : 'same', path), + separator(kit), + drawCell(kit, row.right, row.changed ? 'added' : 'same', path), + ], + }) +} + +function drawCell( + { Box, Text, Code }: Kit, + cell: Cell | null, + kind: 'removed' | 'added' | 'same', + path: string | undefined, +) { + const column = { width: '50%', flexShrink: 1, flexDirection: 'row' } as const + + // Nothing on this side of the row: keep the column, draw nothing in it. + if (cell === null) return Box({ ...column, children: [Text({ children: [' '] })] }) + + // One-line hunk through the engine's diff renderer, so gutter, marker and + // add/remove colors are exactly the built-in diff's. + const n = cell.no + const header = + kind === 'removed' ? `@@ -${n},1 +${n},0 @@` : kind === 'added' ? `@@ -${n},0 +${n},1 @@` : `@@ -${n},1 +${n},1 @@` + const sign = kind === 'removed' ? '-' : kind === 'added' ? '+' : ' ' + + return Box({ + ...column, + children: [ + Code({ + source: header + '\n' + sign + cell.text, + format: 'diff', + wrap: 'wrap', + ...(path === undefined ? {} : { path }), + }), + ], + }) +} + +/** + * A removed line next to the line that replaced it. The engine draws the two as + * one hunk, which is what makes it tint the words that changed; each column + * then shows its own line of that hunk. Long lines are cut with an ellipsis, as + * a column clipped to one row cannot wrap. + */ +function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) { + const { Box, Code } = kit + const hunk = () => + Code({ + source: `@@ -${left.no},1 +${right.no},1 @@\n-${left.text}\n+${right.text}`, + format: 'diff', + wrap: 'truncate-end', + ...(path === undefined ? {} : { path }), + }) + const column = { width: '50%', flexShrink: 1, height: 1, overflow: 'hidden' } as const + return Box({ + flexDirection: 'row', + children: [ + Box({ ...column, children: [hunk()] }), + separator(kit), + Box({ + ...column, + children: [Box({ position: 'absolute', top: -1, left: 0, right: 0, children: [hunk()] })], + }), + ], + }) +} + +/** + * A dim vertical rule between the columns. It stretches to the row's height + * (wrapped lines make rows taller), so it draws a tall line out of flow and + * lets the clipped box cut it to the row. + */ +function separator({ Box, Text }: Kit) { + return Box({ + width: 1, + flexShrink: 0, + overflow: 'hidden', + children: [ + Box({ + position: 'absolute', + top: 0, + bottom: 0, + width: 1, + // One node: the glyphs wrap at the box's one column, a line each. + children: [Text({ dimColor: true, children: ['│'.repeat(RULE_LENGTH)] })], + }), + ], + }) +} + +/** "Added 3 lines, removed 1 line", the counts bold, as the built-in diff words it. */ +function summary(Text: Kit['Text'], added: number, removed: number) { + const noun = (n: number) => (n > 1 ? ' lines' : ' line') + const parts: ReturnType[] = [] + if (added > 0) { + parts.push(Text({ children: ['Added '] }), Text({ bold: true, children: [String(added)] })) + parts.push(Text({ children: [noun(added) + (removed > 0 ? ', ' : '')] })) + } + if (removed > 0) { + parts.push(Text({ children: [added === 0 ? 'Removed ' : 'removed '] })) + parts.push(Text({ bold: true, children: [String(removed)] }), Text({ children: [noun(removed)] })) + } + return parts +} diff --git a/hooks/git-diff.ts b/hooks/git-diff.ts new file mode 100644 index 0000000..19365b5 --- /dev/null +++ b/hooks/git-diff.ts @@ -0,0 +1,60 @@ +import type { Change, ChangeStatus, Hunk } from '../types' + +const STATUS: Record = { A: 'added', D: 'deleted' } + +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/ + +/** + * Joins the output of `git diff --name-status -z --no-renames` and + * `git diff --numstat -z --no-renames` into one list, in git's order. + */ +export function parseChanges(nameStatus: string, numstat: string): Change[] { + const counts = new Map() + for (const record of numstat.split('\0')) { + const [added, removed, ...path] = record.split('\t') + if (path.length === 0) continue + // A binary file counts as '-' on both sides. + const isBinary = added === '-' + counts.set(path.join('\t'), { + added: isBinary ? 0 : Number(added), + removed: isBinary ? 0 : Number(removed), + isBinary, + }) + } + + const tokens = nameStatus.split('\0') + const changes: Change[] = [] + for (let i = 0; i + 1 < tokens.length; i += 2) { + const path = tokens[i + 1] as string + const count = counts.get(path) ?? { added: 0, removed: 0, isBinary: false } + changes.push({ path, status: STATUS[(tokens[i] as string).charAt(0)] ?? 'modified', ...count }) + } + return changes +} + +/** The files of `git ls-files --others -z`, as changes. */ +export function parseUntracked(output: string): Change[] { + return output + .split('\0') + .filter(path => path !== '') + .map(path => ({ path, status: 'untracked', added: 0, removed: 0, isBinary: false })) +} + +/** + * The hunks of the unified diff of one file. Everything before the first + * `@@` (the `diff --git`, mode and `---`/`+++` lines) is skipped. + */ +export function parseHunks(diff: string): Hunk[] { + const hunks: Hunk[] = [] + let current: Hunk | null = null + for (const line of diff.split('\n')) { + const header = HUNK_HEADER.exec(line) + if (header !== null) { + current = { oldStart: Number(header[1]), newStart: Number(header[2]), lines: [] } + hunks.push(current) + continue + } + if (current !== null && line !== '' && ' +-\\'.includes(line.charAt(0))) current.lines.push(line) + } + return hunks +} diff --git a/hooks/register.ts b/hooks/register.ts index 89edb33..7a28568 100644 --- a/hooks/register.ts +++ b/hooks/register.ts @@ -1,7 +1,9 @@ -import type { ElementTable, On } from 'claude-code' +import type { On } from 'claude-code' +import { registerDiffSplit } from './diff-command.js' +import { TEXT_BUDGET, drawDiff, rowsWithin } from './draw.js' +import type { Kit } from './draw.js' import { splitRows } from './split-rows.js' -import type { Cell, SplitRow } from './split-rows.js' /** Tools whose result is a diff this mod redraws. */ const TOOLS = ['Edit', 'Write'] @@ -9,8 +11,6 @@ const TOOLS = ['Edit', 'Write'] /** Rows drawn before "… N more rows". */ const MAX_ROWS = 200 -type Kit = Pick, 'Box' | 'Text' | 'Code'> - const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null @@ -21,6 +21,8 @@ const isRecord = (value: unknown): value is Record => * @param on the engine's registrar */ export function register(on: On) { + registerDiffSplit(on) + on('ui.render', { component: 'ToolResult' }, async ($, e, next) => { const { tool, output, isErrored } = e.props if (!TOOLS.includes(tool) || isErrored || !isRecord(output)) return next(e) @@ -32,158 +34,11 @@ export function register(on: On) { if (diff === null) return next(e) const kit: Kit = $.ui.resolve(e) - const { Box, Text } = kit const path = typeof output.filePath === 'string' ? output.filePath : undefined - const shown = diff.rows.slice(0, MAX_ROWS) - const hidden = diff.rows.length - shown.length - - return Box({ - flexDirection: 'column', - children: [ - // The built-in summary line, with its response marker. - Box({ - flexDirection: 'row', - children: [ - Text({ dimColor: true, children: [' ⎿ '] }), - ...summary(Text, diff.added, diff.removed), - ], - }), - // The rows sit in a frame under the summary text, as the built-in diff does. - Box({ - flexDirection: 'column', - marginLeft: 5, - borderStyle: 'round', - borderDimColor: true, - children: [ - ...shown.map(row => drawRow(kit, row, path)), - hidden > 0 ? Text({ dimColor: true, children: ['… ' + hidden + ' more rows'] }) : null, - ], - }), - ], - }) - }) -} - -function drawRow(kit: Kit, row: SplitRow, path: string | undefined) { - const { Box, Text } = kit - if (row.kind === 'gap') { - // The label sits in both columns, so a wide screen doesn't leave the right one blank. - const label = () => Text({ dimColor: true, children: ['⋯ ' + row.hidden + ' unchanged lines'] }) - // A blank line above and below sets the label apart; an empty 1-wide Box takes the separator's place so the columns line up. - const column = { width: '50%', flexShrink: 1, paddingY: 1 } as const - return Box({ - flexDirection: 'row', - children: [Box({ ...column, children: [label()] }), Box({ width: 1, flexShrink: 0 }), Box({ ...column, children: [label()] })], - }) - } - if (row.changed && row.left !== null && row.right !== null) { - return drawPair(kit, row.left, row.right, path) - } - return Box({ - flexDirection: 'row', - alignItems: 'stretch', - children: [ - drawCell(kit, row.left, row.changed ? 'removed' : 'same', path), - separator(kit), - drawCell(kit, row.right, row.changed ? 'added' : 'same', path), - ], - }) -} - -function drawCell( - { Box, Text, Code }: Kit, - cell: Cell | null, - kind: 'removed' | 'added' | 'same', - path: string | undefined, -) { - const column = { width: '50%', flexShrink: 1, flexDirection: 'row' } as const + // The same limits as the pane: rows, and the characters the engine draws of a tree. + const { count } = rowsWithin(diff, MAX_ROWS, TEXT_BUDGET) - // Nothing on this side of the row: keep the column, draw nothing in it. - if (cell === null) return Box({ ...column, children: [Text({ children: [' '] })] }) - - // One-line hunk through the engine's diff renderer, so gutter, marker and - // add/remove colors are exactly the built-in diff's. - const n = cell.no - const header = - kind === 'removed' ? `@@ -${n},1 +${n},0 @@` : kind === 'added' ? `@@ -${n},0 +${n},1 @@` : `@@ -${n},1 +${n},1 @@` - const sign = kind === 'removed' ? '-' : kind === 'added' ? '+' : ' ' - - return Box({ - ...column, - children: [ - Code({ - source: header + '\n' + sign + cell.text, - format: 'diff', - wrap: 'wrap', - ...(path === undefined ? {} : { path }), - }), - ], + return drawDiff(kit, diff, { path, maxRows: count, layout: 'tool-result' }) }) } - -/** - * A removed line next to the line that replaced it. The engine draws the two as - * one hunk, which is what makes it tint the words that changed; each column - * then shows its own line of that hunk. Long lines are cut with an ellipsis, as - * a column clipped to one row cannot wrap. - */ -function drawPair(kit: Kit, left: Cell, right: Cell, path: string | undefined) { - const { Box, Code } = kit - const hunk = () => - Code({ - source: `@@ -${left.no},1 +${right.no},1 @@\n-${left.text}\n+${right.text}`, - format: 'diff', - wrap: 'truncate-end', - ...(path === undefined ? {} : { path }), - }) - const column = { width: '50%', flexShrink: 1, height: 1, overflow: 'hidden' } as const - return Box({ - flexDirection: 'row', - children: [ - Box({ ...column, children: [hunk()] }), - separator(kit), - Box({ - ...column, - children: [Box({ position: 'absolute', top: -1, left: 0, right: 0, children: [hunk()] })], - }), - ], - }) -} - -/** - * A dim vertical rule between the columns. It stretches to the row's height - * (wrapped lines make rows taller), so it draws a tall line out of flow and - * lets the clipped box cut it to the row. - */ -function separator({ Box, Text }: Kit) { - return Box({ - width: 1, - flexShrink: 0, - overflow: 'hidden', - children: [ - Box({ - position: 'absolute', - top: 0, - bottom: 0, - flexDirection: 'column', - children: Array.from({ length: 20 }, () => Text({ dimColor: true, children: ['│'] })), - }), - ], - }) -} - -/** "Added 3 lines, removed 1 line", the counts bold, as the built-in diff words it. */ -function summary(Text: Kit['Text'], added: number, removed: number) { - const noun = (n: number) => (n > 1 ? ' lines' : ' line') - const parts: ReturnType[] = [] - if (added > 0) { - parts.push(Text({ children: ['Added '] }), Text({ bold: true, children: [String(added)] })) - parts.push(Text({ children: [noun(added) + (removed > 0 ? ', ' : '')] })) - } - if (removed > 0) { - parts.push(Text({ children: [added === 0 ? 'Removed ' : 'removed '] })) - parts.push(Text({ bold: true, children: [String(removed)] }), Text({ children: [noun(removed)] })) - } - return parts -} diff --git a/hooks/split-rows.ts b/hooks/split-rows.ts index a1ff59a..284c436 100644 --- a/hooks/split-rows.ts +++ b/hooks/split-rows.ts @@ -32,7 +32,7 @@ const MAX_TEXT = 10_000 * invisible character dropped (a stray '\r' from a CRLF file would otherwise * get the whole tree refused), and cut to the engine's cap. */ -const drawable = (text: string) => +export const drawable = (text: string) => text .replace(/\t/g, TAB) .replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '') diff --git a/tests/diffsplit-pane.test.ts b/tests/diffsplit-pane.test.ts new file mode 100644 index 0000000..422e000 --- /dev/null +++ b/tests/diffsplit-pane.test.ts @@ -0,0 +1,298 @@ +import type { On, UiPane } from 'claude-code' +import { expect, test } from 'claude-code/testing' + +// The test runner has it; the types of the testing module do not declare it. +declare function setTimeout(fn: (value: unknown) => void, ms: number): unknown + +const PANE = { + plugin: 'cc-split-diff-view', + component: 'Pane', + requestId: 'diffsplit', + surface: 'terminal', + props: { + title: 'Split Diff', + isFocused: false, + bodyColumns: 100, + placement: 'dock', + scroll: { offset: 0, bodyRows: 30 }, + view: {}, + }, +} as const + +const RUN = { + command: 'diffsplit', + args: '', + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns: 120 }, +} as const + +/** A new file of `lines` added lines, as `git diff` prints it. */ +const added = (lines: number) => + [ + 'diff --git a/f.txt b/f.txt', + '--- /dev/null', + '+++ b/f.txt', + `@@ -0,0 +1,${lines} @@`, + ...Array.from({ length: lines }, (_, i) => `+line number ${i} with some text in it`), + '', + ].join('\n') + +/** The engine below the mod: an in-memory state, and git answering for `files` files of `lines` lines. */ +function engine(on: On, files: number, lines: number) { + // What the tests change and read: the tree git answers for, and how often the pane's list was stored. + const tree = { files, lines, loads: 0, untracked: [] as string[], calls: [] as string[], diff: null as string | null } + const store = new Map() + on('state.get', (_, e) => ({ value: store.get(e.key) ?? { value: undefined, version: 0 } })) + on('state.set', (_, e) => { + const version = (store.get(e.key)?.version ?? 0) + 1 + store.set(e.key, { value: e.value, version }) + if (e.key === 'snapshot') tree.loads++ + return { value: { isSet: true, version } } + }) + on('command.register', () => ({ value: { command: 'diffsplit' } })) + on('ui.open', () => ({ value: { isPlaced: true } })) + on('process.run', (_, e) => { + const out = (stdout: string) => ({ + value: { exitCode: 0, stdout, stderr: '', isStdoutTruncated: false, isStderrTruncated: false }, + }) + const git = e.argv.join(' ') + tree.calls.push(git) + const names = Array.from({ length: tree.files }, (_, i) => `f${i}.txt`) + if (git.includes('--name-status')) return out(names.map(name => `A\0${name}\0`).join('')) + if (git.includes('--numstat')) return out(names.map(name => `${tree.lines}\t0\t${name}\0`).join('')) + if (git.includes('rev-parse')) return out('true\n') + if (git.includes('ls-files')) return out(tree.untracked.map(path => path + '\0').join('')) + if (git.includes('hash-object')) return out('') + // The diff of the whole tree, as the pane reads it to tell if anything changed. + if (!git.includes(' -- ')) return out(names.map(() => added(tree.lines)).join('')) + return out(tree.diff ?? added(tree.lines)) + }) + return tree +} + +/** Lets the work a hook started behind its answer run: a refresh is not awaited by the tool. */ +async function settle() { + await new Promise(resolve => setTimeout(resolve, 50)) +} + +test('the pane draws every file open, with its counts', async ($, on) => { + engine(on, 2, 3) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + expect((await ui.find({ type: 'Text', text: /2 files/ }))?.text).toContain('2 files') + expect((await ui.findAll({ type: 'Text', text: /^\+3$/ })).length).toBeGreaterThan(0) + await ui.unmount() +}) + +test('the list at the top has a row per file, and a click on it goes through', async ($, on) => { + engine(on, 3, 2) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + // One row in the list and one above its diff, per file. + for (const name of ['f0.txt', 'f1.txt', 'f2.txt']) { + expect(await ui.find({ type: 'Button', key: 'jump:' + name })).toBeDefined() + expect(await ui.find({ type: 'Button', key: 'file:' + name })).toBeDefined() + } + // The counts sit in the row, after the name. + expect((await ui.findAll({ type: 'Text', text: /^\+2$/ })).length).toBeGreaterThanOrEqual(6) + + // The test engine has no scrolling to answer, so the target is not checked here: the + // click must only leave the pane standing, and the key it aims at (`file:`) is drawn above. + await ui.press({ key: 'jump:f1.txt' }) + expect(await ui.find({ type: 'Button', key: 'file:f1.txt' })).toBeDefined() + await ui.unmount() +}) + +test('each file name sits between two lines the width of the pane', async ($, on) => { + engine(on, 2, 2) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + const rules = await ui.findAll({ type: 'Text', text: /^─+$/ }) + expect(rules.map(rule => rule.text)).toEqual(Array(4).fill('─'.repeat(100))) + await ui.unmount() +}) + +// The pane and the result of an Edit draw a diff through the same code: same summary, same rows. +test('a tool result and the pane draw the same diff', async ($, on) => { + engine(on, 1, 2) + await $.command.run(RUN) + + const result = await $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'edit-1', + surface: 'terminal', + props: { + tool_use_id: 'toolu_1', + tool: 'Edit', + isErrored: false, + output: { + filePath: '/tmp/f.txt', + structuredPatch: [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: [' keep', '-old', '+new'] }], + }, + }, + }) + const pane = await $.ui.mount(PANE) + + // A hunk of the diff and the rule between the columns come out of both. + for (const ui of [result, pane]) { + expect(await ui.find({ type: 'Code', text: /^@@ -\d+,\d+ \+\d+,\d+ @@/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^│+$/ })).toBeDefined() + } + // Only the transcript has the summary line and its response marker: in the pane + // the counts are on the file's row. + expect(await result.find({ type: 'Text', text: /^Added $/ })).toBeDefined() + expect(await result.find({ type: 'Text', text: /⎿/ })).toBeDefined() + expect(await pane.find({ type: 'Text', text: /^Added $/ })).toBeUndefined() + expect(await pane.find({ type: 'Text', text: /⎿/ })).toBeUndefined() + await result.unmount() + await pane.unmount() +}) + +// Git names the tracked files from the root of the repository and the untracked ones from the +// working directory; `--relative` makes the tracked ones follow the working directory too, or +// a path from the list is no pathspec for the diff of the file when the session sits in a subfolder. +test('the diff of the tracked files is named from the working directory', async ($, on) => { + const tree = engine(on, 1, 2) + tree.untracked = ['new.txt'] + on('clock.every', () => ({ value: undefined })) + await $.command.run(RUN) + + const diffs = tree.calls.filter(call => call.includes(' diff ')) + const untracked = diffs.filter(call => call.includes('--no-index')) + const tracked = diffs.filter(call => !call.includes('--no-index')) + + // The list (name-status, numstat), the diff of the file and the fingerprint. + expect(tracked.length).toBeGreaterThanOrEqual(4) + expect(tracked.filter(call => !call.includes('--relative'))).toEqual([]) + // The `--no-index` call keeps the arguments it had. + expect(untracked).toHaveLength(1) + expect(untracked[0]).not.toContain('--relative') +}) + +/** A diff with no split form: the old side lacks the final newline and the new side has it. */ +const noSplit = (lines: number, text: (side: '-' | '+', i: number) => string) => + [ + `@@ -1,${lines} +1,${lines} @@`, + ...Array.from({ length: lines }, (_, i) => text('-', i)), + '\\ No newline at end of file', + ...Array.from({ length: lines }, (_, i) => text('+', i)), + '', + ].join('\n') + +// What has no split form is drawn from git's own lines, but out of the same budgets as the rest. +test('a diff with no split form keeps to the rows of the file and of the pane', async ($, on) => { + const tree = engine(on, 3, 300) + tree.diff = noSplit(300, (side, i) => `${side}line number ${i}`) + on('clock.every', () => ({ value: undefined })) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + const sources = (await ui.findAll({ type: 'Code' })).map(code => code.text.split('\n').length) + // 3 files of 602 lines: no file past 500 rows, and not more than 1200 in all. + expect(sources).toHaveLength(3) + expect(Math.max(...sources)).toBeLessThanOrEqual(500) + expect(sources.reduce((sum, rows) => sum + rows, 0)).toBeLessThanOrEqual(1200) + // And the part left out is said, per file. + expect((await ui.findAll({ type: 'Text', text: /more rows/ })).length).toBeGreaterThan(0) + await ui.unmount() +}) + +test('a diff with no split form is cut at the engine limit and loses control characters', async ($, on) => { + const tree = engine(on, 1, 2) + tree.diff = noSplit(1, (side, i) => (i === 0 && side === '-' ? '-a\rb\tc' + 'x'.repeat(20_000) : '+short')) + on('clock.every', () => ({ value: undefined })) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + const code = (await ui.find({ type: 'Code' }))?.text ?? '' + expect(code).not.toContain('\r') + expect(code).not.toContain('\t') + expect(Math.max(...code.split('\n').map(line => line.length))).toBeLessThanOrEqual(10_000) + await ui.unmount() +}) + +/** The pane is open: what `$.ui.panes` answers. */ +const OPEN = [{ id: 'diffsplit' }] as unknown as readonly UiPane[] + +test('the open pane reloads by itself after a tool changes the tree', async ($, on) => { + const tree = engine(on, 1, 2) + on('clock.every', () => ({ value: undefined })) + on('ui.panes', () => ({ value: OPEN })) + on('tool.call', () => ({ result: {} })) + await $.command.run(RUN) + expect(tree.loads).toBe(1) + + tree.files = 2 + await $.tool.call({ tool: 'Bash', command: 'touch f1.txt' }) + await settle() + expect(tree.loads).toBe(2) + + const ui = await $.ui.mount(PANE) + expect(await ui.find({ type: 'Button', key: 'file:f1.txt' })).toBeDefined() + await ui.unmount() +}) + +test('a tool that changes nothing leaves the pane as it is', async ($, on) => { + const tree = engine(on, 1, 2) + on('clock.every', () => ({ value: undefined })) + on('ui.panes', () => ({ value: OPEN })) + on('tool.call', () => ({ result: {} })) + await $.command.run(RUN) + + await $.tool.call({ tool: 'Bash', command: 'true' }) + await settle() + expect(tree.loads).toBe(1) +}) + +test('a closed pane is not reloaded', async ($, on) => { + const tree = engine(on, 1, 2) + on('clock.every', () => ({ value: undefined })) + on('ui.panes', () => ({ value: [] })) + on('tool.call', () => ({ result: {} })) + await $.command.run(RUN) + + tree.files = 2 + await $.tool.call({ tool: 'Bash', command: 'touch f1.txt' }) + await settle() + expect(tree.loads).toBe(1) +}) + +// The engine draws only the first 100000 characters of a tree: the chat is held to the +// same budget as the pane, or a result with long lines loses its end. +test('a tool result with long lines stops at the text budget and says so', async ($) => { + const ui = await $.ui.mount({ + plugin: 'cc-split-diff-view', + component: 'ToolResult', + requestId: 'edit-2', + surface: 'terminal', + props: { + tool_use_id: 'toolu_2', + tool: 'Write', + isErrored: false, + output: { + filePath: '/tmp/long.txt', + structuredPatch: [ + { oldStart: 1, oldLines: 0, newStart: 1, newLines: 200, lines: Array.from({ length: 200 }, () => '+' + 'x'.repeat(600)) }, + ], + }, + }, + }) + + // 639 characters a row, 80000 characters of budget: 125 rows, 75 left out. + expect((await ui.find({ type: 'Text', text: /more rows/ }))?.text).toBe('… 75 more rows') + await ui.unmount() +}) + +// The engine refuses a tree of more than 20000 nodes, and the pane then draws nothing. +test('a pane with many long diffs still draws, and says what it left out', async ($, on) => { + engine(on, 8, 300) + await $.command.run(RUN) + + const ui = await $.ui.mount(PANE) + expect(await ui.find({ type: 'Text', text: /more rows/ })).toBeDefined() + await ui.unmount() +}) diff --git a/tests/git-diff.test.ts b/tests/git-diff.test.ts new file mode 100644 index 0000000..98d3b70 --- /dev/null +++ b/tests/git-diff.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'claude-code/testing' + +import { parseChanges, parseHunks, parseUntracked } from '../hooks/git-diff.js' +import { splitRows } from '../hooks/split-rows.js' + +test('joins name-status and numstat into one list', async () => { + const nameStatus = ['M', 'src/a.ts', 'A', 'new file.ts', 'D', 'gone.ts', 'M', 'logo.png', ''].join('\0') + const numstat = ['3\t1\tsrc/a.ts', '10\t0\tnew file.ts', '0\t7\tgone.ts', '-\t-\tlogo.png', ''].join('\0') + + expect(parseChanges(nameStatus, numstat)).toEqual([ + { path: 'src/a.ts', status: 'modified', added: 3, removed: 1, isBinary: false }, + { path: 'new file.ts', status: 'added', added: 10, removed: 0, isBinary: false }, + { path: 'gone.ts', status: 'deleted', added: 0, removed: 7, isBinary: false }, + { path: 'logo.png', status: 'modified', added: 0, removed: 0, isBinary: true }, + ]) +}) + +test('an empty diff has no changes', async () => { + expect(parseChanges('', '')).toEqual([]) +}) + +test('lists untracked files', async () => { + expect(parseUntracked(['a.txt', 'dir/b c.txt', ''].join('\0'))).toEqual([ + { path: 'a.txt', status: 'untracked', added: 0, removed: 0, isBinary: false }, + { path: 'dir/b c.txt', status: 'untracked', added: 0, removed: 0, isBinary: false }, + ]) +}) + +const diff = [ + 'diff --git a/f.txt b/f.txt', + 'index 1111111..2222222 100644', + '--- a/f.txt', + '+++ b/f.txt', + '@@ -1,3 +1,3 @@', + ' keep', + '-old', + '+new', + ' tail', + '@@ -20 +20,2 @@ function x() {', + '-gone', + '+one', + '+two', + '\\ No newline at end of file', + '', +].join('\n') + +test('reads hunks and skips the file header', async () => { + expect(parseHunks(diff)).toEqual([ + { oldStart: 1, newStart: 1, lines: [' keep', '-old', '+new', ' tail'] }, + { oldStart: 20, newStart: 20, lines: ['-gone', '+one', '+two', '\\ No newline at end of file'] }, + ]) +}) + +test('a diff with no hunk (binary, mode change) gives none', async () => { + expect(parseHunks('diff --git a/x.png b/x.png\nBinary files a/x.png and b/x.png differ\n')).toEqual([]) +}) + +test('hunks from git feed the split view', async () => { + const rows = splitRows(parseHunks(diff.replace('\\ No newline at end of file\n', ''))) + expect(rows?.added).toBe(3) + expect(rows?.removed).toBe(2) +}) + +test('a missing final newline on one side has no split form', async () => { + expect(splitRows(parseHunks(diff))).toBeNull() +}) diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..f071fe9 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,42 @@ +/** How git sees a changed file. */ +export type ChangeStatus = 'modified' | 'added' | 'deleted' | 'untracked' + +/** One changed file in the `/diffsplit` pane. */ +export type Change = { + path: string + status: ChangeStatus + added: number + removed: number + /** Git cannot show a text diff of the file. */ + isBinary: boolean +} + +/** One hunk of a unified diff; `lines` keep their ' ', '-', '+' or '\' mark. */ +export type Hunk = { + oldStart: number + newStart: number + lines: string[] +} + +/** A changed file with the diff git gave for it. */ +export type FileDiff = Change & { hunks: Hunk[] } + +/** What `git` reported when the pane was last loaded. */ +export type Snapshot = + | { + kind: 'changes' + files: FileDiff[] + /** Changed files past the limit: counted, not shown. */ + hiddenFiles: number + } + | { kind: 'error'; message: string } + +declare module 'claude-code' { + interface PluginState { + 'cc-split-diff-view': { + snapshot: Shaped + /** Paths of the files the person folded. */ + folded: Shaped + } + } +}