spacehub

surshar / claude-code

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

Built by Anthropic · github.com/anthropics

mods/diff: the docked pane draws as the built-in panel does (every file's hunks under an eight-row list, rows that scroll to their file, no focus or close mark of its own, /diff's transcript line) and the inline pane as its dialogBrowse files
poteat authored on Sep 13, 2026 parent 883b12c · commit 38dc1e4b6953
144 changed files+1645 -616compared with 883b12c
Mmods/diff/README.md
Amods/diff/hooks/drawn-files-of/drawn-files-of.ts
Amods/diff/hooks/drawn-files-of/index.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
Mmods/diff/hooks/limits/sizes/index.ts
Amods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts
Amods/diff/hooks/limits/sizes/pane/index.ts
Amods/diff/hooks/limits/sizes/pane/max-dialog-rows.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
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/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
Mmods/diff/hooks/names/texts/index.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-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
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
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
Mmods/diff/hooks/views/current-pane/current-pane.tsx
Amods/diff/hooks/views/current-pane/dialog-rows-of/dialog-rows-of.tsx
Amods/diff/hooks/views/current-pane/dialog-rows-of/index.ts
Mmods/diff/hooks/views/current-pane/file-row-of/file-row-of.ts
Mmods/diff/hooks/views/current-pane/index.ts
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
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/detail-draw/detail-draw.ts
Amods/diff/hooks/views/detail/types/detail-draw/index.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/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
Amods/diff/hooks/views/sections/body-key-of/body-key-of.ts
Amods/diff/hooks/views/sections/body-key-of/index.ts
Dmods/diff/hooks/views/sections/close-button/close-button.tsx
Dmods/diff/hooks/views/sections/close-button/index.ts
Mmods/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
Amods/diff/hooks/views/sections/dialog-path-floor/dialog-path-floor.ts
Amods/diff/hooks/views/sections/dialog-path-floor/index.ts
Amods/diff/hooks/views/sections/dialog-path-reserve/dialog-path-reserve.ts
Amods/diff/hooks/views/sections/dialog-path-reserve/index.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/list-row/index.ts
Amods/diff/hooks/views/sections/list-row/list-row.tsx
Amods/diff/hooks/views/sections/path-floor/index.ts
Amods/diff/hooks/views/sections/path-floor/path-floor.ts
Mmods/diff/hooks/views/sections/present/present.ts
Mmods/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
Amods/diff/hooks/views/selected-detail-of/index.ts
Amods/diff/hooks/views/selected-detail-of/selected-detail-of.ts
Amods/diff/hooks/views/sidebar-pane/body-frame/body-frame.ts
Amods/diff/hooks/views/sidebar-pane/body-frame/index.ts
Amods/diff/hooks/views/sidebar-pane/drawn-blocks/drawn-blocks.ts
Amods/diff/hooks/views/sidebar-pane/drawn-blocks/index.ts
Amods/diff/hooks/views/sidebar-pane/earlier-section-of/earlier-section-of.tsx
Amods/diff/hooks/views/sidebar-pane/earlier-section-of/index.ts
Amods/diff/hooks/views/sidebar-pane/file-blocks-of/file-blocks-of.tsx
Amods/diff/hooks/views/sidebar-pane/file-blocks-of/index.ts
Amods/diff/hooks/views/sidebar-pane/index.ts
Amods/diff/hooks/views/sidebar-pane/list-rows-of/index.ts
Amods/diff/hooks/views/sidebar-pane/list-rows-of/list-rows-of.ts
Amods/diff/hooks/views/sidebar-pane/sidebar-pane.tsx
Mmods/diff/hooks/views/turn-pane/turn-pane.tsx
@@ -1,16 +1,19 @@
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
4+beside the transcript, one row per changed file and every file's hunks
5+beneath, a row's click scrolling to its file, and closes it again; each
6+toggle leaves `Diff panel shown` or `Diff panel hidden` in the transcript.
7+The pane refreshes as Claude edits, runs shell commands and finishes turns,
8+and while it is open it polls the repository's HEAD so a commit or checkout
9+made elsewhere shows too. The first successful edit of a session opens the
10+pane by itself where the terminal is wide enough (144 columns when the
11+person never chose, 110 when they kept it open before; a person who closed
12+it is left alone). On a terminal under 110 columns `/diff` answers with the
13+built-in's line asking for a wider terminal and opens nothing; a pane the
14+surface seats inline on a terminal that wide shows only that line, and on a
15+wider one (a session without the fullscreen layout) the built-in dialog's
16+layout: the rows round the selected file and that file's hunks. A file's
1417 ask button arms that file: its hunks ride the next prompt as context, once.
1518
1619 The pane compares the working tree against HEAD, split at the session's
@@ -18,9 +21,10 @@
1821 the default branch; the choice is kept per repository in the plugin's
1922 store. A second picker shows one earlier turn's edits instead of the
2023 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.
24+the session started (by their timestamp, among the paths already dirty at
25+the start), and noise (lockfiles, generated and test files), are listed
26+apart and folded until asked for; a rename lists as git prints it. Outside
27+a git repository `/diff` says so and does nothing else.
2428
2529 `hooks/register.ts` is the module; everything under `hooks/` is its parts.
2630
@@ -30,8 +34,9 @@
3034 | --- | --- |
3135 | `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. |
3236 | `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. |
37+| `ui.render` of `Pane` | Draws the pane: header, the base and source pickers, the file list, the toggles, and the files' hunks. |
38+| `command.run` of `diff` | Opens or closes the pane, says which, and remembers the choice. |
39+| `ui.close` of the pane | Remembers the person's close as `/diff`'s. |
3540 | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. |
3641 | `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. |
3742 | `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. |
@@ -40,10 +45,10 @@
4045
4146 ## What it calls on `$`
4247
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`,
48+`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`,
49+`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`,
4550 `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`,
46-`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`.
51+`ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.scroll`, `ui.status`.
4752
4853 `$.telemetry` is the telemetry plugin's noun; where it is absent the rows
4954 are dropped and nothing else changes.
@@ -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 '.'
@@ -9,6 +9,7 @@
99 ProcessRunResult,
1010 SessionMessage,
1111 TimerCall,
12+ UiScrollResult,
1213 } from 'claude-code'
1314
1415 /**
@@ -32,11 +33,6 @@
3233 every: TimerCall
3334
3435 /**
35- * `$.clock.sleep`, no signal.
36- */
37- sleep: (ms: number) => Promise<void>
38-
39- /**
4036 * `$.process.run`.
4137 */
4238 run: (
@@ -100,6 +96,12 @@
10096 closePane: (pane: PaneCloseArgs) => Promise<void>
10197
10298 /**
99+ * `$.ui.scroll` to one of the pane's own keyed elements, its top at the
100+ * window's top.
101+ */
102+ scrollTo: (key: string) => Promise<UiScrollResult>
103+
104+ /**
103105 * `$.command.register`; rejects while another `/diff` is listed.
104106 */
105107 registerCommand: (spec: CommandSpec) => Promise<unknown>
@@ -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)
@@ -4,7 +4,7 @@
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 './pane'
88 export * from './prompt-context-max-chars.js'
99
1010 export * as default from '.'
@@ -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,7 @@
1+export * from './body-fetch-concurrency.js'
2+export * from './max-dialog-rows.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+
7+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How many file rows the inline pane's list shows at once, around the
3+ * selected one (the built-in dialog's window).
4+ */
5+export const MAX_DIALOG_ROWS = 5
@@ -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
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,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,6 +1,5 @@
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'
@@ -1,6 +1,7 @@
11 export * from './builtin-holds-pattern.js'
22 export * from './git-unanswered-text.js'
33 export * from './not-in-repository-text.js'
4+export * from './panel-toggle'
45 export * from './register-failed-text-of.js'
56 export * from './resize-terminal-text.js'
67 export * from './untracked-withheld-text-of.js'
@@ -1,0 +1,4 @@
1+export * from './panel-hidden-text.js'
2+export * from './panel-shown-text.js'
3+
4+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+ * 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,8 +1,9 @@
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-title'
6+export * from './dialog-title-of.js'
67 export * from './empty-state'
78 export * from './empty-state-of.js'
89 export * from './header-totals'
@@ -10,13 +11,16 @@
1011 export * from './initial-model'
1112 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'
1719 export * from './selection-of'
1820 export * from './source'
21+export * from './turn-title-of'
1922 export * from './turn-totals-of'
23+export * from './unborn-note-of.js'
2024 export * from './zero-totals'
2125
2226 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,8 @@
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',
2626 })
@@ -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'
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 files' bodies read so
12+ * far, the transcript's turns and todos, where the surface seated the pane
13+ * last, and the pinned backend's words and base modes (git's until pinned).
1414 */
1515 export type PaneModel = {
1616 words: Backend.BackendWords
@@ -25,9 +25,8 @@
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'
3332 }
@@ -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 {
@@ -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 }
@@ -3,11 +3,13 @@
33 import Ask from './ask'
44 import Backend from './backend'
55 import { COMMAND_SPEC } from './command-spec'
6+import { drawnFilesOf } from './drawn-files-of'
67 import { entryKindsOf } from './entry-kinds-of'
78 import type Git from './git'
89 import type { Host } from './host'
910 import { isOnPaneSurface } from './is-on-pane-surface'
1011 import Limits from './limits'
12+import { mapLimited } from './map-limited'
1113 import { messageOf } from './message-of'
1214 import { mtimeOf } from './mtime-of'
1315 import Names from './names'
@@ -36,14 +38,16 @@
3638 let hasAutoOpened = false
3739 let columns: number | null = null
3840 let shownSessionId: string | null = null
39- let wasDrawnSinceProbe = false
4041 let armed: Ask.ArmedAsk | null = null
4142 let carrying: Ask.ArmedAsk | null = null
4243 let isRefreshing = false
4344 let isRefreshQueued = false
4445 let generation = 0
45- let bodyKey: string | null = null
46+ let bodyStamp: string | null = null
47+ let bodyBase: string | null = null
4648
49+ const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
50+
4751 const polled = { toplevel: '', headKey: '' }
4852
4953 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
@@ -151,49 +155,70 @@
151155 )
152156 }
153157
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> {
158+ async function loadBodies(engine: Host): Promise<boolean> {
167159 const { data } = model
168- const selected = selectedOf()
160+ const pinned = backend
169161
170- if (!data || !selected || !backend) {
171- bodyKey = null
172- model = { ...model, body: null, bodyState: 'idle' }
162+ if (!data || !pinned) {
163+ bodyStamp = null
164+ bodyBase = null
165+ bodyLoads.clear()
166+ model = { ...model, bodies: PaneState.NO_BODIES }
173167
174168 return false
175169 }
176170
177- const key = `${generation}|${data.baseRef}|${selected.path}`
171+ const stamp = bodyStampOf(data)
172+ const isNewBase = data.baseRef !== bodyBase
178173
179- if (key === bodyKey) {
180- return false
174+ if (stamp !== bodyStamp) {
175+ bodyStamp = stamp
176+ bodyLoads.clear()
181177 }
182178
183- bodyKey = key
184- model = { ...model, body: null, bodyState: 'loading' }
185- redraw(engine)
179+ if (isNewBase) {
180+ bodyBase = data.baseRef
181+ model = { ...model, bodies: PaneState.NO_BODIES }
182+ redraw(engine)
183+ }
186184
187- const body = await backend.fetchFileHunks(data, selected)
185+ return fetchBodies(engine, pinned, data)
186+ }
188187
189- if (bodyKey !== key) {
190- return body === null
191- }
188+ const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
192189
193- model = { ...model, body, bodyState: body ? 'ready' : 'failed' }
194- redraw(engine)
190+ async function fetchBodies(
191+ engine: Host,
192+ pinned: Backend.Backend,
193+ data: Git.DiffData,
194+ ): Promise<boolean> {
195+ const stamp = bodyStampOf(data)
195196
196- return body === null
197+ function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> {
198+ const load = pinned.fetchFileHunks(data, file)
199+ bodyLoads.set(file.path, load)
200+
201+ return load.then(body => {
202+ if (bodyStamp === stamp) {
203+ model = {
204+ ...model,
205+ bodies: new Map(model.bodies).set(file.path, body),
206+ }
207+
208+ redraw(engine)
209+ }
210+
211+ return body
212+ })
213+ }
214+
215+ return (
216+ await mapLimited(
217+ drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)),
218+ Limits.BODY_FETCH_CONCURRENCY,
219+ loadOf,
220+ )
221+ ).includes(null)
197222 }
198223
199224 function startPoll(engine: Host, pinned: Backend.Backend) {
@@ -274,7 +299,7 @@
274299 break
275300 }
276301
277- const hasHunksFailed = await loadBody(engine)
302+ const hasHunksFailed = await loadBodies(engine)
278303
279304 if (outcome.kind === 'data') {
280305 record.mark(
@@ -318,9 +343,8 @@
318343 engine: Host,
319344 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
320345 ): 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)
346+ model = { ...model, selectedPath: null }
347+ await engine.openPane({ id: Names.PANE_ID, title: Names.PANE_TITLE })
324348 isPaneOpen = true
325349
326350 const sessionId = await engine.sessionId().catch(() => null)
@@ -345,14 +369,6 @@
345369 })
346370 }
347371
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-
356372 async function openOnFirstEdit(engine: Host): Promise<void> {
357373 const isTaken = () => isPaneOpen || hasAutoOpened
358374
@@ -393,17 +409,21 @@
393409 const actionsOf = (engine: Host): Views.PaneActions => ({
394410 selectFile: path => {
395411 model = { ...model, selectedPath: path }
396- void loadBody(engine)
412+
413+ if (model.placement === 'dock') {
414+ void engine.scrollTo(Views.bodyKeyOf(path)).catch(() => undefined)
415+ }
416+
397417 redraw(engine)
398418 },
399419 toggleNoise: () => {
400420 model = { ...model, isNoiseShown: !model.isNoiseShown }
401- void loadBody(engine)
421+ void loadBodies(engine)
402422 redraw(engine)
403423 },
404424 togglePreSession: () => {
405425 model = { ...model, isPreSessionShown: !model.isPreSessionShown }
406- void loadBody(engine)
426+ void loadBodies(engine)
407427 redraw(engine)
408428 },
409429 chooseBase: value => {
@@ -413,8 +433,7 @@
413433 return
414434 }
415435
416- bodyKey = null
417- model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' }
436+ model = { ...model, requestedMode: mode }
418437
419438 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
420439 kind: 'ok',
@@ -441,7 +460,6 @@
441460 : { kind: 'current' }
442461
443462 model = { ...model, source, selectedPath: null }
444- void loadBody(engine)
445463 redraw(engine)
446464 },
447465 toggleAsk: path => {
@@ -454,12 +472,6 @@
454472
455473 arm(engine, path)
456474 },
457- close: () => {
458- void closePane(engine)
459- .then(() => markTabSwitch(engine, 'convo'))
460- .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false))
461- .catch(() => undefined)
462- },
463475 })
464476
465477 function arm(engine: Host, path: string) {
@@ -467,7 +479,7 @@
467479 path,
468480 PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
469481 ?.hunks ??
470- model.body?.hunks ??
482+ model.bodies.get(path)?.hunks ??
471483 [],
472484 )
473485
@@ -505,7 +517,6 @@
505517 now: () => $.clock.now(),
506518 after: (ms, fn) => $.clock.after(ms, fn),
507519 every: (ms, fn) => $.clock.every(ms, fn),
508- sleep: ms => $.clock.sleep(ms),
509520 run: (argv, init) => $.process.run(argv, init),
510521 stat: path => $.fs.stat(path),
511522 listDir: path => $.fs.list(path),
@@ -518,6 +529,8 @@
518529 uiLog: text => $.ui.log(text),
519530 openPane: pane => $.ui.open(pane),
520531 closePane: pane => $.ui.close(pane),
532+ scrollTo: key =>
533+ $.ui.scroll({ to: { key }, in: Names.PANE_ID, block: 'start' }),
521534 registerCommand: spec => $.command.register(spec),
522535 sessionId: () => $.session.id(),
523536 mark: entry => $.telemetry.mark(entry),
@@ -542,9 +555,8 @@
542555
543556 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
544557
545- wasDrawnSinceProbe = true
546558 columns = e.viewport?.columns ?? columns
547- model = { ...model, isFocused: e.props.isFocused }
559+ model = { ...model, placement: e.props.placement }
548560
549561 return Views.paneView(
550562 {
@@ -554,7 +566,7 @@
554566 rows: e.props.scroll.bodyRows,
555567 },
556568 model,
557- e.props.placement,
569+ { placement: e.props.placement, terminalColumns: columns },
558570 )
559571 })
560572
@@ -573,11 +585,7 @@
573585 }
574586 }
575587
576- const toggle = PaneToggle.paneToggleOf({
577- isBelievedOpen: isPaneOpen,
578- wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)),
579- columns,
580- })
588+ const toggle = PaneToggle.paneToggleOf({ isOpen: isPaneOpen, columns })
581589
582590 if (toggle === 'too-narrow') {
583591 return { text: Names.RESIZE_TERMINAL_TEXT }
@@ -588,9 +596,28 @@
588596 markTabSwitch(host, isOpening ? 'diff' : 'convo')
589597 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
590598
591- return {}
599+ return {
600+ text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
601+ }
592602 })
593603
604+ on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
605+ const result = await next(e)
606+ const isClosed = result.deny === undefined
607+ const isPersons = isClosed && e.origin.kind === 'person'
608+
609+ if (isClosed) {
610+ isPaneOpen = false
611+ }
612+
613+ if (isPersons && host) {
614+ markTabSwitch(host, 'convo')
615+ await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
616+ }
617+
618+ return result
619+ })
620+
594621 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
595622 const result = await next(e)
596623
@@ -600,7 +627,9 @@
600627 }
601628
602629 hasAutoOpened = false
603- bodyKey = null
630+ bodyStamp = null
631+ bodyBase = null
632+ bodyLoads.clear()
604633 disarm(host)
605634 model = PaneState.afterNewSession(model)
606635 }
@@ -6,21 +6,23 @@
66 import type Git from '../../git'
77 import Names from '../../names'
88 import PaneState from '../../pane-state'
9-import Detail from '../detail'
9+import Entries from '../entries'
1010 import type { Kit } from '../kit'
1111 import Layout from '../layout'
12+import { preludeOf } from '../prelude-of'
1213 import Sections from '../sections'
13-import { fileRowOf } from './file-row-of'
14+import { selectedDetailOf } from '../selected-detail-of'
15+import { dialogRowsOf } from './dialog-rows-of'
1416 import { listBodyOf } from './list-body-of'
1517 import { messagePaneOf } from './message-pane-of'
1618
1719 /**
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+ * The inline pane over the repository's diff now (DiffDialog): header,
21+ * pickers, todo bar, the rows round the selected file, that file's detail.
2022 *
2123 * 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+ * note and pre-session line below. With nothing to list, messagePaneOf
25+ * draws the body.
2426 *
2527 * @param kit the elements, the handlers, the width, the rows
2628 * @param model the pane's state
@@ -32,21 +34,12 @@
3234 ): RenderElement {
3335 const { Box } = kit.ui
3436 const { data } = model
37+ const prelude = preludeOf(kit, model)
3538
36- if (model.isOutsideRepository) {
37- return <Box>{Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)}</Box>
39+ if (prelude) {
40+ return prelude
3841 }
3942
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-
5043 const noise = model.isNoiseShown ? 'shown' : 'hidden'
5144 const preSession = model.isPreSessionShown ? 'shown' : 'hidden'
5245 const partition = PaneState.partitionOf(data?.files ?? [], noise)
@@ -55,18 +48,14 @@
5548 ? PaneState.headerTotalsOf(data, partition)
5649 : PaneState.ZERO_TOTALS
5750
58- const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words)
59- const baseLabel = PaneState.baseLabelOf(model, totals.filesCount)
51+ const empty = PaneState.emptyStateOf(model, totals.filesCount)
6052
6153 const selected = PaneState.selectionOf(
6254 PaneState.listedOf(partition, preSession),
6355 model.selectedPath,
6456 )
6557
66- const rowOf = (file: Git.FileStat): RenderElement =>
67- Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () =>
68- kit.actions.selectFile(file.path),
69- )
58+ const selectedPath = selected?.path ?? null
7059
7160 const noteOf = (text: string | null): RenderElement | null =>
7261 text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
@@ -97,72 +86,62 @@
9786 const isEarlierListed = earlierFiles.length > 0
9887
9988 const earlierRows = isEarlierListed
100- ? [<Box height={1} />, ...earlierFiles.map(rowOf)]
89+ ? [<Box height={1} />, ...dialogRowsOf(kit, earlierFiles, selectedPath)]
10190 : []
10291
10392 const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty
10493 const listed = listBodyOf(kit, { data, partition, totals, empty })
105- const notes = listed.filter(line => typeof line === 'string')
106- const hasRows = notes.length < listed.length
94+
95+ const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
96+
97+ const rows = listed.filter(
98+ (line): line is Git.FileStat => typeof line !== 'string',
99+ )
100+
101+ const hasRows = rows.length > 0
107102 const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
108- const header = Sections.headerView(kit, empty ? null : totals)
103+ const header = Sections.headerView(kit, empty ? null : totals, null)
104+ const subline = noteOf(PaneState.unbornNoteOf(model, totals.filesCount))
109105
110106 const notShownNote = noteOf(
111107 totals.notShown > 0 ? `${totals.notShown} not shown` : null,
112108 )
113109
114110 const untrackedNote = noteOf(
115- isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null,
111+ isUntrackedNoted ? Names.untrackedWithheldTextOf(model) : null,
116112 )
117113
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- : []
114+ const detail = selectedDetailOf(
115+ kit,
116+ selected ? Entries.bodyEntryOf(selected, model) : null,
117+ model,
118+ )
137119
120+ const top = Sections.present([
121+ Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
122+ header,
123+ subline,
124+ Sections.controlsView(kit, model),
125+ Sections.todoBar(kit, model),
126+ noiseToggle,
127+ ])
128+
138129 const isMessageShown = message.length > 0
139130
140131 return isMessageShown ? (
141132 messagePaneOf(kit, {
142- top: Sections.present([
143- header,
144- noteOf(baseLabel),
145- Sections.todoBar(kit, model),
146- noiseToggle,
147- notShownNote,
148- untrackedNote,
149- ]),
133+ top: Sections.present([...top, notShownNote, untrackedNote]),
150134 message,
151- controls: Sections.controlsView(kit, model),
135+ controls: null,
152136 earlier: earlierToggle,
153137 rest: [...earlierRows, ...detail],
154138 })
155139 ) : (
156140 <Box flexDirection="column">
157141 {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- ),
142+ ...top,
143+ ...dialogRowsOf(kit, rows, selectedPath),
144+ ...notes.map(noteOf),
166145 notShownNote,
167146 untrackedNote,
168147 hasEarlier ? <Box height={1} /> : null,
@@ -1,0 +1,61 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Git from '../../../git'
7+import Limits from '../../../limits'
8+import type { Kit } from '../../kit'
9+import Layout from '../../layout'
10+import Sections from '../../sections'
11+import { fileRowOf } from '../file-row-of'
12+
13+/**
14+ * The inline list (DiffFileList): MAX_DIALOG_ROWS rows round the selected
15+ * file, the pointer on it, and over a longer list a dim count each side.
16+ *
17+ * A side with no file out of the window keeps its row, blank.
18+ *
19+ * @param kit the elements, the handlers, the width
20+ * @param files the listed files, in order
21+ * @param selectedPath the selected file's path, or null
22+ * @returns the rows
23+ */
24+export function dialogRowsOf(
25+ kit: Kit,
26+ files: readonly Git.FileStat[],
27+ selectedPath: string | null,
28+): RenderElement[] {
29+ const selected = Math.max(
30+ 0,
31+ files.findIndex(file => file.path === selectedPath),
32+ )
33+
34+ const isPaged = files.length > Limits.MAX_DIALOG_ROWS
35+
36+ const last = Math.min(
37+ files.length,
38+ Math.max(0, selected - Math.floor(Limits.MAX_DIALOG_ROWS / 2)) +
39+ Limits.MAX_DIALOG_ROWS,
40+ )
41+
42+ const first = Math.max(0, last - Limits.MAX_DIALOG_ROWS)
43+ const above = first
44+ const below = files.length - last
45+
46+ const edgeOf = (count: number, arrow: string): RenderElement =>
47+ Sections.dimNote(
48+ kit,
49+ count > 0 ? ` ${arrow} ${Layout.plural(count, 'more file')}` : ' ',
50+ )
51+
52+ const rows = files
53+ .slice(first, last)
54+ .map(file =>
55+ Sections.dialogFileRow(kit, fileRowOf(file, selectedPath), () =>
56+ kit.actions.selectFile(file.path),
57+ ),
58+ )
59+
60+ return isPaged ? [edgeOf(above, '↑'), ...rows, edgeOf(below, '↓')] : rows
61+}
@@ -1,0 +1,3 @@
1+export * from './dialog-rows-of.jsx'
2+
3+export * as default from '.'
@@ -1,9 +1,9 @@
1-import type Git from '../../../git'
1+import Git from '../../../git'
22 import Sections from '../../sections'
33
44 /**
5- * A fetched row as the list draws it: its counts, or a note in their
6- * place (`untracked`, `Binary file`, `renamed`), and selection.
5+ * A fetched row as the inline list draws it (DiffFileList FileItem): its
6+ * counts or a note for them (`untracked`, `Binary file`), name, selection.
77 *
88 * @param file the row
99 * @param selectedPath the selected file's path, or null
@@ -13,19 +13,16 @@
1313 file: Git.FileStat,
1414 selectedPath: string | null,
1515 ): Sections.FileRowModel {
16- const isRenamed = file.renamedFrom !== null
17-
1816 const note = file.isUntracked
1917 ? 'untracked'
2018 : file.isBinary
2119 ? 'Binary file'
22- : isRenamed
23- ? 'renamed'
24- : null
20+ : null
2521
2622 return {
2723 key: Sections.fileKeyOf(file.path),
2824 path: file.path,
25+ displayPath: Git.displayPathOf(file),
2926 added: file.added,
3027 removed: file.removed,
3128 note,
@@ -1,4 +1,5 @@
11 export * from './current-pane.jsx'
2+export * from './dialog-rows-of'
23 export * from './file-row-of'
34 export * from './list-body-of'
45 export * from './message-pane-of'
@@ -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,49 +1,53 @@
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
15+ * One file's detail (DiffDetailView): its bold name and aside, the ask
1716 * Button, the built-in's dim rule under them, 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 name is cut from its start to the width. The body is a placeholder,
19+ * or the hunks as the engine's diff `Code` blocks (codeBlocksOf) within the
20+ * room given, then a footer when anything was cut; 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 draw the room the bodies have left, and the ask's press
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 {
30+ draw: Types.DetailDraw,
31+): Types.DrawnDetail {
3332 const { Box, Text, Button, Code } = kit.ui
33+ const { room, onToggleAsk } = draw
3434 const placeholder = placeholderOf(detail)
35- const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []))
36- const isTruncated = detail.body?.isTruncated === true || code.isTruncated
37- const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS)
35+ const name = Layout.sanitizeName(detail.displayPath)
3836
39- const nameOf = (name: string) =>
40- Layout.truncateStart(Layout.sanitizeName(name), kit.columns)
37+ const framed: Types.BodyRoom = {
38+ chars: room.chars - name.length,
39+ nodes: room.nodes - FILE_FRAME_NODES,
40+ }
4141
42- const renamedFrom =
43- detail.renamedFrom === null ? null : nameOf(detail.renamedFrom)
42+ const code = codeBlocksOf(
43+ placeholder ? [] : (detail.body?.hunks ?? []),
44+ framed,
45+ )
4446
47+ const isTruncated = detail.body?.isTruncated === true || code.isTruncated
48+ const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS)
49+
4550 const asides = [
46- renamedFrom === null ? null : `renamed from ${renamedFrom}`,
4751 detail.isUntracked ? 'untracked' : null,
4852 isTruncated ? 'truncated' : null,
4953 ].filter(word => word !== null)
@@ -53,7 +57,7 @@
5357 const ask = placeholder
5458 ? []
5559 : [
56- <Button key="ask" onPress={onToggleAsk}>
60+ <Button key={Sections.askKeyOf(detail.path)} onPress={onToggleAsk}>
5761 {detail.isArmed ? 'asked ✓' : 'ask'}
5862 </Button>,
5963 ]
@@ -72,18 +76,17 @@
7276 </Text>
7377 ))
7478
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- ])
79+ const hunks = code.sources.map(source => (
80+ <Code source={source} format="diff" path={path} />
81+ ))
7982
80- return (
83+ const element = (
8184 <Box flexDirection="column">
8285 {[
8386 <Box flexDirection="row">
8487 {[
8588 <Text bold wrap="truncate-start">
86- {nameOf(detail.path)}
89+ {Layout.truncateStart(name, kit.columns)}
8790 </Text>,
8891 <Text dimColor>{aside}</Text>,
8992 <Box flexGrow={1} />,
@@ -96,4 +99,6 @@
9699 ]}
97100 </Box>
98101 )
102+
103+ return { element, room: code.room }
99104 }
@@ -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,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,14 @@
1+import type { BodyRoom } from '../body-room'
2+
3+/**
4+ * What drawing one file's detail takes besides the file: the room the
5+ * pane's bodies have left, and the press that arms or disarms its ask.
6+ */
7+export type DetailDraw = {
8+ room: BodyRoom
9+
10+ /**
11+ * Arms the file for the next prompt, or disarms it.
12+ */
13+ onToggleAsk: () => void
14+}
@@ -1,0 +1,3 @@
1+export type * from './detail-draw.js'
2+
3+export * as default from '.'
@@ -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 './detail-draw'
4+export * from './drawn-detail'
35
46 export * as default from '.'
@@ -1,0 +1,15 @@
1+import type Git from '../../../git'
2+
3+/**
4+ * One file as the pane lists and draws it: its row's counts and name, its
5+ * kind, and its hunks so far (undefined loading, null unreadable).
6+ */
7+export type BodyEntry = {
8+ path: string
9+ displayPath: string
10+ added: number
11+ removed: number
12+ isUntracked: boolean
13+ isBinary: boolean
14+ body: Git.FileHunks | null | undefined
15+}
@@ -1,0 +1,3 @@
1+export type * from './body-entry.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+import Git from '../../../git'
2+import type PaneState from '../../../pane-state'
3+import type { BodyEntry } from '../body-entry'
4+
5+/**
6+ * A fetched row as the pane draws it, its body looked up in what the pane
7+ * has read.
8+ *
9+ * @param file the row
10+ * @param model the pane's state, holding the bodies read so far
11+ * @returns the entry
12+ */
13+export const bodyEntryOf = (
14+ file: Git.FileStat,
15+ model: Pick<PaneState.PaneModel, 'bodies'>,
16+): BodyEntry => ({
17+ path: file.path,
18+ displayPath: Git.displayPathOf(file),
19+ added: file.added,
20+ removed: file.removed,
21+ isUntracked: file.isUntracked,
22+ isBinary: file.isBinary,
23+ body: model.bodies.get(file.path),
24+})
@@ -1,0 +1,3 @@
1+export * from './body-entry-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+import type PaneState from '../../../pane-state'
2+import type Detail from '../../detail'
3+import type { BodyEntry } from '../body-entry'
4+
5+/**
6+ * A listed file as its detail draws it: the entry, whether it is the armed
7+ * one, and the backend's words for its notes.
8+ *
9+ * @param entry the file
10+ * @param model which file is armed, and the backend's words
11+ * @returns the detail model
12+ */
13+export const detailModelOf = (
14+ entry: BodyEntry,
15+ model: Pick<PaneState.PaneModel, 'armedPath' | 'words'>,
16+): Detail.DetailModel => ({
17+ words: model.words,
18+ path: entry.path,
19+ displayPath: entry.displayPath,
20+ isUntracked: entry.isUntracked,
21+ isBinary: entry.isBinary,
22+ body: entry.body,
23+ isArmed: model.armedPath === entry.path,
24+})
@@ -1,0 +1,3 @@
1+export * from './detail-model-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './body-entry'
2+export * from './body-entry-of'
3+export * from './detail-model-of'
4+export * from './turn-entry-of'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './turn-entry-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,19 @@
1+import type Turns from '../../../turns'
2+import type { BodyEntry } from '../body-entry'
3+
4+/**
5+ * A turn's file as the pane draws it: its hunks come from the transcript,
6+ * so its body is always in hand.
7+ *
8+ * @param file the turn's file
9+ * @returns the entry
10+ */
11+export const turnEntryOf = (file: Turns.TurnFile): BodyEntry => ({
12+ path: file.path,
13+ displayPath: file.path,
14+ added: file.added,
15+ removed: file.removed,
16+ isUntracked: false,
17+ isBinary: false,
18+ body: { hunks: file.hunks, isTruncated: file.isTruncated, isLarge: false },
19+})
@@ -1,11 +1,16 @@
11 export * from './current-pane'
22 export * from './detail'
3+export * from './entries'
34 export * from './inset-of'
45 export * from './kit'
56 export * from './layout'
67 export * from './pane-actions'
8+export * from './pane-seat'
79 export * from './pane-view.jsx'
10+export * from './prelude-of'
811 export * from './sections'
12+export * from './selected-detail-of'
13+export * from './sidebar-pane'
914 export * from './turn-pane'
1015
1116 export * as default from '.'
@@ -1,5 +1,6 @@
11 export * from './cell-width'
22 export * from './charged-width'
3+export * from './pad-end'
34 export * from './plural'
45 export * from './sanitize-line'
56 export * from './sanitize-name'
@@ -1,0 +1,3 @@
1+export * from './pad-end.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+import { chargedWidth } from '../charged-width'
2+
3+/**
4+ * A sanitized text with spaces after it up to a width in cells, counted by
5+ * chargedWidth; unchanged when it is that wide already.
6+ *
7+ * @param text the text
8+ * @param cells the width to reach
9+ * @returns the padded text
10+ */
11+export const padEnd = (text: string, cells: number) =>
12+ `${text}${' '.repeat(Math.max(0, cells - chargedWidth(text)))}`
@@ -4,7 +4,8 @@
44 */
55 export type PaneActions = {
66 /**
7- * Shows this file's body under the list.
7+ * Docked, scrolls the body to this file's hunks; inline, selects the file
8+ * so its body shows under the list.
89 */
910 selectFile: (path: string) => void
1011
@@ -33,10 +34,4 @@
3334 * Arms this file's diff for the next prompt, or disarms it.
3435 */
3536 toggleAsk: (path: string) => void
36-
37- /**
38- * Closes the pane as `/diff` closes it, the choice kept for the next
39- * first edit.
40- */
41- close: () => void
4237 }
@@ -1,0 +1,3 @@
1+export type * from './pane-seat.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+/**
2+ * Where the surface put the pane for one drawing (`props.placement`), and
3+ * how wide the terminal is (`viewport.columns`; null before it measured).
4+ *
5+ * Inline on a terminal the dock would fit (OPEN_MIN_COLUMNS) means the
6+ * session draws no fullscreen layout, where the built-in shows its dialog.
7+ */
8+export type PaneSeat = {
9+ placement: 'dock' | 'inline'
10+ terminalColumns: number | null
11+}
@@ -9,44 +9,53 @@
99 import { currentPane } from './current-pane'
1010 import { insetOf } from './inset-of'
1111 import type { Kit } from './kit'
12+import type { PaneSeat } from './pane-seat'
1213 import Sections from './sections'
14+import { sidebarPane } from './sidebar-pane'
1315 import { turnPane } from './turn-pane'
1416
1517 /**
16- * The diff pane's body for one `ui.render`: the repository's diff now, or
17- * the picked turn's edits while that turn still exists (a rewind drops it).
18+ * The diff pane's body for one `ui.render`: docked, the built-in panel's
19+ * layout (sidebarPane); inline, its dialog's, or its wider-terminal line.
1820 *
19- * Seated inline, only the built-in's wider-terminal line, as it shows no
20- * panel there; docked, the built-in's blank row above the header and blank
21- * last column, so rows sit and wrap where the built-in's do.
21+ * Inline for want of width (under OPEN_MIN_COLUMNS) the built-in shows no
22+ * panel, only that line; inline on a wider terminal there is no fullscreen
23+ * layout, where it shows its dialog. Docked, its blank top row and column.
2224 *
2325 * @param kit the elements, the handlers, the width
2426 * @param model the pane's state
25- * @param placement where the surface seated the pane (`props.placement`)
27+ * @param seat where the surface seated the pane, and the terminal's width
2628 * @returns the tree
2729 */
2830 export function paneView(
2931 kit: Kit,
3032 model: PaneState.PaneModel,
31- placement: 'dock' | 'inline',
33+ seat: PaneSeat,
3234 ): RenderElement {
3335 const { Box } = kit.ui
34- const turn = PaneState.pickedTurnOf(model)
35- const isCurrent = turn === undefined
36+ const isDocked = seat.placement === 'dock'
3637
37- if (placement === 'inline') {
38- return <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box>
39- }
38+ const isNarrow =
39+ seat.terminalColumns !== null &&
40+ seat.terminalColumns < Limits.OPEN_MIN_COLUMNS
4041
41- const inset = insetOf(kit)
42+ function dialogOf(): RenderElement {
43+ const turn = PaneState.pickedTurnOf(model)
4244
43- return (
45+ return turn ? turnPane(kit, model, turn) : currentPane(kit, model)
46+ }
47+
48+ return isDocked ? (
4449 <Box
4550 flexDirection="column"
4651 paddingTop={Limits.PANE_TOP_PAD_ROWS}
4752 paddingRight={Limits.PANE_RIGHT_PAD_COLUMNS}
4853 >
49- {isCurrent ? currentPane(inset, model) : turnPane(inset, model, turn)}
54+ {sidebarPane(insetOf(kit), model)}
5055 </Box>
56+ ) : isNarrow ? (
57+ <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box>
58+ ) : (
59+ <Box flexDirection="column">{dialogOf()}</Box>
5160 )
5261 }
@@ -1,0 +1,3 @@
1+export * from './prelude-of.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,48 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Names from '../../names'
7+import type PaneState from '../../pane-state'
8+import CurrentPane from '../current-pane'
9+import type { Kit } from '../kit'
10+import Sections from '../sections'
11+
12+/**
13+ * What the pane draws before it has a diff to lay out: the line saying the
14+ * directory is in no repository, or the first fetch's `Loading diff…`.
15+ *
16+ * Null once a fetch settled (or a turn is picked), when the layout draws.
17+ *
18+ * @param kit the elements, the width, the rows
19+ * @param model the pane's state
20+ * @returns the early tree, or null
21+ */
22+export function preludeOf(
23+ kit: Kit,
24+ model: Pick<
25+ PaneState.PaneModel,
26+ 'isOutsideRepository' | 'data' | 'hasSettled' | 'source' | 'turns'
27+ >,
28+): RenderElement | null {
29+ const { Box } = kit.ui
30+ const isLoading = model.data === null && !model.hasSettled
31+
32+ const outside = (
33+ <Box>{Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)}</Box>
34+ )
35+
36+ const loadingOf = (): RenderElement =>
37+ CurrentPane.messagePaneOf(kit, {
38+ top: [Sections.headerView(kit, null, null)],
39+ message: ['Loading diff…'],
40+ controls: null,
41+ earlier: null,
42+ rest: [],
43+ })
44+
45+ const settled = isLoading ? loadingOf() : null
46+
47+ return model.isOutsideRepository ? outside : settled
48+}
@@ -1,0 +1,10 @@
1+import Layout from '../../layout'
2+
3+/**
4+ * A file's ask Button address: the path made printable, under `ask:`, so
5+ * each file's Button in the body has its own.
6+ *
7+ * @param path the file's path as git or the transcript gave it
8+ * @returns the key
9+ */
10+export const askKeyOf = (path: string) => `ask:${Layout.sanitizeName(path)}`
@@ -1,0 +1,3 @@
1+export * from './ask-key-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+import Layout from '../../layout'
2+
3+/**
4+ * The key of a file's block in the docked body, under `body:`: what a row's
5+ * press scrolls to (`$.ui.scroll({ to: { key } })`).
6+ *
7+ * @param path the file's path as git or the transcript gave it
8+ * @returns the key
9+ */
10+export const bodyKeyOf = (path: string) => `body:${Layout.sanitizeName(path)}`
@@ -1,0 +1,3 @@
1+export * from './body-key-of.js'
2+
3+export * as default from '.'
Dmods/diff/hooks/views/sections/close-button/close-button.tsx+0 -33
@@ -1,33 +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-
8-/**
9- * The pane's close control (ReplDiffSidebar's PanelCloseButton): a `✕` at
10- * the header's right edge that closes the pane as `/diff` does.
11- *
12- * Bold under the pointer, as the built-in's is. The built-in's rest style,
13- * dim, is not a Button's to take: its label's only style is `hover`.
14- *
15- * @param kit the drawing's kit; its handlers close the pane
16- * @returns the control, in the keyed Box its hover needs
17- */
18-export function closeButton(kit: Kit): RenderElement {
19- const { Box, Button } = kit.ui
20-
21- return (
22- <Box key="close">
23- <Button
24- key="close"
25- plain
26- hover={{ bold: true }}
27- onPress={kit.actions.close}
28- >
29- {'✕'}
30- </Button>
31- </Box>
32- )
33-}
Dmods/diff/hooks/views/sections/close-button/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export * from './close-button.jsx'
2-
3-export * as default from '.'
@@ -3,7 +3,7 @@
33 /* @jsxFrag Fragment */
44 import type { RenderElement } from 'claude-code'
55
6-import type PaneState from '../../pane-state'
6+import PaneState from '../../pane-state'
77 import type { Kit } from '../kit'
88 import { present } from './present'
99
@@ -13,7 +13,7 @@
1313 *
1414 * The source picker draws only when a turn exists, as DiffDialog hides its
1515 * tabs; the base picker is ctrl+x b's cycle as a Select over the backend's
16- * modes, uncommitted naming the base as the base line does (modeLabelOf).
16+ * modes, each worded as the built-in's base line (modeLabelOf) it replaces.
1717 *
1818 * @param kit the elements and the handlers
1919 * @param model what is picked now and the turns to pick from
@@ -47,26 +47,15 @@
4747 ) : null
4848
4949 const isCurrent = source.kind === 'current'
50- const fetchedSource = model.data?.source
51- const isWorkingTree = fetchedSource?.kind === 'working-tree'
52- const base = isWorkingTree ? fetchedSource.base : model.words.base
5350
5451 const basePicker = isCurrent ? (
5552 <Select
5653 key="base"
5754 label="base"
58- options={model.baseModes.map(mode => {
59- const isUncommitted = mode === 'uncommitted'
60- const isSession = mode === 'session'
61-
62- const label = isUncommitted
63- ? `uncommitted (vs ${base})`
64- : isSession
65- ? 'this session'
66- : mode
67-
68- return { value: mode, label }
69- })}
55+ options={model.baseModes.map(mode => ({
56+ value: mode,
57+ label: PaneState.modeLabelOf(mode, model),
58+ }))}
7059 value={model.requestedMode}
7160 onSelect={value => kit.actions.chooseBase(value)}
7261 />
@@ -1,0 +1,44 @@
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 { DIALOG_PATH_FLOOR } from '../dialog-path-floor'
9+import { DIALOG_PATH_RESERVE } from '../dialog-path-reserve'
10+import { diffStat } from '../diff-stat'
11+import type { FileRowModel } from '../file-row-model'
12+import { listRow } from '../list-row'
13+import { POINTER } from '../pointer'
14+
15+/**
16+ * One file of the inline list (DiffFileList FileItem): the pointer before
17+ * the selected one, the start-truncated name, its counts or note right.
18+ *
19+ * @param kit the elements, the handlers, the width
20+ * @param row the file
21+ * @param onPress selects the file
22+ * @returns the row element
23+ */
24+export function dialogFileRow(
25+ kit: Kit,
26+ row: FileRowModel,
27+ onPress: () => void,
28+): RenderElement {
29+ const { Text } = kit.ui
30+ const room = Math.max(DIALOG_PATH_FLOOR, kit.columns - DIALOG_PATH_RESERVE)
31+ const mark = row.isSelected ? POINTER : ' '
32+ const name = Layout.truncateStart(Layout.sanitizeName(row.displayPath), room)
33+
34+ const note = (
35+ <Text dimColor italic>
36+ {row.note ?? ''}
37+ </Text>
38+ )
39+
40+ const hasNote = row.note !== null
41+ const tail = hasNote ? note : diffStat(kit, row.added, row.removed)
42+
43+ return listRow(kit, { key: row.key, label: `${mark} ${name}`, onPress }, tail)
44+}
@@ -1,0 +1,3 @@
1+export * from './dialog-file-row.jsx'
2+
3+export * as default from '.'
@@ -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,0 +1,3 @@
1+export * from './dialog-path-floor.js'
2+
3+export * as default from '.'
@@ -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,0 +1,3 @@
1+export * from './dialog-path-reserve.js'
2+
3+export * as default from '.'
@@ -1,0 +1,42 @@
1+import type { RenderElement } from 'claude-code'
2+
3+import type { Kit } from '../../kit'
4+import Layout from '../../layout'
5+import { diffStat } from '../diff-stat'
6+import type { FileRowModel } from '../file-row-model'
7+import { listRow } from '../list-row'
8+import { PATH_FLOOR } from '../path-floor'
9+import { STAT_CELLS } from '../stat-cells'
10+import { statTextOf } from '../stat-text-of'
11+
12+/**
13+ * One file of the docked list (ReplDiffSidebar FileSummaryRow): the
14+ * start-truncated name as a plain Button, its counts at the right.
15+ *
16+ * Nothing at the right at 0/0 (an untracked or binary file). The label is
17+ * padded up to the counts, so the whole row before them presses.
18+ *
19+ * @param kit the elements, the handlers, the width
20+ * @param row the file
21+ * @param onPress scrolls the body to the file
22+ * @returns the row element
23+ */
24+export function fileRow(
25+ kit: Kit,
26+ row: FileRowModel,
27+ onPress: () => void,
28+): RenderElement {
29+ const label = Layout.padEnd(
30+ Layout.truncateStart(
31+ Layout.sanitizeName(row.displayPath),
32+ Math.max(kit.columns - STAT_CELLS, PATH_FLOOR),
33+ ),
34+ kit.columns - statTextOf(row.added, row.removed).length,
35+ )
36+
37+ return listRow(
38+ kit,
39+ { key: row.key, label, onPress },
40+ diffStat(kit, row.added, row.removed),
41+ )
42+}
Dmods/diff/hooks/views/sections/file-row/file-row.tsx+0 -51
@@ -1,51 +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 { diffStat } from '../diff-stat'
9-import type { FileRowModel } from '../file-row-model'
10-import { POINTER } from '../pointer'
11-import { STAT_CELLS } from '../stat-cells'
12-
13-/**
14- * One file of the list (ReplDiffSidebar FileSummaryRow): a plain Button
15- * with the start-truncated path, its counts or note at the right.
16- *
17- * The pointer marks the selected one.
18- *
19- * @param kit the elements, the handlers, the width
20- * @param row the file
21- * @param onPress selects the file
22- * @returns the row element
23- */
24-export function fileRow(
25- kit: Kit,
26- row: FileRowModel,
27- onPress: () => void,
28-): RenderElement {
29- const { Box, Text, Button } = kit.ui
30- const room = Math.max(kit.columns - STAT_CELLS, STAT_CELLS)
31- const mark = row.isSelected ? POINTER : ' '
32-
33- const note = (
34- <Text dimColor italic>
35- {row.note ?? ''}
36- </Text>
37- )
38-
39- const hasNote = row.note !== null
40- const tail = hasNote ? note : diffStat(kit, row.added, row.removed)
41-
42- return (
43- <Box flexDirection="row">
44- <Button key={row.key} plain onPress={onPress}>
45- {`${mark} ${Layout.truncateStart(Layout.sanitizeName(row.path), room)}`}
46- </Button>
47- <Box flexGrow={1} />
48- {tail}
49- </Box>
50- )
51-}
@@ -1,3 +1,3 @@
1-export * from './file-row.jsx'
1+export * from './file-row.js'
22
33 export * as default from '.'
@@ -1,10 +1,11 @@
11 /**
2- * One listed file as its row draws it: the Button's address, the path, the
3- * counts or a dim note in their place, and whether its body shows.
2+ * One listed file as its row draws it: the Button's address, the path and
3+ * the name listed, the counts or (inline) a dim note for them, selection.
44 */
55 export type FileRowModel = {
66 key: string
77 path: string
8+ displayPath: string
89 added: number
910 removed: number
1011 note: string | null
@@ -6,40 +6,39 @@
66 import type PaneState from '../../pane-state'
77 import type { Kit } from '../kit'
88 import Layout from '../layout'
9-import { closeButton } from './close-button'
109 import { diffStat } from './diff-stat'
10+import { present } from './present'
1111
1212 /**
1313 * The pane's first line (ReplDiffSidebar's header row): `N files changed
14- * +A -R`, or nothing over an empty state.
14+ * +A -R`, nothing over an empty state, and the pickers at its right edge.
1515 *
16- * The close `✕` sits at the right edge either way.
16+ * The pickers sit where the built-in draws its close mark, so every row
17+ * below lands where the built-in's does; this pane's mark is the engine's.
1718 *
1819 * @param kit the drawing's kit; its elements draw the line
1920 * @param totals the header's counts, or null when there is nothing to count
21+ * @param controls the pickers (Sections.controlsView), or null for none
2022 * @returns the line
2123 */
2224 export function headerView(
2325 kit: Kit,
2426 totals: PaneState.HeaderTotals | null,
27+ controls: RenderElement | null,
2528 ): RenderElement {
2629 const { Box, Text } = kit.ui
2730
31+ const countOf = (counted: PaneState.HeaderTotals): RenderElement => (
32+ <Text wrap="truncate-end">
33+ <Text bold>{Layout.plural(counted.filesCount, 'file')}</Text>
34+ {' changed '}
35+ {diffStat(kit, counted.linesAdded, counted.linesRemoved)}
36+ </Text>
37+ )
38+
2839 return (
29- <Box flexDirection="row">
30- {[
31- ...(totals
32- ? [
33- <Text wrap="truncate-end">
34- <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text>
35- {' changed '}
36- {diffStat(kit, totals.linesAdded, totals.linesRemoved)}
37- </Text>,
38- ]
39- : []),
40- <Box flexGrow={1} />,
41- closeButton(kit),
42- ]}
40+ <Box flexDirection="row" height={1}>
41+ {present([totals && countOf(totals), <Box flexGrow={1} />, controls])}
4342 </Box>
4443 )
4544 }
@@ -1,5 +1,9 @@
1-export * from './close-button'
1+export * from './ask-key-of'
2+export * from './body-key-of'
23 export * from './controls-view.jsx'
4+export * from './dialog-file-row'
5+export * from './dialog-path-floor'
6+export * from './dialog-path-reserve'
37 export * from './diff-stat'
48 export * from './dim-note'
59 export * from './divider.jsx'
@@ -7,10 +11,15 @@
711 export * from './file-row'
812 export * from './file-row-model'
913 export * from './header-view.jsx'
14+export * from './keyed-pressable'
15+export * from './list-row'
16+export * from './path-floor'
1017 export * from './pointer'
1118 export * from './present'
1219 export * from './pressable'
1320 export * from './stat-cells'
21+export * from './stat-text-of'
22+export * from './title-row.jsx'
1423 export * from './todo-bar.jsx'
1524 export * from './todo-bar-cells'
1625 export * from './todo-bar-reserve'
@@ -1,0 +1,3 @@
1+export type * from './keyed-pressable.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+import type { Pressable } from '../pressable'
2+
3+/**
4+ * A plain Button's face, press and address: a list row's Button.
5+ */
6+export type KeyedPressable = Pressable & {
7+ key: string
8+}
@@ -1,0 +1,3 @@
1+export * from './list-row.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,34 @@
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 type { KeyedPressable } from '../keyed-pressable'
8+
9+/**
10+ * One row of a file list: a plain Button under its key, then whatever the
11+ * row shows at its right edge (its counts, a note).
12+ *
13+ * @param kit the elements
14+ * @param row the Button's key, label and press
15+ * @param tail the element at the right edge
16+ * @returns the row element
17+ */
18+export function listRow(
19+ kit: Kit,
20+ row: KeyedPressable,
21+ tail: RenderElement,
22+): RenderElement {
23+ const { Box, Button } = kit.ui
24+
25+ return (
26+ <Box flexDirection="row">
27+ <Button key={row.key} plain onPress={row.onPress}>
28+ {row.label}
29+ </Button>
30+ <Box flexGrow={1} />
31+ {tail}
32+ </Box>
33+ )
34+}
@@ -1,0 +1,3 @@
1+export * from './path-floor.js'
2+
3+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,5 +1,7 @@
11 import type { RenderElement } from 'claude-code'
22
3+import { keptOf } from '../../../kept-of'
4+
35 /**
46 * The children that are there: nulls dropped, so a part may answer null for
57 * "nothing to draw" and the parent lists it all the same.
@@ -9,5 +11,4 @@
911 */
1012 export const present = (
1113 children: readonly (RenderElement | null)[],
12-): RenderElement[] =>
13- children.filter((child): child is RenderElement => child !== null)
14+): RenderElement[] => keptOf(children)
@@ -1,5 +1,5 @@
11 /**
2- * The cells a file row keeps for its `+N -M` (ReplDiffSidebar's 12) and the
3- * selection pointer before the path.
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.
44 */
5-export const STAT_CELLS = 14
5+export const STAT_CELLS = 12
@@ -1,0 +1,3 @@
1+export * from './stat-text-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+import { keptOf } from '../../../kept-of'
2+
3+/**
4+ * `+N -M` as diffStat draws it, as plain text: what a row measures to know
5+ * where its counts start; empty at 0/0.
6+ *
7+ * @param added lines added
8+ * @param removed lines removed
9+ * @returns the text
10+ */
11+export const statTextOf = (added: number, removed: number) =>
12+ keptOf([
13+ added > 0 ? `+${added}` : null,
14+ removed > 0 ? `-${removed}` : null,
15+ ]).join(' ')
@@ -1,0 +1,33 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import type PaneState from '../../pane-state'
7+import type { Kit } from '../kit'
8+import Layout from '../layout'
9+
10+/**
11+ * The inline pane's heading (DiffDialog's title line): the title, then
12+ * its subtitle in dim after a space.
13+ *
14+ * @param kit the elements and the width
15+ * @param heading the title and subtitle
16+ * @returns the row
17+ */
18+export function titleRow(
19+ kit: Kit,
20+ heading: PaneState.DialogTitle,
21+): RenderElement {
22+ const { Text } = kit.ui
23+ const hasSubtitle = heading.subtitle !== ''
24+
25+ return (
26+ <Text wrap="truncate-end">
27+ {Layout.sanitizeName(heading.title)}
28+ <Text dimColor>
29+ {hasSubtitle ? ` ${Layout.sanitizeName(heading.subtitle)}` : ''}
30+ </Text>
31+ </Text>
32+ )
33+}
@@ -1,0 +1,3 @@
1+export * from './selected-detail-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,33 @@
1+import type { RenderElement } from 'claude-code'
2+
3+import type PaneState from '../../pane-state'
4+import Detail from '../detail'
5+import Entries from '../entries'
6+import type { Kit } from '../kit'
7+import Sections from '../sections'
8+
9+/**
10+ * The inline pane's one body: a rule, then the selected file's detail with
11+ * the whole body budget to itself; nothing when no file is selected.
12+ *
13+ * @param kit the elements, the handlers, the width
14+ * @param entry the selected file, or null
15+ * @param model which file is armed, and the backend's words
16+ * @returns the rule and the detail, or none
17+ */
18+export function selectedDetailOf(
19+ kit: Kit,
20+ entry: Entries.BodyEntry | null,
21+ model: Pick<PaneState.PaneModel, 'armedPath' | 'words'>,
22+): RenderElement[] {
23+ if (!entry) {
24+ return []
25+ }
26+
27+ const drawn = Detail.detailView(kit, Entries.detailModelOf(entry, model), {
28+ room: Detail.BODY_BUDGET,
29+ onToggleAsk: () => kit.actions.toggleAsk(entry.path),
30+ })
31+
32+ return [Sections.divider(kit), drawn.element]
33+}
@@ -1,0 +1,17 @@
1+import type PaneState from '../../../pane-state'
2+import type Detail from '../../detail'
3+
4+/**
5+ * What drawing a run of file bodies takes besides the files: the pane's
6+ * state they read, the room left, and whether a blank row parts the files.
7+ */
8+export type BodyFrame = {
9+ model: Pick<PaneState.PaneModel, 'armedPath' | 'words' | 'isPreSessionShown'>
10+ room: Detail.BodyRoom
11+
12+ /**
13+ * True in the session's body, whose files the built-in parts with a blank
14+ * row; false among the pre-session bodies, which it stacks flush.
15+ */
16+ isSpaced: boolean
17+}
@@ -1,0 +1,3 @@
1+export type * from './body-frame.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type { RenderElement } from 'claude-code'
2+
3+import type Detail from '../../detail'
4+
5+/**
6+ * A run of file bodies as drawn, and the room left for whatever follows.
7+ */
8+export type DrawnBlocks = {
9+ blocks: RenderElement[]
10+ room: Detail.BodyRoom
11+}
@@ -1,0 +1,3 @@
1+export type * from './drawn-blocks.js'
2+
3+export * as default from '.'
@@ -1,0 +1,87 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Limits from '../../../limits'
7+import type Entries from '../../entries'
8+import type { Kit } from '../../kit'
9+import Layout from '../../layout'
10+import Sections from '../../sections'
11+import type { BodyFrame } from '../body-frame'
12+import { fileBlocksOf } from '../file-blocks-of'
13+
14+/**
15+ * The pre-session section after the body (PreSessionSection): its toggle
16+ * line, and once open a dim legend of every file, then their bodies.
17+ *
18+ * Past PRE_SESSION_BODY_CAP files a line says the bodies are hidden and
19+ * the legend alone lists them.
20+ *
21+ * @param kit the elements, the handlers, the width
22+ * @param entries the pre-session files, in order; none draws nothing
23+ * @param frame whether the section is open, the armed file, the room left
24+ * @returns the section's rows, none without pre-session files
25+ */
26+export function earlierSectionOf(
27+ kit: Kit,
28+ entries: readonly Entries.BodyEntry[],
29+ frame: BodyFrame,
30+): RenderElement[] {
31+ const { Box, Text } = kit.ui
32+
33+ if (entries.length === 0) {
34+ return []
35+ }
36+
37+ const face = frame.model.isPreSessionShown ? 'hide' : 'show'
38+ const count = Layout.plural(entries.length, 'file')
39+
40+ const toggle = Sections.toggleRow(kit, 'presession', {
41+ label: `+${count} edited before this session (${face})`,
42+ onPress: kit.actions.togglePreSession,
43+ })
44+
45+ if (!frame.model.isPreSessionShown) {
46+ return [toggle]
47+ }
48+
49+ const nameRoom = Math.max(
50+ kit.columns - Sections.STAT_CELLS,
51+ Sections.PATH_FLOOR,
52+ )
53+
54+ const legend = entries.map(entry => (
55+ <Box flexDirection="row">
56+ {[
57+ <Text dimColor wrap="truncate-end">
58+ {Layout.truncateStart(
59+ Layout.sanitizeName(entry.displayPath),
60+ nameRoom,
61+ )}
62+ </Text>,
63+ <Box flexGrow={1} />,
64+ Sections.diffStat(kit, entry.added, entry.removed),
65+ ]}
66+ </Box>
67+ ))
68+
69+ const isCapped = entries.length > Limits.PRE_SESSION_BODY_CAP
70+
71+ const bodies = isCapped
72+ ? [
73+ Sections.dimNote(
74+ kit,
75+ `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`,
76+ ),
77+ ]
78+ : fileBlocksOf(kit, entries, { ...frame, isSpaced: false }).blocks
79+
80+ return [
81+ toggle,
82+ <Box flexDirection="column" marginTop={1}>
83+ {legend}
84+ </Box>,
85+ ...bodies,
86+ ]
87+}
@@ -1,0 +1,3 @@
1+export * from './earlier-section-of.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Detail from '../../detail'
7+import Entries from '../../entries'
8+import type { Kit } from '../../kit'
9+import Sections from '../../sections'
10+import type { BodyFrame } from '../body-frame'
11+import type { DrawnBlocks } from '../drawn-blocks'
12+
13+/**
14+ * Every listed file's detail, one under the other as ReplDiffSidebar's
15+ * body stacks them: a rule, the file (Detail.detailView), a gap if spaced.
16+ *
17+ * Each block is keyed (Sections.bodyKeyOf) so a row's press scrolls to it.
18+ * The bodies share one budget in drawing order; a file it runs out on
19+ * draws truncated, as its footer says.
20+ *
21+ * @param kit the elements, the handlers, the width
22+ * @param entries the files, in drawing order
23+ * @param frame the pane's state the details read, and the room they share
24+ * @returns the blocks, and the room left after the last
25+ */
26+export function fileBlocksOf(
27+ kit: Kit,
28+ entries: readonly Entries.BodyEntry[],
29+ frame: BodyFrame,
30+): DrawnBlocks {
31+ const { Box } = kit.ui
32+ const blocks: RenderElement[] = []
33+ const gap = frame.isSpaced ? [<Box height={1} />] : []
34+
35+ let { room } = frame
36+
37+ for (const entry of entries) {
38+ const drawn = Detail.detailView(
39+ kit,
40+ Entries.detailModelOf(entry, frame.model),
41+ { room, onToggleAsk: () => kit.actions.toggleAsk(entry.path) },
42+ )
43+
44+ room = drawn.room
45+
46+ blocks.push(
47+ <Box key={Sections.bodyKeyOf(entry.path)} flexDirection="column">
48+ {[Sections.divider(kit), drawn.element, ...gap]}
49+ </Box>,
50+ )
51+ }
52+
53+ return { blocks, room }
54+}
@@ -1,0 +1,3 @@
1+export * from './file-blocks-of.jsx'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+export * from './body-frame'
2+export * from './drawn-blocks'
3+export * from './earlier-section-of'
4+export * from './file-blocks-of'
5+export * from './list-rows-of'
6+export * from './sidebar-pane.jsx'
7+
8+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './list-rows-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,57 @@
1+import type { RenderElement } from 'claude-code'
2+
3+import { keptOf } from '../../../kept-of'
4+import Limits from '../../../limits'
5+import type Entries from '../../entries'
6+import type { Kit } from '../../kit'
7+import Layout from '../../layout'
8+import Sections from '../../sections'
9+
10+/**
11+ * The docked list (ReplDiffSidebar's summary rows): MAX_SUMMARY_ROWS files
12+ * as rows that scroll the body to their file, then a dim count of the rest.
13+ *
14+ * The rest are the rows below the window and the files past the row cap.
15+ * The built-in moves this window with the wheel over it or a chord; a pane
16+ * has neither, so the window stays at the top and names no key.
17+ *
18+ * @param kit the elements, the handlers, the width
19+ * @param entries the listed files, in order
20+ * @param notShown how many session files fell past the row cap
21+ * @returns the rows
22+ */
23+export function listRowsOf(
24+ kit: Kit,
25+ entries: readonly Entries.BodyEntry[],
26+ notShown: number,
27+): RenderElement[] {
28+ const shown = entries.slice(0, Limits.MAX_SUMMARY_ROWS)
29+ const moreBelow = entries.length - shown.length
30+
31+ const rows = shown.map(entry =>
32+ Sections.fileRow(
33+ kit,
34+ {
35+ key: Sections.fileKeyOf(entry.path),
36+ path: entry.path,
37+ displayPath: entry.displayPath,
38+ added: entry.added,
39+ removed: entry.removed,
40+ note: null,
41+ isSelected: false,
42+ },
43+ () => kit.actions.selectFile(entry.path),
44+ ),
45+ )
46+
47+ const counts = keptOf([
48+ moreBelow > 0 ? `${moreBelow} more below` : null,
49+ notShown > 0 ? `${notShown} not shown` : null,
50+ ])
51+
52+ const lead = moreBelow > 0 ? '↓ ' : '… '
53+ const hasCounts = counts.length > 0
54+ const more = Layout.sanitizeName(`${lead}${counts.join(' · ')}`)
55+
56+ return hasCounts ? [...rows, Sections.dimNote(kit, more)] : rows
57+}
@@ -1,0 +1,145 @@
1+/* @jsxRuntime classic */
2+/* @jsx h */
3+/* @jsxFrag Fragment */
4+import type { RenderElement } from 'claude-code'
5+
6+import Names from '../../names'
7+import PaneState from '../../pane-state'
8+import CurrentPane from '../current-pane'
9+import Detail from '../detail'
10+import Entries from '../entries'
11+import type { Kit } from '../kit'
12+import Layout from '../layout'
13+import { preludeOf } from '../prelude-of'
14+import Sections from '../sections'
15+import { earlierSectionOf } from './earlier-section-of'
16+import { fileBlocksOf } from './file-blocks-of'
17+import { listRowsOf } from './list-rows-of'
18+
19+/**
20+ * The docked pane (ReplDiffSidebarBody): header, pickers, todo bar, the
21+ * list, then every listed file's hunks stacked, the pre-session after.
22+ *
23+ * A row's press scrolls the body to its file. Over a picked turn the rows
24+ * and bodies are that turn's edits. With nothing to list, messagePaneOf
25+ * centers the empty state under the pickers.
26+ *
27+ * @param kit the elements, the handlers, the width, the rows
28+ * @param model the pane's state
29+ * @returns the pane's tree
30+ */
31+export function sidebarPane(
32+ kit: Kit,
33+ model: PaneState.PaneModel,
34+): RenderElement {
35+ const { Box } = kit.ui
36+ const { data } = model
37+ const turn = PaneState.pickedTurnOf(model)
38+
39+ const prelude = preludeOf(kit, model)
40+
41+ if (prelude) {
42+ return prelude
43+ }
44+
45+ const noise = model.isNoiseShown ? 'shown' : 'hidden'
46+ const partition = PaneState.partitionOf(data?.files ?? [], noise)
47+
48+ const totals = turn
49+ ? PaneState.turnTotalsOf(turn)
50+ : data
51+ ? PaneState.headerTotalsOf(data, partition)
52+ : PaneState.ZERO_TOTALS
53+
54+ const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount)
55+
56+ const noteOf = (text: string | null): RenderElement | null =>
57+ text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text))
58+
59+ const subline = turn
60+ ? noteOf(`Turn ${turn.index} "${turn.preview}"`)
61+ : noteOf(PaneState.unbornNoteOf(model, totals.filesCount))
62+
63+ const entries = turn
64+ ? turn.files.map(Entries.turnEntryOf)
65+ : partition.shown.map(file => Entries.bodyEntryOf(file, model))
66+
67+ const earlier = turn
68+ ? []
69+ : partition.preSession.map(file => Entries.bodyEntryOf(file, model))
70+
71+ const listed = turn
72+ ? entries
73+ : CurrentPane.listBodyOf(kit, { data, partition, totals, empty })
74+
75+ const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : []))
76+ const hasRows = notes.length < listed.length
77+ const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes
78+
79+ const header = Sections.headerView(
80+ kit,
81+ empty ? null : totals,
82+ Sections.controlsView(kit, model),
83+ )
84+
85+ const hasNoise = !turn && partition.noiseCount > 0
86+ const noiseFace = model.isNoiseShown ? 'hide' : 'show'
87+
88+ const noiseToggle = hasNoise
89+ ? Sections.toggleRow(kit, 'noise', {
90+ label:
91+ `${Layout.plural(partition.noiseCount, 'test')}/generated ` +
92+ `(${noiseFace})`,
93+ onPress: kit.actions.toggleNoise,
94+ })
95+ : null
96+
97+ const isUntrackedNoted =
98+ !turn && data?.isUntrackedWithheld === true && empty === null
99+
100+ const untrackedNote = noteOf(
101+ isUntrackedNoted ? Names.untrackedWithheldTextOf(model) : null,
102+ )
103+
104+ const body = fileBlocksOf(kit, hasRows ? entries : [], {
105+ model,
106+ room: Detail.BODY_BUDGET,
107+ isSpaced: true,
108+ })
109+
110+ const earlierRows = earlierSectionOf(kit, earlier, {
111+ model,
112+ room: body.room,
113+ isSpaced: false,
114+ })
115+
116+ const top = Sections.present([header, subline, Sections.todoBar(kit, model)])
117+
118+ const isMessageShown = message.length > 0
119+
120+ if (isMessageShown) {
121+ return CurrentPane.messagePaneOf(kit, {
122+ top: Sections.present([...top, noiseToggle, untrackedNote]),
123+ message,
124+ controls: null,
125+ earlier: earlierRows[0] ?? null,
126+ rest: earlierRows.slice(1),
127+ })
128+ }
129+
130+ const list = (
131+ <Box flexDirection="column" marginTop={1} marginBottom={1}>
132+ {Sections.present([
133+ ...listRowsOf(kit, entries, totals.notShown),
134+ untrackedNote,
135+ noiseToggle,
136+ ])}
137+ </Box>
138+ )
139+
140+ return (
141+ <Box flexDirection="column">
142+ {[...top, list, ...body.blocks, ...earlierRows]}
143+ </Box>
144+ )
145+}
@@ -5,13 +5,13 @@
55
66 import PaneState from '../../pane-state'
77 import type Turns from '../../turns'
8-import Detail from '../detail'
8+import Entries from '../entries'
99 import type { Kit } from '../kit'
10-import Layout from '../layout'
1110 import Sections from '../sections'
11+import { selectedDetailOf } from '../selected-detail-of'
1212
1313 /**
14- * The pane over one past turn's edits (DiffDialog's T<n> tab): the turn's
14+ * The inline pane over one past turn's edits (DiffDialog's T<n> tab): its
1515 * counts, its prompt's opening words, the pickers, its files, one body.
1616 *
1717 * @param kit the elements, the handlers, the width
@@ -32,11 +32,12 @@
3232 null
3333
3434 const rows = turn.files.map(file =>
35- Sections.fileRow(
35+ Sections.dialogFileRow(
3636 kit,
3737 {
3838 key: Sections.fileKeyOf(file.path),
3939 path: file.path,
40+ displayPath: file.path,
4041 added: file.added,
4142 removed: file.removed,
4243 note: null,
@@ -46,38 +47,17 @@
4647 ),
4748 )
4849
49- const detail = selected
50- ? [
51- Sections.divider(kit),
52- Detail.detailView(
53- kit,
54- {
55- words: model.words,
56- path: selected.path,
57- renamedFrom: null,
58- isUntracked: false,
59- isBinary: false,
60- body: {
61- hunks: selected.hunks,
62- isTruncated: selected.isTruncated,
63- isLarge: false,
64- },
65- bodyState: 'ready',
66- isArmed: model.armedPath === selected.path,
67- },
68- () => kit.actions.toggleAsk(selected.path),
69- ),
70- ]
71- : []
50+ const detail = selectedDetailOf(
51+ kit,
52+ selected ? Entries.turnEntryOf(selected) : null,
53+ model,
54+ )
7255
7356 return (
7457 <Box flexDirection="column">
7558 {Sections.present([
76- Sections.headerView(kit, PaneState.turnTotalsOf(turn)),
77- Sections.dimNote(
78- kit,
79- `Turn ${turn.index} "${Layout.sanitizeName(turn.preview)}"`,
80- ),
59+ Sections.titleRow(kit, PaneState.dialogTitleOf(model)),
60+ Sections.headerView(kit, PaneState.turnTotalsOf(turn), null),
8161 Sections.controlsView(kit, model),
8262 ...rows,
8363 ...detail,