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

Merge pull request #94184 from anthropics/poteat/diff-parity-2Browse files
mods/diff: pinned header with body-only scroll, the built-in's list and base chords, wheel routing, and the DiffDialog off fullscreen
Alice T'Poteat authored on Sep 14, 2026 · GitHub committed on Sep 14, 2026 parents 18be13b + 7d4f947 · commit f4ceeecabff1
298 changed files+2312 -857compared with 18be13b
Mmods/diff/README.md
Amods/diff/hooks/drawn-files-of/drawn-files-of.ts
Amods/diff/hooks/drawn-files-of/index.ts
Amods/diff/hooks/git/display-path-of/display-path-of.ts
Amods/diff/hooks/git/display-path-of/index.ts
Mmods/diff/hooks/git/fetch-file-hunks/fetch-file-hunks.ts
Mmods/diff/hooks/git/fetch-file-hunks/index.ts
Amods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/index.ts
Amods/diff/hooks/git/fetch-file-hunks/is-last-of-diff/is-last-of-diff.ts
Amods/diff/hooks/git/fetch-file-hunks/with-closing-line/index.ts
Amods/diff/hooks/git/fetch-file-hunks/with-closing-line/with-closing-line.ts
Mmods/diff/hooks/git/git-backend-of.ts
Amods/diff/hooks/git/hunk-after.ts
Mmods/diff/hooks/git/index.ts
Amods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts
Amods/diff/hooks/git/probes/dirty-paths-of/index.ts
Amods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/index.ts
Amods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/status-path-offset.ts
Mmods/diff/hooks/git/probes/index.ts
Mmods/diff/hooks/git/probes/tag-pre-session.ts
Mmods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts
Amods/diff/hooks/git/probes/was-dirty-at-start/index.ts
Amods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts
Amods/diff/hooks/git/rename-display-of/index.ts
Amods/diff/hooks/git/rename-display-of/rename-display-of.ts
Mmods/diff/hooks/git/types/dating-context/dating-context.ts
Mmods/diff/hooks/git/types/git-deps/git-deps.ts
Mmods/diff/hooks/host/host.ts
Mmods/diff/hooks/index.ts
Amods/diff/hooks/kept-of/index.ts
Amods/diff/hooks/kept-of/kept-of.ts
Amods/diff/hooks/limits/columns/dialog-path-reserve.ts
Mmods/diff/hooks/limits/columns/index.ts
Amods/diff/hooks/limits/columns/path-floor.ts
Amods/diff/hooks/limits/columns/stat-cells.ts
Amods/diff/hooks/limits/dialog-path-floor.ts
Mmods/diff/hooks/limits/index.ts
Mmods/diff/hooks/limits/sizes/index.ts
Amods/diff/hooks/limits/sizes/max-visible-files.ts
Amods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts
Amods/diff/hooks/limits/sizes/pane/gutter-chrome.ts
Amods/diff/hooks/limits/sizes/pane/index.ts
Amods/diff/hooks/limits/sizes/pane/max-summary-rows.ts
Amods/diff/hooks/limits/sizes/pane/pane-top-pad-rows.ts
Amods/diff/hooks/limits/sizes/pane/pre-session-body-cap.ts
Amods/diff/hooks/limits/sizes/pane/scroll-margin-rows.ts
Amods/diff/hooks/limits/sizes/pane/wheel-rows.ts
Dmods/diff/hooks/limits/sizes/pane-top-pad-rows.ts
Mmods/diff/hooks/limits/timing/index.ts
Dmods/diff/hooks/limits/timing/open-probe-ms.ts
Amods/diff/hooks/limits/todo-bar-cells.ts
Amods/diff/hooks/limits/todo-bar-reserve.ts
Amods/diff/hooks/map-limited/index.ts
Amods/diff/hooks/map-limited/map-limited.ts
Dmods/diff/hooks/names/focused-pane/focused-pane.ts
Dmods/diff/hooks/names/focused-pane/index.ts
Mmods/diff/hooks/names/index.ts
Amods/diff/hooks/names/plugin-name.ts
Mmods/diff/hooks/names/texts/index.ts
Amods/diff/hooks/names/texts/more-below-text.ts
Amods/diff/hooks/names/texts/panel-toggle/dialog-dismissed-text.ts
Amods/diff/hooks/names/texts/panel-toggle/index.ts
Amods/diff/hooks/names/texts/panel-toggle/panel-hidden-text.ts
Amods/diff/hooks/names/texts/panel-toggle/panel-shown-text.ts
Mmods/diff/hooks/names/texts/untracked-withheld-text-of.ts
Mmods/diff/hooks/pane-state/after-new-session/new-session-fields/new-session-fields.ts
Dmods/diff/hooks/pane-state/base-label-of.ts
Amods/diff/hooks/pane-state/bodies/bodies.ts
Amods/diff/hooks/pane-state/bodies/index.ts
Dmods/diff/hooks/pane-state/body-state/body-state.ts
Dmods/diff/hooks/pane-state/body-state/index.ts
Amods/diff/hooks/pane-state/dialog-focus/dialog-focus.ts
Amods/diff/hooks/pane-state/dialog-focus/index.ts
Amods/diff/hooks/pane-state/dialog-title/dialog-title.ts
Amods/diff/hooks/pane-state/dialog-title/index.ts
Amods/diff/hooks/pane-state/dialog-title-of.ts
Mmods/diff/hooks/pane-state/empty-state-of.ts
Mmods/diff/hooks/pane-state/index.ts
Mmods/diff/hooks/pane-state/initial-model/initial-model.ts
Dmods/diff/hooks/pane-state/listed-of.ts
Mmods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts
Amods/diff/hooks/pane-state/no-bodies/index.ts
Amods/diff/hooks/pane-state/no-bodies/no-bodies.ts
Mmods/diff/hooks/pane-state/pane-model/pane-model.ts
Mmods/diff/hooks/pane-state/partition-of.ts
Dmods/diff/hooks/pane-state/selection-of/index.ts
Dmods/diff/hooks/pane-state/selection-of/selection-of.ts
Amods/diff/hooks/pane-state/turn-title-of/index.ts
Amods/diff/hooks/pane-state/turn-title-of/turn-title-of.ts
Amods/diff/hooks/pane-state/unborn-note-of.ts
Mmods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts
Mmods/diff/hooks/pane-toggle/pane-toggle-of.ts
Mmods/diff/hooks/register.ts
Amods/diff/hooks/views/body/body-scrolled-by.ts
Amods/diff/hooks/views/body/draw-window.tsx
Amods/diff/hooks/views/body/index.ts
Amods/diff/hooks/views/body/is-wheel-over-list.ts
Amods/diff/hooks/views/body/list-block-of.tsx
Amods/diff/hooks/views/body/list-scrolled-by.ts
Amods/diff/hooks/views/body/place-at-file.ts
Amods/diff/hooks/views/body/plan/body-layout-of.ts
Amods/diff/hooks/views/body/plan/dock-plan-of.ts
Amods/diff/hooks/views/body/plan/hunk-window-of.ts
Amods/diff/hooks/views/body/plan/index.ts
Amods/diff/hooks/views/body/plan/segments/index.ts
Amods/diff/hooks/views/body/plan/segments/list-window-of.ts
Amods/diff/hooks/views/body/plan/segments/segment-rows-of.ts
Amods/diff/hooks/views/body/plan/segments/segments-of.ts
Amods/diff/hooks/views/body/types/body-layout.ts
Amods/diff/hooks/views/body/types/dock-plan.ts
Amods/diff/hooks/views/body/types/index.ts
Amods/diff/hooks/views/body/types/list-window.ts
Amods/diff/hooks/views/body/types/segment/hunk-segment/hunk-segment.ts
Amods/diff/hooks/views/body/types/segment/hunk-segment/index.ts
Amods/diff/hooks/views/body/types/segment/index.ts
Amods/diff/hooks/views/body/types/segment/segment.ts
Amods/diff/hooks/views/body/types/window-cut/index.ts
Amods/diff/hooks/views/body/types/window-cut/window-cut.ts
Dmods/diff/hooks/views/current-pane/current-pane.tsx
Dmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts
Dmods/diff/hooks/views/current-pane/file-row-of/index.ts
Dmods/diff/hooks/views/current-pane/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-body-of.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/index.ts
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/list-place.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/foot-rows.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/index.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/message-pane.ts
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx
Amods/diff/hooks/views/detail/body-budget/body-budget.ts
Amods/diff/hooks/views/detail/body-budget/index.ts
Mmods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts
Mmods/diff/hooks/views/detail/detail-model/detail-model.ts
Mmods/diff/hooks/views/detail/detail-view.tsx
Amods/diff/hooks/views/detail/file-frame-nodes/file-frame-nodes.ts
Amods/diff/hooks/views/detail/file-frame-nodes/index.ts
Dmods/diff/hooks/views/detail/hunk-divider/hunk-divider.ts
Dmods/diff/hooks/views/detail/hunk-divider/index.ts
Dmods/diff/hooks/views/detail/hunk-divider-nodes/hunk-divider-nodes.ts
Dmods/diff/hooks/views/detail/hunk-divider-nodes/index.ts
Mmods/diff/hooks/views/detail/index.ts
Mmods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts
Mmods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts
Mmods/diff/hooks/views/detail/placeholder-of/placeholder-of.ts
Mmods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts
Amods/diff/hooks/views/detail/types/body-room/body-room.ts
Amods/diff/hooks/views/detail/types/body-room/index.ts
Dmods/diff/hooks/views/detail/types/code-block/code-block.ts
Dmods/diff/hooks/views/detail/types/code-block/index.ts
Mmods/diff/hooks/views/detail/types/code-body/code-body.ts
Amods/diff/hooks/views/detail/types/drawn-detail/drawn-detail.ts
Amods/diff/hooks/views/detail/types/drawn-detail/index.ts
Mmods/diff/hooks/views/detail/types/index.ts
Amods/diff/hooks/views/detail/types/named-detail/index.ts
Amods/diff/hooks/views/detail/types/named-detail/named-detail.ts
Amods/diff/hooks/views/dialog-pane/dialog-entries-of/dialog-entries-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-entries-of/index.ts
Amods/diff/hooks/views/dialog-pane/dialog-focus-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-pane.tsx
Amods/diff/hooks/views/dialog-pane/dialog-rows-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts
Amods/diff/hooks/views/dialog-pane/dialog-window-of/index.ts
Amods/diff/hooks/views/dialog-pane/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-body-of.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-place/index.ts
Amods/diff/hooks/views/dialog-pane/list-body-of/list-place/list-place.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/foot-rows.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/index.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/message-pane.ts
Amods/diff/hooks/views/dialog-pane/message-pane-of/message-pane-of.tsx
Amods/diff/hooks/views/entries/body-entry/body-entry.ts
Amods/diff/hooks/views/entries/body-entry/index.ts
Amods/diff/hooks/views/entries/body-entry-of/body-entry-of.ts
Amods/diff/hooks/views/entries/body-entry-of/index.ts
Amods/diff/hooks/views/entries/detail-model-of/detail-model-of.ts
Amods/diff/hooks/views/entries/detail-model-of/index.ts
Amods/diff/hooks/views/entries/index.ts
Amods/diff/hooks/views/entries/turn-entry-of/index.ts
Amods/diff/hooks/views/entries/turn-entry-of/turn-entry-of.ts
Mmods/diff/hooks/views/index.ts
Mmods/diff/hooks/views/layout/index.ts
Amods/diff/hooks/views/layout/pad-end/index.ts
Amods/diff/hooks/views/layout/pad-end/pad-end.ts
Mmods/diff/hooks/views/pane-actions/pane-actions.ts
Amods/diff/hooks/views/pane-seat/index.ts
Amods/diff/hooks/views/pane-seat/pane-seat.ts
Mmods/diff/hooks/views/pane-view.tsx
Amods/diff/hooks/views/prelude-of/index.ts
Amods/diff/hooks/views/prelude-of/prelude-of.tsx
Amods/diff/hooks/views/sections/ask-key-of/ask-key-of.ts
Amods/diff/hooks/views/sections/ask-key-of/index.ts
Dmods/diff/hooks/views/sections/close-button/close-button.tsx
Dmods/diff/hooks/views/sections/close-button/index.ts
Dmods/diff/hooks/views/sections/controls-view.tsx
Amods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx
Amods/diff/hooks/views/sections/dialog-file-row/index.ts
Mmods/diff/hooks/views/sections/file-key-of/file-key-of.ts
Amods/diff/hooks/views/sections/file-row/file-row.ts
Dmods/diff/hooks/views/sections/file-row/file-row.tsx
Mmods/diff/hooks/views/sections/file-row/index.ts
Mmods/diff/hooks/views/sections/file-row-model/file-row-model.ts
Mmods/diff/hooks/views/sections/header-view.tsx
Mmods/diff/hooks/views/sections/index.ts
Amods/diff/hooks/views/sections/keyed-pressable/index.ts
Amods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts
Amods/diff/hooks/views/sections/legend-row.tsx
Amods/diff/hooks/views/sections/list-edge-button/index.ts
Amods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx
Amods/diff/hooks/views/sections/list-row/index.ts
Amods/diff/hooks/views/sections/list-row/list-row.tsx
Amods/diff/hooks/views/sections/name-row/index.ts
Amods/diff/hooks/views/sections/name-row/name-row.tsx
Mmods/diff/hooks/views/sections/present/present.ts
Amods/diff/hooks/views/sections/source-picker.tsx
Dmods/diff/hooks/views/sections/stat-cells/index.ts
Dmods/diff/hooks/views/sections/stat-cells/stat-cells.ts
Amods/diff/hooks/views/sections/stat-text-of/index.ts
Amods/diff/hooks/views/sections/stat-text-of/stat-text-of.ts
Amods/diff/hooks/views/sections/title-row.tsx
Dmods/diff/hooks/views/sections/todo-bar-cells/index.ts
Dmods/diff/hooks/views/sections/todo-bar-cells/todo-bar-cells.ts
Dmods/diff/hooks/views/sections/todo-bar-reserve/index.ts
Dmods/diff/hooks/views/sections/todo-bar-reserve/todo-bar-reserve.ts
Mmods/diff/hooks/views/sections/todo-bar.tsx
Mmods/diff/hooks/views/sections/toggle-row.tsx
Amods/diff/hooks/views/sidebar-pane/index.ts
Amods/diff/hooks/views/sidebar-pane/sidebar-pane.tsx
Dmods/diff/hooks/views/turn-pane/index.ts
Dmods/diff/hooks/views/turn-pane/turn-pane.tsx
Mmods/diff/tests/backend/backend-of.test.ts
Amods/diff/tests/fixtures/answers-of.ts
Dmods/diff/tests/fixtures/base-label-of/at-sha.ts
Dmods/diff/tests/fixtures/base-label-of/index.ts
Dmods/diff/tests/fixtures/base-label-of/one-file.ts
Dmods/diff/tests/fixtures/base-label-of/working-tree.ts
Mmods/diff/tests/fixtures/clear.ts
Mmods/diff/tests/fixtures/code-blocks-of/is-within-code-cap.ts
Mmods/diff/tests/fixtures/code-blocks-of/parsed-hunks-of.ts
Amods/diff/tests/fixtures/dialog-diff.ts
Amods/diff/tests/fixtures/diff-at.ts
Mmods/diff/tests/fixtures/diff.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/mode-label-of/at-sha.ts
Amods/diff/tests/fixtures/mode-label-of/index.ts
Amods/diff/tests/fixtures/mode-label-of/one-file.ts
Amods/diff/tests/fixtures/mode-label-of/working-tree.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/pane-view/big-body-of.ts
Amods/diff/tests/fixtures/pane-view/bodies-of.ts
Mmods/diff/tests/fixtures/pane-view/config-detail-of.ts
Amods/diff/tests/fixtures/pane-view/dock-seat.ts
Mmods/diff/tests/fixtures/pane-view/docks-pane.ts
Mmods/diff/tests/fixtures/pane-view/index.ts
Amods/diff/tests/fixtures/pane-view/narrow-seat.ts
Mmods/diff/tests/fixtures/pane-view/noop-actions-of.ts
Amods/diff/tests/fixtures/pane-view/whole-place.ts
Amods/diff/tests/fixtures/renamed.ts
Amods/diff/tests/fixtures/ring-onto.ts
Mmods/diff/tests/fixtures/settle-ms.ts
Mmods/diff/tests/fixtures/starts-session.ts
Mmods/diff/tests/fixtures/tree/text-of.ts
Amods/diff/tests/fixtures/two-files.ts
Amods/diff/tests/fixtures/wheel-over-list.ts
Amods/diff/tests/fixtures/wheel-tick.ts
Mmods/diff/tests/git/fetch-diff.test.ts
Mmods/diff/tests/git.test.ts
Dmods/diff/tests/pane-state/base-label-of.test.ts
Mmods/diff/tests/pane-state/empty-state-of.test.ts
Amods/diff/tests/pane-state/mode-label-of.test.ts
Mmods/diff/tests/pane-toggle/pane-toggle-of.test.ts
Mmods/diff/tests/register.test.ts
Mmods/diff/tests/views/detail/code-blocks-of.test.ts
Mmods/diff/tests/views/detail/detail-view.test.ts
Mmods/diff/tests/views/pane-view.test.ts
Amods/diff/tests/views.test.ts
Amods/sec-default/tests/fixtures/fullscreen.ts
Mmods/sec-default/tests/fixtures/index.ts
Mmods/sec-default/tests/fixtures/policy-command.ts
Mmods/sec-default/tests/fixtures/tools-command.ts
Mmods/sec-default/tests/register.test.ts
Amods/telemetry/tests/fixtures/fullscreen.ts
Mmods/telemetry/tests/fixtures/index.ts
Mmods/telemetry/tests/fixtures/mark.ts
Mmods/telemetry/tests/fixtures/record.ts
Mmods/telemetry/tests/register.test.ts
Mmods/types/claude-code.d.ts
@@ -1,26 +1,45 @@
11 # diff
22
33 The diff pane as a plugin: `/diff` opens the session's uncommitted changes
4-beside the transcript, one row per changed file and the selected file's
5-hunks beneath, and closes it again. The pane refreshes as Claude edits,
6-runs shell commands and finishes turns, and while it is open it polls the
7-repository's HEAD so a commit or checkout made elsewhere shows too. The
8-first successful edit of a session opens the pane by itself where the
9-terminal is wide enough (144 columns when the person never chose, 110 when
10-they kept it open before; a person who closed it is left alone). On a
11-terminal under 110 columns `/diff` answers with the built-in's line asking
12-for a wider terminal and opens nothing; a pane opened wider that the surface
13-then seats inline shows only that line. A file's
14-ask button arms that file: its hunks ride the next prompt as context, once.
4+beside the transcript, one row per changed file and every file's hunks
5+beneath, and closes it again; each toggle leaves `Diff panel shown` or
6+`Diff panel hidden` in the transcript. The header, the file list and its
7+toggles stay put while the wheel moves the hunks under them three rows a
8+tick, or the list a file a tick while the wheel is over a list longer than
9+its eight rows (the plugin answers the pane's `ui.scroll` itself); a row's
10+click puts that file's hunks at the top; the list also scrolls under the
11+built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
12+`ctrl+x b` moves the comparison base on, as the built-in's chord does: both
13+through Buttons that declare the engine's own actions. The pane refreshes
14+as Claude edits, runs shell commands and finishes turns, and while it is
15+open it polls the repository's HEAD so a commit or checkout made elsewhere
16+shows too. The first successful edit of a session opens the pane by itself
17+where the terminal is wide enough (144 columns when the person never chose,
18+110 when they kept it open before; a person who closed it is left alone).
1519
20+Under the fullscreen layout a terminal under 110 columns gets the
21+built-in's line asking for a wider one and nothing opens. Without that
22+layout (`CLAUDE_CODE_NO_FLICKER=0`, which `/diff` learns from the command's
23+`presentation`) the pane opens inline at any width, focused and as tall
24+as its content (the open's `rows`), in the built-in dialog's shape: the
25+title, the count, five file rows at a time round the selected one (`❯`,
26+where the focus ring starts; the plugin follows the ring's walk through
27+`ui.focus` and re-centres the rows as the built-in does), the key hints;
28+Enter shows that file's hunks alone,
29+Escape backs out to the list and then closes, leaving `Diff dialog
30+dismissed`; toasts are held while it is up. A file's ask button arms that
31+file: its hunks ride the next prompt as context, once.
32+
1633 The pane compares the working tree against HEAD, split at the session's
1734 start (the default), against HEAD plainly, or against the merge-base with
18-the default branch; the choice is kept per repository in the plugin's
19-store. A second picker shows one earlier turn's edits instead of the
20-working tree, read from the session's messages. Files that changed before
21-the session started, and noise (lockfiles, generated and test files), are
22-listed apart and folded until asked for. Outside a git repository `/diff`
23-says so and does nothing else.
35+the default branch; the base line under the header names a base other than
36+the session's, and the choice is kept per repository in the plugin's store.
37+A picker shows one earlier turn's edits instead of the working tree, read
38+from the session's messages. Files that changed before the session started
39+(by their timestamp, among the paths already dirty at the start), and noise
40+(lockfiles, generated and test files), are listed apart and folded until
41+asked for; a rename lists as git prints it. Outside a git repository
42+`/diff` says so and does nothing else.
2443
2544 `hooks/register.ts` is the module; everything under `hooks/` is its parts.
2645
@@ -30,8 +49,11 @@
3049 | --- | --- |
3150 | `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. |
3251 | `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. |
33-| `ui.render` of `Pane` | Draws the pane: header, the file list, the toggles, the base and source pickers, and the selected file's hunks. |
34-| `command.run` of `diff` | Opens or closes the pane and remembers the choice. |
52+| `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
53+| `command.run` of `diff` | Opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
54+| `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. |
55+| `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
56+| `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
3557 | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. |
3658 | `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. |
3759 | `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. |
@@ -40,8 +62,8 @@
4062
4163 ## What it calls on `$`
4264
43-`clock.after`, `clock.every`, `clock.now`, `clock.sleep`, `command.register`,
44-`fs.list`, `fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
65+`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
66+`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
4567 `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
4668 `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
4769
@@ -1,0 +1,31 @@
1+import type Git from '../git'
2+import Limits from '../limits'
3+import PaneState from '../pane-state'
4+
5+/**
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.
8+ *
9+ * Past PRE_SESSION_BODY_CAP pre-session files only their legend shows, so
10+ * none of their bodies is wanted.
11+ *
12+ * @param model the pane's state: its fetch and its two toggles
13+ * @returns the rows, none without a fetch
14+ */
15+export function drawnFilesOf(
16+ model: Pick<
17+ PaneState.PaneModel,
18+ 'data' | 'isNoiseShown' | 'isPreSessionShown'
19+ >,
20+): readonly Git.FileStat[] {
21+ const partition = PaneState.partitionOf(
22+ model.data?.files ?? [],
23+ model.isNoiseShown ? 'shown' : 'hidden',
24+ )
25+
26+ const isBodied =
27+ model.isPreSessionShown &&
28+ partition.preSession.length <= Limits.PRE_SESSION_BODY_CAP
29+
30+ return [...partition.shown, ...(isBodied ? partition.preSession : [])]
31+}
@@ -1,0 +1,3 @@
1+export * from './drawn-files-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+import { renameDisplayOf } from '../rename-display-of'
2+import type Types from '../types'
3+
4+/**
5+ * A row's name as the built-in lists it: the path, or for a rename the one
6+ * line `git diff --stat` prints for it (renameDisplayOf).
7+ *
8+ * @param file the row
9+ * @returns the name to draw and to sort by
10+ */
11+export function displayPathOf(
12+ file: Pick<Types.FileStat, 'path' | 'renamedFrom'>,
13+): string {
14+ const { renamedFrom, path } = file
15+
16+ return renamedFrom === null ? path : renameDisplayOf(renamedFrom, path)
17+}
@@ -1,0 +1,3 @@
1+export * from './display-path-of.js'
2+
3+export * as default from '.'
@@ -2,14 +2,16 @@
22 import { EMPTY_FILE_HUNKS } from '../empty-file-hunks'
33 import Parse from '../parse'
44 import type Types from '../types'
5+import { isLastOfDiff } from './is-last-of-diff'
6+import { withClosingLine } from './with-closing-line'
57
68 /**
79 * One file's hunks against the base its row was read against, so body and
810 * counts agree, by literal pathspec, as the built-in panel reads hunks.
911 *
10- * A rename passes both its paths so git pairs them; an untracked or binary
11- * row, or on an unborn HEAD a file edited after staging, has no honest
12- * body and answers no hunks.
12+ * An untracked, binary or renamed row (the built-in shows a rename's
13+ * counts, never its body), or on an unborn HEAD a file edited after
14+ * staging, answers no hunks; each file but the last, a closing empty row.
1315 *
1416 * @param run runs git against the pinned repository
1517 * @param data the fetch the row belongs to
@@ -22,7 +24,10 @@
2224 file: Types.FileStat,
2325 ): Promise<Types.FileHunks | null> {
2426 const hasNoBody =
25- file.isUntracked || file.isBinary || data.stalePaths.includes(file.path)
27+ file.isUntracked ||
28+ file.isBinary ||
29+ file.renamedFrom !== null ||
30+ data.stalePaths.includes(file.path)
2631
2732 if (hasNoBody) {
2833 return EMPTY_FILE_HUNKS
@@ -33,9 +38,14 @@
3338 ...Argv.DIFF_LEADING_ARGS,
3439 data.baseRef,
3540 '--',
36- ...(file.renamedFrom === null ? [] : [file.renamedFrom]),
3741 file.path,
3842 ])
3943
40- return exitCode === 0 ? Parse.parseFileDiff(stdout) : null
44+ if (exitCode !== 0) {
45+ return null
46+ }
47+
48+ const body = Parse.parseFileDiff(stdout)
49+
50+ return isLastOfDiff(data, file) ? body : withClosingLine(body)
4151 }
@@ -1,3 +1,5 @@
11 export * from './fetch-file-hunks.js'
2+export * from './is-last-of-diff'
3+export * from './with-closing-line'
24
35 export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './is-last-of-diff.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type Types from '../../types'
2+
3+/**
4+ * Whether a row is the last file of the whole diff git printed for its
5+ * fetch: the last tracked row, the untracked ones being listed apart.
6+ *
7+ * The built-in reads that diff in one piece and trims its end, so the last
8+ * file alone has no closing empty line to draw (withClosingLine).
9+ *
10+ * @param data the fetch the row belongs to
11+ * @param file the row
12+ * @returns whether git printed this file last
13+ */
14+export const isLastOfDiff = (
15+ data: Types.DiffData,
16+ file: Types.FileStat,
17+): boolean =>
18+ data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path
@@ -1,0 +1,3 @@
1+export * from './with-closing-line.js'
2+
3+export * as default from '.'
@@ -1,0 +1,27 @@
1+import type Types from '../../types'
2+
3+/**
4+ * A parsed body with one empty context row after its last hunk's lines, as
5+ * the built-in panel draws the empty line that closes git's output.
6+ *
7+ * The built-in reads every file's diff in one piece: the newline before the
8+ * next file's header parses as a context line of the file before it. A
9+ * body with no hunk, or one already cut, gains nothing.
10+ *
11+ * @param body the parsed body
12+ * @returns the body, its last hunk one row longer
13+ */
14+export function withClosingLine(body: Types.FileHunks): Types.FileHunks {
15+ const last = body.hunks.at(-1)
16+ const isClosed = last !== undefined && !body.isTruncated
17+
18+ return isClosed
19+ ? {
20+ ...body,
21+ hunks: [
22+ ...body.hunks.slice(0, -1),
23+ { ...last, lines: [...last.lines, ' '] },
24+ ],
25+ }
26+ : body
27+}
@@ -14,7 +14,8 @@
1414 * one `rev-parse` in that directory, then every child pinned to it.
1515 *
1616 * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under
17- * the C locale and the fetch's timeout (the built-in's execPinnedGit).
17+ * the C locale and the fetch's timeout (the built-in's execPinnedGit); the
18+ * paths dirty at the start are read once, first (Probes.dirtyPathsOf).
1819 *
1920 * @param host the bound host's runner and probes
2021 * @returns the backend, or null outside a git working tree
@@ -46,6 +47,7 @@
4647 }
4748
4849 const run = runOf(repository)
50+ const baseline = await Probes.dirtyPathsOf(run)
4951
5052 const depsOf = (): Types.GitDeps => ({
5153 run,
@@ -53,6 +55,7 @@
5355 mtimeOf: host.mtimeOf,
5456 entryKindsOf: host.entryKindsOf,
5557 sessionStartMs: host.sessionStartMsOf(),
58+ baseline,
5659 onBranchBase: host.onBranchBase,
5760 })
5861
@@ -1,0 +1,20 @@
1+import { countOf } from '../count-of'
2+import type { Hunk } from './types'
3+
4+/**
5+ * The hunk less its first so many lines, its starts moved past them so
6+ * the numbers of the lines kept stay right; whole at 0.
7+ *
8+ * @param hunk the hunk, its lines body lines only
9+ * @param count how many leading lines to drop
10+ * @returns the rest of the hunk
11+ */
12+export function hunkAfter(hunk: Hunk, count: number): Hunk {
13+ const dropped = hunk.lines.slice(0, count)
14+
15+ return {
16+ oldStart: hunk.oldStart + countOf(dropped, line => !line.startsWith('+')),
17+ newStart: hunk.newStart + countOf(dropped, line => !line.startsWith('-')),
18+ lines: hunk.lines.slice(count),
19+ }
20+}
@@ -1,5 +1,6 @@
11 export * from './argv'
22 export * from './cut-hunks'
3+export * from './display-path-of'
34 export * from './empty-file-hunks'
45 export * from './failed-run'
56 export * from './fetch-diff'
@@ -7,10 +8,12 @@
78 export * from './git-backend-of.js'
89 export * from './git-base-modes'
910 export * from './git-words'
11+export * from './hunk-after.js'
1012 export * from './hunk-of'
1113 export * from './large-file-hunks'
1214 export * from './parse'
1315 export * from './probes'
16+export * from './rename-display-of'
1417 export * from './tiers'
1518 export * from './types'
1619
@@ -1,0 +1,35 @@
1+import Argv from '../../argv'
2+import Parse from '../../parse'
3+import type Types from '../../types'
4+import { STATUS_PATH_OFFSET } from './status-path-offset'
5+
6+/**
7+ * Every path dirty in the working tree right now, tracked changes and
8+ * untracked files alike, as `git status -z` lists them; null unread.
9+ *
10+ * Read once when the backend is pinned, so a fetch can tell a path that
11+ * was already dirty when the session began from one that turned up since
12+ * (GitDeps `baseline`). Renames are not paired: each side is its own path.
13+ *
14+ * @param run runs git against the pinned repository
15+ * @returns the paths, root-relative, or null on a failed or cut listing
16+ */
17+export async function dirtyPathsOf(
18+ run: Types.GitRun,
19+): Promise<ReadonlySet<string> | null> {
20+ const listing = await run([
21+ Argv.NO_OPTIONAL_LOCKS,
22+ 'status',
23+ '--porcelain',
24+ '-z',
25+ '--untracked-files=all',
26+ '--no-renames',
27+ '--ignore-submodules=dirty',
28+ ])
29+
30+ const records = listing.stdout.split('\0').filter(record => record !== '')
31+
32+ return Parse.isWholeAnswer(listing)
33+ ? new Set(records.map(record => record.slice(STATUS_PATH_OFFSET)))
34+ : null
35+}
@@ -1,0 +1,4 @@
1+export * from './dirty-paths-of.js'
2+export * from './status-path-offset'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './status-path-offset.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * Where the path starts in a `status --porcelain -z` record: after the two
3+ * status letters and a space.
4+ */
5+export const STATUS_PATH_OFFSET = 3
@@ -3,6 +3,7 @@
33 export * from './current-branch-of'
44 export * from './datings-of'
55 export * from './default-branch-of'
6+export * from './dirty-paths-of'
67 export * from './head-key-of'
78 export * from './is-absolute-path'
89 export * from './is-safe-ref-name'
@@ -24,5 +25,6 @@
2425 export * from './untracked-files'
2526 export * from './untracked-place'
2627 export * from './untracked-rows-of'
28+export * from './was-dirty-at-start'
2729
2830 export * as default from '.'
@@ -1,14 +1,16 @@
11 import type Types from '../types'
22 import { datingsOf } from './datings-of'
3+import { wasDirtyAtStart } from './was-dirty-at-start'
34
45 /**
56 * The tracked rows, `isPreSession` set on each whose file was modified
6- * before the session began, by mtime alone as the built-in panel tags them.
7+ * before the session began and was already dirty then (the built-in's tag).
78 *
89 * A file that cannot be dated (deleted this session, a symbolic link, past
9- * the listing budget) stays session work, so nothing real is hidden.
10+ * the listing budget), or that turned up since the start (wasDirtyAtStart),
11+ * stays session work, so nothing real is hidden.
1012 *
11- * @param context the fetch: its stamp probe and the session's start
13+ * @param context the fetch: its stamp probe, the session's start, its baseline
1214 * @param files the tracked rows
1315 * @returns the rows, tagged
1416 */
@@ -23,6 +25,8 @@
2325
2426 return files.map(file => ({
2527 ...file,
26- isPreSession: datings.get(file.path) === 'pre-session',
28+ isPreSession:
29+ datings.get(file.path) === 'pre-session' &&
30+ wasDirtyAtStart(context, file.path),
2731 }))
2832 }
@@ -2,14 +2,15 @@
22 import type Types from '../../types'
33 import { datingsOf } from '../datings-of'
44 import type { UntrackedPlace } from '../untracked-place'
5+import { wasDirtyAtStart } from '../was-dirty-at-start'
56
67 /**
78 * Listed untracked paths as 0/0 rows: the session's own first, then,
89 * when the scope asks, the tagged pre-session ones, up to the slots.
910 *
1011 * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the
11- * listing budget reads as pre-session, an undatable one as the session's
12- * (the built-in panel's rule).
12+ * listing budget reads as pre-session, an undatable one or one that turned
13+ * up since the start (wasDirtyAtStart) as the session's (the built-in's).
1314 *
1415 * @param context the fetch's stamp probe and session start
1516 * @param paths the untracked paths, root-relative, as the lister printed them
@@ -24,9 +25,13 @@
2425 const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES)
2526 const datings = await datingsOf(context, probedPaths)
2627
28+ const isPreSessionOf = (path: string, dating: Types.Dating | undefined) =>
29+ dating === 'unlisted' ||
30+ (dating === 'pre-session' && wasDirtyAtStart(context, path))
31+
2732 const probed = probedPaths.map(path => ({
2833 path,
29- isPreSession: datings.get(path) !== 'session',
34+ isPreSession: isPreSessionOf(path, datings.get(path)),
3035 }))
3136
3237 const isWithPreSession = place.scope === 'with-pre-session'
@@ -1,0 +1,3 @@
1+export * from './was-dirty-at-start.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type Types from '../../types'
2+
3+/**
4+ * Whether a path may predate the session at all: it was dirty when the
5+ * session began, or nothing says otherwise (no baseline was read).
6+ *
7+ * A path missing from the baseline turned up since, so its old timestamp
8+ * (a rename keeps the file's, a move keeps the source's) does not date it,
9+ * as the built-in's change-time check does not let it.
10+ *
11+ * @param context the fetch's dating context
12+ * @param path the root-relative path
13+ * @returns false only when a baseline exists and lacks the path
14+ */
15+export const wasDirtyAtStart = (
16+ context: Types.DatingContext,
17+ path: string,
18+): boolean => context.deps.baseline?.has(path) ?? true
@@ -1,0 +1,3 @@
1+export * from './rename-display-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+/**
2+ * A rename as `git diff --stat` prints it: the shared leading and trailing
3+ * directories once, the differing middles in braces, `old => new` between.
4+ *
5+ * `docs/{notes => renamed}`, `{src => lib}/main`, or with nothing shared
6+ * `old => new`; a prefix or suffix only counts up to a slash, as git counts
7+ * it (pprint_rename).
8+ *
9+ * @param oldPath the path before the rename
10+ * @param newPath the path after it
11+ * @returns the one-line rename
12+ */
13+export function renameDisplayOf(oldPath: string, newPath: string): string {
14+ let prefix = 0
15+ let suffix = 0
16+
17+ for (let at = 0; at < oldPath.length && at < newPath.length; at++) {
18+ if (oldPath[at] !== newPath[at]) {
19+ break
20+ }
21+
22+ if (oldPath[at] === '/') {
23+ prefix = at + 1
24+ }
25+ }
26+
27+ const floor = prefix - (prefix > 0 ? 1 : 0)
28+
29+ for (
30+ let back = 1;
31+ oldPath.length - back >= floor && newPath.length - back >= floor;
32+ back++
33+ ) {
34+ const character = oldPath[oldPath.length - back]
35+
36+ if (character !== newPath[newPath.length - back]) {
37+ break
38+ }
39+
40+ if (character === '/') {
41+ suffix = back
42+ }
43+ }
44+
45+ const middleOf = (path: string) =>
46+ path.slice(prefix, Math.max(prefix, path.length - suffix))
47+
48+ const middle = `${middleOf(oldPath)} => ${middleOf(newPath)}`
49+ const isBraced = prefix + suffix > 0
50+ const head = oldPath.slice(0, prefix)
51+ const tail = oldPath.slice(oldPath.length - suffix)
52+
53+ return isBraced ? `${head}{${middle}}${tail}` : middle
54+}
@@ -2,10 +2,10 @@
22 import type { StampProbe } from '../stamp-probe'
33
44 /**
5- * What dating a path against the session takes: the session's start and
6- * one stamp probe over the working tree's top.
5+ * What dating a path against the session takes: the session's start, one
6+ * stamp probe over the tree's top, and when read, the paths dirty then.
77 */
88 export type DatingContext = {
9- deps: Pick<GitDeps, 'sessionStartMs'>
9+ deps: Pick<GitDeps, 'sessionStartMs' | 'baseline'>
1010 stamps: StampProbe
1111 }
@@ -33,6 +33,15 @@
3333 sessionStartMs: number
3434
3535 /**
36+ * The paths dirty when the backend was pinned, at the session's start
37+ * (Probes.dirtyPathsOf); null when unlisted, absent where none is kept.
38+ *
39+ * A path absent from it turned up during the session (a rename's new
40+ * name, a file moved in), whatever timestamp it carries.
41+ */
42+ baseline?: ReadonlySet<string> | null
43+
44+ /**
3645 * Told each time branch mode resolves its base, for telemetry.
3746 */
3847 onBranchBase: (base: BranchBase) => void
@@ -32,11 +32,6 @@
3232 every: TimerCall
3333
3434 /**
35- * `$.clock.sleep`, no signal.
36- */
37- sleep: (ms: number) => Promise<void>
38-
39- /**
4035 * `$.process.run`.
4136 */
4237 run: (
@@ -4,12 +4,15 @@
44 export * from './classify'
55 export * from './command-spec'
66 export * from './count-of'
7+export * from './drawn-files-of'
78 export * from './entry-kinds-of'
89 export * from './git'
910 export * from './host'
1011 export * from './is-on-pane-surface'
1112 export * from './is-record'
13+export * from './kept-of'
1214 export * from './limits'
15+export * from './map-limited'
1316 export * from './message-of'
1417 export * from './mtime-of'
1518 export * from './names'
@@ -1,0 +1,3 @@
1+export * from './kept-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * The items that are there: nulls dropped, so a part may answer null for
3+ * "nothing here" and the whole lists what is left.
4+ *
5+ * @param items values or nulls
6+ * @returns the values, in order
7+ */
8+export const keptOf = <T>(items: readonly (T | null)[]): T[] =>
9+ items.filter((item): item is T => item !== null)
@@ -1,0 +1,5 @@
1+/**
2+ * The cells an inline file row keeps off its path (DiffFileList's 16 for
3+ * the counts, 3 for the pointer, 4 for the frame).
4+ */
5+export const DIALOG_PATH_RESERVE = 23
@@ -1,6 +1,9 @@
11 export * from './auto-open-min-columns.js'
2+export * from './dialog-path-reserve.js'
23 export * from './open-min-columns.js'
34 export * from './pane-right-pad-columns.js'
5+export * from './path-floor.js'
6+export * from './stat-cells.js'
47 export * from './widest-bucket-columns.js'
58
69 export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The fewest cells a docked file row gives its path, however narrow the
3+ * pane (ReplDiffSidebar's 8).
4+ */
5+export const PATH_FLOOR = 8
@@ -1,0 +1,5 @@
1+/**
2+ * The cells a docked file row keeps for its `+N -M` (ReplDiffSidebar's 12):
3+ * the path is cut from its start to the rest.
4+ */
5+export const STAT_CELLS = 12
@@ -1,0 +1,5 @@
1+/**
2+ * The fewest cells an inline file row gives its path, however narrow the
3+ * pane (DiffFileList's 20).
4+ */
5+export const DIALOG_PATH_FLOOR = 20
@@ -1,5 +1,8 @@
11 export * from './columns'
2+export * from './dialog-path-floor.js'
23 export * from './sizes'
34 export * from './timing'
5+export * from './todo-bar-cells.js'
6+export * from './todo-bar-reserve.js'
47
58 export * as default from '.'
@@ -4,7 +4,8 @@
44 export * from './max-lines-per-file.js'
55 export * from './max-listed-directories.js'
66 export * from './max-untracked-probes.js'
7-export * from './pane-top-pad-rows.js'
7+export * from './max-visible-files.js'
8+export * from './pane'
89 export * from './prompt-context-max-chars.js'
910
1011 export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The file rows the dialog lists at once (DiffFileList's MAX_VISIBLE_FILES);
3+ * past them its window moves, `more files` rows counting the rest.
4+ */
5+export const MAX_VISIBLE_FILES = 5
@@ -1,0 +1,5 @@
1+/**
2+ * How many files' hunks are read from the backend at once while the pane
3+ * loads its bodies.
4+ */
5+export const BODY_FETCH_CONCURRENCY = 6
@@ -1,0 +1,5 @@
1+/**
2+ * The cells a diff gutter takes besides its digits: the marker and the
3+ * spaces round the number.
4+ */
5+export const GUTTER_CHROME = 3
@@ -1,0 +1,9 @@
1+export * from './body-fetch-concurrency.js'
2+export * from './gutter-chrome.js'
3+export * from './max-summary-rows.js'
4+export * from './pane-top-pad-rows.js'
5+export * from './pre-session-body-cap.js'
6+export * from './scroll-margin-rows.js'
7+export * from './wheel-rows.js'
8+
9+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How many file rows the docked pane's list shows at once (the built-in
3+ * panel's window); the rest are counted on a dim row under them.
4+ */
5+export const MAX_SUMMARY_ROWS = 8
@@ -1,0 +1,5 @@
1+/**
2+ * The blank row the docked pane keeps above its header, as the built-in
3+ * sidebar's header does (its `paddingY`).
4+ */
5+export const PANE_TOP_PAD_ROWS = 1
@@ -1,0 +1,5 @@
1+/**
2+ * How many pre-session files get their bodies drawn once that section is
3+ * open (the built-in panel's cap); past it only their legend is listed.
4+ */
5+export const PRE_SESSION_BODY_CAP = 20
@@ -1,0 +1,5 @@
1+/**
2+ * The rows the docked window draws past its own height, so a line whose
3+ * wrap was counted short still leaves it full; the pane clips the rest.
4+ */
5+export const SCROLL_MARGIN_ROWS = 8
@@ -1,0 +1,5 @@
1+/**
2+ * The rows one wheel tick moves the docked body, as the built-in panel's
3+ * body moves.
4+ */
5+export const WHEEL_ROWS = 3
Dmods/diff/hooks/limits/sizes/pane-top-pad-rows.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The blank row the docked pane keeps above its header, as the built-in
3- * sidebar's header does (its `paddingY`).
4- */
5-export const PANE_TOP_PAD_ROWS = 1
@@ -1,6 +1,5 @@
11 export * from './git-timeout-ms.js'
22 export * from './head-poll-ms.js'
3-export * from './open-probe-ms.js'
43 export * from './redraw-coalesce-ms.js'
54 export * from './refresh-debounce-ms.js'
65
Dmods/diff/hooks/limits/timing/open-probe-ms.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * How long `/diff` waits for the pane to draw after an invalidate before it
3- * takes the pane as closed by the person and opens it again.
4- */
5-export const OPEN_PROBE_MS = 120
@@ -1,0 +1,4 @@
1+/**
2+ * The widest the todo progress bar draws (ReplDiffSidebar's 20).
3+ */
4+export const TODO_BAR_CELLS = 20
@@ -1,0 +1,4 @@
1+/**
2+ * The cells kept beside the todo bar for its `done/total` count.
3+ */
4+export const TODO_BAR_RESERVE = 12
@@ -1,0 +1,3 @@
1+export * from './map-limited.js'
2+
3+export * as default from '.'
@@ -1,0 +1,30 @@
1+/**
2+ * Maps every item through an async function, a bounded number of them in
3+ * flight at once, and resolves to the results in the items' order.
4+ *
5+ * @param items the inputs
6+ * @param limit how many may run at once, at least one
7+ * @param fn the work for one item
8+ * @returns the results, in order
9+ */
10+export async function mapLimited<T, R>(
11+ items: readonly T[],
12+ limit: number,
13+ fn: (item: T) => Promise<R>,
14+): Promise<R[]> {
15+ const results: R[] = []
16+
17+ let next = 0
18+
19+ async function worker(): Promise<void> {
20+ for (let at = next++; at < items.length; at = next++) {
21+ results[at] = await fn(items[at] as T)
22+ }
23+ }
24+
25+ await Promise.all(
26+ Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker),
27+ )
28+
29+ return results
30+}
Dmods/diff/hooks/names/focused-pane/focused-pane.ts+0 -9
@@ -1,9 +1,0 @@
1-import type { PaneOpenArgs } from 'claude-code'
2-
3-/**
4- * The part of `$.ui.open`'s argument that asks the surface for the
5- * person's keyboard: what `/diff` adds and the first-edit open leaves out.
6- */
7-export const FOCUSED_PANE: Pick<PaneOpenArgs, 'focus'> = Object.freeze({
8- focus: true,
9-})
Dmods/diff/hooks/names/focused-pane/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './focused-pane.js'
2-
3-export * as default from '.'
@@ -1,9 +1,9 @@
11 export * from './command-description.js'
22 export * from './command-name.js'
3-export * from './focused-pane'
43 export * from './keys'
54 export * from './pane-id.js'
65 export * from './pane-title.js'
6+export * from './plugin-name.js'
77 export * from './texts'
88
99 export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The plugin's own name, as the engine stamps it on the elements its render
3+ * hooks draw (`ui.press`, `ui.focus`).
4+ */
5+export const PLUGIN_NAME = 'diff'
@@ -1,6 +1,8 @@
11 export * from './builtin-holds-pattern.js'
22 export * from './git-unanswered-text.js'
3+export * from './more-below-text.js'
34 export * from './not-in-repository-text.js'
5+export * from './panel-toggle'
46 export * from './register-failed-text-of.js'
57 export * from './resize-terminal-text.js'
68 export * from './untracked-withheld-text-of.js'
@@ -1,0 +1,5 @@
1+/**
2+ * What follows a count on the docked list's lower edge row: the words and
3+ * the built-in's advertised list key (its macOS spelling).
4+ */
5+export const MORE_BELOW_TEXT = 'more below (opt+\u2193 to scroll)'
@@ -1,0 +1,4 @@
1+/**
2+ * The transcript line the built-in dialog leaves when the person closes it.
3+ */
4+export const DIALOG_DISMISSED_TEXT = 'Diff dialog dismissed'
@@ -1,0 +1,5 @@
1+export * from './dialog-dismissed-text.js'
2+export * from './panel-hidden-text.js'
3+export * from './panel-shown-text.js'
4+
5+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * `/diff`'s transcript line once it closed the pane, worded as the built-in
3+ * words its own.
4+ */
5+export const PANEL_HIDDEN_TEXT = 'Diff panel hidden'
@@ -1,0 +1,5 @@
1+/**
2+ * `/diff`'s transcript line once it opened the pane, worded as the built-in
3+ * words its own.
4+ */
5+export const PANEL_SHOWN_TEXT = 'Diff panel shown'
@@ -1,14 +1,14 @@
1-import type Backend from '../../backend'
1+import type PaneState from '../../pane-state'
22
33 /**
44 * The pane's word for a fetch whose untracked listing the backend did not
55 * give whole: the tracked rows stand, the new files are not counted.
66 *
7- * @param words the backend's words, naming the program that lists them
7+ * @param model the pane's state, its words naming the program that lists them
88 * @returns the note
99 */
1010 export const untrackedWithheldTextOf = (
11- words: Pick<Backend.BackendWords, 'lister'>,
11+ model: Pick<PaneState.PaneModel, 'words'>,
1212 ) =>
13- `Untracked files unavailable (${words.lister} could not list them); ` +
13+ `Untracked files unavailable (${model.words.lister} could not list them); ` +
1414 'not counted'
@@ -1,11 +1,12 @@
1+import { NO_BODIES } from '../../no-bodies'
2+
13 /**
24 * What `/clear` and `/resume` reset: the pick, the source, the turns and
3- * the body; the fetch itself stays.
5+ * the bodies; the fetch itself stays.
46 */
57 export const NEW_SESSION_FIELDS = Object.freeze({
68 selectedPath: null,
79 source: Object.freeze({ kind: 'current' }),
810 turns: Object.freeze([]),
9- body: null,
10- bodyState: 'idle',
11+ bodies: NO_BODIES,
1112 } as const)
Dmods/diff/hooks/pane-state/base-label-of.ts+0 -44
@@ -1,44 +1,0 @@
1-import { modeLabelOf } from './mode-label-of'
2-import type { PaneModel } from './pane-model'
3-
4-/**
5- * The dim line under the header naming what the diff compares against
6- * (ReplDiffSidebar diffBaseLabel), or null for none.
7- *
8- * Hidden in settled session mode and before any fetch; the requested mode
9- * with an ellipsis while its fetch is pending; on an unborn HEAD with
10- * rows, what the rows are instead.
11- *
12- * @param model the mode the person picked, the last good fetch, and the
13- * backend's words
14- * @param filesCount the header's session file count
15- * @returns the line, or null
16- */
17-export function baseLabelOf(
18- model: Pick<PaneModel, 'requestedMode' | 'data' | 'words'>,
19- filesCount: number,
20-): string | null {
21- const { requestedMode, data } = model
22-
23- if (!data) {
24- return null
25- }
26-
27- if (data.isUnborn) {
28- const hasRows = filesCount > 0
29-
30- return hasRows ? 'no commits yet — showing staged and new files' : null
31- }
32-
33- const isPending = requestedMode !== data.mode
34- const isSettledSession = !isPending && requestedMode === 'session'
35-
36- if (isSettledSession) {
37- return null
38- }
39-
40- const phase = isPending ? 'pending' : 'settled'
41- const label = modeLabelOf(model, data.source, phase)
42-
43- return isPending ? `${label}…` : label
44-}
@@ -1,0 +1,7 @@
1+import type Git from '../../git'
2+
3+/**
4+ * The file bodies the pane has read, by path: the parsed hunks, or null
5+ * where the backend could not read them; a path not in it is still loading.
6+ */
7+export type Bodies = ReadonlyMap<string, Git.FileHunks | null>
@@ -1,0 +1,3 @@
1+export type * from './bodies.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/pane-state/body-state/body-state.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * Where the selected file's body stands: nothing selected, being read, git
3- * could not read it, or in hand.
4- */
5-export type BodyState = 'idle' | 'loading' | 'failed' | 'ready'
Dmods/diff/hooks/pane-state/body-state/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './body-state.js'
2-
3-export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * What the ring landing on a dialog row settles: the file now selected,
3+ * and the key of the row to land the ring on once the window re-centres.
4+ */
5+export type DialogFocus = {
6+ selectedPath: string
7+ landing: string
8+}
@@ -1,0 +1,3 @@
1+export type * from './dialog-focus.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * The inline pane's first line, as the built-in dialog heads itself: what
3+ * the rows are, then in dim what they are compared with.
4+ */
5+export type DialogTitle = {
6+ title: string
7+ subtitle: string
8+}
@@ -1,0 +1,3 @@
1+export type * from './dialog-title.js'
2+
3+export * as default from '.'
@@ -1,0 +1,37 @@
1+import type { DialogTitle } from './dialog-title'
2+import type { PaneModel } from './pane-model'
3+import { pickedTurnOf } from './picked-turn-of'
4+import { turnTitleOf } from './turn-title-of'
5+
6+/**
7+ * The inline pane's heading (DiffDialog's title): the picked turn and its
8+ * prompt's opening words, or what the working tree is compared with.
9+ *
10+ * An unborn HEAD lists staged and new files; a branch diff names its base;
11+ * anything else is the uncommitted changes against HEAD.
12+ *
13+ * @param model the pane's state
14+ * @returns the title and its dim subtitle
15+ */
16+export function dialogTitleOf(model: PaneModel): DialogTitle {
17+ const turn = pickedTurnOf(model)
18+ const source = model.data?.source
19+ const isUnborn = model.data?.isUnborn === true
20+ const isBranch = source?.kind === 'branch'
21+
22+ function currentOf(): DialogTitle {
23+ switch (true) {
24+ case isUnborn:
25+ return { title: 'Staged and new files', subtitle: '(no commits yet)' }
26+ case isBranch && source?.kind === 'branch':
27+ return {
28+ title: 'Branch changes',
29+ subtitle: `(vs ${source.baseBranch})`,
30+ }
31+ default:
32+ return { title: 'Uncommitted changes', subtitle: '(git diff HEAD)' }
33+ }
34+ }
35+
36+ return turn ? turnTitleOf(turn) : currentOf()
37+}
@@ -1,7 +1,6 @@
1-import type Backend from '../backend'
2-import type Git from '../git'
31 import Names from '../names'
42 import type { EmptyState } from './empty-state'
3+import type { PaneModel } from './pane-model'
54
65 /**
76 * The headline that replaces the count when nothing is listed, worded as
@@ -11,16 +10,16 @@
1110 * "Diff unavailable"; no tracked rows while the untracked listing was
1211 * withheld claims nothing about new files.
1312 *
14- * @param data the last good fetch, or null when none ever settled
13+ * @param model the last good fetch (null when none ever settled), the words
1514 * @param filesCount the header's session file count
16- * @param words the backend's words
1715 * @returns the empty state, or null
1816 */
1917 export function emptyStateOf(
20- data: Git.DiffData | null,
18+ model: Pick<PaneModel, 'data' | 'words'>,
2119 filesCount: number,
22- words: Backend.BackendWords,
2320 ): EmptyState | null {
21+ const { data, words } = model
22+
2423 if (!data) {
2524 return {
2625 headline: 'Diff unavailable',
@@ -37,7 +36,7 @@
3736 if (data.isUntrackedWithheld) {
3837 return {
3938 headline: 'No tracked changes',
40- hint: Names.untrackedWithheldTextOf(words),
39+ hint: Names.untrackedWithheldTextOf(model),
4140 }
4241 }
4342
@@ -1,22 +1,25 @@
11 export * from './after-fetch'
22 export * from './after-new-session'
3-export * from './base-label-of.js'
43 export * from './base-mode-of'
5-export * from './body-state'
4+export * from './bodies'
5+export * from './dialog-focus'
6+export * from './dialog-title'
7+export * from './dialog-title-of.js'
68 export * from './empty-state'
79 export * from './empty-state-of.js'
810 export * from './header-totals'
911 export * from './header-totals-of.js'
1012 export * from './initial-model'
11-export * from './listed-of.js'
1213 export * from './mode-label-of'
14+export * from './no-bodies'
1315 export * from './pane-model'
1416 export * from './partition'
1517 export * from './partition-of.js'
1618 export * from './picked-turn-of'
17-export * from './selection-of'
1819 export * from './source'
20+export * from './turn-title-of'
1921 export * from './turn-totals-of'
22+export * from './unborn-note-of.js'
2023 export * from './zero-totals'
2124
2225 export * as default from '.'
@@ -1,9 +1,10 @@
11 import Git from '../../git'
2+import { NO_BODIES } from '../no-bodies'
23 import type { PaneModel } from '../pane-model'
34
45 /**
56 * The pane before anything happened: session mode, nothing fetched,
6- * nothing picked, the current source, git's words.
7+ * nothing picked, no body read, the current source, git's words, docked.
78 */
89 export const INITIAL_MODEL: PaneModel = Object.freeze({
910 words: Git.GIT_WORDS,
@@ -18,9 +19,11 @@
1819 isPreSessionShown: false,
1920 source: Object.freeze({ kind: 'current' }),
2021 turns: Object.freeze([]),
21- body: null,
22- bodyState: 'idle',
22+ bodies: NO_BODIES,
2323 todos: Object.freeze({ done: 0, total: 0 }),
2424 armedPath: null,
25- isFocused: false,
25+ placement: 'dock',
26+ isFullscreen: null,
27+ dialogView: 'list',
28+ place: Object.freeze({ top: 0, listStart: 0, columns: 0, rows: 0 }),
2629 })
Dmods/diff/hooks/pane-state/listed-of.ts+0 -21
@@ -1,21 +1,0 @@
1-import type Git from '../git'
2-import type { Partition } from './partition'
3-
4-/**
5- * The rows a person can pick from right now: the session's shown rows,
6- * then the pre-session rows while that section is open.
7- *
8- * @param partition the fetch's rows, grouped
9- * @param preSession whether the pre-session section is open
10- * @returns the pickable rows in drawing order
11- */
12-export function listedOf(
13- partition: Partition,
14- preSession: 'shown' | 'hidden',
15-): readonly Git.FileStat[] {
16- const isOpen = preSession === 'shown'
17-
18- return isOpen
19- ? [...partition.shown, ...partition.preSession]
20- : partition.shown
21-}
@@ -2,34 +2,43 @@
22 import type { PaneModel } from '../pane-model'
33
44 /**
5- * A requested mode's name for the base line.
5+ * One base mode's name in the picker, worded as the built-in's base line
6+ * (diffBaseLabel) words the mode it shows.
67 *
7- * Branch mode names its base branch from the data, or says what stands in
8- * while none is known. A working-tree diff names its own base (`HEAD`, a
9- * short sha); anything else falls back to the backend's word for it.
8+ * Branch mode names its base branch from the data, or once its own fetch
9+ * settled without one says what stands in; uncommitted names the working
10+ * tree's base. The requested mode carries an ellipsis while pending.
1011 *
11- * @param model the mode the person picked, and the backend's words
12- * @param source what the data on screen compares
13- * @param phase `pending` while the requested mode's fetch has not landed
14- * @returns the label without its pending ellipsis
12+ * @param mode the mode to name
13+ * @param model the mode the person picked, the last good fetch, the words
14+ * @returns the label
1515 */
1616 export function modeLabelOf(
17- model: Pick<PaneModel, 'requestedMode' | 'words'>,
18- source: Git.DiffSource,
19- phase: 'pending' | 'settled',
20-) {
21- const base = source.kind === 'working-tree' ? source.base : model.words.base
17+ mode: Git.BaseMode,
18+ model: Pick<PaneModel, 'requestedMode' | 'data' | 'words'>,
19+): string {
20+ const { data } = model
21+ const source = data?.source
22+ const isPending = data !== null && model.requestedMode !== data.mode
23+ const isRequested = mode === model.requestedMode
24+ const suffix = isRequested && isPending ? '…' : ''
25+ const base = source?.kind === 'working-tree' ? source.base : model.words.base
26+ const isSettledBranch = isRequested && !isPending && data?.mode === 'branch'
2227
23- switch (model.requestedMode) {
24- case 'session':
25- return 'this session'
26- case 'uncommitted':
27- return `uncommitted (vs ${base})`
28- case 'branch':
29- if (source.kind === 'branch') {
30- return `branch vs ${source.baseBranch}`
31- }
28+ function nameOf(): string {
29+ switch (mode) {
30+ case 'session':
31+ return 'this session'
32+ case 'uncommitted':
33+ return `uncommitted (vs ${base})`
34+ case 'branch':
35+ if (source?.kind === 'branch') {
36+ return `branch vs ${source.baseBranch}`
37+ }
3238
33- return phase === 'pending' ? 'branch diff' : `vs ${base} (no base branch)`
39+ return isSettledBranch ? `vs ${base} (no base branch)` : 'branch diff'
40+ }
3441 }
42+
43+ return `${nameOf()}${suffix}`
3544 }
@@ -1,0 +1,3 @@
1+export * from './no-bodies.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import type { Bodies } from '../bodies'
2+
3+/**
4+ * No body read yet: the pane's bodies before a fetch, and after one whose
5+ * base moved.
6+ */
7+export const NO_BODIES: Bodies = new Map()
@@ -2,15 +2,15 @@
22 import type Git from '../../git'
33 import type Todos from '../../todos'
44 import type Turns from '../../turns'
5-import type { BodyState } from '../body-state'
5+import type { Bodies } from '../bodies'
66 import type { Source } from '../source'
77
88 /**
99 * Everything one drawing of the pane reads.
1010 *
11- * The last good fetch, what the person picked, the selected file's body,
12- * the transcript's turns and todos, and the pinned backend's words and
13- * base modes (git's until one is pinned).
11+ * The last good fetch, what the person picked, the bodies read so far, the
12+ * turns and todos, how the surface seated it, whether fullscreen (once
13+ * `/diff` said), the dialog's view, and the docked scroll in its last box.
1414 */
1515 export type PaneModel = {
1616 words: Backend.BackendWords
@@ -25,9 +25,11 @@
2525 isPreSessionShown: boolean
2626 source: Source
2727 turns: readonly Turns.TurnDiff[]
28- body: Git.FileHunks | null
29- bodyState: BodyState
28+ bodies: Bodies
3029 todos: Todos.TodoProgress
3130 armedPath: string | null
32- isFocused: boolean
31+ placement: 'dock' | 'inline'
32+ isFullscreen: boolean | null
33+ dialogView: 'list' | 'detail'
34+ place: { top: number; listStart: number; columns: number; rows: number }
3335 }
@@ -1,10 +1,10 @@
11 import Classify from '../classify'
2-import type Git from '../git'
2+import Git from '../git'
33 import type { Partition } from './partition'
44
55 /**
6- * A fetch's rows split the way ReplDiffSidebar splits them: pre-session
7- * rows apart, tests and generated files counted and hidden unless shown.
6+ * A fetch's rows split as ReplDiffSidebar splits them, in its order (by
7+ * name): pre-session apart, tests and generated files hidden unless shown.
88 *
99 * No read-deny bucket: the pane feeds nothing to the model on its own.
1010 *
@@ -16,8 +16,12 @@
1616 files: readonly Git.FileStat[],
1717 noise: 'shown' | 'hidden',
1818 ): Partition {
19- const preSession = files.filter(file => file.isPreSession)
20- const session = files.filter(file => !file.isPreSession)
19+ const sorted = [...files].sort((a, b) =>
20+ Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)),
21+ )
22+
23+ const preSession = sorted.filter(file => file.isPreSession)
24+ const session = sorted.filter(file => !file.isPreSession)
2125 const quiet = session.filter(file => !Classify.isNoiseFile(file.path))
2226
2327 return {
Dmods/diff/hooks/pane-state/selection-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './selection-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/pane-state/selection-of/selection-of.ts+0 -16
@@ -1,16 +1,0 @@
1-import type Git from '../../git'
2-
3-/**
4- * The file whose body the pane shows: the one the person picked while it is
5- * still listed, else the first listed one, else none.
6- *
7- * @param listed the rows on screen, in order (session rows, then the
8- * pre-session rows while that section is open)
9- * @param selectedPath the person's pick, or null
10- * @returns the selected row, or null for an empty list
11- */
12-export const selectionOf = (
13- listed: readonly Git.FileStat[],
14- selectedPath: string | null,
15-): Git.FileStat | null =>
16- listed.find(file => file.path === selectedPath) ?? listed[0] ?? null
@@ -1,0 +1,3 @@
1+export * from './turn-title-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,16 @@
1+import type Turns from '../../turns'
2+import type { DialogTitle } from '../dialog-title'
3+
4+/**
5+ * A picked turn's heading, as the built-in dialog titles its tab: the
6+ * turn's number, then its prompt's opening words in quotes when it has any.
7+ *
8+ * @param turn the turn
9+ * @returns the title and its dim subtitle
10+ */
11+export const turnTitleOf = (
12+ turn: Pick<Turns.TurnDiff, 'index' | 'preview'>,
13+): DialogTitle => ({
14+ title: `Turn ${turn.index}`,
15+ subtitle: turn.preview === '' ? '' : `"${turn.preview}"`,
16+})
@@ -1,0 +1,22 @@
1+import type { PaneModel } from './pane-model'
2+
3+/**
4+ * The dim line under the header on an unborn HEAD that still lists rows:
5+ * what the rows are, since no comparison runs before the first commit.
6+ *
7+ * ReplDiffSidebar's no-commits qualifier; null with commits or no rows.
8+ *
9+ * @param model the last good fetch
10+ * @param filesCount the header's session file count
11+ * @returns the line, or null
12+ */
13+export function unbornNoteOf(
14+ model: Pick<PaneModel, 'data'>,
15+ filesCount: number,
16+): string | null {
17+ const isUnbornListing = model.data?.isUnborn === true && filesCount > 0
18+
19+ return isUnbornListing
20+ ? 'no commits yet — showing staged and new files'
21+ : null
22+}
@@ -1,11 +1,11 @@
11 /**
22 * What the plugin knows of its pane when `/diff` runs.
33 *
4- * Whether it opened it and never closed it, whether it drew when a redraw
5- * was asked, and the terminal's width as last drawn (null before any draw).
4+ * Whether the pane is open (it opened it, and no close of any origin has
5+ * gone through since), and the terminal's width as last drawn (null before
6+ * any draw).
67 */
78 export type PaneBelief = {
8- isBelievedOpen: boolean
9- wasDrawnWhenProbed: boolean
9+ isOpen: boolean
1010 columns: number | null
1111 }
@@ -2,23 +2,20 @@
22 import type { PaneBelief } from './pane-belief'
33
44 /**
5- * What `/diff` does, the one place its toggle is decided: close only when
6- * the plugin opened the pane and it still draws; otherwise open, focused.
5+ * What `/diff` does, the one place its toggle is decided: close the open
6+ * pane; otherwise open it, unless the terminal is too narrow.
77 *
88 * Narrower than the built-in panel shows on, it answers the resize line
9- * (`too-narrow`). The person's close raises nothing it hooks, so a pane
10- * believed open is probed with a redraw first; a width not drawn yet opens.
9+ * (`too-narrow`); a width not drawn yet opens.
1110 *
12- * @param pane the plugin's belief, the probe's answer and the width
11+ * @param pane whether the pane is open, and the width
1312 * @returns `close`, `open` or `too-narrow`
1413 */
1514 export function paneToggleOf(
1615 pane: PaneBelief,
1716 ): 'open' | 'close' | 'too-narrow' {
18- const isClosing = pane.isBelievedOpen && pane.wasDrawnWhenProbed
19-
2017 const isTooNarrow =
2118 pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS
2219
23- return isClosing ? 'close' : isTooNarrow ? 'too-narrow' : 'open'
20+ return pane.isOpen ? 'close' : isTooNarrow ? 'too-narrow' : 'open'
2421 }
@@ -1,13 +1,21 @@
1-import type { On, ResultOf, SessionMessage, Timer } from 'claude-code'
1+import type {
2+ On,
3+ PaneOpenArgs,
4+ ResultOf,
5+ SessionMessage,
6+ Timer,
7+} from 'claude-code'
28
39 import Ask from './ask'
410 import Backend from './backend'
511 import { COMMAND_SPEC } from './command-spec'
12+import { drawnFilesOf } from './drawn-files-of'
613 import { entryKindsOf } from './entry-kinds-of'
714 import type Git from './git'
815 import type { Host } from './host'
916 import { isOnPaneSurface } from './is-on-pane-surface'
1017 import Limits from './limits'
18+import { mapLimited } from './map-limited'
1119 import { messageOf } from './message-of'
1220 import { mtimeOf } from './mtime-of'
1321 import Names from './names'
@@ -33,17 +41,20 @@
3341 let probing: Promise<boolean> | null = null
3442 let sessionStartMs = 0
3543 let isPaneOpen = false
44+ let dialogRows: number | null = null
3645 let hasAutoOpened = false
3746 let columns: number | null = null
3847 let shownSessionId: string | null = null
39- let wasDrawnSinceProbe = false
4048 let armed: Ask.ArmedAsk | null = null
4149 let carrying: Ask.ArmedAsk | null = null
4250 let isRefreshing = false
4351 let isRefreshQueued = false
4452 let generation = 0
45- let bodyKey: string | null = null
53+ let bodyStamp: string | null = null
54+ let bodyBase: string | null = null
4655
56+ const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
57+
4758 const polled = { toplevel: '', headKey: '' }
4859
4960 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
@@ -137,7 +148,31 @@
137148 return true
138149 }
139150
151+ function dialogPane(): PaneOpenArgs {
152+ return {
153+ id: Names.PANE_ID,
154+ title: Names.PANE_TITLE,
155+ holdToasts: true,
156+ closeOnEscape: true,
157+ rows: Views.dialogRowsOf(model),
158+ }
159+ }
160+
161+ function fitDialog(engine: Host) {
162+ const rows = Views.dialogRowsOf(model)
163+
164+ const isStale =
165+ isPaneOpen && model.isFullscreen === false && rows !== dialogRows
166+
167+ if (isStale) {
168+ dialogRows = rows
169+ void engine.openPane(dialogPane()).catch(() => undefined)
170+ }
171+ }
172+
140173 function redraw(engine: Host) {
174+ fitDialog(engine)
175+
141176 if (timers.has('redraw')) {
142177 return
143178 }
@@ -151,49 +186,70 @@
151186 )
152187 }
153188
154- const selectedOf = (): Git.FileStat | null =>
155- PaneState.selectionOf(
156- PaneState.listedOf(
157- PaneState.partitionOf(
158- model.data?.files ?? [],
159- model.isNoiseShown ? 'shown' : 'hidden',
160- ),
161- model.isPreSessionShown ? 'shown' : 'hidden',
162- ),
163- model.selectedPath,
164- )
165-
166- async function loadBody(engine: Host): Promise<boolean> {
189+ async function loadBodies(engine: Host): Promise<boolean> {
167190 const { data } = model
168- const selected = selectedOf()
191+ const pinned = backend
169192
170- if (!data || !selected || !backend) {
171- bodyKey = null
172- model = { ...model, body: null, bodyState: 'idle' }
193+ if (!data || !pinned) {
194+ bodyStamp = null
195+ bodyBase = null
196+ bodyLoads.clear()
197+ model = { ...model, bodies: PaneState.NO_BODIES }
173198
174199 return false
175200 }
176201
177- const key = `${generation}|${data.baseRef}|${selected.path}`
202+ const stamp = bodyStampOf(data)
203+ const isNewBase = data.baseRef !== bodyBase
178204
179- if (key === bodyKey) {
180- return false
205+ if (stamp !== bodyStamp) {
206+ bodyStamp = stamp
207+ bodyLoads.clear()
181208 }
182209
183- bodyKey = key
184- model = { ...model, body: null, bodyState: 'loading' }
185- redraw(engine)
210+ if (isNewBase) {
211+ bodyBase = data.baseRef
212+ model = { ...model, bodies: PaneState.NO_BODIES }
213+ redraw(engine)
214+ }
186215
187- const body = await backend.fetchFileHunks(data, selected)
216+ return fetchBodies(engine, pinned, data)
217+ }
188218
189- if (bodyKey !== key) {
190- return body === null
191- }
219+ const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
192220
193- model = { ...model, body, bodyState: body ? 'ready' : 'failed' }
194- redraw(engine)
221+ async function fetchBodies(
222+ engine: Host,
223+ pinned: Backend.Backend,
224+ data: Git.DiffData,
225+ ): Promise<boolean> {
226+ const stamp = bodyStampOf(data)
195227
196- return body === null
228+ function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> {
229+ const load = pinned.fetchFileHunks(data, file)
230+ bodyLoads.set(file.path, load)
231+
232+ return load.then(body => {
233+ if (bodyStamp === stamp) {
234+ model = {
235+ ...model,
236+ bodies: new Map(model.bodies).set(file.path, body),
237+ }
238+
239+ redraw(engine)
240+ }
241+
242+ return body
243+ })
244+ }
245+
246+ return (
247+ await mapLimited(
248+ drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)),
249+ Limits.BODY_FETCH_CONCURRENCY,
250+ loadOf,
251+ )
252+ ).includes(null)
197253 }
198254
199255 function startPoll(engine: Host, pinned: Backend.Backend) {
@@ -274,7 +330,7 @@
274330 break
275331 }
276332
277- const hasHunksFailed = await loadBody(engine)
333+ const hasHunksFailed = await loadBodies(engine)
278334
279335 if (outcome.kind === 'data') {
280336 record.mark(
@@ -318,9 +374,23 @@
318374 engine: Host,
319375 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
320376 ): Promise<void> {
321- const pane = { id: Names.PANE_ID, title: Names.PANE_TITLE }
322- const isManual = trigger === 'manual'
323- await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane)
377+ const isDialog = model.isFullscreen === false
378+
379+ model = {
380+ ...model,
381+ selectedPath: null,
382+ dialogView: 'list',
383+ place: { ...model.place, top: 0, listStart: 0 },
384+ }
385+
386+ dialogRows = isDialog ? Views.dialogRowsOf(model) : null
387+
388+ await engine.openPane(
389+ isDialog
390+ ? { ...dialogPane(), focus: true }
391+ : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
392+ )
393+
324394 isPaneOpen = true
325395
326396 const sessionId = await engine.sessionId().catch(() => null)
@@ -345,14 +415,6 @@
345415 })
346416 }
347417
348- async function wasDrawnWhenProbed(engine: Host): Promise<boolean> {
349- wasDrawnSinceProbe = false
350- engine.invalidate()
351- await engine.sleep(Limits.OPEN_PROBE_MS)
352-
353- return wasDrawnSinceProbe
354- }
355-
356418 async function openOnFirstEdit(engine: Host): Promise<void> {
357419 const isTaken = () => isPaneOpen || hasAutoOpened
358420
@@ -372,6 +434,7 @@
372434
373435 const isEligible =
374436 preference !== false &&
437+ model.isFullscreen !== false &&
375438 columns !== null &&
376439 columns >= floor &&
377440 backend !== null
@@ -392,29 +455,43 @@
392455
393456 const actionsOf = (engine: Host): Views.PaneActions => ({
394457 selectFile: path => {
395- model = { ...model, selectedPath: path }
396- void loadBody(engine)
458+ const isDocked = model.placement === 'dock'
459+
460+ model = {
461+ ...model,
462+ selectedPath: path,
463+ dialogView: isDocked ? model.dialogView : 'detail',
464+ place: isDocked ? Views.placeAtFile(model, path) : model.place,
465+ }
466+
397467 redraw(engine)
398468 },
469+ scrollList: delta => {
470+ model = { ...model, place: Views.listScrolledBy(model, delta) }
471+ redraw(engine)
472+ },
399473 toggleNoise: () => {
400474 model = { ...model, isNoiseShown: !model.isNoiseShown }
401- void loadBody(engine)
475+ void loadBodies(engine)
402476 redraw(engine)
403477 },
404478 togglePreSession: () => {
405479 model = { ...model, isPreSessionShown: !model.isPreSessionShown }
406- void loadBody(engine)
480+ void loadBodies(engine)
407481 redraw(engine)
408482 },
409- chooseBase: value => {
410- const mode = PaneState.baseModeOf(value)
483+ cycleBase: () => {
484+ const { baseModes, requestedMode } = model
411485
412- if (!mode || mode === model.requestedMode) {
486+ const mode =
487+ baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ??
488+ requestedMode
489+
490+ if (mode === requestedMode) {
413491 return
414492 }
415493
416- bodyKey = null
417- model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' }
494+ model = { ...model, requestedMode: mode }
418495
419496 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
420497 kind: 'ok',
@@ -440,8 +517,7 @@
440517 ? { kind: 'turn', index }
441518 : { kind: 'current' }
442519
443- model = { ...model, source, selectedPath: null }
444- void loadBody(engine)
520+ model = { ...model, source, selectedPath: null, dialogView: 'list' }
445521 redraw(engine)
446522 },
447523 toggleAsk: path => {
@@ -454,12 +530,6 @@
454530
455531 arm(engine, path)
456532 },
457- close: () => {
458- void closePane(engine)
459- .then(() => markTabSwitch(engine, 'convo'))
460- .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false))
461- .catch(() => undefined)
462- },
463533 })
464534
465535 function arm(engine: Host, path: string) {
@@ -467,7 +537,7 @@
467537 path,
468538 PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
469539 ?.hunks ??
470- model.body?.hunks ??
540+ model.bodies.get(path)?.hunks ??
471541 [],
472542 )
473543
@@ -505,7 +575,6 @@
505575 now: () => $.clock.now(),
506576 after: (ms, fn) => $.clock.after(ms, fn),
507577 every: (ms, fn) => $.clock.every(ms, fn),
508- sleep: ms => $.clock.sleep(ms),
509578 run: (argv, init) => $.process.run(argv, init),
510579 stat: path => $.fs.stat(path),
511580 listDir: path => $.fs.list(path),
@@ -542,10 +611,21 @@
542611
543612 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
544613
545- wasDrawnSinceProbe = true
546614 columns = e.viewport?.columns ?? columns
547- model = { ...model, isFocused: e.props.isFocused }
548615
616+ model = {
617+ ...model,
618+ placement: e.props.placement,
619+ place: {
620+ ...model.place,
621+ columns: Math.max(
622+ 1,
623+ e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
624+ ),
625+ rows: e.props.scroll.bodyRows,
626+ },
627+ }
628+
549629 return Views.paneView(
550630 {
551631 ui: { Box, Text, Button, Select, Code },
@@ -554,7 +634,7 @@
554634 rows: e.props.scroll.bodyRows,
555635 },
556636 model,
557- e.props.placement,
637+ { placement: e.props.placement, terminalColumns: columns },
558638 )
559639 })
560640
@@ -573,10 +653,14 @@
573653 }
574654 }
575655
656+ const { isFullscreen } = e.presentation
657+
658+ columns = e.presentation.columns
659+ model = { ...model, isFullscreen }
660+
576661 const toggle = PaneToggle.paneToggleOf({
577- isBelievedOpen: isPaneOpen,
578- wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)),
579- columns,
662+ isOpen: isPaneOpen,
663+ columns: isFullscreen ? columns : null,
580664 })
581665
582666 if (toggle === 'too-narrow') {
@@ -585,9 +669,99 @@
585669
586670 const isOpening = toggle === 'open'
587671 await (isOpening ? openPane(host, 'manual') : closePane(host))
672+
673+ if (!isFullscreen) {
674+ return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
675+ }
676+
588677 markTabSwitch(host, isOpening ? 'diff' : 'convo')
589678 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
590679
680+ return {
681+ text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
682+ }
683+ })
684+
685+ on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
686+ const isBack =
687+ e.origin.kind === 'person' &&
688+ model.placement === 'inline' &&
689+ model.dialogView === 'detail'
690+
691+ if (isBack && host) {
692+ model = { ...model, dialogView: 'list' }
693+ redraw(host)
694+
695+ dialogRows = Views.dialogRowsOf(model)
696+
697+ void host
698+ .openPane({ ...dialogPane(), focus: true })
699+ .catch(() => undefined)
700+
701+ return { deny: 'back to the file list' }
702+ }
703+
704+ const result = await next(e)
705+ const isClosed = result.deny === undefined
706+ const isPersons = isClosed && e.origin.kind === 'person'
707+
708+ if (isClosed) {
709+ isPaneOpen = false
710+ }
711+
712+ const isDialog = model.isFullscreen === false
713+
714+ if (isPersons && host && isDialog) {
715+ host.uiLog(Names.DIALOG_DISMISSED_TEXT)
716+ }
717+
718+ if (isPersons && host && !isDialog) {
719+ markTabSwitch(host, 'convo')
720+ await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
721+ }
722+
723+ return result
724+ })
725+
726+ on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
727+ const isListed =
728+ model.placement === 'inline' && model.dialogView === 'list' && host
729+
730+ const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
731+
732+ if (focus === 'stay') {
733+ return {}
734+ }
735+
736+ if (!focus || !host) {
737+ return next(e)
738+ }
739+
740+ model = { ...model, selectedPath: focus.selectedPath }
741+ fitDialog(host)
742+ host.invalidate()
743+
744+ return next({ ...e, element: focus.landing })
745+ })
746+
747+ on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
748+ const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
749+
750+ if (!isOwnBody || !host) {
751+ return next(e)
752+ }
753+
754+ const isOverList = Views.isWheelOverList(model, e)
755+
756+ model = {
757+ ...model,
758+ place: isOverList
759+ ? Views.listScrolledBy(model, e.by)
760+ : Views.bodyScrolledBy(model, e),
761+ }
762+
763+ host.invalidate()
764+
591765 return {}
592766 })
593767
@@ -600,7 +774,9 @@
600774 }
601775
602776 hasAutoOpened = false
603- bodyKey = null
777+ bodyStamp = null
778+ bodyBase = null
779+ bodyLoads.clear()
604780 disarm(host)
605781 model = PaneState.afterNewSession(model)
606782 }
@@ -1,0 +1,47 @@
1+import type { UiScrollInput } from 'claude-code'
2+
3+import Limits from '../../limits'
4+import type PaneState from '../../pane-state'
5+import Plan from './plan'
6+
7+/**
8+ * The docked pane's place after one of the person's scrolls of the body:
9+ * a wheel tick or arrow three rows, a page key the window, Home/End an end.
10+ *
11+ * Told apart by the rows the move asks for against the pane's body and
12+ * tree, as the engine sizes each; clamped to the body's extent.
13+ *
14+ * @param model the pane's state
15+ * @param scroll the move: its signed rows, the body's rows, the tree's rows
16+ * @returns the place
17+ */
18+export function bodyScrolledBy(
19+ model: PaneState.PaneModel,
20+ scroll: Pick<UiScrollInput, 'by' | 'bodyRows' | 'contentRows'>,
21+): PaneState.PaneModel['place'] {
22+ const { visibleRows, maxTop } = Plan.bodyLayoutOf(
23+ model,
24+ Plan.dockPlanOf(model),
25+ )
26+
27+ const size = Math.abs(scroll.by)
28+
29+ const isEnd =
30+ size >= scroll.contentRows && scroll.contentRows > scroll.bodyRows
31+
32+ const isPage = !isEnd && size >= scroll.bodyRows
33+
34+ const step = isEnd
35+ ? maxTop
36+ : isPage
37+ ? Math.max(1, visibleRows)
38+ : size * Limits.WHEEL_ROWS
39+
40+ return {
41+ ...model.place,
42+ top: Math.max(
43+ 0,
44+ Math.min(maxTop, model.place.top + Math.sign(scroll.by) * step),
45+ ),
46+ }
47+}
@@ -1,0 +1,104 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../limits'
7+import Detail from '../detail'
8+import type { Kit } from '../kit'
9+import Layout from '../layout'
10+import Sections from '../sections'
11+import Plan from './plan'
12+import type { BodyLayout, HunkSegment, Segment, WindowCut } from './types'
13+
14+/**
15+ * The rows of the docked body its window shows from a row down: each
16+ * segment drawn from the row the window cuts it at, until the window fills.
17+ *
18+ * A hunk draws only its lines in view, and past MAX_BODY_CHARS of hunk
19+ * text the rest waits for a scroll (the tree's cap); rows past the foot
20+ * are the pane's to clip, so SCROLL_MARGIN_ROWS more are drawn than fit.
21+ *
22+ * @param kit the elements, the handlers, the width
23+ * @param layout the body laid out
24+ * @param top the body row wanted at the window's top; clamped to the body
25+ * @returns the elements, top to bottom
26+ */
27+export function drawWindow(
28+ kit: Kit,
29+ layout: BodyLayout,
30+ top: number,
31+): RenderElement[] {
32+ const { Box, Text, Code } = kit.ui
33+ const drawn: RenderElement[] = []
34+ const wanted = layout.visibleRows + Limits.SCROLL_MARGIN_ROWS
35+ const at = Math.max(0, Math.min(top, layout.maxTop))
36+ const onPress = kit.actions.togglePreSession
37+
38+ let start = 0
39+ let filled = 0
40+ let chars = 0
41+
42+ function codeOf(segment: HunkSegment, cut: WindowCut): RenderElement {
43+ const source = Detail.hunkSourceOf(Plan.hunkWindowOf(segment, cut))
44+ const path = Layout.sanitizeName(segment.path)
45+ chars += source.length
46+
47+ const isOver = chars > Detail.MAX_BODY_CHARS
48+
49+ return isOver ? (
50+ <Text dimColor italic>
51+ …
52+ </Text>
53+ ) : (
54+ <Code source={source} format="diff" path={path} />
55+ )
56+ }
57+
58+ function elementOf(segment: Segment, cut: WindowCut): RenderElement {
59+ switch (segment.kind) {
60+ case 'rule':
61+ return Sections.divider(kit)
62+ case 'blank':
63+ return <Box height={1} />
64+ case 'name':
65+ return Sections.nameRow(kit, segment.name)
66+ case 'hunk':
67+ return codeOf(segment, cut)
68+ case 'note':
69+ return (
70+ <Text dimColor italic wrap="wrap">
71+ {segment.text}
72+ </Text>
73+ )
74+ case 'footer':
75+ return (
76+ <Text dimColor italic>
77+ … diff truncated (exceeded 400 line limit)
78+ </Text>
79+ )
80+ case 'text':
81+ return Sections.dimNote(kit, segment.text)
82+ case 'toggle':
83+ return Sections.toggleRow(kit, 'presession', { ...segment, onPress })
84+ case 'legend':
85+ return Sections.legendRow(kit, segment.entry)
86+ }
87+ }
88+
89+ for (const segment of layout.segments) {
90+ const rows = Plan.segmentRowsOf(segment)
91+ const isShown = start + rows > at && filled < wanted
92+
93+ if (isShown) {
94+ const skip = Math.max(0, at - start)
95+
96+ drawn.push(elementOf(segment, { skip, take: wanted - filled }))
97+ filled += rows - skip
98+ }
99+
100+ start += rows
101+ }
102+
103+ return drawn
104+}
@@ -1,0 +1,10 @@
1+export * from './body-scrolled-by.js'
2+export * from './draw-window.jsx'
3+export * from './is-wheel-over-list.js'
4+export * from './list-block-of.jsx'
5+export * from './list-scrolled-by.js'
6+export * from './place-at-file.js'
7+export * from './plan'
8+export * from './types'
9+
10+export * as default from '.'
@@ -1,0 +1,28 @@
1+import type { UiScrollInput } from 'claude-code'
2+
3+import Limits from '../../limits'
4+import type PaneState from '../../pane-state'
5+import Plan from './plan'
6+
7+/**
8+ * Whether a scroll is a wheel tick over the docked list block while the
9+ * list overflows its window: it moves the list then, as ReplDiffSidebar's.
10+ *
11+ * @param model the pane's state
12+ * @param scroll the move: the cell the wheel was over, absent for keys
13+ * @returns whether the list takes it
14+ */
15+export function isWheelOverList(
16+ model: PaneState.PaneModel,
17+ scroll: Pick<UiScrollInput, 'pointer'>,
18+): boolean {
19+ const plan = Plan.dockPlanOf(model)
20+ const { listTop, listEnd } = Plan.bodyLayoutOf(model, plan)
21+ const row = scroll.pointer?.row ?? -1
22+
23+ return (
24+ row >= listTop &&
25+ row < listEnd &&
26+ plan.rows.length > Limits.MAX_SUMMARY_ROWS
27+ )
28+}
@@ -1,0 +1,89 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import { keptOf } from '../../kept-of'
7+import Names from '../../names'
8+import type PaneState from '../../pane-state'
9+import type Entries from '../entries'
10+import type { Kit } from '../kit'
11+import Layout from '../layout'
12+import Sections from '../sections'
13+import Plan from './plan'
14+import type { DockPlan } from './types'
15+
16+/**
17+ * The docked list block (ReplDiffSidebar's summary rows): the window's rows
18+ * between its edge rows, the untracked note, the noise toggle.
19+ *
20+ * The edge rows count the files out of the window and take the built-in's
21+ * list keys; files past the row cap join the lower one. Empty with no row
22+ * and no noise to toggle.
23+ *
24+ * @param kit the elements, the handlers, the width
25+ * @param model the pane's state
26+ * @param plan the docked pane's decisions over that state
27+ * @returns the block's rows
28+ */
29+export function listBlockOf(
30+ kit: Kit,
31+ model: PaneState.PaneModel,
32+ plan: DockPlan,
33+): RenderElement[] {
34+ const { shown, above, below } = Plan.listWindowOf(model, plan)
35+ const { noiseCount } = plan.partition
36+ const { notShown } = plan.totals
37+ const hasNoise = !plan.turn && noiseCount > 0
38+ const hasMoreAbove = above > 0
39+ const hasMoreBelow = below > 0
40+ const hasNotShown = notShown > 0
41+ const noiseFace = model.isNoiseShown ? 'hide' : 'show'
42+
43+ const lower = keptOf([
44+ hasMoreBelow ? `${below} ${Names.MORE_BELOW_TEXT}` : null,
45+ hasNotShown ? `${notShown} not shown` : null,
46+ ]).join(' · ')
47+
48+ const rowOf = (entry: Entries.BodyEntry) =>
49+ Sections.fileRow(
50+ kit,
51+ {
52+ key: Sections.fileKeyOf(entry),
53+ path: entry.path,
54+ displayPath: entry.displayPath,
55+ added: entry.added,
56+ removed: entry.removed,
57+ note: null,
58+ isSelected: false,
59+ },
60+ () => kit.actions.selectFile(entry.path),
61+ )
62+
63+ const upperEdge = hasMoreAbove
64+ ? Sections.listEdgeButton(kit, 'list-up', `↑ ${above} more above`)
65+ : null
66+
67+ const lowerEdge = hasMoreBelow
68+ ? Sections.listEdgeButton(kit, 'list-down', `↓ ${lower}`)
69+ : hasNotShown
70+ ? Sections.dimNote(kit, `… ${lower}`)
71+ : null
72+
73+ const noiseToggle = hasNoise
74+ ? Sections.toggleRow(kit, 'noise', {
75+ label: `${Layout.plural(noiseCount, 'test')}/generated (${noiseFace})`,
76+ onPress: kit.actions.toggleNoise,
77+ })
78+ : null
79+
80+ return Sections.present([
81+ upperEdge,
82+ ...shown.map(rowOf),
83+ lowerEdge,
84+ plan.isUntrackedNoted
85+ ? Sections.dimNote(kit, Names.untrackedWithheldTextOf(model))
86+ : null,
87+ noiseToggle,
88+ ])
89+}
@@ -1,0 +1,29 @@
1+import Limits from '../../limits'
2+import type PaneState from '../../pane-state'
3+import Plan from './plan'
4+
5+/**
6+ * The docked pane's place with the list window moved by so many files,
7+ * clamped to the list (ReplDiffSidebar's scrollSummary).
8+ *
9+ * @param model the pane's state
10+ * @param delta the files to move by, negative toward the top
11+ * @returns the place
12+ */
13+export function listScrolledBy(
14+ model: PaneState.PaneModel,
15+ delta: number,
16+): PaneState.PaneModel['place'] {
17+ const last = Math.max(
18+ 0,
19+ Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS,
20+ )
21+
22+ return {
23+ ...model.place,
24+ listStart: Math.max(
25+ 0,
26+ Math.min(last, Math.min(model.place.listStart, last) + delta),
27+ ),
28+ }
29+}
@@ -1,0 +1,20 @@
1+import type PaneState from '../../pane-state'
2+import Plan from './plan'
3+
4+/**
5+ * The docked pane's place with the body scrolled so this file's block
6+ * heads the window (the built-in's jump to file); unchanged for no such file.
7+ *
8+ * @param model the pane's state
9+ * @param path the file's path
10+ * @returns the place
11+ */
12+export const placeAtFile = (
13+ model: PaneState.PaneModel,
14+ path: string,
15+): PaneState.PaneModel['place'] => ({
16+ ...model.place,
17+ top:
18+ Plan.bodyLayoutOf(model, Plan.dockPlanOf(model)).tops.get(path) ??
19+ model.place.top,
20+})
@@ -1,0 +1,68 @@
1+import Limits from '../../../limits'
2+import type PaneState from '../../../pane-state'
3+import type { BodyLayout, DockPlan } from '../types'
4+import Segments from './segments'
5+
6+/**
7+ * The docked pane's scrolling body laid out under its pinned rows: the
8+ * segments and their rows, the window's rows, the files' and list's rows.
9+ *
10+ * The pinned rows are counted as sidebarPane draws them (header, base or
11+ * turn line, todo bar, list block); read from the box last drawn (`place`),
12+ * so before any draw the window shows nothing and the top stays 0.
13+ *
14+ * @param model the pane's state
15+ * @param plan the docked pane's decisions over that state
16+ * @returns the layout
17+ */
18+export function bodyLayoutOf(
19+ model: PaneState.PaneModel,
20+ plan: DockPlan,
21+): BodyLayout {
22+ const segments = Segments.segmentsOf(model, plan)
23+ const window = Segments.listWindowOf(model, plan)
24+ const hasNoise = !plan.turn && plan.partition.noiseCount > 0
25+ const isListed = plan.message.length === 0
26+ const hasList = isListed && (plan.rows.length > 0 || hasNoise)
27+
28+ const listRows = hasList
29+ ? window.shown.length +
30+ Number(window.above > 0) +
31+ Number(window.below > 0 || plan.totals.notShown > 0) +
32+ Number(plan.isUntrackedNoted) +
33+ Number(hasNoise) +
34+ 2
35+ : Number(isListed)
36+
37+ const headRows =
38+ 1 + Number(plan.line !== null) + (model.todos.total > 0 ? 2 : 0)
39+
40+ const tops = new Map<string, number>()
41+
42+ const visibleRows = Math.max(
43+ 0,
44+ model.place.rows - Limits.PANE_TOP_PAD_ROWS - headRows - listRows,
45+ )
46+
47+ let extent = 0
48+
49+ for (const segment of segments) {
50+ if (segment.kind === 'rule' && segment.path !== null) {
51+ tops.set(segment.path, extent)
52+ }
53+
54+ extent += Segments.segmentRowsOf(segment)
55+ }
56+
57+ const listTop = Limits.PANE_TOP_PAD_ROWS + headRows + 1
58+
59+ return {
60+ segments,
61+ extent,
62+ visibleRows,
63+ maxTop: Math.max(0, extent - visibleRows),
64+ tops,
65+ listTop,
66+ listEnd: hasList ? listTop + listRows - 2 : listTop,
67+ }
68+}
@@ -1,0 +1,80 @@
1+import PaneState from '../../../pane-state'
2+import DialogPane from '../../dialog-pane'
3+import Entries from '../../entries'
4+import Layout from '../../layout'
5+import type { DockPlan } from '../types'
6+
7+/**
8+ * The docked pane's decisions over a model (ReplDiffSidebarBody's): which
9+ * rows list, what the header counts, what stands in for an empty list.
10+ *
11+ * Over a picked turn the rows are the turn's files and nothing is empty;
12+ * else the fetch's session rows, its pre-session rows apart. Under the
13+ * header: the base line outside settled session mode, or the unborn note.
14+ *
15+ * @param model the pane's state
16+ * @returns the plan
17+ */
18+export function dockPlanOf(model: PaneState.PaneModel): DockPlan {
19+ const { data, requestedMode } = model
20+ const turn = PaneState.pickedTurnOf(model)
21+ const noise = model.isNoiseShown ? 'shown' : 'hidden'
22+ const partition = PaneState.partitionOf(data?.files ?? [], noise)
23+ const face = model.isPreSessionShown ? 'hide' : 'show'
24+
25+ const totals = turn
26+ ? PaneState.turnTotalsOf(turn)
27+ : data
28+ ? PaneState.headerTotalsOf(data, partition)
29+ : PaneState.ZERO_TOTALS
30+
31+ const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount)
32+
33+ const listed = turn
34+ ? []
35+ : DialogPane.listBodyOf({ data, partition, totals, empty })
36+
37+ const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
38+ const hasRows = turn ? turn.files.length > 0 : notes.length < listed.length
39+
40+ const entryOf = (file: Parameters<typeof Entries.bodyEntryOf>[0]) =>
41+ Entries.bodyEntryOf(file, model)
42+
43+ const rows = turn
44+ ? turn.files.map(Entries.turnEntryOf)
45+ : hasRows
46+ ? partition.shown.map(entryOf)
47+ : []
48+
49+ const earlier = turn ? [] : partition.preSession.map(entryOf)
50+ const hasEarlier = earlier.length > 0
51+
52+ const isBaseShown =
53+ data !== null &&
54+ !data.isUnborn &&
55+ (requestedMode !== 'session' || data.mode !== 'session')
56+
57+ const line = turn
58+ ? `Turn ${turn.index} "${turn.preview}"`
59+ : isBaseShown
60+ ? PaneState.modeLabelOf(requestedMode, model)
61+ : PaneState.unbornNoteOf(model, totals.filesCount)
62+
63+ return {
64+ turn,
65+ partition,
66+ totals,
67+ empty,
68+ line: line === null ? null : Layout.sanitizeName(line),
69+ rows,
70+ earlier,
71+ earlierLabel: hasEarlier
72+ ? `+${Layout.plural(earlier.length, 'file')} edited before this ` +
73+ `session (${face})`
74+ : null,
75+ notes,
76+ message: empty ? [empty.headline, ...notes] : hasRows ? [] : notes,
77+ isUntrackedNoted:
78+ !turn && data?.isUntrackedWithheld === true && empty === null,
79+ }
80+}
@@ -1,0 +1,32 @@
1+import Git from '../../../git'
2+import type { HunkSegment, WindowCut } from '../types'
3+
4+/**
5+ * The part of a hunk the window shows: from the line holding its first
6+ * shown row (hunkAfter renumbers) to the last that starts in the room left.
7+ *
8+ * @param segment the hunk and the rows each of its lines takes
9+ * @param cut the rows hidden above the window and the rows it has left
10+ * @returns the hunk's shown lines
11+ */
12+export function hunkWindowOf(segment: HunkSegment, cut: WindowCut): Git.Hunk {
13+ let passed = 0
14+ let first = 0
15+ let shown = 0
16+ let last = 0
17+
18+ for (const rows of segment.lineRows) {
19+ if (passed + rows <= cut.skip) {
20+ first += 1
21+ } else if (shown < cut.take) {
22+ shown += rows
23+ last += 1
24+ }
25+
26+ passed += rows
27+ }
28+
29+ const tail = Git.hunkAfter(segment.hunk, first)
30+
31+ return { ...tail, lines: tail.lines.slice(0, last) }
32+}
@@ -1,0 +1,6 @@
1+export * from './body-layout-of.js'
2+export * from './dock-plan-of.js'
3+export * from './hunk-window-of.js'
4+export * from './segments'
5+
6+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './list-window-of.js'
2+export * from './segment-rows-of.js'
3+export * from './segments-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,29 @@
1+import Limits from '../../../../limits'
2+import type PaneState from '../../../../pane-state'
3+import type { DockPlan, ListWindow } from '../../types'
4+
5+/**
6+ * The docked list's MAX_SUMMARY_ROWS-row window starting at the file the
7+ * person scrolled it to, clamped so a shrinking list pulls it back.
8+ *
9+ * @param model where the person put the list window
10+ * @param plan the docked pane's rows
11+ * @returns the window
12+ */
13+export function listWindowOf(
14+ model: Pick<PaneState.PaneModel, 'place'>,
15+ plan: Pick<DockPlan, 'rows'>,
16+): ListWindow {
17+ const first = Math.max(
18+ 0,
19+ Math.min(model.place.listStart, plan.rows.length - Limits.MAX_SUMMARY_ROWS),
20+ )
21+
22+ const shown = plan.rows.slice(first, first + Limits.MAX_SUMMARY_ROWS)
23+
24+ return {
25+ shown,
26+ above: first,
27+ below: plan.rows.length - first - shown.length,
28+ }
29+}
@@ -1,0 +1,11 @@
1+import type { Segment } from '../../types'
2+
3+/**
4+ * The rows a segment takes: a hunk's as counted when it was laid out, one
5+ * for anything else.
6+ *
7+ * @param segment the segment
8+ * @returns the rows
9+ */
10+export const segmentRowsOf = (segment: Segment): number =>
11+ segment.kind === 'hunk' ? segment.rows : 1
@@ -1,0 +1,137 @@
1+import { countOf } from '../../../../count-of'
2+import Git from '../../../../git'
3+import { keptOf } from '../../../../kept-of'
4+import Limits from '../../../../limits'
5+import type PaneState from '../../../../pane-state'
6+import Detail from '../../../detail'
7+import Entries from '../../../entries'
8+import Layout from '../../../layout'
9+import type { DockPlan, Segment } from '../../types'
10+
11+/**
12+ * The docked pane's scrolling body as segments (ReplDiffSidebar's scroll
13+ * box): each listed file's block, a blank between, the pre-session section.
14+ *
15+ * Under an empty state whose pre-session section is open, the empty lines
16+ * head the body instead. Hunks are made printable and split under a leaf's
17+ * cap (subHunksOf), each carrying the rows its lines take wrapped.
18+ *
19+ * @param model the pane's state: which file is armed, which section is open
20+ * @param plan the docked pane's decisions over that state
21+ * @returns the segments, top to bottom
22+ */
23+export function segmentsOf(
24+ model: PaneState.PaneModel,
25+ plan: DockPlan,
26+): Segment[] {
27+ const { earlier } = plan
28+ const { columns } = model.place
29+ const isEmptied = plan.message.length > 0
30+ const isOpen = model.isPreSessionShown && earlier.length > 0
31+ const isCapped = earlier.length > Limits.PRE_SESSION_BODY_CAP
32+
33+ const gutterOf = (hunk: Git.Hunk) =>
34+ String(
35+ Math.max(
36+ 1,
37+ hunk.oldStart + countOf(hunk.lines, line => !line.startsWith('+')) - 1,
38+ hunk.newStart + countOf(hunk.lines, line => !line.startsWith('-')) - 1,
39+ ),
40+ ).length + Limits.GUTTER_CHROME
41+
42+ function hunkOf(entry: Entries.BodyEntry, hunk: Git.Hunk): Segment {
43+ const room = Math.max(1, columns - gutterOf(hunk))
44+
45+ const lineRows = hunk.lines.map(line =>
46+ Math.max(
47+ 1,
48+ Math.ceil(
49+ Layout.cellWidth(
50+ Layout.sanitizeLine(
51+ line.slice(1).replaceAll('\t', ' '.repeat(Layout.TAB_WIDTH)),
52+ ),
53+ ) / room,
54+ ),
55+ ),
56+ )
57+
58+ return {
59+ kind: 'hunk',
60+ path: entry.path,
61+ hunk,
62+ lineRows,
63+ rows: lineRows.reduce((sum, rows) => sum + rows, 0),
64+ }
65+ }
66+
67+ const splitOf = (hunk: Git.Hunk) =>
68+ Detail.subHunksOf(
69+ {
70+ ...hunk,
71+ lines: hunk.lines.filter(Git.isBodyLine).map(Detail.drawnLineOf),
72+ },
73+ Detail.MAX_CODE_CHARS,
74+ )
75+
76+ function fileOf(entry: Entries.BodyEntry): Segment[] {
77+ const detail = Entries.detailModelOf(entry, model)
78+ const notes = Detail.placeholderOf(detail)
79+ const splits = (entry.body?.hunks ?? []).map(splitOf)
80+
81+ const isTruncated =
82+ entry.body?.isTruncated === true ||
83+ splits.some(split => split.isTruncated)
84+
85+ const body: Segment[] = notes
86+ ? notes.map(text => ({ kind: 'note', text }))
87+ : splits.flatMap(split => split.hunks).map(hunk => hunkOf(entry, hunk))
88+
89+ return [
90+ { kind: 'rule', path: entry.path },
91+ {
92+ kind: 'name',
93+ name: { ...detail, isTruncated, isAskable: notes === null },
94+ },
95+ { kind: 'rule', path: null },
96+ ...body,
97+ ...(isTruncated ? [{ kind: 'footer' } as const] : []),
98+ ]
99+ }
100+
101+ const spacedFileOf = (entry: Entries.BodyEntry): Segment[] => [
102+ ...fileOf(entry),
103+ { kind: 'blank' },
104+ ]
105+
106+ const messageOf = (): Segment[] => [
107+ { kind: 'blank' },
108+ ...plan.message.map(text => ({
109+ kind: 'text' as const,
110+ text: Layout.sanitizeName(text),
111+ })),
112+ { kind: 'blank' },
113+ ]
114+
115+ const hiddenNote: Segment = {
116+ kind: 'text',
117+ text: `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`,
118+ }
119+
120+ const bodiesOf = (): Segment[] =>
121+ isCapped ? [hiddenNote] : earlier.flatMap(fileOf)
122+
123+ const openedOf = (): Segment[] => [
124+ { kind: 'blank' },
125+ ...earlier.map(entry => ({ kind: 'legend' as const, entry })),
126+ ...bodiesOf(),
127+ ]
128+
129+ return [
130+ ...(isEmptied ? messageOf() : plan.rows.flatMap(spacedFileOf)),
131+ ...keptOf([plan.earlierLabel]).map(label => ({
132+ kind: 'toggle' as const,
133+ label,
134+ })),
135+ ...(isOpen ? openedOf() : []),
136+ ]
137+}
@@ -1,0 +1,15 @@
1+import type { Segment } from './segment'
2+
3+/**
4+ * The docked pane's scrolling body laid out: its segments and their rows,
5+ * the window, each file's first row, the list block's rows (end exclusive).
6+ */
7+export type BodyLayout = {
8+ segments: readonly Segment[]
9+ extent: number
10+ visibleRows: number
11+ maxTop: number
12+ tops: ReadonlyMap<string, number>
13+ listTop: number
14+ listEnd: number
15+}
@@ -1,0 +1,25 @@
1+import type PaneState from '../../../pane-state'
2+import type Turns from '../../../turns'
3+import type Entries from '../../entries'
4+
5+/**
6+ * What the docked pane decided before drawing: the turn or fetch it shows,
7+ * its rows and counts, the empty state, the lines round the list.
8+ *
9+ * `message` holds the lines that stand in for the list (an empty state, a
10+ * too-many or only-noise state); `line` the turn's preview, base line or
11+ * no-commits note, printable; `earlierLabel` the pre-session grey line.
12+ */
13+export type DockPlan = {
14+ turn: Turns.TurnDiff | undefined
15+ partition: PaneState.Partition
16+ totals: PaneState.HeaderTotals
17+ empty: PaneState.EmptyState | null
18+ line: string | null
19+ rows: readonly Entries.BodyEntry[]
20+ earlier: readonly Entries.BodyEntry[]
21+ earlierLabel: string | null
22+ notes: readonly string[]
23+ message: readonly string[]
24+ isUntrackedNoted: boolean
25+}
@@ -1,0 +1,7 @@
1+export type * from './body-layout.js'
2+export type * from './dock-plan.js'
3+export type * from './list-window.js'
4+export * from './segment'
5+export * from './window-cut'
6+
7+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Entries from '../../entries'
2+
3+/**
4+ * The docked list's window over its files: the rows in view, and how many
5+ * files lie above and below it.
6+ */
7+export type ListWindow = {
8+ shown: readonly Entries.BodyEntry[]
9+ above: number
10+ below: number
11+}
@@ -1,0 +1,13 @@
1+import type Git from '../../../../../git'
2+
3+/**
4+ * One hunk of a file in the docked body: whose it is, the hunk, and the
5+ * rows each of its lines takes wrapped at the body's width, summed too.
6+ */
7+export type HunkSegment = {
8+ kind: 'hunk'
9+ path: string
10+ hunk: Git.Hunk
11+ lineRows: readonly number[]
12+ rows: number
13+}
@@ -1,0 +1,3 @@
1+export type * from './hunk-segment.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './hunk-segment'
2+export type * from './segment.js'
3+
4+export * as default from '.'
@@ -1,0 +1,22 @@
1+import type Detail from '../../../detail'
2+import type Entries from '../../../entries'
3+import type { HunkSegment } from './hunk-segment'
4+
5+/**
6+ * One piece of the docked pane's scrolling body: a hunk some rows tall
7+ * (HunkSegment), or one row of another kind, top to bottom.
8+ *
9+ * A file is a `rule` (its top, what a row's press scrolls to), its `name`,
10+ * a `rule`, then hunks or `note`s and a `footer` when cut; `text` is an
11+ * empty state's line, `toggle` and `legend` the pre-session section's.
12+ */
13+export type Segment =
14+ | HunkSegment
15+ | { kind: 'rule'; path: string | null }
16+ | { kind: 'blank' }
17+ | { kind: 'name'; name: Detail.NamedDetail }
18+ | { kind: 'note'; text: string }
19+ | { kind: 'footer' }
20+ | { kind: 'text'; text: string }
21+ | { kind: 'toggle'; label: string }
22+ | { kind: 'legend'; entry: Entries.BodyEntry }
@@ -1,0 +1,3 @@
1+export type * from './window-cut.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * How the body's window cuts one hunk: the wrapped rows its top hides, and
3+ * the rows it still has room for from there.
4+ */
5+export type WindowCut = {
6+ skip: number
7+ take: number
8+}
Dmods/diff/hooks/views/current-pane/current-pane.tsx+0 -175
@@ -1,175 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import type Git from '../../git'
7-import Names from '../../names'
8-import PaneState from '../../pane-state'
9-import Detail from '../detail'
10-import type { Kit } from '../kit'
11-import Layout from '../layout'
12-import Sections from '../sections'
13-import { fileRowOf } from './file-row-of'
14-import { listBodyOf } from './list-body-of'
15-import { messagePaneOf } from './message-pane-of'
16-
17-/**
18- * The pane over the repository's diff now (ReplDiffSidebarBody, one file's
19- * body at a time): header, base line, todo bar, pickers, rows, detail.
20- *
21- * The toggle sits above the rows; the elision count, withheld-untracked
22- * note and pre-session line below, with blank rows as the built-in leaves.
23- * With nothing to list, messagePaneOf draws the body, the header a close.
24- *
25- * @param kit the elements, the handlers, the width, the rows
26- * @param model the pane's state
27- * @returns the pane's tree
28- */
29-export function currentPane(
30- kit: Kit,
31- model: PaneState.PaneModel,
32-): RenderElement {
33- const { Box } = kit.ui
34- const { data } = model
35-
36- if (model.isOutsideRepository) {
37- return <Box>{Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)}</Box>
38- }
39-
40- if (!data && !model.hasSettled) {
41- return messagePaneOf(kit, {
42- top: [Sections.headerView(kit, null)],
43- message: ['Loading diff…'],
44- controls: null,
45- earlier: null,
46- rest: [],
47- })
48- }
49-
50- const noise = model.isNoiseShown ? 'shown' : 'hidden'
51- const preSession = model.isPreSessionShown ? 'shown' : 'hidden'
52- const partition = PaneState.partitionOf(data?.files ?? [], noise)
53-
54- const totals = data
55- ? PaneState.headerTotalsOf(data, partition)
56- : PaneState.ZERO_TOTALS
57-
58- const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words)
59- const baseLabel = PaneState.baseLabelOf(model, totals.filesCount)
60-
61- const selected = PaneState.selectionOf(
62- PaneState.listedOf(partition, preSession),
63- model.selectedPath,
64- )
65-
66- const rowOf = (file: Git.FileStat): RenderElement =>
67- Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () =>
68- kit.actions.selectFile(file.path),
69- )
70-
71- const noteOf = (text: string | null): RenderElement | null =>
72- text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
73-
74- const noiseFace = model.isNoiseShown ? 'hide' : 'show'
75- const noiseCount = Layout.plural(partition.noiseCount, 'test')
76- const hasNoise = partition.noiseCount > 0
77-
78- const noiseToggle = hasNoise
79- ? Sections.toggleRow(kit, 'noise', {
80- label: `${noiseCount}/generated (${noiseFace})`,
81- onPress: kit.actions.toggleNoise,
82- })
83- : null
84-
85- const earlierFace = model.isPreSessionShown ? 'hide' : 'show'
86- const earlierCount = Layout.plural(partition.preSession.length, 'file')
87- const hasEarlier = partition.preSession.length > 0
88-
89- const earlierToggle = hasEarlier
90- ? Sections.toggleRow(kit, 'presession', {
91- label: `+${earlierCount} edited before this session (${earlierFace})`,
92- onPress: kit.actions.togglePreSession,
93- })
94- : null
95-
96- const earlierFiles = model.isPreSessionShown ? partition.preSession : []
97- const isEarlierListed = earlierFiles.length > 0
98-
99- const earlierRows = isEarlierListed
100- ? [<Box height={1} />, ...earlierFiles.map(rowOf)]
101- : []
102-
103- const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty
104- const listed = listBodyOf(kit, { data, partition, totals, empty })
105- const notes = listed.filter(line => typeof line === 'string')
106- const hasRows = notes.length < listed.length
107- const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
108- const header = Sections.headerView(kit, empty ? null : totals)
109-
110- const notShownNote = noteOf(
111- totals.notShown > 0 ? `${totals.notShown} not shown` : null,
112- )
113-
114- const untrackedNote = noteOf(
115- isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null,
116- )
117-
118- const detail = selected
119- ? [
120- Sections.divider(kit),
121- Detail.detailView(
122- kit,
123- {
124- words: model.words,
125- path: selected.path,
126- renamedFrom: selected.renamedFrom,
127- isUntracked: selected.isUntracked,
128- isBinary: selected.isBinary,
129- body: model.body,
130- bodyState: model.bodyState,
131- isArmed: model.armedPath === selected.path,
132- },
133- () => kit.actions.toggleAsk(selected.path),
134- ),
135- ]
136- : []
137-
138- const isMessageShown = message.length > 0
139-
140- return isMessageShown ? (
141- messagePaneOf(kit, {
142- top: Sections.present([
143- header,
144- noteOf(baseLabel),
145- Sections.todoBar(kit, model),
146- noiseToggle,
147- notShownNote,
148- untrackedNote,
149- ]),
150- message,
151- controls: Sections.controlsView(kit, model),
152- earlier: earlierToggle,
153- rest: [...earlierRows, ...detail],
154- })
155- ) : (
156- <Box flexDirection="column">
157- {Sections.present([
158- header,
159- noteOf(baseLabel),
160- Sections.todoBar(kit, model),
161- Sections.controlsView(kit, model),
162- noiseToggle,
163- ...listed.map(line =>
164- typeof line === 'string' ? noteOf(line) : rowOf(line),
165- ),
166- notShownNote,
167- untrackedNote,
168- hasEarlier ? <Box height={1} /> : null,
169- earlierToggle,
170- ...earlierRows,
171- ...detail,
172- ])}
173- </Box>
174- )
175-}
Dmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts+0 -34
@@ -1,34 +1,0 @@
1-import type Git from '../../../git'
2-import Sections from '../../sections'
3-
4-/**
5- * A fetched row as the list draws it: its counts, or a note in their
6- * place (`untracked`, `Binary file`, `renamed`), and selection.
7- *
8- * @param file the row
9- * @param selectedPath the selected file's path, or null
10- * @returns the row model
11- */
12-export function fileRowOf(
13- file: Git.FileStat,
14- selectedPath: string | null,
15-): Sections.FileRowModel {
16- const isRenamed = file.renamedFrom !== null
17-
18- const note = file.isUntracked
19- ? 'untracked'
20- : file.isBinary
21- ? 'Binary file'
22- : isRenamed
23- ? 'renamed'
24- : null
25-
26- return {
27- key: Sections.fileKeyOf(file.path),
28- path: file.path,
29- added: file.added,
30- removed: file.removed,
31- note,
32- isSelected: file.path === selectedPath,
33- }
34-}
Dmods/diff/hooks/views/current-pane/file-row-of/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './file-row-of.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/index.ts+0 -6
@@ -1,6 +1,0 @@
1-export * from './current-pane.jsx'
2-export * from './file-row-of'
3-export * from './list-body-of'
4-export * from './message-pane-of'
5-
6-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/index.ts+0 -4
@@ -1,4 +1,0 @@
1-export * from './list-body-of.js'
2-export * from './list-place'
3-
4-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/list-body-of.ts+0 -46
@@ -1,46 +1,0 @@
1-import type Git from '../../../git'
2-import type { Kit } from '../../kit'
3-import type { ListPlace } from './list-place'
4-
5-/**
6- * The list's body as ReplDiffSidebarBody decides it: the empty state's
7- * hint, the too-many state, the only-noise state, or the session's rows.
8- *
9- * Strings are dim lines; rows are files to draw.
10- *
11- * @param _kit unused; keeps the part's shape
12- * @param place the fetch, its groups, the counts, the empty state
13- * @returns the lines and rows, in order
14- */
15-export function listBodyOf(
16- _kit: Kit,
17- place: ListPlace,
18-): readonly (string | Git.FileStat)[] {
19- const { data, partition, totals, empty } = place
20-
21- const hint = empty?.hint ?? null
22-
23- if (empty) {
24- return hint === null ? [] : [hint]
25- }
26-
27- const isTooMany =
28- data !== null && data.files.length === 0 && totals.filesCount > 0
29-
30- if (isTooMany) {
31- return [
32- 'Too many changed files to show diff',
33- 'Per-file diff is skipped above 500 files',
34- ]
35- }
36-
37- const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0
38-
39- return isOnlyNoise
40- ? [
41- 'Only tests and generated files changed',
42- 'Tests and generated files are hidden · click "show" above to view ' +
43- 'them',
44- ]
45- : partition.shown
46-}
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './list-place.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/list-body-of/list-place/list-place.ts+0 -13
@@ -1,13 +1,0 @@
1-import type Git from '../../../../git'
2-import type PaneState from '../../../../pane-state'
3-
4-/**
5- * What decides the list's body: the fetch, its grouped rows, the header's
6- * counts, and the empty state when there is one.
7- */
8-export type ListPlace = {
9- data: Git.DiffData | null
10- partition: PaneState.Partition
11- totals: PaneState.HeaderTotals
12- empty: PaneState.EmptyState | null
13-}
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/foot-rows.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The rows the pre-session line takes at the pane's foot: a blank row, the
3- * line, and a blank row under it.
4- */
5-export const FOOT_ROWS = 3
Dmods/diff/hooks/views/current-pane/message-pane-of/foot-rows/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './foot-rows.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/index.ts+0 -5
@@ -1,5 +1,0 @@
1-export * from './foot-rows'
2-export * from './message-pane'
3-export * from './message-pane-of.jsx'
4-
5-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './message-pane.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane/message-pane.ts+0 -13
@@ -1,13 +1,0 @@
1-import type { RenderElement } from 'claude-code'
2-
3-/**
4- * What stands around a message in the pane: the header block, the message,
5- * the pickers, the pre-session line, and what is listed under it.
6- */
7-export type MessagePane = {
8- top: readonly RenderElement[]
9- message: readonly string[]
10- controls: RenderElement | null
11- earlier: RenderElement | null
12- rest: readonly RenderElement[]
13-}
Dmods/diff/hooks/views/current-pane/message-pane-of/message-pane-of.tsx+0 -85
@@ -1,85 +1,0 @@
1-/* @jsxRuntime classic */
2-/* @jsx h */
3-/* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
6-import type { Kit } from '../../kit'
7-import Layout from '../../layout'
8-import { FOOT_ROWS } from './foot-rows'
9-import type { MessagePane } from './message-pane'
10-
11-/**
12- * The pane while a message stands in for the list (ReplDiffSidebar's
13- * centered body): the header, a blank row, the message, the pickers.
14- *
15- * With nothing listed below and room, the lines sit where the built-in
16- * centers them (half the spare rows above), the pickers under them;
17- * otherwise all stacks from the top, the pickers over the pre-session line.
18- *
19- * @param kit the elements, the width, the body's rows
20- * @param pane the header block, the message's lines, the pickers, the
21- * pre-session line, and what is listed under it
22- * @returns the pane's tree
23- */
24-export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement {
25- const { Box, Text } = kit.ui
26- const { top, controls, earlier, rest } = pane
27-
28- const lines = pane.message.flatMap(text =>
29- Layout.wrappedLines(Layout.sanitizeName(text), kit.columns),
30- )
31-
32- const drawn = lines.map(line => <Text dimColor>{line}</Text>)
33- const controlRows = controls === null ? [] : [controls]
34- const earlierRows = earlier === null ? [] : [earlier]
35-
36- const isControlsAboveEarlier =
37- controlRows.length > 0 || earlierRows.length === 0
38-
39- const aboveEarlier = isControlsAboveEarlier
40- ? controlRows
41- : [<Box height={1} />]
42-
43- const footRows = earlier === null ? 0 : FOOT_ROWS
44- const middleRows = kit.rows - top.length - 1 - footRows
45- const above = Math.floor((middleRows - lines.length) / 2)
46-
47- const isCentered =
48- rest.length === 0 && middleRows - above - lines.length >= controlRows.length
49-
50- if (!isCentered) {
51- return (
52- <Box flexDirection="column">
53- {[
54- ...top,
55- <Box height={1} />,
56- ...drawn,
57- ...aboveEarlier,
58- ...earlierRows,
59- ...rest,
60- ]}
61- </Box>
62- )
63- }
64-
65- const spacer = above > 0 ? [<Box height={above} />] : []
66-
67- const foot = earlierRows.map(row => (
68- <Box flexDirection="column" marginTop={1} paddingBottom={1}>
69- {row}
70- </Box>
71- ))
72-
73- return (
74- <Box flexDirection="column" height={kit.rows}>
75- {[
76- ...top,
77- <Box height={1} />,
78- <Box flexDirection="column" height={middleRows} alignItems="center">
79- {[...spacer, ...drawn, ...controlRows]}
80- </Box>,
81- ...foot,
82- ]}
83- </Box>
84- )
85-}
@@ -1,0 +1,12 @@
1+import { MAX_BODY_CHARS } from '../max-body-chars'
2+import { MAX_BODY_NODES } from '../max-body-nodes'
3+import type Types from '../types'
4+
5+/**
6+ * The room the pane's file bodies start from at each drawing, spent file by
7+ * file in drawing order.
8+ */
9+export const BODY_BUDGET: Types.BodyRoom = Object.freeze({
10+ chars: MAX_BODY_CHARS,
11+ nodes: MAX_BODY_NODES,
12+})
@@ -1,0 +1,3 @@
1+export * from './body-budget.js'
2+
3+export * as default from '.'
@@ -1,82 +1,62 @@
11 import Git from '../../../git'
2-import { HUNK_DIVIDER } from '../hunk-divider'
3-import { HUNK_DIVIDER_NODES } from '../hunk-divider-nodes'
42 import { hunkSourceOf } from '../hunk-source-of'
53 import { leadingHunkOf } from '../leading-hunk-of'
6-import { MAX_BODY_CHARS } from '../max-body-chars'
7-import { MAX_BODY_NODES } from '../max-body-nodes'
84 import { MAX_CODE_CHARS } from '../max-code-chars'
95 import { subHunksOf } from '../sub-hunks-of'
106 import type Types from '../types'
117 import { drawnLineOf } from './drawn-line-of'
128
139 /**
14- * A file's hunks as the sources of `Code` blocks, each at most
15- * MAX_CODE_CHARS, hunks sharing a block (the engine's ellipsis between).
10+ * A file's hunks as the sources of `Code` blocks, one a hunk, stacked with
11+ * nothing between as the built-in stacks a file's hunks.
1612 *
17- * A new block at a hunk gets HUNK_DIVIDER; a hunk too long for one is split
18- * (subHunksOf) into blocks stacked with none. The body stops, truncated, at
19- * the whole lines MAX_BODY_CHARS and MAX_BODY_NODES hold, as the host counts.
13+ * A hunk longer than MAX_CODE_CHARS is split (subHunksOf) into several. The
14+ * body stops, truncated, at the whole lines the room holds as the host
15+ * counts, a block a node; the room left comes back.
2016 *
2117 * @param hunks the file's hunks
22- * @returns the blocks in order, and whether any line was left out or cut
18+ * @param room the characters and nodes the pane's bodies have left
19+ * @returns the sources in order, whether any line was left out or cut, and
20+ * the room after them
2321 */
24-export function codeBlocksOf(hunks: readonly Git.Hunk[]): Types.CodeBody {
25- const blocks: Types.CodeBlock[] = []
22+export function codeBlocksOf(
23+ hunks: readonly Git.Hunk[],
24+ room: Types.BodyRoom,
25+): Types.CodeBody {
26+ const sources: string[] = []
2627
2728 let chars = 0
28- let nodes = 0
2929 let isTruncated = false
3030
31+ const left = (): Types.BodyRoom => ({
32+ chars: room.chars - chars,
33+ nodes: room.nodes - sources.length,
34+ })
35+
3136 for (const hunk of hunks) {
3237 const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf)
3338 const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS)
3439 isTruncated ||= split.isTruncated
3540
36- for (const [at, piece] of split.hunks.entries()) {
37- const last = blocks.at(-1)
38- const source = hunkSourceOf(piece)
39- const isHunkStart = at === 0
41+ for (const piece of split.hunks) {
42+ const fits = room.chars - chars
43+ const isWhole = hunkSourceOf(piece).length <= fits
44+ const fitted = isWhole ? piece : leadingHunkOf(piece, fits)
45+ const isOverNodes = sources.length + 1 > room.nodes
4046
41- const isJoining =
42- isHunkStart &&
43- last !== undefined &&
44- last.source.length + 1 + source.length <= MAX_CODE_CHARS
45-
46- const hasDivider = isHunkStart && !isJoining && last !== undefined
47- const overhead = isJoining ? 1 : hasDivider ? HUNK_DIVIDER.length : 0
48-
49- const addedNodes = isJoining
50- ? 0
51- : 1 + (hasDivider ? HUNK_DIVIDER_NODES : 0)
52-
53- const room = MAX_BODY_CHARS - chars - overhead
54- const fitted = source.length <= room ? piece : leadingHunkOf(piece, room)
55- const isOverNodes = nodes + addedNodes > MAX_BODY_NODES
56-
5747 if (isOverNodes || fitted.lines.length === 0) {
58- return { blocks, isTruncated: true }
48+ return { sources, isTruncated: true, room: left() }
5949 }
6050
6151 const text = hunkSourceOf(fitted)
52+ sources.push(text)
53+ chars += text.length
6254
63- if (isJoining) {
64- blocks.pop()
65- }
66-
67- blocks.push({
68- source: isJoining ? `${last.source}\n${text}` : text,
69- hasDivider: isJoining ? last.hasDivider : hasDivider,
70- })
71-
72- chars += overhead + text.length
73- nodes += addedNodes
74-
7555 if (fitted.lines.length < piece.lines.length) {
76- return { blocks, isTruncated: true }
56+ return { sources, isTruncated: true, room: left() }
7757 }
7858 }
7959 }
8060
81- return { blocks, isTruncated }
61+ return { sources, isTruncated, room: left() }
8262 }
@@ -1,20 +1,19 @@
11 import type Backend from '../../../backend'
22 import type Git from '../../../git'
3-import type PaneState from '../../../pane-state'
43
54 /**
6- * The selected file as its body draws it.
5+ * One file as its body draws it.
76 *
8- * The path, what kind of row it is, its hunks once read, whether it is armed
9- * for the next prompt, and the backend's words for an untracked row's note.
7+ * Its name as listed, what kind of row it is, its hunks once read (undefined
8+ * while they load, null when the backend could not read them), whether it is
9+ * armed for the next prompt, and the backend's words for an untracked note.
1010 */
1111 export type DetailModel = {
1212 words: Pick<Backend.BackendWords, 'untrackedNoteOf'>
1313 path: string
14- renamedFrom: string | null
14+ displayPath: string
1515 isUntracked: boolean
1616 isBinary: boolean
17- body: Git.FileHunks | null
18- bodyState: PaneState.BodyState
17+ body: Git.FileHunks | null | undefined
1918 isArmed: boolean
2019 }
@@ -1,63 +1,45 @@
11 /* @jsxRuntime classic */
22 /* @jsx h */
33 /* @jsxFrag Fragment */
4-import type { RenderElement } from 'claude-code'
5-
64 import type { Kit } from '../kit'
75 import Layout from '../layout'
86 import Sections from '../sections'
97 import { codeBlocksOf } from './code-blocks-of'
108 import type { DetailModel } from './detail-model'
11-import { HUNK_DIVIDER } from './hunk-divider'
9+import { FILE_FRAME_NODES } from './file-frame-nodes'
1210 import { MAX_CODE_CHARS } from './max-code-chars'
1311 import { placeholderOf } from './placeholder-of'
12+import type Types from './types'
1413
1514 /**
16- * The selected file under the list: its bold path and asides, the ask
17- * Button, the built-in's dim rule under them, the body.
15+ * One file's detail (DiffDetailView): its name row (nameRow), the
16+ * built-in's dim rule under it, the body.
1817 *
19- * Each name is cut from its start to the width. The body is a placeholder,
20- * or the hunks as the engine's diff `Code` blocks (codeBlocksOf), a dim
21- * divider where two meet, then the footer when anything was cut.
18+ * The body is a placeholder, or the hunks as the engine's diff `Code`
19+ * blocks (codeBlocksOf) within the room given, then a footer when anything
20+ * was cut; with it, the room left over.
2221 *
2322 * @param kit the elements and the width
24- * @param detail the selected file
25- * @param onToggleAsk arms or disarms the file for the next prompt
26- * @returns the detail element
23+ * @param detail the file
24+ * @param room the room the pane's bodies have left
25+ * @returns the detail element and the room after it
2726 */
2827 export function detailView(
2928 kit: Kit,
3029 detail: DetailModel,
31- onToggleAsk: () => void,
32-): RenderElement {
33- const { Box, Text, Button, Code } = kit.ui
30+ room: Types.BodyRoom,
31+): Types.DrawnDetail {
32+ const { Box, Text, Code } = kit.ui
3433 const placeholder = placeholderOf(detail)
35- const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []))
34+
35+ const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []), {
36+ chars: room.chars - Layout.sanitizeName(detail.displayPath).length,
37+ nodes: room.nodes - FILE_FRAME_NODES,
38+ })
39+
3640 const isTruncated = detail.body?.isTruncated === true || code.isTruncated
3741 const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS)
3842
39- const nameOf = (name: string) =>
40- Layout.truncateStart(Layout.sanitizeName(name), kit.columns)
41-
42- const renamedFrom =
43- detail.renamedFrom === null ? null : nameOf(detail.renamedFrom)
44-
45- const asides = [
46- renamedFrom === null ? null : `renamed from ${renamedFrom}`,
47- detail.isUntracked ? 'untracked' : null,
48- isTruncated ? 'truncated' : null,
49- ].filter(word => word !== null)
50-
51- const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})`
52-
53- const ask = placeholder
54- ? []
55- : [
56- <Button key="ask" onPress={onToggleAsk}>
57- {detail.isArmed ? 'asked ✓' : 'ask'}
58- </Button>,
59- ]
60-
6143 const footer = isTruncated
6244 ? [
6345 <Text dimColor italic>
@@ -72,28 +54,24 @@
7254 </Text>
7355 ))
7456
75- const hunks = code.blocks.flatMap(block => [
76- ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []),
77- <Code source={block.source} format="diff" path={path} />,
78- ])
57+ const hunks = code.sources.map(source => (
58+ <Code source={source} format="diff" path={path} />
59+ ))
7960
80- return (
61+ const element = (
8162 <Box flexDirection="column">
8263 {[
83- <Box flexDirection="row">
84- {[
85- <Text bold wrap="truncate-start">
86- {nameOf(detail.path)}
87- </Text>,
88- <Text dimColor>{aside}</Text>,
89- <Box flexGrow={1} />,
90- ...ask,
91- ]}
92- </Box>,
64+ Sections.nameRow(kit, {
65+ ...detail,
66+ isTruncated,
67+ isAskable: placeholder === null,
68+ }),
9369 Sections.divider(kit),
9470 ...(placeholder ? notes : hunks),
9571 ...footer,
9672 ]}
9773 </Box>
9874 )
75+
76+ return { element, room: code.room }
9977 }
@@ -1,0 +1,5 @@
1+/**
2+ * The tree nodes one file's detail costs before any `Code` block: its box,
3+ * the name row and its parts, the two rules, a note or footer.
4+ */
5+export const FILE_FRAME_NODES = 16
@@ -1,0 +1,3 @@
1+export * from './file-frame-nodes.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-divider/hunk-divider.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The dim text between two `Code` blocks that meet at a hunk boundary: the
3- * ellipsis the engine draws between the hunks of one `Code`.
4- */
5-export const HUNK_DIVIDER = '...'
Dmods/diff/hooks/views/detail/hunk-divider/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './hunk-divider.js'
2-
3-export * as default from '.'
Dmods/diff/hooks/views/detail/hunk-divider-nodes/hunk-divider-nodes.ts+0 -4
@@ -1,4 +1,0 @@
1-/**
2- * The nodes one HUNK_DIVIDER costs the tree: its Text and its string.
3- */
4-export const HUNK_DIVIDER_NODES = 2
Dmods/diff/hooks/views/detail/hunk-divider-nodes/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './hunk-divider-nodes.js'
2-
3-export * as default from '.'
@@ -1,8 +1,8 @@
1+export * from './body-budget'
12 export * from './code-blocks-of'
23 export * from './detail-model'
34 export * from './detail-view.jsx'
4-export * from './hunk-divider'
5-export * from './hunk-divider-nodes'
5+export * from './file-frame-nodes'
66 export * from './hunk-header-of'
77 export * from './hunk-source-of'
88 export * from './leading-hunk-of'
@@ -1,6 +1,6 @@
11 /**
2- * The most characters a file's drawn body holds, its `Code` sources and
3- * dividers in all.
2+ * The most characters the drawn file bodies hold between them, their
3+ * `Code` sources and dividers in all.
44 *
55 * Under the host's whole-tree cap (100 000), with room for the pane's rest.
66 */
@@ -1,7 +1,8 @@
11 /**
2- * The most nodes a file's drawn body holds, a `Code` one and a divider two.
2+ * The most nodes the drawn file bodies hold between them, a hunk's `Code`
3+ * one, a file's frame FILE_FRAME_NODES.
34 *
4- * Under the host's whole-tree cap (2 000), with room for the list and the
5- * pane's rest.
5+ * Under the host's whole-tree cap (2 000), with room for the list, the
6+ * pre-session legend and the pane's rest.
67 */
7-export const MAX_BODY_NODES = 1_200
8+export const MAX_BODY_NODES = 1_500
@@ -9,7 +9,7 @@
99 * letters, digits and `._/@+-`), binary, loading, unreadable, large, or
1010 * empty.
1111 *
12- * @param detail the selected file
12+ * @param detail the file
1313 * @returns the lines, or null
1414 */
1515 export function placeholderOf(detail: DetailModel): readonly string[] | null {
@@ -24,11 +24,11 @@
2424 return ['Binary file - cannot display diff']
2525 }
2626
27- if (detail.bodyState === 'idle' || detail.bodyState === 'loading') {
27+ if (detail.body === undefined) {
2828 return ['Loading diff…']
2929 }
3030
31- if (detail.bodyState === 'failed' || !detail.body) {
31+ if (detail.body === null) {
3232 return ['Diff unavailable']
3333 }
3434
@@ -1,5 +1,4 @@
1-import { countOf } from '../../../count-of'
2-import type Git from '../../../git'
1+import Git from '../../../git'
32 import { leadingHunkOf } from '../leading-hunk-of'
43 import { firstLineCut } from './first-line-cut'
54
@@ -31,16 +30,9 @@
3130 ? { ...rest, lines: [firstLineCut(rest, maxChars)] }
3231 : leading
3332
34- const taken = rest.lines.slice(0, piece.lines.length)
35-
3633 hunks.push(piece)
3734 isTruncated ||= isLineCut
38-
39- rest = {
40- oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')),
41- newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')),
42- lines: rest.lines.slice(piece.lines.length),
43- }
35+ rest = Git.hunkAfter(rest, piece.lines.length)
4436 }
4537
4638 return { hunks, isTruncated }
@@ -1,0 +1,8 @@
1+/**
2+ * What is left of the pane's budget for file bodies: characters of `Code`
3+ * source and tree nodes, both counted as the host counts a tree.
4+ */
5+export type BodyRoom = {
6+ chars: number
7+ nodes: number
8+}
@@ -1,0 +1,3 @@
1+export type * from './body-room.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/views/detail/types/code-block/code-block.ts+0 -8
@@ -1,8 +1,0 @@
1-/**
2- * One `Code` element of a file's body: its unified-diff source, and whether
3- * a divider stands above it (it opens a hunk after another block).
4- */
5-export type CodeBlock = {
6- source: string
7- hasDivider: boolean
8-}
Dmods/diff/hooks/views/detail/types/code-block/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './code-block.js'
2-
3-export * as default from '.'
@@ -1,10 +1,11 @@
1-import type { CodeBlock } from '../code-block'
1+import type { BodyRoom } from '../body-room'
22
33 /**
4- * A file's hunks as the body draws them: the `Code` blocks in order, and
5- * whether the body's budget or a source's cap left any of a line out.
4+ * A file's hunks as the body draws them: one `Code` source per hunk in
5+ * order, whether any of a line was left out, and the room after them.
66 */
77 export type CodeBody = {
8- blocks: readonly CodeBlock[]
8+ sources: readonly string[]
99 isTruncated: boolean
10+ room: BodyRoom
1011 }
@@ -1,0 +1,11 @@
1+import type { RenderElement } from 'claude-code'
2+
3+import type { BodyRoom } from '../body-room'
4+
5+/**
6+ * One file's detail as drawn, and the room left for the files after it.
7+ */
8+export type DrawnDetail = {
9+ element: RenderElement
10+ room: BodyRoom
11+}
@@ -1,0 +1,3 @@
1+export type * from './drawn-detail.js'
2+
3+export * as default from '.'
@@ -1,4 +1,6 @@
1-export * from './code-block'
1+export * from './body-room'
22 export * from './code-body'
3+export * from './drawn-detail'
4+export * from './named-detail'
35
46 export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './named-detail.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+import type { DetailModel } from '../../detail-model'
2+
3+/**
4+ * A file as its name row draws it: the detail, whether its body was cut,
5+ * and whether it can be asked about (it has hunks to attach).
6+ */
7+export type NamedDetail = DetailModel & {
8+ isTruncated: boolean
9+ isAskable: boolean
10+}
@@ -1,0 +1,24 @@
1+import Git from '../../../git'
2+import PaneState from '../../../pane-state'
3+import Entries from '../../entries'
4+
5+/**
6+ * The files the dialog lists, in its order: the picked turn's, or every
7+ * file of the fetch, pre-session or not, sorted by its displayed name.
8+ *
9+ * @param model the pane's state
10+ * @returns the entries
11+ */
12+export function dialogEntriesOf(
13+ model: PaneState.PaneModel,
14+): readonly Entries.BodyEntry[] {
15+ const turn = PaneState.pickedTurnOf(model)
16+
17+ return turn
18+ ? turn.files.map(Entries.turnEntryOf)
19+ : [...(model.data?.files ?? [])]
20+ .sort((a, b) =>
21+ Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)),
22+ )
23+ .map(file => Entries.bodyEntryOf(file, model))
24+}
@@ -1,0 +1,3 @@
1+export * from './dialog-entries-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,46 @@
1+import Limits from '../../limits'
2+import type PaneState from '../../pane-state'
3+import Sections from '../sections'
4+import { dialogEntriesOf } from './dialog-entries-of'
5+import { dialogWindowOf } from './dialog-window-of'
6+
7+/**
8+ * What the focus ring landing on a dialog row makes of the list: that file
9+ * selected, the window re-centred, the key of the row drawn where it sits.
10+ *
11+ * DiffFileList's selection, which stops at an end where the ring wraps: a
12+ * jump across three or more rows from the first or last file is `stay`.
13+ * The ring goes to the row holding the file's place after the re-centre.
14+ *
15+ * @param model the pane's state
16+ * @param element the key of the element taking the ring
17+ * @returns the selection and the key to land on, `stay`, or null for a key
18+ * that is none of the listed rows'
19+ */
20+export function dialogFocusOf(
21+ model: PaneState.PaneModel,
22+ element: string | undefined,
23+): PaneState.DialogFocus | 'stay' | null {
24+ const entries = dialogEntriesOf(model)
25+ const paths = entries.map(entry => entry.path)
26+ const keys = entries.map(entry => Sections.fileKeyOf(entry))
27+ const picked = keys.indexOf(element ?? '')
28+ const current = Math.max(0, paths.indexOf(model.selectedPath ?? ''))
29+ const before = dialogWindowOf(model, paths)
30+
31+ const lastDrawn =
32+ Math.min(paths.length, before + Limits.MAX_VISIBLE_FILES) - 1
33+
34+ const selectedPath = paths[picked] ?? null
35+
36+ const landing =
37+ keys[before + picked - dialogWindowOf({ selectedPath }, paths)]
38+
39+ const isOverStart = current === 0 && picked === lastDrawn
40+ const isOverEnd = current === paths.length - 1 && picked === before
41+ const isFar = lastDrawn - before >= 2
42+ const isRow = selectedPath !== null && landing !== undefined
43+ const isWrap = isFar && (isOverEnd || isOverStart)
44+
45+ return isWrap ? 'stay' : isRow ? { selectedPath, landing } : null
46+}
@@ -1,0 +1,146 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../limits'
7+import PaneState from '../../pane-state'
8+import Detail from '../detail'
9+import Entries from '../entries'
10+import type { Kit } from '../kit'
11+import Layout from '../layout'
12+import { preludeOf } from '../prelude-of'
13+import Sections from '../sections'
14+import { dialogEntriesOf } from './dialog-entries-of'
15+import { dialogWindowOf } from './dialog-window-of'
16+
17+/**
18+ * The inline pane (DiffDialog): its title, the dim count, then the file
19+ * list's window, or the picked file's detail alone, and the key hints.
20+ *
21+ * Every file lists in the built-in's order, five at a time round the
22+ * selected row (`❯ `, where the focus ring starts and what its walk moves,
23+ * ui.focus); Enter views that file, Escape backs out or closes.
24+ *
25+ * @param kit the elements, the handlers, the width
26+ * @param model the pane's state
27+ * @returns the pane's tree
28+ */
29+export function dialogPane(
30+ kit: Kit,
31+ model: PaneState.PaneModel,
32+): RenderElement {
33+ const { Box, Text } = kit.ui
34+ const prelude = preludeOf(kit, model)
35+
36+ if (prelude) {
37+ return prelude
38+ }
39+
40+ const turn = PaneState.pickedTurnOf(model)
41+ const { data } = model
42+ const entries = dialogEntriesOf(model)
43+ const totals = turn ? PaneState.turnTotalsOf(turn) : (data?.stats ?? null)
44+ const isTurn = model.source.kind === 'turn'
45+ const isPaged = entries.length > Limits.MAX_VISIBLE_FILES
46+ const picked = entries.find(entry => entry.path === model.selectedPath)
47+ const selected = picked ?? entries[0]
48+ const isDetail = model.dialogView === 'detail' && picked !== undefined
49+
50+ const start = dialogWindowOf(
51+ model,
52+ entries.map(entry => entry.path),
53+ )
54+
55+ const shown = entries.slice(start, start + Limits.MAX_VISIBLE_FILES)
56+ const blankEdge = Sections.dimNote(kit, ' ')
57+ const below = entries.length - start - shown.length
58+
59+ const isTooMany =
60+ !isTurn &&
61+ data !== null &&
62+ data.stats.filesCount > 0 &&
63+ entries.length === 0
64+
65+ const emptyWords = isTurn
66+ ? 'No file changes in this turn'
67+ : isTooMany
68+ ? 'Too many files to display details'
69+ : 'No changes yet'
70+
71+ const message = entries.length > 0 ? null : emptyWords
72+
73+ const subtitle = totals && (
74+ <Text dimColor wrap="truncate-end">
75+ {`${Layout.plural(totals.filesCount, 'file')} changed `}
76+ {Sections.diffStat(kit, totals.linesAdded, totals.linesRemoved)}
77+ </Text>
78+ )
79+
80+ const noteOf = (entry: Entries.BodyEntry) =>
81+ entry.isUntracked ? 'untracked' : entry.isBinary ? 'Binary file' : null
82+
83+ const rowOf = (entry: Entries.BodyEntry) =>
84+ Sections.dialogFileRow(
85+ kit,
86+ {
87+ key: Sections.fileKeyOf(entry),
88+ path: entry.path,
89+ displayPath: entry.displayPath,
90+ added: entry.added,
91+ removed: entry.removed,
92+ note: noteOf(entry),
93+ isSelected: entry === selected,
94+ },
95+ () => kit.actions.selectFile(entry.path),
96+ )
97+
98+ function moreOf(way: 'up' | 'down', count: number): RenderElement {
99+ const arrow = way === 'up' ? '\u2191' : '\u2193'
100+ const words = Layout.plural(count, 'file').replace(' ', ' more ')
101+
102+ return Sections.dimNote(kit, ` ${arrow} ${words}`)
103+ }
104+
105+ const pagedEdgeOf = (way: 'up' | 'down', count: number) =>
106+ count > 0 ? moreOf(way, count) : blankEdge
107+
108+ const edgeOf = (way: 'up' | 'down', count: number) =>
109+ isPaged ? pagedEdgeOf(way, count) : null
110+
111+ const detailOf = (entry: Entries.BodyEntry): RenderElement[] => [
112+ Detail.detailView(
113+ kit,
114+ Entries.detailModelOf(entry, model),
115+ Detail.BODY_BUDGET,
116+ ).element,
117+ ]
118+
119+ const rows = Sections.present([
120+ edgeOf('up', start),
121+ ...shown.map(rowOf),
122+ edgeOf('down', below),
123+ ])
124+
125+ const listing = isDetail && picked ? detailOf(picked) : rows
126+ const body = message === null ? listing : [Sections.dimNote(kit, message)]
127+
128+ const hint = isDetail
129+ ? '\u2191/\u2193 to scroll \u00b7 Esc to back'
130+ : '\u2191/\u2193 to select \u00b7 Enter to view \u00b7 Esc to close'
131+
132+ return (
133+ <Box flexDirection="column">
134+ {Sections.present([
135+ Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
136+ <Box height={1} />,
137+ subtitle,
138+ Sections.sourcePicker(kit, model),
139+ <Box flexDirection="column" marginTop={1} marginBottom={1}>
140+ {body}
141+ </Box>,
142+ Sections.dimNote(kit, hint),
143+ ])}
144+ </Box>
145+ )
146+}
@@ -1,0 +1,46 @@
1+import Limits from '../../limits'
2+import PaneState from '../../pane-state'
3+import { dialogEntriesOf } from './dialog-entries-of'
4+import { dialogWindowOf } from './dialog-window-of'
5+
6+/**
7+ * The rows the dialog's content takes as dialogPane draws it, for the
8+ * `rows` an inline open asks for: title, blank, count, picker, body, hints.
9+ *
10+ * The body is the message line, the list's window with its `more files`
11+ * rows, or the file's name, rule and hunk lines (their wraps not counted:
12+ * the layout caps a long file anyway and its arrows scroll it).
13+ *
14+ * @param model the pane's state
15+ * @returns the rows wanted
16+ */
17+export function dialogRowsOf(model: PaneState.PaneModel): number {
18+ const entries = dialogEntriesOf(model)
19+
20+ const hasCount =
21+ PaneState.pickedTurnOf(model) !== undefined || model.data !== null
22+
23+ const isPaged = entries.length > Limits.MAX_VISIBLE_FILES
24+ const picked = entries.find(entry => entry.path === model.selectedPath)
25+ const isDetail = model.dialogView === 'detail' && picked !== undefined
26+
27+ const start = dialogWindowOf(
28+ model,
29+ entries.map(entry => entry.path),
30+ )
31+
32+ const lines = (picked?.body?.hunks ?? []).reduce(
33+ (sum, hunk) => sum + 1 + hunk.lines.length,
34+ 0,
35+ )
36+
37+ const listed = Math.min(entries.length - start, Limits.MAX_VISIBLE_FILES)
38+
39+ const isEmpty = entries.length === 0
40+ const listRows = isEmpty ? 1 : listed + (isPaged ? 2 : 0)
41+ const bodyRows = isDetail ? 2 + Math.max(1, lines) : listRows
42+
43+ return (
44+ 2 + Number(hasCount) + Number(model.turns.length > 0) + 2 + bodyRows + 1
45+ )
46+}
@@ -1,0 +1,23 @@
1+import Limits from '../../../limits'
2+import type PaneState from '../../../pane-state'
3+
4+/**
5+ * The first of the MAX_VISIBLE_FILES rows the dialog lists (DiffFileList's
6+ * window): centred on the selected file, the first when none is, clamped.
7+ *
8+ * @param model the pane's state: the selection
9+ * @param paths the listed files' paths, in order
10+ * @returns the first listed row's index
11+ */
12+export const dialogWindowOf = (
13+ model: Pick<PaneState.PaneModel, 'selectedPath'>,
14+ paths: readonly string[],
15+): number =>
16+ Math.max(
17+ 0,
18+ Math.min(
19+ paths.length - Limits.MAX_VISIBLE_FILES,
20+ Math.max(0, paths.indexOf(model.selectedPath ?? '')) -
21+ Math.floor(Limits.MAX_VISIBLE_FILES / 2),
22+ ),
23+ )
@@ -1,0 +1,3 @@
1+export * from './dialog-window-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+export * from './dialog-entries-of'
2+export * from './dialog-focus-of.js'
3+export * from './dialog-pane.jsx'
4+export * from './dialog-rows-of.js'
5+export * from './dialog-window-of'
6+export * from './list-body-of'
7+export * from './message-pane-of'
8+
9+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './list-body-of.js'
2+export * from './list-place'
3+
4+export * as default from '.'
@@ -1,0 +1,43 @@
1+import type Git from '../../../git'
2+import type { ListPlace } from './list-place'
3+
4+/**
5+ * The list's body as ReplDiffSidebarBody decides it: the empty state's
6+ * hint, the too-many state, the only-noise state, or the session's rows.
7+ *
8+ * Strings are dim lines; rows are files to draw.
9+ *
10+ * @param place the fetch, its groups, the counts, the empty state
11+ * @returns the lines and rows, in order
12+ */
13+export function listBodyOf(
14+ place: ListPlace,
15+): readonly (string | Git.FileStat)[] {
16+ const { data, partition, totals, empty } = place
17+
18+ const hint = empty?.hint ?? null
19+
20+ if (empty) {
21+ return hint === null ? [] : [hint]
22+ }
23+
24+ const isTooMany =
25+ data !== null && data.files.length === 0 && totals.filesCount > 0
26+
27+ if (isTooMany) {
28+ return [
29+ 'Too many changed files to show diff',
30+ 'Per-file diff is skipped above 500 files',
31+ ]
32+ }
33+
34+ const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0
35+
36+ return isOnlyNoise
37+ ? [
38+ 'Only tests and generated files changed',
39+ 'Tests and generated files are hidden · click "show" above to view ' +
40+ 'them',
41+ ]
42+ : partition.shown
43+}
@@ -1,0 +1,3 @@
1+export type * from './list-place.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type Git from '../../../../git'
2+import type PaneState from '../../../../pane-state'
3+
4+/**
5+ * What decides the list's body: the fetch, its grouped rows, the header's
6+ * counts, and the empty state when there is one.
7+ */
8+export type ListPlace = {
9+ data: Git.DiffData | null
10+ partition: PaneState.Partition
11+ totals: PaneState.HeaderTotals
12+ empty: PaneState.EmptyState | null
13+}
@@ -1,0 +1,5 @@
1+/**
2+ * The rows the pre-session line takes at the pane's foot: a blank row, the
3+ * line, and a blank row under it.
4+ */
5+export const FOOT_ROWS = 3
@@ -1,0 +1,3 @@
1+export * from './foot-rows.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './foot-rows'
2+export * from './message-pane'
3+export * from './message-pane-of.jsx'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './message-pane.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { RenderElement } from 'claude-code'
2+
3+/**
4+ * What stands around a message in the pane: the header block, the message,
5+ * the pickers, the pre-session line, and what is listed under it.
6+ */
7+export type MessagePane = {
8+ top: readonly RenderElement[]
9+ message: readonly string[]
10+ controls: RenderElement | null
11+ earlier: RenderElement | null
12+ rest: readonly RenderElement[]
13+}
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/close-button/close-button.tsx
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/close-button/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/controls-view.tsx
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/file-row/file-row.tsx
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/stat-cells/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/stat-cells/stat-cells.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/todo-bar-cells/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/todo-bar-cells/todo-bar-cells.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/todo-bar-reserve/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/sections/todo-bar-reserve/todo-bar-reserve.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/turn-pane/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/hooks/views/turn-pane/turn-pane.tsx
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/tests/fixtures/base-label-of/at-sha.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/tests/fixtures/base-label-of/index.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/tests/fixtures/base-label-of/one-file.ts
Diff not shown: this commit changes too much to display at once.
Dmods/diff/tests/fixtures/base-label-of/working-tree.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Dmods/diff/tests/pane-state/base-label-of.test.ts
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.