diff --git a/mods/diff/hooks/git/probes/branch-base-of/branch-base-of.ts b/mods/diff/hooks/git/probes/branch-base-of/branch-base-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..778d9924584c70bfe4dd0eeb4875911cf8cc1aec --- /dev/null +++ b/mods/diff/hooks/git/probes/branch-base-of/branch-base-of.ts @@ -0,0 +1,75 @@ +import Argv from '../../argv' +import type Types from '../../types' +import { currentBranchOf } from '../current-branch-of' +import { defaultBranchOf } from '../default-branch-of' +import { mergeBaseCandidatesOf } from '../merge-base-candidates-of' +import { missingBaseOf } from '../missing-base-of' + +/** + * What branch mode compares against, resolved as the built-in panel does: + * the merge-base of HEAD and the default branch, HEAD itself, or none. + * + * `origin/` wins unless the local ref's base descends from it; a + * detached HEAD, a `-`-led default name or no common ancestor is `none`; + * a failure that may pass (a ref still there, a failed rev-parse) `error`. + * + * @param run runs git + * @returns the base + */ +export async function branchBaseOf( + run: Types.GitRun, +): Promise { + const [branch, defaultBranch] = await Promise.all([ + currentBranchOf(run), + defaultBranchOf(run), + ]) + + const isUnrelated = branch === 'HEAD' || defaultBranch.startsWith('-') + + if (isUnrelated) { + return { kind: 'none' } + } + + if (branch === defaultBranch) { + return { kind: 'head-is-base', baseBranch: defaultBranch } + } + + const { candidates, hasNoCommonAncestor } = await mergeBaseCandidatesOf( + run, + defaultBranch, + ) + + const [first, second] = candidates + + if (first === undefined) { + return hasNoCommonAncestor + ? { kind: 'none' } + : missingBaseOf(run, defaultBranch) + } + + const isLocalBaseNewer = + second !== undefined && + second !== first && + ( + await run([ + Argv.NO_OPTIONAL_LOCKS, + 'merge-base', + '--is-ancestor', + first, + second, + ]) + ).exitCode === 0 + + const mergeBase = isLocalBaseNewer ? second : first + const head = await run([Argv.NO_OPTIONAL_LOCKS, 'rev-parse', 'HEAD']) + + if (head.exitCode !== 0) { + return { kind: 'error', reason: 'head_rev_parse_failed' } + } + + const isHeadTheBase = head.stdout.trim() === mergeBase + + return isHeadTheBase + ? { kind: 'head-is-base', baseBranch: defaultBranch } + : { kind: 'merge-base', mergeBase, baseBranch: defaultBranch } +} diff --git a/mods/diff/hooks/git/probes/branch-base-of/index.ts b/mods/diff/hooks/git/probes/branch-base-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ababaf982120c4976a2364e0e820e3a43a3915b7 --- /dev/null +++ b/mods/diff/hooks/git/probes/branch-base-of/index.ts @@ -0,0 +1,3 @@ +export * from './branch-base-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/conventional-default-branches/conventional-default-branches.ts b/mods/diff/hooks/git/probes/conventional-default-branches/conventional-default-branches.ts new file mode 100644 index 0000000000000000000000000000000000000000..1af3d2679e16223275d182c3300e66db70ad99a9 --- /dev/null +++ b/mods/diff/hooks/git/probes/conventional-default-branches/conventional-default-branches.ts @@ -0,0 +1,5 @@ +/** + * Tried in order as the default branch when `refs/remotes/origin/HEAD` names + * none that resolves; past them the guess is `main`, as the engine's is. + */ +export const CONVENTIONAL_DEFAULT_BRANCHES = ['main', 'master'] as const diff --git a/mods/diff/hooks/git/probes/conventional-default-branches/index.ts b/mods/diff/hooks/git/probes/conventional-default-branches/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..883c38a5d5c427f78b69f282dc966b7b136f9d34 --- /dev/null +++ b/mods/diff/hooks/git/probes/conventional-default-branches/index.ts @@ -0,0 +1,3 @@ +export * from './conventional-default-branches.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/current-branch-of/current-branch-of.ts b/mods/diff/hooks/git/probes/current-branch-of/current-branch-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed126a9e8350fa4db02a8eb7aee89dc22e919250 --- /dev/null +++ b/mods/diff/hooks/git/probes/current-branch-of/current-branch-of.ts @@ -0,0 +1,21 @@ +import Argv from '../../argv' +import type Types from '../../types' + +/** + * The checked-out branch's name, or `HEAD` when detached or unreadable. + * + * @param run runs git + * @returns the branch name + */ +export async function currentBranchOf(run: Types.GitRun): Promise { + const { exitCode, stdout } = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'rev-parse', + '--abbrev-ref', + 'HEAD', + ]) + + const name = stdout.trim() + + return exitCode === 0 && name !== '' ? name : 'HEAD' +} diff --git a/mods/diff/hooks/git/probes/current-branch-of/index.ts b/mods/diff/hooks/git/probes/current-branch-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cae908ba110489df0a9f11a7d17e27b69af9b20a --- /dev/null +++ b/mods/diff/hooks/git/probes/current-branch-of/index.ts @@ -0,0 +1,3 @@ +export * from './current-branch-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/datings-of/datings-of.ts b/mods/diff/hooks/git/probes/datings-of/datings-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0629405f3d3c0ac9a5da8982de46c4a4d96d181 --- /dev/null +++ b/mods/diff/hooks/git/probes/datings-of/datings-of.ts @@ -0,0 +1,38 @@ +import type Types from '../../types' +import { stampIfFile } from '../stamp-if-file' + +/** + * When each path was last modified against the session's start, dating a + * path only when it is a real file under real directories at every level. + * + * Listings report an entry's own kind, so a symbolic link anywhere on the + * path is never listed, descended or stat'ed: session work (the engine + * lstats); a path the listing budget never reached is the caller's to place. + * + * @param context the fetch's stamp probe over the working tree's top, and + * when the session began + * @param paths repository-relative paths, `/`-separated as git prints them + * @returns each path's Dating + */ +export async function datingsOf( + context: Types.DatingContext, + paths: readonly string[], +): Promise> { + const { sessionStartMs } = context.deps + + const stamps: readonly Types.Stamp[] = await Promise.all( + paths.map(path => stampIfFile(context.stamps, path)), + ) + + function datingOf(stamp: Types.Stamp): Types.Dating { + if (stamp === 'over-budget') { + return 'unlisted' + } + + const isEarlier = (stamp ?? Number.POSITIVE_INFINITY) < sessionStartMs + + return isEarlier ? 'pre-session' : 'session' + } + + return new Map(paths.map((path, at) => [path, datingOf(stamps[at] ?? null)])) +} diff --git a/mods/diff/hooks/git/probes/datings-of/index.ts b/mods/diff/hooks/git/probes/datings-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..370c3cfd4a7a753be30ca01510e9d89bfca92c84 --- /dev/null +++ b/mods/diff/hooks/git/probes/datings-of/index.ts @@ -0,0 +1,3 @@ +export * from './datings-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/default-branch-of/default-branch-of.ts b/mods/diff/hooks/git/probes/default-branch-of/default-branch-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..c32461f12d7eecaf7e79bcc074bdb06498d4c010 --- /dev/null +++ b/mods/diff/hooks/git/probes/default-branch-of/default-branch-of.ts @@ -0,0 +1,47 @@ +import Argv from '../../argv' +import type Types from '../../types' +import { CONVENTIONAL_DEFAULT_BRANCHES } from '../conventional-default-branches' + +/** + * The remote's default branch: what `origin/HEAD` names while that ref + * resolves, else the first conventional name with a remote ref, else main. + * + * The engine's own default-branch confidence ladder, over `$.process.run`. + * + * @param run runs git + * @returns the branch name, never empty + */ +export async function defaultBranchOf(run: Types.GitRun): Promise { + const hasRemoteRef = async (branch: string): Promise => + ( + await run([ + Argv.NO_OPTIONAL_LOCKS, + 'show-ref', + '--verify', + '--quiet', + `refs/remotes/origin/${branch}`, + ]) + ).exitCode === 0 + + const symref = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'symbolic-ref', + '--short', + 'refs/remotes/origin/HEAD', + ]) + + const named = + symref.exitCode === 0 ? symref.stdout.trim().replace(/^origin\//, '') : '' + + if (named !== '' && (await hasRemoteRef(named))) { + return named + } + + for (const candidate of CONVENTIONAL_DEFAULT_BRANCHES) { + if (await hasRemoteRef(candidate)) { + return candidate + } + } + + return 'main' +} diff --git a/mods/diff/hooks/git/probes/default-branch-of/index.ts b/mods/diff/hooks/git/probes/default-branch-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bfb45edb6eeafc5c749c502e4b744e0d7e62e075 --- /dev/null +++ b/mods/diff/hooks/git/probes/default-branch-of/index.ts @@ -0,0 +1,3 @@ +export * from './default-branch-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts b/mods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..a92e6f3bade0a4cc560070b254cdac089ba087a0 --- /dev/null +++ b/mods/diff/hooks/git/probes/dirty-paths-of/dirty-paths-of.ts @@ -0,0 +1,35 @@ +import Argv from '../../argv' +import Parse from '../../parse' +import type Types from '../../types' +import { STATUS_PATH_OFFSET } from './status-path-offset' + +/** + * Every path dirty in the working tree right now, tracked changes and + * untracked files alike, as `git status -z` lists them; null unread. + * + * Read once, by the backend's first fetch (it walks the whole tree), so a + * later fetch can tell a path that was dirty then from one that turned up + * since (GitDeps `baseline`). Renames unpaired: each side is its own path. + * + * @param run runs git against the pinned repository + * @returns the paths, root-relative, or null on a failed or cut listing + */ +export async function dirtyPathsOf( + run: Types.GitRun, +): Promise | null> { + const listing = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'status', + '--porcelain', + '-z', + '--untracked-files=all', + '--no-renames', + '--ignore-submodules=dirty', + ]) + + const records = listing.stdout.split('\0').filter(record => record !== '') + + return Parse.isWholeAnswer(listing) + ? new Set(records.map(record => record.slice(STATUS_PATH_OFFSET))) + : null +} diff --git a/mods/diff/hooks/git/probes/dirty-paths-of/index.ts b/mods/diff/hooks/git/probes/dirty-paths-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd0d03bb44e0ff55cd732f2ba20be827f3f210e5 --- /dev/null +++ b/mods/diff/hooks/git/probes/dirty-paths-of/index.ts @@ -0,0 +1,4 @@ +export * from './dirty-paths-of.js' +export * from './status-path-offset' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/index.ts b/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9693541f99ec2acddf00f6a5a989491c928a32d8 --- /dev/null +++ b/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/index.ts @@ -0,0 +1,3 @@ +export * from './status-path-offset.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/status-path-offset.ts b/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/status-path-offset.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef55dee1a94dcc9de16e0ba85430f73e0504c387 --- /dev/null +++ b/mods/diff/hooks/git/probes/dirty-paths-of/status-path-offset/status-path-offset.ts @@ -0,0 +1,5 @@ +/** + * Where the path starts in a `status --porcelain -z` record: after the two + * status letters and a space. + */ +export const STATUS_PATH_OFFSET = 3 diff --git a/mods/diff/hooks/git/probes/head-key-of/head-key-of.ts b/mods/diff/hooks/git/probes/head-key-of/head-key-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..453e1736bdef52aa99e591873c8e6eb919a9eb91 --- /dev/null +++ b/mods/diff/hooks/git/probes/head-key-of/head-key-of.ts @@ -0,0 +1,66 @@ +import Limits from '../../../limits' +import Argv from '../../argv' +import type Types from '../../types' +import { isSafeRefName } from '../is-safe-ref-name' +import { stampIfFile } from '../stamp-if-file' +import { stampProbeOf } from '../stamp-probe-of' + +/** + * A string that changes when HEAD moves: HEAD's text and the timestamps + * of HEAD, its ref file and packed-refs, each a listed real file. + * + * A HEAD that is not a plain file, or a `ref:` outside the safe refname + * grammar, is read through `git rev-parse` instead and nothing is stat'ed; + * every directory above a stat'ed file was listed as a real directory. + * + * @param deps git, listings, timestamps, a file read + * @param repository the repository's directories + * @returns the key for this tick + */ +export async function headKeyOf( + deps: Types.HeadKeyDeps, + repository: Types.Repository, +): Promise { + const own = stampProbeOf( + deps, + repository.gitDir, + Limits.MAX_LISTED_DIRECTORIES, + ) + + const isShared = repository.commonDir === repository.gitDir + + const common = isShared + ? own + : stampProbeOf(deps, repository.commonDir, Limits.MAX_LISTED_DIRECTORIES) + + async function viaGit() { + const parsed = await deps.run([ + Argv.NO_OPTIONAL_LOCKS, + 'rev-parse', + '--verify', + '--quiet', + 'HEAD', + ]) + + return `rev:${parsed.exitCode === 0 ? parsed.stdout.trim() : ''}` + } + + if ((await own.walk.kindOf('HEAD')) !== 'file') { + return viaGit() + } + + const head = await deps.readFile(`${repository.gitDir}/HEAD`).catch(() => '') + const ref = head.startsWith('ref: ') ? head.slice('ref: '.length).trim() : '' + const isSymbolic = ref !== '' + + const isUnsafeRef = isSymbolic && !isSafeRefName(ref) + + return isUnsafeRef + ? viaGit() + : [ + head, + await stampIfFile(own, 'HEAD'), + isSymbolic ? await stampIfFile(common, ref) : null, + await stampIfFile(common, 'packed-refs'), + ].join('|') +} diff --git a/mods/diff/hooks/git/probes/head-key-of/index.ts b/mods/diff/hooks/git/probes/head-key-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..52ac966e69d839f91a8ebd395631720f776ef26e --- /dev/null +++ b/mods/diff/hooks/git/probes/head-key-of/index.ts @@ -0,0 +1,3 @@ +export * from './head-key-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-absolute-path/index.ts b/mods/diff/hooks/git/probes/is-absolute-path/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9cd599eba06550967eab9b9ede802bdda68c17b --- /dev/null +++ b/mods/diff/hooks/git/probes/is-absolute-path/index.ts @@ -0,0 +1,3 @@ +export * from './is-absolute-path.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-absolute-path/is-absolute-path.ts b/mods/diff/hooks/git/probes/is-absolute-path/is-absolute-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..de7e43630de1fec5e1e3c7edd948853a46f267b3 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-absolute-path/is-absolute-path.ts @@ -0,0 +1,9 @@ +/** + * Whether a line `rev-parse --path-format=absolute` printed is an absolute + * path (POSIX or drive-letter), and so neither relative nor an echoed flag. + * + * @param line one line of rev-parse output + * @returns whether it names an absolute path + */ +export const isAbsolutePath = (line: string) => + line.startsWith('/') || /^[A-Za-z]:[\\/]/.test(line) diff --git a/mods/diff/hooks/git/probes/is-safe-ref-name/index.ts b/mods/diff/hooks/git/probes/is-safe-ref-name/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..be3dc8d903144483fa701847d686f9f776a82a7b --- /dev/null +++ b/mods/diff/hooks/git/probes/is-safe-ref-name/index.ts @@ -0,0 +1,4 @@ +export * from './is-safe-ref-name.js' +export * from './safe-ref-pattern' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-safe-ref-name/is-safe-ref-name.ts b/mods/diff/hooks/git/probes/is-safe-ref-name/is-safe-ref-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..708fc9da9fb2af1808ea54ccacec78833da0d152 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-safe-ref-name/is-safe-ref-name.ts @@ -0,0 +1,15 @@ +import { SAFE_REF_PATTERN } from './safe-ref-pattern' + +/** + * Whether HEAD's `ref:` text names a ref the poll may stat: under `refs/`, + * plain characters, no `..`, no component ending `.lock`. + * + * Git's refname rules, narrowed; anything else is read through git. + * + * @param ref the text after `ref: ` + * @returns whether the ref is safe to join under the common directory + */ +export const isSafeRefName = (ref: string) => + SAFE_REF_PATTERN.test(ref) && + !ref.includes('..') && + !ref.split('/').some(part => part.endsWith('.lock')) diff --git a/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/index.ts b/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9633570f5cea03a7e2b46a1c4ea3ebf2428f06f --- /dev/null +++ b/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/index.ts @@ -0,0 +1,3 @@ +export * from './safe-ref-pattern.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/safe-ref-pattern.ts b/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/safe-ref-pattern.ts new file mode 100644 index 0000000000000000000000000000000000000000..0137a3b1f561e4745ef386ebbbc857987747bdbc --- /dev/null +++ b/mods/diff/hooks/git/probes/is-safe-ref-name/safe-ref-pattern/safe-ref-pattern.ts @@ -0,0 +1,5 @@ +/** + * The refnames the HEAD poll will touch on disk: under `refs/`, of plain + * name characters only, `/` between components. + */ +export const SAFE_REF_PATTERN = /^refs(?:\/[A-Za-z0-9_@+-][A-Za-z0-9._@+-]*)+$/ diff --git a/mods/diff/hooks/git/probes/is-sha/index.ts b/mods/diff/hooks/git/probes/is-sha/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cbf1a6ddeb4db8d530c4803e3cac840d6990216 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-sha/index.ts @@ -0,0 +1,3 @@ +export * from './is-sha.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/is-sha/is-sha.ts b/mods/diff/hooks/git/probes/is-sha/is-sha.ts new file mode 100644 index 0000000000000000000000000000000000000000..773f39552c6fa9b558e3c59dbeaf365500102515 --- /dev/null +++ b/mods/diff/hooks/git/probes/is-sha/is-sha.ts @@ -0,0 +1,9 @@ +/** + * Whether git printed an object name: 40 or 64 lowercase hex digits, the + * only shape a merge-base may take before it reaches a `git diff` argv. + * + * @param text one trimmed line of git output + * @returns whether it is a full object name + */ +export const isSha = (text: string) => + /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(text) diff --git a/mods/diff/hooks/git/probes/kind-walk-of/index.ts b/mods/diff/hooks/git/probes/kind-walk-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd5a1efbd34a7009cf647373271b803f75221d9d --- /dev/null +++ b/mods/diff/hooks/git/probes/kind-walk-of/index.ts @@ -0,0 +1,3 @@ +export * from './kind-walk-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts b/mods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc2caf82ccc0f0c2a43f02f2c5fd52150a5f8722 --- /dev/null +++ b/mods/diff/hooks/git/probes/kind-walk-of/kind-walk-of.ts @@ -0,0 +1,72 @@ +import type Types from '../../types' + +/** + * A KindWalk: each directory is listed once, only after its own entry + * read as `dir` in its parent, and only while listings remain. + * + * Read from the root down, one listing a component: the depth is the + * budget's, never the stack's. A symbolic link is never listed or descended + * and what lies under it reads as null; the unlisted reads `over-budget`. + * + * @param entryKindsOf the host's directory listing + * @param root the absolute directory the walk's paths are relative to + * @param budget how many directories this walk may list + * @returns the walk + */ +export function kindWalkOf( + entryKindsOf: Types.GitDeps['entryKindsOf'], + root: string, + budget: number, +): Types.KindWalk { + const listings = new Map< + string, + Promise | null> + >() + + function listingOf(directory: string) { + const held = listings.get(directory) + const isOverBudget = held === undefined && listings.size >= budget + + if (isOverBudget) { + return 'over-budget' + } + + const listed = + held ?? entryKindsOf(directory === '' ? root : `${root}/${directory}`) + + listings.set(directory, listed) + + return listed + } + + async function kindOf(path: string): Promise { + const names = path === '' ? [] : path.split('/') + + let directory = '' + + for (const [depth, name] of names.entries()) { + const listing = listingOf(directory) + + if (listing === 'over-budget') { + return 'over-budget' + } + + const kind = (await listing)?.get(name) ?? null + const isLeaf = depth === names.length - 1 + + if (isLeaf) { + return kind + } + + if (kind !== 'dir') { + return null + } + + directory = directory === '' ? name : `${directory}/${name}` + } + + return 'dir' + } + + return { kindOf } +} diff --git a/mods/diff/hooks/git/probes/merge-base-candidates-of/index.ts b/mods/diff/hooks/git/probes/merge-base-candidates-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..777dcb013ed7a3b1e43fe4006ba05e65a6795ffd --- /dev/null +++ b/mods/diff/hooks/git/probes/merge-base-candidates-of/index.ts @@ -0,0 +1,4 @@ +export * from './merge-base-candidates' +export * from './merge-base-candidates-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates-of.ts b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbbe2feb0e6e14ab825138c659094e6915e9cd62 --- /dev/null +++ b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates-of.ts @@ -0,0 +1,38 @@ +import Argv from '../../argv' +import type Types from '../../types' +import { isSha } from '../is-sha' +import type { MergeBaseCandidates } from './merge-base-candidates' + +/** + * The merge-bases of HEAD with `origin/` and with ``, in + * that order, each present only when git printed an object name. + * + * Also whether any call ran and exited 1: no common ancestor, a stable + * state rather than a failure. + * + * @param run runs git + * @param defaultBranch the default branch's name + * @returns the candidate object names and the no-ancestor flag + */ +export async function mergeBaseCandidatesOf( + run: Types.GitRun, + defaultBranch: string, +): Promise { + const results = [ + await run([ + Argv.NO_OPTIONAL_LOCKS, + 'merge-base', + 'HEAD', + `origin/${defaultBranch}`, + ]), + await run([Argv.NO_OPTIONAL_LOCKS, 'merge-base', 'HEAD', defaultBranch]), + ] + + return { + candidates: results + .filter(result => result.exitCode === 0) + .map(result => result.stdout.trim()) + .filter(isSha), + hasNoCommonAncestor: results.some(result => result.exitCode === 1), + } +} diff --git a/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/index.ts b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f1b58ab2c5eb4b2e805302ba25497341b6af3a4 --- /dev/null +++ b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/index.ts @@ -0,0 +1,3 @@ +export type * from './merge-base-candidates.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/merge-base-candidates.ts b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/merge-base-candidates.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ed1044ea24178383f2663fcc8dd428523e73b35 --- /dev/null +++ b/mods/diff/hooks/git/probes/merge-base-candidates-of/merge-base-candidates/merge-base-candidates.ts @@ -0,0 +1,8 @@ +/** + * What the two merge-base calls yielded: the object names printed, in + * call order, and whether either ran to a no-common-ancestor exit. + */ +export type MergeBaseCandidates = { + candidates: readonly string[] + hasNoCommonAncestor: boolean +} diff --git a/mods/diff/hooks/git/probes/missing-base-of/index.ts b/mods/diff/hooks/git/probes/missing-base-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bb88e78035f037b6245cf7ebf10d6652424fc41 --- /dev/null +++ b/mods/diff/hooks/git/probes/missing-base-of/index.ts @@ -0,0 +1,3 @@ +export * from './missing-base-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/missing-base-of/missing-base-of.ts b/mods/diff/hooks/git/probes/missing-base-of/missing-base-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..a253e21a2f637ca4c6582cd834ccbf083f98e278 --- /dev/null +++ b/mods/diff/hooks/git/probes/missing-base-of/missing-base-of.ts @@ -0,0 +1,39 @@ +import Argv from '../../argv' +import type Types from '../../types' + +/** + * The verdict for a merge-base that produced nothing: `none` when neither + * ref of the default branch exists, else `error`. + * + * Only a probe that ran and exited non-zero is an authoritative miss; one + * that answered 0 or never ran means the merge-base failure may pass. + * + * @param run runs git + * @param defaultBranch the default branch's name + * @returns `none` or `error` + */ +export async function missingBaseOf( + run: Types.GitRun, + defaultBranch: string, +): Promise { + for (const ref of [ + `refs/remotes/origin/${defaultBranch}`, + `refs/heads/${defaultBranch}`, + ]) { + const { exitCode } = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'show-ref', + '--verify', + '--quiet', + ref, + ]) + + const isPresentOrUnknown = exitCode === 0 || exitCode === -1 + + if (isPresentOrUnknown) { + return { kind: 'error', reason: 'merge_base_failed' } + } + } + + return { kind: 'none' } +} diff --git a/mods/diff/hooks/git/probes/repository-lines/index.ts b/mods/diff/hooks/git/probes/repository-lines/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..47f95bf9538cef4905ef55ff6e575272d2e49d96 --- /dev/null +++ b/mods/diff/hooks/git/probes/repository-lines/index.ts @@ -0,0 +1,3 @@ +export * from './repository-lines.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/repository-lines/repository-lines.ts b/mods/diff/hooks/git/probes/repository-lines/repository-lines.ts new file mode 100644 index 0000000000000000000000000000000000000000..15ee0548d9e2bfa9575e945af4bf27af6d4c5c2f --- /dev/null +++ b/mods/diff/hooks/git/probes/repository-lines/repository-lines.ts @@ -0,0 +1,5 @@ +/** + * How many lines the repository probe's `rev-parse` answers when git + * understood every option: top level, git directory, common directory. + */ +export const REPOSITORY_LINES = 3 diff --git a/mods/diff/hooks/git/probes/repository-of/index.ts b/mods/diff/hooks/git/probes/repository-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a5a77e74f6d025d42f305cc8748be0b4d41f57c --- /dev/null +++ b/mods/diff/hooks/git/probes/repository-of/index.ts @@ -0,0 +1,3 @@ +export * from './repository-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/repository-of/repository-of.ts b/mods/diff/hooks/git/probes/repository-of/repository-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..9638cff77dfcb94ee5d55e3dbbae1b9a3c7df5aa --- /dev/null +++ b/mods/diff/hooks/git/probes/repository-of/repository-of.ts @@ -0,0 +1,38 @@ +import Argv from '../../argv' +import type Types from '../../types' +import { isAbsolutePath } from '../is-absolute-path' +import { REPOSITORY_LINES } from '../repository-lines' + +/** + * The repository the session's directory is in, read with one `rev-parse`: + * working tree top, git directory, common directory, all absolute. + * + * Exactly three absolute lines or nothing: a git too old for + * `--path-format` echoes the flag as a line of its own, and such an answer + * pins no repository rather than a wrong one. + * + * @param run runs git in the session's directory, nothing pinned yet + * @returns the three absolute paths, or null outside a working tree + */ +export async function repositoryOf( + run: Types.GitRun, +): Promise { + const { exitCode, stdout } = await run([ + Argv.NO_OPTIONAL_LOCKS, + 'rev-parse', + '--path-format=absolute', + '--show-toplevel', + '--git-dir', + '--git-common-dir', + ]) + + const lines = stdout.split('\n').filter(line => line !== '') + const [toplevel = '', gitDir = '', commonDir = ''] = lines + + const isResolved = + exitCode === 0 && + lines.length === REPOSITORY_LINES && + lines.every(isAbsolutePath) + + return isResolved ? { toplevel, gitDir, commonDir } : null +} diff --git a/mods/diff/hooks/git/probes/stamp-if-file/index.ts b/mods/diff/hooks/git/probes/stamp-if-file/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..35623ede7923a3a3df9532384ea4e91837a7ee66 --- /dev/null +++ b/mods/diff/hooks/git/probes/stamp-if-file/index.ts @@ -0,0 +1,3 @@ +export * from './stamp-if-file.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/stamp-if-file/stamp-if-file.ts b/mods/diff/hooks/git/probes/stamp-if-file/stamp-if-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ad1c53b19c89cebaeddc09d0febc3b11e907077 --- /dev/null +++ b/mods/diff/hooks/git/probes/stamp-if-file/stamp-if-file.ts @@ -0,0 +1,29 @@ +import type Types from '../../types' + +/** + * A path's modification time when the walk shows it as a real entry of the + * wanted kind under real directories; otherwise nothing is stat'ed. + * + * A path git printed with a trailing `/` (an untracked directory, a nested + * repository) names a directory and is dated as one. + * + * @param probe the walk over the base directory and the timestamp probe + * @param path the path relative to the walk's root + * @returns the timestamp, `over-budget`, or null + */ +export async function stampIfFile( + probe: Types.StampProbe, + path: string, +): Promise { + const isDirectory = path.endsWith('/') + const entry = isDirectory ? path.slice(0, -1) : path + const kind = await probe.walk.kindOf(entry) + const isUnlisted = kind === 'over-budget' + const isWanted = kind === (isDirectory ? 'dir' : 'file') + + return isUnlisted + ? 'over-budget' + : isWanted + ? probe.mtimeOf(`${probe.base}/${entry}`) + : null +} diff --git a/mods/diff/hooks/git/probes/stamp-probe-of/index.ts b/mods/diff/hooks/git/probes/stamp-probe-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..15094899204e117ee857c719932c5f7dcac5e74a --- /dev/null +++ b/mods/diff/hooks/git/probes/stamp-probe-of/index.ts @@ -0,0 +1,3 @@ +export * from './stamp-probe-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/stamp-probe-of/stamp-probe-of.ts b/mods/diff/hooks/git/probes/stamp-probe-of/stamp-probe-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..03cd0052972495078ed00350b73418ae0d56f18f --- /dev/null +++ b/mods/diff/hooks/git/probes/stamp-probe-of/stamp-probe-of.ts @@ -0,0 +1,21 @@ +import type Types from '../../types' +import { kindWalkOf } from '../kind-walk-of' + +/** + * A StampProbe rooted at a directory: a fresh, bounded kind walk over it + * and the host's timestamp probe. + * + * @param deps lists a directory, reads a timestamp + * @param base the absolute directory the probe's paths are under + * @param budget how many directories the walk may list + * @returns the probe + */ +export const stampProbeOf = ( + deps: Pick, + base: string, + budget: number, +): Types.StampProbe => ({ + walk: kindWalkOf(deps.entryKindsOf, base, budget), + base, + mtimeOf: deps.mtimeOf, +}) diff --git a/mods/diff/hooks/git/probes/transient-state-files/index.ts b/mods/diff/hooks/git/probes/transient-state-files/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbf7938f35a4b80944929cf248bfd5695239ea16 --- /dev/null +++ b/mods/diff/hooks/git/probes/transient-state-files/index.ts @@ -0,0 +1,3 @@ +export * from './transient-state-files.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/transient-state-files/transient-state-files.ts b/mods/diff/hooks/git/probes/transient-state-files/transient-state-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e601f8cc0d3e6c106eaea20e300a0d2426eb415 --- /dev/null +++ b/mods/diff/hooks/git/probes/transient-state-files/transient-state-files.ts @@ -0,0 +1,10 @@ +/** + * The git-directory files that mean a merge, rebase, cherry-pick or revert + * is under way: the working tree then holds incoming changes, unread. + */ +export const TRANSIENT_STATE_FILES = [ + 'MERGE_HEAD', + 'REBASE_HEAD', + 'CHERRY_PICK_HEAD', + 'REVERT_HEAD', +] as const diff --git a/mods/diff/hooks/git/probes/untracked-files/index.ts b/mods/diff/hooks/git/probes/untracked-files/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..addc27ea402da0c36d20b0dcde23cf97b97ccb63 --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-files/index.ts @@ -0,0 +1,3 @@ +export * from './untracked-files.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/untracked-files/untracked-files.ts b/mods/diff/hooks/git/probes/untracked-files/untracked-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..aabd75c25f2256ee6f149ee046be5c3277958b28 --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-files/untracked-files.ts @@ -0,0 +1,40 @@ +import Argv from '../../argv' +import Parse from '../../parse' +import type Types from '../../types' +import type { UntrackedPlace } from '../untracked-place' +import { untrackedRowsOf } from '../untracked-rows-of' + +/** + * The untracked, unignored files as 0/0 rows (untrackedRowsOf), listed + * with `ls-files -z`. A listing not given whole: no answer. + * + * @param context the fetch: its pinned git, stamp probe and session start + * @param place the free row slots and the scope + * @returns the rows to merge after the tracked ones, or null when the + * `-z` listing failed, timed out or was cut + */ +export async function untrackedFiles( + context: Pick & Types.DatingContext, + place: UntrackedPlace, +): Promise { + if (place.slots <= 0) { + return [] + } + + const listing = await context.run([ + Argv.NO_OPTIONAL_LOCKS, + 'ls-files', + '-z', + '--others', + '--exclude-standard', + '--full-name', + ]) + + return Parse.isWholeAnswer(listing) + ? untrackedRowsOf( + context, + listing.stdout.split('\0').filter(path => path !== ''), + place, + ) + : null +} diff --git a/mods/diff/hooks/git/probes/untracked-place/index.ts b/mods/diff/hooks/git/probes/untracked-place/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b89a40e58214991d26605229419e9ba763776d5 --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-place/index.ts @@ -0,0 +1,3 @@ +export type * from './untracked-place.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/untracked-place/untracked-place.ts b/mods/diff/hooks/git/probes/untracked-place/untracked-place.ts new file mode 100644 index 0000000000000000000000000000000000000000..a53586a584932f90a5664bdaf1abd5793bd3c79c --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-place/untracked-place.ts @@ -0,0 +1,10 @@ +import type Types from '../../types' + +/** + * How much untrackedFiles may keep: the row slots left under MAX_FILES, and + * which files count. + */ +export type UntrackedPlace = { + slots: number + scope: Types.UntrackedScope +} diff --git a/mods/diff/hooks/git/probes/untracked-rows-of/index.ts b/mods/diff/hooks/git/probes/untracked-rows-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d398924248717bf6434e4beafc751c3d03915346 --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-rows-of/index.ts @@ -0,0 +1,3 @@ +export * from './untracked-rows-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts b/mods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..de683a4c62dbd34c93a230b6a2f7d243066d6bfe --- /dev/null +++ b/mods/diff/hooks/git/probes/untracked-rows-of/untracked-rows-of.ts @@ -0,0 +1,59 @@ +import Limits from '../../../limits' +import type Types from '../../types' +import { datingsOf } from '../datings-of' +import type { UntrackedPlace } from '../untracked-place' +import { wasDirtyAtStart } from '../was-dirty-at-start' + +/** + * Listed untracked paths as 0/0 rows: the session's own first, then, + * when the scope asks, the tagged pre-session ones, up to the slots. + * + * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the + * listing budget reads as pre-session, an undatable one or one that turned + * up since the start (wasDirtyAtStart) as the session's (the built-in's). + * + * @param context the fetch's stamp probe and session start + * @param paths the untracked paths, root-relative, as the lister printed them + * @param place the free row slots and the scope + * @returns the rows to merge after the tracked ones + */ +export async function untrackedRowsOf( + context: Types.DatingContext, + paths: readonly string[], + place: UntrackedPlace, +): Promise { + const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) + const datings = await datingsOf(context, probedPaths) + + const isPreSessionOf = (path: string, dating: Types.Dating | undefined) => + dating === 'unlisted' || + (dating === 'pre-session' && wasDirtyAtStart(context, path)) + + const probed = probedPaths.map(path => ({ + path, + isPreSession: isPreSessionOf(path, datings.get(path)), + })) + + const isWithPreSession = place.scope === 'with-pre-session' + + const earlier = isWithPreSession + ? [ + ...probed.filter(file => file.isPreSession), + ...paths + .slice(Limits.MAX_UNTRACKED_PROBES) + .map(path => ({ path, isPreSession: true })), + ] + : [] + + const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] + + return ordered.slice(0, place.slots).map(file => ({ + path: file.path, + renamedFrom: null, + added: 0, + removed: 0, + isBinary: false, + isUntracked: true, + isPreSession: file.isPreSession, + })) +} diff --git a/mods/diff/hooks/git/probes/was-dirty-at-start/index.ts b/mods/diff/hooks/git/probes/was-dirty-at-start/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0db2a69e6339ce5ab76442a63c2c2641f28f9a69 --- /dev/null +++ b/mods/diff/hooks/git/probes/was-dirty-at-start/index.ts @@ -0,0 +1,3 @@ +export * from './was-dirty-at-start.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts b/mods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts new file mode 100644 index 0000000000000000000000000000000000000000..5472cc1afc634b75bf3bb12a604a2114b28b31dd --- /dev/null +++ b/mods/diff/hooks/git/probes/was-dirty-at-start/was-dirty-at-start.ts @@ -0,0 +1,16 @@ +import type Types from '../../types' + +/** + * Whether a path may predate the session at all: it was dirty when the + * baseline was read (the first fetch), or nothing says otherwise (none was). + * + * A path missing from the baseline turned up since, so its old timestamp + * (a rename keeps the file's, a move keeps the source's) does not date it, + * as the built-in's change-time check does not let it. + * + * @param context the fetch's dating context + * @param path the root-relative path + * @returns false only when a baseline exists and lacks the path + */ +export const wasDirtyAtStart = (context: Types.DatingContext, path: string) => + context.deps.baseline?.has(path) ?? true diff --git a/mods/diff/hooks/git/types/base-mode/base-mode.ts b/mods/diff/hooks/git/types/base-mode/base-mode.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d955e5840fc17a396b716a152bee389acb14707 --- /dev/null +++ b/mods/diff/hooks/git/types/base-mode/base-mode.ts @@ -0,0 +1,5 @@ +/** + * What the diff compares the working tree against: HEAD partitioned by the + * session's start, HEAD plainly, or the merge-base with the default branch. + */ +export type BaseMode = 'session' | 'uncommitted' | 'branch' diff --git a/mods/diff/hooks/git/types/base-mode/index.ts b/mods/diff/hooks/git/types/base-mode/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d67c2450efcef3da027b0e78a4536ea5003369e2 --- /dev/null +++ b/mods/diff/hooks/git/types/base-mode/index.ts @@ -0,0 +1,3 @@ +export type * from './base-mode.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/dating/dating.ts b/mods/diff/hooks/git/types/dating/dating.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cbc87fc25155175634bf6a5ebdd4ac3dbcc8836 --- /dev/null +++ b/mods/diff/hooks/git/types/dating/dating.ts @@ -0,0 +1,7 @@ +/** + * When a path was last written, against the session: before it began, + * since, or never reached because the listing budget was spent. + * + * An undatable path (deleted, a symbolic link) reads as `session`. + */ +export type Dating = 'pre-session' | 'session' | 'unlisted' diff --git a/mods/diff/hooks/git/types/dating/index.ts b/mods/diff/hooks/git/types/dating/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..57b5af7f6ebe89daa8a30f25740681e57f7cdc4c --- /dev/null +++ b/mods/diff/hooks/git/types/dating/index.ts @@ -0,0 +1,3 @@ +export type * from './dating.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/diff-stats/diff-stats.ts b/mods/diff/hooks/git/types/diff-stats/diff-stats.ts new file mode 100644 index 0000000000000000000000000000000000000000..85f948a9f3a1c73b8c6500099158848ce5363d45 --- /dev/null +++ b/mods/diff/hooks/git/types/diff-stats/diff-stats.ts @@ -0,0 +1,8 @@ +/** + * A diff's totals as `--numstat` or `--shortstat` count them. + */ +export type DiffStats = { + filesCount: number + linesAdded: number + linesRemoved: number +} diff --git a/mods/diff/hooks/git/types/entry-kind/entry-kind.ts b/mods/diff/hooks/git/types/entry-kind/entry-kind.ts new file mode 100644 index 0000000000000000000000000000000000000000..40b5011c2ecb72a82b588a3f64ff3c3dc0a5fe0d --- /dev/null +++ b/mods/diff/hooks/git/types/entry-kind/entry-kind.ts @@ -0,0 +1,7 @@ +import type { FsEntry } from 'claude-code' + +/** + * A directory entry's own kind as `$.fs.list` reports it, no link + * followed: `file`, `dir`, or `other` (a symbolic link is `other`). + */ +export type EntryKind = FsEntry['kind'] diff --git a/mods/diff/hooks/git/types/entry-kind/index.ts b/mods/diff/hooks/git/types/entry-kind/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dbbbe547d9ce6e3bdfe31b9bae2c49de6c409c26 --- /dev/null +++ b/mods/diff/hooks/git/types/entry-kind/index.ts @@ -0,0 +1,3 @@ +export type * from './entry-kind.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/git-deps/git-deps.ts b/mods/diff/hooks/git/types/git-deps/git-deps.ts new file mode 100644 index 0000000000000000000000000000000000000000..103c63d73eb4c6c70635ea8cf33900aa3eaa7e96 --- /dev/null +++ b/mods/diff/hooks/git/types/git-deps/git-deps.ts @@ -0,0 +1,48 @@ +import type { BranchBase } from '../branch-base' +import type { EntryKind } from '../entry-kind' +import type { GitRun } from '../git-run' +import type { Repository } from '../repository' + +/** + * What the fetch reaches the host through: pinned git, the repository, a + * directory listing, a timestamp probe, the start, a branch-base listener. + */ +export type GitDeps = { + /** + * Runs git against `repository`; never rejects (GitRun). + */ + run: GitRun + + /** + * Where the session started, resolved once and pinned on every child. + */ + repository: Repository + + /** + * A path's modification time in epoch milliseconds, or null when it + * cannot be read. Called only on a path a listing showed as a file. + */ + mtimeOf: (path: string) => Promise + + /** + * A directory's entries by name with their own kinds (no link followed), + * or null when it cannot be listed. + */ + entryKindsOf: (dir: string) => Promise | null> + + sessionStartMs: number + + /** + * The paths dirty when the backend first fetched (Probes.dirtyPathsOf), + * read then and kept; null when unlisted, absent where none is kept. + * + * A path absent from it turned up since (a rename's new name, a file + * moved in), whatever timestamp it carries. + */ + baseline?: ReadonlySet | null + + /** + * Told each time branch mode resolves its base, for telemetry. + */ + onBranchBase: (base: BranchBase) => void +} diff --git a/mods/diff/hooks/git/types/git-deps/index.ts b/mods/diff/hooks/git/types/git-deps/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..200b088d5eeaf0b2d2cc79e8bf4b7fd3f10884c6 --- /dev/null +++ b/mods/diff/hooks/git/types/git-deps/index.ts @@ -0,0 +1,3 @@ +export type * from './git-deps.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/head-key-deps/head-key-deps.ts b/mods/diff/hooks/git/types/head-key-deps/head-key-deps.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d6b6d7aadc019a7d0dbe065747e2e1c3746cd7f --- /dev/null +++ b/mods/diff/hooks/git/types/head-key-deps/head-key-deps.ts @@ -0,0 +1,12 @@ +import type { GitDeps } from '../git-deps' + +/** + * What reading the HEAD key takes: git for the fallback, listings and + * timestamps for the files, and a file read for HEAD's text. + */ +export type HeadKeyDeps = Pick & { + /** + * A file's text; rejects when it cannot be read. + */ + readFile: (path: string) => Promise +} diff --git a/mods/diff/hooks/git/types/head-key-deps/index.ts b/mods/diff/hooks/git/types/head-key-deps/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dfd2ee3ad58d1b519a2d9a90195987289bf9b06 --- /dev/null +++ b/mods/diff/hooks/git/types/head-key-deps/index.ts @@ -0,0 +1,3 @@ +export type * from './head-key-deps.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/hunk/hunk.ts b/mods/diff/hooks/git/types/hunk/hunk.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f4faff92937d5de3ae26bd0039b179b818ab510 --- /dev/null +++ b/mods/diff/hooks/git/types/hunk/hunk.ts @@ -0,0 +1,9 @@ +/** + * One hunk of a unified diff: where it starts on each side and its lines, + * each still carrying its `+`, `-` or space marker. + */ +export type Hunk = { + oldStart: number + newStart: number + lines: readonly string[] +} diff --git a/mods/diff/hooks/git/types/hunk/index.ts b/mods/diff/hooks/git/types/hunk/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd22de7fd22089aace67a408d0c25313a45e1e48 --- /dev/null +++ b/mods/diff/hooks/git/types/hunk/index.ts @@ -0,0 +1,3 @@ +export type * from './hunk.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/merged-result/index.ts b/mods/diff/hooks/git/types/merged-result/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d4a85da6705fc495dedfea8741f6fa2b924aab1 --- /dev/null +++ b/mods/diff/hooks/git/types/merged-result/index.ts @@ -0,0 +1,4 @@ +export type * from './merged-result.js' +export * from './numstat-result' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/merged-result/merged-result.ts b/mods/diff/hooks/git/types/merged-result/merged-result.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ce4449595a87647cd0212345d541cbc35591588 --- /dev/null +++ b/mods/diff/hooks/git/types/merged-result/merged-result.ts @@ -0,0 +1,10 @@ +import type { NumstatResult } from './numstat-result' + +/** + * A tier's rows once the untracked files were merged in, and whether git + * withheld their listing (failed, timed out, cut). + * + * Withheld: the rows are the tracked ones alone, as the built-in degrades, + * and the pane says so. + */ +export type MergedResult = NumstatResult & { isUntrackedWithheld: boolean } diff --git a/mods/diff/hooks/git/types/merged-result/numstat-result/index.ts b/mods/diff/hooks/git/types/merged-result/numstat-result/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c5af9811829cf0ef07c50a70f358849d11e1d37 --- /dev/null +++ b/mods/diff/hooks/git/types/merged-result/numstat-result/index.ts @@ -0,0 +1,3 @@ +export type * from './numstat-result.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/merged-result/numstat-result/numstat-result.ts b/mods/diff/hooks/git/types/merged-result/numstat-result/numstat-result.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7d1b692f24ee8d0d3f7aaff7b8c207aab474cdc --- /dev/null +++ b/mods/diff/hooks/git/types/merged-result/numstat-result/numstat-result.ts @@ -0,0 +1,11 @@ +import type { DiffStats } from '../../diff-stats' +import type { FileStat } from '../../file-stat' + +/** + * A parsed `--numstat`: the totals over every row, and the first MAX_FILES + * rows. + */ +export type NumstatResult = { + stats: DiffStats + files: readonly FileStat[] +} diff --git a/mods/diff/hooks/git/types/stamp-probe/index.ts b/mods/diff/hooks/git/types/stamp-probe/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c29c50f9e78fd93927ad4214009058c3aeb7b5b --- /dev/null +++ b/mods/diff/hooks/git/types/stamp-probe/index.ts @@ -0,0 +1,3 @@ +export type * from './stamp-probe.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/stamp-probe/stamp-probe.ts b/mods/diff/hooks/git/types/stamp-probe/stamp-probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..6373fe06355a0072b2aafc146620503c1a0ee941 --- /dev/null +++ b/mods/diff/hooks/git/types/stamp-probe/stamp-probe.ts @@ -0,0 +1,12 @@ +import type { GitDeps } from '../git-deps' +import type { KindWalk } from '../kind-walk' + +/** + * What stamping a file behind a kind walk takes: the walk, the absolute + * base its paths are under, and the timestamp probe. + */ +export type StampProbe = { + walk: KindWalk + base: string + mtimeOf: GitDeps['mtimeOf'] +} diff --git a/mods/diff/hooks/git/types/stamp/index.ts b/mods/diff/hooks/git/types/stamp/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f851597902d2d290e0767ea1b3a480922872b2f --- /dev/null +++ b/mods/diff/hooks/git/types/stamp/index.ts @@ -0,0 +1,3 @@ +export type * from './stamp.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/stamp/stamp.ts b/mods/diff/hooks/git/types/stamp/stamp.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c110e5b69fbd86b326f49a72ca25492278a93cb --- /dev/null +++ b/mods/diff/hooks/git/types/stamp/stamp.ts @@ -0,0 +1,7 @@ +/** + * A file's date behind a kind walk: its modification time; null when it is + * no real file under real directories or cannot be read. + * + * `over-budget` when the walk's listings ran out before reaching it. + */ +export type Stamp = number | 'over-budget' | null diff --git a/mods/diff/hooks/git/types/walk-kind/index.ts b/mods/diff/hooks/git/types/walk-kind/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a210ca329cc8638cd110f99b486954ff9055e876 --- /dev/null +++ b/mods/diff/hooks/git/types/walk-kind/index.ts @@ -0,0 +1,3 @@ +export type * from './walk-kind.js' + +export * as default from '.' diff --git a/mods/diff/hooks/git/types/walk-kind/walk-kind.ts b/mods/diff/hooks/git/types/walk-kind/walk-kind.ts new file mode 100644 index 0000000000000000000000000000000000000000..21f4fb74d6f36918447557a3a6351c40b2289bf0 --- /dev/null +++ b/mods/diff/hooks/git/types/walk-kind/walk-kind.ts @@ -0,0 +1,9 @@ +import type { EntryKind } from '../entry-kind' + +/** + * What a kind walk answers for a path: the entry's own kind; null when it + * is absent, an ancestor is no real directory, or a listing failed. + * + * `over-budget` when the walk had spent its listings before reaching it. + */ +export type WalkKind = EntryKind | 'over-budget' | null diff --git a/mods/diff/hooks/views/body/body-scrolled-by.ts b/mods/diff/hooks/views/body/body-scrolled-by.ts new file mode 100644 index 0000000000000000000000000000000000000000..8363e933cf6982714facb84122a9dca385175193 --- /dev/null +++ b/mods/diff/hooks/views/body/body-scrolled-by.ts @@ -0,0 +1,47 @@ +import type { UiScrollInput } from 'claude-code' + +import Limits from '../../limits' +import type PaneState from '../../pane-state' +import Plan from './plan' + +/** + * The docked pane's place after one of the person's scrolls of the body: + * a wheel tick or arrow three rows, a page key the window, Home/End an end. + * + * Told apart by the rows the move asks for against the pane's body and + * tree, as the engine sizes each; clamped to the body's extent. + * + * @param model the pane's state + * @param scroll the move: its signed rows, the body's rows, the tree's rows + * @returns the place + */ +export function bodyScrolledBy( + model: PaneState.PaneModel, + scroll: Pick, +): PaneState.PaneModel['place'] { + const { visibleRows, maxTop } = Plan.bodyLayoutOf( + model, + Plan.dockPlanOf(model), + ) + + const size = Math.abs(scroll.by) + + const isEnd = + size >= scroll.contentRows && scroll.contentRows > scroll.bodyRows + + const isPage = !isEnd && size >= scroll.bodyRows + + const step = isEnd + ? maxTop + : isPage + ? Math.max(1, visibleRows) + : size * Limits.WHEEL_ROWS + + return { + ...model.place, + top: Math.max( + 0, + Math.min(maxTop, model.place.top + Math.sign(scroll.by) * step), + ), + } +} diff --git a/mods/diff/hooks/views/body/draw-window.tsx b/mods/diff/hooks/views/body/draw-window.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a22147304a0126224bc7748d4dc70814f6725ed --- /dev/null +++ b/mods/diff/hooks/views/body/draw-window.tsx @@ -0,0 +1,104 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Limits from '../../limits' +import Detail from '../detail' +import type { Kit } from '../kit' +import Layout from '../layout' +import Sections from '../sections' +import Plan from './plan' +import type { BodyLayout, HunkSegment, Segment, WindowCut } from './types' + +/** + * The rows of the docked body its window shows from a row down: each + * segment drawn from the row the window cuts it at, until the window fills. + * + * A hunk draws only its lines in view, and past MAX_BODY_CHARS of hunk + * text the rest waits for a scroll (the tree's cap); rows past the foot + * are the pane's to clip, so SCROLL_MARGIN_ROWS more are drawn than fit. + * + * @param kit the elements, the handlers, the width + * @param layout the body laid out + * @param top the body row wanted at the window's top; clamped to the body + * @returns the elements, top to bottom + */ +export function drawWindow( + kit: Kit, + layout: BodyLayout, + top: number, +): RenderElement[] { + const { Box, Text, Code } = kit.ui + const drawn: RenderElement[] = [] + const wanted = layout.visibleRows + Limits.SCROLL_MARGIN_ROWS + const at = Math.max(0, Math.min(top, layout.maxTop)) + const onPress = kit.actions.togglePreSession + + let start = 0 + let filled = 0 + let chars = 0 + + function codeOf(segment: HunkSegment, cut: WindowCut): RenderElement { + const source = Detail.hunkSourceOf(Plan.hunkWindowOf(segment, cut)) + const path = Layout.sanitizeName(segment.path) + chars += source.length + + const isOver = chars > Detail.MAX_BODY_CHARS + + return isOver ? ( + + … + + ) : ( + + ) + } + + function elementOf(segment: Segment, cut: WindowCut): RenderElement { + switch (segment.kind) { + case 'rule': + return Sections.divider(kit) + case 'blank': + return + case 'name': + return Sections.nameRow(kit, segment.name) + case 'hunk': + return codeOf(segment, cut) + case 'note': + return ( + + {segment.text} + + ) + case 'footer': + return ( + + … diff truncated (exceeded 400 line limit) + + ) + case 'text': + return Sections.dimNote(kit, segment.text) + case 'toggle': + return Sections.toggleRow(kit, 'presession', { ...segment, onPress }) + case 'legend': + return Sections.legendRow(kit, segment.entry) + } + } + + for (const segment of layout.segments) { + const rows = Plan.segmentRowsOf(segment) + const isShown = start + rows > at && filled < wanted + + if (isShown) { + const skip = Math.max(0, at - start) + + drawn.push(elementOf(segment, { skip, take: wanted - filled })) + filled += rows - skip + } + + start += rows + } + + return drawn +} diff --git a/mods/diff/hooks/views/body/index.ts b/mods/diff/hooks/views/body/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..37d56a6e345c93caa2c5392a857d3a13bfbeeeb6 --- /dev/null +++ b/mods/diff/hooks/views/body/index.ts @@ -0,0 +1,10 @@ +export * from './body-scrolled-by.js' +export * from './draw-window.jsx' +export * from './is-wheel-over-list.js' +export * from './list-block-of.jsx' +export * from './list-scrolled-by.js' +export * from './place-at-file.js' +export * from './plan' +export * from './types' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/is-wheel-over-list.ts b/mods/diff/hooks/views/body/is-wheel-over-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..b111731ef836fc8ce6734042b8d00fce4975faaa --- /dev/null +++ b/mods/diff/hooks/views/body/is-wheel-over-list.ts @@ -0,0 +1,28 @@ +import type { UiScrollInput } from 'claude-code' + +import Limits from '../../limits' +import type PaneState from '../../pane-state' +import Plan from './plan' + +/** + * Whether a scroll is a wheel tick over the docked list block while the + * list overflows its window: it moves the list then, as ReplDiffSidebar's. + * + * @param model the pane's state + * @param scroll the move: the cell the wheel was over, absent for keys + * @returns whether the list takes it + */ +export function isWheelOverList( + model: PaneState.PaneModel, + scroll: Pick, +): boolean { + const plan = Plan.dockPlanOf(model) + const { listTop, listEnd } = Plan.bodyLayoutOf(model, plan) + const row = scroll.pointer?.row ?? -1 + + return ( + row >= listTop && + row < listEnd && + plan.rows.length > Limits.MAX_SUMMARY_ROWS + ) +} diff --git a/mods/diff/hooks/views/body/list-block-of.tsx b/mods/diff/hooks/views/body/list-block-of.tsx new file mode 100644 index 0000000000000000000000000000000000000000..934defc3167befc845edb72287253282e13879d7 --- /dev/null +++ b/mods/diff/hooks/views/body/list-block-of.tsx @@ -0,0 +1,89 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import { keptOf } from '../../kept-of' +import Names from '../../names' +import type PaneState from '../../pane-state' +import type Entries from '../entries' +import type { Kit } from '../kit' +import Layout from '../layout' +import Sections from '../sections' +import Plan from './plan' +import type { DockPlan } from './types' + +/** + * The docked list block (ReplDiffSidebar's summary rows): the window's rows + * between its edge rows, the untracked note, the noise toggle. + * + * The edge rows count the files out of the window and take the built-in's + * list keys; files past the row cap join the lower one. Empty with no row + * and no noise to toggle. + * + * @param kit the elements, the handlers, the width + * @param model the pane's state + * @param plan the docked pane's decisions over that state + * @returns the block's rows + */ +export function listBlockOf( + kit: Kit, + model: PaneState.PaneModel, + plan: DockPlan, +): RenderElement[] { + const { shown, above, below } = Plan.listWindowOf(model, plan) + const { noiseCount } = plan.partition + const { notShown } = plan.totals + const hasNoise = !plan.turn && noiseCount > 0 + const hasMoreAbove = above > 0 + const hasMoreBelow = below > 0 + const hasNotShown = notShown > 0 + const noiseFace = model.isNoiseShown ? 'hide' : 'show' + + const lower = keptOf([ + hasMoreBelow ? `${below} ${Names.MORE_BELOW_TEXT}` : null, + hasNotShown ? `${notShown} not shown` : null, + ]).join(' · ') + + const rowOf = (entry: Entries.BodyEntry) => + Sections.fileRow( + kit, + { + key: Sections.fileKeyOf(entry), + path: entry.path, + displayPath: entry.displayPath, + added: entry.added, + removed: entry.removed, + note: null, + isSelected: false, + }, + () => kit.actions.selectFile(entry.path), + ) + + const upperEdge = hasMoreAbove + ? Sections.listEdgeButton(kit, 'list-up', `↑ ${above} more above`) + : null + + const lowerEdge = hasMoreBelow + ? Sections.listEdgeButton(kit, 'list-down', `↓ ${lower}`) + : hasNotShown + ? Sections.dimNote(kit, `… ${lower}`) + : null + + const noiseToggle = hasNoise + ? Sections.toggleRow(kit, 'noise', { + label: `${Layout.plural(noiseCount, 'test')}/generated (${noiseFace})`, + onPress: kit.actions.toggleNoise, + }) + : null + + return Sections.present([ + upperEdge, + ...shown.map(rowOf), + lowerEdge, + plan.isUntrackedNoted + ? Sections.dimNote(kit, Names.untrackedWithheldTextOf(model)) + : null, + noiseToggle, + ]) +} diff --git a/mods/diff/hooks/views/body/list-scrolled-by.ts b/mods/diff/hooks/views/body/list-scrolled-by.ts new file mode 100644 index 0000000000000000000000000000000000000000..d31e6fd399cc1d05fb47bd6adcbedf85264b5357 --- /dev/null +++ b/mods/diff/hooks/views/body/list-scrolled-by.ts @@ -0,0 +1,29 @@ +import Limits from '../../limits' +import type PaneState from '../../pane-state' +import Plan from './plan' + +/** + * The docked pane's place with the list window moved by so many files, + * clamped to the list (ReplDiffSidebar's scrollSummary). + * + * @param model the pane's state + * @param delta the files to move by, negative toward the top + * @returns the place + */ +export function listScrolledBy( + model: PaneState.PaneModel, + delta: number, +): PaneState.PaneModel['place'] { + const last = Math.max( + 0, + Plan.dockPlanOf(model).rows.length - Limits.MAX_SUMMARY_ROWS, + ) + + return { + ...model.place, + listStart: Math.max( + 0, + Math.min(last, Math.min(model.place.listStart, last) + delta), + ), + } +} diff --git a/mods/diff/hooks/views/body/place-at-file.ts b/mods/diff/hooks/views/body/place-at-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..324917cd5a42778765a80ddb09944b6f0c2bc599 --- /dev/null +++ b/mods/diff/hooks/views/body/place-at-file.ts @@ -0,0 +1,20 @@ +import type PaneState from '../../pane-state' +import Plan from './plan' + +/** + * The docked pane's place with the body scrolled so this file's block + * heads the window (the built-in's jump to file); unchanged for no such file. + * + * @param model the pane's state + * @param path the file's path + * @returns the place + */ +export const placeAtFile = ( + model: PaneState.PaneModel, + path: string, +): PaneState.PaneModel['place'] => ({ + ...model.place, + top: + Plan.bodyLayoutOf(model, Plan.dockPlanOf(model)).tops.get(path) ?? + model.place.top, +}) diff --git a/mods/diff/hooks/views/body/plan/body-layout-of.ts b/mods/diff/hooks/views/body/plan/body-layout-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c3ae6cbbb62b3ada45e3ddd47a3b7ef75d2551c --- /dev/null +++ b/mods/diff/hooks/views/body/plan/body-layout-of.ts @@ -0,0 +1,68 @@ +import Limits from '../../../limits' +import type PaneState from '../../../pane-state' +import type { BodyLayout, DockPlan } from '../types' +import Segments from './segments' + +/** + * The docked pane's scrolling body laid out under its pinned rows: the + * segments and their rows, the window's rows, the files' and list's rows. + * + * The pinned rows are counted as sidebarPane draws them (header, base or + * turn line, todo bar, list block); read from the box last drawn (`place`), + * so before any draw the window shows nothing and the top stays 0. + * + * @param model the pane's state + * @param plan the docked pane's decisions over that state + * @returns the layout + */ +export function bodyLayoutOf( + model: PaneState.PaneModel, + plan: DockPlan, +): BodyLayout { + const segments = Segments.segmentsOf(model, plan) + const window = Segments.listWindowOf(model, plan) + const hasNoise = !plan.turn && plan.partition.noiseCount > 0 + const isListed = plan.message.length === 0 + const hasList = isListed && (plan.rows.length > 0 || hasNoise) + + const listRows = hasList + ? window.shown.length + + Number(window.above > 0) + + Number(window.below > 0 || plan.totals.notShown > 0) + + Number(plan.isUntrackedNoted) + + Number(hasNoise) + + 2 + : Number(isListed) + + const headRows = + 1 + Number(plan.line !== null) + (model.todos.total > 0 ? 2 : 0) + + const tops = new Map() + + const visibleRows = Math.max( + 0, + model.place.rows - Limits.PANE_TOP_PAD_ROWS - headRows - listRows, + ) + + let extent = 0 + + for (const segment of segments) { + if (segment.kind === 'rule' && segment.path !== null) { + tops.set(segment.path, extent) + } + + extent += Segments.segmentRowsOf(segment) + } + + const listTop = Limits.PANE_TOP_PAD_ROWS + headRows + 1 + + return { + segments, + extent, + visibleRows, + maxTop: Math.max(0, extent - visibleRows), + tops, + listTop, + listEnd: hasList ? listTop + listRows - 2 : listTop, + } +} diff --git a/mods/diff/hooks/views/body/plan/dock-plan-of.ts b/mods/diff/hooks/views/body/plan/dock-plan-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..74bcda956f535ef3a2d96d71c6b12da80dc71507 --- /dev/null +++ b/mods/diff/hooks/views/body/plan/dock-plan-of.ts @@ -0,0 +1,80 @@ +import PaneState from '../../../pane-state' +import DialogPane from '../../dialog-pane' +import Entries from '../../entries' +import Layout from '../../layout' +import type { DockPlan } from '../types' + +/** + * The docked pane's decisions over a model (ReplDiffSidebarBody's): which + * rows list, what the header counts, what stands in for an empty list. + * + * Over a picked turn the rows are the turn's files and nothing is empty; + * else the fetch's session rows, its pre-session rows apart. Under the + * header: the base line outside settled session mode, or the unborn note. + * + * @param model the pane's state + * @returns the plan + */ +export function dockPlanOf(model: PaneState.PaneModel): DockPlan { + const { data, requestedMode } = model + const turn = PaneState.pickedTurnOf(model) + const noise = model.isNoiseShown ? 'shown' : 'hidden' + const partition = PaneState.partitionOf(data?.files ?? [], noise) + const face = model.isPreSessionShown ? 'hide' : 'show' + + const totals = turn + ? PaneState.turnTotalsOf(turn) + : data + ? PaneState.headerTotalsOf(data, partition) + : PaneState.ZERO_TOTALS + + const empty = turn ? null : PaneState.emptyStateOf(model, totals.filesCount) + + const listed = turn + ? [] + : DialogPane.listBodyOf({ data, partition, totals, empty }) + + const notes = listed.flatMap(line => (typeof line === 'string' ? [line] : [])) + const hasRows = turn ? turn.files.length > 0 : notes.length < listed.length + + const entryOf = (file: Parameters[0]) => + Entries.bodyEntryOf(file, model) + + const rows = turn + ? turn.files.map(Entries.turnEntryOf) + : hasRows + ? partition.shown.map(entryOf) + : [] + + const earlier = turn ? [] : partition.preSession.map(entryOf) + const hasEarlier = earlier.length > 0 + + const isBaseShown = + data !== null && + !data.isUnborn && + (requestedMode !== 'session' || data.mode !== 'session') + + const line = turn + ? `Turn ${turn.index} "${turn.preview}"` + : isBaseShown + ? PaneState.modeLabelOf(requestedMode, model) + : PaneState.unbornNoteOf(model, totals.filesCount) + + return { + turn, + partition, + totals, + empty, + line: line === null ? null : Layout.sanitizeName(line), + rows, + earlier, + earlierLabel: hasEarlier + ? `+${Layout.plural(earlier.length, 'file')} edited before this ` + + `session (${face})` + : null, + notes, + message: empty ? [empty.headline, ...notes] : hasRows ? [] : notes, + isUntrackedNoted: + !turn && data?.isUntrackedWithheld === true && empty === null, + } +} diff --git a/mods/diff/hooks/views/body/plan/hunk-window-of.ts b/mods/diff/hooks/views/body/plan/hunk-window-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d779478b48203263ae19f7d28dfbd398e959815d --- /dev/null +++ b/mods/diff/hooks/views/body/plan/hunk-window-of.ts @@ -0,0 +1,32 @@ +import Git from '../../../git' +import type { HunkSegment, WindowCut } from '../types' + +/** + * The part of a hunk the window shows: from the line holding its first + * shown row (hunkAfter renumbers) to the last that starts in the room left. + * + * @param segment the hunk and the rows each of its lines takes + * @param cut the rows hidden above the window and the rows it has left + * @returns the hunk's shown lines + */ +export function hunkWindowOf(segment: HunkSegment, cut: WindowCut): Git.Hunk { + let passed = 0 + let first = 0 + let shown = 0 + let last = 0 + + for (const rows of segment.lineRows) { + if (passed + rows <= cut.skip) { + first += 1 + } else if (shown < cut.take) { + shown += rows + last += 1 + } + + passed += rows + } + + const tail = Git.hunkAfter(segment.hunk, first) + + return { ...tail, lines: tail.lines.slice(0, last) } +} diff --git a/mods/diff/hooks/views/body/plan/index.ts b/mods/diff/hooks/views/body/plan/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..25e8bac3622e2cfd64b6dc9af8e3eb35e5d61be5 --- /dev/null +++ b/mods/diff/hooks/views/body/plan/index.ts @@ -0,0 +1,6 @@ +export * from './body-layout-of.js' +export * from './dock-plan-of.js' +export * from './hunk-window-of.js' +export * from './segments' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/plan/segments/index.ts b/mods/diff/hooks/views/body/plan/segments/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..53c32d027bd4d0a7f6c08ce6ec8ca1075767ab7f --- /dev/null +++ b/mods/diff/hooks/views/body/plan/segments/index.ts @@ -0,0 +1,5 @@ +export * from './list-window-of.js' +export * from './segment-rows-of.js' +export * from './segments-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/plan/segments/list-window-of.ts b/mods/diff/hooks/views/body/plan/segments/list-window-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba11a1a998599c0b8360684ac94ccf93c6426bb1 --- /dev/null +++ b/mods/diff/hooks/views/body/plan/segments/list-window-of.ts @@ -0,0 +1,29 @@ +import Limits from '../../../../limits' +import type PaneState from '../../../../pane-state' +import type { DockPlan, ListWindow } from '../../types' + +/** + * The docked list's MAX_SUMMARY_ROWS-row window starting at the file the + * person scrolled it to, clamped so a shrinking list pulls it back. + * + * @param model where the person put the list window + * @param plan the docked pane's rows + * @returns the window + */ +export function listWindowOf( + model: Pick, + plan: Pick, +): ListWindow { + const first = Math.max( + 0, + Math.min(model.place.listStart, plan.rows.length - Limits.MAX_SUMMARY_ROWS), + ) + + const shown = plan.rows.slice(first, first + Limits.MAX_SUMMARY_ROWS) + + return { + shown, + above: first, + below: plan.rows.length - first - shown.length, + } +} diff --git a/mods/diff/hooks/views/body/plan/segments/segment-rows-of.ts b/mods/diff/hooks/views/body/plan/segments/segment-rows-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2dfaf01f0614a6b429a0fdcb658d5a18fa1271d --- /dev/null +++ b/mods/diff/hooks/views/body/plan/segments/segment-rows-of.ts @@ -0,0 +1,11 @@ +import type { Segment } from '../../types' + +/** + * The rows a segment takes: a hunk's as counted when it was laid out, one + * for anything else. + * + * @param segment the segment + * @returns the rows + */ +export const segmentRowsOf = (segment: Segment): number => + segment.kind === 'hunk' ? segment.rows : 1 diff --git a/mods/diff/hooks/views/body/plan/segments/segments-of.ts b/mods/diff/hooks/views/body/plan/segments/segments-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..969b025a893d5fc7f4e734b390973da868f78df3 --- /dev/null +++ b/mods/diff/hooks/views/body/plan/segments/segments-of.ts @@ -0,0 +1,137 @@ +import { countOf } from '../../../../count-of' +import Git from '../../../../git' +import { keptOf } from '../../../../kept-of' +import Limits from '../../../../limits' +import type PaneState from '../../../../pane-state' +import Detail from '../../../detail' +import Entries from '../../../entries' +import Layout from '../../../layout' +import type { DockPlan, Segment } from '../../types' + +/** + * The docked pane's scrolling body as segments (ReplDiffSidebar's scroll + * box): each listed file's block, a blank between, the pre-session section. + * + * Under an empty state whose pre-session section is open, the empty lines + * head the body instead. Hunks are made printable and split under a leaf's + * cap (subHunksOf), each carrying the rows its lines take wrapped. + * + * @param model the pane's state: which file is armed, which section is open + * @param plan the docked pane's decisions over that state + * @returns the segments, top to bottom + */ +export function segmentsOf( + model: PaneState.PaneModel, + plan: DockPlan, +): Segment[] { + const { earlier } = plan + const { columns } = model.place + const isEmptied = plan.message.length > 0 + const isOpen = model.isPreSessionShown && earlier.length > 0 + const isCapped = earlier.length > Limits.PRE_SESSION_BODY_CAP + + const gutterOf = (hunk: Git.Hunk) => + String( + Math.max( + 1, + hunk.oldStart + countOf(hunk.lines, line => !line.startsWith('+')) - 1, + hunk.newStart + countOf(hunk.lines, line => !line.startsWith('-')) - 1, + ), + ).length + Limits.GUTTER_CHROME + + function hunkOf(entry: Entries.BodyEntry, hunk: Git.Hunk): Segment { + const room = Math.max(1, columns - gutterOf(hunk)) + + const lineRows = hunk.lines.map(line => + Math.max( + 1, + Math.ceil( + Layout.cellWidth( + Layout.sanitizeLine( + line.slice(1).replaceAll('\t', ' '.repeat(Layout.TAB_WIDTH)), + ), + ) / room, + ), + ), + ) + + return { + kind: 'hunk', + path: entry.path, + hunk, + lineRows, + rows: lineRows.reduce((sum, rows) => sum + rows, 0), + } + } + + const splitOf = (hunk: Git.Hunk) => + Detail.subHunksOf( + { + ...hunk, + lines: hunk.lines.filter(Git.isBodyLine).map(Detail.drawnLineOf), + }, + Detail.MAX_CODE_CHARS, + ) + + function fileOf(entry: Entries.BodyEntry): Segment[] { + const detail = Entries.detailModelOf(entry, model) + const notes = Detail.placeholderOf(detail) + const splits = (entry.body?.hunks ?? []).map(splitOf) + + const isTruncated = + entry.body?.isTruncated === true || + splits.some(split => split.isTruncated) + + const body: Segment[] = notes + ? notes.map(text => ({ kind: 'note', text })) + : splits.flatMap(split => split.hunks).map(hunk => hunkOf(entry, hunk)) + + return [ + { kind: 'rule', path: entry.path }, + { + kind: 'name', + name: { ...detail, isTruncated, isAskable: notes === null }, + }, + { kind: 'rule', path: null }, + ...body, + ...(isTruncated ? [{ kind: 'footer' } as const] : []), + ] + } + + const spacedFileOf = (entry: Entries.BodyEntry): Segment[] => [ + ...fileOf(entry), + { kind: 'blank' }, + ] + + const messageOf = (): Segment[] => [ + { kind: 'blank' }, + ...plan.message.map(text => ({ + kind: 'text' as const, + text: Layout.sanitizeName(text), + })), + { kind: 'blank' }, + ] + + const hiddenNote: Segment = { + kind: 'text', + text: `diffs hidden above ${Limits.PRE_SESSION_BODY_CAP} files`, + } + + const bodiesOf = (): Segment[] => + isCapped ? [hiddenNote] : earlier.flatMap(fileOf) + + const openedOf = (): Segment[] => [ + { kind: 'blank' }, + ...earlier.map(entry => ({ kind: 'legend' as const, entry })), + ...bodiesOf(), + ] + + return [ + ...(isEmptied ? messageOf() : plan.rows.flatMap(spacedFileOf)), + ...keptOf([plan.earlierLabel]).map(label => ({ + kind: 'toggle' as const, + label, + })), + ...(isOpen ? openedOf() : []), + ] +} diff --git a/mods/diff/hooks/views/body/types/body-layout.ts b/mods/diff/hooks/views/body/types/body-layout.ts new file mode 100644 index 0000000000000000000000000000000000000000..51e389f3f1abe90a1cced5facc73e338381bab17 --- /dev/null +++ b/mods/diff/hooks/views/body/types/body-layout.ts @@ -0,0 +1,15 @@ +import type { Segment } from './segment' + +/** + * The docked pane's scrolling body laid out: its segments and their rows, + * the window, each file's first row, the list block's rows (end exclusive). + */ +export type BodyLayout = { + segments: readonly Segment[] + extent: number + visibleRows: number + maxTop: number + tops: ReadonlyMap + listTop: number + listEnd: number +} diff --git a/mods/diff/hooks/views/body/types/dock-plan.ts b/mods/diff/hooks/views/body/types/dock-plan.ts new file mode 100644 index 0000000000000000000000000000000000000000..11bc1beb743a731529c88d5d2e57ffe843c07f7f --- /dev/null +++ b/mods/diff/hooks/views/body/types/dock-plan.ts @@ -0,0 +1,25 @@ +import type PaneState from '../../../pane-state' +import type Turns from '../../../turns' +import type Entries from '../../entries' + +/** + * What the docked pane decided before drawing: the turn or fetch it shows, + * its rows and counts, the empty state, the lines round the list. + * + * `message` holds the lines that stand in for the list (an empty state, a + * too-many or only-noise state); `line` the turn's preview, base line or + * no-commits note, printable; `earlierLabel` the pre-session grey line. + */ +export type DockPlan = { + turn: Turns.TurnDiff | undefined + partition: PaneState.Partition + totals: PaneState.HeaderTotals + empty: PaneState.EmptyState | null + line: string | null + rows: readonly Entries.BodyEntry[] + earlier: readonly Entries.BodyEntry[] + earlierLabel: string | null + notes: readonly string[] + message: readonly string[] + isUntrackedNoted: boolean +} diff --git a/mods/diff/hooks/views/body/types/index.ts b/mods/diff/hooks/views/body/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..55892e63668b48ec7ae2c319b4288133ff5f61ba --- /dev/null +++ b/mods/diff/hooks/views/body/types/index.ts @@ -0,0 +1,7 @@ +export type * from './body-layout.js' +export type * from './dock-plan.js' +export type * from './list-window.js' +export * from './segment' +export * from './window-cut' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/types/list-window.ts b/mods/diff/hooks/views/body/types/list-window.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca08954eb6fb40a5b26557a703e5ca2973ca70e8 --- /dev/null +++ b/mods/diff/hooks/views/body/types/list-window.ts @@ -0,0 +1,11 @@ +import type Entries from '../../entries' + +/** + * The docked list's window over its files: the rows in view, and how many + * files lie above and below it. + */ +export type ListWindow = { + shown: readonly Entries.BodyEntry[] + above: number + below: number +} diff --git a/mods/diff/hooks/views/body/types/segment/hunk-segment/hunk-segment.ts b/mods/diff/hooks/views/body/types/segment/hunk-segment/hunk-segment.ts new file mode 100644 index 0000000000000000000000000000000000000000..0152e31d4404dd136dce530d56e5a1b9af738e22 --- /dev/null +++ b/mods/diff/hooks/views/body/types/segment/hunk-segment/hunk-segment.ts @@ -0,0 +1,13 @@ +import type Git from '../../../../../git' + +/** + * One hunk of a file in the docked body: whose it is, the hunk, and the + * rows each of its lines takes wrapped at the body's width, summed too. + */ +export type HunkSegment = { + kind: 'hunk' + path: string + hunk: Git.Hunk + lineRows: readonly number[] + rows: number +} diff --git a/mods/diff/hooks/views/body/types/segment/hunk-segment/index.ts b/mods/diff/hooks/views/body/types/segment/hunk-segment/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..24731444ebe4b79500bbb289de2f2ceb6559ba4d --- /dev/null +++ b/mods/diff/hooks/views/body/types/segment/hunk-segment/index.ts @@ -0,0 +1,3 @@ +export type * from './hunk-segment.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/types/segment/index.ts b/mods/diff/hooks/views/body/types/segment/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ba0521791f46503dc25881d7fcac4a8936fec7b --- /dev/null +++ b/mods/diff/hooks/views/body/types/segment/index.ts @@ -0,0 +1,4 @@ +export * from './hunk-segment' +export type * from './segment.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/types/segment/segment.ts b/mods/diff/hooks/views/body/types/segment/segment.ts new file mode 100644 index 0000000000000000000000000000000000000000..73f8a1edab2e19a0bcb507b7c465e4a285e19e49 --- /dev/null +++ b/mods/diff/hooks/views/body/types/segment/segment.ts @@ -0,0 +1,22 @@ +import type Detail from '../../../detail' +import type Entries from '../../../entries' +import type { HunkSegment } from './hunk-segment' + +/** + * One piece of the docked pane's scrolling body: a hunk some rows tall + * (HunkSegment), or one row of another kind, top to bottom. + * + * A file is a `rule` (its top, what a row's press scrolls to), its `name`, + * a `rule`, then hunks or `note`s and a `footer` when cut; `text` is an + * empty state's line, `toggle` and `legend` the pre-session section's. + */ +export type Segment = + | HunkSegment + | { kind: 'rule'; path: string | null } + | { kind: 'blank' } + | { kind: 'name'; name: Detail.NamedDetail } + | { kind: 'note'; text: string } + | { kind: 'footer' } + | { kind: 'text'; text: string } + | { kind: 'toggle'; label: string } + | { kind: 'legend'; entry: Entries.BodyEntry } diff --git a/mods/diff/hooks/views/body/types/window-cut/index.ts b/mods/diff/hooks/views/body/types/window-cut/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8c67c7a8968089474cf24646df302a8df85c5de --- /dev/null +++ b/mods/diff/hooks/views/body/types/window-cut/index.ts @@ -0,0 +1,3 @@ +export type * from './window-cut.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/body/types/window-cut/window-cut.ts b/mods/diff/hooks/views/body/types/window-cut/window-cut.ts new file mode 100644 index 0000000000000000000000000000000000000000..0569c04e60341966ac22dcd2893764ba58e3a613 --- /dev/null +++ b/mods/diff/hooks/views/body/types/window-cut/window-cut.ts @@ -0,0 +1,8 @@ +/** + * How the body's window cuts one hunk: the wrapped rows its top hides, and + * the rows it still has room for from there. + */ +export type WindowCut = { + skip: number + take: number +} diff --git a/mods/diff/hooks/views/detail/body-budget/body-budget.ts b/mods/diff/hooks/views/detail/body-budget/body-budget.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f10b5d4cc6ec4b09ed8ce617ce185b56fad4de8 --- /dev/null +++ b/mods/diff/hooks/views/detail/body-budget/body-budget.ts @@ -0,0 +1,12 @@ +import { MAX_BODY_CHARS } from '../max-body-chars' +import { MAX_BODY_NODES } from '../max-body-nodes' +import type Types from '../types' + +/** + * The room the pane's file bodies start from at each drawing, spent file by + * file in drawing order. + */ +export const BODY_BUDGET: Types.BodyRoom = Object.freeze({ + chars: MAX_BODY_CHARS, + nodes: MAX_BODY_NODES, +}) diff --git a/mods/diff/hooks/views/detail/body-budget/index.ts b/mods/diff/hooks/views/detail/body-budget/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fba2bd1cd7dfea461de2529a9d78f757d1f7652 --- /dev/null +++ b/mods/diff/hooks/views/detail/body-budget/index.ts @@ -0,0 +1,3 @@ +export * from './body-budget.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts b/mods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd28e59e7f78b275e45f2e88cf4c870a85a034a9 --- /dev/null +++ b/mods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts @@ -0,0 +1,62 @@ +import Git from '../../../git' +import { hunkSourceOf } from '../hunk-source-of' +import { leadingHunkOf } from '../leading-hunk-of' +import { MAX_CODE_CHARS } from '../max-code-chars' +import { subHunksOf } from '../sub-hunks-of' +import type Types from '../types' +import { drawnLineOf } from './drawn-line-of' + +/** + * A file's hunks as the sources of `Code` blocks, one a hunk, stacked with + * nothing between as the built-in stacks a file's hunks. + * + * A hunk longer than MAX_CODE_CHARS is split (subHunksOf) into several. The + * body stops, truncated, at the whole lines the room holds as the host + * counts, a block a node; the room left comes back. + * + * @param hunks the file's hunks + * @param room the characters and nodes the pane's bodies have left + * @returns the sources in order, whether any line was left out or cut, and + * the room after them + */ +export function codeBlocksOf( + hunks: readonly Git.Hunk[], + room: Types.BodyRoom, +): Types.CodeBody { + const sources: string[] = [] + + let chars = 0 + let isTruncated = false + + const left = (): Types.BodyRoom => ({ + chars: room.chars - chars, + nodes: room.nodes - sources.length, + }) + + for (const hunk of hunks) { + const lines = hunk.lines.filter(Git.isBodyLine).map(drawnLineOf) + const split = subHunksOf({ ...hunk, lines }, MAX_CODE_CHARS) + isTruncated ||= split.isTruncated + + for (const piece of split.hunks) { + const fits = room.chars - chars + const isWhole = hunkSourceOf(piece).length <= fits + const fitted = isWhole ? piece : leadingHunkOf(piece, fits) + const isOverNodes = sources.length + 1 > room.nodes + + if (isOverNodes || fitted.lines.length === 0) { + return { sources, isTruncated: true, room: left() } + } + + const text = hunkSourceOf(fitted) + sources.push(text) + chars += text.length + + if (fitted.lines.length < piece.lines.length) { + return { sources, isTruncated: true, room: left() } + } + } + } + + return { sources, isTruncated, room: left() } +} diff --git a/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/drawn-line-of.ts b/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/drawn-line-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5807d533d9179d293a6f81d19174b3a19ad32c7 --- /dev/null +++ b/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/drawn-line-of.ts @@ -0,0 +1,15 @@ +import Layout from '../../../layout' + +/** + * A hunk line made safe for a `Code` source: its marker and its tabs kept, + * the engine laying tabs out as it does every diff it draws. + * + * Between the tabs sanitizeLine's rule holds: every control, format and + * invisible character dropped, so nothing can reorder or hide the line. + * + * @param line a body line, its `+`, `-` or space marker first + * @returns the drawable line + */ +export const drawnLineOf = (line: string) => + line.slice(0, 1) + + line.slice(1).split('\t').map(Layout.sanitizeLine).join('\t') diff --git a/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/index.ts b/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fad691797a829b84bc25c9b00671695158b349d1 --- /dev/null +++ b/mods/diff/hooks/views/detail/code-blocks-of/drawn-line-of/index.ts @@ -0,0 +1,3 @@ +export * from './drawn-line-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/code-blocks-of/index.ts b/mods/diff/hooks/views/detail/code-blocks-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f279db5609fe5e1cb276b36e6323b942ffb0a713 --- /dev/null +++ b/mods/diff/hooks/views/detail/code-blocks-of/index.ts @@ -0,0 +1,4 @@ +export * from './code-blocks-of.js' +export * from './drawn-line-of' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/detail-model/detail-model.ts b/mods/diff/hooks/views/detail/detail-model/detail-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b26558af831860dc49572e7c2e937cdda0c4520 --- /dev/null +++ b/mods/diff/hooks/views/detail/detail-model/detail-model.ts @@ -0,0 +1,19 @@ +import type Backend from '../../../backend' +import type Git from '../../../git' + +/** + * One file as its body draws it. + * + * Its name as listed, what kind of row it is, its hunks once read (undefined + * while they load, null when the backend could not read them), whether it is + * armed for the next prompt, and the backend's words for an untracked note. + */ +export type DetailModel = { + words: Pick + path: string + displayPath: string + isUntracked: boolean + isBinary: boolean + body: Git.FileHunks | null | undefined + isArmed: boolean +} diff --git a/mods/diff/hooks/views/detail/detail-model/index.ts b/mods/diff/hooks/views/detail/detail-model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0404f71c9d459ae2c7098cf677fb62cbe6fe241 --- /dev/null +++ b/mods/diff/hooks/views/detail/detail-model/index.ts @@ -0,0 +1,3 @@ +export type * from './detail-model.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/detail-view.tsx b/mods/diff/hooks/views/detail/detail-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6bc1b6401f29c362fa0448e6eba28b3360c7f360 --- /dev/null +++ b/mods/diff/hooks/views/detail/detail-view.tsx @@ -0,0 +1,77 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { Kit } from '../kit' +import Layout from '../layout' +import Sections from '../sections' +import { codeBlocksOf } from './code-blocks-of' +import type { DetailModel } from './detail-model' +import { FILE_FRAME_NODES } from './file-frame-nodes' +import { MAX_CODE_CHARS } from './max-code-chars' +import { placeholderOf } from './placeholder-of' +import type Types from './types' + +/** + * One file's detail (DiffDetailView): its name row (nameRow), the + * built-in's dim rule under it, the body. + * + * The body is a placeholder, or the hunks as the engine's diff `Code` + * blocks (codeBlocksOf) within the room given, then a footer when anything + * was cut; with it, the room left over. + * + * @param kit the elements and the width + * @param detail the file + * @param room the room the pane's bodies have left + * @returns the detail element and the room after it + */ +export function detailView( + kit: Kit, + detail: DetailModel, + room: Types.BodyRoom, +): Types.DrawnDetail { + const { Box, Text, Code } = kit.ui + const placeholder = placeholderOf(detail) + + const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? []), { + chars: room.chars - Layout.sanitizeName(detail.displayPath).length, + nodes: room.nodes - FILE_FRAME_NODES, + }) + + const isTruncated = detail.body?.isTruncated === true || code.isTruncated + const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) + + const footer = isTruncated + ? [ + + … diff truncated (exceeded 400 line limit) + , + ] + : [] + + const notes = (placeholder ?? []).map(line => ( + + {line} + + )) + + const hunks = code.sources.map(source => ( + + )) + + const element = ( + + {[ + Sections.nameRow(kit, { + ...detail, + isTruncated, + isAskable: placeholder === null, + }), + Sections.divider(kit), + ...(placeholder ? notes : hunks), + ...footer, + ]} + + ) + + return { element, room: code.room } +} diff --git a/mods/diff/hooks/views/detail/file-frame-nodes/file-frame-nodes.ts b/mods/diff/hooks/views/detail/file-frame-nodes/file-frame-nodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..64f919bf398f999b04ec91932bc5ce9b6573b97d --- /dev/null +++ b/mods/diff/hooks/views/detail/file-frame-nodes/file-frame-nodes.ts @@ -0,0 +1,5 @@ +/** + * The tree nodes one file's detail costs before any `Code` block: its box, + * the name row and its parts, the two rules, a note or footer. + */ +export const FILE_FRAME_NODES = 16 diff --git a/mods/diff/hooks/views/detail/file-frame-nodes/index.ts b/mods/diff/hooks/views/detail/file-frame-nodes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ebec2484fcd35aa459474e2551ebf6bc2e1ab24d --- /dev/null +++ b/mods/diff/hooks/views/detail/file-frame-nodes/index.ts @@ -0,0 +1,3 @@ +export * from './file-frame-nodes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/hunk-header-of/hunk-header-of.ts b/mods/diff/hooks/views/detail/hunk-header-of/hunk-header-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ab3e772dee9f9f1f31ffe0d3bb20c42e5def89c --- /dev/null +++ b/mods/diff/hooks/views/detail/hunk-header-of/hunk-header-of.ts @@ -0,0 +1,23 @@ +import { countOf } from '../../../count-of' +import type Git from '../../../git' + +/** + * A hunk's `@@ -a,b +c,d @@` header, its counts read off its lines, so a + * cut or split hunk still parses: context counts on both sides. + * + * A side with no lines names the line before its start, as jsdiff's + * formatPatch writes a structured hunk and the engine's parser reads back. + * + * @param hunk the hunk, its lines body lines only + * @returns the header line + */ +export function hunkHeaderOf(hunk: Git.Hunk) { + const oldLines = countOf(hunk.lines, line => !line.startsWith('+')) + const newLines = countOf(hunk.lines, line => !line.startsWith('-')) + const isOldEmpty = oldLines === 0 + const isNewEmpty = newLines === 0 + const oldStart = isOldEmpty ? Math.max(0, hunk.oldStart - 1) : hunk.oldStart + const newStart = isNewEmpty ? Math.max(0, hunk.newStart - 1) : hunk.newStart + + return `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@` +} diff --git a/mods/diff/hooks/views/detail/hunk-header-of/index.ts b/mods/diff/hooks/views/detail/hunk-header-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eb801a975c82dcd6d7677240173eeab3421fbbf --- /dev/null +++ b/mods/diff/hooks/views/detail/hunk-header-of/index.ts @@ -0,0 +1,3 @@ +export * from './hunk-header-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/hunk-source-of/hunk-source-of.ts b/mods/diff/hooks/views/detail/hunk-source-of/hunk-source-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d837ce7bfd32486e17ea40f2904da4ebfbb1a218 --- /dev/null +++ b/mods/diff/hooks/views/detail/hunk-source-of/hunk-source-of.ts @@ -0,0 +1,11 @@ +import type Git from '../../../git' +import { hunkHeaderOf } from '../hunk-header-of' + +/** + * A hunk as unified-diff text: its header (hunkHeaderOf), then its lines. + * + * @param hunk the hunk, its lines body lines only + * @returns the text, no trailing newline + */ +export const hunkSourceOf = (hunk: Git.Hunk) => + [hunkHeaderOf(hunk), ...hunk.lines].join('\n') diff --git a/mods/diff/hooks/views/detail/hunk-source-of/index.ts b/mods/diff/hooks/views/detail/hunk-source-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..87be857236696665df684701e922b61d10db4689 --- /dev/null +++ b/mods/diff/hooks/views/detail/hunk-source-of/index.ts @@ -0,0 +1,3 @@ +export * from './hunk-source-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/index.ts b/mods/diff/hooks/views/detail/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..79cacdb7affa1e4e6e572bfd9002c79dbe08b9ed --- /dev/null +++ b/mods/diff/hooks/views/detail/index.ts @@ -0,0 +1,16 @@ +export * from './body-budget' +export * from './code-blocks-of' +export * from './detail-model' +export * from './detail-view.jsx' +export * from './file-frame-nodes' +export * from './hunk-header-of' +export * from './hunk-source-of' +export * from './leading-hunk-of' +export * from './max-body-chars' +export * from './max-body-nodes' +export * from './max-code-chars' +export * from './placeholder-of' +export * from './sub-hunks-of' +export * from './types' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/leading-hunk-of/index.ts b/mods/diff/hooks/views/detail/leading-hunk-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..efc0949757da60b02408cdac9c8efd4d75ef633f --- /dev/null +++ b/mods/diff/hooks/views/detail/leading-hunk-of/index.ts @@ -0,0 +1,3 @@ +export * from './leading-hunk-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/leading-hunk-of/leading-hunk-of.ts b/mods/diff/hooks/views/detail/leading-hunk-of/leading-hunk-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3159043c9c195d574c2492181ace25a04cb4b8a --- /dev/null +++ b/mods/diff/hooks/views/detail/leading-hunk-of/leading-hunk-of.ts @@ -0,0 +1,32 @@ +import type Git from '../../../git' +import { hunkHeaderOf } from '../hunk-header-of' + +/** + * The most of a hunk's first lines whose text (hunkSourceOf) fits the + * room, whole lines only: none when the first line alone does not fit. + * + * @param hunk the hunk, its lines body lines only + * @param maxChars the room, in UTF-16 units + * @returns the hunk cut to those lines, its starts unchanged + */ +export function leadingHunkOf(hunk: Git.Hunk, maxChars: number): Git.Hunk { + let bodyChars = 0 + let taken = 0 + + for (const line of hunk.lines) { + const header = hunkHeaderOf({ + ...hunk, + lines: hunk.lines.slice(0, taken + 1), + }) + + bodyChars += 1 + line.length + + if (header.length + bodyChars > maxChars) { + break + } + + taken += 1 + } + + return { ...hunk, lines: hunk.lines.slice(0, taken) } +} diff --git a/mods/diff/hooks/views/detail/max-body-chars/index.ts b/mods/diff/hooks/views/detail/max-body-chars/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..560568831b9ff5c8538ddd55cb9083cf65a610bf --- /dev/null +++ b/mods/diff/hooks/views/detail/max-body-chars/index.ts @@ -0,0 +1,3 @@ +export * from './max-body-chars.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts b/mods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfddef7bb487edff70add1183128a24407e83c34 --- /dev/null +++ b/mods/diff/hooks/views/detail/max-body-chars/max-body-chars.ts @@ -0,0 +1,7 @@ +/** + * The most characters the drawn file bodies hold between them, their + * `Code` sources and dividers in all. + * + * Under the host's whole-tree cap (100 000), with room for the pane's rest. + */ +export const MAX_BODY_CHARS = 80_000 diff --git a/mods/diff/hooks/views/detail/max-body-nodes/index.ts b/mods/diff/hooks/views/detail/max-body-nodes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b81bd72538dd88446c276d7fa94273e787430db4 --- /dev/null +++ b/mods/diff/hooks/views/detail/max-body-nodes/index.ts @@ -0,0 +1,3 @@ +export * from './max-body-nodes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts b/mods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0969c55dfe9ec8088e239254dfd35771294e4dd8 --- /dev/null +++ b/mods/diff/hooks/views/detail/max-body-nodes/max-body-nodes.ts @@ -0,0 +1,8 @@ +/** + * The most nodes the drawn file bodies hold between them, a hunk's `Code` + * one, a file's frame FILE_FRAME_NODES. + * + * Under the host's whole-tree cap (2 000), with room for the list, the + * pre-session legend and the pane's rest. + */ +export const MAX_BODY_NODES = 1_500 diff --git a/mods/diff/hooks/views/detail/max-code-chars/index.ts b/mods/diff/hooks/views/detail/max-code-chars/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a45f7d5372848248ad2c45a5eafaabdc1ed8b43d --- /dev/null +++ b/mods/diff/hooks/views/detail/max-code-chars/index.ts @@ -0,0 +1,3 @@ +export * from './max-code-chars.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/max-code-chars/max-code-chars.ts b/mods/diff/hooks/views/detail/max-code-chars/max-code-chars.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c7d3a4c905e37f60b7ad7e039165f34684f563b --- /dev/null +++ b/mods/diff/hooks/views/detail/max-code-chars/max-code-chars.ts @@ -0,0 +1,5 @@ +/** + * The most characters one `Code` source (or path) holds: the host's cap on + * a leaf's text (10 000), which a hooks module cannot import. + */ +export const MAX_CODE_CHARS = 10_000 diff --git a/mods/diff/hooks/views/detail/placeholder-of/index.ts b/mods/diff/hooks/views/detail/placeholder-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..36afd40d6f472456353e3210c7dd8f35c511eca1 --- /dev/null +++ b/mods/diff/hooks/views/detail/placeholder-of/index.ts @@ -0,0 +1,4 @@ +export * from './placeholder-of.js' +export * from './safe-pathspec-pattern' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/placeholder-of/placeholder-of.ts b/mods/diff/hooks/views/detail/placeholder-of/placeholder-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..406efcdd56682898ed13c1c5fdb359006e04d8dd --- /dev/null +++ b/mods/diff/hooks/views/detail/placeholder-of/placeholder-of.ts @@ -0,0 +1,45 @@ +import type { DetailModel } from '../detail-model' +import { SAFE_PATHSPEC_PATTERN } from './safe-pathspec-pattern' + +/** + * The dim italic lines drawn instead of hunks (DiffDetailView's wording), + * or null when the hunks draw. + * + * Untracked (the backend's note, its pasteable command only for a name of + * letters, digits and `._/@+-`), binary, loading, unreadable, large, or + * empty. + * + * @param detail the file + * @returns the lines, or null + */ +export function placeholderOf(detail: DetailModel): readonly string[] | null { + if (detail.isUntracked) { + return detail.words.untrackedNoteOf( + detail.path, + SAFE_PATHSPEC_PATTERN.test(detail.path), + ) + } + + if (detail.isBinary) { + return ['Binary file - cannot display diff'] + } + + if (detail.body === undefined) { + return ['Loading diff…'] + } + + if (detail.body === null) { + return ['Diff unavailable'] + } + + const { isLarge, hunks } = detail.body + const isEmpty = hunks.length === 0 + + const note = isLarge + ? 'Large file - diff exceeds 1 MB limit' + : isEmpty + ? 'No diff content' + : null + + return note ? [note] : null +} diff --git a/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/index.ts b/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..90d3e07f081d306c110dc7d6b985d122d16cae6d --- /dev/null +++ b/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/index.ts @@ -0,0 +1,3 @@ +export * from './safe-pathspec-pattern.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/safe-pathspec-pattern.ts b/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/safe-pathspec-pattern.ts new file mode 100644 index 0000000000000000000000000000000000000000..043df8657958764f9dbdfa1851089fa3fb8f236d --- /dev/null +++ b/mods/diff/hooks/views/detail/placeholder-of/safe-pathspec-pattern/safe-pathspec-pattern.ts @@ -0,0 +1,8 @@ +/** + * A file name safe to paste, unquoted, into a stage command in any shell + * (POSIX, PowerShell, cmd). + * + * Letters and digits of any script and `._/@+-` only: no quote of any + * kind, no separator and no substitution can ride in it. + */ +export const SAFE_PATHSPEC_PATTERN = /^[\p{L}\p{N}._/@+-]+$/u diff --git a/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/first-line-cut.ts b/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/first-line-cut.ts new file mode 100644 index 0000000000000000000000000000000000000000..d438b2e8a33e5b374fd7b2492b711899d09d4208 --- /dev/null +++ b/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/first-line-cut.ts @@ -0,0 +1,24 @@ +import type Git from '../../../../git' +import { hunkHeaderOf } from '../../hunk-header-of' + +/** + * A hunk's first line cut so that it alone, under its header, fits the + * room: its marker kept, no surrogate pair split. + * + * @param hunk the hunk, its first line too long for a sub-hunk of its own + * @param maxChars the room the one-line sub-hunk's text fits + * @returns the cut line + */ +export function firstLineCut(hunk: Git.Hunk, maxChars: number) { + const [first = ''] = hunk.lines + + const kept = first.slice( + 0, + Math.max( + 1, + maxChars - 1 - hunkHeaderOf({ ...hunk, lines: [first] }).length, + ), + ) + + return /[\uD800-\uDBFF]$/u.test(kept) ? kept.slice(0, -1) : kept +} diff --git a/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/index.ts b/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..36981a1286a96cf2e9b1d72ac5517d776a02b02f --- /dev/null +++ b/mods/diff/hooks/views/detail/sub-hunks-of/first-line-cut/index.ts @@ -0,0 +1,3 @@ +export * from './first-line-cut.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/sub-hunks-of/index.ts b/mods/diff/hooks/views/detail/sub-hunks-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf1c98deee2648d0a559bbf0a2cdf7db4f30c68e --- /dev/null +++ b/mods/diff/hooks/views/detail/sub-hunks-of/index.ts @@ -0,0 +1,4 @@ +export * from './first-line-cut' +export * from './sub-hunks-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts b/mods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..624eb6e594e41462586a5fcf0b24f8bb32fcf36c --- /dev/null +++ b/mods/diff/hooks/views/detail/sub-hunks-of/sub-hunks-of.ts @@ -0,0 +1,39 @@ +import Git from '../../../git' +import { leadingHunkOf } from '../leading-hunk-of' +import { firstLineCut } from './first-line-cut' + +/** + * A hunk as consecutive sub-hunks whose texts (hunkSourceOf) each fit the + * room, each starting where the last left off, so the counts add up. + * + * A line too long for a sub-hunk of its own is cut to fit, not dropped, and + * `isTruncated` says so; a hunk that fits comes back whole. + * + * @param hunk the hunk, its lines body lines only + * @param maxChars the room each sub-hunk's text fits, in UTF-16 units + * @returns the sub-hunks in order, and whether a line was cut + */ +export function subHunksOf( + hunk: Git.Hunk, + maxChars: number, +): Pick { + const hunks: Git.Hunk[] = [] + + let rest = hunk + let isTruncated = false + + while (rest.lines.length > 0) { + const leading = leadingHunkOf(rest, maxChars) + const isLineCut = leading.lines.length === 0 + + const piece = isLineCut + ? { ...rest, lines: [firstLineCut(rest, maxChars)] } + : leading + + hunks.push(piece) + isTruncated ||= isLineCut + rest = Git.hunkAfter(rest, piece.lines.length) + } + + return { hunks, isTruncated } +} diff --git a/mods/diff/hooks/views/detail/types/body-room/body-room.ts b/mods/diff/hooks/views/detail/types/body-room/body-room.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b8632abcde3bd020e45f852c82ead46be8fabb6 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/body-room/body-room.ts @@ -0,0 +1,8 @@ +/** + * What is left of the pane's budget for file bodies: characters of `Code` + * source and tree nodes, both counted as the host counts a tree. + */ +export type BodyRoom = { + chars: number + nodes: number +} diff --git a/mods/diff/hooks/views/detail/types/body-room/index.ts b/mods/diff/hooks/views/detail/types/body-room/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ca015ea81d503b2a8c868d5de2c46ace5e822ba --- /dev/null +++ b/mods/diff/hooks/views/detail/types/body-room/index.ts @@ -0,0 +1,3 @@ +export type * from './body-room.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/types/code-body/code-body.ts b/mods/diff/hooks/views/detail/types/code-body/code-body.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb52a965386b075cc562385a0eac838e496214b4 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/code-body/code-body.ts @@ -0,0 +1,11 @@ +import type { BodyRoom } from '../body-room' + +/** + * A file's hunks as the body draws them: one `Code` source per hunk in + * order, whether any of a line was left out, and the room after them. + */ +export type CodeBody = { + sources: readonly string[] + isTruncated: boolean + room: BodyRoom +} diff --git a/mods/diff/hooks/views/detail/types/code-body/index.ts b/mods/diff/hooks/views/detail/types/code-body/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1951707edd0f84302401d905d4b0f47dc2bbd392 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/code-body/index.ts @@ -0,0 +1,3 @@ +export type * from './code-body.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/types/drawn-detail/drawn-detail.ts b/mods/diff/hooks/views/detail/types/drawn-detail/drawn-detail.ts new file mode 100644 index 0000000000000000000000000000000000000000..c831c8f3e4c574789c0eca0b1f1a0ea65da785c3 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/drawn-detail/drawn-detail.ts @@ -0,0 +1,11 @@ +import type { RenderElement } from 'claude-code' + +import type { BodyRoom } from '../body-room' + +/** + * One file's detail as drawn, and the room left for the files after it. + */ +export type DrawnDetail = { + element: RenderElement + room: BodyRoom +} diff --git a/mods/diff/hooks/views/detail/types/drawn-detail/index.ts b/mods/diff/hooks/views/detail/types/drawn-detail/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2baa5aab092bbe1cae89e9b49931b61931af0032 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/drawn-detail/index.ts @@ -0,0 +1,3 @@ +export type * from './drawn-detail.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/types/index.ts b/mods/diff/hooks/views/detail/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f01505f178b69aace71ecae1a5edde086b980619 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/index.ts @@ -0,0 +1,6 @@ +export * from './body-room' +export * from './code-body' +export * from './drawn-detail' +export * from './named-detail' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/types/named-detail/index.ts b/mods/diff/hooks/views/detail/types/named-detail/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f59f148f94037d2903c044481fcfb557c96ef0c5 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/named-detail/index.ts @@ -0,0 +1,3 @@ +export type * from './named-detail.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/detail/types/named-detail/named-detail.ts b/mods/diff/hooks/views/detail/types/named-detail/named-detail.ts new file mode 100644 index 0000000000000000000000000000000000000000..2822333fe1a1d84927198423f841034b95609895 --- /dev/null +++ b/mods/diff/hooks/views/detail/types/named-detail/named-detail.ts @@ -0,0 +1,10 @@ +import type { DetailModel } from '../../detail-model' + +/** + * A file as its name row draws it: the detail, whether its body was cut, + * and whether it can be asked about (it has hunks to attach). + */ +export type NamedDetail = DetailModel & { + isTruncated: boolean + isAskable: boolean +} diff --git a/mods/diff/hooks/views/dialog-pane/dialog-focus-of.ts b/mods/diff/hooks/views/dialog-pane/dialog-focus-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f11392f75b3c833002f688ae5e7f57fc28a8c02 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-focus-of.ts @@ -0,0 +1,46 @@ +import Limits from '../../limits' +import type PaneState from '../../pane-state' +import Sections from '../sections' +import { dialogEntriesOf } from './dialog-entries-of' +import { dialogWindowOf } from './dialog-window-of' + +/** + * What the focus ring landing on a dialog row makes of the list: that file + * selected, the window re-centred, the key of the row drawn where it sits. + * + * DiffFileList's selection, which stops at an end where the ring wraps: a + * jump across three or more rows from the first or last file is `stay`. + * The ring goes to the row holding the file's place after the re-centre. + * + * @param model the pane's state + * @param element the key of the element taking the ring + * @returns the selection and the key to land on, `stay`, or null for a key + * that is none of the listed rows' + */ +export function dialogFocusOf( + model: PaneState.PaneModel, + element: string | undefined, +): PaneState.DialogFocus | 'stay' | null { + const entries = dialogEntriesOf(model) + const paths = entries.map(entry => entry.path) + const keys = entries.map(entry => Sections.fileKeyOf(entry)) + const picked = keys.indexOf(element ?? '') + const current = Math.max(0, paths.indexOf(model.selectedPath ?? '')) + const before = dialogWindowOf(model, paths) + + const lastDrawn = + Math.min(paths.length, before + Limits.MAX_VISIBLE_FILES) - 1 + + const selectedPath = paths[picked] ?? null + + const landing = + keys[before + picked - dialogWindowOf({ selectedPath }, paths)] + + const isOverStart = current === 0 && picked === lastDrawn + const isOverEnd = current === paths.length - 1 && picked === before + const isFar = lastDrawn - before >= 2 + const isRow = selectedPath !== null && landing !== undefined + const isWrap = isFar && (isOverEnd || isOverStart) + + return isWrap ? 'stay' : isRow ? { selectedPath, landing } : null +} diff --git a/mods/diff/hooks/views/dialog-pane/dialog-pane.tsx b/mods/diff/hooks/views/dialog-pane/dialog-pane.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d669a604c1b9bfc51f39df9b271131cc2badc02 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-pane.tsx @@ -0,0 +1,146 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Limits from '../../limits' +import PaneState from '../../pane-state' +import Detail from '../detail' +import Entries from '../entries' +import type { Kit } from '../kit' +import Layout from '../layout' +import { preludeOf } from '../prelude-of' +import Sections from '../sections' +import { dialogEntriesOf } from './dialog-entries-of' +import { dialogWindowOf } from './dialog-window-of' + +/** + * The inline pane (DiffDialog): its title, the dim count, then the file + * list's window, or the picked file's detail alone, and the key hints. + * + * Every file lists in the built-in's order, five at a time round the + * selected row (`❯ `, where the focus ring starts and what its walk moves, + * ui.focus); Enter views that file, Escape backs out or closes. + * + * @param kit the elements, the handlers, the width + * @param model the pane's state + * @returns the pane's tree + */ +export function dialogPane( + kit: Kit, + model: PaneState.PaneModel, +): RenderElement { + const { Box, Text } = kit.ui + const prelude = preludeOf(kit, model) + + if (prelude) { + return prelude + } + + const turn = PaneState.pickedTurnOf(model) + const { data } = model + const entries = dialogEntriesOf(model) + const totals = turn ? PaneState.turnTotalsOf(turn) : (data?.stats ?? null) + const isTurn = model.source.kind === 'turn' + const isPaged = entries.length > Limits.MAX_VISIBLE_FILES + const picked = entries.find(entry => entry.path === model.selectedPath) + const selected = picked ?? entries[0] + const isDetail = model.dialogView === 'detail' && picked !== undefined + + const start = dialogWindowOf( + model, + entries.map(entry => entry.path), + ) + + const shown = entries.slice(start, start + Limits.MAX_VISIBLE_FILES) + const blankEdge = Sections.dimNote(kit, ' ') + const below = entries.length - start - shown.length + + const isTooMany = + !isTurn && + data !== null && + data.stats.filesCount > 0 && + entries.length === 0 + + const emptyWords = isTurn + ? 'No file changes in this turn' + : isTooMany + ? 'Too many files to display details' + : 'No changes yet' + + const message = entries.length > 0 ? null : emptyWords + + const subtitle = totals && ( + + {`${Layout.plural(totals.filesCount, 'file')} changed `} + {Sections.diffStat(kit, totals.linesAdded, totals.linesRemoved)} + + ) + + const noteOf = (entry: Entries.BodyEntry) => + entry.isUntracked ? 'untracked' : entry.isBinary ? 'Binary file' : null + + const rowOf = (entry: Entries.BodyEntry) => + Sections.dialogFileRow( + kit, + { + key: Sections.fileKeyOf(entry), + path: entry.path, + displayPath: entry.displayPath, + added: entry.added, + removed: entry.removed, + note: noteOf(entry), + isSelected: entry === selected, + }, + () => kit.actions.selectFile(entry.path), + ) + + function moreOf(way: 'up' | 'down', count: number): RenderElement { + const arrow = way === 'up' ? '\u2191' : '\u2193' + const words = Layout.plural(count, 'file').replace(' ', ' more ') + + return Sections.dimNote(kit, ` ${arrow} ${words}`) + } + + const pagedEdgeOf = (way: 'up' | 'down', count: number) => + count > 0 ? moreOf(way, count) : blankEdge + + const edgeOf = (way: 'up' | 'down', count: number) => + isPaged ? pagedEdgeOf(way, count) : null + + const detailOf = (entry: Entries.BodyEntry): RenderElement[] => [ + Detail.detailView( + kit, + Entries.detailModelOf(entry, model), + Detail.BODY_BUDGET, + ).element, + ] + + const rows = Sections.present([ + edgeOf('up', start), + ...shown.map(rowOf), + edgeOf('down', below), + ]) + + const listing = isDetail && picked ? detailOf(picked) : rows + const body = message === null ? listing : [Sections.dimNote(kit, message)] + + const hint = isDetail + ? '\u2191/\u2193 to scroll \u00b7 Esc to back' + : '\u2191/\u2193 to select \u00b7 Enter to view \u00b7 Esc to close' + + return ( + + {Sections.present([ + Sections.titleRow(kit, PaneState.dialogTitleOf(model)), + , + subtitle, + Sections.sourcePicker(kit, model), + + {body} + , + Sections.dimNote(kit, hint), + ])} + + ) +} diff --git a/mods/diff/hooks/views/dialog-pane/dialog-rows-of.ts b/mods/diff/hooks/views/dialog-pane/dialog-rows-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c0fd3ef0e938b616701d00bae6503d301694c0b --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-rows-of.ts @@ -0,0 +1,46 @@ +import Limits from '../../limits' +import PaneState from '../../pane-state' +import { dialogEntriesOf } from './dialog-entries-of' +import { dialogWindowOf } from './dialog-window-of' + +/** + * The rows the dialog's content takes as dialogPane draws it, for the + * `rows` an inline open asks for: title, blank, count, picker, body, hints. + * + * The body is the message line, the list's window with its `more files` + * rows, or the file's name, rule and hunk lines (their wraps not counted: + * the layout caps a long file anyway and its arrows scroll it). + * + * @param model the pane's state + * @returns the rows wanted + */ +export function dialogRowsOf(model: PaneState.PaneModel): number { + const entries = dialogEntriesOf(model) + + const hasCount = + PaneState.pickedTurnOf(model) !== undefined || model.data !== null + + const isPaged = entries.length > Limits.MAX_VISIBLE_FILES + const picked = entries.find(entry => entry.path === model.selectedPath) + const isDetail = model.dialogView === 'detail' && picked !== undefined + + const start = dialogWindowOf( + model, + entries.map(entry => entry.path), + ) + + const lines = (picked?.body?.hunks ?? []).reduce( + (sum, hunk) => sum + 1 + hunk.lines.length, + 0, + ) + + const listed = Math.min(entries.length - start, Limits.MAX_VISIBLE_FILES) + + const isEmpty = entries.length === 0 + const listRows = isEmpty ? 1 : listed + (isPaged ? 2 : 0) + const bodyRows = isDetail ? 2 + Math.max(1, lines) : listRows + + return ( + 2 + Number(hasCount) + Number(model.turns.length > 0) + 2 + bodyRows + 1 + ) +} diff --git a/mods/diff/hooks/views/dialog-pane/index.ts b/mods/diff/hooks/views/dialog-pane/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cf665d22a3bd033f0897dd79f425f983038e809 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/index.ts @@ -0,0 +1,9 @@ +export * from './dialog-entries-of' +export * from './dialog-focus-of.js' +export * from './dialog-pane.jsx' +export * from './dialog-rows-of.js' +export * from './dialog-window-of' +export * from './list-body-of' +export * from './message-pane-of' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/body-entry-of/body-entry-of.ts b/mods/diff/hooks/views/entries/body-entry-of/body-entry-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2c9ba4b111e404b9e0e45abc57e017ac870dc75 --- /dev/null +++ b/mods/diff/hooks/views/entries/body-entry-of/body-entry-of.ts @@ -0,0 +1,24 @@ +import Git from '../../../git' +import type PaneState from '../../../pane-state' +import type { BodyEntry } from '../body-entry' + +/** + * A fetched row as the pane draws it, its body looked up in what the pane + * has read. + * + * @param file the row + * @param model the pane's state, holding the bodies read so far + * @returns the entry + */ +export const bodyEntryOf = ( + file: Git.FileStat, + model: Pick, +): BodyEntry => ({ + path: file.path, + displayPath: Git.displayPathOf(file), + added: file.added, + removed: file.removed, + isUntracked: file.isUntracked, + isBinary: file.isBinary, + body: model.bodies.get(file.path), +}) diff --git a/mods/diff/hooks/views/entries/body-entry-of/index.ts b/mods/diff/hooks/views/entries/body-entry-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fae4424fc1fab389041ab2de19ecb4c43a8bb93 --- /dev/null +++ b/mods/diff/hooks/views/entries/body-entry-of/index.ts @@ -0,0 +1,3 @@ +export * from './body-entry-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/body-entry/body-entry.ts b/mods/diff/hooks/views/entries/body-entry/body-entry.ts new file mode 100644 index 0000000000000000000000000000000000000000..f90261e1165c2496e56e9cbff707f17ab8f39ccf --- /dev/null +++ b/mods/diff/hooks/views/entries/body-entry/body-entry.ts @@ -0,0 +1,15 @@ +import type Git from '../../../git' + +/** + * One file as the pane lists and draws it: its row's counts and name, its + * kind, and its hunks so far (undefined loading, null unreadable). + */ +export type BodyEntry = { + path: string + displayPath: string + added: number + removed: number + isUntracked: boolean + isBinary: boolean + body: Git.FileHunks | null | undefined +} diff --git a/mods/diff/hooks/views/entries/body-entry/index.ts b/mods/diff/hooks/views/entries/body-entry/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..57e0a21564667ea53997e1cf6c9146c7dca2ae3e --- /dev/null +++ b/mods/diff/hooks/views/entries/body-entry/index.ts @@ -0,0 +1,3 @@ +export type * from './body-entry.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/detail-model-of/index.ts b/mods/diff/hooks/views/entries/detail-model-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..de2d84c5c8e0c49631d36f9cc60b3a4cdd43e2da --- /dev/null +++ b/mods/diff/hooks/views/entries/detail-model-of/index.ts @@ -0,0 +1,3 @@ +export * from './detail-model-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/index.ts b/mods/diff/hooks/views/entries/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0fbc61e47530ee2df535eabc6e17b5b8e894421 --- /dev/null +++ b/mods/diff/hooks/views/entries/index.ts @@ -0,0 +1,6 @@ +export * from './body-entry' +export * from './body-entry-of' +export * from './detail-model-of' +export * from './turn-entry-of' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/turn-entry-of/index.ts b/mods/diff/hooks/views/entries/turn-entry-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..626047cf70864f096de7d3a10b5de2959a366a62 --- /dev/null +++ b/mods/diff/hooks/views/entries/turn-entry-of/index.ts @@ -0,0 +1,3 @@ +export * from './turn-entry-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/entries/turn-entry-of/turn-entry-of.ts b/mods/diff/hooks/views/entries/turn-entry-of/turn-entry-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..92210d020ae22a0c992fc4e724794f4d67991d25 --- /dev/null +++ b/mods/diff/hooks/views/entries/turn-entry-of/turn-entry-of.ts @@ -0,0 +1,19 @@ +import type Turns from '../../../turns' +import type { BodyEntry } from '../body-entry' + +/** + * A turn's file as the pane draws it: its hunks come from the transcript, + * so its body is always in hand. + * + * @param file the turn's file + * @returns the entry + */ +export const turnEntryOf = (file: Turns.TurnFile): BodyEntry => ({ + path: file.path, + displayPath: file.path, + added: file.added, + removed: file.removed, + isUntracked: false, + isBinary: false, + body: { hunks: file.hunks, isTruncated: file.isTruncated, isLarge: false }, +}) diff --git a/mods/diff/hooks/views/inset-of/index.ts b/mods/diff/hooks/views/inset-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b8643012278c342c58cfc03757c85989b86d733 --- /dev/null +++ b/mods/diff/hooks/views/inset-of/index.ts @@ -0,0 +1,3 @@ +export * from './inset-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/inset-of/inset-of.ts b/mods/diff/hooks/views/inset-of/inset-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e349553a6e7d084af431c0bdb16fa47c96c62a0 --- /dev/null +++ b/mods/diff/hooks/views/inset-of/inset-of.ts @@ -0,0 +1,15 @@ +import Limits from '../../limits' +import type { Kit } from '../kit' + +/** + * A drawing's kit inside the docked pane's padding: the built-in's blank + * last column and its blank row above the header taken off. + * + * @param kit the drawing's kit at the pane's full size + * @returns the kit its body draws with + */ +export const insetOf = (kit: Kit): Kit => ({ + ...kit, + columns: Math.max(1, kit.columns - Limits.PANE_RIGHT_PAD_COLUMNS), + rows: Math.max(0, kit.rows - Limits.PANE_TOP_PAD_ROWS), +}) diff --git a/mods/diff/hooks/views/kit/index.ts b/mods/diff/hooks/views/kit/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae1cb7959aa999f65a712f6e2f8762b88f9c7513 --- /dev/null +++ b/mods/diff/hooks/views/kit/index.ts @@ -0,0 +1,4 @@ +export type * from './kit.js' +export * from './ui' + +export * as default from '.' diff --git a/mods/diff/hooks/views/kit/kit.ts b/mods/diff/hooks/views/kit/kit.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2ca951128ddebdfa14886a3ff39e935dae0bf05 --- /dev/null +++ b/mods/diff/hooks/views/kit/kit.ts @@ -0,0 +1,15 @@ +import type { PaneActions } from '../pane-actions' +import type { Ui } from './ui' + +/** + * What every part of one drawing is handed. + * + * The surface's elements, the controls' handlers, the body's width + * (`props.bodyColumns`) and its rows (`props.scroll.bodyRows`). + */ +export type Kit = { + ui: Ui + actions: PaneActions + columns: number + rows: number +} diff --git a/mods/diff/hooks/views/kit/ui/index.ts b/mods/diff/hooks/views/kit/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d8eb46ef7a2e72faf0b22b3f77de6e3195de3d3 --- /dev/null +++ b/mods/diff/hooks/views/kit/ui/index.ts @@ -0,0 +1,3 @@ +export type * from './ui.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/kit/ui/ui.ts b/mods/diff/hooks/views/kit/ui/ui.ts new file mode 100644 index 0000000000000000000000000000000000000000..21e6dfe3ed62d5f03ed0fca1cb2b9ee8f45ff3eb --- /dev/null +++ b/mods/diff/hooks/views/kit/ui/ui.ts @@ -0,0 +1,13 @@ +import type { ElementTable } from 'claude-code' + +/** + * The element constructors the pane draws with, destructured from the + * table `$.ui.resolve(e)` hands the render hook. + * + * The terminal and the desktop carry all five; the mobile app has no + * Select, so the pane never draws there. + */ +export type Ui = Pick< + ElementTable<'terminal' | 'desktop'>, + 'Box' | 'Text' | 'Button' | 'Select' | 'Code' +> diff --git a/mods/diff/hooks/views/layout/cell-width/cell-width.ts b/mods/diff/hooks/views/layout/cell-width/cell-width.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd0596be781092a9ce4057ee763407219e896a8b --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/cell-width.ts @@ -0,0 +1,31 @@ +import { FIRST_WIDE_POINT } from './first-wide-point' +import { WIDE_RANGES } from './wide-ranges' +import { ZERO_WIDTH_RANGES } from './zero-width-ranges' + +/** + * How many terminal cells a sanitized line takes: one per code point, two + * for a wide one, none for a combining or zero-width one. + * + * Everything below FIRST_WIDE_POINT, the common case in a diff, never + * reaches the tables. + * + * @param text a line with no tabs or control characters + * @returns its width in cells + */ +export function cellWidth(text: string) { + let width = 0 + + for (const character of text) { + const point = character.codePointAt(0) ?? 0 + + const holds = ([low, high]: readonly [number, number]) => + point >= low && point <= high + + const isNarrow = point < FIRST_WIDE_POINT + const isZero = !isNarrow && ZERO_WIDTH_RANGES.some(holds) + const isWide = !isNarrow && !isZero && WIDE_RANGES.some(holds) + width += isZero ? 0 : isWide ? 2 : 1 + } + + return width +} diff --git a/mods/diff/hooks/views/layout/cell-width/first-wide-point/first-wide-point.ts b/mods/diff/hooks/views/layout/cell-width/first-wide-point/first-wide-point.ts new file mode 100644 index 0000000000000000000000000000000000000000..243bdb512ef5f1b32a54089ad9ef9e8a8fb611b4 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/first-wide-point/first-wide-point.ts @@ -0,0 +1,5 @@ +/** + * The lowest code point any width table holds; everything below it is one + * cell wide without a table lookup. + */ +export const FIRST_WIDE_POINT = 0x0300 diff --git a/mods/diff/hooks/views/layout/cell-width/first-wide-point/index.ts b/mods/diff/hooks/views/layout/cell-width/first-wide-point/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf9cfd8fc005cc6a5cd36fc510b452fa6f1aebb2 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/first-wide-point/index.ts @@ -0,0 +1,3 @@ +export * from './first-wide-point.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/cell-width/index.ts b/mods/diff/hooks/views/layout/cell-width/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b3ef08cd69a41239b74b84d29a93a8490380842 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/index.ts @@ -0,0 +1,6 @@ +export * from './cell-width.js' +export * from './first-wide-point' +export * from './wide-ranges' +export * from './zero-width-ranges' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/cell-width/wide-ranges/index.ts b/mods/diff/hooks/views/layout/cell-width/wide-ranges/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec1884b02f5270e7e4e83ca6f65fa8a0b89a6eee --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/wide-ranges/index.ts @@ -0,0 +1,3 @@ +export * from './wide-ranges.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/cell-width/wide-ranges/wide-ranges.ts b/mods/diff/hooks/views/layout/cell-width/wide-ranges/wide-ranges.ts new file mode 100644 index 0000000000000000000000000000000000000000..609dba3fbec690b59b3d727e780acf1bcf2df871 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/wide-ranges/wide-ranges.ts @@ -0,0 +1,20 @@ +/** + * Code point ranges a terminal draws two cells wide (Hangul Jamo, CJK, + * fullwidth forms, emoji); an approximation of the engine's string width. + */ +export const WIDE_RANGES: readonly (readonly [number, number])[] = [ + [0x1100, 0x115f], + [0x2e80, 0x303e], + [0x3041, 0x33ff], + [0x3400, 0x4dbf], + [0x4e00, 0x9fff], + [0xa000, 0xa4cf], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe30, 0xfe4f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f300, 0x1f64f], + [0x1f900, 0x1f9ff], + [0x20000, 0x3fffd], +] diff --git a/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/index.ts b/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b57e96ccc65f96e27589fb54153a7ba6079d7c9 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/index.ts @@ -0,0 +1,3 @@ +export * from './zero-width-ranges.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/zero-width-ranges.ts b/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/zero-width-ranges.ts new file mode 100644 index 0000000000000000000000000000000000000000..349d28d55bb356ddababce4b017e6c209b752cf3 --- /dev/null +++ b/mods/diff/hooks/views/layout/cell-width/zero-width-ranges/zero-width-ranges.ts @@ -0,0 +1,10 @@ +/** + * Code point ranges that take no cell: combining marks, zero-width spaces + * and joiners, variation selectors. + */ +export const ZERO_WIDTH_RANGES: readonly (readonly [number, number])[] = [ + [0x0300, 0x036f], + [0x200b, 0x200f], + [0xfe00, 0xfe0f], + [0x20d0, 0x20ff], +] diff --git a/mods/diff/hooks/views/layout/charged-width/charged-width.ts b/mods/diff/hooks/views/layout/charged-width/charged-width.ts new file mode 100644 index 0000000000000000000000000000000000000000..3354a8404272c2aa42c1f4aab9d9c0304cbdff22 --- /dev/null +++ b/mods/diff/hooks/views/layout/charged-width/charged-width.ts @@ -0,0 +1,22 @@ +import { cellWidth } from '../cell-width' + +/** + * The cells a sanitized text is charged when laid into rows and padded: + * cellWidth, but a code point drawing in no cell is still charged one. + * + * The slicer and the band padding both count by this, so a row never + * emits more code points than the cells it was given, combining marks or + * not. + * + * @param text a line or segment with no tabs or control characters + * @returns its charge in cells, at least one per code point + */ +export function chargedWidth(text: string) { + let charged = 0 + + for (const character of text) { + charged += Math.max(1, cellWidth(character)) + } + + return charged +} diff --git a/mods/diff/hooks/views/layout/charged-width/index.ts b/mods/diff/hooks/views/layout/charged-width/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbb7075bb33441a82892520d59d8837b8f36a029 --- /dev/null +++ b/mods/diff/hooks/views/layout/charged-width/index.ts @@ -0,0 +1,3 @@ +export * from './charged-width.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/index.ts b/mods/diff/hooks/views/layout/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..520e4ad49ecf8f1eb476714d4c510c9789cb7fa2 --- /dev/null +++ b/mods/diff/hooks/views/layout/index.ts @@ -0,0 +1,11 @@ +export * from './cell-width' +export * from './charged-width' +export * from './pad-end' +export * from './plural' +export * from './sanitize-line' +export * from './sanitize-name' +export * from './tab-width' +export * from './truncate-start' +export * from './wrapped-lines' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/pad-end/index.ts b/mods/diff/hooks/views/layout/pad-end/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa07fddf740ca703afc4be8e71de0bca45abbe5b --- /dev/null +++ b/mods/diff/hooks/views/layout/pad-end/index.ts @@ -0,0 +1,3 @@ +export * from './pad-end.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/pad-end/pad-end.ts b/mods/diff/hooks/views/layout/pad-end/pad-end.ts new file mode 100644 index 0000000000000000000000000000000000000000..bca69de6c44fc919d583c439455856ac63fb87c6 --- /dev/null +++ b/mods/diff/hooks/views/layout/pad-end/pad-end.ts @@ -0,0 +1,12 @@ +import { chargedWidth } from '../charged-width' + +/** + * A sanitized text with spaces after it up to a width in cells, counted by + * chargedWidth; unchanged when it is that wide already. + * + * @param text the text + * @param cells the width to reach + * @returns the padded text + */ +export const padEnd = (text: string, cells: number) => + `${text}${' '.repeat(Math.max(0, cells - chargedWidth(text)))}` diff --git a/mods/diff/hooks/views/layout/plural/index.ts b/mods/diff/hooks/views/layout/plural/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3ed00c1767306e4a16fcd18cad274f2005b11c3 --- /dev/null +++ b/mods/diff/hooks/views/layout/plural/index.ts @@ -0,0 +1,3 @@ +export * from './plural.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/plural/plural.ts b/mods/diff/hooks/views/layout/plural/plural.ts new file mode 100644 index 0000000000000000000000000000000000000000..71a8ac465b99539bcaa8cf6fa979e466590c6d0a --- /dev/null +++ b/mods/diff/hooks/views/layout/plural/plural.ts @@ -0,0 +1,10 @@ +/** + * `1 file`, `2 files`: the count, a space, and the noun with an `s` unless + * the count is one. + * + * @param count how many + * @param noun the singular noun + * @returns the phrase + */ +export const plural = (count: number, noun: string) => + count === 1 ? `1 ${noun}` : `${count} ${noun}s` diff --git a/mods/diff/hooks/views/layout/sanitize-line/index.ts b/mods/diff/hooks/views/layout/sanitize-line/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..da8f4c56d192c6c3d4fb29798556f406b7fa7297 --- /dev/null +++ b/mods/diff/hooks/views/layout/sanitize-line/index.ts @@ -0,0 +1,3 @@ +export * from './sanitize-line.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/sanitize-line/sanitize-line.ts b/mods/diff/hooks/views/layout/sanitize-line/sanitize-line.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1bf798cbe97a4873a8bd8ed0ea4696a91674c10 --- /dev/null +++ b/mods/diff/hooks/views/layout/sanitize-line/sanitize-line.ts @@ -0,0 +1,17 @@ +import { TAB_WIDTH } from '../tab-width' + +/** + * A diff line made safe to measure and draw: tabs become spaces, and every + * control, format and default-ignorable character is dropped. + * + * The render tree refuses control characters; the rest (bidi overrides, + * zero-width spaces and joiners, variation selectors, the soft hyphen) + * draw as nothing, so nothing invisible can reorder, hide or pad a line. + * + * @param text one line of file content + * @returns the drawable line + */ +export const sanitizeLine = (text: string) => + text + .replaceAll('\t', ' '.repeat(TAB_WIDTH)) + .replace(/[\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/gu, '') diff --git a/mods/diff/hooks/views/layout/sanitize-name/index.ts b/mods/diff/hooks/views/layout/sanitize-name/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fce8ca0c3b3e8d41d4676aaafe58ee3c531e25fa --- /dev/null +++ b/mods/diff/hooks/views/layout/sanitize-name/index.ts @@ -0,0 +1,3 @@ +export * from './sanitize-name.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/sanitize-name/sanitize-name.ts b/mods/diff/hooks/views/layout/sanitize-name/sanitize-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a9730923bc99d71a63c818308012b0aade0ea86 --- /dev/null +++ b/mods/diff/hooks/views/layout/sanitize-name/sanitize-name.ts @@ -0,0 +1,13 @@ +import { sanitizeLine } from '../sanitize-line' + +/** + * A path, preview or message made safe to draw as a name: sanitizeLine's + * rule, which already drops every control, format and invisible character. + * + * Kept as its own word so a name's rule can tighten without touching the + * diff body's. + * + * @param text the name as git, the transcript or an error gave it + * @returns the drawable name + */ +export const sanitizeName = (text: string) => sanitizeLine(text) diff --git a/mods/diff/hooks/views/layout/tab-width/index.ts b/mods/diff/hooks/views/layout/tab-width/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5c8786b72a299f4b27b54cff65826dc26406fbd --- /dev/null +++ b/mods/diff/hooks/views/layout/tab-width/index.ts @@ -0,0 +1,3 @@ +export * from './tab-width.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/tab-width/tab-width.ts b/mods/diff/hooks/views/layout/tab-width/tab-width.ts new file mode 100644 index 0000000000000000000000000000000000000000..b271b2abd76f27e2bcb95376c52924ed1ace0773 --- /dev/null +++ b/mods/diff/hooks/views/layout/tab-width/tab-width.ts @@ -0,0 +1,5 @@ +/** + * The spaces a tab in a diff line becomes before the line is measured and + * wrapped. + */ +export const TAB_WIDTH = 4 diff --git a/mods/diff/hooks/views/layout/truncate-start/index.ts b/mods/diff/hooks/views/layout/truncate-start/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..615b34a732a0832aa6afdb7a3620bb1b1dadf0f4 --- /dev/null +++ b/mods/diff/hooks/views/layout/truncate-start/index.ts @@ -0,0 +1,3 @@ +export * from './truncate-start.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/truncate-start/truncate-start.ts b/mods/diff/hooks/views/layout/truncate-start/truncate-start.ts new file mode 100644 index 0000000000000000000000000000000000000000..3981f0a06855d69dcf9c4218864250599e49ee29 --- /dev/null +++ b/mods/diff/hooks/views/layout/truncate-start/truncate-start.ts @@ -0,0 +1,35 @@ +import { chargedWidth } from '../charged-width' + +/** + * A path cut from its start to fit, an ellipsis standing for what went, so + * the file name survives, as the engine truncates a path from its start. + * + * It counts by chargedWidth, a cell at least per code point, so a name + * padded with combining marks is cut like any other and never passes the + * room whole. + * + * @param text the path + * @param cells the room + * @returns the path, or `…` and its tail + */ +export function truncateStart(text: string, cells: number) { + if (chargedWidth(text) <= cells) { + return text + } + + const characters = [...text] + + let kept = '' + + for (let at = characters.length - 1; at >= 0; at--) { + const candidate = `${characters[at]}${kept}` + + if (chargedWidth(candidate) + 1 > cells) { + break + } + + kept = candidate + } + + return `…${kept}` +} diff --git a/mods/diff/hooks/views/layout/wrapped-lines/index.ts b/mods/diff/hooks/views/layout/wrapped-lines/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c41c4d5f8434490be1ae2cced84f01a50df722f --- /dev/null +++ b/mods/diff/hooks/views/layout/wrapped-lines/index.ts @@ -0,0 +1,3 @@ +export * from './wrapped-lines.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts b/mods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts new file mode 100644 index 0000000000000000000000000000000000000000..04452cfbae6958549cc436e44b4209fefd878d66 --- /dev/null +++ b/mods/diff/hooks/views/layout/wrapped-lines/wrapped-lines.ts @@ -0,0 +1,75 @@ +import { cellWidth } from '../cell-width' + +/** + * A line cut into rows as Ink's `wrap` cuts the built-in's empty state + * before it centers each row. + * + * It cuts at spaces, keeping the space a row breaks on at its end; a word + * wider than a row is cut across rows. + * + * @param text one sanitized line + * @param columns the width, at least one cell; no row holds more + * @returns the rows, at least one + */ +export function wrappedLines(text: string, columns: number): string[] { + const width = Math.max(1, columns) + const rows: string[] = [''] + const words = text.split(' ') + + for (const [index, word] of words.entries()) { + const wordWidth = cellWidth(word) + + let rowWidth = cellWidth(rows[rows.length - 1] ?? '') + + if (index !== 0) { + if (rowWidth >= width) { + rows.push('') + rowWidth = 0 + } + + rows[rows.length - 1] += ' ' + rowWidth += 1 + } + + if (wordWidth > width) { + const breaksHere = + 1 + Math.floor((wordWidth - (width - rowWidth) - 1) / width) + + if (Math.floor((wordWidth - 1) / width) < breaksHere) { + rows.push('') + } + + const characters = [...word] + + let visible = cellWidth(rows[rows.length - 1] ?? '') + + for (const [at, character] of characters.entries()) { + const characterWidth = cellWidth(character) + + if (visible + characterWidth <= width) { + rows[rows.length - 1] += character + } else { + rows.push(character) + visible = 0 + } + + visible += characterWidth + + if (visible === width && at < characters.length - 1) { + rows.push('') + visible = 0 + } + } + + continue + } + + if (rowWidth + wordWidth > width && rowWidth > 0 && wordWidth > 0) { + rows.push('') + } + + rows[rows.length - 1] += word + } + + return rows +} diff --git a/mods/diff/hooks/views/pane-actions/index.ts b/mods/diff/hooks/views/pane-actions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c76c6da767b3b2c6b2108e1f0d9d231fe59826e9 --- /dev/null +++ b/mods/diff/hooks/views/pane-actions/index.ts @@ -0,0 +1,3 @@ +export type * from './pane-actions.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/pane-actions/pane-actions.ts b/mods/diff/hooks/views/pane-actions/pane-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..67db0f5c9b6f708f80efde773f2799d2f6cfa530 --- /dev/null +++ b/mods/diff/hooks/views/pane-actions/pane-actions.ts @@ -0,0 +1,41 @@ +/** + * What the pane's Buttons and Selects do, each closing over the plugin's + * state in its register function. + */ +export type PaneActions = { + /** + * Docked, scrolls the body to this file's hunks; inline, picks the file + * and opens its detail. + */ + selectFile: (path: string) => void + + /** + * Moves the docked list's window by so many files, clamped to the list. + */ + scrollList: (delta: number) => void + + /** + * Shows or hides the tests and generated files. + */ + toggleNoise: () => void + + /** + * Opens or closes the pre-session section. + */ + togglePreSession: () => void + + /** + * Moves the comparison base on to the next mode, round to the first. + */ + cycleBase: () => void + + /** + * Picks the source by its Select value: `current` or a turn's number. + */ + chooseSource: (value: string) => void + + /** + * Arms this file's diff for the next prompt, or disarms it. + */ + toggleAsk: (path: string) => void +} diff --git a/mods/diff/hooks/views/pane-seat/index.ts b/mods/diff/hooks/views/pane-seat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e42ed895bfa9f419ba6dd1423d4e86e7f08991c0 --- /dev/null +++ b/mods/diff/hooks/views/pane-seat/index.ts @@ -0,0 +1,3 @@ +export type * from './pane-seat.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/pane-seat/pane-seat.ts b/mods/diff/hooks/views/pane-seat/pane-seat.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b7352543f2a1c69ed14811d9952261dd2d237ad --- /dev/null +++ b/mods/diff/hooks/views/pane-seat/pane-seat.ts @@ -0,0 +1,11 @@ +/** + * Where the surface put the pane for one drawing (`props.placement`), and + * how wide the terminal is (`viewport.columns`; null before it measured). + * + * Inline on a terminal the dock would fit (OPEN_MIN_COLUMNS) means the + * session draws no fullscreen layout, where the built-in shows its dialog. + */ +export type PaneSeat = { + placement: 'dock' | 'inline' + terminalColumns: number | null +} diff --git a/mods/diff/hooks/views/prelude-of/index.ts b/mods/diff/hooks/views/prelude-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aabd699723a7b22de1ce3154427151878995aa04 --- /dev/null +++ b/mods/diff/hooks/views/prelude-of/index.ts @@ -0,0 +1,3 @@ +export * from './prelude-of.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/prelude-of/prelude-of.tsx b/mods/diff/hooks/views/prelude-of/prelude-of.tsx new file mode 100644 index 0000000000000000000000000000000000000000..347e7d3c3ddca44850a4d6a959bc482006c9e8e2 --- /dev/null +++ b/mods/diff/hooks/views/prelude-of/prelude-of.tsx @@ -0,0 +1,48 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Names from '../../names' +import type PaneState from '../../pane-state' +import DialogPane from '../dialog-pane' +import type { Kit } from '../kit' +import Sections from '../sections' + +/** + * What the pane draws before it has a diff to lay out: the line saying the + * directory is in no repository, or the first fetch's `Loading diff…`. + * + * Null once a fetch settled (or a turn is picked), when the layout draws. + * + * @param kit the elements, the width, the rows + * @param model the pane's state + * @returns the early tree, or null + */ +export function preludeOf( + kit: Kit, + model: Pick< + PaneState.PaneModel, + 'isOutsideRepository' | 'data' | 'hasSettled' | 'source' | 'turns' + >, +): RenderElement | null { + const { Box } = kit.ui + const isLoading = model.data === null && !model.hasSettled + + const outside = ( + {Sections.dimNote(kit, Names.NOT_IN_REPOSITORY_TEXT)} + ) + + const loadingOf = (): RenderElement => + DialogPane.messagePaneOf(kit, { + top: [Sections.headerView(kit, null, null)], + message: ['Loading diff…'], + controls: null, + earlier: null, + rest: [], + }) + + const settled = isLoading ? loadingOf() : null + + return model.isOutsideRepository ? outside : settled +} diff --git a/mods/diff/hooks/views/sections/divider.tsx b/mods/diff/hooks/views/sections/divider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0510aee52c4d0bcd11b260f8d8391471f6210714 --- /dev/null +++ b/mods/diff/hooks/views/sections/divider.tsx @@ -0,0 +1,23 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../kit' + +/** + * A dim rule across the body: between the list and the selected file, and + * under that file's name. + * + * @param kit the elements and the width + * @returns the rule + */ +export function divider(kit: Kit): RenderElement { + const { Text } = kit.ui + + return ( + + {'─'.repeat(Math.max(1, kit.columns))} + + ) +} diff --git a/mods/diff/hooks/views/sections/header-view.tsx b/mods/diff/hooks/views/sections/header-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8dd0d0d1e92435e096d340150a3ced7bbbd965c --- /dev/null +++ b/mods/diff/hooks/views/sections/header-view.tsx @@ -0,0 +1,44 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type PaneState from '../../pane-state' +import type { Kit } from '../kit' +import Layout from '../layout' +import { diffStat } from './diff-stat' +import { present } from './present' + +/** + * The pane's first line (ReplDiffSidebar's header row): `N files changed + * +A -R`, nothing over an empty state, and the pickers at its right edge. + * + * The pickers sit where the built-in draws its close mark, so every row + * below lands where the built-in's does; this pane's mark is the engine's. + * + * @param kit the drawing's kit; its elements draw the line + * @param totals the header's counts, or null when there is nothing to count + * @param controls the pickers (Sections.controlsView), or null for none + * @returns the line + */ +export function headerView( + kit: Kit, + totals: PaneState.HeaderTotals | null, + controls: RenderElement | null, +): RenderElement { + const { Box, Text } = kit.ui + + const countOf = (counted: PaneState.HeaderTotals): RenderElement => ( + + {Layout.plural(counted.filesCount, 'file')} + {' changed '} + {diffStat(kit, counted.linesAdded, counted.linesRemoved)} + + ) + + return ( + + {present([totals && countOf(totals), , controls])} + + ) +} diff --git a/mods/diff/hooks/views/sections/index.ts b/mods/diff/hooks/views/sections/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4840c7db592bdfb3d834131d338db17cc4699f0 --- /dev/null +++ b/mods/diff/hooks/views/sections/index.ts @@ -0,0 +1,24 @@ +export * from './ask-key-of' +export * from './dialog-file-row' +export * from './diff-stat' +export * from './dim-note' +export * from './divider.jsx' +export * from './file-key-of' +export * from './file-row' +export * from './file-row-model' +export * from './header-view.jsx' +export * from './keyed-pressable' +export * from './legend-row.jsx' +export * from './list-edge-button' +export * from './list-row' +export * from './name-row' +export * from './pointer' +export * from './present' +export * from './pressable' +export * from './source-picker.jsx' +export * from './stat-text-of' +export * from './title-row.jsx' +export * from './todo-bar.jsx' +export * from './toggle-row.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/legend-row.tsx b/mods/diff/hooks/views/sections/legend-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0bcc7f5f3de8b1385e4445ee0309994d73c58892 --- /dev/null +++ b/mods/diff/hooks/views/sections/legend-row.tsx @@ -0,0 +1,37 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Limits from '../../limits' +import type Entries from '../entries' +import type { Kit } from '../kit' +import Layout from '../layout' +import { diffStat } from './diff-stat' + +/** + * One row of the open pre-session section's legend (PreSessionSection): the + * file's dim name cut from its start, its counts at the right; no press. + * + * @param kit the elements and the width + * @param entry the pre-session file + * @returns the row + */ +export function legendRow(kit: Kit, entry: Entries.BodyEntry): RenderElement { + const { Box, Text } = kit.ui + + return ( + + {[ + + {Layout.truncateStart( + Layout.sanitizeName(entry.displayPath), + Math.max(kit.columns - Limits.STAT_CELLS, Limits.PATH_FLOOR), + )} + , + , + diffStat(kit, entry.added, entry.removed), + ]} + + ) +} diff --git a/mods/diff/hooks/views/sections/present/index.ts b/mods/diff/hooks/views/sections/present/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae76f18f8194f06efb506649534b0040ff70af69 --- /dev/null +++ b/mods/diff/hooks/views/sections/present/index.ts @@ -0,0 +1,3 @@ +export * from './present.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/present/present.ts b/mods/diff/hooks/views/sections/present/present.ts new file mode 100644 index 0000000000000000000000000000000000000000..2332d6406d1d2a78f2705bc12294bdfa96822d6d --- /dev/null +++ b/mods/diff/hooks/views/sections/present/present.ts @@ -0,0 +1,14 @@ +import type { RenderElement } from 'claude-code' + +import { keptOf } from '../../../kept-of' + +/** + * The children that are there: nulls dropped, so a part may answer null for + * "nothing to draw" and the parent lists it all the same. + * + * @param children elements or nulls + * @returns the elements + */ +export const present = ( + children: readonly (RenderElement | null)[], +): RenderElement[] => keptOf(children) diff --git a/mods/diff/hooks/views/sections/source-picker.tsx b/mods/diff/hooks/views/sections/source-picker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..437a792f7e773a746a39e05620a8c55d17590265 --- /dev/null +++ b/mods/diff/hooks/views/sections/source-picker.tsx @@ -0,0 +1,42 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type PaneState from '../../pane-state' +import type { Kit } from '../kit' + +/** + * The pick between the working tree and a turn's edits (`Current`, then + * `T` per turn, newest first), drawn only when a turn exists. + * + * As DiffDialog hides its tabs with one source. + * + * @param kit the elements and the handlers + * @param model what is picked now and the turns to pick from + * @returns the picker, or null without turns + */ +export function sourcePicker( + kit: Kit, + model: Pick, +): RenderElement | null { + const { Select } = kit.ui + const { source, turns } = model + const hasTurns = turns.length > 0 + + return hasTurns ? ( +