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: unit tests for diff, sec-default and telemetry, typed against the plugin declarations
| @@ -1,0 +1,40 @@ | ||
| 1 | +name: "Mod tests" | |
| 2 | + | |
| 3 | +on: | |
| 4 | + pull_request: | |
| 5 | + paths: | |
| 6 | + - "mods/**" | |
| 7 | + - ".github/workflows/mod-tests.yml" | |
| 8 | + | |
| 9 | +permissions: | |
| 10 | + contents: read | |
| 11 | + | |
| 12 | +jobs: | |
| 13 | + test: | |
| 14 | + runs-on: ubuntu-latest | |
| 15 | + steps: | |
| 16 | + - name: Checkout repository | |
| 17 | + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 | |
| 18 | + with: | |
| 19 | + persist-credentials: false | |
| 20 | + | |
| 21 | + - name: Install Claude Code and TypeScript | |
| 22 | + run: npm install -g @anthropic-ai/claude-code typescript@5.9.3 | |
| 23 | + | |
| 24 | + - name: Typecheck each mod's hooks and tests | |
| 25 | + run: tsc -p mods/tsconfig.json | |
| 26 | + | |
| 27 | + - name: Run each mod's tests | |
| 28 | + run: | | |
| 29 | + if ! claude plugin test --help 2>/dev/null | grep -q '^Usage: claude plugin test'; then | |
| 30 | + echo "claude $(claude --version) has no 'plugin test' yet; skipping the test step" | |
| 31 | + exit 0 | |
| 32 | + fi | |
| 33 | + status=0 | |
| 34 | + for mod in mods/*/; do | |
| 35 | + [ -d "$mod/tests" ] || continue | |
| 36 | + claude plugin test "$mod" || status=1 | |
| 37 | + done | |
| 38 | + exit $status | |
| 39 | + env: | |
| 40 | + CLAUDE_CODE_ENABLE_FUNCTION_HOOKS: "1" |
| @@ -14,10 +14,102 @@ | ||
| 14 | 14 | Each folder is a complete plugin: `.claude-plugin/plugin.json`, a |
| 15 | 15 | `hooks/hooks.json` naming the module, and TypeScript under `hooks/` typed |
| 16 | 16 | against the declarations `/plugin-types` writes (`import type … from |
| 17 | -'claude-code'`). To read one running from source: | |
| 17 | +'claude-code'`), kept here in `types/`. To read one running from source: | |
| 18 | 18 | |
| 19 | 19 | claude --plugin-dir mods/diff |
| 20 | 20 | |
| 21 | +## Testing | |
| 22 | + | |
| 23 | +A mod's tests are in its `tests/` folder, and run with | |
| 24 | + | |
| 25 | + claude plugin test mods/diff | |
| 26 | + | |
| 27 | +A test gets the engine's own `$` and a plugin's `on`. Each call on `$` is one | |
| 28 | +the engine makes, through every hook of the mod loaded as it ships. The hooks | |
| 29 | +the test registers with `on` sit beneath the mod, where the rest of the world | |
| 30 | +would be, and nothing is beneath them: a call they leave unanswered throws, | |
| 31 | +naming its event. | |
| 32 | + | |
| 33 | +A test file is named for what it covers under `hooks/` (`register.test.ts` | |
| 34 | +beside `hooks/register.ts`, `git.test.ts` beside `hooks/git/`), and holds its | |
| 35 | +imports, the tier the mod loads in, and one `describe` titled with that name; | |
| 36 | +what several tests share sits under `tests/fixtures/`, one export a file. | |
| 37 | + | |
| 38 | +```ts | |
| 39 | +import { describe, expect, mock, test, tier } from 'claude-code/testing' | |
| 40 | + | |
| 41 | +tier('builtin') | |
| 42 | + | |
| 43 | +describe('register', () => { | |
| 44 | + test('outside a git repository /diff says so, opens nothing', async ($, on) => { | |
| 45 | + const opened: string[] = [] | |
| 46 | + mock.clock(on) | |
| 47 | + on('session.start', ($, e) => ({ cwd: e.cwd })) | |
| 48 | + on('command.register', ($, e) => ({ value: { command: e.name } })) | |
| 49 | + on('process.run', () => ({ | |
| 50 | + value: { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' }, | |
| 51 | + })) | |
| 52 | + on('ui.open', ($, e, next) => { | |
| 53 | + opened.push(e.id) | |
| 54 | + return next(e) | |
| 55 | + }) | |
| 56 | + | |
| 57 | + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) | |
| 58 | + const { text } = await $.command.run({ | |
| 59 | + command: 'diff', | |
| 60 | + args: '', | |
| 61 | + origin: { kind: 'composer' }, | |
| 62 | + }) | |
| 63 | + | |
| 64 | + expect(text).toContain("isn't in a git repository") | |
| 65 | + expect(opened).toEqual([]) | |
| 66 | + }) | |
| 67 | +}) | |
| 68 | +``` | |
| 69 | + | |
| 70 | +The kit's `mock` answers the world beneath the mod from memory, noun by noun, | |
| 71 | +each member a plain function over `on` registering hooks where the test calls | |
| 72 | +it: `mock.env(on, variables)` for `$.env`, `mock.store(on, entries)` for | |
| 73 | +`$.store`, and `mock.clock(on)` for `$.clock`, whose `advance(ms)` resolves | |
| 74 | +every wait the mod asked for (`$.clock.sleep`, `after`, `every`) as the clock | |
| 75 | +crosses it. To see what a dispatch does before it answers, start it | |
| 76 | +unawaited, `await clock.settle()`, then look: the clock stays where it was. | |
| 77 | +`$.ui.press({ plugin, key })` presses a `Button` the test rendered, as a | |
| 78 | +click in the terminal does. | |
| 79 | + | |
| 80 | +`tsc -p mods/tsconfig.json` typechecks every mod's hooks and tests against | |
| 81 | +`types/` and each mod's own `types/` contract. | |
| 82 | + | |
| 83 | +## Composing mods: noun contracts | |
| 84 | + | |
| 85 | +A mod that adds a noun to `$` in the `engine.create` fold owns that noun's | |
| 86 | +types, and keeps them in one place: its `types/index.d.ts`, a declaration | |
| 87 | +file with no imports that exports the types the noun is made of, each named | |
| 88 | +for the noun, and declares the noun on `EngineInterface` in `claude-code` | |
| 89 | +(`telemetry/types/index.d.ts` exports `Telemetry`, `TelemetryLogEntry`, | |
| 90 | +`TelemetryMarkEntry` and the rest, and declares `$.telemetry`). | |
| 91 | + | |
| 92 | +- The contract is the only declaration of the noun. The mod's own hooks | |
| 93 | + import its types from the folder (`import type { Telemetry } from | |
| 94 | + '../types'`), and the value its `engine.create` hook returns is checked | |
| 95 | + against `EngineInterface['telemetry']`, so the implementation cannot drift | |
| 96 | + from what callers read. | |
| 97 | +- A mod that calls another's noun reads the same file and never copies it: | |
| 98 | + `mods/tsconfig.json` includes `*/types/**/*.d.ts`, so `$.telemetry.log(…)` | |
| 99 | + in `diff` types against `telemetry`'s contract as it stands, and a helper | |
| 100 | + that must name one of its types imports it from that folder by path. | |
| 101 | +- A test of a mod that calls another's noun seats a provider for it, an inline | |
| 102 | + plugin whose `engine.create` hook adds the noun, and answers the calls the | |
| 103 | + way it answers the engine's: `on('telemetry.log', ($, e) => ({ value: | |
| 104 | + undefined }))` runs above the provider's own method, its `e` typed by the | |
| 105 | + contract. With no provider loaded the `$` build refuses the hook, naming the | |
| 106 | + noun nobody provides. | |
| 107 | + | |
| 108 | +A plugin outside this repository that depends on a mod's noun points its | |
| 109 | +tsconfig `include` at that mod's `types/` folder for now; once the engine | |
| 110 | +writes the contracts of the plugins a session has installed, `/plugin-types` | |
| 111 | +will put them beside `claude-code.d.ts` and the include goes away. | |
| 112 | + | |
| 21 | 113 | Early access: hooks modules load only where function hooks are enabled, and |
| 22 | 114 | the API these mods are written against may change between releases without |
| 23 | 115 | notice. They are not listed in this repository's marketplace; the copies that |
| @@ -7,7 +7,10 @@ | ||
| 7 | 7 | repository's HEAD so a commit or checkout made elsewhere shows too. The |
| 8 | 8 | first successful edit of a session opens the pane by itself where the |
| 9 | 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). A file's | |
| 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 | |
| 11 | 14 | ask button arms that file: its hunks ride the next prompt as context, once. |
| 12 | 15 | |
| 13 | 16 | The pane compares the working tree against HEAD, split at the session's |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The line an ask ends with when its diff was cut to fit the prompt. | |
| 3 | + */ | |
| 4 | +export const ASK_CUT_NOTE = | |
| 5 | + '(The rest of this diff was cut: it did not fit in the prompt.)' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './ask-cut-note.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,36 @@ | ||
| 1 | +import { ASK_CUT_NOTE } from './ask-cut-note' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * An armed ask's text as it fits in the room a prompt's context has left: | |
| 5 | + * whole when it fits, else its first lines and ASK_CUT_NOTE. | |
| 6 | + * | |
| 7 | + * @param text the armed ask's text | |
| 8 | + * @param room the characters the context has left | |
| 9 | + * @returns the text to attach, or undefined when not even its first line fits | |
| 10 | + */ | |
| 11 | +export function fittedAskTextOf( | |
| 12 | + text: string, | |
| 13 | + room: number, | |
| 14 | +): string | undefined { | |
| 15 | + if (text.length <= room) { | |
| 16 | + return text | |
| 17 | + } | |
| 18 | + | |
| 19 | + const kept: string[] = [] | |
| 20 | + let used = ASK_CUT_NOTE.length | |
| 21 | + | |
| 22 | + for (const line of text.split('\n')) { | |
| 23 | + const cost = line.length + 1 | |
| 24 | + | |
| 25 | + if (used + cost > room) { | |
| 26 | + break | |
| 27 | + } | |
| 28 | + | |
| 29 | + kept.push(line) | |
| 30 | + used += cost | |
| 31 | + } | |
| 32 | + | |
| 33 | + const hasBody = kept.length > 1 | |
| 34 | + | |
| 35 | + return hasBody ? `${kept.join('\n')}\n${ASK_CUT_NOTE}` : undefined | |
| 36 | +} |
| @@ -1,4 +1,6 @@ | ||
| 1 | 1 | export * from './armed-ask' |
| 2 | 2 | export * from './armed-ask-of.js' |
| 3 | +export * from './ask-cut-note' | |
| 4 | +export * from './fitted-ask-text-of.js' | |
| 3 | 5 | |
| 4 | 6 | export * as default from '.' |
| @@ -2,10 +2,11 @@ | ||
| 2 | 2 | import type { Backend, BackendHost, BackendProbe } from './types' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * The backend holding the session's directory: the first of `probes` that | |
| 6 | - * answers one, else git when a git working tree holds it, else none. The | |
| 7 | - * probes go first so a probe may claim a working copy by its own rule | |
| 8 | - * (the nearer of two markers, say); a probe answers null wherever git | |
| 5 | + * The backend holding the session's directory: another system's when its | |
| 6 | + * probe answers first, else git when a git working tree holds it, else none. | |
| 7 | + * | |
| 8 | + * Other systems are asked first so one may claim a working copy by its own | |
| 9 | + * rule (the nearer of two markers, say); each answers null wherever git | |
| 9 | 10 | * should govern. |
| 10 | 11 | * |
| 11 | 12 | * @param host the bound host's runner and probes |
| @@ -1,15 +1,12 @@ | ||
| 1 | 1 | import type { BackendProbe } from './types' |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | - * Where a host adds backends to the plugin: probes for version-control systems | |
| 5 | - * other than git, consulted in insertion order before git (backendOf); a | |
| 6 | - * probe answers null wherever git should govern. Empty as shipped, so the | |
| 7 | - * plugin reads git alone. A host | |
| 8 | - * that compiles the module in may add a probe once, before the plugin | |
| 9 | - * registers; the module runs natively, so that probe is what | |
| 10 | - * `session.start` sees. The module contract offers no other channel: the | |
| 11 | - * options `register` receives are the manifest's userConfig, and the | |
| 12 | - * shipped scan requires `register` itself to be a plain exported function. | |
| 4 | + * Where a host adds backends: probes for version-control systems other than | |
| 5 | + * git, asked in insertion order before git (backendOf). Empty as shipped. | |
| 6 | + * | |
| 7 | + * A host compiling the module in adds a probe once, before the plugin | |
| 8 | + * registers: the module runs natively, and there is no other channel (the | |
| 9 | + * options are the manifest's userConfig; the scan wants a plain function). | |
| 13 | 10 | */ |
| 14 | 11 | // module-state-allow: module-evaluation code wiring — a compiling host adds its probe functions (code references, not runtime input) once before registration and never removes or rebinds them; tests pass probes to backendOf directly; a per-Host copy would hold the same functions |
| 15 | 12 | export const INSTALLED_BACKEND_PROBES = new Set<BackendProbe>() |
| @@ -2,9 +2,8 @@ | ||
| 2 | 2 | import type { BackendWords } from '../backend-words' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * The version-control system behind the pane, pinned once per session: | |
| 6 | - * where its working tree is, which comparisons it offers, how it words | |
| 7 | - * them, and the three reads the pane makes of it. | |
| 5 | + * The version-control system behind the pane, pinned once per session: its | |
| 6 | + * working tree, what it compares, how it words them, and the pane's reads. | |
| 8 | 7 | * |
| 9 | 8 | * Git is the plugin's own backend; a host may install probes for others, |
| 10 | 9 | * consulted where no git working tree holds the directory (backendOf). |
| @@ -16,7 +15,7 @@ | ||
| 16 | 15 | repository: Pick<Git.Repository, 'toplevel'> |
| 17 | 16 | |
| 18 | 17 | /** |
| 19 | - * The base modes the picker offers, in picker order. | |
| 18 | + * What the picker offers to compare against, in its order. | |
| 20 | 19 | */ |
| 21 | 20 | baseModes: readonly Git.BaseMode[] |
| 22 | 21 |
| @@ -1,19 +1,20 @@ | ||
| 1 | 1 | import type Git from '../../../git' |
| 2 | -import type Host from '../../../host' | |
| 2 | +import type { Host } from '../../../host' | |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * What pinning a backend takes from the bound host: the raw process | |
| 6 | - * runner (each backend names its own program, argv lead, environment and | |
| 7 | - * timeout), the file probes, the engine's clock, the session's start as it | |
| 8 | - * stands at each fetch (a later `session.start` moves it), and the | |
| 9 | - * branch-base listener. | |
| 5 | + * What pinning a backend takes from the bound host: its raw process runner, | |
| 6 | + * file probes, clock, session start and branch-base listener. | |
| 7 | + * | |
| 8 | + * Each backend names its own program, argv lead, environment and timeout | |
| 9 | + * to the runner; the session's start is read as it stands at each fetch | |
| 10 | + * (a later `session.start` moves it). | |
| 10 | 11 | */ |
| 11 | -export type BackendHost = Pick<Host.Host, 'run' | 'readFile'> & | |
| 12 | +export type BackendHost = Pick<Host, 'run' | 'readFile'> & | |
| 12 | 13 | Pick<Git.GitDeps, 'mtimeOf' | 'entryKindsOf' | 'onBranchBase'> & { |
| 13 | 14 | /** |
| 14 | 15 | * The engine's clock, in milliseconds (`$.clock.now`). |
| 15 | 16 | */ |
| 16 | - nowMs: () => number | |
| 17 | + nowMs: () => Promise<number> | |
| 17 | 18 | |
| 18 | 19 | /** |
| 19 | 20 | * When the current session began, read at each fetch. |
| @@ -3,7 +3,8 @@ | ||
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Probes the session's directory for one backend's working tree: the |
| 6 | - * backend when it holds the directory, null when it does not or when git | |
| 7 | - * should govern it instead (Git.gitBackendOf is git's). | |
| 6 | + * backend when it holds the directory, else null. | |
| 7 | + * | |
| 8 | + * Null too where git should govern it instead (Git.gitBackendOf is git's). | |
| 8 | 9 | */ |
| 9 | 10 | export type BackendProbe = (host: BackendHost) => Promise<Backend | null> |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | - * The words the pane draws that name the backend: what uncommitted mode | |
| 3 | - * compares against, the command whose failure the empty state names, the | |
| 4 | - * tool that lists untracked files, and the note under an untracked row. | |
| 2 | + * The words the pane draws that name the backend. | |
| 3 | + * | |
| 4 | + * What uncommitted mode compares against, the command whose failure the | |
| 5 | + * empty state names, the tool that lists untracked files, and the note | |
| 6 | + * under an untracked row. | |
| 5 | 7 | */ |
| 6 | 8 | export type BackendWords = { |
| 7 | 9 | /** |
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | export function isGeneratedFile(path: string) { |
| 16 | 16 | const name = baseNameOf(path).toLowerCase() |
| 17 | 17 | const rooted = rootedPathOf(path) |
| 18 | + | |
| 18 | 19 | const hasGeneratedSuffix = GENERATED_SUFFIXES.some(suffix => |
| 19 | 20 | name.endsWith(suffix), |
| 20 | 21 | ) |
| @@ -1,0 +1,33 @@ | ||
| 1 | +import Probes from '../probes' | |
| 2 | +import Tiers from '../tiers' | |
| 3 | +import type Types from '../types' | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One fetch of the session's pinned repository, as the built-in panel | |
| 7 | + * fetches; every git child names it so paths stay root-relative. | |
| 8 | + * | |
| 9 | + * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot | |
| 10 | + * answer, it is unavailable and the caller keeps what it had. | |
| 11 | + * | |
| 12 | + * @param deps the host: pinned git, the repository, the file probes | |
| 13 | + * @param mode what to compare against | |
| 14 | + * @returns the outcome | |
| 15 | + */ | |
| 16 | +export async function fetchDiff( | |
| 17 | + deps: Types.GitDeps, | |
| 18 | + mode: Types.BaseMode, | |
| 19 | +): Promise<Types.FetchOutcome> { | |
| 20 | + const isBusy = await Probes.isTransient( | |
| 21 | + deps.entryKindsOf, | |
| 22 | + deps.repository.gitDir, | |
| 23 | + ) | |
| 24 | + | |
| 25 | + if (isBusy) { | |
| 26 | + return { kind: 'unavailable' } | |
| 27 | + } | |
| 28 | + | |
| 29 | + const context = Tiers.fetchContextOf(deps, mode) | |
| 30 | + const isBranch = mode === 'branch' | |
| 31 | + | |
| 32 | + return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context) | |
| 33 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './fetch-diff.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,33 +1,0 @@ | ||
| 1 | -import Probes from './probes' | |
| 2 | -import Tiers from './tiers' | |
| 3 | -import type Types from './types' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * One fetch of the session's pinned repository, as the built-in panel | |
| 7 | - * fetches; every git child names it so paths stay root-relative. | |
| 8 | - * | |
| 9 | - * Mid-merge, mid-rebase, mid-cherry-pick or mid-revert, or when git cannot | |
| 10 | - * answer, it is unavailable and the caller keeps what it had. | |
| 11 | - * | |
| 12 | - * @param deps the host: pinned git, the repository, the file probes | |
| 13 | - * @param mode what to compare against | |
| 14 | - * @returns the outcome | |
| 15 | - */ | |
| 16 | -export async function fetchDiff( | |
| 17 | - deps: Types.GitDeps, | |
| 18 | - mode: Types.BaseMode, | |
| 19 | -): Promise<Types.FetchOutcome> { | |
| 20 | - const isBusy = await Probes.isTransient( | |
| 21 | - deps.entryKindsOf, | |
| 22 | - deps.repository.gitDir, | |
| 23 | - ) | |
| 24 | - | |
| 25 | - if (isBusy) { | |
| 26 | - return { kind: 'unavailable' } | |
| 27 | - } | |
| 28 | - | |
| 29 | - const context = Tiers.fetchContextOf(deps, mode) | |
| 30 | - const isBranch = mode === 'branch' | |
| 31 | - | |
| 32 | - return isBranch ? Tiers.branchTier(context) : Tiers.workingTreeTier(context) | |
| 33 | -} |
| @@ -1,0 +1,41 @@ | ||
| 1 | +import Argv from '../argv' | |
| 2 | +import { EMPTY_FILE_HUNKS } from '../empty-file-hunks' | |
| 3 | +import Parse from '../parse' | |
| 4 | +import type Types from '../types' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * One file's hunks against the base its row was read against, so body and | |
| 8 | + * counts agree, by literal pathspec, as the built-in panel reads hunks. | |
| 9 | + * | |
| 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. | |
| 13 | + * | |
| 14 | + * @param run runs git against the pinned repository | |
| 15 | + * @param data the fetch the row belongs to | |
| 16 | + * @param file the row | |
| 17 | + * @returns the parsed body, or null when git failed | |
| 18 | + */ | |
| 19 | +export async function fetchFileHunks( | |
| 20 | + run: Types.GitRun, | |
| 21 | + data: Types.DiffData, | |
| 22 | + file: Types.FileStat, | |
| 23 | +): Promise<Types.FileHunks | null> { | |
| 24 | + const hasNoBody = | |
| 25 | + file.isUntracked || file.isBinary || data.stalePaths.includes(file.path) | |
| 26 | + | |
| 27 | + if (hasNoBody) { | |
| 28 | + return EMPTY_FILE_HUNKS | |
| 29 | + } | |
| 30 | + | |
| 31 | + const { exitCode, stdout } = await run([ | |
| 32 | + '--literal-pathspecs', | |
| 33 | + ...Argv.DIFF_LEADING_ARGS, | |
| 34 | + data.baseRef, | |
| 35 | + '--', | |
| 36 | + ...(file.renamedFrom === null ? [] : [file.renamedFrom]), | |
| 37 | + file.path, | |
| 38 | + ]) | |
| 39 | + | |
| 40 | + return exitCode === 0 ? Parse.parseFileDiff(stdout) : null | |
| 41 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './fetch-file-hunks.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,41 +1,0 @@ | ||
| 1 | -import Argv from './argv' | |
| 2 | -import { EMPTY_FILE_HUNKS } from './empty-file-hunks' | |
| 3 | -import GitParse from './parse' | |
| 4 | -import type Types from './types' | |
| 5 | - | |
| 6 | -/** | |
| 7 | - * One file's hunks against the base its row was read against, so body and | |
| 8 | - * counts agree, by literal pathspec, as the built-in panel reads hunks. | |
| 9 | - * | |
| 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. | |
| 13 | - * | |
| 14 | - * @param run runs git against the pinned repository | |
| 15 | - * @param data the fetch the row belongs to | |
| 16 | - * @param file the row | |
| 17 | - * @returns the parsed body, or null when git failed | |
| 18 | - */ | |
| 19 | -export async function fetchFileHunks( | |
| 20 | - run: Types.GitRun, | |
| 21 | - data: Types.DiffData, | |
| 22 | - file: Types.FileStat, | |
| 23 | -): Promise<Types.FileHunks | null> { | |
| 24 | - const hasNoBody = | |
| 25 | - file.isUntracked || file.isBinary || data.stalePaths.includes(file.path) | |
| 26 | - | |
| 27 | - if (hasNoBody) { | |
| 28 | - return EMPTY_FILE_HUNKS | |
| 29 | - } | |
| 30 | - | |
| 31 | - const { exitCode, stdout } = await run([ | |
| 32 | - '--literal-pathspecs', | |
| 33 | - ...Argv.DIFF_LEADING_ARGS, | |
| 34 | - data.baseRef, | |
| 35 | - '--', | |
| 36 | - ...(file.renamedFrom === null ? [] : [file.renamedFrom]), | |
| 37 | - file.path, | |
| 38 | - ]) | |
| 39 | - | |
| 40 | - return exitCode === 0 ? GitParse.parseFileDiff(stdout) : null | |
| 41 | -} |
| @@ -2,8 +2,8 @@ | ||
| 2 | 2 | import Limits from '../limits' |
| 3 | 3 | import Argv from './argv' |
| 4 | 4 | import { FAILED_RUN } from './failed-run' |
| 5 | -import { fetchDiff } from './fetch-diff.js' | |
| 6 | -import { fetchFileHunks } from './fetch-file-hunks.js' | |
| 5 | +import { fetchDiff } from './fetch-diff' | |
| 6 | +import { fetchFileHunks } from './fetch-file-hunks' | |
| 7 | 7 | import { GIT_BASE_MODES } from './git-base-modes' |
| 8 | 8 | import { GIT_WORDS } from './git-words' |
| 9 | 9 | import Probes from './probes' |
| @@ -11,10 +11,11 @@ | ||
| 11 | 11 | |
| 12 | 12 | /** |
| 13 | 13 | * The git backend over the session's directory: the repository read with |
| 14 | - * one `rev-parse` in that directory, then every child pinned to it | |
| 15 | - * (`--git-dir`, `--work-tree`, cwd the top) under the C locale and the | |
| 16 | - * fetch's timeout (gitDiff.ts execPinnedGit). | |
| 14 | + * one `rev-parse` in that directory, then every child pinned to it. | |
| 17 | 15 | * |
| 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). | |
| 18 | + * | |
| 18 | 19 | * @param host the bound host's runner and probes |
| 19 | 20 | * @returns the backend, or null outside a git working tree |
| 20 | 21 | */ |
| @@ -45,6 +46,7 @@ | ||
| 45 | 46 | } |
| 46 | 47 | |
| 47 | 48 | const run = runOf(repository) |
| 49 | + | |
| 48 | 50 | const depsOf = (): Types.GitDeps => ({ |
| 49 | 51 | run, |
| 50 | 52 | repository, |
| @@ -1,8 +1,10 @@ | ||
| 1 | 1 | import type Backend from '../../backend' |
| 2 | 2 | |
| 3 | 3 | /** |
| 4 | - * The pane's git words: compared with HEAD, read by `git diff`, untracked | |
| 5 | - * files listed by git and staged with `git add ':/…'` (DiffDetailView's). | |
| 4 | + * The pane's git words, unquoted as DiffDetailView words them. | |
| 5 | + * | |
| 6 | + * Compared with HEAD, read by `git diff`, untracked files listed by git and | |
| 7 | + * staged with `git add :/…`. | |
| 6 | 8 | */ |
| 7 | 9 | export const GIT_WORDS: Backend.BackendWords = Object.freeze({ |
| 8 | 10 | base: 'HEAD', |
| @@ -11,7 +13,7 @@ | ||
| 11 | 13 | untrackedNoteOf: (path: string, isPasteable: boolean) => [ |
| 12 | 14 | 'New file not yet staged.', |
| 13 | 15 | isPasteable |
| 14 | - ? `Run \`git add ':/${path}'\` to see line counts.` | |
| 16 | + ? `Run \`git add :/${path}\` to see line counts.` | |
| 15 | 17 | : 'Stage it with git add to see line counts.', |
| 16 | 18 | ], |
| 17 | 19 | }) |
| @@ -2,8 +2,8 @@ | ||
| 2 | 2 | export * from './cut-hunks' |
| 3 | 3 | export * from './empty-file-hunks' |
| 4 | 4 | export * from './failed-run' |
| 5 | -export * from './fetch-diff.js' | |
| 6 | -export * from './fetch-file-hunks.js' | |
| 5 | +export * from './fetch-diff' | |
| 6 | +export * from './fetch-file-hunks' | |
| 7 | 7 | export * from './git-backend-of.js' |
| 8 | 8 | export * from './git-base-modes' |
| 9 | 9 | export * from './git-words' |
| @@ -3,12 +3,11 @@ | ||
| 3 | 3 | import { MOST_UTF8_BYTES } from './most-utf8-bytes' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | - * Whether a listing arrived whole: every record terminated (NUL for git's | |
| 7 | - * `-z` listings, a newline for line records) and the text short of the | |
| 8 | - * host's output cap, so no record was cut or lost. | |
| 6 | + * Whether a listing arrived whole, so no record was cut or lost. | |
| 9 | 7 | * |
| 10 | - * A cut inside a multi-byte character costs the decoded text up to | |
| 11 | - * MOST_UTF8_BYTES, hence the margin under the cap. | |
| 8 | + * Every record is terminated (NUL for `-z` listings, a newline for line | |
| 9 | + * records) and the text is short of the host's output cap by MOST_UTF8_BYTES, | |
| 10 | + * what a cut inside a multi-byte character can cost the decoded text. | |
| 12 | 11 | * |
| 13 | 12 | * @param stdout the listing as `$.process.run` returned it |
| 14 | 13 | * @param terminator what ends every record; NUL when absent |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | const rows = stdout.split('\n') |
| 25 | + | |
| 25 | 26 | const headerRows = rows.flatMap((row, at) => |
| 26 | 27 | HUNK_HEADER.test(row) ? [at] : [], |
| 27 | 28 | ) |
| @@ -26,11 +26,13 @@ | ||
| 26 | 26 | const [addedField, removedField, ...pathFields] = (records[at] ?? '').split( |
| 27 | 27 | '\t', |
| 28 | 28 | ) |
| 29 | + | |
| 29 | 30 | const inline = pathFields.join('\t') |
| 30 | 31 | const isRename = pathFields.length === 1 && inline === '' |
| 31 | 32 | const renamedFrom = isRename ? (records[at + 1] ?? null) : null |
| 32 | 33 | const path = isRename ? (records[at + 2] ?? '') : inline |
| 33 | 34 | at += isRename ? RENAME_RECORDS : 1 |
| 35 | + | |
| 34 | 36 | const isRow = |
| 35 | 37 | addedField !== undefined && removedField !== undefined && path !== '' |
| 36 | 38 | |
| @@ -41,6 +43,7 @@ | ||
| 41 | 43 | const isBinary = addedField === '-' || removedField === '-' |
| 42 | 44 | const added = isBinary ? 0 : Number(addedField) || 0 |
| 43 | 45 | const removed = isBinary ? 0 : Number(removedField) || 0 |
| 46 | + | |
| 44 | 47 | stats.filesCount += 1 |
| 45 | 48 | stats.linesAdded += added |
| 46 | 49 | stats.linesRemoved += removed |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | currentBranchOf(run), |
| 24 | 24 | defaultBranchOf(run), |
| 25 | 25 | ]) |
| 26 | + | |
| 26 | 27 | const isUnrelated = branch === 'HEAD' || defaultBranch.startsWith('-') |
| 27 | 28 | |
| 28 | 29 | if (isUnrelated) { |
| @@ -37,6 +38,7 @@ | ||
| 37 | 38 | run, |
| 38 | 39 | defaultBranch, |
| 39 | 40 | ) |
| 41 | + | |
| 40 | 42 | const [first, second] = candidates |
| 41 | 43 | |
| 42 | 44 | if (first === undefined) { |
| @@ -57,6 +59,7 @@ | ||
| 57 | 59 | second, |
| 58 | 60 | ]) |
| 59 | 61 | ).exitCode === 0 |
| 62 | + | |
| 60 | 63 | const mergeBase = isLocalBaseNewer ? second : first |
| 61 | 64 | const head = await run([Argv.NO_OPTIONAL_LOCKS, 'rev-parse', 'HEAD']) |
| 62 | 65 |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | '--abbrev-ref', |
| 15 | 15 | 'HEAD', |
| 16 | 16 | ]) |
| 17 | + | |
| 17 | 18 | const name = stdout.trim() |
| 18 | 19 | |
| 19 | 20 | return exitCode === 0 && name !== '' ? name : 'HEAD' |
| @@ -9,18 +9,19 @@ | ||
| 9 | 9 | * path is never listed, descended or stat'ed: session work (the engine |
| 10 | 10 | * lstats); a path the listing budget never reached is the caller's to place. |
| 11 | 11 | * |
| 12 | - * @param probe the fetch's stamp probe over the working tree's top | |
| 13 | - * @param sessionStartMs when the session began | |
| 12 | + * @param context the fetch's stamp probe over the working tree's top, and | |
| 13 | + * when the session began | |
| 14 | 14 | * @param paths repository-relative paths, `/`-separated as git prints them |
| 15 | 15 | * @returns each path's Dating |
| 16 | 16 | */ |
| 17 | 17 | export async function datingsOf( |
| 18 | - probe: Types.StampProbe, | |
| 19 | - sessionStartMs: number, | |
| 18 | + context: Types.DatingContext, | |
| 20 | 19 | paths: readonly string[], |
| 21 | 20 | ): Promise<ReadonlyMap<string, Types.Dating>> { |
| 21 | + const { sessionStartMs } = context.deps | |
| 22 | + | |
| 22 | 23 | const stamps: readonly Types.Stamp[] = await Promise.all( |
| 23 | - paths.map(path => stampIfFile(probe, path)), | |
| 24 | + paths.map(path => stampIfFile(context.stamps, path)), | |
| 24 | 25 | ) |
| 25 | 26 | |
| 26 | 27 | function datingOf(stamp: Types.Stamp): Types.Dating { |
| @@ -22,12 +22,14 @@ | ||
| 22 | 22 | `refs/remotes/origin/${branch}`, |
| 23 | 23 | ]) |
| 24 | 24 | ).exitCode === 0 |
| 25 | + | |
| 25 | 26 | const symref = await run([ |
| 26 | 27 | Argv.NO_OPTIONAL_LOCKS, |
| 27 | 28 | 'symbolic-ref', |
| 28 | 29 | '--short', |
| 29 | 30 | 'refs/remotes/origin/HEAD', |
| 30 | 31 | ]) |
| 32 | + | |
| 31 | 33 | const named = |
| 32 | 34 | symref.exitCode === 0 ? symref.stdout.trim().replace(/^origin\//, '') : '' |
| 33 | 35 |
| @@ -26,7 +26,9 @@ | ||
| 26 | 26 | repository.gitDir, |
| 27 | 27 | Limits.MAX_LISTED_DIRECTORIES, |
| 28 | 28 | ) |
| 29 | + | |
| 29 | 30 | const isShared = repository.commonDir === repository.gitDir |
| 31 | + | |
| 30 | 32 | const common = isShared |
| 31 | 33 | ? own |
| 32 | 34 | : stampProbeOf(deps, repository.commonDir, Limits.MAX_LISTED_DIRECTORIES) |
| @@ -22,7 +22,7 @@ | ||
| 22 | 22 | export * from './transient-state-files' |
| 23 | 23 | export * from './unstaged-numstat.js' |
| 24 | 24 | export * from './untracked-files' |
| 25 | -export * from './untracked-rows-of' | |
| 26 | 25 | export * from './untracked-place' |
| 26 | +export * from './untracked-rows-of' | |
| 27 | 27 | |
| 28 | 28 | export * as default from '.' |
| @@ -33,6 +33,7 @@ | ||
| 33 | 33 | |
| 34 | 34 | const listed = |
| 35 | 35 | held ?? entryKindsOf(directory === '' ? root : `${root}/${directory}`) |
| 36 | + | |
| 36 | 37 | listings.set(directory, listed) |
| 37 | 38 | |
| 38 | 39 | return listed |
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | '--quiet', |
| 28 | 28 | ref, |
| 29 | 29 | ]) |
| 30 | + | |
| 30 | 31 | const isPresentOrUnknown = exitCode === 0 || exitCode === -1 |
| 31 | 32 | |
| 32 | 33 | if (isPresentOrUnknown) { |
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | unstaged: Types.NumstatResult, |
| 18 | 18 | ): Types.NumstatResult { |
| 19 | 19 | const edits = new Map(unstaged.files.map(file => [file.path, file])) |
| 20 | + | |
| 20 | 21 | const files = staged.files.map(file => { |
| 21 | 22 | const edit = edits.get(file.path) |
| 22 | 23 | |
| @@ -25,16 +26,19 @@ | ||
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | const isBinary = file.isBinary || edit.isBinary |
| 29 | + | |
| 28 | 30 | const added = isBinary |
| 29 | 31 | ? 0 |
| 30 | 32 | : Math.max(0, file.added + edit.added - edit.removed) |
| 31 | 33 | |
| 32 | 34 | return { ...file, added, removed: 0, isBinary } |
| 33 | 35 | }) |
| 36 | + | |
| 34 | 37 | const linesAdded = |
| 35 | 38 | staged.stats.linesAdded + |
| 36 | 39 | files.reduce((sum, file) => sum + file.added, 0) - |
| 37 | 40 | staged.files.reduce((sum, file) => sum + file.added, 0) |
| 41 | + | |
| 38 | 42 | const stats = { ...staged.stats, linesAdded } |
| 39 | 43 | |
| 40 | 44 | return { stats, files } |
| @@ -25,8 +25,10 @@ | ||
| 25 | 25 | '--git-dir', |
| 26 | 26 | '--git-common-dir', |
| 27 | 27 | ]) |
| 28 | + | |
| 28 | 29 | const lines = stdout.split('\n').filter(line => line !== '') |
| 29 | 30 | const [toplevel = '', gitDir = '', commonDir = ''] = lines |
| 31 | + | |
| 30 | 32 | const isResolved = |
| 31 | 33 | exitCode === 0 && |
| 32 | 34 | lines.length === REPOSITORY_LINES && |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import Limits from '../../limits' |
| 2 | 2 | import Argv from '../argv' |
| 3 | -import GitParse from '../parse' | |
| 3 | +import Parse from '../parse' | |
| 4 | 4 | import type Types from '../types' |
| 5 | 5 | |
| 6 | 6 | /** |
| @@ -21,8 +21,10 @@ | ||
| 21 | 21 | const run = context.run |
| 22 | 22 | |
| 23 | 23 | const shortstat = await run([...Argv.DIFF_LEADING_ARGS, base, '--shortstat']) |
| 24 | + | |
| 24 | 25 | const totals = |
| 25 | - shortstat.exitCode === 0 ? GitParse.parseShortstat(shortstat.stdout) : null | |
| 26 | + shortstat.exitCode === 0 ? Parse.parseShortstat(shortstat.stdout) : null | |
| 27 | + | |
| 26 | 28 | const isPastDetails = |
| 27 | 29 | totals !== null && totals.filesCount > Limits.MAX_FILES_FOR_DETAILS |
| 28 | 30 | |
| @@ -36,7 +38,8 @@ | ||
| 36 | 38 | '--numstat', |
| 37 | 39 | '-z', |
| 38 | 40 | ]) |
| 39 | - const isRead = GitParse.isWholeAnswer(numstat) | |
| 40 | 41 | |
| 41 | - return isRead ? GitParse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null | |
| 42 | + const isRead = Parse.isWholeAnswer(numstat) | |
| 43 | + | |
| 44 | + return isRead ? Parse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null | |
| 42 | 45 | } |
| @@ -17,8 +17,7 @@ | ||
| 17 | 17 | files: readonly Types.FileStat[], |
| 18 | 18 | ): Promise<readonly Types.FileStat[]> { |
| 19 | 19 | const datings = await datingsOf( |
| 20 | - context.stamps, | |
| 21 | - context.deps.sessionStartMs, | |
| 20 | + context, | |
| 22 | 21 | files.map(file => file.path), |
| 23 | 22 | ) |
| 24 | 23 |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import Argv from '../argv' |
| 2 | -import GitParse from '../parse' | |
| 2 | +import Parse from '../parse' | |
| 3 | 3 | import type Types from '../types' |
| 4 | 4 | |
| 5 | 5 | /** |
| @@ -15,9 +15,9 @@ | ||
| 15 | 15 | run: Types.GitRun, |
| 16 | 16 | ): Promise<Types.NumstatResult | null> { |
| 17 | 17 | const numstat = await run([...Argv.DIFF_LEADING_ARGS, '--numstat', '-z']) |
| 18 | - const isRead = GitParse.isWholeAnswer(numstat) | |
| 18 | + const isRead = Parse.isWholeAnswer(numstat) | |
| 19 | 19 | |
| 20 | 20 | return isRead |
| 21 | - ? GitParse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY) | |
| 21 | + ? Parse.parseNumstat(numstat.stdout, Number.POSITIVE_INFINITY) | |
| 22 | 22 | : null |
| 23 | 23 | } |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import Argv from '../../argv' |
| 2 | -import GitParse from '../../parse' | |
| 2 | +import Parse from '../../parse' | |
| 3 | 3 | import type Types from '../../types' |
| 4 | 4 | import type { UntrackedPlace } from '../untracked-place' |
| 5 | 5 | import { untrackedRowsOf } from '../untracked-rows-of' |
| @@ -30,11 +30,11 @@ | ||
| 30 | 30 | '--full-name', |
| 31 | 31 | ]) |
| 32 | 32 | |
| 33 | - if (!GitParse.isWholeAnswer(listing)) { | |
| 34 | - return null | |
| 35 | - } | |
| 36 | - | |
| 37 | - const paths = listing.stdout.split('\0').filter(path => path !== '') | |
| 38 | - | |
| 39 | - return untrackedRowsOf(context, paths, place) | |
| 33 | + return Parse.isWholeAnswer(listing) | |
| 34 | + ? untrackedRowsOf( | |
| 35 | + context, | |
| 36 | + listing.stdout.split('\0').filter(path => path !== ''), | |
| 37 | + place, | |
| 38 | + ) | |
| 39 | + : null | |
| 40 | 40 | } |
| @@ -22,16 +22,15 @@ | ||
| 22 | 22 | place: UntrackedPlace, |
| 23 | 23 | ): Promise<readonly Types.FileStat[]> { |
| 24 | 24 | const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) |
| 25 | - const datings = await datingsOf( | |
| 26 | - context.stamps, | |
| 27 | - context.deps.sessionStartMs, | |
| 28 | - probedPaths, | |
| 29 | - ) | |
| 25 | + const datings = await datingsOf(context, probedPaths) | |
| 26 | + | |
| 30 | 27 | const probed = probedPaths.map(path => ({ |
| 31 | 28 | path, |
| 32 | 29 | isPreSession: datings.get(path) !== 'session', |
| 33 | 30 | })) |
| 31 | + | |
| 34 | 32 | const isWithPreSession = place.scope === 'with-pre-session' |
| 33 | + | |
| 35 | 34 | const earlier = isWithPreSession |
| 36 | 35 | ? [ |
| 37 | 36 | ...probed.filter(file => file.isPreSession), |
| @@ -40,6 +39,7 @@ | ||
| 40 | 39 | .map(path => ({ path, isPreSession: true })), |
| 41 | 40 | ] |
| 42 | 41 | : [] |
| 42 | + | |
| 43 | 43 | const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] |
| 44 | 44 | |
| 45 | 45 | return ordered.slice(0, place.slots).map(file => ({ |
| @@ -31,6 +31,7 @@ | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | const isOverlaid = staged.files.length > 0 |
| 34 | + | |
| 34 | 35 | const unstaged = isOverlaid |
| 35 | 36 | ? await Probes.unstagedNumstat(run) |
| 36 | 37 | : EMPTY_NUMSTAT |
| @@ -3,10 +3,10 @@ | ||
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * A tier's tracked rows and totals with a listed set of untracked rows |
| 6 | - * after them (gitDiff.ts mergeSessionUntrackedFiles), or flagged withheld | |
| 7 | - * when the lister gave no whole listing. | |
| 6 | + * after them, or flagged withheld when the lister gave no whole listing. | |
| 8 | 7 | * |
| 9 | - * A path a tracked row already holds keeps that row and counts once. | |
| 8 | + * As the built-in's mergeSessionUntrackedFiles merges them, a path a | |
| 9 | + * tracked row already holds keeps that row and counts once. | |
| 10 | 10 | * |
| 11 | 11 | * @param result the tier's tracked rows and totals |
| 12 | 12 | * @param untracked the untracked rows, or null when their listing was withheld |
| @@ -22,15 +22,13 @@ | ||
| 22 | 22 | result: Types.NumstatResult, |
| 23 | 23 | scope: Types.UntrackedScope, |
| 24 | 24 | ): Promise<Types.MergedResult> { |
| 25 | - if (isPastDetails(result)) { | |
| 26 | - return mergedResultOf(result, { isUntrackedWithheld: false }) | |
| 27 | - } | |
| 28 | - | |
| 29 | - return mergedWithUntracked( | |
| 30 | - result, | |
| 31 | - await Probes.untrackedFiles(context, { | |
| 32 | - slots: Limits.MAX_FILES - result.files.length, | |
| 33 | - scope, | |
| 34 | - }), | |
| 35 | - ) | |
| 25 | + return isPastDetails(result) | |
| 26 | + ? mergedResultOf(result, { isUntrackedWithheld: false }) | |
| 27 | + : mergedWithUntracked( | |
| 28 | + result, | |
| 29 | + await Probes.untrackedFiles(context, { | |
| 30 | + slots: Limits.MAX_FILES - result.files.length, | |
| 31 | + scope, | |
| 32 | + }), | |
| 33 | + ) | |
| 36 | 34 | } |
| @@ -26,9 +26,11 @@ | ||
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | const isSession = context.mode === 'session' |
| 29 | + | |
| 29 | 30 | const files = isSession |
| 30 | 31 | ? await Probes.tagPreSession(context, workingTree.files) |
| 31 | 32 | : workingTree.files |
| 33 | + | |
| 32 | 34 | const data = diffDataOf( |
| 33 | 35 | context, |
| 34 | 36 | await withUntracked( |
| @@ -6,8 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | /** |
| 8 | 8 | * One settled fetch: the rows, the totals, what they compare, and what a |
| 9 | - * file's hunks must be read against so both agree, over the working tree's | |
| 10 | - * top (every backend has one; git's other directories stay its own). | |
| 9 | + * file's hunks are read against so both agree, over the working tree's top. | |
| 11 | 10 | * |
| 12 | 11 | * `isUnborn`: a repository before its first commit (rows are the staged |
| 13 | 12 | * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * What a fetched diff compares: the working tree against its checked-out |
| 3 | - * commit (`base` names it as the pane should: `HEAD`, or a short sha where | |
| 4 | - * the backend has no such word), or against the merge-base with the default | |
| 5 | - * branch (`baseRef` the ref git was handed). | |
| 3 | + * commit, or against the merge-base with the default branch. | |
| 4 | + * | |
| 5 | + * `base` names the commit as the pane should (`HEAD`, or a short sha where | |
| 6 | + * the backend has no such word); `baseRef` is the ref git was handed. | |
| 6 | 7 | */ |
| 7 | 8 | export type DiffSource = |
| 8 | 9 | | { kind: 'working-tree'; base: string } |
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | /** |
| 20 | 20 | * `$.clock.now`. |
| 21 | 21 | */ |
| 22 | - now: () => number | |
| 22 | + now: () => Promise<number> | |
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | 25 | * `$.clock.after`. |
| @@ -105,6 +105,11 @@ | ||
| 105 | 105 | registerCommand: (spec: CommandSpec) => Promise<unknown> |
| 106 | 106 | |
| 107 | 107 | /** |
| 108 | + * Which session the pane-shown row is latched to, as `$.session.id` says. | |
| 109 | + */ | |
| 110 | + sessionId: () => Promise<string> | |
| 111 | + | |
| 112 | + /** | |
| 108 | 113 | * `$.telemetry.mark`; rejects where the telemetry built-in is absent. |
| 109 | 114 | */ |
| 110 | 115 | mark: EngineInterface['telemetry']['mark'] |
| @@ -21,6 +21,5 @@ | ||
| 21 | 21 | export * from './tools' |
| 22 | 22 | export * from './turns' |
| 23 | 23 | export * from './views' |
| 24 | -export * from './word-diff' | |
| 25 | 24 | |
| 26 | 25 | export * as default from '.' |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './auto-open-min-columns.js' |
| 2 | 2 | export * from './open-min-columns.js' |
| 3 | +export * from './pane-right-pad-columns.js' | |
| 3 | 4 | export * from './widest-bucket-columns.js' |
| 4 | 5 | |
| 5 | 6 | export * as default from '.' |
| @@ -1,5 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | - * The terminal width from which the first edit opens the pane when the person | |
| 3 | - * kept it open before (the built-in sidebar's own minimum). | |
| 2 | + * The terminal width from which `/diff` opens the pane (the built-in | |
| 3 | + * sidebar's own minimum). | |
| 4 | + * | |
| 5 | + * The first edit opens it from this width too, when the person kept it | |
| 6 | + * open before. | |
| 4 | 7 | */ |
| 5 | 8 | export const OPEN_MIN_COLUMNS = 110 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The blank column the docked pane keeps at its right edge, as the built-in | |
| 3 | + * sidebar's body does (its `paddingX`): rows wrap one column short of it. | |
| 4 | + */ | |
| 5 | +export const PANE_RIGHT_PAD_COLUMNS = 1 |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The most of a child's stdout `$.process.run` hands back, in bytes | |
| 3 | + * (process-host OUTPUT_LIMIT); a listing that long was cut, silently. | |
| 4 | + */ | |
| 5 | +export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024 |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './host-output-cap-bytes.js' | |
| 2 | +export * from './max-diff-bytes.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * A file whose unified diff text is longer than this is a "large file": its | |
| 3 | + * hunks are not parsed (the built-in panel's size cap). | |
| 4 | + */ | |
| 5 | +export const MAX_DIFF_BYTES = 1_000_000 |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The most of a child's stdout `$.process.run` hands back, in bytes | |
| 3 | - * (process-host OUTPUT_LIMIT); a listing that long was cut, silently. | |
| 4 | - */ | |
| 5 | -export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024 |
| @@ -1,9 +1,10 @@ | ||
| 1 | -export * from './host-output-cap-bytes.js' | |
| 2 | -export * from './max-diff-bytes.js' | |
| 1 | +export * from './bytes' | |
| 3 | 2 | export * from './max-files.js' |
| 4 | 3 | export * from './max-files-for-details.js' |
| 5 | 4 | export * from './max-lines-per-file.js' |
| 6 | 5 | export * from './max-listed-directories.js' |
| 7 | 6 | export * from './max-untracked-probes.js' |
| 7 | +export * from './pane-top-pad-rows.js' | |
| 8 | +export * from './prompt-context-max-chars.js' | |
| 8 | 9 | |
| 9 | 10 | export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * A file whose unified diff text is longer than this is a "large file": its | |
| 3 | - * hunks are not parsed (the built-in panel's size cap). | |
| 4 | - */ | |
| 5 | -export const MAX_DIFF_BYTES = 1_000_000 |
| @@ -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 | + * The most characters a prompt's context carries, its entries together; the | |
| 3 | + * engine refuses a context past it, so an ask is fitted to what is left. | |
| 4 | + */ | |
| 5 | +export const PROMPT_CONTEXT_MAX_CHARS = 32_000 |
| @@ -1,4 +1,4 @@ | ||
| 1 | 1 | /** |
| 2 | - * The pane header's text. | |
| 2 | + * The pane's title: its tab while another pane is open too. | |
| 3 | 3 | */ |
| 4 | 4 | export const PANE_TITLE = 'Diff' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s answer when git did not say whether this is a repository. | |
| 3 | + * | |
| 4 | + * It timed out: the directory is unknown, not outside one. A git that | |
| 5 | + * cannot start has answered, and gets the not-a-repository text. | |
| 6 | + */ | |
| 7 | +export const GIT_UNANSWERED_TEXT = | |
| 8 | + "The diff panel couldn't read git state — git didn't answer; run /diff " + | |
| 9 | + 'again' |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | export * from './builtin-holds-pattern.js' |
| 2 | +export * from './git-unanswered-text.js' | |
| 2 | 3 | export * from './not-in-repository-text.js' |
| 3 | 4 | export * from './register-failed-text-of.js' |
| 4 | -export * from './untracked-withheld-text.js' | |
| 5 | +export * from './resize-terminal-text.js' | |
| 6 | +export * from './untracked-withheld-text-of.js' | |
| 5 | 7 | |
| 6 | 8 | export * as default from '.' |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | - * `/diff`'s answer outside a git repository, worded as the built-in words it. | |
| 2 | + * `/diff`'s answer outside a git repository, worded as the built-in words it | |
| 3 | + * but for its apostrophe, which is ASCII. | |
| 3 | 4 | */ |
| 4 | 5 | export const NOT_IN_REPOSITORY_TEXT = |
| 5 | - 'The diff panel shows git changes — the current directory isn’t in a ' + | |
| 6 | + "The diff panel shows git changes — the current directory isn't in a " + | |
| 6 | 7 | 'git repository' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s answer on a terminal narrower than the built-in panel shows | |
| 3 | + * on, worded as the built-in words it. | |
| 4 | + * | |
| 5 | + * Also the pane's one line where the surface seats it inline (no room | |
| 6 | + * beside the transcript). | |
| 7 | + */ | |
| 8 | +export const RESIZE_TERMINAL_TEXT = | |
| 9 | + 'Resize your terminal to at least 110 columns to show the diff panel' |
| @@ -1,0 +1,14 @@ | ||
| 1 | +import type Backend from '../../backend' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The pane's word for a fetch whose untracked listing the backend did not | |
| 5 | + * give whole: the tracked rows stand, the new files are not counted. | |
| 6 | + * | |
| 7 | + * @param words the backend's words, naming the program that lists them | |
| 8 | + * @returns the note | |
| 9 | + */ | |
| 10 | +export const untrackedWithheldTextOf = ( | |
| 11 | + words: Pick<Backend.BackendWords, 'lister'>, | |
| 12 | +) => | |
| 13 | + `Untracked files unavailable (${words.lister} could not list them); ` + | |
| 14 | + 'not counted' |
| @@ -1,9 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The pane's word for a fetch whose untracked listing the backend did not | |
| 3 | - * give whole: the tracked rows stand, the new files are not counted. | |
| 4 | - * | |
| 5 | - * @param lister the program that lists untracked files (BackendWords) | |
| 6 | - * @returns the note | |
| 7 | - */ | |
| 8 | -export const untrackedWithheldTextOf = (lister: string) => | |
| 9 | - `Untracked files unavailable (${lister} could not list them); not counted` |
| @@ -1,6 +1,5 @@ | ||
| 1 | -import type Backend from '../backend' | |
| 2 | -import type Git from '../git' | |
| 3 | 1 | import { modeLabelOf } from './mode-label-of' |
| 2 | +import type { PaneModel } from './pane-model' | |
| 4 | 3 | |
| 5 | 4 | /** |
| 6 | 5 | * The dim line under the header naming what the diff compares against |
| @@ -10,18 +9,17 @@ | ||
| 10 | 9 | * with an ellipsis while its fetch is pending; on an unborn HEAD with |
| 11 | 10 | * rows, what the rows are instead. |
| 12 | 11 | * |
| 13 | - * @param requested the mode the person picked | |
| 14 | - * @param data the last good fetch | |
| 12 | + * @param model the mode the person picked, the last good fetch, and the | |
| 13 | + * backend's words | |
| 15 | 14 | * @param filesCount the header's session file count |
| 16 | - * @param words the backend's words | |
| 17 | 15 | * @returns the line, or null |
| 18 | 16 | */ |
| 19 | 17 | export function baseLabelOf( |
| 20 | - requested: Git.BaseMode, | |
| 21 | - data: Git.DiffData | null, | |
| 18 | + model: Pick<PaneModel, 'requestedMode' | 'data' | 'words'>, | |
| 22 | 19 | filesCount: number, |
| 23 | - words: Pick<Backend.BackendWords, 'base'>, | |
| 24 | 20 | ): string | null { |
| 21 | + const { requestedMode, data } = model | |
| 22 | + | |
| 25 | 23 | if (!data) { |
| 26 | 24 | return null |
| 27 | 25 | } |
| @@ -32,15 +30,15 @@ | ||
| 32 | 30 | return hasRows ? 'no commits yet — showing staged and new files' : null |
| 33 | 31 | } |
| 34 | 32 | |
| 35 | - const isPending = requested !== data.mode | |
| 36 | - const isSettledSession = !isPending && requested === 'session' | |
| 33 | + const isPending = requestedMode !== data.mode | |
| 34 | + const isSettledSession = !isPending && requestedMode === 'session' | |
| 37 | 35 | |
| 38 | 36 | if (isSettledSession) { |
| 39 | 37 | return null |
| 40 | 38 | } |
| 41 | 39 | |
| 42 | 40 | const phase = isPending ? 'pending' : 'settled' |
| 43 | - const label = modeLabelOf(requested, data.source, phase, words) | |
| 41 | + const label = modeLabelOf(model, data.source, phase) | |
| 44 | 42 | |
| 45 | 43 | return isPending ? `${label}…` : label |
| 46 | 44 | } |
| @@ -24,7 +24,9 @@ | ||
| 24 | 24 | if (!data) { |
| 25 | 25 | return { |
| 26 | 26 | headline: 'Diff unavailable', |
| 27 | - hint: `Couldn't read the ${words.diffCommand} — it will retry on the next change`, | |
| 27 | + hint: | |
| 28 | + `Couldn't read the ${words.diffCommand} — it will retry on the ` + | |
| 29 | + 'next change', | |
| 28 | 30 | } |
| 29 | 31 | } |
| 30 | 32 | |
| @@ -35,7 +37,7 @@ | ||
| 35 | 37 | if (data.isUntrackedWithheld) { |
| 36 | 38 | return { |
| 37 | 39 | headline: 'No tracked changes', |
| 38 | - hint: Names.untrackedWithheldTextOf(words.lister), | |
| 40 | + hint: Names.untrackedWithheldTextOf(words), | |
| 39 | 41 | } |
| 40 | 42 | } |
| 41 | 43 |
| @@ -1,27 +1,26 @@ | ||
| 1 | -import type Backend from '../../backend' | |
| 2 | 1 | import type Git from '../../git' |
| 2 | +import type { PaneModel } from '../pane-model' | |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * A requested mode's name for the base line; branch mode names its base | |
| 6 | - * branch from the data, or says what stands in while none is known. A | |
| 7 | - * working-tree diff names its own base (`HEAD`, a short sha); anything | |
| 8 | - * else falls back to the backend's word for it. | |
| 5 | + * A requested mode's name for the base line. | |
| 9 | 6 | * |
| 10 | - * @param requested the mode the person picked | |
| 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. | |
| 10 | + * | |
| 11 | + * @param model the mode the person picked, and the backend's words | |
| 11 | 12 | * @param source what the data on screen compares |
| 12 | 13 | * @param phase `pending` while the requested mode's fetch has not landed |
| 13 | - * @param words the backend's words | |
| 14 | 14 | * @returns the label without its pending ellipsis |
| 15 | 15 | */ |
| 16 | 16 | export function modeLabelOf( |
| 17 | - requested: Git.BaseMode, | |
| 17 | + model: Pick<PaneModel, 'requestedMode' | 'words'>, | |
| 18 | 18 | source: Git.DiffSource, |
| 19 | 19 | phase: 'pending' | 'settled', |
| 20 | - words: Pick<Backend.BackendWords, 'base'>, | |
| 21 | 20 | ) { |
| 22 | - const base = source.kind === 'working-tree' ? source.base : words.base | |
| 21 | + const base = source.kind === 'working-tree' ? source.base : model.words.base | |
| 23 | 22 | |
| 24 | - switch (requested) { | |
| 23 | + switch (model.requestedMode) { | |
| 25 | 24 | case 'session': |
| 26 | 25 | return 'this session' |
| 27 | 26 | case 'uncommitted': |
| @@ -6,9 +6,11 @@ | ||
| 6 | 6 | import type { Source } from '../source' |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | - * Everything one drawing of the pane reads: the last good fetch, what the | |
| 10 | - * person picked, the selected file's body, the transcript's turns and todos, | |
| 11 | - * and the pinned backend's words and base modes (git's until one is pinned). | |
| 9 | + * Everything one drawing of the pane reads. | |
| 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). | |
| 12 | 14 | */ |
| 13 | 15 | export type PaneModel = { |
| 14 | 16 | words: Backend.BackendWords |
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | - * What the plugin knows of its pane when `/diff` runs: whether it opened | |
| 3 | - * it and never closed it, and whether it drew when a redraw was asked. | |
| 2 | + * What the plugin knows of its pane when `/diff` runs. | |
| 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 | 6 | */ |
| 5 | 7 | export type PaneBelief = { |
| 6 | 8 | isBelievedOpen: boolean |
| 7 | 9 | wasDrawnWhenProbed: boolean |
| 10 | + columns: number | null | |
| 8 | 11 | } |
| @@ -1,14 +1,24 @@ | ||
| 1 | +import Limits from '../limits' | |
| 1 | 2 | import type { PaneBelief } from './pane-belief' |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * What `/diff` does, the one place its toggle is decided: close only when |
| 5 | 6 | * the plugin opened the pane and it still draws; otherwise open, focused. |
| 6 | 7 | * |
| 7 | - * The person's `[x]` raises nothing a plugin sees, so a pane believed | |
| 8 | - * open is probed with a redraw first. | |
| 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 | 11 | * |
| 10 | - * @param pane the plugin's belief and the probe's answer | |
| 11 | - * @returns `close` or `open` | |
| 12 | + * @param pane the plugin's belief, the probe's answer and the width | |
| 13 | + * @returns `close`, `open` or `too-narrow` | |
| 12 | 14 | */ |
| 13 | -export const paneToggleOf = (pane: PaneBelief): 'open' | 'close' => | |
| 14 | - pane.isBelievedOpen && pane.wasDrawnWhenProbed ? 'close' : 'open' | |
| 15 | +export function paneToggleOf( | |
| 16 | + pane: PaneBelief, | |
| 17 | +): 'open' | 'close' | 'too-narrow' { | |
| 18 | + const isClosing = pane.isBelievedOpen && pane.wasDrawnWhenProbed | |
| 19 | + | |
| 20 | + const isTooNarrow = | |
| 21 | + pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS | |
| 22 | + | |
| 23 | + return isClosing ? 'close' : isTooNarrow ? 'too-narrow' : 'open' | |
| 24 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What branch mode resolved its base to, as a base-resolve mark names it, | |
| 3 | + * the built-in panel's own: the merge base, HEAD itself, or nothing. | |
| 4 | + */ | |
| 5 | +export const BASE_OUTCOMES = ['merge-base', 'head-is-base', 'none'] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './base-outcomes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -2,12 +2,15 @@ | ||
| 2 | 2 | * The feature names the plugin marks, kept as the built-in panel's so the |
| 3 | 3 | * feature surface keeps one series across the swap. |
| 4 | 4 | * |
| 5 | - * `read` per fetch round, `tabSwitch` per open or close, `baseSwitch` per | |
| 6 | - * base pick, `baseResolve` once a session. | |
| 5 | + * `read` per fetch round, `tabSwitch` per `/diff` open or close and per | |
| 6 | + * close by the pane's own control, `baseSwitch` per base pick, `baseResolve` | |
| 7 | + * on the first resolve and the first failure, `selectionAttach` per diff | |
| 8 | + * asked. | |
| 7 | 9 | */ |
| 8 | 10 | export const FEATURES = { |
| 9 | 11 | read: 'repl_diff_read', |
| 10 | 12 | tabSwitch: 'repl_tab_switch', |
| 11 | 13 | baseSwitch: 'repl_diff_base_switch', |
| 12 | 14 | baseResolve: 'diff_base_resolve', |
| 15 | + selectionAttach: 'diff_selection_attach', | |
| 13 | 16 | } as const |
| @@ -1,9 +1,11 @@ | ||
| 1 | +export * from './base-outcomes' | |
| 1 | 2 | export * from './features' |
| 2 | 3 | export * from './mark-outcome' |
| 3 | 4 | export * from './recorder' |
| 4 | 5 | export * from './recorder-of.js' |
| 5 | 6 | export * from './safely' |
| 6 | 7 | export * from './shown-triggers' |
| 8 | +export * from './tabs' | |
| 7 | 9 | export * from './width-bucket-of' |
| 8 | 10 | export * from './width-buckets' |
| 9 | 11 |
| @@ -1,5 +1,12 @@ | ||
| 1 | +import type { Host } from '../../host' | |
| 2 | + | |
| 1 | 3 | /** |
| 2 | - * How a marked feature went: fine, or degraded for a reason (the pane never | |
| 3 | - * marks `bad`: a failed fetch keeps the last good diff on screen). | |
| 4 | + * How a marked feature went: fine, or degraded for a reason, with the | |
| 5 | + * properties the built-in panel's mark carries beside it. | |
| 6 | + * | |
| 7 | + * The pane never marks `bad`: a failed fetch keeps the last good diff on | |
| 8 | + * screen. | |
| 4 | 9 | */ |
| 5 | -export type MarkOutcome = { kind: 'ok' } | { kind: 'sad'; reason: string } | |
| 10 | +export type MarkOutcome = ({ kind: 'ok' } | { kind: 'sad'; reason: string }) & { | |
| 11 | + props?: Parameters<Host['mark']>[0]['props'] | |
| 12 | +} |
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | mark: (feature: string, outcome: MarkOutcome) => void |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | - * `tengu_plugin_diff_pane_shown`, once a session. | |
| 16 | + * The built-in panel's `tengu_repl_diff_panel_shown` row, once a session. | |
| 17 | 17 | */ |
| 18 | 18 | shown: ( |
| 19 | 19 | trigger: (typeof SHOWN_TRIGGERS)[number], |
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | ) => void |
| 22 | 22 | |
| 23 | 23 | /** |
| 24 | - * `tengu_plugin_diff_ask_attach`: a file's diff rode the next prompt. | |
| 24 | + * The built-in's `diff_selection_attach` feature mark: a file's diff rode | |
| 25 | + * the next prompt. | |
| 25 | 26 | */ |
| 26 | - asked: (lines: number) => void | |
| 27 | + asked: () => void | |
| 27 | 28 | } |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Host } from '../host' |
| 2 | +import { FEATURES } from './features' | |
| 2 | 3 | import type { Recorder } from './recorder' |
| 3 | 4 | import { safely } from './safely' |
| 4 | 5 | import { SHOWN_TRIGGERS } from './shown-triggers' |
| @@ -6,8 +7,10 @@ | ||
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * The plugin's Recorder over the host: each row goes through the telemetry |
| 9 | - * built-in's `$.telemetry`; a missing noun or a refused row is dropped. | |
| 10 | + * built-in's `$.telemetry`, named and shaped as the built-in panel's own. | |
| 10 | 11 | * |
| 12 | + * A missing noun or a refused row is dropped. | |
| 13 | + * | |
| 11 | 14 | * @param host the engine as session.start bound it |
| 12 | 15 | * @returns the recorder |
| 13 | 16 | */ |
| @@ -16,13 +19,13 @@ | ||
| 16 | 19 | shown: (trigger, bucket) => |
| 17 | 20 | safely(() => |
| 18 | 21 | host.log({ |
| 19 | - event: 'diff_pane_shown', | |
| 22 | + event: 'tengu_repl_diff_panel_shown', | |
| 20 | 23 | props: { |
| 21 | 24 | trigger: { value: trigger, of: SHOWN_TRIGGERS }, |
| 22 | - width_bucket: { value: bucket, of: WIDTH_BUCKETS }, | |
| 25 | + terminal_width_bucket: { value: bucket, of: WIDTH_BUCKETS }, | |
| 23 | 26 | }, |
| 24 | 27 | }), |
| 25 | 28 | ), |
| 26 | - asked: lines => | |
| 27 | - safely(() => host.log({ event: 'diff_ask_attach', props: { lines } })), | |
| 29 | + asked: () => | |
| 30 | + safely(() => host.mark({ feature: FEATURES.selectionAttach, kind: 'ok' })), | |
| 28 | 31 | }) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './tabs.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Where a tab-switch mark says the person went, named as the built-in | |
| 3 | + * panel names it: the diff, or the conversation it closes back to. | |
| 4 | + */ | |
| 5 | +export const TABS = ['diff', 'convo'] as const |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | - * The terminal-width buckets `diff_pane_shown` reports, the built-in's | |
| 3 | - * `tengu_repl_diff_panel_shown` buckets. | |
| 2 | + * The terminal-width buckets the pane-shown row reports, the built-in | |
| 3 | + * panel's own. | |
| 4 | 4 | */ |
| 5 | 5 | export const WIDTH_BUCKETS = [ |
| 6 | 6 | 'under_110', |
| @@ -21,24 +21,24 @@ | ||
| 21 | 21 | * Registers the diff pane: `/diff` once the built-in stands down, the |
| 22 | 22 | * pane's drawing and refresh, its opening on Claude's first edit, the ask. |
| 23 | 23 | * |
| 24 | - * `session.start` binds the engine once, registers the command (refused | |
| 25 | - * because the built-in holds it, the plugin does nothing) and pins the | |
| 26 | - * backend holding the session's directory (probed again on `/diff` and | |
| 27 | - * first edit until found): what an installed backend probe finds, else git | |
| 28 | - * (backendOf). | |
| 24 | + * `session.start` registers `/diff`, binds the host every pinned backend | |
| 25 | + * reads through (currentOf: the latest start's, the engine only before | |
| 26 | + * bind()), and pins the backend (backendOf), asked again on `/diff`. | |
| 29 | 27 | * |
| 30 | 28 | * @param on the engine's registrar |
| 31 | 29 | */ |
| 32 | 30 | export function register(on: On) { |
| 33 | 31 | let host: Host | null = null |
| 34 | 32 | let backend: Backend.Backend | null = null |
| 33 | + let probing: Promise<boolean> | null = null | |
| 35 | 34 | let sessionStartMs = 0 |
| 36 | 35 | let isPaneOpen = false |
| 37 | 36 | let hasAutoOpened = false |
| 38 | 37 | let columns: number | null = null |
| 39 | - let hasShownOnce = false | |
| 38 | + let shownSessionId: string | null = null | |
| 40 | 39 | let wasDrawnSinceProbe = false |
| 41 | 40 | let armed: Ask.ArmedAsk | null = null |
| 41 | + let carrying: Ask.ArmedAsk | null = null | |
| 42 | 42 | let isRefreshing = false |
| 43 | 43 | let isRefreshQueued = false |
| 44 | 44 | let generation = 0 |
| @@ -48,9 +48,6 @@ | ||
| 48 | 48 | const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>() |
| 49 | 49 | const loggedBaseKinds = new Set<'ok' | 'sad'>() |
| 50 | 50 | |
| 51 | - // The bound host once bind() has run (it sets `host` before pinning), so a | |
| 52 | - // backend pinned under one session.start keeps reading through whatever | |
| 53 | - // host a later session.start bound; `engine` only before that. | |
| 54 | 51 | const currentOf = (engine: Host): Host => host ?? engine |
| 55 | 52 | |
| 56 | 53 | const backendHostOf = (engine: Host): Backend.BackendHost => ({ |
| @@ -62,12 +59,19 @@ | ||
| 62 | 59 | sessionStartMsOf: () => sessionStartMs, |
| 63 | 60 | onBranchBase: base => { |
| 64 | 61 | const isError = base.kind === 'error' |
| 62 | + | |
| 65 | 63 | const outcome: Record.MarkOutcome = isError |
| 66 | 64 | ? { kind: 'sad', reason: base.reason } |
| 67 | - : { kind: 'ok' } | |
| 65 | + : { | |
| 66 | + kind: 'ok', | |
| 67 | + props: { | |
| 68 | + outcome: { value: base.kind, of: Record.BASE_OUTCOMES }, | |
| 69 | + }, | |
| 70 | + } | |
| 68 | 71 | |
| 69 | 72 | if (!loggedBaseKinds.has(outcome.kind)) { |
| 70 | 73 | loggedBaseKinds.add(outcome.kind) |
| 74 | + | |
| 71 | 75 | Record.recorderOf(currentOf(engine)).mark( |
| 72 | 76 | Record.FEATURES.baseResolve, |
| 73 | 77 | outcome, |
| @@ -76,19 +80,40 @@ | ||
| 76 | 80 | }, |
| 77 | 81 | }) |
| 78 | 82 | |
| 79 | - async function pinBackend(engine: Host): Promise<void> { | |
| 83 | + function pinBackend(engine: Host): Promise<boolean> { | |
| 80 | 84 | if (backend) { |
| 81 | - return | |
| 85 | + return Promise.resolve(true) | |
| 82 | 86 | } |
| 83 | 87 | |
| 88 | + probing ??= probeBackend(engine).finally(() => { | |
| 89 | + probing = null | |
| 90 | + }) | |
| 91 | + | |
| 92 | + return probing | |
| 93 | + } | |
| 94 | + | |
| 95 | + async function probeBackend(engine: Host): Promise<boolean> { | |
| 96 | + const asked = { isAnswered: true } | |
| 97 | + const probeHost = backendHostOf(engine) | |
| 98 | + | |
| 84 | 99 | const probed = await Backend.backendOf( |
| 85 | - backendHostOf(engine), | |
| 100 | + { | |
| 101 | + ...probeHost, | |
| 102 | + run: (argv, init) => | |
| 103 | + probeHost.run(argv, init).catch((error: unknown) => { | |
| 104 | + asked.isAnswered &&= | |
| 105 | + argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error)) | |
| 106 | + | |
| 107 | + throw error | |
| 108 | + }), | |
| 109 | + }, | |
| 86 | 110 | Backend.INSTALLED_BACKEND_PROBES, |
| 87 | 111 | ) |
| 112 | + | |
| 88 | 113 | backend ??= probed |
| 89 | 114 | |
| 90 | 115 | if (!probed || backend !== probed) { |
| 91 | - return | |
| 116 | + return asked.isAnswered || backend !== null | |
| 92 | 117 | } |
| 93 | 118 | |
| 94 | 119 | const stored = PaneState.baseModeOf( |
| @@ -96,13 +121,17 @@ | ||
| 96 | 121 | .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel)) |
| 97 | 122 | .catch(() => undefined), |
| 98 | 123 | ) |
| 124 | + | |
| 99 | 125 | const mode = stored && probed.baseModes.includes(stored) ? stored : null |
| 126 | + | |
| 100 | 127 | model = { |
| 101 | 128 | ...model, |
| 102 | 129 | words: probed.words, |
| 103 | 130 | baseModes: probed.baseModes, |
| 104 | 131 | ...(mode && { requestedMode: mode }), |
| 105 | 132 | } |
| 133 | + | |
| 134 | + return true | |
| 106 | 135 | } |
| 107 | 136 | |
| 108 | 137 | function redraw(engine: Host) { |
| @@ -131,7 +160,7 @@ | ||
| 131 | 160 | model.selectedPath, |
| 132 | 161 | ) |
| 133 | 162 | |
| 134 | - async function loadBody(engine: Host): Promise<void> { | |
| 163 | + async function loadBody(engine: Host): Promise<boolean> { | |
| 135 | 164 | const { data } = model |
| 136 | 165 | const selected = selectedOf() |
| 137 | 166 | |
| @@ -139,26 +168,29 @@ | ||
| 139 | 168 | bodyKey = null |
| 140 | 169 | model = { ...model, body: null, bodyState: 'idle' } |
| 141 | 170 | |
| 142 | - return | |
| 171 | + return false | |
| 143 | 172 | } |
| 144 | 173 | |
| 145 | 174 | const key = `${generation}|${data.baseRef}|${selected.path}` |
| 146 | 175 | |
| 147 | 176 | if (key === bodyKey) { |
| 148 | - return | |
| 177 | + return false | |
| 149 | 178 | } |
| 150 | 179 | |
| 151 | 180 | bodyKey = key |
| 152 | 181 | model = { ...model, body: null, bodyState: 'loading' } |
| 153 | 182 | redraw(engine) |
| 183 | + | |
| 154 | 184 | const body = await backend.fetchFileHunks(data, selected) |
| 155 | 185 | |
| 156 | 186 | if (bodyKey !== key) { |
| 157 | - return | |
| 187 | + return body === null | |
| 158 | 188 | } |
| 159 | 189 | |
| 160 | 190 | model = { ...model, body, bodyState: body ? 'ready' : 'failed' } |
| 161 | 191 | redraw(engine) |
| 192 | + | |
| 193 | + return body === null | |
| 162 | 194 | } |
| 163 | 195 | |
| 164 | 196 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| @@ -171,6 +203,7 @@ | ||
| 171 | 203 | timers.get('poll')?.cancel() |
| 172 | 204 | polled.toplevel = pinned.repository.toplevel |
| 173 | 205 | polled.headKey = '' |
| 206 | + | |
| 174 | 207 | timers.set( |
| 175 | 208 | 'poll', |
| 176 | 209 | engine.every(Limits.HEAD_POLL_MS, () => { |
| @@ -180,6 +213,7 @@ | ||
| 180 | 213 | |
| 181 | 214 | void readHeadKey().then(key => { |
| 182 | 215 | const hasMoved = polled.headKey !== '' && key !== polled.headKey |
| 216 | + | |
| 183 | 217 | polled.headKey = key |
| 184 | 218 | |
| 185 | 219 | if (hasMoved) { |
| @@ -198,8 +232,10 @@ | ||
| 198 | 232 | } |
| 199 | 233 | |
| 200 | 234 | isRefreshing = true |
| 235 | + | |
| 201 | 236 | const record = Record.recorderOf(engine) |
| 202 | 237 | const pinned = backend |
| 238 | + | |
| 203 | 239 | const fetched = (): Promise<Git.FetchOutcome> => |
| 204 | 240 | pinned |
| 205 | 241 | ? pinned.fetchDiff(model.requestedMode) |
| @@ -207,10 +243,12 @@ | ||
| 207 | 243 | |
| 208 | 244 | try { |
| 209 | 245 | model = { ...model, isLoading: model.data === null } |
| 246 | + | |
| 210 | 247 | const [outcome, messages] = await Promise.all([ |
| 211 | 248 | fetched(), |
| 212 | 249 | engine.messages().catch((): SessionMessage[] => []), |
| 213 | 250 | ]) |
| 251 | + | |
| 214 | 252 | model = PaneState.afterFetch(model, { outcome, messages }) |
| 215 | 253 | |
| 216 | 254 | switch (outcome.kind) { |
| @@ -221,9 +259,9 @@ | ||
| 221 | 259 | kind: 'sad', |
| 222 | 260 | reason: 'git_diff_failed', |
| 223 | 261 | }) |
| 262 | + | |
| 224 | 263 | break |
| 225 | 264 | case 'data': |
| 226 | - record.mark(Record.FEATURES.read, { kind: 'ok' }) | |
| 227 | 265 | generation += 1 |
| 228 | 266 | |
| 229 | 267 | if (pinned) { |
| @@ -233,7 +271,23 @@ | ||
| 233 | 271 | break |
| 234 | 272 | } |
| 235 | 273 | |
| 236 | - await loadBody(engine) | |
| 274 | + const hasHunksFailed = await loadBody(engine) | |
| 275 | + | |
| 276 | + if (outcome.kind === 'data') { | |
| 277 | + record.mark( | |
| 278 | + Record.FEATURES.read, | |
| 279 | + hasHunksFailed | |
| 280 | + ? { kind: 'sad', reason: 'git_hunks_failed' } | |
| 281 | + : { kind: 'ok' }, | |
| 282 | + ) | |
| 283 | + } | |
| 284 | + } catch (error) { | |
| 285 | + record.mark(Record.FEATURES.read, { | |
| 286 | + kind: 'sad', | |
| 287 | + reason: 'git_diff_threw', | |
| 288 | + }) | |
| 289 | + | |
| 290 | + throw error | |
| 237 | 291 | } finally { |
| 238 | 292 | isRefreshing = false |
| 239 | 293 | redraw(engine) |
| @@ -247,6 +301,7 @@ | ||
| 247 | 301 | |
| 248 | 302 | function scheduleRefresh(engine: Host): void { |
| 249 | 303 | timers.get('refresh')?.cancel() |
| 304 | + | |
| 250 | 305 | timers.set( |
| 251 | 306 | 'refresh', |
| 252 | 307 | engine.after(Limits.REFRESH_DEBOUNCE_MS, () => { |
| @@ -264,12 +319,12 @@ | ||
| 264 | 319 | const isManual = trigger === 'manual' |
| 265 | 320 | await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane) |
| 266 | 321 | isPaneOpen = true |
| 267 | - const record = Record.recorderOf(engine) | |
| 268 | - record.mark(Record.FEATURES.tabSwitch, { kind: 'ok' }) | |
| 269 | 322 | |
| 270 | - if (!hasShownOnce) { | |
| 271 | - hasShownOnce = true | |
| 272 | - record.shown(trigger, Record.widthBucketOf(columns)) | |
| 323 | + const sessionId = await engine.sessionId().catch(() => null) | |
| 324 | + | |
| 325 | + if (sessionId !== null && sessionId !== shownSessionId) { | |
| 326 | + shownSessionId = sessionId | |
| 327 | + Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) | |
| 273 | 328 | } |
| 274 | 329 | |
| 275 | 330 | void refresh(engine) |
| @@ -278,9 +333,15 @@ | ||
| 278 | 333 | async function closePane(engine: Host): Promise<void> { |
| 279 | 334 | await engine.closePane({ id: Names.PANE_ID }) |
| 280 | 335 | isPaneOpen = false |
| 281 | - Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { kind: 'ok' }) | |
| 282 | 336 | } |
| 283 | 337 | |
| 338 | + function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { | |
| 339 | + Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { | |
| 340 | + kind: 'ok', | |
| 341 | + props: { tab: { value: tab, of: Record.TABS } }, | |
| 342 | + }) | |
| 343 | + } | |
| 344 | + | |
| 284 | 345 | async function wasDrawnWhenProbed(engine: Host): Promise<boolean> { |
| 285 | 346 | wasDrawnSinceProbe = false |
| 286 | 347 | engine.invalidate() |
| @@ -297,11 +358,15 @@ | ||
| 297 | 358 | } |
| 298 | 359 | |
| 299 | 360 | const preference = await engine.storeGet(Names.STORE_OPEN_KEY) |
| 361 | + | |
| 300 | 362 | await pinBackend(engine) |
| 363 | + | |
| 301 | 364 | const isKeptOpen = preference === true |
| 365 | + | |
| 302 | 366 | const floor = isKeptOpen |
| 303 | 367 | ? Limits.OPEN_MIN_COLUMNS |
| 304 | 368 | : Limits.AUTO_OPEN_MIN_COLUMNS |
| 369 | + | |
| 305 | 370 | const isEligible = |
| 306 | 371 | preference !== false && |
| 307 | 372 | columns !== null && |
| @@ -347,9 +412,12 @@ | ||
| 347 | 412 | |
| 348 | 413 | bodyKey = null |
| 349 | 414 | model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' } |
| 415 | + | |
| 350 | 416 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 351 | 417 | kind: 'ok', |
| 418 | + props: { mode: { value: mode, of: model.baseModes } }, | |
| 352 | 419 | }) |
| 420 | + | |
| 353 | 421 | const toplevel = model.data?.repository.toplevel |
| 354 | 422 | |
| 355 | 423 | if (toplevel !== undefined) { |
| @@ -364,9 +432,11 @@ | ||
| 364 | 432 | chooseSource: value => { |
| 365 | 433 | const index = Number(value) |
| 366 | 434 | const isTurn = value !== 'current' && Number.isInteger(index) |
| 435 | + | |
| 367 | 436 | const source: PaneState.Source = isTurn |
| 368 | 437 | ? { kind: 'turn', index } |
| 369 | 438 | : { kind: 'current' } |
| 439 | + | |
| 370 | 440 | model = { ...model, source, selectedPath: null } |
| 371 | 441 | void loadBody(engine) |
| 372 | 442 | redraw(engine) |
| @@ -381,6 +451,12 @@ | ||
| 381 | 451 | |
| 382 | 452 | arm(engine, path) |
| 383 | 453 | }, |
| 454 | + close: () => { | |
| 455 | + void closePane(engine) | |
| 456 | + .then(() => markTabSwitch(engine, 'convo')) | |
| 457 | + .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false)) | |
| 458 | + .catch(() => undefined) | |
| 459 | + }, | |
| 384 | 460 | }) |
| 385 | 461 | |
| 386 | 462 | function arm(engine: Host, path: string) { |
| @@ -391,16 +467,19 @@ | ||
| 391 | 467 | model.body?.hunks ?? |
| 392 | 468 | [], |
| 393 | 469 | ) |
| 470 | + | |
| 394 | 471 | model = { ...model, armedPath: path } |
| 472 | + | |
| 395 | 473 | engine.status( |
| 396 | 474 | `${Views.sanitizeName(path)} rides your next prompt (press ` + |
| 397 | 475 | `asked ✓ to drop it)`, |
| 398 | 476 | ) |
| 477 | + | |
| 399 | 478 | redraw(engine) |
| 400 | 479 | } |
| 401 | 480 | |
| 402 | 481 | async function bind(engine: Host): Promise<void> { |
| 403 | - sessionStartMs = engine.now() | |
| 482 | + sessionStartMs = await engine.now() | |
| 404 | 483 | |
| 405 | 484 | try { |
| 406 | 485 | await engine.registerCommand(COMMAND_SPEC) |
| @@ -437,6 +516,7 @@ | ||
| 437 | 516 | openPane: pane => $.ui.open(pane), |
| 438 | 517 | closePane: pane => $.ui.close(pane), |
| 439 | 518 | registerCommand: spec => $.command.register(spec), |
| 519 | + sessionId: () => $.session.id(), | |
| 440 | 520 | mark: entry => $.telemetry.mark(entry), |
| 441 | 521 | log: entry => $.telemetry.log(entry), |
| 442 | 522 | }) |
| @@ -457,18 +537,21 @@ | ||
| 457 | 537 | return next(e) |
| 458 | 538 | } |
| 459 | 539 | |
| 460 | - const { Box, Text, Button, Select } = await $.ui.resolve(e) | |
| 540 | + const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) | |
| 541 | + | |
| 461 | 542 | wasDrawnSinceProbe = true |
| 462 | 543 | columns = e.viewport?.columns ?? columns |
| 463 | 544 | model = { ...model, isFocused: e.props.isFocused } |
| 464 | 545 | |
| 465 | 546 | return Views.paneView( |
| 466 | 547 | { |
| 467 | - ui: { Box, Text, Button, Select }, | |
| 548 | + ui: { Box, Text, Button, Select, Code }, | |
| 468 | 549 | actions: actionsOf(host), |
| 469 | 550 | columns: e.props.bodyColumns, |
| 551 | + rows: e.props.scroll.bodyRows, | |
| 470 | 552 | }, |
| 471 | 553 | model, |
| 554 | + e.props.placement, | |
| 472 | 555 | ) |
| 473 | 556 | }) |
| 474 | 557 | |
| @@ -477,18 +560,29 @@ | ||
| 477 | 560 | return next(e) |
| 478 | 561 | } |
| 479 | 562 | |
| 480 | - await pinBackend(host) | |
| 563 | + const isAnswered = (await pinBackend(host)) || (await pinBackend(host)) | |
| 481 | 564 | |
| 482 | 565 | if (!backend) { |
| 483 | - return { text: Names.NOT_IN_REPOSITORY_TEXT } | |
| 566 | + return { | |
| 567 | + text: isAnswered | |
| 568 | + ? Names.NOT_IN_REPOSITORY_TEXT | |
| 569 | + : Names.GIT_UNANSWERED_TEXT, | |
| 570 | + } | |
| 484 | 571 | } |
| 485 | 572 | |
| 486 | - const isOpening = | |
| 487 | - PaneToggle.paneToggleOf({ | |
| 488 | - isBelievedOpen: isPaneOpen, | |
| 489 | - wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)), | |
| 490 | - }) === 'open' | |
| 573 | + const toggle = PaneToggle.paneToggleOf({ | |
| 574 | + isBelievedOpen: isPaneOpen, | |
| 575 | + wasDrawnWhenProbed: isPaneOpen && (await wasDrawnWhenProbed(host)), | |
| 576 | + columns, | |
| 577 | + }) | |
| 578 | + | |
| 579 | + if (toggle === 'too-narrow') { | |
| 580 | + return { text: Names.RESIZE_TERMINAL_TEXT } | |
| 581 | + } | |
| 582 | + | |
| 583 | + const isOpening = toggle === 'open' | |
| 491 | 584 | await (isOpening ? openPane(host, 'manual') : closePane(host)) |
| 585 | + markTabSwitch(host, isOpening ? 'diff' : 'convo') | |
| 492 | 586 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 493 | 587 | |
| 494 | 588 | return {} |
| @@ -552,17 +646,50 @@ | ||
| 552 | 646 | }) |
| 553 | 647 | |
| 554 | 648 | on('prompt.submit', async ($, e, next) => { |
| 555 | - const result = await next(e) | |
| 556 | 649 | const asked = armed |
| 557 | 650 | |
| 558 | - if (!host || !asked || result.drop !== undefined) { | |
| 559 | - return result | |
| 651 | + if (!host || !asked || carrying === asked) { | |
| 652 | + return next(e) | |
| 560 | 653 | } |
| 561 | 654 | |
| 562 | - disarm(host) | |
| 563 | - redraw(host) | |
| 564 | - Record.recorderOf(host).asked(asked.lines) | |
| 655 | + const context = e.context ?? [] | |
| 565 | 656 | |
| 566 | - return { ...result, context: [...(result.context ?? []), asked.text] } | |
| 657 | + const text = Ask.fittedAskTextOf( | |
| 658 | + asked.text, | |
| 659 | + Limits.PROMPT_CONTEXT_MAX_CHARS - | |
| 660 | + context.reduce((sum, entry) => sum + entry.length, 0), | |
| 661 | + ) | |
| 662 | + | |
| 663 | + if (text === undefined) { | |
| 664 | + disarm(host) | |
| 665 | + | |
| 666 | + host.status( | |
| 667 | + `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` + | |
| 668 | + `and was dropped`, | |
| 669 | + ) | |
| 670 | + | |
| 671 | + redraw(host) | |
| 672 | + | |
| 673 | + return next(e) | |
| 674 | + } | |
| 675 | + | |
| 676 | + carrying = asked | |
| 677 | + | |
| 678 | + try { | |
| 679 | + const result = await next({ ...e, context: [...context, text] }) | |
| 680 | + | |
| 681 | + if (result.drop === undefined) { | |
| 682 | + Record.recorderOf(host).asked() | |
| 683 | + | |
| 684 | + if (armed === asked) { | |
| 685 | + disarm(host) | |
| 686 | + redraw(host) | |
| 687 | + } | |
| 688 | + } | |
| 689 | + | |
| 690 | + return result | |
| 691 | + } finally { | |
| 692 | + carrying = null | |
| 693 | + } | |
| 567 | 694 | }) |
| 568 | 695 | } |
| @@ -1,56 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The `$.telemetry` noun as this plugin calls it, declared for a build of | |
| 3 | - * this folder on its own; no module imports it. | |
| 4 | - * | |
| 5 | - * The telemetry plugin adds the noun in the engine.create fold on internal | |
| 6 | - * builds and nowhere else: where no plugin provides it the calls throw and | |
| 7 | - * record/ drops the row. The engine's repository leaves this file out. | |
| 8 | - * | |
| 9 | - * @entry | |
| 10 | - */ | |
| 11 | -declare module 'claude-code' { | |
| 12 | - /** | |
| 13 | - * How one use of a feature went: as hoped, degraded, or failed outright. | |
| 14 | - */ | |
| 15 | - type DiffTelemetryMarkKind = 'ok' | 'sad' | 'bad' | |
| 16 | - | |
| 17 | - /** | |
| 18 | - * A string property: the value and the list it is chosen from. | |
| 19 | - */ | |
| 20 | - type DiffTelemetryChoice = { value: string; of: readonly string[] } | |
| 21 | - | |
| 22 | - /** | |
| 23 | - * One property value a row may carry. | |
| 24 | - */ | |
| 25 | - type DiffTelemetryProp = number | boolean | DiffTelemetryChoice | |
| 26 | - | |
| 27 | - /** | |
| 28 | - * What `$.telemetry.log` takes: the event's name after the prefix, and | |
| 29 | - * its properties by snake_case key. | |
| 30 | - */ | |
| 31 | - type DiffTelemetryLogEntry = { | |
| 32 | - event: string | |
| 33 | - props?: Readonly<Record<string, DiffTelemetryProp>> | |
| 34 | - } | |
| 35 | - | |
| 36 | - /** | |
| 37 | - * What `$.telemetry.mark` takes: the feature, how it went, and why when | |
| 38 | - * not ok. | |
| 39 | - */ | |
| 40 | - type DiffTelemetryMarkEntry = { | |
| 41 | - feature: string | |
| 42 | - kind: DiffTelemetryMarkKind | |
| 43 | - reason?: string | |
| 44 | - } | |
| 45 | - | |
| 46 | - interface EngineInterface { | |
| 47 | - /** | |
| 48 | - * A plugin's analytics, one first-party row per call; present only | |
| 49 | - * where the telemetry plugin is seated. | |
| 50 | - */ | |
| 51 | - telemetry: { | |
| 52 | - log: (entry: DiffTelemetryLogEntry) => Promise<void> | |
| 53 | - mark: (entry: DiffTelemetryMarkEntry) => Promise<void> | |
| 54 | - } | |
| 55 | - } | |
| 56 | -} |
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | const latest = messages |
| 22 | 22 | .flatMap(message => message.toolUses) |
| 23 | 23 | .findLast(use => use.tool === Tools.TODO_TOOL) |
| 24 | + | |
| 24 | 25 | const statuses = arrayOf(latest?.input.todos).map(todo => |
| 25 | 26 | isRecord(todo) ? todo.status : null, |
| 26 | 27 | ) |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | .filter(isRecord) |
| 15 | 15 | .flatMap(hunk => { |
| 16 | 16 | const { oldStart, newStart, lines } = hunk |
| 17 | + | |
| 17 | 18 | const isHunk = |
| 18 | 19 | typeof oldStart === 'number' && |
| 19 | 20 | typeof newStart === 'number' && |
| @@ -22,17 +22,21 @@ | ||
| 22 | 22 | ): Types.TurnDiff { |
| 23 | 23 | const text = rows[0]?.text ?? '' |
| 24 | 24 | const isShort = [...text].length <= PREVIEW_CHARS |
| 25 | + | |
| 25 | 26 | const preview = isShort |
| 26 | 27 | ? text |
| 27 | 28 | : `${[...text].slice(0, PREVIEW_CHARS - 1).join('')}…` |
| 29 | + | |
| 28 | 30 | const edits = rows |
| 29 | 31 | .flatMap(row => (row.role === 'assistant' ? row.toolUses : [])) |
| 30 | 32 | .map(use => fileEditOf(use.result)) |
| 31 | 33 | .filter((edit): edit is Types.TurnFile => edit !== null) |
| 34 | + | |
| 32 | 35 | const byPath = new Map<string, Types.TurnFile>() |
| 33 | 36 | |
| 34 | 37 | for (const edit of edits) { |
| 35 | 38 | const earlier = byPath.get(edit.path) |
| 39 | + | |
| 36 | 40 | byPath.set( |
| 37 | 41 | edit.path, |
| 38 | 42 | earlier |
| @@ -12,16 +12,17 @@ | ||
| 12 | 12 | import Sections from '../sections' |
| 13 | 13 | import { fileRowOf } from './file-row-of' |
| 14 | 14 | import { listBodyOf } from './list-body-of' |
| 15 | +import { messagePaneOf } from './message-pane-of' | |
| 15 | 16 | |
| 16 | 17 | /** |
| 17 | 18 | * The pane over the repository's diff now (ReplDiffSidebarBody, one file's |
| 18 | 19 | * body at a time): header, base line, todo bar, pickers, rows, detail. |
| 19 | 20 | * |
| 20 | - * The tests-and-generated toggle sits above the rows; the elision count, the | |
| 21 | - * withheld-untracked note, the pre-session line and rows below them; the | |
| 22 | - * selected file last. | |
| 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. | |
| 23 | 24 | * |
| 24 | - * @param kit the elements, the handlers, the width | |
| 25 | + * @param kit the elements, the handlers, the width, the rows | |
| 25 | 26 | * @param model the pane's state |
| 26 | 27 | * @returns the pane's tree |
| 27 | 28 | */ |
| @@ -37,53 +38,83 @@ | ||
| 37 | 38 | } |
| 38 | 39 | |
| 39 | 40 | if (!data && !model.hasSettled) { |
| 40 | - return <Box>{Sections.dimNote(kit, 'Loading diff…')}</Box> | |
| 41 | + return messagePaneOf(kit, { | |
| 42 | + top: [Sections.headerView(kit, null)], | |
| 43 | + message: ['Loading diff…'], | |
| 44 | + controls: null, | |
| 45 | + earlier: null, | |
| 46 | + rest: [], | |
| 47 | + }) | |
| 41 | 48 | } |
| 42 | 49 | |
| 43 | 50 | const noise = model.isNoiseShown ? 'shown' : 'hidden' |
| 44 | 51 | const preSession = model.isPreSessionShown ? 'shown' : 'hidden' |
| 45 | 52 | const partition = PaneState.partitionOf(data?.files ?? [], noise) |
| 53 | + | |
| 46 | 54 | const totals = data |
| 47 | 55 | ? PaneState.headerTotalsOf(data, partition) |
| 48 | 56 | : PaneState.ZERO_TOTALS |
| 57 | + | |
| 49 | 58 | const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words) |
| 50 | - const baseLabel = PaneState.baseLabelOf( | |
| 51 | - model.requestedMode, | |
| 52 | - data, | |
| 53 | - totals.filesCount, | |
| 54 | - model.words, | |
| 55 | - ) | |
| 59 | + const baseLabel = PaneState.baseLabelOf(model, totals.filesCount) | |
| 60 | + | |
| 56 | 61 | const selected = PaneState.selectionOf( |
| 57 | 62 | PaneState.listedOf(partition, preSession), |
| 58 | 63 | model.selectedPath, |
| 59 | 64 | ) |
| 65 | + | |
| 60 | 66 | const rowOf = (file: Git.FileStat): RenderElement => |
| 61 | 67 | Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () => |
| 62 | 68 | kit.actions.selectFile(file.path), |
| 63 | 69 | ) |
| 70 | + | |
| 64 | 71 | const noteOf = (text: string | null): RenderElement | null => |
| 65 | 72 | text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text)) |
| 73 | + | |
| 66 | 74 | const noiseFace = model.isNoiseShown ? 'hide' : 'show' |
| 67 | 75 | const noiseCount = Layout.plural(partition.noiseCount, 'test') |
| 68 | 76 | const hasNoise = partition.noiseCount > 0 |
| 77 | + | |
| 69 | 78 | const noiseToggle = hasNoise |
| 70 | 79 | ? Sections.toggleRow(kit, 'noise', { |
| 71 | 80 | label: `${noiseCount}/generated (${noiseFace})`, |
| 72 | 81 | onPress: kit.actions.toggleNoise, |
| 73 | 82 | }) |
| 74 | 83 | : null |
| 84 | + | |
| 75 | 85 | const earlierFace = model.isPreSessionShown ? 'hide' : 'show' |
| 76 | 86 | const earlierCount = Layout.plural(partition.preSession.length, 'file') |
| 77 | 87 | const hasEarlier = partition.preSession.length > 0 |
| 88 | + | |
| 78 | 89 | const earlierToggle = hasEarlier |
| 79 | 90 | ? Sections.toggleRow(kit, 'presession', { |
| 80 | 91 | label: `+${earlierCount} edited before this session (${earlierFace})`, |
| 81 | 92 | onPress: kit.actions.togglePreSession, |
| 82 | 93 | }) |
| 83 | 94 | : null |
| 84 | - const earlierRows = model.isPreSessionShown ? partition.preSession : [] | |
| 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 | + | |
| 85 | 103 | const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty |
| 86 | 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 | + | |
| 87 | 118 | const detail = selected |
| 88 | 119 | ? [ |
| 89 | 120 | Sections.divider(kit), |
| @@ -104,25 +135,39 @@ | ||
| 104 | 135 | ] |
| 105 | 136 | : [] |
| 106 | 137 | |
| 107 | - return ( | |
| 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 | + ) : ( | |
| 108 | 156 | <Box flexDirection="column"> |
| 109 | 157 | {Sections.present([ |
| 110 | - Sections.headerView(kit, empty?.headline ?? null, totals), | |
| 158 | + header, | |
| 111 | 159 | noteOf(baseLabel), |
| 112 | - Sections.todoBar(kit, model.todos), | |
| 160 | + Sections.todoBar(kit, model), | |
| 113 | 161 | Sections.controlsView(kit, model), |
| 114 | 162 | noiseToggle, |
| 115 | 163 | ...listed.map(line => |
| 116 | 164 | typeof line === 'string' ? noteOf(line) : rowOf(line), |
| 117 | 165 | ), |
| 118 | - noteOf(totals.notShown > 0 ? `${totals.notShown} not shown` : null), | |
| 119 | - noteOf( | |
| 120 | - isUntrackedNoted | |
| 121 | - ? Names.untrackedWithheldTextOf(model.words.lister) | |
| 122 | - : null, | |
| 123 | - ), | |
| 166 | + notShownNote, | |
| 167 | + untrackedNote, | |
| 168 | + hasEarlier ? <Box height={1} /> : null, | |
| 124 | 169 | earlierToggle, |
| 125 | - ...earlierRows.map(rowOf), | |
| 170 | + ...earlierRows, | |
| 126 | 171 | ...detail, |
| 127 | 172 | ])} |
| 128 | 173 | </Box> |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | selectedPath: string | null, |
| 15 | 15 | ): Sections.FileRowModel { |
| 16 | 16 | const isRenamed = file.renamedFrom !== null |
| 17 | + | |
| 17 | 18 | const note = file.isUntracked |
| 18 | 19 | ? 'untracked' |
| 19 | 20 | : file.isBinary |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './current-pane.jsx' |
| 2 | 2 | export * from './file-row-of' |
| 3 | 3 | export * from './list-body-of' |
| 4 | +export * from './message-pane-of' | |
| 4 | 5 | |
| 5 | 6 | export * as default from '.' |
| @@ -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 | +} |
| @@ -1,0 +1,85 @@ | ||
| 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,14 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The theme key behind a run of each kind: the added and removed bands, and | |
| 5 | - * none behind context, which draws on the terminal's own background. | |
| 6 | - * | |
| 7 | - * The theme's `background` key is the background-task accent, a cyan. | |
| 8 | - */ | |
| 9 | -export const BACKGROUNDS: Readonly<Record<Types.LineKind, string | undefined>> = | |
| 10 | - { | |
| 11 | - added: 'diffAdded', | |
| 12 | - removed: 'diffRemoved', | |
| 13 | - context: undefined, | |
| 14 | - } |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './backgrounds.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,0 +1,81 @@ | ||
| 1 | +import Git from '../../../git' | |
| 2 | +import { HUNK_DIVIDER } from '../hunk-divider' | |
| 3 | +import { HUNK_DIVIDER_NODES } from '../hunk-divider-nodes' | |
| 4 | +import { hunkSourceOf } from '../hunk-source-of' | |
| 5 | +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 | +import { MAX_CODE_CHARS } from '../max-code-chars' | |
| 9 | +import { subHunksOf } from '../sub-hunks-of' | |
| 10 | +import type Types from '../types' | |
| 11 | +import { drawnLineOf } from './drawn-line-of' | |
| 12 | + | |
| 13 | +/** | |
| 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). | |
| 16 | + * | |
| 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. | |
| 20 | + * | |
| 21 | + * @param hunks the file's hunks | |
| 22 | + * @returns the blocks in order, and whether any line was left out or cut | |
| 23 | + */ | |
| 24 | +export function codeBlocksOf(hunks: readonly Git.Hunk[]): Types.CodeBody { | |
| 25 | + const blocks: Types.CodeBlock[] = [] | |
| 26 | + let chars = 0 | |
| 27 | + let nodes = 0 | |
| 28 | + let isTruncated = false | |
| 29 | + | |
| 30 | + for (const hunk of hunks) { | |
| 31 | + const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf) | |
| 32 | + const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS) | |
| 33 | + isTruncated ||= split.isTruncated | |
| 34 | + | |
| 35 | + for (const [at, piece] of split.hunks.entries()) { | |
| 36 | + const last = blocks.at(-1) | |
| 37 | + const source = hunkSourceOf(piece) | |
| 38 | + const isHunkStart = at === 0 | |
| 39 | + | |
| 40 | + const isJoining = | |
| 41 | + isHunkStart && | |
| 42 | + last !== undefined && | |
| 43 | + last.source.length + 1 + source.length <= MAX_CODE_CHARS | |
| 44 | + | |
| 45 | + const hasDivider = isHunkStart && !isJoining && last !== undefined | |
| 46 | + const overhead = isJoining ? 1 : hasDivider ? HUNK_DIVIDER.length : 0 | |
| 47 | + | |
| 48 | + const addedNodes = isJoining | |
| 49 | + ? 0 | |
| 50 | + : 1 + (hasDivider ? HUNK_DIVIDER_NODES : 0) | |
| 51 | + | |
| 52 | + const room = MAX_BODY_CHARS - chars - overhead | |
| 53 | + const fitted = source.length <= room ? piece : leadingHunkOf(piece, room) | |
| 54 | + const isOverNodes = nodes + addedNodes > MAX_BODY_NODES | |
| 55 | + | |
| 56 | + if (isOverNodes || fitted.lines.length === 0) { | |
| 57 | + return { blocks, isTruncated: true } | |
| 58 | + } | |
| 59 | + | |
| 60 | + const text = hunkSourceOf(fitted) | |
| 61 | + | |
| 62 | + if (isJoining) { | |
| 63 | + blocks.pop() | |
| 64 | + } | |
| 65 | + | |
| 66 | + blocks.push({ | |
| 67 | + source: isJoining ? `${last.source}\n${text}` : text, | |
| 68 | + hasDivider: isJoining ? last.hasDivider : hasDivider, | |
| 69 | + }) | |
| 70 | + | |
| 71 | + chars += overhead + text.length | |
| 72 | + nodes += addedNodes | |
| 73 | + | |
| 74 | + if (fitted.lines.length < piece.lines.length) { | |
| 75 | + return { blocks, isTruncated: true } | |
| 76 | + } | |
| 77 | + } | |
| 78 | + } | |
| 79 | + | |
| 80 | + return { blocks, isTruncated } | |
| 81 | +} |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import Layout from '../../../layout' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A hunk line made safe for a `Code` source: its marker and its tabs kept, | |
| 5 | + * the engine laying tabs out as it does every diff it draws. | |
| 6 | + * | |
| 7 | + * Between the tabs sanitizeLine's rule holds: every control, format and | |
| 8 | + * invisible character dropped, so nothing can reorder or hide the line. | |
| 9 | + * | |
| 10 | + * @param line a body line, its `+`, `-` or space marker first | |
| 11 | + * @returns the drawable line | |
| 12 | + */ | |
| 13 | +export const drawnLineOf = (line: string) => | |
| 14 | + line.slice(0, 1) + | |
| 15 | + line.slice(1).split('\t').map(Layout.sanitizeLine).join('\t') |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './drawn-line-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './code-blocks-of.js' | |
| 2 | +export * from './drawn-line-of' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -3,9 +3,10 @@ | ||
| 3 | 3 | import type PaneState from '../../../pane-state' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | - * The selected file as its body draws it: the path, what kind of row it is, | |
| 7 | - * its hunks once read, whether it is armed for the next prompt, and the | |
| 8 | - * backend's words for an untracked row's note. | |
| 6 | + * The selected file as its body draws it. | |
| 7 | + * | |
| 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. | |
| 9 | 10 | */ |
| 10 | 11 | export type DetailModel = { |
| 11 | 12 | words: Pick<Backend.BackendWords, 'untrackedNoteOf'> |
| @@ -5,22 +5,20 @@ | ||
| 5 | 5 | |
| 6 | 6 | import type { Kit } from '../kit' |
| 7 | 7 | import Layout from '../layout' |
| 8 | +import Sections from '../sections' | |
| 9 | +import { codeBlocksOf } from './code-blocks-of' | |
| 8 | 10 | import type { DetailModel } from './detail-model' |
| 9 | -import { gutterCellsOf } from './gutter-cells-of' | |
| 10 | -import { hunkBody } from './hunk-body' | |
| 11 | -import { linesOf } from './lines-of' | |
| 12 | -import { MARKER_CELLS } from './marker-cells' | |
| 13 | -import { maxRowsOf } from './max-rows-of' | |
| 11 | +import { HUNK_DIVIDER } from './hunk-divider' | |
| 12 | +import { MAX_CODE_CHARS } from './max-code-chars' | |
| 14 | 13 | import { placeholderOf } from './placeholder-of' |
| 15 | -import { rowsOf } from './rows-of' | |
| 16 | 14 | |
| 17 | 15 | /** |
| 18 | - * The selected file under the list: its bold path and asides (renamed | |
| 19 | - * from, untracked, truncated) cut to the width, the ask Button, the body. | |
| 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. | |
| 20 | 18 | * |
| 21 | - * The body is a placeholder or the hunks (cut to the rows the body's char | |
| 22 | - * and node budgets hold, maxRowsOf), then the footer; the gutter fits the | |
| 23 | - * largest number. | |
| 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. | |
| 24 | 22 | * |
| 25 | 23 | * @param kit the elements and the width |
| 26 | 24 | * @param detail the selected file |
| @@ -32,34 +30,26 @@ | ||
| 32 | 30 | detail: DetailModel, |
| 33 | 31 | onToggleAsk: () => void, |
| 34 | 32 | ): RenderElement { |
| 35 | - const { Box, Text, Button } = kit.ui | |
| 33 | + const { Box, Text, Button, Code } = kit.ui | |
| 36 | 34 | const placeholder = placeholderOf(detail) |
| 37 | - const hunks = placeholder ? [] : (detail.body?.hunks ?? []) | |
| 38 | - const lines = linesOf(hunks) | |
| 39 | - const gutterCells = gutterCellsOf(lines) | |
| 40 | - const contentCells = Math.max(1, kit.columns - gutterCells - MARKER_CELLS) | |
| 41 | - const rows = placeholder ? [] : rowsOf(lines, contentCells) | |
| 42 | - const cells = { gutterCells, contentCells } | |
| 43 | - const maxRows = maxRowsOf(rows, cells) | |
| 44 | - const isTruncated = detail.body?.isTruncated === true || rows.length > maxRows | |
| 45 | - const nameOf = (path: string) => | |
| 46 | - Layout.truncateStart(Layout.sanitizeName(path), kit.columns) | |
| 35 | + const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? [])) | |
| 36 | + const isTruncated = detail.body?.isTruncated === true || code.isTruncated | |
| 37 | + const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) | |
| 38 | + | |
| 39 | + const nameOf = (name: string) => | |
| 40 | + Layout.truncateStart(Layout.sanitizeName(name), kit.columns) | |
| 41 | + | |
| 47 | 42 | const renamedFrom = |
| 48 | 43 | detail.renamedFrom === null ? null : nameOf(detail.renamedFrom) |
| 44 | + | |
| 49 | 45 | const asides = [ |
| 50 | 46 | renamedFrom === null ? null : `renamed from ${renamedFrom}`, |
| 51 | 47 | detail.isUntracked ? 'untracked' : null, |
| 52 | 48 | isTruncated ? 'truncated' : null, |
| 53 | 49 | ].filter(word => word !== null) |
| 50 | + | |
| 54 | 51 | const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})` |
| 55 | - const notes = (placeholder ?? []).map(line => ( | |
| 56 | - <Text dimColor italic wrap="wrap"> | |
| 57 | - {line} | |
| 58 | - </Text> | |
| 59 | - )) | |
| 60 | - const body = placeholder | |
| 61 | - ? notes | |
| 62 | - : [hunkBody(kit.ui, rows.slice(0, maxRows), cells)] | |
| 52 | + | |
| 63 | 53 | const ask = placeholder |
| 64 | 54 | ? [] |
| 65 | 55 | : [ |
| @@ -67,6 +57,7 @@ | ||
| 67 | 57 | {detail.isArmed ? 'asked ✓' : 'ask'} |
| 68 | 58 | </Button>, |
| 69 | 59 | ] |
| 60 | + | |
| 70 | 61 | const footer = isTruncated |
| 71 | 62 | ? [ |
| 72 | 63 | <Text dimColor italic> |
| @@ -75,18 +66,34 @@ | ||
| 75 | 66 | ] |
| 76 | 67 | : [] |
| 77 | 68 | |
| 78 | - const header = ( | |
| 79 | - <Box flexDirection="row"> | |
| 69 | + const notes = (placeholder ?? []).map(line => ( | |
| 70 | + <Text dimColor italic wrap="wrap"> | |
| 71 | + {line} | |
| 72 | + </Text> | |
| 73 | + )) | |
| 74 | + | |
| 75 | + const hunks = code.blocks.flatMap(block => [ | |
| 76 | + ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []), | |
| 77 | + <Code source={block.source} format="diff" path={path} />, | |
| 78 | + ]) | |
| 79 | + | |
| 80 | + return ( | |
| 81 | + <Box flexDirection="column"> | |
| 80 | 82 | {[ |
| 81 | - <Text bold wrap="truncate-start"> | |
| 82 | - {nameOf(detail.path)} | |
| 83 | - </Text>, | |
| 84 | - <Text dimColor>{aside}</Text>, | |
| 85 | - <Box flexGrow={1} />, | |
| 86 | - ...ask, | |
| 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>, | |
| 93 | + Sections.divider(kit), | |
| 94 | + ...(placeholder ? notes : hunks), | |
| 95 | + ...footer, | |
| 87 | 96 | ]} |
| 88 | 97 | </Box> |
| 89 | 98 | ) |
| 90 | - | |
| 91 | - return <Box flexDirection="column">{[header, ...body, ...footer]}</Box> | |
| 92 | 99 | } |
| @@ -1,12 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * How wide the line-number gutter is for these lines: the longest number's | |
| 5 | - * digits and one leading cell (StructuredDiff's digits + 1). | |
| 6 | - * | |
| 7 | - * @param lines the numbered lines | |
| 8 | - * @returns the gutter's width in cells | |
| 9 | - */ | |
| 10 | -export const gutterCellsOf = (lines: readonly Types.DiffLine[]) => | |
| 11 | - String(lines.reduce((most, line) => Math.max(most, line.lineNumber), 0)) | |
| 12 | - .length + 1 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './gutter-cells-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The two widths a body is drawn in: the gutter's and the content's, the | |
| 3 | - * marker cells between them. | |
| 4 | - */ | |
| 5 | -export type BodyCells = { | |
| 6 | - gutterCells: number | |
| 7 | - contentCells: number | |
| 8 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './body-cells.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,114 +1,0 @@ | ||
| 1 | -/* @jsxRuntime classic */ | |
| 2 | -/* @jsx h */ | |
| 3 | -/* @jsxFrag Fragment */ | |
| 4 | -import type { RenderElement, RenderNode } from 'claude-code' | |
| 5 | - | |
| 6 | -import type Kit from '../../kit' | |
| 7 | -import Layout from '../../layout' | |
| 8 | -import { BACKGROUNDS } from '../backgrounds' | |
| 9 | -import { MARKER_CELLS } from '../marker-cells' | |
| 10 | -import { MAX_RUN_CHARS } from '../max-run-chars' | |
| 11 | -import { runsOf } from '../runs-of' | |
| 12 | -import type Types from '../types' | |
| 13 | -import { WORD_BACKGROUNDS } from '../word-backgrounds' | |
| 14 | -import type { BodyCells } from './body-cells' | |
| 15 | - | |
| 16 | -/** | |
| 17 | - * A file's rows drawn as StructuredDiff's fallback draws them, in few | |
| 18 | - * nodes: one dim gutter Text beside a column of one Text per run. | |
| 19 | - * | |
| 20 | - * Added and removed runs sit on their theme bands, changed words on the | |
| 21 | - * word bands, every row is padded to the band's width less its text's | |
| 22 | - * chargedWidth; a run's text and the gutter split at MAX_RUN_CHARS a string. | |
| 23 | - * | |
| 24 | - * @param ui the surface's elements | |
| 25 | - * @param rows the laid-out rows (rowsOf), each exactly one terminal row | |
| 26 | - * @param cells the gutter's and the content's widths | |
| 27 | - * @returns the body element | |
| 28 | - */ | |
| 29 | -export function hunkBody( | |
| 30 | - ui: Kit.Ui, | |
| 31 | - rows: readonly Types.Row[], | |
| 32 | - cells: BodyCells, | |
| 33 | -): RenderElement { | |
| 34 | - const { Box, Text } = ui | |
| 35 | - const bandCells = MARKER_CELLS + cells.contentCells | |
| 36 | - | |
| 37 | - function gutterChildren(): string[] { | |
| 38 | - const children: string[] = [] | |
| 39 | - let pending = '' | |
| 40 | - | |
| 41 | - rows.forEach((row, at) => { | |
| 42 | - const label = row.gutter.padStart(cells.gutterCells) | |
| 43 | - const text = `${at === 0 ? '' : '\n'}${label}` | |
| 44 | - | |
| 45 | - if (pending.length + text.length > MAX_RUN_CHARS) { | |
| 46 | - children.push(pending) | |
| 47 | - pending = '' | |
| 48 | - } | |
| 49 | - | |
| 50 | - pending += text | |
| 51 | - }) | |
| 52 | - | |
| 53 | - return pending === '' ? children : [...children, pending] | |
| 54 | - } | |
| 55 | - | |
| 56 | - function runChildren(run: Types.Run): RenderNode[] { | |
| 57 | - const children: RenderNode[] = [] | |
| 58 | - let pending = '' | |
| 59 | - | |
| 60 | - function flush() { | |
| 61 | - if (pending !== '') { | |
| 62 | - children.push(pending) | |
| 63 | - pending = '' | |
| 64 | - } | |
| 65 | - } | |
| 66 | - | |
| 67 | - function plain(text: string) { | |
| 68 | - if (pending.length + text.length > MAX_RUN_CHARS) { | |
| 69 | - flush() | |
| 70 | - } | |
| 71 | - | |
| 72 | - pending += text | |
| 73 | - } | |
| 74 | - | |
| 75 | - function marked(text: string) { | |
| 76 | - flush() | |
| 77 | - children.push( | |
| 78 | - <Text backgroundColor={WORD_BACKGROUNDS[run.kind]}>{text}</Text>, | |
| 79 | - ) | |
| 80 | - } | |
| 81 | - | |
| 82 | - run.rows.forEach((row, at) => { | |
| 83 | - const used = row.segments.reduce( | |
| 84 | - (sum, segment) => sum + Layout.chargedWidth(segment.text), | |
| 85 | - MARKER_CELLS, | |
| 86 | - ) | |
| 87 | - plain(`${at === 0 ? '' : '\n'} ${row.marker} `) | |
| 88 | - | |
| 89 | - for (const segment of row.segments) { | |
| 90 | - ;(segment.isChanged ? marked : plain)(segment.text) | |
| 91 | - } | |
| 92 | - | |
| 93 | - plain(' '.repeat(Math.max(0, bandCells - used))) | |
| 94 | - }) | |
| 95 | - flush() | |
| 96 | - | |
| 97 | - return children | |
| 98 | - } | |
| 99 | - | |
| 100 | - return ( | |
| 101 | - <Box flexDirection="row"> | |
| 102 | - <Text dimColor wrap="truncate-end"> | |
| 103 | - {gutterChildren()} | |
| 104 | - </Text> | |
| 105 | - <Box flexDirection="column" flexShrink={0} width={bandCells}> | |
| 106 | - {runsOf(rows).map(run => ( | |
| 107 | - <Text backgroundColor={BACKGROUNDS[run.kind]} wrap="truncate-end"> | |
| 108 | - {runChildren(run)} | |
| 109 | - </Text> | |
| 110 | - ))} | |
| 111 | - </Box> | |
| 112 | - </Box> | |
| 113 | - ) | |
| 114 | -} |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './body-cells' | |
| 2 | -export * from './hunk-body.jsx' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 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,0 +1,3 @@ | ||
| 1 | +export * from './hunk-divider.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +/** | |
| 2 | + * The nodes one HUNK_DIVIDER costs the tree: its Text and its string. | |
| 3 | + */ | |
| 4 | +export const HUNK_DIVIDER_NODES = 2 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-divider-nodes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import { countOf } from '../../../count-of' | |
| 2 | +import type Git from '../../../git' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk's `@@ -a,b +c,d @@` header, its counts read off its lines, so a | |
| 6 | + * cut or split hunk still parses: context counts on both sides. | |
| 7 | + * | |
| 8 | + * A side with no lines names the line before its start, as jsdiff's | |
| 9 | + * formatPatch writes a structured hunk and the engine's parser reads back. | |
| 10 | + * | |
| 11 | + * @param hunk the hunk, its lines body lines only | |
| 12 | + * @returns the header line | |
| 13 | + */ | |
| 14 | +export function hunkHeaderOf(hunk: Git.Hunk) { | |
| 15 | + const oldLines = countOf(hunk.lines, line => !line.startsWith('+')) | |
| 16 | + const newLines = countOf(hunk.lines, line => !line.startsWith('-')) | |
| 17 | + const isOldEmpty = oldLines === 0 | |
| 18 | + const isNewEmpty = newLines === 0 | |
| 19 | + const oldStart = isOldEmpty ? Math.max(0, hunk.oldStart - 1) : hunk.oldStart | |
| 20 | + const newStart = isNewEmpty ? Math.max(0, hunk.newStart - 1) : hunk.newStart | |
| 21 | + | |
| 22 | + return `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@` | |
| 23 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-header-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import { hunkHeaderOf } from '../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk as unified-diff text: its header (hunkHeaderOf), then its lines. | |
| 6 | + * | |
| 7 | + * @param hunk the hunk, its lines body lines only | |
| 8 | + * @returns the text, no trailing newline | |
| 9 | + */ | |
| 10 | +export const hunkSourceOf = (hunk: Git.Hunk) => | |
| 11 | + [hunkHeaderOf(hunk), ...hunk.lines].join('\n') |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './hunk-source-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,18 +1,16 @@ | ||
| 1 | -export * from './backgrounds' | |
| 1 | +export * from './code-blocks-of' | |
| 2 | 2 | export * from './detail-model' |
| 3 | 3 | export * from './detail-view.jsx' |
| 4 | -export * from './gutter-cells-of' | |
| 5 | -export * from './hunk-body' | |
| 6 | -export * from './lines-of' | |
| 7 | -export * from './marker-cells' | |
| 4 | +export * from './hunk-divider' | |
| 5 | +export * from './hunk-divider-nodes' | |
| 6 | +export * from './hunk-header-of' | |
| 7 | +export * from './hunk-source-of' | |
| 8 | +export * from './leading-hunk-of' | |
| 8 | 9 | export * from './max-body-chars' |
| 9 | 10 | export * from './max-body-nodes' |
| 10 | -export * from './max-rows-of' | |
| 11 | -export * from './max-run-chars' | |
| 11 | +export * from './max-code-chars' | |
| 12 | 12 | export * from './placeholder-of' |
| 13 | -export * from './rows-of' | |
| 14 | -export * from './runs-of' | |
| 13 | +export * from './sub-hunks-of' | |
| 15 | 14 | export * from './types' |
| 16 | -export * from './word-backgrounds' | |
| 17 | 15 | |
| 18 | 16 | export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './leading-hunk-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,32 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import { hunkHeaderOf } from '../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The most of a hunk's first lines whose text (hunkSourceOf) fits the | |
| 6 | + * room, whole lines only: none when the first line alone does not fit. | |
| 7 | + * | |
| 8 | + * @param hunk the hunk, its lines body lines only | |
| 9 | + * @param maxChars the room, in UTF-16 units | |
| 10 | + * @returns the hunk cut to those lines, its starts unchanged | |
| 11 | + */ | |
| 12 | +export function leadingHunkOf(hunk: Git.Hunk, maxChars: number): Git.Hunk { | |
| 13 | + let bodyChars = 0 | |
| 14 | + let taken = 0 | |
| 15 | + | |
| 16 | + for (const line of hunk.lines) { | |
| 17 | + const header = hunkHeaderOf({ | |
| 18 | + ...hunk, | |
| 19 | + lines: hunk.lines.slice(0, taken + 1), | |
| 20 | + }) | |
| 21 | + | |
| 22 | + bodyChars += 1 + line.length | |
| 23 | + | |
| 24 | + if (header.length + bodyChars > maxChars) { | |
| 25 | + break | |
| 26 | + } | |
| 27 | + | |
| 28 | + taken += 1 | |
| 29 | + } | |
| 30 | + | |
| 31 | + return { ...hunk, lines: hunk.lines.slice(0, taken) } | |
| 32 | +} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './lines-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,74 +1,0 @@ | ||
| 1 | -import type Git from '../../../git' | |
| 2 | -import Layout from '../../layout' | |
| 3 | -import type Types from '../types' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * A file's hunks as numbered lines, hunk after hunk: context and added | |
| 7 | - * lines count the new file's lines, removed ones the old file's. | |
| 8 | - * | |
| 9 | - * Each run of removed lines is paired index by index with the added run | |
| 10 | - * right after it, for the word diff, as the engine's fallback diff pairs. | |
| 11 | - * | |
| 12 | - * @param hunks the parsed hunks | |
| 13 | - * @returns the lines in drawing order | |
| 14 | - */ | |
| 15 | -export function linesOf(hunks: readonly Git.Hunk[]): Types.DiffLine[] { | |
| 16 | - const lines: Types.DiffLine[] = [] | |
| 17 | - | |
| 18 | - for (const hunk of hunks) { | |
| 19 | - let oldNumber = hunk.oldStart | |
| 20 | - let newNumber = hunk.newStart | |
| 21 | - let removedRun: Types.DiffLine[] = [] | |
| 22 | - let pairing = 0 | |
| 23 | - | |
| 24 | - for (const raw of hunk.lines) { | |
| 25 | - const text = Layout.sanitizeLine(raw.slice(1)) | |
| 26 | - const kind: Types.LineKind = raw.startsWith('+') | |
| 27 | - ? 'added' | |
| 28 | - : raw.startsWith('-') | |
| 29 | - ? 'removed' | |
| 30 | - : 'context' | |
| 31 | - | |
| 32 | - if (kind === 'removed') { | |
| 33 | - const isNewRun = pairing > 0 | |
| 34 | - | |
| 35 | - if (isNewRun) { | |
| 36 | - removedRun = [] | |
| 37 | - pairing = 0 | |
| 38 | - } | |
| 39 | - | |
| 40 | - const line = { kind, lineNumber: oldNumber, text, pairedText: null } | |
| 41 | - removedRun.push(line) | |
| 42 | - lines.push(line) | |
| 43 | - oldNumber += 1 | |
| 44 | - continue | |
| 45 | - } | |
| 46 | - | |
| 47 | - if (kind === 'added') { | |
| 48 | - const partner = removedRun[pairing] | |
| 49 | - | |
| 50 | - if (partner) { | |
| 51 | - partner.pairedText = text | |
| 52 | - pairing += 1 | |
| 53 | - } | |
| 54 | - | |
| 55 | - lines.push({ | |
| 56 | - kind, | |
| 57 | - lineNumber: newNumber, | |
| 58 | - text, | |
| 59 | - pairedText: partner ? partner.text : null, | |
| 60 | - }) | |
| 61 | - newNumber += 1 | |
| 62 | - continue | |
| 63 | - } | |
| 64 | - | |
| 65 | - removedRun = [] | |
| 66 | - pairing = 0 | |
| 67 | - lines.push({ kind, lineNumber: newNumber, text, pairedText: null }) | |
| 68 | - oldNumber += 1 | |
| 69 | - newNumber += 1 | |
| 70 | - } | |
| 71 | - } | |
| 72 | - | |
| 73 | - return lines | |
| 74 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './marker-cells.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The cells between the gutter and a row's content: a space, the `+`, `-` | |
| 3 | - * or space marker, and a space. | |
| 4 | - */ | |
| 5 | -export const MARKER_CELLS = 3 |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | - * The most characters a file's drawn body holds in all, padding included: | |
| 3 | - * under the host's whole-tree cap (100 000) with room for the pane's rest. | |
| 2 | + * The most characters a file's drawn body holds, its `Code` sources and | |
| 3 | + * dividers in all. | |
| 4 | + * | |
| 5 | + * Under the host's whole-tree cap (100 000), with room for the pane's rest. | |
| 4 | 6 | */ |
| 5 | 7 | export const MAX_BODY_CHARS = 80_000 |
| @@ -1,5 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | - * The most nodes a file's drawn body holds, strings included: under the | |
| 3 | - * host's whole-tree cap (2 000) with room for the list and the pane's rest. | |
| 2 | + * The most nodes a file's drawn body holds, a `Code` one and a divider two. | |
| 3 | + * | |
| 4 | + * Under the host's whole-tree cap (2 000), with room for the list and the | |
| 5 | + * pane's rest. | |
| 4 | 6 | */ |
| 5 | 7 | export const MAX_BODY_NODES = 1_200 |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './max-code-chars.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * The most characters one `Code` source (or path) holds: the host's cap on | |
| 3 | + * a leaf's text (10 000), which a hooks module cannot import. | |
| 4 | + */ | |
| 5 | +export const MAX_CODE_CHARS = 10_000 |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes the body costs before any row: its row Box, the gutter Text | |
| 3 | - * beside it, and the column Box that holds the runs. | |
| 4 | - */ | |
| 5 | -export const FRAME_NODES = 3 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './frame-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes one word highlight costs inside a run: the highlighted Text, | |
| 3 | - * its string, and the string the run resumes with. | |
| 4 | - */ | |
| 5 | -export const HIGHLIGHT_NODES = 3 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './highlight-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,0 @@ | ||
| 1 | -export * from './frame-nodes' | |
| 2 | -export * from './highlight-nodes' | |
| 3 | -export * from './max-rows-of.js' | |
| 4 | -export * from './run-nodes' | |
| 5 | - | |
| 6 | -export * as default from '.' |
| @@ -1,59 +1,0 @@ | ||
| 1 | -import Layout from '../../layout' | |
| 2 | -import type { BodyCells } from '../hunk-body/body-cells' | |
| 3 | -import { MARKER_CELLS } from '../marker-cells' | |
| 4 | -import { MAX_BODY_CHARS } from '../max-body-chars' | |
| 5 | -import { MAX_BODY_NODES } from '../max-body-nodes' | |
| 6 | -import { MAX_RUN_CHARS } from '../max-run-chars' | |
| 7 | -import type Types from '../types' | |
| 8 | -import { FRAME_NODES } from './frame-nodes' | |
| 9 | -import { HIGHLIGHT_NODES } from './highlight-nodes' | |
| 10 | -import { RUN_NODES } from './run-nodes' | |
| 11 | - | |
| 12 | -/** | |
| 13 | - * How many of these rows the body may draw under MAX_BODY_CHARS and | |
| 14 | - * MAX_BODY_NODES, counted as the host counts what hunkBody draws. | |
| 15 | - * | |
| 16 | - * A row costs its gutter, marker, segments and padding in UTF-16 units (an | |
| 17 | - * astral letter costs two); a change of kind opens a run (a Text and its | |
| 18 | - * string), a highlight is three nodes, the gutter and long runs split. | |
| 19 | - * | |
| 20 | - * @param rows the laid-out rows, in order | |
| 21 | - * @param cells the gutter's and the content's widths | |
| 22 | - * @returns the count of leading rows whose drawn tree fits both budgets | |
| 23 | - */ | |
| 24 | -export function maxRowsOf(rows: readonly Types.Row[], cells: BodyCells) { | |
| 25 | - let chars = 0 | |
| 26 | - let nodes = FRAME_NODES | |
| 27 | - let kept = 0 | |
| 28 | - let kind: Types.LineKind | undefined | |
| 29 | - | |
| 30 | - for (const row of rows) { | |
| 31 | - let units = 0 | |
| 32 | - let charged = 0 | |
| 33 | - let highlights = 0 | |
| 34 | - | |
| 35 | - for (const segment of row.segments) { | |
| 36 | - units += segment.text.length | |
| 37 | - charged += Layout.chargedWidth(segment.text) | |
| 38 | - highlights += segment.isChanged ? 1 : 0 | |
| 39 | - } | |
| 40 | - | |
| 41 | - chars += | |
| 42 | - cells.gutterCells + | |
| 43 | - 2 + | |
| 44 | - MARKER_CELLS + | |
| 45 | - units + | |
| 46 | - Math.max(0, cells.contentCells - charged) | |
| 47 | - nodes += (row.kind === kind ? 0 : RUN_NODES) + highlights * HIGHLIGHT_NODES | |
| 48 | - kind = row.kind | |
| 49 | - const splits = Math.ceil((2 * chars) / MAX_RUN_CHARS) | |
| 50 | - | |
| 51 | - if (chars > MAX_BODY_CHARS || nodes + splits > MAX_BODY_NODES) { | |
| 52 | - return kept | |
| 53 | - } | |
| 54 | - | |
| 55 | - kept += 1 | |
| 56 | - } | |
| 57 | - | |
| 58 | - return kept | |
| 59 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './run-nodes.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The nodes a change of line kind opens: a Text and its string. | |
| 3 | - */ | |
| 4 | -export const RUN_NODES = 2 |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './max-run-chars.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * The most characters one Text string of a run holds: under the host's | |
| 3 | - * per-string cap (10 000), which a hooks module cannot import. | |
| 4 | - */ | |
| 5 | -export const MAX_RUN_CHARS = 8_000 |
| @@ -34,6 +34,7 @@ | ||
| 34 | 34 | |
| 35 | 35 | const { isLarge, hunks } = detail.body |
| 36 | 36 | const isEmpty = hunks.length === 0 |
| 37 | + | |
| 37 | 38 | const note = isLarge |
| 38 | 39 | ? 'Large file - diff exceeds 1 MB limit' |
| 39 | 40 | : isEmpty |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | - * A file name safe to paste into a stage command in any shell (POSIX, | |
| 3 | - * PowerShell, cmd): letters and digits of any script and `._/@+-` only. | |
| 2 | + * A file name safe to paste, unquoted, into a stage command in any shell | |
| 3 | + * (POSIX, PowerShell, cmd). | |
| 4 | 4 | * |
| 5 | - * No quote of any kind, no separator and no substitution can ride in it. | |
| 5 | + * Letters and digits of any script and `._/@+-` only: no quote of any | |
| 6 | + * kind, no separator and no substitution can ride in it. | |
| 6 | 7 | */ |
| 7 | 8 | export const SAFE_PATHSPEC_PATTERN = /^[\p{L}\p{N}._/@+-]+$/u |
| @@ -1,5 +1,0 @@ | ||
| 1 | -export * from './max-highlights' | |
| 2 | -export * from './rows-of.js' | |
| 3 | -export * from './segments-of' | |
| 4 | - | |
| 5 | -export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './max-highlights.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,5 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * How many word-diff highlights one body may draw; past it lines draw plain, | |
| 3 | - * keeping the tree under its node cap (each highlight is two nodes). | |
| 4 | - */ | |
| 5 | -export const MAX_HIGHLIGHTS = 300 |
| @@ -1,48 +1,0 @@ | ||
| 1 | -import { countOf } from '../../../count-of' | |
| 2 | -import Layout from '../../layout' | |
| 3 | -import type Types from '../types' | |
| 4 | -import { MAX_HIGHLIGHTS } from './max-highlights' | |
| 5 | -import { segmentsOf } from './segments-of' | |
| 6 | - | |
| 7 | -/** | |
| 8 | - * Numbered lines laid into terminal rows: a long line wraps onto | |
| 9 | - * continuation rows with an empty gutter. | |
| 10 | - * | |
| 11 | - * Word highlights stop once MAX_HIGHLIGHTS are spent, keeping the tree | |
| 12 | - * under its node cap. | |
| 13 | - * | |
| 14 | - * @param lines the numbered lines | |
| 15 | - * @param cells the content's room per row after gutter and marker, one at | |
| 16 | - * least | |
| 17 | - * @returns the rows in drawing order | |
| 18 | - */ | |
| 19 | -export function rowsOf( | |
| 20 | - lines: readonly Types.DiffLine[], | |
| 21 | - cells: number, | |
| 22 | -): Types.Row[] { | |
| 23 | - const rows: Types.Row[] = [] | |
| 24 | - let highlights = 0 | |
| 25 | - | |
| 26 | - for (const line of lines) { | |
| 27 | - const marked = segmentsOf(line) | |
| 28 | - const wanted = countOf(marked, segment => segment.isChanged) | |
| 29 | - const isAffordable = highlights + wanted <= MAX_HIGHLIGHTS | |
| 30 | - highlights += isAffordable ? wanted : 0 | |
| 31 | - const segments = isAffordable | |
| 32 | - ? marked | |
| 33 | - : [{ text: line.text, isChanged: false }] | |
| 34 | - const marker = | |
| 35 | - line.kind === 'added' ? '+' : line.kind === 'removed' ? '-' : ' ' | |
| 36 | - | |
| 37 | - Layout.wrapSegments(segments, cells).forEach((wrapped, at) => { | |
| 38 | - rows.push({ | |
| 39 | - kind: line.kind, | |
| 40 | - gutter: at === 0 ? String(line.lineNumber) : '', | |
| 41 | - marker: at === 0 ? marker : ' ', | |
| 42 | - segments: wrapped, | |
| 43 | - }) | |
| 44 | - }) | |
| 45 | - } | |
| 46 | - | |
| 47 | - return rows | |
| 48 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './segments-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,32 +1,0 @@ | ||
| 1 | -import { wordDiff } from '../../../../word-diff' | |
| 2 | -import type Layout from '../../../layout' | |
| 3 | -import type Types from '../../types' | |
| 4 | - | |
| 5 | -/** | |
| 6 | - * A line's content as segments: one plain segment, or, for a paired line | |
| 7 | - * whose word diff holds, its own side's parts with the changed ones marked. | |
| 8 | - * | |
| 9 | - * @param line the numbered line | |
| 10 | - * @returns the segments in order | |
| 11 | - */ | |
| 12 | -export function segmentsOf(line: Types.DiffLine): Layout.Segment[] { | |
| 13 | - const plain = [{ text: line.text, isChanged: false }] | |
| 14 | - | |
| 15 | - if (line.pairedText === null || line.kind === 'context') { | |
| 16 | - return plain | |
| 17 | - } | |
| 18 | - | |
| 19 | - const isRemoved = line.kind === 'removed' | |
| 20 | - const parts = isRemoved | |
| 21 | - ? wordDiff(line.text, line.pairedText) | |
| 22 | - : wordDiff(line.pairedText, line.text) | |
| 23 | - const ownSide = isRemoved ? 'removed' : 'added' | |
| 24 | - | |
| 25 | - if (!parts) { | |
| 26 | - return plain | |
| 27 | - } | |
| 28 | - | |
| 29 | - return parts | |
| 30 | - .filter(part => part.kind === 'same' || part.kind === ownSide) | |
| 31 | - .map(part => ({ text: part.text, isChanged: part.kind === ownSide })) | |
| 32 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './runs-of.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,23 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * Rows grouped into runs of one kind, in order. | |
| 5 | - * | |
| 6 | - * @param rows the laid-out rows | |
| 7 | - * @returns the runs | |
| 8 | - */ | |
| 9 | -export function runsOf(rows: readonly Types.Row[]): Types.Run[] { | |
| 10 | - const runs: Types.Run[] = [] | |
| 11 | - let start = 0 | |
| 12 | - | |
| 13 | - rows.forEach((row, at) => { | |
| 14 | - const isRunEnd = rows[at + 1]?.kind !== row.kind | |
| 15 | - | |
| 16 | - if (isRunEnd) { | |
| 17 | - runs.push({ kind: row.kind, rows: rows.slice(start, at + 1) }) | |
| 18 | - start = at + 1 | |
| 19 | - } | |
| 20 | - }) | |
| 21 | - | |
| 22 | - return runs | |
| 23 | -} |
| @@ -1,0 +1,24 @@ | ||
| 1 | +import type Git from '../../../../git' | |
| 2 | +import { hunkHeaderOf } from '../../hunk-header-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A hunk's first line cut so that it alone, under its header, fits the | |
| 6 | + * room: its marker kept, no surrogate pair split. | |
| 7 | + * | |
| 8 | + * @param hunk the hunk, its first line too long for a sub-hunk of its own | |
| 9 | + * @param maxChars the room the one-line sub-hunk's text fits | |
| 10 | + * @returns the cut line | |
| 11 | + */ | |
| 12 | +export function firstLineCut(hunk: Git.Hunk, maxChars: number) { | |
| 13 | + const [first = ''] = hunk.lines | |
| 14 | + | |
| 15 | + const kept = first.slice( | |
| 16 | + 0, | |
| 17 | + Math.max( | |
| 18 | + 1, | |
| 19 | + maxChars - 1 - hunkHeaderOf({ ...hunk, lines: [first] }).length, | |
| 20 | + ), | |
| 21 | + ) | |
| 22 | + | |
| 23 | + return /[\uD800-\uDBFF]$/u.test(kept) ? kept.slice(0, -1) : kept | |
| 24 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './first-line-cut.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export * from './first-line-cut' | |
| 2 | +export * from './sub-hunks-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,46 @@ | ||
| 1 | +import { countOf } from '../../../count-of' | |
| 2 | +import type Git from '../../../git' | |
| 3 | +import { leadingHunkOf } from '../leading-hunk-of' | |
| 4 | +import { firstLineCut } from './first-line-cut' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * A hunk as consecutive sub-hunks whose texts (hunkSourceOf) each fit the | |
| 8 | + * room, each starting where the last left off, so the counts add up. | |
| 9 | + * | |
| 10 | + * A line too long for a sub-hunk of its own is cut to fit, not dropped, and | |
| 11 | + * `isTruncated` says so; a hunk that fits comes back whole. | |
| 12 | + * | |
| 13 | + * @param hunk the hunk, its lines body lines only | |
| 14 | + * @param maxChars the room each sub-hunk's text fits, in UTF-16 units | |
| 15 | + * @returns the sub-hunks in order, and whether a line was cut | |
| 16 | + */ | |
| 17 | +export function subHunksOf( | |
| 18 | + hunk: Git.Hunk, | |
| 19 | + maxChars: number, | |
| 20 | +): Pick<Git.FileHunks, 'hunks' | 'isTruncated'> { | |
| 21 | + const hunks: Git.Hunk[] = [] | |
| 22 | + let rest = hunk | |
| 23 | + let isTruncated = false | |
| 24 | + | |
| 25 | + while (rest.lines.length > 0) { | |
| 26 | + const leading = leadingHunkOf(rest, maxChars) | |
| 27 | + const isLineCut = leading.lines.length === 0 | |
| 28 | + | |
| 29 | + const piece = isLineCut | |
| 30 | + ? { ...rest, lines: [firstLineCut(rest, maxChars)] } | |
| 31 | + : leading | |
| 32 | + | |
| 33 | + const taken = rest.lines.slice(0, piece.lines.length) | |
| 34 | + | |
| 35 | + hunks.push(piece) | |
| 36 | + isTruncated ||= isLineCut | |
| 37 | + | |
| 38 | + rest = { | |
| 39 | + oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')), | |
| 40 | + newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')), | |
| 41 | + lines: rest.lines.slice(piece.lines.length), | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + return { hunks, isTruncated } | |
| 46 | +} |
| @@ -1,0 +1,8 @@ | ||
| 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,0 +1,3 @@ | ||
| 1 | +export type * from './code-block.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type { CodeBlock } from '../code-block' | |
| 2 | + | |
| 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. | |
| 6 | + */ | |
| 7 | +export type CodeBody = { | |
| 8 | + blocks: readonly CodeBlock[] | |
| 9 | + isTruncated: boolean | |
| 10 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './code-body.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,15 +1,0 @@ | ||
| 1 | -import type { LineKind } from '../line-kind' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * One hunk line ready to lay out: its side, its gutter's line number, its | |
| 5 | - * sanitized text, and the paired line's text for a word diff, if any. | |
| 6 | - * | |
| 7 | - * The number is the new file's for context and added lines, the old | |
| 8 | - * file's for removed ones; a removed line pairs with its added counterpart. | |
| 9 | - */ | |
| 10 | -export type DiffLine = { | |
| 11 | - kind: LineKind | |
| 12 | - lineNumber: number | |
| 13 | - text: string | |
| 14 | - pairedText: string | null | |
| 15 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './diff-line.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,6 +1,4 @@ | ||
| 1 | -export * from './diff-line' | |
| 2 | -export * from './line-kind' | |
| 3 | -export * from './row' | |
| 4 | -export type * from './run.js' | |
| 1 | +export * from './code-block' | |
| 2 | +export * from './code-body' | |
| 5 | 3 | |
| 6 | 4 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './line-kind.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * Which side of the diff a line is on, read from its marker. | |
| 3 | - */ | |
| 4 | -export type LineKind = 'context' | 'added' | 'removed' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './row.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type Layout from '../../../layout' | |
| 2 | -import type { LineKind } from '../line-kind' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * One terminal row of a file's body: the gutter's text (the line number, | |
| 6 | - * or nothing on a continuation), the marker, and the content's segments. | |
| 7 | - */ | |
| 8 | -export type Row = { | |
| 9 | - kind: LineKind | |
| 10 | - gutter: string | |
| 11 | - marker: string | |
| 12 | - segments: readonly Layout.Segment[] | |
| 13 | -} |
| @@ -1,11 +1,0 @@ | ||
| 1 | -import type { LineKind } from './line-kind' | |
| 2 | -import type { Row } from './row' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * Consecutive rows of one kind, drawn as one Text so a long file stays far | |
| 6 | - * under the tree's node cap. | |
| 7 | - */ | |
| 8 | -export type Run = { | |
| 9 | - kind: LineKind | |
| 10 | - rows: readonly Row[] | |
| 11 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './word-backgrounds.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,13 +1,0 @@ | ||
| 1 | -import type Types from '../types' | |
| 2 | - | |
| 3 | -/** | |
| 4 | - * The theme key behind a changed word of each kind; a context line never | |
| 5 | - * carries one, so it has none. | |
| 6 | - */ | |
| 7 | -export const WORD_BACKGROUNDS: Readonly< | |
| 8 | - Record<Types.LineKind, string | undefined> | |
| 9 | -> = { | |
| 10 | - added: 'diffAddedWord', | |
| 11 | - removed: 'diffRemovedWord', | |
| 12 | - context: undefined, | |
| 13 | -} |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './current-pane' |
| 2 | 2 | export * from './detail' |
| 3 | +export * from './inset-of' | |
| 3 | 4 | export * from './kit' |
| 4 | 5 | export * from './layout' |
| 5 | 6 | export * from './pane-actions' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './inset-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import Limits from '../../limits' | |
| 2 | +import type { Kit } from '../kit' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A drawing's kit inside the docked pane's padding: the built-in's blank | |
| 6 | + * last column and its blank row above the header taken off. | |
| 7 | + * | |
| 8 | + * @param kit the drawing's kit at the pane's full size | |
| 9 | + * @returns the kit its body draws with | |
| 10 | + */ | |
| 11 | +export const insetOf = (kit: Kit): Kit => ({ | |
| 12 | + ...kit, | |
| 13 | + columns: Math.max(1, kit.columns - Limits.PANE_RIGHT_PAD_COLUMNS), | |
| 14 | + rows: Math.max(0, kit.rows - Limits.PANE_TOP_PAD_ROWS), | |
| 15 | +}) |
| @@ -2,11 +2,14 @@ | ||
| 2 | 2 | import type { Ui } from './ui' |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | - * What every part of one drawing is handed: the surface's elements, the | |
| 6 | - * controls' handlers, and the body's width (`props.bodyColumns`). | |
| 5 | + * What every part of one drawing is handed. | |
| 6 | + * | |
| 7 | + * The surface's elements, the controls' handlers, the body's width | |
| 8 | + * (`props.bodyColumns`) and its rows (`props.scroll.bodyRows`). | |
| 7 | 9 | */ |
| 8 | 10 | export type Kit = { |
| 9 | 11 | ui: Ui |
| 10 | 12 | actions: PaneActions |
| 11 | 13 | columns: number |
| 14 | + rows: number | |
| 12 | 15 | } |
| @@ -4,10 +4,10 @@ | ||
| 4 | 4 | * The element constructors the pane draws with, destructured from the |
| 5 | 5 | * table `$.ui.resolve(e)` hands the render hook. |
| 6 | 6 | * |
| 7 | - * The terminal and the desktop carry all four; the mobile app has no | |
| 7 | + * The terminal and the desktop carry all five; the mobile app has no | |
| 8 | 8 | * Select, so the pane never draws there. |
| 9 | 9 | */ |
| 10 | 10 | export type Ui = Pick< |
| 11 | 11 | ElementTable<'terminal' | 'desktop'>, |
| 12 | - 'Box' | 'Text' | 'Button' | 'Select' | |
| 12 | + 'Box' | 'Text' | 'Button' | 'Select' | 'Code' | |
| 13 | 13 | > |
| @@ -17,8 +17,10 @@ | ||
| 17 | 17 | |
| 18 | 18 | for (const character of text) { |
| 19 | 19 | const point = character.codePointAt(0) ?? 0 |
| 20 | + | |
| 20 | 21 | const holds = ([low, high]: readonly [number, number]) => |
| 21 | 22 | point >= low && point <= high |
| 23 | + | |
| 22 | 24 | const isNarrow = point < FIRST_WIDE_POINT |
| 23 | 25 | const isZero = !isNarrow && ZERO_WIDTH_RANGES.some(holds) |
| 24 | 26 | const isWide = !isNarrow && !isZero && WIDE_RANGES.some(holds) |
| @@ -3,10 +3,8 @@ | ||
| 3 | 3 | export * from './plural' |
| 4 | 4 | export * from './sanitize-line' |
| 5 | 5 | export * from './sanitize-name' |
| 6 | -export * from './segment' | |
| 7 | -export * from './slice-cells' | |
| 8 | 6 | export * from './tab-width' |
| 9 | 7 | export * from './truncate-start' |
| 10 | -export * from './wrap-segments' | |
| 8 | +export * from './wrapped-lines' | |
| 11 | 9 | |
| 12 | 10 | export * as default from '.' |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './segment.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,8 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * One stretch of a drawn diff row: its text and whether the word diff | |
| 3 | - * marks it as changed. | |
| 4 | - */ | |
| 5 | -export type Segment = { | |
| 6 | - text: string | |
| 7 | - isChanged: boolean | |
| 8 | -} |
| @@ -1,9 +1,0 @@ | ||
| 1 | -/** | |
| 2 | - * A line cut at a cell budget: the head that fits, the budget it spent | |
| 3 | - * (a code point that draws in no cell still spends one), and the tail. | |
| 4 | - */ | |
| 5 | -export type CellsSplit = { | |
| 6 | - head: string | |
| 7 | - spent: number | |
| 8 | - tail: string | |
| 9 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export type * from './cells-split.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,4 +1,0 @@ | ||
| 1 | -export * from './cells-split' | |
| 2 | -export * from './slice-cells.js' | |
| 3 | - | |
| 4 | -export * as default from '.' |
| @@ -1,37 +1,0 @@ | ||
| 1 | -import { chargedWidth } from '../charged-width' | |
| 2 | -import type { CellsSplit } from './cells-split' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * The longest head of a line that fits a cell budget, cut between code | |
| 6 | - * points, and what is left after it. | |
| 7 | - * | |
| 8 | - * The budget is spent by chargedWidth, at least one cell a code point, so | |
| 9 | - * a head never holds more code points than cells: a line of ten thousand | |
| 10 | - * combining marks still wraps. | |
| 11 | - * | |
| 12 | - * @param text a sanitized line | |
| 13 | - * @param cells the room, at least one cell | |
| 14 | - * @returns the head that fits, the budget it spent, and the remaining tail | |
| 15 | - */ | |
| 16 | -export function sliceCells(text: string, cells: number): CellsSplit { | |
| 17 | - let spent = 0 | |
| 18 | - let taken = 0 | |
| 19 | - const characters = [...text] | |
| 20 | - | |
| 21 | - for (const character of characters) { | |
| 22 | - const next = spent + chargedWidth(character) | |
| 23 | - | |
| 24 | - if (next > cells && taken > 0) { | |
| 25 | - break | |
| 26 | - } | |
| 27 | - | |
| 28 | - spent = next | |
| 29 | - taken += 1 | |
| 30 | - } | |
| 31 | - | |
| 32 | - return { | |
| 33 | - head: characters.slice(0, taken).join(''), | |
| 34 | - spent, | |
| 35 | - tail: characters.slice(taken).join(''), | |
| 36 | - } | |
| 37 | -} |
| @@ -1,3 +1,0 @@ | ||
| 1 | -export * from './wrap-segments.js' | |
| 2 | - | |
| 3 | -export * as default from '.' |
| @@ -1,43 +1,0 @@ | ||
| 1 | -import type { Segment } from '../segment' | |
| 2 | -import { sliceCells } from '../slice-cells' | |
| 3 | - | |
| 4 | -/** | |
| 5 | - * A line's segments laid into rows of at most a cell budget, a segment | |
| 6 | - * split where a row fills, so each row draws as exactly one terminal row. | |
| 7 | - * | |
| 8 | - * A row holds no more code points than cells (sliceCells' spending), and | |
| 9 | - * a continuation can sit under an empty gutter. | |
| 10 | - * | |
| 11 | - * @param segments the line's segments in order | |
| 12 | - * @param cells the room per row, at least one cell | |
| 13 | - * @returns the rows; one empty row for an empty line | |
| 14 | - */ | |
| 15 | -export function wrapSegments( | |
| 16 | - segments: readonly Segment[], | |
| 17 | - cells: number, | |
| 18 | -): Segment[][] { | |
| 19 | - const rows: Segment[][] = [] | |
| 20 | - let row: Segment[] = [] | |
| 21 | - let used = 0 | |
| 22 | - | |
| 23 | - for (const segment of segments) { | |
| 24 | - let rest = segment.text | |
| 25 | - | |
| 26 | - while (rest !== '') { | |
| 27 | - const split = sliceCells(rest, Math.max(1, cells - used)) | |
| 28 | - row.push({ text: split.head, isChanged: segment.isChanged }) | |
| 29 | - used += split.spent | |
| 30 | - rest = split.tail | |
| 31 | - | |
| 32 | - if (rest !== '' || used >= cells) { | |
| 33 | - rows.push(row) | |
| 34 | - row = [] | |
| 35 | - used = 0 | |
| 36 | - } | |
| 37 | - } | |
| 38 | - } | |
| 39 | - | |
| 40 | - const isRowPending = row.length > 0 || rows.length === 0 | |
| 41 | - | |
| 42 | - return isRowPending ? [...rows, row] : rows | |
| 43 | -} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './wrapped-lines.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,73 @@ | ||
| 1 | +import { cellWidth } from '../cell-width' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A line cut into rows as Ink's `wrap` cuts the built-in's empty state | |
| 5 | + * before it centers each row. | |
| 6 | + * | |
| 7 | + * It cuts at spaces, keeping the space a row breaks on at its end; a word | |
| 8 | + * wider than a row is cut across rows. | |
| 9 | + * | |
| 10 | + * @param text one sanitized line | |
| 11 | + * @param columns the width, at least one cell; no row holds more | |
| 12 | + * @returns the rows, at least one | |
| 13 | + */ | |
| 14 | +export function wrappedLines(text: string, columns: number): string[] { | |
| 15 | + const width = Math.max(1, columns) | |
| 16 | + const rows: string[] = [''] | |
| 17 | + const words = text.split(' ') | |
| 18 | + | |
| 19 | + for (const [index, word] of words.entries()) { | |
| 20 | + const wordWidth = cellWidth(word) | |
| 21 | + let rowWidth = cellWidth(rows[rows.length - 1] ?? '') | |
| 22 | + | |
| 23 | + if (index !== 0) { | |
| 24 | + if (rowWidth >= width) { | |
| 25 | + rows.push('') | |
| 26 | + rowWidth = 0 | |
| 27 | + } | |
| 28 | + | |
| 29 | + rows[rows.length - 1] += ' ' | |
| 30 | + rowWidth += 1 | |
| 31 | + } | |
| 32 | + | |
| 33 | + if (wordWidth > width) { | |
| 34 | + const breaksHere = | |
| 35 | + 1 + Math.floor((wordWidth - (width - rowWidth) - 1) / width) | |
| 36 | + | |
| 37 | + if (Math.floor((wordWidth - 1) / width) < breaksHere) { | |
| 38 | + rows.push('') | |
| 39 | + } | |
| 40 | + | |
| 41 | + const characters = [...word] | |
| 42 | + let visible = cellWidth(rows[rows.length - 1] ?? '') | |
| 43 | + | |
| 44 | + for (const [at, character] of characters.entries()) { | |
| 45 | + const characterWidth = cellWidth(character) | |
| 46 | + | |
| 47 | + if (visible + characterWidth <= width) { | |
| 48 | + rows[rows.length - 1] += character | |
| 49 | + } else { | |
| 50 | + rows.push(character) | |
| 51 | + visible = 0 | |
| 52 | + } | |
| 53 | + | |
| 54 | + visible += characterWidth | |
| 55 | + | |
| 56 | + if (visible === width && at < characters.length - 1) { | |
| 57 | + rows.push('') | |
| 58 | + visible = 0 | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + continue | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (rowWidth + wordWidth > width && rowWidth > 0 && wordWidth > 0) { | |
| 66 | + rows.push('') | |
| 67 | + } | |
| 68 | + | |
| 69 | + rows[rows.length - 1] += word | |
| 70 | + } | |
| 71 | + | |
| 72 | + return rows | |
| 73 | +} |
| @@ -33,4 +33,10 @@ | ||
| 33 | 33 | * Arms this file's diff for the next prompt, or disarms it. |
| 34 | 34 | */ |
| 35 | 35 | toggleAsk: (path: string) => void |
| 36 | + | |
| 37 | + /** | |
| 38 | + * Closes the pane as `/diff` closes it, the choice kept for the next | |
| 39 | + * first edit. | |
| 40 | + */ | |
| 41 | + close: () => void | |
| 36 | 42 | } |
| @@ -3,22 +3,50 @@ | ||
| 3 | 3 | /* @jsxFrag Fragment */ |
| 4 | 4 | import type { RenderElement } from 'claude-code' |
| 5 | 5 | |
| 6 | +import Limits from '../limits' | |
| 7 | +import Names from '../names' | |
| 6 | 8 | import PaneState from '../pane-state' |
| 7 | 9 | import { currentPane } from './current-pane' |
| 10 | +import { insetOf } from './inset-of' | |
| 8 | 11 | import type { Kit } from './kit' |
| 12 | +import Sections from './sections' | |
| 9 | 13 | import { turnPane } from './turn-pane' |
| 10 | 14 | |
| 11 | 15 | /** |
| 12 | 16 | * The diff pane's body for one `ui.render`: the repository's diff now, or |
| 13 | 17 | * the picked turn's edits while that turn still exists (a rewind drops it). |
| 14 | 18 | * |
| 19 | + * Seated inline, only the built-in's wider-terminal line, as it shows no | |
| 20 | + * panel there; docked, the built-in's blank row above the header and blank | |
| 21 | + * last column, so rows sit and wrap where the built-in's do. | |
| 22 | + * | |
| 15 | 23 | * @param kit the elements, the handlers, the width |
| 16 | 24 | * @param model the pane's state |
| 25 | + * @param placement where the surface seated the pane (`props.placement`) | |
| 17 | 26 | * @returns the tree |
| 18 | 27 | */ |
| 19 | -export function paneView(kit: Kit, model: PaneState.PaneModel): RenderElement { | |
| 28 | +export function paneView( | |
| 29 | + kit: Kit, | |
| 30 | + model: PaneState.PaneModel, | |
| 31 | + placement: 'dock' | 'inline', | |
| 32 | +): RenderElement { | |
| 33 | + const { Box } = kit.ui | |
| 20 | 34 | const turn = PaneState.pickedTurnOf(model) |
| 21 | 35 | const isCurrent = turn === undefined |
| 22 | 36 | |
| 23 | - return isCurrent ? currentPane(kit, model) : turnPane(kit, model, turn) | |
| 37 | + if (placement === 'inline') { | |
| 38 | + return <Box>{Sections.dimNote(kit, Names.RESIZE_TERMINAL_TEXT)}</Box> | |
| 39 | + } | |
| 40 | + | |
| 41 | + const inset = insetOf(kit) | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + <Box | |
| 45 | + flexDirection="column" | |
| 46 | + paddingTop={Limits.PANE_TOP_PAD_ROWS} | |
| 47 | + paddingRight={Limits.PANE_RIGHT_PAD_COLUMNS} | |
| 48 | + > | |
| 49 | + {isCurrent ? currentPane(inset, model) : turnPane(inset, model, turn)} | |
| 50 | + </Box> | |
| 51 | + ) | |
| 24 | 52 | } |
| @@ -1,0 +1,33 @@ | ||
| 1 | +/* @jsxRuntime classic */ | |
| 2 | +/* @jsx h */ | |
| 3 | +/* @jsxFrag Fragment */ | |
| 4 | +import type { RenderElement } from 'claude-code' | |
| 5 | + | |
| 6 | +import type { Kit } from '../../kit' | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * The pane's close control (ReplDiffSidebar's PanelCloseButton): a `✕` at | |
| 10 | + * the header's right edge that closes the pane as `/diff` does. | |
| 11 | + * | |
| 12 | + * Bold under the pointer, as the built-in's is. The built-in's rest style, | |
| 13 | + * dim, is not a Button's to take: its label's only style is `hover`. | |
| 14 | + * | |
| 15 | + * @param kit the drawing's kit; its handlers close the pane | |
| 16 | + * @returns the control, in the keyed Box its hover needs | |
| 17 | + */ | |
| 18 | +export function closeButton(kit: Kit): RenderElement { | |
| 19 | + const { Box, Button } = kit.ui | |
| 20 | + | |
| 21 | + return ( | |
| 22 | + <Box key="close"> | |
| 23 | + <Button | |
| 24 | + key="close" | |
| 25 | + plain | |
| 26 | + hover={{ bold: true }} | |
| 27 | + onPress={kit.actions.close} | |
| 28 | + > | |
| 29 | + {'✕'} | |
| 30 | + </Button> | |
| 31 | + </Box> | |
| 32 | + ) | |
| 33 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './close-button.jsx' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -12,9 +12,8 @@ | ||
| 12 | 12 | * turn with edits, newest first) and, for the current source, the base. |
| 13 | 13 | * |
| 14 | 14 | * The source picker draws only when a turn exists, as DiffDialog hides its |
| 15 | - * tabs; the base picker is the built-in's ctrl+x b cycle as a Select over | |
| 16 | - * the modes the backend offers, uncommitted mode naming the base as the | |
| 17 | - * base line does (modeLabelOf). | |
| 15 | + * tabs; the base picker is ctrl+x b's cycle as a Select over the backend's | |
| 16 | + * modes, uncommitted naming the base as the base line does (modeLabelOf). | |
| 18 | 17 | * |
| 19 | 18 | * @param kit the elements and the handlers |
| 20 | 19 | * @param model what is picked now and the turns to pick from |
| @@ -30,6 +29,7 @@ | ||
| 30 | 29 | const { Box, Select } = kit.ui |
| 31 | 30 | const { source, turns } = model |
| 32 | 31 | const hasTurns = turns.length > 0 |
| 32 | + | |
| 33 | 33 | const sourcePicker = hasTurns ? ( |
| 34 | 34 | <Select |
| 35 | 35 | key="source" |
| @@ -45,30 +45,35 @@ | ||
| 45 | 45 | onSelect={value => kit.actions.chooseSource(value)} |
| 46 | 46 | /> |
| 47 | 47 | ) : null |
| 48 | + | |
| 48 | 49 | const isCurrent = source.kind === 'current' |
| 49 | 50 | const fetchedSource = model.data?.source |
| 50 | - const base = | |
| 51 | - fetchedSource?.kind === 'working-tree' | |
| 52 | - ? fetchedSource.base | |
| 53 | - : model.words.base | |
| 51 | + const isWorkingTree = fetchedSource?.kind === 'working-tree' | |
| 52 | + const base = isWorkingTree ? fetchedSource.base : model.words.base | |
| 53 | + | |
| 54 | 54 | const basePicker = isCurrent ? ( |
| 55 | 55 | <Select |
| 56 | 56 | key="base" |
| 57 | 57 | label="base" |
| 58 | - options={model.baseModes.map(mode => ({ | |
| 59 | - value: mode, | |
| 60 | - label: | |
| 61 | - mode === 'uncommitted' | |
| 62 | - ? `uncommitted (vs ${base})` | |
| 63 | - : mode === 'session' | |
| 64 | - ? 'this session' | |
| 65 | - : mode, | |
| 66 | - }))} | |
| 58 | + options={model.baseModes.map(mode => { | |
| 59 | + const isUncommitted = mode === 'uncommitted' | |
| 60 | + const isSession = mode === 'session' | |
| 61 | + | |
| 62 | + const label = isUncommitted | |
| 63 | + ? `uncommitted (vs ${base})` | |
| 64 | + : isSession | |
| 65 | + ? 'this session' | |
| 66 | + : mode | |
| 67 | + | |
| 68 | + return { value: mode, label } | |
| 69 | + })} | |
| 67 | 70 | value={model.requestedMode} |
| 68 | 71 | onSelect={value => kit.actions.chooseBase(value)} |
| 69 | 72 | /> |
| 70 | 73 | ) : null |
| 74 | + | |
| 71 | 75 | const pickers = present([sourcePicker, basePicker]) |
| 76 | + | |
| 72 | 77 | const row = ( |
| 73 | 78 | <Box flexDirection="row" gap={2}> |
| 74 | 79 | {pickers} |
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | import type { Kit } from '../kit' |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | - * A dim rule across the body, between the list and the selected file. | |
| 9 | + * A dim rule across the body: between the list and the selected file, and | |
| 10 | + * under that file's name. | |
| 10 | 11 | * |
| 11 | 12 | * @param kit the elements and the width |
| 12 | 13 | * @returns the rule |
| @@ -29,11 +29,13 @@ | ||
| 29 | 29 | const { Box, Text, Button } = kit.ui |
| 30 | 30 | const room = Math.max(kit.columns - STAT_CELLS, STAT_CELLS) |
| 31 | 31 | const mark = row.isSelected ? POINTER : ' ' |
| 32 | + | |
| 32 | 33 | const note = ( |
| 33 | 34 | <Text dimColor italic> |
| 34 | 35 | {row.note ?? ''} |
| 35 | 36 | </Text> |
| 36 | 37 | ) |
| 38 | + | |
| 37 | 39 | const hasNote = row.note !== null |
| 38 | 40 | const tail = hasNote ? note : diffStat(kit, row.added, row.removed) |
| 39 | 41 |
| @@ -6,38 +6,40 @@ | ||
| 6 | 6 | import type PaneState from '../../pane-state' |
| 7 | 7 | import type { Kit } from '../kit' |
| 8 | 8 | import Layout from '../layout' |
| 9 | +import { closeButton } from './close-button' | |
| 9 | 10 | import { diffStat } from './diff-stat' |
| 10 | 11 | |
| 11 | 12 | /** |
| 12 | - * The pane's first line: `N files changed +A -R`, or the empty headline in | |
| 13 | - * its place when there is nothing to count (ReplDiffSidebar's header). | |
| 13 | + * The pane's first line (ReplDiffSidebar's header row): `N files changed | |
| 14 | + * +A -R`, or nothing over an empty state. | |
| 14 | 15 | * |
| 15 | - * The headline may name a branch the repository chose, so it is drawn as | |
| 16 | - * a name: no control or format character survives. | |
| 16 | + * The close `✕` sits at the right edge either way. | |
| 17 | 17 | * |
| 18 | 18 | * @param kit the drawing's kit; its elements draw the line |
| 19 | - * @param headline the empty headline, or null to draw the counts | |
| 20 | - * @param totals the header's counts | |
| 19 | + * @param totals the header's counts, or null when there is nothing to count | |
| 21 | 20 | * @returns the line |
| 22 | 21 | */ |
| 23 | 22 | export function headerView( |
| 24 | 23 | kit: Kit, |
| 25 | - headline: string | null, | |
| 26 | - totals: PaneState.HeaderTotals, | |
| 24 | + totals: PaneState.HeaderTotals | null, | |
| 27 | 25 | ): RenderElement { |
| 28 | - const { Text } = kit.ui | |
| 29 | - const empty = ( | |
| 30 | - <Text dimColor wrap="truncate-end"> | |
| 31 | - {Layout.sanitizeName(headline ?? '')} | |
| 32 | - </Text> | |
| 33 | - ) | |
| 34 | - const counts = ( | |
| 35 | - <Text wrap="truncate-end"> | |
| 36 | - <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text> | |
| 37 | - {' changed '} | |
| 38 | - {diffStat(kit, totals.linesAdded, totals.linesRemoved)} | |
| 39 | - </Text> | |
| 40 | - ) | |
| 26 | + const { Box, Text } = kit.ui | |
| 41 | 27 | |
| 42 | - return headline === null ? counts : empty | |
| 28 | + return ( | |
| 29 | + <Box flexDirection="row"> | |
| 30 | + {[ | |
| 31 | + ...(totals | |
| 32 | + ? [ | |
| 33 | + <Text wrap="truncate-end"> | |
| 34 | + <Text bold>{Layout.plural(totals.filesCount, 'file')}</Text> | |
| 35 | + {' changed '} | |
| 36 | + {diffStat(kit, totals.linesAdded, totals.linesRemoved)} | |
| 37 | + </Text>, | |
| 38 | + ] | |
| 39 | + : []), | |
| 40 | + <Box flexGrow={1} />, | |
| 41 | + closeButton(kit), | |
| 42 | + ]} | |
| 43 | + </Box> | |
| 44 | + ) | |
| 43 | 45 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | +export * from './close-button' | |
| 1 | 2 | export * from './controls-view.jsx' |
| 2 | 3 | export * from './diff-stat' |
| 3 | 4 | export * from './dim-note' |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | /* @jsxFrag Fragment */ |
| 4 | 4 | import type { RenderElement } from 'claude-code' |
| 5 | 5 | |
| 6 | -import type Todos from '../../todos' | |
| 6 | +import type PaneState from '../../pane-state' | |
| 7 | 7 | import type { Kit } from '../kit' |
| 8 | 8 | import { TODO_BAR_CELLS } from './todo-bar-cells' |
| 9 | 9 | import { TODO_BAR_RESERVE } from './todo-bar-reserve' |
| @@ -13,22 +13,26 @@ | ||
| 13 | 13 | * a todo list exists (ReplDiffSidebar's ProgressBar row). |
| 14 | 14 | * |
| 15 | 15 | * @param kit the elements and the width |
| 16 | - * @param todos completed over total | |
| 16 | + * @param model the pane's state, holding its todos' completed over total | |
| 17 | 17 | * @returns the row, or null without todos |
| 18 | 18 | */ |
| 19 | 19 | export function todoBar( |
| 20 | 20 | kit: Kit, |
| 21 | - todos: Todos.TodoProgress, | |
| 21 | + model: Pick<PaneState.PaneModel, 'todos'>, | |
| 22 | 22 | ): RenderElement | null { |
| 23 | + const { todos } = model | |
| 24 | + | |
| 23 | 25 | if (todos.total === 0) { |
| 24 | 26 | return null |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | const { Box, Text } = kit.ui |
| 30 | + | |
| 28 | 31 | const cells = Math.max( |
| 29 | 32 | 1, |
| 30 | 33 | Math.min(TODO_BAR_CELLS, kit.columns - TODO_BAR_RESERVE), |
| 31 | 34 | ) |
| 35 | + | |
| 32 | 36 | const filled = Math.round((todos.done / todos.total) * cells) |
| 33 | 37 | |
| 34 | 38 | return ( |
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.