mirror of
https://github.com/anthropics/claude-code.git
synced 2026-09-29 16:59:25 +08:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 '.'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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) })
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './feature-head.js'
|
||||
export * from './local-base.js'
|
||||
export * from './origin-base.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -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'
|
||||
Vendored
+11
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}'
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
+15
@@ -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
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
@@ -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']]
|
||||
@@ -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'],
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* A shortstat line for one staged file of three insertions.
|
||||
*/
|
||||
export const ONE_STAGED = ' 1 file changed, 3 insertions(+)'
|
||||
@@ -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'
|
||||
Vendored
+9
-5
@@ -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: '' }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* What the scripted `rev-parse` answers when the key falls back to git.
|
||||
*/
|
||||
export const HEAD_SHA = '0123456789abcdef0123456789abcdef01234567'
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './head-sha.js'
|
||||
export * from './stamp.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The mtime every stat in the HEAD-key cases answers.
|
||||
*/
|
||||
export const STAMP = 42
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Vendored
+15
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+29
-2
@@ -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 '.'
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './walk-budget.js'
|
||||
export * from './walk-depth.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* How many directory listings the kind walk may spend.
|
||||
*/
|
||||
export const WALK_BUDGET = 8
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The depth of the path that outruns any listing budget.
|
||||
*/
|
||||
export const WALK_DEPTH = 50_000
|
||||
+2
-2
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './at-sha.js'
|
||||
export * from './one-file.js'
|
||||
export * from './working-tree.js'
|
||||
|
||||
export * as default from '.'
|
||||
@@ -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',
|
||||
}
|
||||
Vendored
+2
-2
@@ -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',
|
||||
|
||||
Vendored
+10
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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]))
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The pane's body width in the ordinary cases.
|
||||
*/
|
||||
export const COLUMNS = 60
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 '.'
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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: {},
|
||||
},
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user