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.
- package/README.md +10 -4
- package/dist/index.d.ts +26 -0
- package/dist/index.esm.mjs +1310 -123
- package/dist/index.js +1310 -123
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.59.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -1164,6 +1164,11 @@ const schema$1 = {
|
|
|
1164
1164
|
"type": "boolean",
|
|
1165
1165
|
"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.",
|
|
1166
1166
|
"default": true
|
|
1167
|
+
},
|
|
1168
|
+
"syntaxHighlight": {
|
|
1169
|
+
"type": "boolean",
|
|
1170
|
+
"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.",
|
|
1171
|
+
"default": true
|
|
1167
1172
|
}
|
|
1168
1173
|
},
|
|
1169
1174
|
"additionalProperties": false,
|
|
@@ -2162,6 +2167,31 @@ const schema$1 = {
|
|
|
2162
2167
|
},
|
|
2163
2168
|
"warning": {
|
|
2164
2169
|
"type": "string"
|
|
2170
|
+
},
|
|
2171
|
+
"syntaxKeyword": {
|
|
2172
|
+
"type": "string",
|
|
2173
|
+
"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."
|
|
2174
|
+
},
|
|
2175
|
+
"syntaxString": {
|
|
2176
|
+
"type": "string"
|
|
2177
|
+
},
|
|
2178
|
+
"syntaxComment": {
|
|
2179
|
+
"type": "string"
|
|
2180
|
+
},
|
|
2181
|
+
"syntaxNumber": {
|
|
2182
|
+
"type": "string"
|
|
2183
|
+
},
|
|
2184
|
+
"syntaxType": {
|
|
2185
|
+
"type": "string"
|
|
2186
|
+
},
|
|
2187
|
+
"syntaxFunction": {
|
|
2188
|
+
"type": "string"
|
|
2189
|
+
},
|
|
2190
|
+
"syntaxConstant": {
|
|
2191
|
+
"type": "string"
|
|
2192
|
+
},
|
|
2193
|
+
"syntaxProperty": {
|
|
2194
|
+
"type": "string"
|
|
2165
2195
|
}
|
|
2166
2196
|
},
|
|
2167
2197
|
"additionalProperties": false
|
|
@@ -21726,6 +21756,69 @@ function isLogInkContextKeyLoading(status, key) {
|
|
|
21726
21756
|
return status[key] === 'loading';
|
|
21727
21757
|
}
|
|
21728
21758
|
|
|
21759
|
+
/**
|
|
21760
|
+
* Derive a short menu of sensible `.gitignore` patterns from the path of
|
|
21761
|
+
* the cursored worktree file (the "add to .gitignore" quick-pick, `i` on
|
|
21762
|
+
* the status view).
|
|
21763
|
+
*
|
|
21764
|
+
* The goal is to turn the common asks — "ignore exactly this", "ignore
|
|
21765
|
+
* everything with this extension", "ignore this whole folder" — into
|
|
21766
|
+
* one-keystroke choices, while always offering a `Custom pattern…` escape
|
|
21767
|
+
* hatch that opens a free-text prompt for anything the menu doesn't cover
|
|
21768
|
+
* (negations, globs, anchored paths, etc.).
|
|
21769
|
+
*
|
|
21770
|
+
* Pure / synchronous so it's trivially unit-testable and reusable from the
|
|
21771
|
+
* reducer, the input handler, and the overlay renderer without pulling in
|
|
21772
|
+
* `fs`.
|
|
21773
|
+
*/
|
|
21774
|
+
/**
|
|
21775
|
+
* Build the option list for a repo-relative path. Git reports untracked
|
|
21776
|
+
* directories with a trailing slash (`.www/`), which is how we tell a
|
|
21777
|
+
* directory from a file. Duplicate patterns are collapsed (e.g. a
|
|
21778
|
+
* top-level dir whose anchored and bare forms would otherwise repeat).
|
|
21779
|
+
*/
|
|
21780
|
+
function deriveGitignoreOptions(rawPath) {
|
|
21781
|
+
const input = rawPath.trim();
|
|
21782
|
+
const options = [];
|
|
21783
|
+
const seen = new Set();
|
|
21784
|
+
const add = (pattern, label) => {
|
|
21785
|
+
if (!pattern || seen.has(pattern))
|
|
21786
|
+
return;
|
|
21787
|
+
seen.add(pattern);
|
|
21788
|
+
options.push({ pattern, label, custom: false });
|
|
21789
|
+
};
|
|
21790
|
+
if (input) {
|
|
21791
|
+
const isDir = input.endsWith('/');
|
|
21792
|
+
const clean = input.replace(/\/+$/, '');
|
|
21793
|
+
const segments = clean.split('/').filter(Boolean);
|
|
21794
|
+
const base = segments[segments.length - 1] || clean;
|
|
21795
|
+
const parent = segments.slice(0, -1).join('/');
|
|
21796
|
+
if (isDir) {
|
|
21797
|
+
// Anchored to the repo root vs. matching any folder of that name.
|
|
21798
|
+
add(`/${clean}/`, `This folder only (/${clean}/)`);
|
|
21799
|
+
add(`${base}/`, `Any “${base}/” folder`);
|
|
21800
|
+
}
|
|
21801
|
+
else {
|
|
21802
|
+
add(input, `This file only (${input})`);
|
|
21803
|
+
const dot = base.lastIndexOf('.');
|
|
21804
|
+
if (dot > 0 && dot < base.length - 1) {
|
|
21805
|
+
const ext = base.slice(dot);
|
|
21806
|
+
add(`*${ext}`, `All ${ext} files (*${ext})`);
|
|
21807
|
+
}
|
|
21808
|
+
if (parent) {
|
|
21809
|
+
add(`${parent}/`, `Its folder (${parent}/)`);
|
|
21810
|
+
}
|
|
21811
|
+
add(base, `Any file named “${base}”`);
|
|
21812
|
+
}
|
|
21813
|
+
}
|
|
21814
|
+
options.push({
|
|
21815
|
+
pattern: input,
|
|
21816
|
+
label: 'Custom pattern…',
|
|
21817
|
+
custom: true,
|
|
21818
|
+
});
|
|
21819
|
+
return options;
|
|
21820
|
+
}
|
|
21821
|
+
|
|
21729
21822
|
/**
|
|
21730
21823
|
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
21731
21824
|
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
@@ -22865,6 +22958,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22865
22958
|
description: 'Browse, live-preview, and apply a color theme.',
|
|
22866
22959
|
contexts: ['normal'],
|
|
22867
22960
|
},
|
|
22961
|
+
{
|
|
22962
|
+
id: 'openProjectConfig',
|
|
22963
|
+
keys: ['gk'],
|
|
22964
|
+
label: 'project config',
|
|
22965
|
+
description: 'Open this repo’s .coco.json in $EDITOR (creates a starter file if missing).',
|
|
22966
|
+
contexts: ['normal'],
|
|
22967
|
+
},
|
|
22968
|
+
{
|
|
22969
|
+
id: 'openGlobalConfig',
|
|
22970
|
+
keys: ['gK'],
|
|
22971
|
+
label: 'global config',
|
|
22972
|
+
description: 'Open ~/.config/coco/config.json in $EDITOR (creates a starter file if missing).',
|
|
22973
|
+
contexts: ['normal'],
|
|
22974
|
+
},
|
|
22975
|
+
{
|
|
22976
|
+
id: 'gitignoreFile',
|
|
22977
|
+
keys: ['i'],
|
|
22978
|
+
label: 'gitignore',
|
|
22979
|
+
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22980
|
+
contexts: ['status'],
|
|
22981
|
+
},
|
|
22868
22982
|
{
|
|
22869
22983
|
id: 'viewChangelog',
|
|
22870
22984
|
keys: ['L'],
|
|
@@ -22955,6 +23069,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
22955
23069
|
help: 'essentials',
|
|
22956
23070
|
commandPalette: 'essentials',
|
|
22957
23071
|
themePicker: 'view',
|
|
23072
|
+
openProjectConfig: 'view',
|
|
23073
|
+
openGlobalConfig: 'view',
|
|
23074
|
+
gitignoreFile: 'mutate',
|
|
22958
23075
|
quit: 'essentials',
|
|
22959
23076
|
refresh: 'essentials',
|
|
22960
23077
|
navigateBack: 'essentials',
|
|
@@ -23277,7 +23394,7 @@ function getLogInkFooterHints(options) {
|
|
|
23277
23394
|
}
|
|
23278
23395
|
if (options.activeView === 'status') {
|
|
23279
23396
|
return {
|
|
23280
|
-
contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'e/c compose', 'y yank'],
|
|
23397
|
+
contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
|
|
23281
23398
|
global: NORMAL_GLOBAL_HINTS,
|
|
23282
23399
|
};
|
|
23283
23400
|
}
|
|
@@ -25322,6 +25439,11 @@ function applyLogInkAction(state, action) {
|
|
|
25322
25439
|
bootLoading: action.value,
|
|
25323
25440
|
pendingKey: undefined,
|
|
25324
25441
|
};
|
|
25442
|
+
case 'setRemoteOp':
|
|
25443
|
+
return {
|
|
25444
|
+
...state,
|
|
25445
|
+
remoteOp: action.value,
|
|
25446
|
+
};
|
|
25325
25447
|
case 'moveTag':
|
|
25326
25448
|
return {
|
|
25327
25449
|
...state,
|
|
@@ -25858,6 +25980,29 @@ function applyLogInkAction(state, action) {
|
|
|
25858
25980
|
themePickerIndex: 0,
|
|
25859
25981
|
pendingKey: undefined,
|
|
25860
25982
|
};
|
|
25983
|
+
case 'openGitignorePicker':
|
|
25984
|
+
return {
|
|
25985
|
+
...state,
|
|
25986
|
+
gitignorePicker: { file: action.file, index: 0 },
|
|
25987
|
+
pendingKey: undefined,
|
|
25988
|
+
};
|
|
25989
|
+
case 'closeGitignorePicker':
|
|
25990
|
+
return {
|
|
25991
|
+
...state,
|
|
25992
|
+
gitignorePicker: undefined,
|
|
25993
|
+
pendingKey: undefined,
|
|
25994
|
+
};
|
|
25995
|
+
case 'moveGitignorePicker':
|
|
25996
|
+
return state.gitignorePicker
|
|
25997
|
+
? {
|
|
25998
|
+
...state,
|
|
25999
|
+
gitignorePicker: {
|
|
26000
|
+
...state.gitignorePicker,
|
|
26001
|
+
index: clampIndex(state.gitignorePicker.index + action.delta, action.count),
|
|
26002
|
+
},
|
|
26003
|
+
pendingKey: undefined,
|
|
26004
|
+
}
|
|
26005
|
+
: state;
|
|
25861
26006
|
case 'setChangelogLoading':
|
|
25862
26007
|
return {
|
|
25863
26008
|
...state,
|
|
@@ -26595,6 +26740,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26595
26740
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26596
26741
|
// this opens the theme picker.
|
|
26597
26742
|
return [action({ type: 'toggleThemePicker' })];
|
|
26743
|
+
case 'openProjectConfig':
|
|
26744
|
+
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26745
|
+
case 'openGlobalConfig':
|
|
26746
|
+
return [{ type: 'openConfigInEditor', scope: 'global' }];
|
|
26747
|
+
case 'gitignoreFile':
|
|
26748
|
+
// Runtime resolves the cursored worktree file and opens the picker
|
|
26749
|
+
// (no-ops with a warning when there's no file under the cursor).
|
|
26750
|
+
return [{ type: 'openGitignorePicker' }];
|
|
26598
26751
|
case 'workflowDeleteBranch':
|
|
26599
26752
|
case 'workflowDeleteTag':
|
|
26600
26753
|
case 'workflowDropStash':
|
|
@@ -26688,6 +26841,12 @@ function submitInputPrompt(state) {
|
|
|
26688
26841
|
if (!value) {
|
|
26689
26842
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26690
26843
|
}
|
|
26844
|
+
if (state.inputPrompt.kind === 'gitignore-pattern') {
|
|
26845
|
+
return [
|
|
26846
|
+
{ type: 'runWorkflowAction', id: 'add-to-gitignore', payload: value },
|
|
26847
|
+
action({ type: 'closeInputPrompt' }),
|
|
26848
|
+
];
|
|
26849
|
+
}
|
|
26691
26850
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26692
26851
|
const mode = value.toLowerCase();
|
|
26693
26852
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27166,6 +27325,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27166
27325
|
}
|
|
27167
27326
|
return [];
|
|
27168
27327
|
}
|
|
27328
|
+
if (state.gitignorePicker) {
|
|
27329
|
+
const options = deriveGitignoreOptions(state.gitignorePicker.file);
|
|
27330
|
+
if (key.escape) {
|
|
27331
|
+
return [action({ type: 'closeGitignorePicker' })];
|
|
27332
|
+
}
|
|
27333
|
+
if (key.upArrow || (key.ctrl && inputValue === 'p')) {
|
|
27334
|
+
return [action({ type: 'moveGitignorePicker', delta: -1, count: options.length })];
|
|
27335
|
+
}
|
|
27336
|
+
if (key.downArrow || (key.ctrl && inputValue === 'n')) {
|
|
27337
|
+
return [action({ type: 'moveGitignorePicker', delta: 1, count: options.length })];
|
|
27338
|
+
}
|
|
27339
|
+
if (key.return) {
|
|
27340
|
+
const selected = options[Math.max(0, Math.min(state.gitignorePicker.index, options.length - 1))];
|
|
27341
|
+
if (!selected) {
|
|
27342
|
+
return [action({ type: 'closeGitignorePicker' })];
|
|
27343
|
+
}
|
|
27344
|
+
if (selected.custom) {
|
|
27345
|
+
// Hand off to a free-text prompt seeded with the file path so
|
|
27346
|
+
// the user can type any valid gitignore pattern (negations,
|
|
27347
|
+
// globs, anchored paths) the derived options don't cover.
|
|
27348
|
+
return [
|
|
27349
|
+
action({ type: 'closeGitignorePicker' }),
|
|
27350
|
+
action({
|
|
27351
|
+
type: 'openInputPrompt',
|
|
27352
|
+
kind: 'gitignore-pattern',
|
|
27353
|
+
label: `.gitignore pattern (e.g. ${selected.pattern || '*.log'})`,
|
|
27354
|
+
initial: selected.pattern,
|
|
27355
|
+
}),
|
|
27356
|
+
];
|
|
27357
|
+
}
|
|
27358
|
+
return [
|
|
27359
|
+
action({ type: 'closeGitignorePicker' }),
|
|
27360
|
+
{ type: 'runWorkflowAction', id: 'add-to-gitignore', payload: selected.pattern },
|
|
27361
|
+
];
|
|
27362
|
+
}
|
|
27363
|
+
// Consume everything else so the underlying status view keys don't
|
|
27364
|
+
// leak through while the picker owns the screen.
|
|
27365
|
+
return [];
|
|
27366
|
+
}
|
|
27169
27367
|
if (state.showCommandPalette) {
|
|
27170
27368
|
const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
|
|
27171
27369
|
if (key.escape) {
|
|
@@ -27443,6 +27641,20 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27443
27641
|
action({ type: 'toggleThemePicker' }),
|
|
27444
27642
|
];
|
|
27445
27643
|
}
|
|
27644
|
+
// gk — open this repo's project config (.coco.json) in $EDITOR.
|
|
27645
|
+
if (state.pendingKey === 'g' && inputValue === 'k') {
|
|
27646
|
+
return [
|
|
27647
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
27648
|
+
{ type: 'openConfigInEditor', scope: 'project' },
|
|
27649
|
+
];
|
|
27650
|
+
}
|
|
27651
|
+
// gK — open the global config (~/.config/coco/config.json) in $EDITOR.
|
|
27652
|
+
if (state.pendingKey === 'g' && inputValue === 'K') {
|
|
27653
|
+
return [
|
|
27654
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
27655
|
+
{ type: 'openConfigInEditor', scope: 'global' },
|
|
27656
|
+
];
|
|
27657
|
+
}
|
|
27446
27658
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
27447
27659
|
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
27448
27660
|
// stay free everywhere else. `g` and `b` collide with the global
|
|
@@ -28524,6 +28736,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28524
28736
|
if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
|
|
28525
28737
|
return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
|
|
28526
28738
|
}
|
|
28739
|
+
// `i` opens the "add to .gitignore" picker for the cursored worktree
|
|
28740
|
+
// file. The runtime resolves the path + opens the picker (the bare
|
|
28741
|
+
// event carries no path — same selection-resolution pattern as the
|
|
28742
|
+
// revert / stage events).
|
|
28743
|
+
if (inputValue === 'i' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
|
|
28744
|
+
return [{ type: 'openGitignorePicker' }];
|
|
28745
|
+
}
|
|
28527
28746
|
if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
|
|
28528
28747
|
return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
|
|
28529
28748
|
}
|
|
@@ -29981,6 +30200,35 @@ async function runAction$5(action, successMessage) {
|
|
|
29981
30200
|
};
|
|
29982
30201
|
}
|
|
29983
30202
|
}
|
|
30203
|
+
/** Configured remote names (best-effort; `[]` if the call fails). */
|
|
30204
|
+
async function listRemotes(git) {
|
|
30205
|
+
try {
|
|
30206
|
+
return (await git.getRemotes()).map((remote) => remote.name).filter(Boolean);
|
|
30207
|
+
}
|
|
30208
|
+
catch {
|
|
30209
|
+
return [];
|
|
30210
|
+
}
|
|
30211
|
+
}
|
|
30212
|
+
/**
|
|
30213
|
+
* Remote to push a not-yet-tracked branch to: `origin` when it exists,
|
|
30214
|
+
* else the first configured remote, else `undefined` (no remotes).
|
|
30215
|
+
*/
|
|
30216
|
+
async function resolveDefaultRemote(git) {
|
|
30217
|
+
const remotes = await listRemotes(git);
|
|
30218
|
+
if (remotes.length === 0)
|
|
30219
|
+
return undefined;
|
|
30220
|
+
return remotes.includes('origin') ? 'origin' : remotes[0];
|
|
30221
|
+
}
|
|
30222
|
+
/** Whether the remote-tracking ref `refs/remotes/<remote>/<branch>` exists locally. */
|
|
30223
|
+
async function remoteBranchExists(git, remote, branch) {
|
|
30224
|
+
try {
|
|
30225
|
+
await git.raw(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
|
|
30226
|
+
return true;
|
|
30227
|
+
}
|
|
30228
|
+
catch {
|
|
30229
|
+
return false;
|
|
30230
|
+
}
|
|
30231
|
+
}
|
|
29984
30232
|
function checkoutBranch(git, branch) {
|
|
29985
30233
|
const refs = getBranchActionRefs(branch);
|
|
29986
30234
|
if (branch.type === 'remote') {
|
|
@@ -30015,11 +30263,58 @@ function fetchRemotes(git) {
|
|
|
30015
30263
|
function pullCurrentBranch(git) {
|
|
30016
30264
|
return runAction$5(() => git.raw(['pull', '--ff-only']), 'Pulled current branch');
|
|
30017
30265
|
}
|
|
30018
|
-
function pushCurrentBranch(git) {
|
|
30019
|
-
|
|
30020
|
-
}
|
|
30021
|
-
|
|
30022
|
-
|
|
30266
|
+
async function pushCurrentBranch(git) {
|
|
30267
|
+
const hasUpstream = await git
|
|
30268
|
+
.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
30269
|
+
.then(() => true)
|
|
30270
|
+
.catch(() => false);
|
|
30271
|
+
if (hasUpstream) {
|
|
30272
|
+
return runAction$5(() => git.raw(['push']), 'Pushed current branch');
|
|
30273
|
+
}
|
|
30274
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30275
|
+
// tracking, instead of failing with git's bare "has no upstream" error.
|
|
30276
|
+
const remote = await resolveDefaultRemote(git);
|
|
30277
|
+
if (!remote) {
|
|
30278
|
+
return { ok: false, message: 'No upstream and no remote configured — add one with `git remote add origin <url>`.' };
|
|
30279
|
+
}
|
|
30280
|
+
const current = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
30281
|
+
return runAction$5(() => git.raw(['push', '-u', remote, current]), `Pushed ${current} and set upstream to ${remote}/${current}`);
|
|
30282
|
+
}
|
|
30283
|
+
/**
|
|
30284
|
+
* Set (or create) the upstream for a local branch from a user-typed target.
|
|
30285
|
+
*
|
|
30286
|
+
* The target may be a bare branch name (`main` → `<default-remote>/main`) or
|
|
30287
|
+
* a `remote/branch` ref (`origin/main`). If that remote-tracking branch
|
|
30288
|
+
* already exists, we just link to it (`git branch --set-upstream-to`). If it
|
|
30289
|
+
* does NOT exist yet — the common "I just created this branch" case — we
|
|
30290
|
+
* `git push -u` to create the remote branch and set tracking in one step.
|
|
30291
|
+
* The old behavior ran `--set-upstream-to <bare-name>`, which silently
|
|
30292
|
+
* resolved `main` to the *local* branch and left push still complaining.
|
|
30293
|
+
*/
|
|
30294
|
+
async function setUpstream(git, localBranch, target) {
|
|
30295
|
+
const cleaned = target.trim();
|
|
30296
|
+
if (!cleaned)
|
|
30297
|
+
return { ok: false, message: 'Upstream ref required' };
|
|
30298
|
+
const remotes = await listRemotes(git);
|
|
30299
|
+
const slash = cleaned.indexOf('/');
|
|
30300
|
+
let remote;
|
|
30301
|
+
let remoteBranch;
|
|
30302
|
+
if (slash > 0 && remotes.includes(cleaned.slice(0, slash))) {
|
|
30303
|
+
remote = cleaned.slice(0, slash);
|
|
30304
|
+
remoteBranch = cleaned.slice(slash + 1);
|
|
30305
|
+
}
|
|
30306
|
+
else {
|
|
30307
|
+
remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
30308
|
+
remoteBranch = cleaned;
|
|
30309
|
+
}
|
|
30310
|
+
if (!remote) {
|
|
30311
|
+
return { ok: false, message: 'No remote configured — add one with `git remote add origin <url>` first.' };
|
|
30312
|
+
}
|
|
30313
|
+
if (await remoteBranchExists(git, remote, remoteBranch)) {
|
|
30314
|
+
return runAction$5(() => git.raw(['branch', '--set-upstream-to', `${remote}/${remoteBranch}`, localBranch]), `Set ${localBranch} to track ${remote}/${remoteBranch}`);
|
|
30315
|
+
}
|
|
30316
|
+
// Remote branch doesn't exist yet — push it and set upstream in one step.
|
|
30317
|
+
return runAction$5(() => git.raw(['push', '-u', remote, `${localBranch}:${remoteBranch}`]), `Pushed ${localBranch} → ${remote}/${remoteBranch} and set upstream`);
|
|
30023
30318
|
}
|
|
30024
30319
|
/**
|
|
30025
30320
|
* Push an arbitrary local branch (need not be the current branch) to
|
|
@@ -30030,18 +30325,21 @@ function setUpstream(git, localBranch, upstreamBranch) {
|
|
|
30030
30325
|
* Pairs with `pushCurrentBranch` (no-arg variant); the workstation
|
|
30031
30326
|
* dispatcher picks one or the other based on where the cursor is.
|
|
30032
30327
|
*/
|
|
30033
|
-
function pushBranch(git, branch) {
|
|
30328
|
+
async function pushBranch(git, branch) {
|
|
30034
30329
|
if (branch.type !== 'local') {
|
|
30035
|
-
return
|
|
30036
|
-
ok: false,
|
|
30037
|
-
message: 'Only local branches can be pushed.',
|
|
30038
|
-
});
|
|
30330
|
+
return { ok: false, message: 'Only local branches can be pushed.' };
|
|
30039
30331
|
}
|
|
30040
30332
|
if (!branch.upstream || !branch.remote) {
|
|
30041
|
-
|
|
30042
|
-
|
|
30043
|
-
|
|
30044
|
-
|
|
30333
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30334
|
+
// tracking, rather than refusing and sending the user to the shell.
|
|
30335
|
+
const remote = await resolveDefaultRemote(git);
|
|
30336
|
+
if (!remote) {
|
|
30337
|
+
return {
|
|
30338
|
+
ok: false,
|
|
30339
|
+
message: `${branch.shortName} has no upstream and no remote is configured — add one with \`git remote add origin <url>\`.`,
|
|
30340
|
+
};
|
|
30341
|
+
}
|
|
30342
|
+
return runAction$5(() => git.raw(['push', '-u', remote, branch.shortName]), `Pushed ${branch.shortName} and set upstream to ${remote}/${branch.shortName}`);
|
|
30045
30343
|
}
|
|
30046
30344
|
return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
|
|
30047
30345
|
}
|
|
@@ -30122,6 +30420,493 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30122
30420
|
]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
|
|
30123
30421
|
}
|
|
30124
30422
|
|
|
30423
|
+
/**
|
|
30424
|
+
* Append a pattern to the repository's `.gitignore` (the runtime side of
|
|
30425
|
+
* the "add to .gitignore" quick-pick, `i` on the status view).
|
|
30426
|
+
*
|
|
30427
|
+
* Kept separate from the pure pattern-derivation helper
|
|
30428
|
+
* (`workstation/chrome/gitignore.ts`) because this touches the filesystem
|
|
30429
|
+
* and resolves the repo root via git — neither of which the UI layer
|
|
30430
|
+
* should pull in.
|
|
30431
|
+
*/
|
|
30432
|
+
/**
|
|
30433
|
+
* Append `pattern` to `<repoRoot>/.gitignore`, creating the file if it
|
|
30434
|
+
* doesn't exist. No-ops (reporting success) when the exact pattern is
|
|
30435
|
+
* already present so re-running is safe. Handles the missing-trailing-
|
|
30436
|
+
* newline case so we never glue the new entry onto the previous line.
|
|
30437
|
+
*/
|
|
30438
|
+
async function addToGitignore(git, pattern) {
|
|
30439
|
+
const entry = pattern.trim();
|
|
30440
|
+
if (!entry) {
|
|
30441
|
+
return { ok: false, message: 'No pattern to add.' };
|
|
30442
|
+
}
|
|
30443
|
+
let root;
|
|
30444
|
+
try {
|
|
30445
|
+
root = (await git.revparse(['--show-toplevel'])).trim();
|
|
30446
|
+
}
|
|
30447
|
+
catch {
|
|
30448
|
+
return { ok: false, message: 'Could not resolve the repository root.' };
|
|
30449
|
+
}
|
|
30450
|
+
if (!root) {
|
|
30451
|
+
return { ok: false, message: 'Could not resolve the repository root.' };
|
|
30452
|
+
}
|
|
30453
|
+
const file = path__namespace.join(root, '.gitignore');
|
|
30454
|
+
let existing = '';
|
|
30455
|
+
try {
|
|
30456
|
+
existing = await fs.promises.readFile(file, 'utf8');
|
|
30457
|
+
}
|
|
30458
|
+
catch {
|
|
30459
|
+
// No .gitignore yet — we'll create it.
|
|
30460
|
+
existing = '';
|
|
30461
|
+
}
|
|
30462
|
+
// Already ignored (exact line match, ignoring surrounding whitespace)?
|
|
30463
|
+
const alreadyPresent = existing
|
|
30464
|
+
.split('\n')
|
|
30465
|
+
.some((line) => line.trim() === entry);
|
|
30466
|
+
if (alreadyPresent) {
|
|
30467
|
+
return { ok: true, message: `${entry} is already in .gitignore` };
|
|
30468
|
+
}
|
|
30469
|
+
const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n');
|
|
30470
|
+
const addition = `${needsLeadingNewline ? '\n' : ''}${entry}\n`;
|
|
30471
|
+
try {
|
|
30472
|
+
await fs.promises.appendFile(file, addition, 'utf8');
|
|
30473
|
+
}
|
|
30474
|
+
catch (error) {
|
|
30475
|
+
return { ok: false, message: error.message };
|
|
30476
|
+
}
|
|
30477
|
+
return { ok: true, message: `Added ${entry} to .gitignore` };
|
|
30478
|
+
}
|
|
30479
|
+
|
|
30480
|
+
/**
|
|
30481
|
+
* Embedded tree-sitter highlight queries (one per language).
|
|
30482
|
+
*
|
|
30483
|
+
* We ship our own compact queries rather than reading the upstream
|
|
30484
|
+
* `queries/highlights.scm` from the grammar packages because those are
|
|
30485
|
+
* dev-only / not present in a published install, and they lean on
|
|
30486
|
+
* `#match?` / `#is-not? local` predicates that web-tree-sitter's
|
|
30487
|
+
* `Query.captures()` does NOT evaluate for us — including them would
|
|
30488
|
+
* mis-tag every identifier. These subsets are **predicate-free** (so
|
|
30489
|
+
* every capture is unconditionally correct) and use only grammar-valid
|
|
30490
|
+
* node/token names (so the query compiles — verified against the real
|
|
30491
|
+
* grammars), distilled from each language's upstream `highlights.scm`.
|
|
30492
|
+
*
|
|
30493
|
+
* The TS query serves both `typescript` and `tsx` (tsx is a superset).
|
|
30494
|
+
*/
|
|
30495
|
+
const TS_HIGHLIGHT_QUERY = `
|
|
30496
|
+
; Comments
|
|
30497
|
+
(comment) @comment
|
|
30498
|
+
|
|
30499
|
+
; Strings
|
|
30500
|
+
[
|
|
30501
|
+
(string)
|
|
30502
|
+
(template_string)
|
|
30503
|
+
] @string
|
|
30504
|
+
(regex) @string
|
|
30505
|
+
|
|
30506
|
+
; Numbers
|
|
30507
|
+
(number) @number
|
|
30508
|
+
|
|
30509
|
+
; Types
|
|
30510
|
+
(type_identifier) @type
|
|
30511
|
+
(predefined_type) @type
|
|
30512
|
+
|
|
30513
|
+
; Literals
|
|
30514
|
+
[
|
|
30515
|
+
(true)
|
|
30516
|
+
(false)
|
|
30517
|
+
(null)
|
|
30518
|
+
(undefined)
|
|
30519
|
+
] @constant
|
|
30520
|
+
(this) @keyword
|
|
30521
|
+
(super) @keyword
|
|
30522
|
+
|
|
30523
|
+
; Properties
|
|
30524
|
+
(property_identifier) @property
|
|
30525
|
+
|
|
30526
|
+
; Function definitions and calls (field-name patterns — no predicates)
|
|
30527
|
+
(function_declaration
|
|
30528
|
+
name: (identifier) @function)
|
|
30529
|
+
(function_expression
|
|
30530
|
+
name: (identifier) @function)
|
|
30531
|
+
(method_definition
|
|
30532
|
+
name: (property_identifier) @function)
|
|
30533
|
+
(call_expression
|
|
30534
|
+
function: (identifier) @function)
|
|
30535
|
+
(call_expression
|
|
30536
|
+
function: (member_expression
|
|
30537
|
+
property: (property_identifier) @function))
|
|
30538
|
+
(variable_declarator
|
|
30539
|
+
name: (identifier) @function
|
|
30540
|
+
value: [(function_expression) (arrow_function)])
|
|
30541
|
+
|
|
30542
|
+
; Keywords (anonymous tokens — all valid in the TS/TSX grammars)
|
|
30543
|
+
[
|
|
30544
|
+
"abstract"
|
|
30545
|
+
"declare"
|
|
30546
|
+
"enum"
|
|
30547
|
+
"implements"
|
|
30548
|
+
"interface"
|
|
30549
|
+
"keyof"
|
|
30550
|
+
"namespace"
|
|
30551
|
+
"private"
|
|
30552
|
+
"protected"
|
|
30553
|
+
"public"
|
|
30554
|
+
"type"
|
|
30555
|
+
"readonly"
|
|
30556
|
+
"override"
|
|
30557
|
+
"satisfies"
|
|
30558
|
+
"as"
|
|
30559
|
+
"async"
|
|
30560
|
+
"await"
|
|
30561
|
+
"break"
|
|
30562
|
+
"case"
|
|
30563
|
+
"catch"
|
|
30564
|
+
"class"
|
|
30565
|
+
"const"
|
|
30566
|
+
"continue"
|
|
30567
|
+
"debugger"
|
|
30568
|
+
"default"
|
|
30569
|
+
"delete"
|
|
30570
|
+
"do"
|
|
30571
|
+
"else"
|
|
30572
|
+
"export"
|
|
30573
|
+
"extends"
|
|
30574
|
+
"finally"
|
|
30575
|
+
"for"
|
|
30576
|
+
"from"
|
|
30577
|
+
"function"
|
|
30578
|
+
"get"
|
|
30579
|
+
"if"
|
|
30580
|
+
"import"
|
|
30581
|
+
"in"
|
|
30582
|
+
"instanceof"
|
|
30583
|
+
"let"
|
|
30584
|
+
"new"
|
|
30585
|
+
"of"
|
|
30586
|
+
"return"
|
|
30587
|
+
"set"
|
|
30588
|
+
"static"
|
|
30589
|
+
"switch"
|
|
30590
|
+
"throw"
|
|
30591
|
+
"try"
|
|
30592
|
+
"typeof"
|
|
30593
|
+
"var"
|
|
30594
|
+
"void"
|
|
30595
|
+
"while"
|
|
30596
|
+
"with"
|
|
30597
|
+
"yield"
|
|
30598
|
+
] @keyword
|
|
30599
|
+
`.trim();
|
|
30600
|
+
/** Python (validated against tree-sitter-python 0.23.6). */
|
|
30601
|
+
const PYTHON_HIGHLIGHT_QUERY = `
|
|
30602
|
+
(comment) @comment
|
|
30603
|
+
(string) @string
|
|
30604
|
+
(integer) @number
|
|
30605
|
+
(float) @number
|
|
30606
|
+
(type) @type
|
|
30607
|
+
(function_definition
|
|
30608
|
+
name: (identifier) @function)
|
|
30609
|
+
(class_definition
|
|
30610
|
+
name: (identifier) @type)
|
|
30611
|
+
(call
|
|
30612
|
+
function: (identifier) @function)
|
|
30613
|
+
[ (true) (false) (none) ] @constant
|
|
30614
|
+
[
|
|
30615
|
+
"def" "class" "return" "pass" "if" "elif" "else" "for" "while"
|
|
30616
|
+
"import" "from" "as" "with" "try" "except" "finally" "raise"
|
|
30617
|
+
"lambda" "yield" "global" "nonlocal" "assert" "del" "in" "not"
|
|
30618
|
+
"and" "or" "is" "await" "async"
|
|
30619
|
+
] @keyword
|
|
30620
|
+
`.trim();
|
|
30621
|
+
/** Rust (validated against tree-sitter-rust 0.24.0). */
|
|
30622
|
+
const RUST_HIGHLIGHT_QUERY = `
|
|
30623
|
+
[ (line_comment) (block_comment) ] @comment
|
|
30624
|
+
[ (string_literal) (char_literal) (raw_string_literal) ] @string
|
|
30625
|
+
(integer_literal) @number
|
|
30626
|
+
(float_literal) @number
|
|
30627
|
+
[ (primitive_type) (type_identifier) ] @type
|
|
30628
|
+
(function_item
|
|
30629
|
+
name: (identifier) @function)
|
|
30630
|
+
(call_expression
|
|
30631
|
+
function: (identifier) @function)
|
|
30632
|
+
(boolean_literal) @constant
|
|
30633
|
+
[
|
|
30634
|
+
"fn" "let" "const" "static" "if" "else" "match" "for" "while"
|
|
30635
|
+
"loop" "return" "break" "continue" "struct" "enum" "trait" "impl"
|
|
30636
|
+
"use" "mod" "pub" "as" "where" "in" "unsafe" "async" "await"
|
|
30637
|
+
"dyn" "type"
|
|
30638
|
+
] @keyword
|
|
30639
|
+
`.trim();
|
|
30640
|
+
/** Go (validated against tree-sitter-go 0.25.0). */
|
|
30641
|
+
const GO_HIGHLIGHT_QUERY = `
|
|
30642
|
+
(comment) @comment
|
|
30643
|
+
[ (interpreted_string_literal) (raw_string_literal) (rune_literal) ] @string
|
|
30644
|
+
(int_literal) @number
|
|
30645
|
+
(float_literal) @number
|
|
30646
|
+
(type_identifier) @type
|
|
30647
|
+
(function_declaration
|
|
30648
|
+
name: (identifier) @function)
|
|
30649
|
+
(method_declaration
|
|
30650
|
+
name: (field_identifier) @function)
|
|
30651
|
+
(call_expression
|
|
30652
|
+
function: (identifier) @function)
|
|
30653
|
+
(call_expression
|
|
30654
|
+
function: (selector_expression
|
|
30655
|
+
field: (field_identifier) @function))
|
|
30656
|
+
[ (true) (false) (nil) (iota) ] @constant
|
|
30657
|
+
[
|
|
30658
|
+
"func" "var" "const" "type" "struct" "interface" "map" "chan"
|
|
30659
|
+
"package" "import" "return" "if" "else" "for" "range" "switch"
|
|
30660
|
+
"case" "default" "break" "continue" "go" "defer" "select"
|
|
30661
|
+
"fallthrough" "goto"
|
|
30662
|
+
] @keyword
|
|
30663
|
+
`.trim();
|
|
30664
|
+
/** Highlight query keyed by tree-sitter language id. */
|
|
30665
|
+
const HIGHLIGHT_QUERIES = {
|
|
30666
|
+
typescript: TS_HIGHLIGHT_QUERY,
|
|
30667
|
+
tsx: TS_HIGHLIGHT_QUERY,
|
|
30668
|
+
python: PYTHON_HIGHLIGHT_QUERY,
|
|
30669
|
+
rust: RUST_HIGHLIGHT_QUERY,
|
|
30670
|
+
go: GO_HIGHLIGHT_QUERY,
|
|
30671
|
+
};
|
|
30672
|
+
|
|
30673
|
+
/**
|
|
30674
|
+
* Map a tree-sitter capture name to a normalized token type. Captures
|
|
30675
|
+
* are dotted (`a.b.c`); we key off the leading segment and fold the rest
|
|
30676
|
+
* in. Anything we don't have a color for collapses to `plain` (rendered
|
|
30677
|
+
* in the default foreground), so unmapped captures degrade gracefully.
|
|
30678
|
+
*/
|
|
30679
|
+
function captureToToken(capture) {
|
|
30680
|
+
const base = capture.split('.')[0];
|
|
30681
|
+
switch (base) {
|
|
30682
|
+
case 'keyword':
|
|
30683
|
+
return 'keyword';
|
|
30684
|
+
case 'string':
|
|
30685
|
+
return 'string';
|
|
30686
|
+
case 'comment':
|
|
30687
|
+
return 'comment';
|
|
30688
|
+
case 'number':
|
|
30689
|
+
return 'number';
|
|
30690
|
+
case 'type':
|
|
30691
|
+
return 'type';
|
|
30692
|
+
case 'function':
|
|
30693
|
+
case 'method':
|
|
30694
|
+
case 'constructor':
|
|
30695
|
+
return 'function';
|
|
30696
|
+
case 'property':
|
|
30697
|
+
return 'property';
|
|
30698
|
+
case 'constant':
|
|
30699
|
+
return 'constant';
|
|
30700
|
+
default:
|
|
30701
|
+
return 'plain';
|
|
30702
|
+
}
|
|
30703
|
+
}
|
|
30704
|
+
|
|
30705
|
+
/**
|
|
30706
|
+
* Tree-sitter syntax highlighter for the diff view.
|
|
30707
|
+
*
|
|
30708
|
+
* Reuses the existing tree-sitter runtime (`getTreeSitterParser`) — same
|
|
30709
|
+
* lazy init, same bundled `typescript` / `tsx` grammars (offline-safe),
|
|
30710
|
+
* same "return undefined when .wasm is unavailable" contract. On top of
|
|
30711
|
+
* that we build one `Query` per language from our embedded highlight
|
|
30712
|
+
* query and tokenize code **per line**.
|
|
30713
|
+
*
|
|
30714
|
+
* Per-line (rather than whole-file) tokenization keeps this uniform
|
|
30715
|
+
* across every diff source — stash / compare / commit / worktree all
|
|
30716
|
+
* hand us diff lines, never reconstructed files — and tree-sitter's
|
|
30717
|
+
* error tolerance means a single statement still yields good captures.
|
|
30718
|
+
* Results are cached by (language, line) so re-renders and repeated
|
|
30719
|
+
* lines are free.
|
|
30720
|
+
*
|
|
30721
|
+
* Everything degrades to "no spans" (the caller renders the plain
|
|
30722
|
+
* single-color line): missing grammar, query compile failure, parse
|
|
30723
|
+
* error, non-ASCII text (byte/char offset skew), or an over-long line.
|
|
30724
|
+
*/
|
|
30725
|
+
// Longest extension first so `.d.ts` / `.mts` win over `.ts`.
|
|
30726
|
+
//
|
|
30727
|
+
// NOTE: typescript + tsx grammars are bundled (offline). python / rust /
|
|
30728
|
+
// go grammars download on demand and are only available once cached
|
|
30729
|
+
// (prefetched) — `getTreeSitterParser` returns undefined otherwise, so
|
|
30730
|
+
// those diffs render plain until the grammar is present. Same
|
|
30731
|
+
// availability model the structural parsers already use.
|
|
30732
|
+
const EXT_LANGUAGE = [
|
|
30733
|
+
['.tsx', 'tsx'],
|
|
30734
|
+
['.jsx', 'tsx'],
|
|
30735
|
+
['.mts', 'typescript'],
|
|
30736
|
+
['.cts', 'typescript'],
|
|
30737
|
+
['.mjs', 'tsx'],
|
|
30738
|
+
['.cjs', 'tsx'],
|
|
30739
|
+
['.ts', 'typescript'],
|
|
30740
|
+
['.js', 'tsx'],
|
|
30741
|
+
['.py', 'python'],
|
|
30742
|
+
['.pyi', 'python'],
|
|
30743
|
+
['.rs', 'rust'],
|
|
30744
|
+
['.go', 'go'],
|
|
30745
|
+
];
|
|
30746
|
+
/** Map a file path to a highlight language, or undefined when unsupported. */
|
|
30747
|
+
function detectSyntaxLanguage(filePath) {
|
|
30748
|
+
const lower = filePath.toLowerCase();
|
|
30749
|
+
for (const [ext, language] of EXT_LANGUAGE) {
|
|
30750
|
+
if (lower.endsWith(ext))
|
|
30751
|
+
return language;
|
|
30752
|
+
}
|
|
30753
|
+
return undefined;
|
|
30754
|
+
}
|
|
30755
|
+
// Printable ASCII (+ tab). Outside this range tree-sitter's byte offsets
|
|
30756
|
+
// diverge from JS string char offsets, which would misalign spans — so
|
|
30757
|
+
// we skip those lines entirely (rendered plain).
|
|
30758
|
+
const ASCII_ONLY = /^[\t\x20-\x7E]*$/;
|
|
30759
|
+
const MAX_LINE_LENGTH = 2000;
|
|
30760
|
+
// `null` marks "tried and failed" so we don't retry the grammar/query
|
|
30761
|
+
// load on every line.
|
|
30762
|
+
const queryCache = new Map();
|
|
30763
|
+
const spanCache = new Map();
|
|
30764
|
+
async function getQuery(language) {
|
|
30765
|
+
if (queryCache.has(language)) {
|
|
30766
|
+
return queryCache.get(language) ?? undefined;
|
|
30767
|
+
}
|
|
30768
|
+
const querySource = HIGHLIGHT_QUERIES[language];
|
|
30769
|
+
const loaded = querySource ? await getTreeSitterParser(language) : undefined;
|
|
30770
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30771
|
+
const grammar = loaded?.parser?.language;
|
|
30772
|
+
if (!loaded || !grammar || !querySource) {
|
|
30773
|
+
queryCache.set(language, null);
|
|
30774
|
+
return undefined;
|
|
30775
|
+
}
|
|
30776
|
+
try {
|
|
30777
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30778
|
+
const mod = (await import('web-tree-sitter'));
|
|
30779
|
+
const QueryCtor = mod.Query;
|
|
30780
|
+
const query = new QueryCtor(grammar, querySource);
|
|
30781
|
+
queryCache.set(language, query);
|
|
30782
|
+
return query;
|
|
30783
|
+
}
|
|
30784
|
+
catch {
|
|
30785
|
+
queryCache.set(language, null);
|
|
30786
|
+
return undefined;
|
|
30787
|
+
}
|
|
30788
|
+
}
|
|
30789
|
+
function buildSpans(text, captures) {
|
|
30790
|
+
const n = text.length;
|
|
30791
|
+
const charType = new Array(n);
|
|
30792
|
+
// Paint widest captures first so narrower/inner captures override them
|
|
30793
|
+
// — yields clean, non-overlapping spans without nesting bookkeeping.
|
|
30794
|
+
const ordered = [...captures].sort((a, b) => (b.node.endIndex - b.node.startIndex) - (a.node.endIndex - a.node.startIndex));
|
|
30795
|
+
for (const capture of ordered) {
|
|
30796
|
+
const token = captureToToken(capture.name);
|
|
30797
|
+
if (token === 'plain')
|
|
30798
|
+
continue;
|
|
30799
|
+
const start = Math.max(0, capture.node.startIndex);
|
|
30800
|
+
const end = Math.min(n, capture.node.endIndex);
|
|
30801
|
+
for (let i = start; i < end; i++)
|
|
30802
|
+
charType[i] = token;
|
|
30803
|
+
}
|
|
30804
|
+
// Coalesce runs of equal token type (gaps → 'plain').
|
|
30805
|
+
const spans = [];
|
|
30806
|
+
let i = 0;
|
|
30807
|
+
while (i < n) {
|
|
30808
|
+
const token = charType[i] ?? 'plain';
|
|
30809
|
+
let j = i + 1;
|
|
30810
|
+
while (j < n && (charType[j] ?? 'plain') === token)
|
|
30811
|
+
j++;
|
|
30812
|
+
spans.push({ start: i, end: j, token });
|
|
30813
|
+
i = j;
|
|
30814
|
+
}
|
|
30815
|
+
return spans;
|
|
30816
|
+
}
|
|
30817
|
+
/**
|
|
30818
|
+
* Tokenize a single line of code into non-overlapping spans covering the
|
|
30819
|
+
* whole string (plain runs included). Returns `[]` when the line can't
|
|
30820
|
+
* be highlighted (no grammar, parse error, non-ASCII, too long) so the
|
|
30821
|
+
* caller falls back to its plain single-color rendering.
|
|
30822
|
+
*/
|
|
30823
|
+
async function highlightLine(language, text) {
|
|
30824
|
+
if (!text)
|
|
30825
|
+
return [];
|
|
30826
|
+
if (text.length > MAX_LINE_LENGTH || !ASCII_ONLY.test(text))
|
|
30827
|
+
return [];
|
|
30828
|
+
const key = `${language}${text}`;
|
|
30829
|
+
const cached = spanCache.get(key);
|
|
30830
|
+
if (cached)
|
|
30831
|
+
return cached;
|
|
30832
|
+
const query = await getQuery(language);
|
|
30833
|
+
const loaded = await getTreeSitterParser(language);
|
|
30834
|
+
if (!query || !loaded) {
|
|
30835
|
+
spanCache.set(key, []);
|
|
30836
|
+
return [];
|
|
30837
|
+
}
|
|
30838
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30839
|
+
let tree;
|
|
30840
|
+
try {
|
|
30841
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30842
|
+
tree = loaded.parser.parse(text);
|
|
30843
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30844
|
+
const captures = query.captures(tree.rootNode);
|
|
30845
|
+
const spans = buildSpans(text, captures);
|
|
30846
|
+
spanCache.set(key, spans);
|
|
30847
|
+
return spans;
|
|
30848
|
+
}
|
|
30849
|
+
catch {
|
|
30850
|
+
spanCache.set(key, []);
|
|
30851
|
+
return [];
|
|
30852
|
+
}
|
|
30853
|
+
finally {
|
|
30854
|
+
tree?.delete?.();
|
|
30855
|
+
}
|
|
30856
|
+
}
|
|
30857
|
+
/**
|
|
30858
|
+
* Tokenize the code lines of a unified diff. Strips the leading
|
|
30859
|
+
* `+` / `-` / ` ` marker before highlighting (headers and `@@` lines are
|
|
30860
|
+
* skipped — they're not code), and returns a map keyed by the
|
|
30861
|
+
* marker-stripped line text so the renderer can look spans up directly.
|
|
30862
|
+
* Lines that yield no spans are omitted (renderer falls back to plain).
|
|
30863
|
+
*/
|
|
30864
|
+
/**
|
|
30865
|
+
* Pick the unique, marker-stripped code lines out of a unified diff:
|
|
30866
|
+
* only `+` / `-` / ` ` rows are code; `diff`/`index`/`@@`/`+++`/`---`
|
|
30867
|
+
* headers are skipped. Pure + exported so the selection is testable
|
|
30868
|
+
* without a grammar.
|
|
30869
|
+
*/
|
|
30870
|
+
function selectDiffCodeLines(lines) {
|
|
30871
|
+
const seen = new Set();
|
|
30872
|
+
// Hunk-aware: a `+`/`-`/` ` line is code only INSIDE a hunk. This is
|
|
30873
|
+
// what distinguishes a real added line from the `+++ b/file` /
|
|
30874
|
+
// `--- a/file` file headers (which precede the first `@@` and also
|
|
30875
|
+
// start with `+`/`-`). Same stateful rule the split-diff parser uses.
|
|
30876
|
+
let inHunk = false;
|
|
30877
|
+
for (const line of lines) {
|
|
30878
|
+
if (!line)
|
|
30879
|
+
continue;
|
|
30880
|
+
if (line.startsWith('@@')) {
|
|
30881
|
+
inHunk = true;
|
|
30882
|
+
continue;
|
|
30883
|
+
}
|
|
30884
|
+
if (!inHunk)
|
|
30885
|
+
continue;
|
|
30886
|
+
const marker = line[0];
|
|
30887
|
+
if (marker !== '+' && marker !== '-' && marker !== ' ') {
|
|
30888
|
+
// A non-diff line (blank-separator label, next file's `diff --git`)
|
|
30889
|
+
// ends the current hunk until the next `@@`.
|
|
30890
|
+
inHunk = false;
|
|
30891
|
+
continue;
|
|
30892
|
+
}
|
|
30893
|
+
seen.add(line.slice(1));
|
|
30894
|
+
}
|
|
30895
|
+
return [...seen];
|
|
30896
|
+
}
|
|
30897
|
+
async function highlightDiffCode(filePath, lines) {
|
|
30898
|
+
const result = new Map();
|
|
30899
|
+
const language = detectSyntaxLanguage(filePath);
|
|
30900
|
+
if (!language)
|
|
30901
|
+
return result;
|
|
30902
|
+
for (const code of selectDiffCodeLines(lines)) {
|
|
30903
|
+
const spans = await highlightLine(language, code);
|
|
30904
|
+
if (spans.length)
|
|
30905
|
+
result.set(code, spans);
|
|
30906
|
+
}
|
|
30907
|
+
return result;
|
|
30908
|
+
}
|
|
30909
|
+
|
|
30125
30910
|
async function runAction$4(action, successMessage) {
|
|
30126
30911
|
try {
|
|
30127
30912
|
await action();
|
|
@@ -33843,11 +34628,50 @@ function flushChangeBlock(removals, additions, rows) {
|
|
|
33843
34628
|
removals.length = 0;
|
|
33844
34629
|
additions.length = 0;
|
|
33845
34630
|
}
|
|
33846
|
-
|
|
33847
|
-
|
|
34631
|
+
/**
|
|
34632
|
+
* Replay the hunk parser over `unifiedLines[0..upTo)` (exclusive) and
|
|
34633
|
+
* return the parse state at that boundary. Used by the split renderer
|
|
34634
|
+
* to seed `buildSplitDiffRows` with the correct in-hunk flag and
|
|
34635
|
+
* line-number cursors when it windows the diff to a scroll offset that
|
|
34636
|
+
* starts partway through a hunk. Counting mirrors `buildSplitDiffRows`
|
|
34637
|
+
* exactly so the seeded line numbers stay continuous across the cut.
|
|
34638
|
+
*/
|
|
34639
|
+
function computeDiffContext(unifiedLines, upTo) {
|
|
33848
34640
|
let oldLineNo = 0;
|
|
33849
34641
|
let newLineNo = 0;
|
|
33850
34642
|
let inHunk = false;
|
|
34643
|
+
const bound = Math.max(0, Math.min(upTo, unifiedLines.length));
|
|
34644
|
+
for (let i = 0; i < bound; i++) {
|
|
34645
|
+
const raw = unifiedLines[i];
|
|
34646
|
+
if (raw.startsWith('@@')) {
|
|
34647
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
34648
|
+
oldLineNo = oldStart;
|
|
34649
|
+
newLineNo = newStart;
|
|
34650
|
+
inHunk = true;
|
|
34651
|
+
continue;
|
|
34652
|
+
}
|
|
34653
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
34654
|
+
continue;
|
|
34655
|
+
}
|
|
34656
|
+
if (raw.startsWith('-')) {
|
|
34657
|
+
oldLineNo += 1;
|
|
34658
|
+
continue;
|
|
34659
|
+
}
|
|
34660
|
+
if (raw.startsWith('+')) {
|
|
34661
|
+
newLineNo += 1;
|
|
34662
|
+
continue;
|
|
34663
|
+
}
|
|
34664
|
+
// Context line (or `\ No newline` marker) advances both cursors.
|
|
34665
|
+
oldLineNo += 1;
|
|
34666
|
+
newLineNo += 1;
|
|
34667
|
+
}
|
|
34668
|
+
return { inHunk, oldLineNo, newLineNo };
|
|
34669
|
+
}
|
|
34670
|
+
function buildSplitDiffRows(unifiedLines, seed) {
|
|
34671
|
+
const rows = [];
|
|
34672
|
+
let oldLineNo = seed?.oldLineNo ?? 0;
|
|
34673
|
+
let newLineNo = seed?.newLineNo ?? 0;
|
|
34674
|
+
let inHunk = seed?.inHunk ?? false;
|
|
33851
34675
|
const removals = [];
|
|
33852
34676
|
const additions = [];
|
|
33853
34677
|
const flushHeader = (text) => {
|
|
@@ -33908,6 +34732,32 @@ function buildSplitDiffRows(unifiedLines) {
|
|
|
33908
34732
|
return rows;
|
|
33909
34733
|
}
|
|
33910
34734
|
|
|
34735
|
+
function resolveSyntaxColor(token, theme) {
|
|
34736
|
+
if (theme.noColor)
|
|
34737
|
+
return undefined;
|
|
34738
|
+
const c = theme.colors;
|
|
34739
|
+
switch (token) {
|
|
34740
|
+
case 'keyword':
|
|
34741
|
+
return c.syntaxKeyword ?? 'magenta';
|
|
34742
|
+
case 'string':
|
|
34743
|
+
return c.syntaxString ?? 'green';
|
|
34744
|
+
case 'comment':
|
|
34745
|
+
return c.syntaxComment ?? 'gray';
|
|
34746
|
+
case 'number':
|
|
34747
|
+
return c.syntaxNumber ?? 'yellow';
|
|
34748
|
+
case 'type':
|
|
34749
|
+
return c.syntaxType ?? 'cyan';
|
|
34750
|
+
case 'function':
|
|
34751
|
+
return c.syntaxFunction ?? 'blue';
|
|
34752
|
+
case 'constant':
|
|
34753
|
+
return c.syntaxConstant ?? 'yellow';
|
|
34754
|
+
case 'property':
|
|
34755
|
+
return c.syntaxProperty ?? undefined;
|
|
34756
|
+
default:
|
|
34757
|
+
return undefined;
|
|
34758
|
+
}
|
|
34759
|
+
}
|
|
34760
|
+
|
|
33911
34761
|
/**
|
|
33912
34762
|
* Split-diff rendering helpers (#785) — shared between the diff
|
|
33913
34763
|
* surface and any future surface that wants side-by-side diff layout.
|
|
@@ -33979,30 +34829,107 @@ function formatSplitDiffCell(side, columnWidth) {
|
|
|
33979
34829
|
return `${lineNo} ${truncateCells(text, textRoom)}`.padEnd(columnWidth);
|
|
33980
34830
|
}
|
|
33981
34831
|
/**
|
|
33982
|
-
* Render
|
|
33983
|
-
*
|
|
33984
|
-
*
|
|
34832
|
+
* Render one split-diff column as an Ink node — syntax-highlighted when
|
|
34833
|
+
* spans are available for the line, plain otherwise.
|
|
34834
|
+
*
|
|
34835
|
+
* Highlighted cells keep the 4-digit line-number gutter but color IT
|
|
34836
|
+
* with the add/remove cue (green/red, dim for context) so the code body
|
|
34837
|
+
* is free to carry its syntax colors — the split layout's position
|
|
34838
|
+
* (old | new) plus the colored gutter still tells you what changed.
|
|
34839
|
+
* Width is budgeted exactly like `formatSplitDiffCell` (gutter + 1 space
|
|
34840
|
+
* + truncated code) so columns never drift.
|
|
34841
|
+
*/
|
|
34842
|
+
function renderSplitDiffCell(h, Text, side, columnWidth, theme, syntaxSpans, key) {
|
|
34843
|
+
const text = side.text.replace(/\n$/, '');
|
|
34844
|
+
const spans = side.kind === 'add' || side.kind === 'remove' || side.kind === 'context'
|
|
34845
|
+
? syntaxSpans?.get(text)
|
|
34846
|
+
: undefined;
|
|
34847
|
+
if (!spans || spans.length === 0) {
|
|
34848
|
+
return h(Text, { key, ...splitDiffSideProps(side.kind, theme) }, formatSplitDiffCell(side, columnWidth));
|
|
34849
|
+
}
|
|
34850
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
34851
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
34852
|
+
const gutterColor = side.kind === 'add'
|
|
34853
|
+
? theme.colors.gitAdded
|
|
34854
|
+
: side.kind === 'remove'
|
|
34855
|
+
? theme.colors.gitDeleted
|
|
34856
|
+
: undefined;
|
|
34857
|
+
const children = [];
|
|
34858
|
+
let used = 0;
|
|
34859
|
+
for (const span of spans) {
|
|
34860
|
+
if (used >= textRoom)
|
|
34861
|
+
break;
|
|
34862
|
+
const segment = truncateCells(text.slice(span.start, span.end), textRoom - used);
|
|
34863
|
+
if (!segment)
|
|
34864
|
+
continue;
|
|
34865
|
+
used += cellWidth(segment);
|
|
34866
|
+
children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
|
|
34867
|
+
}
|
|
34868
|
+
return h(Text, { key }, h(Text, { key: `${key}-g`, color: gutterColor, dimColor: !gutterColor }, `${lineNo} `), ...children);
|
|
34869
|
+
}
|
|
34870
|
+
/**
|
|
34871
|
+
* Render the split-diff body as a list of two-column rows.
|
|
34872
|
+
*
|
|
34873
|
+
* Takes the FULL unified-line array plus the scroll offset + visible
|
|
34874
|
+
* row budget, and windows it internally. The windowing has to live
|
|
34875
|
+
* here (not the caller) because the parser is stateful: a window that
|
|
34876
|
+
* starts partway through a hunk needs the hunk context (in-hunk flag +
|
|
34877
|
+
* line-number cursors) that precedes it, or every visible line gets
|
|
34878
|
+
* misclassified as a header and painted in the accent color (#1114).
|
|
34879
|
+
* We compute that context from the lines before the window and seed
|
|
34880
|
+
* the parser with it.
|
|
33985
34881
|
*/
|
|
33986
|
-
function renderSplitDiffBody(h, components,
|
|
34882
|
+
function renderSplitDiffBody(h, components, unifiedLines, startOffset, visibleRows, width, theme, keyPrefix, syntaxSpans) {
|
|
33987
34883
|
const { Box, Text } = components;
|
|
33988
|
-
const
|
|
34884
|
+
const seed = computeDiffContext(unifiedLines, startOffset);
|
|
34885
|
+
const unifiedSlice = unifiedLines.slice(startOffset, startOffset + visibleRows);
|
|
34886
|
+
const rows = buildSplitDiffRows(unifiedSlice, seed);
|
|
33989
34887
|
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
33990
34888
|
// separator + 1 right padding) so neither side touches the border.
|
|
33991
34889
|
const usable = Math.max(20, width - 4);
|
|
33992
34890
|
const gutter = 1;
|
|
33993
34891
|
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
33994
34892
|
return rows.map((row, index) => {
|
|
33995
|
-
const
|
|
33996
|
-
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
33997
|
-
const leftText = formatSplitDiffCell(row.left, half);
|
|
33998
|
-
const rightText = formatSplitDiffCell(row.right, half);
|
|
34893
|
+
const rowKey = `${keyPrefix}-${startOffset + index}`;
|
|
33999
34894
|
return h(Box, {
|
|
34000
|
-
key:
|
|
34895
|
+
key: rowKey,
|
|
34001
34896
|
flexDirection: 'row',
|
|
34002
|
-
}, h(Box, { width: half, flexShrink: 0 }, h
|
|
34897
|
+
}, 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`)));
|
|
34003
34898
|
});
|
|
34004
34899
|
}
|
|
34005
34900
|
|
|
34901
|
+
/**
|
|
34902
|
+
* @param syntaxSpans map of marker-stripped code line → token spans
|
|
34903
|
+
* (from `highlightDiffCode`), or undefined when highlighting is off.
|
|
34904
|
+
* @param maxCells total cell budget for the whole line (marker + code).
|
|
34905
|
+
*/
|
|
34906
|
+
function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
34907
|
+
const spans = line ? syntaxSpans?.get(line.slice(1)) : undefined;
|
|
34908
|
+
if (!spans || spans.length === 0) {
|
|
34909
|
+
return h(Text, { key, ...diffLineProps(line, theme) }, truncateCells(line, maxCells));
|
|
34910
|
+
}
|
|
34911
|
+
const marker = line[0];
|
|
34912
|
+
const markerColor = marker === '+'
|
|
34913
|
+
? theme.colors.gitAdded
|
|
34914
|
+
: marker === '-'
|
|
34915
|
+
? theme.colors.gitDeleted
|
|
34916
|
+
: undefined;
|
|
34917
|
+
const code = line.slice(1);
|
|
34918
|
+
const budget = Math.max(0, maxCells - 1); // reserve one cell for the marker
|
|
34919
|
+
const children = [];
|
|
34920
|
+
let used = 0;
|
|
34921
|
+
for (const span of spans) {
|
|
34922
|
+
if (used >= budget)
|
|
34923
|
+
break;
|
|
34924
|
+
const segment = truncateCells(code.slice(span.start, span.end), budget - used);
|
|
34925
|
+
if (!segment)
|
|
34926
|
+
continue;
|
|
34927
|
+
used += cellWidth(segment);
|
|
34928
|
+
children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
|
|
34929
|
+
}
|
|
34930
|
+
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34931
|
+
}
|
|
34932
|
+
|
|
34006
34933
|
/**
|
|
34007
34934
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34008
34935
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34025,7 +34952,7 @@ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, th
|
|
|
34025
34952
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34026
34953
|
* of #890. No behavior change.
|
|
34027
34954
|
*/
|
|
34028
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
34955
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
|
|
34029
34956
|
const { Box, Text } = components;
|
|
34030
34957
|
const focused = state.focus === 'commits';
|
|
34031
34958
|
const worktree = context.worktree;
|
|
@@ -34079,7 +35006,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34079
35006
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
34080
35007
|
? []
|
|
34081
35008
|
: splitActive
|
|
34082
|
-
? renderSplitDiffBody(h, components,
|
|
35009
|
+
? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'stash-diff-split', syntaxSpans)
|
|
34083
35010
|
: visibleLines.map((line, index) => {
|
|
34084
35011
|
const absoluteIndex = state.diffPreviewOffset + index;
|
|
34085
35012
|
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
@@ -34116,10 +35043,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34116
35043
|
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
34117
35044
|
})());
|
|
34118
35045
|
}
|
|
34119
|
-
return h
|
|
34120
|
-
key: `stash-diff-line-${absoluteIndex}`,
|
|
34121
|
-
...diffLineProps(line, theme),
|
|
34122
|
-
}, truncateCells(line, width - 4));
|
|
35046
|
+
return renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `stash-diff-line-${absoluteIndex}`);
|
|
34123
35047
|
});
|
|
34124
35048
|
return h(Box, {
|
|
34125
35049
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -34160,11 +35084,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34160
35084
|
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
34161
35085
|
? []
|
|
34162
35086
|
: splitActive
|
|
34163
|
-
? renderSplitDiffBody(h, components,
|
|
34164
|
-
: visibleLines.map((line, index) => h
|
|
34165
|
-
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
34166
|
-
...diffLineProps(line, theme),
|
|
34167
|
-
}, truncateCells(line, width - 4)));
|
|
35087
|
+
? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'compare-diff-split', syntaxSpans)
|
|
35088
|
+
: visibleLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `compare-diff-line-${state.diffPreviewOffset + index}`));
|
|
34168
35089
|
return h(Box, {
|
|
34169
35090
|
borderColor: focusBorderColor(theme, focused),
|
|
34170
35091
|
borderStyle: theme.borderStyle,
|
|
@@ -34214,11 +35135,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34214
35135
|
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
34215
35136
|
? []
|
|
34216
35137
|
: splitActive
|
|
34217
|
-
? renderSplitDiffBody(h, components,
|
|
34218
|
-
: visiblePreviewHunks.map((line, index) => h
|
|
34219
|
-
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
34220
|
-
...diffLineProps(line, theme),
|
|
34221
|
-
}, truncateCells(line, 140)));
|
|
35138
|
+
? renderSplitDiffBody(h, components, previewHunks, state.diffPreviewOffset, visibleRows, width, theme, 'commit-diff-split', syntaxSpans)
|
|
35139
|
+
: visiblePreviewHunks.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.diffPreviewOffset + index}`));
|
|
34222
35140
|
return h(Box, {
|
|
34223
35141
|
borderColor: focusBorderColor(theme, focused),
|
|
34224
35142
|
borderStyle: theme.borderStyle,
|
|
@@ -34264,10 +35182,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34264
35182
|
key: `diff-surface-header-${index}`,
|
|
34265
35183
|
dimColor: index > 0,
|
|
34266
35184
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
34267
|
-
? visibleDiffLines.map((line, index) => h
|
|
34268
|
-
key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
|
|
34269
|
-
...diffLineProps(line, theme),
|
|
34270
|
-
}, truncateCells(line, 140)))
|
|
35185
|
+
? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
|
|
34271
35186
|
: []));
|
|
34272
35187
|
}
|
|
34273
35188
|
|
|
@@ -35413,9 +36328,55 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
35413
36328
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
35414
36329
|
}, truncateCells(label, 140));
|
|
35415
36330
|
}
|
|
35416
|
-
|
|
36331
|
+
/**
|
|
36332
|
+
* Full-panel loader shown over the history surface while a remote
|
|
36333
|
+
* operation (fetch / pull / push) is in flight. Same bordered frame
|
|
36334
|
+
* and `Commits` title row as the real panel so the swap in/out is
|
|
36335
|
+
* seamless: a centered spinner + label + a travelling arrow track
|
|
36336
|
+
* give the user an unmistakable "we're talking to the remote" beat in
|
|
36337
|
+
* place of a frozen, soon-to-abruptly-repaint commit list.
|
|
36338
|
+
*/
|
|
36339
|
+
function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame) {
|
|
36340
|
+
const { Box, Text } = components;
|
|
36341
|
+
const op = state.remoteOp;
|
|
36342
|
+
if (!op) {
|
|
36343
|
+
return h(Box, { width });
|
|
36344
|
+
}
|
|
36345
|
+
const spinner = pickSpinnerFrame(spinnerFrame);
|
|
36346
|
+
// Directional glyph hints which way the bits are flowing.
|
|
36347
|
+
const glyph = op.kind === 'push' ? '↑' : op.kind === 'pull' ? '↓' : '↕';
|
|
36348
|
+
// A single glyph "travels" along a dotted track each tick so the
|
|
36349
|
+
// motion reads even on terminals that render braille spinners poorly.
|
|
36350
|
+
const trackWidth = 9;
|
|
36351
|
+
const pos = Math.max(0, spinnerFrame) % trackWidth;
|
|
36352
|
+
const track = Array.from({ length: trackWidth }, (_, i) => (i === pos ? glyph : '·')).join(' ');
|
|
36353
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
36354
|
+
const innerHeight = Math.max(3, bodyRows - 2);
|
|
36355
|
+
return h(Box, {
|
|
36356
|
+
borderColor: focusBorderColor(theme, focused),
|
|
36357
|
+
borderStyle: theme.borderStyle,
|
|
36358
|
+
flexDirection: 'column',
|
|
36359
|
+
flexShrink: 0,
|
|
36360
|
+
paddingX: 1,
|
|
36361
|
+
width,
|
|
36362
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${op.kind} in progress`)), h(Box, {
|
|
36363
|
+
flexDirection: 'column',
|
|
36364
|
+
alignItems: 'center',
|
|
36365
|
+
justifyContent: 'center',
|
|
36366
|
+
height: innerHeight,
|
|
36367
|
+
}, 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.')));
|
|
36368
|
+
}
|
|
36369
|
+
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
35417
36370
|
const { Box, Text } = components;
|
|
35418
36371
|
const focused = state.focus === 'commits';
|
|
36372
|
+
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
36373
|
+
// for a centered, animated loader. Keeping the same bordered panel
|
|
36374
|
+
// (same width, same title row) means that when the op completes and
|
|
36375
|
+
// `remoteOp` clears, the fresh rows paint in place without the panel
|
|
36376
|
+
// jumping — smoothing over the "frozen list → sudden repaint" feel.
|
|
36377
|
+
if (state.remoteOp) {
|
|
36378
|
+
return renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame);
|
|
36379
|
+
}
|
|
35419
36380
|
const worktree = context.worktree;
|
|
35420
36381
|
// Distinct remote names seen across the repo's remote-tracking
|
|
35421
36382
|
// branches — `['origin']` for a typical fork, `['origin', 'upstream']`
|
|
@@ -35896,6 +36857,37 @@ function renderThemePickerOverlay(h, components, filter, index, width, theme, fo
|
|
|
35896
36857
|
? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
|
|
35897
36858
|
: []));
|
|
35898
36859
|
}
|
|
36860
|
+
/**
|
|
36861
|
+
* "Add to .gitignore" quick-pick overlay (`i` on the status view).
|
|
36862
|
+
* Modeled on the theme picker but with a fixed, file-derived option list
|
|
36863
|
+
* (no fuzzy filter — the menu is short): pick exact / by-extension /
|
|
36864
|
+
* by-folder / by-name, or the `Custom pattern…` escape hatch which opens
|
|
36865
|
+
* a free-text prompt. ↑/↓ to move, Enter to choose, Esc to cancel.
|
|
36866
|
+
*/
|
|
36867
|
+
function renderGitignorePickerOverlay(h, components, file, index, width, theme, focused) {
|
|
36868
|
+
const { Box, Text } = components;
|
|
36869
|
+
const options = deriveGitignoreOptions(file);
|
|
36870
|
+
const selectedIndex = Math.max(0, Math.min(index, options.length - 1));
|
|
36871
|
+
const hint = '↑/↓ select · enter add · esc cancel';
|
|
36872
|
+
const itemLines = options.map((option, offset) => {
|
|
36873
|
+
const isSelected = offset === selectedIndex;
|
|
36874
|
+
const cursor = isSelected ? '>' : ' ';
|
|
36875
|
+
const glyph = option.custom ? '✎ ' : '+ ';
|
|
36876
|
+
return h(Text, {
|
|
36877
|
+
key: `gitignore-opt-${offset}`,
|
|
36878
|
+
bold: isSelected,
|
|
36879
|
+
dimColor: !isSelected,
|
|
36880
|
+
color: isSelected && !theme.noColor ? theme.colors.accent : undefined,
|
|
36881
|
+
}, `${cursor} ${glyph}`, truncateCells(option.label, width - 8));
|
|
36882
|
+
});
|
|
36883
|
+
return h(Box, {
|
|
36884
|
+
borderColor: focusBorderColor(theme, focused),
|
|
36885
|
+
borderStyle: theme.borderStyle,
|
|
36886
|
+
flexDirection: 'column',
|
|
36887
|
+
width,
|
|
36888
|
+
paddingX: 1,
|
|
36889
|
+
}, 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);
|
|
36890
|
+
}
|
|
35899
36891
|
/**
|
|
35900
36892
|
* Split-plan overlay (#907) — renders the proposed commit groups for
|
|
35901
36893
|
* the user to review before applying. Three phases driven by
|
|
@@ -37240,7 +38232,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
37240
38232
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
37241
38233
|
* of #890. No behavior change.
|
|
37242
38234
|
*/
|
|
37243
|
-
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) {
|
|
38235
|
+
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) {
|
|
37244
38236
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
37245
38237
|
// detail) when active, because the content — multiple commit groups
|
|
37246
38238
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -37255,7 +38247,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
37255
38247
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
37256
38248
|
}
|
|
37257
38249
|
if (state.activeView === 'diff') {
|
|
37258
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
38250
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
|
|
37259
38251
|
}
|
|
37260
38252
|
if (state.activeView === 'compose') {
|
|
37261
38253
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
|
|
@@ -37296,7 +38288,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
37296
38288
|
if (state.activeView === 'changelog') {
|
|
37297
38289
|
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
37298
38290
|
}
|
|
37299
|
-
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
|
|
38291
|
+
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
37300
38292
|
}
|
|
37301
38293
|
|
|
37302
38294
|
/**
|
|
@@ -38335,6 +39327,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
38335
39327
|
if (state.showThemePicker) {
|
|
38336
39328
|
return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
|
|
38337
39329
|
}
|
|
39330
|
+
if (state.gitignorePicker) {
|
|
39331
|
+
return renderGitignorePickerOverlay(h, components, state.gitignorePicker.file, state.gitignorePicker.index, width, theme, focused);
|
|
39332
|
+
}
|
|
38338
39333
|
if (state.inputPrompt) {
|
|
38339
39334
|
return renderInputPromptPanel(h, components, state, width, theme, focused);
|
|
38340
39335
|
}
|
|
@@ -38412,6 +39407,67 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
38412
39407
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
38413
39408
|
}
|
|
38414
39409
|
|
|
39410
|
+
/**
|
|
39411
|
+
* Resolve + scaffold the coco config files the workstation can open in
|
|
39412
|
+
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
39413
|
+
*
|
|
39414
|
+
* Two scopes:
|
|
39415
|
+
* - `global` → `$XDG_CONFIG_HOME/coco/config.json` (default
|
|
39416
|
+
* `~/.config/coco/config.json`) — applies to every repo.
|
|
39417
|
+
* - `project` → `.coco.json` (preferred) or the legacy
|
|
39418
|
+
* `.coco.config.json` at the repo root — applies to the
|
|
39419
|
+
* current repository only.
|
|
39420
|
+
*
|
|
39421
|
+
* When the chosen file doesn't exist yet we write a minimal templated
|
|
39422
|
+
* starter (just the `$schema` link + a sample `logTui.theme.preset`) so
|
|
39423
|
+
* the user lands in an editable, schema-aware file instead of an empty
|
|
39424
|
+
* buffer or an error.
|
|
39425
|
+
*/
|
|
39426
|
+
/**
|
|
39427
|
+
* Minimal starter config written when scaffolding a missing file. Keeps
|
|
39428
|
+
* the `$schema` link (so editors offer completion/validation) and one
|
|
39429
|
+
* illustrative key showing where settings live — small enough to not
|
|
39430
|
+
* impose opinions, structured enough to be a useful starting point.
|
|
39431
|
+
*/
|
|
39432
|
+
const STARTER_CONFIG = `${JSON.stringify({
|
|
39433
|
+
$schema: SCHEMA_PUBLIC_URL,
|
|
39434
|
+
logTui: { theme: { preset: 'default' } },
|
|
39435
|
+
}, null, 2)}\n`;
|
|
39436
|
+
/** `$XDG_CONFIG_HOME/coco/config.json` (default `~/.config/coco/config.json`). */
|
|
39437
|
+
function getGlobalConfigPath() {
|
|
39438
|
+
return getXdgConfigPath();
|
|
39439
|
+
}
|
|
39440
|
+
/**
|
|
39441
|
+
* The project config path for `repoRoot`: the first existing of
|
|
39442
|
+
* `.coco.json` / `.coco.config.json`, else `.coco.json` as the default
|
|
39443
|
+
* to create.
|
|
39444
|
+
*/
|
|
39445
|
+
function getProjectConfigPath(repoRoot) {
|
|
39446
|
+
for (const name of ['.coco.json', '.coco.config.json']) {
|
|
39447
|
+
const candidate = path__namespace.join(repoRoot, name);
|
|
39448
|
+
if (fs__namespace.existsSync(candidate))
|
|
39449
|
+
return candidate;
|
|
39450
|
+
}
|
|
39451
|
+
return path__namespace.join(repoRoot, '.coco.json');
|
|
39452
|
+
}
|
|
39453
|
+
/** Resolve the config path for a scope. `project` needs the repo root. */
|
|
39454
|
+
function resolveConfigPath(scope, repoRoot) {
|
|
39455
|
+
return scope === 'global' ? getGlobalConfigPath() : getProjectConfigPath(repoRoot);
|
|
39456
|
+
}
|
|
39457
|
+
/**
|
|
39458
|
+
* Ensure `filePath` exists, scaffolding the starter template (and any
|
|
39459
|
+
* missing parent directories) when it doesn't. Returns whether it was
|
|
39460
|
+
* just created so the caller can surface a "Created …" message.
|
|
39461
|
+
*/
|
|
39462
|
+
function ensureConfigFile(filePath) {
|
|
39463
|
+
if (fs__namespace.existsSync(filePath)) {
|
|
39464
|
+
return { created: false };
|
|
39465
|
+
}
|
|
39466
|
+
fs__namespace.mkdirSync(path__namespace.dirname(filePath), { recursive: true });
|
|
39467
|
+
fs__namespace.writeFileSync(filePath, STARTER_CONFIG);
|
|
39468
|
+
return { created: true };
|
|
39469
|
+
}
|
|
39470
|
+
|
|
38415
39471
|
/**
|
|
38416
39472
|
* `LogInkApp` — the workstation's root React component. Hosts all state
|
|
38417
39473
|
* via `useState`/`useEffect`/`useMemo`/`useCallback` hooks; wires up the
|
|
@@ -38558,6 +39614,18 @@ function loadLogInkContextEntries(git) {
|
|
|
38558
39614
|
},
|
|
38559
39615
|
];
|
|
38560
39616
|
}
|
|
39617
|
+
// Workflow action ids that hit the network (fetch / pull / push) →
|
|
39618
|
+
// the loader copy shown over the history surface while they run. Any
|
|
39619
|
+
// id NOT in this map runs without the full-screen loader (local-only
|
|
39620
|
+
// mutations repaint fast enough that a loader would just flicker).
|
|
39621
|
+
const REMOTE_OP_LOADERS = {
|
|
39622
|
+
'fetch-remotes': { kind: 'fetch', label: 'Fetching all remotes…' },
|
|
39623
|
+
'pull-current-branch': { kind: 'pull', label: 'Pulling from origin…' },
|
|
39624
|
+
'push-current-branch': { kind: 'push', label: 'Pushing to origin…' },
|
|
39625
|
+
'fetch-selected-branch': { kind: 'fetch', label: 'Fetching branch from remote…' },
|
|
39626
|
+
'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
|
|
39627
|
+
'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
|
|
39628
|
+
};
|
|
38561
39629
|
function predictNextFilter(action, currentFilter) {
|
|
38562
39630
|
switch (action.type) {
|
|
38563
39631
|
case 'appendFilter':
|
|
@@ -38629,7 +39697,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
38629
39697
|
}
|
|
38630
39698
|
}
|
|
38631
39699
|
function LogInkApp(deps) {
|
|
38632
|
-
const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
|
|
39700
|
+
const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, syntaxHighlightEnabled, theme: baseTheme, themeConfig } = deps;
|
|
38633
39701
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
38634
39702
|
const h = React.createElement;
|
|
38635
39703
|
// Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
|
|
@@ -38800,6 +39868,11 @@ function LogInkApp(deps) {
|
|
|
38800
39868
|
const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
|
|
38801
39869
|
const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
|
|
38802
39870
|
const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
|
|
39871
|
+
// Syntax-highlight spans for the diff currently in view (#1117
|
|
39872
|
+
// follow-up). Computed off the render path by the effect below;
|
|
39873
|
+
// keyed by marker-stripped code line so the diff renderer looks
|
|
39874
|
+
// spans up directly. `undefined` = no highlighting (renders plain).
|
|
39875
|
+
const [diffSyntaxSpans, setDiffSyntaxSpans] = React.useState(undefined);
|
|
38803
39876
|
// Stash diff explorer (Enter on a stash row): the runtime fetches
|
|
38804
39877
|
// `git stash show -p <ref>` lazily once the diff view becomes active
|
|
38805
39878
|
// with diffSource='stash'. Lines are stored as a flat string[] —
|
|
@@ -38864,6 +39937,7 @@ function LogInkApp(deps) {
|
|
|
38864
39937
|
state.splitPlan?.status === 'applying' ||
|
|
38865
39938
|
state.changelogView.status === 'loading' ||
|
|
38866
39939
|
state.commitCompose.loading ||
|
|
39940
|
+
Boolean(state.remoteOp) ||
|
|
38867
39941
|
Boolean(state.statusLoading);
|
|
38868
39942
|
React.useEffect(() => {
|
|
38869
39943
|
if (!anyLoading) {
|
|
@@ -39830,6 +40904,53 @@ function LogInkApp(deps) {
|
|
|
39830
40904
|
selectedWorktreeFile?.worktreeStatus,
|
|
39831
40905
|
state.activeView,
|
|
39832
40906
|
]);
|
|
40907
|
+
// Syntax-highlight the diff currently in view, off the render path
|
|
40908
|
+
// (#1117 follow-up). Mirrors the worktree-diff effect: detect the
|
|
40909
|
+
// active file + its diff lines (worktree or commit source), tokenize
|
|
40910
|
+
// via tree-sitter, and store the per-line spans for the renderer.
|
|
40911
|
+
// Stash / compare sources aren't highlighted yet (multi-file patch /
|
|
40912
|
+
// no single path). Gated on the config flag + a color terminal.
|
|
40913
|
+
React.useEffect(() => {
|
|
40914
|
+
if (!syntaxHighlightEnabled || theme.noColor || state.activeView !== 'diff') {
|
|
40915
|
+
setDiffSyntaxSpans(undefined);
|
|
40916
|
+
return;
|
|
40917
|
+
}
|
|
40918
|
+
let filePath;
|
|
40919
|
+
let lines;
|
|
40920
|
+
if (state.diffSource === 'commit') {
|
|
40921
|
+
filePath = selectedDetailFile?.path;
|
|
40922
|
+
lines = filePreview?.hunks;
|
|
40923
|
+
}
|
|
40924
|
+
else if (worktreeDiff && !worktreeDiff.untracked) {
|
|
40925
|
+
filePath = worktreeDiff.filePath;
|
|
40926
|
+
lines = worktreeDiff.lines;
|
|
40927
|
+
}
|
|
40928
|
+
if (!filePath || !lines || lines.length === 0) {
|
|
40929
|
+
setDiffSyntaxSpans(undefined);
|
|
40930
|
+
return;
|
|
40931
|
+
}
|
|
40932
|
+
let active = true;
|
|
40933
|
+
void highlightDiffCode(filePath, lines)
|
|
40934
|
+
.then((map) => {
|
|
40935
|
+
if (active)
|
|
40936
|
+
setDiffSyntaxSpans(map.size > 0 ? map : undefined);
|
|
40937
|
+
})
|
|
40938
|
+
.catch(() => {
|
|
40939
|
+
if (active)
|
|
40940
|
+
setDiffSyntaxSpans(undefined);
|
|
40941
|
+
});
|
|
40942
|
+
return () => {
|
|
40943
|
+
active = false;
|
|
40944
|
+
};
|
|
40945
|
+
}, [
|
|
40946
|
+
syntaxHighlightEnabled,
|
|
40947
|
+
theme.noColor,
|
|
40948
|
+
state.activeView,
|
|
40949
|
+
state.diffSource,
|
|
40950
|
+
selectedDetailFile?.path,
|
|
40951
|
+
filePreview,
|
|
40952
|
+
worktreeDiff,
|
|
40953
|
+
]);
|
|
39833
40954
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
39834
40955
|
if (!selectedWorktreeFile) {
|
|
39835
40956
|
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
@@ -40504,6 +41625,28 @@ function LogInkApp(deps) {
|
|
|
40504
41625
|
// refresh so the file row reflects the new staged/unstaged state.
|
|
40505
41626
|
void refreshWorktreeContext({ silent: true });
|
|
40506
41627
|
}, [dispatch, refreshWorktreeContext, resumeRef]);
|
|
41628
|
+
// Open the global or project coco config in $EDITOR (gk / gK + their
|
|
41629
|
+
// command-palette entries). Scaffolds a templated starter when the file
|
|
41630
|
+
// doesn't exist yet so the user never lands in an empty buffer or hits
|
|
41631
|
+
// a "no such file" error.
|
|
41632
|
+
const openConfigInEditor = React.useCallback((scope) => {
|
|
41633
|
+
// `repoRootRef` is populated async from `git rev-parse --show-toplevel`;
|
|
41634
|
+
// fall back to cwd so a freshly-launched session can still scaffold +
|
|
41635
|
+
// open the project config before that resolves.
|
|
41636
|
+
const repoRoot = repoRootRef.current || process.cwd();
|
|
41637
|
+
const filePath = resolveConfigPath(scope, repoRoot);
|
|
41638
|
+
try {
|
|
41639
|
+
const { created } = ensureConfigFile(filePath);
|
|
41640
|
+
if (created) {
|
|
41641
|
+
dispatch({ type: 'setStatus', value: `Created ${scope} config at ${filePath}`, kind: 'success' });
|
|
41642
|
+
}
|
|
41643
|
+
}
|
|
41644
|
+
catch (error) {
|
|
41645
|
+
dispatch({ type: 'setStatus', value: `Could not create config: ${error.message}`, kind: 'error' });
|
|
41646
|
+
return;
|
|
41647
|
+
}
|
|
41648
|
+
openInEditor(filePath);
|
|
41649
|
+
}, [dispatch, openInEditor]);
|
|
40507
41650
|
// `E` keystroke handler — open the current commit draft in $EDITOR
|
|
40508
41651
|
// (or $VISUAL), then read the file back and update the compose state
|
|
40509
41652
|
// with the saved content. Mirrors the suspend → spawn → resume
|
|
@@ -41359,6 +42502,7 @@ function LogInkApp(deps) {
|
|
|
41359
42502
|
return { ok: false, message: 'No branch selected' };
|
|
41360
42503
|
return pushBranch(git, branch);
|
|
41361
42504
|
},
|
|
42505
|
+
'add-to-gitignore': async () => addToGitignore(git, payload || ''),
|
|
41362
42506
|
'rename-branch': async () => {
|
|
41363
42507
|
const newName = payload?.trim();
|
|
41364
42508
|
if (!newName)
|
|
@@ -41654,76 +42798,102 @@ function LogInkApp(deps) {
|
|
|
41654
42798
|
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
41655
42799
|
return;
|
|
41656
42800
|
}
|
|
41657
|
-
|
|
41658
|
-
|
|
41659
|
-
//
|
|
41660
|
-
//
|
|
41661
|
-
//
|
|
41662
|
-
//
|
|
41663
|
-
|
|
41664
|
-
|
|
41665
|
-
|
|
41666
|
-
const historyMutatingIds = new Set([
|
|
41667
|
-
'checkout-branch',
|
|
41668
|
-
'continue-operation',
|
|
41669
|
-
'pull-current-branch',
|
|
41670
|
-
'cherry-pick-commit',
|
|
41671
|
-
'revert-commit',
|
|
41672
|
-
'reset-hard-to-commit',
|
|
41673
|
-
'reset-soft-to-commit',
|
|
41674
|
-
'reset-mixed-to-commit',
|
|
41675
|
-
'interactive-rebase-to-commit',
|
|
41676
|
-
'bisect-good',
|
|
41677
|
-
'bisect-bad',
|
|
41678
|
-
'bisect-skip',
|
|
41679
|
-
'bisect-reset',
|
|
41680
|
-
]);
|
|
41681
|
-
if (result?.ok && historyMutatingIds.has(id)) {
|
|
41682
|
-
await refreshHistoryRows();
|
|
41683
|
-
}
|
|
41684
|
-
// Checkout-branch is the one workflow where we want a *visible*
|
|
41685
|
-
// refresh so the user sees the branches sidebar repaint with the
|
|
41686
|
-
// new current branch (per #806 follow-up). Snap the cursor to
|
|
41687
|
-
// position 0 first so when the refresh completes and the new
|
|
41688
|
-
// current branch lands at the top (per #809's pin-current rule),
|
|
41689
|
-
// the cursor is already there waiting.
|
|
41690
|
-
if (id === 'checkout-branch' && result?.ok) {
|
|
41691
|
-
dispatch({ type: 'resetBranchSelection' });
|
|
41692
|
-
await refreshContext();
|
|
42801
|
+
// Remote network ops (fetch / pull / push) get a full-screen
|
|
42802
|
+
// history loader while in flight so the commit list doesn't sit
|
|
42803
|
+
// frozen and then abruptly repaint when the call returns. Cleared
|
|
42804
|
+
// in `finally` *after* the post-op refresh below so the loader
|
|
42805
|
+
// hands straight off to the freshly-fetched rows instead of
|
|
42806
|
+
// flashing the stale list for a frame in between.
|
|
42807
|
+
const remoteOp = REMOTE_OP_LOADERS[id];
|
|
42808
|
+
if (remoteOp) {
|
|
42809
|
+
dispatch({ type: 'setRemoteOp', value: remoteOp });
|
|
41693
42810
|
}
|
|
41694
|
-
|
|
41695
|
-
|
|
41696
|
-
|
|
41697
|
-
|
|
41698
|
-
|
|
41699
|
-
|
|
41700
|
-
|
|
41701
|
-
|
|
41702
|
-
|
|
41703
|
-
|
|
41704
|
-
|
|
41705
|
-
|
|
41706
|
-
|
|
41707
|
-
|
|
41708
|
-
|
|
41709
|
-
|
|
41710
|
-
|
|
41711
|
-
|
|
41712
|
-
|
|
41713
|
-
|
|
41714
|
-
|
|
41715
|
-
|
|
41716
|
-
|
|
42811
|
+
try {
|
|
42812
|
+
const result = await handler();
|
|
42813
|
+
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
42814
|
+
// Refresh history rows AS WELL when the workflow could have
|
|
42815
|
+
// changed the commits the user sees (#945 follow-up). The
|
|
42816
|
+
// workflow IDs below all either create/rewrite local commits or
|
|
42817
|
+
// change which branch's history is being viewed — without this
|
|
42818
|
+
// the history pane shows stale data even after the operation
|
|
42819
|
+
// succeeds. Cheap one-off `git log` call; doesn't fire on
|
|
42820
|
+
// metadata-only mutations (delete-tag, set-upstream, etc.).
|
|
42821
|
+
const historyMutatingIds = new Set([
|
|
42822
|
+
'checkout-branch',
|
|
42823
|
+
'continue-operation',
|
|
42824
|
+
'pull-current-branch',
|
|
42825
|
+
'cherry-pick-commit',
|
|
42826
|
+
'revert-commit',
|
|
42827
|
+
'reset-hard-to-commit',
|
|
42828
|
+
'reset-soft-to-commit',
|
|
42829
|
+
'reset-mixed-to-commit',
|
|
42830
|
+
'interactive-rebase-to-commit',
|
|
42831
|
+
'bisect-good',
|
|
42832
|
+
'bisect-bad',
|
|
42833
|
+
'bisect-skip',
|
|
42834
|
+
'bisect-reset',
|
|
42835
|
+
]);
|
|
42836
|
+
if (result?.ok && historyMutatingIds.has(id)) {
|
|
42837
|
+
await refreshHistoryRows();
|
|
42838
|
+
}
|
|
42839
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
42840
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
42841
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
42842
|
+
// position 0 first so when the refresh completes and the new
|
|
42843
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
42844
|
+
// the cursor is already there waiting.
|
|
42845
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
42846
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
42847
|
+
await refreshContext();
|
|
42848
|
+
}
|
|
42849
|
+
else {
|
|
42850
|
+
// Silent refresh so the deleted item disappears from the list
|
|
42851
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
42852
|
+
await refreshContext({ silent: true });
|
|
42853
|
+
}
|
|
42854
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
42855
|
+
//
|
|
42856
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
42857
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
42858
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
42859
|
+
// jump them to history view (where the worktree counts in the
|
|
42860
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
42861
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
42862
|
+
//
|
|
42863
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
42864
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
42865
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
42866
|
+
// affect untracked files when the stash held `-u` state), and
|
|
42867
|
+
// surface the new stash count on the status line so there's
|
|
42868
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
42869
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
42870
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
42871
|
+
await refreshWorktreeContext();
|
|
42872
|
+
}
|
|
42873
|
+
// Refresh the worktree so a now-ignored untracked file drops out of
|
|
42874
|
+
// the status list immediately (the silent context refresh above
|
|
42875
|
+
// doesn't always re-read the worktree file set).
|
|
42876
|
+
if (result?.ok && id === 'add-to-gitignore') {
|
|
42877
|
+
await refreshWorktreeContext();
|
|
42878
|
+
}
|
|
42879
|
+
if (result?.ok && id === 'drop-stash') {
|
|
42880
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
42881
|
+
// untracked-file state that's now collected.
|
|
42882
|
+
await refreshWorktreeContext();
|
|
42883
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
42884
|
+
// reading the count back here would be stale because closures
|
|
42885
|
+
// capture the pre-refresh value. Status message stays generic
|
|
42886
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
42887
|
+
// unambiguous signal that the operation landed.
|
|
42888
|
+
}
|
|
41717
42889
|
}
|
|
41718
|
-
|
|
41719
|
-
//
|
|
41720
|
-
//
|
|
41721
|
-
|
|
41722
|
-
|
|
41723
|
-
|
|
41724
|
-
|
|
41725
|
-
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
41726
|
-
// unambiguous signal that the operation landed.
|
|
42890
|
+
finally {
|
|
42891
|
+
// Always clear the loader — even if a refresh threw — so a
|
|
42892
|
+
// failed fetch/pull can't leave the history surface stuck behind
|
|
42893
|
+
// the spinner.
|
|
42894
|
+
if (remoteOp) {
|
|
42895
|
+
dispatch({ type: 'setRemoteOp', value: undefined });
|
|
42896
|
+
}
|
|
41727
42897
|
}
|
|
41728
42898
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
41729
42899
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
@@ -42475,9 +43645,22 @@ function LogInkApp(deps) {
|
|
|
42475
43645
|
else if (event.type === 'openFileInEditor') {
|
|
42476
43646
|
openInEditor(event.path);
|
|
42477
43647
|
}
|
|
43648
|
+
else if (event.type === 'openConfigInEditor') {
|
|
43649
|
+
openConfigInEditor(event.scope);
|
|
43650
|
+
}
|
|
42478
43651
|
else if (event.type === 'yankFromActiveView') {
|
|
42479
43652
|
void yankFromActiveView(event.short);
|
|
42480
43653
|
}
|
|
43654
|
+
else if (event.type === 'openGitignorePicker') {
|
|
43655
|
+
// Resolve the cursored worktree file here (the runtime owns the
|
|
43656
|
+
// selection→file mapping) and open the picker over its path.
|
|
43657
|
+
if (selectedWorktreeFile?.path) {
|
|
43658
|
+
dispatch({ type: 'openGitignorePicker', file: selectedWorktreeFile.path });
|
|
43659
|
+
}
|
|
43660
|
+
else {
|
|
43661
|
+
dispatch({ type: 'setStatus', value: 'No file under the cursor to ignore.', kind: 'warning' });
|
|
43662
|
+
}
|
|
43663
|
+
}
|
|
42481
43664
|
else if (event.type === 'applyThemePreset') {
|
|
42482
43665
|
// Apply for the session immediately, and best-effort persist to the
|
|
42483
43666
|
// global config so it sticks across launches. The picker has already
|
|
@@ -42521,7 +43704,7 @@ function LogInkApp(deps) {
|
|
|
42521
43704
|
if (showOnboarding) {
|
|
42522
43705
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
42523
43706
|
}
|
|
42524
|
-
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));
|
|
43707
|
+
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));
|
|
42525
43708
|
}
|
|
42526
43709
|
|
|
42527
43710
|
/**
|
|
@@ -42692,6 +43875,8 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
42692
43875
|
// Resolve undefined → true so the default flips on automatically.
|
|
42693
43876
|
// An explicit `false` from config opts out.
|
|
42694
43877
|
dateBucketingEnabled: options.dateBucketing !== false,
|
|
43878
|
+
// Undefined → on; explicit `false` opts out.
|
|
43879
|
+
syntaxHighlightEnabled: options.syntaxHighlight !== false,
|
|
42695
43880
|
ink,
|
|
42696
43881
|
initialView: options.initialView || 'history',
|
|
42697
43882
|
logArgv: options.logArgv,
|
|
@@ -42899,6 +44084,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
42899
44084
|
appLabel: 'coco',
|
|
42900
44085
|
idleTips: config.logTui?.idleTips,
|
|
42901
44086
|
dateBucketing: config.logTui?.dateBucketing,
|
|
44087
|
+
syntaxHighlight: config.logTui?.syntaxHighlight,
|
|
42902
44088
|
initialView: 'history',
|
|
42903
44089
|
loadRows,
|
|
42904
44090
|
logArgv,
|
|
@@ -42921,6 +44107,7 @@ async function startCocoUi(argv) {
|
|
|
42921
44107
|
appLabel: 'coco',
|
|
42922
44108
|
idleTips: config.logTui?.idleTips,
|
|
42923
44109
|
dateBucketing: config.logTui?.dateBucketing,
|
|
44110
|
+
syntaxHighlight: config.logTui?.syntaxHighlight,
|
|
42924
44111
|
initialView: argv.view || 'history',
|
|
42925
44112
|
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
42926
44113
|
logArgv,
|