git-coco 0.54.1 → 0.55.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.esm.mjs +1493 -515
- package/dist/index.js +1493 -515
- package/package.json +1 -1
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.55.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18368,7 +18368,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
|
|
|
18368
18368
|
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
18369
18369
|
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
18370
18370
|
const LOG_DEFAULT_LIMIT = 30;
|
|
18371
|
-
|
|
18371
|
+
// Bumped from 300 → 1000 in 0.54.2. With the full-graph default
|
|
18372
|
+
// (#1034) the workstation surfaces many more refs (all branches, all
|
|
18373
|
+
// tags, plus stash commits added via `extraRefs`), and on active repos
|
|
18374
|
+
// the 300-commit cap was cutting off year+-old stash bases and old
|
|
18375
|
+
// tag commits — making the cursor-syncs-history effect report "tip
|
|
18376
|
+
// not in loaded window" instead of moving the graph cursor. 1000
|
|
18377
|
+
// fits a year of activity for most repos, git log is still sub-200ms,
|
|
18378
|
+
// and Ink virtualises scroll so render cost stays flat.
|
|
18379
|
+
const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
|
|
18372
18380
|
function toArray(value) {
|
|
18373
18381
|
if (!value) {
|
|
18374
18382
|
return [];
|
|
@@ -18548,12 +18556,77 @@ function buildLogArgs(argv, options = {}) {
|
|
|
18548
18556
|
else if (argv.branch) {
|
|
18549
18557
|
args.push(argv.branch);
|
|
18550
18558
|
}
|
|
18559
|
+
// Extra refs (stash commits etc.) — append after the --all / branch
|
|
18560
|
+
// selector but BEFORE the path separator. Git treats them as
|
|
18561
|
+
// additional graph roots, so the traversal includes them alongside
|
|
18562
|
+
// whatever --all / --branch already covers.
|
|
18563
|
+
if (options.extraRefs && options.extraRefs.length > 0) {
|
|
18564
|
+
args.push(...options.extraRefs);
|
|
18565
|
+
}
|
|
18551
18566
|
const paths = toArray(argv.path);
|
|
18552
18567
|
if (paths.length > 0) {
|
|
18553
18568
|
args.push('--', ...paths);
|
|
18554
18569
|
}
|
|
18555
18570
|
return args;
|
|
18556
18571
|
}
|
|
18572
|
+
/**
|
|
18573
|
+
* Default size of a targeted-context window. Sized to comfortably
|
|
18574
|
+
* cover a year of activity on most repos so the cursor-sync's
|
|
18575
|
+
* "jump to commit anchored on a ref I just selected" can succeed
|
|
18576
|
+
* without paginating through the whole history.
|
|
18577
|
+
*/
|
|
18578
|
+
const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
|
|
18579
|
+
/**
|
|
18580
|
+
* Load a window of commits anchored on a specific hash. Used by the
|
|
18581
|
+
* cursor-sync effect when the user selects a ref (branch / tag /
|
|
18582
|
+
* stash) whose target commit isn't in the loaded graph window.
|
|
18583
|
+
*
|
|
18584
|
+
* Critical detail: this walks **only from the target** (and its
|
|
18585
|
+
* ancestors), NOT from `--all`. Why: when you combine `--all` with
|
|
18586
|
+
* `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
|
|
18587
|
+
* the result by date, and slices the newest N rows. If the target
|
|
18588
|
+
* is older than the Nth newest commit across all refs (very common
|
|
18589
|
+
* for year-old tags / branches on active repos), it falls off the
|
|
18590
|
+
* slice even though it was passed as a root. Walking from the
|
|
18591
|
+
* target alone guarantees the target IS the first row of the
|
|
18592
|
+
* output and its ancestors fill the rest.
|
|
18593
|
+
*
|
|
18594
|
+
* The caller merges the result via the `appendRows` reducer action
|
|
18595
|
+
* which deduplicates by hash, so the target's ancestry slots into
|
|
18596
|
+
* the existing `--all` graph cleanly. The user's loaded view ends
|
|
18597
|
+
* up as the union of: the original `--all` window + target's
|
|
18598
|
+
* ancestry — exactly what's needed for the cursor to land.
|
|
18599
|
+
*
|
|
18600
|
+
* Capped at `options.limit` (default 5000) to keep one targeted
|
|
18601
|
+
* fetch bounded. For most refs, even a 100-commit limit would be
|
|
18602
|
+
* enough to surface the target; we go higher to also pull in the
|
|
18603
|
+
* surrounding context so the user can scroll around the landed
|
|
18604
|
+
* cursor.
|
|
18605
|
+
*/
|
|
18606
|
+
async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
|
|
18607
|
+
// Strip every "walk many refs" toggle so buildLogArgs produces a
|
|
18608
|
+
// clean `git log <flags> <targetHash>` — exactly the walk that
|
|
18609
|
+
// guarantees the target's inclusion.
|
|
18610
|
+
const merged = {
|
|
18611
|
+
...argv,
|
|
18612
|
+
all: false,
|
|
18613
|
+
view: 'compact', // suppresses 'full' → '--all' mapping
|
|
18614
|
+
branch: undefined,
|
|
18615
|
+
path: undefined,
|
|
18616
|
+
};
|
|
18617
|
+
// Also drop --first-parent / --no-merges so the target's ancestry
|
|
18618
|
+
// renders with full topology (matters for stash commits which are
|
|
18619
|
+
// merges by construction).
|
|
18620
|
+
const baseArgs = buildLogArgs(merged, {
|
|
18621
|
+
limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
|
|
18622
|
+
}).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
|
|
18623
|
+
// Splice the target as the positional ref. `buildLogArgs` already
|
|
18624
|
+
// appended any `--all`/`--branch`/`<extraRefs>` it considered;
|
|
18625
|
+
// since we cleared all those above, the only positional ref we
|
|
18626
|
+
// add is the target.
|
|
18627
|
+
baseArgs.push(targetHash);
|
|
18628
|
+
return parseLogOutput(await git.raw(baseArgs));
|
|
18629
|
+
}
|
|
18557
18630
|
/**
|
|
18558
18631
|
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
18559
18632
|
*
|
|
@@ -18661,6 +18734,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
18661
18734
|
};
|
|
18662
18735
|
}
|
|
18663
18736
|
|
|
18737
|
+
function parseStashSubject(subject) {
|
|
18738
|
+
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18739
|
+
if (!match) {
|
|
18740
|
+
return {
|
|
18741
|
+
branch: '<unknown>',
|
|
18742
|
+
message: subject,
|
|
18743
|
+
};
|
|
18744
|
+
}
|
|
18745
|
+
return {
|
|
18746
|
+
branch: match[1],
|
|
18747
|
+
message: match[2] || subject,
|
|
18748
|
+
};
|
|
18749
|
+
}
|
|
18750
|
+
function parseStashList(output) {
|
|
18751
|
+
return output
|
|
18752
|
+
.split('\n')
|
|
18753
|
+
.map((line) => line.trim())
|
|
18754
|
+
.filter(Boolean)
|
|
18755
|
+
.map((line) => {
|
|
18756
|
+
const [ref, hash, parents, date, subject] = line.split('\x1f');
|
|
18757
|
+
const parsedSubject = parseStashSubject(subject || '');
|
|
18758
|
+
// `%P` returns space-separated parent hashes. Stash commits are
|
|
18759
|
+
// merges with 2-3 parents; the FIRST is the base (HEAD at stash
|
|
18760
|
+
// time). Empty parents string (legacy / corrupted entries) maps
|
|
18761
|
+
// to an empty baseHash; the cursor-sync caller treats that as
|
|
18762
|
+
// "no base available, fall back to stash hash."
|
|
18763
|
+
const baseHash = parents ? (parents.split(' ')[0] || '') : '';
|
|
18764
|
+
return {
|
|
18765
|
+
ref,
|
|
18766
|
+
hash,
|
|
18767
|
+
baseHash,
|
|
18768
|
+
date,
|
|
18769
|
+
branch: parsedSubject.branch,
|
|
18770
|
+
message: parsedSubject.message,
|
|
18771
|
+
};
|
|
18772
|
+
});
|
|
18773
|
+
}
|
|
18774
|
+
function parseStashFiles(output) {
|
|
18775
|
+
return output
|
|
18776
|
+
.split('\n')
|
|
18777
|
+
.map((line) => line.trim())
|
|
18778
|
+
.filter(Boolean);
|
|
18779
|
+
}
|
|
18780
|
+
/**
|
|
18781
|
+
* Resolve the commit hashes for every stash, in `stash@{N}` order.
|
|
18782
|
+
*
|
|
18783
|
+
* Used by the workstation's history loader to include older stashes
|
|
18784
|
+
* as graph roots — `git log --all` only walks `refs/stash` (the
|
|
18785
|
+
* latest stash) by default, so stash@{1+} commits live off-graph
|
|
18786
|
+
* unless explicitly referenced. Passing this list as positional refs
|
|
18787
|
+
* to `git log` makes every stash appear as a graph node, which lets
|
|
18788
|
+
* the cursor-syncs-history effect actually land on them when the
|
|
18789
|
+
* user navigates the stashes sidebar.
|
|
18790
|
+
*
|
|
18791
|
+
* Cheap: one `git stash list` call, no per-stash fan-out. Returns
|
|
18792
|
+
* an empty array when there are no stashes — callers can pass the
|
|
18793
|
+
* result through unconditionally.
|
|
18794
|
+
*/
|
|
18795
|
+
async function getStashCommitHashes(git) {
|
|
18796
|
+
const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
|
|
18797
|
+
return raw
|
|
18798
|
+
.split('\n')
|
|
18799
|
+
.map((line) => line.trim())
|
|
18800
|
+
.filter(Boolean);
|
|
18801
|
+
}
|
|
18802
|
+
async function getStashOverview(git) {
|
|
18803
|
+
// Format fields (separated by 0x1f / unit separator):
|
|
18804
|
+
// %gd — stash reflog selector (stash@{N})
|
|
18805
|
+
// %H — stash commit hash
|
|
18806
|
+
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18807
|
+
// %ci — committer date, ISO format
|
|
18808
|
+
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18809
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
|
|
18810
|
+
return {
|
|
18811
|
+
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18812
|
+
...stash,
|
|
18813
|
+
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18814
|
+
}))),
|
|
18815
|
+
};
|
|
18816
|
+
}
|
|
18817
|
+
/**
|
|
18818
|
+
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18819
|
+
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18820
|
+
*
|
|
18821
|
+
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18822
|
+
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18823
|
+
* back to a "no diff to display" message.
|
|
18824
|
+
*/
|
|
18825
|
+
async function getStashDiff(git, stashRef) {
|
|
18826
|
+
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18827
|
+
.split('\n')
|
|
18828
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
18829
|
+
}
|
|
18830
|
+
/**
|
|
18831
|
+
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18832
|
+
* file path and the offset of its `diff --git` header within `lines`.
|
|
18833
|
+
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18834
|
+
* the file at the cursor.
|
|
18835
|
+
*
|
|
18836
|
+
* Renames / moves return the destination path (the `b/` side); the
|
|
18837
|
+
* action surface treats that as the path to materialize from the stash.
|
|
18838
|
+
*
|
|
18839
|
+
* Path quoting: git wraps paths containing spaces or special characters
|
|
18840
|
+
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18841
|
+
* The parser handles both the unquoted and quoted forms; without that,
|
|
18842
|
+
* stash-file navigation and cherry-pick silently broke for any file
|
|
18843
|
+
* whose path contained a space.
|
|
18844
|
+
*/
|
|
18845
|
+
function parseStashDiffFiles(lines) {
|
|
18846
|
+
const files = [];
|
|
18847
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
18848
|
+
const line = lines[i];
|
|
18849
|
+
const parsed = parseDiffGitHeader(line);
|
|
18850
|
+
if (parsed) {
|
|
18851
|
+
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18852
|
+
}
|
|
18853
|
+
}
|
|
18854
|
+
return files;
|
|
18855
|
+
}
|
|
18856
|
+
/**
|
|
18857
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
18858
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18859
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18860
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18861
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
18862
|
+
*
|
|
18863
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
18864
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
18865
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18866
|
+
* "no file selected" state in that case.
|
|
18867
|
+
*/
|
|
18868
|
+
function findStashFileForOffset(files, offset) {
|
|
18869
|
+
if (files.length === 0)
|
|
18870
|
+
return undefined;
|
|
18871
|
+
let current;
|
|
18872
|
+
for (const file of files) {
|
|
18873
|
+
if (file.startLine <= offset) {
|
|
18874
|
+
current = file;
|
|
18875
|
+
}
|
|
18876
|
+
else {
|
|
18877
|
+
break;
|
|
18878
|
+
}
|
|
18879
|
+
}
|
|
18880
|
+
// First file is the canonical fallback — even if the offset lands
|
|
18881
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
18882
|
+
// so the user's actions have a target.
|
|
18883
|
+
return current ?? files[0];
|
|
18884
|
+
}
|
|
18885
|
+
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18886
|
+
function parseDiffGitHeader(line) {
|
|
18887
|
+
const match = line.match(DIFF_GIT_HEADER);
|
|
18888
|
+
if (!match)
|
|
18889
|
+
return undefined;
|
|
18890
|
+
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18891
|
+
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18892
|
+
if (!aPath || !bPath)
|
|
18893
|
+
return undefined;
|
|
18894
|
+
return { aPath, bPath };
|
|
18895
|
+
}
|
|
18896
|
+
function unescapeGitQuoted(value) {
|
|
18897
|
+
if (value === undefined)
|
|
18898
|
+
return undefined;
|
|
18899
|
+
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18900
|
+
// C-style sequences. Reverse the most common ones so callers get the
|
|
18901
|
+
// raw on-disk path.
|
|
18902
|
+
return value
|
|
18903
|
+
.replace(/\\\\/g, '\\')
|
|
18904
|
+
.replace(/\\"/g, '"')
|
|
18905
|
+
.replace(/\\t/g, '\t')
|
|
18906
|
+
.replace(/\\n/g, '\n');
|
|
18907
|
+
}
|
|
18908
|
+
|
|
18664
18909
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
18665
18910
|
function parseBranchRefs(output) {
|
|
18666
18911
|
return output
|
|
@@ -18853,143 +19098,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18853
19098
|
}
|
|
18854
19099
|
}
|
|
18855
19100
|
|
|
18856
|
-
function parseStashSubject(subject) {
|
|
18857
|
-
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18858
|
-
if (!match) {
|
|
18859
|
-
return {
|
|
18860
|
-
branch: '<unknown>',
|
|
18861
|
-
message: subject,
|
|
18862
|
-
};
|
|
18863
|
-
}
|
|
18864
|
-
return {
|
|
18865
|
-
branch: match[1],
|
|
18866
|
-
message: match[2] || subject,
|
|
18867
|
-
};
|
|
18868
|
-
}
|
|
18869
|
-
function parseStashList(output) {
|
|
18870
|
-
return output
|
|
18871
|
-
.split('\n')
|
|
18872
|
-
.map((line) => line.trim())
|
|
18873
|
-
.filter(Boolean)
|
|
18874
|
-
.map((line) => {
|
|
18875
|
-
const [ref, hash, date, subject] = line.split('\x1f');
|
|
18876
|
-
const parsedSubject = parseStashSubject(subject || '');
|
|
18877
|
-
return {
|
|
18878
|
-
ref,
|
|
18879
|
-
hash,
|
|
18880
|
-
date,
|
|
18881
|
-
branch: parsedSubject.branch,
|
|
18882
|
-
message: parsedSubject.message,
|
|
18883
|
-
};
|
|
18884
|
-
});
|
|
18885
|
-
}
|
|
18886
|
-
function parseStashFiles(output) {
|
|
18887
|
-
return output
|
|
18888
|
-
.split('\n')
|
|
18889
|
-
.map((line) => line.trim())
|
|
18890
|
-
.filter(Boolean);
|
|
18891
|
-
}
|
|
18892
|
-
async function getStashOverview(git) {
|
|
18893
|
-
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
|
|
18894
|
-
return {
|
|
18895
|
-
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18896
|
-
...stash,
|
|
18897
|
-
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18898
|
-
}))),
|
|
18899
|
-
};
|
|
18900
|
-
}
|
|
18901
|
-
/**
|
|
18902
|
-
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18903
|
-
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18904
|
-
*
|
|
18905
|
-
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18906
|
-
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18907
|
-
* back to a "no diff to display" message.
|
|
18908
|
-
*/
|
|
18909
|
-
async function getStashDiff(git, stashRef) {
|
|
18910
|
-
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18911
|
-
.split('\n')
|
|
18912
|
-
.map((line) => line.replace(/\r$/, ''));
|
|
18913
|
-
}
|
|
18914
|
-
/**
|
|
18915
|
-
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18916
|
-
* file path and the offset of its `diff --git` header within `lines`.
|
|
18917
|
-
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18918
|
-
* the file at the cursor.
|
|
18919
|
-
*
|
|
18920
|
-
* Renames / moves return the destination path (the `b/` side); the
|
|
18921
|
-
* action surface treats that as the path to materialize from the stash.
|
|
18922
|
-
*
|
|
18923
|
-
* Path quoting: git wraps paths containing spaces or special characters
|
|
18924
|
-
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18925
|
-
* The parser handles both the unquoted and quoted forms; without that,
|
|
18926
|
-
* stash-file navigation and cherry-pick silently broke for any file
|
|
18927
|
-
* whose path contained a space.
|
|
18928
|
-
*/
|
|
18929
|
-
function parseStashDiffFiles(lines) {
|
|
18930
|
-
const files = [];
|
|
18931
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
18932
|
-
const line = lines[i];
|
|
18933
|
-
const parsed = parseDiffGitHeader(line);
|
|
18934
|
-
if (parsed) {
|
|
18935
|
-
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18936
|
-
}
|
|
18937
|
-
}
|
|
18938
|
-
return files;
|
|
18939
|
-
}
|
|
18940
|
-
/**
|
|
18941
|
-
* Resolve which stash file *contains* a given line offset — the user's
|
|
18942
|
-
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18943
|
-
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18944
|
-
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18945
|
-
* dispatchers' "what file is the cursor on" lookup.
|
|
18946
|
-
*
|
|
18947
|
-
* Returns `undefined` when the file list is empty *or* the offset
|
|
18948
|
-
* lands before the very first file's `diff --git` header (e.g. when
|
|
18949
|
-
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18950
|
-
* "no file selected" state in that case.
|
|
18951
|
-
*/
|
|
18952
|
-
function findStashFileForOffset(files, offset) {
|
|
18953
|
-
if (files.length === 0)
|
|
18954
|
-
return undefined;
|
|
18955
|
-
let current;
|
|
18956
|
-
for (const file of files) {
|
|
18957
|
-
if (file.startLine <= offset) {
|
|
18958
|
-
current = file;
|
|
18959
|
-
}
|
|
18960
|
-
else {
|
|
18961
|
-
break;
|
|
18962
|
-
}
|
|
18963
|
-
}
|
|
18964
|
-
// First file is the canonical fallback — even if the offset lands
|
|
18965
|
-
// before its header (rare), we want the cursor to be "in" something
|
|
18966
|
-
// so the user's actions have a target.
|
|
18967
|
-
return current ?? files[0];
|
|
18968
|
-
}
|
|
18969
|
-
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18970
|
-
function parseDiffGitHeader(line) {
|
|
18971
|
-
const match = line.match(DIFF_GIT_HEADER);
|
|
18972
|
-
if (!match)
|
|
18973
|
-
return undefined;
|
|
18974
|
-
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18975
|
-
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18976
|
-
if (!aPath || !bPath)
|
|
18977
|
-
return undefined;
|
|
18978
|
-
return { aPath, bPath };
|
|
18979
|
-
}
|
|
18980
|
-
function unescapeGitQuoted(value) {
|
|
18981
|
-
if (value === undefined)
|
|
18982
|
-
return undefined;
|
|
18983
|
-
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18984
|
-
// C-style sequences. Reverse the most common ones so callers get the
|
|
18985
|
-
// raw on-disk path.
|
|
18986
|
-
return value
|
|
18987
|
-
.replace(/\\\\/g, '\\')
|
|
18988
|
-
.replace(/\\"/g, '"')
|
|
18989
|
-
.replace(/\\t/g, '\t')
|
|
18990
|
-
.replace(/\\n/g, '\n');
|
|
18991
|
-
}
|
|
18992
|
-
|
|
18993
19101
|
function fileState(indexStatus, worktreeStatus) {
|
|
18994
19102
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
18995
19103
|
return 'untracked';
|
|
@@ -19056,7 +19164,18 @@ function parseTagRefs(output) {
|
|
|
19056
19164
|
.map((line) => line.trimEnd())
|
|
19057
19165
|
.filter(Boolean)
|
|
19058
19166
|
.map((line) => {
|
|
19059
|
-
const [name,
|
|
19167
|
+
const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
19168
|
+
// For annotated tags `%(objectname:short)` returns the TAG
|
|
19169
|
+
// OBJECT's SHA, not the commit it points to — that's the SHA
|
|
19170
|
+
// sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
|
|
19171
|
+
// dereferences the tag and yields the commit's SHA, but is
|
|
19172
|
+
// EMPTY for lightweight tags (which are already direct
|
|
19173
|
+
// pointers to commits). Prefer the dereferenced form when
|
|
19174
|
+
// present, fall back to the object SHA otherwise. This is what
|
|
19175
|
+
// lets cursor-sync find the tagged commit in the loaded log
|
|
19176
|
+
// window — anchoring on the tag object's own SHA would never
|
|
19177
|
+
// match a commit row.
|
|
19178
|
+
const hash = derefedHash || objectHash;
|
|
19060
19179
|
return {
|
|
19061
19180
|
name,
|
|
19062
19181
|
hash,
|
|
@@ -19068,7 +19187,7 @@ function parseTagRefs(output) {
|
|
|
19068
19187
|
async function getTagOverview(git) {
|
|
19069
19188
|
const output = await git.raw([
|
|
19070
19189
|
'for-each-ref',
|
|
19071
|
-
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19190
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19072
19191
|
'--sort=-creatordate',
|
|
19073
19192
|
'refs/tags',
|
|
19074
19193
|
]);
|
|
@@ -19950,6 +20069,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
19950
20069
|
output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
|
|
19951
20070
|
}
|
|
19952
20071
|
|
|
20072
|
+
/**
|
|
20073
|
+
* Shared hash-matching helpers for cross-command lookups.
|
|
20074
|
+
*
|
|
20075
|
+
* Git surfaces the same commit with different short-hash lengths
|
|
20076
|
+
* depending on which command produced the row:
|
|
20077
|
+
*
|
|
20078
|
+
* - `for-each-ref --format=%(objectname:short)` (branches, tags,
|
|
20079
|
+
* stashes) honors `core.abbrev`, typically 7 chars.
|
|
20080
|
+
* - `git log --pretty=format:%h` (history rows) honors the same
|
|
20081
|
+
* setting BUT git auto-extends abbreviations to keep them unique
|
|
20082
|
+
* within the walked set — so the same commit can come back as 7
|
|
20083
|
+
* chars from one command and 8 (or more) from another.
|
|
20084
|
+
*
|
|
20085
|
+
* Consequence: any exact-equality lookup that compares a hash from
|
|
20086
|
+
* `for-each-ref` against a hash from `git log` will miss the match
|
|
20087
|
+
* even when both refer to the same commit. This bit the workstation's
|
|
20088
|
+
* cursor-sync effect twice during 0.54.2 — once in the resolver, once
|
|
20089
|
+
* in the `selectCommitByHash` reducer — and shows up wherever a ref
|
|
20090
|
+
* hash is checked against the loaded log window.
|
|
20091
|
+
*
|
|
20092
|
+
* The fix is bidirectional prefix matching: a hash matches another if
|
|
20093
|
+
* one is a prefix of the other. Below a 4-char floor we refuse to
|
|
20094
|
+
* match — three chars would collide with too many real commits.
|
|
20095
|
+
*
|
|
20096
|
+
* This module is the canonical place for that logic. Import it
|
|
20097
|
+
* anywhere you compare a "hash from one git formatter" against a
|
|
20098
|
+
* "hash from a different git formatter."
|
|
20099
|
+
*
|
|
20100
|
+
* Lives in `src/git/` because both `workstation/` and `commands/log/`
|
|
20101
|
+
* depend on it — `commands/log/` must not depend on `workstation/`,
|
|
20102
|
+
* so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
|
|
20103
|
+
*/
|
|
20104
|
+
/**
|
|
20105
|
+
* Minimum length below which we refuse to prefix-match. Three chars
|
|
20106
|
+
* is too small to be a meaningful unique prefix for any real-world
|
|
20107
|
+
* git history.
|
|
20108
|
+
*/
|
|
20109
|
+
const MIN_PREFIX_LENGTH = 4;
|
|
20110
|
+
/**
|
|
20111
|
+
* True when `a` and `b` refer to the same commit, tolerating
|
|
20112
|
+
* short-hash length differences from different git formatters.
|
|
20113
|
+
*
|
|
20114
|
+
* Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
|
|
20115
|
+
* string equality wins immediately (the common path); otherwise we
|
|
20116
|
+
* test bidirectional `startsWith` and bail when either input is too
|
|
20117
|
+
* short to be a meaningful prefix.
|
|
20118
|
+
*/
|
|
20119
|
+
function hashesMatch(a, b) {
|
|
20120
|
+
if (!a || !b)
|
|
20121
|
+
return false;
|
|
20122
|
+
if (a === b)
|
|
20123
|
+
return true;
|
|
20124
|
+
if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
|
|
20125
|
+
return false;
|
|
20126
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
20127
|
+
}
|
|
20128
|
+
/**
|
|
20129
|
+
* True when `hash` matches any entry in `candidates`. Convenience
|
|
20130
|
+
* wrapper for the common "is this ref's hash in any of the row's
|
|
20131
|
+
* hash variants?" check.
|
|
20132
|
+
*/
|
|
20133
|
+
function hashesMatchAny(hash, candidates) {
|
|
20134
|
+
if (!hash)
|
|
20135
|
+
return false;
|
|
20136
|
+
return candidates.some((candidate) => hashesMatch(hash, candidate));
|
|
20137
|
+
}
|
|
20138
|
+
/**
|
|
20139
|
+
* True when `hash` is present in the loaded set — exact match first
|
|
20140
|
+
* (the O(1) fast path), then bidirectional `startsWith` over the set
|
|
20141
|
+
* to cover the formatter mismatch.
|
|
20142
|
+
*
|
|
20143
|
+
* The set is small in practice (1k–5k entries) so O(N) iteration on
|
|
20144
|
+
* miss is fine.
|
|
20145
|
+
*/
|
|
20146
|
+
function hashLoaded(hash, loaded) {
|
|
20147
|
+
if (loaded.has(hash))
|
|
20148
|
+
return true;
|
|
20149
|
+
if (hash.length < MIN_PREFIX_LENGTH)
|
|
20150
|
+
return false;
|
|
20151
|
+
for (const entry of loaded) {
|
|
20152
|
+
if (entry.startsWith(hash) || hash.startsWith(entry))
|
|
20153
|
+
return true;
|
|
20154
|
+
}
|
|
20155
|
+
return false;
|
|
20156
|
+
}
|
|
20157
|
+
|
|
19953
20158
|
const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
|
|
19954
20159
|
/**
|
|
19955
20160
|
* Parse a single `.gitattributes` body into the LFS-tracked
|
|
@@ -23642,7 +23847,16 @@ function createLogInkState(rows, options = {}) {
|
|
|
23642
23847
|
worktreeDiffOffset: 0,
|
|
23643
23848
|
filter: '',
|
|
23644
23849
|
filterMode: false,
|
|
23645
|
-
|
|
23850
|
+
// Default to the full multi-ref graph (`git log --all`) so users
|
|
23851
|
+
// see how branches, tags, and stashes weave through the history
|
|
23852
|
+
// out of the box. Pre-0.54.x this defaulted to false (current
|
|
23853
|
+
// branch only); user feedback consistently asked for the
|
|
23854
|
+
// GitKraken-style "see everything" view as the starting state.
|
|
23855
|
+
// The `\` toggle still flips back to compact / current-branch
|
|
23856
|
+
// mode for users who want the cleaner single-line graph. Tests
|
|
23857
|
+
// override via `options.fullGraph` when they need the compact
|
|
23858
|
+
// case explicitly.
|
|
23859
|
+
fullGraph: options.fullGraph ?? true,
|
|
23646
23860
|
showHelp: false,
|
|
23647
23861
|
helpScrollOffset: 0,
|
|
23648
23862
|
showCommandPalette: false,
|
|
@@ -23751,8 +23965,17 @@ function applyLogInkAction(state, action) {
|
|
|
23751
23965
|
// branch's tip without the user manually scrolling. No-op when
|
|
23752
23966
|
// the hash isn't in the loaded list (the runtime surfaces a
|
|
23753
23967
|
// status hint in that case).
|
|
23968
|
+
//
|
|
23969
|
+
// Uses the shared `hashesMatchAny` helper to cover the
|
|
23970
|
+
// short-hash auto-extension mismatch between
|
|
23971
|
+
// `for-each-ref --format=%(objectname:short)` (cursored ref)
|
|
23972
|
+
// and `git log --pretty=format:%h` (history row). Without that
|
|
23973
|
+
// tolerance the resolver could decide "jump" but this reducer
|
|
23974
|
+
// would silently no-op — the status updates but the cursor
|
|
23975
|
+
// doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
|
|
23976
|
+
// testing. See `src/git/hashes.ts` for the matching rules.
|
|
23754
23977
|
const target = action.hash;
|
|
23755
|
-
const index = state.filteredCommits.findIndex((commit) => commit.hash
|
|
23978
|
+
const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
|
|
23756
23979
|
if (index < 0) {
|
|
23757
23980
|
return state;
|
|
23758
23981
|
}
|
|
@@ -24689,7 +24912,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24689
24912
|
const commit = state.filteredCommits[state.selectedIndex];
|
|
24690
24913
|
const requireCommit = (fn) => {
|
|
24691
24914
|
if (!commit) {
|
|
24692
|
-
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
24915
|
+
return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
|
|
24693
24916
|
}
|
|
24694
24917
|
return fn(commit.hash, state.selectedIndex);
|
|
24695
24918
|
};
|
|
@@ -24728,6 +24951,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24728
24951
|
return [action({
|
|
24729
24952
|
type: 'setStatus',
|
|
24730
24953
|
value: `Action ${inspectorAction.key} not yet wired`,
|
|
24954
|
+
kind: 'warning',
|
|
24731
24955
|
})];
|
|
24732
24956
|
}
|
|
24733
24957
|
}
|
|
@@ -24942,6 +25166,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24942
25166
|
return [action({
|
|
24943
25167
|
type: 'setStatus',
|
|
24944
25168
|
value: 'open the diff view and press [ or ] to jump hunks',
|
|
25169
|
+
kind: 'warning',
|
|
24945
25170
|
})];
|
|
24946
25171
|
case 'focusNext':
|
|
24947
25172
|
return [action({ type: 'focusNext' })];
|
|
@@ -24990,6 +25215,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24990
25215
|
return [action({
|
|
24991
25216
|
type: 'setStatus',
|
|
24992
25217
|
value: 'open branches / tags / history and press m on the cursored ref',
|
|
25218
|
+
kind: 'warning',
|
|
24993
25219
|
})];
|
|
24994
25220
|
case 'navigateBack':
|
|
24995
25221
|
// Mirror the Esc / `<` semantics (#931): drain the frame's view
|
|
@@ -25065,6 +25291,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
25065
25291
|
return [action({
|
|
25066
25292
|
type: 'setStatus',
|
|
25067
25293
|
value: 'Sort cycle is available in the branches and tags views',
|
|
25294
|
+
kind: 'warning',
|
|
25068
25295
|
})];
|
|
25069
25296
|
case 'yankClipboard':
|
|
25070
25297
|
// The runtime resolves the value/label against the live filtered
|
|
@@ -25121,7 +25348,7 @@ function submitInputPrompt(state) {
|
|
|
25121
25348
|
return [];
|
|
25122
25349
|
const value = state.inputPrompt.value.trim();
|
|
25123
25350
|
if (!value) {
|
|
25124
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
25351
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
25125
25352
|
}
|
|
25126
25353
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
25127
25354
|
const mode = value.toLowerCase();
|
|
@@ -25129,6 +25356,7 @@ function submitInputPrompt(state) {
|
|
|
25129
25356
|
return [action({
|
|
25130
25357
|
type: 'setStatus',
|
|
25131
25358
|
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
25359
|
+
kind: 'warning',
|
|
25132
25360
|
})];
|
|
25133
25361
|
}
|
|
25134
25362
|
return [
|
|
@@ -25142,6 +25370,7 @@ function submitInputPrompt(state) {
|
|
|
25142
25370
|
return [action({
|
|
25143
25371
|
type: 'setStatus',
|
|
25144
25372
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25373
|
+
kind: 'warning',
|
|
25145
25374
|
})];
|
|
25146
25375
|
}
|
|
25147
25376
|
return [
|
|
@@ -25205,6 +25434,7 @@ function submitInputPrompt(state) {
|
|
|
25205
25434
|
return [action({
|
|
25206
25435
|
type: 'setStatus',
|
|
25207
25436
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25437
|
+
kind: 'warning',
|
|
25208
25438
|
})];
|
|
25209
25439
|
}
|
|
25210
25440
|
return [
|
|
@@ -25802,7 +26032,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25802
26032
|
}
|
|
25803
26033
|
return [
|
|
25804
26034
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25805
|
-
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
26035
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
|
|
25806
26036
|
];
|
|
25807
26037
|
}
|
|
25808
26038
|
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
@@ -25826,7 +26056,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25826
26056
|
}
|
|
25827
26057
|
return [
|
|
25828
26058
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25829
|
-
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
26059
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
|
|
25830
26060
|
];
|
|
25831
26061
|
}
|
|
25832
26062
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
@@ -25941,6 +26171,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25941
26171
|
value: next === 'split'
|
|
25942
26172
|
? 'Switched to side-by-side diff'
|
|
25943
26173
|
: 'Switched to unified diff',
|
|
26174
|
+
kind: 'success',
|
|
25944
26175
|
}),
|
|
25945
26176
|
];
|
|
25946
26177
|
}
|
|
@@ -26391,10 +26622,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26391
26622
|
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
26392
26623
|
const head = getCursoredCompareRef(state, context);
|
|
26393
26624
|
if (!head) {
|
|
26394
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26625
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26395
26626
|
}
|
|
26396
26627
|
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
26397
|
-
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
26628
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
|
|
26398
26629
|
}
|
|
26399
26630
|
return [
|
|
26400
26631
|
action({
|
|
@@ -26597,7 +26828,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26597
26828
|
action({ type: 'setFocus', value: 'commits' }),
|
|
26598
26829
|
];
|
|
26599
26830
|
}
|
|
26600
|
-
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
26831
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
|
|
26601
26832
|
}
|
|
26602
26833
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
26603
26834
|
}
|
|
@@ -26708,7 +26939,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26708
26939
|
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
26709
26940
|
const ref = getCursoredCompareRef(state, context);
|
|
26710
26941
|
if (!ref) {
|
|
26711
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26942
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26712
26943
|
}
|
|
26713
26944
|
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
26714
26945
|
return [
|
|
@@ -26939,7 +27170,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26939
27170
|
// Always intercept `C` on the conflicts view to prevent fallthrough to
|
|
26940
27171
|
// the global `C` (Create PR) binding when conflicts remain.
|
|
26941
27172
|
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
26942
|
-
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
27173
|
+
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
|
|
26943
27174
|
}
|
|
26944
27175
|
// Global `C` — create a pull request from the current branch. The
|
|
26945
27176
|
// runtime callback handles pre-flight (current branch resolution,
|
|
@@ -26955,6 +27186,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26955
27186
|
return [action({
|
|
26956
27187
|
type: 'setStatus',
|
|
26957
27188
|
value: 'Finish or cancel the commit draft before creating a PR.',
|
|
27189
|
+
kind: 'warning',
|
|
26958
27190
|
})];
|
|
26959
27191
|
}
|
|
26960
27192
|
if (inputValue === 'C' && state.activeView !== 'conflicts') {
|
|
@@ -27014,7 +27246,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
27014
27246
|
return events;
|
|
27015
27247
|
}
|
|
27016
27248
|
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
27017
|
-
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
27249
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
|
|
27018
27250
|
}
|
|
27019
27251
|
}
|
|
27020
27252
|
// `c` on the history view cherry-picks the full selected commit on
|
|
@@ -27899,7 +28131,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
|
|
|
27899
28131
|
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
27900
28132
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
27901
28133
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
27902
|
-
wide: { min: 28, max:
|
|
28134
|
+
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
27903
28135
|
};
|
|
27904
28136
|
function calcSidebarAtRestWidth(columns, density) {
|
|
27905
28137
|
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
@@ -29852,18 +30084,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
29852
30084
|
];
|
|
29853
30085
|
}
|
|
29854
30086
|
|
|
30087
|
+
function resolveCursorSyncDecision(input) {
|
|
30088
|
+
if (!input.target) {
|
|
30089
|
+
return { type: 'noop', reason: 'no-target' };
|
|
30090
|
+
}
|
|
30091
|
+
if (input.target.hash === input.lastSyncedHash) {
|
|
30092
|
+
return { type: 'noop', reason: 'duplicate-of-last' };
|
|
30093
|
+
}
|
|
30094
|
+
if (isHashLoaded(input.target.hash, input.loadedHashes)) {
|
|
30095
|
+
return {
|
|
30096
|
+
type: 'jump',
|
|
30097
|
+
hash: input.target.hash,
|
|
30098
|
+
label: input.target.label,
|
|
30099
|
+
};
|
|
30100
|
+
}
|
|
30101
|
+
if (input.attemptedContextHashes.has(input.target.hash)) {
|
|
30102
|
+
return { type: 'unreachable', target: input.target };
|
|
30103
|
+
}
|
|
30104
|
+
return { type: 'load-context', target: input.target };
|
|
30105
|
+
}
|
|
30106
|
+
/**
|
|
30107
|
+
* Re-export of the shared `hashLoaded` helper under the resolver's
|
|
30108
|
+
* historical name. Kept exported so existing tests (and any external
|
|
30109
|
+
* importers) keep working unchanged — see `src/git/hashes.ts` for the
|
|
30110
|
+
* canonical implementation and the rationale behind bidirectional
|
|
30111
|
+
* prefix matching.
|
|
30112
|
+
*/
|
|
30113
|
+
function isHashLoaded(hash, loadedHashes) {
|
|
30114
|
+
return hashLoaded(hash, loadedHashes);
|
|
30115
|
+
}
|
|
30116
|
+
/**
|
|
30117
|
+
* Build the membership set the resolver expects. Includes BOTH the
|
|
30118
|
+
* full hash and the short hash for every commit so the caller can
|
|
30119
|
+
* match either form (refs sometimes carry only the short hash and
|
|
30120
|
+
* `state.filteredCommits` items always have both).
|
|
30121
|
+
*
|
|
30122
|
+
* Exported so the cursor-sync effect can build the set once per
|
|
30123
|
+
* re-render and pass it down without leaking the implementation
|
|
30124
|
+
* detail. Tests use it to construct realistic inputs without
|
|
30125
|
+
* hand-rolling the dual-hash logic.
|
|
30126
|
+
*/
|
|
30127
|
+
function buildLoadedHashSet(commits) {
|
|
30128
|
+
const set = new Set();
|
|
30129
|
+
for (const commit of commits) {
|
|
30130
|
+
if (commit.hash)
|
|
30131
|
+
set.add(commit.hash);
|
|
30132
|
+
if (commit.shortHash)
|
|
30133
|
+
set.add(commit.shortHash);
|
|
30134
|
+
}
|
|
30135
|
+
return set;
|
|
30136
|
+
}
|
|
30137
|
+
|
|
29855
30138
|
/**
|
|
29856
|
-
* Status-bar / footer renderer. Two-
|
|
29857
|
-
*
|
|
29858
|
-
*
|
|
29859
|
-
*
|
|
29860
|
-
*
|
|
30139
|
+
* Status-bar / footer renderer. Two-row layout, using the full
|
|
30140
|
+
* `height: 2` the footer already reserves:
|
|
30141
|
+
*
|
|
30142
|
+
* Row 1 — keyboard hint band:
|
|
30143
|
+
* ┌──── contextual hints ────┐ ┌──── globals ────┐
|
|
30144
|
+
* ↑/↓ branches ←/→ tab … ? help · : cmds · q
|
|
30145
|
+
*
|
|
30146
|
+
* Row 2 — status / feedback band:
|
|
30147
|
+
* ⠋ main has no upstream — nothing to fetch.
|
|
30148
|
+
*
|
|
30149
|
+
* Row 2 is empty when there's no status message, idle tip, or error.
|
|
30150
|
+
* This is a behaviour change from the pre-0.54.2 single-row layout
|
|
30151
|
+
* where the status message sat awkwardly between the contextual and
|
|
30152
|
+
* global hints, getting visually crushed.
|
|
29861
30153
|
*
|
|
29862
|
-
*
|
|
30154
|
+
* The separation matters because:
|
|
30155
|
+
* - status text and key hints serve different cognitive purposes
|
|
30156
|
+
* (read vs. scan) and competing for the same row makes both
|
|
30157
|
+
* harder to use,
|
|
30158
|
+
* - long status messages (especially errors / multi-clause loading
|
|
30159
|
+
* copy) no longer push global hints off screen or wrap into the
|
|
30160
|
+
* hint cluster,
|
|
30161
|
+
* - errors now keep the global hints visible — the user often
|
|
30162
|
+
* needs `?` / `:` / `q` to *recover* from the error.
|
|
30163
|
+
*
|
|
30164
|
+
* Idle tips fill row 2 only when no real status message is set so the
|
|
29863
30165
|
* tip cycle never overwrites genuine workflow feedback.
|
|
29864
30166
|
*
|
|
29865
|
-
*
|
|
29866
|
-
*
|
|
30167
|
+
* Row 2 styling is kind-aware. Each statusKind gets its own theme
|
|
30168
|
+
* color and glyph prefix so the message is identifiable at a glance
|
|
30169
|
+
* — even with NO_COLOR set, the glyph alone communicates kind:
|
|
30170
|
+
*
|
|
30171
|
+
* loading → spinner + accent + bold
|
|
30172
|
+
* error → ✗ / ! + danger + bold
|
|
30173
|
+
* warning → ⚠ / ! + warning + bold
|
|
30174
|
+
* success → ✓ / + + success + bold
|
|
30175
|
+
* info → ℹ / i + info + bold
|
|
30176
|
+
* idle tip → no glyph + dim muted (passive)
|
|
30177
|
+
*
|
|
30178
|
+
* Pre-redesign success and loading both used `accent` (cyan), so the
|
|
30179
|
+
* user couldn't tell "done" from "in progress" by color alone. Each
|
|
30180
|
+
* kind now uses its dedicated theme color and ships an ASCII glyph
|
|
30181
|
+
* fallback for `theme.ascii` mode (TERM=dumb / vt100).
|
|
30182
|
+
*
|
|
30183
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30184
|
+
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
30185
|
+
* colors + glyphs added in the same pass.
|
|
29867
30186
|
*/
|
|
29868
30187
|
function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
29869
30188
|
const { Box, Text } = components;
|
|
@@ -29895,50 +30214,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
|
|
|
29895
30214
|
});
|
|
29896
30215
|
// Real status messages always win; idle tips only fill the slot when it
|
|
29897
30216
|
// would otherwise be empty.
|
|
29898
|
-
const
|
|
29899
|
-
const
|
|
29900
|
-
// Loading status gets a spinner prefix in front of the message —
|
|
29901
|
-
// motion makes transient LLM calls (create-PR body, PR fetches,
|
|
29902
|
-
// etc.) feel less frozen even when they're sub-second.
|
|
29903
|
-
const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
|
|
29904
|
-
const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
|
|
29905
|
-
const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
|
|
30217
|
+
const hasStatusMessage = Boolean(state.statusMessage);
|
|
30218
|
+
const isLoading = Boolean(state.statusLoading && hasStatusMessage);
|
|
29906
30219
|
const isError = state.statusKind === 'error';
|
|
30220
|
+
const isWarning = state.statusKind === 'warning';
|
|
29907
30221
|
const isSuccess = state.statusKind === 'success';
|
|
29908
|
-
|
|
29909
|
-
|
|
29910
|
-
|
|
29911
|
-
|
|
29912
|
-
|
|
29913
|
-
|
|
29914
|
-
|
|
29915
|
-
|
|
30222
|
+
// 'info' is the implicit kind when statusKind is undefined but
|
|
30223
|
+
// statusMessage is set — it's a deliberate status update, not an
|
|
30224
|
+
// idle tip, so it gets info treatment rather than the dim fallback.
|
|
30225
|
+
const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
|
|
30226
|
+
const rawTrailing = state.statusMessage || idleTip || '';
|
|
30227
|
+
// Glyphs per kind so the message is identifiable even before reading
|
|
30228
|
+
// the color — improves scan-ability and degrades gracefully when the
|
|
30229
|
+
// terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
|
|
30230
|
+
// = dumb / vt100) where unicode glyphs render as garbage.
|
|
30231
|
+
// loading → spinner (animated)
|
|
30232
|
+
// error → ✗ / !
|
|
30233
|
+
// warning → ⚠ / !
|
|
30234
|
+
// success → ✓ / +
|
|
30235
|
+
// info → ℹ / i
|
|
30236
|
+
// idle tip → no glyph (passive)
|
|
30237
|
+
const glyph = (() => {
|
|
30238
|
+
if (isLoading)
|
|
30239
|
+
return pickSpinnerFrame(spinnerFrame);
|
|
30240
|
+
if (isError)
|
|
30241
|
+
return theme.ascii ? '!' : '✗';
|
|
30242
|
+
if (isWarning)
|
|
30243
|
+
return theme.ascii ? '!' : '⚠';
|
|
30244
|
+
if (isSuccess)
|
|
30245
|
+
return theme.ascii ? '+' : '✓';
|
|
30246
|
+
if (isInfo)
|
|
30247
|
+
return theme.ascii ? 'i' : 'ℹ';
|
|
30248
|
+
return '';
|
|
30249
|
+
})();
|
|
30250
|
+
const statusBody = rawTrailing
|
|
30251
|
+
? glyph
|
|
30252
|
+
? `${glyph} ${rawTrailing}`
|
|
30253
|
+
: rawTrailing
|
|
30254
|
+
: '';
|
|
30255
|
+
// Row 2 color picks. Each kind gets its own theme color so success
|
|
30256
|
+
// and loading are visually distinct (was conflated under `accent`
|
|
30257
|
+
// pre-redesign — users couldn't tell "done" from "in progress").
|
|
30258
|
+
// loading → accent (cyan / preset blue)
|
|
30259
|
+
// error → danger (red / preset red)
|
|
30260
|
+
// warning → warning (yellow)
|
|
30261
|
+
// success → success (green)
|
|
30262
|
+
// info → info (blue / preset accent in light themes)
|
|
30263
|
+
// idle → undefined + dim (passive, blends with chrome)
|
|
30264
|
+
const statusColor = isError
|
|
30265
|
+
? theme.colors.danger
|
|
30266
|
+
: isWarning
|
|
30267
|
+
? theme.colors.warning
|
|
30268
|
+
: isSuccess
|
|
30269
|
+
? theme.colors.success
|
|
30270
|
+
: isLoading
|
|
30271
|
+
? theme.colors.accent
|
|
30272
|
+
: isInfo
|
|
30273
|
+
? theme.colors.info
|
|
30274
|
+
: undefined;
|
|
30275
|
+
const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
|
|
30276
|
+
const statusDim = !statusBold;
|
|
30277
|
+
const hintsText = hints.contextual.join(' ');
|
|
29916
30278
|
const globalText = hints.global.join(' · ');
|
|
29917
|
-
|
|
29918
|
-
//
|
|
29919
|
-
//
|
|
29920
|
-
//
|
|
29921
|
-
|
|
29922
|
-
|
|
29923
|
-
|
|
29924
|
-
|
|
29925
|
-
|
|
29926
|
-
|
|
29927
|
-
|
|
29928
|
-
|
|
29929
|
-
|
|
29930
|
-
|
|
29931
|
-
|
|
29932
|
-
|
|
29933
|
-
|
|
29934
|
-
|
|
29935
|
-
|
|
29936
|
-
|
|
29937
|
-
|
|
29938
|
-
|
|
29939
|
-
|
|
29940
|
-
|
|
29941
|
-
|
|
30279
|
+
return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
|
|
30280
|
+
// Row 1: contextual ↔ global hints. justifyContent pushes them
|
|
30281
|
+
// to opposite edges so the eye can scan each cluster as one
|
|
30282
|
+
// block instead of hunting through a single concatenated line.
|
|
30283
|
+
h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
|
|
30284
|
+
// Row 2: status / loading / idle tip / error. Empty Text keeps
|
|
30285
|
+
// the row reserved when nothing's set so the surrounding layout
|
|
30286
|
+
// doesn't shift as status flips on/off.
|
|
30287
|
+
h(Text, {
|
|
30288
|
+
color: statusColor,
|
|
30289
|
+
dimColor: statusDim,
|
|
30290
|
+
bold: statusBold,
|
|
30291
|
+
}, statusBody));
|
|
30292
|
+
}
|
|
30293
|
+
|
|
30294
|
+
const COMBINING_MARK_RANGES = [
|
|
30295
|
+
[0x0300, 0x036f],
|
|
30296
|
+
[0x1ab0, 0x1aff],
|
|
30297
|
+
[0x1dc0, 0x1dff],
|
|
30298
|
+
[0x20d0, 0x20ff],
|
|
30299
|
+
[0xfe20, 0xfe2f],
|
|
30300
|
+
];
|
|
30301
|
+
const WIDE_CHARACTER_RANGES = [
|
|
30302
|
+
[0x1100, 0x115f],
|
|
30303
|
+
[0x2329, 0x232a],
|
|
30304
|
+
[0x2e80, 0xa4cf],
|
|
30305
|
+
[0xac00, 0xd7a3],
|
|
30306
|
+
[0xf900, 0xfaff],
|
|
30307
|
+
[0xfe10, 0xfe19],
|
|
30308
|
+
[0xfe30, 0xfe6f],
|
|
30309
|
+
[0xff00, 0xff60],
|
|
30310
|
+
[0xffe0, 0xffe6],
|
|
30311
|
+
[0x2600, 0x27bf],
|
|
30312
|
+
[0x1f000, 0x1f9ff],
|
|
30313
|
+
[0x20000, 0x3fffd],
|
|
30314
|
+
];
|
|
30315
|
+
function isInRange(codePoint, ranges) {
|
|
30316
|
+
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30317
|
+
}
|
|
30318
|
+
function characterWidth(character) {
|
|
30319
|
+
const codePoint = character.codePointAt(0) || 0;
|
|
30320
|
+
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30321
|
+
return 0;
|
|
30322
|
+
}
|
|
30323
|
+
if (codePoint === 0x200d ||
|
|
30324
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30325
|
+
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30326
|
+
return 0;
|
|
30327
|
+
}
|
|
30328
|
+
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30329
|
+
}
|
|
30330
|
+
function cellWidth(value) {
|
|
30331
|
+
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30332
|
+
}
|
|
30333
|
+
/**
|
|
30334
|
+
* Word-wrap `value` into lines that each fit within `width` cells. Breaks
|
|
30335
|
+
* on whitespace where possible; falls back to mid-word splits when a single
|
|
30336
|
+
* word is wider than the budget. Preserves blank input as a single empty
|
|
30337
|
+
* line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
|
|
30338
|
+
*/
|
|
30339
|
+
function wrapCells(value, width) {
|
|
30340
|
+
if (width < 1) {
|
|
30341
|
+
return [value];
|
|
30342
|
+
}
|
|
30343
|
+
if (cellWidth(value) <= width) {
|
|
30344
|
+
return [value];
|
|
30345
|
+
}
|
|
30346
|
+
const lines = [];
|
|
30347
|
+
let current = '';
|
|
30348
|
+
let currentWidth = 0;
|
|
30349
|
+
const flush = () => {
|
|
30350
|
+
if (current.length > 0) {
|
|
30351
|
+
lines.push(current);
|
|
30352
|
+
current = '';
|
|
30353
|
+
currentWidth = 0;
|
|
30354
|
+
}
|
|
30355
|
+
};
|
|
30356
|
+
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30357
|
+
// boundaries when possible.
|
|
30358
|
+
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30359
|
+
for (const token of tokens) {
|
|
30360
|
+
const tokenWidth = cellWidth(token);
|
|
30361
|
+
if (currentWidth + tokenWidth <= width) {
|
|
30362
|
+
current += token;
|
|
30363
|
+
currentWidth += tokenWidth;
|
|
30364
|
+
continue;
|
|
30365
|
+
}
|
|
30366
|
+
if (/^\s+$/.test(token)) {
|
|
30367
|
+
// Drop boundary whitespace at line breaks.
|
|
30368
|
+
flush();
|
|
30369
|
+
continue;
|
|
30370
|
+
}
|
|
30371
|
+
flush();
|
|
30372
|
+
if (tokenWidth <= width) {
|
|
30373
|
+
current = token;
|
|
30374
|
+
currentWidth = tokenWidth;
|
|
30375
|
+
continue;
|
|
30376
|
+
}
|
|
30377
|
+
// Word longer than budget — hard-split into chunks.
|
|
30378
|
+
let remaining = token;
|
|
30379
|
+
while (cellWidth(remaining) > width) {
|
|
30380
|
+
let chunk = '';
|
|
30381
|
+
let chunkWidth = 0;
|
|
30382
|
+
for (const character of Array.from(remaining)) {
|
|
30383
|
+
const charW = characterWidth(character);
|
|
30384
|
+
if (chunkWidth + charW > width)
|
|
30385
|
+
break;
|
|
30386
|
+
chunk += character;
|
|
30387
|
+
chunkWidth += charW;
|
|
30388
|
+
}
|
|
30389
|
+
lines.push(chunk);
|
|
30390
|
+
remaining = remaining.slice(chunk.length);
|
|
30391
|
+
}
|
|
30392
|
+
if (remaining.length > 0) {
|
|
30393
|
+
current = remaining;
|
|
30394
|
+
currentWidth = cellWidth(remaining);
|
|
30395
|
+
}
|
|
30396
|
+
}
|
|
30397
|
+
flush();
|
|
30398
|
+
return lines.length > 0 ? lines : [value];
|
|
30399
|
+
}
|
|
30400
|
+
function truncateCells(value, width) {
|
|
30401
|
+
if (width < 1) {
|
|
30402
|
+
return '';
|
|
30403
|
+
}
|
|
30404
|
+
if (cellWidth(value) <= width) {
|
|
30405
|
+
return value;
|
|
30406
|
+
}
|
|
30407
|
+
const suffix = width > 3 ? '...' : '';
|
|
30408
|
+
const available = width - cellWidth(suffix);
|
|
30409
|
+
let used = 0;
|
|
30410
|
+
let output = '';
|
|
30411
|
+
for (const character of Array.from(value)) {
|
|
30412
|
+
const nextWidth = characterWidth(character);
|
|
30413
|
+
if (used + nextWidth > available) {
|
|
30414
|
+
break;
|
|
30415
|
+
}
|
|
30416
|
+
output += character;
|
|
30417
|
+
used += nextWidth;
|
|
30418
|
+
}
|
|
30419
|
+
return `${output}${suffix}`;
|
|
30420
|
+
}
|
|
30421
|
+
/**
|
|
30422
|
+
* Truncate a file path so the filename (last segment) is preserved,
|
|
30423
|
+
* eliding middle directory segments with `…/` instead of dropping
|
|
30424
|
+
* end-of-string characters.
|
|
30425
|
+
*
|
|
30426
|
+
* `truncateCells` is the wrong tool for paths because it preserves the
|
|
30427
|
+
* START of the string and drops the END — losing the filename, which
|
|
30428
|
+
* is the most useful part. Example with `truncateCells`:
|
|
30429
|
+
*
|
|
30430
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
|
|
30431
|
+
*
|
|
30432
|
+
* `truncatePathCells` preserves the filename and elides middle:
|
|
30433
|
+
*
|
|
30434
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
|
|
30435
|
+
*
|
|
30436
|
+
* The algorithm tries successively-smaller prefixes (keeping the start
|
|
30437
|
+
* of the path, the filename, and replacing the dropped middle segments
|
|
30438
|
+
* with `…`) and returns the largest variant that fits. When even
|
|
30439
|
+
* `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
|
|
30440
|
+
* the abbreviated form — better to show end-of-name than start-of-path.
|
|
30441
|
+
*
|
|
30442
|
+
* For inputs without `/` separators, behaves identically to
|
|
30443
|
+
* `truncateCells`. Empty / width-0 cases match `truncateCells` too.
|
|
30444
|
+
*
|
|
30445
|
+
* @example
|
|
30446
|
+
* truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
|
|
30447
|
+
* truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
|
|
30448
|
+
* truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
|
|
30449
|
+
* truncatePathCells('plainname.ts', 8) // 'plain...'
|
|
30450
|
+
*/
|
|
30451
|
+
function truncatePathCells(value, width) {
|
|
30452
|
+
if (width < 1)
|
|
30453
|
+
return '';
|
|
30454
|
+
if (cellWidth(value) <= width)
|
|
30455
|
+
return value;
|
|
30456
|
+
// No path structure to exploit — fall through to plain truncation.
|
|
30457
|
+
if (!value.includes('/'))
|
|
30458
|
+
return truncateCells(value, width);
|
|
30459
|
+
const segments = value.split('/');
|
|
30460
|
+
const filename = segments[segments.length - 1] ?? '';
|
|
30461
|
+
const prefix = segments.slice(0, -1);
|
|
30462
|
+
// Path is just '/filename' or has only the filename — no middle to
|
|
30463
|
+
// elide. Defer to plain truncation.
|
|
30464
|
+
if (prefix.length === 0)
|
|
30465
|
+
return truncateCells(value, width);
|
|
30466
|
+
// Walk from "keep all prefix segments except the deepest" down to
|
|
30467
|
+
// "keep no prefix segments." First variant that fits wins.
|
|
30468
|
+
for (let keep = prefix.length - 1; keep >= 0; keep--) {
|
|
30469
|
+
const candidate = keep === 0
|
|
30470
|
+
? `…/${filename}`
|
|
30471
|
+
: `${prefix.slice(0, keep).join('/')}/…/${filename}`;
|
|
30472
|
+
if (cellWidth(candidate) <= width)
|
|
30473
|
+
return candidate;
|
|
30474
|
+
}
|
|
30475
|
+
// Even `…/<filename>` doesn't fit. Use plain truncation on that
|
|
30476
|
+
// form — preserves the leading `…/` so the user knows a path was
|
|
30477
|
+
// elided, then ellipsis-truncates the filename.
|
|
30478
|
+
return truncateCells(`…/${filename}`, width);
|
|
29942
30479
|
}
|
|
29943
30480
|
|
|
29944
30481
|
/**
|
|
@@ -30165,218 +30702,318 @@ function sidebarTabCount(tab, context) {
|
|
|
30165
30702
|
}
|
|
30166
30703
|
}
|
|
30167
30704
|
|
|
30168
|
-
const COMBINING_MARK_RANGES = [
|
|
30169
|
-
[0x0300, 0x036f],
|
|
30170
|
-
[0x1ab0, 0x1aff],
|
|
30171
|
-
[0x1dc0, 0x1dff],
|
|
30172
|
-
[0x20d0, 0x20ff],
|
|
30173
|
-
[0xfe20, 0xfe2f],
|
|
30174
|
-
];
|
|
30175
|
-
const WIDE_CHARACTER_RANGES = [
|
|
30176
|
-
[0x1100, 0x115f],
|
|
30177
|
-
[0x2329, 0x232a],
|
|
30178
|
-
[0x2e80, 0xa4cf],
|
|
30179
|
-
[0xac00, 0xd7a3],
|
|
30180
|
-
[0xf900, 0xfaff],
|
|
30181
|
-
[0xfe10, 0xfe19],
|
|
30182
|
-
[0xfe30, 0xfe6f],
|
|
30183
|
-
[0xff00, 0xff60],
|
|
30184
|
-
[0xffe0, 0xffe6],
|
|
30185
|
-
[0x2600, 0x27bf],
|
|
30186
|
-
[0x1f000, 0x1f9ff],
|
|
30187
|
-
[0x20000, 0x3fffd],
|
|
30188
|
-
];
|
|
30189
|
-
function isInRange(codePoint, ranges) {
|
|
30190
|
-
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30191
|
-
}
|
|
30192
|
-
function characterWidth(character) {
|
|
30193
|
-
const codePoint = character.codePointAt(0) || 0;
|
|
30194
|
-
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30195
|
-
return 0;
|
|
30196
|
-
}
|
|
30197
|
-
if (codePoint === 0x200d ||
|
|
30198
|
-
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30199
|
-
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30200
|
-
return 0;
|
|
30201
|
-
}
|
|
30202
|
-
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30203
|
-
}
|
|
30204
|
-
function cellWidth(value) {
|
|
30205
|
-
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30206
|
-
}
|
|
30207
30705
|
/**
|
|
30208
|
-
*
|
|
30209
|
-
*
|
|
30210
|
-
*
|
|
30211
|
-
*
|
|
30706
|
+
* Header chip builder. Turns the workstation's title-bar state into an
|
|
30707
|
+
* ordered list of small visually-distinct chips:
|
|
30708
|
+
*
|
|
30709
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30710
|
+
*
|
|
30711
|
+
* Pre-refactor the title bar concatenated every segment into a single
|
|
30712
|
+
* Text span, which made the eye read the whole thing as one run of
|
|
30713
|
+
* words (the same problem the footer had). Splitting into chips with a
|
|
30714
|
+
* fixed separator lets each segment carry its own color and lets the
|
|
30715
|
+
* user scan the bar in chunks — "what app, what repo, what branch,
|
|
30716
|
+
* how clean, what PR state, what mode" — instead of parsing left-to-
|
|
30717
|
+
* right.
|
|
30718
|
+
*
|
|
30719
|
+
* Why a separate module: the header runtime renders chips and handles
|
|
30720
|
+
* truncation; chip construction is pure transformation of state +
|
|
30721
|
+
* context + theme. Splitting them keeps the chips testable in
|
|
30722
|
+
* isolation and keeps the runtime small.
|
|
30723
|
+
*
|
|
30724
|
+
* Truncation strategy lives in the consumer, not here — when the total
|
|
30725
|
+
* width exceeds the column budget, the header falls back to the
|
|
30726
|
+
* pre-redesign single-fragment truncated string so the ellipsis can't
|
|
30727
|
+
* land mid-glyph. We always return the FULL chip list; the consumer
|
|
30728
|
+
* decides whether to drop chips, fall back, or render all of them.
|
|
30212
30729
|
*/
|
|
30213
|
-
|
|
30214
|
-
|
|
30215
|
-
|
|
30730
|
+
/**
|
|
30731
|
+
* Default separator inserted between chips by the consumer. Exported as
|
|
30732
|
+
* a constant so tests and width math agree on what they're measuring.
|
|
30733
|
+
* The trailing/leading spaces are part of the separator — `·` alone
|
|
30734
|
+
* would butt against adjacent chip labels.
|
|
30735
|
+
*/
|
|
30736
|
+
const HEADER_CHIP_SEPARATOR = ' · ';
|
|
30737
|
+
/**
|
|
30738
|
+
* Build the ordered chip list for the header. Chips not relevant to the
|
|
30739
|
+
* current state (no PR loaded, no breadcrumb, no search input, …) are
|
|
30740
|
+
* omitted entirely rather than rendered as empty placeholders, so the
|
|
30741
|
+
* consumer can just `chips.map(render)` without checking for empties.
|
|
30742
|
+
*/
|
|
30743
|
+
function buildHeaderChips(input) {
|
|
30744
|
+
const { theme } = input;
|
|
30745
|
+
const chips = [];
|
|
30746
|
+
// App label — the constant identity. Accent + bold so it anchors the
|
|
30747
|
+
// left edge of the bar.
|
|
30748
|
+
chips.push({
|
|
30749
|
+
id: 'app',
|
|
30750
|
+
label: input.appLabel,
|
|
30751
|
+
color: theme.colors.accent,
|
|
30752
|
+
dim: false,
|
|
30753
|
+
bold: true,
|
|
30754
|
+
});
|
|
30755
|
+
// Repo. Default color — it's contextual but not the headline.
|
|
30756
|
+
chips.push({
|
|
30757
|
+
id: 'repo',
|
|
30758
|
+
label: input.repo,
|
|
30759
|
+
color: undefined,
|
|
30760
|
+
dim: false,
|
|
30761
|
+
bold: false,
|
|
30762
|
+
});
|
|
30763
|
+
// Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
|
|
30764
|
+
// is identifiable even when the branch name is generic ("main" /
|
|
30765
|
+
// "master").
|
|
30766
|
+
const branchGlyph = theme.ascii ? 'git:' : '⎇';
|
|
30767
|
+
chips.push({
|
|
30768
|
+
id: 'branch',
|
|
30769
|
+
label: `${branchGlyph} ${input.branch}`,
|
|
30770
|
+
color: theme.colors.accent,
|
|
30771
|
+
dim: false,
|
|
30772
|
+
bold: true,
|
|
30773
|
+
});
|
|
30774
|
+
// Dirty/clean. Positive framing on clean (success color + ✓), warning
|
|
30775
|
+
// on dirty (warning color + ●). ASCII fallbacks keep the chip
|
|
30776
|
+
// identifiable on dumb terminals.
|
|
30777
|
+
const dirtyChip = input.dirty
|
|
30778
|
+
? {
|
|
30779
|
+
id: 'dirty',
|
|
30780
|
+
label: theme.ascii ? '* dirty' : '● dirty',
|
|
30781
|
+
color: theme.colors.warning,
|
|
30782
|
+
dim: false,
|
|
30783
|
+
bold: false,
|
|
30784
|
+
}
|
|
30785
|
+
: {
|
|
30786
|
+
id: 'dirty',
|
|
30787
|
+
label: theme.ascii ? '+ clean' : '✓ clean',
|
|
30788
|
+
color: theme.colors.success,
|
|
30789
|
+
dim: false,
|
|
30790
|
+
bold: false,
|
|
30791
|
+
};
|
|
30792
|
+
chips.push(dirtyChip);
|
|
30793
|
+
// Bisect — only when active. Distinct chip so users entering the TUI
|
|
30794
|
+
// mid-bisect see it immediately (#784). Warning color because bisect
|
|
30795
|
+
// is an "in progress, requires user action" state.
|
|
30796
|
+
if (input.bisecting) {
|
|
30797
|
+
chips.push({
|
|
30798
|
+
id: 'bisecting',
|
|
30799
|
+
label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
|
|
30800
|
+
color: theme.colors.warning,
|
|
30801
|
+
dim: false,
|
|
30802
|
+
bold: true,
|
|
30803
|
+
});
|
|
30216
30804
|
}
|
|
30217
|
-
|
|
30218
|
-
|
|
30805
|
+
// PR state. When present, the chip uses the PR-state glyph + a short
|
|
30806
|
+
// label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
|
|
30807
|
+
// "no PR" chip so users know the system DID look (vs. the bar just
|
|
30808
|
+
// being blank).
|
|
30809
|
+
if (input.pullRequest) {
|
|
30810
|
+
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
30811
|
+
const stateLabel = input.pullRequest.isDraft
|
|
30812
|
+
? 'DRAFT'
|
|
30813
|
+
: input.pullRequest.state.toUpperCase();
|
|
30814
|
+
const label = prGlyph.glyph
|
|
30815
|
+
? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
|
|
30816
|
+
: `PR #${input.pullRequest.number} ${stateLabel}`;
|
|
30817
|
+
chips.push({
|
|
30818
|
+
id: 'pr',
|
|
30819
|
+
label,
|
|
30820
|
+
color: prGlyph.color,
|
|
30821
|
+
dim: prGlyph.dim,
|
|
30822
|
+
bold: false,
|
|
30823
|
+
});
|
|
30219
30824
|
}
|
|
30220
|
-
|
|
30221
|
-
|
|
30222
|
-
|
|
30223
|
-
|
|
30224
|
-
|
|
30225
|
-
|
|
30226
|
-
|
|
30227
|
-
|
|
30228
|
-
}
|
|
30229
|
-
};
|
|
30230
|
-
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30231
|
-
// boundaries when possible.
|
|
30232
|
-
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30233
|
-
for (const token of tokens) {
|
|
30234
|
-
const tokenWidth = cellWidth(token);
|
|
30235
|
-
if (currentWidth + tokenWidth <= width) {
|
|
30236
|
-
current += token;
|
|
30237
|
-
currentWidth += tokenWidth;
|
|
30238
|
-
continue;
|
|
30239
|
-
}
|
|
30240
|
-
if (/^\s+$/.test(token)) {
|
|
30241
|
-
// Drop boundary whitespace at line breaks.
|
|
30242
|
-
flush();
|
|
30243
|
-
continue;
|
|
30244
|
-
}
|
|
30245
|
-
flush();
|
|
30246
|
-
if (tokenWidth <= width) {
|
|
30247
|
-
current = token;
|
|
30248
|
-
currentWidth = tokenWidth;
|
|
30249
|
-
continue;
|
|
30250
|
-
}
|
|
30251
|
-
// Word longer than budget — hard-split into chunks.
|
|
30252
|
-
let remaining = token;
|
|
30253
|
-
while (cellWidth(remaining) > width) {
|
|
30254
|
-
let chunk = '';
|
|
30255
|
-
let chunkWidth = 0;
|
|
30256
|
-
for (const character of Array.from(remaining)) {
|
|
30257
|
-
const charW = characterWidth(character);
|
|
30258
|
-
if (chunkWidth + charW > width)
|
|
30259
|
-
break;
|
|
30260
|
-
chunk += character;
|
|
30261
|
-
chunkWidth += charW;
|
|
30262
|
-
}
|
|
30263
|
-
lines.push(chunk);
|
|
30264
|
-
remaining = remaining.slice(chunk.length);
|
|
30265
|
-
}
|
|
30266
|
-
if (remaining.length > 0) {
|
|
30267
|
-
current = remaining;
|
|
30268
|
-
currentWidth = cellWidth(remaining);
|
|
30269
|
-
}
|
|
30825
|
+
else {
|
|
30826
|
+
chips.push({
|
|
30827
|
+
id: 'pr',
|
|
30828
|
+
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
30829
|
+
color: theme.colors.muted,
|
|
30830
|
+
dim: true,
|
|
30831
|
+
bold: false,
|
|
30832
|
+
});
|
|
30270
30833
|
}
|
|
30271
|
-
|
|
30272
|
-
|
|
30273
|
-
|
|
30274
|
-
|
|
30275
|
-
|
|
30276
|
-
|
|
30834
|
+
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
30835
|
+
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
30836
|
+
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
30837
|
+
// dirty/PR) reads as one cluster and the "navigation" group (view
|
|
30838
|
+
// breadcrumb / loading) reads as a separate cluster.
|
|
30839
|
+
if (input.breadcrumb) {
|
|
30840
|
+
chips.push({
|
|
30841
|
+
id: 'view',
|
|
30842
|
+
label: input.breadcrumb,
|
|
30843
|
+
color: theme.colors.muted,
|
|
30844
|
+
dim: true,
|
|
30845
|
+
bold: false,
|
|
30846
|
+
});
|
|
30277
30847
|
}
|
|
30278
|
-
if (
|
|
30279
|
-
|
|
30848
|
+
if (input.loading) {
|
|
30849
|
+
chips.push({
|
|
30850
|
+
id: 'loading',
|
|
30851
|
+
label: input.loading.trim(),
|
|
30852
|
+
color: theme.colors.muted,
|
|
30853
|
+
dim: true,
|
|
30854
|
+
bold: false,
|
|
30855
|
+
});
|
|
30280
30856
|
}
|
|
30281
|
-
|
|
30282
|
-
|
|
30283
|
-
|
|
30284
|
-
|
|
30285
|
-
|
|
30286
|
-
|
|
30287
|
-
|
|
30288
|
-
|
|
30289
|
-
|
|
30290
|
-
|
|
30291
|
-
|
|
30857
|
+
// Mode — the explicit input-mode indicator (#P2.2). Always present
|
|
30858
|
+
// so users never wonder why `q` doesn't quit while they're editing.
|
|
30859
|
+
// EDIT / FILTER use the warning color to signal "your keystrokes
|
|
30860
|
+
// mean something different right now"; NORMAL uses accent (matches
|
|
30861
|
+
// the app chip's home base).
|
|
30862
|
+
const modeColor = input.mode === 'NORMAL'
|
|
30863
|
+
? theme.colors.accent
|
|
30864
|
+
: theme.colors.warning;
|
|
30865
|
+
chips.push({
|
|
30866
|
+
id: 'mode',
|
|
30867
|
+
label: `[${input.mode}]`,
|
|
30868
|
+
color: modeColor,
|
|
30869
|
+
dim: false,
|
|
30870
|
+
bold: true,
|
|
30871
|
+
});
|
|
30872
|
+
// Search — only when active. Dim so it doesn't compete with the
|
|
30873
|
+
// identity chips for attention; the user knows it's there because
|
|
30874
|
+
// they're typing into it.
|
|
30875
|
+
if (input.search) {
|
|
30876
|
+
chips.push({
|
|
30877
|
+
id: 'search',
|
|
30878
|
+
label: input.search,
|
|
30879
|
+
color: theme.colors.muted,
|
|
30880
|
+
dim: true,
|
|
30881
|
+
bold: false,
|
|
30882
|
+
});
|
|
30292
30883
|
}
|
|
30293
|
-
return
|
|
30884
|
+
return chips;
|
|
30885
|
+
}
|
|
30886
|
+
/**
|
|
30887
|
+
* Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
|
|
30888
|
+
* between every pair. Used by the consumer to decide whether the
|
|
30889
|
+
* chip layout fits the column budget or whether to fall back to the
|
|
30890
|
+
* single-fragment truncated path.
|
|
30891
|
+
*/
|
|
30892
|
+
function measureHeaderChipsWidth(chips) {
|
|
30893
|
+
if (chips.length === 0)
|
|
30894
|
+
return 0;
|
|
30895
|
+
const labels = chips.map((chip) => cellWidth(chip.label));
|
|
30896
|
+
const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
|
|
30897
|
+
return labels.reduce((sum, w) => sum + w, 0) + separators;
|
|
30294
30898
|
}
|
|
30295
30899
|
|
|
30296
30900
|
/**
|
|
30297
|
-
* Title-bar renderer. Surfaces
|
|
30298
|
-
*
|
|
30299
|
-
* - current repo owner/name (or "local repository")
|
|
30300
|
-
* - current branch + dirty / BISECTING flag
|
|
30301
|
-
* - PR glyph + label when one is detected
|
|
30302
|
-
* - breadcrumb of the view stack
|
|
30303
|
-
* - loading hint for boot / context fetches
|
|
30304
|
-
* - mode indicator: [NORMAL] / [EDIT] / [FILTER]
|
|
30305
|
-
* - active filter / search input
|
|
30901
|
+
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
30902
|
+
* state as a row of small visually-distinct chips:
|
|
30306
30903
|
*
|
|
30307
|
-
*
|
|
30308
|
-
* fall back to a single-fragment Text (truncating the joined string) so
|
|
30309
|
-
* the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
|
|
30310
|
-
* glyph in its own colored span when there's headroom.
|
|
30904
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30311
30905
|
*
|
|
30312
|
-
*
|
|
30313
|
-
*
|
|
30906
|
+
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
30907
|
+
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
30908
|
+
* instead of parsing one long sentence. Chip construction is in
|
|
30909
|
+
* `chrome/headerChips.ts`; this runtime just renders.
|
|
30910
|
+
*
|
|
30911
|
+
* Truncation: when the assembled chip row overruns the available
|
|
30912
|
+
* columns we fall back to a single Text fragment (truncating the
|
|
30913
|
+
* joined chip labels) so the ellipsis can't land mid-glyph. This is
|
|
30914
|
+
* the same defensive pattern the pre-redesign single-fragment code
|
|
30915
|
+
* used, applied at the chip-list level instead of the inline glyph
|
|
30916
|
+
* split.
|
|
30917
|
+
*
|
|
30918
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30919
|
+
* 5a.7 of #890. Chip restructuring introduced post-0.54.2.
|
|
30314
30920
|
*/
|
|
30315
30921
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
30316
30922
|
const { Box, Text } = components;
|
|
30923
|
+
// Pull the source state into the small "describe what to render"
|
|
30924
|
+
// shape the chip builder expects. Keeps the runtime decoupled from
|
|
30925
|
+
// the chip layout — the builder doesn't know about LogInkState /
|
|
30926
|
+
// LogInkContext, just plain values.
|
|
30317
30927
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
30318
|
-
|
|
30319
|
-
|
|
30320
|
-
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
30321
|
-
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
30928
|
+
const dirty = Boolean(context.branches?.dirty);
|
|
30929
|
+
const bisecting = Boolean(context.bisect?.active);
|
|
30322
30930
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
30323
30931
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
30324
30932
|
: 'local repository';
|
|
30325
30933
|
const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
30326
|
-
|
|
30327
|
-
|
|
30328
|
-
|
|
30329
|
-
: 'no PR';
|
|
30330
|
-
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
30331
|
-
// Boot loading wins over the per-context loading hint because it
|
|
30332
|
-
// tells the user the headline thing they care about (commits aren't
|
|
30333
|
-
// ready yet) — the context fetches finish independently and surface
|
|
30334
|
-
// their own per-section loading copy in the sidebars.
|
|
30934
|
+
// Boot loading wins over the per-context loading hint — same
|
|
30935
|
+
// priority as pre-redesign. Context fetches still surface their own
|
|
30936
|
+
// copy in the sidebars.
|
|
30335
30937
|
const loading = state.bootLoading
|
|
30336
|
-
? '
|
|
30337
|
-
: isLogInkContextLoading(contextStatus) ? '
|
|
30938
|
+
? 'loading commits'
|
|
30939
|
+
: isLogInkContextLoading(contextStatus) ? 'loading context' : '';
|
|
30338
30940
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
30339
30941
|
const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
|
|
30340
|
-
// Repo breadcrumb (when nested) comes first so the user sees which
|
|
30341
|
-
// submodule they're in at a glance, then the view breadcrumb (when
|
|
30342
|
-
// pushed deeper than the root view). The truncate fallback in the
|
|
30343
|
-
// title row still applies — when both fight for space, the ellipsis
|
|
30344
|
-
// lands at the end of whichever segment overflows.
|
|
30345
30942
|
const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
|
|
30346
|
-
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
30347
|
-
// never wonder why `q` doesn't quit while they're editing or filtering.
|
|
30348
30943
|
const mode = state.commitCompose.editing
|
|
30349
|
-
? '
|
|
30944
|
+
? 'EDIT'
|
|
30350
30945
|
: state.filterMode
|
|
30351
|
-
? '
|
|
30352
|
-
: '
|
|
30353
|
-
const
|
|
30354
|
-
|
|
30355
|
-
|
|
30356
|
-
|
|
30357
|
-
|
|
30358
|
-
const
|
|
30359
|
-
|
|
30360
|
-
|
|
30361
|
-
|
|
30362
|
-
|
|
30363
|
-
|
|
30364
|
-
?
|
|
30365
|
-
|
|
30366
|
-
|
|
30367
|
-
:
|
|
30946
|
+
? 'FILTER'
|
|
30947
|
+
: 'NORMAL';
|
|
30948
|
+
const search = state.filterMode
|
|
30949
|
+
? `search: ${state.filter}_`
|
|
30950
|
+
: state.filter
|
|
30951
|
+
? `filter: ${state.filter}`
|
|
30952
|
+
: '';
|
|
30953
|
+
const chips = buildHeaderChips({
|
|
30954
|
+
appLabel,
|
|
30955
|
+
repo,
|
|
30956
|
+
branch,
|
|
30957
|
+
dirty,
|
|
30958
|
+
bisecting,
|
|
30959
|
+
pullRequest: prInfo ? {
|
|
30960
|
+
number: prInfo.number,
|
|
30961
|
+
state: prInfo.state,
|
|
30962
|
+
isDraft: prInfo.isDraft,
|
|
30963
|
+
} : undefined,
|
|
30964
|
+
breadcrumb: view,
|
|
30965
|
+
loading,
|
|
30966
|
+
mode,
|
|
30967
|
+
search: search ? truncateCells(search, 36) : '',
|
|
30968
|
+
theme,
|
|
30969
|
+
});
|
|
30970
|
+
// Truncation budget. Header line gets the full terminal width minus
|
|
30971
|
+
// the box's horizontal padding (2 cells) and a small safety margin.
|
|
30972
|
+
const budget = Math.max(0, columns - 4);
|
|
30973
|
+
const chipsWidth = measureHeaderChipsWidth(chips);
|
|
30368
30974
|
return h(Box, {
|
|
30369
30975
|
borderColor: theme.colors.border,
|
|
30370
30976
|
borderStyle: theme.borderStyle,
|
|
30371
30977
|
height: 3,
|
|
30372
30978
|
paddingX: 1,
|
|
30373
|
-
},
|
|
30374
|
-
? h
|
|
30375
|
-
: h
|
|
30376
|
-
|
|
30377
|
-
|
|
30378
|
-
|
|
30379
|
-
|
|
30979
|
+
}, chipsWidth <= budget
|
|
30980
|
+
? renderChipRow(h, Text, chips)
|
|
30981
|
+
: renderFallback(h, Text, chips, theme, budget));
|
|
30982
|
+
}
|
|
30983
|
+
/**
|
|
30984
|
+
* Render every chip as its own Text span with its own color/style,
|
|
30985
|
+
* interleaved with dim separator spans. This is the path used when
|
|
30986
|
+
* everything fits — the eye gets the full chip treatment.
|
|
30987
|
+
*/
|
|
30988
|
+
function renderChipRow(h, Text, chips) {
|
|
30989
|
+
const nodes = [];
|
|
30990
|
+
chips.forEach((chip, index) => {
|
|
30991
|
+
if (index > 0) {
|
|
30992
|
+
// Separator is intentionally dim so the eye can use it as a
|
|
30993
|
+
// visual delimiter without it competing with chip labels for
|
|
30994
|
+
// attention.
|
|
30995
|
+
nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
|
|
30996
|
+
}
|
|
30997
|
+
nodes.push(h(Text, {
|
|
30998
|
+
key: chip.id,
|
|
30999
|
+
color: chip.color,
|
|
31000
|
+
dimColor: chip.dim,
|
|
31001
|
+
bold: chip.bold,
|
|
31002
|
+
}, chip.label));
|
|
31003
|
+
});
|
|
31004
|
+
return nodes;
|
|
31005
|
+
}
|
|
31006
|
+
/**
|
|
31007
|
+
* Fallback path for narrow terminals. Concatenates every chip label
|
|
31008
|
+
* with separators, then truncates the whole string with
|
|
31009
|
+
* `truncateCells` so the ellipsis lands at a cell boundary. Loses the
|
|
31010
|
+
* per-chip color treatment in exchange for guaranteed legibility on
|
|
31011
|
+
* narrow displays — the same trade-off the pre-redesign single-
|
|
31012
|
+
* fragment code made for its inline glyph color split.
|
|
31013
|
+
*/
|
|
31014
|
+
function renderFallback(h, Text, chips, theme, budget) {
|
|
31015
|
+
const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
|
|
31016
|
+
return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
|
|
30380
31017
|
}
|
|
30381
31018
|
|
|
30382
31019
|
/**
|
|
@@ -30599,10 +31236,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
30599
31236
|
const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
|
|
30600
31237
|
const fileRows = worktree.files.slice(0, 12).map((file, index) => {
|
|
30601
31238
|
const codes = `${file.indexStatus}${file.worktreeStatus}`;
|
|
31239
|
+
// Smart path truncation: keep the leading status codes and elide
|
|
31240
|
+
// middle directory segments to preserve the filename. Falls back
|
|
31241
|
+
// to plain truncation when the codes + a meaningful filename
|
|
31242
|
+
// don't both fit. Same shape as the detail surface so all the
|
|
31243
|
+
// status-row renderings elide consistently.
|
|
31244
|
+
const prefix = ` ${codes} `;
|
|
31245
|
+
const totalBudget = width - 4;
|
|
31246
|
+
const pathBudget = totalBudget - cellWidth(prefix);
|
|
31247
|
+
const label = pathBudget >= 8
|
|
31248
|
+
? `${prefix}${truncatePathCells(file.path, pathBudget)}`
|
|
31249
|
+
: truncateCells(`${prefix}${file.path}`, totalBudget);
|
|
30602
31250
|
return h(Text, {
|
|
30603
31251
|
key: `tab-status-file-${index}`,
|
|
30604
31252
|
color: colorOf(file.state),
|
|
30605
|
-
},
|
|
31253
|
+
}, label);
|
|
30606
31254
|
});
|
|
30607
31255
|
return [
|
|
30608
31256
|
summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
|
|
@@ -31945,7 +32593,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
31945
32593
|
color: theme.noColor ? undefined : theme.colors.accent,
|
|
31946
32594
|
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
31947
32595
|
inverse: isActive && focused,
|
|
31948
|
-
},
|
|
32596
|
+
}, (() => {
|
|
32597
|
+
// Smart path truncation for the diff file header: keep
|
|
32598
|
+
// the leading arrow glyph and elide middle path
|
|
32599
|
+
// segments so the filename is never lost. Falls back to
|
|
32600
|
+
// plain truncation when there isn't room for a
|
|
32601
|
+
// meaningful filename.
|
|
32602
|
+
const pathBudget = (width - 4) - cellWidth(arrow);
|
|
32603
|
+
return pathBudget >= 8
|
|
32604
|
+
? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
|
|
32605
|
+
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
32606
|
+
})());
|
|
31949
32607
|
}
|
|
31950
32608
|
return h(Text, {
|
|
31951
32609
|
key: `stash-diff-line-${absoluteIndex}`,
|
|
@@ -35426,6 +36084,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
|
|
|
35426
36084
|
});
|
|
35427
36085
|
return out;
|
|
35428
36086
|
}
|
|
36087
|
+
/**
|
|
36088
|
+
* Compose a `<prefix><path><suffix>` line where the path gets smart
|
|
36089
|
+
* middle-elision truncation if needed, while the fixed prefix/suffix
|
|
36090
|
+
* decorations stay intact. Falls back to plain whole-line truncation
|
|
36091
|
+
* when the suffix decorations consume too much of the budget for the
|
|
36092
|
+
* path-aware variant to leave a meaningful filename.
|
|
36093
|
+
*
|
|
36094
|
+
* Used by the changed-files list AND the compose-context staged /
|
|
36095
|
+
* unstaged sections so all three places elide identically — same
|
|
36096
|
+
* floor (8 cells), same fallback shape.
|
|
36097
|
+
*/
|
|
36098
|
+
function smartPathLabel(prefix, path, suffix, totalBudget) {
|
|
36099
|
+
const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
|
|
36100
|
+
if (pathBudget >= 8) {
|
|
36101
|
+
return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
|
|
36102
|
+
}
|
|
36103
|
+
return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
|
|
36104
|
+
}
|
|
35429
36105
|
/**
|
|
35430
36106
|
* Render a list of changed files with status-code colors and stats. Used
|
|
35431
36107
|
* by both the history inspector and the commit-diff detail panel so the
|
|
@@ -35453,13 +36129,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
|
|
|
35453
36129
|
// in `lfsPointer.ts` so even rename / mode-only rows are
|
|
35454
36130
|
// flagged.
|
|
35455
36131
|
const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
|
|
35456
|
-
|
|
36132
|
+
// Smart path truncation via `smartPathLabel`: keeps the cursor +
|
|
36133
|
+
// status-code prefix and the stats/badge suffix intact, gives
|
|
36134
|
+
// the path's remaining width budget to middle-elision so the
|
|
36135
|
+
// filename survives instead of getting blunt-truncated off the
|
|
36136
|
+
// end (the issue users hit when inspector paths read like
|
|
36137
|
+
// `src/commands/log/da...`).
|
|
36138
|
+
const labelPrefix = `${cursor} ${statusCode} `;
|
|
36139
|
+
const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
|
|
36140
|
+
const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
|
|
35457
36141
|
return h(Text, {
|
|
35458
36142
|
key: `commit-file-${index}`,
|
|
35459
36143
|
color: statusCodeColor(file.status, theme),
|
|
35460
36144
|
inverse: isSelected && focused && !theme.noColor,
|
|
35461
36145
|
bold: isSelected,
|
|
35462
|
-
},
|
|
36146
|
+
}, label);
|
|
35463
36147
|
});
|
|
35464
36148
|
}
|
|
35465
36149
|
function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
|
|
@@ -35735,7 +36419,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35735
36419
|
...stagedFiles.map((file, index) => h(Text, {
|
|
35736
36420
|
key: `compose-context-staged-${index}`,
|
|
35737
36421
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
35738
|
-
},
|
|
36422
|
+
}, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
|
|
35739
36423
|
h(Text, { key: 'compose-context-staged-spacer' }, ''),
|
|
35740
36424
|
]
|
|
35741
36425
|
: []), ...(unstagedFiles.length
|
|
@@ -35744,7 +36428,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35744
36428
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
35745
36429
|
key: `compose-context-unstaged-${index}`,
|
|
35746
36430
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
35747
|
-
},
|
|
36431
|
+
}, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
|
|
35748
36432
|
]
|
|
35749
36433
|
: !stagedFiles.length && !loadingWorktree
|
|
35750
36434
|
? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
|
|
@@ -36644,7 +37328,7 @@ function LogInkApp(deps) {
|
|
|
36644
37328
|
if (cancelled || !mountedRef.current)
|
|
36645
37329
|
return;
|
|
36646
37330
|
const message = error instanceof Error ? error.message : String(error);
|
|
36647
|
-
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}
|
|
37331
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
|
|
36648
37332
|
dispatch({ type: 'setBootLoading', value: false });
|
|
36649
37333
|
});
|
|
36650
37334
|
return () => {
|
|
@@ -36712,8 +37396,15 @@ function LogInkApp(deps) {
|
|
|
36712
37396
|
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
36713
37397
|
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
36714
37398
|
};
|
|
37399
|
+
// Stash commits as graph roots so post-operation refreshes
|
|
37400
|
+
// keep the same rich graph the boot loader assembled. Without
|
|
37401
|
+
// this, every commit / split-apply / etc. would drop stash
|
|
37402
|
+
// anchors and the cursor-syncs-history effect would degrade
|
|
37403
|
+
// back to "tip not in loaded window" for older stashes.
|
|
37404
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
36715
37405
|
const fresh = await getLogRows(git, mergedArgv, {
|
|
36716
37406
|
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
37407
|
+
extraRefs: stashHashes,
|
|
36717
37408
|
});
|
|
36718
37409
|
if (mountedRef.current && fresh) {
|
|
36719
37410
|
dispatch({ type: 'replaceRows', rows: fresh });
|
|
@@ -37279,12 +37970,33 @@ function LogInkApp(deps) {
|
|
|
37279
37970
|
// fetched yet); a status hint surfaces in that case so the user
|
|
37280
37971
|
// knows to toggle full graph or load older commits.
|
|
37281
37972
|
const lastSyncedHashRef = React.useRef(undefined);
|
|
37973
|
+
// Tracks which target hashes we've already anchored a `git log`
|
|
37974
|
+
// fetch on (#1034 follow-up). When the cursor-syncs-history effect
|
|
37975
|
+
// sees a target whose hash isn't in the loaded window AND isn't in
|
|
37976
|
+
// this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
|
|
37977
|
+
// here. After the fetch resolves and rows are appended, the effect
|
|
37978
|
+
// re-fires; if the target STILL isn't loaded the resolver sees the
|
|
37979
|
+
// hash in this set and returns `unreachable` instead of looping.
|
|
37980
|
+
//
|
|
37981
|
+
// Stored as a ref because (a) the resolver only ever reads it and
|
|
37982
|
+
// (b) component re-renders on state.filteredCommits change are the
|
|
37983
|
+
// re-fire trigger; storing here in state would add a redundant
|
|
37984
|
+
// render per attempt.
|
|
37985
|
+
const attemptedContextHashesRef = React.useRef(new Set());
|
|
37986
|
+
const loadCommitContextRef = React.useRef(null);
|
|
37282
37987
|
React.useEffect(() => {
|
|
37283
37988
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37284
37989
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37285
37990
|
const onTagTab = state.activeView === 'tags' ||
|
|
37286
37991
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37287
|
-
|
|
37992
|
+
// User-reported gap: cursoring a stash didn't sync the history
|
|
37993
|
+
// cursor the way cursoring a branch / tag did. Same auto-jump
|
|
37994
|
+
// affordance now extends to stashes; the stash's commit hash IS
|
|
37995
|
+
// the row to land on (stashes are commits living off the
|
|
37996
|
+
// `refs/stash` tree, visible under `--all` / fullGraph).
|
|
37997
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
37998
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
37999
|
+
if (!onBranchTab && !onTagTab && !onStashTab)
|
|
37288
38000
|
return;
|
|
37289
38001
|
let targetHash;
|
|
37290
38002
|
let targetLabel;
|
|
@@ -37310,51 +38022,117 @@ function LogInkApp(deps) {
|
|
|
37310
38022
|
targetLabel = `tag ${tag.name}`;
|
|
37311
38023
|
}
|
|
37312
38024
|
}
|
|
37313
|
-
if (
|
|
37314
|
-
|
|
37315
|
-
|
|
37316
|
-
|
|
37317
|
-
|
|
37318
|
-
|
|
37319
|
-
|
|
37320
|
-
|
|
37321
|
-
|
|
37322
|
-
|
|
37323
|
-
|
|
37324
|
-
|
|
37325
|
-
|
|
37326
|
-
|
|
37327
|
-
|
|
37328
|
-
|
|
37329
|
-
|
|
37330
|
-
|
|
37331
|
-
|
|
37332
|
-
|
|
37333
|
-
|
|
38025
|
+
else if (onStashTab) {
|
|
38026
|
+
const all = context.stashes?.stashes || [];
|
|
38027
|
+
const visible = state.filter
|
|
38028
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
38029
|
+
: all;
|
|
38030
|
+
const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
|
|
38031
|
+
if (stash) {
|
|
38032
|
+
// Two-step fallback chain for stash cursor sync:
|
|
38033
|
+
//
|
|
38034
|
+
// 1. Try `baseHash` (the branch tip the stash was created
|
|
38035
|
+
// from). This answers the user-visible question "where
|
|
38036
|
+
// in larger git history was this stash made?" — that's
|
|
38037
|
+
// the branch origin point, not the stash's own merge-
|
|
38038
|
+
// commit row off in `refs/stash`. Base commits live on
|
|
38039
|
+
// regular branches so they're almost always in the
|
|
38040
|
+
// loaded window.
|
|
38041
|
+
//
|
|
38042
|
+
// 2. If `baseHash` isn't in the loaded window (the stash's
|
|
38043
|
+
// base branch was deleted, or the base is older than
|
|
38044
|
+
// the 1000-commit cap), fall back to `stash.hash`
|
|
38045
|
+
// itself. The stash commit was added as an extraRef so
|
|
38046
|
+
// it's reachable from the graph if it fits the window.
|
|
38047
|
+
//
|
|
38048
|
+
// Only after BOTH miss does the effect report "tip not in
|
|
38049
|
+
// loaded window." The label flips to mention "base" vs the
|
|
38050
|
+
// stash commit so the user knows what they're looking at.
|
|
38051
|
+
// hashesMatchAny handles the short-hash auto-extension
|
|
38052
|
+
// mismatch between `git stash list --format=%h` (stash hash)
|
|
38053
|
+
// and `git log --pretty=format:%h` (history row). Same
|
|
38054
|
+
// hazard as the branch/tag cursor sync — see src/git/hashes.ts.
|
|
38055
|
+
const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
|
|
38056
|
+
const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
|
|
38057
|
+
if (baseLoaded) {
|
|
38058
|
+
targetHash = stash.baseHash;
|
|
38059
|
+
targetLabel = `${stash.ref}'s base`;
|
|
38060
|
+
}
|
|
38061
|
+
else if (hashLoaded) {
|
|
38062
|
+
targetHash = stash.hash;
|
|
38063
|
+
targetLabel = stash.ref;
|
|
38064
|
+
}
|
|
38065
|
+
else {
|
|
38066
|
+
// Neither in window — set to baseHash so the standard
|
|
38067
|
+
// "not in loaded window" message fires with a meaningful
|
|
38068
|
+
// label (the base is what the user actually wants to see).
|
|
38069
|
+
targetHash = stash.baseHash || stash.hash;
|
|
38070
|
+
targetLabel = stash.ref;
|
|
38071
|
+
}
|
|
38072
|
+
}
|
|
37334
38073
|
}
|
|
37335
|
-
|
|
37336
|
-
|
|
37337
|
-
|
|
37338
|
-
|
|
37339
|
-
}
|
|
38074
|
+
// Delegate the actual decision to the pure resolver so the
|
|
38075
|
+
// logic is testable in isolation. The effect just performs the
|
|
38076
|
+
// resolver's chosen action.
|
|
38077
|
+
const decision = resolveCursorSyncDecision({
|
|
38078
|
+
target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
|
|
38079
|
+
loadedHashes: buildLoadedHashSet(state.filteredCommits),
|
|
38080
|
+
lastSyncedHash: lastSyncedHashRef.current,
|
|
38081
|
+
attemptedContextHashes: attemptedContextHashesRef.current,
|
|
38082
|
+
});
|
|
38083
|
+
switch (decision.type) {
|
|
38084
|
+
case 'noop':
|
|
38085
|
+
return;
|
|
38086
|
+
case 'jump':
|
|
38087
|
+
lastSyncedHashRef.current = decision.hash;
|
|
38088
|
+
dispatch({ type: 'selectCommitByHash', hash: decision.hash });
|
|
38089
|
+
dispatch({
|
|
38090
|
+
type: 'setStatus',
|
|
38091
|
+
value: `Synced history to ${decision.label} tip`,
|
|
38092
|
+
});
|
|
38093
|
+
return;
|
|
38094
|
+
case 'load-context':
|
|
38095
|
+
// Mark the hash as attempted BEFORE firing the load so a
|
|
38096
|
+
// re-fire of this effect (state.filteredCommits change while
|
|
38097
|
+
// the load is in flight) doesn't kick off a duplicate
|
|
38098
|
+
// request. The resolver sees the hash in the set and
|
|
38099
|
+
// returns `noop` until the load completes; on completion the
|
|
38100
|
+
// appendRows triggers a final re-fire that either jumps or
|
|
38101
|
+
// returns `unreachable`.
|
|
38102
|
+
attemptedContextHashesRef.current.add(decision.target.hash);
|
|
38103
|
+
void loadCommitContextRef.current?.(decision.target);
|
|
38104
|
+
return;
|
|
38105
|
+
case 'unreachable':
|
|
38106
|
+
dispatch({
|
|
38107
|
+
type: 'setStatus',
|
|
38108
|
+
value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
|
|
38109
|
+
kind: 'warning',
|
|
38110
|
+
});
|
|
38111
|
+
return;
|
|
37340
38112
|
}
|
|
37341
38113
|
}, [
|
|
37342
|
-
dispatch, context.branches, context.tags,
|
|
38114
|
+
dispatch, context.branches, context.tags, context.stashes,
|
|
37343
38115
|
state.activeView, state.focus, state.sidebarTab,
|
|
37344
|
-
state.selectedBranchIndex, state.selectedTagIndex,
|
|
38116
|
+
state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
|
|
37345
38117
|
state.branchSort, state.tagSort, state.filter,
|
|
37346
38118
|
state.filteredCommits,
|
|
37347
38119
|
]);
|
|
37348
38120
|
// Reset the dedup ref when the user moves focus away from the
|
|
37349
|
-
// sidebar branches / tags tab so re-entering re-fires the
|
|
37350
|
-
// even if the cursored
|
|
38121
|
+
// sidebar branches / tags / stashes tab so re-entering re-fires the
|
|
38122
|
+
// sync even if the cursored row is the same as before.
|
|
37351
38123
|
React.useEffect(() => {
|
|
37352
38124
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37353
38125
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37354
38126
|
const onTagTab = state.activeView === 'tags' ||
|
|
37355
38127
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37356
|
-
|
|
38128
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
38129
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
38130
|
+
if (!onBranchTab && !onTagTab && !onStashTab) {
|
|
37357
38131
|
lastSyncedHashRef.current = undefined;
|
|
38132
|
+
// Drop any context-load attempt tracking too. If the user
|
|
38133
|
+
// navigates back later we want to retry rather than show
|
|
38134
|
+
// "unreachable" based on a stale attempted-set.
|
|
38135
|
+
attemptedContextHashesRef.current = new Set();
|
|
37358
38136
|
}
|
|
37359
38137
|
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
37360
38138
|
React.useEffect(() => {
|
|
@@ -37385,7 +38163,7 @@ function LogInkApp(deps) {
|
|
|
37385
38163
|
]);
|
|
37386
38164
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
37387
38165
|
if (!selectedWorktreeFile) {
|
|
37388
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38166
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37389
38167
|
return;
|
|
37390
38168
|
}
|
|
37391
38169
|
dispatch({ type: 'setStatus', value: 'updating file stage state' });
|
|
@@ -37400,7 +38178,7 @@ function LogInkApp(deps) {
|
|
|
37400
38178
|
const toggleSelectedHunkStage = React.useCallback(async () => {
|
|
37401
38179
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37402
38180
|
if (!selectedHunk) {
|
|
37403
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38181
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37404
38182
|
return;
|
|
37405
38183
|
}
|
|
37406
38184
|
dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
|
|
@@ -37414,6 +38192,7 @@ function LogInkApp(deps) {
|
|
|
37414
38192
|
dispatch({
|
|
37415
38193
|
type: 'setStatus',
|
|
37416
38194
|
value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
|
|
38195
|
+
kind: 'success',
|
|
37417
38196
|
});
|
|
37418
38197
|
await refreshWorktreeContext();
|
|
37419
38198
|
setWorktreeDiff(undefined);
|
|
@@ -37423,12 +38202,13 @@ function LogInkApp(deps) {
|
|
|
37423
38202
|
dispatch({
|
|
37424
38203
|
type: 'setStatus',
|
|
37425
38204
|
value: error.message || 'failed to update hunk stage state',
|
|
38205
|
+
kind: 'error',
|
|
37426
38206
|
});
|
|
37427
38207
|
}
|
|
37428
38208
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37429
38209
|
const revertSelectedFile = React.useCallback(async () => {
|
|
37430
38210
|
if (!selectedWorktreeFile) {
|
|
37431
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38211
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37432
38212
|
return;
|
|
37433
38213
|
}
|
|
37434
38214
|
dispatch({ type: 'setStatus', value: 'reverting selected file' });
|
|
@@ -37441,13 +38221,13 @@ function LogInkApp(deps) {
|
|
|
37441
38221
|
const revertSelectedHunk = React.useCallback(async () => {
|
|
37442
38222
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37443
38223
|
if (!selectedHunk) {
|
|
37444
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38224
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37445
38225
|
return;
|
|
37446
38226
|
}
|
|
37447
38227
|
dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
|
|
37448
38228
|
try {
|
|
37449
38229
|
await revertHunk(git, selectedHunk);
|
|
37450
|
-
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}
|
|
38230
|
+
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
|
|
37451
38231
|
await refreshWorktreeContext();
|
|
37452
38232
|
setWorktreeDiff(undefined);
|
|
37453
38233
|
setWorktreeHunks(undefined);
|
|
@@ -37456,13 +38236,14 @@ function LogInkApp(deps) {
|
|
|
37456
38236
|
dispatch({
|
|
37457
38237
|
type: 'setStatus',
|
|
37458
38238
|
value: error.message || 'failed to revert hunk',
|
|
38239
|
+
kind: 'error',
|
|
37459
38240
|
});
|
|
37460
38241
|
}
|
|
37461
38242
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37462
38243
|
const createCommitFromCompose = React.useCallback(async () => {
|
|
37463
38244
|
const stagedCount = context.worktree?.stagedCount || 0;
|
|
37464
38245
|
if (!stagedCount) {
|
|
37465
|
-
dispatch({ type: 'setStatus', value: 'stage changes before committing' });
|
|
38246
|
+
dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
|
|
37466
38247
|
return;
|
|
37467
38248
|
}
|
|
37468
38249
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
@@ -37554,12 +38335,12 @@ function LogInkApp(deps) {
|
|
|
37554
38335
|
// dispatches because the user already knows what happened.
|
|
37555
38336
|
if (result.cancelled) {
|
|
37556
38337
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37557
|
-
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
38338
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
|
|
37558
38339
|
return;
|
|
37559
38340
|
}
|
|
37560
38341
|
if (result.ok && result.draft) {
|
|
37561
38342
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37562
|
-
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
38343
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
37563
38344
|
return;
|
|
37564
38345
|
}
|
|
37565
38346
|
dispatch({
|
|
@@ -37643,7 +38424,7 @@ function LogInkApp(deps) {
|
|
|
37643
38424
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
37644
38425
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37645
38426
|
if (!head) {
|
|
37646
|
-
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
|
|
38427
|
+
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
|
|
37647
38428
|
return;
|
|
37648
38429
|
}
|
|
37649
38430
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37651,11 +38432,12 @@ function LogInkApp(deps) {
|
|
|
37651
38432
|
dispatch({
|
|
37652
38433
|
type: 'setStatus',
|
|
37653
38434
|
value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
|
|
38435
|
+
kind: 'warning',
|
|
37654
38436
|
});
|
|
37655
38437
|
return;
|
|
37656
38438
|
}
|
|
37657
38439
|
if (head === defaultBranch) {
|
|
37658
|
-
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first
|
|
38440
|
+
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
|
|
37659
38441
|
return;
|
|
37660
38442
|
}
|
|
37661
38443
|
if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
|
|
@@ -37665,6 +38447,7 @@ function LogInkApp(deps) {
|
|
|
37665
38447
|
value: existing
|
|
37666
38448
|
? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
|
|
37667
38449
|
: `A pull request is already open for ${head}.`,
|
|
38450
|
+
kind: 'warning',
|
|
37668
38451
|
});
|
|
37669
38452
|
return;
|
|
37670
38453
|
}
|
|
@@ -37705,10 +38488,10 @@ function LogInkApp(deps) {
|
|
|
37705
38488
|
const initialBody = body.body || '';
|
|
37706
38489
|
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37707
38490
|
if (!body.ok) {
|
|
37708
|
-
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually
|
|
38491
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
|
|
37709
38492
|
}
|
|
37710
38493
|
else {
|
|
37711
|
-
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
38494
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
|
|
37712
38495
|
}
|
|
37713
38496
|
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
37714
38497
|
// prompt. If a future refactor adds an `await` between the flag
|
|
@@ -37775,17 +38558,18 @@ function LogInkApp(deps) {
|
|
|
37775
38558
|
const yankText = React.useCallback(async (value, label) => {
|
|
37776
38559
|
const clipboard = clipboardRunner || defaultClipboardRunner;
|
|
37777
38560
|
if (!value) {
|
|
37778
|
-
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty
|
|
38561
|
+
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
|
|
37779
38562
|
return;
|
|
37780
38563
|
}
|
|
37781
38564
|
try {
|
|
37782
38565
|
await clipboard(value);
|
|
37783
|
-
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard
|
|
38566
|
+
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
|
|
37784
38567
|
}
|
|
37785
38568
|
catch (error) {
|
|
37786
38569
|
dispatch({
|
|
37787
38570
|
type: 'setStatus',
|
|
37788
38571
|
value: `Copy failed (${label}): ${error.message}`,
|
|
38572
|
+
kind: 'error',
|
|
37789
38573
|
});
|
|
37790
38574
|
}
|
|
37791
38575
|
}, [clipboardRunner, dispatch]);
|
|
@@ -37809,7 +38593,7 @@ function LogInkApp(deps) {
|
|
|
37809
38593
|
const startChangelogView = React.useCallback(async (options = {}) => {
|
|
37810
38594
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37811
38595
|
if (!head) {
|
|
37812
|
-
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
|
|
38596
|
+
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
|
|
37813
38597
|
return;
|
|
37814
38598
|
}
|
|
37815
38599
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37861,7 +38645,7 @@ function LogInkApp(deps) {
|
|
|
37861
38645
|
baseLabel,
|
|
37862
38646
|
error: result.message,
|
|
37863
38647
|
});
|
|
37864
|
-
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}
|
|
38648
|
+
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
|
|
37865
38649
|
return;
|
|
37866
38650
|
}
|
|
37867
38651
|
dispatch({
|
|
@@ -37876,6 +38660,7 @@ function LogInkApp(deps) {
|
|
|
37876
38660
|
dispatch({
|
|
37877
38661
|
type: 'setStatus',
|
|
37878
38662
|
value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
|
|
38663
|
+
kind: 'success',
|
|
37879
38664
|
});
|
|
37880
38665
|
}, [
|
|
37881
38666
|
context.branches?.currentBranch,
|
|
@@ -37896,7 +38681,7 @@ function LogInkApp(deps) {
|
|
|
37896
38681
|
const yankChangelog = React.useCallback(() => {
|
|
37897
38682
|
const text = state.changelogView.text;
|
|
37898
38683
|
if (!text) {
|
|
37899
|
-
dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
|
|
38684
|
+
dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
|
|
37900
38685
|
return;
|
|
37901
38686
|
}
|
|
37902
38687
|
void yankText(text, 'changelog');
|
|
@@ -37909,7 +38694,7 @@ function LogInkApp(deps) {
|
|
|
37909
38694
|
const openChangelogInEditor = React.useCallback(() => {
|
|
37910
38695
|
const current = state.changelogView.text;
|
|
37911
38696
|
if (current === undefined) {
|
|
37912
|
-
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
|
|
38697
|
+
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
|
|
37913
38698
|
return;
|
|
37914
38699
|
}
|
|
37915
38700
|
let dir;
|
|
@@ -37920,6 +38705,7 @@ function LogInkApp(deps) {
|
|
|
37920
38705
|
dispatch({
|
|
37921
38706
|
type: 'setStatus',
|
|
37922
38707
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38708
|
+
kind: 'error',
|
|
37923
38709
|
});
|
|
37924
38710
|
return;
|
|
37925
38711
|
}
|
|
@@ -37931,6 +38717,7 @@ function LogInkApp(deps) {
|
|
|
37931
38717
|
dispatch({
|
|
37932
38718
|
type: 'setStatus',
|
|
37933
38719
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38720
|
+
kind: 'error',
|
|
37934
38721
|
});
|
|
37935
38722
|
try {
|
|
37936
38723
|
fs$1.rmSync(dir, { recursive: true, force: true });
|
|
@@ -37954,13 +38741,13 @@ function LogInkApp(deps) {
|
|
|
37954
38741
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37955
38742
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37956
38743
|
if (result.error) {
|
|
37957
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38744
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37958
38745
|
}
|
|
37959
38746
|
else if (result.signal) {
|
|
37960
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38747
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37961
38748
|
}
|
|
37962
38749
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37963
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38750
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37964
38751
|
}
|
|
37965
38752
|
else {
|
|
37966
38753
|
editorOk = true;
|
|
@@ -37975,12 +38762,13 @@ function LogInkApp(deps) {
|
|
|
37975
38762
|
try {
|
|
37976
38763
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
37977
38764
|
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
37978
|
-
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
38765
|
+
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
|
|
37979
38766
|
}
|
|
37980
38767
|
catch (error) {
|
|
37981
38768
|
dispatch({
|
|
37982
38769
|
type: 'setStatus',
|
|
37983
38770
|
value: `Failed to read back edited changelog: ${error.message}`,
|
|
38771
|
+
kind: 'error',
|
|
37984
38772
|
});
|
|
37985
38773
|
}
|
|
37986
38774
|
}
|
|
@@ -38021,19 +38809,19 @@ function LogInkApp(deps) {
|
|
|
38021
38809
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
38022
38810
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
|
|
38023
38811
|
if (result.error) {
|
|
38024
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38812
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
38025
38813
|
}
|
|
38026
38814
|
else if (result.signal) {
|
|
38027
38815
|
// Editor was killed by a signal (e.g. ^C, SIGTERM). status is
|
|
38028
38816
|
// null in this case, so the old `status !== 0` check would
|
|
38029
38817
|
// mistakenly fall through to the success branch.
|
|
38030
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38818
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
38031
38819
|
}
|
|
38032
38820
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
38033
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38821
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
38034
38822
|
}
|
|
38035
38823
|
else {
|
|
38036
|
-
dispatch({ type: 'setStatus', value: `Edited ${path}
|
|
38824
|
+
dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
|
|
38037
38825
|
}
|
|
38038
38826
|
}
|
|
38039
38827
|
finally {
|
|
@@ -38080,6 +38868,7 @@ function LogInkApp(deps) {
|
|
|
38080
38868
|
dispatch({
|
|
38081
38869
|
type: 'setStatus',
|
|
38082
38870
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38871
|
+
kind: 'error',
|
|
38083
38872
|
});
|
|
38084
38873
|
return;
|
|
38085
38874
|
}
|
|
@@ -38091,6 +38880,7 @@ function LogInkApp(deps) {
|
|
|
38091
38880
|
dispatch({
|
|
38092
38881
|
type: 'setStatus',
|
|
38093
38882
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38883
|
+
kind: 'error',
|
|
38094
38884
|
});
|
|
38095
38885
|
try {
|
|
38096
38886
|
fs$1.rmSync(dir, { recursive: true, force: true });
|
|
@@ -38114,13 +38904,13 @@ function LogInkApp(deps) {
|
|
|
38114
38904
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
38115
38905
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
38116
38906
|
if (result.error) {
|
|
38117
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38907
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
38118
38908
|
}
|
|
38119
38909
|
else if (result.signal) {
|
|
38120
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38910
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
38121
38911
|
}
|
|
38122
38912
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
38123
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38913
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
38124
38914
|
}
|
|
38125
38915
|
else {
|
|
38126
38916
|
editorOk = true;
|
|
@@ -38139,12 +38929,13 @@ function LogInkApp(deps) {
|
|
|
38139
38929
|
try {
|
|
38140
38930
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
38141
38931
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
|
|
38142
|
-
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
|
|
38932
|
+
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
|
|
38143
38933
|
}
|
|
38144
38934
|
catch (error) {
|
|
38145
38935
|
dispatch({
|
|
38146
38936
|
type: 'setStatus',
|
|
38147
38937
|
value: `Failed to read back edited draft: ${error.message}`,
|
|
38938
|
+
kind: 'error',
|
|
38148
38939
|
});
|
|
38149
38940
|
}
|
|
38150
38941
|
}
|
|
@@ -38220,7 +39011,7 @@ function LogInkApp(deps) {
|
|
|
38220
39011
|
const applyCommitSplit = React.useCallback(async () => {
|
|
38221
39012
|
const splitPlan = state.splitPlan;
|
|
38222
39013
|
if (!splitPlan?.plan || !splitPlan.planContext) {
|
|
38223
|
-
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
|
|
39014
|
+
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
|
|
38224
39015
|
return;
|
|
38225
39016
|
}
|
|
38226
39017
|
// Diagnostic dump for the silent-failure bug surfaced in #944
|
|
@@ -39191,7 +39982,7 @@ function LogInkApp(deps) {
|
|
|
39191
39982
|
};
|
|
39192
39983
|
const handler = handlers[id];
|
|
39193
39984
|
if (!handler) {
|
|
39194
|
-
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired
|
|
39985
|
+
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
39195
39986
|
return;
|
|
39196
39987
|
}
|
|
39197
39988
|
const result = await handler();
|
|
@@ -39236,7 +40027,37 @@ function LogInkApp(deps) {
|
|
|
39236
40027
|
// without flickering the surfaces through a 'loading' phase.
|
|
39237
40028
|
await refreshContext({ silent: true });
|
|
39238
40029
|
}
|
|
39239
|
-
|
|
40030
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
40031
|
+
//
|
|
40032
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
40033
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
40034
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
40035
|
+
// jump them to history view (where the worktree counts in the
|
|
40036
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
40037
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
40038
|
+
//
|
|
40039
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
40040
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
40041
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
40042
|
+
// affect untracked files when the stash held `-u` state), and
|
|
40043
|
+
// surface the new stash count on the status line so there's
|
|
40044
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
40045
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
40046
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
40047
|
+
await refreshWorktreeContext();
|
|
40048
|
+
}
|
|
40049
|
+
if (result?.ok && id === 'drop-stash') {
|
|
40050
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
40051
|
+
// untracked-file state that's now collected.
|
|
40052
|
+
await refreshWorktreeContext();
|
|
40053
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
40054
|
+
// reading the count back here would be stale because closures
|
|
40055
|
+
// capture the pre-refresh value. Status message stays generic
|
|
40056
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
40057
|
+
// unambiguous signal that the operation landed.
|
|
40058
|
+
}
|
|
40059
|
+
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
40060
|
+
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
39240
40061
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
39241
40062
|
state.statusFilterMask, state.tagSort]);
|
|
39242
40063
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
@@ -39398,15 +40219,15 @@ function LogInkApp(deps) {
|
|
|
39398
40219
|
}
|
|
39399
40220
|
}
|
|
39400
40221
|
if (!value || !label) {
|
|
39401
|
-
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
|
|
40222
|
+
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
|
|
39402
40223
|
return;
|
|
39403
40224
|
}
|
|
39404
40225
|
try {
|
|
39405
40226
|
await clipboard(value);
|
|
39406
|
-
dispatch({ type: 'setStatus', value: `Copied ${label}
|
|
40227
|
+
dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
|
|
39407
40228
|
}
|
|
39408
40229
|
catch (error) {
|
|
39409
|
-
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}
|
|
40230
|
+
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
|
|
39410
40231
|
}
|
|
39411
40232
|
}, [
|
|
39412
40233
|
clipboardRunner,
|
|
@@ -39466,63 +40287,175 @@ function LogInkApp(deps) {
|
|
|
39466
40287
|
React.useEffect(() => {
|
|
39467
40288
|
loadingMoreCommitsRef.current = loadingMoreCommits;
|
|
39468
40289
|
}, [loadingMoreCommits]);
|
|
40290
|
+
// STABLE useCallback (empty deps) for loadMoreCommits. The function
|
|
40291
|
+
// reads the volatile state (commit counts, fetch args, hasMore) via
|
|
40292
|
+
// refs that update on every render so the identity stays constant.
|
|
40293
|
+
//
|
|
40294
|
+
// Why stable matters: the cursor-syncs-history auto-load chain
|
|
40295
|
+
// calls this through a forward-reference ref (loadMoreCommitsRef).
|
|
40296
|
+
// If loadMoreCommits regenerated on every render — as the previous
|
|
40297
|
+
// implementation did via state deps — there was a render-order
|
|
40298
|
+
// race: the cursor sync effect would call the PREVIOUS render's
|
|
40299
|
+
// callback (still in the ref because the ref-setter useEffect runs
|
|
40300
|
+
// after the cursor-sync effect in declaration order), which had
|
|
40301
|
+
// captured a stale `state.commits.length` and re-fetched the same
|
|
40302
|
+
// window. The auto-load chain appeared to fire but never advanced
|
|
40303
|
+
// through history.
|
|
40304
|
+
//
|
|
40305
|
+
// Stable identity + refs sidesteps the race entirely: the function
|
|
40306
|
+
// never changes, and every call reads the latest state.
|
|
40307
|
+
const loadMoreStateRef = React.useRef({
|
|
40308
|
+
commitsLength: state.commits.length,
|
|
40309
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40310
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40311
|
+
hasMoreCommits,
|
|
40312
|
+
logArgv,
|
|
40313
|
+
});
|
|
40314
|
+
loadMoreStateRef.current = {
|
|
40315
|
+
commitsLength: state.commits.length,
|
|
40316
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40317
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40318
|
+
hasMoreCommits,
|
|
40319
|
+
logArgv,
|
|
40320
|
+
};
|
|
40321
|
+
const loadMoreCommits = React.useCallback(async (options = {}) => {
|
|
40322
|
+
const snap = loadMoreStateRef.current;
|
|
40323
|
+
if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
|
|
40324
|
+
return { fired: false, addedCommits: 0 };
|
|
40325
|
+
}
|
|
40326
|
+
if (snap.filteredCommitsLength === 0) {
|
|
40327
|
+
return { fired: false, addedCommits: 0 };
|
|
40328
|
+
}
|
|
40329
|
+
loadingMoreCommitsRef.current = true;
|
|
40330
|
+
const requestId = loadMoreRequestRef.current + 1;
|
|
40331
|
+
loadMoreRequestRef.current = requestId;
|
|
40332
|
+
setLoadingMoreCommits(true);
|
|
40333
|
+
dispatch({
|
|
40334
|
+
type: 'setStatus',
|
|
40335
|
+
value: options.statusMessage || 'loading older commits',
|
|
40336
|
+
loading: true,
|
|
40337
|
+
});
|
|
40338
|
+
const fetchArgs = snap.historyFetchArgs;
|
|
40339
|
+
const mergedArgv = {
|
|
40340
|
+
...snap.logArgv,
|
|
40341
|
+
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
40342
|
+
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
40343
|
+
};
|
|
40344
|
+
// Load-more paths a fresh page from git AFTER what's already
|
|
40345
|
+
// loaded; pass the stash hashes again so the additional rows
|
|
40346
|
+
// stay graph-consistent with the boot fetch (a window that
|
|
40347
|
+
// dropped stashes mid-stream would render with broken junctions).
|
|
40348
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40349
|
+
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
40350
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40351
|
+
skip: snap.commitsLength,
|
|
40352
|
+
extraRefs: stashHashes,
|
|
40353
|
+
}));
|
|
40354
|
+
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
40355
|
+
return { fired: false, addedCommits: 0 };
|
|
40356
|
+
}
|
|
40357
|
+
loadingMoreCommitsRef.current = false;
|
|
40358
|
+
setLoadingMoreCommits(false);
|
|
40359
|
+
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
40360
|
+
if (!nextRows) {
|
|
40361
|
+
dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
|
|
40362
|
+
return { fired: false, addedCommits: 0 };
|
|
40363
|
+
}
|
|
40364
|
+
if (nextRows?.length) {
|
|
40365
|
+
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40366
|
+
}
|
|
40367
|
+
setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
40368
|
+
return { fired: true, addedCommits: nextCommitCount };
|
|
40369
|
+
// Empty deps — the function is intentionally stable. State is
|
|
40370
|
+
// read via `loadMoreStateRef.current` at call time, and `dispatch`
|
|
40371
|
+
// / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
|
|
40372
|
+
// already stable across renders by React's contract.
|
|
40373
|
+
}, [dispatch, git]);
|
|
40374
|
+
// Scroll-near-bottom auto-trigger. Fires when the user's cursor is
|
|
40375
|
+
// within 20 rows of the last loaded commit so older history is
|
|
40376
|
+
// already on its way by the time they reach the bottom.
|
|
39469
40377
|
React.useEffect(() => {
|
|
39470
40378
|
const remaining = state.filteredCommits.length - state.selectedIndex - 1;
|
|
39471
|
-
|
|
39472
|
-
|
|
39473
|
-
|
|
39474
|
-
|
|
39475
|
-
|
|
39476
|
-
|
|
39477
|
-
|
|
39478
|
-
|
|
39479
|
-
|
|
39480
|
-
|
|
39481
|
-
setLoadingMoreCommits(true);
|
|
39482
|
-
dispatch({ type: 'setStatus', value: 'loading older commits' });
|
|
39483
|
-
const fetchArgs = state.historyFetchArgs;
|
|
39484
|
-
const mergedArgv = {
|
|
39485
|
-
...logArgv,
|
|
39486
|
-
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
39487
|
-
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
39488
|
-
};
|
|
39489
|
-
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
39490
|
-
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
39491
|
-
skip: state.commits.length,
|
|
39492
|
-
}));
|
|
39493
|
-
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
39494
|
-
return;
|
|
39495
|
-
}
|
|
39496
|
-
loadingMoreCommitsRef.current = false;
|
|
39497
|
-
setLoadingMoreCommits(false);
|
|
39498
|
-
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
39499
|
-
if (!nextRows) {
|
|
39500
|
-
dispatch({ type: 'setStatus', value: 'failed to load older commits' });
|
|
39501
|
-
return;
|
|
39502
|
-
}
|
|
39503
|
-
if (nextRows?.length) {
|
|
39504
|
-
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40379
|
+
if (remaining > 20)
|
|
40380
|
+
return;
|
|
40381
|
+
void loadMoreCommits().then((result) => {
|
|
40382
|
+
if (result.fired) {
|
|
40383
|
+
dispatch({
|
|
40384
|
+
type: 'setStatus',
|
|
40385
|
+
value: result.addedCommits
|
|
40386
|
+
? `loaded ${result.addedCommits} older commits`
|
|
40387
|
+
: 'end of history',
|
|
40388
|
+
});
|
|
39505
40389
|
}
|
|
39506
|
-
|
|
39507
|
-
dispatch({
|
|
39508
|
-
type: 'setStatus',
|
|
39509
|
-
value: nextCommitCount
|
|
39510
|
-
? `loaded ${nextCommitCount} older commits`
|
|
39511
|
-
: 'end of history',
|
|
39512
|
-
});
|
|
39513
|
-
}
|
|
39514
|
-
void loadMoreCommits();
|
|
40390
|
+
});
|
|
39515
40391
|
}, [
|
|
39516
40392
|
dispatch,
|
|
39517
|
-
|
|
39518
|
-
hasMoreCommits,
|
|
39519
|
-
loadingMoreCommits,
|
|
39520
|
-
logArgv,
|
|
39521
|
-
state.commits.length,
|
|
40393
|
+
loadMoreCommits,
|
|
39522
40394
|
state.filteredCommits.length,
|
|
39523
|
-
state.historyFetchArgs,
|
|
39524
40395
|
state.selectedIndex,
|
|
39525
40396
|
]);
|
|
40397
|
+
/**
|
|
40398
|
+
* Targeted-context loader for the cursor-syncs-history effect. Called
|
|
40399
|
+
* when the resolver returns `load-context` — the user cursored a
|
|
40400
|
+
* branch / tag / stash whose target commit isn't in the loaded
|
|
40401
|
+
* window, so we run a `git log` anchored on that commit (guaranteed
|
|
40402
|
+
* to include it) and merge the result via `appendRows` (which
|
|
40403
|
+
* already deduplicates by hash).
|
|
40404
|
+
*
|
|
40405
|
+
* Stable identity (empty deps) for the same reason as
|
|
40406
|
+
* `loadMoreCommits` — the cursor-sync effect calls this through a
|
|
40407
|
+
* forward-reference ref, and a regenerating callback would
|
|
40408
|
+
* reintroduce the render-order race that bit the previous chain.
|
|
40409
|
+
* All volatile state (logArgv, mostly) is read via refs.
|
|
40410
|
+
*/
|
|
40411
|
+
const loadCommitContextStateRef = React.useRef({ logArgv });
|
|
40412
|
+
loadCommitContextStateRef.current = { logArgv };
|
|
40413
|
+
const loadCommitContext = React.useCallback(async (target) => {
|
|
40414
|
+
const snap = loadCommitContextStateRef.current;
|
|
40415
|
+
if (!snap.logArgv)
|
|
40416
|
+
return;
|
|
40417
|
+
dispatch({
|
|
40418
|
+
type: 'setStatus',
|
|
40419
|
+
value: `Loading commits around ${target.label}…`,
|
|
40420
|
+
loading: true,
|
|
40421
|
+
});
|
|
40422
|
+
try {
|
|
40423
|
+
// No stashHashes here — `getLogRowsAnchoredOn` walks only from
|
|
40424
|
+
// the target so it can guarantee the target's inclusion.
|
|
40425
|
+
// Stashes are already in the loaded graph from boot's
|
|
40426
|
+
// `loadRowsWithStashes`; `appendRows` deduplicates by hash so
|
|
40427
|
+
// the merged result keeps both views without double-counting.
|
|
40428
|
+
const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
|
|
40429
|
+
if (!mountedRef.current)
|
|
40430
|
+
return;
|
|
40431
|
+
if (rows.length > 0) {
|
|
40432
|
+
dispatch({ type: 'appendRows', rows });
|
|
40433
|
+
// Don't dispatch a setStatus here — the cursor-sync effect
|
|
40434
|
+
// will re-fire on the appendRows-driven filteredCommits
|
|
40435
|
+
// change and either jump (success) or report unreachable
|
|
40436
|
+
// (failure), surfacing the right message.
|
|
40437
|
+
}
|
|
40438
|
+
else {
|
|
40439
|
+
dispatch({
|
|
40440
|
+
type: 'setStatus',
|
|
40441
|
+
value: `${target.label} target commit returned no rows — orphan ref?`,
|
|
40442
|
+
kind: 'warning',
|
|
40443
|
+
});
|
|
40444
|
+
}
|
|
40445
|
+
}
|
|
40446
|
+
catch (error) {
|
|
40447
|
+
if (mountedRef.current) {
|
|
40448
|
+
dispatch({
|
|
40449
|
+
type: 'setStatus',
|
|
40450
|
+
value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
40451
|
+
kind: 'error',
|
|
40452
|
+
});
|
|
40453
|
+
}
|
|
40454
|
+
}
|
|
40455
|
+
}, [dispatch, git]);
|
|
40456
|
+
React.useEffect(() => {
|
|
40457
|
+
loadCommitContextRef.current = loadCommitContext;
|
|
40458
|
+
}, [loadCommitContext]);
|
|
39526
40459
|
// Server-side history filter (#776). When the user submits `path:foo`
|
|
39527
40460
|
// or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
|
|
39528
40461
|
// this effect picks up the change, re-runs `getLogRows` with merged
|
|
@@ -39558,12 +40491,16 @@ function LogInkApp(deps) {
|
|
|
39558
40491
|
value: description ? `Refetching with ${description}` : 'Restoring full log',
|
|
39559
40492
|
});
|
|
39560
40493
|
void (async () => {
|
|
39561
|
-
const
|
|
40494
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40495
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40496
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40497
|
+
extraRefs: stashHashes,
|
|
40498
|
+
}));
|
|
39562
40499
|
if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
|
|
39563
40500
|
return;
|
|
39564
40501
|
}
|
|
39565
40502
|
if (!nextRows) {
|
|
39566
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
|
|
40503
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
|
|
39567
40504
|
return;
|
|
39568
40505
|
}
|
|
39569
40506
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39574,6 +40511,7 @@ function LogInkApp(deps) {
|
|
|
39574
40511
|
value: description
|
|
39575
40512
|
? `Showing ${matched} commits matching ${description}`
|
|
39576
40513
|
: 'Showing full log',
|
|
40514
|
+
kind: 'success',
|
|
39577
40515
|
});
|
|
39578
40516
|
})();
|
|
39579
40517
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
@@ -39603,12 +40541,20 @@ function LogInkApp(deps) {
|
|
|
39603
40541
|
: 'Loading compact history…',
|
|
39604
40542
|
});
|
|
39605
40543
|
void (async () => {
|
|
39606
|
-
|
|
40544
|
+
// Include stash commits as graph roots so the toggle's re-fetch
|
|
40545
|
+
// sees the same rich graph the boot loader assembles. Without
|
|
40546
|
+
// this, flipping `\` into full mode and back loses the stash
|
|
40547
|
+
// anchors that loadRowsWithStashes seeded on boot.
|
|
40548
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40549
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40550
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40551
|
+
extraRefs: stashHashes,
|
|
40552
|
+
}));
|
|
39607
40553
|
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
39608
40554
|
return;
|
|
39609
40555
|
}
|
|
39610
40556
|
if (!nextRows) {
|
|
39611
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
40557
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
|
|
39612
40558
|
return;
|
|
39613
40559
|
}
|
|
39614
40560
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39619,6 +40565,7 @@ function LogInkApp(deps) {
|
|
|
39619
40565
|
value: state.fullGraph
|
|
39620
40566
|
? `Showing ${matched} commits across all branches`
|
|
39621
40567
|
: `Showing ${matched} commits (compact)`,
|
|
40568
|
+
kind: 'success',
|
|
39622
40569
|
});
|
|
39623
40570
|
})();
|
|
39624
40571
|
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
@@ -40313,6 +41260,17 @@ function createLogArgvFromUiArgv(argv) {
|
|
|
40313
41260
|
return {
|
|
40314
41261
|
$0: argv.$0,
|
|
40315
41262
|
_: ['log'],
|
|
41263
|
+
// Pass `--all` through from the CLI. The yargs default is `true`
|
|
41264
|
+
// since 0.54.x — user feedback consistently asked for the
|
|
41265
|
+
// GitKraken-style "see all branches, tags, stashes" view as the
|
|
41266
|
+
// starting state. `coco ui --no-all` opts back to
|
|
41267
|
+
// current-branch-only.
|
|
41268
|
+
//
|
|
41269
|
+
// Note: passing `--branch foo` does NOT automatically scope away
|
|
41270
|
+
// from --all. If the user wants strictly that branch, they pass
|
|
41271
|
+
// `coco ui --branch foo --no-all`. We considered the implicit
|
|
41272
|
+
// scope-narrowing but it surprises users who pass `--branch` as
|
|
41273
|
+
// a "highlight this branch in the all-refs view" hint.
|
|
40316
41274
|
all: argv.all,
|
|
40317
41275
|
branch: argv.branch,
|
|
40318
41276
|
format: 'table',
|
|
@@ -40349,6 +41307,26 @@ function withCacheWrite(repoPath, loader) {
|
|
|
40349
41307
|
return rows;
|
|
40350
41308
|
};
|
|
40351
41309
|
}
|
|
41310
|
+
/**
|
|
41311
|
+
* Workstation-aware log loader (#1034 follow-up). Calls `git stash
|
|
41312
|
+
* list` first to collect every stash's commit hash, then passes them
|
|
41313
|
+
* as extra refs to `getLogRows` so the graph includes every stash as
|
|
41314
|
+
* a node — not just the latest (which is the only one `refs/stash`
|
|
41315
|
+
* points at and the only one `git log --all` walks).
|
|
41316
|
+
*
|
|
41317
|
+
* Without this, the stash → history cursor sync added in #1034 only
|
|
41318
|
+
* worked for `stash@{0}`; cursoring any older stash row reported
|
|
41319
|
+
* "tip not in loaded window" because that stash's commit hash was
|
|
41320
|
+
* never in the loaded graph window in the first place.
|
|
41321
|
+
*
|
|
41322
|
+
* The extra git call is cheap (one `git stash list --format=%H`,
|
|
41323
|
+
* usually sub-50ms). It's only an additive cost when stashes exist;
|
|
41324
|
+
* users on stash-free repos pay nothing.
|
|
41325
|
+
*/
|
|
41326
|
+
async function loadRowsWithStashes(git, logArgv) {
|
|
41327
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
41328
|
+
return getLogRows(git, logArgv, { extraRefs: stashHashes });
|
|
41329
|
+
}
|
|
40352
41330
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
40353
41331
|
const config = options.config || loadConfig(logArgv);
|
|
40354
41332
|
const git = options.git || getRepo();
|
|
@@ -40367,7 +41345,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
40367
41345
|
const initialRows = options.rows || cachedRows || [];
|
|
40368
41346
|
const loadRows = options.rows
|
|
40369
41347
|
? undefined
|
|
40370
|
-
: withCacheWrite(repoPath, () =>
|
|
41348
|
+
: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
|
|
40371
41349
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
40372
41350
|
appLabel: 'coco',
|
|
40373
41351
|
idleTips: config.logTui?.idleTips,
|
|
@@ -40395,7 +41373,7 @@ async function startCocoUi(argv) {
|
|
|
40395
41373
|
idleTips: config.logTui?.idleTips,
|
|
40396
41374
|
dateBucketing: config.logTui?.dateBucketing,
|
|
40397
41375
|
initialView: argv.view || 'history',
|
|
40398
|
-
loadRows: withCacheWrite(repoPath, () =>
|
|
41376
|
+
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
40399
41377
|
logArgv,
|
|
40400
41378
|
theme: createUiTheme(config, argv),
|
|
40401
41379
|
});
|
|
@@ -41598,9 +42576,9 @@ const options = {
|
|
|
41598
42576
|
default: 'history',
|
|
41599
42577
|
},
|
|
41600
42578
|
all: {
|
|
41601
|
-
description: 'Load commits from all local and remote refs in history mode',
|
|
42579
|
+
description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
|
|
41602
42580
|
type: 'boolean',
|
|
41603
|
-
default:
|
|
42581
|
+
default: true,
|
|
41604
42582
|
},
|
|
41605
42583
|
branch: {
|
|
41606
42584
|
description: 'Load history reachable from a branch or ref',
|