surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| @@ -1,0 +1,29 @@ | ||
| 1 | +import Git from '../git' | |
| 2 | +import type { Backend, BackendHost, BackendProbe } from './types' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The backend holding the session's directory: the first of `probes` that | |
| 6 | + * answers one, else git when a git working tree holds it, else none. The | |
| 7 | + * probes go first so a probe may claim a working copy by its own rule | |
| 8 | + * (the nearer of two markers, say); a probe answers null wherever git | |
| 9 | + * should govern. | |
| 10 | + * | |
| 11 | + * @param host the bound host's runner and probes | |
| 12 | + * @param probes probes for other version-control systems, in order | |
| 13 | + * (INSTALLED_BACKEND_PROBES at the pane; none as published) | |
| 14 | + * @returns the pinned backend, or null outside any working tree | |
| 15 | + */ | |
| 16 | +export async function backendOf( | |
| 17 | + host: BackendHost, | |
| 18 | + probes: Iterable<BackendProbe>, | |
| 19 | +): Promise<Backend | null> { | |
| 20 | + for (const probe of probes) { | |
| 21 | + const backend = await probe(host) | |
| 22 | + | |
| 23 | + if (backend) { | |
| 24 | + return backend | |
| 25 | + } | |
| 26 | + } | |
| 27 | + | |
| 28 | + return Git.gitBackendOf(host) | |
| 29 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +export * from './backend-of.js' | |
| 2 | +export * from './installed-backend-probes.js' | |
| 3 | +export * from './types' | |
| 4 | + | |
| 5 | +export * as default from '.' |
| @@ -1,0 +1,15 @@ | ||
| 1 | +import type { BackendProbe } from './types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The plugin's backend-extension point: probes for version-control systems | |
| 5 | + * other than git, consulted in insertion order before git (backendOf); a | |
| 6 | + * probe answers null wherever git should govern. Empty as shipped, so the | |
| 7 | + * plugin reads git alone. A host | |
| 8 | + * that compiles the module in may add a probe once, before the plugin | |
| 9 | + * registers; the module runs natively, so that probe is what | |
| 10 | + * `session.start` sees. The module contract offers no other channel: the | |
| 11 | + * options `register` receives are the manifest's userConfig, and the | |
| 12 | + * shipped scan requires `register` itself to be a plain exported function. | |
| 13 | + */ | |
| 14 | +// module-state-allow: module-evaluation code wiring — a compiling host adds its probe functions (code references, not runtime input) once before registration and never removes or rebinds them; tests pass probes to backendOf directly; a per-Host copy would hold the same functions | |
| 15 | +export const INSTALLED_BACKEND_PROBES = new Set<BackendProbe>() |
| @@ -1,0 +1,46 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import type { BackendWords } from '../backend-words' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * The version-control system behind the pane, pinned once per session: | |
| 6 | + * where its working tree is, which comparisons it offers, how it words | |
| 7 | + * them, and the three reads the pane makes of it. | |
| 8 | + * | |
| 9 | + * Git is the plugin's own backend; a host may install probes for others, | |
| 10 | + * consulted where no git working tree holds the directory (backendOf). | |
| 11 | + */ | |
| 12 | +export type Backend = { | |
| 13 | + /** | |
| 14 | + * The working tree's top; every row's path is relative to it. | |
| 15 | + */ | |
| 16 | + repository: Pick<Git.Repository, 'toplevel'> | |
| 17 | + | |
| 18 | + /** | |
| 19 | + * The base modes the picker offers, in picker order. | |
| 20 | + */ | |
| 21 | + baseModes: readonly Git.BaseMode[] | |
| 22 | + | |
| 23 | + /** | |
| 24 | + * The pane's words for what this backend compares and runs. | |
| 25 | + */ | |
| 26 | + words: BackendWords | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * One fetch of the working tree against the mode's base (Git.fetchDiff). | |
| 30 | + */ | |
| 31 | + fetchDiff: (mode: Git.BaseMode) => Promise<Git.FetchOutcome> | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * One row's hunks against the base its fetch read (Git.fetchFileHunks). | |
| 35 | + */ | |
| 36 | + fetchFileHunks: ( | |
| 37 | + data: Git.DiffData, | |
| 38 | + file: Git.FileStat, | |
| 39 | + ) => Promise<Git.FileHunks | null> | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * A string that changes when the checked-out commit moves | |
| 43 | + * (Git.headKeyOf); empty when it cannot be read this tick. | |
| 44 | + */ | |
| 45 | + headKeyOf: () => Promise<string> | |
| 46 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './backend.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,22 @@ | ||
| 1 | +import type Git from '../../../git' | |
| 2 | +import type Host from '../../../host' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * What pinning a backend takes from the bound host: the raw process | |
| 6 | + * runner (each backend names its own program, argv lead, environment and | |
| 7 | + * timeout), the file probes, the engine's clock, the session's start as it | |
| 8 | + * stands at each fetch (a later `session.start` moves it), and the | |
| 9 | + * branch-base listener. | |
| 10 | + */ | |
| 11 | +export type BackendHost = Pick<Host.Host, 'run' | 'readFile'> & | |
| 12 | + Pick<Git.GitDeps, 'mtimeOf' | 'entryKindsOf' | 'onBranchBase'> & { | |
| 13 | + /** | |
| 14 | + * The engine's clock, in milliseconds (`$.clock.now`). | |
| 15 | + */ | |
| 16 | + nowMs: () => number | |
| 17 | + | |
| 18 | + /** | |
| 19 | + * When the current session began, read at each fetch. | |
| 20 | + */ | |
| 21 | + sessionStartMsOf: () => number | |
| 22 | + } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './backend-host.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,9 @@ | ||
| 1 | +import type { Backend } from '../backend' | |
| 2 | +import type { BackendHost } from '../backend-host' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Probes the session's directory for one backend's working tree: the | |
| 6 | + * backend when it holds the directory, null when it does not or when git | |
| 7 | + * should govern it instead (Git.gitBackendOf is git's). | |
| 8 | + */ | |
| 9 | +export type BackendProbe = (host: BackendHost) => Promise<Backend | null> |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './backend-probe.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,27 @@ | ||
| 1 | +/** | |
| 2 | + * The words the pane draws that name the backend: what uncommitted mode | |
| 3 | + * compares against, the command whose failure the empty state names, the | |
| 4 | + * tool that lists untracked files, and the note under an untracked row. | |
| 5 | + */ | |
| 6 | +export type BackendWords = { | |
| 7 | + /** | |
| 8 | + * What the working tree is compared with outside branch mode: `HEAD`. | |
| 9 | + */ | |
| 10 | + base: string | |
| 11 | + | |
| 12 | + /** | |
| 13 | + * The command a failed fetch could not read: `git diff`. | |
| 14 | + */ | |
| 15 | + diffCommand: string | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * The program that lists untracked files: `git`. | |
| 19 | + */ | |
| 20 | + lister: string | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * The dim lines under an untracked row: what it is, and how to get its | |
| 24 | + * line counts (a pasteable command only when the path is safe to paste). | |
| 25 | + */ | |
| 26 | + untrackedNoteOf: (path: string, isPasteable: boolean) => readonly string[] | |
| 27 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './backend-words.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,6 @@ | ||
| 1 | +export * from './backend' | |
| 2 | +export * from './backend-host' | |
| 3 | +export * from './backend-probe' | |
| 4 | +export * from './backend-words' | |
| 5 | + | |
| 6 | +export * as default from '.' |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What a child that never ran, was killed, or timed out reads as: exit code | |
| 5 | + * -1 and no output, so a runner never rejects (GitRun). | |
| 6 | + */ | |
| 7 | +export const FAILED_RUN: Types.RunResult = Object.freeze({ | |
| 8 | + exitCode: -1, | |
| 9 | + stdout: '', | |
| 10 | + stderr: '', | |
| 11 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './failed-run.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,66 @@ | ||
| 1 | +import type Backend from '../backend' | |
| 2 | +import Limits from '../limits' | |
| 3 | +import Argv from './argv' | |
| 4 | +import { FAILED_RUN } from './failed-run' | |
| 5 | +import { fetchDiff } from './fetch-diff.js' | |
| 6 | +import { fetchFileHunks } from './fetch-file-hunks.js' | |
| 7 | +import { GIT_BASE_MODES } from './git-base-modes' | |
| 8 | +import { GIT_WORDS } from './git-words' | |
| 9 | +import Probes from './probes' | |
| 10 | +import type Types from './types' | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * The git backend over the session's directory: the repository read with | |
| 14 | + * one `rev-parse` in that directory, then every child pinned to it | |
| 15 | + * (`--git-dir`, `--work-tree`, cwd the top) under the C locale and the | |
| 16 | + * fetch's timeout (gitDiff.ts execPinnedGit). | |
| 17 | + * | |
| 18 | + * @param host the bound host's runner and probes | |
| 19 | + * @returns the backend, or null outside a git working tree | |
| 20 | + */ | |
| 21 | +export async function gitBackendOf( | |
| 22 | + host: Backend.BackendHost, | |
| 23 | +): Promise<Backend.Backend | null> { | |
| 24 | + const runOf = | |
| 25 | + (pinned: Types.Repository | null): Types.GitRun => | |
| 26 | + async argv => { | |
| 27 | + try { | |
| 28 | + return await host.run( | |
| 29 | + ['git', ...(pinned ? Argv.pinnedLeadOf(pinned) : []), ...argv], | |
| 30 | + { | |
| 31 | + timeoutMs: Limits.GIT_TIMEOUT_MS, | |
| 32 | + env: Argv.GIT_CHILD_ENV, | |
| 33 | + ...(pinned && { cwd: pinned.toplevel }), | |
| 34 | + }, | |
| 35 | + ) | |
| 36 | + } catch { | |
| 37 | + return FAILED_RUN | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + const repository = await Probes.repositoryOf(runOf(null)) | |
| 42 | + | |
| 43 | + if (!repository) { | |
| 44 | + return null | |
| 45 | + } | |
| 46 | + | |
| 47 | + const run = runOf(repository) | |
| 48 | + const depsOf = (): Types.GitDeps => ({ | |
| 49 | + run, | |
| 50 | + repository, | |
| 51 | + mtimeOf: host.mtimeOf, | |
| 52 | + entryKindsOf: host.entryKindsOf, | |
| 53 | + sessionStartMs: host.sessionStartMsOf(), | |
| 54 | + onBranchBase: host.onBranchBase, | |
| 55 | + }) | |
| 56 | + | |
| 57 | + return { | |
| 58 | + repository, | |
| 59 | + baseModes: GIT_BASE_MODES, | |
| 60 | + words: GIT_WORDS, | |
| 61 | + fetchDiff: mode => fetchDiff(depsOf(), mode), | |
| 62 | + fetchFileHunks: (data, file) => fetchFileHunks(run, data, file), | |
| 63 | + headKeyOf: () => | |
| 64 | + Probes.headKeyOf({ ...depsOf(), readFile: host.readFile }, repository), | |
| 65 | + } | |
| 66 | +} |
| @@ -1,0 +1,10 @@ | ||
| 1 | +import type Types from '../types' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The comparisons git offers, in picker order: all three. | |
| 5 | + */ | |
| 6 | +export const GIT_BASE_MODES: readonly Types.BaseMode[] = Object.freeze([ | |
| 7 | + 'session', | |
| 8 | + 'uncommitted', | |
| 9 | + 'branch', | |
| 10 | +]) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './git-base-modes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,17 @@ | ||
| 1 | +import type Backend from '../../backend' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * The pane's git words: compared with HEAD, read by `git diff`, untracked | |
| 5 | + * files listed by git and staged with `git add ':/…'` (DiffDetailView's). | |
| 6 | + */ | |
| 7 | +export const GIT_WORDS: Backend.BackendWords = Object.freeze({ | |
| 8 | + base: 'HEAD', | |
| 9 | + diffCommand: 'git diff', | |
| 10 | + lister: 'git', | |
| 11 | + untrackedNoteOf: (path: string, isPasteable: boolean) => [ | |
| 12 | + 'New file not yet staged.', | |
| 13 | + isPasteable | |
| 14 | + ? `Run \`git add ':/${path}'\` to see line counts.` | |
| 15 | + : 'Stage it with git add to see line counts.', | |
| 16 | + ], | |
| 17 | +}) |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './git-words.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | export * from './argv' |
| 2 | 2 | export * from './cut-hunks' |
| 3 | 3 | export * from './empty-file-hunks' |
| 4 | +export * from './failed-run' | |
| 4 | 5 | export * from './fetch-diff.js' |
| 5 | 6 | export * from './fetch-file-hunks.js' |
| 7 | +export * from './git-backend-of.js' | |
| 8 | +export * from './git-base-modes' | |
| 9 | +export * from './git-words' | |
| 6 | 10 | export * from './hunk-of' |
| 7 | 11 | export * from './large-file-hunks' |
| 8 | 12 | export * from './parse' |
| @@ -3,15 +3,17 @@ | ||
| 3 | 3 | import { MOST_UTF8_BYTES } from './most-utf8-bytes' |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | - * Whether a `-z` listing arrived whole: every record NUL-terminated and | |
| 7 | - * the text short of the host's output cap, so no record was cut or lost. | |
| 6 | + * Whether a listing arrived whole: every record terminated (NUL for git's | |
| 7 | + * `-z` listings, a newline for line records) and the text short of the | |
| 8 | + * host's output cap, so no record was cut or lost. | |
| 8 | 9 | * |
| 9 | 10 | * A cut inside a multi-byte character costs the decoded text up to |
| 10 | 11 | * MOST_UTF8_BYTES, hence the margin under the cap. |
| 11 | 12 | * |
| 12 | 13 | * @param stdout the listing as `$.process.run` returned it |
| 14 | + * @param terminator what ends every record; NUL when absent | |
| 13 | 15 | * @returns whether it can be parsed as the whole answer |
| 14 | 16 | */ |
| 15 | -export const isWholeListing = (stdout: string) => | |
| 16 | - (stdout === '' || stdout.endsWith('\0')) && | |
| 17 | +export const isWholeListing = (stdout: string, terminator = '\0') => | |
| 18 | + (stdout === '' || stdout.endsWith(terminator)) && | |
| 17 | 19 | isUnderUtf8Ceiling(stdout, Limits.HOST_OUTPUT_CAP_BYTES - MOST_UTF8_BYTES) |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | export * from './transient-state-files' |
| 23 | 23 | export * from './unstaged-numstat.js' |
| 24 | 24 | export * from './untracked-files' |
| 25 | +export * from './untracked-rows-of' | |
| 25 | 26 | export * from './untracked-place' |
| 26 | 27 | |
| 27 | 28 | export * as default from '.' |
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | * @returns the rows, tagged |
| 14 | 14 | */ |
| 15 | 15 | export async function tagPreSession( |
| 16 | - context: Pick<Types.FetchContext, 'deps' | 'stamps'>, | |
| 16 | + context: Types.DatingContext, | |
| 17 | 17 | files: readonly Types.FileStat[], |
| 18 | 18 | ): Promise<readonly Types.FileStat[]> { |
| 19 | 19 | const datings = await datingsOf( |
| @@ -1,25 +1,20 @@ | ||
| 1 | -import Limits from '../../../limits' | |
| 2 | 1 | import Argv from '../../argv' |
| 3 | 2 | import GitParse from '../../parse' |
| 4 | 3 | import type Types from '../../types' |
| 5 | -import { datingsOf } from '../datings-of' | |
| 6 | 4 | import type { UntrackedPlace } from '../untracked-place' |
| 5 | +import { untrackedRowsOf } from '../untracked-rows-of' | |
| 7 | 6 | |
| 8 | 7 | /** |
| 9 | - * The untracked, unignored files as 0/0 rows: the session's own first, | |
| 10 | - * then, when the scope asks, the tagged pre-session ones, up to the slots. | |
| 8 | + * The untracked, unignored files as 0/0 rows (untrackedRowsOf), listed | |
| 9 | + * with `ls-files -z`. A listing not given whole: no answer. | |
| 11 | 10 | * |
| 12 | - * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the | |
| 13 | - * listing budget reads as pre-session, an undatable one as the session's | |
| 14 | - * (the built-in panel's rule). A listing not given whole: no answer. | |
| 15 | - * | |
| 16 | 11 | * @param context the fetch: its pinned git, stamp probe and session start |
| 17 | 12 | * @param place the free row slots and the scope |
| 18 | 13 | * @returns the rows to merge after the tracked ones, or null when the |
| 19 | 14 | * `-z` listing failed, timed out or was cut |
| 20 | 15 | */ |
| 21 | 16 | export async function untrackedFiles( |
| 22 | - context: Pick<Types.FetchContext, 'run' | 'deps' | 'stamps'>, | |
| 17 | + context: Pick<Types.FetchContext, 'run'> & Types.DatingContext, | |
| 23 | 18 | place: UntrackedPlace, |
| 24 | 19 | ): Promise<readonly Types.FileStat[] | null> { |
| 25 | 20 | if (place.slots <= 0) { |
| @@ -40,34 +35,6 @@ | ||
| 40 | 35 | } |
| 41 | 36 | |
| 42 | 37 | const paths = listing.stdout.split('\0').filter(path => path !== '') |
| 43 | - const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) | |
| 44 | - const datings = await datingsOf( | |
| 45 | - context.stamps, | |
| 46 | - context.deps.sessionStartMs, | |
| 47 | - probedPaths, | |
| 48 | - ) | |
| 49 | - const probed = probedPaths.map(path => ({ | |
| 50 | - path, | |
| 51 | - isPreSession: datings.get(path) !== 'session', | |
| 52 | - })) | |
| 53 | - const isWithPreSession = place.scope === 'with-pre-session' | |
| 54 | - const earlier = isWithPreSession | |
| 55 | - ? [ | |
| 56 | - ...probed.filter(file => file.isPreSession), | |
| 57 | - ...paths | |
| 58 | - .slice(Limits.MAX_UNTRACKED_PROBES) | |
| 59 | - .map(path => ({ path, isPreSession: true })), | |
| 60 | - ] | |
| 61 | - : [] | |
| 62 | - const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] | |
| 63 | 38 | |
| 64 | - return ordered.slice(0, place.slots).map(file => ({ | |
| 65 | - path: file.path, | |
| 66 | - renamedFrom: null, | |
| 67 | - added: 0, | |
| 68 | - removed: 0, | |
| 69 | - isBinary: false, | |
| 70 | - isUntracked: true, | |
| 71 | - isPreSession: file.isPreSession, | |
| 72 | - })) | |
| 39 | + return untrackedRowsOf(context, paths, place) | |
| 73 | 40 | } |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './untracked-rows-of.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,54 @@ | ||
| 1 | +import Limits from '../../../limits' | |
| 2 | +import type Types from '../../types' | |
| 3 | +import { datingsOf } from '../datings-of' | |
| 4 | +import type { UntrackedPlace } from '../untracked-place' | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Listed untracked paths as 0/0 rows: the session's own first, then, | |
| 8 | + * when the scope asks, the tagged pre-session ones, up to the slots. | |
| 9 | + * | |
| 10 | + * At most MAX_UNTRACKED_PROBES are dated; a file past that or past the | |
| 11 | + * listing budget reads as pre-session, an undatable one as the session's | |
| 12 | + * (the built-in panel's rule). | |
| 13 | + * | |
| 14 | + * @param context the fetch's stamp probe and session start | |
| 15 | + * @param paths the untracked paths, root-relative, as the lister printed them | |
| 16 | + * @param place the free row slots and the scope | |
| 17 | + * @returns the rows to merge after the tracked ones | |
| 18 | + */ | |
| 19 | +export async function untrackedRowsOf( | |
| 20 | + context: Types.DatingContext, | |
| 21 | + paths: readonly string[], | |
| 22 | + place: UntrackedPlace, | |
| 23 | +): Promise<readonly Types.FileStat[]> { | |
| 24 | + const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) | |
| 25 | + const datings = await datingsOf( | |
| 26 | + context.stamps, | |
| 27 | + context.deps.sessionStartMs, | |
| 28 | + probedPaths, | |
| 29 | + ) | |
| 30 | + const probed = probedPaths.map(path => ({ | |
| 31 | + path, | |
| 32 | + isPreSession: datings.get(path) !== 'session', | |
| 33 | + })) | |
| 34 | + const isWithPreSession = place.scope === 'with-pre-session' | |
| 35 | + const earlier = isWithPreSession | |
| 36 | + ? [ | |
| 37 | + ...probed.filter(file => file.isPreSession), | |
| 38 | + ...paths | |
| 39 | + .slice(Limits.MAX_UNTRACKED_PROBES) | |
| 40 | + .map(path => ({ path, isPreSession: true })), | |
| 41 | + ] | |
| 42 | + : [] | |
| 43 | + const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] | |
| 44 | + | |
| 45 | + return ordered.slice(0, place.slots).map(file => ({ | |
| 46 | + path: file.path, | |
| 47 | + renamedFrom: null, | |
| 48 | + added: 0, | |
| 49 | + removed: 0, | |
| 50 | + isBinary: false, | |
| 51 | + isUntracked: true, | |
| 52 | + isPreSession: file.isPreSession, | |
| 53 | + })) | |
| 54 | +} |
| @@ -20,6 +20,6 @@ | ||
| 20 | 20 | case 'head-is-base': |
| 21 | 21 | return { kind: 'branch', baseBranch: base.baseBranch, baseRef: 'HEAD' } |
| 22 | 22 | case 'none': |
| 23 | - return { kind: 'working-tree' } | |
| 23 | + return { kind: 'working-tree', base: 'HEAD' } | |
| 24 | 24 | } |
| 25 | 25 | } |
| @@ -48,7 +48,7 @@ | ||
| 48 | 48 | return { |
| 49 | 49 | kind: 'data', |
| 50 | 50 | data: diffDataOf(context, merged, { |
| 51 | - source: { kind: 'working-tree' }, | |
| 51 | + source: { kind: 'working-tree', base: 'HEAD' }, | |
| 52 | 52 | baseRef: '--cached', |
| 53 | 53 | isUnborn: true, |
| 54 | 54 | stalePaths: unstaged.files.map(file => file.path), |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export * from './merged-result-of' |
| 2 | +export * from './merged-with-untracked' | |
| 2 | 3 | export * from './with-untracked.js' |
| 3 | 4 | |
| 4 | 5 | export * as default from '.' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './merged-with-untracked.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,32 @@ | ||
| 1 | +import type Types from '../../../types' | |
| 2 | +import { mergedResultOf } from '../merged-result-of' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * A tier's tracked rows and totals with a listed set of untracked rows | |
| 6 | + * after them (gitDiff.ts mergeSessionUntrackedFiles), or flagged withheld | |
| 7 | + * when the lister gave no whole listing. | |
| 8 | + * | |
| 9 | + * A path a tracked row already holds keeps that row and counts once. | |
| 10 | + * | |
| 11 | + * @param result the tier's tracked rows and totals | |
| 12 | + * @param untracked the untracked rows, or null when their listing was withheld | |
| 13 | + * @returns the merged rows and totals, and whether the listing was withheld | |
| 14 | + */ | |
| 15 | +export function mergedWithUntracked( | |
| 16 | + result: Types.NumstatResult, | |
| 17 | + untracked: readonly Types.FileStat[] | null, | |
| 18 | +): Types.MergedResult { | |
| 19 | + if (!untracked) { | |
| 20 | + return mergedResultOf(result, { isUntrackedWithheld: true }) | |
| 21 | + } | |
| 22 | + | |
| 23 | + const tracked = new Set(result.files.map(file => file.path)) | |
| 24 | + const fresh = untracked.filter(file => !tracked.has(file.path)) | |
| 25 | + const filesCount = result.stats.filesCount + fresh.length | |
| 26 | + const stats = { ...result.stats, filesCount } | |
| 27 | + | |
| 28 | + return mergedResultOf( | |
| 29 | + { stats, files: [...result.files, ...fresh] }, | |
| 30 | + { isUntrackedWithheld: false }, | |
| 31 | + ) | |
| 32 | +} |
| @@ -3,14 +3,14 @@ | ||
| 3 | 3 | import type Types from '../../types' |
| 4 | 4 | import { isPastDetails } from '../is-past-details' |
| 5 | 5 | import { mergedResultOf } from './merged-result-of' |
| 6 | +import { mergedWithUntracked } from './merged-with-untracked' | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | - * A tier's rows with the untracked files merged after them, as the built-in | |
| 9 | - * panel merges the session's untracked files. | |
| 9 | + * A tier's rows with the untracked files merged after them | |
| 10 | + * (mergedWithUntracked). | |
| 10 | 11 | * |
| 11 | - * A path a numstat row already holds keeps that row and counts once. Past | |
| 12 | - * MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld leaves | |
| 13 | - * the tracked rows as they are and says so, as the built-in degrades. | |
| 12 | + * Past MAX_FILES_FOR_DETAILS nothing is listed; a listing git withheld | |
| 13 | + * leaves the tracked rows as they are and says so, as the built-in degrades. | |
| 14 | 14 | * |
| 15 | 15 | * @param context the fetch |
| 16 | 16 | * @param result the tier's tracked rows and totals |
| @@ -26,22 +26,11 @@ | ||
| 26 | 26 | return mergedResultOf(result, { isUntrackedWithheld: false }) |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | - const tracked = new Set(result.files.map(file => file.path)) | |
| 30 | - const untracked = await Probes.untrackedFiles(context, { | |
| 31 | - slots: Limits.MAX_FILES - result.files.length, | |
| 32 | - scope, | |
| 33 | - }) | |
| 34 | - | |
| 35 | - if (!untracked) { | |
| 36 | - return mergedResultOf(result, { isUntrackedWithheld: true }) | |
| 37 | - } | |
| 38 | - | |
| 39 | - const fresh = untracked.filter(file => !tracked.has(file.path)) | |
| 40 | - const filesCount = result.stats.filesCount + fresh.length | |
| 41 | - const stats = { ...result.stats, filesCount } | |
| 42 | - | |
| 43 | - return mergedResultOf( | |
| 44 | - { stats, files: [...result.files, ...fresh] }, | |
| 45 | - { isUntrackedWithheld: false }, | |
| 29 | + return mergedWithUntracked( | |
| 30 | + result, | |
| 31 | + await Probes.untrackedFiles(context, { | |
| 32 | + slots: Limits.MAX_FILES - result.files.length, | |
| 33 | + scope, | |
| 34 | + }), | |
| 46 | 35 | ) |
| 47 | 36 | } |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * every body current. |
| 6 | 6 | */ |
| 7 | 7 | export const WORKING_TREE_BASIS: DiffDataOf.DiffBasis = Object.freeze({ |
| 8 | - source: Object.freeze({ kind: 'working-tree' }), | |
| 8 | + source: Object.freeze({ kind: 'working-tree', base: 'HEAD' }), | |
| 9 | 9 | baseRef: 'HEAD', |
| 10 | 10 | isUnborn: false, |
| 11 | 11 | stalePaths: Object.freeze([]), |
| @@ -1,0 +1,11 @@ | ||
| 1 | +import type { GitDeps } from '../git-deps' | |
| 2 | +import type { StampProbe } from '../stamp-probe' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * What dating a path against the session takes: the session's start and | |
| 6 | + * one stamp probe over the working tree's top. | |
| 7 | + */ | |
| 8 | +export type DatingContext = { | |
| 9 | + deps: Pick<GitDeps, 'sessionStartMs'> | |
| 10 | + stamps: StampProbe | |
| 11 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './dating-context.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -6,14 +6,15 @@ | ||
| 6 | 6 | |
| 7 | 7 | /** |
| 8 | 8 | * One settled fetch: the rows, the totals, what they compare, and what a |
| 9 | - * file's hunks must be read against so both agree. | |
| 9 | + * file's hunks must be read against so both agree, over the working tree's | |
| 10 | + * top (every backend has one; git's other directories stay its own). | |
| 10 | 11 | * |
| 11 | 12 | * `isUnborn`: a repository before its first commit (rows are the staged |
| 12 | 13 | * and new files, `baseRef` is `--cached`); `stalePaths`: files whose staged |
| 13 | 14 | * body is behind the working tree; `isUntrackedWithheld`: MergedResult's. |
| 14 | 15 | */ |
| 15 | 16 | export type DiffData = { |
| 16 | - repository: Repository | |
| 17 | + repository: Pick<Repository, 'toplevel'> | |
| 17 | 18 | mode: BaseMode |
| 18 | 19 | stats: DiffStats |
| 19 | 20 | files: readonly FileStat[] |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | - * What a fetched diff compares: the working tree against HEAD, or against | |
| 3 | - * the merge-base with the default branch (`baseRef` the ref git was handed). | |
| 2 | + * What a fetched diff compares: the working tree against its checked-out | |
| 3 | + * commit (`base` names it as the pane should: `HEAD`, or a short sha where | |
| 4 | + * the backend has no such word), or against the merge-base with the default | |
| 5 | + * branch (`baseRef` the ref git was handed). | |
| 4 | 6 | */ |
| 5 | 7 | export type DiffSource = |
| 6 | - | { kind: 'working-tree' } | |
| 8 | + | { kind: 'working-tree'; base: string } | |
| 7 | 9 | | { kind: 'branch'; baseBranch: string; baseRef: string } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | export * from './base-mode' |
| 2 | 2 | export * from './branch-base' |
| 3 | 3 | export * from './dating' |
| 4 | +export * from './dating-context' | |
| 4 | 5 | export * from './diff-data' |
| 5 | 6 | export * from './diff-source' |
| 6 | 7 | export * from './diff-stats' |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | export * from './array-of' |
| 2 | 2 | export * from './ask' |
| 3 | +export * from './backend' | |
| 3 | 4 | export * from './classify' |
| 4 | 5 | export * from './command-spec' |
| 5 | 6 | export * from './count-of' |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | - * The pane's word for a fetch whose untracked listing git did not give | |
| 3 | - * whole: the tracked rows stand, the new files are not counted. | |
| 2 | + * The pane's word for a fetch whose untracked listing the backend did not | |
| 3 | + * give whole: the tracked rows stand, the new files are not counted. | |
| 4 | + * | |
| 5 | + * @param lister the program that lists untracked files (BackendWords) | |
| 6 | + * @returns the note | |
| 4 | 7 | */ |
| 5 | -export const UNTRACKED_WITHHELD_TEXT = | |
| 6 | - 'Untracked files unavailable (git could not list them); not counted' | |
| 8 | +export const untrackedWithheldTextOf = (lister: string) => | |
| 9 | + `Untracked files unavailable (${lister} could not list them); not counted` |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import type Backend from '../backend' | |
| 1 | 2 | import type Git from '../git' |
| 2 | 3 | import { modeLabelOf } from './mode-label-of' |
| 3 | 4 | |
| @@ -12,12 +13,14 @@ | ||
| 12 | 13 | * @param requested the mode the person picked |
| 13 | 14 | * @param data the last good fetch |
| 14 | 15 | * @param filesCount the header's session file count |
| 16 | + * @param words the backend's words | |
| 15 | 17 | * @returns the line, or null |
| 16 | 18 | */ |
| 17 | 19 | export function baseLabelOf( |
| 18 | 20 | requested: Git.BaseMode, |
| 19 | 21 | data: Git.DiffData | null, |
| 20 | 22 | filesCount: number, |
| 23 | + words: Pick<Backend.BackendWords, 'base'>, | |
| 21 | 24 | ): string | null { |
| 22 | 25 | if (!data) { |
| 23 | 26 | return null |
| @@ -37,7 +40,7 @@ | ||
| 37 | 40 | } |
| 38 | 41 | |
| 39 | 42 | const phase = isPending ? 'pending' : 'settled' |
| 40 | - const label = modeLabelOf(requested, data.source, phase) | |
| 43 | + const label = modeLabelOf(requested, data.source, phase, words) | |
| 41 | 44 | |
| 42 | 45 | return isPending ? `${label}…` : label |
| 43 | 46 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import type Backend from '../backend' | |
| 1 | 2 | import type Git from '../git' |
| 2 | 3 | import Names from '../names' |
| 3 | 4 | import type { EmptyState } from './empty-state' |
| @@ -12,16 +13,18 @@ | ||
| 12 | 13 | * |
| 13 | 14 | * @param data the last good fetch, or null when none ever settled |
| 14 | 15 | * @param filesCount the header's session file count |
| 16 | + * @param words the backend's words | |
| 15 | 17 | * @returns the empty state, or null |
| 16 | 18 | */ |
| 17 | 19 | export function emptyStateOf( |
| 18 | 20 | data: Git.DiffData | null, |
| 19 | 21 | filesCount: number, |
| 22 | + words: Backend.BackendWords, | |
| 20 | 23 | ): EmptyState | null { |
| 21 | 24 | if (!data) { |
| 22 | 25 | return { |
| 23 | 26 | headline: 'Diff unavailable', |
| 24 | - hint: "Couldn't read the git diff — it will retry on the next change", | |
| 27 | + hint: `Couldn't read the ${words.diffCommand} — it will retry on the next change`, | |
| 25 | 28 | } |
| 26 | 29 | } |
| 27 | 30 | |
| @@ -32,7 +35,7 @@ | ||
| 32 | 35 | if (data.isUntrackedWithheld) { |
| 33 | 36 | return { |
| 34 | 37 | headline: 'No tracked changes', |
| 35 | - hint: Names.UNTRACKED_WITHHELD_TEXT, | |
| 38 | + hint: Names.untrackedWithheldTextOf(words.lister), | |
| 36 | 39 | } |
| 37 | 40 | } |
| 38 | 41 | |
| @@ -55,8 +58,10 @@ | ||
| 55 | 58 | } |
| 56 | 59 | |
| 57 | 60 | return { |
| 58 | - headline: 'No changes vs HEAD', | |
| 59 | - hint: 'No base branch to compare against — showing changes vs HEAD', | |
| 61 | + headline: `No changes vs ${data.source.base}`, | |
| 62 | + hint: | |
| 63 | + 'No base branch to compare against — showing changes vs ' + | |
| 64 | + data.source.base, | |
| 60 | 65 | } |
| 61 | 66 | case 'session': |
| 62 | 67 | return { headline: 'No changes this session', hint: null } |
| @@ -1,10 +1,13 @@ | ||
| 1 | +import Git from '../../git' | |
| 1 | 2 | import type { PaneModel } from '../pane-model' |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * The pane before anything happened: session mode, nothing fetched, |
| 5 | - * nothing picked, the current source. | |
| 6 | + * nothing picked, the current source, git's words. | |
| 6 | 7 | */ |
| 7 | 8 | export const INITIAL_MODEL: PaneModel = Object.freeze({ |
| 9 | + words: Git.GIT_WORDS, | |
| 10 | + baseModes: Git.GIT_BASE_MODES, | |
| 8 | 11 | isLoading: false, |
| 9 | 12 | hasSettled: false, |
| 10 | 13 | isOutsideRepository: false, |
| @@ -1,29 +1,36 @@ | ||
| 1 | +import type Backend from '../../backend' | |
| 1 | 2 | import type Git from '../../git' |
| 2 | 3 | |
| 3 | 4 | /** |
| 4 | 5 | * A requested mode's name for the base line; branch mode names its base |
| 5 | - * branch from the data, or says what stands in while none is known. | |
| 6 | + * branch from the data, or says what stands in while none is known. A | |
| 7 | + * working-tree diff names its own base (`HEAD`, a short sha); anything | |
| 8 | + * else falls back to the backend's word for it. | |
| 6 | 9 | * |
| 7 | 10 | * @param requested the mode the person picked |
| 8 | 11 | * @param source what the data on screen compares |
| 9 | 12 | * @param phase `pending` while the requested mode's fetch has not landed |
| 13 | + * @param words the backend's words | |
| 10 | 14 | * @returns the label without its pending ellipsis |
| 11 | 15 | */ |
| 12 | 16 | export function modeLabelOf( |
| 13 | 17 | requested: Git.BaseMode, |
| 14 | 18 | source: Git.DiffSource, |
| 15 | 19 | phase: 'pending' | 'settled', |
| 20 | + words: Pick<Backend.BackendWords, 'base'>, | |
| 16 | 21 | ) { |
| 22 | + const base = source.kind === 'working-tree' ? source.base : words.base | |
| 23 | + | |
| 17 | 24 | switch (requested) { |
| 18 | 25 | case 'session': |
| 19 | 26 | return 'this session' |
| 20 | 27 | case 'uncommitted': |
| 21 | - return 'uncommitted (vs HEAD)' | |
| 28 | + return `uncommitted (vs ${base})` | |
| 22 | 29 | case 'branch': |
| 23 | 30 | if (source.kind === 'branch') { |
| 24 | 31 | return `branch vs ${source.baseBranch}` |
| 25 | 32 | } |
| 26 | 33 | |
| 27 | - return phase === 'pending' ? 'branch diff' : 'vs HEAD (no base branch)' | |
| 34 | + return phase === 'pending' ? 'branch diff' : `vs ${base} (no base branch)` | |
| 28 | 35 | } |
| 29 | 36 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import type Backend from '../../backend' | |
| 1 | 2 | import type Git from '../../git' |
| 2 | 3 | import type Todos from '../../todos' |
| 3 | 4 | import type Turns from '../../turns' |
| @@ -6,9 +7,12 @@ | ||
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | 9 | * Everything one drawing of the pane reads: the last good fetch, what the |
| 9 | - * person picked, the selected file's body, the transcript's turns and todos. | |
| 10 | + * person picked, the selected file's body, the transcript's turns and todos, | |
| 11 | + * and the pinned backend's words and base modes (git's until one is pinned). | |
| 10 | 12 | */ |
| 11 | 13 | export type PaneModel = { |
| 14 | + words: Backend.BackendWords | |
| 15 | + baseModes: readonly Git.BaseMode[] | |
| 12 | 16 | isLoading: boolean |
| 13 | 17 | hasSettled: boolean |
| 14 | 18 | isOutsideRepository: boolean |
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import type { On, ResultOf, SessionMessage, Timer } from 'claude-code' |
| 2 | 2 | |
| 3 | 3 | import Ask from './ask' |
| 4 | +import Backend from './backend' | |
| 4 | 5 | import { COMMAND_SPEC } from './command-spec' |
| 5 | 6 | import { entryKindsOf } from './entry-kinds-of' |
| 6 | -import Git from './git' | |
| 7 | +import type Git from './git' | |
| 7 | 8 | import type { Host } from './host' |
| 8 | 9 | import { isOnPaneSurface } from './is-on-pane-surface' |
| 9 | 10 | import Limits from './limits' |
| @@ -22,13 +23,15 @@ | ||
| 22 | 23 | * |
| 23 | 24 | * `session.start` binds the engine once, registers the command (refused |
| 24 | 25 | * because the built-in holds it, the plugin does nothing) and pins the |
| 25 | - * repository (probed again on `/diff` and first edit until found). | |
| 26 | + * backend holding the session's directory (probed again on `/diff` and | |
| 27 | + * first edit until found): what an installed backend probe finds, else git | |
| 28 | + * (backendOf). | |
| 26 | 29 | * |
| 27 | 30 | * @param on the engine's registrar |
| 28 | 31 | */ |
| 29 | 32 | export function register(on: On) { |
| 30 | 33 | let host: Host | null = null |
| 31 | - let repository: Git.Repository | null = null | |
| 34 | + let backend: Backend.Backend | null = null | |
| 32 | 35 | let sessionStartMs = 0 |
| 33 | 36 | let isPaneOpen = false |
| 34 | 37 | let hasAutoOpened = false |
| @@ -40,70 +43,68 @@ | ||
| 40 | 43 | let isRefreshQueued = false |
| 41 | 44 | let generation = 0 |
| 42 | 45 | let bodyKey: string | null = null |
| 43 | - const polled = { gitDir: '', headKey: '' } | |
| 46 | + const polled = { toplevel: '', headKey: '' } | |
| 44 | 47 | let model: PaneState.PaneModel = PaneState.INITIAL_MODEL |
| 45 | 48 | const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>() |
| 46 | 49 | const loggedBaseKinds = new Set<'ok' | 'sad'>() |
| 47 | 50 | |
| 48 | - const gitOf = | |
| 49 | - (engine: Host, pinned: Git.Repository | null): Git.GitRun => | |
| 50 | - async argv => { | |
| 51 | - try { | |
| 52 | - return await engine.run( | |
| 53 | - ['git', ...(pinned ? Git.pinnedLeadOf(pinned) : []), ...argv], | |
| 54 | - { | |
| 55 | - timeoutMs: Limits.GIT_TIMEOUT_MS, | |
| 56 | - env: Git.GIT_CHILD_ENV, | |
| 57 | - ...(pinned && { cwd: pinned.toplevel }), | |
| 58 | - }, | |
| 51 | + // The bound host once bind() has run (it sets `host` before pinning), so a | |
| 52 | + // backend pinned under one session.start keeps reading through whatever | |
| 53 | + // host a later session.start bound; `engine` only before that. | |
| 54 | + const currentOf = (engine: Host): Host => host ?? engine | |
| 55 | + | |
| 56 | + const backendHostOf = (engine: Host): Backend.BackendHost => ({ | |
| 57 | + run: (argv, init) => currentOf(engine).run(argv, init), | |
| 58 | + readFile: path => currentOf(engine).readFile(path), | |
| 59 | + mtimeOf: path => mtimeOf(currentOf(engine))(path), | |
| 60 | + entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir), | |
| 61 | + nowMs: () => currentOf(engine).now(), | |
| 62 | + sessionStartMsOf: () => sessionStartMs, | |
| 63 | + onBranchBase: base => { | |
| 64 | + const isError = base.kind === 'error' | |
| 65 | + const outcome: Record.MarkOutcome = isError | |
| 66 | + ? { kind: 'sad', reason: base.reason } | |
| 67 | + : { kind: 'ok' } | |
| 68 | + | |
| 69 | + if (!loggedBaseKinds.has(outcome.kind)) { | |
| 70 | + loggedBaseKinds.add(outcome.kind) | |
| 71 | + Record.recorderOf(currentOf(engine)).mark( | |
| 72 | + Record.FEATURES.baseResolve, | |
| 73 | + outcome, | |
| 59 | 74 | ) |
| 60 | - } catch { | |
| 61 | - return { exitCode: -1, stdout: '', stderr: '' } | |
| 62 | 75 | } |
| 63 | - } | |
| 76 | + }, | |
| 77 | + }) | |
| 64 | 78 | |
| 65 | - async function pinRepository(engine: Host): Promise<void> { | |
| 66 | - if (repository) { | |
| 79 | + async function pinBackend(engine: Host): Promise<void> { | |
| 80 | + if (backend) { | |
| 67 | 81 | return |
| 68 | 82 | } |
| 69 | 83 | |
| 70 | - const probed = await Git.repositoryOf(gitOf(engine, null)) | |
| 71 | - repository ??= probed | |
| 84 | + const probed = await Backend.backendOf( | |
| 85 | + backendHostOf(engine), | |
| 86 | + Backend.INSTALLED_BACKEND_PROBES, | |
| 87 | + ) | |
| 88 | + backend ??= probed | |
| 72 | 89 | |
| 73 | - if (!probed || repository !== probed) { | |
| 90 | + if (!probed || backend !== probed) { | |
| 74 | 91 | return |
| 75 | 92 | } |
| 76 | 93 | |
| 77 | - const mode = PaneState.baseModeOf( | |
| 94 | + const stored = PaneState.baseModeOf( | |
| 78 | 95 | await engine |
| 79 | - .storeGet(Names.baseStoreKeyOf(probed.toplevel)) | |
| 96 | + .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel)) | |
| 80 | 97 | .catch(() => undefined), |
| 81 | 98 | ) |
| 82 | - | |
| 83 | - if (mode) { | |
| 84 | - model = { ...model, requestedMode: mode } | |
| 99 | + const mode = stored && probed.baseModes.includes(stored) ? stored : null | |
| 100 | + model = { | |
| 101 | + ...model, | |
| 102 | + words: probed.words, | |
| 103 | + baseModes: probed.baseModes, | |
| 104 | + ...(mode && { requestedMode: mode }), | |
| 85 | 105 | } |
| 86 | 106 | } |
| 87 | 107 | |
| 88 | - const depsOf = (engine: Host, pinned: Git.Repository): Git.GitDeps => ({ | |
| 89 | - run: gitOf(engine, pinned), | |
| 90 | - repository: pinned, | |
| 91 | - mtimeOf: mtimeOf(engine), | |
| 92 | - entryKindsOf: entryKindsOf(engine), | |
| 93 | - sessionStartMs, | |
| 94 | - onBranchBase: base => { | |
| 95 | - const isError = base.kind === 'error' | |
| 96 | - const outcome: Record.MarkOutcome = isError | |
| 97 | - ? { kind: 'sad', reason: base.reason } | |
| 98 | - : { kind: 'ok' } | |
| 99 | - | |
| 100 | - if (!loggedBaseKinds.has(outcome.kind)) { | |
| 101 | - loggedBaseKinds.add(outcome.kind) | |
| 102 | - Record.recorderOf(engine).mark(Record.FEATURES.baseResolve, outcome) | |
| 103 | - } | |
| 104 | - }, | |
| 105 | - }) | |
| 106 | - | |
| 107 | 108 | function redraw(engine: Host) { |
| 108 | 109 | if (timers.has('redraw')) { |
| 109 | 110 | return |
| @@ -134,7 +135,7 @@ | ||
| 134 | 135 | const { data } = model |
| 135 | 136 | const selected = selectedOf() |
| 136 | 137 | |
| 137 | - if (!data || !selected || !repository) { | |
| 138 | + if (!data || !selected || !backend) { | |
| 138 | 139 | bodyKey = null |
| 139 | 140 | model = { ...model, body: null, bodyState: 'idle' } |
| 140 | 141 | |
| @@ -150,11 +151,7 @@ | ||
| 150 | 151 | bodyKey = key |
| 151 | 152 | model = { ...model, body: null, bodyState: 'loading' } |
| 152 | 153 | redraw(engine) |
| 153 | - const body = await Git.fetchFileHunks( | |
| 154 | - gitOf(engine, repository), | |
| 155 | - data, | |
| 156 | - selected, | |
| 157 | - ) | |
| 154 | + const body = await backend.fetchFileHunks(data, selected) | |
| 158 | 155 | |
| 159 | 156 | if (bodyKey !== key) { |
| 160 | 157 | return |
| @@ -164,24 +161,15 @@ | ||
| 164 | 161 | redraw(engine) |
| 165 | 162 | } |
| 166 | 163 | |
| 167 | - function startPoll(engine: Host, polledRepository: Git.Repository) { | |
| 168 | - const readHeadKey = () => | |
| 169 | - Git.headKeyOf( | |
| 170 | - { | |
| 171 | - run: gitOf(engine, polledRepository), | |
| 172 | - mtimeOf: mtimeOf(engine), | |
| 173 | - entryKindsOf: entryKindsOf(engine), | |
| 174 | - readFile: path => engine.readFile(path), | |
| 175 | - }, | |
| 176 | - polledRepository, | |
| 177 | - ).catch(() => '') | |
| 164 | + function startPoll(engine: Host, pinned: Backend.Backend) { | |
| 165 | + const readHeadKey = () => pinned.headKeyOf().catch(() => '') | |
| 178 | 166 | |
| 179 | - if (polled.gitDir === polledRepository.gitDir) { | |
| 167 | + if (polled.toplevel === pinned.repository.toplevel) { | |
| 180 | 168 | return |
| 181 | 169 | } |
| 182 | 170 | |
| 183 | 171 | timers.get('poll')?.cancel() |
| 184 | - polled.gitDir = polledRepository.gitDir | |
| 172 | + polled.toplevel = pinned.repository.toplevel | |
| 185 | 173 | polled.headKey = '' |
| 186 | 174 | timers.set( |
| 187 | 175 | 'poll', |
| @@ -211,9 +199,10 @@ | ||
| 211 | 199 | |
| 212 | 200 | isRefreshing = true |
| 213 | 201 | const record = Record.recorderOf(engine) |
| 202 | + const pinned = backend | |
| 214 | 203 | const fetched = (): Promise<Git.FetchOutcome> => |
| 215 | - repository | |
| 216 | - ? Git.fetchDiff(depsOf(engine, repository), model.requestedMode) | |
| 204 | + pinned | |
| 205 | + ? pinned.fetchDiff(model.requestedMode) | |
| 217 | 206 | : Promise.resolve({ kind: 'no-repository' }) |
| 218 | 207 | |
| 219 | 208 | try { |
| @@ -236,7 +225,11 @@ | ||
| 236 | 225 | case 'data': |
| 237 | 226 | record.mark(Record.FEATURES.read, { kind: 'ok' }) |
| 238 | 227 | generation += 1 |
| 239 | - startPoll(engine, outcome.data.repository) | |
| 228 | + | |
| 229 | + if (pinned) { | |
| 230 | + startPoll(engine, pinned) | |
| 231 | + } | |
| 232 | + | |
| 240 | 233 | break |
| 241 | 234 | } |
| 242 | 235 | |
| @@ -304,7 +297,7 @@ | ||
| 304 | 297 | } |
| 305 | 298 | |
| 306 | 299 | const preference = await engine.storeGet(Names.STORE_OPEN_KEY) |
| 307 | - await pinRepository(engine) | |
| 300 | + await pinBackend(engine) | |
| 308 | 301 | const isKeptOpen = preference === true |
| 309 | 302 | const floor = isKeptOpen |
| 310 | 303 | ? Limits.OPEN_MIN_COLUMNS |
| @@ -313,7 +306,7 @@ | ||
| 313 | 306 | preference !== false && |
| 314 | 307 | columns !== null && |
| 315 | 308 | columns >= floor && |
| 316 | - repository !== null | |
| 309 | + backend !== null | |
| 317 | 310 | |
| 318 | 311 | if (!isEligible || isTaken()) { |
| 319 | 312 | return |
| @@ -422,7 +415,7 @@ | ||
| 422 | 415 | return |
| 423 | 416 | } |
| 424 | 417 | |
| 425 | - await pinRepository(engine) | |
| 418 | + await pinBackend(engine) | |
| 426 | 419 | } |
| 427 | 420 | |
| 428 | 421 | on('session.start', async ($, e, next) => { |
| @@ -484,9 +477,9 @@ | ||
| 484 | 477 | return next(e) |
| 485 | 478 | } |
| 486 | 479 | |
| 487 | - await pinRepository(host) | |
| 480 | + await pinBackend(host) | |
| 488 | 481 | |
| 489 | - if (!repository) { | |
| 482 | + if (!backend) { | |
| 490 | 483 | return { text: Names.NOT_IN_REPOSITORY_TEXT } |
| 491 | 484 | } |
| 492 | 485 |
| @@ -46,11 +46,12 @@ | ||
| 46 | 46 | const totals = data |
| 47 | 47 | ? PaneState.headerTotalsOf(data, partition) |
| 48 | 48 | : PaneState.ZERO_TOTALS |
| 49 | - const empty = PaneState.emptyStateOf(data, totals.filesCount) | |
| 49 | + const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words) | |
| 50 | 50 | const baseLabel = PaneState.baseLabelOf( |
| 51 | 51 | model.requestedMode, |
| 52 | 52 | data, |
| 53 | 53 | totals.filesCount, |
| 54 | + model.words, | |
| 54 | 55 | ) |
| 55 | 56 | const selected = PaneState.selectionOf( |
| 56 | 57 | PaneState.listedOf(partition, preSession), |
| @@ -89,6 +90,7 @@ | ||
| 89 | 90 | Detail.detailView( |
| 90 | 91 | kit, |
| 91 | 92 | { |
| 93 | + words: model.words, | |
| 92 | 94 | path: selected.path, |
| 93 | 95 | renamedFrom: selected.renamedFrom, |
| 94 | 96 | isUntracked: selected.isUntracked, |
| @@ -114,7 +116,11 @@ | ||
| 114 | 116 | typeof line === 'string' ? noteOf(line) : rowOf(line), |
| 115 | 117 | ), |
| 116 | 118 | noteOf(totals.notShown > 0 ? `${totals.notShown} not shown` : null), |
| 117 | - noteOf(isUntrackedNoted ? Names.UNTRACKED_WITHHELD_TEXT : null), | |
| 119 | + noteOf( | |
| 120 | + isUntrackedNoted | |
| 121 | + ? Names.untrackedWithheldTextOf(model.words.lister) | |
| 122 | + : null, | |
| 123 | + ), | |
| 118 | 124 | earlierToggle, |
| 119 | 125 | ...earlierRows.map(rowOf), |
| 120 | 126 | ...detail, |
| @@ -1,11 +1,14 @@ | ||
| 1 | +import type Backend from '../../../backend' | |
| 1 | 2 | import type Git from '../../../git' |
| 2 | 3 | import type PaneState from '../../../pane-state' |
| 3 | 4 | |
| 4 | 5 | /** |
| 5 | 6 | * The selected file as its body draws it: the path, what kind of row it is, |
| 6 | - * its hunks once read, and whether it is armed for the next prompt. | |
| 7 | + * its hunks once read, whether it is armed for the next prompt, and the | |
| 8 | + * backend's words for an untracked row's note. | |
| 7 | 9 | */ |
| 8 | 10 | export type DetailModel = { |
| 11 | + words: Pick<Backend.BackendWords, 'untrackedNoteOf'> | |
| 9 | 12 | path: string |
| 10 | 13 | renamedFrom: string | null |
| 11 | 14 | isUntracked: boolean |
| @@ -5,23 +5,19 @@ | ||
| 5 | 5 | * The dim italic lines drawn instead of hunks (DiffDetailView's wording), |
| 6 | 6 | * or null when the hunks draw. |
| 7 | 7 | * |
| 8 | - * Untracked (the pasteable `git add` line only for a name of letters, | |
| 9 | - * digits and `._/@+-`, any other told to stage without one), binary, | |
| 10 | - * loading, unreadable, large, or empty. | |
| 8 | + * Untracked (the backend's note, its pasteable command only for a name of | |
| 9 | + * letters, digits and `._/@+-`), binary, loading, unreadable, large, or | |
| 10 | + * empty. | |
| 11 | 11 | * |
| 12 | 12 | * @param detail the selected file |
| 13 | 13 | * @returns the lines, or null |
| 14 | 14 | */ |
| 15 | 15 | export function placeholderOf(detail: DetailModel): readonly string[] | null { |
| 16 | 16 | if (detail.isUntracked) { |
| 17 | - const isPasteable = SAFE_PATHSPEC_PATTERN.test(detail.path) | |
| 18 | - | |
| 19 | - return [ | |
| 20 | - 'New file not yet staged.', | |
| 21 | - isPasteable | |
| 22 | - ? `Run \`git add ':/${detail.path}'\` to see line counts.` | |
| 23 | - : 'Stage it with git add to see line counts.', | |
| 24 | - ] | |
| 17 | + return detail.words.untrackedNoteOf( | |
| 18 | + detail.path, | |
| 19 | + SAFE_PATHSPEC_PATTERN.test(detail.path), | |
| 20 | + ) | |
| 25 | 21 | } |
| 26 | 22 | |
| 27 | 23 | if (detail.isBinary) { |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | - * A file name safe to paste inside `git add ':/…'` in any shell (POSIX, | |
| 2 | + * A file name safe to paste into a stage command in any shell (POSIX, | |
| 3 | 3 | * PowerShell, cmd): letters and digits of any script and `._/@+-` only. |
| 4 | 4 | * |
| 5 | 5 | * No quote of any kind, no separator and no substitution can ride in it. |
| @@ -12,7 +12,9 @@ | ||
| 12 | 12 | * turn with edits, newest first) and, for the current source, the base. |
| 13 | 13 | * |
| 14 | 14 | * The source picker draws only when a turn exists, as DiffDialog hides its |
| 15 | - * tabs; the base picker is the built-in's ctrl+x b cycle as a Select. | |
| 15 | + * tabs; the base picker is the built-in's ctrl+x b cycle as a Select over | |
| 16 | + * the modes the backend offers, uncommitted mode naming the base as the | |
| 17 | + * base line does (modeLabelOf). | |
| 16 | 18 | * |
| 17 | 19 | * @param kit the elements and the handlers |
| 18 | 20 | * @param model what is picked now and the turns to pick from |
| @@ -20,7 +22,10 @@ | ||
| 20 | 22 | */ |
| 21 | 23 | export function controlsView( |
| 22 | 24 | kit: Kit, |
| 23 | - model: Pick<PaneState.PaneModel, 'source' | 'requestedMode' | 'turns'>, | |
| 25 | + model: Pick< | |
| 26 | + PaneState.PaneModel, | |
| 27 | + 'source' | 'requestedMode' | 'turns' | 'baseModes' | 'words' | 'data' | |
| 28 | + >, | |
| 24 | 29 | ): RenderElement | null { |
| 25 | 30 | const { Box, Select } = kit.ui |
| 26 | 31 | const { source, turns } = model |
| @@ -41,15 +46,24 @@ | ||
| 41 | 46 | /> |
| 42 | 47 | ) : null |
| 43 | 48 | const isCurrent = source.kind === 'current' |
| 49 | + const fetchedSource = model.data?.source | |
| 50 | + const base = | |
| 51 | + fetchedSource?.kind === 'working-tree' | |
| 52 | + ? fetchedSource.base | |
| 53 | + : model.words.base | |
| 44 | 54 | const basePicker = isCurrent ? ( |
| 45 | 55 | <Select |
| 46 | 56 | key="base" |
| 47 | 57 | label="base" |
| 48 | - options={[ | |
| 49 | - { value: 'session', label: 'this session' }, | |
| 50 | - { value: 'uncommitted', label: 'uncommitted (vs HEAD)' }, | |
| 51 | - { value: 'branch', label: 'branch' }, | |
| 52 | - ]} | |
| 58 | + options={model.baseModes.map(mode => ({ | |
| 59 | + value: mode, | |
| 60 | + label: | |
| 61 | + mode === 'uncommitted' | |
| 62 | + ? `uncommitted (vs ${base})` | |
| 63 | + : mode === 'session' | |
| 64 | + ? 'this session' | |
| 65 | + : mode, | |
| 66 | + }))} | |
| 53 | 67 | value={model.requestedMode} |
| 54 | 68 | onSelect={value => kit.actions.chooseBase(value)} |
| 55 | 69 | /> |
| @@ -49,6 +49,7 @@ | ||
| 49 | 49 | Detail.detailView( |
| 50 | 50 | kit, |
| 51 | 51 | { |
| 52 | + words: model.words, | |
| 52 | 53 | path: selected.path, |
| 53 | 54 | renamedFrom: null, |
| 54 | 55 | isUntracked: false, |