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
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | export function isGeneratedFile(path: string) { |
| 16 | 16 | const name = baseNameOf(path).toLowerCase() |
| 17 | 17 | const rooted = rootedPathOf(path) |
| 18 | + | |
| 18 | 19 | const hasGeneratedSuffix = GENERATED_SUFFIXES.some(suffix => |
| 19 | 20 | name.endsWith(suffix), |
| 20 | 21 | ) |
| @@ -46,6 +46,7 @@ | ||
| 46 | 46 | } |
| 47 | 47 | |
| 48 | 48 | const run = runOf(repository) |
| 49 | + | |
| 49 | 50 | const depsOf = (): Types.GitDeps => ({ |
| 50 | 51 | run, |
| 51 | 52 | repository, |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | const rows = stdout.split('\n') |
| 25 | + | |
| 25 | 26 | const headerRows = rows.flatMap((row, at) => |
| 26 | 27 | HUNK_HEADER.test(row) ? [at] : [], |
| 27 | 28 | ) |
| @@ -26,11 +26,13 @@ | ||
| 26 | 26 | const [addedField, removedField, ...pathFields] = (records[at] ?? '').split( |
| 27 | 27 | '\t', |
| 28 | 28 | ) |
| 29 | + | |
| 29 | 30 | const inline = pathFields.join('\t') |
| 30 | 31 | const isRename = pathFields.length === 1 && inline === '' |
| 31 | 32 | const renamedFrom = isRename ? (records[at + 1] ?? null) : null |
| 32 | 33 | const path = isRename ? (records[at + 2] ?? '') : inline |
| 33 | 34 | at += isRename ? RENAME_RECORDS : 1 |
| 35 | + | |
| 34 | 36 | const isRow = |
| 35 | 37 | addedField !== undefined && removedField !== undefined && path !== '' |
| 36 | 38 | |
| @@ -41,6 +43,7 @@ | ||
| 41 | 43 | const isBinary = addedField === '-' || removedField === '-' |
| 42 | 44 | const added = isBinary ? 0 : Number(addedField) || 0 |
| 43 | 45 | const removed = isBinary ? 0 : Number(removedField) || 0 |
| 46 | + | |
| 44 | 47 | stats.filesCount += 1 |
| 45 | 48 | stats.linesAdded += added |
| 46 | 49 | stats.linesRemoved += removed |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | currentBranchOf(run), |
| 24 | 24 | defaultBranchOf(run), |
| 25 | 25 | ]) |
| 26 | + | |
| 26 | 27 | const isUnrelated = branch === 'HEAD' || defaultBranch.startsWith('-') |
| 27 | 28 | |
| 28 | 29 | if (isUnrelated) { |
| @@ -37,6 +38,7 @@ | ||
| 37 | 38 | run, |
| 38 | 39 | defaultBranch, |
| 39 | 40 | ) |
| 41 | + | |
| 40 | 42 | const [first, second] = candidates |
| 41 | 43 | |
| 42 | 44 | if (first === undefined) { |
| @@ -57,6 +59,7 @@ | ||
| 57 | 59 | second, |
| 58 | 60 | ]) |
| 59 | 61 | ).exitCode === 0 |
| 62 | + | |
| 60 | 63 | const mergeBase = isLocalBaseNewer ? second : first |
| 61 | 64 | const head = await run([Argv.NO_OPTIONAL_LOCKS, 'rev-parse', 'HEAD']) |
| 62 | 65 |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | '--abbrev-ref', |
| 15 | 15 | 'HEAD', |
| 16 | 16 | ]) |
| 17 | + | |
| 17 | 18 | const name = stdout.trim() |
| 18 | 19 | |
| 19 | 20 | return exitCode === 0 && name !== '' ? name : 'HEAD' |
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | paths: readonly string[], |
| 20 | 20 | ): Promise<ReadonlyMap<string, Types.Dating>> { |
| 21 | 21 | const { sessionStartMs } = context.deps |
| 22 | + | |
| 22 | 23 | const stamps: readonly Types.Stamp[] = await Promise.all( |
| 23 | 24 | paths.map(path => stampIfFile(context.stamps, path)), |
| 24 | 25 | ) |
| @@ -22,12 +22,14 @@ | ||
| 22 | 22 | `refs/remotes/origin/${branch}`, |
| 23 | 23 | ]) |
| 24 | 24 | ).exitCode === 0 |
| 25 | + | |
| 25 | 26 | const symref = await run([ |
| 26 | 27 | Argv.NO_OPTIONAL_LOCKS, |
| 27 | 28 | 'symbolic-ref', |
| 28 | 29 | '--short', |
| 29 | 30 | 'refs/remotes/origin/HEAD', |
| 30 | 31 | ]) |
| 32 | + | |
| 31 | 33 | const named = |
| 32 | 34 | symref.exitCode === 0 ? symref.stdout.trim().replace(/^origin\//, '') : '' |
| 33 | 35 |
| @@ -26,7 +26,9 @@ | ||
| 26 | 26 | repository.gitDir, |
| 27 | 27 | Limits.MAX_LISTED_DIRECTORIES, |
| 28 | 28 | ) |
| 29 | + | |
| 29 | 30 | const isShared = repository.commonDir === repository.gitDir |
| 31 | + | |
| 30 | 32 | const common = isShared |
| 31 | 33 | ? own |
| 32 | 34 | : stampProbeOf(deps, repository.commonDir, Limits.MAX_LISTED_DIRECTORIES) |
| @@ -33,6 +33,7 @@ | ||
| 33 | 33 | |
| 34 | 34 | const listed = |
| 35 | 35 | held ?? entryKindsOf(directory === '' ? root : `${root}/${directory}`) |
| 36 | + | |
| 36 | 37 | listings.set(directory, listed) |
| 37 | 38 | |
| 38 | 39 | return listed |
| @@ -27,6 +27,7 @@ | ||
| 27 | 27 | '--quiet', |
| 28 | 28 | ref, |
| 29 | 29 | ]) |
| 30 | + | |
| 30 | 31 | const isPresentOrUnknown = exitCode === 0 || exitCode === -1 |
| 31 | 32 | |
| 32 | 33 | if (isPresentOrUnknown) { |
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | unstaged: Types.NumstatResult, |
| 18 | 18 | ): Types.NumstatResult { |
| 19 | 19 | const edits = new Map(unstaged.files.map(file => [file.path, file])) |
| 20 | + | |
| 20 | 21 | const files = staged.files.map(file => { |
| 21 | 22 | const edit = edits.get(file.path) |
| 22 | 23 | |
| @@ -25,16 +26,19 @@ | ||
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 28 | const isBinary = file.isBinary || edit.isBinary |
| 29 | + | |
| 28 | 30 | const added = isBinary |
| 29 | 31 | ? 0 |
| 30 | 32 | : Math.max(0, file.added + edit.added - edit.removed) |
| 31 | 33 | |
| 32 | 34 | return { ...file, added, removed: 0, isBinary } |
| 33 | 35 | }) |
| 36 | + | |
| 34 | 37 | const linesAdded = |
| 35 | 38 | staged.stats.linesAdded + |
| 36 | 39 | files.reduce((sum, file) => sum + file.added, 0) - |
| 37 | 40 | staged.files.reduce((sum, file) => sum + file.added, 0) |
| 41 | + | |
| 38 | 42 | const stats = { ...staged.stats, linesAdded } |
| 39 | 43 | |
| 40 | 44 | return { stats, files } |
| @@ -25,8 +25,10 @@ | ||
| 25 | 25 | '--git-dir', |
| 26 | 26 | '--git-common-dir', |
| 27 | 27 | ]) |
| 28 | + | |
| 28 | 29 | const lines = stdout.split('\n').filter(line => line !== '') |
| 29 | 30 | const [toplevel = '', gitDir = '', commonDir = ''] = lines |
| 31 | + | |
| 30 | 32 | const isResolved = |
| 31 | 33 | exitCode === 0 && |
| 32 | 34 | lines.length === REPOSITORY_LINES && |
| @@ -21,8 +21,10 @@ | ||
| 21 | 21 | const run = context.run |
| 22 | 22 | |
| 23 | 23 | const shortstat = await run([...Argv.DIFF_LEADING_ARGS, base, '--shortstat']) |
| 24 | + | |
| 24 | 25 | const totals = |
| 25 | 26 | shortstat.exitCode === 0 ? Parse.parseShortstat(shortstat.stdout) : null |
| 27 | + | |
| 26 | 28 | const isPastDetails = |
| 27 | 29 | totals !== null && totals.filesCount > Limits.MAX_FILES_FOR_DETAILS |
| 28 | 30 | |
| @@ -36,6 +38,7 @@ | ||
| 36 | 38 | '--numstat', |
| 37 | 39 | '-z', |
| 38 | 40 | ]) |
| 41 | + | |
| 39 | 42 | const isRead = Parse.isWholeAnswer(numstat) |
| 40 | 43 | |
| 41 | 44 | return isRead ? Parse.parseNumstat(numstat.stdout, Limits.MAX_FILES) : null |
| @@ -23,11 +23,14 @@ | ||
| 23 | 23 | ): Promise<readonly Types.FileStat[]> { |
| 24 | 24 | const probedPaths = paths.slice(0, Limits.MAX_UNTRACKED_PROBES) |
| 25 | 25 | const datings = await datingsOf(context, probedPaths) |
| 26 | + | |
| 26 | 27 | const probed = probedPaths.map(path => ({ |
| 27 | 28 | path, |
| 28 | 29 | isPreSession: datings.get(path) !== 'session', |
| 29 | 30 | })) |
| 31 | + | |
| 30 | 32 | const isWithPreSession = place.scope === 'with-pre-session' |
| 33 | + | |
| 31 | 34 | const earlier = isWithPreSession |
| 32 | 35 | ? [ |
| 33 | 36 | ...probed.filter(file => file.isPreSession), |
| @@ -36,6 +39,7 @@ | ||
| 36 | 39 | .map(path => ({ path, isPreSession: true })), |
| 37 | 40 | ] |
| 38 | 41 | : [] |
| 42 | + | |
| 39 | 43 | const ordered = [...probed.filter(file => !file.isPreSession), ...earlier] |
| 40 | 44 | |
| 41 | 45 | return ordered.slice(0, place.slots).map(file => ({ |
| @@ -31,6 +31,7 @@ | ||
| 31 | 31 | } |
| 32 | 32 | |
| 33 | 33 | const isOverlaid = staged.files.length > 0 |
| 34 | + | |
| 34 | 35 | const unstaged = isOverlaid |
| 35 | 36 | ? await Probes.unstagedNumstat(run) |
| 36 | 37 | : EMPTY_NUMSTAT |
| @@ -26,9 +26,11 @@ | ||
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | const isSession = context.mode === 'session' |
| 29 | + | |
| 29 | 30 | const files = isSession |
| 30 | 31 | ? await Probes.tagPreSession(context, workingTree.files) |
| 31 | 32 | : workingTree.files |
| 33 | + | |
| 32 | 34 | const data = diffDataOf( |
| 33 | 35 | context, |
| 34 | 36 | await withUntracked( |
| @@ -16,6 +16,7 @@ | ||
| 16 | 16 | pane: PaneBelief, |
| 17 | 17 | ): 'open' | 'close' | 'too-narrow' { |
| 18 | 18 | const isClosing = pane.isBelievedOpen && pane.wasDrawnWhenProbed |
| 19 | + | |
| 19 | 20 | const isTooNarrow = |
| 20 | 21 | pane.columns !== null && pane.columns < Limits.OPEN_MIN_COLUMNS |
| 21 | 22 |
| @@ -59,6 +59,7 @@ | ||
| 59 | 59 | sessionStartMsOf: () => sessionStartMs, |
| 60 | 60 | onBranchBase: base => { |
| 61 | 61 | const isError = base.kind === 'error' |
| 62 | + | |
| 62 | 63 | const outcome: Record.MarkOutcome = isError |
| 63 | 64 | ? { kind: 'sad', reason: base.reason } |
| 64 | 65 | : { |
| @@ -70,6 +71,7 @@ | ||
| 70 | 71 | |
| 71 | 72 | if (!loggedBaseKinds.has(outcome.kind)) { |
| 72 | 73 | loggedBaseKinds.add(outcome.kind) |
| 74 | + | |
| 73 | 75 | Record.recorderOf(currentOf(engine)).mark( |
| 74 | 76 | Record.FEATURES.baseResolve, |
| 75 | 77 | outcome, |
| @@ -93,6 +95,7 @@ | ||
| 93 | 95 | async function probeBackend(engine: Host): Promise<boolean> { |
| 94 | 96 | const asked = { isAnswered: true } |
| 95 | 97 | const probeHost = backendHostOf(engine) |
| 98 | + | |
| 96 | 99 | const probed = await Backend.backendOf( |
| 97 | 100 | { |
| 98 | 101 | ...probeHost, |
| @@ -100,11 +103,13 @@ | ||
| 100 | 103 | probeHost.run(argv, init).catch((error: unknown) => { |
| 101 | 104 | asked.isAnswered &&= |
| 102 | 105 | argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error)) |
| 106 | + | |
| 103 | 107 | throw error |
| 104 | 108 | }), |
| 105 | 109 | }, |
| 106 | 110 | Backend.INSTALLED_BACKEND_PROBES, |
| 107 | 111 | ) |
| 112 | + | |
| 108 | 113 | backend ??= probed |
| 109 | 114 | |
| 110 | 115 | if (!probed || backend !== probed) { |
| @@ -116,7 +121,9 @@ | ||
| 116 | 121 | .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel)) |
| 117 | 122 | .catch(() => undefined), |
| 118 | 123 | ) |
| 124 | + | |
| 119 | 125 | const mode = stored && probed.baseModes.includes(stored) ? stored : null |
| 126 | + | |
| 120 | 127 | model = { |
| 121 | 128 | ...model, |
| 122 | 129 | words: probed.words, |
| @@ -173,6 +180,7 @@ | ||
| 173 | 180 | bodyKey = key |
| 174 | 181 | model = { ...model, body: null, bodyState: 'loading' } |
| 175 | 182 | redraw(engine) |
| 183 | + | |
| 176 | 184 | const body = await backend.fetchFileHunks(data, selected) |
| 177 | 185 | |
| 178 | 186 | if (bodyKey !== key) { |
| @@ -195,6 +203,7 @@ | ||
| 195 | 203 | timers.get('poll')?.cancel() |
| 196 | 204 | polled.toplevel = pinned.repository.toplevel |
| 197 | 205 | polled.headKey = '' |
| 206 | + | |
| 198 | 207 | timers.set( |
| 199 | 208 | 'poll', |
| 200 | 209 | engine.every(Limits.HEAD_POLL_MS, () => { |
| @@ -204,6 +213,7 @@ | ||
| 204 | 213 | |
| 205 | 214 | void readHeadKey().then(key => { |
| 206 | 215 | const hasMoved = polled.headKey !== '' && key !== polled.headKey |
| 216 | + | |
| 207 | 217 | polled.headKey = key |
| 208 | 218 | |
| 209 | 219 | if (hasMoved) { |
| @@ -222,8 +232,10 @@ | ||
| 222 | 232 | } |
| 223 | 233 | |
| 224 | 234 | isRefreshing = true |
| 235 | + | |
| 225 | 236 | const record = Record.recorderOf(engine) |
| 226 | 237 | const pinned = backend |
| 238 | + | |
| 227 | 239 | const fetched = (): Promise<Git.FetchOutcome> => |
| 228 | 240 | pinned |
| 229 | 241 | ? pinned.fetchDiff(model.requestedMode) |
| @@ -231,10 +243,12 @@ | ||
| 231 | 243 | |
| 232 | 244 | try { |
| 233 | 245 | model = { ...model, isLoading: model.data === null } |
| 246 | + | |
| 234 | 247 | const [outcome, messages] = await Promise.all([ |
| 235 | 248 | fetched(), |
| 236 | 249 | engine.messages().catch((): SessionMessage[] => []), |
| 237 | 250 | ]) |
| 251 | + | |
| 238 | 252 | model = PaneState.afterFetch(model, { outcome, messages }) |
| 239 | 253 | |
| 240 | 254 | switch (outcome.kind) { |
| @@ -245,6 +259,7 @@ | ||
| 245 | 259 | kind: 'sad', |
| 246 | 260 | reason: 'git_diff_failed', |
| 247 | 261 | }) |
| 262 | + | |
| 248 | 263 | break |
| 249 | 264 | case 'data': |
| 250 | 265 | generation += 1 |
| @@ -286,6 +301,7 @@ | ||
| 286 | 301 | |
| 287 | 302 | function scheduleRefresh(engine: Host): void { |
| 288 | 303 | timers.get('refresh')?.cancel() |
| 304 | + | |
| 289 | 305 | timers.set( |
| 290 | 306 | 'refresh', |
| 291 | 307 | engine.after(Limits.REFRESH_DEBOUNCE_MS, () => { |
| @@ -303,6 +319,7 @@ | ||
| 303 | 319 | const isManual = trigger === 'manual' |
| 304 | 320 | await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane) |
| 305 | 321 | isPaneOpen = true |
| 322 | + | |
| 306 | 323 | const sessionId = await engine.sessionId().catch(() => null) |
| 307 | 324 | |
| 308 | 325 | if (sessionId !== null && sessionId !== shownSessionId) { |
| @@ -341,11 +358,15 @@ | ||
| 341 | 358 | } |
| 342 | 359 | |
| 343 | 360 | const preference = await engine.storeGet(Names.STORE_OPEN_KEY) |
| 361 | + | |
| 344 | 362 | await pinBackend(engine) |
| 363 | + | |
| 345 | 364 | const isKeptOpen = preference === true |
| 365 | + | |
| 346 | 366 | const floor = isKeptOpen |
| 347 | 367 | ? Limits.OPEN_MIN_COLUMNS |
| 348 | 368 | : Limits.AUTO_OPEN_MIN_COLUMNS |
| 369 | + | |
| 349 | 370 | const isEligible = |
| 350 | 371 | preference !== false && |
| 351 | 372 | columns !== null && |
| @@ -391,10 +412,12 @@ | ||
| 391 | 412 | |
| 392 | 413 | bodyKey = null |
| 393 | 414 | model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' } |
| 415 | + | |
| 394 | 416 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 395 | 417 | kind: 'ok', |
| 396 | 418 | props: { mode: { value: mode, of: model.baseModes } }, |
| 397 | 419 | }) |
| 420 | + | |
| 398 | 421 | const toplevel = model.data?.repository.toplevel |
| 399 | 422 | |
| 400 | 423 | if (toplevel !== undefined) { |
| @@ -409,9 +432,11 @@ | ||
| 409 | 432 | chooseSource: value => { |
| 410 | 433 | const index = Number(value) |
| 411 | 434 | const isTurn = value !== 'current' && Number.isInteger(index) |
| 435 | + | |
| 412 | 436 | const source: PaneState.Source = isTurn |
| 413 | 437 | ? { kind: 'turn', index } |
| 414 | 438 | : { kind: 'current' } |
| 439 | + | |
| 415 | 440 | model = { ...model, source, selectedPath: null } |
| 416 | 441 | void loadBody(engine) |
| 417 | 442 | redraw(engine) |
| @@ -442,11 +467,14 @@ | ||
| 442 | 467 | model.body?.hunks ?? |
| 443 | 468 | [], |
| 444 | 469 | ) |
| 470 | + | |
| 445 | 471 | model = { ...model, armedPath: path } |
| 472 | + | |
| 446 | 473 | engine.status( |
| 447 | 474 | `${Views.sanitizeName(path)} rides your next prompt (press ` + |
| 448 | 475 | `asked ✓ to drop it)`, |
| 449 | 476 | ) |
| 477 | + | |
| 450 | 478 | redraw(engine) |
| 451 | 479 | } |
| 452 | 480 | |
| @@ -510,6 +538,7 @@ | ||
| 510 | 538 | } |
| 511 | 539 | |
| 512 | 540 | const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) |
| 541 | + | |
| 513 | 542 | wasDrawnSinceProbe = true |
| 514 | 543 | columns = e.viewport?.columns ?? columns |
| 515 | 544 | model = { ...model, isFocused: e.props.isFocused } |
| @@ -624,6 +653,7 @@ | ||
| 624 | 653 | } |
| 625 | 654 | |
| 626 | 655 | const context = e.context ?? [] |
| 656 | + | |
| 627 | 657 | const text = Ask.fittedAskTextOf( |
| 628 | 658 | asked.text, |
| 629 | 659 | Limits.PROMPT_CONTEXT_MAX_CHARS - |
| @@ -632,10 +662,12 @@ | ||
| 632 | 662 | |
| 633 | 663 | if (text === undefined) { |
| 634 | 664 | disarm(host) |
| 665 | + | |
| 635 | 666 | host.status( |
| 636 | 667 | `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` + |
| 637 | 668 | `and was dropped`, |
| 638 | 669 | ) |
| 670 | + | |
| 639 | 671 | redraw(host) |
| 640 | 672 | |
| 641 | 673 | return next(e) |
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | const latest = messages |
| 22 | 22 | .flatMap(message => message.toolUses) |
| 23 | 23 | .findLast(use => use.tool === Tools.TODO_TOOL) |
| 24 | + | |
| 24 | 25 | const statuses = arrayOf(latest?.input.todos).map(todo => |
| 25 | 26 | isRecord(todo) ? todo.status : null, |
| 26 | 27 | ) |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | .filter(isRecord) |
| 15 | 15 | .flatMap(hunk => { |
| 16 | 16 | const { oldStart, newStart, lines } = hunk |
| 17 | + | |
| 17 | 18 | const isHunk = |
| 18 | 19 | typeof oldStart === 'number' && |
| 19 | 20 | typeof newStart === 'number' && |
| @@ -22,17 +22,21 @@ | ||
| 22 | 22 | ): Types.TurnDiff { |
| 23 | 23 | const text = rows[0]?.text ?? '' |
| 24 | 24 | const isShort = [...text].length <= PREVIEW_CHARS |
| 25 | + | |
| 25 | 26 | const preview = isShort |
| 26 | 27 | ? text |
| 27 | 28 | : `${[...text].slice(0, PREVIEW_CHARS - 1).join('')}…` |
| 29 | + | |
| 28 | 30 | const edits = rows |
| 29 | 31 | .flatMap(row => (row.role === 'assistant' ? row.toolUses : [])) |
| 30 | 32 | .map(use => fileEditOf(use.result)) |
| 31 | 33 | .filter((edit): edit is Types.TurnFile => edit !== null) |
| 34 | + | |
| 32 | 35 | const byPath = new Map<string, Types.TurnFile>() |
| 33 | 36 | |
| 34 | 37 | for (const edit of edits) { |
| 35 | 38 | const earlier = byPath.get(edit.path) |
| 39 | + | |
| 36 | 40 | byPath.set( |
| 37 | 41 | edit.path, |
| 38 | 42 | earlier |
| @@ -50,56 +50,71 @@ | ||
| 50 | 50 | const noise = model.isNoiseShown ? 'shown' : 'hidden' |
| 51 | 51 | const preSession = model.isPreSessionShown ? 'shown' : 'hidden' |
| 52 | 52 | const partition = PaneState.partitionOf(data?.files ?? [], noise) |
| 53 | + | |
| 53 | 54 | const totals = data |
| 54 | 55 | ? PaneState.headerTotalsOf(data, partition) |
| 55 | 56 | : PaneState.ZERO_TOTALS |
| 57 | + | |
| 56 | 58 | const empty = PaneState.emptyStateOf(data, totals.filesCount, model.words) |
| 57 | 59 | const baseLabel = PaneState.baseLabelOf(model, totals.filesCount) |
| 60 | + | |
| 58 | 61 | const selected = PaneState.selectionOf( |
| 59 | 62 | PaneState.listedOf(partition, preSession), |
| 60 | 63 | model.selectedPath, |
| 61 | 64 | ) |
| 65 | + | |
| 62 | 66 | const rowOf = (file: Git.FileStat): RenderElement => |
| 63 | 67 | Sections.fileRow(kit, fileRowOf(file, selected?.path ?? null), () => |
| 64 | 68 | kit.actions.selectFile(file.path), |
| 65 | 69 | ) |
| 70 | + | |
| 66 | 71 | const noteOf = (text: string | null): RenderElement | null => |
| 67 | 72 | text === null ? null : Sections.dimNote(kit, Layout.sanitizeName(text)) |
| 73 | + | |
| 68 | 74 | const noiseFace = model.isNoiseShown ? 'hide' : 'show' |
| 69 | 75 | const noiseCount = Layout.plural(partition.noiseCount, 'test') |
| 70 | 76 | const hasNoise = partition.noiseCount > 0 |
| 77 | + | |
| 71 | 78 | const noiseToggle = hasNoise |
| 72 | 79 | ? Sections.toggleRow(kit, 'noise', { |
| 73 | 80 | label: `${noiseCount}/generated (${noiseFace})`, |
| 74 | 81 | onPress: kit.actions.toggleNoise, |
| 75 | 82 | }) |
| 76 | 83 | : null |
| 84 | + | |
| 77 | 85 | const earlierFace = model.isPreSessionShown ? 'hide' : 'show' |
| 78 | 86 | const earlierCount = Layout.plural(partition.preSession.length, 'file') |
| 79 | 87 | const hasEarlier = partition.preSession.length > 0 |
| 88 | + | |
| 80 | 89 | const earlierToggle = hasEarlier |
| 81 | 90 | ? Sections.toggleRow(kit, 'presession', { |
| 82 | 91 | label: `+${earlierCount} edited before this session (${earlierFace})`, |
| 83 | 92 | onPress: kit.actions.togglePreSession, |
| 84 | 93 | }) |
| 85 | 94 | : null |
| 95 | + | |
| 86 | 96 | const earlierFiles = model.isPreSessionShown ? partition.preSession : [] |
| 87 | 97 | const isEarlierListed = earlierFiles.length > 0 |
| 98 | + | |
| 88 | 99 | const earlierRows = isEarlierListed |
| 89 | 100 | ? [<Box height={1} />, ...earlierFiles.map(rowOf)] |
| 90 | 101 | : [] |
| 102 | + | |
| 91 | 103 | const isUntrackedNoted = data?.isUntrackedWithheld === true && !empty |
| 92 | 104 | const listed = listBodyOf(kit, { data, partition, totals, empty }) |
| 93 | 105 | const notes = listed.filter(line => typeof line === 'string') |
| 94 | 106 | const hasRows = notes.length < listed.length |
| 95 | 107 | const message = empty ? [empty.headline, ...notes] : hasRows ? [] : notes |
| 96 | 108 | const header = Sections.headerView(kit, empty ? null : totals) |
| 109 | + | |
| 97 | 110 | const notShownNote = noteOf( |
| 98 | 111 | totals.notShown > 0 ? `${totals.notShown} not shown` : null, |
| 99 | 112 | ) |
| 113 | + | |
| 100 | 114 | const untrackedNote = noteOf( |
| 101 | 115 | isUntrackedNoted ? Names.untrackedWithheldTextOf(model.words) : null, |
| 102 | 116 | ) |
| 117 | + | |
| 103 | 118 | const detail = selected |
| 104 | 119 | ? [ |
| 105 | 120 | Sections.divider(kit), |
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | selectedPath: string | null, |
| 15 | 15 | ): Sections.FileRowModel { |
| 16 | 16 | const isRenamed = file.renamedFrom !== null |
| 17 | + | |
| 17 | 18 | const note = file.isUntracked |
| 18 | 19 | ? 'untracked' |
| 19 | 20 | : file.isBinary |
| @@ -24,20 +24,26 @@ | ||
| 24 | 24 | export function messagePaneOf(kit: Kit, pane: MessagePane): RenderElement { |
| 25 | 25 | const { Box, Text } = kit.ui |
| 26 | 26 | const { top, controls, earlier, rest } = pane |
| 27 | + | |
| 27 | 28 | const lines = pane.message.flatMap(text => |
| 28 | 29 | Layout.wrappedLines(Layout.sanitizeName(text), kit.columns), |
| 29 | 30 | ) |
| 31 | + | |
| 30 | 32 | const drawn = lines.map(line => <Text dimColor>{line}</Text>) |
| 31 | 33 | const controlRows = controls === null ? [] : [controls] |
| 32 | 34 | const earlierRows = earlier === null ? [] : [earlier] |
| 35 | + | |
| 33 | 36 | const isControlsAboveEarlier = |
| 34 | 37 | controlRows.length > 0 || earlierRows.length === 0 |
| 38 | + | |
| 35 | 39 | const aboveEarlier = isControlsAboveEarlier |
| 36 | 40 | ? controlRows |
| 37 | 41 | : [<Box height={1} />] |
| 42 | + | |
| 38 | 43 | const footRows = earlier === null ? 0 : FOOT_ROWS |
| 39 | 44 | const middleRows = kit.rows - top.length - 1 - footRows |
| 40 | 45 | const above = Math.floor((middleRows - lines.length) / 2) |
| 46 | + | |
| 41 | 47 | const isCentered = |
| 42 | 48 | rest.length === 0 && middleRows - above - lines.length >= controlRows.length |
| 43 | 49 | |
| @@ -57,6 +63,7 @@ | ||
| 57 | 63 | } |
| 58 | 64 | |
| 59 | 65 | const spacer = above > 0 ? [<Box height={above} />] : [] |
| 66 | + | |
| 60 | 67 | const foot = earlierRows.map(row => ( |
| 61 | 68 | <Box flexDirection="column" marginTop={1} paddingBottom={1}> |
| 62 | 69 | {row} |
| @@ -36,15 +36,19 @@ | ||
| 36 | 36 | const last = blocks.at(-1) |
| 37 | 37 | const source = hunkSourceOf(piece) |
| 38 | 38 | const isHunkStart = at === 0 |
| 39 | + | |
| 39 | 40 | const isJoining = |
| 40 | 41 | isHunkStart && |
| 41 | 42 | last !== undefined && |
| 42 | 43 | last.source.length + 1 + source.length <= MAX_CODE_CHARS |
| 44 | + | |
| 43 | 45 | const hasDivider = isHunkStart && !isJoining && last !== undefined |
| 44 | 46 | const overhead = isJoining ? 1 : hasDivider ? HUNK_DIVIDER.length : 0 |
| 47 | + | |
| 45 | 48 | const addedNodes = isJoining |
| 46 | 49 | ? 0 |
| 47 | 50 | : 1 + (hasDivider ? HUNK_DIVIDER_NODES : 0) |
| 51 | + | |
| 48 | 52 | const room = MAX_BODY_CHARS - chars - overhead |
| 49 | 53 | const fitted = source.length <= room ? piece : leadingHunkOf(piece, room) |
| 50 | 54 | const isOverNodes = nodes + addedNodes > MAX_BODY_NODES |
| @@ -63,6 +67,7 @@ | ||
| 63 | 67 | source: isJoining ? `${last.source}\n${text}` : text, |
| 64 | 68 | hasDivider: isJoining ? last.hasDivider : hasDivider, |
| 65 | 69 | }) |
| 70 | + | |
| 66 | 71 | chars += overhead + text.length |
| 67 | 72 | nodes += addedNodes |
| 68 | 73 |
| @@ -35,16 +35,21 @@ | ||
| 35 | 35 | const code = codeBlocksOf(placeholder ? [] : (detail.body?.hunks ?? [])) |
| 36 | 36 | const isTruncated = detail.body?.isTruncated === true || code.isTruncated |
| 37 | 37 | const path = Layout.sanitizeName(detail.path).slice(-MAX_CODE_CHARS) |
| 38 | + | |
| 38 | 39 | const nameOf = (name: string) => |
| 39 | 40 | Layout.truncateStart(Layout.sanitizeName(name), kit.columns) |
| 41 | + | |
| 40 | 42 | const renamedFrom = |
| 41 | 43 | detail.renamedFrom === null ? null : nameOf(detail.renamedFrom) |
| 44 | + | |
| 42 | 45 | const asides = [ |
| 43 | 46 | renamedFrom === null ? null : `renamed from ${renamedFrom}`, |
| 44 | 47 | detail.isUntracked ? 'untracked' : null, |
| 45 | 48 | isTruncated ? 'truncated' : null, |
| 46 | 49 | ].filter(word => word !== null) |
| 50 | + | |
| 47 | 51 | const aside = asides.length === 0 ? '' : ` (${asides.join(', ')})` |
| 52 | + | |
| 48 | 53 | const ask = placeholder |
| 49 | 54 | ? [] |
| 50 | 55 | : [ |
| @@ -52,6 +57,7 @@ | ||
| 52 | 57 | {detail.isArmed ? 'asked ✓' : 'ask'} |
| 53 | 58 | </Button>, |
| 54 | 59 | ] |
| 60 | + | |
| 55 | 61 | const footer = isTruncated |
| 56 | 62 | ? [ |
| 57 | 63 | <Text dimColor italic> |
| @@ -59,11 +65,13 @@ | ||
| 59 | 65 | </Text>, |
| 60 | 66 | ] |
| 61 | 67 | : [] |
| 68 | + | |
| 62 | 69 | const notes = (placeholder ?? []).map(line => ( |
| 63 | 70 | <Text dimColor italic wrap="wrap"> |
| 64 | 71 | {line} |
| 65 | 72 | </Text> |
| 66 | 73 | )) |
| 74 | + | |
| 67 | 75 | const hunks = code.blocks.flatMap(block => [ |
| 68 | 76 | ...(block.hasDivider ? [<Text dimColor>{HUNK_DIVIDER}</Text>] : []), |
| 69 | 77 | <Code source={block.source} format="diff" path={path} />, |
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | ...hunk, |
| 19 | 19 | lines: hunk.lines.slice(0, taken + 1), |
| 20 | 20 | }) |
| 21 | + | |
| 21 | 22 | bodyChars += 1 + line.length |
| 22 | 23 | |
| 23 | 24 | if (header.length + bodyChars > maxChars) { |
| @@ -34,6 +34,7 @@ | ||
| 34 | 34 | |
| 35 | 35 | const { isLarge, hunks } = detail.body |
| 36 | 36 | const isEmpty = hunks.length === 0 |
| 37 | + | |
| 37 | 38 | const note = isLarge |
| 38 | 39 | ? 'Large file - diff exceeds 1 MB limit' |
| 39 | 40 | : isEmpty |
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | export function firstLineCut(hunk: Git.Hunk, maxChars: number) { |
| 13 | 13 | const [first = ''] = hunk.lines |
| 14 | + | |
| 14 | 15 | const kept = first.slice( |
| 15 | 16 | 0, |
| 16 | 17 | Math.max( |
| @@ -25,13 +25,16 @@ | ||
| 25 | 25 | while (rest.lines.length > 0) { |
| 26 | 26 | const leading = leadingHunkOf(rest, maxChars) |
| 27 | 27 | const isLineCut = leading.lines.length === 0 |
| 28 | + | |
| 28 | 29 | const piece = isLineCut |
| 29 | 30 | ? { ...rest, lines: [firstLineCut(rest, maxChars)] } |
| 30 | 31 | : leading |
| 32 | + | |
| 31 | 33 | const taken = rest.lines.slice(0, piece.lines.length) |
| 32 | 34 | |
| 33 | 35 | hunks.push(piece) |
| 34 | 36 | isTruncated ||= isLineCut |
| 37 | + | |
| 35 | 38 | rest = { |
| 36 | 39 | oldStart: rest.oldStart + countOf(taken, line => !line.startsWith('+')), |
| 37 | 40 | newStart: rest.newStart + countOf(taken, line => !line.startsWith('-')), |
| @@ -17,8 +17,10 @@ | ||
| 17 | 17 | |
| 18 | 18 | for (const character of text) { |
| 19 | 19 | const point = character.codePointAt(0) ?? 0 |
| 20 | + | |
| 20 | 21 | const holds = ([low, high]: readonly [number, number]) => |
| 21 | 22 | point >= low && point <= high |
| 23 | + | |
| 22 | 24 | const isNarrow = point < FIRST_WIDE_POINT |
| 23 | 25 | const isZero = !isNarrow && ZERO_WIDTH_RANGES.some(holds) |
| 24 | 26 | const isWide = !isNarrow && !isZero && WIDE_RANGES.some(holds) |
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | const { Box, Select } = kit.ui |
| 30 | 30 | const { source, turns } = model |
| 31 | 31 | const hasTurns = turns.length > 0 |
| 32 | + | |
| 32 | 33 | const sourcePicker = hasTurns ? ( |
| 33 | 34 | <Select |
| 34 | 35 | key="source" |
| @@ -44,10 +45,12 @@ | ||
| 44 | 45 | onSelect={value => kit.actions.chooseSource(value)} |
| 45 | 46 | /> |
| 46 | 47 | ) : null |
| 48 | + | |
| 47 | 49 | const isCurrent = source.kind === 'current' |
| 48 | 50 | const fetchedSource = model.data?.source |
| 49 | 51 | const isWorkingTree = fetchedSource?.kind === 'working-tree' |
| 50 | 52 | const base = isWorkingTree ? fetchedSource.base : model.words.base |
| 53 | + | |
| 51 | 54 | const basePicker = isCurrent ? ( |
| 52 | 55 | <Select |
| 53 | 56 | key="base" |
| @@ -55,6 +58,7 @@ | ||
| 55 | 58 | options={model.baseModes.map(mode => { |
| 56 | 59 | const isUncommitted = mode === 'uncommitted' |
| 57 | 60 | const isSession = mode === 'session' |
| 61 | + | |
| 58 | 62 | const label = isUncommitted |
| 59 | 63 | ? `uncommitted (vs ${base})` |
| 60 | 64 | : isSession |
| @@ -67,7 +71,9 @@ | ||
| 67 | 71 | onSelect={value => kit.actions.chooseBase(value)} |
| 68 | 72 | /> |
| 69 | 73 | ) : null |
| 74 | + | |
| 70 | 75 | const pickers = present([sourcePicker, basePicker]) |
| 76 | + | |
| 71 | 77 | const row = ( |
| 72 | 78 | <Box flexDirection="row" gap={2}> |
| 73 | 79 | {pickers} |
| @@ -29,11 +29,13 @@ | ||
| 29 | 29 | const { Box, Text, Button } = kit.ui |
| 30 | 30 | const room = Math.max(kit.columns - STAT_CELLS, STAT_CELLS) |
| 31 | 31 | const mark = row.isSelected ? POINTER : ' ' |
| 32 | + | |
| 32 | 33 | const note = ( |
| 33 | 34 | <Text dimColor italic> |
| 34 | 35 | {row.note ?? ''} |
| 35 | 36 | </Text> |
| 36 | 37 | ) |
| 38 | + | |
| 37 | 39 | const hasNote = row.note !== null |
| 38 | 40 | const tail = hasNote ? note : diffStat(kit, row.added, row.removed) |
| 39 | 41 |
| @@ -27,10 +27,12 @@ | ||
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | const { Box, Text } = kit.ui |
| 30 | + | |
| 30 | 31 | const cells = Math.max( |
| 31 | 32 | 1, |
| 32 | 33 | Math.min(TODO_BAR_CELLS, kit.columns - TODO_BAR_RESERVE), |
| 33 | 34 | ) |
| 35 | + | |
| 34 | 36 | const filled = Math.round((todos.done / todos.total) * cells) |
| 35 | 37 | |
| 36 | 38 | return ( |
| @@ -25,10 +25,12 @@ | ||
| 25 | 25 | turn: Turns.TurnDiff, |
| 26 | 26 | ): RenderElement { |
| 27 | 27 | const { Box } = kit.ui |
| 28 | + | |
| 28 | 29 | const selected = |
| 29 | 30 | turn.files.find(file => file.path === model.selectedPath) ?? |
| 30 | 31 | turn.files[0] ?? |
| 31 | 32 | null |
| 33 | + | |
| 32 | 34 | const rows = turn.files.map(file => |
| 33 | 35 | Sections.fileRow( |
| 34 | 36 | kit, |
| @@ -43,6 +45,7 @@ | ||
| 43 | 45 | () => kit.actions.selectFile(file.path), |
| 44 | 46 | ), |
| 45 | 47 | ) |
| 48 | + | |
| 46 | 49 | const detail = selected |
| 47 | 50 | ? [ |
| 48 | 51 | Sections.divider(kit), |
| @@ -20,11 +20,13 @@ | ||
| 20 | 20 | const opened = keeping<Args<'ui.open'>>() |
| 21 | 21 | const closed = keeping<Args<'ui.close'>>() |
| 22 | 22 | const clock = startsSession(on) |
| 23 | + | |
| 23 | 24 | on('process.run', ($, e) => { |
| 24 | 25 | runs.push(e) |
| 25 | 26 | |
| 26 | 27 | return { value: gitIn(e.argv) } |
| 27 | 28 | }) |
| 29 | + | |
| 28 | 30 | on('ui.open', opened.hook) |
| 29 | 31 | on('ui.close', closed.hook) |
| 30 | 32 | on('ui.invalidate', () => ({ value: undefined })) |
| @@ -18,8 +18,10 @@ | ||
| 18 | 18 | } |
| 19 | 19 | |
| 20 | 20 | const props: unknown = Reflect.get(tree, 'props') |
| 21 | + | |
| 21 | 22 | const label: unknown = |
| 22 | 23 | typeof props === 'object' && props ? Reflect.get(props, 'label') : undefined |
| 24 | + | |
| 23 | 25 | const lead = typeof label === 'string' ? label : '' |
| 24 | 26 | |
| 25 | 27 | return `${lead}${textOf(Reflect.get(tree, 'children') ?? [])}` |
| @@ -11,6 +11,7 @@ | ||
| 11 | 11 | await $.session.start(Fixtures.SESSION) |
| 12 | 12 | await $.command.run(Fixtures.DIFF) |
| 13 | 13 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 14 | + | |
| 14 | 15 | const [discovery, ...pinned] = world.runs |
| 15 | 16 | |
| 16 | 17 | expect(discovery?.argv).toContain('--show-toplevel') |
| @@ -23,6 +24,7 @@ | ||
| 23 | 24 | '--git-dir=/work/.git', |
| 24 | 25 | '--work-tree=/work', |
| 25 | 26 | ]) |
| 27 | + | |
| 26 | 28 | expect(run.init).toMatchObject({ cwd: '/work', env: { LC_ALL: 'C' } }) |
| 27 | 29 | } |
| 28 | 30 | }) |
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | test('/diff at boot joins the boot probe, then asks again', async ($, on) => { |
| 10 | 10 | const probes: (readonly string[])[] = [] |
| 11 | 11 | const clock = Fixtures.startsSession(on) |
| 12 | + | |
| 12 | 13 | on('process.run', async ($, e) => { |
| 13 | 14 | probes.push(e.argv) |
| 14 | 15 | |
| @@ -22,8 +23,11 @@ | ||
| 22 | 23 | }) |
| 23 | 24 | |
| 24 | 25 | const booting = $.session.start(Fixtures.SESSION) |
| 26 | + | |
| 25 | 27 | await clock.advance(0) |
| 28 | + | |
| 26 | 29 | const ran = $.command.run(Fixtures.DIFF) |
| 30 | + | |
| 27 | 31 | await clock.advance(0) |
| 28 | 32 | |
| 29 | 33 | expect(probes, 'the boot probe, which /diff joined').toHaveLength(1) |
| @@ -34,13 +38,16 @@ | ||
| 34 | 38 | expect(await ran).toEqual({ |
| 35 | 39 | text: expect.stringContaining("isn't in a git repository"), |
| 36 | 40 | }) |
| 41 | + | |
| 37 | 42 | expect(probes, 'then one more of its own').toHaveLength(2) |
| 38 | 43 | }) |
| 39 | 44 | |
| 40 | 45 | test('outside a repository /diff says so, opens nothing', async ($, on) => { |
| 41 | 46 | const opened: string[] = [] |
| 47 | + | |
| 42 | 48 | Fixtures.startsSession(on) |
| 43 | 49 | on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY })) |
| 50 | + | |
| 44 | 51 | on('ui.open', ($, e, next) => { |
| 45 | 52 | opened.push(e.id) |
| 46 | 53 | |
| @@ -48,6 +55,7 @@ | ||
| 48 | 55 | }) |
| 49 | 56 | |
| 50 | 57 | await $.session.start(Fixtures.SESSION) |
| 58 | + | |
| 51 | 59 | const { text } = await $.command.run(Fixtures.DIFF) |
| 52 | 60 | |
| 53 | 61 | expect(text).toContain("isn't in a git repository") |
| @@ -59,6 +67,7 @@ | ||
| 59 | 67 | on('process.run', () => ({ deny: Fixtures.GIT_HUNG })) |
| 60 | 68 | |
| 61 | 69 | await $.session.start(Fixtures.SESSION) |
| 70 | + | |
| 62 | 71 | const { text } = await $.command.run(Fixtures.DIFF) |
| 63 | 72 | |
| 64 | 73 | expect(text).toContain("git didn't answer") |
| @@ -66,10 +75,12 @@ | ||
| 66 | 75 | |
| 67 | 76 | test('when the built-in holds /diff, the mod stands down', async ($, on) => { |
| 68 | 77 | const logged: string[] = [] |
| 78 | + | |
| 69 | 79 | mock.clock(on) |
| 70 | 80 | on('session.start', ($, e) => ({ cwd: e.cwd })) |
| 71 | 81 | on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS })) |
| 72 | 82 | on('command.run', () => ({ text: 'the built-in /diff ran' })) |
| 83 | + | |
| 73 | 84 | on('ui.log', ($, e) => { |
| 74 | 85 | logged.push(e.text) |
| 75 | 86 | |
| @@ -81,16 +92,20 @@ | ||
| 81 | 92 | expect(await $.command.run(Fixtures.DIFF)).toEqual({ |
| 82 | 93 | text: 'the built-in /diff ran', |
| 83 | 94 | }) |
| 95 | + | |
| 84 | 96 | expect(logged).toEqual([]) |
| 85 | 97 | }) |
| 86 | 98 | |
| 87 | 99 | test('a refusal the built-in did not cause is said aloud', async ($, on) => { |
| 88 | 100 | const logged: string[] = [] |
| 101 | + | |
| 89 | 102 | mock.clock(on) |
| 90 | 103 | on('session.start', ($, e) => ({ cwd: e.cwd })) |
| 104 | + | |
| 91 | 105 | on('command.register', () => ({ |
| 92 | 106 | deny: '32 commands are registered already', |
| 93 | 107 | })) |
| 108 | + | |
| 94 | 109 | on('ui.log', ($, e) => { |
| 95 | 110 | logged.push(e.text) |
| 96 | 111 | |
| @@ -114,6 +129,7 @@ | ||
| 114 | 129 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| 115 | 130 | |
| 116 | 131 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 132 | + | |
| 117 | 133 | const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) |
| 118 | 134 | |
| 119 | 135 | expect(drawn).toContain('1 file changed') |
| @@ -141,16 +157,19 @@ | ||
| 141 | 157 | |
| 142 | 158 | test('a wide terminal opens the pane at the first edit', async ($, on) => { |
| 143 | 159 | const world = Fixtures.inRepository(on) |
| 160 | + | |
| 144 | 161 | on('tool.call', () => ({ result: 'edited' })) |
| 145 | 162 | |
| 146 | 163 | await $.session.start(Fixtures.SESSION) |
| 147 | 164 | await $.ui.render(Fixtures.HINT) |
| 165 | + | |
| 148 | 166 | await $.tool.call({ |
| 149 | 167 | tool: 'Edit', |
| 150 | 168 | file_path: '/work/app.ts', |
| 151 | 169 | old_string: '1', |
| 152 | 170 | new_string: '2', |
| 153 | 171 | }) |
| 172 | + | |
| 154 | 173 | await world.clock.advance(Fixtures.SETTLE_MS) |
| 155 | 174 | |
| 156 | 175 | expect(world.opened.map(pane => pane.id)).toEqual(['diff']) |
| @@ -158,6 +177,7 @@ | ||
| 158 | 177 | |
| 159 | 178 | test('/clear closes the pane it finds open', async ($, on) => { |
| 160 | 179 | const world = Fixtures.inRepository(on) |
| 180 | + | |
| 161 | 181 | on('command.run', { command: 'clear' }, () => ({})) |
| 162 | 182 | |
| 163 | 183 | await $.session.start(Fixtures.SESSION) |
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | ...(Array.isArray(allowlist) ? allowlist : []).flatMap((entry: unknown) => { |
| 20 | 20 | const isEntry = |
| 21 | 21 | typeof entry === 'object' && entry !== null && 'serverName' in entry |
| 22 | + | |
| 22 | 23 | const name = isEntry ? entry.serverName : undefined |
| 23 | 24 | const isNamed = typeof name === 'string' && name !== '' |
| 24 | 25 |
| @@ -11,7 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | export function toolsRegistered(on: On) { |
| 13 | 13 | const registered: string[] = [] |
| 14 | + | |
| 14 | 15 | on('session.start', ($, e) => ({ cwd: e.cwd })) |
| 16 | + | |
| 15 | 17 | on('tool.register', ($, e, next) => { |
| 16 | 18 | registered.push(next.origin.plugin) |
| 17 | 19 |
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | }, |
| 16 | 16 | async ($, on) => { |
| 17 | 17 | on('settings.read', () => ({ value: Fixtures.ALLOWLIST })) |
| 18 | + | |
| 18 | 19 | const registered = Fixtures.toolsRegistered(on) |
| 19 | 20 | |
| 20 | 21 | await $.session.start(Fixtures.SESSION) |
| @@ -28,6 +29,7 @@ | ||
| 28 | 29 | { plugins: [Fixtures.registering('mine')] }, |
| 29 | 30 | async ($, on) => { |
| 30 | 31 | on('settings.read', () => ({ value: Fixtures.NO_ALLOWLIST })) |
| 32 | + | |
| 31 | 33 | const registered = Fixtures.toolsRegistered(on) |
| 32 | 34 | |
| 33 | 35 | await $.session.start(Fixtures.SESSION) |
| @@ -41,6 +43,7 @@ | ||
| 41 | 43 | { plugins: [Fixtures.registering('mine')] }, |
| 42 | 44 | async ($, on) => { |
| 43 | 45 | on('settings.read', () => ({ deny: 'managed settings unreadable' })) |
| 46 | + | |
| 44 | 47 | const registered = Fixtures.toolsRegistered(on) |
| 45 | 48 | |
| 46 | 49 | await $.session.start(Fixtures.SESSION) |
| @@ -51,6 +51,7 @@ | ||
| 51 | 51 | |
| 52 | 52 | const members = value.of |
| 53 | 53 | const chosen = value.value |
| 54 | + | |
| 54 | 55 | const isTokenList = |
| 55 | 56 | members.length > 0 && |
| 56 | 57 | members.length <= CHOICES_LIMIT && |
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | isEnvSet(environment.disableTelemetry) || |
| 19 | 19 | isEnvSet(environment.disableNonessentialTraffic) || |
| 20 | 20 | isEnvTruthy(environment.doNotTrack) |
| 21 | + | |
| 21 | 22 | const isThirdParty = [ |
| 22 | 23 | environment.useBedrock, |
| 23 | 24 | environment.useVertex, |
| @@ -26,6 +27,7 @@ | ||
| 26 | 27 | environment.useAnthropicGoogleCloud, |
| 27 | 28 | environment.useMantle, |
| 28 | 29 | ].some(isEnvTruthy) |
| 30 | + | |
| 29 | 31 | const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim()) |
| 30 | 32 | |
| 31 | 33 | return isPrivate || isThirdParty || isCustomDeployment |
| @@ -32,6 +32,7 @@ | ||
| 32 | 32 | model: await deps.model(), |
| 33 | 33 | userType: environment.userType === 'ant' ? 'ant' : 'external', |
| 34 | 34 | }) |
| 35 | + | |
| 35 | 36 | const auth = await deps.authorize() |
| 36 | 37 | |
| 37 | 38 | if (!auth) { |
| @@ -16,9 +16,11 @@ | ||
| 16 | 16 | authorization: SessionAuthorization = BEARER, |
| 17 | 17 | ) { |
| 18 | 18 | const posts: Args<'http.fetch'>[] = [] |
| 19 | + | |
| 19 | 20 | on('session.id', () => ({ value: 'the-session' })) |
| 20 | 21 | on('session.model', () => ({ value: 'the-model' })) |
| 21 | 22 | on('session.authorize', () => ({ value: authorization })) |
| 23 | + | |
| 22 | 24 | on('http.fetch', ($, e) => { |
| 23 | 25 | posts.push(e) |
| 24 | 26 |
| @@ -10,14 +10,17 @@ | ||
| 10 | 10 | { plugins: [Fixtures.recording] }, |
| 11 | 11 | async ($, on) => { |
| 12 | 12 | mock.env(on, { USER_TYPE: 'ant' }) |
| 13 | + | |
| 13 | 14 | const posts = Fixtures.firstPartySession(on) |
| 14 | 15 | |
| 15 | 16 | expect( |
| 16 | 17 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())), |
| 17 | 18 | ).toEqual({ text: 'sent' }) |
| 19 | + | |
| 18 | 20 | expect(posts.map(post => post.init)).toMatchObject([ |
| 19 | 21 | { method: 'POST', auth: 'the-handle' }, |
| 20 | 22 | ]) |
| 23 | + | |
| 21 | 24 | expect(posts.map(Fixtures.batchOf)).toMatchObject([ |
| 22 | 25 | { |
| 23 | 26 | events: [ |
| @@ -40,6 +43,7 @@ | ||
| 40 | 43 | { plugins: [Fixtures.recording] }, |
| 41 | 44 | async ($, on) => { |
| 42 | 45 | mock.env(on, {}) |
| 46 | + | |
| 43 | 47 | const posts = Fixtures.firstPartySession(on) |
| 44 | 48 | |
| 45 | 49 | await $.command.run( |
| @@ -69,7 +73,9 @@ | ||
| 69 | 73 | { plugins: [Fixtures.recording] }, |
| 70 | 74 | async ($, on) => { |
| 71 | 75 | mock.env(on, { DO_NOT_TRACK: '1' }) |
| 76 | + | |
| 72 | 77 | const posts = Fixtures.firstPartySession(on) |
| 78 | + | |
| 73 | 79 | const { text } = await $.command.run( |
| 74 | 80 | Fixtures.record(Fixtures.surveyAnswer()), |
| 75 | 81 | ) |
| @@ -83,6 +89,7 @@ | ||
| 83 | 89 | { plugins: [Fixtures.recording] }, |
| 84 | 90 | async ($, on) => { |
| 85 | 91 | mock.env(on, { CLAUDE_CODE_USE_BEDROCK: '1' }) |
| 92 | + | |
| 86 | 93 | const posts = Fixtures.firstPartySession(on) |
| 87 | 94 | |
| 88 | 95 | await $.command.run(Fixtures.record(Fixtures.surveyAnswer())) |
| @@ -96,7 +103,9 @@ | ||
| 96 | 103 | { plugins: [Fixtures.recording] }, |
| 97 | 104 | async ($, on) => { |
| 98 | 105 | mock.env(on, {}) |
| 106 | + | |
| 99 | 107 | const posts = Fixtures.firstPartySession(on, null) |
| 108 | + | |
| 100 | 109 | const { text } = await $.command.run( |
| 101 | 110 | Fixtures.record(Fixtures.surveyAnswer()), |
| 102 | 111 | ) |
| @@ -105,6 +114,7 @@ | ||
| 105 | 114 | '$.telemetry.log: this session has no first-party credential to ' + |
| 106 | 115 | 'authorize', |
| 107 | 116 | ) |
| 117 | + | |
| 108 | 118 | expect(posts).toEqual([]) |
| 109 | 119 | }, |
| 110 | 120 | ) |
| @@ -114,7 +124,9 @@ | ||
| 114 | 124 | { plugins: [Fixtures.recording] }, |
| 115 | 125 | async ($, on) => { |
| 116 | 126 | mock.env(on, {}) |
| 127 | + | |
| 117 | 128 | const posts = Fixtures.firstPartySession(on) |
| 129 | + | |
| 118 | 130 | const { text } = await $.command.run({ |
| 119 | 131 | ...Fixtures.record(Fixtures.surveyAnswer()), |
| 120 | 132 | args: '{"event":"survey_answered","props":{"note":"hello world"}}', |