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
* diff: the dialog opens every file it lists, and says nothing when closed * diff: the dialog's fetch asks its seat first, and a generated file opens too * diff: a read started by a move of the selection never rejects unheard * diff: a test that a moving window reads only its new rows
| @@ -1,25 +1,35 @@ | ||
| 1 | 1 | import type Git from '../git' |
| 2 | 2 | import Limits from '../limits' |
| 3 | 3 | import PaneState from '../pane-state' |
| 4 | +import Views from '../views' | |
| 4 | 5 | |
| 5 | 6 | /** |
| 6 | - * The files whose bodies the pane draws now, in drawing order: the listed | |
| 7 | - * session rows, then the pre-session rows while their section is open. | |
| 7 | + * The files whose bodies the pane draws or a press opens: every row its | |
| 8 | + * seat lists now. | |
| 8 | 9 | * |
| 9 | - * Past PRE_SESSION_BODY_CAP pre-session files only their legend shows, so | |
| 10 | - * none of their bodies is wanted. | |
| 10 | + * Docked, the listed session rows, then the pre-session rows while their | |
| 11 | + * section is open, none past PRE_SESSION_BODY_CAP. Inline, the rows in the | |
| 12 | + * dialog's window, none for a turn, whose rows carry their own bodies. | |
| 11 | 13 | * |
| 12 | - * @param model the pane's state: its fetch and its two toggles | |
| 14 | + * @param model the pane's state: its fetch, its seat, its toggles, its pick | |
| 13 | 15 | * @returns the rows, none without a fetch |
| 14 | 16 | */ |
| 15 | 17 | export function drawnFilesOf( |
| 16 | - model: Pick< | |
| 17 | - PaneState.PaneModel, | |
| 18 | - 'data' | 'isNoiseShown' | 'isPreSessionShown' | |
| 19 | - >, | |
| 18 | + model: PaneState.PaneModel, | |
| 20 | 19 | ): readonly Git.FileStat[] { |
| 20 | + const files = model.data?.files ?? [] | |
| 21 | + | |
| 22 | + if (model.placement === 'inline') { | |
| 23 | + const isTurn = PaneState.pickedTurnOf(model) !== undefined | |
| 24 | + const listed = Views.dialogEntriesOf(model).map(entry => entry.path) | |
| 25 | + const start = Views.dialogWindowOf(model, listed) | |
| 26 | + const windowed = listed.slice(start, start + Limits.MAX_VISIBLE_FILES) | |
| 27 | + | |
| 28 | + return files.filter(file => !isTurn && windowed.includes(file.path)) | |
| 29 | + } | |
| 30 | + | |
| 21 | 31 | const partition = PaneState.partitionOf( |
| 22 | - model.data?.files ?? [], | |
| 32 | + files, | |
| 23 | 33 | model.isNoiseShown ? 'shown' : 'hidden', |
| 24 | 34 | ) |
| 25 | 35 |
| @@ -544,6 +544,7 @@ | ||
| 544 | 544 | place: isDocked ? Views.placeAtFile(model, path) : model.place, |
| 545 | 545 | } |
| 546 | 546 | |
| 547 | + void loadBodies(engine).catch(() => undefined) | |
| 547 | 548 | redraw(engine) |
| 548 | 549 | }, |
| 549 | 550 | scrollList: delta => { |
| @@ -727,6 +728,15 @@ | ||
| 727 | 728 | |
| 728 | 729 | columns = e.viewport?.columns ?? columns |
| 729 | 730 | |
| 731 | + /** | |
| 732 | + * A seat that changed since the last drawing lists other rows, whose | |
| 733 | + * bodies are read once. | |
| 734 | + * | |
| 735 | + * The model takes the new seat in this same pass, so the drawing that | |
| 736 | + * read asks for finds the seat unchanged. | |
| 737 | + */ | |
| 738 | + const isReseated = e.props.placement !== model.placement | |
| 739 | + | |
| 730 | 740 | model = { |
| 731 | 741 | ...model, |
| 732 | 742 | placement: e.props.placement, |
| @@ -740,6 +750,10 @@ | ||
| 740 | 750 | }, |
| 741 | 751 | } |
| 742 | 752 | |
| 753 | + if (isReseated) { | |
| 754 | + void loadBodies(host).catch(() => undefined) | |
| 755 | + } | |
| 756 | + | |
| 743 | 757 | return Views.paneView( |
| 744 | 758 | { |
| 745 | 759 | ui: { Box, Text, Button, Select, Code }, |
| @@ -830,13 +844,9 @@ | ||
| 830 | 844 | isPaneOpen = false |
| 831 | 845 | } |
| 832 | 846 | |
| 833 | - const isDialog = model.isFullscreen === false | |
| 847 | + const isDocking = model.isFullscreen !== false | |
| 834 | 848 | |
| 835 | - if (isPersons && host && isDialog) { | |
| 836 | - host.uiLog(Names.DIALOG_DISMISSED_TEXT) | |
| 837 | - } | |
| 838 | - | |
| 839 | - if (isPersons && host && !isDialog) { | |
| 849 | + if (isPersons && host && isDocking) { | |
| 840 | 850 | markTabSwitch(host, 'convo') |
| 841 | 851 | await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined) |
| 842 | 852 | } |
| @@ -859,6 +869,7 @@ | ||
| 859 | 869 | } |
| 860 | 870 | |
| 861 | 871 | model = { ...model, selectedPath: focus.selectedPath } |
| 872 | + void loadBodies(host).catch(() => undefined) | |
| 862 | 873 | fitDialog(host) |
| 863 | 874 | host.invalidate() |
| 864 | 875 |
| @@ -61,6 +61,7 @@ | ||
| 61 | 61 | export * from './vs-main.js' |
| 62 | 62 | export * from './wheel-over-list.js' |
| 63 | 63 | export * from './wheel-tick.js' |
| 64 | +export * from './with-lockfile.js' | |
| 64 | 65 | export * from './worktree' |
| 65 | 66 | export * from './wrapped-lines' |
| 66 | 67 |
| @@ -1,17 +1,23 @@ | ||
| 1 | 1 | import type { On } from 'claude-code' |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | - * Answers `$.fs` over /work holding MOVED_IN's two files, both last | |
| 5 | - * written at time 0, before any session began. | |
| 4 | + * Answers `$.fs` over /work holding the named files, MOVED_IN's two when | |
| 5 | + * none is given, each last written at time 0, before any session began. | |
| 6 | 6 | * |
| 7 | 7 | * @param on the test's `on` |
| 8 | + * @param names the files /work holds | |
| 8 | 9 | */ |
| 9 | -export function oldFiles(on: On) { | |
| 10 | +export function oldFiles( | |
| 11 | + on: On, | |
| 12 | + names: readonly string[] = ['old.ts', 'moved.ts'], | |
| 13 | +) { | |
| 10 | 14 | on('fs.list', () => ({ |
| 11 | - value: [ | |
| 12 | - { name: 'old.ts', kind: 'file', size: 2, isLink: false }, | |
| 13 | - { name: 'moved.ts', kind: 'file', size: 2, isLink: false }, | |
| 14 | - ], | |
| 15 | + value: names.map(name => ({ | |
| 16 | + name, | |
| 17 | + kind: 'file' as const, | |
| 18 | + size: 2, | |
| 19 | + isLink: false, | |
| 20 | + })), | |
| 15 | 21 | })) |
| 16 | 22 | |
| 17 | 23 | on('fs.stat', () => ({ |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import { answersOf } from './answers-of.js' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Git's output in /work where a source file and a lockfile changed, the | |
| 5 | + * lockfile a generated file the docked pane hides until asked. | |
| 6 | + */ | |
| 7 | +export const WITH_LOCKFILE = answersOf('1\t1\tapp.ts\0' + '1\t1\tbun.lock\0', { | |
| 8 | + 'app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n', | |
| 9 | + 'bun.lock': '@@ -1 +1 @@\n-"left": "1.0.0"\n+"left": "1.0.1"\n', | |
| 10 | +}) |
| @@ -92,6 +92,97 @@ | ||
| 92 | 92 | expect(drawn).toContain('\u2191/\u2193 to scroll \u00b7 Esc to back') |
| 93 | 93 | }) |
| 94 | 94 | |
| 95 | + test('inline, a file edited before the session opens too', async ($, on) => { | |
| 96 | + const world = Fixtures.inRepository(on, Fixtures.MOVED_IN) | |
| 97 | + | |
| 98 | + on('session.usage', () => ({ value: Fixtures.usageAt(Fixtures.SETTLE_MS) })) | |
| 99 | + Fixtures.oldFiles(on) | |
| 100 | + | |
| 101 | + await $.session.start(Fixtures.SESSION) | |
| 102 | + await $.command.run(Fixtures.DIALOG_DIFF) | |
| 103 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 104 | + | |
| 105 | + expect( | |
| 106 | + Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)), | |
| 107 | + "the dialog lists it beside the session's file", | |
| 108 | + ).toContain('old.ts') | |
| 109 | + | |
| 110 | + expect(await $.ui.press({ plugin: 'diff', key: 'file:old.ts' })).toEqual({ | |
| 111 | + element: 'file:old.ts', | |
| 112 | + }) | |
| 113 | + | |
| 114 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 115 | + | |
| 116 | + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) | |
| 117 | + | |
| 118 | + expect( | |
| 119 | + drawn, | |
| 120 | + 'a listed row can be pressed, so its hunks were read', | |
| 121 | + ).not.toContain('Loading diff') | |
| 122 | + | |
| 123 | + expect(drawn).toContain('+b') | |
| 124 | + expect(drawn).not.toContain('+d') | |
| 125 | + }) | |
| 126 | + | |
| 127 | + test('inline, a file the docked pane hides opens too', async ($, on) => { | |
| 128 | + const world = Fixtures.inRepository(on, Fixtures.WITH_LOCKFILE) | |
| 129 | + | |
| 130 | + await $.session.start(Fixtures.SESSION) | |
| 131 | + await $.command.run(Fixtures.DIALOG_DIFF) | |
| 132 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 133 | + await $.ui.render(Fixtures.INLINE_PANE) | |
| 134 | + | |
| 135 | + expect(await $.ui.press({ plugin: 'diff', key: 'file:bun.lock' })).toEqual({ | |
| 136 | + element: 'file:bun.lock', | |
| 137 | + }) | |
| 138 | + | |
| 139 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 140 | + | |
| 141 | + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) | |
| 142 | + | |
| 143 | + expect(drawn, 'generated, and still a listed row').not.toContain( | |
| 144 | + 'Loading diff', | |
| 145 | + ) | |
| 146 | + | |
| 147 | + expect(drawn).toContain('+"left": "1.0.1"') | |
| 148 | + }) | |
| 149 | + | |
| 150 | + test('inline, a window that moves reads only its new rows', async ($, on) => { | |
| 151 | + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) | |
| 152 | + | |
| 153 | + on('session.usage', () => ({ value: Fixtures.usageAt(Fixtures.SETTLE_MS) })) | |
| 154 | + | |
| 155 | + Fixtures.oldFiles( | |
| 156 | + on, | |
| 157 | + Array.from( | |
| 158 | + { length: Fixtures.MANY_FILE_COUNT }, | |
| 159 | + (_, at) => `file${at}.ts`, | |
| 160 | + ), | |
| 161 | + ) | |
| 162 | + | |
| 163 | + await $.session.start(Fixtures.SESSION) | |
| 164 | + await $.command.run(Fixtures.DIALOG_DIFF) | |
| 165 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 166 | + await $.ui.render(Fixtures.INLINE_PANE) | |
| 167 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 168 | + await $.ui.focus(Fixtures.ringOnto('file:file3.ts')) | |
| 169 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 170 | + await $.ui.render(Fixtures.INLINE_PANE) | |
| 171 | + await $.ui.press({ plugin: 'diff', key: 'file:file5.ts' }) | |
| 172 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 173 | + | |
| 174 | + expect( | |
| 175 | + world.runs | |
| 176 | + .filter(run => run.argv.includes('--raw')) | |
| 177 | + .map(run => run.argv.slice(run.argv.indexOf('--') + 1).join(' ')), | |
| 178 | + 'the first five, the one the walk brought in, the two the press did', | |
| 179 | + ).toEqual([ | |
| 180 | + 'file0.ts file1.ts file2.ts file3.ts file4.ts', | |
| 181 | + 'file5.ts', | |
| 182 | + 'file6.ts file7.ts', | |
| 183 | + ]) | |
| 184 | + }) | |
| 185 | + | |
| 95 | 186 | test('docked, a wheel tick moves the body, not the list', async ($, on) => { |
| 96 | 187 | const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) |
| 97 | 188 |