git-coco 0.54.0 → 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 +1778 -563
- package/dist/index.js +1778 -563
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ var readline__namespace = /*#__PURE__*/_interopNamespaceDefault(readline);
|
|
|
78
78
|
/**
|
|
79
79
|
* Current build version from package.json
|
|
80
80
|
*/
|
|
81
|
-
const BUILD_VERSION = "0.
|
|
81
|
+
const BUILD_VERSION = "0.55.0";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -18368,7 +18368,15 @@ const FIELD_SEPARATOR$3 = '\x1f';
|
|
|
18368
18368
|
const LOG_FORMAT = `%x1f%h%x1f%H%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s`;
|
|
18369
18369
|
const DETAIL_FORMAT = `%H%x1f%h%x1f%P%x1f%ad%x1f%an%x1f%d%x1f%s%x1f%b`;
|
|
18370
18370
|
const LOG_DEFAULT_LIMIT = 30;
|
|
18371
|
-
|
|
18371
|
+
// Bumped from 300 → 1000 in 0.54.2. With the full-graph default
|
|
18372
|
+
// (#1034) the workstation surfaces many more refs (all branches, all
|
|
18373
|
+
// tags, plus stash commits added via `extraRefs`), and on active repos
|
|
18374
|
+
// the 300-commit cap was cutting off year+-old stash bases and old
|
|
18375
|
+
// tag commits — making the cursor-syncs-history effect report "tip
|
|
18376
|
+
// not in loaded window" instead of moving the graph cursor. 1000
|
|
18377
|
+
// fits a year of activity for most repos, git log is still sub-200ms,
|
|
18378
|
+
// and Ink virtualises scroll so render cost stays flat.
|
|
18379
|
+
const LOG_INTERACTIVE_DEFAULT_LIMIT = 1000;
|
|
18372
18380
|
function toArray(value) {
|
|
18373
18381
|
if (!value) {
|
|
18374
18382
|
return [];
|
|
@@ -18548,12 +18556,77 @@ function buildLogArgs(argv, options = {}) {
|
|
|
18548
18556
|
else if (argv.branch) {
|
|
18549
18557
|
args.push(argv.branch);
|
|
18550
18558
|
}
|
|
18559
|
+
// Extra refs (stash commits etc.) — append after the --all / branch
|
|
18560
|
+
// selector but BEFORE the path separator. Git treats them as
|
|
18561
|
+
// additional graph roots, so the traversal includes them alongside
|
|
18562
|
+
// whatever --all / --branch already covers.
|
|
18563
|
+
if (options.extraRefs && options.extraRefs.length > 0) {
|
|
18564
|
+
args.push(...options.extraRefs);
|
|
18565
|
+
}
|
|
18551
18566
|
const paths = toArray(argv.path);
|
|
18552
18567
|
if (paths.length > 0) {
|
|
18553
18568
|
args.push('--', ...paths);
|
|
18554
18569
|
}
|
|
18555
18570
|
return args;
|
|
18556
18571
|
}
|
|
18572
|
+
/**
|
|
18573
|
+
* Default size of a targeted-context window. Sized to comfortably
|
|
18574
|
+
* cover a year of activity on most repos so the cursor-sync's
|
|
18575
|
+
* "jump to commit anchored on a ref I just selected" can succeed
|
|
18576
|
+
* without paginating through the whole history.
|
|
18577
|
+
*/
|
|
18578
|
+
const COMMIT_CONTEXT_DEFAULT_LIMIT = 5000;
|
|
18579
|
+
/**
|
|
18580
|
+
* Load a window of commits anchored on a specific hash. Used by the
|
|
18581
|
+
* cursor-sync effect when the user selects a ref (branch / tag /
|
|
18582
|
+
* stash) whose target commit isn't in the loaded graph window.
|
|
18583
|
+
*
|
|
18584
|
+
* Critical detail: this walks **only from the target** (and its
|
|
18585
|
+
* ancestors), NOT from `--all`. Why: when you combine `--all` with
|
|
18586
|
+
* `<targetHash>` AND `--max-count=N`, git unions the walks, sorts
|
|
18587
|
+
* the result by date, and slices the newest N rows. If the target
|
|
18588
|
+
* is older than the Nth newest commit across all refs (very common
|
|
18589
|
+
* for year-old tags / branches on active repos), it falls off the
|
|
18590
|
+
* slice even though it was passed as a root. Walking from the
|
|
18591
|
+
* target alone guarantees the target IS the first row of the
|
|
18592
|
+
* output and its ancestors fill the rest.
|
|
18593
|
+
*
|
|
18594
|
+
* The caller merges the result via the `appendRows` reducer action
|
|
18595
|
+
* which deduplicates by hash, so the target's ancestry slots into
|
|
18596
|
+
* the existing `--all` graph cleanly. The user's loaded view ends
|
|
18597
|
+
* up as the union of: the original `--all` window + target's
|
|
18598
|
+
* ancestry — exactly what's needed for the cursor to land.
|
|
18599
|
+
*
|
|
18600
|
+
* Capped at `options.limit` (default 5000) to keep one targeted
|
|
18601
|
+
* fetch bounded. For most refs, even a 100-commit limit would be
|
|
18602
|
+
* enough to surface the target; we go higher to also pull in the
|
|
18603
|
+
* surrounding context so the user can scroll around the landed
|
|
18604
|
+
* cursor.
|
|
18605
|
+
*/
|
|
18606
|
+
async function getLogRowsAnchoredOn(git, argv, targetHash, options = {}) {
|
|
18607
|
+
// Strip every "walk many refs" toggle so buildLogArgs produces a
|
|
18608
|
+
// clean `git log <flags> <targetHash>` — exactly the walk that
|
|
18609
|
+
// guarantees the target's inclusion.
|
|
18610
|
+
const merged = {
|
|
18611
|
+
...argv,
|
|
18612
|
+
all: false,
|
|
18613
|
+
view: 'compact', // suppresses 'full' → '--all' mapping
|
|
18614
|
+
branch: undefined,
|
|
18615
|
+
path: undefined,
|
|
18616
|
+
};
|
|
18617
|
+
// Also drop --first-parent / --no-merges so the target's ancestry
|
|
18618
|
+
// renders with full topology (matters for stash commits which are
|
|
18619
|
+
// merges by construction).
|
|
18620
|
+
const baseArgs = buildLogArgs(merged, {
|
|
18621
|
+
limit: options.limit ?? COMMIT_CONTEXT_DEFAULT_LIMIT,
|
|
18622
|
+
}).filter((arg) => arg !== '--first-parent' && arg !== '--no-merges');
|
|
18623
|
+
// Splice the target as the positional ref. `buildLogArgs` already
|
|
18624
|
+
// appended any `--all`/`--branch`/`<extraRefs>` it considered;
|
|
18625
|
+
// since we cleared all those above, the only positional ref we
|
|
18626
|
+
// add is the target.
|
|
18627
|
+
baseArgs.push(targetHash);
|
|
18628
|
+
return parseLogOutput(await git.raw(baseArgs));
|
|
18629
|
+
}
|
|
18557
18630
|
/**
|
|
18558
18631
|
* Build merged `LogArgv` for the interactive TUI's `g` graph toggle.
|
|
18559
18632
|
*
|
|
@@ -18661,6 +18734,178 @@ async function getCommitFilePreview(git, commit, file, limit = 40) {
|
|
|
18661
18734
|
};
|
|
18662
18735
|
}
|
|
18663
18736
|
|
|
18737
|
+
function parseStashSubject(subject) {
|
|
18738
|
+
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18739
|
+
if (!match) {
|
|
18740
|
+
return {
|
|
18741
|
+
branch: '<unknown>',
|
|
18742
|
+
message: subject,
|
|
18743
|
+
};
|
|
18744
|
+
}
|
|
18745
|
+
return {
|
|
18746
|
+
branch: match[1],
|
|
18747
|
+
message: match[2] || subject,
|
|
18748
|
+
};
|
|
18749
|
+
}
|
|
18750
|
+
function parseStashList(output) {
|
|
18751
|
+
return output
|
|
18752
|
+
.split('\n')
|
|
18753
|
+
.map((line) => line.trim())
|
|
18754
|
+
.filter(Boolean)
|
|
18755
|
+
.map((line) => {
|
|
18756
|
+
const [ref, hash, parents, date, subject] = line.split('\x1f');
|
|
18757
|
+
const parsedSubject = parseStashSubject(subject || '');
|
|
18758
|
+
// `%P` returns space-separated parent hashes. Stash commits are
|
|
18759
|
+
// merges with 2-3 parents; the FIRST is the base (HEAD at stash
|
|
18760
|
+
// time). Empty parents string (legacy / corrupted entries) maps
|
|
18761
|
+
// to an empty baseHash; the cursor-sync caller treats that as
|
|
18762
|
+
// "no base available, fall back to stash hash."
|
|
18763
|
+
const baseHash = parents ? (parents.split(' ')[0] || '') : '';
|
|
18764
|
+
return {
|
|
18765
|
+
ref,
|
|
18766
|
+
hash,
|
|
18767
|
+
baseHash,
|
|
18768
|
+
date,
|
|
18769
|
+
branch: parsedSubject.branch,
|
|
18770
|
+
message: parsedSubject.message,
|
|
18771
|
+
};
|
|
18772
|
+
});
|
|
18773
|
+
}
|
|
18774
|
+
function parseStashFiles(output) {
|
|
18775
|
+
return output
|
|
18776
|
+
.split('\n')
|
|
18777
|
+
.map((line) => line.trim())
|
|
18778
|
+
.filter(Boolean);
|
|
18779
|
+
}
|
|
18780
|
+
/**
|
|
18781
|
+
* Resolve the commit hashes for every stash, in `stash@{N}` order.
|
|
18782
|
+
*
|
|
18783
|
+
* Used by the workstation's history loader to include older stashes
|
|
18784
|
+
* as graph roots — `git log --all` only walks `refs/stash` (the
|
|
18785
|
+
* latest stash) by default, so stash@{1+} commits live off-graph
|
|
18786
|
+
* unless explicitly referenced. Passing this list as positional refs
|
|
18787
|
+
* to `git log` makes every stash appear as a graph node, which lets
|
|
18788
|
+
* the cursor-syncs-history effect actually land on them when the
|
|
18789
|
+
* user navigates the stashes sidebar.
|
|
18790
|
+
*
|
|
18791
|
+
* Cheap: one `git stash list` call, no per-stash fan-out. Returns
|
|
18792
|
+
* an empty array when there are no stashes — callers can pass the
|
|
18793
|
+
* result through unconditionally.
|
|
18794
|
+
*/
|
|
18795
|
+
async function getStashCommitHashes(git) {
|
|
18796
|
+
const raw = await git.raw(['stash', 'list', '--format=%H']).catch(() => '');
|
|
18797
|
+
return raw
|
|
18798
|
+
.split('\n')
|
|
18799
|
+
.map((line) => line.trim())
|
|
18800
|
+
.filter(Boolean);
|
|
18801
|
+
}
|
|
18802
|
+
async function getStashOverview(git) {
|
|
18803
|
+
// Format fields (separated by 0x1f / unit separator):
|
|
18804
|
+
// %gd — stash reflog selector (stash@{N})
|
|
18805
|
+
// %H — stash commit hash
|
|
18806
|
+
// %P — space-separated parent hashes (first = base, see StashEntry.baseHash)
|
|
18807
|
+
// %ci — committer date, ISO format
|
|
18808
|
+
// %gs — reflog subject ("WIP on main: <subject>")
|
|
18809
|
+
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%P%x1f%ci%x1f%gs']));
|
|
18810
|
+
return {
|
|
18811
|
+
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18812
|
+
...stash,
|
|
18813
|
+
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18814
|
+
}))),
|
|
18815
|
+
};
|
|
18816
|
+
}
|
|
18817
|
+
/**
|
|
18818
|
+
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18819
|
+
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18820
|
+
*
|
|
18821
|
+
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18822
|
+
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18823
|
+
* back to a "no diff to display" message.
|
|
18824
|
+
*/
|
|
18825
|
+
async function getStashDiff(git, stashRef) {
|
|
18826
|
+
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18827
|
+
.split('\n')
|
|
18828
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
18829
|
+
}
|
|
18830
|
+
/**
|
|
18831
|
+
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18832
|
+
* file path and the offset of its `diff --git` header within `lines`.
|
|
18833
|
+
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18834
|
+
* the file at the cursor.
|
|
18835
|
+
*
|
|
18836
|
+
* Renames / moves return the destination path (the `b/` side); the
|
|
18837
|
+
* action surface treats that as the path to materialize from the stash.
|
|
18838
|
+
*
|
|
18839
|
+
* Path quoting: git wraps paths containing spaces or special characters
|
|
18840
|
+
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18841
|
+
* The parser handles both the unquoted and quoted forms; without that,
|
|
18842
|
+
* stash-file navigation and cherry-pick silently broke for any file
|
|
18843
|
+
* whose path contained a space.
|
|
18844
|
+
*/
|
|
18845
|
+
function parseStashDiffFiles(lines) {
|
|
18846
|
+
const files = [];
|
|
18847
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
18848
|
+
const line = lines[i];
|
|
18849
|
+
const parsed = parseDiffGitHeader(line);
|
|
18850
|
+
if (parsed) {
|
|
18851
|
+
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18852
|
+
}
|
|
18853
|
+
}
|
|
18854
|
+
return files;
|
|
18855
|
+
}
|
|
18856
|
+
/**
|
|
18857
|
+
* Resolve which stash file *contains* a given line offset — the user's
|
|
18858
|
+
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18859
|
+
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18860
|
+
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18861
|
+
* dispatchers' "what file is the cursor on" lookup.
|
|
18862
|
+
*
|
|
18863
|
+
* Returns `undefined` when the file list is empty *or* the offset
|
|
18864
|
+
* lands before the very first file's `diff --git` header (e.g. when
|
|
18865
|
+
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18866
|
+
* "no file selected" state in that case.
|
|
18867
|
+
*/
|
|
18868
|
+
function findStashFileForOffset(files, offset) {
|
|
18869
|
+
if (files.length === 0)
|
|
18870
|
+
return undefined;
|
|
18871
|
+
let current;
|
|
18872
|
+
for (const file of files) {
|
|
18873
|
+
if (file.startLine <= offset) {
|
|
18874
|
+
current = file;
|
|
18875
|
+
}
|
|
18876
|
+
else {
|
|
18877
|
+
break;
|
|
18878
|
+
}
|
|
18879
|
+
}
|
|
18880
|
+
// First file is the canonical fallback — even if the offset lands
|
|
18881
|
+
// before its header (rare), we want the cursor to be "in" something
|
|
18882
|
+
// so the user's actions have a target.
|
|
18883
|
+
return current ?? files[0];
|
|
18884
|
+
}
|
|
18885
|
+
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18886
|
+
function parseDiffGitHeader(line) {
|
|
18887
|
+
const match = line.match(DIFF_GIT_HEADER);
|
|
18888
|
+
if (!match)
|
|
18889
|
+
return undefined;
|
|
18890
|
+
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18891
|
+
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18892
|
+
if (!aPath || !bPath)
|
|
18893
|
+
return undefined;
|
|
18894
|
+
return { aPath, bPath };
|
|
18895
|
+
}
|
|
18896
|
+
function unescapeGitQuoted(value) {
|
|
18897
|
+
if (value === undefined)
|
|
18898
|
+
return undefined;
|
|
18899
|
+
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18900
|
+
// C-style sequences. Reverse the most common ones so callers get the
|
|
18901
|
+
// raw on-disk path.
|
|
18902
|
+
return value
|
|
18903
|
+
.replace(/\\\\/g, '\\')
|
|
18904
|
+
.replace(/\\"/g, '"')
|
|
18905
|
+
.replace(/\\t/g, '\t')
|
|
18906
|
+
.replace(/\\n/g, '\n');
|
|
18907
|
+
}
|
|
18908
|
+
|
|
18664
18909
|
const FIELD_SEPARATOR$2 = '\x1f';
|
|
18665
18910
|
function parseBranchRefs(output) {
|
|
18666
18911
|
return output
|
|
@@ -18853,143 +19098,6 @@ async function getPullRequestOverview(git, runner = defaultGhRunner) {
|
|
|
18853
19098
|
}
|
|
18854
19099
|
}
|
|
18855
19100
|
|
|
18856
|
-
function parseStashSubject(subject) {
|
|
18857
|
-
const match = subject.match(/^(?:WIP on|On) ([^:]+):\s*(.*)$/);
|
|
18858
|
-
if (!match) {
|
|
18859
|
-
return {
|
|
18860
|
-
branch: '<unknown>',
|
|
18861
|
-
message: subject,
|
|
18862
|
-
};
|
|
18863
|
-
}
|
|
18864
|
-
return {
|
|
18865
|
-
branch: match[1],
|
|
18866
|
-
message: match[2] || subject,
|
|
18867
|
-
};
|
|
18868
|
-
}
|
|
18869
|
-
function parseStashList(output) {
|
|
18870
|
-
return output
|
|
18871
|
-
.split('\n')
|
|
18872
|
-
.map((line) => line.trim())
|
|
18873
|
-
.filter(Boolean)
|
|
18874
|
-
.map((line) => {
|
|
18875
|
-
const [ref, hash, date, subject] = line.split('\x1f');
|
|
18876
|
-
const parsedSubject = parseStashSubject(subject || '');
|
|
18877
|
-
return {
|
|
18878
|
-
ref,
|
|
18879
|
-
hash,
|
|
18880
|
-
date,
|
|
18881
|
-
branch: parsedSubject.branch,
|
|
18882
|
-
message: parsedSubject.message,
|
|
18883
|
-
};
|
|
18884
|
-
});
|
|
18885
|
-
}
|
|
18886
|
-
function parseStashFiles(output) {
|
|
18887
|
-
return output
|
|
18888
|
-
.split('\n')
|
|
18889
|
-
.map((line) => line.trim())
|
|
18890
|
-
.filter(Boolean);
|
|
18891
|
-
}
|
|
18892
|
-
async function getStashOverview(git) {
|
|
18893
|
-
const stashes = parseStashList(await git.raw(['stash', 'list', '--date=iso', '--format=%gd%x1f%H%x1f%ci%x1f%gs']));
|
|
18894
|
-
return {
|
|
18895
|
-
stashes: await Promise.all(stashes.map(async (stash) => ({
|
|
18896
|
-
...stash,
|
|
18897
|
-
files: parseStashFiles(await git.raw(['stash', 'show', '--name-only', stash.ref])),
|
|
18898
|
-
}))),
|
|
18899
|
-
};
|
|
18900
|
-
}
|
|
18901
|
-
/**
|
|
18902
|
-
* Full unified-patch diff for a stash. Used by the diff surface when
|
|
18903
|
-
* `state.diffSource === 'stash'` to render the stash's changes inline.
|
|
18904
|
-
*
|
|
18905
|
-
* Empty stashes (e.g. created by `git stash --keep-index` against an
|
|
18906
|
-
* already-clean tree) return [] rather than throwing — surfaces fall
|
|
18907
|
-
* back to a "no diff to display" message.
|
|
18908
|
-
*/
|
|
18909
|
-
async function getStashDiff(git, stashRef) {
|
|
18910
|
-
return (await git.raw(['stash', 'show', '-p', stashRef]))
|
|
18911
|
-
.split('\n')
|
|
18912
|
-
.map((line) => line.replace(/\r$/, ''));
|
|
18913
|
-
}
|
|
18914
|
-
/**
|
|
18915
|
-
* Slice a unified-patch into per-file sections. Each entry records the
|
|
18916
|
-
* file path and the offset of its `diff --git` header within `lines`.
|
|
18917
|
-
* Used by the stash explorer to build a per-file cursor + cherry-pick
|
|
18918
|
-
* the file at the cursor.
|
|
18919
|
-
*
|
|
18920
|
-
* Renames / moves return the destination path (the `b/` side); the
|
|
18921
|
-
* action surface treats that as the path to materialize from the stash.
|
|
18922
|
-
*
|
|
18923
|
-
* Path quoting: git wraps paths containing spaces or special characters
|
|
18924
|
-
* in double-quotes (`diff --git "a/path with spaces" "b/path with spaces"`).
|
|
18925
|
-
* The parser handles both the unquoted and quoted forms; without that,
|
|
18926
|
-
* stash-file navigation and cherry-pick silently broke for any file
|
|
18927
|
-
* whose path contained a space.
|
|
18928
|
-
*/
|
|
18929
|
-
function parseStashDiffFiles(lines) {
|
|
18930
|
-
const files = [];
|
|
18931
|
-
for (let i = 0; i < lines.length; i += 1) {
|
|
18932
|
-
const line = lines[i];
|
|
18933
|
-
const parsed = parseDiffGitHeader(line);
|
|
18934
|
-
if (parsed) {
|
|
18935
|
-
files.push({ path: parsed.bPath || parsed.aPath, startLine: i });
|
|
18936
|
-
}
|
|
18937
|
-
}
|
|
18938
|
-
return files;
|
|
18939
|
-
}
|
|
18940
|
-
/**
|
|
18941
|
-
* Resolve which stash file *contains* a given line offset — the user's
|
|
18942
|
-
* cursor scrolls through a concatenated multi-file patch, and this is
|
|
18943
|
-
* what powers the "File N/M: <path>" panel header, the inline header
|
|
18944
|
-
* highlighting (#791 follow-up), and the cherry-pick / open-in-editor
|
|
18945
|
-
* dispatchers' "what file is the cursor on" lookup.
|
|
18946
|
-
*
|
|
18947
|
-
* Returns `undefined` when the file list is empty *or* the offset
|
|
18948
|
-
* lands before the very first file's `diff --git` header (e.g. when
|
|
18949
|
-
* `--stat` summary lines lead the patch). Callers fall through to a
|
|
18950
|
-
* "no file selected" state in that case.
|
|
18951
|
-
*/
|
|
18952
|
-
function findStashFileForOffset(files, offset) {
|
|
18953
|
-
if (files.length === 0)
|
|
18954
|
-
return undefined;
|
|
18955
|
-
let current;
|
|
18956
|
-
for (const file of files) {
|
|
18957
|
-
if (file.startLine <= offset) {
|
|
18958
|
-
current = file;
|
|
18959
|
-
}
|
|
18960
|
-
else {
|
|
18961
|
-
break;
|
|
18962
|
-
}
|
|
18963
|
-
}
|
|
18964
|
-
// First file is the canonical fallback — even if the offset lands
|
|
18965
|
-
// before its header (rare), we want the cursor to be "in" something
|
|
18966
|
-
// so the user's actions have a target.
|
|
18967
|
-
return current ?? files[0];
|
|
18968
|
-
}
|
|
18969
|
-
const DIFF_GIT_HEADER = /^diff --git (?:"a\/((?:\\.|[^"\\])+)"|a\/(\S+)) (?:"b\/((?:\\.|[^"\\])+)"|b\/(\S+))$/;
|
|
18970
|
-
function parseDiffGitHeader(line) {
|
|
18971
|
-
const match = line.match(DIFF_GIT_HEADER);
|
|
18972
|
-
if (!match)
|
|
18973
|
-
return undefined;
|
|
18974
|
-
const aPath = unescapeGitQuoted(match[1]) || match[2];
|
|
18975
|
-
const bPath = unescapeGitQuoted(match[3]) || match[4];
|
|
18976
|
-
if (!aPath || !bPath)
|
|
18977
|
-
return undefined;
|
|
18978
|
-
return { aPath, bPath };
|
|
18979
|
-
}
|
|
18980
|
-
function unescapeGitQuoted(value) {
|
|
18981
|
-
if (value === undefined)
|
|
18982
|
-
return undefined;
|
|
18983
|
-
// Git's diff header quoting escapes `"`, `\`, and the usual
|
|
18984
|
-
// C-style sequences. Reverse the most common ones so callers get the
|
|
18985
|
-
// raw on-disk path.
|
|
18986
|
-
return value
|
|
18987
|
-
.replace(/\\\\/g, '\\')
|
|
18988
|
-
.replace(/\\"/g, '"')
|
|
18989
|
-
.replace(/\\t/g, '\t')
|
|
18990
|
-
.replace(/\\n/g, '\n');
|
|
18991
|
-
}
|
|
18992
|
-
|
|
18993
19101
|
function fileState(indexStatus, worktreeStatus) {
|
|
18994
19102
|
if (indexStatus === '?' && worktreeStatus === '?') {
|
|
18995
19103
|
return 'untracked';
|
|
@@ -19056,7 +19164,18 @@ function parseTagRefs(output) {
|
|
|
19056
19164
|
.map((line) => line.trimEnd())
|
|
19057
19165
|
.filter(Boolean)
|
|
19058
19166
|
.map((line) => {
|
|
19059
|
-
const [name,
|
|
19167
|
+
const [name, objectHash, derefedHash, date, subject] = line.split(FIELD_SEPARATOR$1);
|
|
19168
|
+
// For annotated tags `%(objectname:short)` returns the TAG
|
|
19169
|
+
// OBJECT's SHA, not the commit it points to — that's the SHA
|
|
19170
|
+
// sitting in `refs/tags/<name>`'s blob. `%(*objectname:short)`
|
|
19171
|
+
// dereferences the tag and yields the commit's SHA, but is
|
|
19172
|
+
// EMPTY for lightweight tags (which are already direct
|
|
19173
|
+
// pointers to commits). Prefer the dereferenced form when
|
|
19174
|
+
// present, fall back to the object SHA otherwise. This is what
|
|
19175
|
+
// lets cursor-sync find the tagged commit in the loaded log
|
|
19176
|
+
// window — anchoring on the tag object's own SHA would never
|
|
19177
|
+
// match a commit row.
|
|
19178
|
+
const hash = derefedHash || objectHash;
|
|
19060
19179
|
return {
|
|
19061
19180
|
name,
|
|
19062
19181
|
hash,
|
|
@@ -19068,7 +19187,7 @@ function parseTagRefs(output) {
|
|
|
19068
19187
|
async function getTagOverview(git) {
|
|
19069
19188
|
const output = await git.raw([
|
|
19070
19189
|
'for-each-ref',
|
|
19071
|
-
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19190
|
+
`--format=%(refname:short)${FIELD_SEPARATOR$1}%(objectname:short)${FIELD_SEPARATOR$1}%(*objectname:short)${FIELD_SEPARATOR$1}%(creatordate:short)${FIELD_SEPARATOR$1}%(subject)`,
|
|
19072
19191
|
'--sort=-creatordate',
|
|
19073
19192
|
'refs/tags',
|
|
19074
19193
|
]);
|
|
@@ -19950,6 +20069,92 @@ async function startInteractiveLog(git, rows, streams = {}) {
|
|
|
19950
20069
|
output.write(`${renderInteractiveLog(state, await loadSelectedDetail(), branches, pullRequest, tags, undefined, worktree, {}, { appLabel }, { stashes, worktreeList }, {}, operationOverview, providerOverview)}\n`, 'utf8');
|
|
19951
20070
|
}
|
|
19952
20071
|
|
|
20072
|
+
/**
|
|
20073
|
+
* Shared hash-matching helpers for cross-command lookups.
|
|
20074
|
+
*
|
|
20075
|
+
* Git surfaces the same commit with different short-hash lengths
|
|
20076
|
+
* depending on which command produced the row:
|
|
20077
|
+
*
|
|
20078
|
+
* - `for-each-ref --format=%(objectname:short)` (branches, tags,
|
|
20079
|
+
* stashes) honors `core.abbrev`, typically 7 chars.
|
|
20080
|
+
* - `git log --pretty=format:%h` (history rows) honors the same
|
|
20081
|
+
* setting BUT git auto-extends abbreviations to keep them unique
|
|
20082
|
+
* within the walked set — so the same commit can come back as 7
|
|
20083
|
+
* chars from one command and 8 (or more) from another.
|
|
20084
|
+
*
|
|
20085
|
+
* Consequence: any exact-equality lookup that compares a hash from
|
|
20086
|
+
* `for-each-ref` against a hash from `git log` will miss the match
|
|
20087
|
+
* even when both refer to the same commit. This bit the workstation's
|
|
20088
|
+
* cursor-sync effect twice during 0.54.2 — once in the resolver, once
|
|
20089
|
+
* in the `selectCommitByHash` reducer — and shows up wherever a ref
|
|
20090
|
+
* hash is checked against the loaded log window.
|
|
20091
|
+
*
|
|
20092
|
+
* The fix is bidirectional prefix matching: a hash matches another if
|
|
20093
|
+
* one is a prefix of the other. Below a 4-char floor we refuse to
|
|
20094
|
+
* match — three chars would collide with too many real commits.
|
|
20095
|
+
*
|
|
20096
|
+
* This module is the canonical place for that logic. Import it
|
|
20097
|
+
* anywhere you compare a "hash from one git formatter" against a
|
|
20098
|
+
* "hash from a different git formatter."
|
|
20099
|
+
*
|
|
20100
|
+
* Lives in `src/git/` because both `workstation/` and `commands/log/`
|
|
20101
|
+
* depend on it — `commands/log/` must not depend on `workstation/`,
|
|
20102
|
+
* so this can't live in `workstation/runtime/cursorSyncResolver.ts`.
|
|
20103
|
+
*/
|
|
20104
|
+
/**
|
|
20105
|
+
* Minimum length below which we refuse to prefix-match. Three chars
|
|
20106
|
+
* is too small to be a meaningful unique prefix for any real-world
|
|
20107
|
+
* git history.
|
|
20108
|
+
*/
|
|
20109
|
+
const MIN_PREFIX_LENGTH = 4;
|
|
20110
|
+
/**
|
|
20111
|
+
* True when `a` and `b` refer to the same commit, tolerating
|
|
20112
|
+
* short-hash length differences from different git formatters.
|
|
20113
|
+
*
|
|
20114
|
+
* Symmetric: `hashesMatch(a, b) === hashesMatch(b, a)`. An exact
|
|
20115
|
+
* string equality wins immediately (the common path); otherwise we
|
|
20116
|
+
* test bidirectional `startsWith` and bail when either input is too
|
|
20117
|
+
* short to be a meaningful prefix.
|
|
20118
|
+
*/
|
|
20119
|
+
function hashesMatch(a, b) {
|
|
20120
|
+
if (!a || !b)
|
|
20121
|
+
return false;
|
|
20122
|
+
if (a === b)
|
|
20123
|
+
return true;
|
|
20124
|
+
if (a.length < MIN_PREFIX_LENGTH || b.length < MIN_PREFIX_LENGTH)
|
|
20125
|
+
return false;
|
|
20126
|
+
return a.startsWith(b) || b.startsWith(a);
|
|
20127
|
+
}
|
|
20128
|
+
/**
|
|
20129
|
+
* True when `hash` matches any entry in `candidates`. Convenience
|
|
20130
|
+
* wrapper for the common "is this ref's hash in any of the row's
|
|
20131
|
+
* hash variants?" check.
|
|
20132
|
+
*/
|
|
20133
|
+
function hashesMatchAny(hash, candidates) {
|
|
20134
|
+
if (!hash)
|
|
20135
|
+
return false;
|
|
20136
|
+
return candidates.some((candidate) => hashesMatch(hash, candidate));
|
|
20137
|
+
}
|
|
20138
|
+
/**
|
|
20139
|
+
* True when `hash` is present in the loaded set — exact match first
|
|
20140
|
+
* (the O(1) fast path), then bidirectional `startsWith` over the set
|
|
20141
|
+
* to cover the formatter mismatch.
|
|
20142
|
+
*
|
|
20143
|
+
* The set is small in practice (1k–5k entries) so O(N) iteration on
|
|
20144
|
+
* miss is fine.
|
|
20145
|
+
*/
|
|
20146
|
+
function hashLoaded(hash, loaded) {
|
|
20147
|
+
if (loaded.has(hash))
|
|
20148
|
+
return true;
|
|
20149
|
+
if (hash.length < MIN_PREFIX_LENGTH)
|
|
20150
|
+
return false;
|
|
20151
|
+
for (const entry of loaded) {
|
|
20152
|
+
if (entry.startsWith(hash) || hash.startsWith(entry))
|
|
20153
|
+
return true;
|
|
20154
|
+
}
|
|
20155
|
+
return false;
|
|
20156
|
+
}
|
|
20157
|
+
|
|
19953
20158
|
const EMPTY_STATUS$1 = { enabled: false, patterns: [] };
|
|
19954
20159
|
/**
|
|
19955
20160
|
* Parse a single `.gitattributes` body into the LFS-tracked
|
|
@@ -20237,9 +20442,16 @@ function applyCommitComposeAction(state, action) {
|
|
|
20237
20442
|
field: state.field === 'summary' ? 'body' : 'summary',
|
|
20238
20443
|
};
|
|
20239
20444
|
case 'setEditing':
|
|
20445
|
+
// Audit finding #12: defensively clear `streamingPreview` when
|
|
20446
|
+
// editing toggles off AND no draft is in flight. The current
|
|
20447
|
+
// input pipeline never triggers this combination, but the
|
|
20448
|
+
// reducer is the source of truth — if a future code path
|
|
20449
|
+
// toggles editing off mid-stream, the preview shouldn't linger
|
|
20450
|
+
// below an idle compose panel.
|
|
20240
20451
|
return {
|
|
20241
20452
|
...state,
|
|
20242
20453
|
editing: action.value,
|
|
20454
|
+
streamingPreview: !action.value && !state.loading ? undefined : state.streamingPreview,
|
|
20243
20455
|
};
|
|
20244
20456
|
case 'setLoading':
|
|
20245
20457
|
// Clearing loading also clears any in-flight streaming preview;
|
|
@@ -20251,6 +20463,22 @@ function applyCommitComposeAction(state, action) {
|
|
|
20251
20463
|
streamingPreview: action.value ? state.streamingPreview : undefined,
|
|
20252
20464
|
};
|
|
20253
20465
|
case 'setDraft':
|
|
20466
|
+
// Audit finding #7: if the user has typed content in summary or
|
|
20467
|
+
// body, the AI draft would silently clobber their work with no
|
|
20468
|
+
// undo. Route the result to `pendingAiDraft` instead and surface
|
|
20469
|
+
// a confirmation message; the user accepts with `R` (replace)
|
|
20470
|
+
// or dismisses with Esc. Empty fields = safe to replace as
|
|
20471
|
+
// before, since there's nothing to lose.
|
|
20472
|
+
if (state.summary.trim() || state.body.trim()) {
|
|
20473
|
+
return {
|
|
20474
|
+
...state,
|
|
20475
|
+
loading: false,
|
|
20476
|
+
streamingPreview: undefined,
|
|
20477
|
+
pendingAiDraft: action.value,
|
|
20478
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20479
|
+
details: undefined,
|
|
20480
|
+
};
|
|
20481
|
+
}
|
|
20254
20482
|
// No `message` here — the loader → filled fields are the confirmation
|
|
20255
20483
|
// that the AI generated something. A lingering "AI draft ready for
|
|
20256
20484
|
// editing" line in the panel reads as stale state. The runtime still
|
|
@@ -20265,6 +20493,7 @@ function applyCommitComposeAction(state, action) {
|
|
|
20265
20493
|
message: undefined,
|
|
20266
20494
|
details: undefined,
|
|
20267
20495
|
streamingPreview: undefined,
|
|
20496
|
+
pendingAiDraft: undefined,
|
|
20268
20497
|
};
|
|
20269
20498
|
case 'setResult':
|
|
20270
20499
|
return {
|
|
@@ -20284,6 +20513,46 @@ function applyCommitComposeAction(state, action) {
|
|
|
20284
20513
|
...state,
|
|
20285
20514
|
streamingPreview: action.value,
|
|
20286
20515
|
};
|
|
20516
|
+
case 'setPendingAiDraft':
|
|
20517
|
+
// Audit finding #7: route the AI draft here (instead of straight
|
|
20518
|
+
// to summary/body via `setDraft`) when the user has unsaved
|
|
20519
|
+
// typing the draft would clobber. The dispatcher does the
|
|
20520
|
+
// user-content check; this reducer just stashes the draft and
|
|
20521
|
+
// surfaces a message inviting the user to accept or dismiss.
|
|
20522
|
+
return {
|
|
20523
|
+
...state,
|
|
20524
|
+
loading: false,
|
|
20525
|
+
streamingPreview: undefined,
|
|
20526
|
+
pendingAiDraft: action.value,
|
|
20527
|
+
message: 'AI draft ready. Press R to replace your text, or Esc to keep what you have.',
|
|
20528
|
+
details: undefined,
|
|
20529
|
+
};
|
|
20530
|
+
case 'acceptPendingAiDraft':
|
|
20531
|
+
// Swap the pending draft into the editable fields and clear it.
|
|
20532
|
+
// Mirrors `setDraft`'s field positioning (focus on summary,
|
|
20533
|
+
// editing on) so the user lands in the same place whether they
|
|
20534
|
+
// accepted immediately or after deliberation.
|
|
20535
|
+
if (!state.pendingAiDraft)
|
|
20536
|
+
return state;
|
|
20537
|
+
return {
|
|
20538
|
+
...state,
|
|
20539
|
+
...splitCommitDraft(state.pendingAiDraft),
|
|
20540
|
+
field: 'summary',
|
|
20541
|
+
editing: true,
|
|
20542
|
+
loading: false,
|
|
20543
|
+
message: undefined,
|
|
20544
|
+
details: undefined,
|
|
20545
|
+
streamingPreview: undefined,
|
|
20546
|
+
pendingAiDraft: undefined,
|
|
20547
|
+
};
|
|
20548
|
+
case 'dismissPendingAiDraft':
|
|
20549
|
+
// User chose to keep their typing; drop the AI draft.
|
|
20550
|
+
return {
|
|
20551
|
+
...state,
|
|
20552
|
+
pendingAiDraft: undefined,
|
|
20553
|
+
message: undefined,
|
|
20554
|
+
details: undefined,
|
|
20555
|
+
};
|
|
20287
20556
|
case 'reset':
|
|
20288
20557
|
// Drop message/details too — the post-commit "Created commit ..."
|
|
20289
20558
|
// notification is already on the runtime status line (footer); a
|
|
@@ -20471,6 +20740,14 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20471
20740
|
// classify below.
|
|
20472
20741
|
const stream = await chain.stream(variables, signal ? { signal } : undefined);
|
|
20473
20742
|
let chunkCount = 0;
|
|
20743
|
+
let callbackFailureCount = 0;
|
|
20744
|
+
// Audit finding #13: cap consecutive callback failures so a
|
|
20745
|
+
// genuinely broken render handler can't tie up the LLM call
|
|
20746
|
+
// silently for the user's entire wait. Five strikes (out of an
|
|
20747
|
+
// expected ~50-500 chunks for a normal commit message) is enough
|
|
20748
|
+
// to ride out a transient blip but small enough to bail before
|
|
20749
|
+
// the user finishes waiting on a useless stream.
|
|
20750
|
+
const MAX_CALLBACK_FAILURES = 5;
|
|
20474
20751
|
for await (const messageChunk of stream) {
|
|
20475
20752
|
const text = coerceChunkText(messageChunk);
|
|
20476
20753
|
if (!text)
|
|
@@ -20479,12 +20756,20 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20479
20756
|
chunkCount += 1;
|
|
20480
20757
|
try {
|
|
20481
20758
|
onChunk({ text, accumulated });
|
|
20759
|
+
// Successful callback resets the consecutive-failure counter —
|
|
20760
|
+
// we only bail on a STREAK of failures, not on isolated ones.
|
|
20761
|
+
callbackFailureCount = 0;
|
|
20482
20762
|
}
|
|
20483
20763
|
catch (callbackError) {
|
|
20484
20764
|
// Deliberately swallow callback errors so a bad render handler
|
|
20485
20765
|
// can't tank the entire LLM call. Log at verbose so users with
|
|
20486
20766
|
// verbose mode on can still see what happened.
|
|
20487
|
-
|
|
20767
|
+
callbackFailureCount += 1;
|
|
20768
|
+
logger?.verbose(`executeChainStreaming: onChunk handler threw (${callbackFailureCount}/${MAX_CALLBACK_FAILURES}): ${callbackError instanceof Error ? callbackError.message : String(callbackError)}`, { color: 'yellow' });
|
|
20769
|
+
if (callbackFailureCount >= MAX_CALLBACK_FAILURES) {
|
|
20770
|
+
logger?.verbose(`executeChainStreaming: bailing stream — ${MAX_CALLBACK_FAILURES} consecutive callback failures suggest a broken render handler.`, { color: 'red' });
|
|
20771
|
+
throw new LangChainExecutionError(`executeChainStreaming: render handler failed ${MAX_CALLBACK_FAILURES} times in a row; aborting stream so the failure surfaces to the caller.`, { accumulatedLength: accumulated.length, chunkCount });
|
|
20772
|
+
}
|
|
20488
20773
|
}
|
|
20489
20774
|
}
|
|
20490
20775
|
if (!accumulated) {
|
|
@@ -20518,15 +20803,22 @@ async function executeChainStreaming({ llm, prompt, variables, parser, onChunk,
|
|
|
20518
20803
|
}
|
|
20519
20804
|
catch (error) {
|
|
20520
20805
|
// Cancellation classifier (#881 phase 3). Three signals: an
|
|
20521
|
-
// explicitly aborted user signal (post-throw check)
|
|
20522
|
-
// standard DOM
|
|
20523
|
-
//
|
|
20524
|
-
//
|
|
20525
|
-
//
|
|
20526
|
-
//
|
|
20527
|
-
//
|
|
20806
|
+
// explicitly aborted user signal (post-throw check) or a thrown
|
|
20807
|
+
// `AbortError` from the standard DOM API. Either means "user
|
|
20808
|
+
// wanted out," not "the call failed." Wrap the raw error so
|
|
20809
|
+
// callers can pattern-match on `LangChainCancelledError` and
|
|
20810
|
+
// carry the partial accumulated text in case the caller wants
|
|
20811
|
+
// to salvage anything.
|
|
20812
|
+
//
|
|
20813
|
+
// Audit finding #8: an earlier implementation also fell back to
|
|
20814
|
+
// `error.message.includes('aborted')` as a third signal. That
|
|
20815
|
+
// substring heuristic is footgun-shaped — legitimate provider
|
|
20816
|
+
// errors ("model not aborted properly", future API copy) would
|
|
20817
|
+
// misclassify as user cancels. Dropped; rely on the structured
|
|
20818
|
+
// signal (`signal.aborted`) and the standard error class
|
|
20819
|
+
// (`name === 'AbortError'`).
|
|
20528
20820
|
const aborted = signal?.aborted ||
|
|
20529
|
-
(error instanceof Error &&
|
|
20821
|
+
(error instanceof Error && error.name === 'AbortError');
|
|
20530
20822
|
if (aborted) {
|
|
20531
20823
|
throw new LangChainCancelledError(error instanceof Error ? error.message : 'Streaming aborted by user', accumulated, {
|
|
20532
20824
|
provider: effectiveProvider,
|
|
@@ -20785,6 +21077,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20785
21077
|
// schema-validated retry — paying for a second LLM call only
|
|
20786
21078
|
// on the edge case where the streamed output is unsalvageable.
|
|
20787
21079
|
const streamingParser = createSchemaParser(schema, llm);
|
|
21080
|
+
// Capture the final accumulated text out-of-band so we can
|
|
21081
|
+
// attempt salvage if the parser throws on completion (audit
|
|
21082
|
+
// finding #1). Updated on every chunk; the last value is
|
|
21083
|
+
// whatever the stream produced before the parser ran. Empty
|
|
21084
|
+
// string when streaming throws before any chunks arrived.
|
|
21085
|
+
let streamedAccumulated = '';
|
|
20788
21086
|
let salvaged;
|
|
20789
21087
|
try {
|
|
20790
21088
|
// `executeChainStreaming` runs the parser on the accumulated
|
|
@@ -20798,6 +21096,7 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20798
21096
|
variables: budgetedPrompt.variables,
|
|
20799
21097
|
parser: streamingParser,
|
|
20800
21098
|
onChunk: ({ text, accumulated }) => {
|
|
21099
|
+
streamedAccumulated = accumulated;
|
|
20801
21100
|
onStreamChunk(text, accumulated);
|
|
20802
21101
|
},
|
|
20803
21102
|
signal,
|
|
@@ -20827,13 +21126,24 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20827
21126
|
cancelled: true,
|
|
20828
21127
|
};
|
|
20829
21128
|
}
|
|
20830
|
-
//
|
|
20831
|
-
//
|
|
20832
|
-
//
|
|
20833
|
-
// to
|
|
20834
|
-
//
|
|
20835
|
-
|
|
20836
|
-
|
|
21129
|
+
// Audit finding #1: try the lossy salvager on the accumulated
|
|
21130
|
+
// text before paying for a second LLM call. The salvager
|
|
21131
|
+
// strips code fences, attempts strict JSON parse, and falls
|
|
21132
|
+
// back to "first line is title, rest is body." We only accept
|
|
21133
|
+
// its output when it produced a real title — the placeholder
|
|
21134
|
+
// title ("Auto-generated commit") means the salvager
|
|
21135
|
+
// couldn't extract anything meaningful and the non-streaming
|
|
21136
|
+
// retry is the better choice.
|
|
21137
|
+
if (streamedAccumulated) {
|
|
21138
|
+
const candidate = salvageCommitMessageFromText(streamedAccumulated);
|
|
21139
|
+
if (candidate.title && candidate.title !== 'Auto-generated commit') {
|
|
21140
|
+
salvaged = candidate;
|
|
21141
|
+
logger.verbose(`Streaming parser failed but salvager recovered a draft from ${streamedAccumulated.length} accumulated chars; skipping non-streaming retry.`, { color: 'green' });
|
|
21142
|
+
}
|
|
21143
|
+
}
|
|
21144
|
+
if (!salvaged) {
|
|
21145
|
+
logger.verbose(`Streaming attempt produced unparseable output: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}. Falling back to non-streaming.`, { color: 'yellow' });
|
|
21146
|
+
}
|
|
20837
21147
|
}
|
|
20838
21148
|
// Type-narrow: commitMsg is set inside try{}, but TS doesn't
|
|
20839
21149
|
// see that across the catch. Re-init through the salvage path
|
|
@@ -20842,10 +21152,12 @@ async function generateCommitDraft({ git, argv, logger = new Logger({ silent: tr
|
|
|
20842
21152
|
commitMsg = salvaged;
|
|
20843
21153
|
}
|
|
20844
21154
|
else if (!(commitMsg)) {
|
|
20845
|
-
// Streaming threw
|
|
20846
|
-
//
|
|
20847
|
-
//
|
|
20848
|
-
//
|
|
21155
|
+
// Streaming threw AND the salvager couldn't recover anything
|
|
21156
|
+
// useful; fall back to the standard non-streaming flow.
|
|
21157
|
+
// Documented trade-off from the issue: streaming gives us a
|
|
21158
|
+
// preview but the validated result still comes from the
|
|
21159
|
+
// schema-aware retry path when both streaming AND salvage
|
|
21160
|
+
// fail.
|
|
20849
21161
|
commitMsg = await executeChainWithSchema(schema, llm, prompt, budgetedPrompt.variables, {
|
|
20850
21162
|
logger,
|
|
20851
21163
|
tokenizer,
|
|
@@ -23535,7 +23847,16 @@ function createLogInkState(rows, options = {}) {
|
|
|
23535
23847
|
worktreeDiffOffset: 0,
|
|
23536
23848
|
filter: '',
|
|
23537
23849
|
filterMode: false,
|
|
23538
|
-
|
|
23850
|
+
// Default to the full multi-ref graph (`git log --all`) so users
|
|
23851
|
+
// see how branches, tags, and stashes weave through the history
|
|
23852
|
+
// out of the box. Pre-0.54.x this defaulted to false (current
|
|
23853
|
+
// branch only); user feedback consistently asked for the
|
|
23854
|
+
// GitKraken-style "see everything" view as the starting state.
|
|
23855
|
+
// The `\` toggle still flips back to compact / current-branch
|
|
23856
|
+
// mode for users who want the cleaner single-line graph. Tests
|
|
23857
|
+
// override via `options.fullGraph` when they need the compact
|
|
23858
|
+
// case explicitly.
|
|
23859
|
+
fullGraph: options.fullGraph ?? true,
|
|
23539
23860
|
showHelp: false,
|
|
23540
23861
|
helpScrollOffset: 0,
|
|
23541
23862
|
showCommandPalette: false,
|
|
@@ -23644,8 +23965,17 @@ function applyLogInkAction(state, action) {
|
|
|
23644
23965
|
// branch's tip without the user manually scrolling. No-op when
|
|
23645
23966
|
// the hash isn't in the loaded list (the runtime surfaces a
|
|
23646
23967
|
// status hint in that case).
|
|
23968
|
+
//
|
|
23969
|
+
// Uses the shared `hashesMatchAny` helper to cover the
|
|
23970
|
+
// short-hash auto-extension mismatch between
|
|
23971
|
+
// `for-each-ref --format=%(objectname:short)` (cursored ref)
|
|
23972
|
+
// and `git log --pretty=format:%h` (history row). Without that
|
|
23973
|
+
// tolerance the resolver could decide "jump" but this reducer
|
|
23974
|
+
// would silently no-op — the status updates but the cursor
|
|
23975
|
+
// doesn't move, exactly the branch-cursor bug surfaced in 0.54.1
|
|
23976
|
+
// testing. See `src/git/hashes.ts` for the matching rules.
|
|
23647
23977
|
const target = action.hash;
|
|
23648
|
-
const index = state.filteredCommits.findIndex((commit) => commit.hash
|
|
23978
|
+
const index = state.filteredCommits.findIndex((commit) => hashesMatchAny(target, [commit.hash, commit.shortHash]));
|
|
23649
23979
|
if (index < 0) {
|
|
23650
23980
|
return state;
|
|
23651
23981
|
}
|
|
@@ -24288,10 +24618,14 @@ function applyLogInkAction(state, action) {
|
|
|
24288
24618
|
// Cache the result so re-entry (or `c` to PR) reuses it instead of
|
|
24289
24619
|
// re-running the LLM. Keyed by branch so a checkout naturally
|
|
24290
24620
|
// produces a fresh generation.
|
|
24621
|
+
// Audit finding #9: `generatedAt` arrives on the action payload
|
|
24622
|
+
// instead of being read from `Date.now()` here, so the reducer
|
|
24623
|
+
// stays pure. Dispatchers (currently `runChangelogView` in
|
|
24624
|
+
// app.ts) call `Date.now()` at dispatch time.
|
|
24291
24625
|
const cached = {
|
|
24292
24626
|
text: action.text,
|
|
24293
24627
|
baseLabel: action.baseLabel,
|
|
24294
|
-
generatedAt:
|
|
24628
|
+
generatedAt: action.generatedAt,
|
|
24295
24629
|
};
|
|
24296
24630
|
return {
|
|
24297
24631
|
...state,
|
|
@@ -24345,7 +24679,8 @@ function applyLogInkAction(state, action) {
|
|
|
24345
24679
|
// Updated-at timestamp reflects the edit. Not the original
|
|
24346
24680
|
// generation time — `r` (regenerate) is the explicit knob
|
|
24347
24681
|
// for "I want fresh LLM output, not my edits".
|
|
24348
|
-
|
|
24682
|
+
// Audit finding #9: timestamp arrives on the action.
|
|
24683
|
+
generatedAt: action.generatedAt,
|
|
24349
24684
|
},
|
|
24350
24685
|
},
|
|
24351
24686
|
pendingKey: undefined,
|
|
@@ -24381,7 +24716,9 @@ function applyLogInkAction(state, action) {
|
|
|
24381
24716
|
}
|
|
24382
24717
|
return {
|
|
24383
24718
|
...state,
|
|
24384
|
-
|
|
24719
|
+
// Audit finding #9: timestamp arrives on the action payload
|
|
24720
|
+
// instead of being read from `Date.now()` here.
|
|
24721
|
+
recentCommitHashes: { hashes: action.hashes, markedAt: action.markedAt },
|
|
24385
24722
|
pendingKey: undefined,
|
|
24386
24723
|
};
|
|
24387
24724
|
case 'clearRecentCommits':
|
|
@@ -24575,7 +24912,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24575
24912
|
const commit = state.filteredCommits[state.selectedIndex];
|
|
24576
24913
|
const requireCommit = (fn) => {
|
|
24577
24914
|
if (!commit) {
|
|
24578
|
-
return [action({ type: 'setStatus', value: 'No commit selected' })];
|
|
24915
|
+
return [action({ type: 'setStatus', value: 'No commit selected', kind: 'warning' })];
|
|
24579
24916
|
}
|
|
24580
24917
|
return fn(commit.hash, state.selectedIndex);
|
|
24581
24918
|
};
|
|
@@ -24614,6 +24951,7 @@ function getInspectorActionExecuteEvents(inspectorAction, state) {
|
|
|
24614
24951
|
return [action({
|
|
24615
24952
|
type: 'setStatus',
|
|
24616
24953
|
value: `Action ${inspectorAction.key} not yet wired`,
|
|
24954
|
+
kind: 'warning',
|
|
24617
24955
|
})];
|
|
24618
24956
|
}
|
|
24619
24957
|
}
|
|
@@ -24828,6 +25166,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24828
25166
|
return [action({
|
|
24829
25167
|
type: 'setStatus',
|
|
24830
25168
|
value: 'open the diff view and press [ or ] to jump hunks',
|
|
25169
|
+
kind: 'warning',
|
|
24831
25170
|
})];
|
|
24832
25171
|
case 'focusNext':
|
|
24833
25172
|
return [action({ type: 'focusNext' })];
|
|
@@ -24876,6 +25215,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24876
25215
|
return [action({
|
|
24877
25216
|
type: 'setStatus',
|
|
24878
25217
|
value: 'open branches / tags / history and press m on the cursored ref',
|
|
25218
|
+
kind: 'warning',
|
|
24879
25219
|
})];
|
|
24880
25220
|
case 'navigateBack':
|
|
24881
25221
|
// Mirror the Esc / `<` semantics (#931): drain the frame's view
|
|
@@ -24951,6 +25291,7 @@ function getLogInkPaletteExecuteEvents(command, state) {
|
|
|
24951
25291
|
return [action({
|
|
24952
25292
|
type: 'setStatus',
|
|
24953
25293
|
value: 'Sort cycle is available in the branches and tags views',
|
|
25294
|
+
kind: 'warning',
|
|
24954
25295
|
})];
|
|
24955
25296
|
case 'yankClipboard':
|
|
24956
25297
|
// The runtime resolves the value/label against the live filtered
|
|
@@ -25007,7 +25348,7 @@ function submitInputPrompt(state) {
|
|
|
25007
25348
|
return [];
|
|
25008
25349
|
const value = state.inputPrompt.value.trim();
|
|
25009
25350
|
if (!value) {
|
|
25010
|
-
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel' })];
|
|
25351
|
+
return [action({ type: 'setStatus', value: 'enter a value or press esc to cancel', kind: 'warning' })];
|
|
25011
25352
|
}
|
|
25012
25353
|
if (state.inputPrompt.kind === 'reset-mode') {
|
|
25013
25354
|
const mode = value.toLowerCase();
|
|
@@ -25015,6 +25356,7 @@ function submitInputPrompt(state) {
|
|
|
25015
25356
|
return [action({
|
|
25016
25357
|
type: 'setStatus',
|
|
25017
25358
|
value: `Unknown reset mode: ${value}. Use soft, mixed, or hard.`,
|
|
25359
|
+
kind: 'warning',
|
|
25018
25360
|
})];
|
|
25019
25361
|
}
|
|
25020
25362
|
return [
|
|
@@ -25028,6 +25370,7 @@ function submitInputPrompt(state) {
|
|
|
25028
25370
|
return [action({
|
|
25029
25371
|
type: 'setStatus',
|
|
25030
25372
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25373
|
+
kind: 'warning',
|
|
25031
25374
|
})];
|
|
25032
25375
|
}
|
|
25033
25376
|
return [
|
|
@@ -25091,6 +25434,7 @@ function submitInputPrompt(state) {
|
|
|
25091
25434
|
return [action({
|
|
25092
25435
|
type: 'setStatus',
|
|
25093
25436
|
value: `Unknown merge strategy: ${value}. Use merge, squash, or rebase.`,
|
|
25437
|
+
kind: 'warning',
|
|
25094
25438
|
})];
|
|
25095
25439
|
}
|
|
25096
25440
|
return [
|
|
@@ -25181,16 +25525,24 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25181
25525
|
return [];
|
|
25182
25526
|
}
|
|
25183
25527
|
// Cancel in-flight AI commit draft (#881 phase 3). When the compose
|
|
25184
|
-
//
|
|
25185
|
-
// and the runtime handler cleans up (clear loading, clear
|
|
25186
|
-
// status line shows "AI draft cancelled.").
|
|
25187
|
-
// / view handlers so the cancel keystroke can't fall through to
|
|
25188
|
-
// "leave compose" or anything else.
|
|
25528
|
+
// state has a draft in flight (loading === true), Esc aborts the
|
|
25529
|
+
// LLM call and the runtime handler cleans up (clear loading, clear
|
|
25530
|
+
// preview, status line shows "AI draft cancelled.").
|
|
25189
25531
|
//
|
|
25190
|
-
//
|
|
25191
|
-
//
|
|
25192
|
-
// the
|
|
25193
|
-
|
|
25532
|
+
// Audit finding #5: the `activeView === 'compose'` gate from the
|
|
25533
|
+
// original phase 3 implementation made the cancel keystroke
|
|
25534
|
+
// unreachable after the user chord-navigated away from compose
|
|
25535
|
+
// mid-stream (Esc would fall through to popView etc., consuming
|
|
25536
|
+
// the navigation intent while the LLM call silently ran to
|
|
25537
|
+
// completion). Cancel should work wherever the user is — they
|
|
25538
|
+
// can always navigate back to compose afterwards.
|
|
25539
|
+
//
|
|
25540
|
+
// Sits above the editing / view handlers so the cancel keystroke
|
|
25541
|
+
// can't fall through to "leave compose" or anything else. Loading
|
|
25542
|
+
// and editing are mutually exclusive in practice (the user can't
|
|
25543
|
+
// type while the AI is generating), but the order here makes the
|
|
25544
|
+
// precedence explicit if that ever changes.
|
|
25545
|
+
if (state.commitCompose.loading && key.escape) {
|
|
25194
25546
|
return [{ type: 'cancelAiCommitDraft' }];
|
|
25195
25547
|
}
|
|
25196
25548
|
// Cancel in-flight PR body draft (#881 phase 4). The `C` keystroke
|
|
@@ -25210,6 +25562,27 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25210
25562
|
if (state.pendingPullRequestBodyDraft && key.escape) {
|
|
25211
25563
|
return [{ type: 'cancelPullRequestBodyDraft' }];
|
|
25212
25564
|
}
|
|
25565
|
+
// Pending AI draft confirmation (audit finding #7). When the AI
|
|
25566
|
+
// draft completes against a non-empty compose surface, it lands in
|
|
25567
|
+
// `pendingAiDraft` instead of overwriting the user's typing. `R`
|
|
25568
|
+
// accepts the swap (user's typing is lost, AI draft becomes the
|
|
25569
|
+
// new content). `Esc` dismisses the AI draft (typing is preserved,
|
|
25570
|
+
// AI draft is lost — the user paid for the tokens but explicitly
|
|
25571
|
+
// chose not to use them).
|
|
25572
|
+
//
|
|
25573
|
+
// Gated on `activeView === 'compose'` because the pending draft is
|
|
25574
|
+
// only meaningful on the compose surface (where the message line
|
|
25575
|
+
// surfaces the prompt). A user who chord-navigated away while the
|
|
25576
|
+
// draft was pending should see the original `R` / Esc semantics of
|
|
25577
|
+
// wherever they are now.
|
|
25578
|
+
if (state.activeView === 'compose' && state.commitCompose.pendingAiDraft) {
|
|
25579
|
+
if (inputValue === 'R' && !key.ctrl && !key.meta) {
|
|
25580
|
+
return [action({ type: 'commitCompose', action: { type: 'acceptPendingAiDraft' } })];
|
|
25581
|
+
}
|
|
25582
|
+
if (key.escape) {
|
|
25583
|
+
return [action({ type: 'commitCompose', action: { type: 'dismissPendingAiDraft' } })];
|
|
25584
|
+
}
|
|
25585
|
+
}
|
|
25213
25586
|
if (state.commitCompose.editing) {
|
|
25214
25587
|
if (key.escape) {
|
|
25215
25588
|
return [action({ type: 'commitCompose', action: { type: 'setEditing', value: false } })];
|
|
@@ -25659,7 +26032,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25659
26032
|
}
|
|
25660
26033
|
return [
|
|
25661
26034
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25662
|
-
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view' }),
|
|
26035
|
+
action({ type: 'setStatus', value: 'gH applies a hunk in commit-diff or stash-diff view', kind: 'warning' }),
|
|
25663
26036
|
];
|
|
25664
26037
|
}
|
|
25665
26038
|
// `gT` chord: create a lightweight tag at the cursored commit on the
|
|
@@ -25683,7 +26056,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25683
26056
|
}
|
|
25684
26057
|
return [
|
|
25685
26058
|
action({ type: 'setPendingKey', value: undefined }),
|
|
25686
|
-
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view' }),
|
|
26059
|
+
action({ type: 'setStatus', value: 'gT creates a tag at the cursored commit on the history view', kind: 'warning' }),
|
|
25687
26060
|
];
|
|
25688
26061
|
}
|
|
25689
26062
|
// #784 — bisect view action keys. Scoped to `state.activeView ===
|
|
@@ -25798,6 +26171,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
25798
26171
|
value: next === 'split'
|
|
25799
26172
|
? 'Switched to side-by-side diff'
|
|
25800
26173
|
: 'Switched to unified diff',
|
|
26174
|
+
kind: 'success',
|
|
25801
26175
|
}),
|
|
25802
26176
|
];
|
|
25803
26177
|
}
|
|
@@ -26248,10 +26622,10 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26248
26622
|
if (key.return && state.compareBase && isCompareFlowTarget(state)) {
|
|
26249
26623
|
const head = getCursoredCompareRef(state, context);
|
|
26250
26624
|
if (!head) {
|
|
26251
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26625
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26252
26626
|
}
|
|
26253
26627
|
if (head.ref === state.compareBase.ref && head.kind === state.compareBase.kind) {
|
|
26254
|
-
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one' })];
|
|
26628
|
+
return [action({ type: 'setStatus', value: 'Compare base and head are the same ref — pick a different one', kind: 'warning' })];
|
|
26255
26629
|
}
|
|
26256
26630
|
return [
|
|
26257
26631
|
action({
|
|
@@ -26454,7 +26828,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26454
26828
|
action({ type: 'setFocus', value: 'commits' }),
|
|
26455
26829
|
];
|
|
26456
26830
|
}
|
|
26457
|
-
return [action({ type: 'setStatus', value: 'no detail view for this tab' })];
|
|
26831
|
+
return [action({ type: 'setStatus', value: 'no detail view for this tab', kind: 'warning' })];
|
|
26458
26832
|
}
|
|
26459
26833
|
// Fall through — per-entity Enter handler below claims the keystroke.
|
|
26460
26834
|
}
|
|
@@ -26565,7 +26939,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26565
26939
|
if (inputValue === 'm' && isCompareFlowTarget(state)) {
|
|
26566
26940
|
const ref = getCursoredCompareRef(state, context);
|
|
26567
26941
|
if (!ref) {
|
|
26568
|
-
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first' })];
|
|
26942
|
+
return [action({ type: 'setStatus', value: 'No ref under cursor — move to a branch / tag / commit row first', kind: 'warning' })];
|
|
26569
26943
|
}
|
|
26570
26944
|
if (state.compareBase && state.compareBase.ref === ref.ref && state.compareBase.kind === ref.kind) {
|
|
26571
26945
|
return [
|
|
@@ -26796,7 +27170,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26796
27170
|
// Always intercept `C` on the conflicts view to prevent fallthrough to
|
|
26797
27171
|
// the global `C` (Create PR) binding when conflicts remain.
|
|
26798
27172
|
if (inputValue === 'C' && state.activeView === 'conflicts') {
|
|
26799
|
-
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing' })];
|
|
27173
|
+
return [action({ type: 'setStatus', value: 'Resolve all conflicts before continuing', kind: 'warning' })];
|
|
26800
27174
|
}
|
|
26801
27175
|
// Global `C` — create a pull request from the current branch. The
|
|
26802
27176
|
// runtime callback handles pre-flight (current branch resolution,
|
|
@@ -26812,6 +27186,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26812
27186
|
return [action({
|
|
26813
27187
|
type: 'setStatus',
|
|
26814
27188
|
value: 'Finish or cancel the commit draft before creating a PR.',
|
|
27189
|
+
kind: 'warning',
|
|
26815
27190
|
})];
|
|
26816
27191
|
}
|
|
26817
27192
|
if (inputValue === 'C' && state.activeView !== 'conflicts') {
|
|
@@ -26871,7 +27246,7 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
|
|
|
26871
27246
|
return events;
|
|
26872
27247
|
}
|
|
26873
27248
|
if (state.activeView === 'diff' && (state.diffSource === 'commit' || state.diffSource === 'stash')) {
|
|
26874
|
-
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first' })];
|
|
27249
|
+
return [action({ type: 'setStatus', value: 'no hunk under cursor — j/k to a + or - line first', kind: 'warning' })];
|
|
26875
27250
|
}
|
|
26876
27251
|
}
|
|
26877
27252
|
// `c` on the history view cherry-picks the full selected commit on
|
|
@@ -27756,7 +28131,7 @@ const SIDEBAR_AT_REST_BY_TIER = {
|
|
|
27756
28131
|
rail: { min: 22, max: 28, fraction: 0.24 }, // unused — rail collapses to LAYOUT_RAIL_PANEL_WIDTH
|
|
27757
28132
|
tight: { min: 22, max: 28, fraction: 0.24 },
|
|
27758
28133
|
normal: { min: 22, max: 30, fraction: 0.22 },
|
|
27759
|
-
wide: { min: 28, max:
|
|
28134
|
+
wide: { min: 28, max: 32, fraction: 0.20 },
|
|
27760
28135
|
};
|
|
27761
28136
|
function calcSidebarAtRestWidth(columns, density) {
|
|
27762
28137
|
const config = SIDEBAR_AT_REST_BY_TIER[density];
|
|
@@ -29709,18 +30084,105 @@ function renderPromotedFilterAffordance(h, Text, state, theme) {
|
|
|
29709
30084
|
];
|
|
29710
30085
|
}
|
|
29711
30086
|
|
|
30087
|
+
function resolveCursorSyncDecision(input) {
|
|
30088
|
+
if (!input.target) {
|
|
30089
|
+
return { type: 'noop', reason: 'no-target' };
|
|
30090
|
+
}
|
|
30091
|
+
if (input.target.hash === input.lastSyncedHash) {
|
|
30092
|
+
return { type: 'noop', reason: 'duplicate-of-last' };
|
|
30093
|
+
}
|
|
30094
|
+
if (isHashLoaded(input.target.hash, input.loadedHashes)) {
|
|
30095
|
+
return {
|
|
30096
|
+
type: 'jump',
|
|
30097
|
+
hash: input.target.hash,
|
|
30098
|
+
label: input.target.label,
|
|
30099
|
+
};
|
|
30100
|
+
}
|
|
30101
|
+
if (input.attemptedContextHashes.has(input.target.hash)) {
|
|
30102
|
+
return { type: 'unreachable', target: input.target };
|
|
30103
|
+
}
|
|
30104
|
+
return { type: 'load-context', target: input.target };
|
|
30105
|
+
}
|
|
30106
|
+
/**
|
|
30107
|
+
* Re-export of the shared `hashLoaded` helper under the resolver's
|
|
30108
|
+
* historical name. Kept exported so existing tests (and any external
|
|
30109
|
+
* importers) keep working unchanged — see `src/git/hashes.ts` for the
|
|
30110
|
+
* canonical implementation and the rationale behind bidirectional
|
|
30111
|
+
* prefix matching.
|
|
30112
|
+
*/
|
|
30113
|
+
function isHashLoaded(hash, loadedHashes) {
|
|
30114
|
+
return hashLoaded(hash, loadedHashes);
|
|
30115
|
+
}
|
|
30116
|
+
/**
|
|
30117
|
+
* Build the membership set the resolver expects. Includes BOTH the
|
|
30118
|
+
* full hash and the short hash for every commit so the caller can
|
|
30119
|
+
* match either form (refs sometimes carry only the short hash and
|
|
30120
|
+
* `state.filteredCommits` items always have both).
|
|
30121
|
+
*
|
|
30122
|
+
* Exported so the cursor-sync effect can build the set once per
|
|
30123
|
+
* re-render and pass it down without leaking the implementation
|
|
30124
|
+
* detail. Tests use it to construct realistic inputs without
|
|
30125
|
+
* hand-rolling the dual-hash logic.
|
|
30126
|
+
*/
|
|
30127
|
+
function buildLoadedHashSet(commits) {
|
|
30128
|
+
const set = new Set();
|
|
30129
|
+
for (const commit of commits) {
|
|
30130
|
+
if (commit.hash)
|
|
30131
|
+
set.add(commit.hash);
|
|
30132
|
+
if (commit.shortHash)
|
|
30133
|
+
set.add(commit.shortHash);
|
|
30134
|
+
}
|
|
30135
|
+
return set;
|
|
30136
|
+
}
|
|
30137
|
+
|
|
29712
30138
|
/**
|
|
29713
|
-
* Status-bar / footer renderer. Two-
|
|
29714
|
-
*
|
|
29715
|
-
*
|
|
29716
|
-
*
|
|
29717
|
-
*
|
|
30139
|
+
* Status-bar / footer renderer. Two-row layout, using the full
|
|
30140
|
+
* `height: 2` the footer already reserves:
|
|
30141
|
+
*
|
|
30142
|
+
* Row 1 — keyboard hint band:
|
|
30143
|
+
* ┌──── contextual hints ────┐ ┌──── globals ────┐
|
|
30144
|
+
* ↑/↓ branches ←/→ tab … ? help · : cmds · q
|
|
30145
|
+
*
|
|
30146
|
+
* Row 2 — status / feedback band:
|
|
30147
|
+
* ⠋ main has no upstream — nothing to fetch.
|
|
30148
|
+
*
|
|
30149
|
+
* Row 2 is empty when there's no status message, idle tip, or error.
|
|
30150
|
+
* This is a behaviour change from the pre-0.54.2 single-row layout
|
|
30151
|
+
* where the status message sat awkwardly between the contextual and
|
|
30152
|
+
* global hints, getting visually crushed.
|
|
29718
30153
|
*
|
|
29719
|
-
*
|
|
30154
|
+
* The separation matters because:
|
|
30155
|
+
* - status text and key hints serve different cognitive purposes
|
|
30156
|
+
* (read vs. scan) and competing for the same row makes both
|
|
30157
|
+
* harder to use,
|
|
30158
|
+
* - long status messages (especially errors / multi-clause loading
|
|
30159
|
+
* copy) no longer push global hints off screen or wrap into the
|
|
30160
|
+
* hint cluster,
|
|
30161
|
+
* - errors now keep the global hints visible — the user often
|
|
30162
|
+
* needs `?` / `:` / `q` to *recover* from the error.
|
|
30163
|
+
*
|
|
30164
|
+
* Idle tips fill row 2 only when no real status message is set so the
|
|
29720
30165
|
* tip cycle never overwrites genuine workflow feedback.
|
|
29721
30166
|
*
|
|
29722
|
-
*
|
|
29723
|
-
*
|
|
30167
|
+
* Row 2 styling is kind-aware. Each statusKind gets its own theme
|
|
30168
|
+
* color and glyph prefix so the message is identifiable at a glance
|
|
30169
|
+
* — even with NO_COLOR set, the glyph alone communicates kind:
|
|
30170
|
+
*
|
|
30171
|
+
* loading → spinner + accent + bold
|
|
30172
|
+
* error → ✗ / ! + danger + bold
|
|
30173
|
+
* warning → ⚠ / ! + warning + bold
|
|
30174
|
+
* success → ✓ / + + success + bold
|
|
30175
|
+
* info → ℹ / i + info + bold
|
|
30176
|
+
* idle tip → no glyph + dim muted (passive)
|
|
30177
|
+
*
|
|
30178
|
+
* Pre-redesign success and loading both used `accent` (cyan), so the
|
|
30179
|
+
* user couldn't tell "done" from "in progress" by color alone. Each
|
|
30180
|
+
* kind now uses its dedicated theme color and ships an ASCII glyph
|
|
30181
|
+
* fallback for `theme.ascii` mode (TERM=dumb / vt100).
|
|
30182
|
+
*
|
|
30183
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30184
|
+
* 5a.7 of #890. Two-row layout introduced post-0.54.2; per-kind
|
|
30185
|
+
* colors + glyphs added in the same pass.
|
|
29724
30186
|
*/
|
|
29725
30187
|
function renderFooter(h, components, state, context, theme, idleTip, spinnerFrame = 0) {
|
|
29726
30188
|
const { Box, Text } = components;
|
|
@@ -29752,50 +30214,268 @@ function renderFooter(h, components, state, context, theme, idleTip, spinnerFram
|
|
|
29752
30214
|
});
|
|
29753
30215
|
// Real status messages always win; idle tips only fill the slot when it
|
|
29754
30216
|
// would otherwise be empty.
|
|
29755
|
-
const
|
|
29756
|
-
const
|
|
29757
|
-
// Loading status gets a spinner prefix in front of the message —
|
|
29758
|
-
// motion makes transient LLM calls (create-PR body, PR fetches,
|
|
29759
|
-
// etc.) feel less frozen even when they're sub-second.
|
|
29760
|
-
const spinnerPrefix = isLoading ? `${pickSpinnerFrame(spinnerFrame)} ` : '';
|
|
29761
|
-
const trailingWithSpinner = trailing ? `${spinnerPrefix}${trailing}` : '';
|
|
29762
|
-
const status = trailingWithSpinner ? ` ${trailingWithSpinner}` : '';
|
|
30217
|
+
const hasStatusMessage = Boolean(state.statusMessage);
|
|
30218
|
+
const isLoading = Boolean(state.statusLoading && hasStatusMessage);
|
|
29763
30219
|
const isError = state.statusKind === 'error';
|
|
30220
|
+
const isWarning = state.statusKind === 'warning';
|
|
29764
30221
|
const isSuccess = state.statusKind === 'success';
|
|
29765
|
-
|
|
29766
|
-
|
|
29767
|
-
|
|
29768
|
-
|
|
29769
|
-
|
|
29770
|
-
|
|
29771
|
-
|
|
29772
|
-
|
|
30222
|
+
// 'info' is the implicit kind when statusKind is undefined but
|
|
30223
|
+
// statusMessage is set — it's a deliberate status update, not an
|
|
30224
|
+
// idle tip, so it gets info treatment rather than the dim fallback.
|
|
30225
|
+
const isInfo = hasStatusMessage && !isError && !isWarning && !isSuccess && !isLoading;
|
|
30226
|
+
const rawTrailing = state.statusMessage || idleTip || '';
|
|
30227
|
+
// Glyphs per kind so the message is identifiable even before reading
|
|
30228
|
+
// the color — improves scan-ability and degrades gracefully when the
|
|
30229
|
+
// terminal lacks color. ASCII fallback for `theme.ascii` mode (TERM
|
|
30230
|
+
// = dumb / vt100) where unicode glyphs render as garbage.
|
|
30231
|
+
// loading → spinner (animated)
|
|
30232
|
+
// error → ✗ / !
|
|
30233
|
+
// warning → ⚠ / !
|
|
30234
|
+
// success → ✓ / +
|
|
30235
|
+
// info → ℹ / i
|
|
30236
|
+
// idle tip → no glyph (passive)
|
|
30237
|
+
const glyph = (() => {
|
|
30238
|
+
if (isLoading)
|
|
30239
|
+
return pickSpinnerFrame(spinnerFrame);
|
|
30240
|
+
if (isError)
|
|
30241
|
+
return theme.ascii ? '!' : '✗';
|
|
30242
|
+
if (isWarning)
|
|
30243
|
+
return theme.ascii ? '!' : '⚠';
|
|
30244
|
+
if (isSuccess)
|
|
30245
|
+
return theme.ascii ? '+' : '✓';
|
|
30246
|
+
if (isInfo)
|
|
30247
|
+
return theme.ascii ? 'i' : 'ℹ';
|
|
30248
|
+
return '';
|
|
30249
|
+
})();
|
|
30250
|
+
const statusBody = rawTrailing
|
|
30251
|
+
? glyph
|
|
30252
|
+
? `${glyph} ${rawTrailing}`
|
|
30253
|
+
: rawTrailing
|
|
30254
|
+
: '';
|
|
30255
|
+
// Row 2 color picks. Each kind gets its own theme color so success
|
|
30256
|
+
// and loading are visually distinct (was conflated under `accent`
|
|
30257
|
+
// pre-redesign — users couldn't tell "done" from "in progress").
|
|
30258
|
+
// loading → accent (cyan / preset blue)
|
|
30259
|
+
// error → danger (red / preset red)
|
|
30260
|
+
// warning → warning (yellow)
|
|
30261
|
+
// success → success (green)
|
|
30262
|
+
// info → info (blue / preset accent in light themes)
|
|
30263
|
+
// idle → undefined + dim (passive, blends with chrome)
|
|
30264
|
+
const statusColor = isError
|
|
30265
|
+
? theme.colors.danger
|
|
30266
|
+
: isWarning
|
|
30267
|
+
? theme.colors.warning
|
|
30268
|
+
: isSuccess
|
|
30269
|
+
? theme.colors.success
|
|
30270
|
+
: isLoading
|
|
30271
|
+
? theme.colors.accent
|
|
30272
|
+
: isInfo
|
|
30273
|
+
? theme.colors.info
|
|
30274
|
+
: undefined;
|
|
30275
|
+
const statusBold = isError || isWarning || isSuccess || isLoading || isInfo;
|
|
30276
|
+
const statusDim = !statusBold;
|
|
30277
|
+
const hintsText = hints.contextual.join(' ');
|
|
29773
30278
|
const globalText = hints.global.join(' · ');
|
|
29774
|
-
|
|
29775
|
-
//
|
|
29776
|
-
//
|
|
29777
|
-
//
|
|
29778
|
-
|
|
29779
|
-
|
|
29780
|
-
|
|
29781
|
-
|
|
29782
|
-
|
|
29783
|
-
|
|
29784
|
-
|
|
29785
|
-
|
|
29786
|
-
|
|
29787
|
-
|
|
29788
|
-
|
|
29789
|
-
|
|
29790
|
-
|
|
29791
|
-
|
|
29792
|
-
|
|
29793
|
-
|
|
29794
|
-
|
|
29795
|
-
|
|
29796
|
-
|
|
29797
|
-
|
|
29798
|
-
|
|
30279
|
+
return h(Box, { flexDirection: 'column', height: 2, paddingX: 1 },
|
|
30280
|
+
// Row 1: contextual ↔ global hints. justifyContent pushes them
|
|
30281
|
+
// to opposite edges so the eye can scan each cluster as one
|
|
30282
|
+
// block instead of hunting through a single concatenated line.
|
|
30283
|
+
h(Box, { flexDirection: 'row', justifyContent: 'space-between' }, h(Text, { color: theme.colors.muted, dimColor: true }, hintsText), h(Text, { color: theme.colors.muted, dimColor: true }, globalText)),
|
|
30284
|
+
// Row 2: status / loading / idle tip / error. Empty Text keeps
|
|
30285
|
+
// the row reserved when nothing's set so the surrounding layout
|
|
30286
|
+
// doesn't shift as status flips on/off.
|
|
30287
|
+
h(Text, {
|
|
30288
|
+
color: statusColor,
|
|
30289
|
+
dimColor: statusDim,
|
|
30290
|
+
bold: statusBold,
|
|
30291
|
+
}, statusBody));
|
|
30292
|
+
}
|
|
30293
|
+
|
|
30294
|
+
const COMBINING_MARK_RANGES = [
|
|
30295
|
+
[0x0300, 0x036f],
|
|
30296
|
+
[0x1ab0, 0x1aff],
|
|
30297
|
+
[0x1dc0, 0x1dff],
|
|
30298
|
+
[0x20d0, 0x20ff],
|
|
30299
|
+
[0xfe20, 0xfe2f],
|
|
30300
|
+
];
|
|
30301
|
+
const WIDE_CHARACTER_RANGES = [
|
|
30302
|
+
[0x1100, 0x115f],
|
|
30303
|
+
[0x2329, 0x232a],
|
|
30304
|
+
[0x2e80, 0xa4cf],
|
|
30305
|
+
[0xac00, 0xd7a3],
|
|
30306
|
+
[0xf900, 0xfaff],
|
|
30307
|
+
[0xfe10, 0xfe19],
|
|
30308
|
+
[0xfe30, 0xfe6f],
|
|
30309
|
+
[0xff00, 0xff60],
|
|
30310
|
+
[0xffe0, 0xffe6],
|
|
30311
|
+
[0x2600, 0x27bf],
|
|
30312
|
+
[0x1f000, 0x1f9ff],
|
|
30313
|
+
[0x20000, 0x3fffd],
|
|
30314
|
+
];
|
|
30315
|
+
function isInRange(codePoint, ranges) {
|
|
30316
|
+
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30317
|
+
}
|
|
30318
|
+
function characterWidth(character) {
|
|
30319
|
+
const codePoint = character.codePointAt(0) || 0;
|
|
30320
|
+
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30321
|
+
return 0;
|
|
30322
|
+
}
|
|
30323
|
+
if (codePoint === 0x200d ||
|
|
30324
|
+
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30325
|
+
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30326
|
+
return 0;
|
|
30327
|
+
}
|
|
30328
|
+
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30329
|
+
}
|
|
30330
|
+
function cellWidth(value) {
|
|
30331
|
+
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30332
|
+
}
|
|
30333
|
+
/**
|
|
30334
|
+
* Word-wrap `value` into lines that each fit within `width` cells. Breaks
|
|
30335
|
+
* on whitespace where possible; falls back to mid-word splits when a single
|
|
30336
|
+
* word is wider than the budget. Preserves blank input as a single empty
|
|
30337
|
+
* line so `value.split('\n').flatMap(wrapCells)` round-trips cleanly.
|
|
30338
|
+
*/
|
|
30339
|
+
function wrapCells(value, width) {
|
|
30340
|
+
if (width < 1) {
|
|
30341
|
+
return [value];
|
|
30342
|
+
}
|
|
30343
|
+
if (cellWidth(value) <= width) {
|
|
30344
|
+
return [value];
|
|
30345
|
+
}
|
|
30346
|
+
const lines = [];
|
|
30347
|
+
let current = '';
|
|
30348
|
+
let currentWidth = 0;
|
|
30349
|
+
const flush = () => {
|
|
30350
|
+
if (current.length > 0) {
|
|
30351
|
+
lines.push(current);
|
|
30352
|
+
current = '';
|
|
30353
|
+
currentWidth = 0;
|
|
30354
|
+
}
|
|
30355
|
+
};
|
|
30356
|
+
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30357
|
+
// boundaries when possible.
|
|
30358
|
+
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30359
|
+
for (const token of tokens) {
|
|
30360
|
+
const tokenWidth = cellWidth(token);
|
|
30361
|
+
if (currentWidth + tokenWidth <= width) {
|
|
30362
|
+
current += token;
|
|
30363
|
+
currentWidth += tokenWidth;
|
|
30364
|
+
continue;
|
|
30365
|
+
}
|
|
30366
|
+
if (/^\s+$/.test(token)) {
|
|
30367
|
+
// Drop boundary whitespace at line breaks.
|
|
30368
|
+
flush();
|
|
30369
|
+
continue;
|
|
30370
|
+
}
|
|
30371
|
+
flush();
|
|
30372
|
+
if (tokenWidth <= width) {
|
|
30373
|
+
current = token;
|
|
30374
|
+
currentWidth = tokenWidth;
|
|
30375
|
+
continue;
|
|
30376
|
+
}
|
|
30377
|
+
// Word longer than budget — hard-split into chunks.
|
|
30378
|
+
let remaining = token;
|
|
30379
|
+
while (cellWidth(remaining) > width) {
|
|
30380
|
+
let chunk = '';
|
|
30381
|
+
let chunkWidth = 0;
|
|
30382
|
+
for (const character of Array.from(remaining)) {
|
|
30383
|
+
const charW = characterWidth(character);
|
|
30384
|
+
if (chunkWidth + charW > width)
|
|
30385
|
+
break;
|
|
30386
|
+
chunk += character;
|
|
30387
|
+
chunkWidth += charW;
|
|
30388
|
+
}
|
|
30389
|
+
lines.push(chunk);
|
|
30390
|
+
remaining = remaining.slice(chunk.length);
|
|
30391
|
+
}
|
|
30392
|
+
if (remaining.length > 0) {
|
|
30393
|
+
current = remaining;
|
|
30394
|
+
currentWidth = cellWidth(remaining);
|
|
30395
|
+
}
|
|
30396
|
+
}
|
|
30397
|
+
flush();
|
|
30398
|
+
return lines.length > 0 ? lines : [value];
|
|
30399
|
+
}
|
|
30400
|
+
function truncateCells(value, width) {
|
|
30401
|
+
if (width < 1) {
|
|
30402
|
+
return '';
|
|
30403
|
+
}
|
|
30404
|
+
if (cellWidth(value) <= width) {
|
|
30405
|
+
return value;
|
|
30406
|
+
}
|
|
30407
|
+
const suffix = width > 3 ? '...' : '';
|
|
30408
|
+
const available = width - cellWidth(suffix);
|
|
30409
|
+
let used = 0;
|
|
30410
|
+
let output = '';
|
|
30411
|
+
for (const character of Array.from(value)) {
|
|
30412
|
+
const nextWidth = characterWidth(character);
|
|
30413
|
+
if (used + nextWidth > available) {
|
|
30414
|
+
break;
|
|
30415
|
+
}
|
|
30416
|
+
output += character;
|
|
30417
|
+
used += nextWidth;
|
|
30418
|
+
}
|
|
30419
|
+
return `${output}${suffix}`;
|
|
30420
|
+
}
|
|
30421
|
+
/**
|
|
30422
|
+
* Truncate a file path so the filename (last segment) is preserved,
|
|
30423
|
+
* eliding middle directory segments with `…/` instead of dropping
|
|
30424
|
+
* end-of-string characters.
|
|
30425
|
+
*
|
|
30426
|
+
* `truncateCells` is the wrong tool for paths because it preserves the
|
|
30427
|
+
* START of the string and drops the END — losing the filename, which
|
|
30428
|
+
* is the most useful part. Example with `truncateCells`:
|
|
30429
|
+
*
|
|
30430
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/commands/lo..."
|
|
30431
|
+
*
|
|
30432
|
+
* `truncatePathCells` preserves the filename and elides middle:
|
|
30433
|
+
*
|
|
30434
|
+
* "src/commands/log/data.ts" (24) at width 18 → "src/…/log/data.ts"
|
|
30435
|
+
*
|
|
30436
|
+
* The algorithm tries successively-smaller prefixes (keeping the start
|
|
30437
|
+
* of the path, the filename, and replacing the dropped middle segments
|
|
30438
|
+
* with `…`) and returns the largest variant that fits. When even
|
|
30439
|
+
* `…/<filename>` doesn't fit, falls back to plain `truncateCells` on
|
|
30440
|
+
* the abbreviated form — better to show end-of-name than start-of-path.
|
|
30441
|
+
*
|
|
30442
|
+
* For inputs without `/` separators, behaves identically to
|
|
30443
|
+
* `truncateCells`. Empty / width-0 cases match `truncateCells` too.
|
|
30444
|
+
*
|
|
30445
|
+
* @example
|
|
30446
|
+
* truncatePathCells('src/commands/log/data.ts', 18) // 'src/…/log/data.ts'
|
|
30447
|
+
* truncatePathCells('src/commands/log/data.ts', 12) // '…/data.ts'
|
|
30448
|
+
* truncatePathCells('a/b/c.ts', 100) // 'a/b/c.ts' (fits)
|
|
30449
|
+
* truncatePathCells('plainname.ts', 8) // 'plain...'
|
|
30450
|
+
*/
|
|
30451
|
+
function truncatePathCells(value, width) {
|
|
30452
|
+
if (width < 1)
|
|
30453
|
+
return '';
|
|
30454
|
+
if (cellWidth(value) <= width)
|
|
30455
|
+
return value;
|
|
30456
|
+
// No path structure to exploit — fall through to plain truncation.
|
|
30457
|
+
if (!value.includes('/'))
|
|
30458
|
+
return truncateCells(value, width);
|
|
30459
|
+
const segments = value.split('/');
|
|
30460
|
+
const filename = segments[segments.length - 1] ?? '';
|
|
30461
|
+
const prefix = segments.slice(0, -1);
|
|
30462
|
+
// Path is just '/filename' or has only the filename — no middle to
|
|
30463
|
+
// elide. Defer to plain truncation.
|
|
30464
|
+
if (prefix.length === 0)
|
|
30465
|
+
return truncateCells(value, width);
|
|
30466
|
+
// Walk from "keep all prefix segments except the deepest" down to
|
|
30467
|
+
// "keep no prefix segments." First variant that fits wins.
|
|
30468
|
+
for (let keep = prefix.length - 1; keep >= 0; keep--) {
|
|
30469
|
+
const candidate = keep === 0
|
|
30470
|
+
? `…/${filename}`
|
|
30471
|
+
: `${prefix.slice(0, keep).join('/')}/…/${filename}`;
|
|
30472
|
+
if (cellWidth(candidate) <= width)
|
|
30473
|
+
return candidate;
|
|
30474
|
+
}
|
|
30475
|
+
// Even `…/<filename>` doesn't fit. Use plain truncation on that
|
|
30476
|
+
// form — preserves the leading `…/` so the user knows a path was
|
|
30477
|
+
// elided, then ellipsis-truncates the filename.
|
|
30478
|
+
return truncateCells(`…/${filename}`, width);
|
|
29799
30479
|
}
|
|
29800
30480
|
|
|
29801
30481
|
/**
|
|
@@ -30022,218 +30702,318 @@ function sidebarTabCount(tab, context) {
|
|
|
30022
30702
|
}
|
|
30023
30703
|
}
|
|
30024
30704
|
|
|
30025
|
-
const COMBINING_MARK_RANGES = [
|
|
30026
|
-
[0x0300, 0x036f],
|
|
30027
|
-
[0x1ab0, 0x1aff],
|
|
30028
|
-
[0x1dc0, 0x1dff],
|
|
30029
|
-
[0x20d0, 0x20ff],
|
|
30030
|
-
[0xfe20, 0xfe2f],
|
|
30031
|
-
];
|
|
30032
|
-
const WIDE_CHARACTER_RANGES = [
|
|
30033
|
-
[0x1100, 0x115f],
|
|
30034
|
-
[0x2329, 0x232a],
|
|
30035
|
-
[0x2e80, 0xa4cf],
|
|
30036
|
-
[0xac00, 0xd7a3],
|
|
30037
|
-
[0xf900, 0xfaff],
|
|
30038
|
-
[0xfe10, 0xfe19],
|
|
30039
|
-
[0xfe30, 0xfe6f],
|
|
30040
|
-
[0xff00, 0xff60],
|
|
30041
|
-
[0xffe0, 0xffe6],
|
|
30042
|
-
[0x2600, 0x27bf],
|
|
30043
|
-
[0x1f000, 0x1f9ff],
|
|
30044
|
-
[0x20000, 0x3fffd],
|
|
30045
|
-
];
|
|
30046
|
-
function isInRange(codePoint, ranges) {
|
|
30047
|
-
return ranges.some(([start, end]) => codePoint >= start && codePoint <= end);
|
|
30048
|
-
}
|
|
30049
|
-
function characterWidth(character) {
|
|
30050
|
-
const codePoint = character.codePointAt(0) || 0;
|
|
30051
|
-
if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) {
|
|
30052
|
-
return 0;
|
|
30053
|
-
}
|
|
30054
|
-
if (codePoint === 0x200d ||
|
|
30055
|
-
(codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
|
|
30056
|
-
isInRange(codePoint, COMBINING_MARK_RANGES)) {
|
|
30057
|
-
return 0;
|
|
30058
|
-
}
|
|
30059
|
-
return isInRange(codePoint, WIDE_CHARACTER_RANGES) ? 2 : 1;
|
|
30060
|
-
}
|
|
30061
|
-
function cellWidth(value) {
|
|
30062
|
-
return Array.from(value).reduce((width, character) => width + characterWidth(character), 0);
|
|
30063
|
-
}
|
|
30064
30705
|
/**
|
|
30065
|
-
*
|
|
30066
|
-
*
|
|
30067
|
-
*
|
|
30068
|
-
*
|
|
30706
|
+
* Header chip builder. Turns the workstation's title-bar state into an
|
|
30707
|
+
* ordered list of small visually-distinct chips:
|
|
30708
|
+
*
|
|
30709
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30710
|
+
*
|
|
30711
|
+
* Pre-refactor the title bar concatenated every segment into a single
|
|
30712
|
+
* Text span, which made the eye read the whole thing as one run of
|
|
30713
|
+
* words (the same problem the footer had). Splitting into chips with a
|
|
30714
|
+
* fixed separator lets each segment carry its own color and lets the
|
|
30715
|
+
* user scan the bar in chunks — "what app, what repo, what branch,
|
|
30716
|
+
* how clean, what PR state, what mode" — instead of parsing left-to-
|
|
30717
|
+
* right.
|
|
30718
|
+
*
|
|
30719
|
+
* Why a separate module: the header runtime renders chips and handles
|
|
30720
|
+
* truncation; chip construction is pure transformation of state +
|
|
30721
|
+
* context + theme. Splitting them keeps the chips testable in
|
|
30722
|
+
* isolation and keeps the runtime small.
|
|
30723
|
+
*
|
|
30724
|
+
* Truncation strategy lives in the consumer, not here — when the total
|
|
30725
|
+
* width exceeds the column budget, the header falls back to the
|
|
30726
|
+
* pre-redesign single-fragment truncated string so the ellipsis can't
|
|
30727
|
+
* land mid-glyph. We always return the FULL chip list; the consumer
|
|
30728
|
+
* decides whether to drop chips, fall back, or render all of them.
|
|
30069
30729
|
*/
|
|
30070
|
-
|
|
30071
|
-
|
|
30072
|
-
|
|
30730
|
+
/**
|
|
30731
|
+
* Default separator inserted between chips by the consumer. Exported as
|
|
30732
|
+
* a constant so tests and width math agree on what they're measuring.
|
|
30733
|
+
* The trailing/leading spaces are part of the separator — `·` alone
|
|
30734
|
+
* would butt against adjacent chip labels.
|
|
30735
|
+
*/
|
|
30736
|
+
const HEADER_CHIP_SEPARATOR = ' · ';
|
|
30737
|
+
/**
|
|
30738
|
+
* Build the ordered chip list for the header. Chips not relevant to the
|
|
30739
|
+
* current state (no PR loaded, no breadcrumb, no search input, …) are
|
|
30740
|
+
* omitted entirely rather than rendered as empty placeholders, so the
|
|
30741
|
+
* consumer can just `chips.map(render)` without checking for empties.
|
|
30742
|
+
*/
|
|
30743
|
+
function buildHeaderChips(input) {
|
|
30744
|
+
const { theme } = input;
|
|
30745
|
+
const chips = [];
|
|
30746
|
+
// App label — the constant identity. Accent + bold so it anchors the
|
|
30747
|
+
// left edge of the bar.
|
|
30748
|
+
chips.push({
|
|
30749
|
+
id: 'app',
|
|
30750
|
+
label: input.appLabel,
|
|
30751
|
+
color: theme.colors.accent,
|
|
30752
|
+
dim: false,
|
|
30753
|
+
bold: true,
|
|
30754
|
+
});
|
|
30755
|
+
// Repo. Default color — it's contextual but not the headline.
|
|
30756
|
+
chips.push({
|
|
30757
|
+
id: 'repo',
|
|
30758
|
+
label: input.repo,
|
|
30759
|
+
color: undefined,
|
|
30760
|
+
dim: false,
|
|
30761
|
+
bold: false,
|
|
30762
|
+
});
|
|
30763
|
+
// Branch. Carries the branch glyph (⎇ / ASCII fallback) so the chip
|
|
30764
|
+
// is identifiable even when the branch name is generic ("main" /
|
|
30765
|
+
// "master").
|
|
30766
|
+
const branchGlyph = theme.ascii ? 'git:' : '⎇';
|
|
30767
|
+
chips.push({
|
|
30768
|
+
id: 'branch',
|
|
30769
|
+
label: `${branchGlyph} ${input.branch}`,
|
|
30770
|
+
color: theme.colors.accent,
|
|
30771
|
+
dim: false,
|
|
30772
|
+
bold: true,
|
|
30773
|
+
});
|
|
30774
|
+
// Dirty/clean. Positive framing on clean (success color + ✓), warning
|
|
30775
|
+
// on dirty (warning color + ●). ASCII fallbacks keep the chip
|
|
30776
|
+
// identifiable on dumb terminals.
|
|
30777
|
+
const dirtyChip = input.dirty
|
|
30778
|
+
? {
|
|
30779
|
+
id: 'dirty',
|
|
30780
|
+
label: theme.ascii ? '* dirty' : '● dirty',
|
|
30781
|
+
color: theme.colors.warning,
|
|
30782
|
+
dim: false,
|
|
30783
|
+
bold: false,
|
|
30784
|
+
}
|
|
30785
|
+
: {
|
|
30786
|
+
id: 'dirty',
|
|
30787
|
+
label: theme.ascii ? '+ clean' : '✓ clean',
|
|
30788
|
+
color: theme.colors.success,
|
|
30789
|
+
dim: false,
|
|
30790
|
+
bold: false,
|
|
30791
|
+
};
|
|
30792
|
+
chips.push(dirtyChip);
|
|
30793
|
+
// Bisect — only when active. Distinct chip so users entering the TUI
|
|
30794
|
+
// mid-bisect see it immediately (#784). Warning color because bisect
|
|
30795
|
+
// is an "in progress, requires user action" state.
|
|
30796
|
+
if (input.bisecting) {
|
|
30797
|
+
chips.push({
|
|
30798
|
+
id: 'bisecting',
|
|
30799
|
+
label: theme.ascii ? '! BISECTING' : '⚠ BISECTING',
|
|
30800
|
+
color: theme.colors.warning,
|
|
30801
|
+
dim: false,
|
|
30802
|
+
bold: true,
|
|
30803
|
+
});
|
|
30073
30804
|
}
|
|
30074
|
-
|
|
30075
|
-
|
|
30805
|
+
// PR state. When present, the chip uses the PR-state glyph + a short
|
|
30806
|
+
// label ("PR #1234 OPEN" / "PR #1234 DRAFT"). When absent, a muted
|
|
30807
|
+
// "no PR" chip so users know the system DID look (vs. the bar just
|
|
30808
|
+
// being blank).
|
|
30809
|
+
if (input.pullRequest) {
|
|
30810
|
+
const prGlyph = getPullRequestStateGlyph({ ...input.pullRequest, isDraft: Boolean(input.pullRequest.isDraft) }, theme);
|
|
30811
|
+
const stateLabel = input.pullRequest.isDraft
|
|
30812
|
+
? 'DRAFT'
|
|
30813
|
+
: input.pullRequest.state.toUpperCase();
|
|
30814
|
+
const label = prGlyph.glyph
|
|
30815
|
+
? `${prGlyph.glyph} PR #${input.pullRequest.number} ${stateLabel}`
|
|
30816
|
+
: `PR #${input.pullRequest.number} ${stateLabel}`;
|
|
30817
|
+
chips.push({
|
|
30818
|
+
id: 'pr',
|
|
30819
|
+
label,
|
|
30820
|
+
color: prGlyph.color,
|
|
30821
|
+
dim: prGlyph.dim,
|
|
30822
|
+
bold: false,
|
|
30823
|
+
});
|
|
30076
30824
|
}
|
|
30077
|
-
|
|
30078
|
-
|
|
30079
|
-
|
|
30080
|
-
|
|
30081
|
-
|
|
30082
|
-
|
|
30083
|
-
|
|
30084
|
-
|
|
30085
|
-
}
|
|
30086
|
-
};
|
|
30087
|
-
// Tokenize into runs of whitespace + non-whitespace so we can keep word
|
|
30088
|
-
// boundaries when possible.
|
|
30089
|
-
const tokens = value.match(/\s+|\S+/g) || [];
|
|
30090
|
-
for (const token of tokens) {
|
|
30091
|
-
const tokenWidth = cellWidth(token);
|
|
30092
|
-
if (currentWidth + tokenWidth <= width) {
|
|
30093
|
-
current += token;
|
|
30094
|
-
currentWidth += tokenWidth;
|
|
30095
|
-
continue;
|
|
30096
|
-
}
|
|
30097
|
-
if (/^\s+$/.test(token)) {
|
|
30098
|
-
// Drop boundary whitespace at line breaks.
|
|
30099
|
-
flush();
|
|
30100
|
-
continue;
|
|
30101
|
-
}
|
|
30102
|
-
flush();
|
|
30103
|
-
if (tokenWidth <= width) {
|
|
30104
|
-
current = token;
|
|
30105
|
-
currentWidth = tokenWidth;
|
|
30106
|
-
continue;
|
|
30107
|
-
}
|
|
30108
|
-
// Word longer than budget — hard-split into chunks.
|
|
30109
|
-
let remaining = token;
|
|
30110
|
-
while (cellWidth(remaining) > width) {
|
|
30111
|
-
let chunk = '';
|
|
30112
|
-
let chunkWidth = 0;
|
|
30113
|
-
for (const character of Array.from(remaining)) {
|
|
30114
|
-
const charW = characterWidth(character);
|
|
30115
|
-
if (chunkWidth + charW > width)
|
|
30116
|
-
break;
|
|
30117
|
-
chunk += character;
|
|
30118
|
-
chunkWidth += charW;
|
|
30119
|
-
}
|
|
30120
|
-
lines.push(chunk);
|
|
30121
|
-
remaining = remaining.slice(chunk.length);
|
|
30122
|
-
}
|
|
30123
|
-
if (remaining.length > 0) {
|
|
30124
|
-
current = remaining;
|
|
30125
|
-
currentWidth = cellWidth(remaining);
|
|
30126
|
-
}
|
|
30825
|
+
else {
|
|
30826
|
+
chips.push({
|
|
30827
|
+
id: 'pr',
|
|
30828
|
+
label: theme.ascii ? '- no PR' : '⊘ no PR',
|
|
30829
|
+
color: theme.colors.muted,
|
|
30830
|
+
dim: true,
|
|
30831
|
+
bold: false,
|
|
30832
|
+
});
|
|
30127
30833
|
}
|
|
30128
|
-
|
|
30129
|
-
|
|
30130
|
-
|
|
30131
|
-
|
|
30132
|
-
|
|
30133
|
-
|
|
30834
|
+
// View breadcrumb. Rendered only when there's content (`coco ui`
|
|
30835
|
+
// root view → no breadcrumb chip; pushed into a sub-view → chip
|
|
30836
|
+
// appears). Comes AFTER PR so the "state" group (app/repo/branch/
|
|
30837
|
+
// dirty/PR) reads as one cluster and the "navigation" group (view
|
|
30838
|
+
// breadcrumb / loading) reads as a separate cluster.
|
|
30839
|
+
if (input.breadcrumb) {
|
|
30840
|
+
chips.push({
|
|
30841
|
+
id: 'view',
|
|
30842
|
+
label: input.breadcrumb,
|
|
30843
|
+
color: theme.colors.muted,
|
|
30844
|
+
dim: true,
|
|
30845
|
+
bold: false,
|
|
30846
|
+
});
|
|
30134
30847
|
}
|
|
30135
|
-
if (
|
|
30136
|
-
|
|
30848
|
+
if (input.loading) {
|
|
30849
|
+
chips.push({
|
|
30850
|
+
id: 'loading',
|
|
30851
|
+
label: input.loading.trim(),
|
|
30852
|
+
color: theme.colors.muted,
|
|
30853
|
+
dim: true,
|
|
30854
|
+
bold: false,
|
|
30855
|
+
});
|
|
30137
30856
|
}
|
|
30138
|
-
|
|
30139
|
-
|
|
30140
|
-
|
|
30141
|
-
|
|
30142
|
-
|
|
30143
|
-
|
|
30144
|
-
|
|
30145
|
-
|
|
30146
|
-
|
|
30147
|
-
|
|
30148
|
-
|
|
30857
|
+
// Mode — the explicit input-mode indicator (#P2.2). Always present
|
|
30858
|
+
// so users never wonder why `q` doesn't quit while they're editing.
|
|
30859
|
+
// EDIT / FILTER use the warning color to signal "your keystrokes
|
|
30860
|
+
// mean something different right now"; NORMAL uses accent (matches
|
|
30861
|
+
// the app chip's home base).
|
|
30862
|
+
const modeColor = input.mode === 'NORMAL'
|
|
30863
|
+
? theme.colors.accent
|
|
30864
|
+
: theme.colors.warning;
|
|
30865
|
+
chips.push({
|
|
30866
|
+
id: 'mode',
|
|
30867
|
+
label: `[${input.mode}]`,
|
|
30868
|
+
color: modeColor,
|
|
30869
|
+
dim: false,
|
|
30870
|
+
bold: true,
|
|
30871
|
+
});
|
|
30872
|
+
// Search — only when active. Dim so it doesn't compete with the
|
|
30873
|
+
// identity chips for attention; the user knows it's there because
|
|
30874
|
+
// they're typing into it.
|
|
30875
|
+
if (input.search) {
|
|
30876
|
+
chips.push({
|
|
30877
|
+
id: 'search',
|
|
30878
|
+
label: input.search,
|
|
30879
|
+
color: theme.colors.muted,
|
|
30880
|
+
dim: true,
|
|
30881
|
+
bold: false,
|
|
30882
|
+
});
|
|
30149
30883
|
}
|
|
30150
|
-
return
|
|
30884
|
+
return chips;
|
|
30885
|
+
}
|
|
30886
|
+
/**
|
|
30887
|
+
* Total rendered width of a chip list assuming `HEADER_CHIP_SEPARATOR`
|
|
30888
|
+
* between every pair. Used by the consumer to decide whether the
|
|
30889
|
+
* chip layout fits the column budget or whether to fall back to the
|
|
30890
|
+
* single-fragment truncated path.
|
|
30891
|
+
*/
|
|
30892
|
+
function measureHeaderChipsWidth(chips) {
|
|
30893
|
+
if (chips.length === 0)
|
|
30894
|
+
return 0;
|
|
30895
|
+
const labels = chips.map((chip) => cellWidth(chip.label));
|
|
30896
|
+
const separators = (chips.length - 1) * cellWidth(HEADER_CHIP_SEPARATOR);
|
|
30897
|
+
return labels.reduce((sum, w) => sum + w, 0) + separators;
|
|
30151
30898
|
}
|
|
30152
30899
|
|
|
30153
30900
|
/**
|
|
30154
|
-
* Title-bar renderer. Surfaces
|
|
30155
|
-
*
|
|
30156
|
-
* - current repo owner/name (or "local repository")
|
|
30157
|
-
* - current branch + dirty / BISECTING flag
|
|
30158
|
-
* - PR glyph + label when one is detected
|
|
30159
|
-
* - breadcrumb of the view stack
|
|
30160
|
-
* - loading hint for boot / context fetches
|
|
30161
|
-
* - mode indicator: [NORMAL] / [EDIT] / [FILTER]
|
|
30162
|
-
* - active filter / search input
|
|
30901
|
+
* Title-bar renderer. Surfaces the workstation's identity + navigation
|
|
30902
|
+
* state as a row of small visually-distinct chips:
|
|
30163
30903
|
*
|
|
30164
|
-
*
|
|
30165
|
-
* fall back to a single-fragment Text (truncating the joined string) so
|
|
30166
|
-
* the ellipsis can't land mid-glyph. The split-fragment path keeps the PR
|
|
30167
|
-
* glyph in its own colored span when there's headroom.
|
|
30904
|
+
* coco · gfargo/coco · ⎇ main · ✓ clean · ⊘ no PR · [NORMAL]
|
|
30168
30905
|
*
|
|
30169
|
-
*
|
|
30170
|
-
*
|
|
30906
|
+
* Per-chip color/glyph treatment lets the user scan in chunks ("what
|
|
30907
|
+
* app, what repo, what branch, how clean, what PR state, what mode")
|
|
30908
|
+
* instead of parsing one long sentence. Chip construction is in
|
|
30909
|
+
* `chrome/headerChips.ts`; this runtime just renders.
|
|
30910
|
+
*
|
|
30911
|
+
* Truncation: when the assembled chip row overruns the available
|
|
30912
|
+
* columns we fall back to a single Text fragment (truncating the
|
|
30913
|
+
* joined chip labels) so the ellipsis can't land mid-glyph. This is
|
|
30914
|
+
* the same defensive pattern the pre-redesign single-fragment code
|
|
30915
|
+
* used, applied at the chip-list level instead of the inline glyph
|
|
30916
|
+
* split.
|
|
30917
|
+
*
|
|
30918
|
+
* Extracted from `src/commands/log/inkRuntime.ts` as part of phase
|
|
30919
|
+
* 5a.7 of #890. Chip restructuring introduced post-0.54.2.
|
|
30171
30920
|
*/
|
|
30172
30921
|
function renderHeader(h, components, state, context, contextStatus, columns, theme, appLabel) {
|
|
30173
30922
|
const { Box, Text } = components;
|
|
30923
|
+
// Pull the source state into the small "describe what to render"
|
|
30924
|
+
// shape the chip builder expects. Keeps the runtime decoupled from
|
|
30925
|
+
// the chip layout — the builder doesn't know about LogInkState /
|
|
30926
|
+
// LogInkContext, just plain values.
|
|
30174
30927
|
const branch = context.branches?.currentBranch || context.provider?.currentBranch || '<detached>';
|
|
30175
|
-
|
|
30176
|
-
|
|
30177
|
-
const dirtyBase = context.branches?.dirty ? 'dirty' : 'clean';
|
|
30178
|
-
const dirty = context.bisect?.active ? `${dirtyBase} · BISECTING` : dirtyBase;
|
|
30928
|
+
const dirty = Boolean(context.branches?.dirty);
|
|
30929
|
+
const bisecting = Boolean(context.bisect?.active);
|
|
30179
30930
|
const repo = context.provider?.repository.owner && context.provider.repository.name
|
|
30180
30931
|
? `${context.provider.repository.owner}/${context.provider.repository.name}`
|
|
30181
30932
|
: 'local repository';
|
|
30182
30933
|
const prInfo = context.provider?.currentPullRequest || context.pullRequest?.currentPullRequest;
|
|
30183
|
-
|
|
30184
|
-
|
|
30185
|
-
|
|
30186
|
-
: 'no PR';
|
|
30187
|
-
const search = state.filterMode ? `search: ${state.filter}_` : state.filter ? `filter: ${state.filter}` : '';
|
|
30188
|
-
// Boot loading wins over the per-context loading hint because it
|
|
30189
|
-
// tells the user the headline thing they care about (commits aren't
|
|
30190
|
-
// ready yet) — the context fetches finish independently and surface
|
|
30191
|
-
// their own per-section loading copy in the sidebars.
|
|
30934
|
+
// Boot loading wins over the per-context loading hint — same
|
|
30935
|
+
// priority as pre-redesign. Context fetches still surface their own
|
|
30936
|
+
// copy in the sidebars.
|
|
30192
30937
|
const loading = state.bootLoading
|
|
30193
|
-
? '
|
|
30194
|
-
: isLogInkContextLoading(contextStatus) ? '
|
|
30938
|
+
? 'loading commits'
|
|
30939
|
+
: isLogInkContextLoading(contextStatus) ? 'loading context' : '';
|
|
30195
30940
|
const breadcrumb = formatLogInkBreadcrumb(state.viewStack);
|
|
30196
30941
|
const repoCrumb = formatLogInkRepoBreadcrumb(state.repoStack);
|
|
30197
|
-
// Repo breadcrumb (when nested) comes first so the user sees which
|
|
30198
|
-
// submodule they're in at a glance, then the view breadcrumb (when
|
|
30199
|
-
// pushed deeper than the root view). The truncate fallback in the
|
|
30200
|
-
// title row still applies — when both fight for space, the ellipsis
|
|
30201
|
-
// lands at the end of whichever segment overflows.
|
|
30202
30942
|
const view = combineLogInkBreadcrumbSegments(repoCrumb, breadcrumb);
|
|
30203
|
-
// Mode indicator (P2.2) — surfaces the current input mode so users
|
|
30204
|
-
// never wonder why `q` doesn't quit while they're editing or filtering.
|
|
30205
30943
|
const mode = state.commitCompose.editing
|
|
30206
|
-
? '
|
|
30944
|
+
? 'EDIT'
|
|
30207
30945
|
: state.filterMode
|
|
30208
|
-
? '
|
|
30209
|
-
: '
|
|
30210
|
-
const
|
|
30211
|
-
|
|
30212
|
-
|
|
30213
|
-
|
|
30214
|
-
|
|
30215
|
-
const
|
|
30216
|
-
|
|
30217
|
-
|
|
30218
|
-
|
|
30219
|
-
|
|
30220
|
-
|
|
30221
|
-
?
|
|
30222
|
-
|
|
30223
|
-
|
|
30224
|
-
:
|
|
30946
|
+
? 'FILTER'
|
|
30947
|
+
: 'NORMAL';
|
|
30948
|
+
const search = state.filterMode
|
|
30949
|
+
? `search: ${state.filter}_`
|
|
30950
|
+
: state.filter
|
|
30951
|
+
? `filter: ${state.filter}`
|
|
30952
|
+
: '';
|
|
30953
|
+
const chips = buildHeaderChips({
|
|
30954
|
+
appLabel,
|
|
30955
|
+
repo,
|
|
30956
|
+
branch,
|
|
30957
|
+
dirty,
|
|
30958
|
+
bisecting,
|
|
30959
|
+
pullRequest: prInfo ? {
|
|
30960
|
+
number: prInfo.number,
|
|
30961
|
+
state: prInfo.state,
|
|
30962
|
+
isDraft: prInfo.isDraft,
|
|
30963
|
+
} : undefined,
|
|
30964
|
+
breadcrumb: view,
|
|
30965
|
+
loading,
|
|
30966
|
+
mode,
|
|
30967
|
+
search: search ? truncateCells(search, 36) : '',
|
|
30968
|
+
theme,
|
|
30969
|
+
});
|
|
30970
|
+
// Truncation budget. Header line gets the full terminal width minus
|
|
30971
|
+
// the box's horizontal padding (2 cells) and a small safety margin.
|
|
30972
|
+
const budget = Math.max(0, columns - 4);
|
|
30973
|
+
const chipsWidth = measureHeaderChipsWidth(chips);
|
|
30225
30974
|
return h(Box, {
|
|
30226
30975
|
borderColor: theme.colors.border,
|
|
30227
30976
|
borderStyle: theme.borderStyle,
|
|
30228
30977
|
height: 3,
|
|
30229
30978
|
paddingX: 1,
|
|
30230
|
-
},
|
|
30231
|
-
? h
|
|
30232
|
-
: h
|
|
30233
|
-
|
|
30234
|
-
|
|
30235
|
-
|
|
30236
|
-
|
|
30979
|
+
}, chipsWidth <= budget
|
|
30980
|
+
? renderChipRow(h, Text, chips)
|
|
30981
|
+
: renderFallback(h, Text, chips, theme, budget));
|
|
30982
|
+
}
|
|
30983
|
+
/**
|
|
30984
|
+
* Render every chip as its own Text span with its own color/style,
|
|
30985
|
+
* interleaved with dim separator spans. This is the path used when
|
|
30986
|
+
* everything fits — the eye gets the full chip treatment.
|
|
30987
|
+
*/
|
|
30988
|
+
function renderChipRow(h, Text, chips) {
|
|
30989
|
+
const nodes = [];
|
|
30990
|
+
chips.forEach((chip, index) => {
|
|
30991
|
+
if (index > 0) {
|
|
30992
|
+
// Separator is intentionally dim so the eye can use it as a
|
|
30993
|
+
// visual delimiter without it competing with chip labels for
|
|
30994
|
+
// attention.
|
|
30995
|
+
nodes.push(h(Text, { key: `sep-${index}`, dimColor: true }, HEADER_CHIP_SEPARATOR));
|
|
30996
|
+
}
|
|
30997
|
+
nodes.push(h(Text, {
|
|
30998
|
+
key: chip.id,
|
|
30999
|
+
color: chip.color,
|
|
31000
|
+
dimColor: chip.dim,
|
|
31001
|
+
bold: chip.bold,
|
|
31002
|
+
}, chip.label));
|
|
31003
|
+
});
|
|
31004
|
+
return nodes;
|
|
31005
|
+
}
|
|
31006
|
+
/**
|
|
31007
|
+
* Fallback path for narrow terminals. Concatenates every chip label
|
|
31008
|
+
* with separators, then truncates the whole string with
|
|
31009
|
+
* `truncateCells` so the ellipsis lands at a cell boundary. Loses the
|
|
31010
|
+
* per-chip color treatment in exchange for guaranteed legibility on
|
|
31011
|
+
* narrow displays — the same trade-off the pre-redesign single-
|
|
31012
|
+
* fragment code made for its inline glyph color split.
|
|
31013
|
+
*/
|
|
31014
|
+
function renderFallback(h, Text, chips, theme, budget) {
|
|
31015
|
+
const joined = chips.map((chip) => chip.label).join(HEADER_CHIP_SEPARATOR);
|
|
31016
|
+
return h(Text, { bold: true, color: theme.colors.accent }, truncateCells(joined, budget));
|
|
30237
31017
|
}
|
|
30238
31018
|
|
|
30239
31019
|
/**
|
|
@@ -30456,10 +31236,21 @@ function renderActiveStatusTabContent(h, Text, context, contextStatus, width, th
|
|
|
30456
31236
|
const summaryRow = (count, label, key, kind) => h(Text, { key }, ' ', h(Text, { color: colorOf(kind), bold: count > 0 }, `${count} ${label}`));
|
|
30457
31237
|
const fileRows = worktree.files.slice(0, 12).map((file, index) => {
|
|
30458
31238
|
const codes = `${file.indexStatus}${file.worktreeStatus}`;
|
|
31239
|
+
// Smart path truncation: keep the leading status codes and elide
|
|
31240
|
+
// middle directory segments to preserve the filename. Falls back
|
|
31241
|
+
// to plain truncation when the codes + a meaningful filename
|
|
31242
|
+
// don't both fit. Same shape as the detail surface so all the
|
|
31243
|
+
// status-row renderings elide consistently.
|
|
31244
|
+
const prefix = ` ${codes} `;
|
|
31245
|
+
const totalBudget = width - 4;
|
|
31246
|
+
const pathBudget = totalBudget - cellWidth(prefix);
|
|
31247
|
+
const label = pathBudget >= 8
|
|
31248
|
+
? `${prefix}${truncatePathCells(file.path, pathBudget)}`
|
|
31249
|
+
: truncateCells(`${prefix}${file.path}`, totalBudget);
|
|
30459
31250
|
return h(Text, {
|
|
30460
31251
|
key: `tab-status-file-${index}`,
|
|
30461
31252
|
color: colorOf(file.state),
|
|
30462
|
-
},
|
|
31253
|
+
}, label);
|
|
30463
31254
|
});
|
|
30464
31255
|
return [
|
|
30465
31256
|
summaryRow(worktree.stagedCount, 'staged', 'tab-status-staged', 'staged'),
|
|
@@ -31802,7 +32593,17 @@ function renderDiffSurface(h, components, state, context, contextStatus, worktre
|
|
|
31802
32593
|
color: theme.noColor ? undefined : theme.colors.accent,
|
|
31803
32594
|
backgroundColor: isActive && focused && !theme.noColor ? theme.colors.selection : undefined,
|
|
31804
32595
|
inverse: isActive && focused,
|
|
31805
|
-
},
|
|
32596
|
+
}, (() => {
|
|
32597
|
+
// Smart path truncation for the diff file header: keep
|
|
32598
|
+
// the leading arrow glyph and elide middle path
|
|
32599
|
+
// segments so the filename is never lost. Falls back to
|
|
32600
|
+
// plain truncation when there isn't room for a
|
|
32601
|
+
// meaningful filename.
|
|
32602
|
+
const pathBudget = (width - 4) - cellWidth(arrow);
|
|
32603
|
+
return pathBudget >= 8
|
|
32604
|
+
? `${arrow}${truncatePathCells(headerFile.path, pathBudget)}`
|
|
32605
|
+
: truncateCells(`${arrow}${headerFile.path}`, width - 4);
|
|
32606
|
+
})());
|
|
31806
32607
|
}
|
|
31807
32608
|
return h(Text, {
|
|
31808
32609
|
key: `stash-diff-line-${absoluteIndex}`,
|
|
@@ -35283,6 +36084,24 @@ function renderInspectorRefs(h, Text, refs, repository) {
|
|
|
35283
36084
|
});
|
|
35284
36085
|
return out;
|
|
35285
36086
|
}
|
|
36087
|
+
/**
|
|
36088
|
+
* Compose a `<prefix><path><suffix>` line where the path gets smart
|
|
36089
|
+
* middle-elision truncation if needed, while the fixed prefix/suffix
|
|
36090
|
+
* decorations stay intact. Falls back to plain whole-line truncation
|
|
36091
|
+
* when the suffix decorations consume too much of the budget for the
|
|
36092
|
+
* path-aware variant to leave a meaningful filename.
|
|
36093
|
+
*
|
|
36094
|
+
* Used by the changed-files list AND the compose-context staged /
|
|
36095
|
+
* unstaged sections so all three places elide identically — same
|
|
36096
|
+
* floor (8 cells), same fallback shape.
|
|
36097
|
+
*/
|
|
36098
|
+
function smartPathLabel(prefix, path, suffix, totalBudget) {
|
|
36099
|
+
const pathBudget = totalBudget - cellWidth(prefix) - cellWidth(suffix);
|
|
36100
|
+
if (pathBudget >= 8) {
|
|
36101
|
+
return `${prefix}${truncatePathCells(path, pathBudget)}${suffix}`;
|
|
36102
|
+
}
|
|
36103
|
+
return truncateCells(`${prefix}${path}${suffix}`, totalBudget);
|
|
36104
|
+
}
|
|
35286
36105
|
/**
|
|
35287
36106
|
* Render a list of changed files with status-code colors and stats. Used
|
|
35288
36107
|
* by both the history inspector and the commit-diff detail panel so the
|
|
@@ -35310,13 +36129,21 @@ function renderCommitFileList(h, Text, files, selectedIndex, focused, maxRows, w
|
|
|
35310
36129
|
// in `lfsPointer.ts` so even rename / mode-only rows are
|
|
35311
36130
|
// flagged.
|
|
35312
36131
|
const lfsBadge = lfsStatus && isPathLfsTracked(lfsStatus, file.path) ? ' [LFS]' : '';
|
|
35313
|
-
|
|
36132
|
+
// Smart path truncation via `smartPathLabel`: keeps the cursor +
|
|
36133
|
+
// status-code prefix and the stats/badge suffix intact, gives
|
|
36134
|
+
// the path's remaining width budget to middle-elision so the
|
|
36135
|
+
// filename survives instead of getting blunt-truncated off the
|
|
36136
|
+
// end (the issue users hit when inspector paths read like
|
|
36137
|
+
// `src/commands/log/da...`).
|
|
36138
|
+
const labelPrefix = `${cursor} ${statusCode} `;
|
|
36139
|
+
const labelSuffix = `${renamed}${lfsBadge}${stats ? ` ${stats}` : ''}`;
|
|
36140
|
+
const label = smartPathLabel(labelPrefix, file.path, labelSuffix, width - 4);
|
|
35314
36141
|
return h(Text, {
|
|
35315
36142
|
key: `commit-file-${index}`,
|
|
35316
36143
|
color: statusCodeColor(file.status, theme),
|
|
35317
36144
|
inverse: isSelected && focused && !theme.noColor,
|
|
35318
36145
|
bold: isSelected,
|
|
35319
|
-
},
|
|
36146
|
+
}, label);
|
|
35320
36147
|
});
|
|
35321
36148
|
}
|
|
35322
36149
|
function renderPreviewPanel(h, components, title, lines, width, theme, focused) {
|
|
@@ -35592,7 +36419,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35592
36419
|
...stagedFiles.map((file, index) => h(Text, {
|
|
35593
36420
|
key: `compose-context-staged-${index}`,
|
|
35594
36421
|
color: theme.noColor ? undefined : theme.colors.gitAdded,
|
|
35595
|
-
},
|
|
36422
|
+
}, smartPathLabel(` ${file.indexStatus} `, file.path, '', width - 4))),
|
|
35596
36423
|
h(Text, { key: 'compose-context-staged-spacer' }, ''),
|
|
35597
36424
|
]
|
|
35598
36425
|
: []), ...(unstagedFiles.length
|
|
@@ -35601,7 +36428,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
|
|
|
35601
36428
|
...unstagedFiles.map((file, index) => h(Text, {
|
|
35602
36429
|
key: `compose-context-unstaged-${index}`,
|
|
35603
36430
|
color: theme.noColor ? undefined : theme.colors.gitModified,
|
|
35604
|
-
},
|
|
36431
|
+
}, smartPathLabel(` ${file.worktreeStatus} `, file.path, '', width - 4))),
|
|
35605
36432
|
]
|
|
35606
36433
|
: !stagedFiles.length && !loadingWorktree
|
|
35607
36434
|
? [h(Text, { dimColor: true }, 'No worktree changes detected.')]
|
|
@@ -36268,6 +37095,14 @@ function LogInkApp(deps) {
|
|
|
36268
37095
|
// workdirs for submodule paths recorded in `.gitmodules` (which
|
|
36269
37096
|
// are repo-relative). Undefined during the brief moment between
|
|
36270
37097
|
// git swap and the revparse callback resolving.
|
|
37098
|
+
//
|
|
37099
|
+
// Audit finding #10: rapid frame push/pop races are prevented by
|
|
37100
|
+
// the per-effect `cancelled` flag — React fires the cleanup
|
|
37101
|
+
// synchronously BEFORE running the next effect body, so any
|
|
37102
|
+
// pending revparse from the old `git` sees `cancelled === true`
|
|
37103
|
+
// and skips its write. The `git` reference itself is captured by
|
|
37104
|
+
// closure, so each effect run resolves against the right binding.
|
|
37105
|
+
// No additional depth tagging is needed.
|
|
36271
37106
|
const [activeRepoRoot, setActiveRepoRoot] = React.useState(undefined);
|
|
36272
37107
|
React.useEffect(() => {
|
|
36273
37108
|
let cancelled = false;
|
|
@@ -36493,7 +37328,7 @@ function LogInkApp(deps) {
|
|
|
36493
37328
|
if (cancelled || !mountedRef.current)
|
|
36494
37329
|
return;
|
|
36495
37330
|
const message = error instanceof Error ? error.message : String(error);
|
|
36496
|
-
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}
|
|
37331
|
+
dispatch({ type: 'setStatus', value: `Failed to load commits: ${message}`, kind: 'error' });
|
|
36497
37332
|
dispatch({ type: 'setBootLoading', value: false });
|
|
36498
37333
|
});
|
|
36499
37334
|
return () => {
|
|
@@ -36561,8 +37396,15 @@ function LogInkApp(deps) {
|
|
|
36561
37396
|
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
36562
37397
|
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
36563
37398
|
};
|
|
37399
|
+
// Stash commits as graph roots so post-operation refreshes
|
|
37400
|
+
// keep the same rich graph the boot loader assembled. Without
|
|
37401
|
+
// this, every commit / split-apply / etc. would drop stash
|
|
37402
|
+
// anchors and the cursor-syncs-history effect would degrade
|
|
37403
|
+
// back to "tip not in loaded window" for older stashes.
|
|
37404
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
36564
37405
|
const fresh = await getLogRows(git, mergedArgv, {
|
|
36565
37406
|
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
37407
|
+
extraRefs: stashHashes,
|
|
36566
37408
|
});
|
|
36567
37409
|
if (mountedRef.current && fresh) {
|
|
36568
37410
|
dispatch({ type: 'replaceRows', rows: fresh });
|
|
@@ -36698,18 +37540,50 @@ function LogInkApp(deps) {
|
|
|
36698
37540
|
})();
|
|
36699
37541
|
return () => { cancelled = true; };
|
|
36700
37542
|
}, [git, dispatch]);
|
|
37543
|
+
// Audit finding #2: re-resolve the repo root inline on every save
|
|
37544
|
+
// and key the deps off `git` + the saved value. The original
|
|
37545
|
+
// implementation read from `repoRootRef.current`, which is async-
|
|
37546
|
+
// populated by the resolver effect above and can lag behind a git
|
|
37547
|
+
// swap. After #995's synchronous pop-restore, the parent's freshly
|
|
37548
|
+
// restored sidebar tab was being written into the submodule's
|
|
37549
|
+
// cache because the ref still held the submodule root during the
|
|
37550
|
+
// brief window before the resolver settled.
|
|
37551
|
+
//
|
|
37552
|
+
// The extra `revparse` cost per save is negligible (saves fire
|
|
37553
|
+
// once per user-initiated tab change, not per render) and the
|
|
37554
|
+
// cancellation flag prevents a stale resolution from racing a
|
|
37555
|
+
// newer one in flight.
|
|
36701
37556
|
React.useEffect(() => {
|
|
36702
|
-
|
|
36703
|
-
|
|
36704
|
-
|
|
36705
|
-
|
|
36706
|
-
|
|
37557
|
+
let cancelled = false;
|
|
37558
|
+
void (async () => {
|
|
37559
|
+
try {
|
|
37560
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
37561
|
+
if (cancelled || !root)
|
|
37562
|
+
return;
|
|
37563
|
+
saveSidebarTab(root, state.userSidebarTab);
|
|
37564
|
+
}
|
|
37565
|
+
catch {
|
|
37566
|
+
// Not in a worktree, or revparse failed — silently skip.
|
|
37567
|
+
// The next save attempt will retry.
|
|
37568
|
+
}
|
|
37569
|
+
})();
|
|
37570
|
+
return () => { cancelled = true; };
|
|
37571
|
+
}, [state.userSidebarTab, git]);
|
|
36707
37572
|
React.useEffect(() => {
|
|
36708
|
-
|
|
36709
|
-
|
|
36710
|
-
|
|
36711
|
-
|
|
36712
|
-
|
|
37573
|
+
let cancelled = false;
|
|
37574
|
+
void (async () => {
|
|
37575
|
+
try {
|
|
37576
|
+
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
37577
|
+
if (cancelled || !root)
|
|
37578
|
+
return;
|
|
37579
|
+
saveDiffViewMode(root, state.diffViewMode);
|
|
37580
|
+
}
|
|
37581
|
+
catch {
|
|
37582
|
+
// Same as above.
|
|
37583
|
+
}
|
|
37584
|
+
})();
|
|
37585
|
+
return () => { cancelled = true; };
|
|
37586
|
+
}, [state.diffViewMode, git]);
|
|
36713
37587
|
// P-stash-explorer: load `git stash show -p <ref>` once the diff view
|
|
36714
37588
|
// becomes active with diffSource='stash'. Best-effort — empty stashes
|
|
36715
37589
|
// or read errors fall through to a "no diff" hint at the render site.
|
|
@@ -37096,12 +37970,33 @@ function LogInkApp(deps) {
|
|
|
37096
37970
|
// fetched yet); a status hint surfaces in that case so the user
|
|
37097
37971
|
// knows to toggle full graph or load older commits.
|
|
37098
37972
|
const lastSyncedHashRef = React.useRef(undefined);
|
|
37973
|
+
// Tracks which target hashes we've already anchored a `git log`
|
|
37974
|
+
// fetch on (#1034 follow-up). When the cursor-syncs-history effect
|
|
37975
|
+
// sees a target whose hash isn't in the loaded window AND isn't in
|
|
37976
|
+
// this set, it kicks off `getLogRowsAnchoredOn` and adds the hash
|
|
37977
|
+
// here. After the fetch resolves and rows are appended, the effect
|
|
37978
|
+
// re-fires; if the target STILL isn't loaded the resolver sees the
|
|
37979
|
+
// hash in this set and returns `unreachable` instead of looping.
|
|
37980
|
+
//
|
|
37981
|
+
// Stored as a ref because (a) the resolver only ever reads it and
|
|
37982
|
+
// (b) component re-renders on state.filteredCommits change are the
|
|
37983
|
+
// re-fire trigger; storing here in state would add a redundant
|
|
37984
|
+
// render per attempt.
|
|
37985
|
+
const attemptedContextHashesRef = React.useRef(new Set());
|
|
37986
|
+
const loadCommitContextRef = React.useRef(null);
|
|
37099
37987
|
React.useEffect(() => {
|
|
37100
37988
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37101
37989
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37102
37990
|
const onTagTab = state.activeView === 'tags' ||
|
|
37103
37991
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37104
|
-
|
|
37992
|
+
// User-reported gap: cursoring a stash didn't sync the history
|
|
37993
|
+
// cursor the way cursoring a branch / tag did. Same auto-jump
|
|
37994
|
+
// affordance now extends to stashes; the stash's commit hash IS
|
|
37995
|
+
// the row to land on (stashes are commits living off the
|
|
37996
|
+
// `refs/stash` tree, visible under `--all` / fullGraph).
|
|
37997
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
37998
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
37999
|
+
if (!onBranchTab && !onTagTab && !onStashTab)
|
|
37105
38000
|
return;
|
|
37106
38001
|
let targetHash;
|
|
37107
38002
|
let targetLabel;
|
|
@@ -37127,51 +38022,117 @@ function LogInkApp(deps) {
|
|
|
37127
38022
|
targetLabel = `tag ${tag.name}`;
|
|
37128
38023
|
}
|
|
37129
38024
|
}
|
|
37130
|
-
if (
|
|
37131
|
-
|
|
37132
|
-
|
|
37133
|
-
|
|
37134
|
-
|
|
37135
|
-
|
|
37136
|
-
|
|
37137
|
-
|
|
37138
|
-
|
|
37139
|
-
|
|
37140
|
-
|
|
37141
|
-
|
|
37142
|
-
|
|
37143
|
-
|
|
37144
|
-
|
|
37145
|
-
|
|
37146
|
-
|
|
37147
|
-
|
|
37148
|
-
|
|
37149
|
-
|
|
37150
|
-
|
|
38025
|
+
else if (onStashTab) {
|
|
38026
|
+
const all = context.stashes?.stashes || [];
|
|
38027
|
+
const visible = state.filter
|
|
38028
|
+
? all.filter((s) => matchesPromotedFilter([s.ref, s.message], state.filter))
|
|
38029
|
+
: all;
|
|
38030
|
+
const stash = visible[Math.min(state.selectedStashIndex, Math.max(0, visible.length - 1))];
|
|
38031
|
+
if (stash) {
|
|
38032
|
+
// Two-step fallback chain for stash cursor sync:
|
|
38033
|
+
//
|
|
38034
|
+
// 1. Try `baseHash` (the branch tip the stash was created
|
|
38035
|
+
// from). This answers the user-visible question "where
|
|
38036
|
+
// in larger git history was this stash made?" — that's
|
|
38037
|
+
// the branch origin point, not the stash's own merge-
|
|
38038
|
+
// commit row off in `refs/stash`. Base commits live on
|
|
38039
|
+
// regular branches so they're almost always in the
|
|
38040
|
+
// loaded window.
|
|
38041
|
+
//
|
|
38042
|
+
// 2. If `baseHash` isn't in the loaded window (the stash's
|
|
38043
|
+
// base branch was deleted, or the base is older than
|
|
38044
|
+
// the 1000-commit cap), fall back to `stash.hash`
|
|
38045
|
+
// itself. The stash commit was added as an extraRef so
|
|
38046
|
+
// it's reachable from the graph if it fits the window.
|
|
38047
|
+
//
|
|
38048
|
+
// Only after BOTH miss does the effect report "tip not in
|
|
38049
|
+
// loaded window." The label flips to mention "base" vs the
|
|
38050
|
+
// stash commit so the user knows what they're looking at.
|
|
38051
|
+
// hashesMatchAny handles the short-hash auto-extension
|
|
38052
|
+
// mismatch between `git stash list --format=%h` (stash hash)
|
|
38053
|
+
// and `git log --pretty=format:%h` (history row). Same
|
|
38054
|
+
// hazard as the branch/tag cursor sync — see src/git/hashes.ts.
|
|
38055
|
+
const baseLoaded = Boolean(stash.baseHash) && state.filteredCommits.some((c) => hashesMatchAny(stash.baseHash, [c.hash, c.shortHash]));
|
|
38056
|
+
const hashLoaded = state.filteredCommits.some((c) => hashesMatchAny(stash.hash, [c.hash, c.shortHash]));
|
|
38057
|
+
if (baseLoaded) {
|
|
38058
|
+
targetHash = stash.baseHash;
|
|
38059
|
+
targetLabel = `${stash.ref}'s base`;
|
|
38060
|
+
}
|
|
38061
|
+
else if (hashLoaded) {
|
|
38062
|
+
targetHash = stash.hash;
|
|
38063
|
+
targetLabel = stash.ref;
|
|
38064
|
+
}
|
|
38065
|
+
else {
|
|
38066
|
+
// Neither in window — set to baseHash so the standard
|
|
38067
|
+
// "not in loaded window" message fires with a meaningful
|
|
38068
|
+
// label (the base is what the user actually wants to see).
|
|
38069
|
+
targetHash = stash.baseHash || stash.hash;
|
|
38070
|
+
targetLabel = stash.ref;
|
|
38071
|
+
}
|
|
38072
|
+
}
|
|
37151
38073
|
}
|
|
37152
|
-
|
|
37153
|
-
|
|
37154
|
-
|
|
37155
|
-
|
|
37156
|
-
}
|
|
38074
|
+
// Delegate the actual decision to the pure resolver so the
|
|
38075
|
+
// logic is testable in isolation. The effect just performs the
|
|
38076
|
+
// resolver's chosen action.
|
|
38077
|
+
const decision = resolveCursorSyncDecision({
|
|
38078
|
+
target: targetHash ? { hash: targetHash, label: targetLabel || targetHash } : undefined,
|
|
38079
|
+
loadedHashes: buildLoadedHashSet(state.filteredCommits),
|
|
38080
|
+
lastSyncedHash: lastSyncedHashRef.current,
|
|
38081
|
+
attemptedContextHashes: attemptedContextHashesRef.current,
|
|
38082
|
+
});
|
|
38083
|
+
switch (decision.type) {
|
|
38084
|
+
case 'noop':
|
|
38085
|
+
return;
|
|
38086
|
+
case 'jump':
|
|
38087
|
+
lastSyncedHashRef.current = decision.hash;
|
|
38088
|
+
dispatch({ type: 'selectCommitByHash', hash: decision.hash });
|
|
38089
|
+
dispatch({
|
|
38090
|
+
type: 'setStatus',
|
|
38091
|
+
value: `Synced history to ${decision.label} tip`,
|
|
38092
|
+
});
|
|
38093
|
+
return;
|
|
38094
|
+
case 'load-context':
|
|
38095
|
+
// Mark the hash as attempted BEFORE firing the load so a
|
|
38096
|
+
// re-fire of this effect (state.filteredCommits change while
|
|
38097
|
+
// the load is in flight) doesn't kick off a duplicate
|
|
38098
|
+
// request. The resolver sees the hash in the set and
|
|
38099
|
+
// returns `noop` until the load completes; on completion the
|
|
38100
|
+
// appendRows triggers a final re-fire that either jumps or
|
|
38101
|
+
// returns `unreachable`.
|
|
38102
|
+
attemptedContextHashesRef.current.add(decision.target.hash);
|
|
38103
|
+
void loadCommitContextRef.current?.(decision.target);
|
|
38104
|
+
return;
|
|
38105
|
+
case 'unreachable':
|
|
38106
|
+
dispatch({
|
|
38107
|
+
type: 'setStatus',
|
|
38108
|
+
value: `${decision.target.label} target commit is unreachable — not in any walked ref's history.`,
|
|
38109
|
+
kind: 'warning',
|
|
38110
|
+
});
|
|
38111
|
+
return;
|
|
37157
38112
|
}
|
|
37158
38113
|
}, [
|
|
37159
|
-
dispatch, context.branches, context.tags,
|
|
38114
|
+
dispatch, context.branches, context.tags, context.stashes,
|
|
37160
38115
|
state.activeView, state.focus, state.sidebarTab,
|
|
37161
|
-
state.selectedBranchIndex, state.selectedTagIndex,
|
|
38116
|
+
state.selectedBranchIndex, state.selectedTagIndex, state.selectedStashIndex,
|
|
37162
38117
|
state.branchSort, state.tagSort, state.filter,
|
|
37163
38118
|
state.filteredCommits,
|
|
37164
38119
|
]);
|
|
37165
38120
|
// Reset the dedup ref when the user moves focus away from the
|
|
37166
|
-
// sidebar branches / tags tab so re-entering re-fires the
|
|
37167
|
-
// even if the cursored
|
|
38121
|
+
// sidebar branches / tags / stashes tab so re-entering re-fires the
|
|
38122
|
+
// sync even if the cursored row is the same as before.
|
|
37168
38123
|
React.useEffect(() => {
|
|
37169
38124
|
const onBranchTab = state.activeView === 'branches' ||
|
|
37170
38125
|
(state.focus === 'sidebar' && state.sidebarTab === 'branches');
|
|
37171
38126
|
const onTagTab = state.activeView === 'tags' ||
|
|
37172
38127
|
(state.focus === 'sidebar' && state.sidebarTab === 'tags');
|
|
37173
|
-
|
|
38128
|
+
const onStashTab = state.activeView === 'stash' ||
|
|
38129
|
+
(state.focus === 'sidebar' && state.sidebarTab === 'stashes');
|
|
38130
|
+
if (!onBranchTab && !onTagTab && !onStashTab) {
|
|
37174
38131
|
lastSyncedHashRef.current = undefined;
|
|
38132
|
+
// Drop any context-load attempt tracking too. If the user
|
|
38133
|
+
// navigates back later we want to retry rather than show
|
|
38134
|
+
// "unreachable" based on a stale attempted-set.
|
|
38135
|
+
attemptedContextHashesRef.current = new Set();
|
|
37175
38136
|
}
|
|
37176
38137
|
}, [state.activeView, state.focus, state.sidebarTab]);
|
|
37177
38138
|
React.useEffect(() => {
|
|
@@ -37202,7 +38163,7 @@ function LogInkApp(deps) {
|
|
|
37202
38163
|
]);
|
|
37203
38164
|
const toggleSelectedFileStage = React.useCallback(async () => {
|
|
37204
38165
|
if (!selectedWorktreeFile) {
|
|
37205
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38166
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37206
38167
|
return;
|
|
37207
38168
|
}
|
|
37208
38169
|
dispatch({ type: 'setStatus', value: 'updating file stage state' });
|
|
@@ -37217,7 +38178,7 @@ function LogInkApp(deps) {
|
|
|
37217
38178
|
const toggleSelectedHunkStage = React.useCallback(async () => {
|
|
37218
38179
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37219
38180
|
if (!selectedHunk) {
|
|
37220
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38181
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37221
38182
|
return;
|
|
37222
38183
|
}
|
|
37223
38184
|
dispatch({ type: 'setStatus', value: 'updating hunk stage state' });
|
|
@@ -37231,6 +38192,7 @@ function LogInkApp(deps) {
|
|
|
37231
38192
|
dispatch({
|
|
37232
38193
|
type: 'setStatus',
|
|
37233
38194
|
value: `${selectedHunk.state === 'staged' ? 'Unstaged' : 'Staged'} hunk`,
|
|
38195
|
+
kind: 'success',
|
|
37234
38196
|
});
|
|
37235
38197
|
await refreshWorktreeContext();
|
|
37236
38198
|
setWorktreeDiff(undefined);
|
|
@@ -37240,12 +38202,13 @@ function LogInkApp(deps) {
|
|
|
37240
38202
|
dispatch({
|
|
37241
38203
|
type: 'setStatus',
|
|
37242
38204
|
value: error.message || 'failed to update hunk stage state',
|
|
38205
|
+
kind: 'error',
|
|
37243
38206
|
});
|
|
37244
38207
|
}
|
|
37245
38208
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37246
38209
|
const revertSelectedFile = React.useCallback(async () => {
|
|
37247
38210
|
if (!selectedWorktreeFile) {
|
|
37248
|
-
dispatch({ type: 'setStatus', value: 'no worktree file selected' });
|
|
38211
|
+
dispatch({ type: 'setStatus', value: 'no worktree file selected', kind: 'warning' });
|
|
37249
38212
|
return;
|
|
37250
38213
|
}
|
|
37251
38214
|
dispatch({ type: 'setStatus', value: 'reverting selected file' });
|
|
@@ -37258,13 +38221,13 @@ function LogInkApp(deps) {
|
|
|
37258
38221
|
const revertSelectedHunk = React.useCallback(async () => {
|
|
37259
38222
|
const selectedHunk = worktreeHunks?.hunks[state.selectedWorktreeHunkIndex];
|
|
37260
38223
|
if (!selectedHunk) {
|
|
37261
|
-
dispatch({ type: 'setStatus', value: 'no hunk selected' });
|
|
38224
|
+
dispatch({ type: 'setStatus', value: 'no hunk selected', kind: 'warning' });
|
|
37262
38225
|
return;
|
|
37263
38226
|
}
|
|
37264
38227
|
dispatch({ type: 'setStatus', value: 'reverting selected hunk' });
|
|
37265
38228
|
try {
|
|
37266
38229
|
await revertHunk(git, selectedHunk);
|
|
37267
|
-
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}
|
|
38230
|
+
dispatch({ type: 'setStatus', value: `Reverted hunk in ${selectedHunk.filePath}`, kind: 'success' });
|
|
37268
38231
|
await refreshWorktreeContext();
|
|
37269
38232
|
setWorktreeDiff(undefined);
|
|
37270
38233
|
setWorktreeHunks(undefined);
|
|
@@ -37273,13 +38236,14 @@ function LogInkApp(deps) {
|
|
|
37273
38236
|
dispatch({
|
|
37274
38237
|
type: 'setStatus',
|
|
37275
38238
|
value: error.message || 'failed to revert hunk',
|
|
38239
|
+
kind: 'error',
|
|
37276
38240
|
});
|
|
37277
38241
|
}
|
|
37278
38242
|
}, [dispatch, git, refreshWorktreeContext, state.selectedWorktreeHunkIndex, worktreeHunks]);
|
|
37279
38243
|
const createCommitFromCompose = React.useCallback(async () => {
|
|
37280
38244
|
const stagedCount = context.worktree?.stagedCount || 0;
|
|
37281
38245
|
if (!stagedCount) {
|
|
37282
|
-
dispatch({ type: 'setStatus', value: 'stage changes before committing' });
|
|
38246
|
+
dispatch({ type: 'setStatus', value: 'stage changes before committing', kind: 'warning' });
|
|
37283
38247
|
return;
|
|
37284
38248
|
}
|
|
37285
38249
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: true } });
|
|
@@ -37343,6 +38307,12 @@ function LogInkApp(deps) {
|
|
|
37343
38307
|
git,
|
|
37344
38308
|
signal: controller.signal,
|
|
37345
38309
|
onStreamChunk: (_text, accumulated) => {
|
|
38310
|
+
// Audit finding #4: skip dispatching into a torn-down
|
|
38311
|
+
// tree. If the user quit (or otherwise unmounted the
|
|
38312
|
+
// workstation) mid-stream, React warns about updates on
|
|
38313
|
+
// an unmounted component. Drop the chunk silently.
|
|
38314
|
+
if (!mountedRef.current)
|
|
38315
|
+
return;
|
|
37346
38316
|
// Dispatch the full accumulated text — the preview chrome
|
|
37347
38317
|
// helper does the last-N-lines slicing at render time, so
|
|
37348
38318
|
// re-doing the slice here would be wasted work. Per-chunk
|
|
@@ -37354,18 +38324,23 @@ function LogInkApp(deps) {
|
|
|
37354
38324
|
});
|
|
37355
38325
|
},
|
|
37356
38326
|
});
|
|
38327
|
+
// Audit finding #4 (unmount race): bail out before any
|
|
38328
|
+
// post-await dispatch if the user quit while the LLM call was
|
|
38329
|
+
// in flight. Same pattern as `refreshHistoryRows` upstream.
|
|
38330
|
+
if (!mountedRef.current)
|
|
38331
|
+
return;
|
|
37357
38332
|
// Cancel path (#881 phase 3). User pressed Esc during the
|
|
37358
38333
|
// stream; reducer drops loading + preview, status line shows
|
|
37359
38334
|
// a neutral "cancelled" message. Skip the result / failure
|
|
37360
38335
|
// dispatches because the user already knows what happened.
|
|
37361
38336
|
if (result.cancelled) {
|
|
37362
38337
|
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
37363
|
-
dispatch({ type: 'setStatus', value: 'AI draft cancelled.' });
|
|
38338
|
+
dispatch({ type: 'setStatus', value: 'AI draft cancelled.', kind: 'info' });
|
|
37364
38339
|
return;
|
|
37365
38340
|
}
|
|
37366
38341
|
if (result.ok && result.draft) {
|
|
37367
38342
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: result.draft } });
|
|
37368
|
-
dispatch({ type: 'setStatus', value: 'AI draft ready for editing' });
|
|
38343
|
+
dispatch({ type: 'setStatus', value: 'AI draft ready for editing', kind: 'success' });
|
|
37369
38344
|
return;
|
|
37370
38345
|
}
|
|
37371
38346
|
dispatch({
|
|
@@ -37374,6 +38349,23 @@ function LogInkApp(deps) {
|
|
|
37374
38349
|
});
|
|
37375
38350
|
dispatch({ type: 'setStatus', value: result.message });
|
|
37376
38351
|
}
|
|
38352
|
+
catch (error) {
|
|
38353
|
+
// Audit finding #3: defensive recovery for unexpected throws
|
|
38354
|
+
// from the workflow. The workflow catches its own errors
|
|
38355
|
+
// today, so this catch is latent — but any future refactor
|
|
38356
|
+
// that lets an error escape would otherwise strand the
|
|
38357
|
+
// spinner permanently with no user-facing recovery short of
|
|
38358
|
+
// quitting. Surface a generic failure and clear the loading
|
|
38359
|
+
// state so the user can re-try.
|
|
38360
|
+
if (mountedRef.current) {
|
|
38361
|
+
dispatch({ type: 'commitCompose', action: { type: 'setLoading', value: false } });
|
|
38362
|
+
dispatch({
|
|
38363
|
+
type: 'setStatus',
|
|
38364
|
+
value: `AI draft failed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
38365
|
+
kind: 'error',
|
|
38366
|
+
});
|
|
38367
|
+
}
|
|
38368
|
+
}
|
|
37377
38369
|
finally {
|
|
37378
38370
|
// Clear the ref only if it still points at OUR controller — a
|
|
37379
38371
|
// rapid second invocation could have already replaced it, in
|
|
@@ -37432,7 +38424,7 @@ function LogInkApp(deps) {
|
|
|
37432
38424
|
const startCreatePullRequest = React.useCallback(async () => {
|
|
37433
38425
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37434
38426
|
if (!head) {
|
|
37435
|
-
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.' });
|
|
38427
|
+
dispatch({ type: 'setStatus', value: 'No current branch to create a PR from.', kind: 'warning' });
|
|
37436
38428
|
return;
|
|
37437
38429
|
}
|
|
37438
38430
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37440,11 +38432,12 @@ function LogInkApp(deps) {
|
|
|
37440
38432
|
dispatch({
|
|
37441
38433
|
type: 'setStatus',
|
|
37442
38434
|
value: 'No default branch detected. Set origin/HEAD or ensure main/master exists locally.',
|
|
38435
|
+
kind: 'warning',
|
|
37443
38436
|
});
|
|
37444
38437
|
return;
|
|
37445
38438
|
}
|
|
37446
38439
|
if (head === defaultBranch) {
|
|
37447
|
-
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first
|
|
38440
|
+
dispatch({ type: 'setStatus', value: `Current branch is ${defaultBranch}; check out a feature branch first.`, kind: 'warning' });
|
|
37448
38441
|
return;
|
|
37449
38442
|
}
|
|
37450
38443
|
if (context.pullRequest?.currentPullRequest || context.provider?.currentPullRequest) {
|
|
@@ -37454,6 +38447,7 @@ function LogInkApp(deps) {
|
|
|
37454
38447
|
value: existing
|
|
37455
38448
|
? `PR #${existing.number} already open for ${head}. Use the PR view to manage it.`
|
|
37456
38449
|
: `A pull request is already open for ${head}.`,
|
|
38450
|
+
kind: 'warning',
|
|
37457
38451
|
});
|
|
37458
38452
|
return;
|
|
37459
38453
|
}
|
|
@@ -37465,9 +38459,14 @@ function LogInkApp(deps) {
|
|
|
37465
38459
|
const cancelHandle = { cancelled: false };
|
|
37466
38460
|
pullRequestBodyCancelRef.current = cancelHandle;
|
|
37467
38461
|
dispatch({ type: 'setPendingPullRequestBodyDraft', value: true });
|
|
38462
|
+
// Audit finding #6: soft cancel today — Esc skips opening the
|
|
38463
|
+
// follow-up prompt, but the LLM call itself keeps running to
|
|
38464
|
+
// completion (no AbortSignal threaded through the changelog CLI
|
|
38465
|
+
// chain). Status copy reflects that honestly so the user isn't
|
|
38466
|
+
// misled into thinking they're saving tokens.
|
|
37468
38467
|
dispatch({
|
|
37469
38468
|
type: 'setStatus',
|
|
37470
|
-
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to
|
|
38469
|
+
value: `generating PR body from changelog (vs ${defaultBranch}) — Esc to skip prompt`,
|
|
37471
38470
|
loading: true,
|
|
37472
38471
|
});
|
|
37473
38472
|
try {
|
|
@@ -37489,11 +38488,20 @@ function LogInkApp(deps) {
|
|
|
37489
38488
|
const initialBody = body.body || '';
|
|
37490
38489
|
const initial = initialBody ? `${initialTitle}\n\n${initialBody}` : initialTitle;
|
|
37491
38490
|
if (!body.ok) {
|
|
37492
|
-
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually
|
|
38491
|
+
dispatch({ type: 'setStatus', value: `PR body generation failed: ${body.message}. Edit manually.`, kind: 'error' });
|
|
37493
38492
|
}
|
|
37494
38493
|
else {
|
|
37495
|
-
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.' });
|
|
37496
|
-
}
|
|
38494
|
+
dispatch({ type: 'setStatus', value: 'PR body drafted — review and Ctrl+D to submit.', kind: 'success' });
|
|
38495
|
+
}
|
|
38496
|
+
// Audit finding #11: clear the pending flag BEFORE opening the
|
|
38497
|
+
// prompt. If a future refactor adds an `await` between the flag
|
|
38498
|
+
// clear (currently in `finally`) and the `openInputPrompt`
|
|
38499
|
+
// dispatch, an Esc keystroke in the gap would dispatch
|
|
38500
|
+
// `cancelPullRequestBodyDraft` AFTER the prompt opens, leaving
|
|
38501
|
+
// the prompt visible with a stale "cancelled" message. Clearing
|
|
38502
|
+
// here moves the flag teardown into the same React batch as the
|
|
38503
|
+
// prompt open, eliminating the race.
|
|
38504
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37497
38505
|
dispatch({
|
|
37498
38506
|
type: 'openInputPrompt',
|
|
37499
38507
|
kind: 'create-pr',
|
|
@@ -37503,11 +38511,14 @@ function LogInkApp(deps) {
|
|
|
37503
38511
|
});
|
|
37504
38512
|
}
|
|
37505
38513
|
finally {
|
|
37506
|
-
//
|
|
38514
|
+
// Belt-and-suspenders: the `try` block clears the flag on the
|
|
38515
|
+
// success path (audit finding #11). This duplicate clear handles
|
|
38516
|
+
// the error / cancel paths where the early-returns skip the
|
|
38517
|
+
// success-path dispatch. Safe to no-op when already false.
|
|
38518
|
+
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37507
38519
|
// Only clear the ref if we still own it — a second invocation
|
|
37508
38520
|
// would have already taken ownership in which case the cancel
|
|
37509
38521
|
// duty has rolled over.
|
|
37510
|
-
dispatch({ type: 'setPendingPullRequestBodyDraft', value: false });
|
|
37511
38522
|
if (pullRequestBodyCancelRef.current === cancelHandle) {
|
|
37512
38523
|
pullRequestBodyCancelRef.current = null;
|
|
37513
38524
|
}
|
|
@@ -37547,17 +38558,18 @@ function LogInkApp(deps) {
|
|
|
37547
38558
|
const yankText = React.useCallback(async (value, label) => {
|
|
37548
38559
|
const clipboard = clipboardRunner || defaultClipboardRunner;
|
|
37549
38560
|
if (!value) {
|
|
37550
|
-
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty
|
|
38561
|
+
dispatch({ type: 'setStatus', value: `Nothing to copy — ${label} is empty.`, kind: 'warning' });
|
|
37551
38562
|
return;
|
|
37552
38563
|
}
|
|
37553
38564
|
try {
|
|
37554
38565
|
await clipboard(value);
|
|
37555
|
-
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard
|
|
38566
|
+
dispatch({ type: 'setStatus', value: `Copied ${label} to clipboard.`, kind: 'success' });
|
|
37556
38567
|
}
|
|
37557
38568
|
catch (error) {
|
|
37558
38569
|
dispatch({
|
|
37559
38570
|
type: 'setStatus',
|
|
37560
38571
|
value: `Copy failed (${label}): ${error.message}`,
|
|
38572
|
+
kind: 'error',
|
|
37561
38573
|
});
|
|
37562
38574
|
}
|
|
37563
38575
|
}, [clipboardRunner, dispatch]);
|
|
@@ -37581,7 +38593,7 @@ function LogInkApp(deps) {
|
|
|
37581
38593
|
const startChangelogView = React.useCallback(async (options = {}) => {
|
|
37582
38594
|
const head = context.branches?.currentBranch || context.provider?.currentBranch;
|
|
37583
38595
|
if (!head) {
|
|
37584
|
-
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.' });
|
|
38596
|
+
dispatch({ type: 'setStatus', value: 'No current branch — check out a branch first.', kind: 'warning' });
|
|
37585
38597
|
return;
|
|
37586
38598
|
}
|
|
37587
38599
|
const defaultBranch = context.provider?.repository.defaultBranch;
|
|
@@ -37608,6 +38620,11 @@ function LogInkApp(deps) {
|
|
|
37608
38620
|
branch: head,
|
|
37609
38621
|
baseLabel: cached.baseLabel,
|
|
37610
38622
|
text: cached.text,
|
|
38623
|
+
// Audit finding #9: cache-hit path preserves the original
|
|
38624
|
+
// generation timestamp rather than minting a fresh one — the
|
|
38625
|
+
// "X ago" header should reflect when the LLM ran, not when
|
|
38626
|
+
// the cached entry was re-displayed.
|
|
38627
|
+
generatedAt: cached.generatedAt,
|
|
37611
38628
|
});
|
|
37612
38629
|
dispatch({
|
|
37613
38630
|
type: 'setStatus',
|
|
@@ -37628,7 +38645,7 @@ function LogInkApp(deps) {
|
|
|
37628
38645
|
baseLabel,
|
|
37629
38646
|
error: result.message,
|
|
37630
38647
|
});
|
|
37631
|
-
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}
|
|
38648
|
+
dispatch({ type: 'setStatus', value: `Changelog failed: ${result.message}`, kind: 'error' });
|
|
37632
38649
|
return;
|
|
37633
38650
|
}
|
|
37634
38651
|
dispatch({
|
|
@@ -37636,10 +38653,14 @@ function LogInkApp(deps) {
|
|
|
37636
38653
|
branch: head,
|
|
37637
38654
|
baseLabel,
|
|
37638
38655
|
text: result.text,
|
|
38656
|
+
// Audit finding #9: timestamp captured at dispatch time, not
|
|
38657
|
+
// inside the reducer.
|
|
38658
|
+
generatedAt: Date.now(),
|
|
37639
38659
|
});
|
|
37640
38660
|
dispatch({
|
|
37641
38661
|
type: 'setStatus',
|
|
37642
38662
|
value: 'Changelog ready — y yank · E $EDITOR · c PR · r regen · < back.',
|
|
38663
|
+
kind: 'success',
|
|
37643
38664
|
});
|
|
37644
38665
|
}, [
|
|
37645
38666
|
context.branches?.currentBranch,
|
|
@@ -37660,7 +38681,7 @@ function LogInkApp(deps) {
|
|
|
37660
38681
|
const yankChangelog = React.useCallback(() => {
|
|
37661
38682
|
const text = state.changelogView.text;
|
|
37662
38683
|
if (!text) {
|
|
37663
|
-
dispatch({ type: 'setStatus', value: 'No changelog text to copy.' });
|
|
38684
|
+
dispatch({ type: 'setStatus', value: 'No changelog text to copy.', kind: 'warning' });
|
|
37664
38685
|
return;
|
|
37665
38686
|
}
|
|
37666
38687
|
void yankText(text, 'changelog');
|
|
@@ -37673,7 +38694,7 @@ function LogInkApp(deps) {
|
|
|
37673
38694
|
const openChangelogInEditor = React.useCallback(() => {
|
|
37674
38695
|
const current = state.changelogView.text;
|
|
37675
38696
|
if (current === undefined) {
|
|
37676
|
-
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.' });
|
|
38697
|
+
dispatch({ type: 'setStatus', value: 'Changelog not loaded yet — wait for generation.', kind: 'warning' });
|
|
37677
38698
|
return;
|
|
37678
38699
|
}
|
|
37679
38700
|
let dir;
|
|
@@ -37684,6 +38705,7 @@ function LogInkApp(deps) {
|
|
|
37684
38705
|
dispatch({
|
|
37685
38706
|
type: 'setStatus',
|
|
37686
38707
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38708
|
+
kind: 'error',
|
|
37687
38709
|
});
|
|
37688
38710
|
return;
|
|
37689
38711
|
}
|
|
@@ -37695,6 +38717,7 @@ function LogInkApp(deps) {
|
|
|
37695
38717
|
dispatch({
|
|
37696
38718
|
type: 'setStatus',
|
|
37697
38719
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38720
|
+
kind: 'error',
|
|
37698
38721
|
});
|
|
37699
38722
|
try {
|
|
37700
38723
|
fs$1.rmSync(dir, { recursive: true, force: true });
|
|
@@ -37718,13 +38741,13 @@ function LogInkApp(deps) {
|
|
|
37718
38741
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37719
38742
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37720
38743
|
if (result.error) {
|
|
37721
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38744
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37722
38745
|
}
|
|
37723
38746
|
else if (result.signal) {
|
|
37724
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38747
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37725
38748
|
}
|
|
37726
38749
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37727
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38750
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37728
38751
|
}
|
|
37729
38752
|
else {
|
|
37730
38753
|
editorOk = true;
|
|
@@ -37738,13 +38761,14 @@ function LogInkApp(deps) {
|
|
|
37738
38761
|
if (editorOk) {
|
|
37739
38762
|
try {
|
|
37740
38763
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
37741
|
-
dispatch({ type: 'setChangelogText', text: content });
|
|
37742
|
-
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.' });
|
|
38764
|
+
dispatch({ type: 'setChangelogText', text: content, generatedAt: Date.now() });
|
|
38765
|
+
dispatch({ type: 'setStatus', value: 'Changelog updated from editor.', kind: 'success' });
|
|
37743
38766
|
}
|
|
37744
38767
|
catch (error) {
|
|
37745
38768
|
dispatch({
|
|
37746
38769
|
type: 'setStatus',
|
|
37747
38770
|
value: `Failed to read back edited changelog: ${error.message}`,
|
|
38771
|
+
kind: 'error',
|
|
37748
38772
|
});
|
|
37749
38773
|
}
|
|
37750
38774
|
}
|
|
@@ -37785,19 +38809,19 @@ function LogInkApp(deps) {
|
|
|
37785
38809
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37786
38810
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, path], { stdio: 'inherit' });
|
|
37787
38811
|
if (result.error) {
|
|
37788
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38812
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37789
38813
|
}
|
|
37790
38814
|
else if (result.signal) {
|
|
37791
38815
|
// Editor was killed by a signal (e.g. ^C, SIGTERM). status is
|
|
37792
38816
|
// null in this case, so the old `status !== 0` check would
|
|
37793
38817
|
// mistakenly fall through to the success branch.
|
|
37794
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38818
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37795
38819
|
}
|
|
37796
38820
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37797
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38821
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37798
38822
|
}
|
|
37799
38823
|
else {
|
|
37800
|
-
dispatch({ type: 'setStatus', value: `Edited ${path}
|
|
38824
|
+
dispatch({ type: 'setStatus', value: `Edited ${path}`, kind: 'success' });
|
|
37801
38825
|
}
|
|
37802
38826
|
}
|
|
37803
38827
|
finally {
|
|
@@ -37844,6 +38868,7 @@ function LogInkApp(deps) {
|
|
|
37844
38868
|
dispatch({
|
|
37845
38869
|
type: 'setStatus',
|
|
37846
38870
|
value: `Failed to create temp file for editor: ${error.message}`,
|
|
38871
|
+
kind: 'error',
|
|
37847
38872
|
});
|
|
37848
38873
|
return;
|
|
37849
38874
|
}
|
|
@@ -37855,6 +38880,7 @@ function LogInkApp(deps) {
|
|
|
37855
38880
|
dispatch({
|
|
37856
38881
|
type: 'setStatus',
|
|
37857
38882
|
value: `Failed to seed temp file: ${error.message}`,
|
|
38883
|
+
kind: 'error',
|
|
37858
38884
|
});
|
|
37859
38885
|
try {
|
|
37860
38886
|
fs$1.rmSync(dir, { recursive: true, force: true });
|
|
@@ -37878,13 +38904,13 @@ function LogInkApp(deps) {
|
|
|
37878
38904
|
out.write(`${SHOW_CURSOR}${EXIT_ALT}`);
|
|
37879
38905
|
const result = node_child_process.spawnSync(editor, [...editorPrefixArgs, file], { stdio: 'inherit' });
|
|
37880
38906
|
if (result.error) {
|
|
37881
|
-
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}
|
|
38907
|
+
dispatch({ type: 'setStatus', value: `Failed to launch ${editor}: ${result.error.message}`, kind: 'error' });
|
|
37882
38908
|
}
|
|
37883
38909
|
else if (result.signal) {
|
|
37884
|
-
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}
|
|
38910
|
+
dispatch({ type: 'setStatus', value: `${editor} interrupted by ${result.signal}`, kind: 'warning' });
|
|
37885
38911
|
}
|
|
37886
38912
|
else if (typeof result.status === 'number' && result.status !== 0) {
|
|
37887
|
-
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}
|
|
38913
|
+
dispatch({ type: 'setStatus', value: `${editor} exited with status ${result.status}`, kind: 'warning' });
|
|
37888
38914
|
}
|
|
37889
38915
|
else {
|
|
37890
38916
|
editorOk = true;
|
|
@@ -37903,12 +38929,13 @@ function LogInkApp(deps) {
|
|
|
37903
38929
|
try {
|
|
37904
38930
|
const content = fs$1.readFileSync(file, 'utf8');
|
|
37905
38931
|
dispatch({ type: 'commitCompose', action: { type: 'setDraft', value: content } });
|
|
37906
|
-
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.' });
|
|
38932
|
+
dispatch({ type: 'setStatus', value: 'Commit draft updated from editor.', kind: 'success' });
|
|
37907
38933
|
}
|
|
37908
38934
|
catch (error) {
|
|
37909
38935
|
dispatch({
|
|
37910
38936
|
type: 'setStatus',
|
|
37911
38937
|
value: `Failed to read back edited draft: ${error.message}`,
|
|
38938
|
+
kind: 'error',
|
|
37912
38939
|
});
|
|
37913
38940
|
}
|
|
37914
38941
|
}
|
|
@@ -37984,7 +39011,7 @@ function LogInkApp(deps) {
|
|
|
37984
39011
|
const applyCommitSplit = React.useCallback(async () => {
|
|
37985
39012
|
const splitPlan = state.splitPlan;
|
|
37986
39013
|
if (!splitPlan?.plan || !splitPlan.planContext) {
|
|
37987
|
-
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.' });
|
|
39014
|
+
dispatch({ type: 'setStatus', value: 'No split plan loaded yet — wait for generation.', kind: 'warning' });
|
|
37988
39015
|
return;
|
|
37989
39016
|
}
|
|
37990
39017
|
// Diagnostic dump for the silent-failure bug surfaced in #944
|
|
@@ -38093,7 +39120,8 @@ function LogInkApp(deps) {
|
|
|
38093
39120
|
// that could disagree with reality on partial-apply.
|
|
38094
39121
|
const commitHashes = result.commitHashes || [];
|
|
38095
39122
|
if (commitHashes.length > 0) {
|
|
38096
|
-
|
|
39123
|
+
// Audit finding #9: timestamp captured at dispatch time.
|
|
39124
|
+
dispatch({ type: 'markRecentCommits', hashes: commitHashes, markedAt: Date.now() });
|
|
38097
39125
|
// DevSkim: ignore DS172411 — function literal, fixed delay,
|
|
38098
39126
|
// no caller-supplied data flowing through.
|
|
38099
39127
|
setTimeout(() => dispatch({ type: 'clearRecentCommits' }), 5000);
|
|
@@ -38954,7 +39982,7 @@ function LogInkApp(deps) {
|
|
|
38954
39982
|
};
|
|
38955
39983
|
const handler = handlers[id];
|
|
38956
39984
|
if (!handler) {
|
|
38957
|
-
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired
|
|
39985
|
+
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
38958
39986
|
return;
|
|
38959
39987
|
}
|
|
38960
39988
|
const result = await handler();
|
|
@@ -38999,7 +40027,37 @@ function LogInkApp(deps) {
|
|
|
38999
40027
|
// without flickering the surfaces through a 'loading' phase.
|
|
39000
40028
|
await refreshContext({ silent: true });
|
|
39001
40029
|
}
|
|
39002
|
-
|
|
40030
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
40031
|
+
//
|
|
40032
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
40033
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
40034
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
40035
|
+
// jump them to history view (where the worktree counts in the
|
|
40036
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
40037
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
40038
|
+
//
|
|
40039
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
40040
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
40041
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
40042
|
+
// affect untracked files when the stash held `-u` state), and
|
|
40043
|
+
// surface the new stash count on the status line so there's
|
|
40044
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
40045
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
40046
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
40047
|
+
await refreshWorktreeContext();
|
|
40048
|
+
}
|
|
40049
|
+
if (result?.ok && id === 'drop-stash') {
|
|
40050
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
40051
|
+
// untracked-file state that's now collected.
|
|
40052
|
+
await refreshWorktreeContext();
|
|
40053
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
40054
|
+
// reading the count back here would be stale because closures
|
|
40055
|
+
// capture the pre-refresh value. Status message stays generic
|
|
40056
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
40057
|
+
// unambiguous signal that the operation landed.
|
|
40058
|
+
}
|
|
40059
|
+
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
40060
|
+
state.branchSort, state.filter, state.selectedBranchIndex,
|
|
39003
40061
|
state.selectedStashIndex, state.selectedTagIndex, state.selectedWorktreeListIndex, state.stashDiffRef,
|
|
39004
40062
|
state.statusFilterMask, state.tagSort]);
|
|
39005
40063
|
// Resolve the active view's "yank target" (commit hash / branch /
|
|
@@ -39161,15 +40219,15 @@ function LogInkApp(deps) {
|
|
|
39161
40219
|
}
|
|
39162
40220
|
}
|
|
39163
40221
|
if (!value || !label) {
|
|
39164
|
-
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view' });
|
|
40222
|
+
dispatch({ type: 'setStatus', value: 'Nothing to yank in this view', kind: 'warning' });
|
|
39165
40223
|
return;
|
|
39166
40224
|
}
|
|
39167
40225
|
try {
|
|
39168
40226
|
await clipboard(value);
|
|
39169
|
-
dispatch({ type: 'setStatus', value: `Copied ${label}
|
|
40227
|
+
dispatch({ type: 'setStatus', value: `Copied ${label}`, kind: 'success' });
|
|
39170
40228
|
}
|
|
39171
40229
|
catch (error) {
|
|
39172
|
-
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}
|
|
40230
|
+
dispatch({ type: 'setStatus', value: `Copy failed: ${error.message}`, kind: 'error' });
|
|
39173
40231
|
}
|
|
39174
40232
|
}, [
|
|
39175
40233
|
clipboardRunner,
|
|
@@ -39229,63 +40287,175 @@ function LogInkApp(deps) {
|
|
|
39229
40287
|
React.useEffect(() => {
|
|
39230
40288
|
loadingMoreCommitsRef.current = loadingMoreCommits;
|
|
39231
40289
|
}, [loadingMoreCommits]);
|
|
40290
|
+
// STABLE useCallback (empty deps) for loadMoreCommits. The function
|
|
40291
|
+
// reads the volatile state (commit counts, fetch args, hasMore) via
|
|
40292
|
+
// refs that update on every render so the identity stays constant.
|
|
40293
|
+
//
|
|
40294
|
+
// Why stable matters: the cursor-syncs-history auto-load chain
|
|
40295
|
+
// calls this through a forward-reference ref (loadMoreCommitsRef).
|
|
40296
|
+
// If loadMoreCommits regenerated on every render — as the previous
|
|
40297
|
+
// implementation did via state deps — there was a render-order
|
|
40298
|
+
// race: the cursor sync effect would call the PREVIOUS render's
|
|
40299
|
+
// callback (still in the ref because the ref-setter useEffect runs
|
|
40300
|
+
// after the cursor-sync effect in declaration order), which had
|
|
40301
|
+
// captured a stale `state.commits.length` and re-fetched the same
|
|
40302
|
+
// window. The auto-load chain appeared to fire but never advanced
|
|
40303
|
+
// through history.
|
|
40304
|
+
//
|
|
40305
|
+
// Stable identity + refs sidesteps the race entirely: the function
|
|
40306
|
+
// never changes, and every call reads the latest state.
|
|
40307
|
+
const loadMoreStateRef = React.useRef({
|
|
40308
|
+
commitsLength: state.commits.length,
|
|
40309
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40310
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40311
|
+
hasMoreCommits,
|
|
40312
|
+
logArgv,
|
|
40313
|
+
});
|
|
40314
|
+
loadMoreStateRef.current = {
|
|
40315
|
+
commitsLength: state.commits.length,
|
|
40316
|
+
filteredCommitsLength: state.filteredCommits.length,
|
|
40317
|
+
historyFetchArgs: state.historyFetchArgs,
|
|
40318
|
+
hasMoreCommits,
|
|
40319
|
+
logArgv,
|
|
40320
|
+
};
|
|
40321
|
+
const loadMoreCommits = React.useCallback(async (options = {}) => {
|
|
40322
|
+
const snap = loadMoreStateRef.current;
|
|
40323
|
+
if (!snap.logArgv || snap.logArgv.limit || loadingMoreCommitsRef.current || !snap.hasMoreCommits) {
|
|
40324
|
+
return { fired: false, addedCommits: 0 };
|
|
40325
|
+
}
|
|
40326
|
+
if (snap.filteredCommitsLength === 0) {
|
|
40327
|
+
return { fired: false, addedCommits: 0 };
|
|
40328
|
+
}
|
|
40329
|
+
loadingMoreCommitsRef.current = true;
|
|
40330
|
+
const requestId = loadMoreRequestRef.current + 1;
|
|
40331
|
+
loadMoreRequestRef.current = requestId;
|
|
40332
|
+
setLoadingMoreCommits(true);
|
|
40333
|
+
dispatch({
|
|
40334
|
+
type: 'setStatus',
|
|
40335
|
+
value: options.statusMessage || 'loading older commits',
|
|
40336
|
+
loading: true,
|
|
40337
|
+
});
|
|
40338
|
+
const fetchArgs = snap.historyFetchArgs;
|
|
40339
|
+
const mergedArgv = {
|
|
40340
|
+
...snap.logArgv,
|
|
40341
|
+
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
40342
|
+
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
40343
|
+
};
|
|
40344
|
+
// Load-more paths a fresh page from git AFTER what's already
|
|
40345
|
+
// loaded; pass the stash hashes again so the additional rows
|
|
40346
|
+
// stay graph-consistent with the boot fetch (a window that
|
|
40347
|
+
// dropped stashes mid-stream would render with broken junctions).
|
|
40348
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40349
|
+
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
40350
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40351
|
+
skip: snap.commitsLength,
|
|
40352
|
+
extraRefs: stashHashes,
|
|
40353
|
+
}));
|
|
40354
|
+
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
40355
|
+
return { fired: false, addedCommits: 0 };
|
|
40356
|
+
}
|
|
40357
|
+
loadingMoreCommitsRef.current = false;
|
|
40358
|
+
setLoadingMoreCommits(false);
|
|
40359
|
+
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
40360
|
+
if (!nextRows) {
|
|
40361
|
+
dispatch({ type: 'setStatus', value: 'failed to load older commits', kind: 'error' });
|
|
40362
|
+
return { fired: false, addedCommits: 0 };
|
|
40363
|
+
}
|
|
40364
|
+
if (nextRows?.length) {
|
|
40365
|
+
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40366
|
+
}
|
|
40367
|
+
setHasMoreCommits(nextCommitCount >= LOG_INTERACTIVE_DEFAULT_LIMIT);
|
|
40368
|
+
return { fired: true, addedCommits: nextCommitCount };
|
|
40369
|
+
// Empty deps — the function is intentionally stable. State is
|
|
40370
|
+
// read via `loadMoreStateRef.current` at call time, and `dispatch`
|
|
40371
|
+
// / `git` / `setLoadingMoreCommits` / `setHasMoreCommits` are
|
|
40372
|
+
// already stable across renders by React's contract.
|
|
40373
|
+
}, [dispatch, git]);
|
|
40374
|
+
// Scroll-near-bottom auto-trigger. Fires when the user's cursor is
|
|
40375
|
+
// within 20 rows of the last loaded commit so older history is
|
|
40376
|
+
// already on its way by the time they reach the bottom.
|
|
39232
40377
|
React.useEffect(() => {
|
|
39233
40378
|
const remaining = state.filteredCommits.length - state.selectedIndex - 1;
|
|
39234
|
-
|
|
39235
|
-
|
|
39236
|
-
|
|
39237
|
-
|
|
39238
|
-
|
|
39239
|
-
|
|
39240
|
-
|
|
39241
|
-
|
|
39242
|
-
|
|
39243
|
-
|
|
39244
|
-
setLoadingMoreCommits(true);
|
|
39245
|
-
dispatch({ type: 'setStatus', value: 'loading older commits' });
|
|
39246
|
-
const fetchArgs = state.historyFetchArgs;
|
|
39247
|
-
const mergedArgv = {
|
|
39248
|
-
...logArgv,
|
|
39249
|
-
...(fetchArgs?.author ? { author: fetchArgs.author } : {}),
|
|
39250
|
-
...(fetchArgs?.path ? { path: fetchArgs.path } : {}),
|
|
39251
|
-
};
|
|
39252
|
-
const nextRows = await safe(getLogRows(git, mergedArgv, {
|
|
39253
|
-
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
39254
|
-
skip: state.commits.length,
|
|
39255
|
-
}));
|
|
39256
|
-
if (!mountedRef.current || loadMoreRequestRef.current !== requestId) {
|
|
39257
|
-
return;
|
|
39258
|
-
}
|
|
39259
|
-
loadingMoreCommitsRef.current = false;
|
|
39260
|
-
setLoadingMoreCommits(false);
|
|
39261
|
-
const nextCommitCount = nextRows ? getCommitRows(nextRows).length : 0;
|
|
39262
|
-
if (!nextRows) {
|
|
39263
|
-
dispatch({ type: 'setStatus', value: 'failed to load older commits' });
|
|
39264
|
-
return;
|
|
39265
|
-
}
|
|
39266
|
-
if (nextRows?.length) {
|
|
39267
|
-
dispatch({ type: 'appendRows', rows: nextRows });
|
|
40379
|
+
if (remaining > 20)
|
|
40380
|
+
return;
|
|
40381
|
+
void loadMoreCommits().then((result) => {
|
|
40382
|
+
if (result.fired) {
|
|
40383
|
+
dispatch({
|
|
40384
|
+
type: 'setStatus',
|
|
40385
|
+
value: result.addedCommits
|
|
40386
|
+
? `loaded ${result.addedCommits} older commits`
|
|
40387
|
+
: 'end of history',
|
|
40388
|
+
});
|
|
39268
40389
|
}
|
|
39269
|
-
|
|
39270
|
-
dispatch({
|
|
39271
|
-
type: 'setStatus',
|
|
39272
|
-
value: nextCommitCount
|
|
39273
|
-
? `loaded ${nextCommitCount} older commits`
|
|
39274
|
-
: 'end of history',
|
|
39275
|
-
});
|
|
39276
|
-
}
|
|
39277
|
-
void loadMoreCommits();
|
|
40390
|
+
});
|
|
39278
40391
|
}, [
|
|
39279
40392
|
dispatch,
|
|
39280
|
-
|
|
39281
|
-
hasMoreCommits,
|
|
39282
|
-
loadingMoreCommits,
|
|
39283
|
-
logArgv,
|
|
39284
|
-
state.commits.length,
|
|
40393
|
+
loadMoreCommits,
|
|
39285
40394
|
state.filteredCommits.length,
|
|
39286
|
-
state.historyFetchArgs,
|
|
39287
40395
|
state.selectedIndex,
|
|
39288
40396
|
]);
|
|
40397
|
+
/**
|
|
40398
|
+
* Targeted-context loader for the cursor-syncs-history effect. Called
|
|
40399
|
+
* when the resolver returns `load-context` — the user cursored a
|
|
40400
|
+
* branch / tag / stash whose target commit isn't in the loaded
|
|
40401
|
+
* window, so we run a `git log` anchored on that commit (guaranteed
|
|
40402
|
+
* to include it) and merge the result via `appendRows` (which
|
|
40403
|
+
* already deduplicates by hash).
|
|
40404
|
+
*
|
|
40405
|
+
* Stable identity (empty deps) for the same reason as
|
|
40406
|
+
* `loadMoreCommits` — the cursor-sync effect calls this through a
|
|
40407
|
+
* forward-reference ref, and a regenerating callback would
|
|
40408
|
+
* reintroduce the render-order race that bit the previous chain.
|
|
40409
|
+
* All volatile state (logArgv, mostly) is read via refs.
|
|
40410
|
+
*/
|
|
40411
|
+
const loadCommitContextStateRef = React.useRef({ logArgv });
|
|
40412
|
+
loadCommitContextStateRef.current = { logArgv };
|
|
40413
|
+
const loadCommitContext = React.useCallback(async (target) => {
|
|
40414
|
+
const snap = loadCommitContextStateRef.current;
|
|
40415
|
+
if (!snap.logArgv)
|
|
40416
|
+
return;
|
|
40417
|
+
dispatch({
|
|
40418
|
+
type: 'setStatus',
|
|
40419
|
+
value: `Loading commits around ${target.label}…`,
|
|
40420
|
+
loading: true,
|
|
40421
|
+
});
|
|
40422
|
+
try {
|
|
40423
|
+
// No stashHashes here — `getLogRowsAnchoredOn` walks only from
|
|
40424
|
+
// the target so it can guarantee the target's inclusion.
|
|
40425
|
+
// Stashes are already in the loaded graph from boot's
|
|
40426
|
+
// `loadRowsWithStashes`; `appendRows` deduplicates by hash so
|
|
40427
|
+
// the merged result keeps both views without double-counting.
|
|
40428
|
+
const rows = await getLogRowsAnchoredOn(git, snap.logArgv, target.hash, {});
|
|
40429
|
+
if (!mountedRef.current)
|
|
40430
|
+
return;
|
|
40431
|
+
if (rows.length > 0) {
|
|
40432
|
+
dispatch({ type: 'appendRows', rows });
|
|
40433
|
+
// Don't dispatch a setStatus here — the cursor-sync effect
|
|
40434
|
+
// will re-fire on the appendRows-driven filteredCommits
|
|
40435
|
+
// change and either jump (success) or report unreachable
|
|
40436
|
+
// (failure), surfacing the right message.
|
|
40437
|
+
}
|
|
40438
|
+
else {
|
|
40439
|
+
dispatch({
|
|
40440
|
+
type: 'setStatus',
|
|
40441
|
+
value: `${target.label} target commit returned no rows — orphan ref?`,
|
|
40442
|
+
kind: 'warning',
|
|
40443
|
+
});
|
|
40444
|
+
}
|
|
40445
|
+
}
|
|
40446
|
+
catch (error) {
|
|
40447
|
+
if (mountedRef.current) {
|
|
40448
|
+
dispatch({
|
|
40449
|
+
type: 'setStatus',
|
|
40450
|
+
value: `Failed to load context for ${target.label}: ${error instanceof Error ? error.message : String(error)}`,
|
|
40451
|
+
kind: 'error',
|
|
40452
|
+
});
|
|
40453
|
+
}
|
|
40454
|
+
}
|
|
40455
|
+
}, [dispatch, git]);
|
|
40456
|
+
React.useEffect(() => {
|
|
40457
|
+
loadCommitContextRef.current = loadCommitContext;
|
|
40458
|
+
}, [loadCommitContext]);
|
|
39289
40459
|
// Server-side history filter (#776). When the user submits `path:foo`
|
|
39290
40460
|
// or `author:foo`, the filter parser dispatches setHistoryFetchArgs;
|
|
39291
40461
|
// this effect picks up the change, re-runs `getLogRows` with merged
|
|
@@ -39321,12 +40491,16 @@ function LogInkApp(deps) {
|
|
|
39321
40491
|
value: description ? `Refetching with ${description}` : 'Restoring full log',
|
|
39322
40492
|
});
|
|
39323
40493
|
void (async () => {
|
|
39324
|
-
const
|
|
40494
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40495
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40496
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40497
|
+
extraRefs: stashHashes,
|
|
40498
|
+
}));
|
|
39325
40499
|
if (!mountedRef.current || historyFetchRequestRef.current !== requestId) {
|
|
39326
40500
|
return;
|
|
39327
40501
|
}
|
|
39328
40502
|
if (!nextRows) {
|
|
39329
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter' });
|
|
40503
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch with active filter', kind: 'error' });
|
|
39330
40504
|
return;
|
|
39331
40505
|
}
|
|
39332
40506
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39337,6 +40511,7 @@ function LogInkApp(deps) {
|
|
|
39337
40511
|
value: description
|
|
39338
40512
|
? `Showing ${matched} commits matching ${description}`
|
|
39339
40513
|
: 'Showing full log',
|
|
40514
|
+
kind: 'success',
|
|
39340
40515
|
});
|
|
39341
40516
|
})();
|
|
39342
40517
|
}, [dispatch, git, logArgv, state.historyFetchArgs]);
|
|
@@ -39366,12 +40541,20 @@ function LogInkApp(deps) {
|
|
|
39366
40541
|
: 'Loading compact history…',
|
|
39367
40542
|
});
|
|
39368
40543
|
void (async () => {
|
|
39369
|
-
|
|
40544
|
+
// Include stash commits as graph roots so the toggle's re-fetch
|
|
40545
|
+
// sees the same rich graph the boot loader assembles. Without
|
|
40546
|
+
// this, flipping `\` into full mode and back loses the stash
|
|
40547
|
+
// anchors that loadRowsWithStashes seeded on boot.
|
|
40548
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
40549
|
+
const nextRows = await safe(getLogRows(git, merged, {
|
|
40550
|
+
limit: LOG_INTERACTIVE_DEFAULT_LIMIT,
|
|
40551
|
+
extraRefs: stashHashes,
|
|
40552
|
+
}));
|
|
39370
40553
|
if (!mountedRef.current || toggleGraphRequestRef.current !== requestId) {
|
|
39371
40554
|
return;
|
|
39372
40555
|
}
|
|
39373
40556
|
if (!nextRows) {
|
|
39374
|
-
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows' });
|
|
40557
|
+
dispatch({ type: 'setStatus', value: 'Failed to refetch graph rows', kind: 'error' });
|
|
39375
40558
|
return;
|
|
39376
40559
|
}
|
|
39377
40560
|
dispatch({ type: 'replaceRows', rows: nextRows });
|
|
@@ -39382,6 +40565,7 @@ function LogInkApp(deps) {
|
|
|
39382
40565
|
value: state.fullGraph
|
|
39383
40566
|
? `Showing ${matched} commits across all branches`
|
|
39384
40567
|
: `Showing ${matched} commits (compact)`,
|
|
40568
|
+
kind: 'success',
|
|
39385
40569
|
});
|
|
39386
40570
|
})();
|
|
39387
40571
|
}, [dispatch, git, logArgv, state.fullGraph]);
|
|
@@ -40076,6 +41260,17 @@ function createLogArgvFromUiArgv(argv) {
|
|
|
40076
41260
|
return {
|
|
40077
41261
|
$0: argv.$0,
|
|
40078
41262
|
_: ['log'],
|
|
41263
|
+
// Pass `--all` through from the CLI. The yargs default is `true`
|
|
41264
|
+
// since 0.54.x — user feedback consistently asked for the
|
|
41265
|
+
// GitKraken-style "see all branches, tags, stashes" view as the
|
|
41266
|
+
// starting state. `coco ui --no-all` opts back to
|
|
41267
|
+
// current-branch-only.
|
|
41268
|
+
//
|
|
41269
|
+
// Note: passing `--branch foo` does NOT automatically scope away
|
|
41270
|
+
// from --all. If the user wants strictly that branch, they pass
|
|
41271
|
+
// `coco ui --branch foo --no-all`. We considered the implicit
|
|
41272
|
+
// scope-narrowing but it surprises users who pass `--branch` as
|
|
41273
|
+
// a "highlight this branch in the all-refs view" hint.
|
|
40079
41274
|
all: argv.all,
|
|
40080
41275
|
branch: argv.branch,
|
|
40081
41276
|
format: 'table',
|
|
@@ -40112,6 +41307,26 @@ function withCacheWrite(repoPath, loader) {
|
|
|
40112
41307
|
return rows;
|
|
40113
41308
|
};
|
|
40114
41309
|
}
|
|
41310
|
+
/**
|
|
41311
|
+
* Workstation-aware log loader (#1034 follow-up). Calls `git stash
|
|
41312
|
+
* list` first to collect every stash's commit hash, then passes them
|
|
41313
|
+
* as extra refs to `getLogRows` so the graph includes every stash as
|
|
41314
|
+
* a node — not just the latest (which is the only one `refs/stash`
|
|
41315
|
+
* points at and the only one `git log --all` walks).
|
|
41316
|
+
*
|
|
41317
|
+
* Without this, the stash → history cursor sync added in #1034 only
|
|
41318
|
+
* worked for `stash@{0}`; cursoring any older stash row reported
|
|
41319
|
+
* "tip not in loaded window" because that stash's commit hash was
|
|
41320
|
+
* never in the loaded graph window in the first place.
|
|
41321
|
+
*
|
|
41322
|
+
* The extra git call is cheap (one `git stash list --format=%H`,
|
|
41323
|
+
* usually sub-50ms). It's only an additive cost when stashes exist;
|
|
41324
|
+
* users on stash-free repos pay nothing.
|
|
41325
|
+
*/
|
|
41326
|
+
async function loadRowsWithStashes(git, logArgv) {
|
|
41327
|
+
const stashHashes = await getStashCommitHashes(git).catch(() => []);
|
|
41328
|
+
return getLogRows(git, logArgv, { extraRefs: stashHashes });
|
|
41329
|
+
}
|
|
40115
41330
|
async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
40116
41331
|
const config = options.config || loadConfig(logArgv);
|
|
40117
41332
|
const git = options.git || getRepo();
|
|
@@ -40130,7 +41345,7 @@ async function startCocoUiFromLogArgv(logArgv, options = {}) {
|
|
|
40130
41345
|
const initialRows = options.rows || cachedRows || [];
|
|
40131
41346
|
const loadRows = options.rows
|
|
40132
41347
|
? undefined
|
|
40133
|
-
: withCacheWrite(repoPath, () =>
|
|
41348
|
+
: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv));
|
|
40134
41349
|
await startInkInteractiveLog(git, initialRows, {}, {
|
|
40135
41350
|
appLabel: 'coco',
|
|
40136
41351
|
idleTips: config.logTui?.idleTips,
|
|
@@ -40158,7 +41373,7 @@ async function startCocoUi(argv) {
|
|
|
40158
41373
|
idleTips: config.logTui?.idleTips,
|
|
40159
41374
|
dateBucketing: config.logTui?.dateBucketing,
|
|
40160
41375
|
initialView: argv.view || 'history',
|
|
40161
|
-
loadRows: withCacheWrite(repoPath, () =>
|
|
41376
|
+
loadRows: withCacheWrite(repoPath, () => loadRowsWithStashes(git, logArgv)),
|
|
40162
41377
|
logArgv,
|
|
40163
41378
|
theme: createUiTheme(config, argv),
|
|
40164
41379
|
});
|
|
@@ -41361,9 +42576,9 @@ const options = {
|
|
|
41361
42576
|
default: 'history',
|
|
41362
42577
|
},
|
|
41363
42578
|
all: {
|
|
41364
|
-
description: 'Load commits from all local and remote refs in history mode',
|
|
42579
|
+
description: 'Load commits from all local and remote refs in history mode. Defaults to true so the history view shows the full multi-ref graph (branches, tags, stashes) out of the box; pass `--no-all` to scope to the current branch only.',
|
|
41365
42580
|
type: 'boolean',
|
|
41366
|
-
default:
|
|
42581
|
+
default: true,
|
|
41367
42582
|
},
|
|
41368
42583
|
branch: {
|
|
41369
42584
|
description: 'Load history reachable from a branch or ref',
|