gwm-cli 1.0.3

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! Command palette (issue #32).
//!
//! Pressing `:` (the default binding of [`Action::CommandPalette`])
//! opens a single-line input at the bottom of the TUI. The user types
//! a verb (`create`, `delete`, `bootstrap`, …); a fuzzy-matched menu
//! above the input surfaces the candidates with their one-line
//! descriptions. `Enter` fires the highlighted action, `Esc` cancels,
//! `Tab` / arrow keys cycle the highlight.
//!
//! The palette and the help overlay share a single registry
//! ([`palette_entries`]) so neither surface can quietly drift from
//! the other: an `Action` variant that exists in `keymap::ACTIONS`
//! but not here would be reachable by key only, and vice versa.
//! `registry_covers_every_action_variant` in
//! `tests/palette_tests.rs` is the tripwire.

use super::keymap::Action;
use nucleo_matcher::{
  pattern::{CaseMatching, Normalization, Pattern},
  Config as NucleoConfig, Matcher, Utf32Str,
};

// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------

/// One row in the palette's command registry. Stable across runs;
/// `name` is what the user types after `:`, `description` is the
/// one-line gloss shown next to it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PaletteEntry {
  pub action: Action,
  pub name: &'static str,
  pub description: &'static str,
}

/// Full palette registry. Order is the suggestion order used when
/// the input buffer is empty (the user pressed `:` and hasn't typed
/// yet) — most-frequent actions first so the top entries on screen
/// are also the ones a user with no specific verb in mind is most
/// likely to want.
pub const fn palette_entries() -> &'static [PaletteEntry] {
  &[
    PaletteEntry {
      action: Action::Create,
      name: "create",
      description: "new worktree (form opens)",
    },
    PaletteEntry {
      action: Action::DeleteConfirm,
      name: "delete",
      description: "delete the selected worktree (with confirm)",
    },
    PaletteEntry {
      action: Action::Bootstrap,
      name: "bootstrap",
      description: "re-run bootstrap on the selected worktree",
    },
    PaletteEntry {
      action: Action::Refresh,
      name: "refresh",
      description: "refresh the worktree list",
    },
    PaletteEntry {
      action: Action::Sync,
      name: "sync",
      description: "sync the selected worktree onto its upstream (rebase)",
    },
    PaletteEntry {
      action: Action::Pull,
      name: "pull",
      description: "pull the selected worktree's branch from its upstream",
    },
    PaletteEntry {
      action: Action::Push,
      name: "push",
      description: "push the selected worktree's branch to its remote",
    },
    PaletteEntry {
      action: Action::EditWorktree,
      name: "edit-worktree",
      description: "rename the selected worktree's branch",
    },
    PaletteEntry {
      action: Action::ExitToWorktree,
      name: "exit-to-worktree",
      description: "quit the TUI and cd to the selected worktree path",
    },
    PaletteEntry {
      action: Action::TerminalFullscreen,
      name: "terminal-fullscreen",
      description: "open a native $SHELL fullscreen (suspend TUI)",
    },
    PaletteEntry {
      action: Action::BrowseLinks,
      name: "browse-links",
      description: "open the issue/PR URL browser menu",
    },
    PaletteEntry {
      action: Action::OpenDocs,
      name: "open-docs",
      description: "open the gwm documentation in the browser",
    },
    PaletteEntry {
      action: Action::LinkPrompt,
      name: "link",
      description: "link the selected worktree to an issue or PR",
    },
    PaletteEntry {
      action: Action::FetchGithub,
      name: "fetch-github",
      description: "refresh GitHub issue/PR status via `gh`",
    },
    PaletteEntry {
      action: Action::LazyGitFullscreen,
      name: "lazygit-fullscreen",
      description: "open lazygit fullscreen (suspends TUI)",
    },
    PaletteEntry {
      action: Action::LazyGitPty,
      name: "lazygit-pty",
      description: "open lazygit in an embedded PTY overlay",
    },
    PaletteEntry {
      action: Action::TerminalPty,
      name: "terminal-pty",
      description: "open a native $SHELL in an embedded PTY overlay",
    },
    PaletteEntry {
      action: Action::ReviewFullscreen,
      name: "review-fullscreen",
      description: "run the [review] launcher fullscreen",
    },
    PaletteEntry {
      action: Action::ReviewPty,
      name: "review-pty",
      description: "run the [review] launcher in an embedded PTY overlay",
    },
    PaletteEntry {
      action: Action::YankPath,
      name: "yank-path",
      description: "yank the selected worktree path to the clipboard",
    },
    PaletteEntry {
      action: Action::YankBranchName,
      name: "yank-branch-name",
      description: "yank the selected worktree's branch name to the clipboard",
    },
    PaletteEntry {
      action: Action::YankWorktreeName,
      name: "yank-worktree-name",
      description: "yank the selected worktree's slug/name to the clipboard",
    },
    PaletteEntry {
      action: Action::Filter,
      name: "filter",
      description: "open the fuzzy filter bar",
    },
    PaletteEntry {
      action: Action::ToggleSidebar,
      name: "toggle-sidebar",
      description: "toggle the git preview sidebar",
    },
    PaletteEntry {
      action: Action::ToggleSidebarMode,
      name: "toggle-sidebar-mode",
      description: "cycle the sidebar between commits and stashes",
    },
    PaletteEntry {
      action: Action::CycleSidebarLayout,
      name: "cycle-sidebar-layout",
      description: "cycle the sidebar layout (auto / side-by-side / stacked)",
    },
    PaletteEntry {
      action: Action::ToggleSidebarPosition,
      name: "toggle-sidebar-position",
      description: "toggle the sidebar position (left / right)",
    },
    PaletteEntry {
      action: Action::ToggleDeleteBranch,
      name: "toggle-delete-branch",
      description: "toggle whether `delete` also drops the branch",
    },
    PaletteEntry {
      action: Action::FocusSwap,
      name: "focus-swap",
      description: "swap focus between worktree list and sidebar",
    },
    PaletteEntry {
      action: Action::FocusWorktrees,
      name: "focus-worktrees",
      description: "focus the worktrees pane",
    },
    PaletteEntry {
      action: Action::FocusStatus,
      name: "focus-status",
      description: "focus the status pane (opens it if hidden)",
    },
    PaletteEntry {
      action: Action::Top,
      name: "top",
      description: "jump to the first worktree",
    },
    PaletteEntry {
      action: Action::Bottom,
      name: "bottom",
      description: "jump to the last worktree",
    },
    PaletteEntry {
      action: Action::Down,
      name: "down",
      description: "select the next worktree",
    },
    PaletteEntry {
      action: Action::Up,
      name: "up",
      description: "select the previous worktree",
    },
    PaletteEntry {
      action: Action::CommandLogs,
      name: "command-logs",
      description: "show the command logs overlay",
    },
    PaletteEntry {
      action: Action::ConfigPanel,
      name: "config-panel",
      description: "show the resolved configuration panel",
    },
    PaletteEntry {
      action: Action::ExecOverlay,
      name: "exec",
      description: "pick an [exec.profiles] profile and run it in a PTY",
    },
    PaletteEntry {
      action: Action::CleanOverlay,
      name: "clean",
      description: "preview and reclaim build artifacts in the selected worktree",
    },
    PaletteEntry {
      action: Action::MuxPane,
      name: "mux-pane",
      description: "open the selected worktree in a new multiplexer pane/tab",
    },
    PaletteEntry {
      action: Action::Macro1,
      name: "macro_one",
      description: "run the user-configured [tui.macro1] command",
    },
    PaletteEntry {
      action: Action::Macro2,
      name: "macro_two",
      description: "run the user-configured [tui.macro2] command",
    },
    PaletteEntry {
      action: Action::Help,
      name: "help",
      description: "show the help overlay",
    },
    PaletteEntry {
      action: Action::Quit,
      name: "quit",
      description: "quit the TUI",
    },
    // Reflective entry — opens the palette itself. Useful if the
    // user remaps `:` and forgets the new chord; typing it through
    // the palette still works (assuming they can reach the palette
    // another way, e.g. via a separate binding pointing at the same
    // Action).
    PaletteEntry {
      action: Action::CommandPalette,
      name: "command-palette",
      description: "this command palette",
    },
  ]
}

// ---------------------------------------------------------------------------
// State machine
// ---------------------------------------------------------------------------

/// Pure state for the palette overlay. Owns the input buffer, the
/// fuzzy-filtered match list (cached per buffer change), and the
/// highlight index. No terminal or ratatui dependency so the event
/// loop in `src/tui/mod.rs` can drive it through method calls and
/// `tests/palette_tests.rs` can pin behaviour without spawning a TUI.
#[derive(Debug)]
pub struct PaletteState {
  pub open: bool,
  buffer: String,
  /// Cached indices into `palette_entries()` matching `buffer`.
  /// Rebuilt by [`Self::recompute_matches`] whenever the buffer
  /// changes. Empty when `buffer` is non-empty and no entry fuzzy-
  /// matches; equal to `0..palette_entries().len()` when the buffer
  /// is empty.
  matches: Vec<usize>,
  highlight: usize,
}

impl PaletteState {
  pub fn new() -> Self {
    let mut s = Self {
      open: false,
      buffer: String::new(),
      matches: Vec::new(),
      highlight: 0,
    };
    s.recompute_matches();
    s
  }

  pub fn buffer(&self) -> &str {
    &self.buffer
  }

  pub fn highlight(&self) -> usize {
    self.highlight
  }

  /// Currently-visible entries in match-rank order. Whatever the
  /// renderer paints is exactly this list — sharing the slice keeps
  /// the highlight index meaningful in both contexts.
  pub fn matches(&self) -> Vec<&'static PaletteEntry> {
    self.matches.iter().map(|&i| &palette_entries()[i]).collect()
  }

  /// Open the palette with an empty buffer and the full registry
  /// visible. Called from the event loop when `:` (the default
  /// binding of `Action::CommandPalette`) fires.
  pub fn open(&mut self) {
    self.open = true;
    self.buffer.clear();
    self.highlight = 0;
    self.recompute_matches();
  }

  /// Close the palette without firing anything. Called on `Esc` and
  /// after a successful [`Self::accept`].
  pub fn close(&mut self) {
    self.open = false;
    self.buffer.clear();
    self.highlight = 0;
    self.recompute_matches();
  }

  pub fn push_char(&mut self, c: char) {
    self.buffer.push(c);
    self.recompute_matches();
    self.highlight = 0;
  }

  pub fn pop_char(&mut self) {
    self.buffer.pop();
    self.recompute_matches();
    self.highlight = 0;
  }

  /// Move the highlight one row down, wrapping to the top when it
  /// runs off the end of the visible matches. No-op when the match
  /// list is empty.
  pub fn cycle_highlight_down(&mut self) {
    if self.matches.is_empty() {
      return;
    }
    self.highlight = (self.highlight + 1) % self.matches.len();
  }

  /// Move the highlight one row up, wrapping to the bottom on
  /// underflow. No-op when the match list is empty.
  pub fn cycle_highlight_up(&mut self) {
    if self.matches.is_empty() {
      return;
    }
    self.highlight = if self.highlight == 0 {
      self.matches.len() - 1
    } else {
      self.highlight - 1
    };
  }

  /// Fire the highlighted entry. Returns its `Action` and closes the
  /// palette on success; returns `None` and leaves the palette open
  /// when there is no match (the user typed something that filters
  /// every entry out — better to let them backspace than to silently
  /// drop the keystroke).
  pub fn accept(&mut self) -> Option<Action> {
    if self.matches.is_empty() {
      return None;
    }
    let idx = *self.matches.get(self.highlight)?;
    let action = palette_entries()[idx].action;
    self.close();
    Some(action)
  }

  fn recompute_matches(&mut self) {
    let registry = palette_entries();
    if self.buffer.is_empty() {
      self.matches = (0..registry.len()).collect();
      return;
    }
    // Reuse the same nucleo matcher configuration the worktree
    // filter uses (smart case, smart normalisation) so the palette
    // and the `/` filter rank identical queries identically.
    let pattern = Pattern::parse(&self.buffer, CaseMatching::Smart, Normalization::Smart);
    let mut matcher = Matcher::new(NucleoConfig::DEFAULT);
    let mut buf: Vec<char> = Vec::new();
    let mut scored: Vec<(u32, usize)> = Vec::with_capacity(registry.len());
    for (i, entry) in registry.iter().enumerate() {
      let hay = Utf32Str::new(entry.name, &mut buf);
      if let Some(score) = pattern.score(hay, &mut matcher) {
        scored.push((score, i));
      }
    }
    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
    self.matches = scored.into_iter().map(|(_, i)| i).collect();
  }
}

impl Default for PaletteState {
  fn default() -> Self {
    Self::new()
  }
}