gitwig 2.6.7

a rust based tui, an alternative to sourcetree and gitui
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
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Central keybindings help overlay listing shortcuts grouped by application modes.

use crate::app::{App, Mode};
use crate::keybindings::Action;
use crate::repo::RemoteInfo;
use crate::ui::layout::{centered_rect, centered_rect_fixed};
use crate::ui::style::{
    ACCENT, CARD_BORDER, DANGER, SUCCESS, WARNING, accent_style, muted_style, parse_color,
    primary_style,
};
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
    Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Padding, Paragraph, Wrap,
};

fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
    let mut lines = Vec::new();
    let mut current_line = String::new();

    for word in text.split_whitespace() {
        if current_line.is_empty() {
            current_line.push_str(word);
        } else if current_line.chars().count() + 1 + word.chars().count() <= max_width {
            current_line.push(' ');
            current_line.push_str(word);
        } else {
            lines.push(current_line);
            current_line = word.to_string();
        }
    }
    if !current_line.is_empty() {
        lines.push(current_line);
    }
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

/// Caption for a navigation-style action in the help overlays: every bound
/// key, arrow and paging names rendered the way the overlays present them
/// (`↑ [Up]`, `⇞ [PgUp]`, ...) and Enter/Tab/Esc mapped like
/// [`KeybindingsConfig::format_action_keys`]. Keys still come from the live
/// keybindings, so a rebinding shows up here instead of a stale literal.
pub(crate) fn nav_key_caption(
    kb: &crate::keybindings::KeybindingsConfig,
    action: Action,
    compat: bool,
) -> String {
    let keys = kb.get_action_keys(action);
    if keys.is_empty() {
        return "-".to_string();
    }
    keys.iter()
        .map(|k| {
            let name = match (k.as_str(), compat) {
                ("up", false) => "↑ [Up]",
                ("up", true) => "Up",
                ("down", false) => "↓ [Down]",
                ("down", true) => "Down",
                ("left", false) => "← [Left]",
                ("left", true) => "Left",
                ("right", false) => "→ [Right]",
                ("right", true) => "Right",
                ("pageup" | "pgup", false) => "⇞ [PgUp]",
                ("pageup" | "pgup", true) => "PgUp",
                ("pagedown" | "pgdn", false) => "⇟ [PgDn]",
                ("pagedown" | "pgdn", true) => "PgDn",
                ("home", _) => "Home",
                ("end", _) => "End",
                ("space", _) => "Space",
                ("delete" | "del", _) => "Del",
                ("backspace", false) => "⌫",
                ("backspace", true) => "Backspace",
                ("enter" | "return", false) => "↵",
                ("enter" | "return", true) => "Enter",
                ("esc" | "escape", false) => "⎋",
                ("esc" | "escape", true) => "Esc",
                ("tab", false) => "⇥",
                ("tab", true) => "Tab",
                ("backtab" | "shift-tab", false) => "⇧⇥",
                ("backtab" | "shift-tab", true) => "Shift+Tab",
                _ => k.as_str(),
            };
            name.to_string()
        })
        .collect::<Vec<_>>()
        .join(" / ")
}

pub fn get_help_lines(app: &App, usable_width: usize) -> Vec<Line<'_>> {
    let mut lines = Vec::new();
    let is_compat = app.config.compatibility_mode;

    let format_key = |k: &str| {
        let mut key = k.to_string();
        if is_compat {
            key = key
                .replace("↑", "^")
                .replace("↓", "v")
                .replace("⇟", "PgDn")
                .replace("⇞", "PgUp")
                .replace("↵", "Enter")
                .replace("→", ">")
                .replace("⎋", "Esc")
                .replace("⌫", "Backspace")
                .replace("⇥", "Tab")
                .replace("⇧⇥", "Shift+Tab")
                .replace("⇧", "Shift+");
        }
        key
    };

    let kb = &app.keybindings;

    let help_key = kb.format_action_keys(Action::Help, is_compat);
    let about_key = kb.format_action_keys(Action::HomeAbout, is_compat);
    let legend_key = kb.format_action_keys(Action::HomeSymbolsHelp, is_compat);
    let close_key = kb.format_action_keys(Action::CloseDetail, is_compat);
    let quit_key = kb.format_action_keys(Action::Close, is_compat);

    let add_key = kb.format_action_keys(Action::HomeAddRepo, is_compat);
    let bulk_add_key = kb.format_action_keys(Action::HomeBulkAdd, is_compat);
    let import_key = kb.format_action_keys(Action::HomeImportRepo, is_compat);
    let edit_key = kb.format_action_keys(Action::HomeEditRepo, is_compat);
    let delete_key = kb.format_action_keys(Action::HomeDeleteRepo, is_compat);
    let search_key = kb.format_action_keys(Action::HomeSearchRepo, is_compat);
    let refresh_key = kb.format_action_keys(Action::HomeRefresh, is_compat);
    let sort_key = format!(
        "{}/{}",
        kb.format_action_keys(Action::HomeCycleSort, is_compat),
        kb.format_action_keys(Action::HomeToggleSortReverse, is_compat)
    );
    let compact_key = kb.format_action_keys(Action::HomeCycleViewMode, is_compat);
    let pin_key = kb.format_action_keys(Action::HomeTogglePin, is_compat);
    let settings_key = kb.format_action_keys(Action::HomeOpenSettings, is_compat);
    let labels_key = kb.format_action_keys(Action::HomeEditLabels, is_compat);
    let debug_key = kb.format_action_keys(Action::HomeOpenDebugLogs, is_compat);
    let update_key = kb.format_action_keys(Action::HomeCheckUpdate, is_compat);

    let open_detail_key = kb.format_action_keys(Action::HomeOpenDetail, is_compat);

    // New actions
    let terminal_key = kb.format_action_keys(Action::HomeOpenTerminal, is_compat);
    let external_shell_key = kb.format_action_keys(Action::HomeOpenExternalShell, is_compat);
    let terminal_toggle_key = kb.format_action_keys(Action::ToggleTerminalPanel, is_compat);
    let palette_key = kb.format_action_keys(Action::CommandPalette, is_compat);
    let star_key = kb.format_action_keys(Action::HomeToggleStar, is_compat);
    let yank_key = kb.format_action_keys(Action::HomeYankPath, is_compat);
    let jump_picker_key = kb.format_action_keys(Action::HomeJumpPicker, is_compat);
    let fetch_all_key = kb.format_action_keys(Action::HomeFetchAll, is_compat);
    let fetch_details_key = kb.format_action_keys(Action::HomeFetchDetails, is_compat);
    let select_key = kb.format_action_keys(Action::HomeSelect, is_compat);
    let global_search_key = kb.format_action_keys(Action::HomeGlobalSearch, is_compat);
    let stats_key = kb.format_action_keys(Action::HomeOpenStatsDashboard, is_compat);
    let cycle_filter_key = format!(
        "{}/{}",
        kb.format_action_keys(Action::HomeCycleFilter, is_compat),
        kb.format_action_keys(Action::HomeCycleFilterBack, is_compat)
    );
    let label_picker_key = kb.format_action_keys(Action::HomeLabelPicker, is_compat);
    let label_slot_keys = kb.format_label_slot_keys(is_compat);

    // The home list navigation keys are configurable (home.move_up etc.), so
    // the rows are built from the live bindings with the arrow glyphs the
    // overlay uses for its fixed rows.
    let nav = |action: Action| nav_key_caption(kb, action, is_compat);
    let move_up_key = nav(Action::HomeMoveUp);
    let move_down_key = nav(Action::HomeMoveDown);
    let page_up_key = nav(Action::HomePageUp);
    let page_down_key = nav(Action::HomePageDown);
    let home_key = nav(Action::HomeHome);
    let end_key = nav(Action::HomeEnd);
    let debug_page_key = format!("{} / {}", nav(Action::NavPageUp), nav(Action::NavPageDown));
    let debug_jump_key = format!("{} / {}", nav(Action::NavHome), nav(Action::NavEnd));

    let make_cat = |title: &'static str,
                    items: Vec<(&str, &'static str)>|
     -> (&str, Vec<(String, &'static str)>) {
        let mapped = items.into_iter().map(|(k, d)| (k.to_string(), d)).collect();
        (title, mapped)
    };

    let mut categories: Vec<(&str, Vec<(String, &str)>)> = vec![
        (
            "Global & Navigation",
            vec![
                (move_up_key, "Move selection up / scroll up"),
                (move_down_key, "Move selection down / scroll down"),
                (page_up_key, "Jump one page up"),
                (page_down_key, "Jump one page down"),
                (home_key, "Go to top / scroll to top"),
                (end_key, "Go to bottom / scroll to bottom"),
                ("⎋ [Esc]".to_string(), "Cancel input, clear search/filter, or cancel selections"),
                (close_key, "Close dialog / leave detail view"),
                (
                    kb.format_action_keys(Action::ToggleStatusBar, is_compat),
                    "Toggle status bar visibility",
                ),
                (terminal_toggle_key, "Toggle the embedded terminal panel (also unfocuses it)"),
                (palette_key, "Open the command palette: run any action by name"),
                ("Shift+PgUp/PgDn".to_string(), "Scroll terminal panel history (while focused)"),
                (help_key, "Toggle this help overlay"),
                (about_key, "Show about popup / creator profile"),
                (legend_key, "Show signs & symbols legend popup"),
                (quit_key, "Quit application"),
            ],
        ),
        (
            "Main List Operations",
            vec![
                (open_detail_key, "Open Detail view for selected repository"),
                (add_key, "Add a new repository"),
                (bulk_add_key, "Bulk add folders in a directory"),
                (import_key, "Import remote repository"),
                (edit_key, "Edit selected item"),
                (delete_key, "Delete selected item"),
                (search_key, "Enter repository search mode"),
                (cycle_filter_key, "Cycle summary filter (repos/dirty/ahead/stale)"),
                (label_picker_key, "Pick a label to filter the list (sticky until deselected)"),
                (
                    label_slot_keys,
                    "Filter by quick-label slot (numbered chips under the summary bar, filled in the order labels are first viewed; press again to clear)",
                ),
                (
                    "→ [in label picker]".to_string(),
                    "Open Label Settings for the highlighted label (shared by its repos)",
                ),
                (global_search_key, "Open global code search popup overlay"),
                (refresh_key, "Refresh status of selected item"),
                (fetch_all_key, "Bulk fetch all tracked repositories concurrently"),
                (fetch_details_key, "Show why the selected repository failed to fetch"),
                (
                    sort_key,
                    "Cycle sorting mode / Toggle reverse sorting (the active label's own sort when its Label Settings set one)",
                ),
                (
                    compact_key,
                    "Cycle between standard cards, compact 1-row view, and tile grid view (the active label's own layout when its Label Settings set one)",
                ),
                (
                    pin_key,
                    "Toggle pin status of selected item (pinned repos stay at the top, above a thin rule)",
                ),
                (star_key, "Toggle Favorite / Star status of selected item"),
                (select_key, "Toggle selection of item for batch operations"),
                (yank_key, "Yank absolute path of selected item to clipboard"),
                (jump_picker_key, "Open fuzzy Jump-to-Repo picker overlay"),
                (
                    "← / → / Space / Enter".to_string(),
                    "Collapse/expand label group (on group header)",
                ),
                ("← / →".to_string(), "Move selection horizontally (Tile view)"),
                (
                    kb.format_action_keys(Action::HomeOpenGitApp, is_compat),
                    "Launch preferred Git client for selected repository",
                ),
                (terminal_key, "Open embedded terminal panel in the selected repository"),
                (external_shell_key, "Suspend the TUI and open a full-screen shell"),
                (settings_key, "Open options/settings page"),
                (labels_key, "Edit labels of selected item"),
                (debug_key, "Open debug logs panel"),
                (stats_key, "Open app usage stats dashboard"),
                (update_key, "Check for application updates manually"),
                ("⌫ [Backspace]".to_string(), "Erase character before the caret while typing"),
                ("← → [Arrows]".to_string(), "Move the caret while typing"),
                ("Home / End".to_string(), "Jump the caret to the start / end of the field"),
                ("Del [Delete]".to_string(), "Erase character under the caret while typing"),
            ],
        ),
    ];

    // The debug panel (src/popups/debug.rs) matches Esc/q/D/l/L, c/C/x, "/",
    // Enter and ↑/↓/j/k as literal key codes rather than configurable
    // actions, so those rows stay literal; only paging and Home/End go
    // through the generic navigation bindings.
    categories.push((
        "Debug Logs Panel",
        vec![
            ("Esc / q / D / l / L".to_string(), "Exit debug logs panel"),
            ("c / C / x".to_string(), "Clear all debug logs"),
            ("/".to_string(), "Fuzzy search/filter debug logs"),
            ("Enter".to_string(), "Leave the search field to scroll results (while searching)"),
            ("↑ / ↓ / j / k".to_string(), "Scroll log entries"),
            (debug_page_key, "Scroll log entries one page up / down"),
            (debug_jump_key, "Jump to the first / last log entry"),
        ],
    ));

    categories.push(make_cat(
        "Mouse Interactions",
        vec![
            ("Left-Click", "Focus clicked panel / change tab (mouse support)"),
            ("Left-Click+Drag", "Drag boundaries to resize split panels"),
        ],
    ));

    // Find max key width for aligned display
    let mut max_key_width = 0;
    for (_, keys) in &categories {
        for (key, _) in keys {
            let width = format_key(key).chars().count();
            if width > max_key_width {
                max_key_width = width;
            }
        }
    }

    let desc_width = usable_width.saturating_sub(4 + max_key_width + 3);

    // Render Categories
    for (cat_title, keys) in categories {
        lines.push(Line::from(""));

        let title_style = if is_compat {
            Style::default().add_modifier(Modifier::BOLD)
        } else {
            primary_style().add_modifier(Modifier::BOLD)
        };

        let prefix = if is_compat { "=== " } else { "■ " };
        lines.push(Line::from(vec![
            Span::raw("  "),
            Span::styled(prefix, if is_compat { Style::default() } else { accent_style() }),
            Span::styled(cat_title.to_uppercase(), title_style),
        ]));

        for (key, desc) in keys {
            let k_str = format_key(&key);
            let padded_key = format!("{:>width$}", k_str, width = max_key_width);
            let desc_lines = wrap_text(desc, desc_width);
            for (idx, desc_line) in desc_lines.into_iter().enumerate() {
                if idx == 0 {
                    lines.push(Line::from(vec![
                        Span::raw("    "),
                        Span::styled(padded_key.clone(), accent_style()),
                        Span::raw("   "),
                        Span::raw(desc_line),
                    ]));
                } else {
                    let indent = " ".repeat(4 + max_key_width + 3);
                    lines.push(Line::from(vec![Span::raw(indent), Span::raw(desc_line)]));
                }
            }
        }
    }

    lines
}

pub fn get_help_lines_len(app: &App, width: u16) -> usize {
    let popup_width = (width * 80) / 100;
    let usable_width = popup_width.saturating_sub(4) as usize;
    get_help_lines(app, usable_width).len()
}

pub fn draw_help_overlay(f: &mut Frame, app: &App, area: Rect, scroll: usize) {
    let popup_area = centered_rect(80, 70, area);
    let usable_width = popup_area.width.saturating_sub(4) as usize;

    let lines = get_help_lines(app, usable_width);

    let help_block = Block::default()
        .borders(Borders::ALL)
        .border_type(CARD_BORDER())
        .border_style(Style::default().fg(ACCENT()))
        .title(
            Line::from(vec![
                Span::raw(" "),
                Span::styled("Shortcuts & Legend", accent_style()),
                Span::raw(" "),
            ])
            .alignment(Alignment::Left),
        )
        .padding(Padding::horizontal(1));

    let inner_height = popup_area.height.saturating_sub(2) as usize;
    let max_scroll = lines.len().saturating_sub(inner_height);
    let scroll = scroll.min(max_scroll);

    let lines_len = lines.len();
    let help = Paragraph::new(lines).block(help_block).scroll((scroll as u16, 0));

    // Clear wipes the underlying cells so the list doesn't bleed through.
    f.render_widget(Clear, popup_area);
    f.render_widget(help, popup_area);

    crate::ui::scrollbar::draw_vertical_scrollbar(f, popup_area, scroll, lines_len, inner_height);
}

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
pub struct HelpPopup;
impl HelpPopup {
    pub fn handle_event(app: &mut crate::app::App, key: KeyEvent) -> bool {
        let code = key.code;
        if app.is_bound(Action::Help, key) || app.is_bound(Action::CloseDetail, key) {
            app.close_dialog();
            return true;
        }
        match code {
            _ if app.keybindings.matches(crate::keybindings::Action::NavUp, key) => {
                app.help_scroll_up();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavDown, key) => {
                app.help_scroll_down();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavPageUp, key) => {
                app.help_scroll_page_up(app.config.page_size);
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavPageDown, key) => {
                app.help_scroll_page_down(app.config.page_size);
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavHome, key) => {
                app.help_scroll_to_top();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavEnd, key) => {
                app.help_scroll_to_bottom();
            }
            _ => {}
        }
        false
    }
}

pub struct DetailHelpPopup;
impl DetailHelpPopup {
    pub fn handle_event(app: &mut crate::app::App, key: KeyEvent) -> bool {
        let code = key.code;
        if app.is_bound(Action::DetailHelp, key) || app.is_bound(Action::CloseDetail, key) {
            app.close_detail_help();
            return true;
        }
        match code {
            _ if app.keybindings.matches(crate::keybindings::Action::NavUp, key) => {
                app.help_scroll_up();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavDown, key) => {
                app.help_scroll_down();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavPageUp, key) => {
                app.help_scroll_page_up(app.config.page_size);
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavPageDown, key) => {
                app.help_scroll_page_down(app.config.page_size);
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavHome, key) => {
                app.help_scroll_to_top();
            }
            _ if app.keybindings.matches(crate::keybindings::Action::NavEnd, key) => {
                app.help_scroll_to_bottom();
            }
            _ => {}
        }
        false
    }
}