diff --git a/mods/diff/hooks/ask/armed-ask/armed-ask.ts b/mods/diff/hooks/ask/armed-ask/armed-ask.ts new file mode 100644 index 0000000000000000000000000000000000000000..e760eb157e496793802fe46c1256e64cee0d3b03 --- /dev/null +++ b/mods/diff/hooks/ask/armed-ask/armed-ask.ts @@ -0,0 +1,9 @@ +/** + * The file the person asked about from the pane: its path and its diff as + * text, held until the next prompt carries it as context. + */ +export type ArmedAsk = { + path: string + text: string + lines: number +} diff --git a/mods/diff/hooks/ask/armed-ask/index.ts b/mods/diff/hooks/ask/armed-ask/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6efab893c558c1b9dea831f76ca2377e2458c50 --- /dev/null +++ b/mods/diff/hooks/ask/armed-ask/index.ts @@ -0,0 +1,3 @@ +export type * from './armed-ask.js' + +export * as default from '.' diff --git a/mods/diff/hooks/ask/ask-cut-note/ask-cut-note.ts b/mods/diff/hooks/ask/ask-cut-note/ask-cut-note.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4b1edbdca85399e4615ce753419f0dafc864cce --- /dev/null +++ b/mods/diff/hooks/ask/ask-cut-note/ask-cut-note.ts @@ -0,0 +1,5 @@ +/** + * The line an ask ends with when its diff was cut to fit the prompt. + */ +export const ASK_CUT_NOTE = + '(The rest of this diff was cut: it did not fit in the prompt.)' diff --git a/mods/diff/hooks/ask/ask-cut-note/index.ts b/mods/diff/hooks/ask/ask-cut-note/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f18978fc434c01c53d559b5c780b0a1c6d4b2f22 --- /dev/null +++ b/mods/diff/hooks/ask/ask-cut-note/index.ts @@ -0,0 +1,3 @@ +export * from './ask-cut-note.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/types/backend-host/backend-host.ts b/mods/diff/hooks/backend/types/backend-host/backend-host.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b2bfe3cf4a0b027846a32487b87fe16aae69878 --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-host/backend-host.ts @@ -0,0 +1,23 @@ +import type Git from '../../../git' +import type { Host } from '../../../host' + +/** + * What pinning a backend takes from the bound host: its raw process runner, + * file probes, clock, session start and branch-base listener. + * + * Each backend names its own program, argv lead, environment and timeout + * to the runner; the session's start is read as it stands at each fetch + * (a later `session.start` moves it). + */ +export type BackendHost = Pick & + Pick & { + /** + * The engine's clock, in milliseconds (`$.clock.now`). + */ + nowMs: () => Promise + + /** + * When the current session began, read at each fetch. + */ + sessionStartMsOf: () => number + } diff --git a/mods/diff/hooks/backend/types/backend-host/index.ts b/mods/diff/hooks/backend/types/backend-host/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd04819f2cc4a6f2ed497266431266d3d4b72724 --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-host/index.ts @@ -0,0 +1,3 @@ +export type * from './backend-host.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/types/backend-probe/backend-probe.ts b/mods/diff/hooks/backend/types/backend-probe/backend-probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..d90827c7483cb18f626b67ccca7b640cf4c22e7a --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-probe/backend-probe.ts @@ -0,0 +1,10 @@ +import type { Backend } from '../backend' +import type { BackendHost } from '../backend-host' + +/** + * Probes the session's directory for one backend's working tree: the + * backend when it holds the directory, else null. + * + * Null too where git should govern it instead (Git.gitBackendOf is git's). + */ +export type BackendProbe = (host: BackendHost) => Promise diff --git a/mods/diff/hooks/backend/types/backend-probe/index.ts b/mods/diff/hooks/backend/types/backend-probe/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..47f881811320b8a6da746e168ca77fa494f3ec2b --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-probe/index.ts @@ -0,0 +1,3 @@ +export type * from './backend-probe.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/types/backend-words/backend-words.ts b/mods/diff/hooks/backend/types/backend-words/backend-words.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1b0a31840bb51932534550670e6234ed5109c3f --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-words/backend-words.ts @@ -0,0 +1,29 @@ +/** + * The words the pane draws that name the backend. + * + * What uncommitted mode compares against, the command whose failure the + * empty state names, the tool that lists untracked files, and the note + * under an untracked row. + */ +export type BackendWords = { + /** + * What the working tree is compared with outside branch mode: `HEAD`. + */ + base: string + + /** + * The command a failed fetch could not read: `git diff`. + */ + diffCommand: string + + /** + * The program that lists untracked files: `git`. + */ + lister: string + + /** + * The dim lines under an untracked row: what it is, and how to get its + * line counts (a pasteable command only when the path is safe to paste). + */ + untrackedNoteOf: (path: string, isPasteable: boolean) => readonly string[] +} diff --git a/mods/diff/hooks/backend/types/backend-words/index.ts b/mods/diff/hooks/backend/types/backend-words/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fc8e52711586492e8bf7c9af9ed8869d6be5d61 --- /dev/null +++ b/mods/diff/hooks/backend/types/backend-words/index.ts @@ -0,0 +1,3 @@ +export type * from './backend-words.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/types/backend/backend.ts b/mods/diff/hooks/backend/types/backend/backend.ts new file mode 100644 index 0000000000000000000000000000000000000000..53486342d24d687aff84055e82c052f01ee5a490 --- /dev/null +++ b/mods/diff/hooks/backend/types/backend/backend.ts @@ -0,0 +1,45 @@ +import type Git from '../../../git' +import type { BackendWords } from '../backend-words' + +/** + * The version-control system behind the pane, pinned once per session: its + * working tree, what it compares, how it words them, and the pane's reads. + * + * Git is the plugin's own backend; a host may install probes for others, + * consulted where no git working tree holds the directory (backendOf). + */ +export type Backend = { + /** + * The working tree's top; every row's path is relative to it. + */ + repository: Pick + + /** + * What the picker offers to compare against, in its order. + */ + baseModes: readonly Git.BaseMode[] + + /** + * The pane's words for what this backend compares and runs. + */ + words: BackendWords + + /** + * One fetch of the working tree against the mode's base (Git.fetchDiff). + */ + fetchDiff: (mode: Git.BaseMode) => Promise + + /** + * One row's hunks against the base its fetch read (Git.fetchFileHunks). + */ + fetchFileHunks: ( + data: Git.DiffData, + file: Git.FileStat, + ) => Promise + + /** + * A string that changes when the checked-out commit moves + * (Git.headKeyOf); empty when it cannot be read this tick. + */ + headKeyOf: () => Promise +} diff --git a/mods/diff/hooks/backend/types/backend/index.ts b/mods/diff/hooks/backend/types/backend/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c3a5c071d40e4ca97948aa3daeb590c0c140282 --- /dev/null +++ b/mods/diff/hooks/backend/types/backend/index.ts @@ -0,0 +1,3 @@ +export type * from './backend.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/types/index.ts b/mods/diff/hooks/backend/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4e249ce2343d53301111ea3b36b6171fbf293d3 --- /dev/null +++ b/mods/diff/hooks/backend/types/index.ts @@ -0,0 +1,6 @@ +export * from './backend' +export * from './backend-host' +export * from './backend-probe' +export * from './backend-words' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/base-name-of/base-name-of.ts b/mods/diff/hooks/classify/base-name-of/base-name-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..72a503911c69f800fb813fdca2811216991dacba --- /dev/null +++ b/mods/diff/hooks/classify/base-name-of/base-name-of.ts @@ -0,0 +1,7 @@ +/** + * A `/`-separated path's last segment: the file's own name. + * + * @param path the repository-relative path + * @returns the base name + */ +export const baseNameOf = (path: string) => path.split('/').at(-1) ?? path diff --git a/mods/diff/hooks/classify/base-name-of/index.ts b/mods/diff/hooks/classify/base-name-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..af27a138cdcfbdbee52e00810e4dedea2310c5b9 --- /dev/null +++ b/mods/diff/hooks/classify/base-name-of/index.ts @@ -0,0 +1,3 @@ +export * from './base-name-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/generated-directories/generated-directories.ts b/mods/diff/hooks/classify/is-generated-file/generated-directories/generated-directories.ts new file mode 100644 index 0000000000000000000000000000000000000000..49e762812e8322739f150f2b27ff5095334d3795 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-directories/generated-directories.ts @@ -0,0 +1,28 @@ +/** + * Path segments of build output, vendored code, caches and snapshots, the + * engine's excluded directories, matched against `/`. + */ +export const GENERATED_DIRECTORIES = [ + '/dist/', + '/build/', + '/out/', + '/output/', + '/node_modules/', + '/vendor/', + '/vendored/', + '/third_party/', + '/third-party/', + '/external/', + '/.next/', + '/.nuxt/', + '/.svelte-kit/', + '/coverage/', + '/__pycache__/', + '/.tox/', + '/venv/', + '/.venv/', + '/target/release/', + '/target/debug/', + '.generated/', + '/__snapshots__/', +] as const diff --git a/mods/diff/hooks/classify/is-generated-file/generated-directories/index.ts b/mods/diff/hooks/classify/is-generated-file/generated-directories/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..13a8df21511ca02a107d8d70d3481e4a04c183c5 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-directories/index.ts @@ -0,0 +1,3 @@ +export * from './generated-directories.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/generated-filename-patterns.ts b/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/generated-filename-patterns.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2d9fc814fc99efe773860689b4ca0e4a1de5c9d --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/generated-filename-patterns.ts @@ -0,0 +1,21 @@ +/** + * File-name shapes of generated code, the engine's excluded file-name + * patterns: minified, bundled, stubs, specs, snapshots. + */ +export const GENERATED_FILENAME_PATTERNS: readonly RegExp[] = Object.freeze([ + /\.min\.[a-z]+$/i, + /-min\.[a-z]+$/i, + /\.bundle\.[a-z]+$/i, + /\.generated\.[a-z]+$/i, + /\.gen\.[a-z]+$/i, + /\.auto\.[a-z]+$/i, + /_generated\.[a-z]+$/i, + /_gen\.[a-z]+$/i, + /\.pb\.(?:go|js|ts|py|rb)$/i, + /_pb2?\.py$/i, + /\.pb\.h$/i, + /\.grpc\.[a-z]+$/i, + /\.swagger\.[a-z]+$/i, + /\.openapi\.[a-z]+$/i, + /\.snap$/i, +]) diff --git a/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/index.ts b/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c01ca1456171420abc51f8268d5a9e33af912998 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-filename-patterns/index.ts @@ -0,0 +1,3 @@ +export * from './generated-filename-patterns.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/generated-filenames/generated-filenames.ts b/mods/diff/hooks/classify/is-generated-file/generated-filenames/generated-filenames.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b152a35dabb3dc4d35a9413435fde1ba3e789b4 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-filenames/generated-filenames.ts @@ -0,0 +1,18 @@ +/** + * File names (lowercase) that are lockfiles or shrinkwraps, never hand + * written, so their churn is noise in a diff. + */ +export const GENERATED_FILENAMES: ReadonlySet = new Set([ + 'package-lock.json', + 'yarn.lock', + 'pnpm-lock.yaml', + 'bun.lockb', + 'bun.lock', + 'composer.lock', + 'gemfile.lock', + 'cargo.lock', + 'poetry.lock', + 'pipfile.lock', + 'shrinkwrap.json', + 'npm-shrinkwrap.json', +]) diff --git a/mods/diff/hooks/classify/is-generated-file/generated-filenames/index.ts b/mods/diff/hooks/classify/is-generated-file/generated-filenames/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..84bf2e29bc9f04a71fc1f27eac8073067113f2af --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-filenames/index.ts @@ -0,0 +1,3 @@ +export * from './generated-filenames.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/generated-suffixes/generated-suffixes.ts b/mods/diff/hooks/classify/is-generated-file/generated-suffixes/generated-suffixes.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a530982ac63e0114efde5ff7aa3a6f1267e7f44 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-suffixes/generated-suffixes.ts @@ -0,0 +1,15 @@ +/** + * Extensions, simple and compound (lowercase), of minified, bundled or + * generated output, the engine's excluded extensions. + */ +export const GENERATED_SUFFIXES = [ + '.lock', + '.min.js', + '.min.css', + '.min.html', + '.bundle.js', + '.bundle.css', + '.generated.ts', + '.generated.js', + '.d.ts', +] as const diff --git a/mods/diff/hooks/classify/is-generated-file/generated-suffixes/index.ts b/mods/diff/hooks/classify/is-generated-file/generated-suffixes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c53be33e94063642eb08dd200e6a54c8885f298a --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/generated-suffixes/index.ts @@ -0,0 +1,3 @@ +export * from './generated-suffixes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/index.ts b/mods/diff/hooks/classify/is-generated-file/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..13d013089881971021d49dfb9313897c879292fb --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/index.ts @@ -0,0 +1,7 @@ +export * from './generated-directories' +export * from './generated-filename-patterns' +export * from './generated-filenames' +export * from './generated-suffixes' +export * from './is-generated-file.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-generated-file/is-generated-file.ts b/mods/diff/hooks/classify/is-generated-file/is-generated-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ced1855f65b9d3426c09613f19700b3c0b0b532 --- /dev/null +++ b/mods/diff/hooks/classify/is-generated-file/is-generated-file.ts @@ -0,0 +1,29 @@ +import { baseNameOf } from '../base-name-of' +import { rootedPathOf } from '../rooted-path-of' +import { GENERATED_DIRECTORIES } from './generated-directories' +import { GENERATED_FILENAME_PATTERNS } from './generated-filename-patterns' +import { GENERATED_FILENAMES } from './generated-filenames' +import { GENERATED_SUFFIXES } from './generated-suffixes' + +/** + * Whether a repository-relative path names generated or vendored content, + * by the Linguist-style rules the engine's own diff panel applies. + * + * @param path the file's path from the repository root, `/`-separated + * @returns whether the diff pane counts it among the tests and generated files + */ +export function isGeneratedFile(path: string) { + const name = baseNameOf(path).toLowerCase() + const rooted = rootedPathOf(path) + + const hasGeneratedSuffix = GENERATED_SUFFIXES.some(suffix => + name.endsWith(suffix), + ) + + return ( + GENERATED_FILENAMES.has(name) || + hasGeneratedSuffix || + GENERATED_DIRECTORIES.some(directory => rooted.includes(directory)) || + GENERATED_FILENAME_PATTERNS.some(pattern => pattern.test(name)) + ) +} diff --git a/mods/diff/hooks/classify/is-test-file/index.ts b/mods/diff/hooks/classify/is-test-file/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..def0bac974e35906140bb41f70d2eef0da980de4 --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/index.ts @@ -0,0 +1,5 @@ +export * from './is-test-file.js' +export * from './test-directories' +export * from './test-filename-patterns' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-test-file/is-test-file.ts b/mods/diff/hooks/classify/is-test-file/is-test-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..2cb41a128777bdaeeafd96eba5cbdd6b3fadb4fc --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/is-test-file.ts @@ -0,0 +1,21 @@ +import { baseNameOf } from '../base-name-of' +import { rootedPathOf } from '../rooted-path-of' +import { TEST_DIRECTORIES } from './test-directories' +import { TEST_FILENAME_PATTERNS } from './test-filename-patterns' + +/** + * Whether a repository-relative path is a test, spec, fixture or snapshot, + * by the same rules the engine's own diff panel applies. + * + * @param path the file's path from the repository root, `/`-separated + * @returns whether the diff pane counts it among the tests and generated files + */ +export function isTestFile(path: string) { + const name = baseNameOf(path) + const rooted = rootedPathOf(path) + + return ( + TEST_DIRECTORIES.some(directory => rooted.includes(directory)) || + TEST_FILENAME_PATTERNS.some(pattern => pattern.test(name)) + ) +} diff --git a/mods/diff/hooks/classify/is-test-file/test-directories/index.ts b/mods/diff/hooks/classify/is-test-file/test-directories/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cecf4b11d44164ed9c59a78862acf3399cb3a313 --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/test-directories/index.ts @@ -0,0 +1,3 @@ +export * from './test-directories.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-test-file/test-directories/test-directories.ts b/mods/diff/hooks/classify/is-test-file/test-directories/test-directories.ts new file mode 100644 index 0000000000000000000000000000000000000000..111c4e2077357d56f2b16b272b92f11b6879c358 --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/test-directories/test-directories.ts @@ -0,0 +1,16 @@ +/** + * Path segments of tests, specs, mocks and fixtures, the engine's test + * directories, matched against `/`. + */ +export const TEST_DIRECTORIES = [ + '/test/', + '/tests/', + '/spec/', + '/specs/', + '/__tests__/', + '/__mocks__/', + '/__snapshots__/', + '/__fixtures__/', + '/fixtures/', + '/testdata/', +] as const diff --git a/mods/diff/hooks/classify/is-test-file/test-filename-patterns/index.ts b/mods/diff/hooks/classify/is-test-file/test-filename-patterns/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..56f60e7855be6f084c838509556636a70111aabf --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/test-filename-patterns/index.ts @@ -0,0 +1,3 @@ +export * from './test-filename-patterns.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-test-file/test-filename-patterns/test-filename-patterns.ts b/mods/diff/hooks/classify/is-test-file/test-filename-patterns/test-filename-patterns.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c472162d1ea3ea7cea68447c948247a2131f32b --- /dev/null +++ b/mods/diff/hooks/classify/is-test-file/test-filename-patterns/test-filename-patterns.ts @@ -0,0 +1,11 @@ +/** + * File-name shapes of test files and snapshots, the engine's test file-name + * patterns. + */ +export const TEST_FILENAME_PATTERNS: readonly RegExp[] = Object.freeze([ + /\.test\.[a-z]+$/i, + /\.spec\.[a-z]+$/i, + /_test\.[a-z]+$/i, + /_spec\.[a-z]+$/i, + /\.snap$/i, +]) diff --git a/mods/diff/hooks/classify/rooted-path-of/index.ts b/mods/diff/hooks/classify/rooted-path-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..827b27f32f2c45c92a6cd2c76b306369a9aff199 --- /dev/null +++ b/mods/diff/hooks/classify/rooted-path-of/index.ts @@ -0,0 +1,3 @@ +export * from './rooted-path-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/rooted-path-of/rooted-path-of.ts b/mods/diff/hooks/classify/rooted-path-of/rooted-path-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..47411f4513080eb6cd94364bf1f773ced73dbc81 --- /dev/null +++ b/mods/diff/hooks/classify/rooted-path-of/rooted-path-of.ts @@ -0,0 +1,8 @@ +/** + * A path with exactly one leading `/`, so a directory pattern like `/test/` + * matches at the start of a relative path too. + * + * @param path the repository-relative path + * @returns the rooted path + */ +export const rootedPathOf = (path: string) => `/${path.replace(/^\/+/, '')}` diff --git a/mods/diff/hooks/limits/columns/auto-open-min-columns.ts b/mods/diff/hooks/limits/columns/auto-open-min-columns.ts new file mode 100644 index 0000000000000000000000000000000000000000..edc538909dc36ae64af606420876007c949124b4 --- /dev/null +++ b/mods/diff/hooks/limits/columns/auto-open-min-columns.ts @@ -0,0 +1,5 @@ +/** + * The terminal width from which the first edit opens the pane when the person + * never said whether they want it (the built-in sidebar's own 144). + */ +export const AUTO_OPEN_MIN_COLUMNS = 144 diff --git a/mods/diff/hooks/limits/columns/dialog-path-reserve.ts b/mods/diff/hooks/limits/columns/dialog-path-reserve.ts new file mode 100644 index 0000000000000000000000000000000000000000..ada8ca20158e1112056b40e0375576e56b375716 --- /dev/null +++ b/mods/diff/hooks/limits/columns/dialog-path-reserve.ts @@ -0,0 +1,5 @@ +/** + * The cells an inline file row keeps off its path (DiffFileList's 16 for + * the counts, 3 for the pointer, 4 for the frame). + */ +export const DIALOG_PATH_RESERVE = 23 diff --git a/mods/diff/hooks/limits/columns/index.ts b/mods/diff/hooks/limits/columns/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b8ddd35a7342c0cf5aaf5fcad8db92e9b2fddf0 --- /dev/null +++ b/mods/diff/hooks/limits/columns/index.ts @@ -0,0 +1,9 @@ +export * from './auto-open-min-columns.js' +export * from './dialog-path-reserve.js' +export * from './open-min-columns.js' +export * from './pane-right-pad-columns.js' +export * from './path-floor.js' +export * from './stat-cells.js' +export * from './widest-bucket-columns.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/columns/open-min-columns.ts b/mods/diff/hooks/limits/columns/open-min-columns.ts new file mode 100644 index 0000000000000000000000000000000000000000..3245b76307420e1de6c7c4208f7aa24fa1e15329 --- /dev/null +++ b/mods/diff/hooks/limits/columns/open-min-columns.ts @@ -0,0 +1,8 @@ +/** + * The terminal width from which `/diff` opens the pane (the built-in + * sidebar's own minimum). + * + * The first edit opens it from this width too, when the person kept it + * open before. + */ +export const OPEN_MIN_COLUMNS = 110 diff --git a/mods/diff/hooks/limits/columns/pane-right-pad-columns.ts b/mods/diff/hooks/limits/columns/pane-right-pad-columns.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dfef13a5700ee40137a29f221f71ad14422cd9f --- /dev/null +++ b/mods/diff/hooks/limits/columns/pane-right-pad-columns.ts @@ -0,0 +1,5 @@ +/** + * The blank column the docked pane keeps at its right edge, as the built-in + * sidebar's body does (its `paddingX`): rows wrap one column short of it. + */ +export const PANE_RIGHT_PAD_COLUMNS = 1 diff --git a/mods/diff/hooks/limits/columns/path-floor.ts b/mods/diff/hooks/limits/columns/path-floor.ts new file mode 100644 index 0000000000000000000000000000000000000000..630017b6bcf22044f79dea00a860f405f0a6260f --- /dev/null +++ b/mods/diff/hooks/limits/columns/path-floor.ts @@ -0,0 +1,5 @@ +/** + * The fewest cells a docked file row gives its path, however narrow the + * pane (ReplDiffSidebar's 8). + */ +export const PATH_FLOOR = 8 diff --git a/mods/diff/hooks/limits/columns/stat-cells.ts b/mods/diff/hooks/limits/columns/stat-cells.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bc6692b051b8b003142716b0cd6bf87b97d7ca4 --- /dev/null +++ b/mods/diff/hooks/limits/columns/stat-cells.ts @@ -0,0 +1,5 @@ +/** + * The cells a docked file row keeps for its `+N -M` (ReplDiffSidebar's 12): + * the path is cut from its start to the rest. + */ +export const STAT_CELLS = 12 diff --git a/mods/diff/hooks/limits/columns/widest-bucket-columns.ts b/mods/diff/hooks/limits/columns/widest-bucket-columns.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb4d66860e7b3d5484055d1f0d3c93d65443f711 --- /dev/null +++ b/mods/diff/hooks/limits/columns/widest-bucket-columns.ts @@ -0,0 +1,4 @@ +/** + * The terminal width from which `diff_pane_shown` reports `200_plus`. + */ +export const WIDEST_BUCKET_COLUMNS = 200 diff --git a/mods/diff/hooks/limits/sizes/bytes/host-output-cap-bytes.ts b/mods/diff/hooks/limits/sizes/bytes/host-output-cap-bytes.ts new file mode 100644 index 0000000000000000000000000000000000000000..08128af0b373bd7ce8e72dc45c90b69d22d1d673 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/bytes/host-output-cap-bytes.ts @@ -0,0 +1,5 @@ +/** + * The most of a child's stdout `$.process.run` hands back, in bytes + * (process-host OUTPUT_LIMIT); a listing that long was cut, silently. + */ +export const HOST_OUTPUT_CAP_BYTES = 4 * 1024 * 1024 diff --git a/mods/diff/hooks/limits/sizes/bytes/index.ts b/mods/diff/hooks/limits/sizes/bytes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0f573676e347249fb24b807cad36a7400387222 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/bytes/index.ts @@ -0,0 +1,4 @@ +export * from './host-output-cap-bytes.js' +export * from './max-diff-bytes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/sizes/bytes/max-diff-bytes.ts b/mods/diff/hooks/limits/sizes/bytes/max-diff-bytes.ts new file mode 100644 index 0000000000000000000000000000000000000000..18be71cea03ffb023e5c383f7622d8263e49a8d5 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/bytes/max-diff-bytes.ts @@ -0,0 +1,5 @@ +/** + * A file whose unified diff text is longer than this is a "large file": its + * hunks are not parsed (the built-in panel's size cap). + */ +export const MAX_DIFF_BYTES = 1_000_000 diff --git a/mods/diff/hooks/limits/sizes/index.ts b/mods/diff/hooks/limits/sizes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..de209816b5ecee14a1da860f0a8217159d08de89 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/index.ts @@ -0,0 +1,11 @@ +export * from './bytes' +export * from './max-files.js' +export * from './max-files-for-details.js' +export * from './max-lines-per-file.js' +export * from './max-listed-directories.js' +export * from './max-untracked-probes.js' +export * from './max-visible-files.js' +export * from './pane' +export * from './prompt-context-max-chars.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/sizes/max-files-for-details.ts b/mods/diff/hooks/limits/sizes/max-files-for-details.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d37404f5a2b174cadbbbfd5efdd08734c2d87fd --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-files-for-details.ts @@ -0,0 +1,5 @@ +/** + * Past this many changed files the shortstat totals alone are kept: no + * per-file rows, no hunks, no untracked probe (the built-in panel's cap). + */ +export const MAX_FILES_FOR_DETAILS = 500 diff --git a/mods/diff/hooks/limits/sizes/max-files.ts b/mods/diff/hooks/limits/sizes/max-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1490c5c751df50f699edd67309072f514319d7f --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-files.ts @@ -0,0 +1,5 @@ +/** + * How many per-file rows a diff keeps (the built-in panel's cap); the rest + * are counted as "not shown". + */ +export const MAX_FILES = 50 diff --git a/mods/diff/hooks/limits/sizes/max-lines-per-file.ts b/mods/diff/hooks/limits/sizes/max-lines-per-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b953d438b4d928cda990367a61d34be334a8f0f --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-lines-per-file.ts @@ -0,0 +1,5 @@ +/** + * How many diff lines of one file are kept before the body is cut and the + * file reads as truncated (GitHub's auto-load limit, as the built-in's). + */ +export const MAX_LINES_PER_FILE = 400 diff --git a/mods/diff/hooks/limits/sizes/max-listed-directories.ts b/mods/diff/hooks/limits/sizes/max-listed-directories.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c3646a1dcd6989f4674944664202e867f15f88d --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-listed-directories.ts @@ -0,0 +1,9 @@ +/** + * How many directories one fetch may list while dating its files; past it + * an untracked file reads as pre-session, a tracked one as session work. + * + * A walk lists only the distinct ancestors of the paths it dates (MAX_FILES + * tracked, MAX_UNTRACKED_PROBES untracked), so the budget in effect is the + * dated set's own directory count; this caps a pathological spread. + */ +export const MAX_LISTED_DIRECTORIES = 512 diff --git a/mods/diff/hooks/limits/sizes/max-untracked-probes.ts b/mods/diff/hooks/limits/sizes/max-untracked-probes.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c35009e187bc2bb8eab250b6c84f2a0e2ca4aad --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-untracked-probes.ts @@ -0,0 +1,5 @@ +/** + * How many untracked paths get a timestamp probe per fetch; the rest read as + * pre-session, since only MAX_FILES rows could ever show. + */ +export const MAX_UNTRACKED_PROBES = 500 diff --git a/mods/diff/hooks/limits/sizes/max-visible-files.ts b/mods/diff/hooks/limits/sizes/max-visible-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..944dbba3aac28ec555023dedc7eb8541d0715f6f --- /dev/null +++ b/mods/diff/hooks/limits/sizes/max-visible-files.ts @@ -0,0 +1,5 @@ +/** + * The file rows the dialog lists at once (DiffFileList's MAX_VISIBLE_FILES); + * past them its window moves, `more files` rows counting the rest. + */ +export const MAX_VISIBLE_FILES = 5 diff --git a/mods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts b/mods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe43ae424f8185a00b8b7c4af36017bf9a2a1136 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/body-fetch-concurrency.ts @@ -0,0 +1,5 @@ +/** + * How many files' hunks are read from the backend at once while the pane + * loads its bodies. + */ +export const BODY_FETCH_CONCURRENCY = 6 diff --git a/mods/diff/hooks/limits/sizes/pane/gutter-chrome.ts b/mods/diff/hooks/limits/sizes/pane/gutter-chrome.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0cd40c0a442a4e95c287e49c1e48628ddb30d34 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/gutter-chrome.ts @@ -0,0 +1,5 @@ +/** + * The cells a diff gutter takes besides its digits: the marker and the + * spaces round the number. + */ +export const GUTTER_CHROME = 3 diff --git a/mods/diff/hooks/limits/sizes/pane/index.ts b/mods/diff/hooks/limits/sizes/pane/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a3e2b7a5d982290ce32da504d1b0fe41dfbaba6 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/index.ts @@ -0,0 +1,9 @@ +export * from './body-fetch-concurrency.js' +export * from './gutter-chrome.js' +export * from './max-summary-rows.js' +export * from './pane-top-pad-rows.js' +export * from './pre-session-body-cap.js' +export * from './scroll-margin-rows.js' +export * from './wheel-rows.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/sizes/pane/max-summary-rows.ts b/mods/diff/hooks/limits/sizes/pane/max-summary-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc4547618f7dc1f1676a7ab5a4bfd9348c4b2805 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/max-summary-rows.ts @@ -0,0 +1,5 @@ +/** + * How many file rows the docked pane's list shows at once (the built-in + * panel's window); the rest are counted on a dim row under them. + */ +export const MAX_SUMMARY_ROWS = 8 diff --git a/mods/diff/hooks/limits/sizes/pane/pane-top-pad-rows.ts b/mods/diff/hooks/limits/sizes/pane/pane-top-pad-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..e487c31804c8ff48cafed19d3c5d2a498ccbe18e --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/pane-top-pad-rows.ts @@ -0,0 +1,5 @@ +/** + * The blank row the docked pane keeps above its header, as the built-in + * sidebar's header does (its `paddingY`). + */ +export const PANE_TOP_PAD_ROWS = 1 diff --git a/mods/diff/hooks/limits/sizes/pane/pre-session-body-cap.ts b/mods/diff/hooks/limits/sizes/pane/pre-session-body-cap.ts new file mode 100644 index 0000000000000000000000000000000000000000..e46db0d58083a94869bf96af66dc3d0498e2d8cf --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/pre-session-body-cap.ts @@ -0,0 +1,5 @@ +/** + * How many pre-session files get their bodies drawn once that section is + * open (the built-in panel's cap); past it only their legend is listed. + */ +export const PRE_SESSION_BODY_CAP = 20 diff --git a/mods/diff/hooks/limits/sizes/pane/scroll-margin-rows.ts b/mods/diff/hooks/limits/sizes/pane/scroll-margin-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..15c020be3fef518e9ac545e3314f59138e0f1a53 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/scroll-margin-rows.ts @@ -0,0 +1,5 @@ +/** + * The rows the docked window draws past its own height, so a line whose + * wrap was counted short still leaves it full; the pane clips the rest. + */ +export const SCROLL_MARGIN_ROWS = 8 diff --git a/mods/diff/hooks/limits/sizes/pane/wheel-rows.ts b/mods/diff/hooks/limits/sizes/pane/wheel-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d0a97fb021da98129df53f974eff80dc1f028ac --- /dev/null +++ b/mods/diff/hooks/limits/sizes/pane/wheel-rows.ts @@ -0,0 +1,5 @@ +/** + * The rows one wheel tick moves the docked body, as the built-in panel's + * body moves. + */ +export const WHEEL_ROWS = 3 diff --git a/mods/diff/hooks/limits/sizes/prompt-context-max-chars.ts b/mods/diff/hooks/limits/sizes/prompt-context-max-chars.ts new file mode 100644 index 0000000000000000000000000000000000000000..81520f109a6912f022605c893d57b0abf4460583 --- /dev/null +++ b/mods/diff/hooks/limits/sizes/prompt-context-max-chars.ts @@ -0,0 +1,5 @@ +/** + * The most characters a prompt's context carries, its entries together; the + * engine refuses a context past it, so an ask is fitted to what is left. + */ +export const PROMPT_CONTEXT_MAX_CHARS = 32_000 diff --git a/mods/diff/hooks/limits/timing/git-timeout-ms.ts b/mods/diff/hooks/limits/timing/git-timeout-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7824e4804c57d93c173ebfbe20973e8aad65b49 --- /dev/null +++ b/mods/diff/hooks/limits/timing/git-timeout-ms.ts @@ -0,0 +1,5 @@ +/** + * How long one git child may run before the fetch gives up on it and keeps + * the last good diff (the built-in panel's own timeout). + */ +export const GIT_TIMEOUT_MS = 5000 diff --git a/mods/diff/hooks/limits/timing/head-poll-ms.ts b/mods/diff/hooks/limits/timing/head-poll-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d49c4ed71759930848a4f4b3e9d5626df7df7c2 --- /dev/null +++ b/mods/diff/hooks/limits/timing/head-poll-ms.ts @@ -0,0 +1,5 @@ +/** + * How often `.git/HEAD` and the checked-out ref are probed for a checkout or + * commit made outside the session. + */ +export const HEAD_POLL_MS = 2000 diff --git a/mods/diff/hooks/limits/timing/index.ts b/mods/diff/hooks/limits/timing/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a9c4d91faa668bba91a85a96413bb5beda86a4c --- /dev/null +++ b/mods/diff/hooks/limits/timing/index.ts @@ -0,0 +1,6 @@ +export * from './git-timeout-ms.js' +export * from './head-poll-ms.js' +export * from './redraw-coalesce-ms.js' +export * from './refresh-debounce-ms.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/timing/redraw-coalesce-ms.ts b/mods/diff/hooks/limits/timing/redraw-coalesce-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa46795215b1aaf23a957f12538014c01ae2b6d6 --- /dev/null +++ b/mods/diff/hooks/limits/timing/redraw-coalesce-ms.ts @@ -0,0 +1,5 @@ +/** + * How long state changes gather before one `$.ui.invalidate`, keeping the + * pane under the engine's ten redraws a second. + */ +export const REDRAW_COALESCE_MS = 100 diff --git a/mods/diff/hooks/limits/timing/refresh-debounce-ms.ts b/mods/diff/hooks/limits/timing/refresh-debounce-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..38332e9c41711bea845b977981e5b11161ac8924 --- /dev/null +++ b/mods/diff/hooks/limits/timing/refresh-debounce-ms.ts @@ -0,0 +1,5 @@ +/** + * The trailing-edge wait between a trigger (an edit, a shell command, a turn + * step) and the refetch it causes, so a burst of edits fetches once. + */ +export const REFRESH_DEBOUNCE_MS = 150 diff --git a/mods/diff/hooks/names/keys/base-store-key-of.ts b/mods/diff/hooks/names/keys/base-store-key-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..7896b6d1b9b7d6c56489fa6c191db79344c9d216 --- /dev/null +++ b/mods/diff/hooks/names/keys/base-store-key-of.ts @@ -0,0 +1,8 @@ +/** + * The `$.store` key of a working tree's chosen comparison base, one per + * `--show-toplevel` so a linked worktree keeps its own. + * + * @param toplevel the working tree's top-level path + * @returns the key + */ +export const baseStoreKeyOf = (toplevel: string) => `base:${toplevel}` diff --git a/mods/diff/hooks/names/keys/index.ts b/mods/diff/hooks/names/keys/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..754b99ce989251a4f65e31868b1f70c625a99798 --- /dev/null +++ b/mods/diff/hooks/names/keys/index.ts @@ -0,0 +1,4 @@ +export * from './base-store-key-of.js' +export * from './store-open-key.js' + +export * as default from '.' diff --git a/mods/diff/hooks/names/keys/store-open-key.ts b/mods/diff/hooks/names/keys/store-open-key.ts new file mode 100644 index 0000000000000000000000000000000000000000..be9f3fd66a5af67613da653b92724751dd033d70 --- /dev/null +++ b/mods/diff/hooks/names/keys/store-open-key.ts @@ -0,0 +1,5 @@ +/** + * The `$.store` key holding whether the person keeps the pane open (`/diff` + * writes it; the first edit reads it): the built-in's `diffSidebarOpen`. + */ +export const STORE_OPEN_KEY = 'open' diff --git a/mods/diff/hooks/names/texts/builtin-holds-pattern.ts b/mods/diff/hooks/names/texts/builtin-holds-pattern.ts new file mode 100644 index 0000000000000000000000000000000000000000..afc27dc93eaacf62e996cf41087ee3f95741c479 --- /dev/null +++ b/mods/diff/hooks/names/texts/builtin-holds-pattern.ts @@ -0,0 +1,5 @@ +/** + * The one `$.command.register` refusal meaning the built-in `/diff` serves + * this session, as the engine words it: the plugin stands down silently. + */ +export const BUILTIN_HOLDS_PATTERN = /refused: it is the built-in \// diff --git a/mods/diff/hooks/names/texts/git-unanswered-text.ts b/mods/diff/hooks/names/texts/git-unanswered-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..e167dda0c823bfea222826f7bed4bf0370d2481e --- /dev/null +++ b/mods/diff/hooks/names/texts/git-unanswered-text.ts @@ -0,0 +1,9 @@ +/** + * `/diff`'s answer when git did not say whether this is a repository. + * + * It timed out: the directory is unknown, not outside one. A git that + * cannot start has answered, and gets the not-a-repository text. + */ +export const GIT_UNANSWERED_TEXT = + "The diff panel couldn't read git state — git didn't answer; run /diff " + + 'again' diff --git a/mods/diff/hooks/names/texts/index.ts b/mods/diff/hooks/names/texts/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e801a598375c1bb775260b4a01f958fcfe6c4dd --- /dev/null +++ b/mods/diff/hooks/names/texts/index.ts @@ -0,0 +1,10 @@ +export * from './builtin-holds-pattern.js' +export * from './git-unanswered-text.js' +export * from './more-below-text.js' +export * from './not-in-repository-text.js' +export * from './panel-toggle' +export * from './register-failed-text-of.js' +export * from './resize-terminal-text.js' +export * from './untracked-withheld-text-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/names/texts/more-below-text.ts b/mods/diff/hooks/names/texts/more-below-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..669d268390ce09228bad0a2218d77d2c6f8eb7fb --- /dev/null +++ b/mods/diff/hooks/names/texts/more-below-text.ts @@ -0,0 +1,5 @@ +/** + * What follows a count on the docked list's lower edge row: the words and + * the built-in's advertised list key (its macOS spelling). + */ +export const MORE_BELOW_TEXT = 'more below (opt+\u2193 to scroll)' diff --git a/mods/diff/hooks/names/texts/not-in-repository-text.ts b/mods/diff/hooks/names/texts/not-in-repository-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d8283656b0a5ac6f9bc28e9424a22523c339c70 --- /dev/null +++ b/mods/diff/hooks/names/texts/not-in-repository-text.ts @@ -0,0 +1,7 @@ +/** + * `/diff`'s answer outside a git repository, worded as the built-in words it + * but for its apostrophe, which is ASCII. + */ +export const NOT_IN_REPOSITORY_TEXT = + "The diff panel shows git changes — the current directory isn't in a " + + 'git repository' diff --git a/mods/diff/hooks/names/texts/panel-toggle/dialog-dismissed-text.ts b/mods/diff/hooks/names/texts/panel-toggle/dialog-dismissed-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f9cd8a518e93f2ddc8c3a2b06daf2c967dcb868 --- /dev/null +++ b/mods/diff/hooks/names/texts/panel-toggle/dialog-dismissed-text.ts @@ -0,0 +1,4 @@ +/** + * The transcript line the built-in dialog leaves when the person closes it. + */ +export const DIALOG_DISMISSED_TEXT = 'Diff dialog dismissed' diff --git a/mods/diff/hooks/names/texts/panel-toggle/index.ts b/mods/diff/hooks/names/texts/panel-toggle/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf4293cb780a290c92642ea924b932e356677b91 --- /dev/null +++ b/mods/diff/hooks/names/texts/panel-toggle/index.ts @@ -0,0 +1,5 @@ +export * from './dialog-dismissed-text.js' +export * from './panel-hidden-text.js' +export * from './panel-shown-text.js' + +export * as default from '.' diff --git a/mods/diff/hooks/names/texts/panel-toggle/panel-hidden-text.ts b/mods/diff/hooks/names/texts/panel-toggle/panel-hidden-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..85bb37e272ed7b89902c4f2ad830f635be33690e --- /dev/null +++ b/mods/diff/hooks/names/texts/panel-toggle/panel-hidden-text.ts @@ -0,0 +1,5 @@ +/** + * `/diff`'s transcript line once it closed the pane, worded as the built-in + * words its own. + */ +export const PANEL_HIDDEN_TEXT = 'Diff panel hidden' diff --git a/mods/diff/hooks/names/texts/panel-toggle/panel-shown-text.ts b/mods/diff/hooks/names/texts/panel-toggle/panel-shown-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..2375f17ff29350bb92135365546e32592b366c4d --- /dev/null +++ b/mods/diff/hooks/names/texts/panel-toggle/panel-shown-text.ts @@ -0,0 +1,5 @@ +/** + * `/diff`'s transcript line once it opened the pane, worded as the built-in + * words its own. + */ +export const PANEL_SHOWN_TEXT = 'Diff panel shown' diff --git a/mods/diff/hooks/names/texts/register-failed-text-of.ts b/mods/diff/hooks/names/texts/register-failed-text-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..11ba7ea7e8fe52c9141b7b98454a325d693ebc6d --- /dev/null +++ b/mods/diff/hooks/names/texts/register-failed-text-of.ts @@ -0,0 +1,10 @@ +/** + * The transcript line when `/diff` could not be registered for a reason + * other than the built-in holding it: the person learns the pane is off. + * + * @param reason the refusal's message + * @returns the line + */ +export const registerFailedTextOf = (reason: string) => + `could not register /diff: ${reason}; the diff panel is unavailable this ` + + `session` diff --git a/mods/diff/hooks/names/texts/resize-terminal-text.ts b/mods/diff/hooks/names/texts/resize-terminal-text.ts new file mode 100644 index 0000000000000000000000000000000000000000..4201bcc6c7c0c1dd4ceacc33da4960724e02e7d5 --- /dev/null +++ b/mods/diff/hooks/names/texts/resize-terminal-text.ts @@ -0,0 +1,9 @@ +/** + * `/diff`'s answer on a terminal narrower than the built-in panel shows + * on, worded as the built-in words it. + * + * Also the pane's one line where the surface seats it inline (no room + * beside the transcript). + */ +export const RESIZE_TERMINAL_TEXT = + 'Resize your terminal to at least 110 columns to show the diff panel' diff --git a/mods/diff/hooks/names/texts/untracked-withheld-text-of.ts b/mods/diff/hooks/names/texts/untracked-withheld-text-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c1d5e31820a6cb2ab3bda676e69a6fdc9d64585 --- /dev/null +++ b/mods/diff/hooks/names/texts/untracked-withheld-text-of.ts @@ -0,0 +1,14 @@ +import type PaneState from '../../pane-state' + +/** + * The pane's word for a fetch whose untracked listing the backend did not + * give whole: the tracked rows stand, the new files are not counted. + * + * @param model the pane's state, its words naming the program that lists them + * @returns the note + */ +export const untrackedWithheldTextOf = ( + model: Pick, +) => + `Untracked files unavailable (${model.words.lister} could not list them); ` + + 'not counted' diff --git a/mods/diff/hooks/pane-state/after-fetch/after-fetch.ts b/mods/diff/hooks/pane-state/after-fetch/after-fetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e5380d9fcb3da3d53d977b13eeb51c2f0ccabe8 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-fetch/after-fetch.ts @@ -0,0 +1,29 @@ +import Todos from '../../todos' +import Turns from '../../turns' +import type { PaneModel } from '../pane-model' +import type { Fetched } from './fetched' + +/** + * The model once a refresh has settled: the outcome's data (kept as it was + * when the fetch was unavailable), the transcript's turns and todos. + * + * @param model the model before the refresh + * @param fetched what the refresh read + * @returns the settled model + */ +export function afterFetch(model: PaneModel, fetched: Fetched): PaneModel { + const { outcome, messages } = fetched + const isData = outcome.kind === 'data' + const isKept = outcome.kind === 'unavailable' + const data = isData ? outcome.data : isKept ? model.data : null + + return { + ...model, + isLoading: false, + hasSettled: true, + isOutsideRepository: outcome.kind === 'no-repository', + data, + turns: Turns.turnDiffsOf(messages), + todos: Todos.todoProgressOf(messages), + } +} diff --git a/mods/diff/hooks/pane-state/after-fetch/fetched/fetched.ts b/mods/diff/hooks/pane-state/after-fetch/fetched/fetched.ts new file mode 100644 index 0000000000000000000000000000000000000000..c93b77cdd48b0817d5401a9f5d63a22b9a64ddd0 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-fetch/fetched/fetched.ts @@ -0,0 +1,11 @@ +import type { SessionMessage } from 'claude-code' + +import type Git from '../../../git' + +/** + * What one refresh read: how the diff fetch ended and the transcript rows. + */ +export type Fetched = { + outcome: Git.FetchOutcome + messages: readonly SessionMessage[] +} diff --git a/mods/diff/hooks/pane-state/after-fetch/fetched/index.ts b/mods/diff/hooks/pane-state/after-fetch/fetched/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7262baa1935f5e4cd0a19604e255609b3c8ae193 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-fetch/fetched/index.ts @@ -0,0 +1,3 @@ +export type * from './fetched.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/after-fetch/index.ts b/mods/diff/hooks/pane-state/after-fetch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..227de9d375672607bcca08ca76f67728589b4694 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-fetch/index.ts @@ -0,0 +1,4 @@ +export * from './after-fetch.js' +export * from './fetched' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/after-new-session/after-new-session.ts b/mods/diff/hooks/pane-state/after-new-session/after-new-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..3769c1c055f46f528709429d58769c2db62bb75a --- /dev/null +++ b/mods/diff/hooks/pane-state/after-new-session/after-new-session.ts @@ -0,0 +1,14 @@ +import type { PaneModel } from '../pane-model' +import { NEW_SESSION_FIELDS } from './new-session-fields' + +/** + * The model after `/clear` or `/resume`: the fetch kept, every pick and + * the transcript's turns dropped. + * + * @param model the model before the switch + * @returns the reset model + */ +export const afterNewSession = (model: PaneModel): PaneModel => ({ + ...model, + ...NEW_SESSION_FIELDS, +}) diff --git a/mods/diff/hooks/pane-state/after-new-session/index.ts b/mods/diff/hooks/pane-state/after-new-session/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..04346ab5da35d41bc673778ad2623f99dd9812e0 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-new-session/index.ts @@ -0,0 +1,4 @@ +export * from './after-new-session.js' +export * from './new-session-fields' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/after-new-session/new-session-fields/index.ts b/mods/diff/hooks/pane-state/after-new-session/new-session-fields/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e57f39c830d34e2da8f31afd500f75f1b8543870 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-new-session/new-session-fields/index.ts @@ -0,0 +1,3 @@ +export * from './new-session-fields.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/after-new-session/new-session-fields/new-session-fields.ts b/mods/diff/hooks/pane-state/after-new-session/new-session-fields/new-session-fields.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7e72d7f66b196c9830928d4156795b00ed63fe5 --- /dev/null +++ b/mods/diff/hooks/pane-state/after-new-session/new-session-fields/new-session-fields.ts @@ -0,0 +1,12 @@ +import { NO_BODIES } from '../../no-bodies' + +/** + * What `/clear` and `/resume` reset: the pick, the source, the turns and + * the bodies; the fetch itself stays. + */ +export const NEW_SESSION_FIELDS = Object.freeze({ + selectedPath: null, + source: Object.freeze({ kind: 'current' }), + turns: Object.freeze([]), + bodies: NO_BODIES, +} as const) diff --git a/mods/diff/hooks/pane-state/base-mode-of/base-mode-of.ts b/mods/diff/hooks/pane-state/base-mode-of/base-mode-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..de11c94af93ed5e26eab6a32fa7e19b4cefb0205 --- /dev/null +++ b/mods/diff/hooks/pane-state/base-mode-of/base-mode-of.ts @@ -0,0 +1,14 @@ +import type Git from '../../git' + +/** + * A stored or picked value as a base mode, or null when it names none. + * + * @param value what `$.store.get` returned or a Select carried + * @returns the mode, or null + */ +export function baseModeOf(value: unknown): Git.BaseMode | null { + const isMode = + value === 'session' || value === 'uncommitted' || value === 'branch' + + return isMode ? value : null +} diff --git a/mods/diff/hooks/pane-state/base-mode-of/index.ts b/mods/diff/hooks/pane-state/base-mode-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3479978c1af1fec8fc6b4f17ddcd2eb227ebeb69 --- /dev/null +++ b/mods/diff/hooks/pane-state/base-mode-of/index.ts @@ -0,0 +1,3 @@ +export * from './base-mode-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/bodies/bodies.ts b/mods/diff/hooks/pane-state/bodies/bodies.ts new file mode 100644 index 0000000000000000000000000000000000000000..91cfcf08431fe4a24bf485fbf9312b2069381388 --- /dev/null +++ b/mods/diff/hooks/pane-state/bodies/bodies.ts @@ -0,0 +1,7 @@ +import type Git from '../../git' + +/** + * The file bodies the pane has read, by path: the parsed hunks, or null + * where the backend could not read them; a path not in it is still loading. + */ +export type Bodies = ReadonlyMap diff --git a/mods/diff/hooks/pane-state/bodies/index.ts b/mods/diff/hooks/pane-state/bodies/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..81ce6a52dd67a3f1a5f6b9fbb3f32a6ff7b2e5e5 --- /dev/null +++ b/mods/diff/hooks/pane-state/bodies/index.ts @@ -0,0 +1,3 @@ +export type * from './bodies.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/dialog-focus/dialog-focus.ts b/mods/diff/hooks/pane-state/dialog-focus/dialog-focus.ts new file mode 100644 index 0000000000000000000000000000000000000000..03616566939f4037cf2e3023237abf1bdaf2d66f --- /dev/null +++ b/mods/diff/hooks/pane-state/dialog-focus/dialog-focus.ts @@ -0,0 +1,8 @@ +/** + * What the ring landing on a dialog row settles: the file now selected, + * and the key of the row to land the ring on once the window re-centres. + */ +export type DialogFocus = { + selectedPath: string + landing: string +} diff --git a/mods/diff/hooks/pane-state/dialog-focus/index.ts b/mods/diff/hooks/pane-state/dialog-focus/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f85ca7bd769deae7d61e162aba8e9212a36fb6c5 --- /dev/null +++ b/mods/diff/hooks/pane-state/dialog-focus/index.ts @@ -0,0 +1,3 @@ +export type * from './dialog-focus.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/dialog-title/dialog-title.ts b/mods/diff/hooks/pane-state/dialog-title/dialog-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..451d87a934025794fce5ce3923dfd34813604fb6 --- /dev/null +++ b/mods/diff/hooks/pane-state/dialog-title/dialog-title.ts @@ -0,0 +1,8 @@ +/** + * The inline pane's first line, as the built-in dialog heads itself: what + * the rows are, then in dim what they are compared with. + */ +export type DialogTitle = { + title: string + subtitle: string +} diff --git a/mods/diff/hooks/pane-state/dialog-title/index.ts b/mods/diff/hooks/pane-state/dialog-title/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbe34f5c8bcaf76ec8ba51c718ab375e9b118479 --- /dev/null +++ b/mods/diff/hooks/pane-state/dialog-title/index.ts @@ -0,0 +1,3 @@ +export type * from './dialog-title.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/empty-state/empty-state.ts b/mods/diff/hooks/pane-state/empty-state/empty-state.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ae9e9d2035286dd92f7bca9310a71d7c8202166 --- /dev/null +++ b/mods/diff/hooks/pane-state/empty-state/empty-state.ts @@ -0,0 +1,8 @@ +/** + * What the header says instead of a count when there is nothing to list, + * and the body's hint under it when the hint adds something. + */ +export type EmptyState = { + headline: string + hint: string | null +} diff --git a/mods/diff/hooks/pane-state/empty-state/index.ts b/mods/diff/hooks/pane-state/empty-state/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a00dc2d2e04c5c04b6968c4303dbb23b4fd4fa5 --- /dev/null +++ b/mods/diff/hooks/pane-state/empty-state/index.ts @@ -0,0 +1,3 @@ +export type * from './empty-state.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/header-totals/header-totals.ts b/mods/diff/hooks/pane-state/header-totals/header-totals.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bbb140936b42be0ac0374d6b2cc859d7eb10e89 --- /dev/null +++ b/mods/diff/hooks/pane-state/header-totals/header-totals.ts @@ -0,0 +1,10 @@ +/** + * The header's counts: the fetched totals less the pre-session rows, and + * how many session files fell past the row cap ("not shown"). + */ +export type HeaderTotals = { + filesCount: number + linesAdded: number + linesRemoved: number + notShown: number +} diff --git a/mods/diff/hooks/pane-state/header-totals/index.ts b/mods/diff/hooks/pane-state/header-totals/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b4702cac1bef13ff5038eb6b030c1084152925e --- /dev/null +++ b/mods/diff/hooks/pane-state/header-totals/index.ts @@ -0,0 +1,3 @@ +export type * from './header-totals.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/initial-model/index.ts b/mods/diff/hooks/pane-state/initial-model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..912d01fa872e3a4b56339513a9cc947b10671d6f --- /dev/null +++ b/mods/diff/hooks/pane-state/initial-model/index.ts @@ -0,0 +1,3 @@ +export * from './initial-model.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/initial-model/initial-model.ts b/mods/diff/hooks/pane-state/initial-model/initial-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..069b92e84cee0ddc3b6d1d878f37597d03276b4b --- /dev/null +++ b/mods/diff/hooks/pane-state/initial-model/initial-model.ts @@ -0,0 +1,29 @@ +import Git from '../../git' +import { NO_BODIES } from '../no-bodies' +import type { PaneModel } from '../pane-model' + +/** + * The pane before anything happened: session mode, nothing fetched, + * nothing picked, no body read, the current source, git's words, docked. + */ +export const INITIAL_MODEL: PaneModel = Object.freeze({ + words: Git.GIT_WORDS, + baseModes: Git.GIT_BASE_MODES, + isLoading: false, + hasSettled: false, + isOutsideRepository: false, + data: null, + requestedMode: 'session', + selectedPath: null, + isNoiseShown: false, + isPreSessionShown: false, + source: Object.freeze({ kind: 'current' }), + turns: Object.freeze([]), + bodies: NO_BODIES, + todos: Object.freeze({ done: 0, total: 0 }), + armedPath: null, + placement: 'dock', + isFullscreen: null, + dialogView: 'list', + place: Object.freeze({ top: 0, listStart: 0, columns: 0, rows: 0 }), +}) diff --git a/mods/diff/hooks/pane-state/mode-label-of/index.ts b/mods/diff/hooks/pane-state/mode-label-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..04fb5c40c413d9d7bb45b808d8cffa10281d58a4 --- /dev/null +++ b/mods/diff/hooks/pane-state/mode-label-of/index.ts @@ -0,0 +1,3 @@ +export * from './mode-label-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts b/mods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9d22458b8c977e8df7b18a717a287fd62fe9c43 --- /dev/null +++ b/mods/diff/hooks/pane-state/mode-label-of/mode-label-of.ts @@ -0,0 +1,44 @@ +import type Git from '../../git' +import type { PaneModel } from '../pane-model' + +/** + * One base mode's name in the picker, worded as the built-in's base line + * (diffBaseLabel) words the mode it shows. + * + * Branch mode names its base branch from the data, or once its own fetch + * settled without one says what stands in; uncommitted names the working + * tree's base. The requested mode carries an ellipsis while pending. + * + * @param mode the mode to name + * @param model the mode the person picked, the last good fetch, the words + * @returns the label + */ +export function modeLabelOf( + mode: Git.BaseMode, + model: Pick, +): string { + const { data } = model + const source = data?.source + const isPending = data !== null && model.requestedMode !== data.mode + const isRequested = mode === model.requestedMode + const suffix = isRequested && isPending ? '…' : '' + const base = source?.kind === 'working-tree' ? source.base : model.words.base + const isSettledBranch = isRequested && !isPending && data?.mode === 'branch' + + function nameOf(): string { + switch (mode) { + case 'session': + return 'this session' + case 'uncommitted': + return `uncommitted (vs ${base})` + case 'branch': + if (source?.kind === 'branch') { + return `branch vs ${source.baseBranch}` + } + + return isSettledBranch ? `vs ${base} (no base branch)` : 'branch diff' + } + } + + return `${nameOf()}${suffix}` +} diff --git a/mods/diff/hooks/pane-state/no-bodies/index.ts b/mods/diff/hooks/pane-state/no-bodies/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..43ea111f7394ebd9bbe7616ce6a6172414e3c6e2 --- /dev/null +++ b/mods/diff/hooks/pane-state/no-bodies/index.ts @@ -0,0 +1,3 @@ +export * from './no-bodies.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/no-bodies/no-bodies.ts b/mods/diff/hooks/pane-state/no-bodies/no-bodies.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bf8be4c9ef290caf5065e6786a9d2434d25e9d4 --- /dev/null +++ b/mods/diff/hooks/pane-state/no-bodies/no-bodies.ts @@ -0,0 +1,7 @@ +import type { Bodies } from '../bodies' + +/** + * No body read yet: the pane's bodies before a fetch, and after one whose + * base moved. + */ +export const NO_BODIES: Bodies = new Map() diff --git a/mods/diff/hooks/pane-state/pane-model/index.ts b/mods/diff/hooks/pane-state/pane-model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4195968877b79a6b9c51ebb5f1259234e8ac92d1 --- /dev/null +++ b/mods/diff/hooks/pane-state/pane-model/index.ts @@ -0,0 +1,3 @@ +export type * from './pane-model.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/pane-model/pane-model.ts b/mods/diff/hooks/pane-state/pane-model/pane-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..905a6f6af0ea8387095c7aa4885c3cc0ccc15fba --- /dev/null +++ b/mods/diff/hooks/pane-state/pane-model/pane-model.ts @@ -0,0 +1,36 @@ +import type Backend from '../../backend' +import type Git from '../../git' +import type Todos from '../../todos' +import type Turns from '../../turns' +import type { Bodies } from '../bodies' +import type { Source } from '../source' + +/** + * Everything one drawing of the pane reads. + * + * The last good fetch, what the person picked, the bodies read so far, the + * turns and todos, how the surface seated it, whether the layout docks a + * pane (once a drawing or `/diff` said), the dialog's view, and the docked + * scroll in its last box. + */ +export type PaneModel = { + words: Backend.BackendWords + baseModes: readonly Git.BaseMode[] + isLoading: boolean + hasSettled: boolean + isOutsideRepository: boolean + data: Git.DiffData | null + requestedMode: Git.BaseMode + selectedPath: string | null + isNoiseShown: boolean + isPreSessionShown: boolean + source: Source + turns: readonly Turns.TurnDiff[] + bodies: Bodies + todos: Todos.TodoProgress + armedPath: string | null + placement: 'dock' | 'inline' + isFullscreen: boolean | null + dialogView: 'list' | 'detail' + place: { top: number; listStart: number; columns: number; rows: number } +} diff --git a/mods/diff/hooks/pane-state/partition/index.ts b/mods/diff/hooks/pane-state/partition/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..993d49fef0c557abdb007d002a9c8a13cf054de4 --- /dev/null +++ b/mods/diff/hooks/pane-state/partition/index.ts @@ -0,0 +1,3 @@ +export type * from './partition.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/partition/partition.ts b/mods/diff/hooks/pane-state/partition/partition.ts new file mode 100644 index 0000000000000000000000000000000000000000..befaaee5584e1b9d3ede981cabd17187b9bce937 --- /dev/null +++ b/mods/diff/hooks/pane-state/partition/partition.ts @@ -0,0 +1,11 @@ +import type Git from '../../git' + +/** + * A fetch's rows as the pane groups them: the session's own that are + * listed, how many tests and generated files there are, the pre-session. + */ +export type Partition = { + shown: readonly Git.FileStat[] + preSession: readonly Git.FileStat[] + noiseCount: number +} diff --git a/mods/diff/hooks/pane-state/picked-turn-of/index.ts b/mods/diff/hooks/pane-state/picked-turn-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..024515313df7c9dc151c133406f9590fb9407915 --- /dev/null +++ b/mods/diff/hooks/pane-state/picked-turn-of/index.ts @@ -0,0 +1,3 @@ +export * from './picked-turn-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/picked-turn-of/picked-turn-of.ts b/mods/diff/hooks/pane-state/picked-turn-of/picked-turn-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..b83f52715b541ad44422aab7737fcef913963000 --- /dev/null +++ b/mods/diff/hooks/pane-state/picked-turn-of/picked-turn-of.ts @@ -0,0 +1,16 @@ +import type Turns from '../../turns' +import type { PaneModel } from '../pane-model' + +/** + * The turn the pane's source names while the transcript still holds it; + * undefined for the current source or a turn a rewind dropped. + * + * @param model the pane's state + * @returns the picked turn, or undefined + */ +export function pickedTurnOf(model: PaneModel): Turns.TurnDiff | undefined { + const { source } = model + const wanted = source.kind === 'turn' ? source.index : null + + return model.turns.find(candidate => candidate.index === wanted) +} diff --git a/mods/diff/hooks/pane-state/source/index.ts b/mods/diff/hooks/pane-state/source/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1b0da3a89156d43838ffdd9e1bba6cc07027e56 --- /dev/null +++ b/mods/diff/hooks/pane-state/source/index.ts @@ -0,0 +1,3 @@ +export type * from './source.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/source/source.ts b/mods/diff/hooks/pane-state/source/source.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5621a26b3d4bb0485148187df7444329b50ece1 --- /dev/null +++ b/mods/diff/hooks/pane-state/source/source.ts @@ -0,0 +1,4 @@ +/** + * What the pane lists: the repository's diff now, or one past turn's edits. + */ +export type Source = { kind: 'current' } | { kind: 'turn'; index: number } diff --git a/mods/diff/hooks/pane-state/turn-title-of/index.ts b/mods/diff/hooks/pane-state/turn-title-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d068ba1bd1e309c014fad20ce77519ad3e21a03 --- /dev/null +++ b/mods/diff/hooks/pane-state/turn-title-of/index.ts @@ -0,0 +1,3 @@ +export * from './turn-title-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/turn-title-of/turn-title-of.ts b/mods/diff/hooks/pane-state/turn-title-of/turn-title-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..92db84d1887c31a94c54a750d713399f5d72c783 --- /dev/null +++ b/mods/diff/hooks/pane-state/turn-title-of/turn-title-of.ts @@ -0,0 +1,16 @@ +import type Turns from '../../turns' +import type { DialogTitle } from '../dialog-title' + +/** + * A picked turn's heading, as the built-in dialog titles its tab: the + * turn's number, then its prompt's opening words in quotes when it has any. + * + * @param turn the turn + * @returns the title and its dim subtitle + */ +export const turnTitleOf = ( + turn: Pick, +): DialogTitle => ({ + title: `Turn ${turn.index}`, + subtitle: turn.preview === '' ? '' : `"${turn.preview}"`, +}) diff --git a/mods/diff/hooks/pane-state/turn-totals-of/index.ts b/mods/diff/hooks/pane-state/turn-totals-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e33459e007ae721d9a9d6ecf0237a5cd7c067485 --- /dev/null +++ b/mods/diff/hooks/pane-state/turn-totals-of/index.ts @@ -0,0 +1,3 @@ +export * from './turn-totals-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/turn-totals-of/turn-totals-of.ts b/mods/diff/hooks/pane-state/turn-totals-of/turn-totals-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea49e80a3bea36563deba4655202ec52bbc3aa73 --- /dev/null +++ b/mods/diff/hooks/pane-state/turn-totals-of/turn-totals-of.ts @@ -0,0 +1,16 @@ +import type Turns from '../../turns' +import type { HeaderTotals } from '../header-totals' + +/** + * A turn's header counts: its files and their summed lines; nothing falls + * past a cap. + * + * @param turn the turn + * @returns the counts + */ +export const turnTotalsOf = (turn: Turns.TurnDiff): HeaderTotals => ({ + filesCount: turn.files.length, + linesAdded: turn.files.reduce((sum, file) => sum + file.added, 0), + linesRemoved: turn.files.reduce((sum, file) => sum + file.removed, 0), + notShown: 0, +}) diff --git a/mods/diff/hooks/pane-state/zero-totals/index.ts b/mods/diff/hooks/pane-state/zero-totals/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..59d381c3690a6b8eb1e1baf3da25de43af99806f --- /dev/null +++ b/mods/diff/hooks/pane-state/zero-totals/index.ts @@ -0,0 +1,3 @@ +export * from './zero-totals.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/zero-totals/zero-totals.ts b/mods/diff/hooks/pane-state/zero-totals/zero-totals.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c372e1a11238c08e6481b6e52f19289731ee361 --- /dev/null +++ b/mods/diff/hooks/pane-state/zero-totals/zero-totals.ts @@ -0,0 +1,12 @@ +import type { HeaderTotals } from '../header-totals' + +/** + * The header's counts when no fetch has settled into data: nothing + * changed, nothing past the cap. + */ +export const ZERO_TOTALS: HeaderTotals = Object.freeze({ + filesCount: 0, + linesAdded: 0, + linesRemoved: 0, + notShown: 0, +}) diff --git a/mods/diff/hooks/pane-toggle/pane-belief/index.ts b/mods/diff/hooks/pane-toggle/pane-belief/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..921fcf308ab415bc9da9d03437b412464c0b775a --- /dev/null +++ b/mods/diff/hooks/pane-toggle/pane-belief/index.ts @@ -0,0 +1,3 @@ +export type * from './pane-belief.js' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts b/mods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts new file mode 100644 index 0000000000000000000000000000000000000000..e965cc1f946920df28188ea7d2053e0fae043222 --- /dev/null +++ b/mods/diff/hooks/pane-toggle/pane-belief/pane-belief.ts @@ -0,0 +1,11 @@ +/** + * What the plugin knows of its pane when `/diff` runs. + * + * Whether the pane is open (it opened it, and no close of any origin has + * gone through since), and the terminal's width as last drawn (null before + * any draw). + */ +export type PaneBelief = { + isOpen: boolean + columns: number | null +} diff --git a/mods/diff/hooks/record/base-outcomes/base-outcomes.ts b/mods/diff/hooks/record/base-outcomes/base-outcomes.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b4a2bf4f39d1166d2cdc174db9ccc3c6fb14be3 --- /dev/null +++ b/mods/diff/hooks/record/base-outcomes/base-outcomes.ts @@ -0,0 +1,5 @@ +/** + * What branch mode resolved its base to, as a base-resolve mark names it, + * the built-in panel's own: the merge base, HEAD itself, or nothing. + */ +export const BASE_OUTCOMES = ['merge-base', 'head-is-base', 'none'] as const diff --git a/mods/diff/hooks/record/base-outcomes/index.ts b/mods/diff/hooks/record/base-outcomes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..54bae72a4b9f9c0e07d9318791a35004a5ab2b40 --- /dev/null +++ b/mods/diff/hooks/record/base-outcomes/index.ts @@ -0,0 +1,3 @@ +export * from './base-outcomes.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/features/features.ts b/mods/diff/hooks/record/features/features.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc245623bc675bf9995eb30b61070fe6725bb467 --- /dev/null +++ b/mods/diff/hooks/record/features/features.ts @@ -0,0 +1,16 @@ +/** + * The feature names the plugin marks, kept as the built-in panel's so the + * feature surface keeps one series across the swap. + * + * `read` per fetch round, `tabSwitch` per `/diff` open or close and per + * close by the pane's own control, `baseSwitch` per base pick, `baseResolve` + * on the first resolve and the first failure, `selectionAttach` per diff + * asked. + */ +export const FEATURES = { + read: 'repl_diff_read', + tabSwitch: 'repl_tab_switch', + baseSwitch: 'repl_diff_base_switch', + baseResolve: 'diff_base_resolve', + selectionAttach: 'diff_selection_attach', +} as const diff --git a/mods/diff/hooks/record/features/index.ts b/mods/diff/hooks/record/features/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..672d470c52134d6c2991012bb72f6fbbc3f6c48f --- /dev/null +++ b/mods/diff/hooks/record/features/index.ts @@ -0,0 +1,3 @@ +export * from './features.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/mark-outcome/index.ts b/mods/diff/hooks/record/mark-outcome/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6e1809167bacae05f907474cb7b61dfb449d337 --- /dev/null +++ b/mods/diff/hooks/record/mark-outcome/index.ts @@ -0,0 +1,3 @@ +export type * from './mark-outcome.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/mark-outcome/mark-outcome.ts b/mods/diff/hooks/record/mark-outcome/mark-outcome.ts new file mode 100644 index 0000000000000000000000000000000000000000..06225a06d286b71ec4aaacbb2557c7185ee87431 --- /dev/null +++ b/mods/diff/hooks/record/mark-outcome/mark-outcome.ts @@ -0,0 +1,12 @@ +import type { Host } from '../../host' + +/** + * How a marked feature went: fine, or degraded for a reason, with the + * properties the built-in panel's mark carries beside it. + * + * The pane never marks `bad`: a failed fetch keeps the last good diff on + * screen. + */ +export type MarkOutcome = ({ kind: 'ok' } | { kind: 'sad'; reason: string }) & { + props?: Parameters[0]['props'] +} diff --git a/mods/diff/hooks/record/recorder/index.ts b/mods/diff/hooks/record/recorder/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffa2cf745b739dd89d5f1b58ccc1fc4b24acb2e4 --- /dev/null +++ b/mods/diff/hooks/record/recorder/index.ts @@ -0,0 +1,3 @@ +export type * from './recorder.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/recorder/recorder.ts b/mods/diff/hooks/record/recorder/recorder.ts new file mode 100644 index 0000000000000000000000000000000000000000..252e70474d5757155d8e138119bc6a9b7a711e3d --- /dev/null +++ b/mods/diff/hooks/record/recorder/recorder.ts @@ -0,0 +1,28 @@ +import type { MarkOutcome } from '../mark-outcome' +import type { SHOWN_TRIGGERS } from '../shown-triggers' +import type { WIDTH_BUCKETS } from '../width-buckets' + +/** + * The plugin's telemetry as its hooks call it; every method swallows a + * missing `$.telemetry` and a refused row, so recording never disturbs. + */ +export type Recorder = { + /** + * One `tengu_feature_` row. + */ + mark: (feature: string, outcome: MarkOutcome) => void + + /** + * The built-in panel's `tengu_repl_diff_panel_shown` row, once a session. + */ + shown: ( + trigger: (typeof SHOWN_TRIGGERS)[number], + bucket: (typeof WIDTH_BUCKETS)[number], + ) => void + + /** + * The built-in's `diff_selection_attach` feature mark: a file's diff rode + * the next prompt. + */ + asked: () => void +} diff --git a/mods/diff/hooks/record/safely/index.ts b/mods/diff/hooks/record/safely/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..002c90f9e8a5d2ae9607a656527aca5d11f6f958 --- /dev/null +++ b/mods/diff/hooks/record/safely/index.ts @@ -0,0 +1,3 @@ +export * from './safely.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/safely/safely.ts b/mods/diff/hooks/record/safely/safely.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bda37a758e9a7a70dd205e477d46f5ee2141ff0 --- /dev/null +++ b/mods/diff/hooks/record/safely/safely.ts @@ -0,0 +1,11 @@ +/** + * Sends one telemetry row and forgets it: a missing `$.telemetry` (no + * telemetry built-in) throws synchronously, a refused row rejects; both drop. + * + * @param send posts the row + */ +export function safely(send: () => Promise) { + try { + void send().catch(() => undefined) + } catch {} +} diff --git a/mods/diff/hooks/record/shown-triggers/index.ts b/mods/diff/hooks/record/shown-triggers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c963ba667b0ac62787801cb9b67260c20109838 --- /dev/null +++ b/mods/diff/hooks/record/shown-triggers/index.ts @@ -0,0 +1,3 @@ +export * from './shown-triggers.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/shown-triggers/shown-triggers.ts b/mods/diff/hooks/record/shown-triggers/shown-triggers.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdb1a80e1e9805de00c48e4c74711ae6e080ce18 --- /dev/null +++ b/mods/diff/hooks/record/shown-triggers/shown-triggers.ts @@ -0,0 +1,5 @@ +/** + * What opened the pane the first time it showed this session: Claude's + * first edit, or the person's `/diff`. + */ +export const SHOWN_TRIGGERS = ['auto_open', 'manual'] as const diff --git a/mods/diff/hooks/record/tabs/index.ts b/mods/diff/hooks/record/tabs/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..27f7c5bb8aeca418fe24c234bd9d3219de325353 --- /dev/null +++ b/mods/diff/hooks/record/tabs/index.ts @@ -0,0 +1,3 @@ +export * from './tabs.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/tabs/tabs.ts b/mods/diff/hooks/record/tabs/tabs.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a614b47692bdd5b4e6ae83ed6693a0392432153 --- /dev/null +++ b/mods/diff/hooks/record/tabs/tabs.ts @@ -0,0 +1,5 @@ +/** + * Where a tab-switch mark says the person went, named as the built-in + * panel names it: the diff, or the conversation it closes back to. + */ +export const TABS = ['diff', 'convo'] as const diff --git a/mods/diff/hooks/record/width-bucket-of/index.ts b/mods/diff/hooks/record/width-bucket-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9127054c1c3ef7887cbbc2bf52a537acdb79ed03 --- /dev/null +++ b/mods/diff/hooks/record/width-bucket-of/index.ts @@ -0,0 +1,3 @@ +export * from './width-bucket-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/width-bucket-of/width-bucket-of.ts b/mods/diff/hooks/record/width-bucket-of/width-bucket-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d98208e38e132446ab4a9c99a6b35ae5bb971521 --- /dev/null +++ b/mods/diff/hooks/record/width-bucket-of/width-bucket-of.ts @@ -0,0 +1,21 @@ +import Limits from '../../limits' +import { WIDTH_BUCKETS } from '../width-buckets' + +/** + * The bucket a terminal width falls in, the built-in panel's own buckets. + * + * @param columns the terminal's width, or null when no render told it yet + * @returns one of WIDTH_BUCKETS + */ +export function widthBucketOf( + columns: number | null, +): (typeof WIDTH_BUCKETS)[number] { + const [narrow, medium, wide, widest] = WIDTH_BUCKETS + const width = columns ?? 0 + + const isNarrow = width < Limits.OPEN_MIN_COLUMNS + const isMedium = width < Limits.AUTO_OPEN_MIN_COLUMNS + const isWide = width < Limits.WIDEST_BUCKET_COLUMNS + + return isNarrow ? narrow : isMedium ? medium : isWide ? wide : widest +} diff --git a/mods/diff/hooks/record/width-buckets/index.ts b/mods/diff/hooks/record/width-buckets/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..14a139cd9c954b25b7d11db94e72b9219cca6f39 --- /dev/null +++ b/mods/diff/hooks/record/width-buckets/index.ts @@ -0,0 +1,3 @@ +export * from './width-buckets.js' + +export * as default from '.' diff --git a/mods/diff/hooks/record/width-buckets/width-buckets.ts b/mods/diff/hooks/record/width-buckets/width-buckets.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3132d63345a0531c4b806ad79a5f18f667bce5f --- /dev/null +++ b/mods/diff/hooks/record/width-buckets/width-buckets.ts @@ -0,0 +1,10 @@ +/** + * The terminal-width buckets the pane-shown row reports, the built-in + * panel's own. + */ +export const WIDTH_BUCKETS = [ + 'under_110', + '110_to_143', + '144_to_199', + '200_plus', +] as const diff --git a/mods/diff/hooks/todos/todo-progress/index.ts b/mods/diff/hooks/todos/todo-progress/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ab3b873448b83edd36d62bd65eb7bbc178d38e6 --- /dev/null +++ b/mods/diff/hooks/todos/todo-progress/index.ts @@ -0,0 +1,3 @@ +export type * from './todo-progress.js' + +export * as default from '.' diff --git a/mods/diff/hooks/todos/todo-progress/todo-progress.ts b/mods/diff/hooks/todos/todo-progress/todo-progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..b053fa2cc52baca3b88e90d56a2c3b8aa9217df1 --- /dev/null +++ b/mods/diff/hooks/todos/todo-progress/todo-progress.ts @@ -0,0 +1,7 @@ +/** + * How far the session's todo list has come: completed items over all. + */ +export type TodoProgress = { + done: number + total: number +} diff --git a/mods/diff/hooks/turns/turn-of/file-edit-of/file-edit-of.ts b/mods/diff/hooks/turns/turn-of/file-edit-of/file-edit-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a68d229ff38358f31cb6bbac6e838147141f39e --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/file-edit-of/file-edit-of.ts @@ -0,0 +1,53 @@ +import { countOf } from '../../../count-of' +import { isRecord } from '../../../is-record' +import type Types from '../../types' +import { hunksOf } from './hunks-of' + +/** + * One Edit or Write result as the file it changed, told as the built-in + * turn view tells an edit result; null for any other result. + * + * A `filePath` with a non-empty `structuredPatch`, or a `create` with its + * `content`, which becomes one all-`+` hunk. + * + * @param result what the transcript stored for the tool call + * @returns the file's edit, or null + */ +export function fileEditOf(result: unknown): Types.TurnFile | null { + if (!isRecord(result) || typeof result.filePath !== 'string') { + return null + } + + const hunks = hunksOf(result.structuredPatch) + const isNewFile = result.type === 'create' + + if (hunks.length > 0) { + const lines = hunks.flatMap(hunk => hunk.lines) + + return { + path: result.filePath, + hunks, + added: countOf(lines, line => line.startsWith('+')), + removed: countOf(lines, line => line.startsWith('-')), + isNewFile, + isTruncated: false, + } + } + + if (!isNewFile || typeof result.content !== 'string') { + return null + } + + const created = result.content.split('\n') + + return { + path: result.filePath, + hunks: [ + { oldStart: 0, newStart: 1, lines: created.map(line => `+${line}`) }, + ], + added: created.length, + removed: 0, + isNewFile: true, + isTruncated: false, + } +} diff --git a/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/hunks-of.ts b/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/hunks-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec1967e6fb22d0dfe356f03dc56226b931a0fd33 --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/hunks-of.ts @@ -0,0 +1,34 @@ +import { arrayOf } from '../../../../array-of' +import type Git from '../../../../git' +import { isRecord } from '../../../../is-record' + +/** + * A tool result's `structuredPatch` as hunks, keeping only entries shaped + * like one (numeric starts, string lines). + * + * @param patch the result's `structuredPatch` field + * @returns the well-formed hunks; empty when the field is not an array + */ +export const hunksOf = (patch: unknown): Git.Hunk[] => + arrayOf(patch) + .filter(isRecord) + .flatMap(hunk => { + const { oldStart, newStart, lines } = hunk + + const isHunk = + typeof oldStart === 'number' && + typeof newStart === 'number' && + Array.isArray(lines) + + return isHunk + ? [ + { + oldStart, + newStart, + lines: arrayOf(lines).filter( + (line): line is string => typeof line === 'string', + ), + }, + ] + : [] + }) diff --git a/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/index.ts b/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a106cc06dacebcdda2e863e6a1bab99bbe335ce6 --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/file-edit-of/hunks-of/index.ts @@ -0,0 +1,3 @@ +export * from './hunks-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-of/file-edit-of/index.ts b/mods/diff/hooks/turns/turn-of/file-edit-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b03ef90492eb571b6ad20b1dfc2fda68c580c0c4 --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/file-edit-of/index.ts @@ -0,0 +1,4 @@ +export * from './file-edit-of.js' +export * from './hunks-of' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-of/index.ts b/mods/diff/hooks/turns/turn-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..33f8048a19db3a8efc411e362b4d2c35ba9022c2 --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/index.ts @@ -0,0 +1,6 @@ +export * from './file-edit-of' +export * from './is-prompt-row' +export * from './preview-chars' +export * from './turn-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-of/is-prompt-row/index.ts b/mods/diff/hooks/turns/turn-of/is-prompt-row/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..486694e91d0025f7eb03249e6ee044e6b40d7042 --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/is-prompt-row/index.ts @@ -0,0 +1,3 @@ +export * from './is-prompt-row.js' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-of/is-prompt-row/is-prompt-row.ts b/mods/diff/hooks/turns/turn-of/is-prompt-row/is-prompt-row.ts new file mode 100644 index 0000000000000000000000000000000000000000..14155f7e06f9280b9cb7a34ef2ed62ae2d204f4c --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/is-prompt-row/is-prompt-row.ts @@ -0,0 +1,13 @@ +import type { SessionMessage } from 'claude-code' + +/** + * Whether a transcript row starts a turn: a user message that carries text + * and no tool results (`$.session.messages()` already leaves out meta rows). + * + * @param message one row of the transcript + * @returns whether it is a typed prompt + */ +export const isPromptRow = (message: SessionMessage) => + message.role === 'user' && + (message.toolResults === undefined || message.toolResults.length === 0) && + message.text !== '' diff --git a/mods/diff/hooks/turns/turn-of/preview-chars/index.ts b/mods/diff/hooks/turns/turn-of/preview-chars/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..114ed80bbc804877e46abaf14f235e6896e539ca --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/preview-chars/index.ts @@ -0,0 +1,3 @@ +export * from './preview-chars.js' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/turn-of/preview-chars/preview-chars.ts b/mods/diff/hooks/turns/turn-of/preview-chars/preview-chars.ts new file mode 100644 index 0000000000000000000000000000000000000000..883d6038cb3237b9ca16f6187ec077c07958721f --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/preview-chars/preview-chars.ts @@ -0,0 +1,5 @@ +/** + * How many characters of a turn's prompt its source label keeps, as the + * built-in turn view does: 30, an ellipsis standing for the rest. + */ +export const PREVIEW_CHARS = 30 diff --git a/mods/diff/hooks/turns/turn-of/turn-of.ts b/mods/diff/hooks/turns/turn-of/turn-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..dac2c73afc15f37af51d7bd4005e34705dc3d4fd --- /dev/null +++ b/mods/diff/hooks/turns/turn-of/turn-of.ts @@ -0,0 +1,61 @@ +import type { SessionMessage } from 'claude-code' + +import Git from '../../git' +import type Types from '../types' +import { fileEditOf } from './file-edit-of' +import { PREVIEW_CHARS } from './preview-chars' + +/** + * One turn's rows (its prompt first) as the files its tool calls edited, + * as the built-in turn view reads them. + * + * A file edited twice keeps both calls' hunks (cut at MAX_LINES_PER_FILE + * in all) and the summed counts, and one created then edited stays new. + * + * @param rows the prompt row through the last row before the next prompt + * @param index the turn's number, from 1 + * @returns the turn; `files` empty when nothing was edited + */ +export function turnOf( + rows: readonly SessionMessage[], + index: number, +): Types.TurnDiff { + const text = rows[0]?.text ?? '' + const isShort = [...text].length <= PREVIEW_CHARS + + const preview = isShort + ? text + : `${[...text].slice(0, PREVIEW_CHARS - 1).join('')}…` + + const edits = rows + .flatMap(row => (row.role === 'assistant' ? row.toolUses : [])) + .map(use => fileEditOf(use.result)) + .filter((edit): edit is Types.TurnFile => edit !== null) + + const byPath = new Map() + + for (const edit of edits) { + const earlier = byPath.get(edit.path) + + byPath.set( + edit.path, + earlier + ? { + path: edit.path, + hunks: [...earlier.hunks, ...edit.hunks], + added: earlier.added + edit.added, + removed: earlier.removed + edit.removed, + isNewFile: earlier.isNewFile || edit.isNewFile, + isTruncated: false, + } + : edit, + ) + } + + const files = [...byPath.values()].map(file => ({ + ...file, + ...Git.cutHunks(file.hunks), + })) + + return { index, preview, files } +} diff --git a/mods/diff/hooks/turns/types/index.ts b/mods/diff/hooks/turns/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c5ba82bf74441b09abd638e3a379a530434c147 --- /dev/null +++ b/mods/diff/hooks/turns/types/index.ts @@ -0,0 +1,4 @@ +export type * from './turn-diff.js' +export * from './turn-file' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/types/turn-diff.ts b/mods/diff/hooks/turns/types/turn-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac0bd7b60424e6ce91838e17e4da3a558b763d90 --- /dev/null +++ b/mods/diff/hooks/turns/types/turn-diff.ts @@ -0,0 +1,11 @@ +import type { TurnFile } from './turn-file' + +/** + * The file edits of one turn: a prompt the person typed through the tool + * results that followed it, numbered from 1 in transcript order. + */ +export type TurnDiff = { + index: number + preview: string + files: readonly TurnFile[] +} diff --git a/mods/diff/hooks/turns/types/turn-file/index.ts b/mods/diff/hooks/turns/types/turn-file/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..abb92a493729c703042eaf303731ee85c9b54e9a --- /dev/null +++ b/mods/diff/hooks/turns/types/turn-file/index.ts @@ -0,0 +1,3 @@ +export type * from './turn-file.js' + +export * as default from '.' diff --git a/mods/diff/hooks/turns/types/turn-file/turn-file.ts b/mods/diff/hooks/turns/types/turn-file/turn-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d1154b389a3e72435be02b00145f5a79c633303 --- /dev/null +++ b/mods/diff/hooks/turns/types/turn-file/turn-file.ts @@ -0,0 +1,18 @@ +import type Git from '../../../git' + +/** + * One file as a turn's edits left it: its hunks (cut at the pane's line + * cap), the counts over every line, and whether the turn created it. + */ +export type TurnFile = { + path: string + hunks: readonly Git.Hunk[] + added: number + removed: number + isNewFile: boolean + + /** + * Whether the hunks were cut at MAX_LINES_PER_FILE lines. + */ + isTruncated: boolean +} diff --git a/mods/diff/hooks/views/dialog-pane/dialog-entries-of/dialog-entries-of.ts b/mods/diff/hooks/views/dialog-pane/dialog-entries-of/dialog-entries-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5eb25867d79e1026fc5530827bf32cd86bfc86d --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-entries-of/dialog-entries-of.ts @@ -0,0 +1,24 @@ +import Git from '../../../git' +import PaneState from '../../../pane-state' +import Entries from '../../entries' + +/** + * The files the dialog lists, in its order: the picked turn's, or every + * file of the fetch, pre-session or not, sorted by its displayed name. + * + * @param model the pane's state + * @returns the entries + */ +export function dialogEntriesOf( + model: PaneState.PaneModel, +): readonly Entries.BodyEntry[] { + const turn = PaneState.pickedTurnOf(model) + + return turn + ? turn.files.map(Entries.turnEntryOf) + : [...(model.data?.files ?? [])] + .sort((a, b) => + Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)), + ) + .map(file => Entries.bodyEntryOf(file, model)) +} diff --git a/mods/diff/hooks/views/dialog-pane/dialog-entries-of/index.ts b/mods/diff/hooks/views/dialog-pane/dialog-entries-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7151916fe162f00ee44a0e240127d0bb9c5af453 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-entries-of/index.ts @@ -0,0 +1,3 @@ +export * from './dialog-entries-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts b/mods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..71090dc99109780f7f815220bbc6c1a5fb6f92a4 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-window-of/dialog-window-of.ts @@ -0,0 +1,23 @@ +import Limits from '../../../limits' +import type PaneState from '../../../pane-state' + +/** + * The first of the MAX_VISIBLE_FILES rows the dialog lists (DiffFileList's + * window): centred on the selected file, the first when none is, clamped. + * + * @param model the pane's state: the selection + * @param paths the listed files' paths, in order + * @returns the first listed row's index + */ +export const dialogWindowOf = ( + model: Pick, + paths: readonly string[], +): number => + Math.max( + 0, + Math.min( + paths.length - Limits.MAX_VISIBLE_FILES, + Math.max(0, paths.indexOf(model.selectedPath ?? '')) - + Math.floor(Limits.MAX_VISIBLE_FILES / 2), + ), + ) diff --git a/mods/diff/hooks/views/dialog-pane/dialog-window-of/index.ts b/mods/diff/hooks/views/dialog-pane/dialog-window-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50f3d1c39c22074e0ca8a3766d4c2baeaf670b6 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/dialog-window-of/index.ts @@ -0,0 +1,3 @@ +export * from './dialog-window-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/list-body-of/index.ts b/mods/diff/hooks/views/dialog-pane/list-body-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..96fa17f5336a67aa92873400d512f88a2e0a7e0e --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/list-body-of/index.ts @@ -0,0 +1,4 @@ +export * from './list-body-of.js' +export * from './list-place' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/list-body-of/list-body-of.ts b/mods/diff/hooks/views/dialog-pane/list-body-of/list-body-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..5273ab03ea1ff8d6adadadd5521c641301250a5f --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/list-body-of/list-body-of.ts @@ -0,0 +1,43 @@ +import type Git from '../../../git' +import type { ListPlace } from './list-place' + +/** + * The list's body as ReplDiffSidebarBody decides it: the empty state's + * hint, the too-many state, the only-noise state, or the session's rows. + * + * Strings are dim lines; rows are files to draw. + * + * @param place the fetch, its groups, the counts, the empty state + * @returns the lines and rows, in order + */ +export function listBodyOf( + place: ListPlace, +): readonly (string | Git.FileStat)[] { + const { data, partition, totals, empty } = place + + const hint = empty?.hint ?? null + + if (empty) { + return hint === null ? [] : [hint] + } + + const isTooMany = + data !== null && data.files.length === 0 && totals.filesCount > 0 + + if (isTooMany) { + return [ + 'Too many changed files to show diff', + 'Per-file diff is skipped above 500 files', + ] + } + + const isOnlyNoise = partition.shown.length === 0 && partition.noiseCount > 0 + + return isOnlyNoise + ? [ + 'Only tests and generated files changed', + 'Tests and generated files are hidden · click "show" above to view ' + + 'them', + ] + : partition.shown +} diff --git a/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/index.ts b/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc5d4cf1070e442211d5774070ebcd71120f6101 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/index.ts @@ -0,0 +1,3 @@ +export type * from './list-place.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/list-place.ts b/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/list-place.ts new file mode 100644 index 0000000000000000000000000000000000000000..e517bde232095c3da57c6c421f55ff5f323e9b3d --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/list-body-of/list-place/list-place.ts @@ -0,0 +1,13 @@ +import type Git from '../../../../git' +import type PaneState from '../../../../pane-state' + +/** + * What decides the list's body: the fetch, its grouped rows, the header's + * counts, and the empty state when there is one. + */ +export type ListPlace = { + data: Git.DiffData | null + partition: PaneState.Partition + totals: PaneState.HeaderTotals + empty: PaneState.EmptyState | null +} diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/foot-rows.ts b/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/foot-rows.ts new file mode 100644 index 0000000000000000000000000000000000000000..60dcdcc5b4524ce232a37dcafd510a9a11301639 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/foot-rows.ts @@ -0,0 +1,5 @@ +/** + * The rows the pre-session line takes at the pane's foot: a blank row, the + * line, and a blank row under it. + */ +export const FOOT_ROWS = 3 diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/index.ts b/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2718b9b52c13be4eab5b5b3decc988815c352bb7 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/foot-rows/index.ts @@ -0,0 +1,3 @@ +export * from './foot-rows.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/index.ts b/mods/diff/hooks/views/dialog-pane/message-pane-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a4704e07aa001d15671b4074182653d313a0f4b --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/index.ts @@ -0,0 +1,5 @@ +export * from './foot-rows' +export * from './message-pane' +export * from './message-pane-of.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane-of.tsx b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane-of.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2b2180b68c8b053570966ebf164c10cfa806eba --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane-of.tsx @@ -0,0 +1,85 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../../kit' +import Layout from '../../layout' +import { FOOT_ROWS } from './foot-rows' +import type { MessagePane } from './message-pane' + +/** + * The pane while a message stands in for the list (ReplDiffSidebar's + * centered body): the header, a blank row, the message, the pickers. + * + * With nothing listed below and room, the lines sit where the built-in + * centers them (half the spare rows above), the pickers under them; + * otherwise all stacks from the top, the pickers over the pre-session line. + * + * @param kit the elements, the width, the body's rows + * @param pane the header block, the message's lines, the pickers, the + * pre-session line, and what is listed under it + * @returns the pane's tree + */ +export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement { + const { Box, Text } = kit.ui + const { top, controls, earlier, rest } = pane + + const lines = pane.message.flatMap(text => + Layout.wrappedLines(Layout.sanitizeName(text), kit.columns), + ) + + const drawn = lines.map(line => {line}) + const controlRows = controls === null ? [] : [controls] + const earlierRows = earlier === null ? [] : [earlier] + + const isControlsAboveEarlier = + controlRows.length > 0 || earlierRows.length === 0 + + const aboveEarlier = isControlsAboveEarlier + ? controlRows + : [] + + const footRows = earlier === null ? 0 : FOOT_ROWS + const middleRows = kit.rows - top.length - 1 - footRows + const above = Math.floor((middleRows - lines.length) / 2) + + const isCentered = + rest.length === 0 && middleRows - above - lines.length >= controlRows.length + + if (!isCentered) { + return ( + + {[ + ...top, + , + ...drawn, + ...aboveEarlier, + ...earlierRows, + ...rest, + ]} + + ) + } + + const spacer = above > 0 ? [] : [] + + const foot = earlierRows.map(row => ( + + {row} + + )) + + return ( + + {[ + ...top, + , + + {[...spacer, ...drawn, ...controlRows]} + , + ...foot, + ]} + + ) +} diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/index.ts b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e64d47d274514bb6f166c7e821e0b3338d0da18 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/index.ts @@ -0,0 +1,3 @@ +export type * from './message-pane.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/message-pane.ts b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/message-pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d42adcd42b302a75eae76aa85bb7ac26a44d6e3 --- /dev/null +++ b/mods/diff/hooks/views/dialog-pane/message-pane-of/message-pane/message-pane.ts @@ -0,0 +1,13 @@ +import type { RenderElement } from 'claude-code' + +/** + * What stands around a message in the pane: the header block, the message, + * the pickers, the pre-session line, and what is listed under it. + */ +export type MessagePane = { + top: readonly RenderElement[] + message: readonly string[] + controls: RenderElement | null + earlier: RenderElement | null + rest: readonly RenderElement[] +} diff --git a/mods/diff/hooks/views/entries/detail-model-of/detail-model-of.ts b/mods/diff/hooks/views/entries/detail-model-of/detail-model-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6f21bf6c29c96c050e65a7c749c7fc6305650b2 --- /dev/null +++ b/mods/diff/hooks/views/entries/detail-model-of/detail-model-of.ts @@ -0,0 +1,24 @@ +import type PaneState from '../../../pane-state' +import type Detail from '../../detail' +import type { BodyEntry } from '../body-entry' + +/** + * A listed file as its detail draws it: the entry, whether it is the armed + * one, and the backend's words for its notes. + * + * @param entry the file + * @param model which file is armed, and the backend's words + * @returns the detail model + */ +export const detailModelOf = ( + entry: BodyEntry, + model: Pick, +): Detail.DetailModel => ({ + words: model.words, + path: entry.path, + displayPath: entry.displayPath, + isUntracked: entry.isUntracked, + isBinary: entry.isBinary, + body: entry.body, + isArmed: model.armedPath === entry.path, +}) diff --git a/mods/diff/hooks/views/sections/ask-key-of/ask-key-of.ts b/mods/diff/hooks/views/sections/ask-key-of/ask-key-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8354567305f1061b2430cad2ac2ce8b03c61cc7 --- /dev/null +++ b/mods/diff/hooks/views/sections/ask-key-of/ask-key-of.ts @@ -0,0 +1,10 @@ +import Layout from '../../layout' + +/** + * A file's ask Button address: the path made printable, under `ask:`, so + * each file's Button in the body has its own. + * + * @param path the file's path as git or the transcript gave it + * @returns the key + */ +export const askKeyOf = (path: string) => `ask:${Layout.sanitizeName(path)}` diff --git a/mods/diff/hooks/views/sections/ask-key-of/index.ts b/mods/diff/hooks/views/sections/ask-key-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d6b428c0434e9db57f7994cd291e0e0e5c637d1 --- /dev/null +++ b/mods/diff/hooks/views/sections/ask-key-of/index.ts @@ -0,0 +1,3 @@ +export * from './ask-key-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx b/mods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea0eefa982490247594db87e761fb0a496179330 --- /dev/null +++ b/mods/diff/hooks/views/sections/dialog-file-row/dialog-file-row.tsx @@ -0,0 +1,58 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import Limits from '../../../limits' +import type { Kit } from '../../kit' +import Layout from '../../layout' +import { diffStat } from '../diff-stat' +import type { FileRowModel } from '../file-row-model' +import { listRow } from '../list-row' +import { POINTER } from '../pointer' + +/** + * One file of the inline list (DiffFileList FileItem): the pointer before + * the selected one, where the focus ring starts, the name, counts or note. + * + * @param kit the elements, the handlers, the width + * @param row the file + * @param onPress selects the file + * @returns the row element + */ +export function dialogFileRow( + kit: Kit, + row: FileRowModel, + onPress: () => void, +): RenderElement { + const { Text } = kit.ui + + const room = Math.max( + Limits.DIALOG_PATH_FLOOR, + kit.columns - Limits.DIALOG_PATH_RESERVE, + ) + + const mark = row.isSelected ? POINTER : ' ' + const name = Layout.truncateStart(Layout.sanitizeName(row.displayPath), room) + + const note = ( + + {row.note ?? ''} + + ) + + const hasNote = row.note !== null + const tail = hasNote ? note : diffStat(kit, row.added, row.removed) + + return listRow( + kit, + { + key: row.key, + label: `${mark} ${name}`, + onPress, + isDim: false, + isAutoFocus: row.isSelected, + }, + tail, + ) +} diff --git a/mods/diff/hooks/views/sections/dialog-file-row/index.ts b/mods/diff/hooks/views/sections/dialog-file-row/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e60674b3423f003e4a6ed6f70562a96eab8af98 --- /dev/null +++ b/mods/diff/hooks/views/sections/dialog-file-row/index.ts @@ -0,0 +1,3 @@ +export * from './dialog-file-row.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/diff-stat/diff-stat.tsx b/mods/diff/hooks/views/sections/diff-stat/diff-stat.tsx new file mode 100644 index 0000000000000000000000000000000000000000..423927e911b23c93d5c397a3013af990dd4be12f --- /dev/null +++ b/mods/diff/hooks/views/sections/diff-stat/diff-stat.tsx @@ -0,0 +1,32 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../../kit' + +/** + * `+N -M` in the diff's word colours, a side left out at zero and nothing + * at all at 0/0 (design-system DiffStat). + * + * @param kit the drawing's kit; its elements draw the counts + * @param added lines added + * @param removed lines removed + * @returns the inline element + */ +export function diffStat( + kit: Kit, + added: number, + removed: number, +): RenderElement { + const { Text } = kit.ui + const hasBoth = added > 0 && removed > 0 + + return ( + + {added > 0 ? {`+${added}`} : ''} + {hasBoth ? ' ' : ''} + {removed > 0 ? {`-${removed}`} : ''} + + ) +} diff --git a/mods/diff/hooks/views/sections/diff-stat/index.ts b/mods/diff/hooks/views/sections/diff-stat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..afaeb9c57bda991763deb504ea4fa4c184c923fb --- /dev/null +++ b/mods/diff/hooks/views/sections/diff-stat/index.ts @@ -0,0 +1,3 @@ +export * from './diff-stat.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/dim-note/dim-note.tsx b/mods/diff/hooks/views/sections/dim-note/dim-note.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0994309d75f2da51ba6e8bf4f20d6c4682c62571 --- /dev/null +++ b/mods/diff/hooks/views/sections/dim-note/dim-note.tsx @@ -0,0 +1,23 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../../kit' + +/** + * One dim line of the pane: a hint, an elision count, a base label. + * + * @param kit the drawing's kit; its elements draw the line + * @param text the line + * @returns the element + */ +export function dimNote(kit: Kit, text: string): RenderElement { + const { Text } = kit.ui + + return ( + + {text} + + ) +} diff --git a/mods/diff/hooks/views/sections/dim-note/index.ts b/mods/diff/hooks/views/sections/dim-note/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7adf3e29e8960e8ad23a316cccdc17a126eb9611 --- /dev/null +++ b/mods/diff/hooks/views/sections/dim-note/index.ts @@ -0,0 +1,3 @@ +export * from './dim-note.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/file-key-of/file-key-of.ts b/mods/diff/hooks/views/sections/file-key-of/file-key-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..c917b6680760f87e7c01f0280eb7258ea6b023e8 --- /dev/null +++ b/mods/diff/hooks/views/sections/file-key-of/file-key-of.ts @@ -0,0 +1,11 @@ +import type { BodyEntry } from '../../entries' +import Layout from '../../layout' + +/** + * A file row's Button address: the path made printable, under `file:`. + * + * @param entry the file, its path as git or the transcript gave it + * @returns the key + */ +export const fileKeyOf = (entry: Pick) => + `file:${Layout.sanitizeName(entry.path)}` diff --git a/mods/diff/hooks/views/sections/file-key-of/index.ts b/mods/diff/hooks/views/sections/file-key-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7db4eb43595a52900b5f31c2c149acf9356f60e --- /dev/null +++ b/mods/diff/hooks/views/sections/file-key-of/index.ts @@ -0,0 +1,3 @@ +export * from './file-key-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/file-row-model/file-row-model.ts b/mods/diff/hooks/views/sections/file-row-model/file-row-model.ts new file mode 100644 index 0000000000000000000000000000000000000000..482a71cc7107238133bcaf732dccbeb897325263 --- /dev/null +++ b/mods/diff/hooks/views/sections/file-row-model/file-row-model.ts @@ -0,0 +1,13 @@ +/** + * One listed file as its row draws it: the Button's address, the path and + * the name listed, the counts or (inline) a dim note for them, selection. + */ +export type FileRowModel = { + key: string + path: string + displayPath: string + added: number + removed: number + note: string | null + isSelected: boolean +} diff --git a/mods/diff/hooks/views/sections/file-row-model/index.ts b/mods/diff/hooks/views/sections/file-row-model/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bac45bae3de313dfa17c0347832cbe5b3e5f7a1 --- /dev/null +++ b/mods/diff/hooks/views/sections/file-row-model/index.ts @@ -0,0 +1,3 @@ +export type * from './file-row-model.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/file-row/file-row.ts b/mods/diff/hooks/views/sections/file-row/file-row.ts new file mode 100644 index 0000000000000000000000000000000000000000..afb792c04e80c6d76522d962a22baa6be38c1bf1 --- /dev/null +++ b/mods/diff/hooks/views/sections/file-row/file-row.ts @@ -0,0 +1,41 @@ +import type { RenderElement } from 'claude-code' + +import Limits from '../../../limits' +import type { Kit } from '../../kit' +import Layout from '../../layout' +import { diffStat } from '../diff-stat' +import type { FileRowModel } from '../file-row-model' +import { listRow } from '../list-row' +import { statTextOf } from '../stat-text-of' + +/** + * One file of the docked list (ReplDiffSidebar FileSummaryRow): the + * start-truncated name as a plain Button, its counts at the right. + * + * Nothing at the right at 0/0 (an untracked or binary file). The label is + * padded up to the counts, so the whole row before them presses. + * + * @param kit the elements, the handlers, the width + * @param row the file + * @param onPress scrolls the body to the file + * @returns the row element + */ +export function fileRow( + kit: Kit, + row: FileRowModel, + onPress: () => void, +): RenderElement { + const label = Layout.padEnd( + Layout.truncateStart( + Layout.sanitizeName(row.displayPath), + Math.max(kit.columns - Limits.STAT_CELLS, Limits.PATH_FLOOR), + ), + kit.columns - statTextOf(row.added, row.removed).length, + ) + + return listRow( + kit, + { key: row.key, label, onPress, isDim: true, isAutoFocus: false }, + diffStat(kit, row.added, row.removed), + ) +} diff --git a/mods/diff/hooks/views/sections/file-row/index.ts b/mods/diff/hooks/views/sections/file-row/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1394b6f679ef41984a7dea216f2c621142cf5e42 --- /dev/null +++ b/mods/diff/hooks/views/sections/file-row/index.ts @@ -0,0 +1,3 @@ +export * from './file-row.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/keyed-pressable/index.ts b/mods/diff/hooks/views/sections/keyed-pressable/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ddcdd6dd750d4986513433637d45cbe633a748ec --- /dev/null +++ b/mods/diff/hooks/views/sections/keyed-pressable/index.ts @@ -0,0 +1,3 @@ +export type * from './keyed-pressable.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts b/mods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts new file mode 100644 index 0000000000000000000000000000000000000000..7725f8d5669e05156454a863d42051768c022434 --- /dev/null +++ b/mods/diff/hooks/views/sections/keyed-pressable/keyed-pressable.ts @@ -0,0 +1,11 @@ +import type { Pressable } from '../pressable' + +/** + * A plain Button's face, press and address: a list row's Button, dim at + * rest where the built-in's is, the focus ring's start where selected. + */ +export type KeyedPressable = Pressable & { + key: string + isDim: boolean + isAutoFocus: boolean +} diff --git a/mods/diff/hooks/views/sections/list-edge-button/index.ts b/mods/diff/hooks/views/sections/list-edge-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..99509f2282da5fdcde8e656f9f5a6feb7c28a87e --- /dev/null +++ b/mods/diff/hooks/views/sections/list-edge-button/index.ts @@ -0,0 +1,3 @@ +export * from './list-edge-button.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx b/mods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6840f0fc69859e4f928b6c6eb9664c9bc681712e --- /dev/null +++ b/mods/diff/hooks/views/sections/list-edge-button/list-edge-button.tsx @@ -0,0 +1,36 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../../kit' + +/** + * The docked list's `more above` or `more below` row as a dim plain Button + * that moves its window a file, under the built-in's list keys too. + * + * @param kit the elements and the handlers + * @param edge which edge, the Button's key + * @param label the row's text + * @returns the Button + */ +export function listEdgeButton( + kit: Kit, + edge: 'list-up' | 'list-down', + label: string, +): RenderElement { + const { Button } = kit.ui + const isUp = edge === 'list-up' + + return ( + + ) +} diff --git a/mods/diff/hooks/views/sections/list-row/index.ts b/mods/diff/hooks/views/sections/list-row/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bb830e59ff68ca484597e8fbfe5df7b9ee53549 --- /dev/null +++ b/mods/diff/hooks/views/sections/list-row/index.ts @@ -0,0 +1,3 @@ +export * from './list-row.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/list-row/list-row.tsx b/mods/diff/hooks/views/sections/list-row/list-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3a50414cf31f82bb757e6b373a3e9a9c6470eaf1 --- /dev/null +++ b/mods/diff/hooks/views/sections/list-row/list-row.tsx @@ -0,0 +1,40 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type { Kit } from '../../kit' +import type { KeyedPressable } from '../keyed-pressable' + +/** + * One list row: a plain Button as wide as its label, a spacer, then the + * tail at the right edge (the counts, or a note). + * + * @param kit the elements + * @param row the Button's key, label, press, rest style and ring start + * @param tail what sits at the row's right edge + * @returns the row element + */ +export function listRow( + kit: Kit, + row: KeyedPressable, + tail: RenderElement, +): RenderElement { + const { Box, Button } = kit.ui + + return ( + + + + {tail} + + ) +} diff --git a/mods/diff/hooks/views/sections/name-row/index.ts b/mods/diff/hooks/views/sections/name-row/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3daf5106881f2b115018b04dd2bd22ecfc081e11 --- /dev/null +++ b/mods/diff/hooks/views/sections/name-row/index.ts @@ -0,0 +1,3 @@ +export * from './name-row.jsx' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/name-row/name-row.tsx b/mods/diff/hooks/views/sections/name-row/name-row.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af64d727edf21444501752a6c6dceb383cb8af54 --- /dev/null +++ b/mods/diff/hooks/views/sections/name-row/name-row.tsx @@ -0,0 +1,56 @@ +/* @jsxRuntime classic */ +/* @jsx h */ +/* @jsxFrag Fragment */ +import type { RenderElement } from 'claude-code' + +import type Detail from '../../detail' +import type { Kit } from '../../kit' +import Layout from '../../layout' +import { askKeyOf } from '../ask-key-of' + +/** + * A file's name row over its body (DiffDetailView's first line): the bold + * name cut from its start, its asides in dim, and its ask Button. + * + * The Button reads `asked ✓` while the file is armed; a file with no hunks + * to attach (a placeholder body) has none. + * + * @param kit the elements, the handlers, the width + * @param name the file, with whether it is truncated, armed and askable + * @returns the row + */ +export function nameRow(kit: Kit, name: Detail.NamedDetail): RenderElement { + const { Box, Text, Button } = kit.ui + + const asides = [ + name.isUntracked ? 'untracked' : null, + name.isTruncated ? 'truncated' : null, + ].filter(word => word !== null) + + const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})` + + const ask = ( + + ) + + return ( + + {[ + + {Layout.truncateStart( + Layout.sanitizeName(name.displayPath), + kit.columns, + )} + , + {aside}, + , + ...(name.isAskable ? [ask] : []), + ]} + + ) +} diff --git a/mods/diff/hooks/views/sections/pointer/index.ts b/mods/diff/hooks/views/sections/pointer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb89af57ff0454d801ae649d5869aad96953175f --- /dev/null +++ b/mods/diff/hooks/views/sections/pointer/index.ts @@ -0,0 +1,3 @@ +export * from './pointer.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/pointer/pointer.ts b/mods/diff/hooks/views/sections/pointer/pointer.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0e13e25f92cb71ca4b6d4ccb9230127f6374658 --- /dev/null +++ b/mods/diff/hooks/views/sections/pointer/pointer.ts @@ -0,0 +1,4 @@ +/** + * Drawn before the selected file's row (DiffFileList's pointer). + */ +export const POINTER = '❯' diff --git a/mods/diff/hooks/views/sections/pressable/index.ts b/mods/diff/hooks/views/sections/pressable/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d5fc84f834f24d1d3f0d74dadfb4c42f141fe65 --- /dev/null +++ b/mods/diff/hooks/views/sections/pressable/index.ts @@ -0,0 +1,3 @@ +export type * from './pressable.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/pressable/pressable.ts b/mods/diff/hooks/views/sections/pressable/pressable.ts new file mode 100644 index 0000000000000000000000000000000000000000..3585854fe48c72c51070ea2a2e44aee22df5db79 --- /dev/null +++ b/mods/diff/hooks/views/sections/pressable/pressable.ts @@ -0,0 +1,11 @@ +/** + * A plain Button's face and what pressing it does. + */ +export type Pressable = { + label: string + + /** + * Runs on a click or Enter. + */ + onPress: () => void +} diff --git a/mods/diff/hooks/views/sections/stat-text-of/index.ts b/mods/diff/hooks/views/sections/stat-text-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..11204da21eaf56271d97d348a1453f93e995f690 --- /dev/null +++ b/mods/diff/hooks/views/sections/stat-text-of/index.ts @@ -0,0 +1,3 @@ +export * from './stat-text-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/views/sections/stat-text-of/stat-text-of.ts b/mods/diff/hooks/views/sections/stat-text-of/stat-text-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5ff21ff2715307ec1b20d10117109c9682500ec --- /dev/null +++ b/mods/diff/hooks/views/sections/stat-text-of/stat-text-of.ts @@ -0,0 +1,15 @@ +import { keptOf } from '../../../kept-of' + +/** + * `+N -M` as diffStat draws it, as plain text: what a row measures to know + * where its counts start; empty at 0/0. + * + * @param added lines added + * @param removed lines removed + * @returns the text + */ +export const statTextOf = (added: number, removed: number) => + keptOf([ + added > 0 ? `+${added}` : null, + removed > 0 ? `-${removed}` : null, + ]).join(' ') diff --git a/mods/diff/tests/ask/fitted-ask-text-of.test.ts b/mods/diff/tests/ask/fitted-ask-text-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f98c6f48ec0db0382817753069b1b924d2682b66 --- /dev/null +++ b/mods/diff/tests/ask/fitted-ask-text-of.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Ask from '../../hooks/ask' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('fitted-ask-text-of', () => { + test('an ask that fits rides whole', () => { + expect( + Ask.fittedAskTextOf(Fixtures.ASK_TEXT, Fixtures.ASK_TEXT.length), + ).toBe(Fixtures.ASK_TEXT) + }) + + test('an ask past the room keeps its first lines and the cut note', () => { + const fitted = Ask.fittedAskTextOf( + Fixtures.ASK_TEXT, + Fixtures.ROOM_FOR_TWO_LINES, + ) + + expect(fitted).toBe( + `${Fixtures.ASK_LINES.slice(0, 2).join('\n')}\n${Ask.ASK_CUT_NOTE}`, + ) + + expect(fitted?.length).toBeLessThanOrEqual(Fixtures.ROOM_FOR_TWO_LINES) + }) + + test('an ask whose first hunk line does not fit is not attached', () => { + expect( + Ask.fittedAskTextOf(Fixtures.ASK_TEXT, Fixtures.ROOM_FOR_ONE_LINE), + ).toBeUndefined() + }) +}) diff --git a/mods/diff/tests/backend/backend-of.test.ts b/mods/diff/tests/backend/backend-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8103c7d1b7d20088949c1c67f28c6b1262d7c1e1 --- /dev/null +++ b/mods/diff/tests/backend/backend-of.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Backend from '../../hooks/backend' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('backend-of', () => { + test('as published: no probes, so git or nothing', async () => { + const inGit = Fixtures.gitHostOf() + const outside = Fixtures.recordingBackendHostOf({}) + + expect((await Backend.backendOf(inGit.host, []))?.words.lister).toBe('git') + expect(await Backend.backendOf(outside.host, [])).toBeNull() + + expect( + [inGit.programs, outside.programs], + 'pinning spawns the one rev-parse and walks nothing', + ).toEqual([['git'], ['git']]) + }) + + test('a declining probe leaves a git working tree to git', async () => { + const declining = Fixtures.recordingProbeOf(null) + const inGit = Fixtures.gitHostOf() + const backend = await Backend.backendOf(inGit.host, [declining.probe]) + + expect(backend?.words.lister).toBe('git') + expect(backend?.baseModes).toEqual(['session', 'uncommitted', 'branch']) + expect(declining.asked).toEqual([inGit.host]) + expect(inGit.programs).toEqual(['git']) + }) + + test('a probe that answers pins before git is asked', async () => { + const inGit = Fixtures.gitHostOf() + + const backend = await Backend.backendOf(inGit.host, [ + Fixtures.recordingProbeOf('/home/me/src').probe, + ]) + + expect(backend?.repository).toEqual({ toplevel: '/home/me/src' }) + expect(inGit.programs).toEqual([]) + }) + + test('the probes are asked in order and the first answer pins', async () => { + const declining = Fixtures.recordingProbeOf(null) + const first = Fixtures.recordingProbeOf('/first') + const second = Fixtures.recordingProbeOf('/second') + const { host } = Fixtures.recordingBackendHostOf({}) + + const backend = await Backend.backendOf(host, [ + declining.probe, + first.probe, + second.probe, + ]) + + expect(backend?.repository).toEqual({ toplevel: '/first' }) + + expect([ + declining.asked.length, + first.asked.length, + second.asked.length, + ]).toEqual([1, 1, 0]) + + expect(declining.asked[0]).toBe(host) + }) +}) diff --git a/mods/diff/tests/classify/is-noise-file.test.ts b/mods/diff/tests/classify/is-noise-file.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a1fd54f5eaa17f5290ece839df227c7c2cdb91a --- /dev/null +++ b/mods/diff/tests/classify/is-noise-file.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Classify from '../../hooks/classify' + +tier('builtin') + +describe('is-noise-file', () => { + test('tests, fixtures and snapshots are noise', () => { + for (const path of [ + 'test/a.ts', + 'src/x/__tests__/y.ts', + 'lib/foo.test.tsx', + 'pkg/bar_test.go', + 'web/fixtures/data.json', + 'ui/__snapshots__/a.snap', + ]) { + expect(Classify.isNoiseFile(path), path).toBe(true) + } + }) + + test('lockfiles, builds, minified and generated files are noise', () => { + for (const path of [ + 'bun.lock', + 'package-lock.json', + 'dist/index.js', + 'assets/app.min.js', + 'types/api.d.ts', + 'src/constants/prompts.generated/x.ts', + 'proto/service.pb.go', + ]) { + expect(Classify.isNoiseFile(path), path).toBe(true) + } + }) + + test('source is not, even with test inside a longer word', () => { + for (const path of ['src/contest/entry.ts', 'src/latest.ts', 'README.md']) { + expect(Classify.isNoiseFile(path), path).toBe(false) + } + }) +}) diff --git a/mods/diff/tests/fixtures/answers-of.ts b/mods/diff/tests/fixtures/answers-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5ba319c5893d3521597e3cf47f4e73491c2033c --- /dev/null +++ b/mods/diff/tests/fixtures/answers-of.ts @@ -0,0 +1,27 @@ +/** + * Git's output in /work for each invocation whose command line holds the + * key, over the given `--numstat -z` records and per-file bodies. + * + * `status` lists the paths dirty at the start (the baseline: every path + * with a body unless given); a body is keyed by the `-- ` its diff is + * asked with. + * + * @param numstat the `--numstat -z` records, joined + * @param bodies each path's unified diff after its header + * @param dirty the paths `status` lists as dirty when the session starts + * @returns the answers by key + */ +export const answersOf = ( + numstat: string, + bodies: Readonly>, + dirty: readonly string[] = Object.keys(bodies), +): Readonly> => ({ + 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n', + 'status --porcelain': dirty.map(path => ` M ${path}\0`).join(''), + 'HEAD --shortstat': ` ${Object.keys(bodies).length} files changed`, + 'HEAD --numstat': numstat, + 'ls-files': '', + ...Object.fromEntries( + Object.entries(bodies).map(([path, body]) => [`-- ${path}`, body]), + ), +}) diff --git a/mods/diff/tests/fixtures/big-lines.ts b/mods/diff/tests/fixtures/big-lines.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef3e047ce74e9b182c1a531aa817d868f580ed3f --- /dev/null +++ b/mods/diff/tests/fixtures/big-lines.ts @@ -0,0 +1,4 @@ +/** + * A hunk length past what one turn's diff keeps whole. + */ +export const BIG_LINES = 1000 diff --git a/mods/diff/tests/fixtures/builtin-holds.ts b/mods/diff/tests/fixtures/builtin-holds.ts new file mode 100644 index 0000000000000000000000000000000000000000..58f081c8e98c13cf3fa3a3934714661d2b144250 --- /dev/null +++ b/mods/diff/tests/fixtures/builtin-holds.ts @@ -0,0 +1,4 @@ +/** + * How `$.command.register` refuses `/diff` while the built-in one is listed. + */ +export const BUILTIN_HOLDS = '"/diff" refused: it is the built-in /diff' diff --git a/mods/diff/tests/fixtures/checkout.ts b/mods/diff/tests/fixtures/checkout.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6e0c6c01a8ef9affcc60207404ae6ced374dd3b --- /dev/null +++ b/mods/diff/tests/fixtures/checkout.ts @@ -0,0 +1,11 @@ +import type Git from '../../hooks/git' + +/** + * Where every model and view fixture sits: a plain checkout at `/r` whose + * git dir is its common dir. + */ +export const CHECKOUT: Git.Repository = { + toplevel: '/r', + gitDir: '/r/.git', + commonDir: '/r/.git', +} diff --git a/mods/diff/tests/fixtures/clear.ts b/mods/diff/tests/fixtures/clear.ts new file mode 100644 index 0000000000000000000000000000000000000000..05ffd4f8a3a7021a4df1ad448ce611df4275a0bb --- /dev/null +++ b/mods/diff/tests/fixtures/clear.ts @@ -0,0 +1,11 @@ +import type { CommandRunInput } from 'claude-code' + +/** + * The command that starts the conversation over, as the person types it. + */ +export const CLEAR: CommandRunInput = { + command: 'clear', + args: '', + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns: 160 }, +} diff --git a/mods/diff/tests/fixtures/dialog-diff.ts b/mods/diff/tests/fixtures/dialog-diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..780d06cda2bea2d21ebb5a4bd257a54e9e4dfb54 --- /dev/null +++ b/mods/diff/tests/fixtures/dialog-diff.ts @@ -0,0 +1,12 @@ +import type { CommandRunInput } from 'claude-code' + +import { DIFF } from './diff.js' + +/** + * The command as the person types it on the main screen (no fullscreen + * layout), where the built-in answers with its dialog. + */ +export const DIALOG_DIFF: CommandRunInput = { + ...DIFF, + presentation: { isFullscreen: false, columns: 100 }, +} diff --git a/mods/diff/tests/fixtures/diff-at.ts b/mods/diff/tests/fixtures/diff-at.ts new file mode 100644 index 0000000000000000000000000000000000000000..43d8bff5c2c1106f3be5ccd61795ac2809d4bdca --- /dev/null +++ b/mods/diff/tests/fixtures/diff-at.ts @@ -0,0 +1,15 @@ +import type { CommandRunInput } from 'claude-code' + +import { DIFF } from './diff.js' + +/** + * The command typed under the fullscreen layout on a terminal so many + * columns wide, as its presentation tells the hooks. + * + * @param columns the terminal's width + * @returns the command's input + */ +export const diffAt = (columns: number): CommandRunInput => ({ + ...DIFF, + presentation: { isFullscreen: true, columns }, +}) diff --git a/mods/diff/tests/fixtures/diff.ts b/mods/diff/tests/fixtures/diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c9eb41ea531445ff038a2fc0a3564c0641374b4 --- /dev/null +++ b/mods/diff/tests/fixtures/diff.ts @@ -0,0 +1,12 @@ +import type { CommandRunInput } from 'claude-code' + +/** + * The command as the person types it, with no arguments, in the fullscreen + * layout on a 160-column terminal. + */ +export const DIFF: CommandRunInput = { + command: 'diff', + args: '', + origin: { kind: 'composer' }, + presentation: { isFullscreen: true, columns: 160 }, +} diff --git a/mods/diff/tests/fixtures/edit-patch-of.ts b/mods/diff/tests/fixtures/edit-patch-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6fff5a156a0f898e7aebfeb45c23ee26e2257af --- /dev/null +++ b/mods/diff/tests/fixtures/edit-patch-of.ts @@ -0,0 +1,12 @@ +/** + * An Edit tool's result record holding one hunk of one file. + * + * @param lines the hunk's lines, `-`/`+`/space prefixed + * @returns the record as the transcript stores it + */ +export const editPatchOf = (lines: string[]) => ({ + filePath: '/r/a.ts', + structuredPatch: [ + { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines }, + ], +}) diff --git a/mods/diff/tests/fixtures/git-hung.ts b/mods/diff/tests/fixtures/git-hung.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2c27a595e75faefe5dd859b8e7c93a857dbd7b5 --- /dev/null +++ b/mods/diff/tests/fixtures/git-hung.ts @@ -0,0 +1,7 @@ +import Limits from '../../hooks/limits' + +/** + * How `$.process.run` refuses a git that outran its timeout. + */ +export const GIT_HUNG = + 'git aborted: still running after ' + `${Limits.GIT_TIMEOUT_MS}ms` diff --git a/mods/diff/tests/fixtures/git-in.ts b/mods/diff/tests/fixtures/git-in.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a63b69fae43c3e753cdb71f41d96c09d33b3428 --- /dev/null +++ b/mods/diff/tests/fixtures/git-in.ts @@ -0,0 +1,27 @@ +import type { ProcessRunResult } from 'claude-code' + +import { NOT_A_REPOSITORY } from './not-a-repository.js' +import { REPOSITORY } from './repository.js' + +/** + * What git answers from a script of outputs by command-line key, REPOSITORY + * when none is given. + * + * An invocation the script does not know fails as git does outside a + * repository. + * + * @param argv the invocation, program first + * @param script git's output for each invocation whose line holds the key + * @returns git's exit code and output + */ +export function gitIn( + argv: readonly string[], + script: Readonly> = REPOSITORY, +): ProcessRunResult { + const line = argv.join(' ') + const found = Object.entries(script).find(([key]) => line.includes(key)) + + return found + ? { exitCode: 0, stdout: found[1], stderr: '' } + : NOT_A_REPOSITORY +} diff --git a/mods/diff/tests/fixtures/git-word-of.ts b/mods/diff/tests/fixtures/git-word-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8a06509afeaacc7af0a7f483c66873f546def10 --- /dev/null +++ b/mods/diff/tests/fixtures/git-word-of.ts @@ -0,0 +1,36 @@ +/** + * One git invocation named by what it asks: its subcommand, and its kind. + * + * For a `diff` what kind (`--shortstat`, `--numstat`, or the path its body + * is asked for), for a `rev-parse` whether it finds the repository + * (`--show-toplevel`) or reads `HEAD` (the poll's fallback where no file + * answers), so a test reads a session's spawns as the built-in's list. + * + * @param argv the invocation, program first, any pinned lead after it + * @returns `rev-parse --show-toplevel`, `status`, `diff --numstat`, ... + */ +export function gitWordOf(argv: readonly string[]) { + const [subcommand = '?'] = argv.filter( + (word, at) => at > 0 && !word.startsWith('-') && argv[at - 1] !== '-c', + ) + + const dashes = argv.indexOf('--') + + if (subcommand === 'rev-parse') { + return argv.includes('--show-toplevel') + ? 'rev-parse --show-toplevel' + : 'rev-parse HEAD' + } + + if (subcommand !== 'diff') { + return subcommand + } + + if (dashes !== -1) { + return `diff -- ${argv.slice(dashes + 1).join(' ')}` + } + + const stat = argv.find(word => word === '--shortstat' || word === '--numstat') + + return stat ? `diff ${stat}` : 'diff' +} diff --git a/mods/diff/tests/fixtures/hint-at.ts b/mods/diff/tests/fixtures/hint-at.ts new file mode 100644 index 0000000000000000000000000000000000000000..485a149ff462aace60b0fe2fb1b17c0272f00aae --- /dev/null +++ b/mods/diff/tests/fixtures/hint-at.ts @@ -0,0 +1,19 @@ +import type { RenderInput } from 'claude-code' + +import { HINT } from './hint.js' + +/** + * The prompt's hint on a fullscreen terminal so many columns wide: drawing + * it is how the plugin learns the width. + * + * @param columns the terminal's width + * @returns the `ui.render` input + */ +export const hintAt = (columns: number): RenderInput<'PromptHint'> => ({ + ...HINT, + viewport: { + columns, + rows: HINT.viewport?.rows ?? columns, + isFullscreen: true, + }, +}) diff --git a/mods/diff/tests/fixtures/hint-drawn.ts b/mods/diff/tests/fixtures/hint-drawn.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9dd87da6abc93f9d1f4ca380f4b472c34eb108a --- /dev/null +++ b/mods/diff/tests/fixtures/hint-drawn.ts @@ -0,0 +1,9 @@ +import type { RenderElement } from 'claude-code' + +/** + * What the engine draws for the hint, standing in beneath the plugin. + */ +export const HINT_DRAWN: RenderElement = { + type: 'Text', + children: ['? for shortcuts'], +} diff --git a/mods/diff/tests/fixtures/hint.ts b/mods/diff/tests/fixtures/hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..1205ef8c4a19c6eb21f1c789799a6b48281e2d18 --- /dev/null +++ b/mods/diff/tests/fixtures/hint.ts @@ -0,0 +1,14 @@ +import type { RenderInput } from 'claude-code' + +/** + * The prompt's hint on a 160-column terminal under the fullscreen layout: + * drawing it is how the plugin learns how wide the terminal is and whether a + * pane docks beside the transcript there. + */ +export const HINT: RenderInput<'PromptHint'> = { + component: 'PromptHint', + surface: 'terminal', + requestId: 'hint', + viewport: { columns: 160, rows: 40, isFullscreen: true }, + props: { isDraft: false, isWorking: false, hint: '' }, +} diff --git a/mods/diff/tests/fixtures/in-repository.ts b/mods/diff/tests/fixtures/in-repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..4310190166c2d6c404b5369e4c5c781ca1258bb3 --- /dev/null +++ b/mods/diff/tests/fixtures/in-repository.ts @@ -0,0 +1,69 @@ +import type { Args, On } from 'claude-code' +import { mock } from 'claude-code/testing' + +import { gitIn } from './git-in.js' +import { HINT_DRAWN } from './hint-drawn.js' +import { keeping } from './keeping.js' +import { REPOSITORY } from './repository.js' +import { startsSession } from './starts-session.js' + +/** + * A session in a repository git answers for from a script (REPOSITORY, in + * /work, when none is given), keeping what the plugin does there. + * + * Kept: each git run, ring move, pane opened or closed, status line. The clock + * starts at 0 and the engine draws the hint. A test that rewrites the + * script between calls changes what git answers next. + * + * @param on the test's `on` + * @param script git's output for each invocation whose line holds the key + * @param stored what the plugin's store holds at the start + * @returns the runs, the ring's moves, the panes opened and closed, the + * statuses, the clock + */ +export function inRepository( + on: On, + script: Readonly> = REPOSITORY, + stored: Readonly> = {}, +) { + const runs: Args<'process.run'>[] = [] + const focused: Args<'ui.focus'>[] = [] + const statuses: (string | undefined)[] = [] + const opened = keeping>() + const closed = keeping>() + const clock = startsSession(on) + + on('process.run', ($, e) => { + runs.push(e) + + return { value: gitIn(e.argv, script) } + }) + + on('ui.focus', (_engine, e) => { + focused.push(e) + + return {} + }) + + on('ui.status', ($, e) => { + statuses.push(e.text) + + return { value: undefined } + }) + + on('ui.open', opened.hook) + on('ui.close', closed.hook) + on('ui.invalidate', () => ({ value: undefined })) + on('ui.render', { component: 'PromptHint' }, () => HINT_DRAWN) + on('session.messages', () => ({ value: [] })) + mock.store(on, stored) + + return { + runs, + focused, + opened: opened.kept, + closed: closed.kept, + statuses, + clock, + } +} diff --git a/mods/diff/tests/fixtures/index.ts b/mods/diff/tests/fixtures/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa75150711ec11ccb9815eb034d681d53da5298b --- /dev/null +++ b/mods/diff/tests/fixtures/index.ts @@ -0,0 +1,62 @@ +export * from './answers-of.js' +export * from './ask' +export * from './backend-of' +export * from './big-lines.js' +export * from './branch-base-of' +export * from './builtin-holds.js' +export * from './checkout.js' +export * from './clear.js' +export * from './code-blocks-of' +export * from './datings-of' +export * from './dialog-diff.js' +export * from './diff.js' +export * from './diff-at.js' +export * from './edit-patch-of.js' +export * from './fetch-diff' +export * from './git-hung.js' +export * from './git-in.js' +export * from './git-word-of.js' +export * from './head-key-of' +export * from './header-totals-of' +export * from './hint.js' +export * from './hint-at.js' +export * from './hint-drawn.js' +export * from './in-repository.js' +export * from './inline-pane.js' +export * from './keeping.js' +export * from './kind-walk-of' +export * from './main-screen-hint.js' +export * from './many-file-count.js' +export * from './many-files.js' +export * from './mode-label-of' +export * from './moved-in.js' +export * from './narrow-inline-pane.js' +export * from './no-stats.js' +export * from './not-a-repository.js' +export * from './old-files.js' +export * from './pane.js' +export * from './pane-view' +export * from './parse-file-diff' +export * from './poll-word.js' +export * from './renamed.js' +export * from './repository.js' +export * from './repository-of' +export * from './ring-onto.js' +export * from './scripted' +export * from './session.js' +export * from './settle-ms.js' +export * from './starts-session.js' +export * from './todo-write-of.js' +export * from './tree' +export * from './truncate-start' +export * from './turn-diffs-of' +export * from './two-files.js' +export * from './typed-prompt-of.js' +export * from './unsaid-hint.js' +export * from './vs-main.js' +export * from './wheel-over-list.js' +export * from './wheel-tick.js' +export * from './worktree' +export * from './wrapped-lines' + +export * as default from '.' diff --git a/mods/diff/tests/fixtures/inline-pane.ts b/mods/diff/tests/fixtures/inline-pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..7acab4a517c6de763f8ca26303ee6640d4c055d9 --- /dev/null +++ b/mods/diff/tests/fixtures/inline-pane.ts @@ -0,0 +1,12 @@ +import type { RenderInput } from 'claude-code' + +import { PANE } from './pane.js' + +/** + * The diff pane seated inline above the prompt on a 160-column terminal, + * as a session without the fullscreen layout seats it. + */ +export const INLINE_PANE: RenderInput<'Pane'> = { + ...PANE, + props: { ...PANE.props, placement: 'inline', bodyColumns: 156 }, +} diff --git a/mods/diff/tests/fixtures/keeping.ts b/mods/diff/tests/fixtures/keeping.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d83ec88356b672c02c64eea8b27e21d9e60d43c --- /dev/null +++ b/mods/diff/tests/fixtures/keeping.ts @@ -0,0 +1,17 @@ +/** + * A hook that keeps each input it is asked with, and answers, beside what + * it kept. + * + * @returns the hook, and each input in the order it came + */ +export function keeping() { + const kept: E[] = [] + + function hook(_engine: unknown, e: E) { + kept.push(e) + + return { value: undefined } + } + + return { hook, kept } +} diff --git a/mods/diff/tests/fixtures/main-screen-hint.ts b/mods/diff/tests/fixtures/main-screen-hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..fab2a36fd653fec2d5d78edb736ecb02c47dc9cd --- /dev/null +++ b/mods/diff/tests/fixtures/main-screen-hint.ts @@ -0,0 +1,12 @@ +import type { RenderInput } from 'claude-code' + +import { HINT } from './hint.js' + +/** + * The prompt's hint on the same wide terminal on its main screen + * (`CLAUDE_CODE_NO_FLICKER=0`), where nothing docks and a pane opens inline. + */ +export const MAIN_SCREEN_HINT: RenderInput<'PromptHint'> = { + ...HINT, + viewport: { columns: 160, rows: 40, isFullscreen: false }, +} diff --git a/mods/diff/tests/fixtures/many-file-count.ts b/mods/diff/tests/fixtures/many-file-count.ts new file mode 100644 index 0000000000000000000000000000000000000000..25abf20b1af5ee3e876c232f6614064a3134e3f5 --- /dev/null +++ b/mods/diff/tests/fixtures/many-file-count.ts @@ -0,0 +1,5 @@ +/** + * How many files MANY_FILES changes: two more than the docked list shows at + * once. + */ +export const MANY_FILE_COUNT = 10 diff --git a/mods/diff/tests/fixtures/many-files.ts b/mods/diff/tests/fixtures/many-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..1096139704ff86e58d2070bb496569db277d73bb --- /dev/null +++ b/mods/diff/tests/fixtures/many-files.ts @@ -0,0 +1,19 @@ +import { answersOf } from './answers-of.js' +import { MANY_FILE_COUNT } from './many-file-count.js' + +/** + * Git's output in /work where MANY_FILE_COUNT files changed a line each: + * more rows than the docked list shows at once. + */ +export const MANY_FILES = answersOf( + Array.from( + { length: MANY_FILE_COUNT }, + (_, at) => `1\t1\tfile${at}.ts\0`, + ).join(''), + Object.fromEntries( + Array.from({ length: MANY_FILE_COUNT }, (_, at) => [ + `file${at}.ts`, + `@@ -1 +1 @@\n-const v = ${at}\n+const v = ${at + 1}\n`, + ]), + ), +) diff --git a/mods/diff/tests/fixtures/moved-in.ts b/mods/diff/tests/fixtures/moved-in.ts new file mode 100644 index 0000000000000000000000000000000000000000..43a239ca3f8af5d7e3aeb46a8aa0501f5987ff09 --- /dev/null +++ b/mods/diff/tests/fixtures/moved-in.ts @@ -0,0 +1,14 @@ +import { answersOf } from './answers-of.js' + +/** + * Git's output in /work where two old files differ from HEAD: one dirty + * before the session began, one moved into place since (not dirty then). + */ +export const MOVED_IN = answersOf( + '1\t1\told.ts\0' + '1\t1\tmoved.ts\0', + { + 'old.ts': '@@ -1 +1 @@\n-a\n+b\n', + 'moved.ts': '@@ -1 +1 @@\n-c\n+d\n', + }, + ['old.ts'], +) diff --git a/mods/diff/tests/fixtures/narrow-inline-pane.ts b/mods/diff/tests/fixtures/narrow-inline-pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..f17e77cb5d87fe017ed0073e4849ef68e40e156a --- /dev/null +++ b/mods/diff/tests/fixtures/narrow-inline-pane.ts @@ -0,0 +1,13 @@ +import type { RenderInput } from 'claude-code' + +import { INLINE_PANE } from './inline-pane.js' + +/** + * The diff pane seated inline on a 100-column terminal: too narrow for the + * built-in panel, which shows only its wider-terminal line there. + */ +export const NARROW_INLINE_PANE: RenderInput<'Pane'> = { + ...INLINE_PANE, + viewport: { columns: 100, rows: 30 }, + props: { ...INLINE_PANE.props, bodyColumns: 96 }, +} diff --git a/mods/diff/tests/fixtures/no-stats.ts b/mods/diff/tests/fixtures/no-stats.ts new file mode 100644 index 0000000000000000000000000000000000000000..95c0940d39ba278a463dd78c0558f0085038ebfe --- /dev/null +++ b/mods/diff/tests/fixtures/no-stats.ts @@ -0,0 +1,10 @@ +import type Git from '../../hooks/git' + +/** + * The totals of a diff with nothing in it. + */ +export const NO_STATS: Git.DiffStats = { + filesCount: 0, + linesAdded: 0, + linesRemoved: 0, +} diff --git a/mods/diff/tests/fixtures/not-a-repository.ts b/mods/diff/tests/fixtures/not-a-repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..67ae31a8375ea321ec2b28af9ad371e4362c05c4 --- /dev/null +++ b/mods/diff/tests/fixtures/not-a-repository.ts @@ -0,0 +1,10 @@ +import type { ProcessRunResult } from 'claude-code' + +/** + * How git fails in a directory outside any repository. + */ +export const NOT_A_REPOSITORY: ProcessRunResult = { + exitCode: 128, + stdout: '', + stderr: 'fatal: not a git repository', +} diff --git a/mods/diff/tests/fixtures/old-files.ts b/mods/diff/tests/fixtures/old-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d71a93772dee3644fc1ab7df65368cf5bdbb2c3 --- /dev/null +++ b/mods/diff/tests/fixtures/old-files.ts @@ -0,0 +1,18 @@ +import type { On } from 'claude-code' + +/** + * Answers `$.fs` over /work holding MOVED_IN's two files, both last + * written at time 0, before any session began. + * + * @param on the test's `on` + */ +export function oldFiles(on: On) { + on('fs.list', () => ({ + value: [ + { name: 'old.ts', kind: 'file', size: 2 }, + { name: 'moved.ts', kind: 'file', size: 2 }, + ], + })) + + on('fs.stat', () => ({ value: { kind: 'file', size: 2, mtimeMs: 0 } })) +} diff --git a/mods/diff/tests/fixtures/pane.ts b/mods/diff/tests/fixtures/pane.ts new file mode 100644 index 0000000000000000000000000000000000000000..914e3071b2183903c9c25aefc5e91bb46a4792ee --- /dev/null +++ b/mods/diff/tests/fixtures/pane.ts @@ -0,0 +1,20 @@ +import type { RenderInput } from 'claude-code' + +/** + * The diff pane docked on a 160-column terminal, unfocused, 80 columns of + * body and 30 rows in view, the main conversation showing. + */ +export const PANE: RenderInput<'Pane'> = { + component: 'Pane', + surface: 'terminal', + requestId: 'diff', + viewport: { columns: 160, rows: 40 }, + props: { + title: 'Diff', + isFocused: false, + bodyColumns: 80, + placement: 'dock', + scroll: { offset: 0, bodyRows: 30 }, + view: {}, + }, +} diff --git a/mods/diff/tests/fixtures/poll-word.ts b/mods/diff/tests/fixtures/poll-word.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e725c10c3843da5265cf030dad14b71912d2998 --- /dev/null +++ b/mods/diff/tests/fixtures/poll-word.ts @@ -0,0 +1,7 @@ +/** + * The open pane's HEAD poll as gitWordOf names it where no file answers. + * + * On disk the poll stats `.git/HEAD` and spawns nothing, so a test that + * lists the session's spawns as the built-in's lets this word by. + */ +export const POLL_WORD = 'rev-parse HEAD' diff --git a/mods/diff/tests/fixtures/renamed.ts b/mods/diff/tests/fixtures/renamed.ts new file mode 100644 index 0000000000000000000000000000000000000000..c16fa0523169a1ca9bb1ccc73de029d7a368a682 --- /dev/null +++ b/mods/diff/tests/fixtures/renamed.ts @@ -0,0 +1,10 @@ +import { answersOf } from './answers-of.js' + +/** + * Git's output in /work where one file was renamed inside its directory, + * its contents untouched. + */ +export const RENAMED = answersOf( + '0\t0\t\0docs/notes.txt\0docs/renamed-notes.txt\0', + {}, +) diff --git a/mods/diff/tests/fixtures/repository.ts b/mods/diff/tests/fixtures/repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..bde9f86d756c0ad7c0de486c9e315644f1f117c8 --- /dev/null +++ b/mods/diff/tests/fixtures/repository.ts @@ -0,0 +1,11 @@ +/** + * Git's output in /work, where one file changed, for each invocation whose + * command line holds the key. + */ +export const REPOSITORY: Readonly> = { + 'rev-parse --path-format=absolute': '/work\n/work/.git\n/work/.git\n', + 'HEAD --shortstat': ' 1 file changed, 1 insertion(+)', + 'HEAD --numstat': '1\t0\tapp.ts\0', + 'ls-files': '', + '-- app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n', +} diff --git a/mods/diff/tests/fixtures/ring-onto.ts b/mods/diff/tests/fixtures/ring-onto.ts new file mode 100644 index 0000000000000000000000000000000000000000..3871a3c47dfc8b0d1e1ba0b08f7c65aae1c0b9af --- /dev/null +++ b/mods/diff/tests/fixtures/ring-onto.ts @@ -0,0 +1,18 @@ +import type { UiFocusInput } from 'claude-code' + +import { PANE } from './pane.js' + +/** + * The person walking the pane's focus ring onto one of the plugin's + * elements, as the engine raises it before the ring moves. + * + * @param key the element's key + * @returns the `ui.focus` input + */ +export const ringOnto = (key: string): UiFocusInput => ({ + component: 'Pane', + requestId: PANE.requestId, + plugin: 'diff', + element: key, + origin: { kind: 'person' }, +}) diff --git a/mods/diff/tests/fixtures/session.ts b/mods/diff/tests/fixtures/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..c387d8e755d6a8020364bae829baa466d2732f11 --- /dev/null +++ b/mods/diff/tests/fixtures/session.ts @@ -0,0 +1,10 @@ +import type { SessionStartInput } from 'claude-code' + +/** + * An interactive terminal session in /work. + */ +export const SESSION: SessionStartInput = { + surface: 'terminal', + isInteractive: true, + cwd: '/work', +} diff --git a/mods/diff/tests/fixtures/settle-ms.ts b/mods/diff/tests/fixtures/settle-ms.ts new file mode 100644 index 0000000000000000000000000000000000000000..820789ad417757298e64cb15bb07e543ba27e2ed --- /dev/null +++ b/mods/diff/tests/fixtures/settle-ms.ts @@ -0,0 +1,5 @@ +/** + * Longer than every wait the pane schedules before it fetches and draws + * (its refresh debounce and redraw), short of its HEAD poll. + */ +export const SETTLE_MS = 1000 diff --git a/mods/diff/tests/fixtures/starts-session.ts b/mods/diff/tests/fixtures/starts-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ec206a21b402ec364ba7a9462319548d7fb6f9b --- /dev/null +++ b/mods/diff/tests/fixtures/starts-session.ts @@ -0,0 +1,18 @@ +import type { On } from 'claude-code' +import { mock } from 'claude-code/testing' +import type { MockClock } from 'claude-code/testing' + +/** + * Answers what every /diff session asks first: its start, with its own + * directory, the time, and each command it registers, under its name. + * + * @param on the test's `on` + * @param now where the clock starts, in milliseconds (0 when not given) + * @returns the clock the session reads, at `now` until the test moves it + */ +export function startsSession(on: On, now = 0): MockClock { + on('session.start', ($, e) => ({ cwd: e.cwd })) + on('command.register', ($, e) => ({ value: { command: e.name } })) + + return mock.clock(on, { now }) +} diff --git a/mods/diff/tests/fixtures/todo-write-of.ts b/mods/diff/tests/fixtures/todo-write-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e29180355c1b86aba58b795b44dda781ce6c278c --- /dev/null +++ b/mods/diff/tests/fixtures/todo-write-of.ts @@ -0,0 +1,20 @@ +import type { SessionMessage } from 'claude-code' + +/** + * An assistant message holding one TodoWrite whose items have the given + * statuses. + * + * @param statuses each item's status + * @returns the message + */ +export const todoWriteOf = (statuses: readonly string[]): SessionMessage => ({ + role: 'assistant', + text: '', + toolUses: [ + { + tool_use_id: 'w', + tool: 'TodoWrite', + input: { todos: statuses.map(status => ({ content: 'x', status })) }, + }, + ], +}) diff --git a/mods/diff/tests/fixtures/two-files.ts b/mods/diff/tests/fixtures/two-files.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6c1801be0f38d3223eb554ebbb29ba766b2a191 --- /dev/null +++ b/mods/diff/tests/fixtures/two-files.ts @@ -0,0 +1,9 @@ +import { answersOf } from './answers-of.js' + +/** + * Git's output in /work where two files changed, a hunk each. + */ +export const TWO_FILES = answersOf('1\t1\tapp.ts\0' + '2\t0\tlib.ts\0', { + 'app.ts': '@@ -1 +1 @@\n-const a = 1\n+const a = 2\n', + 'lib.ts': '@@ -3,0 +4,2 @@\n+export const b = 1\n+export const c = 2\n', +}) diff --git a/mods/diff/tests/fixtures/typed-prompt-of.ts b/mods/diff/tests/fixtures/typed-prompt-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7523ff7fbff67ba96462f5eafb9370d09ab539e --- /dev/null +++ b/mods/diff/tests/fixtures/typed-prompt-of.ts @@ -0,0 +1,19 @@ +import type { PromptSubmitInput } from 'claude-code' + +/** + * A prompt as the person submits it from the composer with a plain Enter, + * carrying the context given, if any. + * + * @param text what the person typed + * @param context what already rides beside the prompt + * @returns the `prompt.submit` input + */ +export const typedPromptOf = ( + text: string, + context?: readonly string[], +): PromptSubmitInput => ({ + text, + ...(context && { context }), + wait: false, + origin: { kind: 'composer' }, +}) diff --git a/mods/diff/tests/fixtures/unsaid-hint.ts b/mods/diff/tests/fixtures/unsaid-hint.ts new file mode 100644 index 0000000000000000000000000000000000000000..d70bcfb41bd906f3c467994ad590c551af4fc965 --- /dev/null +++ b/mods/diff/tests/fixtures/unsaid-hint.ts @@ -0,0 +1,13 @@ +import type { RenderInput } from 'claude-code' + +import { HINT } from './hint.js' + +/** + * The prompt's hint from a surface that measured its size but did not say + * whether it docks a pane: an engine that predates the word, or a remote + * surface that has not reported it. + */ +export const UNSAID_HINT: RenderInput<'PromptHint'> = { + ...HINT, + viewport: { columns: 160, rows: 40 }, +} diff --git a/mods/diff/tests/fixtures/vs-main.ts b/mods/diff/tests/fixtures/vs-main.ts new file mode 100644 index 0000000000000000000000000000000000000000..fccb7c1f0afc942c40f87a4547d1a115a91a0b8d --- /dev/null +++ b/mods/diff/tests/fixtures/vs-main.ts @@ -0,0 +1,11 @@ +import type Git from '../../hooks/git' + +/** + * A branch comparison that resolved: main as the base, at a placeholder + * merge-base ref. + */ +export const VS_MAIN: Git.DiffSource = { + kind: 'branch', + baseBranch: 'main', + baseRef: 'x', +} diff --git a/mods/diff/tests/fixtures/wheel-over-list.ts b/mods/diff/tests/fixtures/wheel-over-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..80ab7b58e6acd422d47091f96a44d3848f9e3ef0 --- /dev/null +++ b/mods/diff/tests/fixtures/wheel-over-list.ts @@ -0,0 +1,12 @@ +import type { UiScrollInput } from 'claude-code' + +import { WHEEL_TICK } from './wheel-tick.js' + +/** + * The same wheel tick with the pointer over the docked list's third row + * (top pad, header, the list's margin, then the rows). + */ +export const WHEEL_OVER_LIST: UiScrollInput = { + ...WHEEL_TICK, + pointer: { column: 10, row: 5 }, +} diff --git a/mods/diff/tests/fixtures/wheel-tick.ts b/mods/diff/tests/fixtures/wheel-tick.ts new file mode 100644 index 0000000000000000000000000000000000000000..e54d29e0a549b4a99a98428a128061d22ce8cdec --- /dev/null +++ b/mods/diff/tests/fixtures/wheel-tick.ts @@ -0,0 +1,17 @@ +import type { UiScrollInput } from 'claude-code' + +import { PANE } from './pane.js' + +/** + * One tick of the person's wheel toward the foot over the docked pane, as + * the engine raises it before anything moves. + */ +export const WHEEL_TICK: UiScrollInput = { + component: 'Pane', + requestId: PANE.requestId, + offset: 1, + by: 1, + bodyRows: PANE.props.scroll.bodyRows, + contentRows: PANE.props.scroll.bodyRows + 1, + origin: { kind: 'person' }, +} diff --git a/mods/diff/tests/pane-state/empty-state-of.test.ts b/mods/diff/tests/pane-state/empty-state-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..85d76c0e4ecd443b77b62959d231e508e5f11a77 --- /dev/null +++ b/mods/diff/tests/pane-state/empty-state-of.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Git from '../../hooks/git' +import PaneState from '../../hooks/pane-state' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('empty-state-of', () => { + const dataIn = ( + mode: Git.BaseMode, + overrides: Partial = {}, + ): Git.DiffData => ({ + repository: Fixtures.CHECKOUT, + mode, + stats: Fixtures.NO_STATS, + files: [], + source: { kind: 'working-tree', base: 'HEAD' }, + isUnborn: false, + baseRef: 'HEAD', + stalePaths: [], + isUntrackedWithheld: false, + ...overrides, + }) + + const emptyOf = (data: Git.DiffData | null, filesCount: number) => + PaneState.emptyStateOf({ data, words: Git.GIT_WORDS }, filesCount) + + test("the built-in's headlines, keyed on the fetched mode", () => { + expect(emptyOf(null, 0)?.headline).toBe('Diff unavailable') + + expect(emptyOf(dataIn('session'), 0)).toEqual({ + headline: 'No changes this session', + hint: null, + }) + + expect(emptyOf(dataIn('uncommitted'), 0)?.headline).toBe( + 'No uncommitted changes', + ) + + expect( + emptyOf(dataIn('branch', { source: Fixtures.VS_MAIN }), 0)?.headline, + ).toBe('No changes vs main') + + expect(emptyOf(dataIn('branch'), 0)).toEqual({ + headline: 'No changes vs HEAD', + hint: 'No base branch to compare against — showing changes vs HEAD', + }) + + expect(emptyOf(dataIn('session', { isUnborn: true }), 0)).toEqual({ + headline: 'No commits yet', + hint: "Nothing to diff against until the repo's first commit", + }) + }) + + test('with files to count there is no empty state', () => { + expect(emptyOf(dataIn('session'), 2)).toBeNull() + }) + + test('no tracked rows, untracked withheld: claims no more', () => { + const withheld = { isUntrackedWithheld: true } + + expect(emptyOf(dataIn('session', withheld), 0)?.headline).toBe( + 'No tracked changes', + ) + + expect( + emptyOf(dataIn('session', { ...withheld, isUnborn: true }), 0)?.headline, + ).toBe('No tracked changes') + + expect(emptyOf(dataIn('uncommitted', withheld), 1)).toBeNull() + }) +}) diff --git a/mods/diff/tests/pane-state/header-totals-of.test.ts b/mods/diff/tests/pane-state/header-totals-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..615f66406e673fa838c637c9c8f117035d3660f3 --- /dev/null +++ b/mods/diff/tests/pane-state/header-totals-of.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import type Git from '../../hooks/git' +import PaneState from '../../hooks/pane-state' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('header-totals-of', () => { + const dataOf = ( + files: readonly Git.FileStat[], + filesCount: number, + ): Git.DiffData => ({ + repository: Fixtures.CHECKOUT, + mode: 'session', + stats: { + filesCount, + linesAdded: files.reduce((sum, each) => sum + each.added, 0), + linesRemoved: files.reduce((sum, each) => sum + each.removed, 0), + }, + files, + source: { kind: 'working-tree', base: 'HEAD' }, + isUnborn: false, + baseRef: 'HEAD', + stalePaths: [], + isUntrackedWithheld: false, + }) + + test('pre-session rows leave the header; the rest is not shown', () => { + const data = dataOf( + [Fixtures.SESSION_ROW, Fixtures.PRE_SESSION_ROW], + Fixtures.REPORTED_COUNT, + ) + + expect( + PaneState.headerTotalsOf( + data, + PaneState.partitionOf(data.files, 'hidden'), + ), + ).toEqual({ filesCount: 3, linesAdded: 3, linesRemoved: 1, notShown: 2 }) + }) + + test('every stored row pre-session: files past the cap still count', () => { + const stored = Array.from({ length: Fixtures.STORED_COUNT }, (_, at) => ({ + ...Fixtures.PRE_SESSION_ROW, + path: `old${at}.ts`, + })) + + const pastCap = Fixtures.PAST_CAP_COUNT - Fixtures.STORED_COUNT + + expect( + PaneState.headerTotalsOf( + dataOf(stored, Fixtures.PAST_CAP_COUNT), + PaneState.partitionOf(stored, 'hidden'), + ), + ).toEqual({ + filesCount: pastCap, + linesAdded: 0, + linesRemoved: 0, + notShown: pastCap, + }) + }) + + test('tests count as noise and leave the shown rows', () => { + const partition = PaneState.partitionOf( + [Fixtures.SESSION_ROW, Fixtures.TEST_ROW], + 'hidden', + ) + + expect(partition.shown.map(each => each.path)).toEqual(['src/a.ts']) + expect(partition.noiseCount).toBe(1) + expect(PaneState.partitionOf(partition.shown, 'shown').noiseCount).toBe(0) + }) +}) diff --git a/mods/diff/tests/pane-state/mode-label-of.test.ts b/mods/diff/tests/pane-state/mode-label-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..52109ef73b8d9d6dfac906318e124ba13cb1f5f9 --- /dev/null +++ b/mods/diff/tests/pane-state/mode-label-of.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Git from '../../hooks/git' +import PaneState from '../../hooks/pane-state' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('mode-label-of', () => { + const dataIn = ( + mode: Git.BaseMode, + source: Git.DiffSource, + ): Git.DiffData => ({ + repository: Fixtures.CHECKOUT, + mode, + stats: Fixtures.ONE_FILE, + files: [], + source, + isUnborn: false, + baseRef: 'HEAD', + stalePaths: [], + isUntrackedWithheld: false, + }) + + const labelOf = ( + mode: Git.BaseMode, + requestedMode: Git.BaseMode, + data: Git.DiffData | null, + ) => + PaneState.modeLabelOf(mode, { requestedMode, data, words: Git.GIT_WORDS }) + + test('each settled mode is named as the built-in names it', () => { + const session = dataIn('session', Fixtures.WORKING_TREE) + + expect(labelOf('session', 'session', session)).toBe('this session') + + expect(labelOf('uncommitted', 'session', session)).toBe( + 'uncommitted (vs HEAD)', + ) + + expect(labelOf('branch', 'session', session)).toBe('branch diff') + }) + + test('settled branch mode names its base, or what stands in', () => { + expect( + labelOf('branch', 'branch', dataIn('branch', Fixtures.VS_MAIN)), + ).toBe('branch vs main') + + expect( + labelOf('branch', 'branch', dataIn('branch', Fixtures.WORKING_TREE)), + ).toBe('vs HEAD (no base branch)') + }) + + test('a working tree that names its own base shows that name', () => { + expect( + labelOf( + 'uncommitted', + 'uncommitted', + dataIn('uncommitted', Fixtures.AT_SHA), + ), + ).toBe('uncommitted (vs 8634408015c1)') + }) + + test('the requested mode pends with an ellipsis until its data lands', () => { + const session = dataIn('session', Fixtures.WORKING_TREE) + + expect(labelOf('branch', 'branch', session)).toBe('branch diff…') + expect(labelOf('session', 'branch', session)).toBe('this session') + + expect( + labelOf('session', 'session', dataIn('branch', Fixtures.VS_MAIN)), + ).toBe('this session…') + + expect(labelOf('branch', 'branch', null)).toBe('branch diff') + }) + + test('an unborn HEAD with rows says what the rows are', () => { + const unborn = { + ...dataIn('session', Fixtures.WORKING_TREE), + isUnborn: true, + } + + expect(PaneState.unbornNoteOf({ data: unborn }, 2)).toBe( + 'no commits yet — showing staged and new files', + ) + + expect(PaneState.unbornNoteOf({ data: unborn }, 0)).toBeNull() + + expect( + PaneState.unbornNoteOf({ data: dataIn('session', Fixtures.VS_MAIN) }, 2), + ).toBeNull() + }) +}) diff --git a/mods/diff/tests/pane-toggle/pane-toggle-of.test.ts b/mods/diff/tests/pane-toggle/pane-toggle-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfb3cb0e1c4e72b17d58abd4ea1c1c7c4d0a50aa --- /dev/null +++ b/mods/diff/tests/pane-toggle/pane-toggle-of.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Limits from '../../hooks/limits' +import PaneToggle from '../../hooks/pane-toggle' + +tier('builtin') + +describe('pane-toggle-of', () => { + test('an open pane closes, however narrow', () => { + expect( + PaneToggle.paneToggleOf({ + isOpen: true, + columns: Limits.OPEN_MIN_COLUMNS - 1, + }), + ).toBe('close') + }) + + test('below the panel width it is too narrow, from it it opens', () => { + expect( + PaneToggle.paneToggleOf({ + isOpen: false, + columns: Limits.OPEN_MIN_COLUMNS - 1, + }), + ).toBe('too-narrow') + + expect( + PaneToggle.paneToggleOf({ + isOpen: false, + columns: Limits.OPEN_MIN_COLUMNS, + }), + ).toBe('open') + + expect(PaneToggle.paneToggleOf({ isOpen: false, columns: null })).toBe( + 'open', + ) + }) +}) diff --git a/mods/diff/tests/todos/todo-progress-of.test.ts b/mods/diff/tests/todos/todo-progress-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5f8040c71d82e2b1da51e376b73710225edf556 --- /dev/null +++ b/mods/diff/tests/todos/todo-progress-of.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Todos from '../../hooks/todos' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('todo-progress-of', () => { + test('the latest TodoWrite decides: completed over total', () => { + expect( + Todos.todoProgressOf([ + Fixtures.todoWriteOf(['pending', 'pending']), + Fixtures.todoWriteOf(['completed', 'in_progress', 'pending']), + ]), + ).toEqual({ done: 1, total: 3 }) + }) + + test('no TodoWrite yet reads as an empty list', () => { + expect( + Todos.todoProgressOf([{ role: 'user', text: 'hi', toolUses: [] }]), + ).toEqual({ + done: 0, + total: 0, + }) + }) +}) diff --git a/mods/diff/tests/turns/turn-diffs-of.test.ts b/mods/diff/tests/turns/turn-diffs-of.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fcffddf80dcaf65454af86113f75b4b26cc23fb --- /dev/null +++ b/mods/diff/tests/turns/turn-diffs-of.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Limits from '../../hooks/limits' +import Turns from '../../hooks/turns' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('turn-diffs-of', () => { + test('turns with edits, newest first, numbered by prompt', () => { + const turns = Turns.turnDiffsOf([ + Fixtures.promptOf('first'), + Fixtures.editsOf(Fixtures.editPatchOf(['-a', '+b'])), + Fixtures.toolResults(), + Fixtures.promptOf('second, nothing edited'), + Fixtures.promptOf('third turn with a long prompt that gets cut'), + Fixtures.editsOf(Fixtures.editPatchOf(['+c']), { + filePath: '/r/new.ts', + type: 'create', + content: 'x\ny', + }), + Fixtures.toolResults(), + ]) + + expect(turns.map(turn => turn.index)).toEqual([3, 1]) + expect(turns[0]?.preview).toBe('third turn with a long prompt…') + + expect( + turns[0]?.files.map(file => [ + file.path, + file.added, + file.removed, + file.isNewFile, + ]), + ).toEqual([ + ['/r/a.ts', 1, 0, false], + ['/r/new.ts', 2, 0, true], + ]) + + expect(turns[0]?.files[1]?.hunks).toEqual([ + { oldStart: 0, newStart: 1, lines: ['+x', '+y'] }, + ]) + + expect(turns[1]?.files.map(file => [file.added, file.removed])).toEqual([ + [1, 1], + ]) + }) + + test('a file edited twice in a turn: both hunks, summed', () => { + const [turn] = Turns.turnDiffsOf([ + Fixtures.promptOf('go'), + Fixtures.editsOf( + Fixtures.editPatchOf(['+a']), + Fixtures.editPatchOf(['-b', '+c']), + ), + ]) + + expect(turn?.files).toHaveLength(1) + expect(turn?.files[0]?.hunks).toHaveLength(2) + expect([turn?.files[0]?.added, turn?.files[0]?.removed]).toEqual([2, 1]) + }) + + test('results that are not file edits are ignored', () => { + expect( + Turns.turnDiffsOf([ + Fixtures.promptOf('go'), + Fixtures.editsOf({ stdout: 'x' }, 'text', null), + ]), + ).toEqual([]) + }) + + test('a big created file is cut at the line cap and says so', () => { + const [turn] = Turns.turnDiffsOf([ + Fixtures.promptOf('write it'), + Fixtures.editsOf({ + filePath: '/r/big.ts', + type: 'create', + content: Array.from( + { length: Fixtures.BIG_LINES }, + (_, at) => `l${at}`, + ).join('\n'), + }), + ]) + + const file = turn?.files[0] + + expect(file?.hunks.flatMap(hunk => hunk.lines)).toHaveLength( + Limits.MAX_LINES_PER_FILE, + ) + + expect(file?.isTruncated).toBe(true) + expect(file?.added).toBe(Fixtures.BIG_LINES) + }) +})