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
| @@ -1,16 +1,19 @@ | ||
| 1 | 1 | # diff |
| 2 | 2 | |
| 3 | 3 | 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 | |
| 14 | 17 | ask button arms that file: its hunks ride the next prompt as context, once. |
| 15 | 18 | |
| 16 | 19 | The pane compares the working tree against HEAD, split at the session's |
| @@ -18,9 +21,10 @@ | ||
| 18 | 21 | the default branch; the choice is kept per repository in the plugin's |
| 19 | 22 | store. A second picker shows one earlier turn's edits instead of the |
| 20 | 23 | 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. | |
| 24 | 28 | |
| 25 | 29 | `hooks/register.ts` is the module; everything under `hooks/` is its parts. |
| 26 | 30 | |
| @@ -30,8 +34,9 @@ | ||
| 30 | 34 | | --- | --- | |
| 31 | 35 | | `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. | |
| 32 | 36 | | `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. | | |
| 35 | 40 | | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. | |
| 36 | 41 | | `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. | |
| 37 | 42 | | `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. | |
| @@ -40,10 +45,10 @@ | ||
| 40 | 45 | |
| 41 | 46 | ## What it calls on `$` |
| 42 | 47 | |
| 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`, | |
| 45 | 50 | `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`. | |
| 47 | 52 | |
| 48 | 53 | `$.telemetry` is the telemetry plugin's noun; where it is absent the rows |
| 49 | 54 | 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 @@ | ||
| 9 | 9 | ProcessRunResult, |
| 10 | 10 | SessionMessage, |
| 11 | 11 | TimerCall, |
| 12 | + UiScrollResult, | |
| 12 | 13 | } from 'claude-code' |
| 13 | 14 | |
| 14 | 15 | /** |
| @@ -32,11 +33,6 @@ | ||
| 32 | 33 | every: TimerCall |
| 33 | 34 | |
| 34 | 35 | /** |
| 35 | - * `$.clock.sleep`, no signal. | |
| 36 | - */ | |
| 37 | - sleep: (ms: number) => Promise<void> | |
| 38 | - | |
| 39 | - /** | |
| 40 | 36 | * `$.process.run`. |
| 41 | 37 | */ |
| 42 | 38 | run: ( |
| @@ -100,6 +96,12 @@ | ||
| 100 | 96 | closePane: (pane: PaneCloseArgs) => Promise<void> |
| 101 | 97 | |
| 102 | 98 | /** |
| 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 | + /** | |
| 103 | 105 | * `$.command.register`; rejects while another `/diff` is listed. |
| 104 | 106 | */ |
| 105 | 107 | registerCommand: (spec: CommandSpec) => Promise<unknown> |
| @@ -4,12 +4,15 @@ | ||
| 4 | 4 | export * from './classify' |
| 5 | 5 | export * from './command-spec' |
| 6 | 6 | export * from './count-of' |
| 7 | +export * from './drawn-files-of' | |
| 7 | 8 | export * from './entry-kinds-of' |
| 8 | 9 | export * from './git' |
| 9 | 10 | export * from './host' |
| 10 | 11 | export * from './is-on-pane-surface' |
| 11 | 12 | export * from './is-record' |
| 13 | +export * from './kept-of' | |
| 12 | 14 | export * from './limits' |
| 15 | +export * from './map-limited' | |
| 13 | 16 | export * from './message-of' |
| 14 | 17 | export * from './mtime-of' |
| 15 | 18 | 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 @@ | ||
| 4 | 4 | export * from './max-lines-per-file.js' |
| 5 | 5 | export * from './max-listed-directories.js' |
| 6 | 6 | export * from './max-untracked-probes.js' |
| 7 | -export * from './pane-top-pad-rows.js' | |
| 7 | +export * from './pane' | |
| 8 | 8 | export * from './prompt-context-max-chars.js' |
| 9 | 9 | |
| 10 | 10 | 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 |
| @@ -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 @@ | ||
| 1 | 1 | export * from './git-timeout-ms.js' |
| 2 | 2 | export * from './head-poll-ms.js' |
| 3 | -export * from './open-probe-ms.js' | |
| 4 | 3 | export * from './redraw-coalesce-ms.js' |
| 5 | 4 | export * from './refresh-debounce-ms.js' |
| 6 | 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 | +} |
| @@ -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 | -}) |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './focused-pane.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,5 @@ | ||
| 1 | 1 | export * from './command-description.js' |
| 2 | 2 | export * from './command-name.js' |
| 3 | -export * from './focused-pane' | |
| 4 | 3 | export * from './keys' |
| 5 | 4 | export * from './pane-id.js' |
| 6 | 5 | export * from './pane-title.js' |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | export * from './builtin-holds-pattern.js' |
| 2 | 2 | export * from './git-unanswered-text.js' |
| 3 | 3 | export * from './not-in-repository-text.js' |
| 4 | +export * from './panel-toggle' | |
| 4 | 5 | export * from './register-failed-text-of.js' |
| 5 | 6 | export * from './resize-terminal-text.js' |
| 6 | 7 | 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' | |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | 4 | * The pane's word for a fetch whose untracked listing the backend did not |
| 5 | 5 | * give whole: the tracked rows stand, the new files are not counted. |
| 6 | 6 | * |
| 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 | |
| 8 | 8 | * @returns the note |
| 9 | 9 | */ |
| 10 | 10 | export const untrackedWithheldTextOf = ( |
| 11 | - words: Pick<Backend.BackendWords, 'lister'>, | |
| 11 | + model: Pick<PaneState.PaneModel, 'words'>, | |
| 12 | 12 | ) => |
| 13 | - `Untracked files unavailable (${words.lister} could not list them); ` + | |
| 13 | + `Untracked files unavailable (${model.words.lister} could not list them); ` + | |
| 14 | 14 | 'not counted' |
| @@ -1,11 +1,12 @@ | ||
| 1 | +import { NO_BODIES } from '../../no-bodies' | |
| 2 | + | |
| 1 | 3 | /** |
| 2 | 4 | * 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. | |
| 4 | 6 | */ |
| 5 | 7 | export const NEW_SESSION_FIELDS = Object.freeze({ |
| 6 | 8 | selectedPath: null, |
| 7 | 9 | source: Object.freeze({ kind: 'current' }), |
| 8 | 10 | turns: Object.freeze([]), |
| 9 | - body: null, | |
| 10 | - bodyState: 'idle', | |
| 11 | + bodies: NO_BODIES, | |
| 11 | 12 | } as const) |
| @@ -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 '.' |
| @@ -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' |
| @@ -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' | |
| 3 | 1 | import Names from '../names' |
| 4 | 2 | import type { EmptyState } from './empty-state' |
| 3 | +import type { PaneModel } from './pane-model' | |
| 5 | 4 | |
| 6 | 5 | /** |
| 7 | 6 | * The headline that replaces the count when nothing is listed, worded as |
| @@ -11,16 +10,16 @@ | ||
| 11 | 10 | * "Diff unavailable"; no tracked rows while the untracked listing was |
| 12 | 11 | * withheld claims nothing about new files. |
| 13 | 12 | * |
| 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 | |
| 15 | 14 | * @param filesCount the header's session file count |
| 16 | - * @param words the backend's words | |
| 17 | 15 | * @returns the empty state, or null |
| 18 | 16 | */ |
| 19 | 17 | export function emptyStateOf( |
| 20 | - data: Git.DiffData | null, | |
| 18 | + model: Pick<PaneModel, 'data' | 'words'>, | |
| 21 | 19 | filesCount: number, |
| 22 | - words: Backend.BackendWords, | |
| 23 | 20 | ): EmptyState | null { |
| 21 | + const { data, words } = model | |
| 22 | + | |
| 24 | 23 | if (!data) { |
| 25 | 24 | return { |
| 26 | 25 | headline: 'Diff unavailable', |
| @@ -37,7 +36,7 @@ | ||
| 37 | 36 | if (data.isUntrackedWithheld) { |
| 38 | 37 | return { |
| 39 | 38 | headline: 'No tracked changes', |
| 40 | - hint: Names.untrackedWithheldTextOf(words), | |
| 39 | + hint: Names.untrackedWithheldTextOf(model), | |
| 41 | 40 | } |
| 42 | 41 | } |
| 43 | 42 |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | export * from './after-fetch' |
| 2 | 2 | export * from './after-new-session' |
| 3 | -export * from './base-label-of.js' | |
| 4 | 3 | 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' | |
| 6 | 7 | export * from './empty-state' |
| 7 | 8 | export * from './empty-state-of.js' |
| 8 | 9 | export * from './header-totals' |
| @@ -10,13 +11,16 @@ | ||
| 10 | 11 | export * from './initial-model' |
| 11 | 12 | export * from './listed-of.js' |
| 12 | 13 | export * from './mode-label-of' |
| 14 | +export * from './no-bodies' | |
| 13 | 15 | export * from './pane-model' |
| 14 | 16 | export * from './partition' |
| 15 | 17 | export * from './partition-of.js' |
| 16 | 18 | export * from './picked-turn-of' |
| 17 | 19 | export * from './selection-of' |
| 18 | 20 | export * from './source' |
| 21 | +export * from './turn-title-of' | |
| 19 | 22 | export * from './turn-totals-of' |
| 23 | +export * from './unborn-note-of.js' | |
| 20 | 24 | export * from './zero-totals' |
| 21 | 25 | |
| 22 | 26 | export * as default from '.' |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import Git from '../../git' |
| 2 | +import { NO_BODIES } from '../no-bodies' | |
| 2 | 3 | import type { PaneModel } from '../pane-model' |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * 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. | |
| 7 | 8 | */ |
| 8 | 9 | export const INITIAL_MODEL: PaneModel = Object.freeze({ |
| 9 | 10 | words: Git.GIT_WORDS, |
| @@ -18,9 +19,8 @@ | ||
| 18 | 19 | isPreSessionShown: false, |
| 19 | 20 | source: Object.freeze({ kind: 'current' }), |
| 20 | 21 | turns: Object.freeze([]), |
| 21 | - body: null, | |
| 22 | - bodyState: 'idle', | |
| 22 | + bodies: NO_BODIES, | |
| 23 | 23 | todos: Object.freeze({ done: 0, total: 0 }), |
| 24 | 24 | armedPath: null, |
| 25 | - isFocused: false, | |
| 25 | + placement: 'dock', | |
| 26 | 26 | }) |
| @@ -2,34 +2,43 @@ | ||
| 2 | 2 | import type { PaneModel } from '../pane-model' |
| 3 | 3 | |
| 4 | 4 | /** |
| 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. | |
| 6 | 7 | * |
| 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. | |
| 10 | 11 | * |
| 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 | |
| 15 | 15 | */ |
| 16 | 16 | 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' | |
| 22 | 27 | |
| 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 | + } | |
| 32 | 38 | |
| 33 | - return phase === 'pending' ? 'branch diff' : `vs ${base} (no base branch)` | |
| 39 | + return isSettledBranch ? `vs ${base} (no base branch)` : 'branch' | |
| 40 | + } | |
| 34 | 41 | } |
| 42 | + | |
| 43 | + return `${nameOf()}${suffix}` | |
| 35 | 44 | } |
| @@ -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 @@ | ||
| 2 | 2 | import type Git from '../../git' |
| 3 | 3 | import type Todos from '../../todos' |
| 4 | 4 | import type Turns from '../../turns' |
| 5 | -import type { BodyState } from '../body-state' | |
| 5 | +import type { Bodies } from '../bodies' | |
| 6 | 6 | import type { Source } from '../source' |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * Everything one drawing of the pane reads. |
| 10 | 10 | * |
| 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). | |
| 14 | 14 | */ |
| 15 | 15 | export type PaneModel = { |
| 16 | 16 | words: Backend.BackendWords |
| @@ -25,9 +25,8 @@ | ||
| 25 | 25 | isPreSessionShown: boolean |
| 26 | 26 | source: Source |
| 27 | 27 | turns: readonly Turns.TurnDiff[] |
| 28 | - body: Git.FileHunks | null | |
| 29 | - bodyState: BodyState | |
| 28 | + bodies: Bodies | |
| 30 | 29 | todos: Todos.TodoProgress |
| 31 | 30 | armedPath: string | null |
| 32 | - isFocused: boolean | |
| 31 | + placement: 'dock' | 'inline' | |
| 33 | 32 | } |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | import Classify from '../classify' |
| 2 | -import type Git from '../git' | |
| 2 | +import Git from '../git' | |
| 3 | 3 | import type { Partition } from './partition' |
| 4 | 4 | |
| 5 | 5 | /** |
| 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. | |
| 8 | 8 | * |
| 9 | 9 | * No read-deny bucket: the pane feeds nothing to the model on its own. |
| 10 | 10 | * |
| @@ -16,8 +16,12 @@ | ||
| 16 | 16 | files: readonly Git.FileStat[], |
| 17 | 17 | noise: 'shown' | 'hidden', |
| 18 | 18 | ): 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) | |
| 21 | 25 | const quiet = session.filter(file => !Classify.isNoiseFile(file.path)) |
| 22 | 26 | |
| 23 | 27 | 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What the plugin knows of its pane when `/diff` runs. |
| 3 | 3 | * |
| 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). | |
| 6 | 7 | */ |
| 7 | 8 | export type PaneBelief = { |
| 8 | - isBelievedOpen: boolean | |
| 9 | - wasDrawnWhenProbed: boolean | |
| 9 | + isOpen: boolean | |
| 10 | 10 | columns: number | null |
| 11 | 11 | } |
| @@ -2,23 +2,20 @@ | ||
| 2 | 2 | import type { PaneBelief } from './pane-belief' |
| 3 | 3 | |
| 4 | 4 | /** |
| 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. | |
| 7 | 7 | * |
| 8 | 8 | * 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. | |
| 11 | 10 | * |
| 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 | |
| 13 | 12 | * @returns `close`, `open` or `too-narrow` |
| 14 | 13 | */ |
| 15 | 14 | export function paneToggleOf( |
| 16 | 15 | pane: PaneBelief, |
| 17 | 16 | ): 'open' | 'close' | 'too-narrow' { |
| 18 | - const isClosing = pane.isBelievedOpen && pane.wasDrawnWhenProbed | |
| 19 | - | |
| 20 | 17 | const isTooNarrow = |
| 21 | 18 | pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS |
| 22 | 19 | |
| 23 | - return isClosing ? 'close' : isTooNarrow ? 'too-narrow' : 'open' | |
| 20 | + return pane.isOpen ? 'close' : isTooNarrow ? 'too-narrow' : 'open' | |
| 24 | 21 | } |
| @@ -3,11 +3,13 @@ | ||
| 3 | 3 | import Ask from './ask' |
| 4 | 4 | import Backend from './backend' |
| 5 | 5 | import { COMMAND_SPEC } from './command-spec' |
| 6 | +import { drawnFilesOf } from './drawn-files-of' | |
| 6 | 7 | import { entryKindsOf } from './entry-kinds-of' |
| 7 | 8 | import type Git from './git' |
| 8 | 9 | import type { Host } from './host' |
| 9 | 10 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 10 | 11 | import Limits from './limits' |
| 12 | +import { mapLimited } from './map-limited' | |
| 11 | 13 | import { messageOf } from './message-of' |
| 12 | 14 | import { mtimeOf } from './mtime-of' |
| 13 | 15 | import Names from './names' |
| @@ -36,14 +38,16 @@ | ||
| 36 | 38 | let hasAutoOpened = false |
| 37 | 39 | let columns: number | null = null |
| 38 | 40 | let shownSessionId: string | null = null |
| 39 | - let wasDrawnSinceProbe = false | |
| 40 | 41 | let armed: Ask.ArmedAsk | null = null |
| 41 | 42 | let carrying: Ask.ArmedAsk | null = null |
| 42 | 43 | let isRefreshing = false |
| 43 | 44 | let isRefreshQueued = false |
| 44 | 45 | let generation = 0 |
| 45 | - let bodyKey: string | null = null | |
| 46 | + let bodyStamp: string | null = null | |
| 47 | + let bodyBase: string | null = null | |
| 46 | 48 | |
| 49 | + const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>() | |
| 50 | + | |
| 47 | 51 | const polled = { toplevel: '', headKey: '' } |
| 48 | 52 | |
| 49 | 53 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| @@ -151,49 +155,70 @@ | ||
| 151 | 155 | ) |
| 152 | 156 | } |
| 153 | 157 | |
| 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> { | |
| 167 | 159 | const { data } = model |
| 168 | - const selected = selectedOf() | |
| 160 | + const pinned = backend | |
| 169 | 161 | |
| 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 } | |
| 173 | 167 | |
| 174 | 168 | return false |
| 175 | 169 | } |
| 176 | 170 | |
| 177 | - const key = `${generation}|${data.baseRef}|${selected.path}` | |
| 171 | + const stamp = bodyStampOf(data) | |
| 172 | + const isNewBase = data.baseRef !== bodyBase | |
| 178 | 173 | |
| 179 | - if (key === bodyKey) { | |
| 180 | - return false | |
| 174 | + if (stamp !== bodyStamp) { | |
| 175 | + bodyStamp = stamp | |
| 176 | + bodyLoads.clear() | |
| 181 | 177 | } |
| 182 | 178 | |
| 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 | + } | |
| 186 | 184 | |
| 187 | - const body = await backend.fetchFileHunks(data, selected) | |
| 185 | + return fetchBodies(engine, pinned, data) | |
| 186 | + } | |
| 188 | 187 | |
| 189 | - if (bodyKey !== key) { | |
| 190 | - return body === null | |
| 191 | - } | |
| 188 | + const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}` | |
| 192 | 189 | |
| 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) | |
| 195 | 196 | |
| 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) | |
| 197 | 222 | } |
| 198 | 223 | |
| 199 | 224 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| @@ -274,7 +299,7 @@ | ||
| 274 | 299 | break |
| 275 | 300 | } |
| 276 | 301 | |
| 277 | - const hasHunksFailed = await loadBody(engine) | |
| 302 | + const hasHunksFailed = await loadBodies(engine) | |
| 278 | 303 | |
| 279 | 304 | if (outcome.kind === 'data') { |
| 280 | 305 | record.mark( |
| @@ -318,9 +343,8 @@ | ||
| 318 | 343 | engine: Host, |
| 319 | 344 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 320 | 345 | ): 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 }) | |
| 324 | 348 | isPaneOpen = true |
| 325 | 349 | |
| 326 | 350 | const sessionId = await engine.sessionId().catch(() => null) |
| @@ -345,14 +369,6 @@ | ||
| 345 | 369 | }) |
| 346 | 370 | } |
| 347 | 371 | |
| 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 | - | |
| 356 | 372 | async function openOnFirstEdit(engine: Host): Promise<void> { |
| 357 | 373 | const isTaken = () => isPaneOpen || hasAutoOpened |
| 358 | 374 | |
| @@ -393,17 +409,21 @@ | ||
| 393 | 409 | const actionsOf = (engine: Host): Views.PaneActions => ({ |
| 394 | 410 | selectFile: path => { |
| 395 | 411 | 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 | + | |
| 397 | 417 | redraw(engine) |
| 398 | 418 | }, |
| 399 | 419 | toggleNoise: () => { |
| 400 | 420 | model = { ...model, isNoiseShown: !model.isNoiseShown } |
| 401 | - void loadBody(engine) | |
| 421 | + void loadBodies(engine) | |
| 402 | 422 | redraw(engine) |
| 403 | 423 | }, |
| 404 | 424 | togglePreSession: () => { |
| 405 | 425 | model = { ...model, isPreSessionShown: !model.isPreSessionShown } |
| 406 | - void loadBody(engine) | |
| 426 | + void loadBodies(engine) | |
| 407 | 427 | redraw(engine) |
| 408 | 428 | }, |
| 409 | 429 | chooseBase: value => { |
| @@ -413,8 +433,7 @@ | ||
| 413 | 433 | return |
| 414 | 434 | } |
| 415 | 435 | |
| 416 | - bodyKey = null | |
| 417 | - model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' } | |
| 436 | + model = { ...model, requestedMode: mode } | |
| 418 | 437 | |
| 419 | 438 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 420 | 439 | kind: 'ok', |
| @@ -441,7 +460,6 @@ | ||
| 441 | 460 | : { kind: 'current' } |
| 442 | 461 | |
| 443 | 462 | model = { ...model, source, selectedPath: null } |
| 444 | - void loadBody(engine) | |
| 445 | 463 | redraw(engine) |
| 446 | 464 | }, |
| 447 | 465 | toggleAsk: path => { |
| @@ -454,12 +472,6 @@ | ||
| 454 | 472 | |
| 455 | 473 | arm(engine, path) |
| 456 | 474 | }, |
| 457 | - close: () => { | |
| 458 | - void closePane(engine) | |
| 459 | - .then(() => markTabSwitch(engine, 'convo')) | |
| 460 | - .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false)) | |
| 461 | - .catch(() => undefined) | |
| 462 | - }, | |
| 463 | 475 | }) |
| 464 | 476 | |
| 465 | 477 | function arm(engine: Host, path: string) { |
| @@ -467,7 +479,7 @@ | ||
| 467 | 479 | path, |
| 468 | 480 | PaneState.pickedTurnOf(model)?.files.find(file => file.path === path) |
| 469 | 481 | ?.hunks ?? |
| 470 | - model.body?.hunks ?? | |
| 482 | + model.bodies.get(path)?.hunks ?? | |
| 471 | 483 | [], |
| 472 | 484 | ) |
| 473 | 485 | |
| @@ -505,7 +517,6 @@ | ||
| 505 | 517 | now: () => $.clock.now(), |
| 506 | 518 | after: (ms, fn) => $.clock.after(ms, fn), |
| 507 | 519 | every: (ms, fn) => $.clock.every(ms, fn), |
| 508 | - sleep: ms => $.clock.sleep(ms), | |
| 509 | 520 | run: (argv, init) => $.process.run(argv, init), |
| 510 | 521 | stat: path => $.fs.stat(path), |
| 511 | 522 | listDir: path => $.fs.list(path), |
| @@ -518,6 +529,8 @@ | ||
| 518 | 529 | uiLog: text => $.ui.log(text), |
| 519 | 530 | openPane: pane => $.ui.open(pane), |
| 520 | 531 | closePane: pane => $.ui.close(pane), |
| 532 | + scrollTo: key => | |
| 533 | + $.ui.scroll({ to: { key }, in: Names.PANE_ID, block: 'start' }), | |
| 521 | 534 | registerCommand: spec => $.command.register(spec), |
| 522 | 535 | sessionId: () => $.session.id(), |
| 523 | 536 | mark: entry => $.telemetry.mark(entry), |
| @@ -542,9 +555,8 @@ | ||
| 542 | 555 | |
| 543 | 556 | const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) |
| 544 | 557 | |
| 545 | - wasDrawnSinceProbe = true | |
| 546 | 558 | columns = e.viewport?.columns ?? columns |
| 547 | - model = { ...model, isFocused: e.props.isFocused } | |
| 559 | + model = { ...model, placement: e.props.placement } | |
| 548 | 560 | |
| 549 | 561 | return Views.paneView( |
| 550 | 562 | { |
| @@ -554,7 +566,7 @@ | ||
| 554 | 566 | rows: e.props.scroll.bodyRows, |
| 555 | 567 | }, |
| 556 | 568 | model, |
| 557 | - e.props.placement, | |
| 569 | + { placement: e.props.placement, terminalColumns: columns }, | |
| 558 | 570 | ) |
| 559 | 571 | }) |
| 560 | 572 | |
| @@ -573,11 +585,7 @@ | ||
| 573 | 585 | } |
| 574 | 586 | } |
| 575 | 587 | |
| 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 }) | |
| 581 | 589 | |
| 582 | 590 | if (toggle === 'too-narrow') { |
| 583 | 591 | return { text: Names.RESIZE_TERMINAL_TEXT } |
| @@ -588,9 +596,28 @@ | ||
| 588 | 596 | markTabSwitch(host, isOpening ? 'diff' : 'convo') |
| 589 | 597 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 590 | 598 | |
| 591 | - return {} | |
| 599 | + return { | |
| 600 | + text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT, | |
| 601 | + } | |
| 592 | 602 | }) |
| 593 | 603 | |
| 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 | + | |
| 594 | 621 | on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { |
| 595 | 622 | const result = await next(e) |
| 596 | 623 | |
| @@ -600,7 +627,9 @@ | ||
| 600 | 627 | } |
| 601 | 628 | |
| 602 | 629 | hasAutoOpened = false |
| 603 | - bodyKey = null | |
| 630 | + bodyStamp = null | |
| 631 | + bodyBase = null | |
| 632 | + bodyLoads.clear() | |
| 604 | 633 | disarm(host) |
| 605 | 634 | model = PaneState.afterNewSession(model) |
| 606 | 635 | } |
| @@ -6,21 +6,23 @@ | ||
| 6 | 6 | import type Git from '../../git' |
| 7 | 7 | import Names from '../../names' |
| 8 | 8 | import PaneState from '../../pane-state' |
| 9 | -import Detail from '../detail' | |
| 9 | +import Entries from '../entries' | |
| 10 | 10 | import type { Kit } from '../kit' |
| 11 | 11 | import Layout from '../layout' |
| 12 | +import { preludeOf } from '../prelude-of' | |
| 12 | 13 | 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' | |
| 14 | 16 | import { listBodyOf } from './list-body-of' |
| 15 | 17 | import { messagePaneOf } from './message-pane-of' |
| 16 | 18 | |
| 17 | 19 | /** |
| 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. | |
| 20 | 22 | * |
| 21 | 23 | * 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. | |
| 24 | 26 | * |
| 25 | 27 | * @param kit the elements, the handlers, the width, the rows |
| 26 | 28 | * @param model the pane's state |
| @@ -32,21 +34,12 @@ | ||
| 32 | 34 | ): RenderElement { |
| 33 | 35 | const { Box } = kit.ui |
| 34 | 36 | const { data } = model |
| 37 | + const prelude = preludeOf(kit, model) | |
| 35 | 38 | |
| 36 | - if (model.isOutsideRepository) { | |
| 37 | - return <Box>{Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)}</Box> | |
| 39 | + if (prelude) { | |
| 40 | + return prelude | |
| 38 | 41 | } |
| 39 | 42 | |
| 40 | - if (!data && !model.hasSettled) { | |
| 41 | - return messagePaneOf(kit, { | |
| 42 | - top: [Sections.headerView(kit, null)], | |
| 43 | - message: ['Loading diff…'], | |
| 44 | - controls: null, | |
| 45 | - earlier: null, | |
| 46 | - rest: [], | |
| 47 | - }) | |
| 48 | - } | |
| 49 | - | |
| 50 | 43 | const noise = model.isNoiseShown ? 'shown' : 'hidden' |
| 51 | 44 | const preSession = model.isPreSessionShown ? 'shown' : 'hidden' |
| 52 | 45 | const partition = PaneState.partitionOf(data?.files ?? [], noise) |
| @@ -55,18 +48,14 @@ | ||
| 55 | 48 | ? PaneState.headerTotalsOf(data, partition) |
| 56 | 49 | : PaneState.ZERO_TOTALS |
| 57 | 50 | |
| 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) | |
| 60 | 52 | |
| 61 | 53 | const selected = PaneState.selectionOf( |
| 62 | 54 | PaneState.listedOf(partition, preSession), |
| 63 | 55 | model.selectedPath, |
| 64 | 56 | ) |
| 65 | 57 | |
| 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 | |
| 70 | 59 | |
| 71 | 60 | const noteOf = (text: string | null): RenderElement | null => |
| 72 | 61 | text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text)) |
| @@ -97,72 +86,62 @@ | ||
| 97 | 86 | const isEarlierListed = earlierFiles.length > 0 |
| 98 | 87 | |
| 99 | 88 | const earlierRows = isEarlierListed |
| 100 | - ? [<Box height={1} />, ...earlierFiles.map(rowOf)] | |
| 89 | + ? [<Box height={1} />, ...dialogRowsOf(kit, earlierFiles, selectedPath)] | |
| 101 | 90 | : [] |
| 102 | 91 | |
| 103 | 92 | const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty |
| 104 | 93 | 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 | |
| 107 | 102 | 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)) | |
| 109 | 105 | |
| 110 | 106 | const notShownNote = noteOf( |
| 111 | 107 | totals.notShown > 0 ? `${totals.notShown} not shown` : null, |
| 112 | 108 | ) |
| 113 | 109 | |
| 114 | 110 | const untrackedNote = noteOf( |
| 115 | - isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null, | |
| 111 | + isUntrackedNoted ? Names.untrackedWithheldTextOf(model) : null, | |
| 116 | 112 | ) |
| 117 | 113 | |
| 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 | + ) | |
| 137 | 119 | |
| 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 | + | |
| 138 | 129 | const isMessageShown = message.length > 0 |
| 139 | 130 | |
| 140 | 131 | return isMessageShown ? ( |
| 141 | 132 | 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]), | |
| 150 | 134 | message, |
| 151 | - controls: Sections.controlsView(kit, model), | |
| 135 | + controls: null, | |
| 152 | 136 | earlier: earlierToggle, |
| 153 | 137 | rest: [...earlierRows, ...detail], |
| 154 | 138 | }) |
| 155 | 139 | ) : ( |
| 156 | 140 | <Box flexDirection="column"> |
| 157 | 141 | {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), | |
| 166 | 145 | notShownNote, |
| 167 | 146 | untrackedNote, |
| 168 | 147 | 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' | |
| 2 | 2 | import Sections from '../../sections' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * A fetched row as the list draws it: its counts, or a note in their | |
| 6 | - * place (`untracked`, `Binary file`, `renamed`), and selection. | |
| 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. | |
| 7 | 7 | * |
| 8 | 8 | * @param file the row |
| 9 | 9 | * @param selectedPath the selected file's path, or null |
| @@ -13,19 +13,16 @@ | ||
| 13 | 13 | file: Git.FileStat, |
| 14 | 14 | selectedPath: string | null, |
| 15 | 15 | ): Sections.FileRowModel { |
| 16 | - const isRenamed = file.renamedFrom !== null | |
| 17 | - | |
| 18 | 16 | const note = file.isUntracked |
| 19 | 17 | ? 'untracked' |
| 20 | 18 | : file.isBinary |
| 21 | 19 | ? 'Binary file' |
| 22 | - : isRenamed | |
| 23 | - ? 'renamed' | |
| 24 | - : null | |
| 20 | + : null | |
| 25 | 21 | |
| 26 | 22 | return { |
| 27 | 23 | key: Sections.fileKeyOf(file.path), |
| 28 | 24 | path: file.path, |
| 25 | + displayPath: Git.displayPathOf(file), | |
| 29 | 26 | added: file.added, |
| 30 | 27 | removed: file.removed, |
| 31 | 28 | note, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export * from './current-pane.jsx' |
| 2 | +export * from './dialog-rows-of' | |
| 2 | 3 | export * from './file-row-of' |
| 3 | 4 | export * from './list-body-of' |
| 4 | 5 | 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 @@ | ||
| 1 | 1 | import Git from '../../../git' |
| 2 | -import { HUNK_DIVIDER } from '../hunk-divider' | |
| 3 | -import { HUNK_DIVIDER_NODES } from '../hunk-divider-nodes' | |
| 4 | 2 | import { hunkSourceOf } from '../hunk-source-of' |
| 5 | 3 | import { leadingHunkOf } from '../leading-hunk-of' |
| 6 | -import { MAX_BODY_CHARS } from '../max-body-chars' | |
| 7 | -import { MAX_BODY_NODES } from '../max-body-nodes' | |
| 8 | 4 | import { MAX_CODE_CHARS } from '../max-code-chars' |
| 9 | 5 | import { subHunksOf } from '../sub-hunks-of' |
| 10 | 6 | import type Types from '../types' |
| 11 | 7 | import { drawnLineOf } from './drawn-line-of' |
| 12 | 8 | |
| 13 | 9 | /** |
| 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. | |
| 16 | 12 | * |
| 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. | |
| 20 | 16 | * |
| 21 | 17 | * @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 | |
| 23 | 21 | */ |
| 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[] = [] | |
| 26 | 27 | |
| 27 | 28 | let chars = 0 |
| 28 | - let nodes = 0 | |
| 29 | 29 | let isTruncated = false |
| 30 | 30 | |
| 31 | + const left = (): Types.BodyRoom => ({ | |
| 32 | + chars: room.chars - chars, | |
| 33 | + nodes: room.nodes - sources.length, | |
| 34 | + }) | |
| 35 | + | |
| 31 | 36 | for (const hunk of hunks) { |
| 32 | 37 | const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf) |
| 33 | 38 | const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS) |
| 34 | 39 | isTruncated ||= split.isTruncated |
| 35 | 40 | |
| 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 | |
| 40 | 46 | |
| 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 | - | |
| 57 | 47 | if (isOverNodes || fitted.lines.length === 0) { |
| 58 | - return { blocks, isTruncated: true } | |
| 48 | + return { sources, isTruncated: true, room: left() } | |
| 59 | 49 | } |
| 60 | 50 | |
| 61 | 51 | const text = hunkSourceOf(fitted) |
| 52 | + sources.push(text) | |
| 53 | + chars += text.length | |
| 62 | 54 | |
| 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 | - | |
| 75 | 55 | if (fitted.lines.length < piece.lines.length) { |
| 76 | - return { blocks, isTruncated: true } | |
| 56 | + return { sources, isTruncated: true, room: left() } | |
| 77 | 57 | } |
| 78 | 58 | } |
| 79 | 59 | } |
| 80 | 60 | |
| 81 | - return { blocks, isTruncated } | |
| 61 | + return { sources, isTruncated, room: left() } | |
| 82 | 62 | } |
| @@ -1,20 +1,19 @@ | ||
| 1 | 1 | import type Backend from '../../../backend' |
| 2 | 2 | import type Git from '../../../git' |
| 3 | -import type PaneState from '../../../pane-state' | |
| 4 | 3 | |
| 5 | 4 | /** |
| 6 | - * The selected file as its body draws it. | |
| 5 | + * One file as its body draws it. | |
| 7 | 6 | * |
| 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. | |
| 10 | 10 | */ |
| 11 | 11 | export type DetailModel = { |
| 12 | 12 | words: Pick<Backend.BackendWords, 'untrackedNoteOf'> |
| 13 | 13 | path: string |
| 14 | - renamedFrom: string | null | |
| 14 | + displayPath: string | |
| 15 | 15 | isUntracked: boolean |
| 16 | 16 | isBinary: boolean |
| 17 | - body: Git.FileHunks | null | |
| 18 | - bodyState: PaneState.BodyState | |
| 17 | + body: Git.FileHunks | null | undefined | |
| 19 | 18 | isArmed: boolean |
| 20 | 19 | } |
| @@ -1,49 +1,53 @@ | ||
| 1 | 1 | /* @jsxRuntime classic */ |
| 2 | 2 | /* @jsx h */ |
| 3 | 3 | /* @jsxFrag Fragment */ |
| 4 | -import type { RenderElement } from 'claude-code' | |
| 5 | - | |
| 6 | 4 | import type { Kit } from '../kit' |
| 7 | 5 | import Layout from '../layout' |
| 8 | 6 | import Sections from '../sections' |
| 9 | 7 | import { codeBlocksOf } from './code-blocks-of' |
| 10 | 8 | import type { DetailModel } from './detail-model' |
| 11 | -import { HUNK_DIVIDER } from './hunk-divider' | |
| 9 | +import { FILE_FRAME_NODES } from './file-frame-nodes' | |
| 12 | 10 | import { MAX_CODE_CHARS } from './max-code-chars' |
| 13 | 11 | import { placeholderOf } from './placeholder-of' |
| 12 | +import type Types from './types' | |
| 14 | 13 | |
| 15 | 14 | /** |
| 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 | |
| 17 | 16 | * Button, the built-in's dim rule under them, the body. |
| 18 | 17 | * |
| 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. | |
| 22 | 21 | * |
| 23 | 22 | * @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 | |
| 27 | 26 | */ |
| 28 | 27 | export function detailView( |
| 29 | 28 | kit: Kit, |
| 30 | 29 | detail: DetailModel, |
| 31 | - onToggleAsk: () => void, | |
| 32 | -): RenderElement { | |
| 30 | + draw: Types.DetailDraw, | |
| 31 | +): Types.DrawnDetail { | |
| 33 | 32 | const { Box, Text, Button, Code } = kit.ui |
| 33 | + const { room, onToggleAsk } = draw | |
| 34 | 34 | 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) | |
| 38 | 36 | |
| 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 | + } | |
| 41 | 41 | |
| 42 | - const renamedFrom = | |
| 43 | - detail.renamedFrom === null ? null : nameOf(detail.renamedFrom) | |
| 42 | + const code = codeBlocksOf( | |
| 43 | + placeholder ? [] : (detail.body?.hunks ?? []), | |
| 44 | + framed, | |
| 45 | + ) | |
| 44 | 46 | |
| 47 | + const isTruncated = detail.body?.isTruncated === true || code.isTruncated | |
| 48 | + const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) | |
| 49 | + | |
| 45 | 50 | const asides = [ |
| 46 | - renamedFrom === null ? null : `renamed from ${renamedFrom}`, | |
| 47 | 51 | detail.isUntracked ? 'untracked' : null, |
| 48 | 52 | isTruncated ? 'truncated' : null, |
| 49 | 53 | ].filter(word => word !== null) |
| @@ -53,7 +57,7 @@ | ||
| 53 | 57 | const ask = placeholder |
| 54 | 58 | ? [] |
| 55 | 59 | : [ |
| 56 | - <Button key="ask" onPress={onToggleAsk}> | |
| 60 | + <Button key={Sections.askKeyOf(detail.path)} onPress={onToggleAsk}> | |
| 57 | 61 | {detail.isArmed ? 'asked ✓' : 'ask'} |
| 58 | 62 | </Button>, |
| 59 | 63 | ] |
| @@ -72,18 +76,17 @@ | ||
| 72 | 76 | </Text> |
| 73 | 77 | )) |
| 74 | 78 | |
| 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 | + )) | |
| 79 | 82 | |
| 80 | - return ( | |
| 83 | + const element = ( | |
| 81 | 84 | <Box flexDirection="column"> |
| 82 | 85 | {[ |
| 83 | 86 | <Box flexDirection="row"> |
| 84 | 87 | {[ |
| 85 | 88 | <Text bold wrap="truncate-start"> |
| 86 | - {nameOf(detail.path)} | |
| 89 | + {Layout.truncateStart(name, kit.columns)} | |
| 87 | 90 | </Text>, |
| 88 | 91 | <Text dimColor>{aside}</Text>, |
| 89 | 92 | <Box flexGrow={1} />, |
| @@ -96,4 +99,6 @@ | ||
| 96 | 99 | ]} |
| 97 | 100 | </Box> |
| 98 | 101 | ) |
| 102 | + | |
| 103 | + return { element, room: code.room } | |
| 99 | 104 | } |
| @@ -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 '.' |
| @@ -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 = '...' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './hunk-divider.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -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 |
| @@ -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' | |
| 1 | 2 | export * from './code-blocks-of' |
| 2 | 3 | export * from './detail-model' |
| 3 | 4 | export * from './detail-view.jsx' |
| 4 | -export * from './hunk-divider' | |
| 5 | -export * from './hunk-divider-nodes' | |
| 5 | +export * from './file-frame-nodes' | |
| 6 | 6 | export * from './hunk-header-of' |
| 7 | 7 | export * from './hunk-source-of' |
| 8 | 8 | export * from './leading-hunk-of' |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | * |
| 5 | 5 | * Under the host's whole-tree cap (100 000), with room for the pane's rest. |
| 6 | 6 | */ |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 3 | 4 | * |
| 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. | |
| 6 | 7 | */ |
| 7 | -export const MAX_BODY_NODES = 1_200 | |
| 8 | +export const MAX_BODY_NODES = 1_500 |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | * letters, digits and `._/@+-`), binary, loading, unreadable, large, or |
| 10 | 10 | * empty. |
| 11 | 11 | * |
| 12 | - * @param detail the selected file | |
| 12 | + * @param detail the file | |
| 13 | 13 | * @returns the lines, or null |
| 14 | 14 | */ |
| 15 | 15 | export function placeholderOf(detail: DetailModel): readonly string[] | null { |
| @@ -24,11 +24,11 @@ | ||
| 24 | 24 | return ['Binary file - cannot display diff'] |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | - if (detail.bodyState === 'idle' || detail.bodyState === 'loading') { | |
| 27 | + if (detail.body === undefined) { | |
| 28 | 28 | return ['Loading diff…'] |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | - if (detail.bodyState === 'failed' || !detail.body) { | |
| 31 | + if (detail.body === null) { | |
| 32 | 32 | return ['Diff unavailable'] |
| 33 | 33 | } |
| 34 | 34 |
| @@ -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 '.' |
| @@ -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 | -} |
| @@ -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' | |
| 2 | 2 | |
| 3 | 3 | /** |
| 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. | |
| 6 | 6 | */ |
| 7 | 7 | export type CodeBody = { |
| 8 | - blocks: readonly CodeBlock[] | |
| 8 | + sources: readonly string[] | |
| 9 | 9 | isTruncated: boolean |
| 10 | + room: BodyRoom | |
| 10 | 11 | } |
| @@ -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' | |
| 2 | 2 | export * from './code-body' |
| 3 | +export * from './detail-draw' | |
| 4 | +export * from './drawn-detail' | |
| 3 | 5 | |
| 4 | 6 | 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 @@ | ||
| 1 | 1 | export * from './current-pane' |
| 2 | 2 | export * from './detail' |
| 3 | +export * from './entries' | |
| 3 | 4 | export * from './inset-of' |
| 4 | 5 | export * from './kit' |
| 5 | 6 | export * from './layout' |
| 6 | 7 | export * from './pane-actions' |
| 8 | +export * from './pane-seat' | |
| 7 | 9 | export * from './pane-view.jsx' |
| 10 | +export * from './prelude-of' | |
| 8 | 11 | export * from './sections' |
| 12 | +export * from './selected-detail-of' | |
| 13 | +export * from './sidebar-pane' | |
| 9 | 14 | export * from './turn-pane' |
| 10 | 15 | |
| 11 | 16 | export * as default from '.' |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './cell-width' |
| 2 | 2 | export * from './charged-width' |
| 3 | +export * from './pad-end' | |
| 3 | 4 | export * from './plural' |
| 4 | 5 | export * from './sanitize-line' |
| 5 | 6 | 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 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | export type PaneActions = { |
| 6 | 6 | /** |
| 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. | |
| 8 | 9 | */ |
| 9 | 10 | selectFile: (path: string) => void |
| 10 | 11 | |
| @@ -33,10 +34,4 @@ | ||
| 33 | 34 | * Arms this file's diff for the next prompt, or disarms it. |
| 34 | 35 | */ |
| 35 | 36 | 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 | |
| 42 | 37 | } |
| @@ -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 @@ | ||
| 9 | 9 | import { currentPane } from './current-pane' |
| 10 | 10 | import { insetOf } from './inset-of' |
| 11 | 11 | import type { Kit } from './kit' |
| 12 | +import type { PaneSeat } from './pane-seat' | |
| 12 | 13 | import Sections from './sections' |
| 14 | +import { sidebarPane } from './sidebar-pane' | |
| 13 | 15 | import { turnPane } from './turn-pane' |
| 14 | 16 | |
| 15 | 17 | /** |
| 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. | |
| 18 | 20 | * |
| 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. | |
| 22 | 24 | * |
| 23 | 25 | * @param kit the elements, the handlers, the width |
| 24 | 26 | * @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 | |
| 26 | 28 | * @returns the tree |
| 27 | 29 | */ |
| 28 | 30 | export function paneView( |
| 29 | 31 | kit: Kit, |
| 30 | 32 | model: PaneState.PaneModel, |
| 31 | - placement: 'dock' | 'inline', | |
| 33 | + seat: PaneSeat, | |
| 32 | 34 | ): RenderElement { |
| 33 | 35 | const { Box } = kit.ui |
| 34 | - const turn = PaneState.pickedTurnOf(model) | |
| 35 | - const isCurrent = turn === undefined | |
| 36 | + const isDocked = seat.placement === 'dock' | |
| 36 | 37 | |
| 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 | |
| 40 | 41 | |
| 41 | - const inset = insetOf(kit) | |
| 42 | + function dialogOf(): RenderElement { | |
| 43 | + const turn = PaneState.pickedTurnOf(model) | |
| 42 | 44 | |
| 43 | - return ( | |
| 45 | + return turn ? turnPane(kit, model, turn) : currentPane(kit, model) | |
| 46 | + } | |
| 47 | + | |
| 48 | + return isDocked ? ( | |
| 44 | 49 | <Box |
| 45 | 50 | flexDirection="column" |
| 46 | 51 | paddingTop={Limits.PANE_TOP_PAD_ROWS} |
| 47 | 52 | paddingRight={Limits.PANE_RIGHT_PAD_COLUMNS} |
| 48 | 53 | > |
| 49 | - {isCurrent ? currentPane(inset, model) : turnPane(inset, model, turn)} | |
| 54 | + {sidebarPane(insetOf(kit), model)} | |
| 50 | 55 | </Box> |
| 56 | + ) : isNarrow ? ( | |
| 57 | + <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box> | |
| 58 | + ) : ( | |
| 59 | + <Box flexDirection="column">{dialogOf()}</Box> | |
| 51 | 60 | ) |
| 52 | 61 | } |
| @@ -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 '.' |
| @@ -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 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './close-button.jsx' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | /* @jsxFrag Fragment */ |
| 4 | 4 | import type { RenderElement } from 'claude-code' |
| 5 | 5 | |
| 6 | -import type PaneState from '../../pane-state' | |
| 6 | +import PaneState from '../../pane-state' | |
| 7 | 7 | import type { Kit } from '../kit' |
| 8 | 8 | import { present } from './present' |
| 9 | 9 | |
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * |
| 14 | 14 | * The source picker draws only when a turn exists, as DiffDialog hides its |
| 15 | 15 | * 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. | |
| 17 | 17 | * |
| 18 | 18 | * @param kit the elements and the handlers |
| 19 | 19 | * @param model what is picked now and the turns to pick from |
| @@ -47,26 +47,15 @@ | ||
| 47 | 47 | ) : null |
| 48 | 48 | |
| 49 | 49 | 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 | |
| 53 | 50 | |
| 54 | 51 | const basePicker = isCurrent ? ( |
| 55 | 52 | <Select |
| 56 | 53 | key="base" |
| 57 | 54 | 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 | + }))} | |
| 70 | 59 | value={model.requestedMode} |
| 71 | 60 | onSelect={value => kit.actions.chooseBase(value)} |
| 72 | 61 | /> |
| @@ -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 | +} |
| @@ -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' | |
| 2 | 2 | |
| 3 | 3 | export * as default from '.' |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | */ |
| 5 | 5 | export type FileRowModel = { |
| 6 | 6 | key: string |
| 7 | 7 | path: string |
| 8 | + displayPath: string | |
| 8 | 9 | added: number |
| 9 | 10 | removed: number |
| 10 | 11 | note: string | null |
| @@ -6,40 +6,39 @@ | ||
| 6 | 6 | import type PaneState from '../../pane-state' |
| 7 | 7 | import type { Kit } from '../kit' |
| 8 | 8 | import Layout from '../layout' |
| 9 | -import { closeButton } from './close-button' | |
| 10 | 9 | import { diffStat } from './diff-stat' |
| 10 | +import { present } from './present' | |
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * 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. | |
| 15 | 15 | * |
| 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. | |
| 17 | 18 | * |
| 18 | 19 | * @param kit the drawing's kit; its elements draw the line |
| 19 | 20 | * @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 | |
| 20 | 22 | * @returns the line |
| 21 | 23 | */ |
| 22 | 24 | export function headerView( |
| 23 | 25 | kit: Kit, |
| 24 | 26 | totals: PaneState.HeaderTotals | null, |
| 27 | + controls: RenderElement | null, | |
| 25 | 28 | ): RenderElement { |
| 26 | 29 | const { Box, Text } = kit.ui |
| 27 | 30 | |
| 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 | + | |
| 28 | 39 | 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])} | |
| 43 | 42 | </Box> |
| 44 | 43 | ) |
| 45 | 44 | } |
| @@ -1,5 +1,9 @@ | ||
| 1 | -export * from './close-button' | |
| 1 | +export * from './ask-key-of' | |
| 2 | +export * from './body-key-of' | |
| 2 | 3 | export * from './controls-view.jsx' |
| 4 | +export * from './dialog-file-row' | |
| 5 | +export * from './dialog-path-floor' | |
| 6 | +export * from './dialog-path-reserve' | |
| 3 | 7 | export * from './diff-stat' |
| 4 | 8 | export * from './dim-note' |
| 5 | 9 | export * from './divider.jsx' |
| @@ -7,10 +11,15 @@ | ||
| 7 | 11 | export * from './file-row' |
| 8 | 12 | export * from './file-row-model' |
| 9 | 13 | export * from './header-view.jsx' |
| 14 | +export * from './keyed-pressable' | |
| 15 | +export * from './list-row' | |
| 16 | +export * from './path-floor' | |
| 10 | 17 | export * from './pointer' |
| 11 | 18 | export * from './present' |
| 12 | 19 | export * from './pressable' |
| 13 | 20 | export * from './stat-cells' |
| 21 | +export * from './stat-text-of' | |
| 22 | +export * from './title-row.jsx' | |
| 14 | 23 | export * from './todo-bar.jsx' |
| 15 | 24 | export * from './todo-bar-cells' |
| 16 | 25 | 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 @@ | ||
| 1 | 1 | import type { RenderElement } from 'claude-code' |
| 2 | 2 | |
| 3 | +import { keptOf } from '../../../kept-of' | |
| 4 | + | |
| 3 | 5 | /** |
| 4 | 6 | * The children that are there: nulls dropped, so a part may answer null for |
| 5 | 7 | * "nothing to draw" and the parent lists it all the same. |
| @@ -9,5 +11,4 @@ | ||
| 9 | 11 | */ |
| 10 | 12 | export const present = ( |
| 11 | 13 | children: readonly (RenderElement | null)[], |
| 12 | -): RenderElement[] => | |
| 13 | - children.filter((child): child is RenderElement => child !== null) | |
| 14 | +): RenderElement[] => keptOf(children) |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 4 | 4 | */ |
| 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 @@ | ||
| 5 | 5 | |
| 6 | 6 | import PaneState from '../../pane-state' |
| 7 | 7 | import type Turns from '../../turns' |
| 8 | -import Detail from '../detail' | |
| 8 | +import Entries from '../entries' | |
| 9 | 9 | import type { Kit } from '../kit' |
| 10 | -import Layout from '../layout' | |
| 11 | 10 | import Sections from '../sections' |
| 11 | +import { selectedDetailOf } from '../selected-detail-of' | |
| 12 | 12 | |
| 13 | 13 | /** |
| 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 | |
| 15 | 15 | * counts, its prompt's opening words, the pickers, its files, one body. |
| 16 | 16 | * |
| 17 | 17 | * @param kit the elements, the handlers, the width |
| @@ -32,11 +32,12 @@ | ||
| 32 | 32 | null |
| 33 | 33 | |
| 34 | 34 | const rows = turn.files.map(file => |
| 35 | - Sections.fileRow( | |
| 35 | + Sections.dialogFileRow( | |
| 36 | 36 | kit, |
| 37 | 37 | { |
| 38 | 38 | key: Sections.fileKeyOf(file.path), |
| 39 | 39 | path: file.path, |
| 40 | + displayPath: file.path, | |
| 40 | 41 | added: file.added, |
| 41 | 42 | removed: file.removed, |
| 42 | 43 | note: null, |
| @@ -46,38 +47,17 @@ | ||
| 46 | 47 | ), |
| 47 | 48 | ) |
| 48 | 49 | |
| 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 | + ) | |
| 72 | 55 | |
| 73 | 56 | return ( |
| 74 | 57 | <Box flexDirection="column"> |
| 75 | 58 | {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), | |
| 81 | 61 | Sections.controlsView(kit, model), |
| 82 | 62 | ...rows, |
| 83 | 63 | ...detail, |