git-coco 0.58.0 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
61
61
  /**
62
62
  * Current build version from package.json
63
63
  */
64
- const BUILD_VERSION = "0.58.0";
64
+ const BUILD_VERSION = "0.59.0";
65
65
 
66
66
  const isInteractive = (config) => {
67
67
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -1147,6 +1147,11 @@ const schema$1 = {
1147
1147
  "type": "boolean",
1148
1148
  "description": "Group adjacent commits in the history surface under shared section headers (`── Today ──`, `── Yesterday ──`, `── April 2026 ──`) and drop the per-row date column in favor of the headers. On by default because the bucketed view gives stronger temporal orientation at a glance and the freed cells go to the commit subject. Flip off if you prefer a date column on every row.\n\nBucketing automatically suppresses itself while a search filter is active (results aren't chronological), regardless of this setting.",
1149
1149
  "default": true
1150
+ },
1151
+ "syntaxHighlight": {
1152
+ "type": "boolean",
1153
+ "description": "Syntax-highlight code in the diff view using tree-sitter (TypeScript / TSX / JavaScript today). On by default. Highlighting degrades gracefully — unsupported languages, non-ASCII lines, and parse failures fall back to the plain add/remove coloring — so the only reason to disable it is preference or a very low-color terminal. Set to `false` to opt out.",
1154
+ "default": true
1150
1155
  }
1151
1156
  },
1152
1157
  "additionalProperties": false,
@@ -2145,6 +2150,31 @@ const schema$1 = {
2145
2150
  },
2146
2151
  "warning": {
2147
2152
  "type": "string"
2153
+ },
2154
+ "syntaxKeyword": {
2155
+ "type": "string",
2156
+ "description": "Optional syntax-highlight token colors for the diff view (#1117 follow-up). All optional: when a slot is unset the resolver (`resolveSyntaxColor`) falls back to a sensible ANSI default, so themes get highlighting for free and only need to define these to customize. `noColor` themes skip syntax coloring entirely."
2157
+ },
2158
+ "syntaxString": {
2159
+ "type": "string"
2160
+ },
2161
+ "syntaxComment": {
2162
+ "type": "string"
2163
+ },
2164
+ "syntaxNumber": {
2165
+ "type": "string"
2166
+ },
2167
+ "syntaxType": {
2168
+ "type": "string"
2169
+ },
2170
+ "syntaxFunction": {
2171
+ "type": "string"
2172
+ },
2173
+ "syntaxConstant": {
2174
+ "type": "string"
2175
+ },
2176
+ "syntaxProperty": {
2177
+ "type": "string"
2148
2178
  }
2149
2179
  },
2150
2180
  "additionalProperties": false
@@ -21709,6 +21739,69 @@ function isLogInkContextKeyLoading(status, key) {
21709
21739
  return status[key] === 'loading';
21710
21740
  }
21711
21741
 
21742
+ /**
21743
+ * Derive a short menu of sensible `.gitignore` patterns from the path of
21744
+ * the cursored worktree file (the "add to .gitignore" quick-pick, `i` on
21745
+ * the status view).
21746
+ *
21747
+ * The goal is to turn the common asks — "ignore exactly this", "ignore
21748
+ * everything with this extension", "ignore this whole folder" — into
21749
+ * one-keystroke choices, while always offering a `Custom pattern…` escape
21750
+ * hatch that opens a free-text prompt for anything the menu doesn't cover
21751
+ * (negations, globs, anchored paths, etc.).
21752
+ *
21753
+ * Pure / synchronous so it's trivially unit-testable and reusable from the
21754
+ * reducer, the input handler, and the overlay renderer without pulling in
21755
+ * `fs`.
21756
+ */
21757
+ /**
21758
+ * Build the option list for a repo-relative path. Git reports untracked
21759
+ * directories with a trailing slash (`.www/`), which is how we tell a
21760
+ * directory from a file. Duplicate patterns are collapsed (e.g. a
21761
+ * top-level dir whose anchored and bare forms would otherwise repeat).
21762
+ */
21763
+ function deriveGitignoreOptions(rawPath) {
21764
+ const input = rawPath.trim();
21765
+ const options = [];
21766
+ const seen = new Set();
21767
+ const add = (pattern, label) => {
21768
+ if (!pattern || seen.has(pattern))
21769
+ return;
21770
+ seen.add(pattern);
21771
+ options.push({ pattern, label, custom: false });
21772
+ };
21773
+ if (input) {
21774
+ const isDir = input.endsWith('/');
21775
+ const clean = input.replace(/\/+$/, '');
21776
+ const segments = clean.split('/').filter(Boolean);
21777
+ const base = segments[segments.length - 1] || clean;
21778
+ const parent = segments.slice(0, -1).join('/');
21779
+ if (isDir) {
21780
+ // Anchored to the repo root vs. matching any folder of that name.
21781
+ add(`/${clean}/`, `This folder only (/${clean}/)`);
21782
+ add(`${base}/`, `Any “${base}/” folder`);
21783
+ }
21784
+ else {
21785
+ add(input, `This file only (${input})`);
21786
+ const dot = base.lastIndexOf('.');
21787
+ if (dot > 0 && dot < base.length - 1) {
21788
+ const ext = base.slice(dot);
21789
+ add(`*${ext}`, `All ${ext} files (*${ext})`);
21790
+ }
21791
+ if (parent) {
21792
+ add(`${parent}/`, `Its folder (${parent}/)`);
21793
+ }
21794
+ add(base, `Any file named “${base}”`);
21795
+ }
21796
+ }
21797
+ options.push({
21798
+ pattern: input,
21799
+ label: 'Custom pattern…',
21800
+ custom: true,
21801
+ });
21802
+ return options;
21803
+ }
21804
+
21712
21805
  /**
21713
21806
  * Extract a single hunk from a unified-patch diff so it can be fed to
21714
21807
  * `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
@@ -22848,6 +22941,27 @@ const LOG_INK_KEY_BINDINGS = [
22848
22941
  description: 'Browse, live-preview, and apply a color theme.',
22849
22942
  contexts: ['normal'],
22850
22943
  },
22944
+ {
22945
+ id: 'openProjectConfig',
22946
+ keys: ['gk'],
22947
+ label: 'project config',
22948
+ description: 'Open this repo’s .coco.json in $EDITOR (creates a starter file if missing).',
22949
+ contexts: ['normal'],
22950
+ },
22951
+ {
22952
+ id: 'openGlobalConfig',
22953
+ keys: ['gK'],
22954
+ label: 'global config',
22955
+ description: 'Open ~/.config/coco/config.json in $EDITOR (creates a starter file if missing).',
22956
+ contexts: ['normal'],
22957
+ },
22958
+ {
22959
+ id: 'gitignoreFile',
22960
+ keys: ['i'],
22961
+ label: 'gitignore',
22962
+ description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
22963
+ contexts: ['status'],
22964
+ },
22851
22965
  {
22852
22966
  id: 'viewChangelog',
22853
22967
  keys: ['L'],
@@ -22938,6 +23052,9 @@ const BINDING_CATEGORY_BY_ID = {
22938
23052
  help: 'essentials',
22939
23053
  commandPalette: 'essentials',
22940
23054
  themePicker: 'view',
23055
+ openProjectConfig: 'view',
23056
+ openGlobalConfig: 'view',
23057
+ gitignoreFile: 'mutate',
22941
23058
  quit: 'essentials',
22942
23059
  refresh: 'essentials',
22943
23060
  navigateBack: 'essentials',
@@ -23260,7 +23377,7 @@ function getLogInkFooterHints(options) {
23260
23377
  }
23261
23378
  if (options.activeView === 'status') {
23262
23379
  return {
23263
- contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'e/c compose', 'y yank'],
23380
+ contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
23264
23381
  global: NORMAL_GLOBAL_HINTS,
23265
23382
  };
23266
23383
  }
@@ -25305,6 +25422,11 @@ function applyLogInkAction(state, action) {
25305
25422
  bootLoading: action.value,
25306
25423
  pendingKey: undefined,
25307
25424
  };
25425
+ case 'setRemoteOp':
25426
+ return {
25427
+ ...state,
25428
+ remoteOp: action.value,
25429
+ };
25308
25430
  case 'moveTag':
25309
25431
  return {
25310
25432
  ...state,
@@ -25841,6 +25963,29 @@ function applyLogInkAction(state, action) {
25841
25963
  themePickerIndex: 0,
25842
25964
  pendingKey: undefined,
25843
25965
  };
25966
+ case 'openGitignorePicker':
25967
+ return {
25968
+ ...state,
25969
+ gitignorePicker: { file: action.file, index: 0 },
25970
+ pendingKey: undefined,
25971
+ };
25972
+ case 'closeGitignorePicker':
25973
+ return {
25974
+ ...state,
25975
+ gitignorePicker: undefined,
25976
+ pendingKey: undefined,
25977
+ };
25978
+ case 'moveGitignorePicker':
25979
+ return state.gitignorePicker
25980
+ ? {
25981
+ ...state,
25982
+ gitignorePicker: {
25983
+ ...state.gitignorePicker,
25984
+ index: clampIndex(state.gitignorePicker.index + action.delta, action.count),
25985
+ },
25986
+ pendingKey: undefined,
25987
+ }
25988
+ : state;
25844
25989
  case 'setChangelogLoading':
25845
25990
  return {
25846
25991
  ...state,
@@ -26578,6 +26723,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
26578
26723
  // Palette closes on execute (toggleCommandPalette runs first), then
26579
26724
  // this opens the theme picker.
26580
26725
  return [action({ type: 'toggleThemePicker' })];
26726
+ case 'openProjectConfig':
26727
+ return [{ type: 'openConfigInEditor', scope: 'project' }];
26728
+ case 'openGlobalConfig':
26729
+ return [{ type: 'openConfigInEditor', scope: 'global' }];
26730
+ case 'gitignoreFile':
26731
+ // Runtime resolves the cursored worktree file and opens the picker
26732
+ // (no-ops with a warning when there's no file under the cursor).
26733
+ return [{ type: 'openGitignorePicker' }];
26581
26734
  case 'workflowDeleteBranch':
26582
26735
  case 'workflowDeleteTag':
26583
26736
  case 'workflowDropStash':
@@ -26671,6 +26824,12 @@ function submitInputPrompt(state) {
26671
26824
  if (!value) {
26672
26825
  return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
26673
26826
  }
26827
+ if (state.inputPrompt.kind === 'gitignore-pattern') {
26828
+ return [
26829
+ { type: 'runWorkflowAction', id: 'add-to-gitignore', payload: value },
26830
+ action({ type: 'closeInputPrompt' }),
26831
+ ];
26832
+ }
26674
26833
  if (state.inputPrompt.kind === 'reset-mode') {
26675
26834
  const mode = value.toLowerCase();
26676
26835
  if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
@@ -27149,6 +27308,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27149
27308
  }
27150
27309
  return [];
27151
27310
  }
27311
+ if (state.gitignorePicker) {
27312
+ const options = deriveGitignoreOptions(state.gitignorePicker.file);
27313
+ if (key.escape) {
27314
+ return [action({ type: 'closeGitignorePicker' })];
27315
+ }
27316
+ if (key.upArrow || (key.ctrl && inputValue === 'p')) {
27317
+ return [action({ type: 'moveGitignorePicker', delta: -1, count: options.length })];
27318
+ }
27319
+ if (key.downArrow || (key.ctrl && inputValue === 'n')) {
27320
+ return [action({ type: 'moveGitignorePicker', delta: 1, count: options.length })];
27321
+ }
27322
+ if (key.return) {
27323
+ const selected = options[Math.max(0, Math.min(state.gitignorePicker.index, options.length - 1))];
27324
+ if (!selected) {
27325
+ return [action({ type: 'closeGitignorePicker' })];
27326
+ }
27327
+ if (selected.custom) {
27328
+ // Hand off to a free-text prompt seeded with the file path so
27329
+ // the user can type any valid gitignore pattern (negations,
27330
+ // globs, anchored paths) the derived options don't cover.
27331
+ return [
27332
+ action({ type: 'closeGitignorePicker' }),
27333
+ action({
27334
+ type: 'openInputPrompt',
27335
+ kind: 'gitignore-pattern',
27336
+ label: `.gitignore pattern (e.g. ${selected.pattern || '*.log'})`,
27337
+ initial: selected.pattern,
27338
+ }),
27339
+ ];
27340
+ }
27341
+ return [
27342
+ action({ type: 'closeGitignorePicker' }),
27343
+ { type: 'runWorkflowAction', id: 'add-to-gitignore', payload: selected.pattern },
27344
+ ];
27345
+ }
27346
+ // Consume everything else so the underlying status view keys don't
27347
+ // leak through while the picker owns the screen.
27348
+ return [];
27349
+ }
27152
27350
  if (state.showCommandPalette) {
27153
27351
  const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
27154
27352
  if (key.escape) {
@@ -27426,6 +27624,20 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
27426
27624
  action({ type: 'toggleThemePicker' }),
27427
27625
  ];
27428
27626
  }
27627
+ // gk — open this repo's project config (.coco.json) in $EDITOR.
27628
+ if (state.pendingKey === 'g' && inputValue === 'k') {
27629
+ return [
27630
+ action({ type: 'setPendingKey', value: undefined }),
27631
+ { type: 'openConfigInEditor', scope: 'project' },
27632
+ ];
27633
+ }
27634
+ // gK — open the global config (~/.config/coco/config.json) in $EDITOR.
27635
+ if (state.pendingKey === 'g' && inputValue === 'K') {
27636
+ return [
27637
+ action({ type: 'setPendingKey', value: undefined }),
27638
+ { type: 'openConfigInEditor', scope: 'global' },
27639
+ ];
27640
+ }
27429
27641
  // #784 — bisect view action keys. Scoped to `state.activeView ===
27430
27642
  // 'bisect' && state.focus === 'commits'` so the single-letter keys
27431
27643
  // stay free everywhere else. `g` and `b` collide with the global
@@ -28507,6 +28719,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
28507
28719
  if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
28508
28720
  return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
28509
28721
  }
28722
+ // `i` opens the "add to .gitignore" picker for the cursored worktree
28723
+ // file. The runtime resolves the path + opens the picker (the bare
28724
+ // event carries no path — same selection-resolution pattern as the
28725
+ // revert / stage events).
28726
+ if (inputValue === 'i' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
28727
+ return [{ type: 'openGitignorePicker' }];
28728
+ }
28510
28729
  if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
28511
28730
  return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
28512
28731
  }
@@ -29964,6 +30183,35 @@ async function runAction$5(action, successMessage) {
29964
30183
  };
29965
30184
  }
29966
30185
  }
30186
+ /** Configured remote names (best-effort; `[]` if the call fails). */
30187
+ async function listRemotes(git) {
30188
+ try {
30189
+ return (await git.getRemotes()).map((remote) => remote.name).filter(Boolean);
30190
+ }
30191
+ catch {
30192
+ return [];
30193
+ }
30194
+ }
30195
+ /**
30196
+ * Remote to push a not-yet-tracked branch to: `origin` when it exists,
30197
+ * else the first configured remote, else `undefined` (no remotes).
30198
+ */
30199
+ async function resolveDefaultRemote(git) {
30200
+ const remotes = await listRemotes(git);
30201
+ if (remotes.length === 0)
30202
+ return undefined;
30203
+ return remotes.includes('origin') ? 'origin' : remotes[0];
30204
+ }
30205
+ /** Whether the remote-tracking ref `refs/remotes/<remote>/<branch>` exists locally. */
30206
+ async function remoteBranchExists(git, remote, branch) {
30207
+ try {
30208
+ await git.raw(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
30209
+ return true;
30210
+ }
30211
+ catch {
30212
+ return false;
30213
+ }
30214
+ }
29967
30215
  function checkoutBranch(git, branch) {
29968
30216
  const refs = getBranchActionRefs(branch);
29969
30217
  if (branch.type === 'remote') {
@@ -29998,11 +30246,58 @@ function fetchRemotes(git) {
29998
30246
  function pullCurrentBranch(git) {
29999
30247
  return runAction$5(() => git.raw(['pull', '--ff-only']), 'Pulled current branch');
30000
30248
  }
30001
- function pushCurrentBranch(git) {
30002
- return runAction$5(() => git.raw(['push']), 'Pushed current branch');
30003
- }
30004
- function setUpstream(git, localBranch, upstreamBranch) {
30005
- return runAction$5(() => git.raw(['branch', '--set-upstream-to', upstreamBranch, localBranch]), `Set ${localBranch} upstream to ${upstreamBranch}`);
30249
+ async function pushCurrentBranch(git) {
30250
+ const hasUpstream = await git
30251
+ .raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
30252
+ .then(() => true)
30253
+ .catch(() => false);
30254
+ if (hasUpstream) {
30255
+ return runAction$5(() => git.raw(['push']), 'Pushed current branch');
30256
+ }
30257
+ // No upstream yet — push with `-u` to create the remote branch AND set
30258
+ // tracking, instead of failing with git's bare "has no upstream" error.
30259
+ const remote = await resolveDefaultRemote(git);
30260
+ if (!remote) {
30261
+ return { ok: false, message: 'No upstream and no remote configured — add one with `git remote add origin <url>`.' };
30262
+ }
30263
+ const current = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
30264
+ return runAction$5(() => git.raw(['push', '-u', remote, current]), `Pushed ${current} and set upstream to ${remote}/${current}`);
30265
+ }
30266
+ /**
30267
+ * Set (or create) the upstream for a local branch from a user-typed target.
30268
+ *
30269
+ * The target may be a bare branch name (`main` → `<default-remote>/main`) or
30270
+ * a `remote/branch` ref (`origin/main`). If that remote-tracking branch
30271
+ * already exists, we just link to it (`git branch --set-upstream-to`). If it
30272
+ * does NOT exist yet — the common "I just created this branch" case — we
30273
+ * `git push -u` to create the remote branch and set tracking in one step.
30274
+ * The old behavior ran `--set-upstream-to <bare-name>`, which silently
30275
+ * resolved `main` to the *local* branch and left push still complaining.
30276
+ */
30277
+ async function setUpstream(git, localBranch, target) {
30278
+ const cleaned = target.trim();
30279
+ if (!cleaned)
30280
+ return { ok: false, message: 'Upstream ref required' };
30281
+ const remotes = await listRemotes(git);
30282
+ const slash = cleaned.indexOf('/');
30283
+ let remote;
30284
+ let remoteBranch;
30285
+ if (slash > 0 && remotes.includes(cleaned.slice(0, slash))) {
30286
+ remote = cleaned.slice(0, slash);
30287
+ remoteBranch = cleaned.slice(slash + 1);
30288
+ }
30289
+ else {
30290
+ remote = remotes.includes('origin') ? 'origin' : remotes[0];
30291
+ remoteBranch = cleaned;
30292
+ }
30293
+ if (!remote) {
30294
+ return { ok: false, message: 'No remote configured — add one with `git remote add origin <url>` first.' };
30295
+ }
30296
+ if (await remoteBranchExists(git, remote, remoteBranch)) {
30297
+ return runAction$5(() => git.raw(['branch', '--set-upstream-to', `${remote}/${remoteBranch}`, localBranch]), `Set ${localBranch} to track ${remote}/${remoteBranch}`);
30298
+ }
30299
+ // Remote branch doesn't exist yet — push it and set upstream in one step.
30300
+ return runAction$5(() => git.raw(['push', '-u', remote, `${localBranch}:${remoteBranch}`]), `Pushed ${localBranch} → ${remote}/${remoteBranch} and set upstream`);
30006
30301
  }
30007
30302
  /**
30008
30303
  * Push an arbitrary local branch (need not be the current branch) to
@@ -30013,18 +30308,21 @@ function setUpstream(git, localBranch, upstreamBranch) {
30013
30308
  * Pairs with `pushCurrentBranch` (no-arg variant); the workstation
30014
30309
  * dispatcher picks one or the other based on where the cursor is.
30015
30310
  */
30016
- function pushBranch(git, branch) {
30311
+ async function pushBranch(git, branch) {
30017
30312
  if (branch.type !== 'local') {
30018
- return Promise.resolve({
30019
- ok: false,
30020
- message: 'Only local branches can be pushed.',
30021
- });
30313
+ return { ok: false, message: 'Only local branches can be pushed.' };
30022
30314
  }
30023
30315
  if (!branch.upstream || !branch.remote) {
30024
- return Promise.resolve({
30025
- ok: false,
30026
- message: `${branch.shortName} has no upstream — checkout the branch and run \`git push -u <remote> ${branch.shortName}\` first.`,
30027
- });
30316
+ // No upstream yet — push with `-u` to create the remote branch AND set
30317
+ // tracking, rather than refusing and sending the user to the shell.
30318
+ const remote = await resolveDefaultRemote(git);
30319
+ if (!remote) {
30320
+ return {
30321
+ ok: false,
30322
+ message: `${branch.shortName} has no upstream and no remote is configured — add one with \`git remote add origin <url>\`.`,
30323
+ };
30324
+ }
30325
+ return runAction$5(() => git.raw(['push', '-u', remote, branch.shortName]), `Pushed ${branch.shortName} and set upstream to ${remote}/${branch.shortName}`);
30028
30326
  }
30029
30327
  return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
30030
30328
  }
@@ -30105,6 +30403,493 @@ function pullBranch(git, branch, currentBranchName) {
30105
30403
  ]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
30106
30404
  }
30107
30405
 
30406
+ /**
30407
+ * Append a pattern to the repository's `.gitignore` (the runtime side of
30408
+ * the "add to .gitignore" quick-pick, `i` on the status view).
30409
+ *
30410
+ * Kept separate from the pure pattern-derivation helper
30411
+ * (`workstation/chrome/gitignore.ts`) because this touches the filesystem
30412
+ * and resolves the repo root via git — neither of which the UI layer
30413
+ * should pull in.
30414
+ */
30415
+ /**
30416
+ * Append `pattern` to `<repoRoot>/.gitignore`, creating the file if it
30417
+ * doesn't exist. No-ops (reporting success) when the exact pattern is
30418
+ * already present so re-running is safe. Handles the missing-trailing-
30419
+ * newline case so we never glue the new entry onto the previous line.
30420
+ */
30421
+ async function addToGitignore(git, pattern) {
30422
+ const entry = pattern.trim();
30423
+ if (!entry) {
30424
+ return { ok: false, message: 'No pattern to add.' };
30425
+ }
30426
+ let root;
30427
+ try {
30428
+ root = (await git.revparse(['--show-toplevel'])).trim();
30429
+ }
30430
+ catch {
30431
+ return { ok: false, message: 'Could not resolve the repository root.' };
30432
+ }
30433
+ if (!root) {
30434
+ return { ok: false, message: 'Could not resolve the repository root.' };
30435
+ }
30436
+ const file = path.join(root, '.gitignore');
30437
+ let existing = '';
30438
+ try {
30439
+ existing = await promises.readFile(file, 'utf8');
30440
+ }
30441
+ catch {
30442
+ // No .gitignore yet — we'll create it.
30443
+ existing = '';
30444
+ }
30445
+ // Already ignored (exact line match, ignoring surrounding whitespace)?
30446
+ const alreadyPresent = existing
30447
+ .split('\n')
30448
+ .some((line) => line.trim() === entry);
30449
+ if (alreadyPresent) {
30450
+ return { ok: true, message: `${entry} is already in .gitignore` };
30451
+ }
30452
+ const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n');
30453
+ const addition = `${needsLeadingNewline ? '\n' : ''}${entry}\n`;
30454
+ try {
30455
+ await promises.appendFile(file, addition, 'utf8');
30456
+ }
30457
+ catch (error) {
30458
+ return { ok: false, message: error.message };
30459
+ }
30460
+ return { ok: true, message: `Added ${entry} to .gitignore` };
30461
+ }
30462
+
30463
+ /**
30464
+ * Embedded tree-sitter highlight queries (one per language).
30465
+ *
30466
+ * We ship our own compact queries rather than reading the upstream
30467
+ * `queries/highlights.scm` from the grammar packages because those are
30468
+ * dev-only / not present in a published install, and they lean on
30469
+ * `#match?` / `#is-not? local` predicates that web-tree-sitter's
30470
+ * `Query.captures()` does NOT evaluate for us — including them would
30471
+ * mis-tag every identifier. These subsets are **predicate-free** (so
30472
+ * every capture is unconditionally correct) and use only grammar-valid
30473
+ * node/token names (so the query compiles — verified against the real
30474
+ * grammars), distilled from each language's upstream `highlights.scm`.
30475
+ *
30476
+ * The TS query serves both `typescript` and `tsx` (tsx is a superset).
30477
+ */
30478
+ const TS_HIGHLIGHT_QUERY = `
30479
+ ; Comments
30480
+ (comment) @comment
30481
+
30482
+ ; Strings
30483
+ [
30484
+ (string)
30485
+ (template_string)
30486
+ ] @string
30487
+ (regex) @string
30488
+
30489
+ ; Numbers
30490
+ (number) @number
30491
+
30492
+ ; Types
30493
+ (type_identifier) @type
30494
+ (predefined_type) @type
30495
+
30496
+ ; Literals
30497
+ [
30498
+ (true)
30499
+ (false)
30500
+ (null)
30501
+ (undefined)
30502
+ ] @constant
30503
+ (this) @keyword
30504
+ (super) @keyword
30505
+
30506
+ ; Properties
30507
+ (property_identifier) @property
30508
+
30509
+ ; Function definitions and calls (field-name patterns — no predicates)
30510
+ (function_declaration
30511
+ name: (identifier) @function)
30512
+ (function_expression
30513
+ name: (identifier) @function)
30514
+ (method_definition
30515
+ name: (property_identifier) @function)
30516
+ (call_expression
30517
+ function: (identifier) @function)
30518
+ (call_expression
30519
+ function: (member_expression
30520
+ property: (property_identifier) @function))
30521
+ (variable_declarator
30522
+ name: (identifier) @function
30523
+ value: [(function_expression) (arrow_function)])
30524
+
30525
+ ; Keywords (anonymous tokens — all valid in the TS/TSX grammars)
30526
+ [
30527
+ "abstract"
30528
+ "declare"
30529
+ "enum"
30530
+ "implements"
30531
+ "interface"
30532
+ "keyof"
30533
+ "namespace"
30534
+ "private"
30535
+ "protected"
30536
+ "public"
30537
+ "type"
30538
+ "readonly"
30539
+ "override"
30540
+ "satisfies"
30541
+ "as"
30542
+ "async"
30543
+ "await"
30544
+ "break"
30545
+ "case"
30546
+ "catch"
30547
+ "class"
30548
+ "const"
30549
+ "continue"
30550
+ "debugger"
30551
+ "default"
30552
+ "delete"
30553
+ "do"
30554
+ "else"
30555
+ "export"
30556
+ "extends"
30557
+ "finally"
30558
+ "for"
30559
+ "from"
30560
+ "function"
30561
+ "get"
30562
+ "if"
30563
+ "import"
30564
+ "in"
30565
+ "instanceof"
30566
+ "let"
30567
+ "new"
30568
+ "of"
30569
+ "return"
30570
+ "set"
30571
+ "static"
30572
+ "switch"
30573
+ "throw"
30574
+ "try"
30575
+ "typeof"
30576
+ "var"
30577
+ "void"
30578
+ "while"
30579
+ "with"
30580
+ "yield"
30581
+ ] @keyword
30582
+ `.trim();
30583
+ /** Python (validated against tree-sitter-python 0.23.6). */
30584
+ const PYTHON_HIGHLIGHT_QUERY = `
30585
+ (comment) @comment
30586
+ (string) @string
30587
+ (integer) @number
30588
+ (float) @number
30589
+ (type) @type
30590
+ (function_definition
30591
+ name: (identifier) @function)
30592
+ (class_definition
30593
+ name: (identifier) @type)
30594
+ (call
30595
+ function: (identifier) @function)
30596
+ [ (true) (false) (none) ] @constant
30597
+ [
30598
+ "def" "class" "return" "pass" "if" "elif" "else" "for" "while"
30599
+ "import" "from" "as" "with" "try" "except" "finally" "raise"
30600
+ "lambda" "yield" "global" "nonlocal" "assert" "del" "in" "not"
30601
+ "and" "or" "is" "await" "async"
30602
+ ] @keyword
30603
+ `.trim();
30604
+ /** Rust (validated against tree-sitter-rust 0.24.0). */
30605
+ const RUST_HIGHLIGHT_QUERY = `
30606
+ [ (line_comment) (block_comment) ] @comment
30607
+ [ (string_literal) (char_literal) (raw_string_literal) ] @string
30608
+ (integer_literal) @number
30609
+ (float_literal) @number
30610
+ [ (primitive_type) (type_identifier) ] @type
30611
+ (function_item
30612
+ name: (identifier) @function)
30613
+ (call_expression
30614
+ function: (identifier) @function)
30615
+ (boolean_literal) @constant
30616
+ [
30617
+ "fn" "let" "const" "static" "if" "else" "match" "for" "while"
30618
+ "loop" "return" "break" "continue" "struct" "enum" "trait" "impl"
30619
+ "use" "mod" "pub" "as" "where" "in" "unsafe" "async" "await"
30620
+ "dyn" "type"
30621
+ ] @keyword
30622
+ `.trim();
30623
+ /** Go (validated against tree-sitter-go 0.25.0). */
30624
+ const GO_HIGHLIGHT_QUERY = `
30625
+ (comment) @comment
30626
+ [ (interpreted_string_literal) (raw_string_literal) (rune_literal) ] @string
30627
+ (int_literal) @number
30628
+ (float_literal) @number
30629
+ (type_identifier) @type
30630
+ (function_declaration
30631
+ name: (identifier) @function)
30632
+ (method_declaration
30633
+ name: (field_identifier) @function)
30634
+ (call_expression
30635
+ function: (identifier) @function)
30636
+ (call_expression
30637
+ function: (selector_expression
30638
+ field: (field_identifier) @function))
30639
+ [ (true) (false) (nil) (iota) ] @constant
30640
+ [
30641
+ "func" "var" "const" "type" "struct" "interface" "map" "chan"
30642
+ "package" "import" "return" "if" "else" "for" "range" "switch"
30643
+ "case" "default" "break" "continue" "go" "defer" "select"
30644
+ "fallthrough" "goto"
30645
+ ] @keyword
30646
+ `.trim();
30647
+ /** Highlight query keyed by tree-sitter language id. */
30648
+ const HIGHLIGHT_QUERIES = {
30649
+ typescript: TS_HIGHLIGHT_QUERY,
30650
+ tsx: TS_HIGHLIGHT_QUERY,
30651
+ python: PYTHON_HIGHLIGHT_QUERY,
30652
+ rust: RUST_HIGHLIGHT_QUERY,
30653
+ go: GO_HIGHLIGHT_QUERY,
30654
+ };
30655
+
30656
+ /**
30657
+ * Map a tree-sitter capture name to a normalized token type. Captures
30658
+ * are dotted (`a.b.c`); we key off the leading segment and fold the rest
30659
+ * in. Anything we don't have a color for collapses to `plain` (rendered
30660
+ * in the default foreground), so unmapped captures degrade gracefully.
30661
+ */
30662
+ function captureToToken(capture) {
30663
+ const base = capture.split('.')[0];
30664
+ switch (base) {
30665
+ case 'keyword':
30666
+ return 'keyword';
30667
+ case 'string':
30668
+ return 'string';
30669
+ case 'comment':
30670
+ return 'comment';
30671
+ case 'number':
30672
+ return 'number';
30673
+ case 'type':
30674
+ return 'type';
30675
+ case 'function':
30676
+ case 'method':
30677
+ case 'constructor':
30678
+ return 'function';
30679
+ case 'property':
30680
+ return 'property';
30681
+ case 'constant':
30682
+ return 'constant';
30683
+ default:
30684
+ return 'plain';
30685
+ }
30686
+ }
30687
+
30688
+ /**
30689
+ * Tree-sitter syntax highlighter for the diff view.
30690
+ *
30691
+ * Reuses the existing tree-sitter runtime (`getTreeSitterParser`) — same
30692
+ * lazy init, same bundled `typescript` / `tsx` grammars (offline-safe),
30693
+ * same "return undefined when .wasm is unavailable" contract. On top of
30694
+ * that we build one `Query` per language from our embedded highlight
30695
+ * query and tokenize code **per line**.
30696
+ *
30697
+ * Per-line (rather than whole-file) tokenization keeps this uniform
30698
+ * across every diff source — stash / compare / commit / worktree all
30699
+ * hand us diff lines, never reconstructed files — and tree-sitter's
30700
+ * error tolerance means a single statement still yields good captures.
30701
+ * Results are cached by (language, line) so re-renders and repeated
30702
+ * lines are free.
30703
+ *
30704
+ * Everything degrades to "no spans" (the caller renders the plain
30705
+ * single-color line): missing grammar, query compile failure, parse
30706
+ * error, non-ASCII text (byte/char offset skew), or an over-long line.
30707
+ */
30708
+ // Longest extension first so `.d.ts` / `.mts` win over `.ts`.
30709
+ //
30710
+ // NOTE: typescript + tsx grammars are bundled (offline). python / rust /
30711
+ // go grammars download on demand and are only available once cached
30712
+ // (prefetched) — `getTreeSitterParser` returns undefined otherwise, so
30713
+ // those diffs render plain until the grammar is present. Same
30714
+ // availability model the structural parsers already use.
30715
+ const EXT_LANGUAGE = [
30716
+ ['.tsx', 'tsx'],
30717
+ ['.jsx', 'tsx'],
30718
+ ['.mts', 'typescript'],
30719
+ ['.cts', 'typescript'],
30720
+ ['.mjs', 'tsx'],
30721
+ ['.cjs', 'tsx'],
30722
+ ['.ts', 'typescript'],
30723
+ ['.js', 'tsx'],
30724
+ ['.py', 'python'],
30725
+ ['.pyi', 'python'],
30726
+ ['.rs', 'rust'],
30727
+ ['.go', 'go'],
30728
+ ];
30729
+ /** Map a file path to a highlight language, or undefined when unsupported. */
30730
+ function detectSyntaxLanguage(filePath) {
30731
+ const lower = filePath.toLowerCase();
30732
+ for (const [ext, language] of EXT_LANGUAGE) {
30733
+ if (lower.endsWith(ext))
30734
+ return language;
30735
+ }
30736
+ return undefined;
30737
+ }
30738
+ // Printable ASCII (+ tab). Outside this range tree-sitter's byte offsets
30739
+ // diverge from JS string char offsets, which would misalign spans — so
30740
+ // we skip those lines entirely (rendered plain).
30741
+ const ASCII_ONLY = /^[\t\x20-\x7E]*$/;
30742
+ const MAX_LINE_LENGTH = 2000;
30743
+ // `null` marks "tried and failed" so we don't retry the grammar/query
30744
+ // load on every line.
30745
+ const queryCache = new Map();
30746
+ const spanCache = new Map();
30747
+ async function getQuery(language) {
30748
+ if (queryCache.has(language)) {
30749
+ return queryCache.get(language) ?? undefined;
30750
+ }
30751
+ const querySource = HIGHLIGHT_QUERIES[language];
30752
+ const loaded = querySource ? await getTreeSitterParser(language) : undefined;
30753
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30754
+ const grammar = loaded?.parser?.language;
30755
+ if (!loaded || !grammar || !querySource) {
30756
+ queryCache.set(language, null);
30757
+ return undefined;
30758
+ }
30759
+ try {
30760
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30761
+ const mod = (await import('web-tree-sitter'));
30762
+ const QueryCtor = mod.Query;
30763
+ const query = new QueryCtor(grammar, querySource);
30764
+ queryCache.set(language, query);
30765
+ return query;
30766
+ }
30767
+ catch {
30768
+ queryCache.set(language, null);
30769
+ return undefined;
30770
+ }
30771
+ }
30772
+ function buildSpans(text, captures) {
30773
+ const n = text.length;
30774
+ const charType = new Array(n);
30775
+ // Paint widest captures first so narrower/inner captures override them
30776
+ // — yields clean, non-overlapping spans without nesting bookkeeping.
30777
+ const ordered = [...captures].sort((a, b) => (b.node.endIndex - b.node.startIndex) - (a.node.endIndex - a.node.startIndex));
30778
+ for (const capture of ordered) {
30779
+ const token = captureToToken(capture.name);
30780
+ if (token === 'plain')
30781
+ continue;
30782
+ const start = Math.max(0, capture.node.startIndex);
30783
+ const end = Math.min(n, capture.node.endIndex);
30784
+ for (let i = start; i < end; i++)
30785
+ charType[i] = token;
30786
+ }
30787
+ // Coalesce runs of equal token type (gaps → 'plain').
30788
+ const spans = [];
30789
+ let i = 0;
30790
+ while (i < n) {
30791
+ const token = charType[i] ?? 'plain';
30792
+ let j = i + 1;
30793
+ while (j < n && (charType[j] ?? 'plain') === token)
30794
+ j++;
30795
+ spans.push({ start: i, end: j, token });
30796
+ i = j;
30797
+ }
30798
+ return spans;
30799
+ }
30800
+ /**
30801
+ * Tokenize a single line of code into non-overlapping spans covering the
30802
+ * whole string (plain runs included). Returns `[]` when the line can't
30803
+ * be highlighted (no grammar, parse error, non-ASCII, too long) so the
30804
+ * caller falls back to its plain single-color rendering.
30805
+ */
30806
+ async function highlightLine(language, text) {
30807
+ if (!text)
30808
+ return [];
30809
+ if (text.length > MAX_LINE_LENGTH || !ASCII_ONLY.test(text))
30810
+ return [];
30811
+ const key = `${language}${text}`;
30812
+ const cached = spanCache.get(key);
30813
+ if (cached)
30814
+ return cached;
30815
+ const query = await getQuery(language);
30816
+ const loaded = await getTreeSitterParser(language);
30817
+ if (!query || !loaded) {
30818
+ spanCache.set(key, []);
30819
+ return [];
30820
+ }
30821
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30822
+ let tree;
30823
+ try {
30824
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30825
+ tree = loaded.parser.parse(text);
30826
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30827
+ const captures = query.captures(tree.rootNode);
30828
+ const spans = buildSpans(text, captures);
30829
+ spanCache.set(key, spans);
30830
+ return spans;
30831
+ }
30832
+ catch {
30833
+ spanCache.set(key, []);
30834
+ return [];
30835
+ }
30836
+ finally {
30837
+ tree?.delete?.();
30838
+ }
30839
+ }
30840
+ /**
30841
+ * Tokenize the code lines of a unified diff. Strips the leading
30842
+ * `+` / `-` / ` ` marker before highlighting (headers and `@@` lines are
30843
+ * skipped — they're not code), and returns a map keyed by the
30844
+ * marker-stripped line text so the renderer can look spans up directly.
30845
+ * Lines that yield no spans are omitted (renderer falls back to plain).
30846
+ */
30847
+ /**
30848
+ * Pick the unique, marker-stripped code lines out of a unified diff:
30849
+ * only `+` / `-` / ` ` rows are code; `diff`/`index`/`@@`/`+++`/`---`
30850
+ * headers are skipped. Pure + exported so the selection is testable
30851
+ * without a grammar.
30852
+ */
30853
+ function selectDiffCodeLines(lines) {
30854
+ const seen = new Set();
30855
+ // Hunk-aware: a `+`/`-`/` ` line is code only INSIDE a hunk. This is
30856
+ // what distinguishes a real added line from the `+++ b/file` /
30857
+ // `--- a/file` file headers (which precede the first `@@` and also
30858
+ // start with `+`/`-`). Same stateful rule the split-diff parser uses.
30859
+ let inHunk = false;
30860
+ for (const line of lines) {
30861
+ if (!line)
30862
+ continue;
30863
+ if (line.startsWith('@@')) {
30864
+ inHunk = true;
30865
+ continue;
30866
+ }
30867
+ if (!inHunk)
30868
+ continue;
30869
+ const marker = line[0];
30870
+ if (marker !== '+' && marker !== '-' && marker !== ' ') {
30871
+ // A non-diff line (blank-separator label, next file's `diff --git`)
30872
+ // ends the current hunk until the next `@@`.
30873
+ inHunk = false;
30874
+ continue;
30875
+ }
30876
+ seen.add(line.slice(1));
30877
+ }
30878
+ return [...seen];
30879
+ }
30880
+ async function highlightDiffCode(filePath, lines) {
30881
+ const result = new Map();
30882
+ const language = detectSyntaxLanguage(filePath);
30883
+ if (!language)
30884
+ return result;
30885
+ for (const code of selectDiffCodeLines(lines)) {
30886
+ const spans = await highlightLine(language, code);
30887
+ if (spans.length)
30888
+ result.set(code, spans);
30889
+ }
30890
+ return result;
30891
+ }
30892
+
30108
30893
  async function runAction$4(action, successMessage) {
30109
30894
  try {
30110
30895
  await action();
@@ -33826,11 +34611,50 @@ function flushChangeBlock(removals, additions, rows) {
33826
34611
  removals.length = 0;
33827
34612
  additions.length = 0;
33828
34613
  }
33829
- function buildSplitDiffRows(unifiedLines) {
33830
- const rows = [];
34614
+ /**
34615
+ * Replay the hunk parser over `unifiedLines[0..upTo)` (exclusive) and
34616
+ * return the parse state at that boundary. Used by the split renderer
34617
+ * to seed `buildSplitDiffRows` with the correct in-hunk flag and
34618
+ * line-number cursors when it windows the diff to a scroll offset that
34619
+ * starts partway through a hunk. Counting mirrors `buildSplitDiffRows`
34620
+ * exactly so the seeded line numbers stay continuous across the cut.
34621
+ */
34622
+ function computeDiffContext(unifiedLines, upTo) {
33831
34623
  let oldLineNo = 0;
33832
34624
  let newLineNo = 0;
33833
34625
  let inHunk = false;
34626
+ const bound = Math.max(0, Math.min(upTo, unifiedLines.length));
34627
+ for (let i = 0; i < bound; i++) {
34628
+ const raw = unifiedLines[i];
34629
+ if (raw.startsWith('@@')) {
34630
+ const [oldStart, newStart] = parseHunkHeader(raw);
34631
+ oldLineNo = oldStart;
34632
+ newLineNo = newStart;
34633
+ inHunk = true;
34634
+ continue;
34635
+ }
34636
+ if (!inHunk || isDiffHeader(raw)) {
34637
+ continue;
34638
+ }
34639
+ if (raw.startsWith('-')) {
34640
+ oldLineNo += 1;
34641
+ continue;
34642
+ }
34643
+ if (raw.startsWith('+')) {
34644
+ newLineNo += 1;
34645
+ continue;
34646
+ }
34647
+ // Context line (or `\ No newline` marker) advances both cursors.
34648
+ oldLineNo += 1;
34649
+ newLineNo += 1;
34650
+ }
34651
+ return { inHunk, oldLineNo, newLineNo };
34652
+ }
34653
+ function buildSplitDiffRows(unifiedLines, seed) {
34654
+ const rows = [];
34655
+ let oldLineNo = seed?.oldLineNo ?? 0;
34656
+ let newLineNo = seed?.newLineNo ?? 0;
34657
+ let inHunk = seed?.inHunk ?? false;
33834
34658
  const removals = [];
33835
34659
  const additions = [];
33836
34660
  const flushHeader = (text) => {
@@ -33891,6 +34715,32 @@ function buildSplitDiffRows(unifiedLines) {
33891
34715
  return rows;
33892
34716
  }
33893
34717
 
34718
+ function resolveSyntaxColor(token, theme) {
34719
+ if (theme.noColor)
34720
+ return undefined;
34721
+ const c = theme.colors;
34722
+ switch (token) {
34723
+ case 'keyword':
34724
+ return c.syntaxKeyword ?? 'magenta';
34725
+ case 'string':
34726
+ return c.syntaxString ?? 'green';
34727
+ case 'comment':
34728
+ return c.syntaxComment ?? 'gray';
34729
+ case 'number':
34730
+ return c.syntaxNumber ?? 'yellow';
34731
+ case 'type':
34732
+ return c.syntaxType ?? 'cyan';
34733
+ case 'function':
34734
+ return c.syntaxFunction ?? 'blue';
34735
+ case 'constant':
34736
+ return c.syntaxConstant ?? 'yellow';
34737
+ case 'property':
34738
+ return c.syntaxProperty ?? undefined;
34739
+ default:
34740
+ return undefined;
34741
+ }
34742
+ }
34743
+
33894
34744
  /**
33895
34745
  * Split-diff rendering helpers (#785) — shared between the diff
33896
34746
  * surface and any future surface that wants side-by-side diff layout.
@@ -33962,30 +34812,107 @@ function formatSplitDiffCell(side, columnWidth) {
33962
34812
  return `${lineNo} ${truncateCells(text, textRoom)}`.padEnd(columnWidth);
33963
34813
  }
33964
34814
  /**
33965
- * Render the split-diff body as a list of two-column rows. The caller
33966
- * is responsible for slicing the unified-line array to the visible
33967
- * window — the helper just transforms that slice into Ink nodes.
34815
+ * Render one split-diff column as an Ink node — syntax-highlighted when
34816
+ * spans are available for the line, plain otherwise.
34817
+ *
34818
+ * Highlighted cells keep the 4-digit line-number gutter but color IT
34819
+ * with the add/remove cue (green/red, dim for context) so the code body
34820
+ * is free to carry its syntax colors — the split layout's position
34821
+ * (old | new) plus the colored gutter still tells you what changed.
34822
+ * Width is budgeted exactly like `formatSplitDiffCell` (gutter + 1 space
34823
+ * + truncated code) so columns never drift.
34824
+ */
34825
+ function renderSplitDiffCell(h, Text, side, columnWidth, theme, syntaxSpans, key) {
34826
+ const text = side.text.replace(/\n$/, '');
34827
+ const spans = side.kind === 'add' || side.kind === 'remove' || side.kind === 'context'
34828
+ ? syntaxSpans?.get(text)
34829
+ : undefined;
34830
+ if (!spans || spans.length === 0) {
34831
+ return h(Text, { key, ...splitDiffSideProps(side.kind, theme) }, formatSplitDiffCell(side, columnWidth));
34832
+ }
34833
+ const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
34834
+ const textRoom = Math.max(1, columnWidth - 5);
34835
+ const gutterColor = side.kind === 'add'
34836
+ ? theme.colors.gitAdded
34837
+ : side.kind === 'remove'
34838
+ ? theme.colors.gitDeleted
34839
+ : undefined;
34840
+ const children = [];
34841
+ let used = 0;
34842
+ for (const span of spans) {
34843
+ if (used >= textRoom)
34844
+ break;
34845
+ const segment = truncateCells(text.slice(span.start, span.end), textRoom - used);
34846
+ if (!segment)
34847
+ continue;
34848
+ used += cellWidth(segment);
34849
+ children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
34850
+ }
34851
+ return h(Text, { key }, h(Text, { key: `${key}-g`, color: gutterColor, dimColor: !gutterColor }, `${lineNo} `), ...children);
34852
+ }
34853
+ /**
34854
+ * Render the split-diff body as a list of two-column rows.
34855
+ *
34856
+ * Takes the FULL unified-line array plus the scroll offset + visible
34857
+ * row budget, and windows it internally. The windowing has to live
34858
+ * here (not the caller) because the parser is stateful: a window that
34859
+ * starts partway through a hunk needs the hunk context (in-hunk flag +
34860
+ * line-number cursors) that precedes it, or every visible line gets
34861
+ * misclassified as a header and painted in the accent color (#1114).
34862
+ * We compute that context from the lines before the window and seed
34863
+ * the parser with it.
33968
34864
  */
33969
- function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, theme, keyPrefix) {
34865
+ function renderSplitDiffBody(h, components, unifiedLines, startOffset, visibleRows, width, theme, keyPrefix, syntaxSpans) {
33970
34866
  const { Box, Text } = components;
33971
- const rows = buildSplitDiffRows(unifiedSlice);
34867
+ const seed = computeDiffContext(unifiedLines, startOffset);
34868
+ const unifiedSlice = unifiedLines.slice(startOffset, startOffset + visibleRows);
34869
+ const rows = buildSplitDiffRows(unifiedSlice, seed);
33972
34870
  // Reserve 3 columns of gutter (1 left padding from the Box + 1 column
33973
34871
  // separator + 1 right padding) so neither side touches the border.
33974
34872
  const usable = Math.max(20, width - 4);
33975
34873
  const gutter = 1;
33976
34874
  const half = Math.max(10, Math.floor((usable - gutter) / 2));
33977
34875
  return rows.map((row, index) => {
33978
- const leftProps = splitDiffSideProps(row.left.kind, theme);
33979
- const rightProps = splitDiffSideProps(row.right.kind, theme);
33980
- const leftText = formatSplitDiffCell(row.left, half);
33981
- const rightText = formatSplitDiffCell(row.right, half);
34876
+ const rowKey = `${keyPrefix}-${startOffset + index}`;
33982
34877
  return h(Box, {
33983
- key: `${keyPrefix}-${startOffset + index}`,
34878
+ key: rowKey,
33984
34879
  flexDirection: 'row',
33985
- }, h(Box, { width: half, flexShrink: 0 }, h(Text, leftProps, leftText)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, h(Text, rightProps, rightText)));
34880
+ }, h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.left, half, theme, syntaxSpans, `${rowKey}-l`)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.right, half, theme, syntaxSpans, `${rowKey}-r`)));
33986
34881
  });
33987
34882
  }
33988
34883
 
34884
+ /**
34885
+ * @param syntaxSpans map of marker-stripped code line → token spans
34886
+ * (from `highlightDiffCode`), or undefined when highlighting is off.
34887
+ * @param maxCells total cell budget for the whole line (marker + code).
34888
+ */
34889
+ function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
34890
+ const spans = line ? syntaxSpans?.get(line.slice(1)) : undefined;
34891
+ if (!spans || spans.length === 0) {
34892
+ return h(Text, { key, ...diffLineProps(line, theme) }, truncateCells(line, maxCells));
34893
+ }
34894
+ const marker = line[0];
34895
+ const markerColor = marker === '+'
34896
+ ? theme.colors.gitAdded
34897
+ : marker === '-'
34898
+ ? theme.colors.gitDeleted
34899
+ : undefined;
34900
+ const code = line.slice(1);
34901
+ const budget = Math.max(0, maxCells - 1); // reserve one cell for the marker
34902
+ const children = [];
34903
+ let used = 0;
34904
+ for (const span of spans) {
34905
+ if (used >= budget)
34906
+ break;
34907
+ const segment = truncateCells(code.slice(span.start, span.end), budget - used);
34908
+ if (!segment)
34909
+ continue;
34910
+ used += cellWidth(segment);
34911
+ children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
34912
+ }
34913
+ return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
34914
+ }
34915
+
33989
34916
  /**
33990
34917
  * Diff surface — the unified or side-by-side diff view. Four sources
33991
34918
  * route through here, disambiguated by `state.diffSource`:
@@ -34008,7 +34935,7 @@ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, th
34008
34935
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
34009
34936
  * of #890. No behavior change.
34010
34937
  */
34011
- function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
34938
+ function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
34012
34939
  const { Box, Text } = components;
34013
34940
  const focused = state.focus === 'commits';
34014
34941
  const worktree = context.worktree;
@@ -34062,7 +34989,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34062
34989
  const stashBodyNodes = stashDiffLoading || !lines.length
34063
34990
  ? []
34064
34991
  : splitActive
34065
- ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'stash-diff-split')
34992
+ ? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'stash-diff-split', syntaxSpans)
34066
34993
  : visibleLines.map((line, index) => {
34067
34994
  const absoluteIndex = state.diffPreviewOffset + index;
34068
34995
  const headerFile = stashFileByStartLine.get(absoluteIndex);
@@ -34099,10 +35026,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34099
35026
  : truncateCells(`${arrow}${headerFile.path}`, width - 4);
34100
35027
  })());
34101
35028
  }
34102
- return h(Text, {
34103
- key: `stash-diff-line-${absoluteIndex}`,
34104
- ...diffLineProps(line, theme),
34105
- }, truncateCells(line, width - 4));
35029
+ return renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `stash-diff-line-${absoluteIndex}`);
34106
35030
  });
34107
35031
  return h(Box, {
34108
35032
  borderColor: focusBorderColor(theme, focused),
@@ -34143,11 +35067,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34143
35067
  const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
34144
35068
  ? []
34145
35069
  : splitActive
34146
- ? renderSplitDiffBody(h, components, visibleLines, state.diffPreviewOffset, width, theme, 'compare-diff-split')
34147
- : visibleLines.map((line, index) => h(Text, {
34148
- key: `compare-diff-line-${state.diffPreviewOffset + index}`,
34149
- ...diffLineProps(line, theme),
34150
- }, truncateCells(line, width - 4)));
35070
+ ? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'compare-diff-split', syntaxSpans)
35071
+ : visibleLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `compare-diff-line-${state.diffPreviewOffset + index}`));
34151
35072
  return h(Box, {
34152
35073
  borderColor: focusBorderColor(theme, focused),
34153
35074
  borderStyle: theme.borderStyle,
@@ -34197,11 +35118,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34197
35118
  const commitBodyNodes = filePreviewLoading || !previewHunks.length
34198
35119
  ? []
34199
35120
  : splitActive
34200
- ? renderSplitDiffBody(h, components, visiblePreviewHunks, state.diffPreviewOffset, width, theme, 'commit-diff-split')
34201
- : visiblePreviewHunks.map((line, index) => h(Text, {
34202
- key: `diff-surface-line-${state.diffPreviewOffset + index}`,
34203
- ...diffLineProps(line, theme),
34204
- }, truncateCells(line, 140)));
35121
+ ? renderSplitDiffBody(h, components, previewHunks, state.diffPreviewOffset, visibleRows, width, theme, 'commit-diff-split', syntaxSpans)
35122
+ : visiblePreviewHunks.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.diffPreviewOffset + index}`));
34205
35123
  return h(Box, {
34206
35124
  borderColor: focusBorderColor(theme, focused),
34207
35125
  borderStyle: theme.borderStyle,
@@ -34247,10 +35165,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
34247
35165
  key: `diff-surface-header-${index}`,
34248
35166
  dimColor: index > 0,
34249
35167
  }, truncateCells(line, 140))), ...(showDiffLines
34250
- ? visibleDiffLines.map((line, index) => h(Text, {
34251
- key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
34252
- ...diffLineProps(line, theme),
34253
- }, truncateCells(line, 140)))
35168
+ ? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
34254
35169
  : []));
34255
35170
  }
34256
35171
 
@@ -35396,9 +36311,55 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
35396
36311
  backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
35397
36312
  }, truncateCells(label, 140));
35398
36313
  }
35399
- function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow()) {
36314
+ /**
36315
+ * Full-panel loader shown over the history surface while a remote
36316
+ * operation (fetch / pull / push) is in flight. Same bordered frame
36317
+ * and `Commits` title row as the real panel so the swap in/out is
36318
+ * seamless: a centered spinner + label + a travelling arrow track
36319
+ * give the user an unmistakable "we're talking to the remote" beat in
36320
+ * place of a frozen, soon-to-abruptly-repaint commit list.
36321
+ */
36322
+ function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame) {
36323
+ const { Box, Text } = components;
36324
+ const op = state.remoteOp;
36325
+ if (!op) {
36326
+ return h(Box, { width });
36327
+ }
36328
+ const spinner = pickSpinnerFrame(spinnerFrame);
36329
+ // Directional glyph hints which way the bits are flowing.
36330
+ const glyph = op.kind === 'push' ? '↑' : op.kind === 'pull' ? '↓' : '↕';
36331
+ // A single glyph "travels" along a dotted track each tick so the
36332
+ // motion reads even on terminals that render braille spinners poorly.
36333
+ const trackWidth = 9;
36334
+ const pos = Math.max(0, spinnerFrame) % trackWidth;
36335
+ const track = Array.from({ length: trackWidth }, (_, i) => (i === pos ? glyph : '·')).join(' ');
36336
+ const accent = theme.noColor ? undefined : theme.colors.accent;
36337
+ const innerHeight = Math.max(3, bodyRows - 2);
36338
+ return h(Box, {
36339
+ borderColor: focusBorderColor(theme, focused),
36340
+ borderStyle: theme.borderStyle,
36341
+ flexDirection: 'column',
36342
+ flexShrink: 0,
36343
+ paddingX: 1,
36344
+ width,
36345
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${op.kind} in progress`)), h(Box, {
36346
+ flexDirection: 'column',
36347
+ alignItems: 'center',
36348
+ justifyContent: 'center',
36349
+ height: innerHeight,
36350
+ }, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
36351
+ }
36352
+ function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
35400
36353
  const { Box, Text } = components;
35401
36354
  const focused = state.focus === 'commits';
36355
+ // Remote op in flight (fetch / pull / push) → swap the commit list
36356
+ // for a centered, animated loader. Keeping the same bordered panel
36357
+ // (same width, same title row) means that when the op completes and
36358
+ // `remoteOp` clears, the fresh rows paint in place without the panel
36359
+ // jumping — smoothing over the "frozen list → sudden repaint" feel.
36360
+ if (state.remoteOp) {
36361
+ return renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame);
36362
+ }
35402
36363
  const worktree = context.worktree;
35403
36364
  // Distinct remote names seen across the repo's remote-tracking
35404
36365
  // branches — `['origin']` for a typical fork, `['origin', 'upstream']`
@@ -35879,6 +36840,37 @@ function renderThemePickerOverlay(h, components, filter, index, width, theme, fo
35879
36840
  ? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
35880
36841
  : []));
35881
36842
  }
36843
+ /**
36844
+ * "Add to .gitignore" quick-pick overlay (`i` on the status view).
36845
+ * Modeled on the theme picker but with a fixed, file-derived option list
36846
+ * (no fuzzy filter — the menu is short): pick exact / by-extension /
36847
+ * by-folder / by-name, or the `Custom pattern…` escape hatch which opens
36848
+ * a free-text prompt. ↑/↓ to move, Enter to choose, Esc to cancel.
36849
+ */
36850
+ function renderGitignorePickerOverlay(h, components, file, index, width, theme, focused) {
36851
+ const { Box, Text } = components;
36852
+ const options = deriveGitignoreOptions(file);
36853
+ const selectedIndex = Math.max(0, Math.min(index, options.length - 1));
36854
+ const hint = '↑/↓ select · enter add · esc cancel';
36855
+ const itemLines = options.map((option, offset) => {
36856
+ const isSelected = offset === selectedIndex;
36857
+ const cursor = isSelected ? '>' : ' ';
36858
+ const glyph = option.custom ? '✎ ' : '+ ';
36859
+ return h(Text, {
36860
+ key: `gitignore-opt-${offset}`,
36861
+ bold: isSelected,
36862
+ dimColor: !isSelected,
36863
+ color: isSelected && !theme.noColor ? theme.colors.accent : undefined,
36864
+ }, `${cursor} ${glyph}`, truncateCells(option.label, width - 8));
36865
+ });
36866
+ return h(Box, {
36867
+ borderColor: focusBorderColor(theme, focused),
36868
+ borderStyle: theme.borderStyle,
36869
+ flexDirection: 'column',
36870
+ width,
36871
+ paddingX: 1,
36872
+ }, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Add to .gitignore', focused)), h(Text, { dimColor: true }, `${options.length} options`)), h(Text, { color: theme.colors.accent }, truncateCells(file || '(no file)', width - 4)), h(Text, { dimColor: true }, truncateCells(hint, width - 4)), h(Text, undefined, ''), ...itemLines);
36873
+ }
35882
36874
  /**
35883
36875
  * Split-plan overlay (#907) — renders the proposed commit groups for
35884
36876
  * the user to review before applying. Three phases driven by
@@ -37223,7 +38215,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
37223
38215
  * Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
37224
38216
  * of #890. No behavior change.
37225
38217
  */
37226
- function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled) {
38218
+ function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
37227
38219
  // Split-plan overlay (#907 polish): renders in the MAIN panel (not
37228
38220
  // detail) when active, because the content — multiple commit groups
37229
38221
  // with file lists, rationale, hunks — needs the full center width
@@ -37238,7 +38230,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
37238
38230
  return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
37239
38231
  }
37240
38232
  if (state.activeView === 'diff') {
37241
- return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
38233
+ return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
37242
38234
  }
37243
38235
  if (state.activeView === 'compose') {
37244
38236
  return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
@@ -37279,7 +38271,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
37279
38271
  if (state.activeView === 'changelog') {
37280
38272
  return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
37281
38273
  }
37282
- return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
38274
+ return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
37283
38275
  }
37284
38276
 
37285
38277
  /**
@@ -38318,6 +39310,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
38318
39310
  if (state.showThemePicker) {
38319
39311
  return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
38320
39312
  }
39313
+ if (state.gitignorePicker) {
39314
+ return renderGitignorePickerOverlay(h, components, state.gitignorePicker.file, state.gitignorePicker.index, width, theme, focused);
39315
+ }
38321
39316
  if (state.inputPrompt) {
38322
39317
  return renderInputPromptPanel(h, components, state, width, theme, focused);
38323
39318
  }
@@ -38395,6 +39390,67 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
38395
39390
  return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
38396
39391
  }
38397
39392
 
39393
+ /**
39394
+ * Resolve + scaffold the coco config files the workstation can open in
39395
+ * `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
39396
+ *
39397
+ * Two scopes:
39398
+ * - `global` → `$XDG_CONFIG_HOME/coco/config.json` (default
39399
+ * `~/.config/coco/config.json`) — applies to every repo.
39400
+ * - `project` → `.coco.json` (preferred) or the legacy
39401
+ * `.coco.config.json` at the repo root — applies to the
39402
+ * current repository only.
39403
+ *
39404
+ * When the chosen file doesn't exist yet we write a minimal templated
39405
+ * starter (just the `$schema` link + a sample `logTui.theme.preset`) so
39406
+ * the user lands in an editable, schema-aware file instead of an empty
39407
+ * buffer or an error.
39408
+ */
39409
+ /**
39410
+ * Minimal starter config written when scaffolding a missing file. Keeps
39411
+ * the `$schema` link (so editors offer completion/validation) and one
39412
+ * illustrative key showing where settings live — small enough to not
39413
+ * impose opinions, structured enough to be a useful starting point.
39414
+ */
39415
+ const STARTER_CONFIG = `${JSON.stringify({
39416
+ $schema: SCHEMA_PUBLIC_URL,
39417
+ logTui: { theme: { preset: 'default' } },
39418
+ }, null, 2)}\n`;
39419
+ /** `$XDG_CONFIG_HOME/coco/config.json` (default `~/.config/coco/config.json`). */
39420
+ function getGlobalConfigPath() {
39421
+ return getXdgConfigPath();
39422
+ }
39423
+ /**
39424
+ * The project config path for `repoRoot`: the first existing of
39425
+ * `.coco.json` / `.coco.config.json`, else `.coco.json` as the default
39426
+ * to create.
39427
+ */
39428
+ function getProjectConfigPath(repoRoot) {
39429
+ for (const name of ['.coco.json', '.coco.config.json']) {
39430
+ const candidate = path.join(repoRoot, name);
39431
+ if (fs.existsSync(candidate))
39432
+ return candidate;
39433
+ }
39434
+ return path.join(repoRoot, '.coco.json');
39435
+ }
39436
+ /** Resolve the config path for a scope. `project` needs the repo root. */
39437
+ function resolveConfigPath(scope, repoRoot) {
39438
+ return scope === 'global' ? getGlobalConfigPath() : getProjectConfigPath(repoRoot);
39439
+ }
39440
+ /**
39441
+ * Ensure `filePath` exists, scaffolding the starter template (and any
39442
+ * missing parent directories) when it doesn't. Returns whether it was
39443
+ * just created so the caller can surface a "Created …" message.
39444
+ */
39445
+ function ensureConfigFile(filePath) {
39446
+ if (fs.existsSync(filePath)) {
39447
+ return { created: false };
39448
+ }
39449
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
39450
+ fs.writeFileSync(filePath, STARTER_CONFIG);
39451
+ return { created: true };
39452
+ }
39453
+
38398
39454
  /**
38399
39455
  * `LogInkApp` — the workstation's root React component. Hosts all state
38400
39456
  * via `useState`/`useEffect`/`useMemo`/`useCallback` hooks; wires up the
@@ -38541,6 +39597,18 @@ function loadLogInkContextEntries(git) {
38541
39597
  },
38542
39598
  ];
38543
39599
  }
39600
+ // Workflow action ids that hit the network (fetch / pull / push) →
39601
+ // the loader copy shown over the history surface while they run. Any
39602
+ // id NOT in this map runs without the full-screen loader (local-only
39603
+ // mutations repaint fast enough that a loader would just flicker).
39604
+ const REMOTE_OP_LOADERS = {
39605
+ 'fetch-remotes': { kind: 'fetch', label: 'Fetching all remotes…' },
39606
+ 'pull-current-branch': { kind: 'pull', label: 'Pulling from origin…' },
39607
+ 'push-current-branch': { kind: 'push', label: 'Pushing to origin…' },
39608
+ 'fetch-selected-branch': { kind: 'fetch', label: 'Fetching branch from remote…' },
39609
+ 'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
39610
+ 'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
39611
+ };
38544
39612
  function predictNextFilter(action, currentFilter) {
38545
39613
  switch (action.type) {
38546
39614
  case 'appendFilter':
@@ -38612,7 +39680,7 @@ function enrichFilterActionWithRectification(action, state, context) {
38612
39680
  }
38613
39681
  }
38614
39682
  function LogInkApp(deps) {
38615
- const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
39683
+ const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, syntaxHighlightEnabled, theme: baseTheme, themeConfig } = deps;
38616
39684
  const { Box, Text, useApp, useInput, useWindowSize } = ink;
38617
39685
  const h = React.createElement;
38618
39686
  // Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
@@ -38783,6 +39851,11 @@ function LogInkApp(deps) {
38783
39851
  const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
38784
39852
  const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
38785
39853
  const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
39854
+ // Syntax-highlight spans for the diff currently in view (#1117
39855
+ // follow-up). Computed off the render path by the effect below;
39856
+ // keyed by marker-stripped code line so the diff renderer looks
39857
+ // spans up directly. `undefined` = no highlighting (renders plain).
39858
+ const [diffSyntaxSpans, setDiffSyntaxSpans] = React.useState(undefined);
38786
39859
  // Stash diff explorer (Enter on a stash row): the runtime fetches
38787
39860
  // `git stash show -p <ref>` lazily once the diff view becomes active
38788
39861
  // with diffSource='stash'. Lines are stored as a flat string[] —
@@ -38847,6 +39920,7 @@ function LogInkApp(deps) {
38847
39920
  state.splitPlan?.status === 'applying' ||
38848
39921
  state.changelogView.status === 'loading' ||
38849
39922
  state.commitCompose.loading ||
39923
+ Boolean(state.remoteOp) ||
38850
39924
  Boolean(state.statusLoading);
38851
39925
  React.useEffect(() => {
38852
39926
  if (!anyLoading) {
@@ -39813,6 +40887,53 @@ function LogInkApp(deps) {
39813
40887
  selectedWorktreeFile?.worktreeStatus,
39814
40888
  state.activeView,
39815
40889
  ]);
40890
+ // Syntax-highlight the diff currently in view, off the render path
40891
+ // (#1117 follow-up). Mirrors the worktree-diff effect: detect the
40892
+ // active file + its diff lines (worktree or commit source), tokenize
40893
+ // via tree-sitter, and store the per-line spans for the renderer.
40894
+ // Stash / compare sources aren't highlighted yet (multi-file patch /
40895
+ // no single path). Gated on the config flag + a color terminal.
40896
+ React.useEffect(() => {
40897
+ if (!syntaxHighlightEnabled || theme.noColor || state.activeView !== 'diff') {
40898
+ setDiffSyntaxSpans(undefined);
40899
+ return;
40900
+ }
40901
+ let filePath;
40902
+ let lines;
40903
+ if (state.diffSource === 'commit') {
40904
+ filePath = selectedDetailFile?.path;
40905
+ lines = filePreview?.hunks;
40906
+ }
40907
+ else if (worktreeDiff && !worktreeDiff.untracked) {
40908
+ filePath = worktreeDiff.filePath;
40909
+ lines = worktreeDiff.lines;
40910
+ }
40911
+ if (!filePath || !lines || lines.length === 0) {
40912
+ setDiffSyntaxSpans(undefined);
40913
+ return;
40914
+ }
40915
+ let active = true;
40916
+ void highlightDiffCode(filePath, lines)
40917
+ .then((map) => {
40918
+ if (active)
40919
+ setDiffSyntaxSpans(map.size > 0 ? map : undefined);
40920
+ })
40921
+ .catch(() => {
40922
+ if (active)
40923
+ setDiffSyntaxSpans(undefined);
40924
+ });
40925
+ return () => {
40926
+ active = false;
40927
+ };
40928
+ }, [
40929
+ syntaxHighlightEnabled,
40930
+ theme.noColor,
40931
+ state.activeView,
40932
+ state.diffSource,
40933
+ selectedDetailFile?.path,
40934
+ filePreview,
40935
+ worktreeDiff,
40936
+ ]);
39816
40937
  const toggleSelectedFileStage = React.useCallback(async () => {
39817
40938
  if (!selectedWorktreeFile) {
39818
40939
  dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
@@ -40487,6 +41608,28 @@ function LogInkApp(deps) {
40487
41608
  // refresh so the file row reflects the new staged/unstaged state.
40488
41609
  void refreshWorktreeContext({ silent: true });
40489
41610
  }, [dispatch, refreshWorktreeContext, resumeRef]);
41611
+ // Open the global or project coco config in $EDITOR (gk / gK + their
41612
+ // command-palette entries). Scaffolds a templated starter when the file
41613
+ // doesn't exist yet so the user never lands in an empty buffer or hits
41614
+ // a "no such file" error.
41615
+ const openConfigInEditor = React.useCallback((scope) => {
41616
+ // `repoRootRef` is populated async from `git rev-parse --show-toplevel`;
41617
+ // fall back to cwd so a freshly-launched session can still scaffold +
41618
+ // open the project config before that resolves.
41619
+ const repoRoot = repoRootRef.current || process.cwd();
41620
+ const filePath = resolveConfigPath(scope, repoRoot);
41621
+ try {
41622
+ const { created } = ensureConfigFile(filePath);
41623
+ if (created) {
41624
+ dispatch({ type: 'setStatus', value: `Created ${scope} config at ${filePath}`, kind: 'success' });
41625
+ }
41626
+ }
41627
+ catch (error) {
41628
+ dispatch({ type: 'setStatus', value: `Could not create config: ${error.message}`, kind: 'error' });
41629
+ return;
41630
+ }
41631
+ openInEditor(filePath);
41632
+ }, [dispatch, openInEditor]);
40490
41633
  // `E` keystroke handler — open the current commit draft in $EDITOR
40491
41634
  // (or $VISUAL), then read the file back and update the compose state
40492
41635
  // with the saved content. Mirrors the suspend → spawn → resume
@@ -41342,6 +42485,7 @@ function LogInkApp(deps) {
41342
42485
  return { ok: false, message: 'No branch selected' };
41343
42486
  return pushBranch(git, branch);
41344
42487
  },
42488
+ 'add-to-gitignore': async () => addToGitignore(git, payload || ''),
41345
42489
  'rename-branch': async () => {
41346
42490
  const newName = payload?.trim();
41347
42491
  if (!newName)
@@ -41637,76 +42781,102 @@ function LogInkApp(deps) {
41637
42781
  dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
41638
42782
  return;
41639
42783
  }
41640
- const result = await handler();
41641
- dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
41642
- // Refresh history rows AS WELL when the workflow could have
41643
- // changed the commits the user sees (#945 follow-up). The
41644
- // workflow IDs below all either create/rewrite local commits or
41645
- // change which branch's history is being viewed without this
41646
- // the history pane shows stale data even after the operation
41647
- // succeeds. Cheap one-off `git log` call; doesn't fire on
41648
- // metadata-only mutations (delete-tag, set-upstream, etc.).
41649
- const historyMutatingIds = new Set([
41650
- 'checkout-branch',
41651
- 'continue-operation',
41652
- 'pull-current-branch',
41653
- 'cherry-pick-commit',
41654
- 'revert-commit',
41655
- 'reset-hard-to-commit',
41656
- 'reset-soft-to-commit',
41657
- 'reset-mixed-to-commit',
41658
- 'interactive-rebase-to-commit',
41659
- 'bisect-good',
41660
- 'bisect-bad',
41661
- 'bisect-skip',
41662
- 'bisect-reset',
41663
- ]);
41664
- if (result?.ok && historyMutatingIds.has(id)) {
41665
- await refreshHistoryRows();
41666
- }
41667
- // Checkout-branch is the one workflow where we want a *visible*
41668
- // refresh so the user sees the branches sidebar repaint with the
41669
- // new current branch (per #806 follow-up). Snap the cursor to
41670
- // position 0 first so when the refresh completes and the new
41671
- // current branch lands at the top (per #809's pin-current rule),
41672
- // the cursor is already there waiting.
41673
- if (id === 'checkout-branch' && result?.ok) {
41674
- dispatch({ type: 'resetBranchSelection' });
41675
- await refreshContext();
42784
+ // Remote network ops (fetch / pull / push) get a full-screen
42785
+ // history loader while in flight so the commit list doesn't sit
42786
+ // frozen and then abruptly repaint when the call returns. Cleared
42787
+ // in `finally` *after* the post-op refresh below so the loader
42788
+ // hands straight off to the freshly-fetched rows instead of
42789
+ // flashing the stale list for a frame in between.
42790
+ const remoteOp = REMOTE_OP_LOADERS[id];
42791
+ if (remoteOp) {
42792
+ dispatch({ type: 'setRemoteOp', value: remoteOp });
41676
42793
  }
41677
- else {
41678
- // Silent refresh so the deleted item disappears from the list
41679
- // without flickering the surfaces through a 'loading' phase.
41680
- await refreshContext({ silent: true });
41681
- }
41682
- // Stash workflow follow-up. Two distinct behaviours.
41683
- //
41684
- // **apply / pop**: the user brought stashed content back into the
41685
- // worktree, but the sidebar still has them on the stash view.
41686
- // Expected next move is "look at what landed in my worktree", so
41687
- // jump them to history view (where the worktree counts in the
41688
- // sidebar are visible) AND refresh worktree context explicitly so
41689
- // the staged / unstaged / untracked numbers reflect the changes.
41690
- //
41691
- // **drop**: the silent context refresh above already re-fetched
41692
- // the stash list, BUT users reported it feeling like nothing
41693
- // happened. Fix two things: refresh worktree alongside (drops can
41694
- // affect untracked files when the stash held `-u` state), and
41695
- // surface the new stash count on the status line so there's
41696
- // unambiguous feedback that the drop landed and the list shrank.
41697
- if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
41698
- dispatch({ type: 'pushView', value: 'history' });
41699
- await refreshWorktreeContext();
42794
+ try {
42795
+ const result = await handler();
42796
+ dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
42797
+ // Refresh history rows AS WELL when the workflow could have
42798
+ // changed the commits the user sees (#945 follow-up). The
42799
+ // workflow IDs below all either create/rewrite local commits or
42800
+ // change which branch's history is being viewed — without this
42801
+ // the history pane shows stale data even after the operation
42802
+ // succeeds. Cheap one-off `git log` call; doesn't fire on
42803
+ // metadata-only mutations (delete-tag, set-upstream, etc.).
42804
+ const historyMutatingIds = new Set([
42805
+ 'checkout-branch',
42806
+ 'continue-operation',
42807
+ 'pull-current-branch',
42808
+ 'cherry-pick-commit',
42809
+ 'revert-commit',
42810
+ 'reset-hard-to-commit',
42811
+ 'reset-soft-to-commit',
42812
+ 'reset-mixed-to-commit',
42813
+ 'interactive-rebase-to-commit',
42814
+ 'bisect-good',
42815
+ 'bisect-bad',
42816
+ 'bisect-skip',
42817
+ 'bisect-reset',
42818
+ ]);
42819
+ if (result?.ok && historyMutatingIds.has(id)) {
42820
+ await refreshHistoryRows();
42821
+ }
42822
+ // Checkout-branch is the one workflow where we want a *visible*
42823
+ // refresh so the user sees the branches sidebar repaint with the
42824
+ // new current branch (per #806 follow-up). Snap the cursor to
42825
+ // position 0 first so when the refresh completes and the new
42826
+ // current branch lands at the top (per #809's pin-current rule),
42827
+ // the cursor is already there waiting.
42828
+ if (id === 'checkout-branch' && result?.ok) {
42829
+ dispatch({ type: 'resetBranchSelection' });
42830
+ await refreshContext();
42831
+ }
42832
+ else {
42833
+ // Silent refresh so the deleted item disappears from the list
42834
+ // without flickering the surfaces through a 'loading' phase.
42835
+ await refreshContext({ silent: true });
42836
+ }
42837
+ // Stash workflow follow-up. Two distinct behaviours.
42838
+ //
42839
+ // **apply / pop**: the user brought stashed content back into the
42840
+ // worktree, but the sidebar still has them on the stash view.
42841
+ // Expected next move is "look at what landed in my worktree", so
42842
+ // jump them to history view (where the worktree counts in the
42843
+ // sidebar are visible) AND refresh worktree context explicitly so
42844
+ // the staged / unstaged / untracked numbers reflect the changes.
42845
+ //
42846
+ // **drop**: the silent context refresh above already re-fetched
42847
+ // the stash list, BUT users reported it feeling like nothing
42848
+ // happened. Fix two things: refresh worktree alongside (drops can
42849
+ // affect untracked files when the stash held `-u` state), and
42850
+ // surface the new stash count on the status line so there's
42851
+ // unambiguous feedback that the drop landed and the list shrank.
42852
+ if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
42853
+ dispatch({ type: 'pushView', value: 'history' });
42854
+ await refreshWorktreeContext();
42855
+ }
42856
+ // Refresh the worktree so a now-ignored untracked file drops out of
42857
+ // the status list immediately (the silent context refresh above
42858
+ // doesn't always re-read the worktree file set).
42859
+ if (result?.ok && id === 'add-to-gitignore') {
42860
+ await refreshWorktreeContext();
42861
+ }
42862
+ if (result?.ok && id === 'drop-stash') {
42863
+ // Explicit worktree refresh in case the dropped stash carried
42864
+ // untracked-file state that's now collected.
42865
+ await refreshWorktreeContext();
42866
+ // The silent context refresh already replaced `context.stashes`;
42867
+ // reading the count back here would be stale because closures
42868
+ // capture the pre-refresh value. Status message stays generic
42869
+ // ("Dropped stash@{N}") — the visible list shrinking is the
42870
+ // unambiguous signal that the operation landed.
42871
+ }
41700
42872
  }
41701
- if (result?.ok && id === 'drop-stash') {
41702
- // Explicit worktree refresh in case the dropped stash carried
41703
- // untracked-file state that's now collected.
41704
- await refreshWorktreeContext();
41705
- // The silent context refresh already replaced `context.stashes`;
41706
- // reading the count back here would be stale because closures
41707
- // capture the pre-refresh value. Status message stays generic
41708
- // ("Dropped stash@{N}") — the visible list shrinking is the
41709
- // unambiguous signal that the operation landed.
42873
+ finally {
42874
+ // Always clear the loader even if a refresh threw — so a
42875
+ // failed fetch/pull can't leave the history surface stuck behind
42876
+ // the spinner.
42877
+ if (remoteOp) {
42878
+ dispatch({ type: 'setRemoteOp', value: undefined });
42879
+ }
41710
42880
  }
41711
42881
  }, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
41712
42882
  state.branchSort, state.filter, state.selectedBranchIndex,
@@ -42458,9 +43628,22 @@ function LogInkApp(deps) {
42458
43628
  else if (event.type === 'openFileInEditor') {
42459
43629
  openInEditor(event.path);
42460
43630
  }
43631
+ else if (event.type === 'openConfigInEditor') {
43632
+ openConfigInEditor(event.scope);
43633
+ }
42461
43634
  else if (event.type === 'yankFromActiveView') {
42462
43635
  void yankFromActiveView(event.short);
42463
43636
  }
43637
+ else if (event.type === 'openGitignorePicker') {
43638
+ // Resolve the cursored worktree file here (the runtime owns the
43639
+ // selection→file mapping) and open the picker over its path.
43640
+ if (selectedWorktreeFile?.path) {
43641
+ dispatch({ type: 'openGitignorePicker', file: selectedWorktreeFile.path });
43642
+ }
43643
+ else {
43644
+ dispatch({ type: 'setStatus', value: 'No file under the cursor to ignore.', kind: 'warning' });
43645
+ }
43646
+ }
42464
43647
  else if (event.type === 'applyThemePreset') {
42465
43648
  // Apply for the session immediately, and best-effort persist to the
42466
43649
  // global config so it sticks across launches. The picker has already
@@ -42504,7 +43687,7 @@ function LogInkApp(deps) {
42504
43687
  if (showOnboarding) {
42505
43688
  return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
42506
43689
  }
42507
- return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
43690
+ return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
42508
43691
  }
42509
43692
 
42510
43693
  /**
@@ -42675,6 +43858,8 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
42675
43858
  // Resolve undefined → true so the default flips on automatically.
42676
43859
  // An explicit `false` from config opts out.
42677
43860
  dateBucketingEnabled: options.dateBucketing !== false,
43861
+ // Undefined → on; explicit `false` opts out.
43862
+ syntaxHighlightEnabled: options.syntaxHighlight !== false,
42678
43863
  ink,
42679
43864
  initialView: options.initialView || 'history',
42680
43865
  logArgv: options.logArgv,
@@ -42882,6 +44067,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
42882
44067
  appLabel: 'coco',
42883
44068
  idleTips: config.logTui?.idleTips,
42884
44069
  dateBucketing: config.logTui?.dateBucketing,
44070
+ syntaxHighlight: config.logTui?.syntaxHighlight,
42885
44071
  initialView: 'history',
42886
44072
  loadRows,
42887
44073
  logArgv,
@@ -42904,6 +44090,7 @@ async function startCocoUi(argv) {
42904
44090
  appLabel: 'coco',
42905
44091
  idleTips: config.logTui?.idleTips,
42906
44092
  dateBucketing: config.logTui?.dateBucketing,
44093
+ syntaxHighlight: config.logTui?.syntaxHighlight,
42907
44094
  initialView: argv.view || 'history',
42908
44095
  loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
42909
44096
  logArgv,