surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
mods/diff: pinned header with body-only scroll, the built-in's list and base chords, wheel routing, and the DiffDialog off fullscreen
| @@ -1,26 +1,45 @@ | ||
| 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 | |
| 14 | -ask button arms that file: its hunks ride the next prompt as context, once. | |
| 4 | +beside the transcript, one row per changed file and every file's hunks | |
| 5 | +beneath, and closes it again; each toggle leaves `Diff panel shown` or | |
| 6 | +`Diff panel hidden` in the transcript. The header, the file list and its | |
| 7 | +toggles stay put while the wheel moves the hunks under them three rows a | |
| 8 | +tick, or the list a file a tick while the wheel is over a list longer than | |
| 9 | +its eight rows (the plugin answers the pane's `ui.scroll` itself); a row's | |
| 10 | +click puts that file's hunks at the top; the list also scrolls under the | |
| 11 | +built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and | |
| 12 | +`ctrl+x b` moves the comparison base on, as the built-in's chord does: both | |
| 13 | +through Buttons that declare the engine's own actions. The pane refreshes | |
| 14 | +as Claude edits, runs shell commands and finishes turns, and while it is | |
| 15 | +open it polls the repository's HEAD so a commit or checkout made elsewhere | |
| 16 | +shows too. The first successful edit of a session opens the pane by itself | |
| 17 | +where the terminal is wide enough (144 columns when the person never chose, | |
| 18 | +110 when they kept it open before; a person who closed it is left alone). | |
| 15 | 19 | |
| 20 | +Under the fullscreen layout a terminal under 110 columns gets the | |
| 21 | +built-in's line asking for a wider one and nothing opens. Without that | |
| 22 | +layout (`CLAUDE_CODE_NO_FLICKER=0`, which `/diff` learns from the command's | |
| 23 | +`presentation`) the pane opens inline at any width, focused and as tall | |
| 24 | +as its content (the open's `rows`), in the built-in dialog's shape: the | |
| 25 | +title, the count, five file rows at a time round the selected one (`❯`, | |
| 26 | +where the focus ring starts; the plugin follows the ring's walk through | |
| 27 | +`ui.focus` and re-centres the rows as the built-in does), the key hints; | |
| 28 | +Enter shows that file's hunks alone, | |
| 29 | +Escape backs out to the list and then closes, leaving `Diff dialog | |
| 30 | +dismissed`; toasts are held while it is up. A file's ask button arms that | |
| 31 | +file: its hunks ride the next prompt as context, once. | |
| 32 | + | |
| 16 | 33 | The pane compares the working tree against HEAD, split at the session's |
| 17 | 34 | start (the default), against HEAD plainly, or against the merge-base with |
| 18 | -the default branch; the choice is kept per repository in the plugin's | |
| 19 | -store. A second picker shows one earlier turn's edits instead of the | |
| 20 | -working tree, read from the session's messages. Files that changed before | |
| 21 | -the session started, and noise (lockfiles, generated and test files), are | |
| 22 | -listed apart and folded until asked for. Outside a git repository `/diff` | |
| 23 | -says so and does nothing else. | |
| 35 | +the default branch; the base line under the header names a base other than | |
| 36 | +the session's, and the choice is kept per repository in the plugin's store. | |
| 37 | +A picker shows one earlier turn's edits instead of the working tree, read | |
| 38 | +from the session's messages. Files that changed before the session started | |
| 39 | +(by their timestamp, among the paths already dirty at the start), and noise | |
| 40 | +(lockfiles, generated and test files), are listed apart and folded until | |
| 41 | +asked for; a rename lists as git prints it. Outside a git repository | |
| 42 | +`/diff` says so and does nothing else. | |
| 24 | 43 | |
| 25 | 44 | `hooks/register.ts` is the module; everything under `hooks/` is its parts. |
| 26 | 45 | |
| @@ -30,8 +49,11 @@ | ||
| 30 | 49 | | --- | --- | |
| 31 | 50 | | `session.start` | Binds the engine once, registers `/diff` (a session where another `/diff` is listed leaves the plugin idle), and pins the repository. | |
| 32 | 51 | | `ui.render` of `PromptHint` | Reads the terminal's width, which decides whether the first edit opens the pane. | |
| 33 | -| `ui.render` of `Pane` | Draws the pane: header, the file list, the toggles, the base and source pickers, and the selected file's hunks. | | |
| 34 | -| `command.run` of `diff` | Opens or closes the pane and remembers the choice. | | |
| 52 | +| `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. | | |
| 53 | +| `command.run` of `diff` | Opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. | | |
| 54 | +| `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. | | |
| 55 | +| `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. | | |
| 56 | +| `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. | | |
| 35 | 57 | | `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state. | |
| 36 | 58 | | `tool.call` of `Edit`, `Write`, `NotebookEdit` | After the edit, refreshes an open pane; the session's first successful edit opens it. | |
| 37 | 59 | | `tool.call` of `Bash`, `PowerShell` | After the command, refreshes an open pane. | |
| @@ -40,8 +62,8 @@ | ||
| 40 | 62 | |
| 41 | 63 | ## What it calls on `$` |
| 42 | 64 | |
| 43 | -`clock.after`, `clock.every`, `clock.now`, `clock.sleep`, `command.register`, | |
| 44 | -`fs.list`, `fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`, | |
| 65 | +`clock.after`, `clock.every`, `clock.now`, `command.register`, `fs.list`, | |
| 66 | +`fs.read`, `fs.stat`, `process.run` (git, read-only), `session.messages`, | |
| 45 | 67 | `store.get`, `store.set`, `telemetry.log`, `telemetry.mark`, `ui.close`, |
| 46 | 68 | `ui.invalidate`, `ui.log`, `ui.open`, `ui.resolve`, `ui.status`. |
| 47 | 69 |
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type Git from '../git' | |
| 2 | +import Limits from '../limits' | |
| 3 | +import PaneState from '../pane-state' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The files whose bodies the pane draws now, in drawing order: the listed | |
| 7 | + * session rows, then the pre-session rows while their section is open. | |
| 8 | + * | |
| 9 | + * Past PRE_SESSION_BODY_CAP pre-session files only their legend shows, so | |
| 10 | + * none of their bodies is wanted. | |
| 11 | + * | |
| 12 | + * @param model the pane's state: its fetch and its two toggles | |
| 13 | + * @returns the rows, none without a fetch | |
| 14 | + */ | |
| 15 | +export function drawnFilesOf( | |
| 16 | + model: Pick< | |
| 17 | + PaneState.PaneModel, | |
| 18 | + 'data' | 'isNoiseShown' | 'isPreSessionShown' | |
| 19 | + >, | |
| 20 | +): readonly Git.FileStat[] { | |
| 21 | + const partition = PaneState.partitionOf( | |
| 22 | + model.data?.files ?? [], | |
| 23 | + model.isNoiseShown ? 'shown' : 'hidden', | |
| 24 | + ) | |
| 25 | + | |
| 26 | + const isBodied = | |
| 27 | + model.isPreSessionShown && | |
| 28 | + partition.preSession.length <= Limits.PRE_SESSION_BODY_CAP | |
| 29 | + | |
| 30 | + return [...partition.shown, ...(isBodied ? partition.preSession : [])] | |
| 31 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './drawn-files-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import { renameDisplayOf } from '../rename-display-of' | |
| 2 | +import type Types from '../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A row's name as the built-in lists it: the path, or for a rename the one | |
| 6 | + * line `git diff --stat` prints for it (renameDisplayOf). | |
| 7 | + * | |
| 8 | + * @param file the row | |
| 9 | + * @returns the name to draw and to sort by | |
| 10 | + */ | |
| 11 | +export function displayPathOf( | |
| 12 | + file: Pick<Types.FileStat, 'path' | 'renamedFrom'>, | |
| 13 | +): string { | |
| 14 | + const { renamedFrom, path } = file | |
| 15 | + | |
| 16 | + return renamedFrom === null ? path : renameDisplayOf(renamedFrom, path) | |
| 17 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './display-path-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -2,14 +2,16 @@ | ||
| 2 | 2 | import { EMPTY_FILE_HUNKS } from '../empty-file-hunks' |
| 3 | 3 | import Parse from '../parse' |
| 4 | 4 | import type Types from '../types' |
| 5 | +import { isLastOfDiff } from './is-last-of-diff' | |
| 6 | +import { withClosingLine } from './with-closing-line' | |
| 5 | 7 | |
| 6 | 8 | /** |
| 7 | 9 | * One file's hunks against the base its row was read against, so body and |
| 8 | 10 | * counts agree, by literal pathspec, as the built-in panel reads hunks. |
| 9 | 11 | * |
| 10 | - * A rename passes both its paths so git pairs them; an untracked or binary | |
| 11 | - * row, or on an unborn HEAD a file edited after staging, has no honest | |
| 12 | - * body and answers no hunks. | |
| 12 | + * An untracked, binary or renamed row (the built-in shows a rename's | |
| 13 | + * counts, never its body), or on an unborn HEAD a file edited after | |
| 14 | + * staging, answers no hunks; each file but the last, a closing empty row. | |
| 13 | 15 | * |
| 14 | 16 | * @param run runs git against the pinned repository |
| 15 | 17 | * @param data the fetch the row belongs to |
| @@ -22,7 +24,10 @@ | ||
| 22 | 24 | file: Types.FileStat, |
| 23 | 25 | ): Promise<Types.FileHunks | null> { |
| 24 | 26 | const hasNoBody = |
| 25 | - file.isUntracked || file.isBinary || data.stalePaths.includes(file.path) | |
| 27 | + file.isUntracked || | |
| 28 | + file.isBinary || | |
| 29 | + file.renamedFrom !== null || | |
| 30 | + data.stalePaths.includes(file.path) | |
| 26 | 31 | |
| 27 | 32 | if (hasNoBody) { |
| 28 | 33 | return EMPTY_FILE_HUNKS |
| @@ -33,9 +38,14 @@ | ||
| 33 | 38 | ...Argv.DIFF_LEADING_ARGS, |
| 34 | 39 | data.baseRef, |
| 35 | 40 | '--', |
| 36 | - ...(file.renamedFrom === null ? [] : [file.renamedFrom]), | |
| 37 | 41 | file.path, |
| 38 | 42 | ]) |
| 39 | 43 | |
| 40 | - return exitCode === 0 ? Parse.parseFileDiff(stdout) : null | |
| 44 | + if (exitCode !== 0) { | |
| 45 | + return null | |
| 46 | + } | |
| 47 | + | |
| 48 | + const body = Parse.parseFileDiff(stdout) | |
| 49 | + | |
| 50 | + return isLastOfDiff(data, file) ? body : withClosingLine(body) | |
| 41 | 51 | } |
| @@ -1,3 +1,5 @@ | ||
| 1 | 1 | export * from './fetch-file-hunks.js' |
| 2 | +export * from './is-last-of-diff' | |
| 3 | +export * from './with-closing-line' | |
| 2 | 4 | |
| 3 | 5 | export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './is-last-of-diff.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,18 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a row is the last file of the whole diff git printed for its | |
| 5 | + * fetch: the last tracked row, the untracked ones being listed apart. | |
| 6 | + * | |
| 7 | + * The built-in reads that diff in one piece and trims its end, so the last | |
| 8 | + * file alone has no closing empty line to draw (withClosingLine). | |
| 9 | + * | |
| 10 | + * @param data the fetch the row belongs to | |
| 11 | + * @param file the row | |
| 12 | + * @returns whether git printed this file last | |
| 13 | + */ | |
| 14 | +export const isLastOfDiff = ( | |
| 15 | + data: Types.DiffData, | |
| 16 | + file: Types.FileStat, | |
| 17 | +): boolean => | |
| 18 | + data.files.filter(row => !row.isUntracked).at(-1)?.path === file.path |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './with-closing-line.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A parsed body with one empty context row after its last hunk's lines, as | |
| 5 | + * the built-in panel draws the empty line that closes git's output. | |
| 6 | + * | |
| 7 | + * The built-in reads every file's diff in one piece: the newline before the | |
| 8 | + * next file's header parses as a context line of the file before it. A | |
| 9 | + * body with no hunk, or one already cut, gains nothing. | |
| 10 | + * | |
| 11 | + * @param body the parsed body | |
| 12 | + * @returns the body, its last hunk one row longer | |
| 13 | + */ | |
| 14 | +export function withClosingLine(body: Types.FileHunks): Types.FileHunks { | |
| 15 | + const last = body.hunks.at(-1) | |
| 16 | + const isClosed = last !== undefined && !body.isTruncated | |
| 17 | + | |
| 18 | + return isClosed | |
| 19 | + ? { | |
| 20 | + ...body, | |
| 21 | + hunks: [ | |
| 22 | + ...body.hunks.slice(0, -1), | |
| 23 | + { ...last, lines: [...last.lines, ' '] }, | |
| 24 | + ], | |
| 25 | + } | |
| 26 | + : body | |
| 27 | +} |
| @@ -14,7 +14,8 @@ | ||
| 14 | 14 | * one `rev-parse` in that directory, then every child pinned to it. |
| 15 | 15 | * |
| 16 | 16 | * Each child runs with `--git-dir`, `--work-tree` and cwd the top, under |
| 17 | - * the C locale and the fetch's timeout (the built-in's execPinnedGit). | |
| 17 | + * the C locale and the fetch's timeout (the built-in's execPinnedGit); the | |
| 18 | + * paths dirty at the start are read once, first (Probes.dirtyPathsOf). | |
| 18 | 19 | * |
| 19 | 20 | * @param host the bound host's runner and probes |
| 20 | 21 | * @returns the backend, or null outside a git working tree |
| @@ -46,6 +47,7 @@ | ||
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | const run = runOf(repository) |
| 50 | + const baseline = await Probes.dirtyPathsOf(run) | |
| 49 | 51 | |
| 50 | 52 | const depsOf = (): Types.GitDeps => ({ |
| 51 | 53 | run, |
| @@ -53,6 +55,7 @@ | ||
| 53 | 55 | mtimeOf: host.mtimeOf, |
| 54 | 56 | entryKindsOf: host.entryKindsOf, |
| 55 | 57 | sessionStartMs: host.sessionStartMsOf(), |
| 58 | + baseline, | |
| 56 | 59 | onBranchBase: host.onBranchBase, |
| 57 | 60 | }) |
| 58 | 61 |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import { countOf } from '../count-of' | |
| 2 | +import type { Hunk } from './types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The hunk less its first so many lines, its starts moved past them so | |
| 6 | + * the numbers of the lines kept stay right; whole at 0. | |
| 7 | + * | |
| 8 | + * @param hunk the hunk, its lines body lines only | |
| 9 | + * @param count how many leading lines to drop | |
| 10 | + * @returns the rest of the hunk | |
| 11 | + */ | |
| 12 | +export function hunkAfter(hunk: Hunk, count: number): Hunk { | |
| 13 | + const dropped = hunk.lines.slice(0, count) | |
| 14 | + | |
| 15 | + return { | |
| 16 | + oldStart: hunk.oldStart + countOf(dropped, line => !line.startsWith('+')), | |
| 17 | + newStart: hunk.newStart + countOf(dropped, line => !line.startsWith('-')), | |
| 18 | + lines: hunk.lines.slice(count), | |
| 19 | + } | |
| 20 | +} |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './argv' |
| 2 | 2 | export * from './cut-hunks' |
| 3 | +export * from './display-path-of' | |
| 3 | 4 | export * from './empty-file-hunks' |
| 4 | 5 | export * from './failed-run' |
| 5 | 6 | export * from './fetch-diff' |
| @@ -7,10 +8,12 @@ | ||
| 7 | 8 | export * from './git-backend-of.js' |
| 8 | 9 | export * from './git-base-modes' |
| 9 | 10 | export * from './git-words' |
| 11 | +export * from './hunk-after.js' | |
| 10 | 12 | export * from './hunk-of' |
| 11 | 13 | export * from './large-file-hunks' |
| 12 | 14 | export * from './parse' |
| 13 | 15 | export * from './probes' |
| 16 | +export * from './rename-display-of' | |
| 14 | 17 | export * from './tiers' |
| 15 | 18 | export * from './types' |
| 16 | 19 |
| @@ -1,0 +1,35 @@ | ||
| 1 | +import Argv from '../../argv' | |
| 2 | +import Parse from '../../parse' | |
| 3 | +import type Types from '../../types' | |
| 4 | +import { STATUS_PATH_OFFSET } from './status-path-offset' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Every path dirty in the working tree right now, tracked changes and | |
| 8 | + * untracked files alike, as `git status -z` lists them; null unread. | |
| 9 | + * | |
| 10 | + * Read once when the backend is pinned, so a fetch can tell a path that | |
| 11 | + * was already dirty when the session began from one that turned up since | |
| 12 | + * (GitDeps `baseline`). Renames are not paired: each side is its own path. | |
| 13 | + * | |
| 14 | + * @param run runs git against the pinned repository | |
| 15 | + * @returns the paths, root-relative, or null on a failed or cut listing | |
| 16 | + */ | |
| 17 | +export async function dirtyPathsOf( | |
| 18 | + run: Types.GitRun, | |
| 19 | +): Promise<ReadonlySet<string> | null> { | |
| 20 | + const listing = await run([ | |
| 21 | + Argv.NO_OPTIONAL_LOCKS, | |
| 22 | + 'status', | |
| 23 | + '--porcelain', | |
| 24 | + '-z', | |
| 25 | + '--untracked-files=all', | |
| 26 | + '--no-renames', | |
| 27 | + '--ignore-submodules=dirty', | |
| 28 | + ]) | |
| 29 | + | |
| 30 | + const records = listing.stdout.split('\0').filter(record => record !== '') | |
| 31 | + | |
| 32 | + return Parse.isWholeAnswer(listing) | |
| 33 | + ? new Set(records.map(record => record.slice(STATUS_PATH_OFFSET))) | |
| 34 | + : null | |
| 35 | +} |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './dirty-paths-of.js' | |
| 2 | +export * from './status-path-offset' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './status-path-offset.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Where the path starts in a `status --porcelain -z` record: after the two | |
| 3 | + * status letters and a space. | |
| 4 | + */ | |
| 5 | +export const STATUS_PATH_OFFSET = 3 |
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | export * from './current-branch-of' |
| 4 | 4 | export * from './datings-of' |
| 5 | 5 | export * from './default-branch-of' |
| 6 | +export * from './dirty-paths-of' | |
| 6 | 7 | export * from './head-key-of' |
| 7 | 8 | export * from './is-absolute-path' |
| 8 | 9 | export * from './is-safe-ref-name' |
| @@ -24,5 +25,6 @@ | ||
| 24 | 25 | export * from './untracked-files' |
| 25 | 26 | export * from './untracked-place' |
| 26 | 27 | export * from './untracked-rows-of' |
| 28 | +export * from './was-dirty-at-start' | |
| 27 | 29 | |
| 28 | 30 | export * as default from '.' |
| @@ -1,14 +1,16 @@ | ||
| 1 | 1 | import type Types from '../types' |
| 2 | 2 | import { datingsOf } from './datings-of' |
| 3 | +import { wasDirtyAtStart } from './was-dirty-at-start' | |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * The tracked rows, `isPreSession` set on each whose file was modified |
| 6 | - * before the session began, by mtime alone as the built-in panel tags them. | |
| 7 | + * before the session began and was already dirty then (the built-in's tag). | |
| 7 | 8 | * |
| 8 | 9 | * A file that cannot be dated (deleted this session, a symbolic link, past |
| 9 | - * the listing budget) stays session work, so nothing real is hidden. | |
| 10 | + * the listing budget), or that turned up since the start (wasDirtyAtStart), | |
| 11 | + * stays session work, so nothing real is hidden. | |
| 10 | 12 | * |
| 11 | - * @param context the fetch: its stamp probe and the session's start | |
| 13 | + * @param context the fetch: its stamp probe, the session's start, its baseline | |
| 12 | 14 | * @param files the tracked rows |
| 13 | 15 | * @returns the rows, tagged |
| 14 | 16 | */ |
| @@ -23,6 +25,8 @@ | ||
| 23 | 25 | |
| 24 | 26 | return files.map(file => ({ |
| 25 | 27 | ...file, |
| 26 | - isPreSession: datings.get(file.path) === 'pre-session', | |
| 28 | + isPreSession: | |
| 29 | + datings.get(file.path) === 'pre-session' && | |
| 30 | + wasDirtyAtStart(context, file.path), | |
| 27 | 31 | })) |
| 28 | 32 | } |
| @@ -2,14 +2,15 @@ | ||
| 2 | 2 | import type Types from '../../types' |
| 3 | 3 | import { datingsOf } from '../datings-of' |
| 4 | 4 | import type { UntrackedPlace } from '../untracked-place' |
| 5 | +import { wasDirtyAtStart } from '../was-dirty-at-start' | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * Listed untracked paths as 0/0 rows: the session's own first, then, |
| 8 | 9 | * when the scope asks, the tagged pre-session ones, up to the slots. |
| 9 | 10 | * |
| 10 | 11 | * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the |
| 11 | - * listing budget reads as pre-session, an undatable one as the session's | |
| 12 | - * (the built-in panel's rule). | |
| 12 | + * listing budget reads as pre-session, an undatable one or one that turned | |
| 13 | + * up since the start (wasDirtyAtStart) as the session's (the built-in's). | |
| 13 | 14 | * |
| 14 | 15 | * @param context the fetch's stamp probe and session start |
| 15 | 16 | * @param paths the untracked paths, root-relative, as the lister printed them |
| @@ -24,9 +25,13 @@ | ||
| 24 | 25 | const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) |
| 25 | 26 | const datings = await datingsOf(context, probedPaths) |
| 26 | 27 | |
| 28 | + const isPreSessionOf = (path: string, dating: Types.Dating | undefined) => | |
| 29 | + dating === 'unlisted' || | |
| 30 | + (dating === 'pre-session' && wasDirtyAtStart(context, path)) | |
| 31 | + | |
| 27 | 32 | const probed = probedPaths.map(path => ({ |
| 28 | 33 | path, |
| 29 | - isPreSession: datings.get(path) !== 'session', | |
| 34 | + isPreSession: isPreSessionOf(path, datings.get(path)), | |
| 30 | 35 | })) |
| 31 | 36 | |
| 32 | 37 | const isWithPreSession = place.scope === 'with-pre-session' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './was-dirty-at-start.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,18 @@ | ||
| 1 | +import type Types from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a path may predate the session at all: it was dirty when the | |
| 5 | + * session began, or nothing says otherwise (no baseline was read). | |
| 6 | + * | |
| 7 | + * A path missing from the baseline turned up since, so its old timestamp | |
| 8 | + * (a rename keeps the file's, a move keeps the source's) does not date it, | |
| 9 | + * as the built-in's change-time check does not let it. | |
| 10 | + * | |
| 11 | + * @param context the fetch's dating context | |
| 12 | + * @param path the root-relative path | |
| 13 | + * @returns false only when a baseline exists and lacks the path | |
| 14 | + */ | |
| 15 | +export const wasDirtyAtStart = ( | |
| 16 | + context: Types.DatingContext, | |
| 17 | + path: string, | |
| 18 | +): boolean => context.deps.baseline?.has(path) ?? true |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './rename-display-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,54 @@ | ||
| 1 | +/** | |
| 2 | + * A rename as `git diff --stat` prints it: the shared leading and trailing | |
| 3 | + * directories once, the differing middles in braces, `old => new` between. | |
| 4 | + * | |
| 5 | + * `docs/{notes => renamed}`, `{src => lib}/main`, or with nothing shared | |
| 6 | + * `old => new`; a prefix or suffix only counts up to a slash, as git counts | |
| 7 | + * it (pprint_rename). | |
| 8 | + * | |
| 9 | + * @param oldPath the path before the rename | |
| 10 | + * @param newPath the path after it | |
| 11 | + * @returns the one-line rename | |
| 12 | + */ | |
| 13 | +export function renameDisplayOf(oldPath: string, newPath: string): string { | |
| 14 | + let prefix = 0 | |
| 15 | + let suffix = 0 | |
| 16 | + | |
| 17 | + for (let at = 0; at < oldPath.length && at < newPath.length; at++) { | |
| 18 | + if (oldPath[at] !== newPath[at]) { | |
| 19 | + break | |
| 20 | + } | |
| 21 | + | |
| 22 | + if (oldPath[at] === '/') { | |
| 23 | + prefix = at + 1 | |
| 24 | + } | |
| 25 | + } | |
| 26 | + | |
| 27 | + const floor = prefix - (prefix > 0 ? 1 : 0) | |
| 28 | + | |
| 29 | + for ( | |
| 30 | + let back = 1; | |
| 31 | + oldPath.length - back >= floor && newPath.length - back >= floor; | |
| 32 | + back++ | |
| 33 | + ) { | |
| 34 | + const character = oldPath[oldPath.length - back] | |
| 35 | + | |
| 36 | + if (character !== newPath[newPath.length - back]) { | |
| 37 | + break | |
| 38 | + } | |
| 39 | + | |
| 40 | + if (character === '/') { | |
| 41 | + suffix = back | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + const middleOf = (path: string) => | |
| 46 | + path.slice(prefix, Math.max(prefix, path.length - suffix)) | |
| 47 | + | |
| 48 | + const middle = `${middleOf(oldPath)} => ${middleOf(newPath)}` | |
| 49 | + const isBraced = prefix + suffix > 0 | |
| 50 | + const head = oldPath.slice(0, prefix) | |
| 51 | + const tail = oldPath.slice(oldPath.length - suffix) | |
| 52 | + | |
| 53 | + return isBraced ? `${head}{${middle}}${tail}` : middle | |
| 54 | +} |
| @@ -2,10 +2,10 @@ | ||
| 2 | 2 | import type { StampProbe } from '../stamp-probe' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * What dating a path against the session takes: the session's start and | |
| 6 | - * one stamp probe over the working tree's top. | |
| 5 | + * What dating a path against the session takes: the session's start, one | |
| 6 | + * stamp probe over the tree's top, and when read, the paths dirty then. | |
| 7 | 7 | */ |
| 8 | 8 | export type DatingContext = { |
| 9 | - deps: Pick<GitDeps, 'sessionStartMs'> | |
| 9 | + deps: Pick<GitDeps, 'sessionStartMs' | 'baseline'> | |
| 10 | 10 | stamps: StampProbe |
| 11 | 11 | } |
| @@ -33,6 +33,15 @@ | ||
| 33 | 33 | sessionStartMs: number |
| 34 | 34 | |
| 35 | 35 | /** |
| 36 | + * The paths dirty when the backend was pinned, at the session's start | |
| 37 | + * (Probes.dirtyPathsOf); null when unlisted, absent where none is kept. | |
| 38 | + * | |
| 39 | + * A path absent from it turned up during the session (a rename's new | |
| 40 | + * name, a file moved in), whatever timestamp it carries. | |
| 41 | + */ | |
| 42 | + baseline?: ReadonlySet<string> | null | |
| 43 | + | |
| 44 | + /** | |
| 36 | 45 | * Told each time branch mode resolves its base, for telemetry. |
| 37 | 46 | */ |
| 38 | 47 | onBranchBase: (base: BranchBase) => void |
| @@ -32,11 +32,6 @@ | ||
| 32 | 32 | every: TimerCall |
| 33 | 33 | |
| 34 | 34 | /** |
| 35 | - * `$.clock.sleep`, no signal. | |
| 36 | - */ | |
| 37 | - sleep: (ms: number) => Promise<void> | |
| 38 | - | |
| 39 | - /** | |
| 40 | 35 | * `$.process.run`. |
| 41 | 36 | */ |
| 42 | 37 | run: ( |
| @@ -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) |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The cells an inline file row keeps off its path (DiffFileList's 16 for | |
| 3 | + * the counts, 3 for the pointer, 4 for the frame). | |
| 4 | + */ | |
| 5 | +export const DIALOG_PATH_RESERVE = 23 |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | export * from './auto-open-min-columns.js' |
| 2 | +export * from './dialog-path-reserve.js' | |
| 2 | 3 | export * from './open-min-columns.js' |
| 3 | 4 | export * from './pane-right-pad-columns.js' |
| 5 | +export * from './path-floor.js' | |
| 6 | +export * from './stat-cells.js' | |
| 4 | 7 | export * from './widest-bucket-columns.js' |
| 5 | 8 | |
| 6 | 9 | export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The fewest cells a docked file row gives its path, however narrow the | |
| 3 | + * pane (ReplDiffSidebar's 8). | |
| 4 | + */ | |
| 5 | +export const PATH_FLOOR = 8 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The cells a docked file row keeps for its `+N -M` (ReplDiffSidebar's 12): | |
| 3 | + * the path is cut from its start to the rest. | |
| 4 | + */ | |
| 5 | +export const STAT_CELLS = 12 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The fewest cells an inline file row gives its path, however narrow the | |
| 3 | + * pane (DiffFileList's 20). | |
| 4 | + */ | |
| 5 | +export const DIALOG_PATH_FLOOR = 20 |
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | export * from './columns' |
| 2 | +export * from './dialog-path-floor.js' | |
| 2 | 3 | export * from './sizes' |
| 3 | 4 | export * from './timing' |
| 5 | +export * from './todo-bar-cells.js' | |
| 6 | +export * from './todo-bar-reserve.js' | |
| 4 | 7 | |
| 5 | 8 | export * as default from '.' |
| @@ -4,7 +4,8 @@ | ||
| 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 './max-visible-files.js' | |
| 8 | +export * from './pane' | |
| 8 | 9 | export * from './prompt-context-max-chars.js' |
| 9 | 10 | |
| 10 | 11 | export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The file rows the dialog lists at once (DiffFileList's MAX_VISIBLE_FILES); | |
| 3 | + * past them its window moves, `more files` rows counting the rest. | |
| 4 | + */ | |
| 5 | +export const MAX_VISIBLE_FILES = 5 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many files' hunks are read from the backend at once while the pane | |
| 3 | + * loads its bodies. | |
| 4 | + */ | |
| 5 | +export const BODY_FETCH_CONCURRENCY = 6 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The cells a diff gutter takes besides its digits: the marker and the | |
| 3 | + * spaces round the number. | |
| 4 | + */ | |
| 5 | +export const GUTTER_CHROME = 3 |
| @@ -1,0 +1,9 @@ | ||
| 1 | +export * from './body-fetch-concurrency.js' | |
| 2 | +export * from './gutter-chrome.js' | |
| 3 | +export * from './max-summary-rows.js' | |
| 4 | +export * from './pane-top-pad-rows.js' | |
| 5 | +export * from './pre-session-body-cap.js' | |
| 6 | +export * from './scroll-margin-rows.js' | |
| 7 | +export * from './wheel-rows.js' | |
| 8 | + | |
| 9 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many file rows the docked pane's list shows at once (the built-in | |
| 3 | + * panel's window); the rest are counted on a dim row under them. | |
| 4 | + */ | |
| 5 | +export const MAX_SUMMARY_ROWS = 8 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The blank row the docked pane keeps above its header, as the built-in | |
| 3 | + * sidebar's header does (its `paddingY`). | |
| 4 | + */ | |
| 5 | +export const PANE_TOP_PAD_ROWS = 1 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * How many pre-session files get their bodies drawn once that section is | |
| 3 | + * open (the built-in panel's cap); past it only their legend is listed. | |
| 4 | + */ | |
| 5 | +export const PRE_SESSION_BODY_CAP = 20 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The rows the docked window draws past its own height, so a line whose | |
| 3 | + * wrap was counted short still leaves it full; the pane clips the rest. | |
| 4 | + */ | |
| 5 | +export const SCROLL_MARGIN_ROWS = 8 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The rows one wheel tick moves the docked body, as the built-in panel's | |
| 3 | + * body moves. | |
| 4 | + */ | |
| 5 | +export const WHEEL_ROWS = 3 |
| @@ -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,4 @@ | ||
| 1 | +/** | |
| 2 | + * The widest the todo progress bar draws (ReplDiffSidebar's 20). | |
| 3 | + */ | |
| 4 | +export const TODO_BAR_CELLS = 20 |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The cells kept beside the todo bar for its `done/total` count. | |
| 3 | + */ | |
| 4 | +export const TODO_BAR_RESERVE = 12 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './map-limited.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,30 @@ | ||
| 1 | +/** | |
| 2 | + * Maps every item through an async function, a bounded number of them in | |
| 3 | + * flight at once, and resolves to the results in the items' order. | |
| 4 | + * | |
| 5 | + * @param items the inputs | |
| 6 | + * @param limit how many may run at once, at least one | |
| 7 | + * @param fn the work for one item | |
| 8 | + * @returns the results, in order | |
| 9 | + */ | |
| 10 | +export async function mapLimited<T, R>( | |
| 11 | + items: readonly T[], | |
| 12 | + limit: number, | |
| 13 | + fn: (item: T) => Promise<R>, | |
| 14 | +): Promise<R[]> { | |
| 15 | + const results: R[] = [] | |
| 16 | + | |
| 17 | + let next = 0 | |
| 18 | + | |
| 19 | + async function worker(): Promise<void> { | |
| 20 | + for (let at = next++; at < items.length; at = next++) { | |
| 21 | + results[at] = await fn(items[at] as T) | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + await Promise.all( | |
| 26 | + Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker), | |
| 27 | + ) | |
| 28 | + | |
| 29 | + return results | |
| 30 | +} |
| @@ -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,9 +1,9 @@ | ||
| 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' |
| 6 | +export * from './plugin-name.js' | |
| 7 | 7 | export * from './texts' |
| 8 | 8 | |
| 9 | 9 | export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The plugin's own name, as the engine stamps it on the elements its render | |
| 3 | + * hooks draw (`ui.press`, `ui.focus`). | |
| 4 | + */ | |
| 5 | +export const PLUGIN_NAME = 'diff' |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | export * from './builtin-holds-pattern.js' |
| 2 | 2 | export * from './git-unanswered-text.js' |
| 3 | +export * from './more-below-text.js' | |
| 3 | 4 | export * from './not-in-repository-text.js' |
| 5 | +export * from './panel-toggle' | |
| 4 | 6 | export * from './register-failed-text-of.js' |
| 5 | 7 | export * from './resize-terminal-text.js' |
| 6 | 8 | export * from './untracked-withheld-text-of.js' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What follows a count on the docked list's lower edge row: the words and | |
| 3 | + * the built-in's advertised list key (its macOS spelling). | |
| 4 | + */ | |
| 5 | +export const MORE_BELOW_TEXT = 'more below (opt+\u2193 to scroll)' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The transcript line the built-in dialog leaves when the person closes it. | |
| 3 | + */ | |
| 4 | +export const DIALOG_DISMISSED_TEXT = 'Diff dialog dismissed' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './dialog-dismissed-text.js' | |
| 2 | +export * from './panel-hidden-text.js' | |
| 3 | +export * from './panel-shown-text.js' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s transcript line once it closed the pane, worded as the built-in | |
| 3 | + * words its own. | |
| 4 | + */ | |
| 5 | +export const PANEL_HIDDEN_TEXT = 'Diff panel hidden' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s transcript line once it opened the pane, worded as the built-in | |
| 3 | + * words its own. | |
| 4 | + */ | |
| 5 | +export const PANEL_SHOWN_TEXT = 'Diff panel shown' |
| @@ -1,14 +1,14 @@ | ||
| 1 | -import type Backend from '../../backend' | |
| 1 | +import type PaneState from '../../pane-state' | |
| 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 | + * What the ring landing on a dialog row settles: the file now selected, | |
| 3 | + * and the key of the row to land the ring on once the window re-centres. | |
| 4 | + */ | |
| 5 | +export type DialogFocus = { | |
| 6 | + selectedPath: string | |
| 7 | + landing: string | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './dialog-focus.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * The inline pane's first line, as the built-in dialog heads itself: what | |
| 3 | + * the rows are, then in dim what they are compared with. | |
| 4 | + */ | |
| 5 | +export type DialogTitle = { | |
| 6 | + title: string | |
| 7 | + subtitle: string | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './dialog-title.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,37 @@ | ||
| 1 | +import type { DialogTitle } from './dialog-title' | |
| 2 | +import type { PaneModel } from './pane-model' | |
| 3 | +import { pickedTurnOf } from './picked-turn-of' | |
| 4 | +import { turnTitleOf } from './turn-title-of' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The inline pane's heading (DiffDialog's title): the picked turn and its | |
| 8 | + * prompt's opening words, or what the working tree is compared with. | |
| 9 | + * | |
| 10 | + * An unborn HEAD lists staged and new files; a branch diff names its base; | |
| 11 | + * anything else is the uncommitted changes against HEAD. | |
| 12 | + * | |
| 13 | + * @param model the pane's state | |
| 14 | + * @returns the title and its dim subtitle | |
| 15 | + */ | |
| 16 | +export function dialogTitleOf(model: PaneModel): DialogTitle { | |
| 17 | + const turn = pickedTurnOf(model) | |
| 18 | + const source = model.data?.source | |
| 19 | + const isUnborn = model.data?.isUnborn === true | |
| 20 | + const isBranch = source?.kind === 'branch' | |
| 21 | + | |
| 22 | + function currentOf(): DialogTitle { | |
| 23 | + switch (true) { | |
| 24 | + case isUnborn: | |
| 25 | + return { title: 'Staged and new files', subtitle: '(no commits yet)' } | |
| 26 | + case isBranch && source?.kind === 'branch': | |
| 27 | + return { | |
| 28 | + title: 'Branch changes', | |
| 29 | + subtitle: `(vs ${source.baseBranch})`, | |
| 30 | + } | |
| 31 | + default: | |
| 32 | + return { title: 'Uncommitted changes', subtitle: '(git diff HEAD)' } | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + return turn ? turnTitleOf(turn) : currentOf() | |
| 37 | +} |
| @@ -1,7 +1,6 @@ | ||
| 1 | -import type Backend from '../backend' | |
| 2 | -import type Git from '../git' | |
| 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,22 +1,25 @@ | ||
| 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-focus' | |
| 6 | +export * from './dialog-title' | |
| 7 | +export * from './dialog-title-of.js' | |
| 6 | 8 | export * from './empty-state' |
| 7 | 9 | export * from './empty-state-of.js' |
| 8 | 10 | export * from './header-totals' |
| 9 | 11 | export * from './header-totals-of.js' |
| 10 | 12 | export * from './initial-model' |
| 11 | -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 | -export * from './selection-of' | |
| 18 | 19 | export * from './source' |
| 20 | +export * from './turn-title-of' | |
| 19 | 21 | export * from './turn-totals-of' |
| 22 | +export * from './unborn-note-of.js' | |
| 20 | 23 | export * from './zero-totals' |
| 21 | 24 | |
| 22 | 25 | 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,11 @@ | ||
| 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 | + isFullscreen: null, | |
| 27 | + dialogView: 'list', | |
| 28 | + place: Object.freeze({ top: 0, listStart: 0, columns: 0, rows: 0 }), | |
| 26 | 29 | }) |
| @@ -1,21 +1,0 @@ | ||
| 1 | -import type Git from '../git' | |
| 2 | -import type { Partition } from './partition' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * The rows a person can pick from right now: the session's shown rows, | |
| 6 | - * then the pre-session rows while that section is open. | |
| 7 | - * | |
| 8 | - * @param partition the fetch's rows, grouped | |
| 9 | - * @param preSession whether the pre-session section is open | |
| 10 | - * @returns the pickable rows in drawing order | |
| 11 | - */ | |
| 12 | -export function listedOf( | |
| 13 | - partition: Partition, | |
| 14 | - preSession: 'shown' | 'hidden', | |
| 15 | -): readonly Git.FileStat[] { | |
| 16 | - const isOpen = preSession === 'shown' | |
| 17 | - | |
| 18 | - return isOpen | |
| 19 | - ? [...partition.shown, ...partition.preSession] | |
| 20 | - : partition.shown | |
| 21 | -} |
| @@ -2,34 +2,43 @@ | ||
| 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 diff' | |
| 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 bodies read so far, the | |
| 12 | + * turns and todos, how the surface seated it, whether fullscreen (once | |
| 13 | + * `/diff` said), the dialog's view, and the docked scroll in its last box. | |
| 14 | 14 | */ |
| 15 | 15 | export type PaneModel = { |
| 16 | 16 | words: Backend.BackendWords |
| @@ -25,9 +25,11 @@ | ||
| 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' | |
| 32 | + isFullscreen: boolean | null | |
| 33 | + dialogView: 'list' | 'detail' | |
| 34 | + place: { top: number; listStart: number; columns: number; rows: number } | |
| 33 | 35 | } |
| @@ -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,3 +1,0 @@ | ||
| 1 | -export * from './selection-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,16 +1,0 @@ | ||
| 1 | -import type Git from '../../git' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The file whose body the pane shows: the one the person picked while it is | |
| 5 | - * still listed, else the first listed one, else none. | |
| 6 | - * | |
| 7 | - * @param listed the rows on screen, in order (session rows, then the | |
| 8 | - * pre-session rows while that section is open) | |
| 9 | - * @param selectedPath the person's pick, or null | |
| 10 | - * @returns the selected row, or null for an empty list | |
| 11 | - */ | |
| 12 | -export const selectionOf = ( | |
| 13 | - listed: readonly Git.FileStat[], | |
| 14 | - selectedPath: string | null, | |
| 15 | -): Git.FileStat | null => | |
| 16 | - listed.find(file => file.path === selectedPath) ?? listed[0] ?? null |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './turn-title-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type Turns from '../../turns' | |
| 2 | +import type { DialogTitle } from '../dialog-title' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A picked turn's heading, as the built-in dialog titles its tab: the | |
| 6 | + * turn's number, then its prompt's opening words in quotes when it has any. | |
| 7 | + * | |
| 8 | + * @param turn the turn | |
| 9 | + * @returns the title and its dim subtitle | |
| 10 | + */ | |
| 11 | +export const turnTitleOf = ( | |
| 12 | + turn: Pick<Turns.TurnDiff, 'index' | 'preview'>, | |
| 13 | +): DialogTitle => ({ | |
| 14 | + title: `Turn ${turn.index}`, | |
| 15 | + subtitle: turn.preview === '' ? '' : `"${turn.preview}"`, | |
| 16 | +}) |
| @@ -1,0 +1,22 @@ | ||
| 1 | +import type { PaneModel } from './pane-model' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The dim line under the header on an unborn HEAD that still lists rows: | |
| 5 | + * what the rows are, since no comparison runs before the first commit. | |
| 6 | + * | |
| 7 | + * ReplDiffSidebar's no-commits qualifier; null with commits or no rows. | |
| 8 | + * | |
| 9 | + * @param model the last good fetch | |
| 10 | + * @param filesCount the header's session file count | |
| 11 | + * @returns the line, or null | |
| 12 | + */ | |
| 13 | +export function unbornNoteOf( | |
| 14 | + model: Pick<PaneModel, 'data'>, | |
| 15 | + filesCount: number, | |
| 16 | +): string | null { | |
| 17 | + const isUnbornListing = model.data?.isUnborn === true && filesCount > 0 | |
| 18 | + | |
| 19 | + return isUnbornListing | |
| 20 | + ? 'no commits yet — showing staged and new files' | |
| 21 | + : null | |
| 22 | +} |
| @@ -1,11 +1,11 @@ | ||
| 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 | } |
| @@ -1,13 +1,21 @@ | ||
| 1 | -import type { On, ResultOf, SessionMessage, Timer } from 'claude-code' | |
| 1 | +import type { | |
| 2 | + On, | |
| 3 | + PaneOpenArgs, | |
| 4 | + ResultOf, | |
| 5 | + SessionMessage, | |
| 6 | + Timer, | |
| 7 | +} from 'claude-code' | |
| 2 | 8 | |
| 3 | 9 | import Ask from './ask' |
| 4 | 10 | import Backend from './backend' |
| 5 | 11 | import { COMMAND_SPEC } from './command-spec' |
| 12 | +import { drawnFilesOf } from './drawn-files-of' | |
| 6 | 13 | import { entryKindsOf } from './entry-kinds-of' |
| 7 | 14 | import type Git from './git' |
| 8 | 15 | import type { Host } from './host' |
| 9 | 16 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 10 | 17 | import Limits from './limits' |
| 18 | +import { mapLimited } from './map-limited' | |
| 11 | 19 | import { messageOf } from './message-of' |
| 12 | 20 | import { mtimeOf } from './mtime-of' |
| 13 | 21 | import Names from './names' |
| @@ -33,17 +41,20 @@ | ||
| 33 | 41 | let probing: Promise<boolean> | null = null |
| 34 | 42 | let sessionStartMs = 0 |
| 35 | 43 | let isPaneOpen = false |
| 44 | + let dialogRows: number | null = null | |
| 36 | 45 | let hasAutoOpened = false |
| 37 | 46 | let columns: number | null = null |
| 38 | 47 | let shownSessionId: string | null = null |
| 39 | - let wasDrawnSinceProbe = false | |
| 40 | 48 | let armed: Ask.ArmedAsk | null = null |
| 41 | 49 | let carrying: Ask.ArmedAsk | null = null |
| 42 | 50 | let isRefreshing = false |
| 43 | 51 | let isRefreshQueued = false |
| 44 | 52 | let generation = 0 |
| 45 | - let bodyKey: string | null = null | |
| 53 | + let bodyStamp: string | null = null | |
| 54 | + let bodyBase: string | null = null | |
| 46 | 55 | |
| 56 | + const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>() | |
| 57 | + | |
| 47 | 58 | const polled = { toplevel: '', headKey: '' } |
| 48 | 59 | |
| 49 | 60 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| @@ -137,7 +148,31 @@ | ||
| 137 | 148 | return true |
| 138 | 149 | } |
| 139 | 150 | |
| 151 | + function dialogPane(): PaneOpenArgs { | |
| 152 | + return { | |
| 153 | + id: Names.PANE_ID, | |
| 154 | + title: Names.PANE_TITLE, | |
| 155 | + holdToasts: true, | |
| 156 | + closeOnEscape: true, | |
| 157 | + rows: Views.dialogRowsOf(model), | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + function fitDialog(engine: Host) { | |
| 162 | + const rows = Views.dialogRowsOf(model) | |
| 163 | + | |
| 164 | + const isStale = | |
| 165 | + isPaneOpen && model.isFullscreen === false && rows !== dialogRows | |
| 166 | + | |
| 167 | + if (isStale) { | |
| 168 | + dialogRows = rows | |
| 169 | + void engine.openPane(dialogPane()).catch(() => undefined) | |
| 170 | + } | |
| 171 | + } | |
| 172 | + | |
| 140 | 173 | function redraw(engine: Host) { |
| 174 | + fitDialog(engine) | |
| 175 | + | |
| 141 | 176 | if (timers.has('redraw')) { |
| 142 | 177 | return |
| 143 | 178 | } |
| @@ -151,49 +186,70 @@ | ||
| 151 | 186 | ) |
| 152 | 187 | } |
| 153 | 188 | |
| 154 | - const selectedOf = (): Git.FileStat | null => | |
| 155 | - PaneState.selectionOf( | |
| 156 | - PaneState.listedOf( | |
| 157 | - PaneState.partitionOf( | |
| 158 | - model.data?.files ?? [], | |
| 159 | - model.isNoiseShown ? 'shown' : 'hidden', | |
| 160 | - ), | |
| 161 | - model.isPreSessionShown ? 'shown' : 'hidden', | |
| 162 | - ), | |
| 163 | - model.selectedPath, | |
| 164 | - ) | |
| 165 | - | |
| 166 | - async function loadBody(engine: Host): Promise<boolean> { | |
| 189 | + async function loadBodies(engine: Host): Promise<boolean> { | |
| 167 | 190 | const { data } = model |
| 168 | - const selected = selectedOf() | |
| 191 | + const pinned = backend | |
| 169 | 192 | |
| 170 | - if (!data || !selected || !backend) { | |
| 171 | - bodyKey = null | |
| 172 | - model = { ...model, body: null, bodyState: 'idle' } | |
| 193 | + if (!data || !pinned) { | |
| 194 | + bodyStamp = null | |
| 195 | + bodyBase = null | |
| 196 | + bodyLoads.clear() | |
| 197 | + model = { ...model, bodies: PaneState.NO_BODIES } | |
| 173 | 198 | |
| 174 | 199 | return false |
| 175 | 200 | } |
| 176 | 201 | |
| 177 | - const key = `${generation}|${data.baseRef}|${selected.path}` | |
| 202 | + const stamp = bodyStampOf(data) | |
| 203 | + const isNewBase = data.baseRef !== bodyBase | |
| 178 | 204 | |
| 179 | - if (key === bodyKey) { | |
| 180 | - return false | |
| 205 | + if (stamp !== bodyStamp) { | |
| 206 | + bodyStamp = stamp | |
| 207 | + bodyLoads.clear() | |
| 181 | 208 | } |
| 182 | 209 | |
| 183 | - bodyKey = key | |
| 184 | - model = { ...model, body: null, bodyState: 'loading' } | |
| 185 | - redraw(engine) | |
| 210 | + if (isNewBase) { | |
| 211 | + bodyBase = data.baseRef | |
| 212 | + model = { ...model, bodies: PaneState.NO_BODIES } | |
| 213 | + redraw(engine) | |
| 214 | + } | |
| 186 | 215 | |
| 187 | - const body = await backend.fetchFileHunks(data, selected) | |
| 216 | + return fetchBodies(engine, pinned, data) | |
| 217 | + } | |
| 188 | 218 | |
| 189 | - if (bodyKey !== key) { | |
| 190 | - return body === null | |
| 191 | - } | |
| 219 | + const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}` | |
| 192 | 220 | |
| 193 | - model = { ...model, body, bodyState: body ? 'ready' : 'failed' } | |
| 194 | - redraw(engine) | |
| 221 | + async function fetchBodies( | |
| 222 | + engine: Host, | |
| 223 | + pinned: Backend.Backend, | |
| 224 | + data: Git.DiffData, | |
| 225 | + ): Promise<boolean> { | |
| 226 | + const stamp = bodyStampOf(data) | |
| 195 | 227 | |
| 196 | - return body === null | |
| 228 | + function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> { | |
| 229 | + const load = pinned.fetchFileHunks(data, file) | |
| 230 | + bodyLoads.set(file.path, load) | |
| 231 | + | |
| 232 | + return load.then(body => { | |
| 233 | + if (bodyStamp === stamp) { | |
| 234 | + model = { | |
| 235 | + ...model, | |
| 236 | + bodies: new Map(model.bodies).set(file.path, body), | |
| 237 | + } | |
| 238 | + | |
| 239 | + redraw(engine) | |
| 240 | + } | |
| 241 | + | |
| 242 | + return body | |
| 243 | + }) | |
| 244 | + } | |
| 245 | + | |
| 246 | + return ( | |
| 247 | + await mapLimited( | |
| 248 | + drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)), | |
| 249 | + Limits.BODY_FETCH_CONCURRENCY, | |
| 250 | + loadOf, | |
| 251 | + ) | |
| 252 | + ).includes(null) | |
| 197 | 253 | } |
| 198 | 254 | |
| 199 | 255 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| @@ -274,7 +330,7 @@ | ||
| 274 | 330 | break |
| 275 | 331 | } |
| 276 | 332 | |
| 277 | - const hasHunksFailed = await loadBody(engine) | |
| 333 | + const hasHunksFailed = await loadBodies(engine) | |
| 278 | 334 | |
| 279 | 335 | if (outcome.kind === 'data') { |
| 280 | 336 | record.mark( |
| @@ -318,9 +374,23 @@ | ||
| 318 | 374 | engine: Host, |
| 319 | 375 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 320 | 376 | ): Promise<void> { |
| 321 | - const pane = { id: Names.PANE_ID, title: Names.PANE_TITLE } | |
| 322 | - const isManual = trigger === 'manual' | |
| 323 | - await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane) | |
| 377 | + const isDialog = model.isFullscreen === false | |
| 378 | + | |
| 379 | + model = { | |
| 380 | + ...model, | |
| 381 | + selectedPath: null, | |
| 382 | + dialogView: 'list', | |
| 383 | + place: { ...model.place, top: 0, listStart: 0 }, | |
| 384 | + } | |
| 385 | + | |
| 386 | + dialogRows = isDialog ? Views.dialogRowsOf(model) : null | |
| 387 | + | |
| 388 | + await engine.openPane( | |
| 389 | + isDialog | |
| 390 | + ? { ...dialogPane(), focus: true } | |
| 391 | + : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }, | |
| 392 | + ) | |
| 393 | + | |
| 324 | 394 | isPaneOpen = true |
| 325 | 395 | |
| 326 | 396 | const sessionId = await engine.sessionId().catch(() => null) |
| @@ -345,14 +415,6 @@ | ||
| 345 | 415 | }) |
| 346 | 416 | } |
| 347 | 417 | |
| 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 | 418 | async function openOnFirstEdit(engine: Host): Promise<void> { |
| 357 | 419 | const isTaken = () => isPaneOpen || hasAutoOpened |
| 358 | 420 | |
| @@ -372,6 +434,7 @@ | ||
| 372 | 434 | |
| 373 | 435 | const isEligible = |
| 374 | 436 | preference !== false && |
| 437 | + model.isFullscreen !== false && | |
| 375 | 438 | columns !== null && |
| 376 | 439 | columns >= floor && |
| 377 | 440 | backend !== null |
| @@ -392,29 +455,43 @@ | ||
| 392 | 455 | |
| 393 | 456 | const actionsOf = (engine: Host): Views.PaneActions => ({ |
| 394 | 457 | selectFile: path => { |
| 395 | - model = { ...model, selectedPath: path } | |
| 396 | - void loadBody(engine) | |
| 458 | + const isDocked = model.placement === 'dock' | |
| 459 | + | |
| 460 | + model = { | |
| 461 | + ...model, | |
| 462 | + selectedPath: path, | |
| 463 | + dialogView: isDocked ? model.dialogView : 'detail', | |
| 464 | + place: isDocked ? Views.placeAtFile(model, path) : model.place, | |
| 465 | + } | |
| 466 | + | |
| 397 | 467 | redraw(engine) |
| 398 | 468 | }, |
| 469 | + scrollList: delta => { | |
| 470 | + model = { ...model, place: Views.listScrolledBy(model, delta) } | |
| 471 | + redraw(engine) | |
| 472 | + }, | |
| 399 | 473 | toggleNoise: () => { |
| 400 | 474 | model = { ...model, isNoiseShown: !model.isNoiseShown } |
| 401 | - void loadBody(engine) | |
| 475 | + void loadBodies(engine) | |
| 402 | 476 | redraw(engine) |
| 403 | 477 | }, |
| 404 | 478 | togglePreSession: () => { |
| 405 | 479 | model = { ...model, isPreSessionShown: !model.isPreSessionShown } |
| 406 | - void loadBody(engine) | |
| 480 | + void loadBodies(engine) | |
| 407 | 481 | redraw(engine) |
| 408 | 482 | }, |
| 409 | - chooseBase: value => { | |
| 410 | - const mode = PaneState.baseModeOf(value) | |
| 483 | + cycleBase: () => { | |
| 484 | + const { baseModes, requestedMode } = model | |
| 411 | 485 | |
| 412 | - if (!mode || mode === model.requestedMode) { | |
| 486 | + const mode = | |
| 487 | + baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ?? | |
| 488 | + requestedMode | |
| 489 | + | |
| 490 | + if (mode === requestedMode) { | |
| 413 | 491 | return |
| 414 | 492 | } |
| 415 | 493 | |
| 416 | - bodyKey = null | |
| 417 | - model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' } | |
| 494 | + model = { ...model, requestedMode: mode } | |
| 418 | 495 | |
| 419 | 496 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 420 | 497 | kind: 'ok', |
| @@ -440,8 +517,7 @@ | ||
| 440 | 517 | ? { kind: 'turn', index } |
| 441 | 518 | : { kind: 'current' } |
| 442 | 519 | |
| 443 | - model = { ...model, source, selectedPath: null } | |
| 444 | - void loadBody(engine) | |
| 520 | + model = { ...model, source, selectedPath: null, dialogView: 'list' } | |
| 445 | 521 | redraw(engine) |
| 446 | 522 | }, |
| 447 | 523 | toggleAsk: path => { |
| @@ -454,12 +530,6 @@ | ||
| 454 | 530 | |
| 455 | 531 | arm(engine, path) |
| 456 | 532 | }, |
| 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 | 533 | }) |
| 464 | 534 | |
| 465 | 535 | function arm(engine: Host, path: string) { |
| @@ -467,7 +537,7 @@ | ||
| 467 | 537 | path, |
| 468 | 538 | PaneState.pickedTurnOf(model)?.files.find(file => file.path === path) |
| 469 | 539 | ?.hunks ?? |
| 470 | - model.body?.hunks ?? | |
| 540 | + model.bodies.get(path)?.hunks ?? | |
| 471 | 541 | [], |
| 472 | 542 | ) |
| 473 | 543 | |
| @@ -505,7 +575,6 @@ | ||
| 505 | 575 | now: () => $.clock.now(), |
| 506 | 576 | after: (ms, fn) => $.clock.after(ms, fn), |
| 507 | 577 | every: (ms, fn) => $.clock.every(ms, fn), |
| 508 | - sleep: ms => $.clock.sleep(ms), | |
| 509 | 578 | run: (argv, init) => $.process.run(argv, init), |
| 510 | 579 | stat: path => $.fs.stat(path), |
| 511 | 580 | listDir: path => $.fs.list(path), |
| @@ -542,10 +611,21 @@ | ||
| 542 | 611 | |
| 543 | 612 | const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) |
| 544 | 613 | |
| 545 | - wasDrawnSinceProbe = true | |
| 546 | 614 | columns = e.viewport?.columns ?? columns |
| 547 | - model = { ...model, isFocused: e.props.isFocused } | |
| 548 | 615 | |
| 616 | + model = { | |
| 617 | + ...model, | |
| 618 | + placement: e.props.placement, | |
| 619 | + place: { | |
| 620 | + ...model.place, | |
| 621 | + columns: Math.max( | |
| 622 | + 1, | |
| 623 | + e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS, | |
| 624 | + ), | |
| 625 | + rows: e.props.scroll.bodyRows, | |
| 626 | + }, | |
| 627 | + } | |
| 628 | + | |
| 549 | 629 | return Views.paneView( |
| 550 | 630 | { |
| 551 | 631 | ui: { Box, Text, Button, Select, Code }, |
| @@ -554,7 +634,7 @@ | ||
| 554 | 634 | rows: e.props.scroll.bodyRows, |
| 555 | 635 | }, |
| 556 | 636 | model, |
| 557 | - e.props.placement, | |
| 637 | + { placement: e.props.placement, terminalColumns: columns }, | |
| 558 | 638 | ) |
| 559 | 639 | }) |
| 560 | 640 | |
| @@ -573,10 +653,14 @@ | ||
| 573 | 653 | } |
| 574 | 654 | } |
| 575 | 655 | |
| 656 | + const { isFullscreen } = e.presentation | |
| 657 | + | |
| 658 | + columns = e.presentation.columns | |
| 659 | + model = { ...model, isFullscreen } | |
| 660 | + | |
| 576 | 661 | const toggle = PaneToggle.paneToggleOf({ |
| 577 | - isBelievedOpen: isPaneOpen, | |
| 578 | - wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)), | |
| 579 | - columns, | |
| 662 | + isOpen: isPaneOpen, | |
| 663 | + columns: isFullscreen ? columns : null, | |
| 580 | 664 | }) |
| 581 | 665 | |
| 582 | 666 | if (toggle === 'too-narrow') { |
| @@ -585,9 +669,99 @@ | ||
| 585 | 669 | |
| 586 | 670 | const isOpening = toggle === 'open' |
| 587 | 671 | await (isOpening ? openPane(host, 'manual') : closePane(host)) |
| 672 | + | |
| 673 | + if (!isFullscreen) { | |
| 674 | + return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } | |
| 675 | + } | |
| 676 | + | |
| 588 | 677 | markTabSwitch(host, isOpening ? 'diff' : 'convo') |
| 589 | 678 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 590 | 679 | |
| 680 | + return { | |
| 681 | + text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT, | |
| 682 | + } | |
| 683 | + }) | |
| 684 | + | |
| 685 | + on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => { | |
| 686 | + const isBack = | |
| 687 | + e.origin.kind === 'person' && | |
| 688 | + model.placement === 'inline' && | |
| 689 | + model.dialogView === 'detail' | |
| 690 | + | |
| 691 | + if (isBack && host) { | |
| 692 | + model = { ...model, dialogView: 'list' } | |
| 693 | + redraw(host) | |
| 694 | + | |
| 695 | + dialogRows = Views.dialogRowsOf(model) | |
| 696 | + | |
| 697 | + void host | |
| 698 | + .openPane({ ...dialogPane(), focus: true }) | |
| 699 | + .catch(() => undefined) | |
| 700 | + | |
| 701 | + return { deny: 'back to the file list' } | |
| 702 | + } | |
| 703 | + | |
| 704 | + const result = await next(e) | |
| 705 | + const isClosed = result.deny === undefined | |
| 706 | + const isPersons = isClosed && e.origin.kind === 'person' | |
| 707 | + | |
| 708 | + if (isClosed) { | |
| 709 | + isPaneOpen = false | |
| 710 | + } | |
| 711 | + | |
| 712 | + const isDialog = model.isFullscreen === false | |
| 713 | + | |
| 714 | + if (isPersons && host && isDialog) { | |
| 715 | + host.uiLog(Names.DIALOG_DISMISSED_TEXT) | |
| 716 | + } | |
| 717 | + | |
| 718 | + if (isPersons && host && !isDialog) { | |
| 719 | + markTabSwitch(host, 'convo') | |
| 720 | + await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined) | |
| 721 | + } | |
| 722 | + | |
| 723 | + return result | |
| 724 | + }) | |
| 725 | + | |
| 726 | + on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => { | |
| 727 | + const isListed = | |
| 728 | + model.placement === 'inline' && model.dialogView === 'list' && host | |
| 729 | + | |
| 730 | + const focus = isListed ? Views.dialogFocusOf(model, e.element) : null | |
| 731 | + | |
| 732 | + if (focus === 'stay') { | |
| 733 | + return {} | |
| 734 | + } | |
| 735 | + | |
| 736 | + if (!focus || !host) { | |
| 737 | + return next(e) | |
| 738 | + } | |
| 739 | + | |
| 740 | + model = { ...model, selectedPath: focus.selectedPath } | |
| 741 | + fitDialog(host) | |
| 742 | + host.invalidate() | |
| 743 | + | |
| 744 | + return next({ ...e, element: focus.landing }) | |
| 745 | + }) | |
| 746 | + | |
| 747 | + on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => { | |
| 748 | + const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock' | |
| 749 | + | |
| 750 | + if (!isOwnBody || !host) { | |
| 751 | + return next(e) | |
| 752 | + } | |
| 753 | + | |
| 754 | + const isOverList = Views.isWheelOverList(model, e) | |
| 755 | + | |
| 756 | + model = { | |
| 757 | + ...model, | |
| 758 | + place: isOverList | |
| 759 | + ? Views.listScrolledBy(model, e.by) | |
| 760 | + : Views.bodyScrolledBy(model, e), | |
| 761 | + } | |
| 762 | + | |
| 763 | + host.invalidate() | |
| 764 | + | |
| 591 | 765 | return {} |
| 592 | 766 | }) |
| 593 | 767 | |
| @@ -600,7 +774,9 @@ | ||
| 600 | 774 | } |
| 601 | 775 | |
| 602 | 776 | hasAutoOpened = false |
| 603 | - bodyKey = null | |
| 777 | + bodyStamp = null | |
| 778 | + bodyBase = null | |
| 779 | + bodyLoads.clear() | |
| 604 | 780 | disarm(host) |
| 605 | 781 | model = PaneState.afterNewSession(model) |
| 606 | 782 | } |
| @@ -1,0 +1,47 @@ | ||
| 1 | +import type { UiScrollInput } from 'claude-code' | |
| 2 | + | |
| 3 | +import Limits from '../../limits' | |
| 4 | +import type PaneState from '../../pane-state' | |
| 5 | +import Plan from './plan' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The docked pane's place after one of the person's scrolls of the body: | |
| 9 | + * a wheel tick or arrow three rows, a page key the window, Home/End an end. | |
| 10 | + * | |
| 11 | + * Told apart by the rows the move asks for against the pane's body and | |
| 12 | + * tree, as the engine sizes each; clamped to the body's extent. | |
| 13 | + * | |
| 14 | + * @param model the pane's state | |
| 15 | + * @param scroll the move: its signed rows, the body's rows, the tree's rows | |
| 16 | + * @returns the place | |
| 17 | + */ | |
| 18 | +export function bodyScrolledBy( | |
| 19 | + model: PaneState.PaneModel, | |
| 20 | + scroll: Pick<UiScrollInput, 'by' | 'bodyRows' | 'contentRows'>, | |
| 21 | +): PaneState.PaneModel['place'] { | |
| 22 | + const { visibleRows, maxTop } = Plan.bodyLayoutOf( | |
| 23 | + model, | |
| 24 | + Plan.dockPlanOf(model), | |
| 25 | + ) | |
| 26 | + | |
| 27 | + const size = Math.abs(scroll.by) | |
| 28 | + | |
| 29 | + const isEnd = | |
| 30 | + size >= scroll.contentRows && scroll.contentRows > scroll.bodyRows | |
| 31 | + | |
| 32 | + const isPage = !isEnd && size >= scroll.bodyRows | |
| 33 | + | |
| 34 | + const step = isEnd | |
| 35 | + ? maxTop | |
| 36 | + : isPage | |
| 37 | + ? Math.max(1, visibleRows) | |
| 38 | + : size * Limits.WHEEL_ROWS | |
| 39 | + | |
| 40 | + return { | |
| 41 | + ...model.place, | |
| 42 | + top: Math.max( | |
| 43 | + 0, | |
| 44 | + Math.min(maxTop, model.place.top + Math.sign(scroll.by) * step), | |
| 45 | + ), | |
| 46 | + } | |
| 47 | +} |
| @@ -1,0 +1,104 @@ | ||
| 1 | +/* @jsxRuntime classic */ | |
| 2 | +/* @jsx h */ | |
| 3 | +/* @jsxFrag Fragment */ | |
| 4 | +import type { RenderElement } from 'claude-code' | |
| 5 | + | |
| 6 | +import Limits from '../../limits' | |
| 7 | +import Detail from '../detail' | |
| 8 | +import type { Kit } from '../kit' | |
| 9 | +import Layout from '../layout' | |
| 10 | +import Sections from '../sections' | |
| 11 | +import Plan from './plan' | |
| 12 | +import type { BodyLayout, HunkSegment, Segment, WindowCut } from './types' | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * The rows of the docked body its window shows from a row down: each | |
| 16 | + * segment drawn from the row the window cuts it at, until the window fills. | |
| 17 | + * | |
| 18 | + * A hunk draws only its lines in view, and past MAX_BODY_CHARS of hunk | |
| 19 | + * text the rest waits for a scroll (the tree's cap); rows past the foot | |
| 20 | + * are the pane's to clip, so SCROLL_MARGIN_ROWS more are drawn than fit. | |
| 21 | + * | |
| 22 | + * @param kit the elements, the handlers, the width | |
| 23 | + * @param layout the body laid out | |
| 24 | + * @param top the body row wanted at the window's top; clamped to the body | |
| 25 | + * @returns the elements, top to bottom | |
| 26 | + */ | |
| 27 | +export function drawWindow( | |
| 28 | + kit: Kit, | |
| 29 | + layout: BodyLayout, | |
| 30 | + top: number, | |
| 31 | +): RenderElement[] { | |
| 32 | + const { Box, Text, Code } = kit.ui | |
| 33 | + const drawn: RenderElement[] = [] | |
| 34 | + const wanted = layout.visibleRows + Limits.SCROLL_MARGIN_ROWS | |
| 35 | + const at = Math.max(0, Math.min(top, layout.maxTop)) | |
| 36 | + const onPress = kit.actions.togglePreSession | |
| 37 | + | |
| 38 | + let start = 0 | |
| 39 | + let filled = 0 | |
| 40 | + let chars = 0 | |
| 41 | + | |
| 42 | + function codeOf(segment: HunkSegment, cut: WindowCut): RenderElement { | |
| 43 | + const source = Detail.hunkSourceOf(Plan.hunkWindowOf(segment, cut)) | |
| 44 | + const path = Layout.sanitizeName(segment.path) | |
| 45 | + chars += source.length | |
| 46 | + | |
| 47 | + const isOver = chars > Detail.MAX_BODY_CHARS | |
| 48 | + | |
| 49 | + return isOver ? ( | |
| 50 | + <Text dimColor italic> | |
| 51 | + … | |
| 52 | + </Text> | |
| 53 | + ) : ( | |
| 54 | + <Code source={source} format="diff" path={path} /> | |
| 55 | + ) | |
| 56 | + } | |
| 57 | + | |
| 58 | + function elementOf(segment: Segment, cut: WindowCut): RenderElement { | |
| 59 | + switch (segment.kind) { | |
| 60 | + case 'rule': | |
| 61 | + return Sections.divider(kit) | |
| 62 | + case 'blank': | |
| 63 | + return <Box height={1} /> | |
| 64 | + case 'name': | |
| 65 | + return Sections.nameRow(kit, segment.name) | |
| 66 | + case 'hunk': | |
| 67 | + return codeOf(segment, cut) | |
| 68 | + case 'note': | |
| 69 | + return ( | |
| 70 | + <Text dimColor italic wrap="wrap"> | |
| 71 | + {segment.text} | |
| 72 | + </Text> | |
| 73 | + ) | |
| 74 | + case 'footer': | |
| 75 | + return ( | |
| 76 | + <Text dimColor italic> | |
| 77 | + … diff truncated (exceeded 400 line limit) | |
| 78 | + </Text> | |
| 79 | + ) | |
| 80 | + case 'text': | |
| 81 | + return Sections.dimNote(kit, segment.text) | |
| 82 | + case 'toggle': | |
| 83 | + return Sections.toggleRow(kit, 'presession', { ...segment, onPress }) | |
| 84 | + case 'legend': | |
| 85 | + return Sections.legendRow(kit, segment.entry) | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + for (const segment of layout.segments) { | |
| 90 | + const rows = Plan.segmentRowsOf(segment) | |
| 91 | + const isShown = start + rows > at && filled < wanted | |
| 92 | + | |
| 93 | + if (isShown) { | |
| 94 | + const skip = Math.max(0, at - start) | |
| 95 | + | |
| 96 | + drawn.push(elementOf(segment, { skip, take: wanted - filled })) | |
| 97 | + filled += rows - skip | |
| 98 | + } | |
| 99 | + | |
| 100 | + start += rows | |
| 101 | + } | |
| 102 | + | |
| 103 | + return drawn | |
| 104 | +} |
| @@ -1,0 +1,10 @@ | ||
| 1 | +export * from './body-scrolled-by.js' | |
| 2 | +export * from './draw-window.jsx' | |
| 3 | +export * from './is-wheel-over-list.js' | |
| 4 | +export * from './list-block-of.jsx' | |
| 5 | +export * from './list-scrolled-by.js' | |
| 6 | +export * from './place-at-file.js' | |
| 7 | +export * from './plan' | |
| 8 | +export * from './types' | |
| 9 | + | |
| 10 | +export * as default from '.' |
| @@ -1,0 +1,28 @@ | ||
| 1 | +import type { UiScrollInput } from 'claude-code' | |
| 2 | + | |
| 3 | +import Limits from '../../limits' | |
| 4 | +import type PaneState from '../../pane-state' | |
| 5 | +import Plan from './plan' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Whether a scroll is a wheel tick over the docked list block while the | |
| 9 | + * list overflows its window: it moves the list then, as ReplDiffSidebar's. | |
| 10 | + * | |
| 11 | + * @param model the pane's state | |
| 12 | + * @param scroll the move: the cell the wheel was over, absent for keys | |
| 13 | + * @returns whether the list takes it | |
| 14 | + */ | |
| 15 | +export function isWheelOverList( | |
| 16 | + model: PaneState.PaneModel, | |
| 17 | + scroll: Pick<UiScrollInput, 'pointer'>, | |
| 18 | +): boolean { | |
| 19 | + const plan = Plan.dockPlanOf(model) | |
| 20 | + const { listTop, listEnd } = Plan.bodyLayoutOf(model, plan) | |
| 21 | + const row = scroll.pointer?.row ?? -1 | |
| 22 | + | |
| 23 | + return ( | |
| 24 | + row >= listTop && | |
| 25 | + row < listEnd && | |
| 26 | + plan.rows.length > Limits.MAX_SUMMARY_ROWS | |
| 27 | + ) | |
| 28 | +} |
| @@ -1,0 +1,89 @@ | ||
| 1 | +/* @jsxRuntime classic */ | |
| 2 | +/* @jsx h */ | |
| 3 | +/* @jsxFrag Fragment */ | |
| 4 | +import type { RenderElement } from 'claude-code' | |
| 5 | + | |
| 6 | +import { keptOf } from '../../kept-of' | |
| 7 | +import Names from '../../names' | |
| 8 | +import type PaneState from '../../pane-state' | |
| 9 | +import type Entries from '../entries' | |
| 10 | +import type { Kit } from '../kit' | |
| 11 | +import Layout from '../layout' | |
| 12 | +import Sections from '../sections' | |
| 13 | +import Plan from './plan' | |
| 14 | +import type { DockPlan } from './types' | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * The docked list block (ReplDiffSidebar's summary rows): the window's rows | |
| 18 | + * between its edge rows, the untracked note, the noise toggle. | |
| 19 | + * | |
| 20 | + * The edge rows count the files out of the window and take the built-in's | |
| 21 | + * list keys; files past the row cap join the lower one. Empty with no row | |
| 22 | + * and no noise to toggle. | |
| 23 | + * | |
| 24 | + * @param kit the elements, the handlers, the width | |
| 25 | + * @param model the pane's state | |
| 26 | + * @param plan the docked pane's decisions over that state | |
| 27 | + * @returns the block's rows | |
| 28 | + */ | |
| 29 | +export function listBlockOf( | |
| 30 | + kit: Kit, | |
| 31 | + model: PaneState.PaneModel, | |
| 32 | + plan: DockPlan, | |
| 33 | +): RenderElement[] { | |
| 34 | + const { shown, above, below } = Plan.listWindowOf(model, plan) | |
| 35 | + const { noiseCount } = plan.partition | |
| 36 | + const { notShown } = plan.totals | |
| 37 | + const hasNoise = !plan.turn && noiseCount > 0 | |
| 38 | + const hasMoreAbove = above > 0 | |
| 39 | + const hasMoreBelow = below > 0 | |
| 40 | + const hasNotShown = notShown > 0 | |
| 41 | + const noiseFace = model.isNoiseShown ? 'hide' : 'show' | |
| 42 | + | |
| 43 | + const lower = keptOf([ | |
| 44 | + hasMoreBelow ? `${below} ${Names.MORE_BELOW_TEXT}` : null, | |
| 45 | + hasNotShown ? `${notShown} not shown` : null, | |
| 46 | + ]).join(' · ') | |
| 47 | + | |
| 48 | + const rowOf = (entry: Entries.BodyEntry) => | |
| 49 | + Sections.fileRow( | |
| 50 | + kit, | |
| 51 | + { | |
| 52 | + key: Sections.fileKeyOf(entry), | |
| 53 | + path: entry.path, | |
| 54 | + displayPath: entry.displayPath, | |
| 55 | + added: entry.added, | |
| 56 | + removed: entry.removed, | |
| 57 | + note: null, | |
| 58 | + isSelected: false, | |
| 59 | + }, | |
| 60 | + () => kit.actions.selectFile(entry.path), | |
| 61 | + ) | |
| 62 | + | |
| 63 | + const upperEdge = hasMoreAbove | |
| 64 | + ? Sections.listEdgeButton(kit, 'list-up', `↑ ${above} more above`) | |
| 65 | + : null | |
| 66 | + | |
| 67 | + const lowerEdge = hasMoreBelow | |
| 68 | + ? Sections.listEdgeButton(kit, 'list-down', `↓ ${lower}`) | |
| 69 | + : hasNotShown | |
| 70 | + ? Sections.dimNote(kit, `… ${lower}`) | |
| 71 | + : null | |
| 72 | + | |
| 73 | + const noiseToggle = hasNoise | |
| 74 | + ? Sections.toggleRow(kit, 'noise', { | |
| 75 | + label: `${Layout.plural(noiseCount, 'test')}/generated (${noiseFace})`, | |
| 76 | + onPress: kit.actions.toggleNoise, | |
| 77 | + }) | |
| 78 | + : null | |
| 79 | + | |
| 80 | + return Sections.present([ | |
| 81 | + upperEdge, | |
| 82 | + ...shown.map(rowOf), | |
| 83 | + lowerEdge, | |
| 84 | + plan.isUntrackedNoted | |
| 85 | + ? Sections.dimNote(kit, Names.untrackedWithheldTextOf(model)) | |
| 86 | + : null, | |
| 87 | + noiseToggle, | |
| 88 | + ]) | |
| 89 | +} |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import type PaneState from '../../pane-state' | |
| 3 | +import Plan from './plan' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The docked pane's place with the list window moved by so many files, | |
| 7 | + * clamped to the list (ReplDiffSidebar's scrollSummary). | |
| 8 | + * | |
| 9 | + * @param model the pane's state | |
| 10 | + * @param delta the files to move by, negative toward the top | |
| 11 | + * @returns the place | |
| 12 | + */ | |
| 13 | +export function listScrolledBy( | |
| 14 | + model: PaneState.PaneModel, | |
| 15 | + delta: number, | |
| 16 | +): PaneState.PaneModel['place'] { | |
| 17 | + const last = Math.max( | |
| 18 | + 0, | |
| 19 | + Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS, | |
| 20 | + ) | |
| 21 | + | |
| 22 | + return { | |
| 23 | + ...model.place, | |
| 24 | + listStart: Math.max( | |
| 25 | + 0, | |
| 26 | + Math.min(last, Math.min(model.place.listStart, last) + delta), | |
| 27 | + ), | |
| 28 | + } | |
| 29 | +} |
| @@ -1,0 +1,20 @@ | ||
| 1 | +import type PaneState from '../../pane-state' | |
| 2 | +import Plan from './plan' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The docked pane's place with the body scrolled so this file's block | |
| 6 | + * heads the window (the built-in's jump to file); unchanged for no such file. | |
| 7 | + * | |
| 8 | + * @param model the pane's state | |
| 9 | + * @param path the file's path | |
| 10 | + * @returns the place | |
| 11 | + */ | |
| 12 | +export const placeAtFile = ( | |
| 13 | + model: PaneState.PaneModel, | |
| 14 | + path: string, | |
| 15 | +): PaneState.PaneModel['place'] => ({ | |
| 16 | + ...model.place, | |
| 17 | + top: | |
| 18 | + Plan.bodyLayoutOf(model, Plan.dockPlanOf(model)).tops.get(path) ?? | |
| 19 | + model.place.top, | |
| 20 | +}) |
| @@ -1,0 +1,68 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import type PaneState from '../../../pane-state' | |
| 3 | +import type { BodyLayout, DockPlan } from '../types' | |
| 4 | +import Segments from './segments' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The docked pane's scrolling body laid out under its pinned rows: the | |
| 8 | + * segments and their rows, the window's rows, the files' and list's rows. | |
| 9 | + * | |
| 10 | + * The pinned rows are counted as sidebarPane draws them (header, base or | |
| 11 | + * turn line, todo bar, list block); read from the box last drawn (`place`), | |
| 12 | + * so before any draw the window shows nothing and the top stays 0. | |
| 13 | + * | |
| 14 | + * @param model the pane's state | |
| 15 | + * @param plan the docked pane's decisions over that state | |
| 16 | + * @returns the layout | |
| 17 | + */ | |
| 18 | +export function bodyLayoutOf( | |
| 19 | + model: PaneState.PaneModel, | |
| 20 | + plan: DockPlan, | |
| 21 | +): BodyLayout { | |
| 22 | + const segments = Segments.segmentsOf(model, plan) | |
| 23 | + const window = Segments.listWindowOf(model, plan) | |
| 24 | + const hasNoise = !plan.turn && plan.partition.noiseCount > 0 | |
| 25 | + const isListed = plan.message.length === 0 | |
| 26 | + const hasList = isListed && (plan.rows.length > 0 || hasNoise) | |
| 27 | + | |
| 28 | + const listRows = hasList | |
| 29 | + ? window.shown.length + | |
| 30 | + Number(window.above > 0) + | |
| 31 | + Number(window.below > 0 || plan.totals.notShown > 0) + | |
| 32 | + Number(plan.isUntrackedNoted) + | |
| 33 | + Number(hasNoise) + | |
| 34 | + 2 | |
| 35 | + : Number(isListed) | |
| 36 | + | |
| 37 | + const headRows = | |
| 38 | + 1 + Number(plan.line !== null) + (model.todos.total > 0 ? 2 : 0) | |
| 39 | + | |
| 40 | + const tops = new Map<string, number>() | |
| 41 | + | |
| 42 | + const visibleRows = Math.max( | |
| 43 | + 0, | |
| 44 | + model.place.rows - Limits.PANE_TOP_PAD_ROWS - headRows - listRows, | |
| 45 | + ) | |
| 46 | + | |
| 47 | + let extent = 0 | |
| 48 | + | |
| 49 | + for (const segment of segments) { | |
| 50 | + if (segment.kind === 'rule' && segment.path !== null) { | |
| 51 | + tops.set(segment.path, extent) | |
| 52 | + } | |
| 53 | + | |
| 54 | + extent += Segments.segmentRowsOf(segment) | |
| 55 | + } | |
| 56 | + | |
| 57 | + const listTop = Limits.PANE_TOP_PAD_ROWS + headRows + 1 | |
| 58 | + | |
| 59 | + return { | |
| 60 | + segments, | |
| 61 | + extent, | |
| 62 | + visibleRows, | |
| 63 | + maxTop: Math.max(0, extent - visibleRows), | |
| 64 | + tops, | |
| 65 | + listTop, | |
| 66 | + listEnd: hasList ? listTop + listRows - 2 : listTop, | |
| 67 | + } | |
| 68 | +} |
| @@ -1,0 +1,80 @@ | ||
| 1 | +import PaneState from '../../../pane-state' | |
| 2 | +import DialogPane from '../../dialog-pane' | |
| 3 | +import Entries from '../../entries' | |
| 4 | +import Layout from '../../layout' | |
| 5 | +import type { DockPlan } from '../types' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * The docked pane's decisions over a model (ReplDiffSidebarBody's): which | |
| 9 | + * rows list, what the header counts, what stands in for an empty list. | |
| 10 | + * | |
| 11 | + * Over a picked turn the rows are the turn's files and nothing is empty; | |
| 12 | + * else the fetch's session rows, its pre-session rows apart. Under the | |
| 13 | + * header: the base line outside settled session mode, or the unborn note. | |
| 14 | + * | |
| 15 | + * @param model the pane's state | |
| 16 | + * @returns the plan | |
| 17 | + */ | |
| 18 | +export function dockPlanOf(model: PaneState.PaneModel): DockPlan { | |
| 19 | + const { data, requestedMode } = model | |
| 20 | + const turn = PaneState.pickedTurnOf(model) | |
| 21 | + const noise = model.isNoiseShown ? 'shown' : 'hidden' | |
| 22 | + const partition = PaneState.partitionOf(data?.files ?? [], noise) | |
| 23 | + const face = model.isPreSessionShown ? 'hide' : 'show' | |
| 24 | + | |
| 25 | + const totals = turn | |
| 26 | + ? PaneState.turnTotalsOf(turn) | |
| 27 | + : data | |
| 28 | + ? PaneState.headerTotalsOf(data, partition) | |
| 29 | + : PaneState.ZERO_TOTALS | |
| 30 | + | |
| 31 | + const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount) | |
| 32 | + | |
| 33 | + const listed = turn | |
| 34 | + ? [] | |
| 35 | + : DialogPane.listBodyOf({ data, partition, totals, empty }) | |
| 36 | + | |
| 37 | + const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : [])) | |
| 38 | + const hasRows = turn ? turn.files.length > 0 : notes.length < listed.length | |
| 39 | + | |
| 40 | + const entryOf = (file: Parameters<typeof Entries.bodyEntryOf>[0]) => | |
| 41 | + Entries.bodyEntryOf(file, model) | |
| 42 | + | |
| 43 | + const rows = turn | |
| 44 | + ? turn.files.map(Entries.turnEntryOf) | |
| 45 | + : hasRows | |
| 46 | + ? partition.shown.map(entryOf) | |
| 47 | + : [] | |
| 48 | + | |
| 49 | + const earlier = turn ? [] : partition.preSession.map(entryOf) | |
| 50 | + const hasEarlier = earlier.length > 0 | |
| 51 | + | |
| 52 | + const isBaseShown = | |
| 53 | + data !== null && | |
| 54 | + !data.isUnborn && | |
| 55 | + (requestedMode !== 'session' || data.mode !== 'session') | |
| 56 | + | |
| 57 | + const line = turn | |
| 58 | + ? `Turn ${turn.index} "${turn.preview}"` | |
| 59 | + : isBaseShown | |
| 60 | + ? PaneState.modeLabelOf(requestedMode, model) | |
| 61 | + : PaneState.unbornNoteOf(model, totals.filesCount) | |
| 62 | + | |
| 63 | + return { | |
| 64 | + turn, | |
| 65 | + partition, | |
| 66 | + totals, | |
| 67 | + empty, | |
| 68 | + line: line === null ? null : Layout.sanitizeName(line), | |
| 69 | + rows, | |
| 70 | + earlier, | |
| 71 | + earlierLabel: hasEarlier | |
| 72 | + ? `+${Layout.plural(earlier.length, 'file')} edited before this ` + | |
| 73 | + `session (${face})` | |
| 74 | + : null, | |
| 75 | + notes, | |
| 76 | + message: empty ? [empty.headline, ...notes] : hasRows ? [] : notes, | |
| 77 | + isUntrackedNoted: | |
| 78 | + !turn && data?.isUntrackedWithheld === true && empty === null, | |
| 79 | + } | |
| 80 | +} |
| @@ -1,0 +1,32 @@ | ||
| 1 | +import Git from '../../../git' | |
| 2 | +import type { HunkSegment, WindowCut } from '../types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The part of a hunk the window shows: from the line holding its first | |
| 6 | + * shown row (hunkAfter renumbers) to the last that starts in the room left. | |
| 7 | + * | |
| 8 | + * @param segment the hunk and the rows each of its lines takes | |
| 9 | + * @param cut the rows hidden above the window and the rows it has left | |
| 10 | + * @returns the hunk's shown lines | |
| 11 | + */ | |
| 12 | +export function hunkWindowOf(segment: HunkSegment, cut: WindowCut): Git.Hunk { | |
| 13 | + let passed = 0 | |
| 14 | + let first = 0 | |
| 15 | + let shown = 0 | |
| 16 | + let last = 0 | |
| 17 | + | |
| 18 | + for (const rows of segment.lineRows) { | |
| 19 | + if (passed + rows <= cut.skip) { | |
| 20 | + first += 1 | |
| 21 | + } else if (shown < cut.take) { | |
| 22 | + shown += rows | |
| 23 | + last += 1 | |
| 24 | + } | |
| 25 | + | |
| 26 | + passed += rows | |
| 27 | + } | |
| 28 | + | |
| 29 | + const tail = Git.hunkAfter(segment.hunk, first) | |
| 30 | + | |
| 31 | + return { ...tail, lines: tail.lines.slice(0, last) } | |
| 32 | +} |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './body-layout-of.js' | |
| 2 | +export * from './dock-plan-of.js' | |
| 3 | +export * from './hunk-window-of.js' | |
| 4 | +export * from './segments' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './list-window-of.js' | |
| 2 | +export * from './segment-rows-of.js' | |
| 3 | +export * from './segments-of.js' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,29 @@ | ||
| 1 | +import Limits from '../../../../limits' | |
| 2 | +import type PaneState from '../../../../pane-state' | |
| 3 | +import type { DockPlan, ListWindow } from '../../types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The docked list's MAX_SUMMARY_ROWS-row window starting at the file the | |
| 7 | + * person scrolled it to, clamped so a shrinking list pulls it back. | |
| 8 | + * | |
| 9 | + * @param model where the person put the list window | |
| 10 | + * @param plan the docked pane's rows | |
| 11 | + * @returns the window | |
| 12 | + */ | |
| 13 | +export function listWindowOf( | |
| 14 | + model: Pick<PaneState.PaneModel, 'place'>, | |
| 15 | + plan: Pick<DockPlan, 'rows'>, | |
| 16 | +): ListWindow { | |
| 17 | + const first = Math.max( | |
| 18 | + 0, | |
| 19 | + Math.min(model.place.listStart, plan.rows.length - Limits.MAX_SUMMARY_ROWS), | |
| 20 | + ) | |
| 21 | + | |
| 22 | + const shown = plan.rows.slice(first, first + Limits.MAX_SUMMARY_ROWS) | |
| 23 | + | |
| 24 | + return { | |
| 25 | + shown, | |
| 26 | + above: first, | |
| 27 | + below: plan.rows.length - first - shown.length, | |
| 28 | + } | |
| 29 | +} |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { Segment } from '../../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The rows a segment takes: a hunk's as counted when it was laid out, one | |
| 5 | + * for anything else. | |
| 6 | + * | |
| 7 | + * @param segment the segment | |
| 8 | + * @returns the rows | |
| 9 | + */ | |
| 10 | +export const segmentRowsOf = (segment: Segment): number => | |
| 11 | + segment.kind === 'hunk' ? segment.rows : 1 |
| @@ -1,0 +1,137 @@ | ||
| 1 | +import { countOf } from '../../../../count-of' | |
| 2 | +import Git from '../../../../git' | |
| 3 | +import { keptOf } from '../../../../kept-of' | |
| 4 | +import Limits from '../../../../limits' | |
| 5 | +import type PaneState from '../../../../pane-state' | |
| 6 | +import Detail from '../../../detail' | |
| 7 | +import Entries from '../../../entries' | |
| 8 | +import Layout from '../../../layout' | |
| 9 | +import type { DockPlan, Segment } from '../../types' | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * The docked pane's scrolling body as segments (ReplDiffSidebar's scroll | |
| 13 | + * box): each listed file's block, a blank between, the pre-session section. | |
| 14 | + * | |
| 15 | + * Under an empty state whose pre-session section is open, the empty lines | |
| 16 | + * head the body instead. Hunks are made printable and split under a leaf's | |
| 17 | + * cap (subHunksOf), each carrying the rows its lines take wrapped. | |
| 18 | + * | |
| 19 | + * @param model the pane's state: which file is armed, which section is open | |
| 20 | + * @param plan the docked pane's decisions over that state | |
| 21 | + * @returns the segments, top to bottom | |
| 22 | + */ | |
| 23 | +export function segmentsOf( | |
| 24 | + model: PaneState.PaneModel, | |
| 25 | + plan: DockPlan, | |
| 26 | +): Segment[] { | |
| 27 | + const { earlier } = plan | |
| 28 | + const { columns } = model.place | |
| 29 | + const isEmptied = plan.message.length > 0 | |
| 30 | + const isOpen = model.isPreSessionShown && earlier.length > 0 | |
| 31 | + const isCapped = earlier.length > Limits.PRE_SESSION_BODY_CAP | |
| 32 | + | |
| 33 | + const gutterOf = (hunk: Git.Hunk) => | |
| 34 | + String( | |
| 35 | + Math.max( | |
| 36 | + 1, | |
| 37 | + hunk.oldStart + countOf(hunk.lines, line => !line.startsWith('+')) - 1, | |
| 38 | + hunk.newStart + countOf(hunk.lines, line => !line.startsWith('-')) - 1, | |
| 39 | + ), | |
| 40 | + ).length + Limits.GUTTER_CHROME | |
| 41 | + | |
| 42 | + function hunkOf(entry: Entries.BodyEntry, hunk: Git.Hunk): Segment { | |
| 43 | + const room = Math.max(1, columns - gutterOf(hunk)) | |
| 44 | + | |
| 45 | + const lineRows = hunk.lines.map(line => | |
| 46 | + Math.max( | |
| 47 | + 1, | |
| 48 | + Math.ceil( | |
| 49 | + Layout.cellWidth( | |
| 50 | + Layout.sanitizeLine( | |
| 51 | + line.slice(1).replaceAll('\t', ' '.repeat(Layout.TAB_WIDTH)), | |
| 52 | + ), | |
| 53 | + ) / room, | |
| 54 | + ), | |
| 55 | + ), | |
| 56 | + ) | |
| 57 | + | |
| 58 | + return { | |
| 59 | + kind: 'hunk', | |
| 60 | + path: entry.path, | |
| 61 | + hunk, | |
| 62 | + lineRows, | |
| 63 | + rows: lineRows.reduce((sum, rows) => sum + rows, 0), | |
| 64 | + } | |
| 65 | + } | |
| 66 | + | |
| 67 | + const splitOf = (hunk: Git.Hunk) => | |
| 68 | + Detail.subHunksOf( | |
| 69 | + { | |
| 70 | + ...hunk, | |
| 71 | + lines: hunk.lines.filter(Git.isBodyLine).map(Detail.drawnLineOf), | |
| 72 | + }, | |
| 73 | + Detail.MAX_CODE_CHARS, | |
| 74 | + ) | |
| 75 | + | |
| 76 | + function fileOf(entry: Entries.BodyEntry): Segment[] { | |
| 77 | + const detail = Entries.detailModelOf(entry, model) | |
| 78 | + const notes = Detail.placeholderOf(detail) | |
| 79 | + const splits = (entry.body?.hunks ?? []).map(splitOf) | |
| 80 | + | |
| 81 | + const isTruncated = | |
| 82 | + entry.body?.isTruncated === true || | |
| 83 | + splits.some(split => split.isTruncated) | |
| 84 | + | |
| 85 | + const body: Segment[] = notes | |
| 86 | + ? notes.map(text => ({ kind: 'note', text })) | |
| 87 | + : splits.flatMap(split => split.hunks).map(hunk => hunkOf(entry, hunk)) | |
| 88 | + | |
| 89 | + return [ | |
| 90 | + { kind: 'rule', path: entry.path }, | |
| 91 | + { | |
| 92 | + kind: 'name', | |
| 93 | + name: { ...detail, isTruncated, isAskable: notes === null }, | |
| 94 | + }, | |
| 95 | + { kind: 'rule', path: null }, | |
| 96 | + ...body, | |
| 97 | + ...(isTruncated ? [{ kind: 'footer' } as const] : []), | |
| 98 | + ] | |
| 99 | + } | |
| 100 | + | |
| 101 | + const spacedFileOf = (entry: Entries.BodyEntry): Segment[] => [ | |
| 102 | + ...fileOf(entry), | |
| 103 | + { kind: 'blank' }, | |
| 104 | + ] | |
| 105 | + | |
| 106 | + const messageOf = (): Segment[] => [ | |
| 107 | + { kind: 'blank' }, | |
| 108 | + ...plan.message.map(text => ({ | |
| 109 | + kind: 'text' as const, | |
| 110 | + text: Layout.sanitizeName(text), | |
| 111 | + })), | |
| 112 | + { kind: 'blank' }, | |
| 113 | + ] | |
| 114 | + | |
| 115 | + const hiddenNote: Segment = { | |
| 116 | + kind: 'text', | |
| 117 | + text: `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`, | |
| 118 | + } | |
| 119 | + | |
| 120 | + const bodiesOf = (): Segment[] => | |
| 121 | + isCapped ? [hiddenNote] : earlier.flatMap(fileOf) | |
| 122 | + | |
| 123 | + const openedOf = (): Segment[] => [ | |
| 124 | + { kind: 'blank' }, | |
| 125 | + ...earlier.map(entry => ({ kind: 'legend' as const, entry })), | |
| 126 | + ...bodiesOf(), | |
| 127 | + ] | |
| 128 | + | |
| 129 | + return [ | |
| 130 | + ...(isEmptied ? messageOf() : plan.rows.flatMap(spacedFileOf)), | |
| 131 | + ...keptOf([plan.earlierLabel]).map(label => ({ | |
| 132 | + kind: 'toggle' as const, | |
| 133 | + label, | |
| 134 | + })), | |
| 135 | + ...(isOpen ? openedOf() : []), | |
| 136 | + ] | |
| 137 | +} |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import type { Segment } from './segment' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The docked pane's scrolling body laid out: its segments and their rows, | |
| 5 | + * the window, each file's first row, the list block's rows (end exclusive). | |
| 6 | + */ | |
| 7 | +export type BodyLayout = { | |
| 8 | + segments: readonly Segment[] | |
| 9 | + extent: number | |
| 10 | + visibleRows: number | |
| 11 | + maxTop: number | |
| 12 | + tops: ReadonlyMap<string, number> | |
| 13 | + listTop: number | |
| 14 | + listEnd: number | |
| 15 | +} |
| @@ -1,0 +1,25 @@ | ||
| 1 | +import type PaneState from '../../../pane-state' | |
| 2 | +import type Turns from '../../../turns' | |
| 3 | +import type Entries from '../../entries' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * What the docked pane decided before drawing: the turn or fetch it shows, | |
| 7 | + * its rows and counts, the empty state, the lines round the list. | |
| 8 | + * | |
| 9 | + * `message` holds the lines that stand in for the list (an empty state, a | |
| 10 | + * too-many or only-noise state); `line` the turn's preview, base line or | |
| 11 | + * no-commits note, printable; `earlierLabel` the pre-session grey line. | |
| 12 | + */ | |
| 13 | +export type DockPlan = { | |
| 14 | + turn: Turns.TurnDiff | undefined | |
| 15 | + partition: PaneState.Partition | |
| 16 | + totals: PaneState.HeaderTotals | |
| 17 | + empty: PaneState.EmptyState | null | |
| 18 | + line: string | null | |
| 19 | + rows: readonly Entries.BodyEntry[] | |
| 20 | + earlier: readonly Entries.BodyEntry[] | |
| 21 | + earlierLabel: string | null | |
| 22 | + notes: readonly string[] | |
| 23 | + message: readonly string[] | |
| 24 | + isUntrackedNoted: boolean | |
| 25 | +} |
| @@ -1,0 +1,7 @@ | ||
| 1 | +export type * from './body-layout.js' | |
| 2 | +export type * from './dock-plan.js' | |
| 3 | +export type * from './list-window.js' | |
| 4 | +export * from './segment' | |
| 5 | +export * from './window-cut' | |
| 6 | + | |
| 7 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Entries from '../../entries' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The docked list's window over its files: the rows in view, and how many | |
| 5 | + * files lie above and below it. | |
| 6 | + */ | |
| 7 | +export type ListWindow = { | |
| 8 | + shown: readonly Entries.BodyEntry[] | |
| 9 | + above: number | |
| 10 | + below: number | |
| 11 | +} |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type Git from '../../../../../git' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * One hunk of a file in the docked body: whose it is, the hunk, and the | |
| 5 | + * rows each of its lines takes wrapped at the body's width, summed too. | |
| 6 | + */ | |
| 7 | +export type HunkSegment = { | |
| 8 | + kind: 'hunk' | |
| 9 | + path: string | |
| 10 | + hunk: Git.Hunk | |
| 11 | + lineRows: readonly number[] | |
| 12 | + rows: number | |
| 13 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './hunk-segment.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './hunk-segment' | |
| 2 | +export type * from './segment.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,22 @@ | ||
| 1 | +import type Detail from '../../../detail' | |
| 2 | +import type Entries from '../../../entries' | |
| 3 | +import type { HunkSegment } from './hunk-segment' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One piece of the docked pane's scrolling body: a hunk some rows tall | |
| 7 | + * (HunkSegment), or one row of another kind, top to bottom. | |
| 8 | + * | |
| 9 | + * A file is a `rule` (its top, what a row's press scrolls to), its `name`, | |
| 10 | + * a `rule`, then hunks or `note`s and a `footer` when cut; `text` is an | |
| 11 | + * empty state's line, `toggle` and `legend` the pre-session section's. | |
| 12 | + */ | |
| 13 | +export type Segment = | |
| 14 | + | HunkSegment | |
| 15 | + | { kind: 'rule'; path: string | null } | |
| 16 | + | { kind: 'blank' } | |
| 17 | + | { kind: 'name'; name: Detail.NamedDetail } | |
| 18 | + | { kind: 'note'; text: string } | |
| 19 | + | { kind: 'footer' } | |
| 20 | + | { kind: 'text'; text: string } | |
| 21 | + | { kind: 'toggle'; label: string } | |
| 22 | + | { kind: 'legend'; entry: Entries.BodyEntry } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './window-cut.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * How the body's window cuts one hunk: the wrapped rows its top hides, and | |
| 3 | + * the rows it still has room for from there. | |
| 4 | + */ | |
| 5 | +export type WindowCut = { | |
| 6 | + skip: number | |
| 7 | + take: number | |
| 8 | +} |
| @@ -1,175 +1,0 @@ | ||
| 1 | -/* @jsxRuntime classic */ | |
| 2 | -/* @jsx h */ | |
| 3 | -/* @jsxFrag Fragment */ | |
| 4 | -import type { RenderElement } from 'claude-code' | |
| 5 | - | |
| 6 | -import type Git from '../../git' | |
| 7 | -import Names from '../../names' | |
| 8 | -import PaneState from '../../pane-state' | |
| 9 | -import Detail from '../detail' | |
| 10 | -import type { Kit } from '../kit' | |
| 11 | -import Layout from '../layout' | |
| 12 | -import Sections from '../sections' | |
| 13 | -import { fileRowOf } from './file-row-of' | |
| 14 | -import { listBodyOf } from './list-body-of' | |
| 15 | -import { messagePaneOf } from './message-pane-of' | |
| 16 | - | |
| 17 | -/** | |
| 18 | - * The pane over the repository's diff now (ReplDiffSidebarBody, one file's | |
| 19 | - * body at a time): header, base line, todo bar, pickers, rows, detail. | |
| 20 | - * | |
| 21 | - * The toggle sits above the rows; the elision count, withheld-untracked | |
| 22 | - * note and pre-session line below, with blank rows as the built-in leaves. | |
| 23 | - * With nothing to list, messagePaneOf draws the body, the header a close. | |
| 24 | - * | |
| 25 | - * @param kit the elements, the handlers, the width, the rows | |
| 26 | - * @param model the pane's state | |
| 27 | - * @returns the pane's tree | |
| 28 | - */ | |
| 29 | -export function currentPane( | |
| 30 | - kit: Kit, | |
| 31 | - model: PaneState.PaneModel, | |
| 32 | -): RenderElement { | |
| 33 | - const { Box } = kit.ui | |
| 34 | - const { data } = model | |
| 35 | - | |
| 36 | - if (model.isOutsideRepository) { | |
| 37 | - return <Box>{Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)}</Box> | |
| 38 | - } | |
| 39 | - | |
| 40 | - if (!data && !model.hasSettled) { | |
| 41 | - return messagePaneOf(kit, { | |
| 42 | - top: [Sections.headerView(kit, null)], | |
| 43 | - message: ['Loading diff…'], | |
| 44 | - controls: null, | |
| 45 | - earlier: null, | |
| 46 | - rest: [], | |
| 47 | - }) | |
| 48 | - } | |
| 49 | - | |
| 50 | - const noise = model.isNoiseShown ? 'shown' : 'hidden' | |
| 51 | - const preSession = model.isPreSessionShown ? 'shown' : 'hidden' | |
| 52 | - const partition = PaneState.partitionOf(data?.files ?? [], noise) | |
| 53 | - | |
| 54 | - const totals = data | |
| 55 | - ? PaneState.headerTotalsOf(data, partition) | |
| 56 | - : PaneState.ZERO_TOTALS | |
| 57 | - | |
| 58 | - const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words) | |
| 59 | - const baseLabel = PaneState.baseLabelOf(model, totals.filesCount) | |
| 60 | - | |
| 61 | - const selected = PaneState.selectionOf( | |
| 62 | - PaneState.listedOf(partition, preSession), | |
| 63 | - model.selectedPath, | |
| 64 | - ) | |
| 65 | - | |
| 66 | - const rowOf = (file: Git.FileStat): RenderElement => | |
| 67 | - Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () => | |
| 68 | - kit.actions.selectFile(file.path), | |
| 69 | - ) | |
| 70 | - | |
| 71 | - const noteOf = (text: string | null): RenderElement | null => | |
| 72 | - text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text)) | |
| 73 | - | |
| 74 | - const noiseFace = model.isNoiseShown ? 'hide' : 'show' | |
| 75 | - const noiseCount = Layout.plural(partition.noiseCount, 'test') | |
| 76 | - const hasNoise = partition.noiseCount > 0 | |
| 77 | - | |
| 78 | - const noiseToggle = hasNoise | |
| 79 | - ? Sections.toggleRow(kit, 'noise', { | |
| 80 | - label: `${noiseCount}/generated (${noiseFace})`, | |
| 81 | - onPress: kit.actions.toggleNoise, | |
| 82 | - }) | |
| 83 | - : null | |
| 84 | - | |
| 85 | - const earlierFace = model.isPreSessionShown ? 'hide' : 'show' | |
| 86 | - const earlierCount = Layout.plural(partition.preSession.length, 'file') | |
| 87 | - const hasEarlier = partition.preSession.length > 0 | |
| 88 | - | |
| 89 | - const earlierToggle = hasEarlier | |
| 90 | - ? Sections.toggleRow(kit, 'presession', { | |
| 91 | - label: `+${earlierCount} edited before this session (${earlierFace})`, | |
| 92 | - onPress: kit.actions.togglePreSession, | |
| 93 | - }) | |
| 94 | - : null | |
| 95 | - | |
| 96 | - const earlierFiles = model.isPreSessionShown ? partition.preSession : [] | |
| 97 | - const isEarlierListed = earlierFiles.length > 0 | |
| 98 | - | |
| 99 | - const earlierRows = isEarlierListed | |
| 100 | - ? [<Box height={1} />, ...earlierFiles.map(rowOf)] | |
| 101 | - : [] | |
| 102 | - | |
| 103 | - const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty | |
| 104 | - const listed = listBodyOf(kit, { data, partition, totals, empty }) | |
| 105 | - const notes = listed.filter(line => typeof line === 'string') | |
| 106 | - const hasRows = notes.length < listed.length | |
| 107 | - const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes | |
| 108 | - const header = Sections.headerView(kit, empty ? null : totals) | |
| 109 | - | |
| 110 | - const notShownNote = noteOf( | |
| 111 | - totals.notShown > 0 ? `${totals.notShown} not shown` : null, | |
| 112 | - ) | |
| 113 | - | |
| 114 | - const untrackedNote = noteOf( | |
| 115 | - isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null, | |
| 116 | - ) | |
| 117 | - | |
| 118 | - const detail = selected | |
| 119 | - ? [ | |
| 120 | - Sections.divider(kit), | |
| 121 | - Detail.detailView( | |
| 122 | - kit, | |
| 123 | - { | |
| 124 | - words: model.words, | |
| 125 | - path: selected.path, | |
| 126 | - renamedFrom: selected.renamedFrom, | |
| 127 | - isUntracked: selected.isUntracked, | |
| 128 | - isBinary: selected.isBinary, | |
| 129 | - body: model.body, | |
| 130 | - bodyState: model.bodyState, | |
| 131 | - isArmed: model.armedPath === selected.path, | |
| 132 | - }, | |
| 133 | - () => kit.actions.toggleAsk(selected.path), | |
| 134 | - ), | |
| 135 | - ] | |
| 136 | - : [] | |
| 137 | - | |
| 138 | - const isMessageShown = message.length > 0 | |
| 139 | - | |
| 140 | - return isMessageShown ? ( | |
| 141 | - messagePaneOf(kit, { | |
| 142 | - top: Sections.present([ | |
| 143 | - header, | |
| 144 | - noteOf(baseLabel), | |
| 145 | - Sections.todoBar(kit, model), | |
| 146 | - noiseToggle, | |
| 147 | - notShownNote, | |
| 148 | - untrackedNote, | |
| 149 | - ]), | |
| 150 | - message, | |
| 151 | - controls: Sections.controlsView(kit, model), | |
| 152 | - earlier: earlierToggle, | |
| 153 | - rest: [...earlierRows, ...detail], | |
| 154 | - }) | |
| 155 | - ) : ( | |
| 156 | - <Box flexDirection="column"> | |
| 157 | - {Sections.present([ | |
| 158 | - header, | |
| 159 | - noteOf(baseLabel), | |
| 160 | - Sections.todoBar(kit, model), | |
| 161 | - Sections.controlsView(kit, model), | |
| 162 | - noiseToggle, | |
| 163 | - ...listed.map(line => | |
| 164 | - typeof line === 'string' ? noteOf(line) : rowOf(line), | |
| 165 | - ), | |
| 166 | - notShownNote, | |
| 167 | - untrackedNote, | |
| 168 | - hasEarlier ? <Box height={1} /> : null, | |
| 169 | - earlierToggle, | |
| 170 | - ...earlierRows, | |
| 171 | - ...detail, | |
| 172 | - ])} | |
| 173 | - </Box> | |
| 174 | - ) | |
| 175 | -} |
| @@ -1,34 +1,0 @@ | ||
| 1 | -import type Git from '../../../git' | |
| 2 | -import Sections from '../../sections' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * A fetched row as the list draws it: its counts, or a note in their | |
| 6 | - * place (`untracked`, `Binary file`, `renamed`), and selection. | |
| 7 | - * | |
| 8 | - * @param file the row | |
| 9 | - * @param selectedPath the selected file's path, or null | |
| 10 | - * @returns the row model | |
| 11 | - */ | |
| 12 | -export function fileRowOf( | |
| 13 | - file: Git.FileStat, | |
| 14 | - selectedPath: string | null, | |
| 15 | -): Sections.FileRowModel { | |
| 16 | - const isRenamed = file.renamedFrom !== null | |
| 17 | - | |
| 18 | - const note = file.isUntracked | |
| 19 | - ? 'untracked' | |
| 20 | - : file.isBinary | |
| 21 | - ? 'Binary file' | |
| 22 | - : isRenamed | |
| 23 | - ? 'renamed' | |
| 24 | - : null | |
| 25 | - | |
| 26 | - return { | |
| 27 | - key: Sections.fileKeyOf(file.path), | |
| 28 | - path: file.path, | |
| 29 | - added: file.added, | |
| 30 | - removed: file.removed, | |
| 31 | - note, | |
| 32 | - isSelected: file.path === selectedPath, | |
| 33 | - } | |
| 34 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './file-row-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,0 @@ | ||
| 1 | -export * from './current-pane.jsx' | |
| 2 | -export * from './file-row-of' | |
| 3 | -export * from './list-body-of' | |
| 4 | -export * from './message-pane-of' | |
| 5 | - | |
| 6 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './list-body-of.js' | |
| 2 | -export * from './list-place' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -1,46 +1,0 @@ | ||
| 1 | -import type Git from '../../../git' | |
| 2 | -import type { Kit } from '../../kit' | |
| 3 | -import type { ListPlace } from './list-place' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * The list's body as ReplDiffSidebarBody decides it: the empty state's | |
| 7 | - * hint, the too-many state, the only-noise state, or the session's rows. | |
| 8 | - * | |
| 9 | - * Strings are dim lines; rows are files to draw. | |
| 10 | - * | |
| 11 | - * @param _kit unused; keeps the part's shape | |
| 12 | - * @param place the fetch, its groups, the counts, the empty state | |
| 13 | - * @returns the lines and rows, in order | |
| 14 | - */ | |
| 15 | -export function listBodyOf( | |
| 16 | - _kit: Kit, | |
| 17 | - place: ListPlace, | |
| 18 | -): readonly (string | Git.FileStat)[] { | |
| 19 | - const { data, partition, totals, empty } = place | |
| 20 | - | |
| 21 | - const hint = empty?.hint ?? null | |
| 22 | - | |
| 23 | - if (empty) { | |
| 24 | - return hint === null ? [] : [hint] | |
| 25 | - } | |
| 26 | - | |
| 27 | - const isTooMany = | |
| 28 | - data !== null && data.files.length === 0 && totals.filesCount > 0 | |
| 29 | - | |
| 30 | - if (isTooMany) { | |
| 31 | - return [ | |
| 32 | - 'Too many changed files to show diff', | |
| 33 | - 'Per-file diff is skipped above 500 files', | |
| 34 | - ] | |
| 35 | - } | |
| 36 | - | |
| 37 | - const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0 | |
| 38 | - | |
| 39 | - return isOnlyNoise | |
| 40 | - ? [ | |
| 41 | - 'Only tests and generated files changed', | |
| 42 | - 'Tests and generated files are hidden · click "show" above to view ' + | |
| 43 | - 'them', | |
| 44 | - ] | |
| 45 | - : partition.shown | |
| 46 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './list-place.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type Git from '../../../../git' | |
| 2 | -import type PaneState from '../../../../pane-state' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * What decides the list's body: the fetch, its grouped rows, the header's | |
| 6 | - * counts, and the empty state when there is one. | |
| 7 | - */ | |
| 8 | -export type ListPlace = { | |
| 9 | - data: Git.DiffData | null | |
| 10 | - partition: PaneState.Partition | |
| 11 | - totals: PaneState.HeaderTotals | |
| 12 | - empty: PaneState.EmptyState | null | |
| 13 | -} |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The rows the pre-session line takes at the pane's foot: a blank row, the | |
| 3 | - * line, and a blank row under it. | |
| 4 | - */ | |
| 5 | -export const FOOT_ROWS = 3 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './foot-rows.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -export * from './foot-rows' | |
| 2 | -export * from './message-pane' | |
| 3 | -export * from './message-pane-of.jsx' | |
| 4 | - | |
| 5 | -export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './message-pane.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type { RenderElement } from 'claude-code' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * What stands around a message in the pane: the header block, the message, | |
| 5 | - * the pickers, the pre-session line, and what is listed under it. | |
| 6 | - */ | |
| 7 | -export type MessagePane = { | |
| 8 | - top: readonly RenderElement[] | |
| 9 | - message: readonly string[] | |
| 10 | - controls: RenderElement | null | |
| 11 | - earlier: RenderElement | null | |
| 12 | - rest: readonly RenderElement[] | |
| 13 | -} |
| @@ -1,85 +1,0 @@ | ||
| 1 | -/* @jsxRuntime classic */ | |
| 2 | -/* @jsx h */ | |
| 3 | -/* @jsxFrag Fragment */ | |
| 4 | -import type { RenderElement } from 'claude-code' | |
| 5 | - | |
| 6 | -import type { Kit } from '../../kit' | |
| 7 | -import Layout from '../../layout' | |
| 8 | -import { FOOT_ROWS } from './foot-rows' | |
| 9 | -import type { MessagePane } from './message-pane' | |
| 10 | - | |
| 11 | -/** | |
| 12 | - * The pane while a message stands in for the list (ReplDiffSidebar's | |
| 13 | - * centered body): the header, a blank row, the message, the pickers. | |
| 14 | - * | |
| 15 | - * With nothing listed below and room, the lines sit where the built-in | |
| 16 | - * centers them (half the spare rows above), the pickers under them; | |
| 17 | - * otherwise all stacks from the top, the pickers over the pre-session line. | |
| 18 | - * | |
| 19 | - * @param kit the elements, the width, the body's rows | |
| 20 | - * @param pane the header block, the message's lines, the pickers, the | |
| 21 | - * pre-session line, and what is listed under it | |
| 22 | - * @returns the pane's tree | |
| 23 | - */ | |
| 24 | -export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement { | |
| 25 | - const { Box, Text } = kit.ui | |
| 26 | - const { top, controls, earlier, rest } = pane | |
| 27 | - | |
| 28 | - const lines = pane.message.flatMap(text => | |
| 29 | - Layout.wrappedLines(Layout.sanitizeName(text), kit.columns), | |
| 30 | - ) | |
| 31 | - | |
| 32 | - const drawn = lines.map(line => <Text dimColor>{line}</Text>) | |
| 33 | - const controlRows = controls === null ? [] : [controls] | |
| 34 | - const earlierRows = earlier === null ? [] : [earlier] | |
| 35 | - | |
| 36 | - const isControlsAboveEarlier = | |
| 37 | - controlRows.length > 0 || earlierRows.length === 0 | |
| 38 | - | |
| 39 | - const aboveEarlier = isControlsAboveEarlier | |
| 40 | - ? controlRows | |
| 41 | - : [<Box height={1} />] | |
| 42 | - | |
| 43 | - const footRows = earlier === null ? 0 : FOOT_ROWS | |
| 44 | - const middleRows = kit.rows - top.length - 1 - footRows | |
| 45 | - const above = Math.floor((middleRows - lines.length) / 2) | |
| 46 | - | |
| 47 | - const isCentered = | |
| 48 | - rest.length === 0 && middleRows - above - lines.length >= controlRows.length | |
| 49 | - | |
| 50 | - if (!isCentered) { | |
| 51 | - return ( | |
| 52 | - <Box flexDirection="column"> | |
| 53 | - {[ | |
| 54 | - ...top, | |
| 55 | - <Box height={1} />, | |
| 56 | - ...drawn, | |
| 57 | - ...aboveEarlier, | |
| 58 | - ...earlierRows, | |
| 59 | - ...rest, | |
| 60 | - ]} | |
| 61 | - </Box> | |
| 62 | - ) | |
| 63 | - } | |
| 64 | - | |
| 65 | - const spacer = above > 0 ? [<Box height={above} />] : [] | |
| 66 | - | |
| 67 | - const foot = earlierRows.map(row => ( | |
| 68 | - <Box flexDirection="column" marginTop={1} paddingBottom={1}> | |
| 69 | - {row} | |
| 70 | - </Box> | |
| 71 | - )) | |
| 72 | - | |
| 73 | - return ( | |
| 74 | - <Box flexDirection="column" height={kit.rows}> | |
| 75 | - {[ | |
| 76 | - ...top, | |
| 77 | - <Box height={1} />, | |
| 78 | - <Box flexDirection="column" height={middleRows} alignItems="center"> | |
| 79 | - {[...spacer, ...drawn, ...controlRows]} | |
| 80 | - </Box>, | |
| 81 | - ...foot, | |
| 82 | - ]} | |
| 83 | - </Box> | |
| 84 | - ) | |
| 85 | -} |
| @@ -1,0 +1,12 @@ | ||
| 1 | +import { MAX_BODY_CHARS } from '../max-body-chars' | |
| 2 | +import { MAX_BODY_NODES } from '../max-body-nodes' | |
| 3 | +import type Types from '../types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The room the pane's file bodies start from at each drawing, spent file by | |
| 7 | + * file in drawing order. | |
| 8 | + */ | |
| 9 | +export const BODY_BUDGET: Types.BodyRoom = Object.freeze({ | |
| 10 | + chars: MAX_BODY_CHARS, | |
| 11 | + nodes: MAX_BODY_NODES, | |
| 12 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './body-budget.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,82 +1,62 @@ | ||
| 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,63 +1,45 @@ | ||
| 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 | |
| 17 | - * Button, the built-in's dim rule under them, the body. | |
| 15 | + * One file's detail (DiffDetailView): its name row (nameRow), the | |
| 16 | + * built-in's dim rule under it, the body. | |
| 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 body is a placeholder, or the hunks as the engine's diff `Code` | |
| 19 | + * blocks (codeBlocksOf) within the room given, then a footer when anything | |
| 20 | + * was cut; with it, the room left over. | |
| 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 room the room the pane's bodies have left | |
| 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 { | |
| 33 | - const { Box, Text, Button, Code } = kit.ui | |
| 30 | + room: Types.BodyRoom, | |
| 31 | +): Types.DrawnDetail { | |
| 32 | + const { Box, Text, Code } = kit.ui | |
| 34 | 33 | const placeholder = placeholderOf(detail) |
| 35 | - const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? [])) | |
| 34 | + | |
| 35 | + const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []), { | |
| 36 | + chars: room.chars - Layout.sanitizeName(detail.displayPath).length, | |
| 37 | + nodes: room.nodes - FILE_FRAME_NODES, | |
| 38 | + }) | |
| 39 | + | |
| 36 | 40 | const isTruncated = detail.body?.isTruncated === true || code.isTruncated |
| 37 | 41 | const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) |
| 38 | 42 | |
| 39 | - const nameOf = (name: string) => | |
| 40 | - Layout.truncateStart(Layout.sanitizeName(name), kit.columns) | |
| 41 | - | |
| 42 | - const renamedFrom = | |
| 43 | - detail.renamedFrom === null ? null : nameOf(detail.renamedFrom) | |
| 44 | - | |
| 45 | - const asides = [ | |
| 46 | - renamedFrom === null ? null : `renamed from ${renamedFrom}`, | |
| 47 | - detail.isUntracked ? 'untracked' : null, | |
| 48 | - isTruncated ? 'truncated' : null, | |
| 49 | - ].filter(word => word !== null) | |
| 50 | - | |
| 51 | - const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})` | |
| 52 | - | |
| 53 | - const ask = placeholder | |
| 54 | - ? [] | |
| 55 | - : [ | |
| 56 | - <Button key="ask" onPress={onToggleAsk}> | |
| 57 | - {detail.isArmed ? 'asked ✓' : 'ask'} | |
| 58 | - </Button>, | |
| 59 | - ] | |
| 60 | - | |
| 61 | 43 | const footer = isTruncated |
| 62 | 44 | ? [ |
| 63 | 45 | <Text dimColor italic> |
| @@ -72,28 +54,24 @@ | ||
| 72 | 54 | </Text> |
| 73 | 55 | )) |
| 74 | 56 | |
| 75 | - const hunks = code.blocks.flatMap(block => [ | |
| 76 | - ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []), | |
| 77 | - <Code source={block.source} format="diff" path={path} />, | |
| 78 | - ]) | |
| 57 | + const hunks = code.sources.map(source => ( | |
| 58 | + <Code source={source} format="diff" path={path} /> | |
| 59 | + )) | |
| 79 | 60 | |
| 80 | - return ( | |
| 61 | + const element = ( | |
| 81 | 62 | <Box flexDirection="column"> |
| 82 | 63 | {[ |
| 83 | - <Box flexDirection="row"> | |
| 84 | - {[ | |
| 85 | - <Text bold wrap="truncate-start"> | |
| 86 | - {nameOf(detail.path)} | |
| 87 | - </Text>, | |
| 88 | - <Text dimColor>{aside}</Text>, | |
| 89 | - <Box flexGrow={1} />, | |
| 90 | - ...ask, | |
| 91 | - ]} | |
| 92 | - </Box>, | |
| 64 | + Sections.nameRow(kit, { | |
| 65 | + ...detail, | |
| 66 | + isTruncated, | |
| 67 | + isAskable: placeholder === null, | |
| 68 | + }), | |
| 93 | 69 | Sections.divider(kit), |
| 94 | 70 | ...(placeholder ? notes : hunks), |
| 95 | 71 | ...footer, |
| 96 | 72 | ]} |
| 97 | 73 | </Box> |
| 98 | 74 | ) |
| 75 | + | |
| 76 | + return { element, room: code.room } | |
| 99 | 77 | } |
| @@ -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,5 +1,4 @@ | ||
| 1 | -import { countOf } from '../../../count-of' | |
| 2 | -import type Git from '../../../git' | |
| 1 | +import Git from '../../../git' | |
| 3 | 2 | import { leadingHunkOf } from '../leading-hunk-of' |
| 4 | 3 | import { firstLineCut } from './first-line-cut' |
| 5 | 4 | |
| @@ -31,16 +30,9 @@ | ||
| 31 | 30 | ? { ...rest, lines: [firstLineCut(rest, maxChars)] } |
| 32 | 31 | : leading |
| 33 | 32 | |
| 34 | - const taken = rest.lines.slice(0, piece.lines.length) | |
| 35 | - | |
| 36 | 33 | hunks.push(piece) |
| 37 | 34 | isTruncated ||= isLineCut |
| 38 | - | |
| 39 | - rest = { | |
| 40 | - oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')), | |
| 41 | - newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')), | |
| 42 | - lines: rest.lines.slice(piece.lines.length), | |
| 43 | - } | |
| 35 | + rest = Git.hunkAfter(rest, piece.lines.length) | |
| 44 | 36 | } |
| 45 | 37 | |
| 46 | 38 | return { hunks, isTruncated } |
| @@ -1,0 +1,8 @@ | ||
| 1 | +/** | |
| 2 | + * What is left of the pane's budget for file bodies: characters of `Code` | |
| 3 | + * source and tree nodes, both counted as the host counts a tree. | |
| 4 | + */ | |
| 5 | +export type BodyRoom = { | |
| 6 | + chars: number | |
| 7 | + nodes: number | |
| 8 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './body-room.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -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,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 './drawn-detail' | |
| 4 | +export * from './named-detail' | |
| 3 | 5 | |
| 4 | 6 | export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './named-detail.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { DetailModel } from '../../detail-model' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A file as its name row draws it: the detail, whether its body was cut, | |
| 5 | + * and whether it can be asked about (it has hunks to attach). | |
| 6 | + */ | |
| 7 | +export type NamedDetail = DetailModel & { | |
| 8 | + isTruncated: boolean | |
| 9 | + isAskable: boolean | |
| 10 | +} |
| @@ -1,0 +1,24 @@ | ||
| 1 | +import Git from '../../../git' | |
| 2 | +import PaneState from '../../../pane-state' | |
| 3 | +import Entries from '../../entries' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The files the dialog lists, in its order: the picked turn's, or every | |
| 7 | + * file of the fetch, pre-session or not, sorted by its displayed name. | |
| 8 | + * | |
| 9 | + * @param model the pane's state | |
| 10 | + * @returns the entries | |
| 11 | + */ | |
| 12 | +export function dialogEntriesOf( | |
| 13 | + model: PaneState.PaneModel, | |
| 14 | +): readonly Entries.BodyEntry[] { | |
| 15 | + const turn = PaneState.pickedTurnOf(model) | |
| 16 | + | |
| 17 | + return turn | |
| 18 | + ? turn.files.map(Entries.turnEntryOf) | |
| 19 | + : [...(model.data?.files ?? [])] | |
| 20 | + .sort((a, b) => | |
| 21 | + Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)), | |
| 22 | + ) | |
| 23 | + .map(file => Entries.bodyEntryOf(file, model)) | |
| 24 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './dialog-entries-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,46 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import type PaneState from '../../pane-state' | |
| 3 | +import Sections from '../sections' | |
| 4 | +import { dialogEntriesOf } from './dialog-entries-of' | |
| 5 | +import { dialogWindowOf } from './dialog-window-of' | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * What the focus ring landing on a dialog row makes of the list: that file | |
| 9 | + * selected, the window re-centred, the key of the row drawn where it sits. | |
| 10 | + * | |
| 11 | + * DiffFileList's selection, which stops at an end where the ring wraps: a | |
| 12 | + * jump across three or more rows from the first or last file is `stay`. | |
| 13 | + * The ring goes to the row holding the file's place after the re-centre. | |
| 14 | + * | |
| 15 | + * @param model the pane's state | |
| 16 | + * @param element the key of the element taking the ring | |
| 17 | + * @returns the selection and the key to land on, `stay`, or null for a key | |
| 18 | + * that is none of the listed rows' | |
| 19 | + */ | |
| 20 | +export function dialogFocusOf( | |
| 21 | + model: PaneState.PaneModel, | |
| 22 | + element: string | undefined, | |
| 23 | +): PaneState.DialogFocus | 'stay' | null { | |
| 24 | + const entries = dialogEntriesOf(model) | |
| 25 | + const paths = entries.map(entry => entry.path) | |
| 26 | + const keys = entries.map(entry => Sections.fileKeyOf(entry)) | |
| 27 | + const picked = keys.indexOf(element ?? '') | |
| 28 | + const current = Math.max(0, paths.indexOf(model.selectedPath ?? '')) | |
| 29 | + const before = dialogWindowOf(model, paths) | |
| 30 | + | |
| 31 | + const lastDrawn = | |
| 32 | + Math.min(paths.length, before + Limits.MAX_VISIBLE_FILES) - 1 | |
| 33 | + | |
| 34 | + const selectedPath = paths[picked] ?? null | |
| 35 | + | |
| 36 | + const landing = | |
| 37 | + keys[before + picked - dialogWindowOf({ selectedPath }, paths)] | |
| 38 | + | |
| 39 | + const isOverStart = current === 0 && picked === lastDrawn | |
| 40 | + const isOverEnd = current === paths.length - 1 && picked === before | |
| 41 | + const isFar = lastDrawn - before >= 2 | |
| 42 | + const isRow = selectedPath !== null && landing !== undefined | |
| 43 | + const isWrap = isFar && (isOverEnd || isOverStart) | |
| 44 | + | |
| 45 | + return isWrap ? 'stay' : isRow ? { selectedPath, landing } : null | |
| 46 | +} |
| @@ -1,0 +1,146 @@ | ||
| 1 | +/* @jsxRuntime classic */ | |
| 2 | +/* @jsx h */ | |
| 3 | +/* @jsxFrag Fragment */ | |
| 4 | +import type { RenderElement } from 'claude-code' | |
| 5 | + | |
| 6 | +import Limits from '../../limits' | |
| 7 | +import PaneState from '../../pane-state' | |
| 8 | +import Detail from '../detail' | |
| 9 | +import Entries from '../entries' | |
| 10 | +import type { Kit } from '../kit' | |
| 11 | +import Layout from '../layout' | |
| 12 | +import { preludeOf } from '../prelude-of' | |
| 13 | +import Sections from '../sections' | |
| 14 | +import { dialogEntriesOf } from './dialog-entries-of' | |
| 15 | +import { dialogWindowOf } from './dialog-window-of' | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * The inline pane (DiffDialog): its title, the dim count, then the file | |
| 19 | + * list's window, or the picked file's detail alone, and the key hints. | |
| 20 | + * | |
| 21 | + * Every file lists in the built-in's order, five at a time round the | |
| 22 | + * selected row (`❯ `, where the focus ring starts and what its walk moves, | |
| 23 | + * ui.focus); Enter views that file, Escape backs out or closes. | |
| 24 | + * | |
| 25 | + * @param kit the elements, the handlers, the width | |
| 26 | + * @param model the pane's state | |
| 27 | + * @returns the pane's tree | |
| 28 | + */ | |
| 29 | +export function dialogPane( | |
| 30 | + kit: Kit, | |
| 31 | + model: PaneState.PaneModel, | |
| 32 | +): RenderElement { | |
| 33 | + const { Box, Text } = kit.ui | |
| 34 | + const prelude = preludeOf(kit, model) | |
| 35 | + | |
| 36 | + if (prelude) { | |
| 37 | + return prelude | |
| 38 | + } | |
| 39 | + | |
| 40 | + const turn = PaneState.pickedTurnOf(model) | |
| 41 | + const { data } = model | |
| 42 | + const entries = dialogEntriesOf(model) | |
| 43 | + const totals = turn ? PaneState.turnTotalsOf(turn) : (data?.stats ?? null) | |
| 44 | + const isTurn = model.source.kind === 'turn' | |
| 45 | + const isPaged = entries.length > Limits.MAX_VISIBLE_FILES | |
| 46 | + const picked = entries.find(entry => entry.path === model.selectedPath) | |
| 47 | + const selected = picked ?? entries[0] | |
| 48 | + const isDetail = model.dialogView === 'detail' && picked !== undefined | |
| 49 | + | |
| 50 | + const start = dialogWindowOf( | |
| 51 | + model, | |
| 52 | + entries.map(entry => entry.path), | |
| 53 | + ) | |
| 54 | + | |
| 55 | + const shown = entries.slice(start, start + Limits.MAX_VISIBLE_FILES) | |
| 56 | + const blankEdge = Sections.dimNote(kit, ' ') | |
| 57 | + const below = entries.length - start - shown.length | |
| 58 | + | |
| 59 | + const isTooMany = | |
| 60 | + !isTurn && | |
| 61 | + data !== null && | |
| 62 | + data.stats.filesCount > 0 && | |
| 63 | + entries.length === 0 | |
| 64 | + | |
| 65 | + const emptyWords = isTurn | |
| 66 | + ? 'No file changes in this turn' | |
| 67 | + : isTooMany | |
| 68 | + ? 'Too many files to display details' | |
| 69 | + : 'No changes yet' | |
| 70 | + | |
| 71 | + const message = entries.length > 0 ? null : emptyWords | |
| 72 | + | |
| 73 | + const subtitle = totals && ( | |
| 74 | + <Text dimColor wrap="truncate-end"> | |
| 75 | + {`${Layout.plural(totals.filesCount, 'file')} changed `} | |
| 76 | + {Sections.diffStat(kit, totals.linesAdded, totals.linesRemoved)} | |
| 77 | + </Text> | |
| 78 | + ) | |
| 79 | + | |
| 80 | + const noteOf = (entry: Entries.BodyEntry) => | |
| 81 | + entry.isUntracked ? 'untracked' : entry.isBinary ? 'Binary file' : null | |
| 82 | + | |
| 83 | + const rowOf = (entry: Entries.BodyEntry) => | |
| 84 | + Sections.dialogFileRow( | |
| 85 | + kit, | |
| 86 | + { | |
| 87 | + key: Sections.fileKeyOf(entry), | |
| 88 | + path: entry.path, | |
| 89 | + displayPath: entry.displayPath, | |
| 90 | + added: entry.added, | |
| 91 | + removed: entry.removed, | |
| 92 | + note: noteOf(entry), | |
| 93 | + isSelected: entry === selected, | |
| 94 | + }, | |
| 95 | + () => kit.actions.selectFile(entry.path), | |
| 96 | + ) | |
| 97 | + | |
| 98 | + function moreOf(way: 'up' | 'down', count: number): RenderElement { | |
| 99 | + const arrow = way === 'up' ? '\u2191' : '\u2193' | |
| 100 | + const words = Layout.plural(count, 'file').replace(' ', ' more ') | |
| 101 | + | |
| 102 | + return Sections.dimNote(kit, ` ${arrow} ${words}`) | |
| 103 | + } | |
| 104 | + | |
| 105 | + const pagedEdgeOf = (way: 'up' | 'down', count: number) => | |
| 106 | + count > 0 ? moreOf(way, count) : blankEdge | |
| 107 | + | |
| 108 | + const edgeOf = (way: 'up' | 'down', count: number) => | |
| 109 | + isPaged ? pagedEdgeOf(way, count) : null | |
| 110 | + | |
| 111 | + const detailOf = (entry: Entries.BodyEntry): RenderElement[] => [ | |
| 112 | + Detail.detailView( | |
| 113 | + kit, | |
| 114 | + Entries.detailModelOf(entry, model), | |
| 115 | + Detail.BODY_BUDGET, | |
| 116 | + ).element, | |
| 117 | + ] | |
| 118 | + | |
| 119 | + const rows = Sections.present([ | |
| 120 | + edgeOf('up', start), | |
| 121 | + ...shown.map(rowOf), | |
| 122 | + edgeOf('down', below), | |
| 123 | + ]) | |
| 124 | + | |
| 125 | + const listing = isDetail && picked ? detailOf(picked) : rows | |
| 126 | + const body = message === null ? listing : [Sections.dimNote(kit, message)] | |
| 127 | + | |
| 128 | + const hint = isDetail | |
| 129 | + ? '\u2191/\u2193 to scroll \u00b7 Esc to back' | |
| 130 | + : '\u2191/\u2193 to select \u00b7 Enter to view \u00b7 Esc to close' | |
| 131 | + | |
| 132 | + return ( | |
| 133 | + <Box flexDirection="column"> | |
| 134 | + {Sections.present([ | |
| 135 | + Sections.titleRow(kit, PaneState.dialogTitleOf(model)), | |
| 136 | + <Box height={1} />, | |
| 137 | + subtitle, | |
| 138 | + Sections.sourcePicker(kit, model), | |
| 139 | + <Box flexDirection="column" marginTop={1} marginBottom={1}> | |
| 140 | + {body} | |
| 141 | + </Box>, | |
| 142 | + Sections.dimNote(kit, hint), | |
| 143 | + ])} | |
| 144 | + </Box> | |
| 145 | + ) | |
| 146 | +} |
| @@ -1,0 +1,46 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import PaneState from '../../pane-state' | |
| 3 | +import { dialogEntriesOf } from './dialog-entries-of' | |
| 4 | +import { dialogWindowOf } from './dialog-window-of' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * The rows the dialog's content takes as dialogPane draws it, for the | |
| 8 | + * `rows` an inline open asks for: title, blank, count, picker, body, hints. | |
| 9 | + * | |
| 10 | + * The body is the message line, the list's window with its `more files` | |
| 11 | + * rows, or the file's name, rule and hunk lines (their wraps not counted: | |
| 12 | + * the layout caps a long file anyway and its arrows scroll it). | |
| 13 | + * | |
| 14 | + * @param model the pane's state | |
| 15 | + * @returns the rows wanted | |
| 16 | + */ | |
| 17 | +export function dialogRowsOf(model: PaneState.PaneModel): number { | |
| 18 | + const entries = dialogEntriesOf(model) | |
| 19 | + | |
| 20 | + const hasCount = | |
| 21 | + PaneState.pickedTurnOf(model) !== undefined || model.data !== null | |
| 22 | + | |
| 23 | + const isPaged = entries.length > Limits.MAX_VISIBLE_FILES | |
| 24 | + const picked = entries.find(entry => entry.path === model.selectedPath) | |
| 25 | + const isDetail = model.dialogView === 'detail' && picked !== undefined | |
| 26 | + | |
| 27 | + const start = dialogWindowOf( | |
| 28 | + model, | |
| 29 | + entries.map(entry => entry.path), | |
| 30 | + ) | |
| 31 | + | |
| 32 | + const lines = (picked?.body?.hunks ?? []).reduce( | |
| 33 | + (sum, hunk) => sum + 1 + hunk.lines.length, | |
| 34 | + 0, | |
| 35 | + ) | |
| 36 | + | |
| 37 | + const listed = Math.min(entries.length - start, Limits.MAX_VISIBLE_FILES) | |
| 38 | + | |
| 39 | + const isEmpty = entries.length === 0 | |
| 40 | + const listRows = isEmpty ? 1 : listed + (isPaged ? 2 : 0) | |
| 41 | + const bodyRows = isDetail ? 2 + Math.max(1, lines) : listRows | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + 2 + Number(hasCount) + Number(model.turns.length > 0) + 2 + bodyRows + 1 | |
| 45 | + ) | |
| 46 | +} |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import type PaneState from '../../../pane-state' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The first of the MAX_VISIBLE_FILES rows the dialog lists (DiffFileList's | |
| 6 | + * window): centred on the selected file, the first when none is, clamped. | |
| 7 | + * | |
| 8 | + * @param model the pane's state: the selection | |
| 9 | + * @param paths the listed files' paths, in order | |
| 10 | + * @returns the first listed row's index | |
| 11 | + */ | |
| 12 | +export const dialogWindowOf = ( | |
| 13 | + model: Pick<PaneState.PaneModel, 'selectedPath'>, | |
| 14 | + paths: readonly string[], | |
| 15 | +): number => | |
| 16 | + Math.max( | |
| 17 | + 0, | |
| 18 | + Math.min( | |
| 19 | + paths.length - Limits.MAX_VISIBLE_FILES, | |
| 20 | + Math.max(0, paths.indexOf(model.selectedPath ?? '')) - | |
| 21 | + Math.floor(Limits.MAX_VISIBLE_FILES / 2), | |
| 22 | + ), | |
| 23 | + ) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './dialog-window-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +export * from './dialog-entries-of' | |
| 2 | +export * from './dialog-focus-of.js' | |
| 3 | +export * from './dialog-pane.jsx' | |
| 4 | +export * from './dialog-rows-of.js' | |
| 5 | +export * from './dialog-window-of' | |
| 6 | +export * from './list-body-of' | |
| 7 | +export * from './message-pane-of' | |
| 8 | + | |
| 9 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './list-body-of.js' | |
| 2 | +export * from './list-place' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,43 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import type { ListPlace } from './list-place' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The list's body as ReplDiffSidebarBody decides it: the empty state's | |
| 6 | + * hint, the too-many state, the only-noise state, or the session's rows. | |
| 7 | + * | |
| 8 | + * Strings are dim lines; rows are files to draw. | |
| 9 | + * | |
| 10 | + * @param place the fetch, its groups, the counts, the empty state | |
| 11 | + * @returns the lines and rows, in order | |
| 12 | + */ | |
| 13 | +export function listBodyOf( | |
| 14 | + place: ListPlace, | |
| 15 | +): readonly (string | Git.FileStat)[] { | |
| 16 | + const { data, partition, totals, empty } = place | |
| 17 | + | |
| 18 | + const hint = empty?.hint ?? null | |
| 19 | + | |
| 20 | + if (empty) { | |
| 21 | + return hint === null ? [] : [hint] | |
| 22 | + } | |
| 23 | + | |
| 24 | + const isTooMany = | |
| 25 | + data !== null && data.files.length === 0 && totals.filesCount > 0 | |
| 26 | + | |
| 27 | + if (isTooMany) { | |
| 28 | + return [ | |
| 29 | + 'Too many changed files to show diff', | |
| 30 | + 'Per-file diff is skipped above 500 files', | |
| 31 | + ] | |
| 32 | + } | |
| 33 | + | |
| 34 | + const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0 | |
| 35 | + | |
| 36 | + return isOnlyNoise | |
| 37 | + ? [ | |
| 38 | + 'Only tests and generated files changed', | |
| 39 | + 'Tests and generated files are hidden · click "show" above to view ' + | |
| 40 | + 'them', | |
| 41 | + ] | |
| 42 | + : partition.shown | |
| 43 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './list-place.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type Git from '../../../../git' | |
| 2 | +import type PaneState from '../../../../pane-state' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * What decides the list's body: the fetch, its grouped rows, the header's | |
| 6 | + * counts, and the empty state when there is one. | |
| 7 | + */ | |
| 8 | +export type ListPlace = { | |
| 9 | + data: Git.DiffData | null | |
| 10 | + partition: PaneState.Partition | |
| 11 | + totals: PaneState.HeaderTotals | |
| 12 | + empty: PaneState.EmptyState | null | |
| 13 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The rows the pre-session line takes at the pane's foot: a blank row, the | |
| 3 | + * line, and a blank row under it. | |
| 4 | + */ | |
| 5 | +export const FOOT_ROWS = 3 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './foot-rows.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './foot-rows' | |
| 2 | +export * from './message-pane' | |
| 3 | +export * from './message-pane-of.jsx' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './message-pane.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type { RenderElement } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What stands around a message in the pane: the header block, the message, | |
| 5 | + * the pickers, the pre-session line, and what is listed under it. | |
| 6 | + */ | |
| 7 | +export type MessagePane = { | |
| 8 | + top: readonly RenderElement[] | |
| 9 | + message: readonly string[] | |
| 10 | + controls: RenderElement | null | |
| 11 | + earlier: RenderElement | null | |
| 12 | + rest: readonly RenderElement[] | |
| 13 | +} |
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.