git-coco 0.41.0 → 0.41.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.
@@ -53,7 +53,7 @@ import { pathToFileURL } from 'url';
53
53
  /**
54
54
  * Current build version from package.json
55
55
  */
56
- const BUILD_VERSION = "0.41.0";
56
+ const BUILD_VERSION = "0.41.1";
57
57
 
58
58
  const isInteractive = (config) => {
59
59
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14951,6 +14951,19 @@ function getLogInkWorkflowActions() {
14951
14951
  kind: 'destructive',
14952
14952
  requiresConfirmation: true,
14953
14953
  },
14954
+ {
14955
+ // Per-view-only — the inkInput handler scopes this to the
14956
+ // worktrees surface so the global `D` keystroke (delete-branch)
14957
+ // keeps working from elsewhere. The empty `key` keeps the
14958
+ // workflow palette-discoverable but does not register a global
14959
+ // hotkey that would collide with delete-branch.
14960
+ id: 'remove-worktree-and-branch',
14961
+ key: '',
14962
+ label: 'Remove worktree + delete branch',
14963
+ description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
14964
+ kind: 'destructive',
14965
+ requiresConfirmation: true,
14966
+ },
14954
14967
  {
14955
14968
  id: 'abort-operation',
14956
14969
  key: 'A',
@@ -18415,6 +18428,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18415
18428
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
18416
18429
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
18417
18430
  }
18431
+ // Per-view worktree action: `D` removes the worktree AND deletes
18432
+ // the branch it was tracking (#838). Scoped to the worktrees
18433
+ // surface so it intercepts BEFORE the global workflow-by-key
18434
+ // dispatcher would otherwise route `D` to delete-branch (which
18435
+ // would silently target whatever was last cursored on the branches
18436
+ // surface instead of acting on the worktree under the cursor here).
18437
+ // `W` keeps its existing "remove worktree only" semantics.
18438
+ if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
18439
+ return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
18440
+ }
18418
18441
  // #783 — full PR action panel keys, scoped to the pull-request view.
18419
18442
  // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18420
18443
  // comment open prompts first, the rest route through the y-confirm
@@ -21485,6 +21508,56 @@ function worktreePathAction(worktree) {
21485
21508
  message: `Worktree path: ${worktree.path}`,
21486
21509
  };
21487
21510
  }
21511
+ /**
21512
+ * Remove a worktree AND delete the branch it was tracking (#838). The
21513
+ * canonical "I'm done with this side branch" wind-down: removes the
21514
+ * worktree directory, then runs `git branch -d` on the previously
21515
+ * checked-out branch.
21516
+ *
21517
+ * Both pre-flight guards inherit from the underlying helpers:
21518
+ * - removeWorktree refuses the current worktree and dirty worktrees
21519
+ * - deleteBranch refuses the current branch and uses `-d` (safe
21520
+ * delete, refuses unmerged commits)
21521
+ *
21522
+ * Aborts cleanly at any failure point and surfaces a message that
21523
+ * names which step broke. When the worktree had no branch (detached
21524
+ * HEAD) the branch step is silently skipped — there's nothing to
21525
+ * delete and the worktree removal alone counts as success.
21526
+ */
21527
+ async function removeWorktreeAndBranch(git, worktree, branchRefs) {
21528
+ const removeResult = await removeWorktree(git, worktree);
21529
+ if (!removeResult.ok) {
21530
+ return removeResult;
21531
+ }
21532
+ const branchName = worktree.branch;
21533
+ if (!branchName) {
21534
+ return {
21535
+ ok: true,
21536
+ message: `Removed worktree ${worktree.path} (no branch to delete)`,
21537
+ };
21538
+ }
21539
+ // Look up the local BranchRef for the branch this worktree was on.
21540
+ // deleteBranch needs the full ref (not just the name) so its
21541
+ // current-branch and local-only guards apply correctly.
21542
+ const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
21543
+ if (!branch) {
21544
+ return {
21545
+ ok: true,
21546
+ message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
21547
+ };
21548
+ }
21549
+ const deleteResult = await deleteBranch(git, branch);
21550
+ if (!deleteResult.ok) {
21551
+ return {
21552
+ ok: false,
21553
+ message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
21554
+ };
21555
+ }
21556
+ return {
21557
+ ok: true,
21558
+ message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
21559
+ };
21560
+ }
21488
21561
 
21489
21562
  function shortBranch(branch) {
21490
21563
  return branch?.replace(/^refs\/heads\//, '');
@@ -24536,12 +24609,27 @@ function LogInkApp(deps) {
24536
24609
  }, [git, selected?.hash]);
24537
24610
  // #806 follow-up — auto-jump the history view to whichever branch /
24538
24611
  // tag the user is currently cursoring in the sidebar (or the
24539
- // dedicated branches / tags view). Debounced so cursor-scrolling
24540
- // through a long branch list doesn't dispatch on every keystroke.
24612
+ // dedicated branches / tags view).
24613
+ //
24614
+ // Originally this fired on a 150ms trailing-edge debounce. The user
24615
+ // reported the sync feeling inconsistent (#839) — the trailing
24616
+ // pattern means a fast scroll through a long branch list cancels
24617
+ // the timer on every keystroke and only fires once on release; the
24618
+ // user never sees the cursor follow their navigation. Switched to
24619
+ // synchronous fire-on-effect so each cursor move snaps the history
24620
+ // graph immediately. The dispatch is cheap (O(n) findIndex on the
24621
+ // filtered commits + a state spread); React batches the re-renders
24622
+ // so even rapid scroll only paints the final position. Tracks the
24623
+ // last-dispatched hash via a ref so we don't fire setStatus
24624
+ // repeatedly when several adjacent branches all point at the same
24625
+ // commit (very common with squash-merged feature branches that all
24626
+ // converge on `main`'s tip).
24627
+ //
24541
24628
  // No-op when the cursored ref's tip isn't in the loaded commit
24542
24629
  // window (under compact mode the cursored branch's tip may not be
24543
24630
  // fetched yet); a status hint surfaces in that case so the user
24544
24631
  // knows to toggle full graph or load older commits.
24632
+ const lastSyncedHashRef = React.useRef(undefined);
24545
24633
  React.useEffect(() => {
24546
24634
  const onBranchTab = state.activeView === 'branches' ||
24547
24635
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
@@ -24549,58 +24637,58 @@ function LogInkApp(deps) {
24549
24637
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24550
24638
  if (!onBranchTab && !onTagTab)
24551
24639
  return;
24552
- let cancelled = false;
24553
- const timer = setTimeout(() => {
24554
- if (cancelled)
24555
- return;
24556
- let targetHash;
24557
- let targetLabel;
24558
- if (onBranchTab) {
24559
- const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24560
- const visible = state.filter
24561
- ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24562
- : all;
24563
- const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24564
- if (branch) {
24565
- targetHash = branch.hash;
24566
- targetLabel = `branch ${branch.shortName}`;
24567
- }
24568
- }
24569
- else if (onTagTab) {
24570
- const all = sortTags(context.tags?.tags || [], state.tagSort);
24571
- const visible = state.filter
24572
- ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24573
- : all;
24574
- const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24575
- if (tag) {
24576
- targetHash = tag.hash;
24577
- targetLabel = `tag ${tag.name}`;
24578
- }
24579
- }
24580
- if (!targetHash)
24581
- return;
24582
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24583
- if (loaded) {
24584
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
24585
- // Confirmation status message so the user gets feedback even
24586
- // when the dedicated branches / tags view is occupying the
24587
- // main panel and the history cursor moves invisibly behind it.
24588
- dispatch({
24589
- type: 'setStatus',
24590
- value: `Synced history to ${targetLabel} tip`,
24591
- });
24640
+ let targetHash;
24641
+ let targetLabel;
24642
+ if (onBranchTab) {
24643
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24644
+ const visible = state.filter
24645
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24646
+ : all;
24647
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24648
+ if (branch) {
24649
+ targetHash = branch.hash;
24650
+ targetLabel = `branch ${branch.shortName}`;
24592
24651
  }
24593
- else {
24594
- dispatch({
24595
- type: 'setStatus',
24596
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24597
- });
24652
+ }
24653
+ else if (onTagTab) {
24654
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24655
+ const visible = state.filter
24656
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24657
+ : all;
24658
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24659
+ if (tag) {
24660
+ targetHash = tag.hash;
24661
+ targetLabel = `tag ${tag.name}`;
24598
24662
  }
24599
- }, 150);
24600
- return () => {
24601
- cancelled = true;
24602
- clearTimeout(timer);
24603
- };
24663
+ }
24664
+ if (!targetHash)
24665
+ return;
24666
+ // Skip the dispatch + status churn when the cursor hasn't
24667
+ // actually changed which commit it's targeting (the case for
24668
+ // rapid navigation through a cluster of branches that all point
24669
+ // at the same commit). Without this guard the user sees a stream
24670
+ // of "Synced history to <branch> tip" status messages even
24671
+ // though the history cursor never moved.
24672
+ if (targetHash === lastSyncedHashRef.current)
24673
+ return;
24674
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24675
+ if (loaded) {
24676
+ lastSyncedHashRef.current = targetHash;
24677
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24678
+ // Confirmation status message so the user gets feedback even
24679
+ // when the dedicated branches / tags view is occupying the
24680
+ // main panel and the history cursor moves invisibly behind it.
24681
+ dispatch({
24682
+ type: 'setStatus',
24683
+ value: `Synced history to ${targetLabel} tip`,
24684
+ });
24685
+ }
24686
+ else {
24687
+ dispatch({
24688
+ type: 'setStatus',
24689
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24690
+ });
24691
+ }
24604
24692
  }, [
24605
24693
  dispatch, context.branches, context.tags,
24606
24694
  state.activeView, state.focus, state.sidebarTab,
@@ -24608,6 +24696,18 @@ function LogInkApp(deps) {
24608
24696
  state.branchSort, state.tagSort, state.filter,
24609
24697
  state.filteredCommits,
24610
24698
  ]);
24699
+ // Reset the dedup ref when the user moves focus away from the
24700
+ // sidebar branches / tags tab so re-entering re-fires the sync
24701
+ // even if the cursored branch is the same as before.
24702
+ React.useEffect(() => {
24703
+ const onBranchTab = state.activeView === 'branches' ||
24704
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24705
+ const onTagTab = state.activeView === 'tags' ||
24706
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24707
+ if (!onBranchTab && !onTagTab) {
24708
+ lastSyncedHashRef.current = undefined;
24709
+ }
24710
+ }, [state.activeView, state.focus, state.sidebarTab]);
24611
24711
  React.useEffect(() => {
24612
24712
  let active = true;
24613
24713
  async function loadWorktreeDiff() {
@@ -25047,6 +25147,29 @@ function LogInkApp(deps) {
25047
25147
  }
25048
25148
  return removeWorktree(git, cursorTarget);
25049
25149
  },
25150
+ 'remove-worktree-and-branch': async () => {
25151
+ const all = context.worktreeList?.worktrees || [];
25152
+ const visible = state.filter
25153
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25154
+ : all;
25155
+ const cursorTarget = visible.length
25156
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
25157
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
25158
+ if (!cursorTarget)
25159
+ return { ok: false, message: 'No worktree selected' };
25160
+ if (cursorTarget.current) {
25161
+ return {
25162
+ ok: false,
25163
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
25164
+ };
25165
+ }
25166
+ // The chained helper handles the worktree removal AND the
25167
+ // safe branch delete in one call. Branch refs come from the
25168
+ // live context so the underlying deleteBranch helper sees
25169
+ // the current/local flags it needs to refuse the destructive
25170
+ // path on the wrong target.
25171
+ return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
25172
+ },
25050
25173
  'abort-operation': async () => {
25051
25174
  const operation = context.operation?.operation;
25052
25175
  if (!operation) {
@@ -27225,7 +27348,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27225
27348
  }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
27226
27349
  : []), ...(stagedFiles.length
27227
27350
  ? [
27228
- h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
27351
+ // Section header carries the total count to match the status
27352
+ // surface's "▾ Staged (n)" treatment (#840). The visible
27353
+ // file list is sliced at 12 rows; using `worktree.stagedCount`
27354
+ // (the total) avoids a misleading "Staged (12)" label when
27355
+ // there are actually more staged files below the slice.
27356
+ h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
27229
27357
  ...stagedFiles.map((file, index) => h(Text, {
27230
27358
  key: `compose-context-staged-${index}`,
27231
27359
  color: theme.noColor ? undefined : theme.colors.gitAdded,
@@ -27234,7 +27362,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27234
27362
  ]
27235
27363
  : []), ...(unstagedFiles.length
27236
27364
  ? [
27237
- h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
27365
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
27238
27366
  ...unstagedFiles.map((file, index) => h(Text, {
27239
27367
  key: `compose-context-unstaged-${index}`,
27240
27368
  color: theme.noColor ? undefined : theme.colors.gitModified,
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.41.0";
81
+ const BUILD_VERSION = "0.41.1";
82
82
 
83
83
  const isInteractive = (config) => {
84
84
  return config?.mode === 'interactive' || !!config?.interactive;
@@ -14976,6 +14976,19 @@ function getLogInkWorkflowActions() {
14976
14976
  kind: 'destructive',
14977
14977
  requiresConfirmation: true,
14978
14978
  },
14979
+ {
14980
+ // Per-view-only — the inkInput handler scopes this to the
14981
+ // worktrees surface so the global `D` keystroke (delete-branch)
14982
+ // keeps working from elsewhere. The empty `key` keeps the
14983
+ // workflow palette-discoverable but does not register a global
14984
+ // hotkey that would collide with delete-branch.
14985
+ id: 'remove-worktree-and-branch',
14986
+ key: '',
14987
+ label: 'Remove worktree + delete branch',
14988
+ description: 'Remove the selected worktree and delete the branch it was tracking after confirmation.',
14989
+ kind: 'destructive',
14990
+ requiresConfirmation: true,
14991
+ },
14979
14992
  {
14980
14993
  id: 'abort-operation',
14981
14994
  key: 'A',
@@ -18440,6 +18453,16 @@ function getLogInkInputEvents(state, inputValue, key = {}, context = {}) {
18440
18453
  if (inputValue === 'R' && isTagActionTarget(state) && context.tagCount) {
18441
18454
  return [action({ type: 'setPendingConfirmation', value: 'delete-remote-tag' })];
18442
18455
  }
18456
+ // Per-view worktree action: `D` removes the worktree AND deletes
18457
+ // the branch it was tracking (#838). Scoped to the worktrees
18458
+ // surface so it intercepts BEFORE the global workflow-by-key
18459
+ // dispatcher would otherwise route `D` to delete-branch (which
18460
+ // would silently target whatever was last cursored on the branches
18461
+ // surface instead of acting on the worktree under the cursor here).
18462
+ // `W` keeps its existing "remove worktree only" semantics.
18463
+ if (inputValue === 'D' && isWorktreeActionTarget(state) && context.worktreeListCount) {
18464
+ return [action({ type: 'setPendingConfirmation', value: 'remove-worktree-and-branch' })];
18465
+ }
18443
18466
  // #783 — full PR action panel keys, scoped to the pull-request view.
18444
18467
  // All five wrap a `gh pr <verb>` invocation; merge / request-changes /
18445
18468
  // comment open prompts first, the rest route through the y-confirm
@@ -21510,6 +21533,56 @@ function worktreePathAction(worktree) {
21510
21533
  message: `Worktree path: ${worktree.path}`,
21511
21534
  };
21512
21535
  }
21536
+ /**
21537
+ * Remove a worktree AND delete the branch it was tracking (#838). The
21538
+ * canonical "I'm done with this side branch" wind-down: removes the
21539
+ * worktree directory, then runs `git branch -d` on the previously
21540
+ * checked-out branch.
21541
+ *
21542
+ * Both pre-flight guards inherit from the underlying helpers:
21543
+ * - removeWorktree refuses the current worktree and dirty worktrees
21544
+ * - deleteBranch refuses the current branch and uses `-d` (safe
21545
+ * delete, refuses unmerged commits)
21546
+ *
21547
+ * Aborts cleanly at any failure point and surfaces a message that
21548
+ * names which step broke. When the worktree had no branch (detached
21549
+ * HEAD) the branch step is silently skipped — there's nothing to
21550
+ * delete and the worktree removal alone counts as success.
21551
+ */
21552
+ async function removeWorktreeAndBranch(git, worktree, branchRefs) {
21553
+ const removeResult = await removeWorktree(git, worktree);
21554
+ if (!removeResult.ok) {
21555
+ return removeResult;
21556
+ }
21557
+ const branchName = worktree.branch;
21558
+ if (!branchName) {
21559
+ return {
21560
+ ok: true,
21561
+ message: `Removed worktree ${worktree.path} (no branch to delete)`,
21562
+ };
21563
+ }
21564
+ // Look up the local BranchRef for the branch this worktree was on.
21565
+ // deleteBranch needs the full ref (not just the name) so its
21566
+ // current-branch and local-only guards apply correctly.
21567
+ const branch = branchRefs.find((entry) => entry.type === 'local' && entry.shortName === branchName);
21568
+ if (!branch) {
21569
+ return {
21570
+ ok: true,
21571
+ message: `Removed worktree ${worktree.path} (branch ${branchName} not found in local branches)`,
21572
+ };
21573
+ }
21574
+ const deleteResult = await deleteBranch(git, branch);
21575
+ if (!deleteResult.ok) {
21576
+ return {
21577
+ ok: false,
21578
+ message: `Removed worktree ${worktree.path}, but branch delete failed: ${deleteResult.message}`,
21579
+ };
21580
+ }
21581
+ return {
21582
+ ok: true,
21583
+ message: `Removed worktree ${worktree.path} and deleted branch ${branchName}`,
21584
+ };
21585
+ }
21513
21586
 
21514
21587
  function shortBranch(branch) {
21515
21588
  return branch?.replace(/^refs\/heads\//, '');
@@ -24561,12 +24634,27 @@ function LogInkApp(deps) {
24561
24634
  }, [git, selected?.hash]);
24562
24635
  // #806 follow-up — auto-jump the history view to whichever branch /
24563
24636
  // tag the user is currently cursoring in the sidebar (or the
24564
- // dedicated branches / tags view). Debounced so cursor-scrolling
24565
- // through a long branch list doesn't dispatch on every keystroke.
24637
+ // dedicated branches / tags view).
24638
+ //
24639
+ // Originally this fired on a 150ms trailing-edge debounce. The user
24640
+ // reported the sync feeling inconsistent (#839) — the trailing
24641
+ // pattern means a fast scroll through a long branch list cancels
24642
+ // the timer on every keystroke and only fires once on release; the
24643
+ // user never sees the cursor follow their navigation. Switched to
24644
+ // synchronous fire-on-effect so each cursor move snaps the history
24645
+ // graph immediately. The dispatch is cheap (O(n) findIndex on the
24646
+ // filtered commits + a state spread); React batches the re-renders
24647
+ // so even rapid scroll only paints the final position. Tracks the
24648
+ // last-dispatched hash via a ref so we don't fire setStatus
24649
+ // repeatedly when several adjacent branches all point at the same
24650
+ // commit (very common with squash-merged feature branches that all
24651
+ // converge on `main`'s tip).
24652
+ //
24566
24653
  // No-op when the cursored ref's tip isn't in the loaded commit
24567
24654
  // window (under compact mode the cursored branch's tip may not be
24568
24655
  // fetched yet); a status hint surfaces in that case so the user
24569
24656
  // knows to toggle full graph or load older commits.
24657
+ const lastSyncedHashRef = React.useRef(undefined);
24570
24658
  React.useEffect(() => {
24571
24659
  const onBranchTab = state.activeView === 'branches' ||
24572
24660
  (state.focus === 'sidebar' && state.sidebarTab === 'branches');
@@ -24574,58 +24662,58 @@ function LogInkApp(deps) {
24574
24662
  (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24575
24663
  if (!onBranchTab && !onTagTab)
24576
24664
  return;
24577
- let cancelled = false;
24578
- const timer = setTimeout(() => {
24579
- if (cancelled)
24580
- return;
24581
- let targetHash;
24582
- let targetLabel;
24583
- if (onBranchTab) {
24584
- const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24585
- const visible = state.filter
24586
- ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24587
- : all;
24588
- const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24589
- if (branch) {
24590
- targetHash = branch.hash;
24591
- targetLabel = `branch ${branch.shortName}`;
24592
- }
24593
- }
24594
- else if (onTagTab) {
24595
- const all = sortTags(context.tags?.tags || [], state.tagSort);
24596
- const visible = state.filter
24597
- ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24598
- : all;
24599
- const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24600
- if (tag) {
24601
- targetHash = tag.hash;
24602
- targetLabel = `tag ${tag.name}`;
24603
- }
24604
- }
24605
- if (!targetHash)
24606
- return;
24607
- const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24608
- if (loaded) {
24609
- dispatch({ type: 'selectCommitByHash', hash: targetHash });
24610
- // Confirmation status message so the user gets feedback even
24611
- // when the dedicated branches / tags view is occupying the
24612
- // main panel and the history cursor moves invisibly behind it.
24613
- dispatch({
24614
- type: 'setStatus',
24615
- value: `Synced history to ${targetLabel} tip`,
24616
- });
24665
+ let targetHash;
24666
+ let targetLabel;
24667
+ if (onBranchTab) {
24668
+ const all = sortBranches(context.branches?.localBranches || [], state.branchSort);
24669
+ const visible = state.filter
24670
+ ? all.filter((b) => matchesPromotedFilter([b.shortName, b.upstream || ''], state.filter))
24671
+ : all;
24672
+ const branch = visible[Math.min(state.selectedBranchIndex, Math.max(0, visible.length - 1))];
24673
+ if (branch) {
24674
+ targetHash = branch.hash;
24675
+ targetLabel = `branch ${branch.shortName}`;
24617
24676
  }
24618
- else {
24619
- dispatch({
24620
- type: 'setStatus',
24621
- value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24622
- });
24677
+ }
24678
+ else if (onTagTab) {
24679
+ const all = sortTags(context.tags?.tags || [], state.tagSort);
24680
+ const visible = state.filter
24681
+ ? all.filter((t) => matchesPromotedFilter([t.name, t.subject], state.filter))
24682
+ : all;
24683
+ const tag = visible[Math.min(state.selectedTagIndex, Math.max(0, visible.length - 1))];
24684
+ if (tag) {
24685
+ targetHash = tag.hash;
24686
+ targetLabel = `tag ${tag.name}`;
24623
24687
  }
24624
- }, 150);
24625
- return () => {
24626
- cancelled = true;
24627
- clearTimeout(timer);
24628
- };
24688
+ }
24689
+ if (!targetHash)
24690
+ return;
24691
+ // Skip the dispatch + status churn when the cursor hasn't
24692
+ // actually changed which commit it's targeting (the case for
24693
+ // rapid navigation through a cluster of branches that all point
24694
+ // at the same commit). Without this guard the user sees a stream
24695
+ // of "Synced history to <branch> tip" status messages even
24696
+ // though the history cursor never moved.
24697
+ if (targetHash === lastSyncedHashRef.current)
24698
+ return;
24699
+ const loaded = state.filteredCommits.some((commit) => commit.hash === targetHash || commit.shortHash === targetHash);
24700
+ if (loaded) {
24701
+ lastSyncedHashRef.current = targetHash;
24702
+ dispatch({ type: 'selectCommitByHash', hash: targetHash });
24703
+ // Confirmation status message so the user gets feedback even
24704
+ // when the dedicated branches / tags view is occupying the
24705
+ // main panel and the history cursor moves invisibly behind it.
24706
+ dispatch({
24707
+ type: 'setStatus',
24708
+ value: `Synced history to ${targetLabel} tip`,
24709
+ });
24710
+ }
24711
+ else {
24712
+ dispatch({
24713
+ type: 'setStatus',
24714
+ value: `${targetLabel} tip not in loaded window — press \\ for full graph or Ctrl+L to load more`,
24715
+ });
24716
+ }
24629
24717
  }, [
24630
24718
  dispatch, context.branches, context.tags,
24631
24719
  state.activeView, state.focus, state.sidebarTab,
@@ -24633,6 +24721,18 @@ function LogInkApp(deps) {
24633
24721
  state.branchSort, state.tagSort, state.filter,
24634
24722
  state.filteredCommits,
24635
24723
  ]);
24724
+ // Reset the dedup ref when the user moves focus away from the
24725
+ // sidebar branches / tags tab so re-entering re-fires the sync
24726
+ // even if the cursored branch is the same as before.
24727
+ React.useEffect(() => {
24728
+ const onBranchTab = state.activeView === 'branches' ||
24729
+ (state.focus === 'sidebar' && state.sidebarTab === 'branches');
24730
+ const onTagTab = state.activeView === 'tags' ||
24731
+ (state.focus === 'sidebar' && state.sidebarTab === 'tags');
24732
+ if (!onBranchTab && !onTagTab) {
24733
+ lastSyncedHashRef.current = undefined;
24734
+ }
24735
+ }, [state.activeView, state.focus, state.sidebarTab]);
24636
24736
  React.useEffect(() => {
24637
24737
  let active = true;
24638
24738
  async function loadWorktreeDiff() {
@@ -25072,6 +25172,29 @@ function LogInkApp(deps) {
25072
25172
  }
25073
25173
  return removeWorktree(git, cursorTarget);
25074
25174
  },
25175
+ 'remove-worktree-and-branch': async () => {
25176
+ const all = context.worktreeList?.worktrees || [];
25177
+ const visible = state.filter
25178
+ ? all.filter((w) => matchesPromotedFilter([w.path, w.branch || ''], state.filter))
25179
+ : all;
25180
+ const cursorTarget = visible.length
25181
+ ? visible[Math.min(state.selectedWorktreeListIndex, visible.length - 1)]
25182
+ : all[Math.min(state.selectedWorktreeListIndex, Math.max(0, all.length - 1))];
25183
+ if (!cursorTarget)
25184
+ return { ok: false, message: 'No worktree selected' };
25185
+ if (cursorTarget.current) {
25186
+ return {
25187
+ ok: false,
25188
+ message: 'Cannot remove the current worktree — switch to another worktree first.',
25189
+ };
25190
+ }
25191
+ // The chained helper handles the worktree removal AND the
25192
+ // safe branch delete in one call. Branch refs come from the
25193
+ // live context so the underlying deleteBranch helper sees
25194
+ // the current/local flags it needs to refuse the destructive
25195
+ // path on the wrong target.
25196
+ return removeWorktreeAndBranch(git, cursorTarget, context.branches?.localBranches || []);
25197
+ },
25075
25198
  'abort-operation': async () => {
25076
25199
  const operation = context.operation?.operation;
25077
25200
  if (!operation) {
@@ -27250,7 +27373,12 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27250
27373
  }, truncate$1(theme.ascii ? '[...] AI draft in progress' : '⏳ AI draft in progress', width - 4))]
27251
27374
  : []), ...(stagedFiles.length
27252
27375
  ? [
27253
- h(Text, { key: 'compose-context-staged-title', bold: true }, 'Staged'),
27376
+ // Section header carries the total count to match the status
27377
+ // surface's "▾ Staged (n)" treatment (#840). The visible
27378
+ // file list is sliced at 12 rows; using `worktree.stagedCount`
27379
+ // (the total) avoids a misleading "Staged (12)" label when
27380
+ // there are actually more staged files below the slice.
27381
+ h(Text, { key: 'compose-context-staged-title', bold: true }, `Staged (${worktree?.stagedCount ?? stagedFiles.length})`),
27254
27382
  ...stagedFiles.map((file, index) => h(Text, {
27255
27383
  key: `compose-context-staged-${index}`,
27256
27384
  color: theme.noColor ? undefined : theme.colors.gitAdded,
@@ -27259,7 +27387,7 @@ function renderComposeContextPanel(h, components, state, context, contextStatus,
27259
27387
  ]
27260
27388
  : []), ...(unstagedFiles.length
27261
27389
  ? [
27262
- h(Text, { key: 'compose-context-unstaged-title', bold: true }, 'Unstaged'),
27390
+ h(Text, { key: 'compose-context-unstaged-title', bold: true }, `Unstaged (${worktree?.unstagedCount ?? unstagedFiles.length})`),
27263
27391
  ...unstagedFiles.map((file, index) => h(Text, {
27264
27392
  key: `compose-context-unstaged-${index}`,
27265
27393
  color: theme.noColor ? undefined : theme.colors.gitModified,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-coco",
3
- "version": "0.41.0",
3
+ "version": "0.41.1",
4
4
  "description": "zero-effort git commits with coco.",
5
5
  "author": "gfargo <ghfargo@gmail.com>",
6
6
  "license": "MIT",