git-coco 0.58.0 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/index.esm.mjs +248 -85
- package/dist/index.js +248 -85
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -19,6 +19,8 @@ An AI-powered git assistant that generates meaningful commit messages, creates c
|
|
|
19
19
|
- 🔧 **Commitlint Integration** - Seamless integration with your existing commitlint configuration
|
|
20
20
|
- 🏠 **Local AI Support** - Run completely offline with Ollama (no API costs, full privacy)
|
|
21
21
|
- 🖥️ **Coco UI Git Workstation** - Sixteen top-level views (history, status, diff, compose, branches, tags, stash, worktrees, pull-request, PR triage, issues, conflicts, reflog, bisect, submodules, changelog) reachable via `g`-prefixed chords, with an interactive command palette (`:`), global search (`/`), and one-keystroke workflows: `S` split staged changes, `L` generate a changelog, `C` create a PR seeded from changelog, `E` open the commit draft in `$EDITOR`
|
|
22
|
+
- 🗂️ **Multi-Repo Workspace** - `coco workspace` (alias `ws`) scans your current directory for git repos and gives you a sortable, filterable overview — branch, dirty count, ahead/behind, open PR count — then `Enter` drills into any one as a full `coco ui` session
|
|
23
|
+
- 🎨 **49 Color Themes** - Catppuccin, Gruvbox, Dracula, Tokyo Night, Solarized, and more (17 light) — browse and **live-preview** them with the in-app theme picker (`gC`), then apply with one keystroke; or set one via `coco ui --theme <name>` / config. `NO_COLOR` honored
|
|
22
24
|
- 🎯 **`--repo <dir>` global flag** - Drive any coco command against any repository without `cd`-ing first
|
|
23
25
|
- 📦 **Package Manager Friendly** - Works with npm, yarn, and pnpm
|
|
24
26
|
- 👥 **Team Ready** - Shared configurations and enterprise deployment
|
|
@@ -48,6 +50,9 @@ coco commit -i
|
|
|
48
50
|
- **`coco review`** - AI-powered code review of your changes
|
|
49
51
|
- **`coco log`** - Explore commit history with graph, filters, JSON output, and commit details
|
|
50
52
|
- **`coco ui`** - Open the full-screen Git workstation TUI
|
|
53
|
+
- **`coco workspace`** (alias `ws`) - Multi-repo overview TUI; drill into any repo as a `coco ui` session
|
|
54
|
+
- **`coco issues`** / **`coco prs`** - List GitHub issues / pull requests (stdout or interactive triage)
|
|
55
|
+
- **`coco doctor`** - Diagnose your environment, config, and provider setup
|
|
51
56
|
- **`coco init`** - Interactive setup wizard
|
|
52
57
|
|
|
53
58
|
> **Smart default (0.57.0+):** running `coco` with **no subcommand** routes by environment — `coco ui` inside a git repo, `coco workspace` outside one, or `coco init` on a fresh install. It no longer defaults to `commit`; use `coco commit` for messages (or `--commit` / `COCO_DEFAULT=commit` to restore the old default).
|
|
@@ -111,10 +116,11 @@ coco log --format json
|
|
|
111
116
|
```text
|
|
112
117
|
g h history g c compose g x conflicts
|
|
113
118
|
g s status g b branches g r reflog
|
|
114
|
-
g d diff g t tags
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
g d diff g t tags g C theme picker
|
|
120
|
+
g w worktrees g z stash < back
|
|
121
|
+
g p pull request Esc back / close modal
|
|
122
|
+
? help overlay
|
|
123
|
+
: command palette
|
|
118
124
|
```
|
|
119
125
|
|
|
120
126
|
The command palette (`:`) is an interactive launcher with fuzzy filter and recently-used at the top — every keybinding and workflow action is reachable from there. `/` searches the active view (history, branches, tags, stash, or reflog). On branches, tags, and history, press `m` to mark a ref as the compare base — then `Enter` on a second ref opens a `git diff <base>..<head>` view. See the [Coco UI](https://github.com/gfargo/coco/wiki/Coco-UI) and [TUI Navigation](https://github.com/gfargo/coco/wiki/TUI-Navigation) wiki pages for the full keymap.
|
package/dist/index.esm.mjs
CHANGED
|
@@ -61,7 +61,7 @@ import { pathToFileURL } from 'url';
|
|
|
61
61
|
/**
|
|
62
62
|
* Current build version from package.json
|
|
63
63
|
*/
|
|
64
|
-
const BUILD_VERSION = "0.58.
|
|
64
|
+
const BUILD_VERSION = "0.58.1";
|
|
65
65
|
|
|
66
66
|
const isInteractive = (config) => {
|
|
67
67
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -25305,6 +25305,11 @@ function applyLogInkAction(state, action) {
|
|
|
25305
25305
|
bootLoading: action.value,
|
|
25306
25306
|
pendingKey: undefined,
|
|
25307
25307
|
};
|
|
25308
|
+
case 'setRemoteOp':
|
|
25309
|
+
return {
|
|
25310
|
+
...state,
|
|
25311
|
+
remoteOp: action.value,
|
|
25312
|
+
};
|
|
25308
25313
|
case 'moveTag':
|
|
25309
25314
|
return {
|
|
25310
25315
|
...state,
|
|
@@ -29964,6 +29969,35 @@ async function runAction$5(action, successMessage) {
|
|
|
29964
29969
|
};
|
|
29965
29970
|
}
|
|
29966
29971
|
}
|
|
29972
|
+
/** Configured remote names (best-effort; `[]` if the call fails). */
|
|
29973
|
+
async function listRemotes(git) {
|
|
29974
|
+
try {
|
|
29975
|
+
return (await git.getRemotes()).map((remote) => remote.name).filter(Boolean);
|
|
29976
|
+
}
|
|
29977
|
+
catch {
|
|
29978
|
+
return [];
|
|
29979
|
+
}
|
|
29980
|
+
}
|
|
29981
|
+
/**
|
|
29982
|
+
* Remote to push a not-yet-tracked branch to: `origin` when it exists,
|
|
29983
|
+
* else the first configured remote, else `undefined` (no remotes).
|
|
29984
|
+
*/
|
|
29985
|
+
async function resolveDefaultRemote(git) {
|
|
29986
|
+
const remotes = await listRemotes(git);
|
|
29987
|
+
if (remotes.length === 0)
|
|
29988
|
+
return undefined;
|
|
29989
|
+
return remotes.includes('origin') ? 'origin' : remotes[0];
|
|
29990
|
+
}
|
|
29991
|
+
/** Whether the remote-tracking ref `refs/remotes/<remote>/<branch>` exists locally. */
|
|
29992
|
+
async function remoteBranchExists(git, remote, branch) {
|
|
29993
|
+
try {
|
|
29994
|
+
await git.raw(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
|
|
29995
|
+
return true;
|
|
29996
|
+
}
|
|
29997
|
+
catch {
|
|
29998
|
+
return false;
|
|
29999
|
+
}
|
|
30000
|
+
}
|
|
29967
30001
|
function checkoutBranch(git, branch) {
|
|
29968
30002
|
const refs = getBranchActionRefs(branch);
|
|
29969
30003
|
if (branch.type === 'remote') {
|
|
@@ -29998,11 +30032,58 @@ function fetchRemotes(git) {
|
|
|
29998
30032
|
function pullCurrentBranch(git) {
|
|
29999
30033
|
return runAction$5(() => git.raw(['pull', '--ff-only']), 'Pulled current branch');
|
|
30000
30034
|
}
|
|
30001
|
-
function pushCurrentBranch(git) {
|
|
30002
|
-
|
|
30003
|
-
}
|
|
30004
|
-
|
|
30005
|
-
|
|
30035
|
+
async function pushCurrentBranch(git) {
|
|
30036
|
+
const hasUpstream = await git
|
|
30037
|
+
.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
30038
|
+
.then(() => true)
|
|
30039
|
+
.catch(() => false);
|
|
30040
|
+
if (hasUpstream) {
|
|
30041
|
+
return runAction$5(() => git.raw(['push']), 'Pushed current branch');
|
|
30042
|
+
}
|
|
30043
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30044
|
+
// tracking, instead of failing with git's bare "has no upstream" error.
|
|
30045
|
+
const remote = await resolveDefaultRemote(git);
|
|
30046
|
+
if (!remote) {
|
|
30047
|
+
return { ok: false, message: 'No upstream and no remote configured — add one with `git remote add origin <url>`.' };
|
|
30048
|
+
}
|
|
30049
|
+
const current = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
30050
|
+
return runAction$5(() => git.raw(['push', '-u', remote, current]), `Pushed ${current} and set upstream to ${remote}/${current}`);
|
|
30051
|
+
}
|
|
30052
|
+
/**
|
|
30053
|
+
* Set (or create) the upstream for a local branch from a user-typed target.
|
|
30054
|
+
*
|
|
30055
|
+
* The target may be a bare branch name (`main` → `<default-remote>/main`) or
|
|
30056
|
+
* a `remote/branch` ref (`origin/main`). If that remote-tracking branch
|
|
30057
|
+
* already exists, we just link to it (`git branch --set-upstream-to`). If it
|
|
30058
|
+
* does NOT exist yet — the common "I just created this branch" case — we
|
|
30059
|
+
* `git push -u` to create the remote branch and set tracking in one step.
|
|
30060
|
+
* The old behavior ran `--set-upstream-to <bare-name>`, which silently
|
|
30061
|
+
* resolved `main` to the *local* branch and left push still complaining.
|
|
30062
|
+
*/
|
|
30063
|
+
async function setUpstream(git, localBranch, target) {
|
|
30064
|
+
const cleaned = target.trim();
|
|
30065
|
+
if (!cleaned)
|
|
30066
|
+
return { ok: false, message: 'Upstream ref required' };
|
|
30067
|
+
const remotes = await listRemotes(git);
|
|
30068
|
+
const slash = cleaned.indexOf('/');
|
|
30069
|
+
let remote;
|
|
30070
|
+
let remoteBranch;
|
|
30071
|
+
if (slash > 0 && remotes.includes(cleaned.slice(0, slash))) {
|
|
30072
|
+
remote = cleaned.slice(0, slash);
|
|
30073
|
+
remoteBranch = cleaned.slice(slash + 1);
|
|
30074
|
+
}
|
|
30075
|
+
else {
|
|
30076
|
+
remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
30077
|
+
remoteBranch = cleaned;
|
|
30078
|
+
}
|
|
30079
|
+
if (!remote) {
|
|
30080
|
+
return { ok: false, message: 'No remote configured — add one with `git remote add origin <url>` first.' };
|
|
30081
|
+
}
|
|
30082
|
+
if (await remoteBranchExists(git, remote, remoteBranch)) {
|
|
30083
|
+
return runAction$5(() => git.raw(['branch', '--set-upstream-to', `${remote}/${remoteBranch}`, localBranch]), `Set ${localBranch} to track ${remote}/${remoteBranch}`);
|
|
30084
|
+
}
|
|
30085
|
+
// Remote branch doesn't exist yet — push it and set upstream in one step.
|
|
30086
|
+
return runAction$5(() => git.raw(['push', '-u', remote, `${localBranch}:${remoteBranch}`]), `Pushed ${localBranch} → ${remote}/${remoteBranch} and set upstream`);
|
|
30006
30087
|
}
|
|
30007
30088
|
/**
|
|
30008
30089
|
* Push an arbitrary local branch (need not be the current branch) to
|
|
@@ -30013,18 +30094,21 @@ function setUpstream(git, localBranch, upstreamBranch) {
|
|
|
30013
30094
|
* Pairs with `pushCurrentBranch` (no-arg variant); the workstation
|
|
30014
30095
|
* dispatcher picks one or the other based on where the cursor is.
|
|
30015
30096
|
*/
|
|
30016
|
-
function pushBranch(git, branch) {
|
|
30097
|
+
async function pushBranch(git, branch) {
|
|
30017
30098
|
if (branch.type !== 'local') {
|
|
30018
|
-
return
|
|
30019
|
-
ok: false,
|
|
30020
|
-
message: 'Only local branches can be pushed.',
|
|
30021
|
-
});
|
|
30099
|
+
return { ok: false, message: 'Only local branches can be pushed.' };
|
|
30022
30100
|
}
|
|
30023
30101
|
if (!branch.upstream || !branch.remote) {
|
|
30024
|
-
|
|
30025
|
-
|
|
30026
|
-
|
|
30027
|
-
|
|
30102
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30103
|
+
// tracking, rather than refusing and sending the user to the shell.
|
|
30104
|
+
const remote = await resolveDefaultRemote(git);
|
|
30105
|
+
if (!remote) {
|
|
30106
|
+
return {
|
|
30107
|
+
ok: false,
|
|
30108
|
+
message: `${branch.shortName} has no upstream and no remote is configured — add one with \`git remote add origin <url>\`.`,
|
|
30109
|
+
};
|
|
30110
|
+
}
|
|
30111
|
+
return runAction$5(() => git.raw(['push', '-u', remote, branch.shortName]), `Pushed ${branch.shortName} and set upstream to ${remote}/${branch.shortName}`);
|
|
30028
30112
|
}
|
|
30029
30113
|
return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
|
|
30030
30114
|
}
|
|
@@ -35396,9 +35480,55 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
35396
35480
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
35397
35481
|
}, truncateCells(label, 140));
|
|
35398
35482
|
}
|
|
35399
|
-
|
|
35483
|
+
/**
|
|
35484
|
+
* Full-panel loader shown over the history surface while a remote
|
|
35485
|
+
* operation (fetch / pull / push) is in flight. Same bordered frame
|
|
35486
|
+
* and `Commits` title row as the real panel so the swap in/out is
|
|
35487
|
+
* seamless: a centered spinner + label + a travelling arrow track
|
|
35488
|
+
* give the user an unmistakable "we're talking to the remote" beat in
|
|
35489
|
+
* place of a frozen, soon-to-abruptly-repaint commit list.
|
|
35490
|
+
*/
|
|
35491
|
+
function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame) {
|
|
35492
|
+
const { Box, Text } = components;
|
|
35493
|
+
const op = state.remoteOp;
|
|
35494
|
+
if (!op) {
|
|
35495
|
+
return h(Box, { width });
|
|
35496
|
+
}
|
|
35497
|
+
const spinner = pickSpinnerFrame(spinnerFrame);
|
|
35498
|
+
// Directional glyph hints which way the bits are flowing.
|
|
35499
|
+
const glyph = op.kind === 'push' ? '↑' : op.kind === 'pull' ? '↓' : '↕';
|
|
35500
|
+
// A single glyph "travels" along a dotted track each tick so the
|
|
35501
|
+
// motion reads even on terminals that render braille spinners poorly.
|
|
35502
|
+
const trackWidth = 9;
|
|
35503
|
+
const pos = Math.max(0, spinnerFrame) % trackWidth;
|
|
35504
|
+
const track = Array.from({ length: trackWidth }, (_, i) => (i === pos ? glyph : '·')).join(' ');
|
|
35505
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35506
|
+
const innerHeight = Math.max(3, bodyRows - 2);
|
|
35507
|
+
return h(Box, {
|
|
35508
|
+
borderColor: focusBorderColor(theme, focused),
|
|
35509
|
+
borderStyle: theme.borderStyle,
|
|
35510
|
+
flexDirection: 'column',
|
|
35511
|
+
flexShrink: 0,
|
|
35512
|
+
paddingX: 1,
|
|
35513
|
+
width,
|
|
35514
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${op.kind} in progress`)), h(Box, {
|
|
35515
|
+
flexDirection: 'column',
|
|
35516
|
+
alignItems: 'center',
|
|
35517
|
+
justifyContent: 'center',
|
|
35518
|
+
height: innerHeight,
|
|
35519
|
+
}, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
|
|
35520
|
+
}
|
|
35521
|
+
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
35400
35522
|
const { Box, Text } = components;
|
|
35401
35523
|
const focused = state.focus === 'commits';
|
|
35524
|
+
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
35525
|
+
// for a centered, animated loader. Keeping the same bordered panel
|
|
35526
|
+
// (same width, same title row) means that when the op completes and
|
|
35527
|
+
// `remoteOp` clears, the fresh rows paint in place without the panel
|
|
35528
|
+
// jumping — smoothing over the "frozen list → sudden repaint" feel.
|
|
35529
|
+
if (state.remoteOp) {
|
|
35530
|
+
return renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame);
|
|
35531
|
+
}
|
|
35402
35532
|
const worktree = context.worktree;
|
|
35403
35533
|
// Distinct remote names seen across the repo's remote-tracking
|
|
35404
35534
|
// branches — `['origin']` for a typical fork, `['origin', 'upstream']`
|
|
@@ -37279,7 +37409,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
37279
37409
|
if (state.activeView === 'changelog') {
|
|
37280
37410
|
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
37281
37411
|
}
|
|
37282
|
-
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
|
|
37412
|
+
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
37283
37413
|
}
|
|
37284
37414
|
|
|
37285
37415
|
/**
|
|
@@ -38541,6 +38671,18 @@ function loadLogInkContextEntries(git) {
|
|
|
38541
38671
|
},
|
|
38542
38672
|
];
|
|
38543
38673
|
}
|
|
38674
|
+
// Workflow action ids that hit the network (fetch / pull / push) →
|
|
38675
|
+
// the loader copy shown over the history surface while they run. Any
|
|
38676
|
+
// id NOT in this map runs without the full-screen loader (local-only
|
|
38677
|
+
// mutations repaint fast enough that a loader would just flicker).
|
|
38678
|
+
const REMOTE_OP_LOADERS = {
|
|
38679
|
+
'fetch-remotes': { kind: 'fetch', label: 'Fetching all remotes…' },
|
|
38680
|
+
'pull-current-branch': { kind: 'pull', label: 'Pulling from origin…' },
|
|
38681
|
+
'push-current-branch': { kind: 'push', label: 'Pushing to origin…' },
|
|
38682
|
+
'fetch-selected-branch': { kind: 'fetch', label: 'Fetching branch from remote…' },
|
|
38683
|
+
'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
|
|
38684
|
+
'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
|
|
38685
|
+
};
|
|
38544
38686
|
function predictNextFilter(action, currentFilter) {
|
|
38545
38687
|
switch (action.type) {
|
|
38546
38688
|
case 'appendFilter':
|
|
@@ -38847,6 +38989,7 @@ function LogInkApp(deps) {
|
|
|
38847
38989
|
state.splitPlan?.status === 'applying' ||
|
|
38848
38990
|
state.changelogView.status === 'loading' ||
|
|
38849
38991
|
state.commitCompose.loading ||
|
|
38992
|
+
Boolean(state.remoteOp) ||
|
|
38850
38993
|
Boolean(state.statusLoading);
|
|
38851
38994
|
React.useEffect(() => {
|
|
38852
38995
|
if (!anyLoading) {
|
|
@@ -41637,76 +41780,96 @@ function LogInkApp(deps) {
|
|
|
41637
41780
|
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
41638
41781
|
return;
|
|
41639
41782
|
}
|
|
41640
|
-
|
|
41641
|
-
|
|
41642
|
-
//
|
|
41643
|
-
//
|
|
41644
|
-
//
|
|
41645
|
-
//
|
|
41646
|
-
|
|
41647
|
-
|
|
41648
|
-
|
|
41649
|
-
const historyMutatingIds = new Set([
|
|
41650
|
-
'checkout-branch',
|
|
41651
|
-
'continue-operation',
|
|
41652
|
-
'pull-current-branch',
|
|
41653
|
-
'cherry-pick-commit',
|
|
41654
|
-
'revert-commit',
|
|
41655
|
-
'reset-hard-to-commit',
|
|
41656
|
-
'reset-soft-to-commit',
|
|
41657
|
-
'reset-mixed-to-commit',
|
|
41658
|
-
'interactive-rebase-to-commit',
|
|
41659
|
-
'bisect-good',
|
|
41660
|
-
'bisect-bad',
|
|
41661
|
-
'bisect-skip',
|
|
41662
|
-
'bisect-reset',
|
|
41663
|
-
]);
|
|
41664
|
-
if (result?.ok && historyMutatingIds.has(id)) {
|
|
41665
|
-
await refreshHistoryRows();
|
|
41666
|
-
}
|
|
41667
|
-
// Checkout-branch is the one workflow where we want a *visible*
|
|
41668
|
-
// refresh so the user sees the branches sidebar repaint with the
|
|
41669
|
-
// new current branch (per #806 follow-up). Snap the cursor to
|
|
41670
|
-
// position 0 first so when the refresh completes and the new
|
|
41671
|
-
// current branch lands at the top (per #809's pin-current rule),
|
|
41672
|
-
// the cursor is already there waiting.
|
|
41673
|
-
if (id === 'checkout-branch' && result?.ok) {
|
|
41674
|
-
dispatch({ type: 'resetBranchSelection' });
|
|
41675
|
-
await refreshContext();
|
|
41783
|
+
// Remote network ops (fetch / pull / push) get a full-screen
|
|
41784
|
+
// history loader while in flight so the commit list doesn't sit
|
|
41785
|
+
// frozen and then abruptly repaint when the call returns. Cleared
|
|
41786
|
+
// in `finally` *after* the post-op refresh below so the loader
|
|
41787
|
+
// hands straight off to the freshly-fetched rows instead of
|
|
41788
|
+
// flashing the stale list for a frame in between.
|
|
41789
|
+
const remoteOp = REMOTE_OP_LOADERS[id];
|
|
41790
|
+
if (remoteOp) {
|
|
41791
|
+
dispatch({ type: 'setRemoteOp', value: remoteOp });
|
|
41676
41792
|
}
|
|
41677
|
-
|
|
41678
|
-
|
|
41679
|
-
|
|
41680
|
-
|
|
41681
|
-
|
|
41682
|
-
|
|
41683
|
-
|
|
41684
|
-
|
|
41685
|
-
|
|
41686
|
-
|
|
41687
|
-
|
|
41688
|
-
|
|
41689
|
-
|
|
41690
|
-
|
|
41691
|
-
|
|
41692
|
-
|
|
41693
|
-
|
|
41694
|
-
|
|
41695
|
-
|
|
41696
|
-
|
|
41697
|
-
|
|
41698
|
-
|
|
41699
|
-
|
|
41793
|
+
try {
|
|
41794
|
+
const result = await handler();
|
|
41795
|
+
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
41796
|
+
// Refresh history rows AS WELL when the workflow could have
|
|
41797
|
+
// changed the commits the user sees (#945 follow-up). The
|
|
41798
|
+
// workflow IDs below all either create/rewrite local commits or
|
|
41799
|
+
// change which branch's history is being viewed — without this
|
|
41800
|
+
// the history pane shows stale data even after the operation
|
|
41801
|
+
// succeeds. Cheap one-off `git log` call; doesn't fire on
|
|
41802
|
+
// metadata-only mutations (delete-tag, set-upstream, etc.).
|
|
41803
|
+
const historyMutatingIds = new Set([
|
|
41804
|
+
'checkout-branch',
|
|
41805
|
+
'continue-operation',
|
|
41806
|
+
'pull-current-branch',
|
|
41807
|
+
'cherry-pick-commit',
|
|
41808
|
+
'revert-commit',
|
|
41809
|
+
'reset-hard-to-commit',
|
|
41810
|
+
'reset-soft-to-commit',
|
|
41811
|
+
'reset-mixed-to-commit',
|
|
41812
|
+
'interactive-rebase-to-commit',
|
|
41813
|
+
'bisect-good',
|
|
41814
|
+
'bisect-bad',
|
|
41815
|
+
'bisect-skip',
|
|
41816
|
+
'bisect-reset',
|
|
41817
|
+
]);
|
|
41818
|
+
if (result?.ok && historyMutatingIds.has(id)) {
|
|
41819
|
+
await refreshHistoryRows();
|
|
41820
|
+
}
|
|
41821
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
41822
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
41823
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
41824
|
+
// position 0 first so when the refresh completes and the new
|
|
41825
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
41826
|
+
// the cursor is already there waiting.
|
|
41827
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
41828
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
41829
|
+
await refreshContext();
|
|
41830
|
+
}
|
|
41831
|
+
else {
|
|
41832
|
+
// Silent refresh so the deleted item disappears from the list
|
|
41833
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
41834
|
+
await refreshContext({ silent: true });
|
|
41835
|
+
}
|
|
41836
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
41837
|
+
//
|
|
41838
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
41839
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
41840
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
41841
|
+
// jump them to history view (where the worktree counts in the
|
|
41842
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
41843
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
41844
|
+
//
|
|
41845
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
41846
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
41847
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
41848
|
+
// affect untracked files when the stash held `-u` state), and
|
|
41849
|
+
// surface the new stash count on the status line so there's
|
|
41850
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
41851
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
41852
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
41853
|
+
await refreshWorktreeContext();
|
|
41854
|
+
}
|
|
41855
|
+
if (result?.ok && id === 'drop-stash') {
|
|
41856
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
41857
|
+
// untracked-file state that's now collected.
|
|
41858
|
+
await refreshWorktreeContext();
|
|
41859
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
41860
|
+
// reading the count back here would be stale because closures
|
|
41861
|
+
// capture the pre-refresh value. Status message stays generic
|
|
41862
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
41863
|
+
// unambiguous signal that the operation landed.
|
|
41864
|
+
}
|
|
41700
41865
|
}
|
|
41701
|
-
|
|
41702
|
-
//
|
|
41703
|
-
//
|
|
41704
|
-
|
|
41705
|
-
|
|
41706
|
-
|
|
41707
|
-
|
|
41708
|
-
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
41709
|
-
// unambiguous signal that the operation landed.
|
|
41866
|
+
finally {
|
|
41867
|
+
// Always clear the loader — even if a refresh threw — so a
|
|
41868
|
+
// failed fetch/pull can't leave the history surface stuck behind
|
|
41869
|
+
// the spinner.
|
|
41870
|
+
if (remoteOp) {
|
|
41871
|
+
dispatch({ type: 'setRemoteOp', value: undefined });
|
|
41872
|
+
}
|
|
41710
41873
|
}
|
|
41711
41874
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
41712
41875
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
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.58.
|
|
81
|
+
const BUILD_VERSION = "0.58.1";
|
|
82
82
|
|
|
83
83
|
const isInteractive = (config) => {
|
|
84
84
|
return config?.mode === 'interactive' || !!config?.interactive;
|
|
@@ -25322,6 +25322,11 @@ function applyLogInkAction(state, action) {
|
|
|
25322
25322
|
bootLoading: action.value,
|
|
25323
25323
|
pendingKey: undefined,
|
|
25324
25324
|
};
|
|
25325
|
+
case 'setRemoteOp':
|
|
25326
|
+
return {
|
|
25327
|
+
...state,
|
|
25328
|
+
remoteOp: action.value,
|
|
25329
|
+
};
|
|
25325
25330
|
case 'moveTag':
|
|
25326
25331
|
return {
|
|
25327
25332
|
...state,
|
|
@@ -29981,6 +29986,35 @@ async function runAction$5(action, successMessage) {
|
|
|
29981
29986
|
};
|
|
29982
29987
|
}
|
|
29983
29988
|
}
|
|
29989
|
+
/** Configured remote names (best-effort; `[]` if the call fails). */
|
|
29990
|
+
async function listRemotes(git) {
|
|
29991
|
+
try {
|
|
29992
|
+
return (await git.getRemotes()).map((remote) => remote.name).filter(Boolean);
|
|
29993
|
+
}
|
|
29994
|
+
catch {
|
|
29995
|
+
return [];
|
|
29996
|
+
}
|
|
29997
|
+
}
|
|
29998
|
+
/**
|
|
29999
|
+
* Remote to push a not-yet-tracked branch to: `origin` when it exists,
|
|
30000
|
+
* else the first configured remote, else `undefined` (no remotes).
|
|
30001
|
+
*/
|
|
30002
|
+
async function resolveDefaultRemote(git) {
|
|
30003
|
+
const remotes = await listRemotes(git);
|
|
30004
|
+
if (remotes.length === 0)
|
|
30005
|
+
return undefined;
|
|
30006
|
+
return remotes.includes('origin') ? 'origin' : remotes[0];
|
|
30007
|
+
}
|
|
30008
|
+
/** Whether the remote-tracking ref `refs/remotes/<remote>/<branch>` exists locally. */
|
|
30009
|
+
async function remoteBranchExists(git, remote, branch) {
|
|
30010
|
+
try {
|
|
30011
|
+
await git.raw(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`]);
|
|
30012
|
+
return true;
|
|
30013
|
+
}
|
|
30014
|
+
catch {
|
|
30015
|
+
return false;
|
|
30016
|
+
}
|
|
30017
|
+
}
|
|
29984
30018
|
function checkoutBranch(git, branch) {
|
|
29985
30019
|
const refs = getBranchActionRefs(branch);
|
|
29986
30020
|
if (branch.type === 'remote') {
|
|
@@ -30015,11 +30049,58 @@ function fetchRemotes(git) {
|
|
|
30015
30049
|
function pullCurrentBranch(git) {
|
|
30016
30050
|
return runAction$5(() => git.raw(['pull', '--ff-only']), 'Pulled current branch');
|
|
30017
30051
|
}
|
|
30018
|
-
function pushCurrentBranch(git) {
|
|
30019
|
-
|
|
30020
|
-
}
|
|
30021
|
-
|
|
30022
|
-
|
|
30052
|
+
async function pushCurrentBranch(git) {
|
|
30053
|
+
const hasUpstream = await git
|
|
30054
|
+
.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])
|
|
30055
|
+
.then(() => true)
|
|
30056
|
+
.catch(() => false);
|
|
30057
|
+
if (hasUpstream) {
|
|
30058
|
+
return runAction$5(() => git.raw(['push']), 'Pushed current branch');
|
|
30059
|
+
}
|
|
30060
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30061
|
+
// tracking, instead of failing with git's bare "has no upstream" error.
|
|
30062
|
+
const remote = await resolveDefaultRemote(git);
|
|
30063
|
+
if (!remote) {
|
|
30064
|
+
return { ok: false, message: 'No upstream and no remote configured — add one with `git remote add origin <url>`.' };
|
|
30065
|
+
}
|
|
30066
|
+
const current = (await git.raw(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
|
|
30067
|
+
return runAction$5(() => git.raw(['push', '-u', remote, current]), `Pushed ${current} and set upstream to ${remote}/${current}`);
|
|
30068
|
+
}
|
|
30069
|
+
/**
|
|
30070
|
+
* Set (or create) the upstream for a local branch from a user-typed target.
|
|
30071
|
+
*
|
|
30072
|
+
* The target may be a bare branch name (`main` → `<default-remote>/main`) or
|
|
30073
|
+
* a `remote/branch` ref (`origin/main`). If that remote-tracking branch
|
|
30074
|
+
* already exists, we just link to it (`git branch --set-upstream-to`). If it
|
|
30075
|
+
* does NOT exist yet — the common "I just created this branch" case — we
|
|
30076
|
+
* `git push -u` to create the remote branch and set tracking in one step.
|
|
30077
|
+
* The old behavior ran `--set-upstream-to <bare-name>`, which silently
|
|
30078
|
+
* resolved `main` to the *local* branch and left push still complaining.
|
|
30079
|
+
*/
|
|
30080
|
+
async function setUpstream(git, localBranch, target) {
|
|
30081
|
+
const cleaned = target.trim();
|
|
30082
|
+
if (!cleaned)
|
|
30083
|
+
return { ok: false, message: 'Upstream ref required' };
|
|
30084
|
+
const remotes = await listRemotes(git);
|
|
30085
|
+
const slash = cleaned.indexOf('/');
|
|
30086
|
+
let remote;
|
|
30087
|
+
let remoteBranch;
|
|
30088
|
+
if (slash > 0 && remotes.includes(cleaned.slice(0, slash))) {
|
|
30089
|
+
remote = cleaned.slice(0, slash);
|
|
30090
|
+
remoteBranch = cleaned.slice(slash + 1);
|
|
30091
|
+
}
|
|
30092
|
+
else {
|
|
30093
|
+
remote = remotes.includes('origin') ? 'origin' : remotes[0];
|
|
30094
|
+
remoteBranch = cleaned;
|
|
30095
|
+
}
|
|
30096
|
+
if (!remote) {
|
|
30097
|
+
return { ok: false, message: 'No remote configured — add one with `git remote add origin <url>` first.' };
|
|
30098
|
+
}
|
|
30099
|
+
if (await remoteBranchExists(git, remote, remoteBranch)) {
|
|
30100
|
+
return runAction$5(() => git.raw(['branch', '--set-upstream-to', `${remote}/${remoteBranch}`, localBranch]), `Set ${localBranch} to track ${remote}/${remoteBranch}`);
|
|
30101
|
+
}
|
|
30102
|
+
// Remote branch doesn't exist yet — push it and set upstream in one step.
|
|
30103
|
+
return runAction$5(() => git.raw(['push', '-u', remote, `${localBranch}:${remoteBranch}`]), `Pushed ${localBranch} → ${remote}/${remoteBranch} and set upstream`);
|
|
30023
30104
|
}
|
|
30024
30105
|
/**
|
|
30025
30106
|
* Push an arbitrary local branch (need not be the current branch) to
|
|
@@ -30030,18 +30111,21 @@ function setUpstream(git, localBranch, upstreamBranch) {
|
|
|
30030
30111
|
* Pairs with `pushCurrentBranch` (no-arg variant); the workstation
|
|
30031
30112
|
* dispatcher picks one or the other based on where the cursor is.
|
|
30032
30113
|
*/
|
|
30033
|
-
function pushBranch(git, branch) {
|
|
30114
|
+
async function pushBranch(git, branch) {
|
|
30034
30115
|
if (branch.type !== 'local') {
|
|
30035
|
-
return
|
|
30036
|
-
ok: false,
|
|
30037
|
-
message: 'Only local branches can be pushed.',
|
|
30038
|
-
});
|
|
30116
|
+
return { ok: false, message: 'Only local branches can be pushed.' };
|
|
30039
30117
|
}
|
|
30040
30118
|
if (!branch.upstream || !branch.remote) {
|
|
30041
|
-
|
|
30042
|
-
|
|
30043
|
-
|
|
30044
|
-
|
|
30119
|
+
// No upstream yet — push with `-u` to create the remote branch AND set
|
|
30120
|
+
// tracking, rather than refusing and sending the user to the shell.
|
|
30121
|
+
const remote = await resolveDefaultRemote(git);
|
|
30122
|
+
if (!remote) {
|
|
30123
|
+
return {
|
|
30124
|
+
ok: false,
|
|
30125
|
+
message: `${branch.shortName} has no upstream and no remote is configured — add one with \`git remote add origin <url>\`.`,
|
|
30126
|
+
};
|
|
30127
|
+
}
|
|
30128
|
+
return runAction$5(() => git.raw(['push', '-u', remote, branch.shortName]), `Pushed ${branch.shortName} and set upstream to ${remote}/${branch.shortName}`);
|
|
30045
30129
|
}
|
|
30046
30130
|
return runAction$5(() => git.raw(['push', branch.remote, branch.shortName]), `Pushed ${branch.shortName} to ${branch.upstream}`);
|
|
30047
30131
|
}
|
|
@@ -35413,9 +35497,55 @@ function renderPendingCommitRow(h, Text, worktree, selected, theme) {
|
|
|
35413
35497
|
backgroundColor: selected && !theme.noColor ? theme.colors.selection : undefined,
|
|
35414
35498
|
}, truncateCells(label, 140));
|
|
35415
35499
|
}
|
|
35416
|
-
|
|
35500
|
+
/**
|
|
35501
|
+
* Full-panel loader shown over the history surface while a remote
|
|
35502
|
+
* operation (fetch / pull / push) is in flight. Same bordered frame
|
|
35503
|
+
* and `Commits` title row as the real panel so the swap in/out is
|
|
35504
|
+
* seamless: a centered spinner + label + a travelling arrow track
|
|
35505
|
+
* give the user an unmistakable "we're talking to the remote" beat in
|
|
35506
|
+
* place of a frozen, soon-to-abruptly-repaint commit list.
|
|
35507
|
+
*/
|
|
35508
|
+
function renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame) {
|
|
35509
|
+
const { Box, Text } = components;
|
|
35510
|
+
const op = state.remoteOp;
|
|
35511
|
+
if (!op) {
|
|
35512
|
+
return h(Box, { width });
|
|
35513
|
+
}
|
|
35514
|
+
const spinner = pickSpinnerFrame(spinnerFrame);
|
|
35515
|
+
// Directional glyph hints which way the bits are flowing.
|
|
35516
|
+
const glyph = op.kind === 'push' ? '↑' : op.kind === 'pull' ? '↓' : '↕';
|
|
35517
|
+
// A single glyph "travels" along a dotted track each tick so the
|
|
35518
|
+
// motion reads even on terminals that render braille spinners poorly.
|
|
35519
|
+
const trackWidth = 9;
|
|
35520
|
+
const pos = Math.max(0, spinnerFrame) % trackWidth;
|
|
35521
|
+
const track = Array.from({ length: trackWidth }, (_, i) => (i === pos ? glyph : '·')).join(' ');
|
|
35522
|
+
const accent = theme.noColor ? undefined : theme.colors.accent;
|
|
35523
|
+
const innerHeight = Math.max(3, bodyRows - 2);
|
|
35524
|
+
return h(Box, {
|
|
35525
|
+
borderColor: focusBorderColor(theme, focused),
|
|
35526
|
+
borderStyle: theme.borderStyle,
|
|
35527
|
+
flexDirection: 'column',
|
|
35528
|
+
flexShrink: 0,
|
|
35529
|
+
paddingX: 1,
|
|
35530
|
+
width,
|
|
35531
|
+
}, h(Box, { justifyContent: 'space-between' }, h(Text, { bold: true }, panelTitle('Commits', focused)), h(Text, { dimColor: true }, `${op.kind} in progress`)), h(Box, {
|
|
35532
|
+
flexDirection: 'column',
|
|
35533
|
+
alignItems: 'center',
|
|
35534
|
+
justifyContent: 'center',
|
|
35535
|
+
height: innerHeight,
|
|
35536
|
+
}, h(Text, { color: accent, bold: true }, `${spinner} ${op.label}`), h(Text, undefined, ''), h(Text, { color: accent }, track), h(Text, undefined, ''), h(Text, { dimColor: true }, 'Talking to the remote — history refreshes automatically.')));
|
|
35537
|
+
}
|
|
35538
|
+
function renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled = false, now = getRenderNow(), spinnerFrame = 0) {
|
|
35417
35539
|
const { Box, Text } = components;
|
|
35418
35540
|
const focused = state.focus === 'commits';
|
|
35541
|
+
// Remote op in flight (fetch / pull / push) → swap the commit list
|
|
35542
|
+
// for a centered, animated loader. Keeping the same bordered panel
|
|
35543
|
+
// (same width, same title row) means that when the op completes and
|
|
35544
|
+
// `remoteOp` clears, the fresh rows paint in place without the panel
|
|
35545
|
+
// jumping — smoothing over the "frozen list → sudden repaint" feel.
|
|
35546
|
+
if (state.remoteOp) {
|
|
35547
|
+
return renderRemoteOpLoader(h, components, state, width, bodyRows, theme, focused, spinnerFrame);
|
|
35548
|
+
}
|
|
35419
35549
|
const worktree = context.worktree;
|
|
35420
35550
|
// Distinct remote names seen across the repo's remote-tracking
|
|
35421
35551
|
// branches — `['origin']` for a typical fork, `['origin', 'upstream']`
|
|
@@ -37296,7 +37426,7 @@ function renderMainPanel(h, components, state, context, contextStatus, worktreeD
|
|
|
37296
37426
|
if (state.activeView === 'changelog') {
|
|
37297
37427
|
return renderChangelogSurface(h, components, state, context, contextStatus, bodyRows, width, theme);
|
|
37298
37428
|
}
|
|
37299
|
-
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled);
|
|
37429
|
+
return renderHistoryPanel(h, components, state, context, bodyRows, width, theme, hasMoreCommits, loadingMoreCommits, density, rowMode, dateBucketingEnabled, undefined, spinnerFrame);
|
|
37300
37430
|
}
|
|
37301
37431
|
|
|
37302
37432
|
/**
|
|
@@ -38558,6 +38688,18 @@ function loadLogInkContextEntries(git) {
|
|
|
38558
38688
|
},
|
|
38559
38689
|
];
|
|
38560
38690
|
}
|
|
38691
|
+
// Workflow action ids that hit the network (fetch / pull / push) →
|
|
38692
|
+
// the loader copy shown over the history surface while they run. Any
|
|
38693
|
+
// id NOT in this map runs without the full-screen loader (local-only
|
|
38694
|
+
// mutations repaint fast enough that a loader would just flicker).
|
|
38695
|
+
const REMOTE_OP_LOADERS = {
|
|
38696
|
+
'fetch-remotes': { kind: 'fetch', label: 'Fetching all remotes…' },
|
|
38697
|
+
'pull-current-branch': { kind: 'pull', label: 'Pulling from origin…' },
|
|
38698
|
+
'push-current-branch': { kind: 'push', label: 'Pushing to origin…' },
|
|
38699
|
+
'fetch-selected-branch': { kind: 'fetch', label: 'Fetching branch from remote…' },
|
|
38700
|
+
'pull-selected-branch': { kind: 'pull', label: 'Pulling branch from remote…' },
|
|
38701
|
+
'push-selected-branch': { kind: 'push', label: 'Pushing branch to remote…' },
|
|
38702
|
+
};
|
|
38561
38703
|
function predictNextFilter(action, currentFilter) {
|
|
38562
38704
|
switch (action.type) {
|
|
38563
38705
|
case 'appendFilter':
|
|
@@ -38864,6 +39006,7 @@ function LogInkApp(deps) {
|
|
|
38864
39006
|
state.splitPlan?.status === 'applying' ||
|
|
38865
39007
|
state.changelogView.status === 'loading' ||
|
|
38866
39008
|
state.commitCompose.loading ||
|
|
39009
|
+
Boolean(state.remoteOp) ||
|
|
38867
39010
|
Boolean(state.statusLoading);
|
|
38868
39011
|
React.useEffect(() => {
|
|
38869
39012
|
if (!anyLoading) {
|
|
@@ -41654,76 +41797,96 @@ function LogInkApp(deps) {
|
|
|
41654
41797
|
dispatch({ type: 'setStatus', value: `Workflow action ${id} not yet wired`, kind: 'warning' });
|
|
41655
41798
|
return;
|
|
41656
41799
|
}
|
|
41657
|
-
|
|
41658
|
-
|
|
41659
|
-
//
|
|
41660
|
-
//
|
|
41661
|
-
//
|
|
41662
|
-
//
|
|
41663
|
-
|
|
41664
|
-
|
|
41665
|
-
|
|
41666
|
-
const historyMutatingIds = new Set([
|
|
41667
|
-
'checkout-branch',
|
|
41668
|
-
'continue-operation',
|
|
41669
|
-
'pull-current-branch',
|
|
41670
|
-
'cherry-pick-commit',
|
|
41671
|
-
'revert-commit',
|
|
41672
|
-
'reset-hard-to-commit',
|
|
41673
|
-
'reset-soft-to-commit',
|
|
41674
|
-
'reset-mixed-to-commit',
|
|
41675
|
-
'interactive-rebase-to-commit',
|
|
41676
|
-
'bisect-good',
|
|
41677
|
-
'bisect-bad',
|
|
41678
|
-
'bisect-skip',
|
|
41679
|
-
'bisect-reset',
|
|
41680
|
-
]);
|
|
41681
|
-
if (result?.ok && historyMutatingIds.has(id)) {
|
|
41682
|
-
await refreshHistoryRows();
|
|
41683
|
-
}
|
|
41684
|
-
// Checkout-branch is the one workflow where we want a *visible*
|
|
41685
|
-
// refresh so the user sees the branches sidebar repaint with the
|
|
41686
|
-
// new current branch (per #806 follow-up). Snap the cursor to
|
|
41687
|
-
// position 0 first so when the refresh completes and the new
|
|
41688
|
-
// current branch lands at the top (per #809's pin-current rule),
|
|
41689
|
-
// the cursor is already there waiting.
|
|
41690
|
-
if (id === 'checkout-branch' && result?.ok) {
|
|
41691
|
-
dispatch({ type: 'resetBranchSelection' });
|
|
41692
|
-
await refreshContext();
|
|
41800
|
+
// Remote network ops (fetch / pull / push) get a full-screen
|
|
41801
|
+
// history loader while in flight so the commit list doesn't sit
|
|
41802
|
+
// frozen and then abruptly repaint when the call returns. Cleared
|
|
41803
|
+
// in `finally` *after* the post-op refresh below so the loader
|
|
41804
|
+
// hands straight off to the freshly-fetched rows instead of
|
|
41805
|
+
// flashing the stale list for a frame in between.
|
|
41806
|
+
const remoteOp = REMOTE_OP_LOADERS[id];
|
|
41807
|
+
if (remoteOp) {
|
|
41808
|
+
dispatch({ type: 'setRemoteOp', value: remoteOp });
|
|
41693
41809
|
}
|
|
41694
|
-
|
|
41695
|
-
|
|
41696
|
-
|
|
41697
|
-
|
|
41698
|
-
|
|
41699
|
-
|
|
41700
|
-
|
|
41701
|
-
|
|
41702
|
-
|
|
41703
|
-
|
|
41704
|
-
|
|
41705
|
-
|
|
41706
|
-
|
|
41707
|
-
|
|
41708
|
-
|
|
41709
|
-
|
|
41710
|
-
|
|
41711
|
-
|
|
41712
|
-
|
|
41713
|
-
|
|
41714
|
-
|
|
41715
|
-
|
|
41716
|
-
|
|
41810
|
+
try {
|
|
41811
|
+
const result = await handler();
|
|
41812
|
+
dispatch({ type: 'setStatus', value: result?.message || 'Workflow action complete' });
|
|
41813
|
+
// Refresh history rows AS WELL when the workflow could have
|
|
41814
|
+
// changed the commits the user sees (#945 follow-up). The
|
|
41815
|
+
// workflow IDs below all either create/rewrite local commits or
|
|
41816
|
+
// change which branch's history is being viewed — without this
|
|
41817
|
+
// the history pane shows stale data even after the operation
|
|
41818
|
+
// succeeds. Cheap one-off `git log` call; doesn't fire on
|
|
41819
|
+
// metadata-only mutations (delete-tag, set-upstream, etc.).
|
|
41820
|
+
const historyMutatingIds = new Set([
|
|
41821
|
+
'checkout-branch',
|
|
41822
|
+
'continue-operation',
|
|
41823
|
+
'pull-current-branch',
|
|
41824
|
+
'cherry-pick-commit',
|
|
41825
|
+
'revert-commit',
|
|
41826
|
+
'reset-hard-to-commit',
|
|
41827
|
+
'reset-soft-to-commit',
|
|
41828
|
+
'reset-mixed-to-commit',
|
|
41829
|
+
'interactive-rebase-to-commit',
|
|
41830
|
+
'bisect-good',
|
|
41831
|
+
'bisect-bad',
|
|
41832
|
+
'bisect-skip',
|
|
41833
|
+
'bisect-reset',
|
|
41834
|
+
]);
|
|
41835
|
+
if (result?.ok && historyMutatingIds.has(id)) {
|
|
41836
|
+
await refreshHistoryRows();
|
|
41837
|
+
}
|
|
41838
|
+
// Checkout-branch is the one workflow where we want a *visible*
|
|
41839
|
+
// refresh so the user sees the branches sidebar repaint with the
|
|
41840
|
+
// new current branch (per #806 follow-up). Snap the cursor to
|
|
41841
|
+
// position 0 first so when the refresh completes and the new
|
|
41842
|
+
// current branch lands at the top (per #809's pin-current rule),
|
|
41843
|
+
// the cursor is already there waiting.
|
|
41844
|
+
if (id === 'checkout-branch' && result?.ok) {
|
|
41845
|
+
dispatch({ type: 'resetBranchSelection' });
|
|
41846
|
+
await refreshContext();
|
|
41847
|
+
}
|
|
41848
|
+
else {
|
|
41849
|
+
// Silent refresh so the deleted item disappears from the list
|
|
41850
|
+
// without flickering the surfaces through a 'loading' phase.
|
|
41851
|
+
await refreshContext({ silent: true });
|
|
41852
|
+
}
|
|
41853
|
+
// Stash workflow follow-up. Two distinct behaviours.
|
|
41854
|
+
//
|
|
41855
|
+
// **apply / pop**: the user brought stashed content back into the
|
|
41856
|
+
// worktree, but the sidebar still has them on the stash view.
|
|
41857
|
+
// Expected next move is "look at what landed in my worktree", so
|
|
41858
|
+
// jump them to history view (where the worktree counts in the
|
|
41859
|
+
// sidebar are visible) AND refresh worktree context explicitly so
|
|
41860
|
+
// the staged / unstaged / untracked numbers reflect the changes.
|
|
41861
|
+
//
|
|
41862
|
+
// **drop**: the silent context refresh above already re-fetched
|
|
41863
|
+
// the stash list, BUT users reported it feeling like nothing
|
|
41864
|
+
// happened. Fix two things: refresh worktree alongside (drops can
|
|
41865
|
+
// affect untracked files when the stash held `-u` state), and
|
|
41866
|
+
// surface the new stash count on the status line so there's
|
|
41867
|
+
// unambiguous feedback that the drop landed and the list shrank.
|
|
41868
|
+
if (result?.ok && (id === 'apply-stash' || id === 'pop-stash')) {
|
|
41869
|
+
dispatch({ type: 'pushView', value: 'history' });
|
|
41870
|
+
await refreshWorktreeContext();
|
|
41871
|
+
}
|
|
41872
|
+
if (result?.ok && id === 'drop-stash') {
|
|
41873
|
+
// Explicit worktree refresh in case the dropped stash carried
|
|
41874
|
+
// untracked-file state that's now collected.
|
|
41875
|
+
await refreshWorktreeContext();
|
|
41876
|
+
// The silent context refresh already replaced `context.stashes`;
|
|
41877
|
+
// reading the count back here would be stale because closures
|
|
41878
|
+
// capture the pre-refresh value. Status message stays generic
|
|
41879
|
+
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
41880
|
+
// unambiguous signal that the operation landed.
|
|
41881
|
+
}
|
|
41717
41882
|
}
|
|
41718
|
-
|
|
41719
|
-
//
|
|
41720
|
-
//
|
|
41721
|
-
|
|
41722
|
-
|
|
41723
|
-
|
|
41724
|
-
|
|
41725
|
-
// ("Dropped stash@{N}") — the visible list shrinking is the
|
|
41726
|
-
// unambiguous signal that the operation landed.
|
|
41883
|
+
finally {
|
|
41884
|
+
// Always clear the loader — even if a refresh threw — so a
|
|
41885
|
+
// failed fetch/pull can't leave the history surface stuck behind
|
|
41886
|
+
// the spinner.
|
|
41887
|
+
if (remoteOp) {
|
|
41888
|
+
dispatch({ type: 'setRemoteOp', value: undefined });
|
|
41889
|
+
}
|
|
41727
41890
|
}
|
|
41728
41891
|
}, [context, dispatch, git, refreshContext, refreshHistoryRows, refreshWorktreeContext,
|
|
41729
41892
|
state.branchSort, state.filter, state.selectedBranchIndex,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-coco",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.1",
|
|
4
4
|
"description": "zero-effort git commits with coco.",
|
|
5
5
|
"author": "gfargo <ghfargo@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"@types/ini": "^4.1.1",
|
|
80
80
|
"@types/jest": "^30.0.0",
|
|
81
81
|
"@types/node": "^25.0.10",
|
|
82
|
-
"@types/react": "19.2.
|
|
82
|
+
"@types/react": "19.2.15",
|
|
83
83
|
"@types/yargs": "^17.0.33",
|
|
84
84
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
|
85
85
|
"@typescript-eslint/parser": "^7.13.1",
|
|
@@ -114,13 +114,13 @@
|
|
|
114
114
|
"chalk": "4.1.2",
|
|
115
115
|
"diff": "9.0.0",
|
|
116
116
|
"ini": "7.0.0",
|
|
117
|
-
"ink": "7.0.
|
|
117
|
+
"ink": "7.0.5",
|
|
118
118
|
"minimatch": "^10.2.5",
|
|
119
119
|
"ora": "5.4.1",
|
|
120
120
|
"p-queue": "5.0.0",
|
|
121
121
|
"performance-now": "2.1.0",
|
|
122
122
|
"pretty-ms": "7.0.1",
|
|
123
|
-
"react": "19.2.
|
|
123
|
+
"react": "19.2.6",
|
|
124
124
|
"react-devtools-core": "7.0.1",
|
|
125
125
|
"simple-git": "3.36.0",
|
|
126
126
|
"tiktoken": "^1.0.21",
|