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.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.
|
|
64
|
+
const BUILD_VERSION = "0.55.0";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18351,7 +18351,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
|
|
|
18351
18351
|
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
18352
18352
|
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
18353
18353
|
const LOG_DEFAULT_LIMIT = 30;
|
|
18354
|
-
|
|
18354
|
+
// Bumped from 300 → 1000 in 0.54.2. With the full-graph default
|
|
18355
|
+
// (#1034) the workstation surfaces many more refs (all branches, all
|
|
18356
|
+
// tags, plus stash commits added via `extraRefs`), and on active repos
|
|
18357
|
+
// the 300-commit cap was cutting off year+-old stash bases and old
|
|
18358
|
+
// tag commits — making the cursor-syncs-history effect report "tip
|
|
18359
|
+
// not in loaded window" instead of moving the graph cursor. 1000
|
|
18360
|
+
// fits a year of activity for most repos, git log is still sub-200ms,
|
|
18361
|
+
// and Ink virtualises scroll so render cost stays flat.
|
|
18362
|
+
const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
|
|
18355
18363
|
function toArray(value) {
|
|
18356
18364
|
if (!value) {
|
|
18357
18365
|
return [];
|
|
@@ -18531,12 +18539,77 @@ function buildLogArgs(argv, options = {}) {
|
|
|
18531
18539
|
else if (argv.branch) {
|
|
18532
18540
|
args.push(argv.branch);
|
|
18533
18541
|
}
|
|
18542
|
+
// Extra refs (stash commits etc.) — append after the --all / branch
|
|
18543
|
+
// selector but BEFORE the path separator. Git treats them as
|
|
18544
|
+
// additional graph roots, so the traversal includes them alongside
|
|
18545
|
+
// whatever --all / --branch already covers.
|
|
18546
|
+
if (options.extraRefs && options.extraRefs.length > 0) {
|
|
18547
|
+
args.push(...options.extraRefs);
|
|
18548
|
+
}
|
|
18534
18549
|
const paths = toArray(argv.path);
|
|
18535
18550
|
if (paths.length > 0) {
|
|
18536
18551
|
args.push('--', ...paths);
|
|
18537
18552
|
}
|
|
18538
18553
|
return args;
|
|
18539
18554
|
}
|
|
18555
|
+
/**
|
|
18556
|
+
* Default size of a targeted-context window. Sized to comfortably
|
|
18557
|
+
* cover a year of activity on most repos so the cursor-sync's
|
|
18558
|
+
* "jump to commit anchored on a ref I just selected" can succeed
|
|
18559
|
+
* without paginating through the whole history.
|
|
18560
|
+
*/
|
|
18561
|
+
const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
|
|
18562
|
+
/**
|
|
18563
|
+
* Load a window of commits anchored on a specific hash. Used by the
|
|
18564
|
+
* cursor-sync effect when the user selects a ref (branch / tag /
|
|
18565
|
+
* stash) whose target commit isn't in the loaded graph window.
|
|
18566
|
+
*
|
|
18567
|
+
* Critical detail: this walks **only from the target** (and its
|
|
18568
|
+
* ancestors), NOT from `--all`. Why: when you combine `--all` with
|
|
18569
|
+
* `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
|
|
18570
|
+
* the result by date, and slices the newest N rows. If the target
|
|
18571
|
+
* is older than the Nth newest commit across all refs (very common
|
|
18572
|
+
* for year-old tags / branches on active repos), it falls off the
|
|
18573
|
+
* slice even though it was passed as a root. Walking from the
|
|
18574
|
+
* target alone guarantees the target IS the first row of the
|
|
18575
|
+
* output and its ancestors fill the rest.
|
|
18576
|
+
*
|
|
18577
|
+
* The caller merges the result via the `appendRows` reducer action
|
|
18578
|
+
* which deduplicates by hash, so the target's ancestry slots into
|
|
18579
|
+
* the existing `--all` graph cleanly. The user's loaded view ends
|
|
18580
|
+
* up as the union of: the original `--all` window + target's
|
|
18581
|
+
* ancestry — exactly what's needed for the cursor to land.
|
|
18582
|
+
*
|
|
18583
|
+
* Capped at `options.limit` (default 5000) to keep one targeted
|
|
18584
|
+
* fetch bounded. For most refs, even a 100-commit limit would be
|
|
18585
|
+
* enough to surface the target; we go higher to also pull in the
|
|
18586
|
+
* surrounding context so the user can scroll around the landed
|
|
18587
|
+
* cursor.
|
|
18588
|
+
*/
|
|
18589
|
+
async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
|
|
18590
|
+
// Strip every "walk many refs" toggle so buildLogArgs produces a
|
|
18591
|
+
// clean `git log <flags> <targetHash>` — exactly the walk that
|
|
18592
|
+
// guarantees the target's inclusion.
|
|
18593
|
+
const merged = {
|
|
18594
|
+
...argv,
|
|
18595
|
+
all: false,
|
|
18596
|
+
view: 'compact', // suppresses 'full' → '--all' mapping
|
|
18597
|
+
branch: undefined,
|
|
18598
|
+
path: undefined,
|
|
18599
|
+
};
|
|
18600
|
+
// Also drop --first-parent / --no-merges so the target's ancestry
|
|
18601
|
+
// renders with full topology (matters for stash commits which are
|
|
18602
|
+
// merges by construction).
|
|
18603
|
+
const baseArgs = buildLogArgs(merged, {
|
|
18604
|
+
limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
|
|
18605
|
+
}).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
|
|
18606
|
+
// Splice the target as the positional ref. `buildLogArgs` already
|
|
18607
|
+
// appended any `--all`/`--branch`/`<extraRefs>` it considered;
|
|
18608
|
+
// since we cleared all those above, the only positional ref we
|
|
18609
|
+
// add is the target.
|
|
18610
|
+
baseArgs.push(targetHash);
|
|
18611
|
+
return parseLogOutput(await git.raw(baseArgs));
|
|
18612
|
+
}
|
|
18540
18613
|
/**
|
|
18541
18614
|
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
18542
18615
|
*
|
|
@@ -18644,6 +18717,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
18644
18717
|
};
|
|
18645
18718
|
}
|
|
18646
18719
|
|
|
18720
|
+
function parseStashSubject(subject) {
|
|
18721
|
+
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18722
|
+
if (!match) {
|
|
18723
|
+
return {
|
|
18724
|
+
branch: '<unknown>',
|
|
18725
|
+
message: subject,
|
|
18726
|
+
};
|
|
18727
|
+
}
|
|
18728
|
+
return {
|
|
18729
|
+
branch: match[1],
|
|
18730
|
+
message: match[2] || subject,
|
|
18731
|
+
};
|
|
18732
|
+
}
|
|
18733
|
+
function parseStashList(output) {
|
|
18734
|
+
return output
|
|
18735
|
+
.split('\n')
|
|
18736
|
+
.map((line) => line.trim())
|
|
18737
|
+
.filter(Boolean)
|
|
18738
|
+
.map((line) => {
|
|
18739
|
+
const [ref, hash, parents, date, subject] = line.split('\x1f');
|
|
18740
|
+
const parsedSubject = parseStashSubject(subject || '');
|
|
18741
|
+
// `%P` returns space-separated parent hashes. Stash commits are
|
|
18742
|
+
// merges with 2-3 parents; the FIRST is the base (HEAD at stash
|
|
18743
|
+
// time). Empty parents string (legacy / corrupted entries) maps
|
|
18744
|
+
// to an empty baseHash; the cursor-sync caller treats that as
|
|
18745
|
+
// "no base available, fall back to stash hash."
|
|
18746
|
+
const baseHash = parents ? (parents.split(' ')[0] || '') : '';
|
|
18747
|
+
return {
|
|
18748
|
+
ref,
|
|
18749
|
+
hash,
|
|
18750
|
+
baseHash,
|
|
18751
|
+
date,
|
|
18752
|
+
branch: parsedSubject.branch,
|
|
18753
|
+
message: parsedSubject.message,
|
|
18754
|
+
};
|
|
18755
|
+
});
|
|
18756
|
+
}
|
|
18757
|
+
function parseStashFiles(output) {
|
|
18758
|
+
return output
|
|
18759
|
+
.split('\n')
|
|
18760
|
+
.map((line) => line.trim())
|
|
18761
|
+
.filter(Boolean);
|
|
18762
|
+
}
|
|
18763
|
+
/**
|
|
18764
|
+
* Resolve the commit hashes for every stash, in `stash@{N}` order.
|
|
18765
|
+
*
|
|
18766
|
+
* Used by the workstation's history loader to include older stashes
|
|
18767
|
+
* as graph roots — `git log --all` only walks `refs/stash` (the
|
|
18768
|
+
* latest stash) by default, so stash@{1+} commits live off-graph
|
|
18769
|
+
* unless explicitly referenced. Passing this list as positional refs
|
|
18770
|
+
* to `git log` makes every stash appear as a graph node, which lets
|
|
18771
|
+
* the cursor-syncs-history effect actually land on them when the
|
|
18772
|
+
* user navigates the stashes sidebar.
|
|
18773
|
+
*
|
|
18774
|
+
* Cheap: one `git stash list` call, no per-stash fan-out. Returns
|
|
18775
|
+
* an empty array when there are no stashes — callers can pass the
|
|
18776
|
+
* result through unconditionally.
|
|
18777
|
+
*/
|
|
18778
|
+
async function getStashCommitHashes(git) {
|
|
18779
|
+
const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
|
|
18780
|
+
return raw
|
|
18781
|
+
.split('\n')
|
|
18782
|
+
.map((line) => line.trim())
|
|
18783
|
+
.filter(Boolean);
|
|
18784
|
+
}
|
|
18785
|
+
async function getStashOverview(git) {
|
|
18786
|
+
// Format fields (separated by 0x1f / unit separator):
|
|
18787
|
+
// %gd — stash reflog selector (stash@{N})
|
|
18788
|
+
// %H — stash commit hash
|
|
18789
|
+
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18790
|
+
// %ci — committer date, ISO format
|
|
18791
|
+
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18792
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
|
|
18793
|
+
return {
|
|
18794
|
+
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18795
|
+
...stash,
|
|
18796
|
+
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18797
|
+
}))),
|
|
18798
|
+
};
|
|
18799
|
+
}
|
|
18800
|
+
/**
|
|
18801
|
+
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18802
|
+
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18803
|
+
*
|
|
18804
|
+
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18805
|
+
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18806
|
+
* back to a "no diff to display" message.
|
|
18807
|
+
*/
|
|
18808
|
+
async function getStashDiff(git, stashRef) {
|
|
18809
|
+
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18810
|
+
.split('\n')
|
|
18811
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
18812
|
+
}
|
|
18813
|
+
/**
|
|
18814
|
+
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18815
|
+
* file path and the offset of its `diff --git` header within `lines`.
|
|
18816
|
+
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18817
|
+
* the file at the cursor.
|
|
18818
|
+
*
|
|
18819
|
+
* Renames / moves return the destination path (the `b/` side); the
|
|
18820
|
+
* action surface treats that as the path to materialize from the stash.
|
|
18821
|
+
*
|
|
18822
|
+
* Path quoting: git wraps paths containing spaces or special characters
|
|
18823
|
+
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18824
|
+
* The parser handles both the unquoted and quoted forms; without that,
|
|
18825
|
+
* stash-file navigation and cherry-pick silently broke for any file
|
|
18826
|
+
* whose path contained a space.
|
|
18827
|
+
*/
|
|
18828
|
+
function parseStashDiffFiles(lines) {
|
|
18829
|
+
const files = [];
|
|
18830
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
18831
|
+
const line = lines[i];
|
|
18832
|
+
const parsed = parseDiffGitHeader(line);
|
|
18833
|
+
if (parsed) {
|
|
18834
|
+
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18835
|
+
}
|
|
18836
|
+
}
|
|
18837
|
+
return files;
|
|
18838
|
+
}
|
|
18839
|
+
/**
|
|
18840
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
18841
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18842
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18843
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18844
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
18845
|
+
*
|
|
18846
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
18847
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
18848
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18849
|
+
* "no file selected" state in that case.
|
|
18850
|
+
*/
|
|
18851
|
+
function findStashFileForOffset(files, offset) {
|
|
18852
|
+
if (files.length === 0)
|
|
18853
|
+
return undefined;
|
|
18854
|
+
let current;
|
|
18855
|
+
for (const file of files) {
|
|
18856
|
+
if (file.startLine <= offset) {
|
|
18857
|
+
current = file;
|
|
18858
|
+
}
|
|
18859
|
+
else {
|
|
18860
|
+
break;
|
|
18861
|
+
}
|
|
18862
|
+
}
|
|
18863
|
+
// First file is the canonical fallback — even if the offset lands
|
|
18864
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
18865
|
+
// so the user's actions have a target.
|
|
18866
|
+
return current ?? files[0];
|
|
18867
|
+
}
|
|
18868
|
+
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18869
|
+
function parseDiffGitHeader(line) {
|
|
18870
|
+
const match = line.match(DIFF_GIT_HEADER);
|
|
18871
|
+
if (!match)
|
|
18872
|
+
return undefined;
|
|
18873
|
+
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18874
|
+
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18875
|
+
if (!aPath || !bPath)
|
|
18876
|
+
return undefined;
|
|
18877
|
+
return { aPath, bPath };
|
|
18878
|
+
}
|
|
18879
|
+
function unescapeGitQuoted(value) {
|
|
18880
|
+
if (value === undefined)
|
|
18881
|
+
return undefined;
|
|
18882
|
+
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18883
|
+
// C-style sequences. Reverse the most common ones so callers get the
|
|
18884
|
+
// raw on-disk path.
|
|
18885
|
+
return value
|
|
18886
|
+
.replace(/\\\\/g, '\\')
|
|
18887
|
+
.replace(/\\"/g, '"')
|
|
18888
|
+
.replace(/\\t/g, '\t')
|
|
18889
|
+
.replace(/\\n/g, '\n');
|
|
18890
|
+
}
|
|
18891
|
+
|
|
18647
18892
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
18648
18893
|
function parseBranchRefs(output) {
|
|
18649
18894
|
return output
|
|
@@ -18836,143 +19081,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18836
19081
|
}
|
|
18837
19082
|
}
|
|
18838
19083
|
|
|
18839
|
-
function parseStashSubject(subject) {
|
|
18840
|
-
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18841
|
-
if (!match) {
|
|
18842
|
-
return {
|
|
18843
|
-
branch: '<unknown>',
|
|
18844
|
-
message: subject,
|
|
18845
|
-
};
|
|
18846
|
-
}
|
|
18847
|
-
return {
|
|
18848
|
-
branch: match[1],
|
|
18849
|
-
message: match[2] || subject,
|
|
18850
|
-
};
|
|
18851
|
-
}
|
|
18852
|
-
function parseStashList(output) {
|
|
18853
|
-
return output
|
|
18854
|
-
.split('\n')
|
|
18855
|
-
.map((line) => line.trim())
|
|
18856
|
-
.filter(Boolean)
|
|
18857
|
-
.map((line) => {
|
|
18858
|
-
const [ref, hash, date, subject] = line.split('\x1f');
|
|
18859
|
-
const parsedSubject = parseStashSubject(subject || '');
|
|
18860
|
-
return {
|
|
18861
|
-
ref,
|
|
18862
|
-
hash,
|
|
18863
|
-
date,
|
|
18864
|
-
branch: parsedSubject.branch,
|
|
18865
|
-
message: parsedSubject.message,
|
|
18866
|
-
};
|
|
18867
|
-
});
|
|
18868
|
-
}
|
|
18869
|
-
function parseStashFiles(output) {
|
|
18870
|
-
return output
|
|
18871
|
-
.split('\n')
|
|
18872
|
-
.map((line) => line.trim())
|
|
18873
|
-
.filter(Boolean);
|
|
18874
|
-
}
|
|
18875
|
-
async function getStashOverview(git) {
|
|
18876
|
-
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
|
|
18877
|
-
return {
|
|
18878
|
-
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18879
|
-
...stash,
|
|
18880
|
-
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18881
|
-
}))),
|
|
18882
|
-
};
|
|
18883
|
-
}
|
|
18884
|
-
/**
|
|
18885
|
-
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18886
|
-
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18887
|
-
*
|
|
18888
|
-
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18889
|
-
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18890
|
-
* back to a "no diff to display" message.
|
|
18891
|
-
*/
|
|
18892
|
-
async function getStashDiff(git, stashRef) {
|
|
18893
|
-
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18894
|
-
.split('\n')
|
|
18895
|
-
.map((line) => line.replace(/\r$/, ''));
|
|
18896
|
-
}
|
|
18897
|
-
/**
|
|
18898
|
-
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18899
|
-
* file path and the offset of its `diff --git` header within `lines`.
|
|
18900
|
-
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18901
|
-
* the file at the cursor.
|
|
18902
|
-
*
|
|
18903
|
-
* Renames / moves return the destination path (the `b/` side); the
|
|
18904
|
-
* action surface treats that as the path to materialize from the stash.
|
|
18905
|
-
*
|
|
18906
|
-
* Path quoting: git wraps paths containing spaces or special characters
|
|
18907
|
-
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18908
|
-
* The parser handles both the unquoted and quoted forms; without that,
|
|
18909
|
-
* stash-file navigation and cherry-pick silently broke for any file
|
|
18910
|
-
* whose path contained a space.
|
|
18911
|
-
*/
|
|
18912
|
-
function parseStashDiffFiles(lines) {
|
|
18913
|
-
const files = [];
|
|
18914
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
18915
|
-
const line = lines[i];
|
|
18916
|
-
const parsed = parseDiffGitHeader(line);
|
|
18917
|
-
if (parsed) {
|
|
18918
|
-
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18919
|
-
}
|
|
18920
|
-
}
|
|
18921
|
-
return files;
|
|
18922
|
-
}
|
|
18923
|
-
/**
|
|
18924
|
-
* Resolve which stash file *contains* a given line offset — the user's
|
|
18925
|
-
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18926
|
-
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18927
|
-
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18928
|
-
* dispatchers' "what file is the cursor on" lookup.
|
|
18929
|
-
*
|
|
18930
|
-
* Returns `undefined` when the file list is empty *or* the offset
|
|
18931
|
-
* lands before the very first file's `diff --git` header (e.g. when
|
|
18932
|
-
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18933
|
-
* "no file selected" state in that case.
|
|
18934
|
-
*/
|
|
18935
|
-
function findStashFileForOffset(files, offset) {
|
|
18936
|
-
if (files.length === 0)
|
|
18937
|
-
return undefined;
|
|
18938
|
-
let current;
|
|
18939
|
-
for (const file of files) {
|
|
18940
|
-
if (file.startLine <= offset) {
|
|
18941
|
-
current = file;
|
|
18942
|
-
}
|
|
18943
|
-
else {
|
|
18944
|
-
break;
|
|
18945
|
-
}
|
|
18946
|
-
}
|
|
18947
|
-
// First file is the canonical fallback — even if the offset lands
|
|
18948
|
-
// before its header (rare), we want the cursor to be "in" something
|
|
18949
|
-
// so the user's actions have a target.
|
|
18950
|
-
return current ?? files[0];
|
|
18951
|
-
}
|
|
18952
|
-
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18953
|
-
function parseDiffGitHeader(line) {
|
|
18954
|
-
const match = line.match(DIFF_GIT_HEADER);
|
|
18955
|
-
if (!match)
|
|
18956
|
-
return undefined;
|
|
18957
|
-
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18958
|
-
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18959
|
-
if (!aPath || !bPath)
|
|
18960
|
-
return undefined;
|
|
18961
|
-
return { aPath, bPath };
|
|
18962
|
-
}
|
|
18963
|
-
function unescapeGitQuoted(value) {
|
|
18964
|
-
if (value === undefined)
|
|
18965
|
-
return undefined;
|
|
18966
|
-
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18967
|
-
// C-style sequences. Reverse the most common ones so callers get the
|
|
18968
|
-
// raw on-disk path.
|
|
18969
|
-
return value
|
|
18970
|
-
.replace(/\\\\/g, '\\')
|
|
18971
|
-
.replace(/\\"/g, '"')
|
|
18972
|
-
.replace(/\\t/g, '\t')
|
|
18973
|
-
.replace(/\\n/g, '\n');
|
|
18974
|
-
}
|
|
18975
|
-
|
|
18976
19084
|
function fileState(indexStatus, worktreeStatus) {
|
|
18977
19085
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
18978
19086
|
return 'untracked';
|
|
@@ -19039,7 +19147,18 @@ function parseTagRefs(output) {
|
|
|
19039
19147
|
.map((line) => line.trimEnd())
|
|
19040
19148
|
.filter(Boolean)
|
|
19041
19149
|
.map((line) => {
|
|
19042
|
-
const [name,
|
|
19150
|
+
const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
19151
|
+
// For annotated tags `%(objectname:short)` returns the TAG
|
|
19152
|
+
// OBJECT's SHA, not the commit it points to — that's the SHA
|
|
19153
|
+
// sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
|
|
19154
|
+
// dereferences the tag and yields the commit's SHA, but is
|
|
19155
|
+
// EMPTY for lightweight tags (which are already direct
|
|
19156
|
+
// pointers to commits). Prefer the dereferenced form when
|
|
19157
|
+
// present, fall back to the object SHA otherwise. This is what
|
|
19158
|
+
// lets cursor-sync find the tagged commit in the loaded log
|
|
19159
|
+
// window — anchoring on the tag object's own SHA would never
|
|
19160
|
+
// match a commit row.
|
|
19161
|
+
const hash = derefedHash || objectHash;
|
|
19043
19162
|
return {
|
|
19044
19163
|
name,
|
|
19045
19164
|
hash,
|
|
@@ -19051,7 +19170,7 @@ function parseTagRefs(output) {
|
|
|
19051
19170
|
async function getTagOverview(git) {
|
|
19052
19171
|
const output = await git.raw([
|
|
19053
19172
|
'for-each-ref',
|
|
19054
|
-
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19173
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19055
19174
|
'--sort=-creatordate',
|
|
19056
19175
|
'refs/tags',
|
|
19057
19176
|
]);
|
|
@@ -19933,6 +20052,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
19933
20052
|
output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
|
|
19934
20053
|
}
|
|
19935
20054
|
|
|
20055
|
+
/**
|
|
20056
|
+
* Shared hash-matching helpers for cross-command lookups.
|
|
20057
|
+
*
|
|
20058
|
+
* Git surfaces the same commit with different short-hash lengths
|
|
20059
|
+
* depending on which command produced the row:
|
|
20060
|
+
*
|
|
20061
|
+
* - `for-each-ref --format=%(objectname:short)` (branches, tags,
|
|
20062
|
+
* stashes) honors `core.abbrev`, typically 7 chars.
|
|
20063
|
+
* - `git log --pretty=format:%h` (history rows) honors the same
|
|
20064
|
+
* setting BUT git auto-extends abbreviations to keep them unique
|
|
20065
|
+
* within the walked set — so the same commit can come back as 7
|
|
20066
|
+
* chars from one command and 8 (or more) from another.
|
|
20067
|
+
*
|
|
20068
|
+
* Consequence: any exact-equality lookup that compares a hash from
|
|
20069
|
+
* `for-each-ref` against a hash from `git log` will miss the match
|
|
20070
|
+
* even when both refer to the same commit. This bit the workstation's
|
|
20071
|
+
* cursor-sync effect twice during 0.54.2 — once in the resolver, once
|
|
20072
|
+
* in the `selectCommitByHash` reducer — and shows up wherever a ref
|
|
20073
|
+
* hash is checked against the loaded log window.
|
|
20074
|
+
*
|
|
20075
|
+
* The fix is bidirectional prefix matching: a hash matches another if
|
|
20076
|
+
* one is a prefix of the other. Below a 4-char floor we refuse to
|
|
20077
|
+
* match — three chars would collide with too many real commits.
|
|
20078
|
+
*
|
|
20079
|
+
* This module is the canonical place for that logic. Import it
|
|
20080
|
+
* anywhere you compare a "hash from one git formatter" against a
|
|
20081
|
+
* "hash from a different git formatter."
|
|
20082
|
+
*
|
|
20083
|
+
* Lives in `src/git/` because both `workstation/` and `commands/log/`
|
|
20084
|
+
* depend on it — `commands/log/` must not depend on `workstation/`,
|
|
20085
|
+
* so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
|
|
20086
|
+
*/
|
|
20087
|
+
/**
|
|
20088
|
+
* Minimum length below which we refuse to prefix-match. Three chars
|
|
20089
|
+
* is too small to be a meaningful unique prefix for any real-world
|
|
20090
|
+
* git history.
|
|
20091
|
+
*/
|
|
20092
|
+
const MIN_PREFIX_LENGTH = 4;
|
|
20093
|
+
/**
|
|
20094
|
+
* True when `a` and `b` refer to the same commit, tolerating
|
|
20095
|
+
* short-hash length differences from different git formatters.
|
|
20096
|
+
*
|
|
20097
|
+
* Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
|
|
20098
|
+
* string equality wins immediately (the common path); otherwise we
|
|
20099
|
+
* test bidirectional `startsWith` and bail when either input is too
|
|
20100
|
+
* short to be a meaningful prefix.
|
|
20101
|
+
*/
|
|
20102
|
+
function hashesMatch(a, b) {
|
|
20103
|
+
if (!a || !b)
|
|
20104
|
+
return false;
|
|
20105
|
+
if (a === b)
|
|
20106
|
+
return true;
|
|
20107
|
+
if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
|
|
20108
|
+
return false;
|
|
20109
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
20110
|
+
}
|
|
20111
|
+
/**
|
|
20112
|
+
* True when `hash` matches any entry in `candidates`. Convenience
|
|
20113
|
+
* wrapper for the common "is this ref's hash in any of the row's
|
|
20114
|
+
* hash variants?" check.
|
|
20115
|
+
*/
|
|
20116
|
+
function hashesMatchAny(hash, candidates) {
|
|
20117
|
+
if (!hash)
|
|
20118
|
+
return false;
|
|
20119
|
+
return candidates.some((candidate) => hashesMatch(hash, candidate));
|
|
20120
|
+
}
|
|
20121
|
+
/**
|
|
20122
|
+
* True when `hash` is present in the loaded set — exact match first
|
|
20123
|
+
* (the O(1) fast path), then bidirectional `startsWith` over the set
|
|
20124
|
+
* to cover the formatter mismatch.
|
|
20125
|
+
*
|
|
20126
|
+
* The set is small in practice (1k–5k entries) so O(N) iteration on
|
|
20127
|
+
* miss is fine.
|
|
20128
|
+
*/
|
|
20129
|
+
function hashLoaded(hash, loaded) {
|
|
20130
|
+
if (loaded.has(hash))
|
|
20131
|
+
return true;
|
|
20132
|
+
if (hash.length < MIN_PREFIX_LENGTH)
|
|
20133
|
+
return false;
|
|
20134
|
+
for (const entry of loaded) {
|
|
20135
|
+
if (entry.startsWith(hash) || hash.startsWith(entry))
|
|
20136
|
+
return true;
|
|
20137
|
+
}
|
|
20138
|
+
return false;
|
|
20139
|
+
}
|
|
20140
|
+
|
|
19936
20141
|
const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
|
|
19937
20142
|
/**
|
|
19938
20143
|
* Parse a single `.gitattributes` body into the LFS-tracked
|
|
@@ -23625,7 +23830,16 @@ function createLogInkState(rows, options = {}) {
|
|
|
23625
23830
|
worktreeDiffOffset: 0,
|
|
23626
23831
|
filter: '',
|
|
23627
23832
|
filterMode: false,
|
|
23628
|
-
|
|
23833
|
+
// Default to the full multi-ref graph (`git log --all`) so users
|
|
23834
|
+
// see how branches, tags, and stashes weave through the history
|
|
23835
|
+
// out of the box. Pre-0.54.x this defaulted to false (current
|
|
23836
|
+
// branch only); user feedback consistently asked for the
|
|
23837
|
+
// GitKraken-style "see everything" view as the starting state.
|
|
23838
|
+
// The `\` toggle still flips back to compact / current-branch
|
|
23839
|
+
// mode for users who want the cleaner single-line graph. Tests
|
|
23840
|
+
// override via `options.fullGraph` when they need the compact
|
|
23841
|
+
// case explicitly.
|
|
23842
|
+
fullGraph: options.fullGraph ?? true,
|
|
23629
23843
|
showHelp: false,
|
|
23630
23844
|
helpScrollOffset: 0,
|
|
23631
23845
|
showCommandPalette: false,
|
|
@@ -23734,8 +23948,17 @@ function applyLogInkAction(state, action) {
|
|
|
23734
23948
|
// branch's tip without the user manually scrolling. No-op when
|
|
23735
23949
|
// the hash isn't in the loaded list (the runtime surfaces a
|
|
23736
23950
|
// status hint in that case).
|
|
23951
|
+
//
|
|
23952
|
+
// Uses the shared `hashesMatchAny` helper to cover the
|
|
23953
|
+
// short-hash auto-extension mismatch between
|
|
23954
|
+
// `for-each-ref --format=%(objectname:short)` (cursored ref)
|
|
23955
|
+
// and `git log --pretty=format:%h` (history row). Without that
|
|
23956
|
+
// tolerance the resolver could decide "jump" but this reducer
|
|
23957
|
+
// would silently no-op — the status updates but the cursor
|
|
23958
|
+
// doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
|
|
23959
|
+
// testing. See `src/git/hashes.ts` for the matching rules.
|
|
23737
23960
|
const target = action.hash;
|
|
23738
|
-
const index = state.filteredCommits.findIndex((commit) => commit.hash
|
|
23961
|
+
const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
|
|
23739
23962
|
if (index < 0) {
|
|
23740
23963
|
return state;
|
|
23741
23964
|
}
|
|
@@ -24672,7 +24895,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24672
24895
|
const commit = state.filteredCommits[state.selectedIndex];
|
|
24673
24896
|
const requireCommit = (fn) => {
|
|
24674
24897
|
if (!commit) {
|
|
24675
|
-
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
24898
|
+
return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
|
|
24676
24899
|
}
|
|
24677
24900
|
return fn(commit.hash, state.selectedIndex);
|
|
24678
24901
|
};
|
|
@@ -24711,6 +24934,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24711
24934
|
return [action({
|
|
24712
24935
|
type: 'setStatus',
|
|
24713
24936
|
value: `Action ${inspectorAction.key} not yet wired`,
|
|
24937
|
+
kind: 'warning',
|
|
24714
24938
|
})];
|
|
24715
24939
|
}
|
|
24716
24940
|
}
|
|
@@ -24925,6 +25149,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24925
25149
|
return [action({
|
|
24926
25150
|
type: 'setStatus',
|
|
24927
25151
|
value: 'open the diff view and press [ or ] to jump hunks',
|
|
25152
|
+
kind: 'warning',
|
|
24928
25153
|
})];
|
|
24929
25154
|
case 'focusNext':
|
|
24930
25155
|
return [action({ type: 'focusNext' })];
|
|
@@ -24973,6 +25198,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24973
25198
|
return [action({
|
|
24974
25199
|
type: 'setStatus',
|
|
24975
25200
|
value: 'open branches / tags / history and press m on the cursored ref',
|
|
25201
|
+
kind: 'warning',
|
|
24976
25202
|
})];
|
|
24977
25203
|
case 'navigateBack':
|
|
24978
25204
|
// Mirror the Esc / `<` semantics (#931): drain the frame's view
|
|
@@ -25048,6 +25274,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
25048
25274
|
return [action({
|
|
25049
25275
|
type: 'setStatus',
|
|
25050
25276
|
value: 'Sort cycle is available in the branches and tags views',
|
|
25277
|
+
kind: 'warning',
|
|
25051
25278
|
})];
|
|
25052
25279
|
case 'yankClipboard':
|
|
25053
25280
|
// The runtime resolves the value/label against the live filtered
|
|
@@ -25104,7 +25331,7 @@ function submitInputPrompt(state) {
|
|
|
25104
25331
|
return [];
|
|
25105
25332
|
const value = state.inputPrompt.value.trim();
|
|
25106
25333
|
if (!value) {
|
|
25107
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
25334
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
25108
25335
|
}
|
|
25109
25336
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
25110
25337
|
const mode = value.toLowerCase();
|
|
@@ -25112,6 +25339,7 @@ function submitInputPrompt(state) {
|
|
|
25112
25339
|
return [action({
|
|
25113
25340
|
type: 'setStatus',
|
|
25114
25341
|
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
25342
|
+
kind: 'warning',
|
|
25115
25343
|
})];
|
|
25116
25344
|
}
|
|
25117
25345
|
return [
|
|
@@ -25125,6 +25353,7 @@ function submitInputPrompt(state) {
|
|
|
25125
25353
|
return [action({
|
|
25126
25354
|
type: 'setStatus',
|
|
25127
25355
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25356
|
+
kind: 'warning',
|
|
25128
25357
|
})];
|
|
25129
25358
|
}
|
|
25130
25359
|
return [
|
|
@@ -25188,6 +25417,7 @@ function submitInputPrompt(state) {
|
|
|
25188
25417
|
return [action({
|
|
25189
25418
|
type: 'setStatus',
|
|
25190
25419
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25420
|
+
kind: 'warning',
|
|
25191
25421
|
})];
|
|
25192
25422
|
}
|
|
25193
25423
|
return [
|
|
@@ -25785,7 +26015,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25785
26015
|
}
|
|
25786
26016
|
return [
|
|
25787
26017
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25788
|
-
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
26018
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
|
|
25789
26019
|
];
|
|
25790
26020
|
}
|
|
25791
26021
|
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
@@ -25809,7 +26039,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25809
26039
|
}
|
|
25810
26040
|
return [
|
|
25811
26041
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25812
|
-
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
26042
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
|
|
25813
26043
|
];
|
|
25814
26044
|
}
|
|
25815
26045
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
@@ -25924,6 +26154,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25924
26154
|
value: next === 'split'
|
|
25925
26155
|
? 'Switched to side-by-side diff'
|
|
25926
26156
|
: 'Switched to unified diff',
|
|
26157
|
+
kind: 'success',
|
|
25927
26158
|
}),
|
|
25928
26159
|
];
|
|
25929
26160
|
}
|
|
@@ -26374,10 +26605,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26374
26605
|
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
26375
26606
|
const head = getCursoredCompareRef(state, context);
|
|
26376
26607
|
if (!head) {
|
|
26377
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26608
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26378
26609
|
}
|
|
26379
26610
|
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
26380
|
-
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
26611
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
|
|
26381
26612
|
}
|
|
26382
26613
|
return [
|
|
26383
26614
|
action({
|
|
@@ -26580,7 +26811,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26580
26811
|
action({ type: 'setFocus', value: 'commits' }),
|
|
26581
26812
|
];
|
|
26582
26813
|
}
|
|
26583
|
-
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
26814
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
|
|
26584
26815
|
}
|
|
26585
26816
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
26586
26817
|
}
|
|
@@ -26691,7 +26922,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26691
26922
|
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
26692
26923
|
const ref = getCursoredCompareRef(state, context);
|
|
26693
26924
|
if (!ref) {
|
|
26694
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26925
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26695
26926
|
}
|
|
26696
26927
|
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
26697
26928
|
return [
|
|
@@ -26922,7 +27153,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26922
27153
|
// Always intercept `C` on the conflicts view to prevent fallthrough to
|
|
26923
27154
|
// the global `C` (Create PR) binding when conflicts remain.
|
|
26924
27155
|
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
26925
|
-
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
27156
|
+
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
|
|
26926
27157
|
}
|
|
26927
27158
|
// Global `C` — create a pull request from the current branch. The
|
|
26928
27159
|
// runtime callback handles pre-flight (current branch resolution,
|
|
@@ -26938,6 +27169,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26938
27169
|
return [action({
|
|
26939
27170
|
type: 'setStatus',
|
|
26940
27171
|
value: 'Finish or cancel the commit draft before creating a PR.',
|
|
27172
|
+
kind: 'warning',
|
|
26941
27173
|
})];
|
|
26942
27174
|
}
|
|
26943
27175
|
if (inputValue === 'C' && state.activeView !== 'conflicts') {
|
|
@@ -26997,7 +27229,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26997
27229
|
return events;
|
|
26998
27230
|
}
|
|
26999
27231
|
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
27000
|
-
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
27232
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
|
|
27001
27233
|
}
|
|
27002
27234
|
}
|
|
27003
27235
|
// `c` on the history view cherry-picks the full selected commit on
|
|
@@ -27882,7 +28114,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
|
|
|
27882
28114
|
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
27883
28115
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
27884
28116
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
27885
|
-
wide: { min: 28, max:
|
|
28117
|
+
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
27886
28118
|
};
|
|
27887
28119
|
function calcSidebarAtRestWidth(columns, density) {
|
|
27888
28120
|
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
@@ -29835,18 +30067,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
29835
30067
|
];
|
|
29836
30068
|
}
|
|
29837
30069
|
|
|
30070
|
+
function resolveCursorSyncDecision(input) {
|
|
30071
|
+
if (!input.target) {
|
|
30072
|
+
return { type: 'noop', reason: 'no-target' };
|
|
30073
|
+
}
|
|
30074
|
+
if (input.target.hash === input.lastSyncedHash) {
|
|
30075
|
+
return { type: 'noop', reason: 'duplicate-of-last' };
|
|
30076
|
+
}
|
|
30077
|
+
if (isHashLoaded(input.target.hash, input.loadedHashes)) {
|
|
30078
|
+
return {
|
|
30079
|
+
type: 'jump',
|
|
30080
|
+
hash: input.target.hash,
|
|
30081
|
+
label: input.target.label,
|
|
30082
|
+
};
|
|
30083
|
+
}
|
|
30084
|
+
if (input.attemptedContextHashes.has(input.target.hash)) {
|
|
30085
|
+
return { type: 'unreachable', target: input.target };
|
|
30086
|
+
}
|
|
30087
|
+
return { type: 'load-context', target: input.target };
|
|
30088
|
+
}
|
|
30089
|
+
/**
|
|
30090
|
+
* Re-export of the shared `hashLoaded` helper under the resolver's
|
|
30091
|
+
* historical name. Kept exported so existing tests (and any external
|
|
30092
|
+
* importers) keep working unchanged — see `src/git/hashes.ts` for the
|
|
30093
|
+
* canonical implementation and the rationale behind bidirectional
|
|
30094
|
+
* prefix matching.
|
|
30095
|
+
*/
|
|
30096
|
+
function isHashLoaded(hash, loadedHashes) {
|
|
30097
|
+
return hashLoaded(hash, loadedHashes);
|
|
30098
|
+
}
|
|
30099
|
+
/**
|
|
30100
|
+
* Build the membership set the resolver expects. Includes BOTH the
|
|
30101
|
+
* full hash and the short hash for every commit so the caller can
|
|
30102
|
+
* match either form (refs sometimes carry only the short hash and
|
|
30103
|
+
* `state.filteredCommits` items always have both).
|
|
30104
|
+
*
|
|
30105
|
+
* Exported so the cursor-sync effect can build the set once per
|
|
30106
|
+
* re-render and pass it down without leaking the implementation
|
|
30107
|
+
* detail. Tests use it to construct realistic inputs without
|
|
30108
|
+
* hand-rolling the dual-hash logic.
|
|
30109
|
+
*/
|
|
30110
|
+
function buildLoadedHashSet(commits) {
|
|
30111
|
+
const set = new Set();
|
|
30112
|
+
for (const commit of commits) {
|
|
30113
|
+
if (commit.hash)
|
|
30114
|
+
set.add(commit.hash);
|
|
30115
|
+
if (commit.shortHash)
|
|
30116
|
+
set.add(commit.shortHash);
|
|
30117
|
+
}
|
|
30118
|
+
return set;
|
|
30119
|
+
}
|
|
30120
|
+
|
|
29838
30121
|
/**
|
|
29839
|
-
* Status-bar / footer renderer. Two-
|
|
29840
|
-
*
|
|
29841
|
-
*
|
|
29842
|
-
*
|
|
29843
|
-
*
|
|
30122
|
+
* Status-bar / footer renderer. Two-row layout, using the full
|
|
30123
|
+
* `height: 2` the footer already reserves:
|
|
30124
|
+
*
|
|
30125
|
+
* Row 1 — keyboard hint band:
|
|
30126
|
+
* ┌──── contextual hints ────┐ ┌──── globals ────┐
|
|
30127
|
+
* ↑/↓ branches ←/→ tab … ? help · : cmds · q
|
|
30128
|
+
*
|
|
30129
|
+
* Row 2 — status / feedback band:
|
|
30130
|
+
* ⠋ main has no upstream — nothing to fetch.
|
|
30131
|
+
*
|
|
30132
|
+
* Row 2 is empty when there's no status message, idle tip, or error.
|
|
30133
|
+
* This is a behaviour change from the pre-0.54.2 single-row layout
|
|
30134
|
+
* where the status message sat awkwardly between the contextual and
|
|
30135
|
+
* global hints, getting visually crushed.
|
|
29844
30136
|
*
|
|
29845
|
-
*
|
|
30137
|
+
* The separation matters because:
|
|
30138
|
+
* - status text and key hints serve different cognitive purposes
|
|
30139
|
+
* (read vs. scan) and competing for the same row makes both
|
|
30140
|
+
* harder to use,
|
|
30141
|
+
* - long status messages (especially errors / multi-clause loading
|
|
30142
|
+
* copy) no longer push global hints off screen or wrap into the
|
|
30143
|
+
* hint cluster,
|
|
30144
|
+
* - errors now keep the global hints visible — the user often
|
|
30145
|
+
* needs `?` / `:` / `q` to *recover* from the error.
|
|
30146
|
+
*
|
|
30147
|
+
* Idle tips fill row 2 only when no real status message is set so the
|
|
29846
30148
|
* tip cycle never overwrites genuine workflow feedback.
|
|
29847
30149
|
*
|
|
29848
|
-
*
|
|
29849
|
-
*
|
|
30150
|
+
* Row 2 styling is kind-aware. Each statusKind gets its own theme
|
|
30151
|
+
* color and glyph prefix so the message is identifiable at a glance
|
|
30152
|
+
* — even with NO_COLOR set, the glyph alone communicates kind:
|
|
30153
|
+
*
|
|
30154
|
+
* loading → spinner + accent + bold
|
|
30155
|
+
* error → ✗ / ! + danger + bold
|
|
30156
|
+
* warning → ⚠ / ! + warning + bold
|
|
30157
|
+
* success → ✓ / + + success + bold
|
|
30158
|
+
* info → ℹ / i + info + bold
|
|
30159
|
+
* idle tip → no glyph + dim muted (passive)
|
|
30160
|
+
*
|
|
30161
|
+
* Pre-redesign success and loading both used `accent` (cyan), so the
|
|
30162
|
+
* user couldn't tell "done" from "in progress" by color alone. Each
|
|
30163
|
+
* kind now uses its dedicated theme color and ships an ASCII glyph
|
|
30164
|
+
* fallback for `theme.ascii` mode (TERM=dumb / vt100).
|
|
30165
|
+
*
|
|
30166
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30167
|
+
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
30168
|
+
* colors + glyphs added in the same pass.
|
|
29850
30169
|
*/
|
|
29851
30170
|
function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
29852
30171
|
const { Box, Text } = components;
|
|
@@ -29878,50 +30197,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
|
|
|
29878
30197
|
});
|
|
29879
30198
|
// Real status messages always win; idle tips only fill the slot when it
|
|
29880
30199
|
// would otherwise be empty.
|
|
29881
|
-
const
|
|
29882
|
-
const
|
|
29883
|
-
// Loading status gets a spinner prefix in front of the message —
|
|
29884
|
-
// motion makes transient LLM calls (create-PR body, PR fetches,
|
|
29885
|
-
// etc.) feel less frozen even when they're sub-second.
|
|
29886
|
-
const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
|
|
29887
|
-
const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
|
|
29888
|
-
const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
|
|
30200
|
+
const hasStatusMessage = Boolean(state.statusMessage);
|
|
30201
|
+
const isLoading = Boolean(state.statusLoading && hasStatusMessage);
|
|
29889
30202
|
const isError = state.statusKind === 'error';
|
|
30203
|
+
const isWarning = state.statusKind === 'warning';
|
|
29890
30204
|
const isSuccess = state.statusKind === 'success';
|
|
29891
|
-
|
|
29892
|
-
|
|
29893
|
-
|
|
29894
|
-
|
|
29895
|
-
|
|
29896
|
-
|
|
29897
|
-
|
|
29898
|
-
|
|
30205
|
+
// 'info' is the implicit kind when statusKind is undefined but
|
|
30206
|
+
// statusMessage is set — it's a deliberate status update, not an
|
|
30207
|
+
// idle tip, so it gets info treatment rather than the dim fallback.
|
|
30208
|
+
const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
|
|
30209
|
+
const rawTrailing = state.statusMessage || idleTip || '';
|
|
30210
|
+
// Glyphs per kind so the message is identifiable even before reading
|
|
30211
|
+
// the color — improves scan-ability and degrades gracefully when the
|
|
30212
|
+
// terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
|
|
30213
|
+
// = dumb / vt100) where unicode glyphs render as garbage.
|
|
30214
|
+
// loading → spinner (animated)
|
|
30215
|
+
// error → ✗ / !
|
|
30216
|
+
// warning → ⚠ / !
|
|
30217
|
+
// success → ✓ / +
|
|
30218
|
+
// info → ℹ / i
|
|
30219
|
+
// idle tip → no glyph (passive)
|
|
30220
|
+
const glyph = (() => {
|
|
30221
|
+
if (isLoading)
|
|
30222
|
+
return pickSpinnerFrame(spinnerFrame);
|
|
30223
|
+
if (isError)
|
|
30224
|
+
return theme.ascii ? '!' : '✗';
|
|
30225
|
+
if (isWarning)
|
|
30226
|
+
return theme.ascii ? '!' : '⚠';
|
|
30227
|
+
if (isSuccess)
|
|
30228
|
+
return theme.ascii ? '+' : '✓';
|
|
30229
|
+
if (isInfo)
|
|
30230
|
+
return theme.ascii ? 'i' : 'ℹ';
|
|
30231
|
+
return '';
|
|
30232
|
+
})();
|
|
30233
|
+
const statusBody = rawTrailing
|
|
30234
|
+
? glyph
|
|
30235
|
+
? `${glyph} ${rawTrailing}`
|
|
30236
|
+
: rawTrailing
|
|
30237
|
+
: '';
|
|
30238
|
+
// Row 2 color picks. Each kind gets its own theme color so success
|
|
30239
|
+
// and loading are visually distinct (was conflated under `accent`
|
|
30240
|
+
// pre-redesign — users couldn't tell "done" from "in progress").
|
|
30241
|
+
// loading → accent (cyan / preset blue)
|
|
30242
|
+
// error → danger (red / preset red)
|
|
30243
|
+
// warning → warning (yellow)
|
|
30244
|
+
// success → success (green)
|
|
30245
|
+
// info → info (blue / preset accent in light themes)
|
|
30246
|
+
// idle → undefined + dim (passive, blends with chrome)
|
|
30247
|
+
const statusColor = isError
|
|
30248
|
+
? theme.colors.danger
|
|
30249
|
+
: isWarning
|
|
30250
|
+
? theme.colors.warning
|
|
30251
|
+
: isSuccess
|
|
30252
|
+
? theme.colors.success
|
|
30253
|
+
: isLoading
|
|
30254
|
+
? theme.colors.accent
|
|
30255
|
+
: isInfo
|
|
30256
|
+
? theme.colors.info
|
|
30257
|
+
: undefined;
|
|
30258
|
+
const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
|
|
30259
|
+
const statusDim = !statusBold;
|
|
30260
|
+
const hintsText = hints.contextual.join(' ');
|
|
29899
30261
|
const globalText = hints.global.join(' · ');
|
|
29900
|
-
|
|
29901
|
-
//
|
|
29902
|
-
//
|
|
29903
|
-
//
|
|
29904
|
-
|
|
29905
|
-
|
|
29906
|
-
|
|
29907
|
-
|
|
29908
|
-
|
|
29909
|
-
|
|
29910
|
-
|
|
29911
|
-
|
|
29912
|
-
|
|
29913
|
-
|
|
29914
|
-
|
|
29915
|
-
|
|
29916
|
-
|
|
29917
|
-
|
|
29918
|
-
|
|
29919
|
-
|
|
29920
|
-
|
|
29921
|
-
|
|
29922
|
-
|
|
29923
|
-
|
|
29924
|
-
|
|
30262
|
+
return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
|
|
30263
|
+
// Row 1: contextual ↔ global hints. justifyContent pushes them
|
|
30264
|
+
// to opposite edges so the eye can scan each cluster as one
|
|
30265
|
+
// block instead of hunting through a single concatenated line.
|
|
30266
|
+
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)),
|
|
30267
|
+
// Row 2: status / loading / idle tip / error. Empty Text keeps
|
|
30268
|
+
// the row reserved when nothing's set so the surrounding layout
|
|
30269
|
+
// doesn't shift as status flips on/off.
|
|
30270
|
+
h(Text, {
|
|
30271
|
+
color: statusColor,
|
|
30272
|
+
dimColor: statusDim,
|
|
30273
|
+
bold: statusBold,
|
|
30274
|
+
}, statusBody));
|
|
30275
|
+
}
|
|
30276
|
+
|
|
30277
|
+
const COMBINING_MARK_RANGES = [
|
|
30278
|
+
[0x0300, 0x036f],
|
|
30279
|
+
[0x1ab0, 0x1aff],
|
|
30280
|
+
[0x1dc0, 0x1dff],
|
|
30281
|
+
[0x20d0, 0x20ff],
|
|
30282
|
+
[0xfe20, 0xfe2f],
|
|
30283
|
+
];
|
|
30284
|
+
const WIDE_CHARACTER_RANGES = [
|
|
30285
|
+
[0x1100, 0x115f],
|
|
30286
|
+
[0x2329, 0x232a],
|
|
30287
|
+
[0x2e80, 0xa4cf],
|
|
30288
|
+
[0xac00, 0xd7a3],
|
|
30289
|
+
[0xf900, 0xfaff],
|
|
30290
|
+
[0xfe10, 0xfe19],
|
|
30291
|
+
[0xfe30, 0xfe6f],
|
|
30292
|
+
[0xff00, 0xff60],
|
|
30293
|
+
[0xffe0, 0xffe6],
|
|
30294
|
+
[0x2600, 0x27bf],
|
|
30295
|
+
[0x1f000, 0x1f9ff],
|
|
30296
|
+
[0x20000, 0x3fffd],
|
|
30297
|
+
];
|
|
30298
|
+
function isInRange(codePoint, ranges) {
|
|
30299
|
+
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30300
|
+
}
|
|
30301
|
+
function characterWidth(character) {
|
|
30302
|
+
const codePoint = character.codePointAt(0) || 0;
|
|
30303
|
+
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30304
|
+
return 0;
|
|
30305
|
+
}
|
|
30306
|
+
if (codePoint === 0x200d ||
|
|
30307
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30308
|
+
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30309
|
+
return 0;
|
|
30310
|
+
}
|
|
30311
|
+
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30312
|
+
}
|
|
30313
|
+
function cellWidth(value) {
|
|
30314
|
+
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30315
|
+
}
|
|
30316
|
+
/**
|
|
30317
|
+
* Word-wrap `value` into lines that each fit within `width` cells. Breaks
|
|
30318
|
+
* on whitespace where possible; falls back to mid-word splits when a single
|
|
30319
|
+
* word is wider than the budget. Preserves blank input as a single empty
|
|
30320
|
+
* line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
|
|
30321
|
+
*/
|
|
30322
|
+
function wrapCells(value, width) {
|
|
30323
|
+
if (width < 1) {
|
|
30324
|
+
return [value];
|
|
30325
|
+
}
|
|
30326
|
+
if (cellWidth(value) <= width) {
|
|
30327
|
+
return [value];
|
|
30328
|
+
}
|
|
30329
|
+
const lines = [];
|
|
30330
|
+
let current = '';
|
|
30331
|
+
let currentWidth = 0;
|
|
30332
|
+
const flush = () => {
|
|
30333
|
+
if (current.length > 0) {
|
|
30334
|
+
lines.push(current);
|
|
30335
|
+
current = '';
|
|
30336
|
+
currentWidth = 0;
|
|
30337
|
+
}
|
|
30338
|
+
};
|
|
30339
|
+
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30340
|
+
// boundaries when possible.
|
|
30341
|
+
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30342
|
+
for (const token of tokens) {
|
|
30343
|
+
const tokenWidth = cellWidth(token);
|
|
30344
|
+
if (currentWidth + tokenWidth <= width) {
|
|
30345
|
+
current += token;
|
|
30346
|
+
currentWidth += tokenWidth;
|
|
30347
|
+
continue;
|
|
30348
|
+
}
|
|
30349
|
+
if (/^\s+$/.test(token)) {
|
|
30350
|
+
// Drop boundary whitespace at line breaks.
|
|
30351
|
+
flush();
|
|
30352
|
+
continue;
|
|
30353
|
+
}
|
|
30354
|
+
flush();
|
|
30355
|
+
if (tokenWidth <= width) {
|
|
30356
|
+
current = token;
|
|
30357
|
+
currentWidth = tokenWidth;
|
|
30358
|
+
continue;
|
|
30359
|
+
}
|
|
30360
|
+
// Word longer than budget — hard-split into chunks.
|
|
30361
|
+
let remaining = token;
|
|
30362
|
+
while (cellWidth(remaining) > width) {
|
|
30363
|
+
let chunk = '';
|
|
30364
|
+
let chunkWidth = 0;
|
|
30365
|
+
for (const character of Array.from(remaining)) {
|
|
30366
|
+
const charW = characterWidth(character);
|
|
30367
|
+
if (chunkWidth + charW > width)
|
|
30368
|
+
break;
|
|
30369
|
+
chunk += character;
|
|
30370
|
+
chunkWidth += charW;
|
|
30371
|
+
}
|
|
30372
|
+
lines.push(chunk);
|
|
30373
|
+
remaining = remaining.slice(chunk.length);
|
|
30374
|
+
}
|
|
30375
|
+
if (remaining.length > 0) {
|
|
30376
|
+
current = remaining;
|
|
30377
|
+
currentWidth = cellWidth(remaining);
|
|
30378
|
+
}
|
|
30379
|
+
}
|
|
30380
|
+
flush();
|
|
30381
|
+
return lines.length > 0 ? lines : [value];
|
|
30382
|
+
}
|
|
30383
|
+
function truncateCells(value, width) {
|
|
30384
|
+
if (width < 1) {
|
|
30385
|
+
return '';
|
|
30386
|
+
}
|
|
30387
|
+
if (cellWidth(value) <= width) {
|
|
30388
|
+
return value;
|
|
30389
|
+
}
|
|
30390
|
+
const suffix = width > 3 ? '...' : '';
|
|
30391
|
+
const available = width - cellWidth(suffix);
|
|
30392
|
+
let used = 0;
|
|
30393
|
+
let output = '';
|
|
30394
|
+
for (const character of Array.from(value)) {
|
|
30395
|
+
const nextWidth = characterWidth(character);
|
|
30396
|
+
if (used + nextWidth > available) {
|
|
30397
|
+
break;
|
|
30398
|
+
}
|
|
30399
|
+
output += character;
|
|
30400
|
+
used += nextWidth;
|
|
30401
|
+
}
|
|
30402
|
+
return `${output}${suffix}`;
|
|
30403
|
+
}
|
|
30404
|
+
/**
|
|
30405
|
+
* Truncate a file path so the filename (last segment) is preserved,
|
|
30406
|
+
* eliding middle directory segments with `…/` instead of dropping
|
|
30407
|
+
* end-of-string characters.
|
|
30408
|
+
*
|
|
30409
|
+
* `truncateCells` is the wrong tool for paths because it preserves the
|
|
30410
|
+
* START of the string and drops the END — losing the filename, which
|
|
30411
|
+
* is the most useful part. Example with `truncateCells`:
|
|
30412
|
+
*
|
|
30413
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
|
|
30414
|
+
*
|
|
30415
|
+
* `truncatePathCells` preserves the filename and elides middle:
|
|
30416
|
+
*
|
|
30417
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
|
|
30418
|
+
*
|
|
30419
|
+
* The algorithm tries successively-smaller prefixes (keeping the start
|
|
30420
|
+
* of the path, the filename, and replacing the dropped middle segments
|
|
30421
|
+
* with `…`) and returns the largest variant that fits. When even
|
|
30422
|
+
* `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
|
|
30423
|
+
* the abbreviated form — better to show end-of-name than start-of-path.
|
|
30424
|
+
*
|
|
30425
|
+
* For inputs without `/` separators, behaves identically to
|
|
30426
|
+
* `truncateCells`. Empty / width-0 cases match `truncateCells` too.
|
|
30427
|
+
*
|
|
30428
|
+
* @example
|
|
30429
|
+
* truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
|
|
30430
|
+
* truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
|
|
30431
|
+
* truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
|
|
30432
|
+
* truncatePathCells('plainname.ts', 8) // 'plain...'
|
|
30433
|
+
*/
|
|
30434
|
+
function truncatePathCells(value, width) {
|
|
30435
|
+
if (width < 1)
|
|
30436
|
+
return '';
|
|
30437
|
+
if (cellWidth(value) <= width)
|
|
30438
|
+
return value;
|
|
30439
|
+
// No path structure to exploit — fall through to plain truncation.
|
|
30440
|
+
if (!value.includes('/'))
|
|
30441
|
+
return truncateCells(value, width);
|
|
30442
|
+
const segments = value.split('/');
|
|
30443
|
+
const filename = segments[segments.length - 1] ?? '';
|
|
30444
|
+
const prefix = segments.slice(0, -1);
|
|
30445
|
+
// Path is just '/filename' or has only the filename — no middle to
|
|
30446
|
+
// elide. Defer to plain truncation.
|
|
30447
|
+
if (prefix.length === 0)
|
|
30448
|
+
return truncateCells(value, width);
|
|
30449
|
+
// Walk from "keep all prefix segments except the deepest" down to
|
|
30450
|
+
// "keep no prefix segments." First variant that fits wins.
|
|
30451
|
+
for (let keep = prefix.length - 1; keep >= 0; keep--) {
|
|
30452
|
+
const candidate = keep === 0
|
|
30453
|
+
? `…/${filename}`
|
|
30454
|
+
: `${prefix.slice(0, keep).join('/')}/…/${filename}`;
|
|
30455
|
+
if (cellWidth(candidate) <= width)
|
|
30456
|
+
return candidate;
|
|
30457
|
+
}
|
|
30458
|
+
// Even `…/<filename>` doesn't fit. Use plain truncation on that
|
|
30459
|
+
// form — preserves the leading `…/` so the user knows a path was
|
|
30460
|
+
// elided, then ellipsis-truncates the filename.
|
|
30461
|
+
return truncateCells(`…/${filename}`, width);
|
|
29925
30462
|
}
|
|
29926
30463
|
|
|
29927
30464
|
/**
|
|
@@ -30148,218 +30685,318 @@ function sidebarTabCount(tab, context) {
|
|
|
30148
30685
|
}
|
|
30149
30686
|
}
|
|
30150
30687
|
|
|
30151
|
-
const COMBINING_MARK_RANGES = [
|
|
30152
|
-
[0x0300, 0x036f],
|
|
30153
|
-
[0x1ab0, 0x1aff],
|
|
30154
|
-
[0x1dc0, 0x1dff],
|
|
30155
|
-
[0x20d0, 0x20ff],
|
|
30156
|
-
[0xfe20, 0xfe2f],
|
|
30157
|
-
];
|
|
30158
|
-
const WIDE_CHARACTER_RANGES = [
|
|
30159
|
-
[0x1100, 0x115f],
|
|
30160
|
-
[0x2329, 0x232a],
|
|
30161
|
-
[0x2e80, 0xa4cf],
|
|
30162
|
-
[0xac00, 0xd7a3],
|
|
30163
|
-
[0xf900, 0xfaff],
|
|
30164
|
-
[0xfe10, 0xfe19],
|
|
30165
|
-
[0xfe30, 0xfe6f],
|
|
30166
|
-
[0xff00, 0xff60],
|
|
30167
|
-
[0xffe0, 0xffe6],
|
|
30168
|
-
[0x2600, 0x27bf],
|
|
30169
|
-
[0x1f000, 0x1f9ff],
|
|
30170
|
-
[0x20000, 0x3fffd],
|
|
30171
|
-
];
|
|
30172
|
-
function isInRange(codePoint, ranges) {
|
|
30173
|
-
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30174
|
-
}
|
|
30175
|
-
function characterWidth(character) {
|
|
30176
|
-
const codePoint = character.codePointAt(0) || 0;
|
|
30177
|
-
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30178
|
-
return 0;
|
|
30179
|
-
}
|
|
30180
|
-
if (codePoint === 0x200d ||
|
|
30181
|
-
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30182
|
-
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30183
|
-
return 0;
|
|
30184
|
-
}
|
|
30185
|
-
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30186
|
-
}
|
|
30187
|
-
function cellWidth(value) {
|
|
30188
|
-
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30189
|
-
}
|
|
30190
30688
|
/**
|
|
30191
|
-
*
|
|
30192
|
-
*
|
|
30193
|
-
*
|
|
30194
|
-
*
|
|
30689
|
+
* Header chip builder. Turns the workstation's title-bar state into an
|
|
30690
|
+
* ordered list of small visually-distinct chips:
|
|
30691
|
+
*
|
|
30692
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30693
|
+
*
|
|
30694
|
+
* Pre-refactor the title bar concatenated every segment into a single
|
|
30695
|
+
* Text span, which made the eye read the whole thing as one run of
|
|
30696
|
+
* words (the same problem the footer had). Splitting into chips with a
|
|
30697
|
+
* fixed separator lets each segment carry its own color and lets the
|
|
30698
|
+
* user scan the bar in chunks — "what app, what repo, what branch,
|
|
30699
|
+
* how clean, what PR state, what mode" — instead of parsing left-to-
|
|
30700
|
+
* right.
|
|
30701
|
+
*
|
|
30702
|
+
* Why a separate module: the header runtime renders chips and handles
|
|
30703
|
+
* truncation; chip construction is pure transformation of state +
|
|
30704
|
+
* context + theme. Splitting them keeps the chips testable in
|
|
30705
|
+
* isolation and keeps the runtime small.
|
|
30706
|
+
*
|
|
30707
|
+
* Truncation strategy lives in the consumer, not here — when the total
|
|
30708
|
+
* width exceeds the column budget, the header falls back to the
|
|
30709
|
+
* pre-redesign single-fragment truncated string so the ellipsis can't
|
|
30710
|
+
* land mid-glyph. We always return the FULL chip list; the consumer
|
|
30711
|
+
* decides whether to drop chips, fall back, or render all of them.
|
|
30195
30712
|
*/
|
|
30196
|
-
|
|
30197
|
-
|
|
30198
|
-
|
|
30713
|
+
/**
|
|
30714
|
+
* Default separator inserted between chips by the consumer. Exported as
|
|
30715
|
+
* a constant so tests and width math agree on what they're measuring.
|
|
30716
|
+
* The trailing/leading spaces are part of the separator — `·` alone
|
|
30717
|
+
* would butt against adjacent chip labels.
|
|
30718
|
+
*/
|
|
30719
|
+
const HEADER_CHIP_SEPARATOR = ' · ';
|
|
30720
|
+
/**
|
|
30721
|
+
* Build the ordered chip list for the header. Chips not relevant to the
|
|
30722
|
+
* current state (no PR loaded, no breadcrumb, no search input, …) are
|
|
30723
|
+
* omitted entirely rather than rendered as empty placeholders, so the
|
|
30724
|
+
* consumer can just `chips.map(render)` without checking for empties.
|
|
30725
|
+
*/
|
|
30726
|
+
function buildHeaderChips(input) {
|
|
30727
|
+
const { theme } = input;
|
|
30728
|
+
const chips = [];
|
|
30729
|
+
// App label — the constant identity. Accent + bold so it anchors the
|
|
30730
|
+
// left edge of the bar.
|
|
30731
|
+
chips.push({
|
|
30732
|
+
id: 'app',
|
|
30733
|
+
label: input.appLabel,
|
|
30734
|
+
color: theme.colors.accent,
|
|
30735
|
+
dim: false,
|
|
30736
|
+
bold: true,
|
|
30737
|
+
});
|
|
30738
|
+
// Repo. Default color — it's contextual but not the headline.
|
|
30739
|
+
chips.push({
|
|
30740
|
+
id: 'repo',
|
|
30741
|
+
label: input.repo,
|
|
30742
|
+
color: undefined,
|
|
30743
|
+
dim: false,
|
|
30744
|
+
bold: false,
|
|
30745
|
+
});
|
|
30746
|
+
// Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
|
|
30747
|
+
// is identifiable even when the branch name is generic ("main" /
|
|
30748
|
+
// "master").
|
|
30749
|
+
const branchGlyph = theme.ascii ? 'git:' : '⎇';
|
|
30750
|
+
chips.push({
|
|
30751
|
+
id: 'branch',
|
|
30752
|
+
label: `${branchGlyph} ${input.branch}`,
|
|
30753
|
+
color: theme.colors.accent,
|
|
30754
|
+
dim: false,
|
|
30755
|
+
bold: true,
|
|
30756
|
+
});
|
|
30757
|
+
// Dirty/clean. Positive framing on clean (success color + ✓), warning
|
|
30758
|
+
// on dirty (warning color + ●). ASCII fallbacks keep the chip
|
|
30759
|
+
// identifiable on dumb terminals.
|
|
30760
|
+
const dirtyChip = input.dirty
|
|
30761
|
+
? {
|
|
30762
|
+
id: 'dirty',
|
|
30763
|
+
label: theme.ascii ? '* dirty' : '● dirty',
|
|
30764
|
+
color: theme.colors.warning,
|
|
30765
|
+
dim: false,
|
|
30766
|
+
bold: false,
|
|
30767
|
+
}
|
|
30768
|
+
: {
|
|
30769
|
+
id: 'dirty',
|
|
30770
|
+
label: theme.ascii ? '+ clean' : '✓ clean',
|
|
30771
|
+
color: theme.colors.success,
|
|
30772
|
+
dim: false,
|
|
30773
|
+
bold: false,
|
|
30774
|
+
};
|
|
30775
|
+
chips.push(dirtyChip);
|
|
30776
|
+
// Bisect — only when active. Distinct chip so users entering the TUI
|
|
30777
|
+
// mid-bisect see it immediately (#784). Warning color because bisect
|
|
30778
|
+
// is an "in progress, requires user action" state.
|
|
30779
|
+
if (input.bisecting) {
|
|
30780
|
+
chips.push({
|
|
30781
|
+
id: 'bisecting',
|
|
30782
|
+
label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
|
|
30783
|
+
color: theme.colors.warning,
|
|
30784
|
+
dim: false,
|
|
30785
|
+
bold: true,
|
|
30786
|
+
});
|
|
30199
30787
|
}
|
|
30200
|
-
|
|
30201
|
-
|
|
30788
|
+
// PR state. When present, the chip uses the PR-state glyph + a short
|
|
30789
|
+
// label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
|
|
30790
|
+
// "no PR" chip so users know the system DID look (vs. the bar just
|
|
30791
|
+
// being blank).
|
|
30792
|
+
if (input.pullRequest) {
|
|
30793
|
+
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
30794
|
+
const stateLabel = input.pullRequest.isDraft
|
|
30795
|
+
? 'DRAFT'
|
|
30796
|
+
: input.pullRequest.state.toUpperCase();
|
|
30797
|
+
const label = prGlyph.glyph
|
|
30798
|
+
? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
|
|
30799
|
+
: `PR #${input.pullRequest.number} ${stateLabel}`;
|
|
30800
|
+
chips.push({
|
|
30801
|
+
id: 'pr',
|
|
30802
|
+
label,
|
|
30803
|
+
color: prGlyph.color,
|
|
30804
|
+
dim: prGlyph.dim,
|
|
30805
|
+
bold: false,
|
|
30806
|
+
});
|
|
30202
30807
|
}
|
|
30203
|
-
|
|
30204
|
-
|
|
30205
|
-
|
|
30206
|
-
|
|
30207
|
-
|
|
30208
|
-
|
|
30209
|
-
|
|
30210
|
-
|
|
30211
|
-
}
|
|
30212
|
-
};
|
|
30213
|
-
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30214
|
-
// boundaries when possible.
|
|
30215
|
-
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30216
|
-
for (const token of tokens) {
|
|
30217
|
-
const tokenWidth = cellWidth(token);
|
|
30218
|
-
if (currentWidth + tokenWidth <= width) {
|
|
30219
|
-
current += token;
|
|
30220
|
-
currentWidth += tokenWidth;
|
|
30221
|
-
continue;
|
|
30222
|
-
}
|
|
30223
|
-
if (/^\s+$/.test(token)) {
|
|
30224
|
-
// Drop boundary whitespace at line breaks.
|
|
30225
|
-
flush();
|
|
30226
|
-
continue;
|
|
30227
|
-
}
|
|
30228
|
-
flush();
|
|
30229
|
-
if (tokenWidth <= width) {
|
|
30230
|
-
current = token;
|
|
30231
|
-
currentWidth = tokenWidth;
|
|
30232
|
-
continue;
|
|
30233
|
-
}
|
|
30234
|
-
// Word longer than budget — hard-split into chunks.
|
|
30235
|
-
let remaining = token;
|
|
30236
|
-
while (cellWidth(remaining) > width) {
|
|
30237
|
-
let chunk = '';
|
|
30238
|
-
let chunkWidth = 0;
|
|
30239
|
-
for (const character of Array.from(remaining)) {
|
|
30240
|
-
const charW = characterWidth(character);
|
|
30241
|
-
if (chunkWidth + charW > width)
|
|
30242
|
-
break;
|
|
30243
|
-
chunk += character;
|
|
30244
|
-
chunkWidth += charW;
|
|
30245
|
-
}
|
|
30246
|
-
lines.push(chunk);
|
|
30247
|
-
remaining = remaining.slice(chunk.length);
|
|
30248
|
-
}
|
|
30249
|
-
if (remaining.length > 0) {
|
|
30250
|
-
current = remaining;
|
|
30251
|
-
currentWidth = cellWidth(remaining);
|
|
30252
|
-
}
|
|
30808
|
+
else {
|
|
30809
|
+
chips.push({
|
|
30810
|
+
id: 'pr',
|
|
30811
|
+
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
30812
|
+
color: theme.colors.muted,
|
|
30813
|
+
dim: true,
|
|
30814
|
+
bold: false,
|
|
30815
|
+
});
|
|
30253
30816
|
}
|
|
30254
|
-
|
|
30255
|
-
|
|
30256
|
-
|
|
30257
|
-
|
|
30258
|
-
|
|
30259
|
-
|
|
30817
|
+
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
30818
|
+
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
30819
|
+
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
30820
|
+
// dirty/PR) reads as one cluster and the "navigation" group (view
|
|
30821
|
+
// breadcrumb / loading) reads as a separate cluster.
|
|
30822
|
+
if (input.breadcrumb) {
|
|
30823
|
+
chips.push({
|
|
30824
|
+
id: 'view',
|
|
30825
|
+
label: input.breadcrumb,
|
|
30826
|
+
color: theme.colors.muted,
|
|
30827
|
+
dim: true,
|
|
30828
|
+
bold: false,
|
|
30829
|
+
});
|
|
30260
30830
|
}
|
|
30261
|
-
if (
|
|
30262
|
-
|
|
30831
|
+
if (input.loading) {
|
|
30832
|
+
chips.push({
|
|
30833
|
+
id: 'loading',
|
|
30834
|
+
label: input.loading.trim(),
|
|
30835
|
+
color: theme.colors.muted,
|
|
30836
|
+
dim: true,
|
|
30837
|
+
bold: false,
|
|
30838
|
+
});
|
|
30263
30839
|
}
|
|
30264
|
-
|
|
30265
|
-
|
|
30266
|
-
|
|
30267
|
-
|
|
30268
|
-
|
|
30269
|
-
|
|
30270
|
-
|
|
30271
|
-
|
|
30272
|
-
|
|
30273
|
-
|
|
30274
|
-
|
|
30840
|
+
// Mode — the explicit input-mode indicator (#P2.2). Always present
|
|
30841
|
+
// so users never wonder why `q` doesn't quit while they're editing.
|
|
30842
|
+
// EDIT / FILTER use the warning color to signal "your keystrokes
|
|
30843
|
+
// mean something different right now"; NORMAL uses accent (matches
|
|
30844
|
+
// the app chip's home base).
|
|
30845
|
+
const modeColor = input.mode === 'NORMAL'
|
|
30846
|
+
? theme.colors.accent
|
|
30847
|
+
: theme.colors.warning;
|
|
30848
|
+
chips.push({
|
|
30849
|
+
id: 'mode',
|
|
30850
|
+
label: `[${input.mode}]`,
|
|
30851
|
+
color: modeColor,
|
|
30852
|
+
dim: false,
|
|
30853
|
+
bold: true,
|
|
30854
|
+
});
|
|
30855
|
+
// Search — only when active. Dim so it doesn't compete with the
|
|
30856
|
+
// identity chips for attention; the user knows it's there because
|
|
30857
|
+
// they're typing into it.
|
|
30858
|
+
if (input.search) {
|
|
30859
|
+
chips.push({
|
|
30860
|
+
id: 'search',
|
|
30861
|
+
label: input.search,
|
|
30862
|
+
color: theme.colors.muted,
|
|
30863
|
+
dim: true,
|
|
30864
|
+
bold: false,
|
|
30865
|
+
});
|
|
30275
30866
|
}
|
|
30276
|
-
return
|
|
30867
|
+
return chips;
|
|
30868
|
+
}
|
|
30869
|
+
/**
|
|
30870
|
+
* Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
|
|
30871
|
+
* between every pair. Used by the consumer to decide whether the
|
|
30872
|
+
* chip layout fits the column budget or whether to fall back to the
|
|
30873
|
+
* single-fragment truncated path.
|
|
30874
|
+
*/
|
|
30875
|
+
function measureHeaderChipsWidth(chips) {
|
|
30876
|
+
if (chips.length === 0)
|
|
30877
|
+
return 0;
|
|
30878
|
+
const labels = chips.map((chip) => cellWidth(chip.label));
|
|
30879
|
+
const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
|
|
30880
|
+
return labels.reduce((sum, w) => sum + w, 0) + separators;
|
|
30277
30881
|
}
|
|
30278
30882
|
|
|
30279
30883
|
/**
|
|
30280
|
-
* Title-bar renderer. Surfaces
|
|
30281
|
-
*
|
|
30282
|
-
* - current repo owner/name (or "local repository")
|
|
30283
|
-
* - current branch + dirty / BISECTING flag
|
|
30284
|
-
* - PR glyph + label when one is detected
|
|
30285
|
-
* - breadcrumb of the view stack
|
|
30286
|
-
* - loading hint for boot / context fetches
|
|
30287
|
-
* - mode indicator: [NORMAL] / [EDIT] / [FILTER]
|
|
30288
|
-
* - active filter / search input
|
|
30884
|
+
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
30885
|
+
* state as a row of small visually-distinct chips:
|
|
30289
30886
|
*
|
|
30290
|
-
*
|
|
30291
|
-
* fall back to a single-fragment Text (truncating the joined string) so
|
|
30292
|
-
* the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
|
|
30293
|
-
* glyph in its own colored span when there's headroom.
|
|
30887
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30294
30888
|
*
|
|
30295
|
-
*
|
|
30296
|
-
*
|
|
30889
|
+
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
30890
|
+
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
30891
|
+
* instead of parsing one long sentence. Chip construction is in
|
|
30892
|
+
* `chrome/headerChips.ts`; this runtime just renders.
|
|
30893
|
+
*
|
|
30894
|
+
* Truncation: when the assembled chip row overruns the available
|
|
30895
|
+
* columns we fall back to a single Text fragment (truncating the
|
|
30896
|
+
* joined chip labels) so the ellipsis can't land mid-glyph. This is
|
|
30897
|
+
* the same defensive pattern the pre-redesign single-fragment code
|
|
30898
|
+
* used, applied at the chip-list level instead of the inline glyph
|
|
30899
|
+
* split.
|
|
30900
|
+
*
|
|
30901
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30902
|
+
* 5a.7 of #890. Chip restructuring introduced post-0.54.2.
|
|
30297
30903
|
*/
|
|
30298
30904
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
30299
30905
|
const { Box, Text } = components;
|
|
30906
|
+
// Pull the source state into the small "describe what to render"
|
|
30907
|
+
// shape the chip builder expects. Keeps the runtime decoupled from
|
|
30908
|
+
// the chip layout — the builder doesn't know about LogInkState /
|
|
30909
|
+
// LogInkContext, just plain values.
|
|
30300
30910
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
30301
|
-
|
|
30302
|
-
|
|
30303
|
-
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
30304
|
-
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
30911
|
+
const dirty = Boolean(context.branches?.dirty);
|
|
30912
|
+
const bisecting = Boolean(context.bisect?.active);
|
|
30305
30913
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
30306
30914
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
30307
30915
|
: 'local repository';
|
|
30308
30916
|
const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
30309
|
-
|
|
30310
|
-
|
|
30311
|
-
|
|
30312
|
-
: 'no PR';
|
|
30313
|
-
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
30314
|
-
// Boot loading wins over the per-context loading hint because it
|
|
30315
|
-
// tells the user the headline thing they care about (commits aren't
|
|
30316
|
-
// ready yet) — the context fetches finish independently and surface
|
|
30317
|
-
// their own per-section loading copy in the sidebars.
|
|
30917
|
+
// Boot loading wins over the per-context loading hint — same
|
|
30918
|
+
// priority as pre-redesign. Context fetches still surface their own
|
|
30919
|
+
// copy in the sidebars.
|
|
30318
30920
|
const loading = state.bootLoading
|
|
30319
|
-
? '
|
|
30320
|
-
: isLogInkContextLoading(contextStatus) ? '
|
|
30921
|
+
? 'loading commits'
|
|
30922
|
+
: isLogInkContextLoading(contextStatus) ? 'loading context' : '';
|
|
30321
30923
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
30322
30924
|
const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
|
|
30323
|
-
// Repo breadcrumb (when nested) comes first so the user sees which
|
|
30324
|
-
// submodule they're in at a glance, then the view breadcrumb (when
|
|
30325
|
-
// pushed deeper than the root view). The truncate fallback in the
|
|
30326
|
-
// title row still applies — when both fight for space, the ellipsis
|
|
30327
|
-
// lands at the end of whichever segment overflows.
|
|
30328
30925
|
const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
|
|
30329
|
-
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
30330
|
-
// never wonder why `q` doesn't quit while they're editing or filtering.
|
|
30331
30926
|
const mode = state.commitCompose.editing
|
|
30332
|
-
? '
|
|
30927
|
+
? 'EDIT'
|
|
30333
30928
|
: state.filterMode
|
|
30334
|
-
? '
|
|
30335
|
-
: '
|
|
30336
|
-
const
|
|
30337
|
-
|
|
30338
|
-
|
|
30339
|
-
|
|
30340
|
-
|
|
30341
|
-
const
|
|
30342
|
-
|
|
30343
|
-
|
|
30344
|
-
|
|
30345
|
-
|
|
30346
|
-
|
|
30347
|
-
?
|
|
30348
|
-
|
|
30349
|
-
|
|
30350
|
-
:
|
|
30929
|
+
? 'FILTER'
|
|
30930
|
+
: 'NORMAL';
|
|
30931
|
+
const search = state.filterMode
|
|
30932
|
+
? `search: ${state.filter}_`
|
|
30933
|
+
: state.filter
|
|
30934
|
+
? `filter: ${state.filter}`
|
|
30935
|
+
: '';
|
|
30936
|
+
const chips = buildHeaderChips({
|
|
30937
|
+
appLabel,
|
|
30938
|
+
repo,
|
|
30939
|
+
branch,
|
|
30940
|
+
dirty,
|
|
30941
|
+
bisecting,
|
|
30942
|
+
pullRequest: prInfo ? {
|
|
30943
|
+
number: prInfo.number,
|
|
30944
|
+
state: prInfo.state,
|
|
30945
|
+
isDraft: prInfo.isDraft,
|
|
30946
|
+
} : undefined,
|
|
30947
|
+
breadcrumb: view,
|
|
30948
|
+
loading,
|
|
30949
|
+
mode,
|
|
30950
|
+
search: search ? truncateCells(search, 36) : '',
|
|
30951
|
+
theme,
|
|
30952
|
+
});
|
|
30953
|
+
// Truncation budget. Header line gets the full terminal width minus
|
|
30954
|
+
// the box's horizontal padding (2 cells) and a small safety margin.
|
|
30955
|
+
const budget = Math.max(0, columns - 4);
|
|
30956
|
+
const chipsWidth = measureHeaderChipsWidth(chips);
|
|
30351
30957
|
return h(Box, {
|
|
30352
30958
|
borderColor: theme.colors.border,
|
|
30353
30959
|
borderStyle: theme.borderStyle,
|
|
30354
30960
|
height: 3,
|
|
30355
30961
|
paddingX: 1,
|
|
30356
|
-
},
|
|
30357
|
-
? h
|
|
30358
|
-
: h
|
|
30359
|
-
|
|
30360
|
-
|
|
30361
|
-
|
|
30362
|
-
|
|
30962
|
+
}, chipsWidth <= budget
|
|
30963
|
+
? renderChipRow(h, Text, chips)
|
|
30964
|
+
: renderFallback(h, Text, chips, theme, budget));
|
|
30965
|
+
}
|
|
30966
|
+
/**
|
|
30967
|
+
* Render every chip as its own Text span with its own color/style,
|
|
30968
|
+
* interleaved with dim separator spans. This is the path used when
|
|
30969
|
+
* everything fits — the eye gets the full chip treatment.
|
|
30970
|
+
*/
|
|
30971
|
+
function renderChipRow(h, Text, chips) {
|
|
30972
|
+
const nodes = [];
|
|
30973
|
+
chips.forEach((chip, index) => {
|
|
30974
|
+
if (index > 0) {
|
|
30975
|
+
// Separator is intentionally dim so the eye can use it as a
|
|
30976
|
+
// visual delimiter without it competing with chip labels for
|
|
30977
|
+
// attention.
|
|
30978
|
+
nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
|
|
30979
|
+
}
|
|
30980
|
+
nodes.push(h(Text, {
|
|
30981
|
+
key: chip.id,
|
|
30982
|
+
color: chip.color,
|
|
30983
|
+
dimColor: chip.dim,
|
|
30984
|
+
bold: chip.bold,
|
|
30985
|
+
}, chip.label));
|
|
30986
|
+
});
|
|
30987
|
+
return nodes;
|
|
30988
|
+
}
|
|
30989
|
+
/**
|
|
30990
|
+
* Fallback path for narrow terminals. Concatenates every chip label
|
|
30991
|
+
* with separators, then truncates the whole string with
|
|
30992
|
+
* `truncateCells` so the ellipsis lands at a cell boundary. Loses the
|
|
30993
|
+
* per-chip color treatment in exchange for guaranteed legibility on
|
|
30994
|
+
* narrow displays — the same trade-off the pre-redesign single-
|
|
30995
|
+
* fragment code made for its inline glyph color split.
|
|
30996
|
+
*/
|
|
30997
|
+
function renderFallback(h, Text, chips, theme, budget) {
|
|
30998
|
+
const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
|
|
30999
|
+
return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
|
|
30363
31000
|
}
|
|
30364
31001
|
|
|
30365
31002
|
/**
|
|
@@ -30582,10 +31219,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
30582
31219
|
const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
|
|
30583
31220
|
const fileRows = worktree.files.slice(0, 12).map((file, index) => {
|
|
30584
31221
|
const codes = `${file.indexStatus}${file.worktreeStatus}`;
|
|
31222
|
+
// Smart path truncation: keep the leading status codes and elide
|
|
31223
|
+
// middle directory segments to preserve the filename. Falls back
|
|
31224
|
+
// to plain truncation when the codes + a meaningful filename
|
|
31225
|
+
// don't both fit. Same shape as the detail surface so all the
|
|
31226
|
+
// status-row renderings elide consistently.
|
|
31227
|
+
const prefix = ` ${codes} `;
|
|
31228
|
+
const totalBudget = width - 4;
|
|
31229
|
+
const pathBudget = totalBudget - cellWidth(prefix);
|
|
31230
|
+
const label = pathBudget >= 8
|
|
31231
|
+
? `${prefix}${truncatePathCells(file.path, pathBudget)}`
|
|
31232
|
+
: truncateCells(`${prefix}${file.path}`, totalBudget);
|
|
30585
31233
|
return h(Text, {
|
|
30586
31234
|
key: `tab-status-file-${index}`,
|
|
30587
31235
|
color: colorOf(file.state),
|
|
30588
|
-
},
|
|
31236
|
+
}, label);
|
|
30589
31237
|
});
|
|
30590
31238
|
return [
|
|
30591
31239
|
summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
|
|
@@ -31928,7 +32576,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
31928
32576
|
color: theme.noColor ? undefined : theme.colors.accent,
|
|
31929
32577
|
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
31930
32578
|
inverse: isActive && focused,
|
|
31931
|
-
},
|
|
32579
|
+
}, (() => {
|
|
32580
|
+
// Smart path truncation for the diff file header: keep
|
|
32581
|
+
// the leading arrow glyph and elide middle path
|
|
32582
|
+
// segments so the filename is never lost. Falls back to
|
|
32583
|
+
// plain truncation when there isn't room for a
|
|
32584
|
+
// meaningful filename.
|
|
32585
|
+
const pathBudget = (width - 4) - cellWidth(arrow);
|
|
32586
|
+
return pathBudget >= 8
|
|
32587
|
+
? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
|
|
32588
|
+
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
32589
|
+
})());
|
|
31932
32590
|
}
|
|
31933
32591
|
return h(Text, {
|
|
31934
32592
|
key: `stash-diff-line-${absoluteIndex}`,
|
|
@@ -35409,6 +36067,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
|
|
|
35409
36067
|
});
|
|
35410
36068
|
return out;
|
|
35411
36069
|
}
|
|
36070
|
+
/**
|
|
36071
|
+
* Compose a `<prefix><path><suffix>` line where the path gets smart
|
|
36072
|
+
* middle-elision truncation if needed, while the fixed prefix/suffix
|
|
36073
|
+
* decorations stay intact. Falls back to plain whole-line truncation
|
|
36074
|
+
* when the suffix decorations consume too much of the budget for the
|
|
36075
|
+
* path-aware variant to leave a meaningful filename.
|
|
36076
|
+
*
|
|
36077
|
+
* Used by the changed-files list AND the compose-context staged /
|
|
36078
|
+
* unstaged sections so all three places elide identically — same
|
|
36079
|
+
* floor (8 cells), same fallback shape.
|
|
36080
|
+
*/
|
|
36081
|
+
function smartPathLabel(prefix, path, suffix, totalBudget) {
|
|
36082
|
+
const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
|
|
36083
|
+
if (pathBudget >= 8) {
|
|
36084
|
+
return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
|
|
36085
|
+
}
|
|
36086
|
+
return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
|
|
36087
|
+
}
|
|
35412
36088
|
/**
|
|
35413
36089
|
* Render a list of changed files with status-code colors and stats. Used
|
|
35414
36090
|
* by both the history inspector and the commit-diff detail panel so the
|
|
@@ -35436,13 +36112,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
|
|
|
35436
36112
|
// in `lfsPointer.ts` so even rename / mode-only rows are
|
|
35437
36113
|
// flagged.
|
|
35438
36114
|
const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
|
|
35439
|
-
|
|
36115
|
+
// Smart path truncation via `smartPathLabel`: keeps the cursor +
|
|
36116
|
+
// status-code prefix and the stats/badge suffix intact, gives
|
|
36117
|
+
// the path's remaining width budget to middle-elision so the
|
|
36118
|
+
// filename survives instead of getting blunt-truncated off the
|
|
36119
|
+
// end (the issue users hit when inspector paths read like
|
|
36120
|
+
// `src/commands/log/da...`).
|
|
36121
|
+
const labelPrefix = `${cursor} ${statusCode} `;
|
|
36122
|
+
const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
|
|
36123
|
+
const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
|
|
35440
36124
|
return h(Text, {
|
|
35441
36125
|
key: `commit-file-${index}`,
|
|
35442
36126
|
color: statusCodeColor(file.status, theme),
|
|
35443
36127
|
inverse: isSelected && focused && !theme.noColor,
|
|
35444
36128
|
bold: isSelected,
|
|
35445
|
-
},
|
|
36129
|
+
}, label);
|
|
35446
36130
|
});
|
|
35447
36131
|
}
|
|
35448
36132
|
function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
|
|
@@ -35718,7 +36402,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35718
36402
|
...stagedFiles.map((file, index) => h(Text, {
|
|
35719
36403
|
key: `compose-context-staged-${index}`,
|
|
35720
36404
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
35721
|
-
},
|
|
36405
|
+
}, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
|
|
35722
36406
|
h(Text, { key: 'compose-context-staged-spacer' }, ''),
|
|
35723
36407
|
]
|
|
35724
36408
|
: []), ...(unstagedFiles.length
|
|
@@ -35727,7 +36411,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35727
36411
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
35728
36412
|
key: `compose-context-unstaged-${index}`,
|
|
35729
36413
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
35730
|
-
},
|
|
36414
|
+
}, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
|
|
35731
36415
|
]
|
|
35732
36416
|
: !stagedFiles.length && !loadingWorktree
|
|
35733
36417
|
? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
|
|
@@ -36627,7 +37311,7 @@ function LogInkApp(deps) {
|
|
|
36627
37311
|
if (cancelled || !mountedRef.current)
|
|
36628
37312
|
return;
|
|
36629
37313
|
const message = error instanceof Error ? error.message : String(error);
|
|
36630
|
-
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}
|
|
37314
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
|
|
36631
37315
|
dispatch({ type: 'setBootLoading', value: false });
|
|
36632
37316
|
});
|
|
36633
37317
|
return () => {
|
|
@@ -36695,8 +37379,15 @@ function LogInkApp(deps) {
|
|
|
36695
37379
|
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
36696
37380
|
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
36697
37381
|
};
|
|
37382
|
+
// Stash commits as graph roots so post-operation refreshes
|
|
37383
|
+
// keep the same rich graph the boot loader assembled. Without
|
|
37384
|
+
// this, every commit / split-apply / etc. would drop stash
|
|
37385
|
+
// anchors and the cursor-syncs-history effect would degrade
|
|
37386
|
+
// back to "tip not in loaded window" for older stashes.
|
|
37387
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
36698
37388
|
const fresh = await getLogRows(git, mergedArgv, {
|
|
36699
37389
|
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
37390
|
+
extraRefs: stashHashes,
|
|
36700
37391
|
});
|
|
36701
37392
|
if (mountedRef.current && fresh) {
|
|
36702
37393
|
dispatch({ type: 'replaceRows', rows: fresh });
|
|
@@ -37262,12 +37953,33 @@ function LogInkApp(deps) {
|
|
|
37262
37953
|
// fetched yet); a status hint surfaces in that case so the user
|
|
37263
37954
|
// knows to toggle full graph or load older commits.
|
|
37264
37955
|
const lastSyncedHashRef = React.useRef(undefined);
|
|
37956
|
+
// Tracks which target hashes we've already anchored a `git log`
|
|
37957
|
+
// fetch on (#1034 follow-up). When the cursor-syncs-history effect
|
|
37958
|
+
// sees a target whose hash isn't in the loaded window AND isn't in
|
|
37959
|
+
// this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
|
|
37960
|
+
// here. After the fetch resolves and rows are appended, the effect
|
|
37961
|
+
// re-fires; if the target STILL isn't loaded the resolver sees the
|
|
37962
|
+
// hash in this set and returns `unreachable` instead of looping.
|
|
37963
|
+
//
|
|
37964
|
+
// Stored as a ref because (a) the resolver only ever reads it and
|
|
37965
|
+
// (b) component re-renders on state.filteredCommits change are the
|
|
37966
|
+
// re-fire trigger; storing here in state would add a redundant
|
|
37967
|
+
// render per attempt.
|
|
37968
|
+
const attemptedContextHashesRef = React.useRef(new Set());
|
|
37969
|
+
const loadCommitContextRef = React.useRef(null);
|
|
37265
37970
|
React.useEffect(() => {
|
|
37266
37971
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37267
37972
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37268
37973
|
const onTagTab = state.activeView === 'tags' ||
|
|
37269
37974
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37270
|
-
|
|
37975
|
+
// User-reported gap: cursoring a stash didn't sync the history
|
|
37976
|
+
// cursor the way cursoring a branch / tag did. Same auto-jump
|
|
37977
|
+
// affordance now extends to stashes; the stash's commit hash IS
|
|
37978
|
+
// the row to land on (stashes are commits living off the
|
|
37979
|
+
// `refs/stash` tree, visible under `--all` / fullGraph).
|
|
37980
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
37981
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
37982
|
+
if (!onBranchTab && !onTagTab && !onStashTab)
|
|
37271
37983
|
return;
|
|
37272
37984
|
let targetHash;
|
|
37273
37985
|
let targetLabel;
|
|
@@ -37293,51 +38005,117 @@ function LogInkApp(deps) {
|
|
|
37293
38005
|
targetLabel = `tag ${tag.name}`;
|
|
37294
38006
|
}
|
|
37295
38007
|
}
|
|
37296
|
-
if (
|
|
37297
|
-
|
|
37298
|
-
|
|
37299
|
-
|
|
37300
|
-
|
|
37301
|
-
|
|
37302
|
-
|
|
37303
|
-
|
|
37304
|
-
|
|
37305
|
-
|
|
37306
|
-
|
|
37307
|
-
|
|
37308
|
-
|
|
37309
|
-
|
|
37310
|
-
|
|
37311
|
-
|
|
37312
|
-
|
|
37313
|
-
|
|
37314
|
-
|
|
37315
|
-
|
|
37316
|
-
|
|
38008
|
+
else if (onStashTab) {
|
|
38009
|
+
const all = context.stashes?.stashes || [];
|
|
38010
|
+
const visible = state.filter
|
|
38011
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
38012
|
+
: all;
|
|
38013
|
+
const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
|
|
38014
|
+
if (stash) {
|
|
38015
|
+
// Two-step fallback chain for stash cursor sync:
|
|
38016
|
+
//
|
|
38017
|
+
// 1. Try `baseHash` (the branch tip the stash was created
|
|
38018
|
+
// from). This answers the user-visible question "where
|
|
38019
|
+
// in larger git history was this stash made?" — that's
|
|
38020
|
+
// the branch origin point, not the stash's own merge-
|
|
38021
|
+
// commit row off in `refs/stash`. Base commits live on
|
|
38022
|
+
// regular branches so they're almost always in the
|
|
38023
|
+
// loaded window.
|
|
38024
|
+
//
|
|
38025
|
+
// 2. If `baseHash` isn't in the loaded window (the stash's
|
|
38026
|
+
// base branch was deleted, or the base is older than
|
|
38027
|
+
// the 1000-commit cap), fall back to `stash.hash`
|
|
38028
|
+
// itself. The stash commit was added as an extraRef so
|
|
38029
|
+
// it's reachable from the graph if it fits the window.
|
|
38030
|
+
//
|
|
38031
|
+
// Only after BOTH miss does the effect report "tip not in
|
|
38032
|
+
// loaded window." The label flips to mention "base" vs the
|
|
38033
|
+
// stash commit so the user knows what they're looking at.
|
|
38034
|
+
// hashesMatchAny handles the short-hash auto-extension
|
|
38035
|
+
// mismatch between `git stash list --format=%h` (stash hash)
|
|
38036
|
+
// and `git log --pretty=format:%h` (history row). Same
|
|
38037
|
+
// hazard as the branch/tag cursor sync — see src/git/hashes.ts.
|
|
38038
|
+
const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
|
|
38039
|
+
const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
|
|
38040
|
+
if (baseLoaded) {
|
|
38041
|
+
targetHash = stash.baseHash;
|
|
38042
|
+
targetLabel = `${stash.ref}'s base`;
|
|
38043
|
+
}
|
|
38044
|
+
else if (hashLoaded) {
|
|
38045
|
+
targetHash = stash.hash;
|
|
38046
|
+
targetLabel = stash.ref;
|
|
38047
|
+
}
|
|
38048
|
+
else {
|
|
38049
|
+
// Neither in window — set to baseHash so the standard
|
|
38050
|
+
// "not in loaded window" message fires with a meaningful
|
|
38051
|
+
// label (the base is what the user actually wants to see).
|
|
38052
|
+
targetHash = stash.baseHash || stash.hash;
|
|
38053
|
+
targetLabel = stash.ref;
|
|
38054
|
+
}
|
|
38055
|
+
}
|
|
37317
38056
|
}
|
|
37318
|
-
|
|
37319
|
-
|
|
37320
|
-
|
|
37321
|
-
|
|
37322
|
-
}
|
|
38057
|
+
// Delegate the actual decision to the pure resolver so the
|
|
38058
|
+
// logic is testable in isolation. The effect just performs the
|
|
38059
|
+
// resolver's chosen action.
|
|
38060
|
+
const decision = resolveCursorSyncDecision({
|
|
38061
|
+
target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
|
|
38062
|
+
loadedHashes: buildLoadedHashSet(state.filteredCommits),
|
|
38063
|
+
lastSyncedHash: lastSyncedHashRef.current,
|
|
38064
|
+
attemptedContextHashes: attemptedContextHashesRef.current,
|
|
38065
|
+
});
|
|
38066
|
+
switch (decision.type) {
|
|
38067
|
+
case 'noop':
|
|
38068
|
+
return;
|
|
38069
|
+
case 'jump':
|
|
38070
|
+
lastSyncedHashRef.current = decision.hash;
|
|
38071
|
+
dispatch({ type: 'selectCommitByHash', hash: decision.hash });
|
|
38072
|
+
dispatch({
|
|
38073
|
+
type: 'setStatus',
|
|
38074
|
+
value: `Synced history to ${decision.label} tip`,
|
|
38075
|
+
});
|
|
38076
|
+
return;
|
|
38077
|
+
case 'load-context':
|
|
38078
|
+
// Mark the hash as attempted BEFORE firing the load so a
|
|
38079
|
+
// re-fire of this effect (state.filteredCommits change while
|
|
38080
|
+
// the load is in flight) doesn't kick off a duplicate
|
|
38081
|
+
// request. The resolver sees the hash in the set and
|
|
38082
|
+
// returns `noop` until the load completes; on completion the
|
|
38083
|
+
// appendRows triggers a final re-fire that either jumps or
|
|
38084
|
+
// returns `unreachable`.
|
|
38085
|
+
attemptedContextHashesRef.current.add(decision.target.hash);
|
|
38086
|
+
void loadCommitContextRef.current?.(decision.target);
|
|
38087
|
+
return;
|
|
38088
|
+
case 'unreachable':
|
|
38089
|
+
dispatch({
|
|
38090
|
+
type: 'setStatus',
|
|
38091
|
+
value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
|
|
38092
|
+
kind: 'warning',
|
|
38093
|
+
});
|
|
38094
|
+
return;
|
|
37323
38095
|
}
|
|
37324
38096
|
}, [
|
|
37325
|
-
dispatch, context.branches, context.tags,
|
|
38097
|
+
dispatch, context.branches, context.tags, context.stashes,
|
|
37326
38098
|
state.activeView, state.focus, state.sidebarTab,
|
|
37327
|
-
state.selectedBranchIndex, state.selectedTagIndex,
|
|
38099
|
+
state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
|
|
37328
38100
|
state.branchSort, state.tagSort, state.filter,
|
|
37329
38101
|
state.filteredCommits,
|
|
37330
38102
|
]);
|
|
37331
38103
|
// Reset the dedup ref when the user moves focus away from the
|
|
37332
|
-
// sidebar branches / tags tab so re-entering re-fires the
|
|
37333
|
-
// even if the cursored
|
|
38104
|
+
// sidebar branches / tags / stashes tab so re-entering re-fires the
|
|
38105
|
+
// sync even if the cursored row is the same as before.
|
|
37334
38106
|
React.useEffect(() => {
|
|
37335
38107
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37336
38108
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37337
38109
|
const onTagTab = state.activeView === 'tags' ||
|
|
37338
38110
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37339
|
-
|
|
38111
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
38112
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
38113
|
+
if (!onBranchTab && !onTagTab && !onStashTab) {
|
|
37340
38114
|
lastSyncedHashRef.current = undefined;
|
|
38115
|
+
// Drop any context-load attempt tracking too. If the user
|
|
38116
|
+
// navigates back later we want to retry rather than show
|
|
38117
|
+
// "unreachable" based on a stale attempted-set.
|
|
38118
|
+
attemptedContextHashesRef.current = new Set();
|
|
37341
38119
|
}
|
|
37342
38120
|
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
37343
38121
|
React.useEffect(() => {
|
|
@@ -37368,7 +38146,7 @@ function LogInkApp(deps) {
|
|
|
37368
38146
|
]);
|
|
37369
38147
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
37370
38148
|
if (!selectedWorktreeFile) {
|
|
37371
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38149
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37372
38150
|
return;
|
|
37373
38151
|
}
|
|
37374
38152
|
dispatch({ type: 'setStatus', value: 'updating file stage state' });
|
|
@@ -37383,7 +38161,7 @@ function LogInkApp(deps) {
|
|
|
37383
38161
|
const toggleSelectedHunkStage = React.useCallback(async () => {
|
|
37384
38162
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37385
38163
|
if (!selectedHunk) {
|
|
37386
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38164
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37387
38165
|
return;
|
|
37388
38166
|
}
|
|
37389
38167
|
dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
|
|
@@ -37397,6 +38175,7 @@ function LogInkApp(deps) {
|
|
|
37397
38175
|
dispatch({
|
|
37398
38176
|
type: 'setStatus',
|
|
37399
38177
|
value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
|
|
38178
|
+
kind: 'success',
|
|
37400
38179
|
});
|
|
37401
38180
|
await refreshWorktreeContext();
|
|
37402
38181
|
setWorktreeDiff(undefined);
|
|
@@ -37406,12 +38185,13 @@ function LogInkApp(deps) {
|
|
|
37406
38185
|
dispatch({
|
|
37407
38186
|
type: 'setStatus',
|
|
37408
38187
|
value: error.message || 'failed to update hunk stage state',
|
|
38188
|
+
kind: 'error',
|
|
37409
38189
|
});
|
|
37410
38190
|
}
|
|
37411
38191
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37412
38192
|
const revertSelectedFile = React.useCallback(async () => {
|
|
37413
38193
|
if (!selectedWorktreeFile) {
|
|
37414
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38194
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37415
38195
|
return;
|
|
37416
38196
|
}
|
|
37417
38197
|
dispatch({ type: 'setStatus', value: 'reverting selected file' });
|
|
@@ -37424,13 +38204,13 @@ function LogInkApp(deps) {
|
|
|
37424
38204
|
const revertSelectedHunk = React.useCallback(async () => {
|
|
37425
38205
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37426
38206
|
if (!selectedHunk) {
|
|
37427
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38207
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37428
38208
|
return;
|
|
37429
38209
|
}
|
|
37430
38210
|
dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
|
|
37431
38211
|
try {
|
|
37432
38212
|
await revertHunk(git, selectedHunk);
|
|
37433
|
-
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}
|
|
38213
|
+
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
|
|
37434
38214
|
await refreshWorktreeContext();
|
|
37435
38215
|
setWorktreeDiff(undefined);
|
|
37436
38216
|
setWorktreeHunks(undefined);
|
|
@@ -37439,13 +38219,14 @@ function LogInkApp(deps) {
|
|
|
37439
38219
|
dispatch({
|
|
37440
38220
|
type: 'setStatus',
|
|
37441
38221
|
value: error.message || 'failed to revert hunk',
|
|
38222
|
+
kind: 'error',
|
|
37442
38223
|
});
|
|
37443
38224
|
}
|
|
37444
38225
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37445
38226
|
const createCommitFromCompose = React.useCallback(async () => {
|
|
37446
38227
|
const stagedCount = context.worktree?.stagedCount || 0;
|
|
37447
38228
|
if (!stagedCount) {
|
|
37448
|
-
dispatch({ type: 'setStatus', value: 'stage changes before committing' });
|
|
38229
|
+
dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
|
|
37449
38230
|
return;
|
|
37450
38231
|
}
|
|
37451
38232
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
@@ -37537,12 +38318,12 @@ function LogInkApp(deps) {
|
|
|
37537
38318
|
// dispatches because the user already knows what happened.
|
|
37538
38319
|
if (result.cancelled) {
|
|
37539
38320
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37540
|
-
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
38321
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
|
|
37541
38322
|
return;
|
|
37542
38323
|
}
|
|
37543
38324
|
if (result.ok && result.draft) {
|
|
37544
38325
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37545
|
-
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
38326
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
37546
38327
|
return;
|
|
37547
38328
|
}
|
|
37548
38329
|
dispatch({
|
|
@@ -37626,7 +38407,7 @@ function LogInkApp(deps) {
|
|
|
37626
38407
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
37627
38408
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37628
38409
|
if (!head) {
|
|
37629
|
-
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
|
|
38410
|
+
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
|
|
37630
38411
|
return;
|
|
37631
38412
|
}
|
|
37632
38413
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37634,11 +38415,12 @@ function LogInkApp(deps) {
|
|
|
37634
38415
|
dispatch({
|
|
37635
38416
|
type: 'setStatus',
|
|
37636
38417
|
value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
|
|
38418
|
+
kind: 'warning',
|
|
37637
38419
|
});
|
|
37638
38420
|
return;
|
|
37639
38421
|
}
|
|
37640
38422
|
if (head === defaultBranch) {
|
|
37641
|
-
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first
|
|
38423
|
+
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
|
|
37642
38424
|
return;
|
|
37643
38425
|
}
|
|
37644
38426
|
if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
|
|
@@ -37648,6 +38430,7 @@ function LogInkApp(deps) {
|
|
|
37648
38430
|
value: existing
|
|
37649
38431
|
? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
|
|
37650
38432
|
: `A pull request is already open for ${head}.`,
|
|
38433
|
+
kind: 'warning',
|
|
37651
38434
|
});
|
|
37652
38435
|
return;
|
|
37653
38436
|
}
|
|
@@ -37688,10 +38471,10 @@ function LogInkApp(deps) {
|
|
|
37688
38471
|
const initialBody = body.body || '';
|
|
37689
38472
|
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37690
38473
|
if (!body.ok) {
|
|
37691
|
-
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually
|
|
38474
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
|
|
37692
38475
|
}
|
|
37693
38476
|
else {
|
|
37694
|
-
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
38477
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
|
|
37695
38478
|
}
|
|
37696
38479
|
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
37697
38480
|
// prompt. If a future refactor adds an `await` between the flag
|
|
@@ -37758,17 +38541,18 @@ function LogInkApp(deps) {
|
|
|
37758
38541
|
const yankText = React.useCallback(async (value, label) => {
|
|
37759
38542
|
const clipboard = clipboardRunner || defaultClipboardRunner;
|
|
37760
38543
|
if (!value) {
|
|
37761
|
-
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty
|
|
38544
|
+
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
|
|
37762
38545
|
return;
|
|
37763
38546
|
}
|
|
37764
38547
|
try {
|
|
37765
38548
|
await clipboard(value);
|
|
37766
|
-
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard
|
|
38549
|
+
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
|
|
37767
38550
|
}
|
|
37768
38551
|
catch (error) {
|
|
37769
38552
|
dispatch({
|
|
37770
38553
|
type: 'setStatus',
|
|
37771
38554
|
value: `Copy failed (${label}): ${error.message}`,
|
|
38555
|
+
kind: 'error',
|
|
37772
38556
|
});
|
|
37773
38557
|
}
|
|
37774
38558
|
}, [clipboardRunner, dispatch]);
|
|
@@ -37792,7 +38576,7 @@ function LogInkApp(deps) {
|
|
|
37792
38576
|
const startChangelogView = React.useCallback(async (options = {}) => {
|
|
37793
38577
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37794
38578
|
if (!head) {
|
|
37795
|
-
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
|
|
38579
|
+
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
|
|
37796
38580
|
return;
|
|
37797
38581
|
}
|
|
37798
38582
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37844,7 +38628,7 @@ function LogInkApp(deps) {
|
|
|
37844
38628
|
baseLabel,
|
|
37845
38629
|
error: result.message,
|
|
37846
38630
|
});
|
|
37847
|
-
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}
|
|
38631
|
+
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
|
|
37848
38632
|
return;
|
|
37849
38633
|
}
|
|
37850
38634
|
dispatch({
|
|
@@ -37859,6 +38643,7 @@ function LogInkApp(deps) {
|
|
|
37859
38643
|
dispatch({
|
|
37860
38644
|
type: 'setStatus',
|
|
37861
38645
|
value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
|
|
38646
|
+
kind: 'success',
|
|
37862
38647
|
});
|
|
37863
38648
|
}, [
|
|
37864
38649
|
context.branches?.currentBranch,
|
|
@@ -37879,7 +38664,7 @@ function LogInkApp(deps) {
|
|
|
37879
38664
|
const yankChangelog = React.useCallback(() => {
|
|
37880
38665
|
const text = state.changelogView.text;
|
|
37881
38666
|
if (!text) {
|
|
37882
|
-
dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
|
|
38667
|
+
dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
|
|
37883
38668
|
return;
|
|
37884
38669
|
}
|
|
37885
38670
|
void yankText(text, 'changelog');
|
|
@@ -37892,7 +38677,7 @@ function LogInkApp(deps) {
|
|
|
37892
38677
|
const openChangelogInEditor = React.useCallback(() => {
|
|
37893
38678
|
const current = state.changelogView.text;
|
|
37894
38679
|
if (current === undefined) {
|
|
37895
|
-
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
|
|
38680
|
+
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
|
|
37896
38681
|
return;
|
|
37897
38682
|
}
|
|
37898
38683
|
let dir;
|
|
@@ -37903,6 +38688,7 @@ function LogInkApp(deps) {
|
|
|
37903
38688
|
dispatch({
|
|
37904
38689
|
type: 'setStatus',
|
|
37905
38690
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38691
|
+
kind: 'error',
|
|
37906
38692
|
});
|
|
37907
38693
|
return;
|
|
37908
38694
|
}
|
|
@@ -37914,6 +38700,7 @@ function LogInkApp(deps) {
|
|
|
37914
38700
|
dispatch({
|
|
37915
38701
|
type: 'setStatus',
|
|
37916
38702
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38703
|
+
kind: 'error',
|
|
37917
38704
|
});
|
|
37918
38705
|
try {
|
|
37919
38706
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -37937,13 +38724,13 @@ function LogInkApp(deps) {
|
|
|
37937
38724
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37938
38725
|
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37939
38726
|
if (result.error) {
|
|
37940
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38727
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37941
38728
|
}
|
|
37942
38729
|
else if (result.signal) {
|
|
37943
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38730
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37944
38731
|
}
|
|
37945
38732
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37946
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38733
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37947
38734
|
}
|
|
37948
38735
|
else {
|
|
37949
38736
|
editorOk = true;
|
|
@@ -37958,12 +38745,13 @@ function LogInkApp(deps) {
|
|
|
37958
38745
|
try {
|
|
37959
38746
|
const content = readFileSync$1(file, 'utf8');
|
|
37960
38747
|
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
37961
|
-
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
38748
|
+
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
|
|
37962
38749
|
}
|
|
37963
38750
|
catch (error) {
|
|
37964
38751
|
dispatch({
|
|
37965
38752
|
type: 'setStatus',
|
|
37966
38753
|
value: `Failed to read back edited changelog: ${error.message}`,
|
|
38754
|
+
kind: 'error',
|
|
37967
38755
|
});
|
|
37968
38756
|
}
|
|
37969
38757
|
}
|
|
@@ -38004,19 +38792,19 @@ function LogInkApp(deps) {
|
|
|
38004
38792
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
38005
38793
|
const result = spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
|
|
38006
38794
|
if (result.error) {
|
|
38007
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38795
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
38008
38796
|
}
|
|
38009
38797
|
else if (result.signal) {
|
|
38010
38798
|
// Editor was killed by a signal (e.g. ^C, SIGTERM). status is
|
|
38011
38799
|
// null in this case, so the old `status !== 0` check would
|
|
38012
38800
|
// mistakenly fall through to the success branch.
|
|
38013
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38801
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
38014
38802
|
}
|
|
38015
38803
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
38016
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38804
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
38017
38805
|
}
|
|
38018
38806
|
else {
|
|
38019
|
-
dispatch({ type: 'setStatus', value: `Edited ${path}
|
|
38807
|
+
dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
|
|
38020
38808
|
}
|
|
38021
38809
|
}
|
|
38022
38810
|
finally {
|
|
@@ -38063,6 +38851,7 @@ function LogInkApp(deps) {
|
|
|
38063
38851
|
dispatch({
|
|
38064
38852
|
type: 'setStatus',
|
|
38065
38853
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38854
|
+
kind: 'error',
|
|
38066
38855
|
});
|
|
38067
38856
|
return;
|
|
38068
38857
|
}
|
|
@@ -38074,6 +38863,7 @@ function LogInkApp(deps) {
|
|
|
38074
38863
|
dispatch({
|
|
38075
38864
|
type: 'setStatus',
|
|
38076
38865
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38866
|
+
kind: 'error',
|
|
38077
38867
|
});
|
|
38078
38868
|
try {
|
|
38079
38869
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -38097,13 +38887,13 @@ function LogInkApp(deps) {
|
|
|
38097
38887
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
38098
38888
|
const result = spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
38099
38889
|
if (result.error) {
|
|
38100
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38890
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
38101
38891
|
}
|
|
38102
38892
|
else if (result.signal) {
|
|
38103
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38893
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
38104
38894
|
}
|
|
38105
38895
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
38106
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38896
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
38107
38897
|
}
|
|
38108
38898
|
else {
|
|
38109
38899
|
editorOk = true;
|
|
@@ -38122,12 +38912,13 @@ function LogInkApp(deps) {
|
|
|
38122
38912
|
try {
|
|
38123
38913
|
const content = readFileSync$1(file, 'utf8');
|
|
38124
38914
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
|
|
38125
|
-
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
|
|
38915
|
+
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
|
|
38126
38916
|
}
|
|
38127
38917
|
catch (error) {
|
|
38128
38918
|
dispatch({
|
|
38129
38919
|
type: 'setStatus',
|
|
38130
38920
|
value: `Failed to read back edited draft: ${error.message}`,
|
|
38921
|
+
kind: 'error',
|
|
38131
38922
|
});
|
|
38132
38923
|
}
|
|
38133
38924
|
}
|
|
@@ -38203,7 +38994,7 @@ function LogInkApp(deps) {
|
|
|
38203
38994
|
const applyCommitSplit = React.useCallback(async () => {
|
|
38204
38995
|
const splitPlan = state.splitPlan;
|
|
38205
38996
|
if (!splitPlan?.plan || !splitPlan.planContext) {
|
|
38206
|
-
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
|
|
38997
|
+
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
|
|
38207
38998
|
return;
|
|
38208
38999
|
}
|
|
38209
39000
|
// Diagnostic dump for the silent-failure bug surfaced in #944
|
|
@@ -39174,7 +39965,7 @@ function LogInkApp(deps) {
|
|
|
39174
39965
|
};
|
|
39175
39966
|
const handler = handlers[id];
|
|
39176
39967
|
if (!handler) {
|
|
39177
|
-
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired
|
|
39968
|
+
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
39178
39969
|
return;
|
|
39179
39970
|
}
|
|
39180
39971
|
const result = await handler();
|
|
@@ -39219,7 +40010,37 @@ function LogInkApp(deps) {
|
|
|
39219
40010
|
// without flickering the surfaces through a 'loading' phase.
|
|
39220
40011
|
await refreshContext({ silent: true });
|
|
39221
40012
|
}
|
|
39222
|
-
|
|
40013
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
40014
|
+
//
|
|
40015
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
40016
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
40017
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
40018
|
+
// jump them to history view (where the worktree counts in the
|
|
40019
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
40020
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
40021
|
+
//
|
|
40022
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
40023
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
40024
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
40025
|
+
// affect untracked files when the stash held `-u` state), and
|
|
40026
|
+
// surface the new stash count on the status line so there's
|
|
40027
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
40028
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
40029
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
40030
|
+
await refreshWorktreeContext();
|
|
40031
|
+
}
|
|
40032
|
+
if (result?.ok && id === 'drop-stash') {
|
|
40033
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
40034
|
+
// untracked-file state that's now collected.
|
|
40035
|
+
await refreshWorktreeContext();
|
|
40036
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
40037
|
+
// reading the count back here would be stale because closures
|
|
40038
|
+
// capture the pre-refresh value. Status message stays generic
|
|
40039
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
40040
|
+
// unambiguous signal that the operation landed.
|
|
40041
|
+
}
|
|
40042
|
+
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
40043
|
+
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
39223
40044
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
39224
40045
|
state.statusFilterMask, state.tagSort]);
|
|
39225
40046
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
@@ -39381,15 +40202,15 @@ function LogInkApp(deps) {
|
|
|
39381
40202
|
}
|
|
39382
40203
|
}
|
|
39383
40204
|
if (!value || !label) {
|
|
39384
|
-
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
|
|
40205
|
+
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
|
|
39385
40206
|
return;
|
|
39386
40207
|
}
|
|
39387
40208
|
try {
|
|
39388
40209
|
await clipboard(value);
|
|
39389
|
-
dispatch({ type: 'setStatus', value: `Copied ${label}
|
|
40210
|
+
dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
|
|
39390
40211
|
}
|
|
39391
40212
|
catch (error) {
|
|
39392
|
-
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}
|
|
40213
|
+
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
|
|
39393
40214
|
}
|
|
39394
40215
|
}, [
|
|
39395
40216
|
clipboardRunner,
|
|
@@ -39449,63 +40270,175 @@ function LogInkApp(deps) {
|
|
|
39449
40270
|
React.useEffect(() => {
|
|
39450
40271
|
loadingMoreCommitsRef.current = loadingMoreCommits;
|
|
39451
40272
|
}, [loadingMoreCommits]);
|
|
40273
|
+
// STABLE useCallback (empty deps) for loadMoreCommits. The function
|
|
40274
|
+
// reads the volatile state (commit counts, fetch args, hasMore) via
|
|
40275
|
+
// refs that update on every render so the identity stays constant.
|
|
40276
|
+
//
|
|
40277
|
+
// Why stable matters: the cursor-syncs-history auto-load chain
|
|
40278
|
+
// calls this through a forward-reference ref (loadMoreCommitsRef).
|
|
40279
|
+
// If loadMoreCommits regenerated on every render — as the previous
|
|
40280
|
+
// implementation did via state deps — there was a render-order
|
|
40281
|
+
// race: the cursor sync effect would call the PREVIOUS render's
|
|
40282
|
+
// callback (still in the ref because the ref-setter useEffect runs
|
|
40283
|
+
// after the cursor-sync effect in declaration order), which had
|
|
40284
|
+
// captured a stale `state.commits.length` and re-fetched the same
|
|
40285
|
+
// window. The auto-load chain appeared to fire but never advanced
|
|
40286
|
+
// through history.
|
|
40287
|
+
//
|
|
40288
|
+
// Stable identity + refs sidesteps the race entirely: the function
|
|
40289
|
+
// never changes, and every call reads the latest state.
|
|
40290
|
+
const loadMoreStateRef = React.useRef({
|
|
40291
|
+
commitsLength: state.commits.length,
|
|
40292
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40293
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40294
|
+
hasMoreCommits,
|
|
40295
|
+
logArgv,
|
|
40296
|
+
});
|
|
40297
|
+
loadMoreStateRef.current = {
|
|
40298
|
+
commitsLength: state.commits.length,
|
|
40299
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40300
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40301
|
+
hasMoreCommits,
|
|
40302
|
+
logArgv,
|
|
40303
|
+
};
|
|
40304
|
+
const loadMoreCommits = React.useCallback(async (options = {}) => {
|
|
40305
|
+
const snap = loadMoreStateRef.current;
|
|
40306
|
+
if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
|
|
40307
|
+
return { fired: false, addedCommits: 0 };
|
|
40308
|
+
}
|
|
40309
|
+
if (snap.filteredCommitsLength === 0) {
|
|
40310
|
+
return { fired: false, addedCommits: 0 };
|
|
40311
|
+
}
|
|
40312
|
+
loadingMoreCommitsRef.current = true;
|
|
40313
|
+
const requestId = loadMoreRequestRef.current + 1;
|
|
40314
|
+
loadMoreRequestRef.current = requestId;
|
|
40315
|
+
setLoadingMoreCommits(true);
|
|
40316
|
+
dispatch({
|
|
40317
|
+
type: 'setStatus',
|
|
40318
|
+
value: options.statusMessage || 'loading older commits',
|
|
40319
|
+
loading: true,
|
|
40320
|
+
});
|
|
40321
|
+
const fetchArgs = snap.historyFetchArgs;
|
|
40322
|
+
const mergedArgv = {
|
|
40323
|
+
...snap.logArgv,
|
|
40324
|
+
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
40325
|
+
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
40326
|
+
};
|
|
40327
|
+
// Load-more paths a fresh page from git AFTER what's already
|
|
40328
|
+
// loaded; pass the stash hashes again so the additional rows
|
|
40329
|
+
// stay graph-consistent with the boot fetch (a window that
|
|
40330
|
+
// dropped stashes mid-stream would render with broken junctions).
|
|
40331
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40332
|
+
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
40333
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40334
|
+
skip: snap.commitsLength,
|
|
40335
|
+
extraRefs: stashHashes,
|
|
40336
|
+
}));
|
|
40337
|
+
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
40338
|
+
return { fired: false, addedCommits: 0 };
|
|
40339
|
+
}
|
|
40340
|
+
loadingMoreCommitsRef.current = false;
|
|
40341
|
+
setLoadingMoreCommits(false);
|
|
40342
|
+
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
40343
|
+
if (!nextRows) {
|
|
40344
|
+
dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
|
|
40345
|
+
return { fired: false, addedCommits: 0 };
|
|
40346
|
+
}
|
|
40347
|
+
if (nextRows?.length) {
|
|
40348
|
+
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40349
|
+
}
|
|
40350
|
+
setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
40351
|
+
return { fired: true, addedCommits: nextCommitCount };
|
|
40352
|
+
// Empty deps — the function is intentionally stable. State is
|
|
40353
|
+
// read via `loadMoreStateRef.current` at call time, and `dispatch`
|
|
40354
|
+
// / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
|
|
40355
|
+
// already stable across renders by React's contract.
|
|
40356
|
+
}, [dispatch, git]);
|
|
40357
|
+
// Scroll-near-bottom auto-trigger. Fires when the user's cursor is
|
|
40358
|
+
// within 20 rows of the last loaded commit so older history is
|
|
40359
|
+
// already on its way by the time they reach the bottom.
|
|
39452
40360
|
React.useEffect(() => {
|
|
39453
40361
|
const remaining = state.filteredCommits.length - state.selectedIndex - 1;
|
|
39454
|
-
|
|
39455
|
-
|
|
39456
|
-
|
|
39457
|
-
|
|
39458
|
-
|
|
39459
|
-
|
|
39460
|
-
|
|
39461
|
-
|
|
39462
|
-
|
|
39463
|
-
|
|
39464
|
-
setLoadingMoreCommits(true);
|
|
39465
|
-
dispatch({ type: 'setStatus', value: 'loading older commits' });
|
|
39466
|
-
const fetchArgs = state.historyFetchArgs;
|
|
39467
|
-
const mergedArgv = {
|
|
39468
|
-
...logArgv,
|
|
39469
|
-
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
39470
|
-
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
39471
|
-
};
|
|
39472
|
-
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
39473
|
-
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
39474
|
-
skip: state.commits.length,
|
|
39475
|
-
}));
|
|
39476
|
-
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
39477
|
-
return;
|
|
39478
|
-
}
|
|
39479
|
-
loadingMoreCommitsRef.current = false;
|
|
39480
|
-
setLoadingMoreCommits(false);
|
|
39481
|
-
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
39482
|
-
if (!nextRows) {
|
|
39483
|
-
dispatch({ type: 'setStatus', value: 'failed to load older commits' });
|
|
39484
|
-
return;
|
|
39485
|
-
}
|
|
39486
|
-
if (nextRows?.length) {
|
|
39487
|
-
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40362
|
+
if (remaining > 20)
|
|
40363
|
+
return;
|
|
40364
|
+
void loadMoreCommits().then((result) => {
|
|
40365
|
+
if (result.fired) {
|
|
40366
|
+
dispatch({
|
|
40367
|
+
type: 'setStatus',
|
|
40368
|
+
value: result.addedCommits
|
|
40369
|
+
? `loaded ${result.addedCommits} older commits`
|
|
40370
|
+
: 'end of history',
|
|
40371
|
+
});
|
|
39488
40372
|
}
|
|
39489
|
-
|
|
39490
|
-
dispatch({
|
|
39491
|
-
type: 'setStatus',
|
|
39492
|
-
value: nextCommitCount
|
|
39493
|
-
? `loaded ${nextCommitCount} older commits`
|
|
39494
|
-
: 'end of history',
|
|
39495
|
-
});
|
|
39496
|
-
}
|
|
39497
|
-
void loadMoreCommits();
|
|
40373
|
+
});
|
|
39498
40374
|
}, [
|
|
39499
40375
|
dispatch,
|
|
39500
|
-
|
|
39501
|
-
hasMoreCommits,
|
|
39502
|
-
loadingMoreCommits,
|
|
39503
|
-
logArgv,
|
|
39504
|
-
state.commits.length,
|
|
40376
|
+
loadMoreCommits,
|
|
39505
40377
|
state.filteredCommits.length,
|
|
39506
|
-
state.historyFetchArgs,
|
|
39507
40378
|
state.selectedIndex,
|
|
39508
40379
|
]);
|
|
40380
|
+
/**
|
|
40381
|
+
* Targeted-context loader for the cursor-syncs-history effect. Called
|
|
40382
|
+
* when the resolver returns `load-context` — the user cursored a
|
|
40383
|
+
* branch / tag / stash whose target commit isn't in the loaded
|
|
40384
|
+
* window, so we run a `git log` anchored on that commit (guaranteed
|
|
40385
|
+
* to include it) and merge the result via `appendRows` (which
|
|
40386
|
+
* already deduplicates by hash).
|
|
40387
|
+
*
|
|
40388
|
+
* Stable identity (empty deps) for the same reason as
|
|
40389
|
+
* `loadMoreCommits` — the cursor-sync effect calls this through a
|
|
40390
|
+
* forward-reference ref, and a regenerating callback would
|
|
40391
|
+
* reintroduce the render-order race that bit the previous chain.
|
|
40392
|
+
* All volatile state (logArgv, mostly) is read via refs.
|
|
40393
|
+
*/
|
|
40394
|
+
const loadCommitContextStateRef = React.useRef({ logArgv });
|
|
40395
|
+
loadCommitContextStateRef.current = { logArgv };
|
|
40396
|
+
const loadCommitContext = React.useCallback(async (target) => {
|
|
40397
|
+
const snap = loadCommitContextStateRef.current;
|
|
40398
|
+
if (!snap.logArgv)
|
|
40399
|
+
return;
|
|
40400
|
+
dispatch({
|
|
40401
|
+
type: 'setStatus',
|
|
40402
|
+
value: `Loading commits around ${target.label}…`,
|
|
40403
|
+
loading: true,
|
|
40404
|
+
});
|
|
40405
|
+
try {
|
|
40406
|
+
// No stashHashes here — `getLogRowsAnchoredOn` walks only from
|
|
40407
|
+
// the target so it can guarantee the target's inclusion.
|
|
40408
|
+
// Stashes are already in the loaded graph from boot's
|
|
40409
|
+
// `loadRowsWithStashes`; `appendRows` deduplicates by hash so
|
|
40410
|
+
// the merged result keeps both views without double-counting.
|
|
40411
|
+
const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
|
|
40412
|
+
if (!mountedRef.current)
|
|
40413
|
+
return;
|
|
40414
|
+
if (rows.length > 0) {
|
|
40415
|
+
dispatch({ type: 'appendRows', rows });
|
|
40416
|
+
// Don't dispatch a setStatus here — the cursor-sync effect
|
|
40417
|
+
// will re-fire on the appendRows-driven filteredCommits
|
|
40418
|
+
// change and either jump (success) or report unreachable
|
|
40419
|
+
// (failure), surfacing the right message.
|
|
40420
|
+
}
|
|
40421
|
+
else {
|
|
40422
|
+
dispatch({
|
|
40423
|
+
type: 'setStatus',
|
|
40424
|
+
value: `${target.label} target commit returned no rows — orphan ref?`,
|
|
40425
|
+
kind: 'warning',
|
|
40426
|
+
});
|
|
40427
|
+
}
|
|
40428
|
+
}
|
|
40429
|
+
catch (error) {
|
|
40430
|
+
if (mountedRef.current) {
|
|
40431
|
+
dispatch({
|
|
40432
|
+
type: 'setStatus',
|
|
40433
|
+
value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
40434
|
+
kind: 'error',
|
|
40435
|
+
});
|
|
40436
|
+
}
|
|
40437
|
+
}
|
|
40438
|
+
}, [dispatch, git]);
|
|
40439
|
+
React.useEffect(() => {
|
|
40440
|
+
loadCommitContextRef.current = loadCommitContext;
|
|
40441
|
+
}, [loadCommitContext]);
|
|
39509
40442
|
// Server-side history filter (#776). When the user submits `path:foo`
|
|
39510
40443
|
// or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
|
|
39511
40444
|
// this effect picks up the change, re-runs `getLogRows` with merged
|
|
@@ -39541,12 +40474,16 @@ function LogInkApp(deps) {
|
|
|
39541
40474
|
value: description ? `Refetching with ${description}` : 'Restoring full log',
|
|
39542
40475
|
});
|
|
39543
40476
|
void (async () => {
|
|
39544
|
-
const
|
|
40477
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40478
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40479
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40480
|
+
extraRefs: stashHashes,
|
|
40481
|
+
}));
|
|
39545
40482
|
if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
|
|
39546
40483
|
return;
|
|
39547
40484
|
}
|
|
39548
40485
|
if (!nextRows) {
|
|
39549
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
|
|
40486
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
|
|
39550
40487
|
return;
|
|
39551
40488
|
}
|
|
39552
40489
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39557,6 +40494,7 @@ function LogInkApp(deps) {
|
|
|
39557
40494
|
value: description
|
|
39558
40495
|
? `Showing ${matched} commits matching ${description}`
|
|
39559
40496
|
: 'Showing full log',
|
|
40497
|
+
kind: 'success',
|
|
39560
40498
|
});
|
|
39561
40499
|
})();
|
|
39562
40500
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
@@ -39586,12 +40524,20 @@ function LogInkApp(deps) {
|
|
|
39586
40524
|
: 'Loading compact history…',
|
|
39587
40525
|
});
|
|
39588
40526
|
void (async () => {
|
|
39589
|
-
|
|
40527
|
+
// Include stash commits as graph roots so the toggle's re-fetch
|
|
40528
|
+
// sees the same rich graph the boot loader assembles. Without
|
|
40529
|
+
// this, flipping `\` into full mode and back loses the stash
|
|
40530
|
+
// anchors that loadRowsWithStashes seeded on boot.
|
|
40531
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40532
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40533
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40534
|
+
extraRefs: stashHashes,
|
|
40535
|
+
}));
|
|
39590
40536
|
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
39591
40537
|
return;
|
|
39592
40538
|
}
|
|
39593
40539
|
if (!nextRows) {
|
|
39594
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
40540
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
|
|
39595
40541
|
return;
|
|
39596
40542
|
}
|
|
39597
40543
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39602,6 +40548,7 @@ function LogInkApp(deps) {
|
|
|
39602
40548
|
value: state.fullGraph
|
|
39603
40549
|
? `Showing ${matched} commits across all branches`
|
|
39604
40550
|
: `Showing ${matched} commits (compact)`,
|
|
40551
|
+
kind: 'success',
|
|
39605
40552
|
});
|
|
39606
40553
|
})();
|
|
39607
40554
|
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
@@ -40296,6 +41243,17 @@ function createLogArgvFromUiArgv(argv) {
|
|
|
40296
41243
|
return {
|
|
40297
41244
|
$0: argv.$0,
|
|
40298
41245
|
_: ['log'],
|
|
41246
|
+
// Pass `--all` through from the CLI. The yargs default is `true`
|
|
41247
|
+
// since 0.54.x — user feedback consistently asked for the
|
|
41248
|
+
// GitKraken-style "see all branches, tags, stashes" view as the
|
|
41249
|
+
// starting state. `coco ui --no-all` opts back to
|
|
41250
|
+
// current-branch-only.
|
|
41251
|
+
//
|
|
41252
|
+
// Note: passing `--branch foo` does NOT automatically scope away
|
|
41253
|
+
// from --all. If the user wants strictly that branch, they pass
|
|
41254
|
+
// `coco ui --branch foo --no-all`. We considered the implicit
|
|
41255
|
+
// scope-narrowing but it surprises users who pass `--branch` as
|
|
41256
|
+
// a "highlight this branch in the all-refs view" hint.
|
|
40299
41257
|
all: argv.all,
|
|
40300
41258
|
branch: argv.branch,
|
|
40301
41259
|
format: 'table',
|
|
@@ -40332,6 +41290,26 @@ function withCacheWrite(repoPath, loader) {
|
|
|
40332
41290
|
return rows;
|
|
40333
41291
|
};
|
|
40334
41292
|
}
|
|
41293
|
+
/**
|
|
41294
|
+
* Workstation-aware log loader (#1034 follow-up). Calls `git stash
|
|
41295
|
+
* list` first to collect every stash's commit hash, then passes them
|
|
41296
|
+
* as extra refs to `getLogRows` so the graph includes every stash as
|
|
41297
|
+
* a node — not just the latest (which is the only one `refs/stash`
|
|
41298
|
+
* points at and the only one `git log --all` walks).
|
|
41299
|
+
*
|
|
41300
|
+
* Without this, the stash → history cursor sync added in #1034 only
|
|
41301
|
+
* worked for `stash@{0}`; cursoring any older stash row reported
|
|
41302
|
+
* "tip not in loaded window" because that stash's commit hash was
|
|
41303
|
+
* never in the loaded graph window in the first place.
|
|
41304
|
+
*
|
|
41305
|
+
* The extra git call is cheap (one `git stash list --format=%H`,
|
|
41306
|
+
* usually sub-50ms). It's only an additive cost when stashes exist;
|
|
41307
|
+
* users on stash-free repos pay nothing.
|
|
41308
|
+
*/
|
|
41309
|
+
async function loadRowsWithStashes(git, logArgv) {
|
|
41310
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
41311
|
+
return getLogRows(git, logArgv, { extraRefs: stashHashes });
|
|
41312
|
+
}
|
|
40335
41313
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
40336
41314
|
const config = options.config || loadConfig(logArgv);
|
|
40337
41315
|
const git = options.git || getRepo();
|
|
@@ -40350,7 +41328,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
40350
41328
|
const initialRows = options.rows || cachedRows || [];
|
|
40351
41329
|
const loadRows = options.rows
|
|
40352
41330
|
? undefined
|
|
40353
|
-
: withCacheWrite(repoPath, () =>
|
|
41331
|
+
: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
|
|
40354
41332
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
40355
41333
|
appLabel: 'coco',
|
|
40356
41334
|
idleTips: config.logTui?.idleTips,
|
|
@@ -40378,7 +41356,7 @@ async function startCocoUi(argv) {
|
|
|
40378
41356
|
idleTips: config.logTui?.idleTips,
|
|
40379
41357
|
dateBucketing: config.logTui?.dateBucketing,
|
|
40380
41358
|
initialView: argv.view || 'history',
|
|
40381
|
-
loadRows: withCacheWrite(repoPath, () =>
|
|
41359
|
+
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
40382
41360
|
logArgv,
|
|
40383
41361
|
theme: createUiTheme(config, argv),
|
|
40384
41362
|
});
|
|
@@ -41581,9 +42559,9 @@ const options = {
|
|
|
41581
42559
|
default: 'history',
|
|
41582
42560
|
},
|
|
41583
42561
|
all: {
|
|
41584
|
-
description: 'Load commits from all local and remote refs in history mode',
|
|
42562
|
+
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.',
|
|
41585
42563
|
type: 'boolean',
|
|
41586
|
-
default:
|
|
42564
|
+
default: true,
|
|
41587
42565
|
},
|
|
41588
42566
|
branch: {
|
|
41589
42567
|
description: 'Load history reachable from a branch or ref',
|