Merge remote-tracking branch 'origin/main' into poteat/diff-parity-2

# Conflicts:
#	mods/diff/hooks/register.ts
#	mods/diff/hooks/views/detail/code-blocks-of/code-blocks-of.ts
#	mods/diff/tests/fixtures/git-in.ts
#	mods/diff/tests/fixtures/in-repository.ts
#	mods/diff/tests/fixtures/index.ts
#	mods/telemetry/tests/fixtures/index.ts
This commit is contained in:
poteat
2026-09-13 19:55:26 -07:00
209 changed files with 5506 additions and 67 deletions
@@ -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()
})
})
@@ -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]).toEqual([
['git', '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', '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)
})
})
@@ -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)
}
})
})
@@ -11,7 +11,7 @@
* @param dirty the paths `status` lists as dirty when the session starts
* @returns the answers by key
*/
export const repositoryOf = (
export const answersOf = (
numstat: string,
bodies: Readonly<Record<string, string>>,
dirty: readonly string[] = Object.keys(bodies),
+12
View File
@@ -0,0 +1,12 @@
/**
* The lines of an armed ask: where it came from, then one hunk long enough
* that two lines and the cut note take less room than the whole.
*/
export const ASK_LINES: readonly string[] = [
'The user attached the diff of a.txt from the diff pane to this prompt:',
'@@ -1,3 +1,3 @@',
'-the first old line of a.txt, long enough to take up some room',
'-the second old line of a.txt, long enough to take up some room',
'+the first new line of a.txt, long enough to take up some room',
'+the second new line of a.txt, long enough to take up some room',
]
+6
View File
@@ -0,0 +1,6 @@
import { ASK_LINES } from './ask-lines.js'
/**
* ASK_LINES as the one text an armed ask carries.
*/
export const ASK_TEXT = ASK_LINES.join('\n')
+6
View File
@@ -0,0 +1,6 @@
export * from './ask-lines.js'
export * from './ask-text.js'
export * from './room-for-one-line.js'
export * from './room-for-two-lines.js'
export * as default from '.'
+8
View File
@@ -0,0 +1,8 @@
import Ask from '../../../hooks/ask'
import { ASK_LINES } from './ask-lines.js'
/**
* Room for ASK_LINES' heading line and the cut note only, so no hunk line.
*/
export const ROOM_FOR_ONE_LINE =
(ASK_LINES[0]?.length ?? 0) + 1 + Ask.ASK_CUT_NOTE.length
+11
View File
@@ -0,0 +1,11 @@
import Ask from '../../../hooks/ask'
import { ASK_LINES } from './ask-lines.js'
/**
* Room for ASK_LINES' first two lines and the cut note, each line with its
* newline, and not a character more.
*/
export const ROOM_FOR_TWO_LINES = ASK_LINES.slice(0, 2).reduce(
(sum, line) => sum + line.length + 1,
Ask.ASK_CUT_NOTE.length,
)
+20
View File
@@ -0,0 +1,20 @@
import type Backend from '../../../hooks/backend'
/**
* A backend host around one runner: every other member answers nothing (no
* file, no mtime, no listing, time zero, a base callback that ignores).
*
* @param run the host's runner
* @returns the host
*/
export const backendHostOf = (
run: Backend.BackendHost['run'],
): Backend.BackendHost => ({
run,
readFile: () => Promise.reject(new Error('ENOENT')),
mtimeOf: () => Promise.resolve(null),
entryKindsOf: () => Promise.resolve(null),
nowMs: () => Promise.resolve(0),
sessionStartMsOf: () => 0,
onBranchBase: () => undefined,
})
+13
View File
@@ -0,0 +1,13 @@
import Scripted from '../scripted'
import { GIT_WORKTREE } from './git-worktree.js'
import { recordingBackendHostOf } from './recording-backend-host-of.js'
import type { RecordingBackendHost } from './recording-backend-host.js'
/**
* A recording backend host inside a git working tree: `rev-parse` answers
* the three lines, nothing else is scripted.
*
* @returns the host and the programs it ran
*/
export const gitHostOf = (): RecordingBackendHost =>
recordingBackendHostOf({ 'rev-parse': Scripted.ok(GIT_WORKTREE) })
+5
View File
@@ -0,0 +1,5 @@
/**
* `git rev-parse`'s three lines inside a working tree at /r: the toplevel,
* the git dir, the common dir.
*/
export const GIT_WORKTREE = '/r\n/r/.git\n/r/.git\n'
+10
View File
@@ -0,0 +1,10 @@
export * from './backend-host-of.js'
export * from './git-host-of.js'
export * from './git-worktree.js'
export type * from './recording-backend-host.js'
export * from './recording-backend-host-of.js'
export type * from './recording-probe.js'
export * from './recording-probe-of.js'
export * from './stub-backend-of.js'
export * as default from '.'
@@ -0,0 +1,27 @@
import type { ProcessRunResult } from 'claude-code'
import Scripted from '../scripted'
import { backendHostOf } from './backend-host-of.js'
import type { RecordingBackendHost } from './recording-backend-host.js'
/**
* A backend host whose runner answers from a script and notes each program
* it was asked to run.
*
* @param script git's answers by subcommand
* @returns the host and the programs it ran, in order
*/
export function recordingBackendHostOf(
script: Readonly<Record<string, ProcessRunResult>>,
): RecordingBackendHost {
const programs: (string | undefined)[] = []
const scripted = Scripted.scriptedGitOf(script)
const host = backendHostOf(([program, ...rest]) => {
programs.push(program)
return scripted.run(rest)
})
return { host, programs }
}
@@ -0,0 +1,10 @@
import type Backend from '../../../hooks/backend'
/**
* A backend host under test with the programs its runner was asked to run,
* in order.
*/
export type RecordingBackendHost = {
host: Backend.BackendHost
programs: readonly (string | undefined)[]
}
@@ -0,0 +1,24 @@
import type Backend from '../../../hooks/backend'
import type { RecordingProbe } from './recording-probe.js'
import { stubBackendOf } from './stub-backend-of.js'
/**
* A backend probe that notes each host it is asked about and answers a stub
* backend at the toplevel, or declines (null) when given none.
*
* @param toplevel the working copy's root, or null to decline
* @returns the probe and the hosts it was asked about, in order
*/
export function recordingProbeOf(toplevel: string | null): RecordingProbe {
const asked: Backend.BackendHost[] = []
const answer = toplevel === null ? null : stubBackendOf(toplevel)
return {
asked,
probe: host => {
asked.push(host)
return Promise.resolve(answer)
},
}
}
@@ -0,0 +1,9 @@
import type Backend from '../../../hooks/backend'
/**
* A backend probe under test with the hosts it was asked about, in order.
*/
export type RecordingProbe = {
probe: Backend.BackendProbe
asked: readonly Backend.BackendHost[]
}
+18
View File
@@ -0,0 +1,18 @@
import type { Backend } from '../../../hooks/backend'
import Git from '../../../hooks/git'
/**
* A backend that holds a working copy at a toplevel and fetches nothing,
* its lister word the toplevel so a test can tell which probe pinned.
*
* @param toplevel the working copy's root
* @returns the backend
*/
export const stubBackendOf = (toplevel: string): Backend => ({
repository: { toplevel },
baseModes: ['uncommitted'],
words: { ...Git.GIT_WORDS, lister: toplevel },
fetchDiff: () => Promise.resolve({ kind: 'unavailable' }),
fetchFileHunks: () => Promise.resolve(null),
headKeyOf: () => Promise.resolve(''),
})
+4
View File
@@ -0,0 +1,4 @@
/**
* A hunk length past what one turn's diff keeps whole.
*/
export const BIG_LINES = 1000
@@ -0,0 +1,4 @@
/**
* What `rev-parse HEAD` answers on the feature branch.
*/
export const FEATURE_HEAD = 'cccccccccccccccccccccccccccccccccccccccc'
+5
View File
@@ -0,0 +1,5 @@
export * from './feature-head.js'
export * from './local-base.js'
export * from './origin-base.js'
export * as default from '.'
+5
View File
@@ -0,0 +1,5 @@
/**
* The merge-base of HEAD and the local default branch, when it differs
* from the remote's.
*/
export const LOCAL_BASE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
@@ -0,0 +1,4 @@
/**
* The merge-base of HEAD and the remote's default branch.
*/
export const ORIGIN_BASE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+11
View File
@@ -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',
}
@@ -0,0 +1,19 @@
import type { ParsedHunk } from './parsed-hunk.js'
/**
* Whether each hunk starts where the one before it ended, on both sides:
* the sub-hunks of one hunk split leave no line out and repeat none.
*
* @param parts the hunks in order
* @returns true when they abut
*/
export const areContiguous = (parts: readonly ParsedHunk[]): boolean =>
parts.slice(1).every((part, at) => {
const before = parts[at]
return (
before !== undefined &&
part.oldStart === before.oldStart + before.oldLines &&
part.newStart === before.newStart + before.newLines
)
})
+36
View File
@@ -0,0 +1,36 @@
import type { ParsedHunk } from './parsed-hunk.js'
/**
* The hunks a unified-diff `Code` source holds, read as a surface reads
* them.
*
* Each `@@ -a,b +c,d @@` header opens one and the lines under it are its
* body; a source that opens with no header holds none.
*
* @param source the `Code` element's source
* @returns the hunks in order
*/
export function hunksOf(source: string): ParsedHunk[] {
const hunks: ParsedHunk[] = []
for (const line of source.split('\n')) {
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
const last = hunks.at(-1)
if (header) {
hunks.push({
oldStart: Number(header[1]),
oldLines: Number(header[2] ?? 1),
newStart: Number(header[3]),
newLines: Number(header[4] ?? 1),
lines: [],
})
} else if (last) {
hunks[hunks.length - 1] = { ...last, lines: [...last.lines, line] }
} else {
return []
}
}
return hunks
}
+10
View File
@@ -0,0 +1,10 @@
export * from './are-contiguous.js'
export * from './hunks-of.js'
export * from './is-within-code-cap.js'
export * from './mathematical-bold-a.js'
export type * from './parsed-hunk.js'
export * from './parsed-hunks-of.js'
export * from './wide-hunk.js'
export * from './wide-line-letters.js'
export * as default from '.'
@@ -0,0 +1,11 @@
import Views from '../../../hooks/views'
/**
* Whether a source is one a `Code` element may carry: at most
* MAX_CODE_CHARS, counted in UTF-16 units as the engine counts.
*
* @param source the block's source
* @returns true when the source fits
*/
export const isWithinCodeCap = (source: string): boolean =>
source.length <= Views.MAX_CODE_CHARS
@@ -0,0 +1,7 @@
/**
* U+1D400, a letter beyond the Basic Multilingual Plane: one character on
* screen, two UTF-16 units in a string.
*
* A `Code` source's length is charged in those units.
*/
export const MATHEMATICAL_BOLD_A = '\u{1D400}'
+11
View File
@@ -0,0 +1,11 @@
/**
* One hunk read back out of a `Code` source: its header's four numbers and
* the marked lines under it.
*/
export type ParsedHunk = {
oldStart: number
oldLines: number
newStart: number
newLines: number
lines: readonly string[]
}
@@ -0,0 +1,12 @@
import type Views from '../../../hooks/views'
import { hunksOf } from './hunks-of.js'
import type { ParsedHunk } from './parsed-hunk.js'
/**
* Every hunk a body's `Code` sources hold, in drawing order.
*
* @param body the file's body as `codeBlocksOf` cut it
* @returns the hunks read back out of each source
*/
export const parsedHunksOf = (body: Views.CodeBody): ParsedHunk[] =>
body.sources.flatMap(hunksOf)
+25
View File
@@ -0,0 +1,25 @@
import type Git from '../../../hooks/git'
import { WIDE_LINE_LETTERS } from './wide-line-letters.js'
/**
* A hunk of wide lines cycling context, removed and added: at its defaults
* two share a `Code` and three do not.
*
* @param start the first line's number on both sides
* @param count how many lines
* @param letter the character each line repeats WIDE_LINE_LETTERS times
* @returns the hunk
*/
export function wideHunk(start = 1, count = 40, letter = 'x'): Git.Hunk {
const markers = [' ', '-', '+']
return {
oldStart: start,
newStart: start,
lines: Array.from(
{ length: count },
(_, row) =>
`${markers[row % markers.length]}${letter.repeat(WIDE_LINE_LETTERS)}`,
),
}
}
@@ -0,0 +1,4 @@
/**
* How many letters follow each marker in a wide hunk's line.
*/
export const WIDE_LINE_LETTERS = 99
+5
View File
@@ -0,0 +1,5 @@
export * from './root-only-budget.js'
export * from './tracked-row.js'
export * from './two-listings-budget.js'
export * as default from '.'
@@ -0,0 +1,4 @@
/**
* A kind-walk budget that lists the repository root and nothing under it.
*/
export const ROOT_ONLY_BUDGET = 1
+14
View File
@@ -0,0 +1,14 @@
import type Git from '../../../hooks/git'
/**
* One row under git, a directory deep: past what a root-only walk lists.
*/
export const TRACKED_ROW: Git.FileStat = {
path: 'deep/x.ts',
renamedFrom: null,
added: 1,
removed: 0,
isBinary: false,
isUntracked: false,
isPreSession: false,
}
@@ -0,0 +1,4 @@
/**
* A kind-walk budget spent after the root and one directory under it.
*/
export const TWO_LISTINGS_BUDGET = 2
+15
View File
@@ -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 },
})
+12
View File
@@ -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 },
],
})
@@ -0,0 +1,5 @@
/**
* An mtime that predates GIT_SESSION_START_MS: a file last touched before
* the session.
*/
export const BEFORE_SESSION_MS = 500
@@ -0,0 +1,5 @@
/**
* How many top-level directories, each with a nested one, the wide layout
* case lists and dates.
*/
export const DEEP_DIRECTORIES = 100
@@ -0,0 +1,4 @@
/**
* An mtime past GIT_SESSION_START_MS: a file touched this session.
*/
export const DURING_SESSION_MS = 2_000
+10
View File
@@ -0,0 +1,10 @@
import type Git from '../../../hooks/git'
/**
* The repository the fetch cases run in: a plain checkout at `/repo`.
*/
export const FETCH_REPOSITORY: Git.Repository = {
toplevel: '/repo',
gitDir: '/repo/.git',
commonDir: '/repo/.git',
}
+18
View File
@@ -0,0 +1,18 @@
import type Git from '../../../hooks/git'
/**
* What the working tree lists as: the files the cases name are plain
* files; the link, the leak and the submodule are something else.
*/
export const FILES_LISTING: readonly (readonly [string, Git.EntryKind])[] = [
['old.ts', 'file'],
['new.ts', 'file'],
['scratch.txt', 'file'],
['stale.txt', 'file'],
['fresh.txt', 'file'],
['a.ts', 'file'],
['staged.ts', 'file'],
['link.ts', 'other'],
['leak', 'other'],
['sub', 'other'],
]
@@ -0,0 +1,5 @@
/**
* When the session began, for the git cases that date files against it by
* hand-picked mtimes.
*/
export const GIT_SESSION_START_MS = 1_000
+12
View File
@@ -0,0 +1,12 @@
export * from './before-session-ms.js'
export * from './deep-directories.js'
export * from './during-session-ms.js'
export * from './fetch-repository.js'
export * from './files-listing.js'
export * from './git-session-start-ms.js'
export * from './merge-head-linked.js'
export * from './merging.js'
export * from './one-staged.js'
export * from './vs-head.js'
export * as default from '.'
@@ -0,0 +1,8 @@
import type Git from '../../../hooks/git'
/**
* A git dir listing whose MERGE_HEAD is a symbolic link, so no merge is in
* progress as far as the fetch is concerned.
*/
export const MERGE_HEAD_LINKED: readonly (readonly [string, Git.EntryKind])[] =
[['MERGE_HEAD', 'other']]
+8
View File
@@ -0,0 +1,8 @@
import type Git from '../../../hooks/git'
/**
* A git dir listing mid-merge: MERGE_HEAD is a plain file.
*/
export const MERGING: readonly (readonly [string, Git.EntryKind])[] = [
['MERGE_HEAD', 'file'],
]
+4
View File
@@ -0,0 +1,4 @@
/**
* A shortstat line for one staged file of three insertions.
*/
export const ONE_STAGED = ' 1 file changed, 3 insertions(+)'
+5
View File
@@ -0,0 +1,5 @@
/**
* The argv fragment of the diff against HEAD, to script it as failing.
*/
export const VS_HEAD =
'diff.relative=false diff --ignore-submodules=dirty --submodule=short HEAD'
+9 -5
View File
@@ -1,21 +1,25 @@
import type { ProcessRunResult } from 'claude-code'
import { NOT_A_REPOSITORY } from './not-a-repository.js'
import { REPOSITORY } from './repository.js'
/**
* What git answers in /work; an invocation the answers do not know fails
* as git does outside a repository.
* 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 answers git's output by a key the invocation's command line holds
* @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[],
answers: Readonly<Record<string, string>>,
script: Readonly<Record<string, string>> = REPOSITORY,
): ProcessRunResult {
const line = argv.join(' ')
const found = Object.entries(answers).find(([key]) => line.includes(key))
const found = Object.entries(script).find(([key]) => line.includes(key))
return found
? { exitCode: 0, stdout: found[1], stderr: '' }
+4
View File
@@ -0,0 +1,4 @@
/**
* What the scripted `rev-parse` answers when the key falls back to git.
*/
export const HEAD_SHA = '0123456789abcdef0123456789abcdef01234567'
+4
View File
@@ -0,0 +1,4 @@
export * from './head-sha.js'
export * from './stamp.js'
export * as default from '.'
+4
View File
@@ -0,0 +1,4 @@
/**
* The mtime every stat in the HEAD-key cases answers.
*/
export const STAMP = 42
+8
View File
@@ -0,0 +1,8 @@
export * from './past-cap-count.js'
export * from './pre-session-row.js'
export * from './reported-count.js'
export * from './session-row.js'
export * from './stored-count.js'
export * from './test-row.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* A reported file count past the row cap: ten files git counted but the
* model never stored.
*/
export const PAST_CAP_COUNT = 60
@@ -0,0 +1,14 @@
import type Git from '../../../hooks/git'
/**
* A file last changed before the session: its lines leave the header.
*/
export const PRE_SESSION_ROW: Git.FileStat = {
path: 'old.ts',
renamedFrom: null,
added: 5,
removed: 1,
isBinary: false,
isUntracked: false,
isPreSession: true,
}
@@ -0,0 +1,4 @@
/**
* The file count git reported, two more than the rows stored.
*/
export const REPORTED_COUNT = 4
@@ -0,0 +1,14 @@
import type Git from '../../../hooks/git'
/**
* A source file changed this session: three added, one removed.
*/
export const SESSION_ROW: Git.FileStat = {
path: 'src/a.ts',
renamedFrom: null,
added: 3,
removed: 1,
isBinary: false,
isUntracked: false,
isPreSession: false,
}
@@ -0,0 +1,4 @@
/**
* How many rows the model stored in the past-the-cap case.
*/
export const STORED_COUNT = 50
+14
View File
@@ -0,0 +1,14 @@
import type Git from '../../../hooks/git'
/**
* A test file changed this session, which the partition counts as noise.
*/
export const TEST_ROW: Git.FileStat = {
path: 'test/a.test.ts',
renamedFrom: null,
added: 3,
removed: 1,
isBinary: false,
isUntracked: false,
isPreSession: false,
}
+15
View File
@@ -0,0 +1,15 @@
import type { RenderInput } from 'claude-code'
import { HINT } from './hint.js'
/**
* The prompt's hint on a 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 },
})
+28 -9
View File
@@ -8,21 +8,27 @@ import { REPOSITORY } from './repository.js'
import { startsSession } from './starts-session.js'
/**
* A session in /work (REPOSITORY), keeping each git run and each pane
* opened or closed.
* A session in a repository git answers for from a script (REPOSITORY, in
* /work, when none is given), keeping what the plugin does there.
*
* The store starts empty, the clock at 0, and the engine draws the hint.
* 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 answers git's output by invocation key; one changed file when absent
* @returns the runs, the panes opened and closed, and the session's clock
* @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,
answers: Readonly<Record<string, string>> = REPOSITORY,
script: Readonly<Record<string, string>> = REPOSITORY,
stored: Readonly<Record<string, unknown>> = {},
) {
const runs: Args<'process.run'>[] = []
const focused: Args<'ui.focus'>[] = []
const statuses: (string | undefined)[] = []
const opened = keeping<Args<'ui.open'>>()
const closed = keeping<Args<'ui.close'>>()
const clock = startsSession(on)
@@ -30,7 +36,7 @@ export function inRepository(
on('process.run', ($, e) => {
runs.push(e)
return { value: gitIn(e.argv, answers) }
return { value: gitIn(e.argv, script) }
})
on('ui.focus', (_engine, e) => {
@@ -39,12 +45,25 @@ export function inRepository(
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)
mock.store(on, stored)
return { runs, focused, opened: opened.kept, closed: closed.kept, clock }
return {
runs,
focused,
opened: opened.kept,
closed: closed.kept,
statuses,
clock,
}
}
+29 -2
View File
@@ -1,31 +1,58 @@
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 './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 './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 './renamed.js'
export * from './repository.js'
export * from './repository-of.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 './text-of.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 './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 '.'
+4
View File
@@ -0,0 +1,4 @@
export * from './walk-budget.js'
export * from './walk-depth.js'
export * as default from '.'
+4
View File
@@ -0,0 +1,4 @@
/**
* How many directory listings the kind walk may spend.
*/
export const WALK_BUDGET = 8
+4
View File
@@ -0,0 +1,4 @@
/**
* The depth of the path that outruns any listing budget.
*/
export const WALK_DEPTH = 50_000
+2 -2
View File
@@ -1,11 +1,11 @@
import { answersOf } from './answers-of.js'
import { MANY_FILE_COUNT } from './many-file-count.js'
import { repositoryOf } from './repository-of.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 = repositoryOf(
export const MANY_FILES = answersOf(
Array.from(
{ length: MANY_FILE_COUNT },
(_, at) => `1\t1\tfile${at}.ts\0`,
+9
View File
@@ -0,0 +1,9 @@
import type Git from '../../../hooks/git'
/**
* A working-tree source whose backend names its own base, a short sha.
*/
export const AT_SHA: Git.DiffSource = {
kind: 'working-tree',
base: '8634408015c1',
}
+5
View File
@@ -0,0 +1,5 @@
export * from './at-sha.js'
export * from './one-file.js'
export * from './working-tree.js'
export * as default from '.'
+10
View File
@@ -0,0 +1,10 @@
import type Git from '../../../hooks/git'
/**
* The totals of a diff with one file of one added line.
*/
export const ONE_FILE: Git.DiffStats = {
filesCount: 1,
linesAdded: 1,
linesRemoved: 0,
}
@@ -0,0 +1,9 @@
import type Git from '../../../hooks/git'
/**
* A diff source that compares against the working tree, its base HEAD.
*/
export const WORKING_TREE: Git.DiffSource = {
kind: 'working-tree',
base: 'HEAD',
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { repositoryOf } from './repository-of.js'
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 = repositoryOf(
export const MOVED_IN = answersOf(
'1\t1\told.ts\0' + '1\t1\tmoved.ts\0',
{
'old.ts': '@@ -1 +1 @@\n-a\n+b\n',
+10
View File
@@ -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,
}
+24
View File
@@ -0,0 +1,24 @@
import type Git from '../../../hooks/git'
import Limits from '../../../hooks/limits'
import { LONG_LINE_CHARS } from './long-line-chars.js'
/**
* A body of one hunk at the line cap whose every line is long: more text
* than the pane's whole body budget holds.
*
* @returns the body
*/
export const bigBodyOf = (): Git.FileHunks => ({
hunks: [
{
oldStart: 0,
newStart: 1,
lines: Array.from(
{ length: Limits.MAX_LINES_PER_FILE },
(_, at) => `+const value${at} = ${'0'.repeat(LONG_LINE_CHARS)}`,
),
},
],
isTruncated: false,
isLarge: false,
})
+14
View File
@@ -0,0 +1,14 @@
import type Git from '../../../hooks/git'
import type PaneState from '../../../hooks/pane-state'
/**
* The pane's read bodies holding one body under each of the given paths.
*
* @param body the body every path reads
* @param paths the files that have it
* @returns the bodies by path
*/
export const bodiesOf = (
body: Git.FileHunks,
...paths: readonly string[]
): PaneState.Bodies => new Map(paths.map(path => [path, body]))
+4
View File
@@ -0,0 +1,4 @@
/**
* The pane's body rows in the ordinary cases: docked in a 40-row terminal.
*/
export const BODY_ROWS = 35
@@ -0,0 +1,8 @@
/**
* The panel row the built-in draws its empty headline on, docked in a
* 40-row terminal over one pre-session file.
*
* Its header's top pad, the close, the header's bottom pad, then half the
* spare rows.
*/
export const BUILTIN_HEADLINE_ROW = 17
+4
View File
@@ -0,0 +1,4 @@
/**
* The pane's body width in the ordinary cases.
*/
export const COLUMNS = 60
+19
View File
@@ -0,0 +1,19 @@
import type Views from '../../../hooks/views'
import { SMALL_BODY } from './small-body.js'
/**
* A config file as the detail view takes it: untracked with no body read,
* or tracked with a small body ready.
*
* @param isUntracked whether the file is untracked
* @returns the detail model
*/
export const configDetailOf = (isUntracked: boolean): Views.DetailModel => ({
words: { untrackedNoteOf: () => ['New file not yet staged.'] },
path: 'src/app/config.ts',
displayPath: 'src/app/config.ts',
isUntracked,
isBinary: false,
body: isUntracked ? undefined : SMALL_BODY,
isArmed: false,
})
+4
View File
@@ -0,0 +1,4 @@
/**
* Body rows too few to center an empty state in, so it stacks from the top.
*/
export const CRAMPED_ROWS = 4
+10
View File
@@ -0,0 +1,10 @@
import type Views from '../../../hooks/views'
import { VIEW_PANE } from './view-pane.js'
/**
* VIEW_PANE's seat: docked beside the transcript on its 160-column terminal.
*/
export const DOCK_SEAT: Views.PaneSeat = {
placement: 'dock',
terminalColumns: VIEW_PANE.viewport?.columns ?? null,
}
+20
View File
@@ -0,0 +1,20 @@
import type { On, RenderElement } from 'claude-code'
import type { Engine } from 'claude-code/testing'
import type PaneState from '../../../hooks/pane-state'
import { docksPane } from './docks-pane.js'
/**
* The pane docked beneath the plugin over one model, drawn once through the
* engine: the tree the engine took.
*
* @param $ the test's `$`
* @param on the test's `on`
* @param model the pane's model
* @returns the tree
*/
export const dockedPane = (
$: Engine,
on: On,
model: PaneState.PaneModel,
): Promise<RenderElement> => docksPane($, on)(model)
+43
View File
@@ -0,0 +1,43 @@
import type { On, RenderElement } from 'claude-code'
import type { Engine } from 'claude-code/testing'
import PaneState from '../../../hooks/pane-state'
import Views from '../../../hooks/views'
import { drawsPane } from './draws-pane.js'
import { VIEW_PANE } from './view-pane.js'
/**
* The pane seated beneath the plugin over whichever model the test hands
* next: docked on a 160-column terminal unless the overrides say inline.
*
* Each call draws VIEW_PANE through the engine with `paneView` over that
* model and answers the tree the engine took.
*
* @param $ the test's `$`
* @param on the test's `on`
* @param overrides kit members and the seat that differ from the ordinary
* @returns draws the pane over a model
*/
export function docksPane(
$: Engine,
on: On,
overrides: Partial<Omit<Views.Kit, 'ui'> & Views.PaneSeat> = {},
): (model: PaneState.PaneModel) => Promise<RenderElement> {
const {
placement = 'dock',
terminalColumns = VIEW_PANE.viewport?.columns ?? null,
...kit
} = overrides
const seat: Views.PaneSeat = { placement, terminalColumns }
let model = PaneState.INITIAL_MODEL
drawsPane(on, drawn => Views.paneView(drawn, model, seat), kit)
return next => {
model = next
return $.ui.render(VIEW_PANE)
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { On, RenderElement } from 'claude-code'
import { isOnPaneSurface } from '../../../hooks/is-on-pane-surface'
import type Views from '../../../hooks/views'
import { BODY_ROWS } from './body-rows.js'
import { COLUMNS } from './columns.js'
import { noopActionsOf } from './noop-actions-of.js'
/**
* Draws a pane beneath the plugin with a view of the test's, handed the kit
* every view takes.
*
* The hook resolves the surface's elements; the body size is the view
* fixtures' and the actions do nothing, unless overridden.
*
* @param on the test's `on`
* @param view draws the tree from the kit
* @param overrides kit members that differ from the ordinary ones
*/
export const drawsPane = (
on: On,
view: (kit: Views.Kit) => RenderElement,
overrides: Partial<Omit<Views.Kit, 'ui'>> = {},
) =>
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (!isOnPaneSurface(e)) {
return next(e)
}
const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
return view({
ui: { Box, Text, Button, Select, Code },
actions: noopActionsOf(),
columns: COLUMNS,
rows: BODY_ROWS,
...overrides,
})
})
+28
View File
@@ -0,0 +1,28 @@
export * from './big-body-of.js'
export * from './bodies-of.js'
export * from './body-rows.js'
export * from './builtin-headline-row.js'
export * from './columns.js'
export * from './config-detail-of.js'
export * from './cramped-rows.js'
export * from './dock-seat.js'
export * from './docked-pane.js'
export * from './docks-pane.js'
export * from './draws-pane.js'
export * from './long-line-chars.js'
export * from './long-path-segments.js'
export * from './narrow-seat.js'
export * from './noop.js'
export * from './noop-actions-of.js'
export * from './row-of.js'
export * from './row-shape-of.js'
export * from './row-shapes-of.js'
export * from './small-body.js'
export * from './totals.js'
export * from './tracked.js'
export * from './turn-two.js'
export * from './view-pane.js'
export * from './vs-spoofed.js'
export * from './whole-place.js'
export * as default from '.'
+5
View File
@@ -0,0 +1,5 @@
/**
* How long each line of the budget case runs: four hundred of them outgrow
* the body's character budget.
*/
export const LONG_LINE_CHARS = 300
@@ -0,0 +1,5 @@
/**
* How many directory segments make a selected path longer than the engine
* allows one string to be.
*/
export const LONG_PATH_SEGMENTS = 2_500
+11
View File
@@ -0,0 +1,11 @@
import Limits from '../../../hooks/limits'
import type Views from '../../../hooks/views'
/**
* A pane seated inline on a terminal one column short of the built-in
* panel's width.
*/
export const NARROW_SEAT: Views.PaneSeat = {
placement: 'inline',
terminalColumns: Limits.OPEN_MIN_COLUMNS - 1,
}
+17
View File
@@ -0,0 +1,17 @@
import type Views from '../../../hooks/views'
import { noop } from './noop.js'
/**
* Pane actions that each do nothing, for a tree drawn only to be read.
*
* @returns the actions
*/
export const noopActionsOf = (): Views.PaneActions => ({
selectFile: noop,
toggleNoise: noop,
togglePreSession: noop,
scrollList: noop,
cycleBase: noop,
chooseSource: noop,
toggleAsk: noop,
})
+7
View File
@@ -0,0 +1,7 @@
/**
* An action that does nothing, for a view whose actions a case never
* presses.
*
* @returns undefined
*/
export const noop = () => undefined
+22
View File
@@ -0,0 +1,22 @@
import type Git from '../../../hooks/git'
import { TRACKED } from './tracked.js'
/**
* A file row at a path: two added, one removed, tracked and in session,
* unless the overrides say otherwise.
*
* @param path the row's path
* @param overrides fields that differ from the ordinary row
* @returns the row
*/
export const rowOf = (
path: string,
overrides: Partial<Git.FileStat> = {},
): Git.FileStat => ({
path,
renamedFrom: null,
added: 2,
removed: 1,
...TRACKED,
...overrides,
})
+18
View File
@@ -0,0 +1,18 @@
import Tree from '../tree'
/**
* A pane row as the layout tests read it: `blank` for a bare one-row Box,
* `rule` for a line of rule characters, else its first label or its strings.
*
* @param node a row of the pane's body
* @returns the row's shape
*/
export function rowShapeOf(node: unknown) {
const text = Tree.jsonOf(node)
const strings = Tree.stringsOf(node).join('')
const label = /"label":"([^"]*)"/.exec(text)?.[1]
const isBlank = text === '{"type":"Box","props":{"height":1}}'
const isRule = /^─+$/.test(strings)
return isBlank ? 'blank' : isRule ? 'rule' : (label ?? strings)
}
+12
View File
@@ -0,0 +1,12 @@
import Tree from '../tree'
import { rowShapeOf } from './row-shape-of.js'
/**
* The rows of a pane's body, top to bottom, each as the layout tests read
* it (rowShapeOf).
*
* @param tree the pane's tree, its body the first child
* @returns each row's shape
*/
export const rowShapesOf = (tree: unknown) =>
Tree.childrenOf(Tree.childrenOf(tree)[0]).map(rowShapeOf)
+11
View File
@@ -0,0 +1,11 @@
import type Git from '../../../hooks/git'
/**
* A one-hunk body with a context line, a removal and an addition: enough
* to draw the word highlight.
*/
export const SMALL_BODY: Git.FileHunks = {
hunks: [{ oldStart: 1, newStart: 1, lines: [' ctx', '-old', '+new'] }],
isTruncated: false,
isLarge: false,
}
+11
View File
@@ -0,0 +1,11 @@
import type Git from '../../../hooks/git'
/**
* What every pane-view diff sums to; the file count is replaced by the
* number of rows a case gives.
*/
export const TOTALS: Git.DiffStats = {
filesCount: 1,
linesAdded: 9,
linesRemoved: 3,
}
+8
View File
@@ -0,0 +1,8 @@
/**
* The flags of an ordinary row: text, tracked, changed this session.
*/
export const TRACKED = {
isBinary: false,
isUntracked: false,
isPreSession: false,
} as const
+20
View File
@@ -0,0 +1,20 @@
import type Turns from '../../../hooks/turns'
/**
* The second turn of a session as the turns model keeps it: one created
* file, its hunk cut at the line cap.
*/
export const TURN_TWO: Turns.TurnDiff = {
index: 2,
preview: 'fix it',
files: [
{
path: '/r/a.ts',
hunks: [{ oldStart: 1, newStart: 1, lines: ['+x'] }],
added: 1,
removed: 0,
isNewFile: true,
isTruncated: true,
},
],
}
+25
View File
@@ -0,0 +1,25 @@
import type { RenderInput } from 'claude-code'
import { BODY_ROWS } from './body-rows.js'
import { COLUMNS } from './columns.js'
/**
* A pane the plugin does not own, docked on a 160-column terminal at the
* view fixtures' body size.
*
* Its render passes beneath the plugin to the hook a test draws a view in.
*/
export const VIEW_PANE: RenderInput<'Pane'> = {
component: 'Pane',
surface: 'terminal',
requestId: 'view',
viewport: { columns: 160, rows: 40 },
props: {
title: 'View',
isFocused: false,
bodyColumns: COLUMNS,
placement: 'dock',
scroll: { offset: 0, bodyRows: BODY_ROWS },
view: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
import type Git from '../../../hooks/git'
/**
* A branch comparison whose base branch name hides a right-to-left
* override, to prove the view strips format characters.
*/
export const VS_SPOOFED: Pick<Git.DiffData, 'mode' | 'source'> = {
mode: 'branch',
source: { kind: 'branch', baseBranch: 'ma\u202Eniam', baseRef: 'abc' },
}

Some files were not shown because too many files have changed in this diff Show More