spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods/diff: tests for the docked and inline layouts, the rename row, the moved-in file and /diff's answersBrowse files
poteat authored on Sep 13, 2026 parent 38dc1e4 · commit 778a2eb5e91e
18 changed files+304 -28compared with 38dc1e4
Mmods/diff/tests/fixtures/git-in.ts
Mmods/diff/tests/fixtures/in-repository.ts
Mmods/diff/tests/fixtures/index.ts
Amods/diff/tests/fixtures/inline-pane.ts
Amods/diff/tests/fixtures/many-file-count.ts
Amods/diff/tests/fixtures/many-files.ts
Amods/diff/tests/fixtures/moved-in.ts
Amods/diff/tests/fixtures/narrow-inline-pane.ts
Amods/diff/tests/fixtures/old-files.ts
Amods/diff/tests/fixtures/renamed.ts
Amods/diff/tests/fixtures/repository-of.ts
Mmods/diff/tests/fixtures/settle-ms.ts
Mmods/diff/tests/fixtures/starts-session.ts
Mmods/diff/tests/fixtures/text-of.ts
Amods/diff/tests/fixtures/two-files.ts
Mmods/diff/tests/git.test.ts
Mmods/diff/tests/register.test.ts
Amods/diff/tests/views.test.ts
@@ -1,18 +1,21 @@
11 import type { ProcessRunResult } from 'claude-code'
22
33 import { NOT_A_REPOSITORY } from './not-a-repository.js'
4-import { REPOSITORY } from './repository.js'
54
65 /**
7- * What git answers in /work (REPOSITORY); an invocation it does not know
8- * fails as git does outside a repository.
6+ * What git answers in /work; an invocation the answers do not know fails
7+ * as git does outside a repository.
98 *
109 * @param argv the invocation, program first
10+ * @param answers git's output by a key the invocation's command line holds
1111 * @returns git's exit code and output
1212 */
13-export function gitIn(argv: readonly string[]): ProcessRunResult {
13+export function gitIn(
14+ argv: readonly string[],
15+ answers: Readonly<Record<string, string>>,
16+): ProcessRunResult {
1417 const line = argv.join(' ')
15- const found = Object.entries(REPOSITORY).find(([key]) => line.includes(key))
18+ const found = Object.entries(answers).find(([key]) => line.includes(key))
1619
1720 return found
1821 ? { exitCode: 0, stdout: found[1], stderr: '' }
@@ -4,6 +4,7 @@
44 import { gitIn } from './git-in.js'
55 import { HINT_DRAWN } from './hint-drawn.js'
66 import { keeping } from './keeping.js'
7+import { REPOSITORY } from './repository.js'
78 import { startsSession } from './starts-session.js'
89
910 /**
@@ -13,9 +14,13 @@
1314 * The store starts empty, the clock at 0, and the engine draws the hint.
1415 *
1516 * @param on the test's `on`
17+ * @param answers git's output by invocation key; one changed file when absent
1618 * @returns the runs, the panes opened and closed, and the session's clock
1719 */
18-export function inRepository(on: On) {
20+export function inRepository(
21+ on: On,
22+ answers: Readonly<Record<string, string>> = REPOSITORY,
23+) {
1924 const runs: Args<'process.run'>[] = []
2025 const opened = keeping<Args<'ui.open'>>()
2126 const closed = keeping<Args<'ui.close'>>()
@@ -24,7 +29,7 @@
2429 on('process.run', ($, e) => {
2530 runs.push(e)
2631
27- return { value: gitIn(e.argv) }
32+ return { value: gitIn(e.argv, answers) }
2833 })
2934
3035 on('ui.open', opened.hook)
@@ -6,13 +6,22 @@
66 export * from './hint.js'
77 export * from './hint-drawn.js'
88 export * from './in-repository.js'
9+export * from './inline-pane.js'
910 export * from './keeping.js'
11+export * from './many-file-count.js'
12+export * from './many-files.js'
13+export * from './moved-in.js'
14+export * from './narrow-inline-pane.js'
1015 export * from './not-a-repository.js'
16+export * from './old-files.js'
1117 export * from './pane.js'
18+export * from './renamed.js'
1219 export * from './repository.js'
20+export * from './repository-of.js'
1321 export * from './session.js'
1422 export * from './settle-ms.js'
1523 export * from './starts-session.js'
1624 export * from './text-of.js'
25+export * from './two-files.js'
1726
1827 export * as default from '.'
@@ -1,0 +1,12 @@
1+import type { RenderInput } from 'claude-code'
2+
3+import { PANE } from './pane.js'
4+
5+/**
6+ * The diff pane seated inline above the prompt on a 160-column terminal,
7+ * as a session without the fullscreen layout seats it.
8+ */
9+export const INLINE_PANE: RenderInput<'Pane'> = {
10+ ...PANE,
11+ props: { ...PANE.props, placement: 'inline', bodyColumns: 156 },
12+}
@@ -1,0 +1,5 @@
1+/**
2+ * How many files MANY_FILES changes: two more than the docked list shows at
3+ * once.
4+ */
5+export const MANY_FILE_COUNT = 10
@@ -1,0 +1,19 @@
1+import { MANY_FILE_COUNT } from './many-file-count.js'
2+import { repositoryOf } from './repository-of.js'
3+
4+/**
5+ * Git's output in /work where MANY_FILE_COUNT files changed a line each:
6+ * more rows than the docked list shows at once.
7+ */
8+export const MANY_FILES = repositoryOf(
9+ Array.from(
10+ { length: MANY_FILE_COUNT },
11+ (_, at) => `1\t1\tfile${at}.ts\0`,
12+ ).join(''),
13+ Object.fromEntries(
14+ Array.from({ length: MANY_FILE_COUNT }, (_, at) => [
15+ `file${at}.ts`,
16+ `@@ -1 +1 @@\n-const v = ${at}\n+const v = ${at + 1}\n`,
17+ ]),
18+ ),
19+)
@@ -1,0 +1,14 @@
1+import { repositoryOf } from './repository-of.js'
2+
3+/**
4+ * Git's output in /work where two old files differ from HEAD: one dirty
5+ * before the session began, one moved into place since (not dirty then).
6+ */
7+export const MOVED_IN = repositoryOf(
8+ '1\t1\told.ts\0' + '1\t1\tmoved.ts\0',
9+ {
10+ 'old.ts': '@@ -1 +1 @@\n-a\n+b\n',
11+ 'moved.ts': '@@ -1 +1 @@\n-c\n+d\n',
12+ },
13+ ['old.ts'],
14+)
@@ -1,0 +1,13 @@
1+import type { RenderInput } from 'claude-code'
2+
3+import { INLINE_PANE } from './inline-pane.js'
4+
5+/**
6+ * The diff pane seated inline on a 100-column terminal: too narrow for the
7+ * built-in panel, which shows only its wider-terminal line there.
8+ */
9+export const NARROW_INLINE_PANE: RenderInput<'Pane'> = {
10+ ...INLINE_PANE,
11+ viewport: { columns: 100, rows: 30 },
12+ props: { ...INLINE_PANE.props, bodyColumns: 96 },
13+}
@@ -1,0 +1,18 @@
1+import type { On } from 'claude-code'
2+
3+/**
4+ * Answers `$.fs` over /work holding MOVED_IN's two files, both last
5+ * written at time 0, before any session began.
6+ *
7+ * @param on the test's `on`
8+ */
9+export function oldFiles(on: On) {
10+ on('fs.list', () => ({
11+ value: [
12+ { name: 'old.ts', kind: 'file', size: 2 },
13+ { name: 'moved.ts', kind: 'file', size: 2 },
14+ ],
15+ }))
16+
17+ on('fs.stat', () => ({ value: { kind: 'file', size: 2, mtimeMs: 0 } }))
18+}
@@ -1,0 +1,10 @@
1+import { repositoryOf } from './repository-of.js'
2+
3+/**
4+ * Git's output in /work where one file was renamed inside its directory,
5+ * its contents untouched.
6+ */
7+export const RENAMED = repositoryOf(
8+ '0\t0\t\0docs/notes.txt\0docs/renamed-notes.txt\0',
9+ {},
10+)
@@ -1,0 +1,27 @@
1+/**
2+ * Git's output in /work for each invocation whose command line holds the
3+ * key, over the given `--numstat -z` records and per-file bodies.
4+ *
5+ * `status` lists the paths dirty at the start (the baseline: every path
6+ * with a body unless given); a body is keyed by the `-- <path>` its diff is
7+ * asked with.
8+ *
9+ * @param numstat the `--numstat -z` records, joined
10+ * @param bodies each path's unified diff after its header
11+ * @param dirty the paths `status` lists as dirty when the session starts
12+ * @returns the answers by key
13+ */
14+export const repositoryOf = (
15+ numstat: string,
16+ bodies: Readonly<Record<string, string>>,
17+ dirty: readonly string[] = Object.keys(bodies),
18+): Readonly<Record<string, string>> => ({
19+ 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n',
20+ 'status --porcelain': dirty.map(path => ` M ${path}\0`).join(''),
21+ 'HEAD --shortstat': ` ${Object.keys(bodies).length} files changed`,
22+ 'HEAD --numstat': numstat,
23+ 'ls-files': '',
24+ ...Object.fromEntries(
25+ Object.entries(bodies).map(([path, body]) => [`-- ${path}`, body]),
26+ ),
27+})
@@ -1,5 +1,5 @@
11 /**
22 * Longer than every wait the pane schedules before it fetches and draws
3- * (its open probe, refresh debounce and redraw), short of its HEAD poll.
3+ * (its refresh debounce and redraw), short of its HEAD poll.
44 */
55 export const SETTLE_MS = 1000
@@ -7,11 +7,12 @@
77 * directory, the time, and each command it registers, under its name.
88 *
99 * @param on the test's `on`
10- * @returns the clock the session reads, at 0 until the test moves it
10+ * @param now where the clock starts, in milliseconds (0 when not given)
11+ * @returns the clock the session reads, at `now` until the test moves it
1112 */
12-export function startsSession(on: On): MockClock {
13+export function startsSession(on: On, now = 0): MockClock {
1314 on('session.start', ($, e) => ({ cwd: e.cwd }))
1415 on('command.register', ($, e) => ({ value: { command: e.name } }))
1516
16- return mock.clock(on)
17+ return mock.clock(on, { now })
1718 }
@@ -1,5 +1,6 @@
11 /**
2- * A rendered tree's text as it reads: its strings and its labels, in order.
2+ * A rendered tree's text as it reads: its strings, its labels and its code
3+ * sources, in order.
34 *
45 * @param tree what `$.ui.render` resolved to, or a part of it
56 * @returns the text
@@ -19,10 +20,14 @@
1920
2021 const props: unknown = Reflect.get(tree, 'props')
2122
22- const label: unknown =
23- typeof props === 'object' && props ? Reflect.get(props, 'label') : undefined
23+ function leadOf(prop: string): string {
24+ const value: unknown =
25+ typeof props === 'object' && props ? Reflect.get(props, prop) : undefined
2426
25- const lead = typeof label === 'string' ? label : ''
27+ return typeof value === 'string' ? value : ''
28+ }
2629
30+ const lead = `${leadOf('label')}${leadOf('source')}`
31+
2732 return `${lead}${textOf(Reflect.get(tree, 'children') ?? [])}`
2833 }
@@ -1,0 +1,9 @@
1+import { repositoryOf } from './repository-of.js'
2+
3+/**
4+ * Git's output in /work where two files changed, a hunk each.
5+ */
6+export const TWO_FILES = repositoryOf('1\t1\tapp.ts\0' + '2\t0\tlib.ts\0', {
7+ 'app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n',
8+ 'lib.ts': '@@ -3,0 +4,2 @@\n+export const b = 1\n+export const c = 2\n',
9+})
@@ -1,4 +1,4 @@
1-import { describe, expect, test, tier } from 'claude-code/testing'
1+import { describe, expect, mock, test, tier } from 'claude-code/testing'
22
33 import Fixtures from './fixtures'
44
@@ -28,4 +28,28 @@
2828 expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } })
2929 }
3030 })
31+
32+ test('a file moved in since the start is session work', async ($, on) => {
33+ const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS)
34+
35+ on('process.run', ($, e) => ({
36+ value: Fixtures.gitIn(e.argv, Fixtures.MOVED_IN),
37+ }))
38+
39+ on('ui.open', () => ({ value: undefined }))
40+ on('ui.invalidate', () => ({ value: undefined }))
41+ on('session.messages', () => ({ value: [] }))
42+ Fixtures.oldFiles(on)
43+ mock.store(on)
44+
45+ await $.session.start(Fixtures.SESSION)
46+ await $.command.run(Fixtures.DIFF)
47+ await clock.advance(Fixtures.SETTLE_MS)
48+
49+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
50+
51+ expect(drawn).toContain('1 file changed +1 -1')
52+ expect(drawn).toContain('moved.ts')
53+ expect(drawn).toContain('+1 file edited before this session (show)')
54+ })
3155 })
@@ -120,14 +120,17 @@
120120 ])
121121 })
122122
123- test('/diff opens the pane over the session changes', async ($, on) => {
123+ test('/diff opens the pane, unfocused, and says so', async ($, on) => {
124124 const world = Fixtures.inRepository(on)
125125
126126 await $.session.start(Fixtures.SESSION)
127127
128- expect(await $.command.run(Fixtures.DIFF)).toEqual({})
129- expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
128+ expect(await $.command.run(Fixtures.DIFF)).toEqual({
129+ text: 'Diff panel shown',
130+ })
130131
132+ expect(world.opened).toEqual([{ id: 'diff', title: 'Diff' }])
133+
131134 await world.clock.advance(Fixtures.SETTLE_MS)
132135
133136 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
@@ -136,23 +139,17 @@
136139 expect(drawn).toContain('app.ts')
137140 })
138141
139- test('the close button closes the pane; /diff reopens it', async ($, on) => {
142+ test('/diff again closes the pane and says so', async ($, on) => {
140143 const world = Fixtures.inRepository(on)
141144
142145 await $.session.start(Fixtures.SESSION)
143146 await $.command.run(Fixtures.DIFF)
144- await world.clock.advance(Fixtures.SETTLE_MS)
145- await $.ui.render(Fixtures.PANE)
146147
147- expect(await $.ui.press({ plugin: 'diff', key: 'close' })).toEqual({
148- element: 'close',
148+ expect(await $.command.run(Fixtures.DIFF)).toEqual({
149+ text: 'Diff panel hidden',
149150 })
150151
151- await world.clock.settle()
152-
153152 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
154- expect(await $.command.run(Fixtures.DIFF)).toEqual({})
155- expect(world.opened.map(pane => pane.id)).toEqual(['diff', 'diff'])
156153 })
157154
158155 test('a wide terminal opens the pane at the first edit', async ($, on) => {
@@ -1,0 +1,105 @@
1+import { describe, expect, test, tier } from 'claude-code/testing'
2+
3+import Fixtures from './fixtures'
4+
5+tier('builtin')
6+
7+describe('views', () => {
8+ test('docked, every file has its hunks under the list', async ($, on) => {
9+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES)
10+
11+ await $.session.start(Fixtures.SESSION)
12+ await $.command.run(Fixtures.DIFF)
13+ await world.clock.advance(Fixtures.SETTLE_MS)
14+
15+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
16+
17+ expect(drawn).toContain('2 files changed +3 -1')
18+ expect(drawn).toContain('+const a = 2')
19+ expect(drawn).toContain('+export const c = 2')
20+ expect(drawn).not.toContain('❯')
21+ })
22+
23+ test('inline, a row moves the pointer and the body to it', async ($, on) => {
24+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES)
25+
26+ await $.session.start(Fixtures.SESSION)
27+ await $.command.run(Fixtures.DIFF)
28+ await world.clock.advance(Fixtures.SETTLE_MS)
29+ await $.ui.render(Fixtures.INLINE_PANE)
30+
31+ expect(await $.ui.press({ plugin: 'diff', key: 'file:lib.ts' })).toEqual({
32+ element: 'file:lib.ts',
33+ })
34+
35+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE))
36+
37+ expect(drawn).toContain('\u276f lib.ts')
38+ expect(drawn).toContain('+export const c = 2')
39+ expect(drawn).not.toContain('+const a = 2')
40+ })
41+
42+ test('past eight files the docked list counts the rest', async ($, on) => {
43+ const world = Fixtures.inRepository(on, Fixtures.MANY_FILES)
44+
45+ await $.session.start(Fixtures.SESSION)
46+ await $.command.run(Fixtures.DIFF)
47+ await world.clock.advance(Fixtures.SETTLE_MS)
48+
49+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
50+
51+ expect(drawn).toContain('10 files changed +10 -10')
52+ expect(drawn).toContain('file7.ts')
53+ expect(drawn).toContain('↓ 2 more below')
54+
55+ expect(drawn.indexOf('↓ 2 more below')).toBeLessThan(
56+ drawn.indexOf('file8.ts'),
57+ )
58+ })
59+
60+ test('a rename lists as git prints it, and reads no body', async ($, on) => {
61+ const world = Fixtures.inRepository(on, Fixtures.RENAMED)
62+
63+ await $.session.start(Fixtures.SESSION)
64+ await $.command.run(Fixtures.DIFF)
65+ await world.clock.advance(Fixtures.SETTLE_MS)
66+
67+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
68+
69+ expect(drawn).toContain('1 file changed')
70+ expect(drawn).toContain('docs/{notes.txt => renamed-notes.txt}')
71+ expect(drawn).toContain('No diff content')
72+
73+ expect(
74+ world.runs.filter(run => run.argv.includes('docs/renamed-notes.txt')),
75+ ).toEqual([])
76+ })
77+
78+ test('inline on a wide terminal, the rows round one file', async ($, on) => {
79+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES)
80+
81+ await $.session.start(Fixtures.SESSION)
82+ await $.command.run(Fixtures.DIFF)
83+ await world.clock.advance(Fixtures.SETTLE_MS)
84+
85+ const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE))
86+
87+ expect(drawn).toContain('❯ app.ts')
88+ expect(drawn).toContain('+const a = 2')
89+ expect(drawn).not.toContain('+export const c = 2')
90+ })
91+
92+ test('inline on a narrow terminal, the resize line', async ($, on) => {
93+ const world = Fixtures.inRepository(on, Fixtures.TWO_FILES)
94+
95+ await $.session.start(Fixtures.SESSION)
96+ await $.command.run(Fixtures.DIFF)
97+ await world.clock.advance(Fixtures.SETTLE_MS)
98+
99+ expect(
100+ Fixtures.textOf(await $.ui.render(Fixtures.NARROW_INLINE_PANE)),
101+ ).toBe(
102+ 'Resize your terminal to at least 110 columns to show the diff panel',
103+ )
104+ })
105+})