git-coco 0.58.1 → 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/dist/index.d.ts +26 -0
- package/dist/index.esm.mjs +1063 -39
- package/dist/index.js +1063 -39
- package/package.json +1 -1
package/dist/index.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.59.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -1147,6 +1147,11 @@ const schema$1 = {
|
|
|
1147
1147
|
"type": "boolean",
|
|
1148
1148
|
"description": "Group adjacent commits in the history surface under shared section headers (`── Today ──`, `── Yesterday ──`, `── April 2026 ──`) and drop the per-row date column in favor of the headers. On by default because the bucketed view gives stronger temporal orientation at a glance and the freed cells go to the commit subject. Flip off if you prefer a date column on every row.\n\nBucketing automatically suppresses itself while a search filter is active (results aren't chronological), regardless of this setting.",
|
|
1149
1149
|
"default": true
|
|
1150
|
+
},
|
|
1151
|
+
"syntaxHighlight": {
|
|
1152
|
+
"type": "boolean",
|
|
1153
|
+
"description": "Syntax-highlight code in the diff view using tree-sitter (TypeScript / TSX / JavaScript today). On by default. Highlighting degrades gracefully — unsupported languages, non-ASCII lines, and parse failures fall back to the plain add/remove coloring — so the only reason to disable it is preference or a very low-color terminal. Set to `false` to opt out.",
|
|
1154
|
+
"default": true
|
|
1150
1155
|
}
|
|
1151
1156
|
},
|
|
1152
1157
|
"additionalProperties": false,
|
|
@@ -2145,6 +2150,31 @@ const schema$1 = {
|
|
|
2145
2150
|
},
|
|
2146
2151
|
"warning": {
|
|
2147
2152
|
"type": "string"
|
|
2153
|
+
},
|
|
2154
|
+
"syntaxKeyword": {
|
|
2155
|
+
"type": "string",
|
|
2156
|
+
"description": "Optional syntax-highlight token colors for the diff view (#1117 follow-up). All optional: when a slot is unset the resolver (`resolveSyntaxColor`) falls back to a sensible ANSI default, so themes get highlighting for free and only need to define these to customize. `noColor` themes skip syntax coloring entirely."
|
|
2157
|
+
},
|
|
2158
|
+
"syntaxString": {
|
|
2159
|
+
"type": "string"
|
|
2160
|
+
},
|
|
2161
|
+
"syntaxComment": {
|
|
2162
|
+
"type": "string"
|
|
2163
|
+
},
|
|
2164
|
+
"syntaxNumber": {
|
|
2165
|
+
"type": "string"
|
|
2166
|
+
},
|
|
2167
|
+
"syntaxType": {
|
|
2168
|
+
"type": "string"
|
|
2169
|
+
},
|
|
2170
|
+
"syntaxFunction": {
|
|
2171
|
+
"type": "string"
|
|
2172
|
+
},
|
|
2173
|
+
"syntaxConstant": {
|
|
2174
|
+
"type": "string"
|
|
2175
|
+
},
|
|
2176
|
+
"syntaxProperty": {
|
|
2177
|
+
"type": "string"
|
|
2148
2178
|
}
|
|
2149
2179
|
},
|
|
2150
2180
|
"additionalProperties": false
|
|
@@ -21709,6 +21739,69 @@ function isLogInkContextKeyLoading(status, key) {
|
|
|
21709
21739
|
return status[key] === 'loading';
|
|
21710
21740
|
}
|
|
21711
21741
|
|
|
21742
|
+
/**
|
|
21743
|
+
* Derive a short menu of sensible `.gitignore` patterns from the path of
|
|
21744
|
+
* the cursored worktree file (the "add to .gitignore" quick-pick, `i` on
|
|
21745
|
+
* the status view).
|
|
21746
|
+
*
|
|
21747
|
+
* The goal is to turn the common asks — "ignore exactly this", "ignore
|
|
21748
|
+
* everything with this extension", "ignore this whole folder" — into
|
|
21749
|
+
* one-keystroke choices, while always offering a `Custom pattern…` escape
|
|
21750
|
+
* hatch that opens a free-text prompt for anything the menu doesn't cover
|
|
21751
|
+
* (negations, globs, anchored paths, etc.).
|
|
21752
|
+
*
|
|
21753
|
+
* Pure / synchronous so it's trivially unit-testable and reusable from the
|
|
21754
|
+
* reducer, the input handler, and the overlay renderer without pulling in
|
|
21755
|
+
* `fs`.
|
|
21756
|
+
*/
|
|
21757
|
+
/**
|
|
21758
|
+
* Build the option list for a repo-relative path. Git reports untracked
|
|
21759
|
+
* directories with a trailing slash (`.www/`), which is how we tell a
|
|
21760
|
+
* directory from a file. Duplicate patterns are collapsed (e.g. a
|
|
21761
|
+
* top-level dir whose anchored and bare forms would otherwise repeat).
|
|
21762
|
+
*/
|
|
21763
|
+
function deriveGitignoreOptions(rawPath) {
|
|
21764
|
+
const input = rawPath.trim();
|
|
21765
|
+
const options = [];
|
|
21766
|
+
const seen = new Set();
|
|
21767
|
+
const add = (pattern, label) => {
|
|
21768
|
+
if (!pattern || seen.has(pattern))
|
|
21769
|
+
return;
|
|
21770
|
+
seen.add(pattern);
|
|
21771
|
+
options.push({ pattern, label, custom: false });
|
|
21772
|
+
};
|
|
21773
|
+
if (input) {
|
|
21774
|
+
const isDir = input.endsWith('/');
|
|
21775
|
+
const clean = input.replace(/\/+$/, '');
|
|
21776
|
+
const segments = clean.split('/').filter(Boolean);
|
|
21777
|
+
const base = segments[segments.length - 1] || clean;
|
|
21778
|
+
const parent = segments.slice(0, -1).join('/');
|
|
21779
|
+
if (isDir) {
|
|
21780
|
+
// Anchored to the repo root vs. matching any folder of that name.
|
|
21781
|
+
add(`/${clean}/`, `This folder only (/${clean}/)`);
|
|
21782
|
+
add(`${base}/`, `Any “${base}/” folder`);
|
|
21783
|
+
}
|
|
21784
|
+
else {
|
|
21785
|
+
add(input, `This file only (${input})`);
|
|
21786
|
+
const dot = base.lastIndexOf('.');
|
|
21787
|
+
if (dot > 0 && dot < base.length - 1) {
|
|
21788
|
+
const ext = base.slice(dot);
|
|
21789
|
+
add(`*${ext}`, `All ${ext} files (*${ext})`);
|
|
21790
|
+
}
|
|
21791
|
+
if (parent) {
|
|
21792
|
+
add(`${parent}/`, `Its folder (${parent}/)`);
|
|
21793
|
+
}
|
|
21794
|
+
add(base, `Any file named “${base}”`);
|
|
21795
|
+
}
|
|
21796
|
+
}
|
|
21797
|
+
options.push({
|
|
21798
|
+
pattern: input,
|
|
21799
|
+
label: 'Custom pattern…',
|
|
21800
|
+
custom: true,
|
|
21801
|
+
});
|
|
21802
|
+
return options;
|
|
21803
|
+
}
|
|
21804
|
+
|
|
21712
21805
|
/**
|
|
21713
21806
|
* Extract a single hunk from a unified-patch diff so it can be fed to
|
|
21714
21807
|
* `git apply` (or `git apply --cached`) for a hunk-level cherry-pick.
|
|
@@ -22848,6 +22941,27 @@ const LOG_INK_KEY_BINDINGS = [
|
|
|
22848
22941
|
description: 'Browse, live-preview, and apply a color theme.',
|
|
22849
22942
|
contexts: ['normal'],
|
|
22850
22943
|
},
|
|
22944
|
+
{
|
|
22945
|
+
id: 'openProjectConfig',
|
|
22946
|
+
keys: ['gk'],
|
|
22947
|
+
label: 'project config',
|
|
22948
|
+
description: 'Open this repo’s .coco.json in $EDITOR (creates a starter file if missing).',
|
|
22949
|
+
contexts: ['normal'],
|
|
22950
|
+
},
|
|
22951
|
+
{
|
|
22952
|
+
id: 'openGlobalConfig',
|
|
22953
|
+
keys: ['gK'],
|
|
22954
|
+
label: 'global config',
|
|
22955
|
+
description: 'Open ~/.config/coco/config.json in $EDITOR (creates a starter file if missing).',
|
|
22956
|
+
contexts: ['normal'],
|
|
22957
|
+
},
|
|
22958
|
+
{
|
|
22959
|
+
id: 'gitignoreFile',
|
|
22960
|
+
keys: ['i'],
|
|
22961
|
+
label: 'gitignore',
|
|
22962
|
+
description: 'Add the cursored file or folder to .gitignore (pick a pattern).',
|
|
22963
|
+
contexts: ['status'],
|
|
22964
|
+
},
|
|
22851
22965
|
{
|
|
22852
22966
|
id: 'viewChangelog',
|
|
22853
22967
|
keys: ['L'],
|
|
@@ -22938,6 +23052,9 @@ const BINDING_CATEGORY_BY_ID = {
|
|
|
22938
23052
|
help: 'essentials',
|
|
22939
23053
|
commandPalette: 'essentials',
|
|
22940
23054
|
themePicker: 'view',
|
|
23055
|
+
openProjectConfig: 'view',
|
|
23056
|
+
openGlobalConfig: 'view',
|
|
23057
|
+
gitignoreFile: 'mutate',
|
|
22941
23058
|
quit: 'essentials',
|
|
22942
23059
|
refresh: 'essentials',
|
|
22943
23060
|
navigateBack: 'essentials',
|
|
@@ -23260,7 +23377,7 @@ function getLogInkFooterHints(options) {
|
|
|
23260
23377
|
}
|
|
23261
23378
|
if (options.activeView === 'status') {
|
|
23262
23379
|
return {
|
|
23263
|
-
contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'e/c compose', 'y yank'],
|
|
23380
|
+
contextual: ['↑/↓ files', 'enter diff', 'space stage', 'z revert', 'i ignore', 'e/c compose', 'y yank'],
|
|
23264
23381
|
global: NORMAL_GLOBAL_HINTS,
|
|
23265
23382
|
};
|
|
23266
23383
|
}
|
|
@@ -25846,6 +25963,29 @@ function applyLogInkAction(state, action) {
|
|
|
25846
25963
|
themePickerIndex: 0,
|
|
25847
25964
|
pendingKey: undefined,
|
|
25848
25965
|
};
|
|
25966
|
+
case 'openGitignorePicker':
|
|
25967
|
+
return {
|
|
25968
|
+
...state,
|
|
25969
|
+
gitignorePicker: { file: action.file, index: 0 },
|
|
25970
|
+
pendingKey: undefined,
|
|
25971
|
+
};
|
|
25972
|
+
case 'closeGitignorePicker':
|
|
25973
|
+
return {
|
|
25974
|
+
...state,
|
|
25975
|
+
gitignorePicker: undefined,
|
|
25976
|
+
pendingKey: undefined,
|
|
25977
|
+
};
|
|
25978
|
+
case 'moveGitignorePicker':
|
|
25979
|
+
return state.gitignorePicker
|
|
25980
|
+
? {
|
|
25981
|
+
...state,
|
|
25982
|
+
gitignorePicker: {
|
|
25983
|
+
...state.gitignorePicker,
|
|
25984
|
+
index: clampIndex(state.gitignorePicker.index + action.delta, action.count),
|
|
25985
|
+
},
|
|
25986
|
+
pendingKey: undefined,
|
|
25987
|
+
}
|
|
25988
|
+
: state;
|
|
25849
25989
|
case 'setChangelogLoading':
|
|
25850
25990
|
return {
|
|
25851
25991
|
...state,
|
|
@@ -26583,6 +26723,14 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
26583
26723
|
// Palette closes on execute (toggleCommandPalette runs first), then
|
|
26584
26724
|
// this opens the theme picker.
|
|
26585
26725
|
return [action({ type: 'toggleThemePicker' })];
|
|
26726
|
+
case 'openProjectConfig':
|
|
26727
|
+
return [{ type: 'openConfigInEditor', scope: 'project' }];
|
|
26728
|
+
case 'openGlobalConfig':
|
|
26729
|
+
return [{ type: 'openConfigInEditor', scope: 'global' }];
|
|
26730
|
+
case 'gitignoreFile':
|
|
26731
|
+
// Runtime resolves the cursored worktree file and opens the picker
|
|
26732
|
+
// (no-ops with a warning when there's no file under the cursor).
|
|
26733
|
+
return [{ type: 'openGitignorePicker' }];
|
|
26586
26734
|
case 'workflowDeleteBranch':
|
|
26587
26735
|
case 'workflowDeleteTag':
|
|
26588
26736
|
case 'workflowDropStash':
|
|
@@ -26676,6 +26824,12 @@ function submitInputPrompt(state) {
|
|
|
26676
26824
|
if (!value) {
|
|
26677
26825
|
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
26678
26826
|
}
|
|
26827
|
+
if (state.inputPrompt.kind === 'gitignore-pattern') {
|
|
26828
|
+
return [
|
|
26829
|
+
{ type: 'runWorkflowAction', id: 'add-to-gitignore', payload: value },
|
|
26830
|
+
action({ type: 'closeInputPrompt' }),
|
|
26831
|
+
];
|
|
26832
|
+
}
|
|
26679
26833
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
26680
26834
|
const mode = value.toLowerCase();
|
|
26681
26835
|
if (mode !== 'soft' && mode !== 'mixed' && mode !== 'hard') {
|
|
@@ -27154,6 +27308,45 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27154
27308
|
}
|
|
27155
27309
|
return [];
|
|
27156
27310
|
}
|
|
27311
|
+
if (state.gitignorePicker) {
|
|
27312
|
+
const options = deriveGitignoreOptions(state.gitignorePicker.file);
|
|
27313
|
+
if (key.escape) {
|
|
27314
|
+
return [action({ type: 'closeGitignorePicker' })];
|
|
27315
|
+
}
|
|
27316
|
+
if (key.upArrow || (key.ctrl && inputValue === 'p')) {
|
|
27317
|
+
return [action({ type: 'moveGitignorePicker', delta: -1, count: options.length })];
|
|
27318
|
+
}
|
|
27319
|
+
if (key.downArrow || (key.ctrl && inputValue === 'n')) {
|
|
27320
|
+
return [action({ type: 'moveGitignorePicker', delta: 1, count: options.length })];
|
|
27321
|
+
}
|
|
27322
|
+
if (key.return) {
|
|
27323
|
+
const selected = options[Math.max(0, Math.min(state.gitignorePicker.index, options.length - 1))];
|
|
27324
|
+
if (!selected) {
|
|
27325
|
+
return [action({ type: 'closeGitignorePicker' })];
|
|
27326
|
+
}
|
|
27327
|
+
if (selected.custom) {
|
|
27328
|
+
// Hand off to a free-text prompt seeded with the file path so
|
|
27329
|
+
// the user can type any valid gitignore pattern (negations,
|
|
27330
|
+
// globs, anchored paths) the derived options don't cover.
|
|
27331
|
+
return [
|
|
27332
|
+
action({ type: 'closeGitignorePicker' }),
|
|
27333
|
+
action({
|
|
27334
|
+
type: 'openInputPrompt',
|
|
27335
|
+
kind: 'gitignore-pattern',
|
|
27336
|
+
label: `.gitignore pattern (e.g. ${selected.pattern || '*.log'})`,
|
|
27337
|
+
initial: selected.pattern,
|
|
27338
|
+
}),
|
|
27339
|
+
];
|
|
27340
|
+
}
|
|
27341
|
+
return [
|
|
27342
|
+
action({ type: 'closeGitignorePicker' }),
|
|
27343
|
+
{ type: 'runWorkflowAction', id: 'add-to-gitignore', payload: selected.pattern },
|
|
27344
|
+
];
|
|
27345
|
+
}
|
|
27346
|
+
// Consume everything else so the underlying status view keys don't
|
|
27347
|
+
// leak through while the picker owns the screen.
|
|
27348
|
+
return [];
|
|
27349
|
+
}
|
|
27157
27350
|
if (state.showCommandPalette) {
|
|
27158
27351
|
const filtered = filterLogInkPaletteCommands(getLogInkPaletteCommands(), state.paletteFilter, state.paletteRecent);
|
|
27159
27352
|
if (key.escape) {
|
|
@@ -27431,6 +27624,20 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27431
27624
|
action({ type: 'toggleThemePicker' }),
|
|
27432
27625
|
];
|
|
27433
27626
|
}
|
|
27627
|
+
// gk — open this repo's project config (.coco.json) in $EDITOR.
|
|
27628
|
+
if (state.pendingKey === 'g' && inputValue === 'k') {
|
|
27629
|
+
return [
|
|
27630
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
27631
|
+
{ type: 'openConfigInEditor', scope: 'project' },
|
|
27632
|
+
];
|
|
27633
|
+
}
|
|
27634
|
+
// gK — open the global config (~/.config/coco/config.json) in $EDITOR.
|
|
27635
|
+
if (state.pendingKey === 'g' && inputValue === 'K') {
|
|
27636
|
+
return [
|
|
27637
|
+
action({ type: 'setPendingKey', value: undefined }),
|
|
27638
|
+
{ type: 'openConfigInEditor', scope: 'global' },
|
|
27639
|
+
];
|
|
27640
|
+
}
|
|
27434
27641
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
27435
27642
|
// 'bisect' && state.focus === 'commits'` so the single-letter keys
|
|
27436
27643
|
// stay free everywhere else. `g` and `b` collide with the global
|
|
@@ -28512,6 +28719,13 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
28512
28719
|
if (inputValue === 'o' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
|
|
28513
28720
|
return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
|
|
28514
28721
|
}
|
|
28722
|
+
// `i` opens the "add to .gitignore" picker for the cursored worktree
|
|
28723
|
+
// file. The runtime resolves the path + opens the picker (the bare
|
|
28724
|
+
// event carries no path — same selection-resolution pattern as the
|
|
28725
|
+
// revert / stage events).
|
|
28726
|
+
if (inputValue === 'i' && state.activeView === 'status' && context.worktreeFileCount && context.worktreeSelectedPath) {
|
|
28727
|
+
return [{ type: 'openGitignorePicker' }];
|
|
28728
|
+
}
|
|
28515
28729
|
if (inputValue === 'o' && state.activeView === 'diff' && state.diffSource === 'worktree' && context.worktreeSelectedPath) {
|
|
28516
28730
|
return [{ type: 'openFileInEditor', path: context.worktreeSelectedPath }];
|
|
28517
28731
|
}
|
|
@@ -30189,6 +30403,493 @@ function pullBranch(git, branch, currentBranchName) {
|
|
|
30189
30403
|
]), `Fast-forwarded ${branch.shortName} to ${branch.upstream}`);
|
|
30190
30404
|
}
|
|
30191
30405
|
|
|
30406
|
+
/**
|
|
30407
|
+
* Append a pattern to the repository's `.gitignore` (the runtime side of
|
|
30408
|
+
* the "add to .gitignore" quick-pick, `i` on the status view).
|
|
30409
|
+
*
|
|
30410
|
+
* Kept separate from the pure pattern-derivation helper
|
|
30411
|
+
* (`workstation/chrome/gitignore.ts`) because this touches the filesystem
|
|
30412
|
+
* and resolves the repo root via git — neither of which the UI layer
|
|
30413
|
+
* should pull in.
|
|
30414
|
+
*/
|
|
30415
|
+
/**
|
|
30416
|
+
* Append `pattern` to `<repoRoot>/.gitignore`, creating the file if it
|
|
30417
|
+
* doesn't exist. No-ops (reporting success) when the exact pattern is
|
|
30418
|
+
* already present so re-running is safe. Handles the missing-trailing-
|
|
30419
|
+
* newline case so we never glue the new entry onto the previous line.
|
|
30420
|
+
*/
|
|
30421
|
+
async function addToGitignore(git, pattern) {
|
|
30422
|
+
const entry = pattern.trim();
|
|
30423
|
+
if (!entry) {
|
|
30424
|
+
return { ok: false, message: 'No pattern to add.' };
|
|
30425
|
+
}
|
|
30426
|
+
let root;
|
|
30427
|
+
try {
|
|
30428
|
+
root = (await git.revparse(['--show-toplevel'])).trim();
|
|
30429
|
+
}
|
|
30430
|
+
catch {
|
|
30431
|
+
return { ok: false, message: 'Could not resolve the repository root.' };
|
|
30432
|
+
}
|
|
30433
|
+
if (!root) {
|
|
30434
|
+
return { ok: false, message: 'Could not resolve the repository root.' };
|
|
30435
|
+
}
|
|
30436
|
+
const file = path.join(root, '.gitignore');
|
|
30437
|
+
let existing = '';
|
|
30438
|
+
try {
|
|
30439
|
+
existing = await promises.readFile(file, 'utf8');
|
|
30440
|
+
}
|
|
30441
|
+
catch {
|
|
30442
|
+
// No .gitignore yet — we'll create it.
|
|
30443
|
+
existing = '';
|
|
30444
|
+
}
|
|
30445
|
+
// Already ignored (exact line match, ignoring surrounding whitespace)?
|
|
30446
|
+
const alreadyPresent = existing
|
|
30447
|
+
.split('\n')
|
|
30448
|
+
.some((line) => line.trim() === entry);
|
|
30449
|
+
if (alreadyPresent) {
|
|
30450
|
+
return { ok: true, message: `${entry} is already in .gitignore` };
|
|
30451
|
+
}
|
|
30452
|
+
const needsLeadingNewline = existing.length > 0 && !existing.endsWith('\n');
|
|
30453
|
+
const addition = `${needsLeadingNewline ? '\n' : ''}${entry}\n`;
|
|
30454
|
+
try {
|
|
30455
|
+
await promises.appendFile(file, addition, 'utf8');
|
|
30456
|
+
}
|
|
30457
|
+
catch (error) {
|
|
30458
|
+
return { ok: false, message: error.message };
|
|
30459
|
+
}
|
|
30460
|
+
return { ok: true, message: `Added ${entry} to .gitignore` };
|
|
30461
|
+
}
|
|
30462
|
+
|
|
30463
|
+
/**
|
|
30464
|
+
* Embedded tree-sitter highlight queries (one per language).
|
|
30465
|
+
*
|
|
30466
|
+
* We ship our own compact queries rather than reading the upstream
|
|
30467
|
+
* `queries/highlights.scm` from the grammar packages because those are
|
|
30468
|
+
* dev-only / not present in a published install, and they lean on
|
|
30469
|
+
* `#match?` / `#is-not? local` predicates that web-tree-sitter's
|
|
30470
|
+
* `Query.captures()` does NOT evaluate for us — including them would
|
|
30471
|
+
* mis-tag every identifier. These subsets are **predicate-free** (so
|
|
30472
|
+
* every capture is unconditionally correct) and use only grammar-valid
|
|
30473
|
+
* node/token names (so the query compiles — verified against the real
|
|
30474
|
+
* grammars), distilled from each language's upstream `highlights.scm`.
|
|
30475
|
+
*
|
|
30476
|
+
* The TS query serves both `typescript` and `tsx` (tsx is a superset).
|
|
30477
|
+
*/
|
|
30478
|
+
const TS_HIGHLIGHT_QUERY = `
|
|
30479
|
+
; Comments
|
|
30480
|
+
(comment) @comment
|
|
30481
|
+
|
|
30482
|
+
; Strings
|
|
30483
|
+
[
|
|
30484
|
+
(string)
|
|
30485
|
+
(template_string)
|
|
30486
|
+
] @string
|
|
30487
|
+
(regex) @string
|
|
30488
|
+
|
|
30489
|
+
; Numbers
|
|
30490
|
+
(number) @number
|
|
30491
|
+
|
|
30492
|
+
; Types
|
|
30493
|
+
(type_identifier) @type
|
|
30494
|
+
(predefined_type) @type
|
|
30495
|
+
|
|
30496
|
+
; Literals
|
|
30497
|
+
[
|
|
30498
|
+
(true)
|
|
30499
|
+
(false)
|
|
30500
|
+
(null)
|
|
30501
|
+
(undefined)
|
|
30502
|
+
] @constant
|
|
30503
|
+
(this) @keyword
|
|
30504
|
+
(super) @keyword
|
|
30505
|
+
|
|
30506
|
+
; Properties
|
|
30507
|
+
(property_identifier) @property
|
|
30508
|
+
|
|
30509
|
+
; Function definitions and calls (field-name patterns — no predicates)
|
|
30510
|
+
(function_declaration
|
|
30511
|
+
name: (identifier) @function)
|
|
30512
|
+
(function_expression
|
|
30513
|
+
name: (identifier) @function)
|
|
30514
|
+
(method_definition
|
|
30515
|
+
name: (property_identifier) @function)
|
|
30516
|
+
(call_expression
|
|
30517
|
+
function: (identifier) @function)
|
|
30518
|
+
(call_expression
|
|
30519
|
+
function: (member_expression
|
|
30520
|
+
property: (property_identifier) @function))
|
|
30521
|
+
(variable_declarator
|
|
30522
|
+
name: (identifier) @function
|
|
30523
|
+
value: [(function_expression) (arrow_function)])
|
|
30524
|
+
|
|
30525
|
+
; Keywords (anonymous tokens — all valid in the TS/TSX grammars)
|
|
30526
|
+
[
|
|
30527
|
+
"abstract"
|
|
30528
|
+
"declare"
|
|
30529
|
+
"enum"
|
|
30530
|
+
"implements"
|
|
30531
|
+
"interface"
|
|
30532
|
+
"keyof"
|
|
30533
|
+
"namespace"
|
|
30534
|
+
"private"
|
|
30535
|
+
"protected"
|
|
30536
|
+
"public"
|
|
30537
|
+
"type"
|
|
30538
|
+
"readonly"
|
|
30539
|
+
"override"
|
|
30540
|
+
"satisfies"
|
|
30541
|
+
"as"
|
|
30542
|
+
"async"
|
|
30543
|
+
"await"
|
|
30544
|
+
"break"
|
|
30545
|
+
"case"
|
|
30546
|
+
"catch"
|
|
30547
|
+
"class"
|
|
30548
|
+
"const"
|
|
30549
|
+
"continue"
|
|
30550
|
+
"debugger"
|
|
30551
|
+
"default"
|
|
30552
|
+
"delete"
|
|
30553
|
+
"do"
|
|
30554
|
+
"else"
|
|
30555
|
+
"export"
|
|
30556
|
+
"extends"
|
|
30557
|
+
"finally"
|
|
30558
|
+
"for"
|
|
30559
|
+
"from"
|
|
30560
|
+
"function"
|
|
30561
|
+
"get"
|
|
30562
|
+
"if"
|
|
30563
|
+
"import"
|
|
30564
|
+
"in"
|
|
30565
|
+
"instanceof"
|
|
30566
|
+
"let"
|
|
30567
|
+
"new"
|
|
30568
|
+
"of"
|
|
30569
|
+
"return"
|
|
30570
|
+
"set"
|
|
30571
|
+
"static"
|
|
30572
|
+
"switch"
|
|
30573
|
+
"throw"
|
|
30574
|
+
"try"
|
|
30575
|
+
"typeof"
|
|
30576
|
+
"var"
|
|
30577
|
+
"void"
|
|
30578
|
+
"while"
|
|
30579
|
+
"with"
|
|
30580
|
+
"yield"
|
|
30581
|
+
] @keyword
|
|
30582
|
+
`.trim();
|
|
30583
|
+
/** Python (validated against tree-sitter-python 0.23.6). */
|
|
30584
|
+
const PYTHON_HIGHLIGHT_QUERY = `
|
|
30585
|
+
(comment) @comment
|
|
30586
|
+
(string) @string
|
|
30587
|
+
(integer) @number
|
|
30588
|
+
(float) @number
|
|
30589
|
+
(type) @type
|
|
30590
|
+
(function_definition
|
|
30591
|
+
name: (identifier) @function)
|
|
30592
|
+
(class_definition
|
|
30593
|
+
name: (identifier) @type)
|
|
30594
|
+
(call
|
|
30595
|
+
function: (identifier) @function)
|
|
30596
|
+
[ (true) (false) (none) ] @constant
|
|
30597
|
+
[
|
|
30598
|
+
"def" "class" "return" "pass" "if" "elif" "else" "for" "while"
|
|
30599
|
+
"import" "from" "as" "with" "try" "except" "finally" "raise"
|
|
30600
|
+
"lambda" "yield" "global" "nonlocal" "assert" "del" "in" "not"
|
|
30601
|
+
"and" "or" "is" "await" "async"
|
|
30602
|
+
] @keyword
|
|
30603
|
+
`.trim();
|
|
30604
|
+
/** Rust (validated against tree-sitter-rust 0.24.0). */
|
|
30605
|
+
const RUST_HIGHLIGHT_QUERY = `
|
|
30606
|
+
[ (line_comment) (block_comment) ] @comment
|
|
30607
|
+
[ (string_literal) (char_literal) (raw_string_literal) ] @string
|
|
30608
|
+
(integer_literal) @number
|
|
30609
|
+
(float_literal) @number
|
|
30610
|
+
[ (primitive_type) (type_identifier) ] @type
|
|
30611
|
+
(function_item
|
|
30612
|
+
name: (identifier) @function)
|
|
30613
|
+
(call_expression
|
|
30614
|
+
function: (identifier) @function)
|
|
30615
|
+
(boolean_literal) @constant
|
|
30616
|
+
[
|
|
30617
|
+
"fn" "let" "const" "static" "if" "else" "match" "for" "while"
|
|
30618
|
+
"loop" "return" "break" "continue" "struct" "enum" "trait" "impl"
|
|
30619
|
+
"use" "mod" "pub" "as" "where" "in" "unsafe" "async" "await"
|
|
30620
|
+
"dyn" "type"
|
|
30621
|
+
] @keyword
|
|
30622
|
+
`.trim();
|
|
30623
|
+
/** Go (validated against tree-sitter-go 0.25.0). */
|
|
30624
|
+
const GO_HIGHLIGHT_QUERY = `
|
|
30625
|
+
(comment) @comment
|
|
30626
|
+
[ (interpreted_string_literal) (raw_string_literal) (rune_literal) ] @string
|
|
30627
|
+
(int_literal) @number
|
|
30628
|
+
(float_literal) @number
|
|
30629
|
+
(type_identifier) @type
|
|
30630
|
+
(function_declaration
|
|
30631
|
+
name: (identifier) @function)
|
|
30632
|
+
(method_declaration
|
|
30633
|
+
name: (field_identifier) @function)
|
|
30634
|
+
(call_expression
|
|
30635
|
+
function: (identifier) @function)
|
|
30636
|
+
(call_expression
|
|
30637
|
+
function: (selector_expression
|
|
30638
|
+
field: (field_identifier) @function))
|
|
30639
|
+
[ (true) (false) (nil) (iota) ] @constant
|
|
30640
|
+
[
|
|
30641
|
+
"func" "var" "const" "type" "struct" "interface" "map" "chan"
|
|
30642
|
+
"package" "import" "return" "if" "else" "for" "range" "switch"
|
|
30643
|
+
"case" "default" "break" "continue" "go" "defer" "select"
|
|
30644
|
+
"fallthrough" "goto"
|
|
30645
|
+
] @keyword
|
|
30646
|
+
`.trim();
|
|
30647
|
+
/** Highlight query keyed by tree-sitter language id. */
|
|
30648
|
+
const HIGHLIGHT_QUERIES = {
|
|
30649
|
+
typescript: TS_HIGHLIGHT_QUERY,
|
|
30650
|
+
tsx: TS_HIGHLIGHT_QUERY,
|
|
30651
|
+
python: PYTHON_HIGHLIGHT_QUERY,
|
|
30652
|
+
rust: RUST_HIGHLIGHT_QUERY,
|
|
30653
|
+
go: GO_HIGHLIGHT_QUERY,
|
|
30654
|
+
};
|
|
30655
|
+
|
|
30656
|
+
/**
|
|
30657
|
+
* Map a tree-sitter capture name to a normalized token type. Captures
|
|
30658
|
+
* are dotted (`a.b.c`); we key off the leading segment and fold the rest
|
|
30659
|
+
* in. Anything we don't have a color for collapses to `plain` (rendered
|
|
30660
|
+
* in the default foreground), so unmapped captures degrade gracefully.
|
|
30661
|
+
*/
|
|
30662
|
+
function captureToToken(capture) {
|
|
30663
|
+
const base = capture.split('.')[0];
|
|
30664
|
+
switch (base) {
|
|
30665
|
+
case 'keyword':
|
|
30666
|
+
return 'keyword';
|
|
30667
|
+
case 'string':
|
|
30668
|
+
return 'string';
|
|
30669
|
+
case 'comment':
|
|
30670
|
+
return 'comment';
|
|
30671
|
+
case 'number':
|
|
30672
|
+
return 'number';
|
|
30673
|
+
case 'type':
|
|
30674
|
+
return 'type';
|
|
30675
|
+
case 'function':
|
|
30676
|
+
case 'method':
|
|
30677
|
+
case 'constructor':
|
|
30678
|
+
return 'function';
|
|
30679
|
+
case 'property':
|
|
30680
|
+
return 'property';
|
|
30681
|
+
case 'constant':
|
|
30682
|
+
return 'constant';
|
|
30683
|
+
default:
|
|
30684
|
+
return 'plain';
|
|
30685
|
+
}
|
|
30686
|
+
}
|
|
30687
|
+
|
|
30688
|
+
/**
|
|
30689
|
+
* Tree-sitter syntax highlighter for the diff view.
|
|
30690
|
+
*
|
|
30691
|
+
* Reuses the existing tree-sitter runtime (`getTreeSitterParser`) — same
|
|
30692
|
+
* lazy init, same bundled `typescript` / `tsx` grammars (offline-safe),
|
|
30693
|
+
* same "return undefined when .wasm is unavailable" contract. On top of
|
|
30694
|
+
* that we build one `Query` per language from our embedded highlight
|
|
30695
|
+
* query and tokenize code **per line**.
|
|
30696
|
+
*
|
|
30697
|
+
* Per-line (rather than whole-file) tokenization keeps this uniform
|
|
30698
|
+
* across every diff source — stash / compare / commit / worktree all
|
|
30699
|
+
* hand us diff lines, never reconstructed files — and tree-sitter's
|
|
30700
|
+
* error tolerance means a single statement still yields good captures.
|
|
30701
|
+
* Results are cached by (language, line) so re-renders and repeated
|
|
30702
|
+
* lines are free.
|
|
30703
|
+
*
|
|
30704
|
+
* Everything degrades to "no spans" (the caller renders the plain
|
|
30705
|
+
* single-color line): missing grammar, query compile failure, parse
|
|
30706
|
+
* error, non-ASCII text (byte/char offset skew), or an over-long line.
|
|
30707
|
+
*/
|
|
30708
|
+
// Longest extension first so `.d.ts` / `.mts` win over `.ts`.
|
|
30709
|
+
//
|
|
30710
|
+
// NOTE: typescript + tsx grammars are bundled (offline). python / rust /
|
|
30711
|
+
// go grammars download on demand and are only available once cached
|
|
30712
|
+
// (prefetched) — `getTreeSitterParser` returns undefined otherwise, so
|
|
30713
|
+
// those diffs render plain until the grammar is present. Same
|
|
30714
|
+
// availability model the structural parsers already use.
|
|
30715
|
+
const EXT_LANGUAGE = [
|
|
30716
|
+
['.tsx', 'tsx'],
|
|
30717
|
+
['.jsx', 'tsx'],
|
|
30718
|
+
['.mts', 'typescript'],
|
|
30719
|
+
['.cts', 'typescript'],
|
|
30720
|
+
['.mjs', 'tsx'],
|
|
30721
|
+
['.cjs', 'tsx'],
|
|
30722
|
+
['.ts', 'typescript'],
|
|
30723
|
+
['.js', 'tsx'],
|
|
30724
|
+
['.py', 'python'],
|
|
30725
|
+
['.pyi', 'python'],
|
|
30726
|
+
['.rs', 'rust'],
|
|
30727
|
+
['.go', 'go'],
|
|
30728
|
+
];
|
|
30729
|
+
/** Map a file path to a highlight language, or undefined when unsupported. */
|
|
30730
|
+
function detectSyntaxLanguage(filePath) {
|
|
30731
|
+
const lower = filePath.toLowerCase();
|
|
30732
|
+
for (const [ext, language] of EXT_LANGUAGE) {
|
|
30733
|
+
if (lower.endsWith(ext))
|
|
30734
|
+
return language;
|
|
30735
|
+
}
|
|
30736
|
+
return undefined;
|
|
30737
|
+
}
|
|
30738
|
+
// Printable ASCII (+ tab). Outside this range tree-sitter's byte offsets
|
|
30739
|
+
// diverge from JS string char offsets, which would misalign spans — so
|
|
30740
|
+
// we skip those lines entirely (rendered plain).
|
|
30741
|
+
const ASCII_ONLY = /^[\t\x20-\x7E]*$/;
|
|
30742
|
+
const MAX_LINE_LENGTH = 2000;
|
|
30743
|
+
// `null` marks "tried and failed" so we don't retry the grammar/query
|
|
30744
|
+
// load on every line.
|
|
30745
|
+
const queryCache = new Map();
|
|
30746
|
+
const spanCache = new Map();
|
|
30747
|
+
async function getQuery(language) {
|
|
30748
|
+
if (queryCache.has(language)) {
|
|
30749
|
+
return queryCache.get(language) ?? undefined;
|
|
30750
|
+
}
|
|
30751
|
+
const querySource = HIGHLIGHT_QUERIES[language];
|
|
30752
|
+
const loaded = querySource ? await getTreeSitterParser(language) : undefined;
|
|
30753
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30754
|
+
const grammar = loaded?.parser?.language;
|
|
30755
|
+
if (!loaded || !grammar || !querySource) {
|
|
30756
|
+
queryCache.set(language, null);
|
|
30757
|
+
return undefined;
|
|
30758
|
+
}
|
|
30759
|
+
try {
|
|
30760
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30761
|
+
const mod = (await import('web-tree-sitter'));
|
|
30762
|
+
const QueryCtor = mod.Query;
|
|
30763
|
+
const query = new QueryCtor(grammar, querySource);
|
|
30764
|
+
queryCache.set(language, query);
|
|
30765
|
+
return query;
|
|
30766
|
+
}
|
|
30767
|
+
catch {
|
|
30768
|
+
queryCache.set(language, null);
|
|
30769
|
+
return undefined;
|
|
30770
|
+
}
|
|
30771
|
+
}
|
|
30772
|
+
function buildSpans(text, captures) {
|
|
30773
|
+
const n = text.length;
|
|
30774
|
+
const charType = new Array(n);
|
|
30775
|
+
// Paint widest captures first so narrower/inner captures override them
|
|
30776
|
+
// — yields clean, non-overlapping spans without nesting bookkeeping.
|
|
30777
|
+
const ordered = [...captures].sort((a, b) => (b.node.endIndex - b.node.startIndex) - (a.node.endIndex - a.node.startIndex));
|
|
30778
|
+
for (const capture of ordered) {
|
|
30779
|
+
const token = captureToToken(capture.name);
|
|
30780
|
+
if (token === 'plain')
|
|
30781
|
+
continue;
|
|
30782
|
+
const start = Math.max(0, capture.node.startIndex);
|
|
30783
|
+
const end = Math.min(n, capture.node.endIndex);
|
|
30784
|
+
for (let i = start; i < end; i++)
|
|
30785
|
+
charType[i] = token;
|
|
30786
|
+
}
|
|
30787
|
+
// Coalesce runs of equal token type (gaps → 'plain').
|
|
30788
|
+
const spans = [];
|
|
30789
|
+
let i = 0;
|
|
30790
|
+
while (i < n) {
|
|
30791
|
+
const token = charType[i] ?? 'plain';
|
|
30792
|
+
let j = i + 1;
|
|
30793
|
+
while (j < n && (charType[j] ?? 'plain') === token)
|
|
30794
|
+
j++;
|
|
30795
|
+
spans.push({ start: i, end: j, token });
|
|
30796
|
+
i = j;
|
|
30797
|
+
}
|
|
30798
|
+
return spans;
|
|
30799
|
+
}
|
|
30800
|
+
/**
|
|
30801
|
+
* Tokenize a single line of code into non-overlapping spans covering the
|
|
30802
|
+
* whole string (plain runs included). Returns `[]` when the line can't
|
|
30803
|
+
* be highlighted (no grammar, parse error, non-ASCII, too long) so the
|
|
30804
|
+
* caller falls back to its plain single-color rendering.
|
|
30805
|
+
*/
|
|
30806
|
+
async function highlightLine(language, text) {
|
|
30807
|
+
if (!text)
|
|
30808
|
+
return [];
|
|
30809
|
+
if (text.length > MAX_LINE_LENGTH || !ASCII_ONLY.test(text))
|
|
30810
|
+
return [];
|
|
30811
|
+
const key = `${language}${text}`;
|
|
30812
|
+
const cached = spanCache.get(key);
|
|
30813
|
+
if (cached)
|
|
30814
|
+
return cached;
|
|
30815
|
+
const query = await getQuery(language);
|
|
30816
|
+
const loaded = await getTreeSitterParser(language);
|
|
30817
|
+
if (!query || !loaded) {
|
|
30818
|
+
spanCache.set(key, []);
|
|
30819
|
+
return [];
|
|
30820
|
+
}
|
|
30821
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30822
|
+
let tree;
|
|
30823
|
+
try {
|
|
30824
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30825
|
+
tree = loaded.parser.parse(text);
|
|
30826
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30827
|
+
const captures = query.captures(tree.rootNode);
|
|
30828
|
+
const spans = buildSpans(text, captures);
|
|
30829
|
+
spanCache.set(key, spans);
|
|
30830
|
+
return spans;
|
|
30831
|
+
}
|
|
30832
|
+
catch {
|
|
30833
|
+
spanCache.set(key, []);
|
|
30834
|
+
return [];
|
|
30835
|
+
}
|
|
30836
|
+
finally {
|
|
30837
|
+
tree?.delete?.();
|
|
30838
|
+
}
|
|
30839
|
+
}
|
|
30840
|
+
/**
|
|
30841
|
+
* Tokenize the code lines of a unified diff. Strips the leading
|
|
30842
|
+
* `+` / `-` / ` ` marker before highlighting (headers and `@@` lines are
|
|
30843
|
+
* skipped — they're not code), and returns a map keyed by the
|
|
30844
|
+
* marker-stripped line text so the renderer can look spans up directly.
|
|
30845
|
+
* Lines that yield no spans are omitted (renderer falls back to plain).
|
|
30846
|
+
*/
|
|
30847
|
+
/**
|
|
30848
|
+
* Pick the unique, marker-stripped code lines out of a unified diff:
|
|
30849
|
+
* only `+` / `-` / ` ` rows are code; `diff`/`index`/`@@`/`+++`/`---`
|
|
30850
|
+
* headers are skipped. Pure + exported so the selection is testable
|
|
30851
|
+
* without a grammar.
|
|
30852
|
+
*/
|
|
30853
|
+
function selectDiffCodeLines(lines) {
|
|
30854
|
+
const seen = new Set();
|
|
30855
|
+
// Hunk-aware: a `+`/`-`/` ` line is code only INSIDE a hunk. This is
|
|
30856
|
+
// what distinguishes a real added line from the `+++ b/file` /
|
|
30857
|
+
// `--- a/file` file headers (which precede the first `@@` and also
|
|
30858
|
+
// start with `+`/`-`). Same stateful rule the split-diff parser uses.
|
|
30859
|
+
let inHunk = false;
|
|
30860
|
+
for (const line of lines) {
|
|
30861
|
+
if (!line)
|
|
30862
|
+
continue;
|
|
30863
|
+
if (line.startsWith('@@')) {
|
|
30864
|
+
inHunk = true;
|
|
30865
|
+
continue;
|
|
30866
|
+
}
|
|
30867
|
+
if (!inHunk)
|
|
30868
|
+
continue;
|
|
30869
|
+
const marker = line[0];
|
|
30870
|
+
if (marker !== '+' && marker !== '-' && marker !== ' ') {
|
|
30871
|
+
// A non-diff line (blank-separator label, next file's `diff --git`)
|
|
30872
|
+
// ends the current hunk until the next `@@`.
|
|
30873
|
+
inHunk = false;
|
|
30874
|
+
continue;
|
|
30875
|
+
}
|
|
30876
|
+
seen.add(line.slice(1));
|
|
30877
|
+
}
|
|
30878
|
+
return [...seen];
|
|
30879
|
+
}
|
|
30880
|
+
async function highlightDiffCode(filePath, lines) {
|
|
30881
|
+
const result = new Map();
|
|
30882
|
+
const language = detectSyntaxLanguage(filePath);
|
|
30883
|
+
if (!language)
|
|
30884
|
+
return result;
|
|
30885
|
+
for (const code of selectDiffCodeLines(lines)) {
|
|
30886
|
+
const spans = await highlightLine(language, code);
|
|
30887
|
+
if (spans.length)
|
|
30888
|
+
result.set(code, spans);
|
|
30889
|
+
}
|
|
30890
|
+
return result;
|
|
30891
|
+
}
|
|
30892
|
+
|
|
30192
30893
|
async function runAction$4(action, successMessage) {
|
|
30193
30894
|
try {
|
|
30194
30895
|
await action();
|
|
@@ -33910,11 +34611,50 @@ function flushChangeBlock(removals, additions, rows) {
|
|
|
33910
34611
|
removals.length = 0;
|
|
33911
34612
|
additions.length = 0;
|
|
33912
34613
|
}
|
|
33913
|
-
|
|
33914
|
-
|
|
34614
|
+
/**
|
|
34615
|
+
* Replay the hunk parser over `unifiedLines[0..upTo)` (exclusive) and
|
|
34616
|
+
* return the parse state at that boundary. Used by the split renderer
|
|
34617
|
+
* to seed `buildSplitDiffRows` with the correct in-hunk flag and
|
|
34618
|
+
* line-number cursors when it windows the diff to a scroll offset that
|
|
34619
|
+
* starts partway through a hunk. Counting mirrors `buildSplitDiffRows`
|
|
34620
|
+
* exactly so the seeded line numbers stay continuous across the cut.
|
|
34621
|
+
*/
|
|
34622
|
+
function computeDiffContext(unifiedLines, upTo) {
|
|
33915
34623
|
let oldLineNo = 0;
|
|
33916
34624
|
let newLineNo = 0;
|
|
33917
34625
|
let inHunk = false;
|
|
34626
|
+
const bound = Math.max(0, Math.min(upTo, unifiedLines.length));
|
|
34627
|
+
for (let i = 0; i < bound; i++) {
|
|
34628
|
+
const raw = unifiedLines[i];
|
|
34629
|
+
if (raw.startsWith('@@')) {
|
|
34630
|
+
const [oldStart, newStart] = parseHunkHeader(raw);
|
|
34631
|
+
oldLineNo = oldStart;
|
|
34632
|
+
newLineNo = newStart;
|
|
34633
|
+
inHunk = true;
|
|
34634
|
+
continue;
|
|
34635
|
+
}
|
|
34636
|
+
if (!inHunk || isDiffHeader(raw)) {
|
|
34637
|
+
continue;
|
|
34638
|
+
}
|
|
34639
|
+
if (raw.startsWith('-')) {
|
|
34640
|
+
oldLineNo += 1;
|
|
34641
|
+
continue;
|
|
34642
|
+
}
|
|
34643
|
+
if (raw.startsWith('+')) {
|
|
34644
|
+
newLineNo += 1;
|
|
34645
|
+
continue;
|
|
34646
|
+
}
|
|
34647
|
+
// Context line (or `\ No newline` marker) advances both cursors.
|
|
34648
|
+
oldLineNo += 1;
|
|
34649
|
+
newLineNo += 1;
|
|
34650
|
+
}
|
|
34651
|
+
return { inHunk, oldLineNo, newLineNo };
|
|
34652
|
+
}
|
|
34653
|
+
function buildSplitDiffRows(unifiedLines, seed) {
|
|
34654
|
+
const rows = [];
|
|
34655
|
+
let oldLineNo = seed?.oldLineNo ?? 0;
|
|
34656
|
+
let newLineNo = seed?.newLineNo ?? 0;
|
|
34657
|
+
let inHunk = seed?.inHunk ?? false;
|
|
33918
34658
|
const removals = [];
|
|
33919
34659
|
const additions = [];
|
|
33920
34660
|
const flushHeader = (text) => {
|
|
@@ -33975,6 +34715,32 @@ function buildSplitDiffRows(unifiedLines) {
|
|
|
33975
34715
|
return rows;
|
|
33976
34716
|
}
|
|
33977
34717
|
|
|
34718
|
+
function resolveSyntaxColor(token, theme) {
|
|
34719
|
+
if (theme.noColor)
|
|
34720
|
+
return undefined;
|
|
34721
|
+
const c = theme.colors;
|
|
34722
|
+
switch (token) {
|
|
34723
|
+
case 'keyword':
|
|
34724
|
+
return c.syntaxKeyword ?? 'magenta';
|
|
34725
|
+
case 'string':
|
|
34726
|
+
return c.syntaxString ?? 'green';
|
|
34727
|
+
case 'comment':
|
|
34728
|
+
return c.syntaxComment ?? 'gray';
|
|
34729
|
+
case 'number':
|
|
34730
|
+
return c.syntaxNumber ?? 'yellow';
|
|
34731
|
+
case 'type':
|
|
34732
|
+
return c.syntaxType ?? 'cyan';
|
|
34733
|
+
case 'function':
|
|
34734
|
+
return c.syntaxFunction ?? 'blue';
|
|
34735
|
+
case 'constant':
|
|
34736
|
+
return c.syntaxConstant ?? 'yellow';
|
|
34737
|
+
case 'property':
|
|
34738
|
+
return c.syntaxProperty ?? undefined;
|
|
34739
|
+
default:
|
|
34740
|
+
return undefined;
|
|
34741
|
+
}
|
|
34742
|
+
}
|
|
34743
|
+
|
|
33978
34744
|
/**
|
|
33979
34745
|
* Split-diff rendering helpers (#785) — shared between the diff
|
|
33980
34746
|
* surface and any future surface that wants side-by-side diff layout.
|
|
@@ -34046,30 +34812,107 @@ function formatSplitDiffCell(side, columnWidth) {
|
|
|
34046
34812
|
return `${lineNo} ${truncateCells(text, textRoom)}`.padEnd(columnWidth);
|
|
34047
34813
|
}
|
|
34048
34814
|
/**
|
|
34049
|
-
* Render
|
|
34050
|
-
*
|
|
34051
|
-
*
|
|
34815
|
+
* Render one split-diff column as an Ink node — syntax-highlighted when
|
|
34816
|
+
* spans are available for the line, plain otherwise.
|
|
34817
|
+
*
|
|
34818
|
+
* Highlighted cells keep the 4-digit line-number gutter but color IT
|
|
34819
|
+
* with the add/remove cue (green/red, dim for context) so the code body
|
|
34820
|
+
* is free to carry its syntax colors — the split layout's position
|
|
34821
|
+
* (old | new) plus the colored gutter still tells you what changed.
|
|
34822
|
+
* Width is budgeted exactly like `formatSplitDiffCell` (gutter + 1 space
|
|
34823
|
+
* + truncated code) so columns never drift.
|
|
34824
|
+
*/
|
|
34825
|
+
function renderSplitDiffCell(h, Text, side, columnWidth, theme, syntaxSpans, key) {
|
|
34826
|
+
const text = side.text.replace(/\n$/, '');
|
|
34827
|
+
const spans = side.kind === 'add' || side.kind === 'remove' || side.kind === 'context'
|
|
34828
|
+
? syntaxSpans?.get(text)
|
|
34829
|
+
: undefined;
|
|
34830
|
+
if (!spans || spans.length === 0) {
|
|
34831
|
+
return h(Text, { key, ...splitDiffSideProps(side.kind, theme) }, formatSplitDiffCell(side, columnWidth));
|
|
34832
|
+
}
|
|
34833
|
+
const lineNo = side.lineNumber !== undefined ? String(side.lineNumber).padStart(4) : ' ';
|
|
34834
|
+
const textRoom = Math.max(1, columnWidth - 5);
|
|
34835
|
+
const gutterColor = side.kind === 'add'
|
|
34836
|
+
? theme.colors.gitAdded
|
|
34837
|
+
: side.kind === 'remove'
|
|
34838
|
+
? theme.colors.gitDeleted
|
|
34839
|
+
: undefined;
|
|
34840
|
+
const children = [];
|
|
34841
|
+
let used = 0;
|
|
34842
|
+
for (const span of spans) {
|
|
34843
|
+
if (used >= textRoom)
|
|
34844
|
+
break;
|
|
34845
|
+
const segment = truncateCells(text.slice(span.start, span.end), textRoom - used);
|
|
34846
|
+
if (!segment)
|
|
34847
|
+
continue;
|
|
34848
|
+
used += cellWidth(segment);
|
|
34849
|
+
children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
|
|
34850
|
+
}
|
|
34851
|
+
return h(Text, { key }, h(Text, { key: `${key}-g`, color: gutterColor, dimColor: !gutterColor }, `${lineNo} `), ...children);
|
|
34852
|
+
}
|
|
34853
|
+
/**
|
|
34854
|
+
* Render the split-diff body as a list of two-column rows.
|
|
34855
|
+
*
|
|
34856
|
+
* Takes the FULL unified-line array plus the scroll offset + visible
|
|
34857
|
+
* row budget, and windows it internally. The windowing has to live
|
|
34858
|
+
* here (not the caller) because the parser is stateful: a window that
|
|
34859
|
+
* starts partway through a hunk needs the hunk context (in-hunk flag +
|
|
34860
|
+
* line-number cursors) that precedes it, or every visible line gets
|
|
34861
|
+
* misclassified as a header and painted in the accent color (#1114).
|
|
34862
|
+
* We compute that context from the lines before the window and seed
|
|
34863
|
+
* the parser with it.
|
|
34052
34864
|
*/
|
|
34053
|
-
function renderSplitDiffBody(h, components,
|
|
34865
|
+
function renderSplitDiffBody(h, components, unifiedLines, startOffset, visibleRows, width, theme, keyPrefix, syntaxSpans) {
|
|
34054
34866
|
const { Box, Text } = components;
|
|
34055
|
-
const
|
|
34867
|
+
const seed = computeDiffContext(unifiedLines, startOffset);
|
|
34868
|
+
const unifiedSlice = unifiedLines.slice(startOffset, startOffset + visibleRows);
|
|
34869
|
+
const rows = buildSplitDiffRows(unifiedSlice, seed);
|
|
34056
34870
|
// Reserve 3 columns of gutter (1 left padding from the Box + 1 column
|
|
34057
34871
|
// separator + 1 right padding) so neither side touches the border.
|
|
34058
34872
|
const usable = Math.max(20, width - 4);
|
|
34059
34873
|
const gutter = 1;
|
|
34060
34874
|
const half = Math.max(10, Math.floor((usable - gutter) / 2));
|
|
34061
34875
|
return rows.map((row, index) => {
|
|
34062
|
-
const
|
|
34063
|
-
const rightProps = splitDiffSideProps(row.right.kind, theme);
|
|
34064
|
-
const leftText = formatSplitDiffCell(row.left, half);
|
|
34065
|
-
const rightText = formatSplitDiffCell(row.right, half);
|
|
34876
|
+
const rowKey = `${keyPrefix}-${startOffset + index}`;
|
|
34066
34877
|
return h(Box, {
|
|
34067
|
-
key:
|
|
34878
|
+
key: rowKey,
|
|
34068
34879
|
flexDirection: 'row',
|
|
34069
|
-
}, h(Box, { width: half, flexShrink: 0 }, h
|
|
34880
|
+
}, h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.left, half, theme, syntaxSpans, `${rowKey}-l`)), h(Box, { width: gutter, flexShrink: 0 }, h(Text, { dimColor: true }, ' ')), h(Box, { width: half, flexShrink: 0 }, renderSplitDiffCell(h, Text, row.right, half, theme, syntaxSpans, `${rowKey}-r`)));
|
|
34070
34881
|
});
|
|
34071
34882
|
}
|
|
34072
34883
|
|
|
34884
|
+
/**
|
|
34885
|
+
* @param syntaxSpans map of marker-stripped code line → token spans
|
|
34886
|
+
* (from `highlightDiffCode`), or undefined when highlighting is off.
|
|
34887
|
+
* @param maxCells total cell budget for the whole line (marker + code).
|
|
34888
|
+
*/
|
|
34889
|
+
function renderDiffLine(h, Text, line, theme, syntaxSpans, maxCells, key) {
|
|
34890
|
+
const spans = line ? syntaxSpans?.get(line.slice(1)) : undefined;
|
|
34891
|
+
if (!spans || spans.length === 0) {
|
|
34892
|
+
return h(Text, { key, ...diffLineProps(line, theme) }, truncateCells(line, maxCells));
|
|
34893
|
+
}
|
|
34894
|
+
const marker = line[0];
|
|
34895
|
+
const markerColor = marker === '+'
|
|
34896
|
+
? theme.colors.gitAdded
|
|
34897
|
+
: marker === '-'
|
|
34898
|
+
? theme.colors.gitDeleted
|
|
34899
|
+
: undefined;
|
|
34900
|
+
const code = line.slice(1);
|
|
34901
|
+
const budget = Math.max(0, maxCells - 1); // reserve one cell for the marker
|
|
34902
|
+
const children = [];
|
|
34903
|
+
let used = 0;
|
|
34904
|
+
for (const span of spans) {
|
|
34905
|
+
if (used >= budget)
|
|
34906
|
+
break;
|
|
34907
|
+
const segment = truncateCells(code.slice(span.start, span.end), budget - used);
|
|
34908
|
+
if (!segment)
|
|
34909
|
+
continue;
|
|
34910
|
+
used += cellWidth(segment);
|
|
34911
|
+
children.push(h(Text, { key: `${key}-s${span.start}`, color: resolveSyntaxColor(span.token, theme) }, segment));
|
|
34912
|
+
}
|
|
34913
|
+
return h(Text, { key }, h(Text, { key: `${key}-m`, color: markerColor }, marker), ...children);
|
|
34914
|
+
}
|
|
34915
|
+
|
|
34073
34916
|
/**
|
|
34074
34917
|
* Diff surface — the unified or side-by-side diff view. Four sources
|
|
34075
34918
|
* route through here, disambiguated by `state.diffSource`:
|
|
@@ -34092,7 +34935,7 @@ function renderSplitDiffBody(h, components, unifiedSlice, startOffset, width, th
|
|
|
34092
34935
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.4
|
|
34093
34936
|
* of #890. No behavior change.
|
|
34094
34937
|
*/
|
|
34095
|
-
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme) {
|
|
34938
|
+
function renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans) {
|
|
34096
34939
|
const { Box, Text } = components;
|
|
34097
34940
|
const focused = state.focus === 'commits';
|
|
34098
34941
|
const worktree = context.worktree;
|
|
@@ -34146,7 +34989,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34146
34989
|
const stashBodyNodes = stashDiffLoading || !lines.length
|
|
34147
34990
|
? []
|
|
34148
34991
|
: splitActive
|
|
34149
|
-
? renderSplitDiffBody(h, components,
|
|
34992
|
+
? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'stash-diff-split', syntaxSpans)
|
|
34150
34993
|
: visibleLines.map((line, index) => {
|
|
34151
34994
|
const absoluteIndex = state.diffPreviewOffset + index;
|
|
34152
34995
|
const headerFile = stashFileByStartLine.get(absoluteIndex);
|
|
@@ -34183,10 +35026,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34183
35026
|
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
34184
35027
|
})());
|
|
34185
35028
|
}
|
|
34186
|
-
return h
|
|
34187
|
-
key: `stash-diff-line-${absoluteIndex}`,
|
|
34188
|
-
...diffLineProps(line, theme),
|
|
34189
|
-
}, truncateCells(line, width - 4));
|
|
35029
|
+
return renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `stash-diff-line-${absoluteIndex}`);
|
|
34190
35030
|
});
|
|
34191
35031
|
return h(Box, {
|
|
34192
35032
|
borderColor: focusBorderColor(theme, focused),
|
|
@@ -34227,11 +35067,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34227
35067
|
const compareBodyNodes = compareDiffLoading || !lines.length || (lines.length === 1 && !lines[0])
|
|
34228
35068
|
? []
|
|
34229
35069
|
: splitActive
|
|
34230
|
-
? renderSplitDiffBody(h, components,
|
|
34231
|
-
: visibleLines.map((line, index) => h
|
|
34232
|
-
key: `compare-diff-line-${state.diffPreviewOffset + index}`,
|
|
34233
|
-
...diffLineProps(line, theme),
|
|
34234
|
-
}, truncateCells(line, width - 4)));
|
|
35070
|
+
? renderSplitDiffBody(h, components, lines, state.diffPreviewOffset, visibleRows, width, theme, 'compare-diff-split', syntaxSpans)
|
|
35071
|
+
: visibleLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, width - 4, `compare-diff-line-${state.diffPreviewOffset + index}`));
|
|
34235
35072
|
return h(Box, {
|
|
34236
35073
|
borderColor: focusBorderColor(theme, focused),
|
|
34237
35074
|
borderStyle: theme.borderStyle,
|
|
@@ -34281,11 +35118,8 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34281
35118
|
const commitBodyNodes = filePreviewLoading || !previewHunks.length
|
|
34282
35119
|
? []
|
|
34283
35120
|
: splitActive
|
|
34284
|
-
? renderSplitDiffBody(h, components,
|
|
34285
|
-
: visiblePreviewHunks.map((line, index) => h
|
|
34286
|
-
key: `diff-surface-line-${state.diffPreviewOffset + index}`,
|
|
34287
|
-
...diffLineProps(line, theme),
|
|
34288
|
-
}, truncateCells(line, 140)));
|
|
35121
|
+
? renderSplitDiffBody(h, components, previewHunks, state.diffPreviewOffset, visibleRows, width, theme, 'commit-diff-split', syntaxSpans)
|
|
35122
|
+
: visiblePreviewHunks.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.diffPreviewOffset + index}`));
|
|
34289
35123
|
return h(Box, {
|
|
34290
35124
|
borderColor: focusBorderColor(theme, focused),
|
|
34291
35125
|
borderStyle: theme.borderStyle,
|
|
@@ -34331,10 +35165,7 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
34331
35165
|
key: `diff-surface-header-${index}`,
|
|
34332
35166
|
dimColor: index > 0,
|
|
34333
35167
|
}, truncateCells(line, 140))), ...(showDiffLines
|
|
34334
|
-
? visibleDiffLines.map((line, index) => h
|
|
34335
|
-
key: `diff-surface-line-${state.worktreeDiffOffset + index}`,
|
|
34336
|
-
...diffLineProps(line, theme),
|
|
34337
|
-
}, truncateCells(line, 140)))
|
|
35168
|
+
? visibleDiffLines.map((line, index) => renderDiffLine(h, Text, line, theme, syntaxSpans, 140, `diff-surface-line-${state.worktreeDiffOffset + index}`))
|
|
34338
35169
|
: []));
|
|
34339
35170
|
}
|
|
34340
35171
|
|
|
@@ -36009,6 +36840,37 @@ function renderThemePickerOverlay(h, components, filter, index, width, theme, fo
|
|
|
36009
36840
|
? [h(Text, { key: 'theme-more-below', dimColor: true }, ` ↓ ${filtered.length - (startIndex + listRows)} more below`)]
|
|
36010
36841
|
: []));
|
|
36011
36842
|
}
|
|
36843
|
+
/**
|
|
36844
|
+
* "Add to .gitignore" quick-pick overlay (`i` on the status view).
|
|
36845
|
+
* Modeled on the theme picker but with a fixed, file-derived option list
|
|
36846
|
+
* (no fuzzy filter — the menu is short): pick exact / by-extension /
|
|
36847
|
+
* by-folder / by-name, or the `Custom pattern…` escape hatch which opens
|
|
36848
|
+
* a free-text prompt. ↑/↓ to move, Enter to choose, Esc to cancel.
|
|
36849
|
+
*/
|
|
36850
|
+
function renderGitignorePickerOverlay(h, components, file, index, width, theme, focused) {
|
|
36851
|
+
const { Box, Text } = components;
|
|
36852
|
+
const options = deriveGitignoreOptions(file);
|
|
36853
|
+
const selectedIndex = Math.max(0, Math.min(index, options.length - 1));
|
|
36854
|
+
const hint = '↑/↓ select · enter add · esc cancel';
|
|
36855
|
+
const itemLines = options.map((option, offset) => {
|
|
36856
|
+
const isSelected = offset === selectedIndex;
|
|
36857
|
+
const cursor = isSelected ? '>' : ' ';
|
|
36858
|
+
const glyph = option.custom ? '✎ ' : '+ ';
|
|
36859
|
+
return h(Text, {
|
|
36860
|
+
key: `gitignore-opt-${offset}`,
|
|
36861
|
+
bold: isSelected,
|
|
36862
|
+
dimColor: !isSelected,
|
|
36863
|
+
color: isSelected && !theme.noColor ? theme.colors.accent : undefined,
|
|
36864
|
+
}, `${cursor} ${glyph}`, truncateCells(option.label, width - 8));
|
|
36865
|
+
});
|
|
36866
|
+
return h(Box, {
|
|
36867
|
+
borderColor: focusBorderColor(theme, focused),
|
|
36868
|
+
borderStyle: theme.borderStyle,
|
|
36869
|
+
flexDirection: 'column',
|
|
36870
|
+
width,
|
|
36871
|
+
paddingX: 1,
|
|
36872
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Add to .gitignore', focused)), h(Text, { dimColor: true }, `${options.length} options`)), h(Text, { color: theme.colors.accent }, truncateCells(file || '(no file)', width - 4)), h(Text, { dimColor: true }, truncateCells(hint, width - 4)), h(Text, undefined, ''), ...itemLines);
|
|
36873
|
+
}
|
|
36012
36874
|
/**
|
|
36013
36875
|
* Split-plan overlay (#907) — renders the proposed commit groups for
|
|
36014
36876
|
* the user to review before applying. Three phases driven by
|
|
@@ -37353,7 +38215,7 @@ function renderWorktreesSurface(h, components, state, context, contextStatus, bo
|
|
|
37353
38215
|
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase 5a.7
|
|
37354
38216
|
* of #890. No behavior change.
|
|
37355
38217
|
*/
|
|
37356
|
-
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled) {
|
|
38218
|
+
function renderMainPanel(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, density, rowMode, dateBucketingEnabled, syntaxSpans) {
|
|
37357
38219
|
// Split-plan overlay (#907 polish): renders in the MAIN panel (not
|
|
37358
38220
|
// detail) when active, because the content — multiple commit groups
|
|
37359
38221
|
// with file lists, rationale, hunks — needs the full center width
|
|
@@ -37368,7 +38230,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
37368
38230
|
return renderStatusSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
37369
38231
|
}
|
|
37370
38232
|
if (state.activeView === 'diff') {
|
|
37371
|
-
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme);
|
|
38233
|
+
return renderDiffSurface(h, components, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bodyRows, width, theme, syntaxSpans);
|
|
37372
38234
|
}
|
|
37373
38235
|
if (state.activeView === 'compose') {
|
|
37374
38236
|
return renderComposeSurface(h, components, state, context, contextStatus, bodyRows, width, theme, spinnerFrame);
|
|
@@ -38448,6 +39310,9 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
38448
39310
|
if (state.showThemePicker) {
|
|
38449
39311
|
return renderThemePickerOverlay(h, components, state.themePickerFilter, state.themePickerIndex, width, theme, focused);
|
|
38450
39312
|
}
|
|
39313
|
+
if (state.gitignorePicker) {
|
|
39314
|
+
return renderGitignorePickerOverlay(h, components, state.gitignorePicker.file, state.gitignorePicker.index, width, theme, focused);
|
|
39315
|
+
}
|
|
38451
39316
|
if (state.inputPrompt) {
|
|
38452
39317
|
return renderInputPromptPanel(h, components, state, width, theme, focused);
|
|
38453
39318
|
}
|
|
@@ -38525,6 +39390,67 @@ function renderDetailPanel(h, components, state, context, contextStatus, detail,
|
|
|
38525
39390
|
return renderHistoryInspector(h, components, state, context, contextStatus, detail, loading, filePreview, filePreviewLoading, width, tabbed, theme, focused);
|
|
38526
39391
|
}
|
|
38527
39392
|
|
|
39393
|
+
/**
|
|
39394
|
+
* Resolve + scaffold the coco config files the workstation can open in
|
|
39395
|
+
* `$EDITOR` (the `gk` / `gK` chords and their command-palette entries).
|
|
39396
|
+
*
|
|
39397
|
+
* Two scopes:
|
|
39398
|
+
* - `global` → `$XDG_CONFIG_HOME/coco/config.json` (default
|
|
39399
|
+
* `~/.config/coco/config.json`) — applies to every repo.
|
|
39400
|
+
* - `project` → `.coco.json` (preferred) or the legacy
|
|
39401
|
+
* `.coco.config.json` at the repo root — applies to the
|
|
39402
|
+
* current repository only.
|
|
39403
|
+
*
|
|
39404
|
+
* When the chosen file doesn't exist yet we write a minimal templated
|
|
39405
|
+
* starter (just the `$schema` link + a sample `logTui.theme.preset`) so
|
|
39406
|
+
* the user lands in an editable, schema-aware file instead of an empty
|
|
39407
|
+
* buffer or an error.
|
|
39408
|
+
*/
|
|
39409
|
+
/**
|
|
39410
|
+
* Minimal starter config written when scaffolding a missing file. Keeps
|
|
39411
|
+
* the `$schema` link (so editors offer completion/validation) and one
|
|
39412
|
+
* illustrative key showing where settings live — small enough to not
|
|
39413
|
+
* impose opinions, structured enough to be a useful starting point.
|
|
39414
|
+
*/
|
|
39415
|
+
const STARTER_CONFIG = `${JSON.stringify({
|
|
39416
|
+
$schema: SCHEMA_PUBLIC_URL,
|
|
39417
|
+
logTui: { theme: { preset: 'default' } },
|
|
39418
|
+
}, null, 2)}\n`;
|
|
39419
|
+
/** `$XDG_CONFIG_HOME/coco/config.json` (default `~/.config/coco/config.json`). */
|
|
39420
|
+
function getGlobalConfigPath() {
|
|
39421
|
+
return getXdgConfigPath();
|
|
39422
|
+
}
|
|
39423
|
+
/**
|
|
39424
|
+
* The project config path for `repoRoot`: the first existing of
|
|
39425
|
+
* `.coco.json` / `.coco.config.json`, else `.coco.json` as the default
|
|
39426
|
+
* to create.
|
|
39427
|
+
*/
|
|
39428
|
+
function getProjectConfigPath(repoRoot) {
|
|
39429
|
+
for (const name of ['.coco.json', '.coco.config.json']) {
|
|
39430
|
+
const candidate = path.join(repoRoot, name);
|
|
39431
|
+
if (fs.existsSync(candidate))
|
|
39432
|
+
return candidate;
|
|
39433
|
+
}
|
|
39434
|
+
return path.join(repoRoot, '.coco.json');
|
|
39435
|
+
}
|
|
39436
|
+
/** Resolve the config path for a scope. `project` needs the repo root. */
|
|
39437
|
+
function resolveConfigPath(scope, repoRoot) {
|
|
39438
|
+
return scope === 'global' ? getGlobalConfigPath() : getProjectConfigPath(repoRoot);
|
|
39439
|
+
}
|
|
39440
|
+
/**
|
|
39441
|
+
* Ensure `filePath` exists, scaffolding the starter template (and any
|
|
39442
|
+
* missing parent directories) when it doesn't. Returns whether it was
|
|
39443
|
+
* just created so the caller can surface a "Created …" message.
|
|
39444
|
+
*/
|
|
39445
|
+
function ensureConfigFile(filePath) {
|
|
39446
|
+
if (fs.existsSync(filePath)) {
|
|
39447
|
+
return { created: false };
|
|
39448
|
+
}
|
|
39449
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
39450
|
+
fs.writeFileSync(filePath, STARTER_CONFIG);
|
|
39451
|
+
return { created: true };
|
|
39452
|
+
}
|
|
39453
|
+
|
|
38528
39454
|
/**
|
|
38529
39455
|
* `LogInkApp` — the workstation's root React component. Hosts all state
|
|
38530
39456
|
* via `useState`/`useEffect`/`useMemo`/`useCallback` hooks; wires up the
|
|
@@ -38754,7 +39680,7 @@ function enrichFilterActionWithRectification(action, state, context) {
|
|
|
38754
39680
|
}
|
|
38755
39681
|
}
|
|
38756
39682
|
function LogInkApp(deps) {
|
|
38757
|
-
const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, theme: baseTheme, themeConfig } = deps;
|
|
39683
|
+
const { appLabel, clipboardRunner, dateBucketingEnabled, git: rootGit, idleTipsEnabled, ink, initialView, loadRows, logArgv, React, resumeRef, rows, syntaxHighlightEnabled, theme: baseTheme, themeConfig } = deps;
|
|
38758
39684
|
const { Box, Text, useApp, useInput, useWindowSize } = ink;
|
|
38759
39685
|
const h = React.createElement;
|
|
38760
39686
|
// Theme picker (gC) — live preview + apply. `themePreviewPreset` follows
|
|
@@ -38925,6 +39851,11 @@ function LogInkApp(deps) {
|
|
|
38925
39851
|
const [worktreeDiffLoading, setWorktreeDiffLoading] = React.useState(false);
|
|
38926
39852
|
const [worktreeHunks, setWorktreeHunks] = React.useState(undefined);
|
|
38927
39853
|
const [worktreeHunksLoading, setWorktreeHunksLoading] = React.useState(false);
|
|
39854
|
+
// Syntax-highlight spans for the diff currently in view (#1117
|
|
39855
|
+
// follow-up). Computed off the render path by the effect below;
|
|
39856
|
+
// keyed by marker-stripped code line so the diff renderer looks
|
|
39857
|
+
// spans up directly. `undefined` = no highlighting (renders plain).
|
|
39858
|
+
const [diffSyntaxSpans, setDiffSyntaxSpans] = React.useState(undefined);
|
|
38928
39859
|
// Stash diff explorer (Enter on a stash row): the runtime fetches
|
|
38929
39860
|
// `git stash show -p <ref>` lazily once the diff view becomes active
|
|
38930
39861
|
// with diffSource='stash'. Lines are stored as a flat string[] —
|
|
@@ -39956,6 +40887,53 @@ function LogInkApp(deps) {
|
|
|
39956
40887
|
selectedWorktreeFile?.worktreeStatus,
|
|
39957
40888
|
state.activeView,
|
|
39958
40889
|
]);
|
|
40890
|
+
// Syntax-highlight the diff currently in view, off the render path
|
|
40891
|
+
// (#1117 follow-up). Mirrors the worktree-diff effect: detect the
|
|
40892
|
+
// active file + its diff lines (worktree or commit source), tokenize
|
|
40893
|
+
// via tree-sitter, and store the per-line spans for the renderer.
|
|
40894
|
+
// Stash / compare sources aren't highlighted yet (multi-file patch /
|
|
40895
|
+
// no single path). Gated on the config flag + a color terminal.
|
|
40896
|
+
React.useEffect(() => {
|
|
40897
|
+
if (!syntaxHighlightEnabled || theme.noColor || state.activeView !== 'diff') {
|
|
40898
|
+
setDiffSyntaxSpans(undefined);
|
|
40899
|
+
return;
|
|
40900
|
+
}
|
|
40901
|
+
let filePath;
|
|
40902
|
+
let lines;
|
|
40903
|
+
if (state.diffSource === 'commit') {
|
|
40904
|
+
filePath = selectedDetailFile?.path;
|
|
40905
|
+
lines = filePreview?.hunks;
|
|
40906
|
+
}
|
|
40907
|
+
else if (worktreeDiff && !worktreeDiff.untracked) {
|
|
40908
|
+
filePath = worktreeDiff.filePath;
|
|
40909
|
+
lines = worktreeDiff.lines;
|
|
40910
|
+
}
|
|
40911
|
+
if (!filePath || !lines || lines.length === 0) {
|
|
40912
|
+
setDiffSyntaxSpans(undefined);
|
|
40913
|
+
return;
|
|
40914
|
+
}
|
|
40915
|
+
let active = true;
|
|
40916
|
+
void highlightDiffCode(filePath, lines)
|
|
40917
|
+
.then((map) => {
|
|
40918
|
+
if (active)
|
|
40919
|
+
setDiffSyntaxSpans(map.size > 0 ? map : undefined);
|
|
40920
|
+
})
|
|
40921
|
+
.catch(() => {
|
|
40922
|
+
if (active)
|
|
40923
|
+
setDiffSyntaxSpans(undefined);
|
|
40924
|
+
});
|
|
40925
|
+
return () => {
|
|
40926
|
+
active = false;
|
|
40927
|
+
};
|
|
40928
|
+
}, [
|
|
40929
|
+
syntaxHighlightEnabled,
|
|
40930
|
+
theme.noColor,
|
|
40931
|
+
state.activeView,
|
|
40932
|
+
state.diffSource,
|
|
40933
|
+
selectedDetailFile?.path,
|
|
40934
|
+
filePreview,
|
|
40935
|
+
worktreeDiff,
|
|
40936
|
+
]);
|
|
39959
40937
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
39960
40938
|
if (!selectedWorktreeFile) {
|
|
39961
40939
|
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
@@ -40630,6 +41608,28 @@ function LogInkApp(deps) {
|
|
|
40630
41608
|
// refresh so the file row reflects the new staged/unstaged state.
|
|
40631
41609
|
void refreshWorktreeContext({ silent: true });
|
|
40632
41610
|
}, [dispatch, refreshWorktreeContext, resumeRef]);
|
|
41611
|
+
// Open the global or project coco config in $EDITOR (gk / gK + their
|
|
41612
|
+
// command-palette entries). Scaffolds a templated starter when the file
|
|
41613
|
+
// doesn't exist yet so the user never lands in an empty buffer or hits
|
|
41614
|
+
// a "no such file" error.
|
|
41615
|
+
const openConfigInEditor = React.useCallback((scope) => {
|
|
41616
|
+
// `repoRootRef` is populated async from `git rev-parse --show-toplevel`;
|
|
41617
|
+
// fall back to cwd so a freshly-launched session can still scaffold +
|
|
41618
|
+
// open the project config before that resolves.
|
|
41619
|
+
const repoRoot = repoRootRef.current || process.cwd();
|
|
41620
|
+
const filePath = resolveConfigPath(scope, repoRoot);
|
|
41621
|
+
try {
|
|
41622
|
+
const { created } = ensureConfigFile(filePath);
|
|
41623
|
+
if (created) {
|
|
41624
|
+
dispatch({ type: 'setStatus', value: `Created ${scope} config at ${filePath}`, kind: 'success' });
|
|
41625
|
+
}
|
|
41626
|
+
}
|
|
41627
|
+
catch (error) {
|
|
41628
|
+
dispatch({ type: 'setStatus', value: `Could not create config: ${error.message}`, kind: 'error' });
|
|
41629
|
+
return;
|
|
41630
|
+
}
|
|
41631
|
+
openInEditor(filePath);
|
|
41632
|
+
}, [dispatch, openInEditor]);
|
|
40633
41633
|
// `E` keystroke handler — open the current commit draft in $EDITOR
|
|
40634
41634
|
// (or $VISUAL), then read the file back and update the compose state
|
|
40635
41635
|
// with the saved content. Mirrors the suspend → spawn → resume
|
|
@@ -41485,6 +42485,7 @@ function LogInkApp(deps) {
|
|
|
41485
42485
|
return { ok: false, message: 'No branch selected' };
|
|
41486
42486
|
return pushBranch(git, branch);
|
|
41487
42487
|
},
|
|
42488
|
+
'add-to-gitignore': async () => addToGitignore(git, payload || ''),
|
|
41488
42489
|
'rename-branch': async () => {
|
|
41489
42490
|
const newName = payload?.trim();
|
|
41490
42491
|
if (!newName)
|
|
@@ -41852,6 +42853,12 @@ function LogInkApp(deps) {
|
|
|
41852
42853
|
dispatch({ type: 'pushView', value: 'history' });
|
|
41853
42854
|
await refreshWorktreeContext();
|
|
41854
42855
|
}
|
|
42856
|
+
// Refresh the worktree so a now-ignored untracked file drops out of
|
|
42857
|
+
// the status list immediately (the silent context refresh above
|
|
42858
|
+
// doesn't always re-read the worktree file set).
|
|
42859
|
+
if (result?.ok && id === 'add-to-gitignore') {
|
|
42860
|
+
await refreshWorktreeContext();
|
|
42861
|
+
}
|
|
41855
42862
|
if (result?.ok && id === 'drop-stash') {
|
|
41856
42863
|
// Explicit worktree refresh in case the dropped stash carried
|
|
41857
42864
|
// untracked-file state that's now collected.
|
|
@@ -42621,9 +43628,22 @@ function LogInkApp(deps) {
|
|
|
42621
43628
|
else if (event.type === 'openFileInEditor') {
|
|
42622
43629
|
openInEditor(event.path);
|
|
42623
43630
|
}
|
|
43631
|
+
else if (event.type === 'openConfigInEditor') {
|
|
43632
|
+
openConfigInEditor(event.scope);
|
|
43633
|
+
}
|
|
42624
43634
|
else if (event.type === 'yankFromActiveView') {
|
|
42625
43635
|
void yankFromActiveView(event.short);
|
|
42626
43636
|
}
|
|
43637
|
+
else if (event.type === 'openGitignorePicker') {
|
|
43638
|
+
// Resolve the cursored worktree file here (the runtime owns the
|
|
43639
|
+
// selection→file mapping) and open the picker over its path.
|
|
43640
|
+
if (selectedWorktreeFile?.path) {
|
|
43641
|
+
dispatch({ type: 'openGitignorePicker', file: selectedWorktreeFile.path });
|
|
43642
|
+
}
|
|
43643
|
+
else {
|
|
43644
|
+
dispatch({ type: 'setStatus', value: 'No file under the cursor to ignore.', kind: 'warning' });
|
|
43645
|
+
}
|
|
43646
|
+
}
|
|
42627
43647
|
else if (event.type === 'applyThemePreset') {
|
|
42628
43648
|
// Apply for the session immediately, and best-effort persist to the
|
|
42629
43649
|
// global config so it sticks across launches. The picker has already
|
|
@@ -42667,7 +43687,7 @@ function LogInkApp(deps) {
|
|
|
42667
43687
|
if (showOnboarding) {
|
|
42668
43688
|
return renderOnboardingOverlay(h, { Box, Text }, layout.rows, layout.columns, theme, appLabel);
|
|
42669
43689
|
}
|
|
42670
|
-
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled)), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
|
|
43690
|
+
return h(Box, { flexDirection: 'column', height: layout.rows }, renderHeader$1(h, { Box, Text }, state, context, contextStatus, layout.columns, theme, appLabel), h(Box, { flexDirection: 'row', height: layout.bodyRows }, renderSidebar$1(h, { Box, Text }, state, context, contextStatus, layout.sidebarWidth, layout.bodyRows, theme, layout.sidebarRailed), renderMainPanel(h, { Box, Text }, state, context, contextStatus, worktreeDiff, worktreeDiffLoading, worktreeHunks, worktreeHunksLoading, filePreview, filePreviewLoading, commitDiffHunkOffsets, selectedDetailFile, stashDiffLines, stashDiffLoading, compareDiffLines, compareDiffLoading, bisectCandidateDetail, bisectCandidateLoading, layout.bodyRows, layout.mainPanelWidth, theme, hasMoreCommits, loadingMoreCommits, spinnerFrame, layout.density, layout.historyRowMode, Boolean(dateBucketingEnabled), diffSyntaxSpans), renderDetailPanel(h, { Box, Text }, state, context, contextStatus, detail, detailLoading, filePreview, filePreviewLoading, layout.detailWidth, layout.inspectorTabbed, theme, layout.inspectorRailed, layout.bodyRows)), renderFooter$1(h, { Box, Text }, state, context, theme, idleTip, spinnerFrame));
|
|
42671
43691
|
}
|
|
42672
43692
|
|
|
42673
43693
|
/**
|
|
@@ -42838,6 +43858,8 @@ async function startInkInteractiveLog(git, rows, streams = {}, options = {}) {
|
|
|
42838
43858
|
// Resolve undefined → true so the default flips on automatically.
|
|
42839
43859
|
// An explicit `false` from config opts out.
|
|
42840
43860
|
dateBucketingEnabled: options.dateBucketing !== false,
|
|
43861
|
+
// Undefined → on; explicit `false` opts out.
|
|
43862
|
+
syntaxHighlightEnabled: options.syntaxHighlight !== false,
|
|
42841
43863
|
ink,
|
|
42842
43864
|
initialView: options.initialView || 'history',
|
|
42843
43865
|
logArgv: options.logArgv,
|
|
@@ -43045,6 +44067,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
43045
44067
|
appLabel: 'coco',
|
|
43046
44068
|
idleTips: config.logTui?.idleTips,
|
|
43047
44069
|
dateBucketing: config.logTui?.dateBucketing,
|
|
44070
|
+
syntaxHighlight: config.logTui?.syntaxHighlight,
|
|
43048
44071
|
initialView: 'history',
|
|
43049
44072
|
loadRows,
|
|
43050
44073
|
logArgv,
|
|
@@ -43067,6 +44090,7 @@ async function startCocoUi(argv) {
|
|
|
43067
44090
|
appLabel: 'coco',
|
|
43068
44091
|
idleTips: config.logTui?.idleTips,
|
|
43069
44092
|
dateBucketing: config.logTui?.dateBucketing,
|
|
44093
|
+
syntaxHighlight: config.logTui?.syntaxHighlight,
|
|
43070
44094
|
initialView: argv.view || 'history',
|
|
43071
44095
|
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
43072
44096
|
logArgv,
|