diff --git a/Cargo.lock b/Cargo.lock index 39757cb0..e9a1d9c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5233,6 +5233,7 @@ dependencies = [ "http_client", "log", "md5", + "notify", "pulldown-cmark", "rfd", "rgitui_ai", @@ -5246,6 +5247,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_json_lenient", "smallvec", "smol", "urlencoding", diff --git a/Cargo.toml b/Cargo.toml index 66d0919d..17b1829a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,9 @@ log = "0.4" env_logger = "0.11" serde = { version = "1", features = ["derive"] } serde_json = "1" +# Comment- and trailing-comma-tolerant JSON for hand-edited config (keymap.json). +# Same crate Zed uses for its keymap and settings files. +serde_json_lenient = "0.2" chrono = { version = "0.4", features = ["serde"] } parking_lot = "0.12" smallvec = { version = "1", features = ["union"] } diff --git a/crates/rgitui/src/main.rs b/crates/rgitui/src/main.rs index ed64ad43..8544f67a 100644 --- a/crates/rgitui/src/main.rs +++ b/crates/rgitui/src/main.rs @@ -75,6 +75,9 @@ impl AppRoot { if let Some(workspace) = &self.workspace { workspace.update(cx, |ws, cx| { ws.show_crash_recovery_toast(cx); + // Startup keymap problems are reported here rather than during + // `keymap::init`, which runs before any window exists. + ws.show_keymap_problems(cx); }); } @@ -220,6 +223,10 @@ fn main() { // Initialize subsystems rgitui_theme::init(cx); rgitui_settings::init(cx); + // Applies the default keybindings plus the user's keymap.json, and + // watches that file so saving it reloads the keymap. Must come after + // settings init, which creates the config directory. + rgitui_workspace::keymap::init(cx); // Initialize empty avatar cache immediately, load disk data in background cx.set_global(rgitui_ui::AvatarCache::new()); diff --git a/crates/rgitui_diff/src/lib.rs b/crates/rgitui_diff/src/lib.rs index c1bcaf9d..8d887107 100644 --- a/crates/rgitui_diff/src/lib.rs +++ b/crates/rgitui_diff/src/lib.rs @@ -9,9 +9,9 @@ use similar::{capture_diff_slices, Algorithm}; use gpui::prelude::*; use gpui::{ div, list, px, uniform_list, AnyElement, App, ClickEvent, ClipboardItem, Context, CursorStyle, - ElementId, EventEmitter, FocusHandle, FontStyle, FontWeight, HighlightStyle, KeyDownEvent, - ListAlignment, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Render, ScrollStrategy, SharedString, StyledText, + ElementId, EventEmitter, FocusHandle, FontStyle, FontWeight, HighlightStyle, ListAlignment, + ListHorizontalSizingBehavior, ListSizingBehavior, ListState, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, Render, ScrollStrategy, SharedString, StyledText, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{DiffLine, FileDiff, ThreeWayFileDiff}; @@ -1124,238 +1124,224 @@ impl DiffViewer { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let primary = event.keystroke.modifiers.secondary(); + /// Moves the diff cursor down one row, scrolling it into view. + /// + /// The workspace drives this and the methods below from the `diff::*` + /// actions: this crate sits below `rgitui_workspace` in the dependency graph + /// and so cannot name them. + pub fn select_next_row(&mut self, cx: &mut Context) { let row_count = self.row_count(); - if row_count == 0 { return; } + let next = match self.highlighted_row { + Some(i) if i + 1 < row_count => i + 1, + None => 0, + Some(i) => i, + }; + self.highlight_row(next, cx); + } - match key { - "j" | "down" if !primary => { - let next = match self.highlighted_row { - Some(i) if i + 1 < row_count => i + 1, - None => 0, - Some(i) => i, - }; - self.highlighted_row = Some(next); - self.scroll_row_into_view(next, cx); - cx.notify(); - } - "k" | "up" if !primary => { - let next = match self.highlighted_row { - Some(i) if i > 0 => i - 1, - None if row_count > 0 => 0, - Some(i) => i, - None => 0, - }; - self.highlighted_row = Some(next); - self.scroll_row_into_view(next, cx); - cx.notify(); - } - "g" if !primary && !event.keystroke.modifiers.shift => { - self.highlighted_row = Some(0); - self.scroll_row_into_view(0, cx); - cx.notify(); - } - "g" if event.keystroke.modifiers.shift => { - let last = row_count.saturating_sub(1); - self.highlighted_row = Some(last); - self.scroll_row_into_view(last, cx); - cx.notify(); - } - "]" if !primary => { - // Jump to next hunk header after current position - let start = self.highlighted_row.map(|r| r + 1).unwrap_or(0); - let next = match self.display_mode { - DiffDisplayMode::Unified => (start..row_count) - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (start..row_count) - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (start..row_count).find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - } - // Wrap around - .or_else(|| match self.display_mode { - DiffDisplayMode::Unified => (0..start) - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (0..start) - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (0..start).find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - }); - if let Some(pos) = next { - self.highlighted_row = Some(pos); - self.scroll_row_into_view(pos, cx); - cx.notify(); - } - cx.stop_propagation(); - } - "[" if !primary => { - // Jump to previous hunk header before current position - let end = self.highlighted_row.unwrap_or(row_count); - let prev = match self.display_mode { - DiffDisplayMode::Unified => (0..end) - .rev() - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (0..end) - .rev() - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (0..end).rev().find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - } - // Wrap around - .or_else(|| match self.display_mode { - DiffDisplayMode::Unified => (end..row_count) - .rev() - .find(|&i| matches!(self.display_rows[i], DisplayRow::HunkHeader { .. })), - DiffDisplayMode::SideBySide => (end..row_count) - .rev() - .find(|&i| matches!(self.sbs_rows[i], SideBySideRow::HunkHeader { .. })), - DiffDisplayMode::ThreeWay => (end..row_count).rev().find(|&i| { - matches!(self.three_way_rows[i], ThreeWayRow::HunkHeader { .. }) - }), - }); - if let Some(pos) = prev { - self.highlighted_row = Some(pos); - self.scroll_row_into_view(pos, cx); - cx.notify(); - } - cx.stop_propagation(); - } - "d" if !primary => { - self.toggle_display_mode(cx); - } - "c" if primary => { - self.copy_selected_lines(cx); - } - "a" if primary && row_count > 0 => { - self.selection_anchor = Some(0); - self.selected_lines = Some(0..row_count); - cx.notify(); - } - "p" if !primary - && !event.keystroke.modifiers.alt - && !event.keystroke.modifiers.shift => - { - // p: toggle partial line-selection mode. Meaningless for - // committed or stashed content, which cannot be staged at all. - if self.source.is_historical() { - cx.stop_propagation(); - return; - } - self.partial_mode = !self.partial_mode; - if !self.partial_mode { - self.selected_lines = None; - self.selection_anchor = None; - } - cx.notify(); - cx.stop_propagation(); + /// Moves the diff cursor up one row, scrolling it into view. + pub fn select_prev_row(&mut self, cx: &mut Context) { + if self.row_count() == 0 { + return; + } + let next = match self.highlighted_row { + Some(i) if i > 0 => i - 1, + Some(i) => i, + None => 0, + }; + self.highlight_row(next, cx); + } + + /// Moves the diff cursor to the first row. + pub fn select_first_row(&mut self, cx: &mut Context) { + if self.row_count() > 0 { + self.highlight_row(0, cx); + } + } + + /// Moves the diff cursor to the last row. + pub fn select_last_row(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count > 0 { + self.highlight_row(row_count - 1, cx); + } + } + + fn highlight_row(&mut self, row: usize, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_row_into_view(row, cx); + cx.notify(); + } + + /// Whether the row at `index` in the current display mode is a hunk header. + fn is_hunk_header(&self, index: usize) -> bool { + match self.display_mode { + DiffDisplayMode::Unified => { + matches!(self.display_rows[index], DisplayRow::HunkHeader { .. }) } - "s" | "S" if !event.keystroke.modifiers.alt && !primary => { - // s: stage hunks under selection (or cursor hunk if no selection). - // Only the working tree can be staged; committed and stashed - // content is historical and has nothing to stage. - if self.source.staging_action() == Some(StagingAction::Stage) { - if self.partial_mode { - // Line-level staging: emit the selected change lines (additions - // and deletions). Each pair carries its old/new line number; the - // git layer matches additions on the new side and deletions on the - // old side, so deletions must flow through unfiltered. - let line_pairs = if let Some(sel) = &self.selected_lines { - self.lines_under_selection(sel.clone()) - .into_iter() - .filter(Self::is_change_line) - .collect() - } else { - self.current_hunk_changes() - }; - if !line_pairs.is_empty() { - cx.emit(DiffViewerEvent::LineStageRequested(line_pairs)); - } - // TODO(audit): BUG-15 surface a "no stageable lines" toast when a - // partial selection yields nothing — needs a new DiffViewerEvent - // variant + a handler arm in rgitui_workspace events.rs (the toast - // system lives there), which can't be added from this crate alone. - } else { - let hunks = if let Some(sel) = &self.selected_lines { - self.hunks_under_selection(sel.clone()) - } else { - self.current_hunk_index() - .map(|i| vec![i]) - .unwrap_or_default() - }; - for idx in hunks { - cx.emit(DiffViewerEvent::HunkStageRequested(idx)); - } - } - } - cx.stop_propagation(); + DiffDisplayMode::SideBySide => { + matches!(self.sbs_rows[index], SideBySideRow::HunkHeader { .. }) } - "u" | "U" if !event.keystroke.modifiers.alt && !primary => { - // u: unstage hunks under selection (or cursor hunk if no selection). - // Only the index can be unstaged. - if self.source.staging_action() == Some(StagingAction::Unstage) { - if self.partial_mode { - // Line-level unstaging: emit the selected change lines (additions - // and deletions). Deletions must flow through so the git layer can - // match them on the old side; filtering to additions would make - // unstaging a pure deletion a silent no-op. - let line_pairs = if let Some(sel) = &self.selected_lines { - self.lines_under_selection(sel.clone()) - .into_iter() - .filter(Self::is_change_line) - .collect() - } else { - self.current_hunk_changes() - }; - if !line_pairs.is_empty() { - cx.emit(DiffViewerEvent::LineUnstageRequested(line_pairs)); - } - } else { - let hunks = if let Some(sel) = &self.selected_lines { - self.hunks_under_selection(sel.clone()) - } else { - self.current_hunk_index() - .map(|i| vec![i]) - .unwrap_or_default() - }; - for idx in hunks { - cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); - } - } - } - cx.stop_propagation(); + DiffDisplayMode::ThreeWay => { + matches!(self.three_way_rows[index], ThreeWayRow::HunkHeader { .. }) } - "s" | "S" if event.keystroke.modifiers.alt && !primary => { - // Alt+S: stage the current hunk - if self.source.staging_action() == Some(StagingAction::Stage) { - if let Some(idx) = self.current_hunk_index() { - cx.emit(DiffViewerEvent::HunkStageRequested(idx)); - } - } - cx.stop_propagation(); + } + } + + /// Moves the cursor to the next hunk header, wrapping at the end. + pub fn select_next_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let start = self.highlighted_row.map(|r| r + 1).unwrap_or(0); + let next = (start..row_count) + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (0..start).find(|&i| self.is_hunk_header(i))); + if let Some(pos) = next { + self.highlight_row(pos, cx); + } + } + + /// Moves the cursor to the previous hunk header, wrapping at the start. + pub fn select_prev_hunk(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + let end = self.highlighted_row.unwrap_or(row_count); + let prev = (0..end) + .rev() + .find(|&i| self.is_hunk_header(i)) + .or_else(|| (end..row_count).rev().find(|&i| self.is_hunk_header(i))); + if let Some(pos) = prev { + self.highlight_row(pos, cx); + } + } + + /// Toggles line-level selection, clearing any selection when leaving it. + pub fn toggle_partial_mode(&mut self, cx: &mut Context) { + // Meaningless for committed or stashed content, which cannot be staged + // at all. + if self.source.is_historical() { + return; + } + self.partial_mode = !self.partial_mode; + if !self.partial_mode { + self.selected_lines = None; + self.selection_anchor = None; + } + cx.notify(); + } + + /// Selects every row in the diff. + pub fn select_all_lines(&mut self, cx: &mut Context) { + let row_count = self.row_count(); + if row_count == 0 { + return; + } + self.selection_anchor = Some(0); + self.selected_lines = Some(0..row_count); + cx.notify(); + } + + /// Copies the selected diff lines to the clipboard. + pub fn copy_selection(&self, cx: &mut Context) { + self.copy_selected_lines(cx); + } + + /// Requests staging of the hunks — or, in partial mode, the individual + /// lines — under the current selection, falling back to the cursor's hunk. + pub fn stage_selection(&mut self, cx: &mut Context) { + // Only the working tree can be staged; committed and stashed content is + // historical and has nothing to stage. + if self.source.staging_action() != Some(StagingAction::Stage) { + return; + } + if self.partial_mode { + // Line-level staging: emit the selected change lines (additions and + // deletions). Each pair carries its old/new line number; the git + // layer matches additions on the new side and deletions on the old + // side, so deletions must flow through unfiltered. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineStageRequested(line_pairs)); } - "u" | "U" if event.keystroke.modifiers.alt && !primary => { - // Alt+U: unstage the current hunk - if self.source.staging_action() == Some(StagingAction::Unstage) { - if let Some(idx) = self.current_hunk_index() { - cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); - } - } - cx.stop_propagation(); + // TODO(audit): BUG-15 surface a "no stageable lines" toast when a + // partial selection yields nothing — needs a new DiffViewerEvent + // variant + a handler arm in rgitui_workspace events.rs (the toast + // system lives there), which can't be added from this crate alone. + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of the hunks — or lines — under the current selection. + pub fn unstage_selection(&mut self, cx: &mut Context) { + // Only the index can be unstaged. + if self.source.staging_action() != Some(StagingAction::Unstage) { + return; + } + if self.partial_mode { + // Deletions must flow through so the git layer can match them on the + // old side; filtering to additions would make unstaging a pure + // deletion a silent no-op. + let line_pairs = self.change_lines_under_selection(); + if !line_pairs.is_empty() { + cx.emit(DiffViewerEvent::LineUnstageRequested(line_pairs)); } - _ => {} + return; + } + for idx in self.hunks_to_act_on() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// Requests staging of just the hunk under the cursor. + pub fn stage_current_hunk(&mut self, cx: &mut Context) { + if self.source.staging_action() != Some(StagingAction::Stage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkStageRequested(idx)); + } + } + + /// Requests unstaging of just the hunk under the cursor. + pub fn unstage_current_hunk(&mut self, cx: &mut Context) { + if self.source.staging_action() != Some(StagingAction::Unstage) { + return; + } + if let Some(idx) = self.current_hunk_index() { + cx.emit(DiffViewerEvent::HunkUnstageRequested(idx)); + } + } + + /// The change lines under the selection, or the cursor hunk's if there is none. + fn change_lines_under_selection(&self) -> Vec<(Option, Option)> { + match &self.selected_lines { + Some(selection) => self + .lines_under_selection(selection.clone()) + .into_iter() + .filter(Self::is_change_line) + .collect(), + None => self.current_hunk_changes(), + } + } + + /// The hunks under the selection, or the cursor's hunk if there is none. + fn hunks_to_act_on(&self) -> Vec { + match &self.selected_lines { + Some(selection) => self.hunks_under_selection(selection.clone()), + None => self + .current_hunk_index() + .map(|i| vec![i]) + .unwrap_or_default(), } } @@ -3875,7 +3861,9 @@ impl Render for DiffViewer { let mut container = div() .id("diff-viewer") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + // Bindings scoped to `DiffViewer` resolve to `diff::*` actions the + // workspace root handles; this crate cannot name them itself. + .key_context("DiffViewer") .on_mouse_up(MouseButton::Left, cx.listener(Self::end_mouse_selection)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::end_mouse_selection)) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { @@ -5051,9 +5039,15 @@ mod view_tests { } /// Shows `source` in a focused viewer, selects every row so a staging - /// request would have a target, presses `s` then `u`, and returns whatever - /// staging requests came back. - fn staging_requests_after_pressing_s_and_u(source: DiffSource) -> Vec { + /// request would have a target, invokes stage-then-unstage, and returns + /// whatever staging requests came back. + /// + /// These go through the methods rather than simulated keystrokes because the + /// `s`/`u` bindings are gpui actions declared in `rgitui_workspace`, which + /// sits above this crate and so cannot be reached from here. The keystrokes + /// are covered by the keymap registry's own tests; what matters here is that + /// the entry points the actions call refuse on historical content. + fn staging_requests_after_stage_then_unstage(source: DiffSource) -> Vec { let mut probe = ViewTest::open(StagingProbe::new); probe.update(|probe, window, cx| { @@ -5063,27 +5057,32 @@ mod view_tests { }); }); - // Guard against a vacuous test: the rows must actually exist, or `s` - // would find no hunk under the selection regardless of provenance. + // Guard against a vacuous test: the rows must actually exist, or the + // selection would find no hunk regardless of provenance. probe.read(|probe, cx| { let viewer = probe.viewer.read(cx); assert!( viewer.row_count() > 0, - "display rows should be prepared before keys are pressed" + "display rows should be prepared before staging is attempted" ); }); - // ctrl-a selects every row, so `s`/`u` resolve to the whole hunk. - probe.simulate_keystroke("ctrl-a"); - probe.simulate_keystroke("s"); - probe.simulate_keystroke("u"); + probe.update(|probe, _window, cx| { + probe.viewer.update(cx, |viewer, cx| { + // Select every row, so the request resolves to the whole hunk. + viewer.select_all_lines(cx); + viewer.stage_selection(cx); + viewer.unstage_selection(cx); + }); + }); probe.read(|probe, _| probe.staging_requests()) } #[test] fn pressing_s_or_u_on_a_commit_diff_requests_no_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Commit(OID.to_string())); + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Commit(OID.to_string())); assert!( requests.is_empty(), "a committed diff must not be stageable, but the viewer emitted {requests:?}" @@ -5092,7 +5091,8 @@ mod view_tests { #[test] fn pressing_s_or_u_on_a_stash_diff_requests_no_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Stash(OID.to_string())); + let requests = + staging_requests_after_stage_then_unstage(DiffSource::Stash(OID.to_string())); assert!( requests.is_empty(), "a stashed diff must not be stageable, but the viewer emitted {requests:?}" @@ -5103,7 +5103,7 @@ mod view_tests { /// keys had simply stopped working everywhere. #[test] fn pressing_s_on_a_worktree_diff_still_requests_staging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Worktree); + let requests = staging_requests_after_stage_then_unstage(DiffSource::Worktree); assert_eq!( requests, vec!["HunkStageRequested(0)".to_string()], @@ -5114,7 +5114,7 @@ mod view_tests { /// The mirror control case: the index unstages on `u` and ignores `s`. #[test] fn pressing_u_on_an_index_diff_still_requests_unstaging() { - let requests = staging_requests_after_pressing_s_and_u(DiffSource::Index); + let requests = staging_requests_after_stage_then_unstage(DiffSource::Index); assert_eq!( requests, vec!["HunkUnstageRequested(0)".to_string()], @@ -5138,7 +5138,11 @@ mod view_tests { }); }); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| { assert!( !probe.viewer.read(cx).partial_mode, @@ -5158,9 +5162,17 @@ mod view_tests { }); }); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| assert!(probe.viewer.read(cx).partial_mode)); - probe.simulate_keystroke("p"); + probe.update(|probe, _window, cx| { + probe + .viewer + .update(cx, |viewer, cx| viewer.toggle_partial_mode(cx)); + }); probe.read(|probe, cx| assert!(!probe.viewer.read(cx).partial_mode)); } } diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index c8b8394d..777f50f5 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -7,10 +7,9 @@ use std::time::Duration; use gpui::prelude::*; use gpui::{ canvas, div, img, point, px, uniform_list, Animation, AnimationExt, App, Bounds, ClickEvent, - Context, CursorStyle, ElementId, Entity, EventEmitter, FocusHandle, Focusable, KeyDownEvent, - ListSizingBehavior, MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, PathBuilder, - Pixels, Point, Render, ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, - Window, + Context, CursorStyle, ElementId, Entity, EventEmitter, FocusHandle, ListSizingBehavior, + MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, PathBuilder, Pixels, Point, Render, + ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{compute_graph, CommitInfo, FileChangeKind, GraphEdge, GraphRow, RefLabel}; use rgitui_settings::{GraphStyle, SettingsState}; @@ -37,26 +36,179 @@ impl Render for DateColumnResize { /// Width of the commit graph context menu. const CONTEXT_MENU_WIDTH: f32 = 200.0; -/// Number of action rows in the context menu. -const CONTEXT_MENU_ITEM_COUNT: f32 = 14.0; /// Height of a single context-menu action row (`.h(px(26.))`). const CONTEXT_MENU_ITEM_HEIGHT: f32 = 26.0; -/// Number of separators drawn between menu groups. -const CONTEXT_MENU_SEPARATOR_COUNT: f32 = 4.0; /// Effective height of one separator: `.h(px(1.))` plus `.my(px(2.))` top and bottom. const CONTEXT_MENU_SEPARATOR_HEIGHT: f32 = 1.0 + 2.0 + 2.0; /// Combined top and bottom padding on the menu container (`.py(px(3.))`). const CONTEXT_MENU_VERTICAL_PADDING: f32 = 3.0 + 3.0; -/// Natural rendered height of the context menu, derived from its real item and -/// separator metrics. Shared by clamping and the dismiss hit-test so both agree -/// on where the menu actually sits. -const fn context_menu_height() -> f32 { - CONTEXT_MENU_ITEM_COUNT * CONTEXT_MENU_ITEM_HEIGHT - + CONTEXT_MENU_SEPARATOR_COUNT * CONTEXT_MENU_SEPARATOR_HEIGHT +/// Natural rendered height of a context menu holding `items`. +/// +/// Counted from the rows themselves rather than from a hand-maintained total, so +/// adding or hiding an item cannot leave the menu mis-sized. Shared by the +/// clamping and the dismiss hit-test, which have to agree on where the menu sits. +fn context_menu_height(items: &[&GraphMenuItem]) -> f32 { + let separators = items.iter().filter(|item| item.separator_before).count(); + items.len() as f32 * CONTEXT_MENU_ITEM_HEIGHT + + separators as f32 * CONTEXT_MENU_SEPARATOR_HEIGHT + CONTEXT_MENU_VERTICAL_PADDING } +/// What a commit graph context-menu row does when clicked. +/// +/// Carries no commit data: the row is built for one commit and +/// [`GraphView::menu_event`] fills that in, which keeps the table below a plain +/// description of the menu. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphMenuAction { + CherryPick, + Revert, + Checkout, + CreateBranch, + CreateTag, + BisectGood, + BisectBad, + Reset, + InteractiveRebase, + SquashSelected, + CopySha, + CopyMessage, + CopyAuthor, + CopyDate, + ViewOnGithub, +} + +/// One row of the commit graph context menu. +struct GraphMenuItem { + /// Row label. + label: &'static str, + /// Leading icon. + icon: IconName, + /// What clicking the row does. + action: GraphMenuAction, + /// Whether a group separator is drawn above this row. + separator_before: bool, + /// Whether the row renders in the error colour, because it rewrites or + /// discards committed work. + destructive: bool, +} + +impl GraphMenuItem { + const fn new(label: &'static str, icon: IconName, action: GraphMenuAction) -> Self { + Self { + label, + icon, + action, + separator_before: false, + destructive: false, + } + } + + /// Starts a new group: a separator is drawn above this row. + const fn grouped(mut self) -> Self { + self.separator_before = true; + self + } + + /// Marks the row as rewriting or discarding work. + const fn destructive(mut self) -> Self { + self.destructive = true; + self + } +} + +/// Every row the commit graph context menu can show, in order. +/// +/// [`GraphView::context_menu_items`] filters it for the current selection; the +/// menu's height is then derived from what survives, so no count is maintained +/// by hand. +const GRAPH_MENU_ITEMS: &[GraphMenuItem] = &[ + GraphMenuItem::new( + "Cherry-pick commit", + IconName::GitCommit, + GraphMenuAction::CherryPick, + ), + GraphMenuItem::new("Revert commit", IconName::Undo, GraphMenuAction::Revert), + GraphMenuItem::new( + "Checkout commit", + IconName::Check, + GraphMenuAction::Checkout, + ), + GraphMenuItem::new( + "Create branch here", + IconName::GitBranch, + GraphMenuAction::CreateBranch, + ), + GraphMenuItem::new("Create tag here", IconName::Tag, GraphMenuAction::CreateTag), + GraphMenuItem::new( + "Mark as good (bisect)", + IconName::Check, + GraphMenuAction::BisectGood, + ) + .grouped(), + GraphMenuItem::new( + "Mark as bad (bisect)", + IconName::X, + GraphMenuAction::BisectBad, + ) + .destructive(), + GraphMenuItem::new("Reset to here", IconName::Trash, GraphMenuAction::Reset) + .grouped() + .destructive(), + GraphMenuItem::new( + "Interactive Rebase", + IconName::GitMerge, + GraphMenuAction::InteractiveRebase, + ) + .grouped(), + // Only offered while the selection could actually be squashed — see + // `GraphView::context_menu_items`. + GraphMenuItem::new( + "Squash selected commits", + IconName::GitMerge, + GraphMenuAction::SquashSelected, + ), + GraphMenuItem::new("Copy SHA", IconName::Copy, GraphMenuAction::CopySha).grouped(), + GraphMenuItem::new( + "Copy commit message", + IconName::Edit, + GraphMenuAction::CopyMessage, + ), + GraphMenuItem::new( + "Copy author name", + IconName::User, + GraphMenuAction::CopyAuthor, + ), + GraphMenuItem::new("Copy date", IconName::Clock, GraphMenuAction::CopyDate), + GraphMenuItem::new( + "View on GitHub", + IconName::ExternalLink, + GraphMenuAction::ViewOnGithub, + ), +]; + +/// Smallest selection a squash can meld: fewer commits than this and there is +/// nothing to squash into. +const MIN_SQUASH_SELECTION: usize = 2; + +/// The context-menu rows to offer when `selected_commits` commits are selected. +/// +/// "Squash selected commits" is only offered once there are two commits to meld; +/// below that it would always fail, so it is hidden rather than shown disabled. +/// The rest of the rules — a contiguous run on HEAD's own first-parent chain, no +/// merge commit in the way — belong to the workspace's `plan_squash`, which this +/// crate sits below. Clicking the row therefore takes the same route as the +/// `graph::SquashSelected` keystroke and a selection that does not qualify is +/// refused there, in the same words. +fn menu_items_for_selection(selected_commits: usize) -> Vec<&'static GraphMenuItem> { + let squashable = selected_commits >= MIN_SQUASH_SELECTION; + GRAPH_MENU_ITEMS + .iter() + .filter(|item| squashable || item.action != GraphMenuAction::SquashSelected) + .collect() +} + /// Pre-computed unit circle vertex offsets (cos, sin) for 36-step circles. /// Computed once and reused across all frames to avoid per-frame trig calls. fn unit_circle_offsets() -> &'static [(f32, f32)] { @@ -135,6 +287,10 @@ fn quarter_arc_offsets() -> &'static [(f32, f32)] { #[derive(Debug, Clone)] pub enum GraphViewEvent { CommitSelected(git2::Oid), + /// The set of selected commits changed. Emitted alongside `CommitSelected` + /// for a single selection, and on its own while a multi-selection is built, + /// so command availability can follow without a diff being recomputed. + SelectionChanged, CherryPick(git2::Oid), RevertCommit(git2::Oid), CreateBranchAtCommit(git2::Oid), @@ -165,6 +321,11 @@ pub enum GraphViewEvent { /// interactive rebase editor, allowing the user to reorder, squash, fixup, /// reword, or drop them. InteractiveRebase(git2::Oid), + /// Squash the selected commits together. The workspace validates the + /// selection and pre-fills the interactive rebase dialog, or explains why it + /// cannot — the same path the `graph::SquashSelected` keystroke takes, so the + /// two cannot disagree. + SquashSelected, } #[derive(Clone, Debug, PartialEq)] @@ -374,6 +535,130 @@ fn worktree_targets_commit_immediately_above( }) } +/// The commit index displayed at `list_index`, or `None` when that row is a +/// virtual worktree row. +/// +/// `worktree_rows` is the ascending list of rows occupied by worktree +/// pseudo-nodes: each one shifts every commit below it down by one. +fn commit_index_for_row(worktree_rows: &[usize], list_index: usize) -> Option { + if worktree_rows.binary_search(&list_index).is_ok() { + return None; + } + let virtual_rows_above = worktree_rows.partition_point(|row| *row < list_index); + Some(list_index - virtual_rows_above) +} + +/// The row `commit_index` is displayed at, or `None` when it is outside the +/// window of commits that have a computed graph row. +/// +/// The inverse of [`commit_index_for_row`]. `worktree_rows` must be ascending; +/// a worktree row sitting at or above the commit's own row pushes it down, and +/// because the rows are distinct and sorted, `row - ordinal` is non-decreasing — +/// so a single pass is enough. +fn row_for_commit_index( + worktree_rows: &[usize], + commit_count: usize, + commit_index: usize, +) -> Option { + if commit_index >= commit_count { + return None; + } + let mut row = commit_index; + for worktree_row in worktree_rows { + if *worktree_row <= row { + row += 1; + } + } + Some(row) +} + +/// The set of commits selected in the graph, plus the anchor a range extension +/// grows from. +/// +/// Members are *commit* indices, never list indices, so a virtual worktree row +/// can never join the selection and inserting or removing one leaves it alone. +/// The set is arbitrary rather than a single range: ctrl-click punches holes in +/// it, and operations that need a contiguous run (squash, for one) validate that +/// for themselves and explain the failure. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +struct CommitSelection { + indices: std::collections::BTreeSet, + anchor: Option, + /// The selection as it stood when the anchor was last set. A range extension + /// unions the anchor range onto this, so extending again *replaces* the + /// previous range instead of accumulating every row the cursor passed over. + anchor_base: std::collections::BTreeSet, +} + +impl CommitSelection { + /// A selection restored from commit indices, e.g. after the commit list was + /// reloaded and the previous members were looked up again by OID. + fn from_parts(indices: impl IntoIterator, anchor: Option) -> Self { + let indices: std::collections::BTreeSet = indices.into_iter().collect(); + Self { + anchor: anchor.filter(|_| !indices.is_empty()), + indices, + anchor_base: std::collections::BTreeSet::new(), + } + } + + fn clear(&mut self) { + self.indices.clear(); + self.anchor = None; + self.anchor_base.clear(); + } + + /// Collapses the selection onto one commit — a plain click or a plain + /// `j`/`k`, which must behave exactly as they did before multi-select. + fn replace(&mut self, commit_index: usize) { + self.indices.clear(); + self.indices.insert(commit_index); + self.anchor = Some(commit_index); + // Extending from here selects exactly the anchor range. + self.anchor_base.clear(); + } + + /// Adds or removes one commit, leaving the rest of the selection alone, and + /// re-anchors so a following range extension grows from this commit. + fn toggle(&mut self, commit_index: usize) { + if !self.indices.remove(&commit_index) { + self.indices.insert(commit_index); + } + self.anchor = Some(commit_index); + self.anchor_base = self.indices.clone(); + } + + /// Selects the inclusive range between the anchor and `commit_index`, on top + /// of whatever was selected when the anchor was set. + fn extend_to(&mut self, commit_index: usize) { + let Some(anchor) = self.anchor else { + self.replace(commit_index); + return; + }; + let (first, last) = (anchor.min(commit_index), anchor.max(commit_index)); + self.indices = self.anchor_base.clone(); + self.indices.extend(first..=last); + } + + fn contains(&self, commit_index: usize) -> bool { + self.indices.contains(&commit_index) + } + + fn len(&self) -> usize { + self.indices.len() + } + + fn anchor(&self) -> Option { + self.anchor + } + + /// Members in ascending commit order — newest commit first, since the commit + /// list itself is ordered newest first. + fn iter(&self) -> impl Iterator + '_ { + self.indices.iter().copied() + } +} + /// The commit graph panel. pub struct GraphView { commits: Arc>, @@ -381,6 +666,12 @@ pub struct GraphView { global_max_lane: usize, selected_index: Option, selected_oid: Option, + /// Every selected commit. The cursor (`selected_index`) is the member that + /// drives the diff and detail panels; the rest are highlighted only. + selection: CommitSelection, + /// [`Self::selection`] projected onto list indices, so a row can test its own + /// membership without walking the set on every frame. + selected_rows: Arc>, row_height: f32, scroll_handle: UniformListScrollHandle, context_menu: Option, @@ -405,7 +696,9 @@ pub struct GraphView { search_debounce_task: Option>, worktree_infos: Vec, worktree_row_positions: Vec, - worktree_row_set: HashSet, + /// Ascending list indices occupied by worktree pseudo-nodes. Drives the + /// list-index ↔ commit-index mapping. + worktree_rows: Vec, virtual_rows_prefix: Arc>, show_settings_popover: bool, /// SHA display length: 0 = default short (7), or specific length (7/8/10/12/40). @@ -461,6 +754,8 @@ impl GraphView { global_max_lane: 0, selected_index: None, selected_oid: None, + selection: CommitSelection::default(), + selected_rows: Arc::new(HashSet::new()), row_height: 32.0, scroll_handle: UniformListScrollHandle::new(), context_menu: None, @@ -478,7 +773,7 @@ impl GraphView { search_debounce_task: None, worktree_infos: Vec::new(), worktree_row_positions: Vec::new(), - worktree_row_set: HashSet::new(), + worktree_rows: Vec::new(), virtual_rows_prefix: Arc::new(Vec::new()), show_settings_popover: false, sha_display_length: 0, @@ -558,7 +853,20 @@ impl GraphView { let prev_selected_oid = this.selected_oid; let prev_selected_index = this.selected_index; let prev_selected_worktree = - prev_selected_index.is_some_and(|index| this.worktree_row_set.contains(&index)); + prev_selected_index.is_some_and(|index| this.is_worktree_row(index)); + // The multi-selection is remembered by OID too: commit indices + // shift whenever commits are loaded, filtered or reordered. + let prev_selected_oids: Vec = this + .selection + .iter() + .filter_map(|commit_index| this.commits.get(commit_index)) + .map(|commit| commit.oid) + .collect(); + let prev_anchor_oid = this + .selection + .anchor() + .and_then(|commit_index| this.commits.get(commit_index)) + .map(|commit| commit.oid); this.global_max_lane = graph_rows .iter() @@ -593,6 +901,17 @@ impl GraphView { this.selected_index = None; } + // Members that survived the reload keep their place in the + // selection; the rest are dropped along with their commits. + let restored: Vec = prev_selected_oids + .iter() + .filter_map(|oid| this.commits.iter().position(|commit| commit.oid == *oid)) + .collect(); + let restored_anchor = prev_anchor_oid + .and_then(|oid| this.commits.iter().position(|commit| commit.oid == oid)); + this.selection = CommitSelection::from_parts(restored, restored_anchor); + this.sync_selected_rows(); + if this.show_search && !this.search_editor.read(cx).is_empty() { this.update_search_filter(cx); } @@ -632,7 +951,7 @@ impl GraphView { /// exactly where the menu sits, even when clamping or scrolling occurs. fn context_menu_geometry(&self, click: Point) -> (Pixels, Pixels, Pixels, Pixels) { let menu_w = px(CONTEXT_MENU_WIDTH); - let natural_h = px(context_menu_height()); + let natural_h = px(context_menu_height(&self.context_menu_items())); let bounds = self.container_bounds; // Cap the visible height to the container so a short window scrolls // rather than overflowing and clipping unreachable items. @@ -665,22 +984,29 @@ impl GraphView { &self.worktree_infos, self.global_max_lane, ); - self.worktree_row_set.clear(); - for position in &self.worktree_row_positions { - self.worktree_row_set.insert(position.list_index); - } + // `compute_worktree_row_positions` returns positions sorted by list index, + // which is what the mapping helpers rely on. + self.worktree_rows = self + .worktree_row_positions + .iter() + .map(|position| position.list_index) + .collect(); + debug_assert!(self.worktree_rows.windows(2).all(|pair| pair[0] < pair[1])); let visible_commit_count = self.commits.len().min(self.graph_rows.len()); let total_items = visible_commit_count + self.worktree_row_positions.len(); let mut virtual_rows_prefix = vec![0; total_items]; let mut virtual_count = 0; for (list_index, prefix) in virtual_rows_prefix.iter_mut().enumerate() { - if self.worktree_row_set.contains(&list_index) { + if self.worktree_rows.binary_search(&list_index).is_ok() { virtual_count += 1; } *prefix = virtual_count; } self.virtual_rows_prefix = Arc::new(virtual_rows_prefix); + // Rows shift when a worktree row appears or disappears, so the projection + // of the selection onto list indices has to follow. + self.sync_selected_rows(); } fn worktree_row_at_list_index(&self, list_index: usize) -> Option<&WorktreeRowPosition> { @@ -689,28 +1015,34 @@ impl GraphView { .find(|position| position.list_index == list_index) } + /// Whether `list_index` is a virtual worktree row rather than a commit. + fn is_worktree_row(&self, list_index: usize) -> bool { + self.worktree_rows.binary_search(&list_index).is_ok() + } + fn commit_index_for_list_index(&self, list_index: usize) -> Option { - if self.worktree_row_set.contains(&list_index) { + if list_index >= self.total_list_items() { return None; } - self.virtual_rows_prefix - .get(list_index) - .map(|virtual_count| list_index - *virtual_count) + commit_index_for_row(&self.worktree_rows, list_index) } fn list_index_for_commit_index(&self, commit_index: usize) -> Option { - if commit_index >= self.commits.len().min(self.graph_rows.len()) { - return None; - } - let virtual_rows_before = self - .worktree_row_positions + row_for_commit_index( + &self.worktree_rows, + self.commits.len().min(self.graph_rows.len()), + commit_index, + ) + } + + /// Re-projects [`Self::selection`] onto the list indices the rows are drawn at. + fn sync_selected_rows(&mut self) { + let rows: HashSet = self + .selection .iter() - .enumerate() - .filter(|(ordinal, position)| { - position.list_index.saturating_sub(*ordinal) <= commit_index - }) - .count(); - Some(commit_index + virtual_rows_before) + .filter_map(|commit_index| self.list_index_for_commit_index(commit_index)) + .collect(); + self.selected_rows = Arc::new(rows); } /// Total number of list items (virtual worktree rows + commits). @@ -731,6 +1063,30 @@ impl GraphView { self.selected_index } + /// Every selected commit, newest first. + /// + /// One plain click or one `j` leaves exactly one entry here, so callers that + /// only care about the cursor can keep using [`Self::selected_commit`]. + pub fn selected_commits(&self) -> Vec<&CommitInfo> { + self.selection + .iter() + .filter_map(|commit_index| self.commits.get(commit_index)) + .collect() + } + + /// The OIDs of [`Self::selected_commits`], newest first. + pub fn selected_commit_oids(&self) -> Vec { + self.selected_commits() + .into_iter() + .map(|commit| commit.oid) + .collect() + } + + /// How many commits are selected. Zero while a worktree row is the cursor. + pub fn selected_commit_count(&self) -> usize { + self.selection.len() + } + pub fn commit_count(&self) -> usize { self.commits.len() } @@ -740,6 +1096,40 @@ impl GraphView { self.total_list_items() } + /// The context-menu rows to show for the selection as it stands. See + /// [`menu_items_for_selection`]. + fn context_menu_items(&self) -> Vec<&'static GraphMenuItem> { + menu_items_for_selection(self.selected_commit_count()) + } + + /// The event a context-menu row emits, bound to the commit it was opened on. + fn menu_event(action: GraphMenuAction, commit: &CommitInfo) -> GraphViewEvent { + let oid = commit.oid; + match action { + GraphMenuAction::CherryPick => GraphViewEvent::CherryPick(oid), + GraphMenuAction::Revert => GraphViewEvent::RevertCommit(oid), + GraphMenuAction::Checkout => GraphViewEvent::CheckoutCommit(oid), + GraphMenuAction::CreateBranch => GraphViewEvent::CreateBranchAtCommit(oid), + GraphMenuAction::CreateTag => GraphViewEvent::CreateTagAtCommit(oid), + GraphMenuAction::BisectGood => GraphViewEvent::BisectGood(oid), + GraphMenuAction::BisectBad => GraphViewEvent::BisectBad(oid), + GraphMenuAction::Reset => GraphViewEvent::ResetToCommit(oid, oid.to_string()), + GraphMenuAction::InteractiveRebase => GraphViewEvent::InteractiveRebase(oid), + GraphMenuAction::SquashSelected => GraphViewEvent::SquashSelected, + GraphMenuAction::CopySha => GraphViewEvent::CopyCommitSha(oid.to_string()), + GraphMenuAction::CopyMessage => { + GraphViewEvent::CopyCommitMessage(commit.message.clone()) + } + GraphMenuAction::CopyAuthor => { + GraphViewEvent::CopyAuthorName(commit.author.name.clone()) + } + GraphMenuAction::CopyDate => { + GraphViewEvent::CopyDate(commit.time.format("%Y-%m-%d %H:%M:%S").to_string()) + } + GraphMenuAction::ViewOnGithub => GraphViewEvent::ViewOnGithub(oid), + } + } + fn dismiss_context_menu(&mut self, cx: &mut Context) { if self.context_menu.is_some() { self.context_menu = None; @@ -817,12 +1207,22 @@ impl GraphView { } /// Select an item by its index in the uniform list (accounts for working tree row). + /// + /// This is the plain-click / plain-`j` path, so it collapses any multi-selection + /// back onto the one row and re-anchors range extension there. fn select_list_index(&mut self, list_index: usize, cx: &mut Context) { let total = self.total_list_items(); if list_index >= total { return; } self.selected_index = Some(list_index); + match self.commit_index_for_list_index(list_index) { + Some(commit_index) => self.selection.replace(commit_index), + // A worktree row is not a commit, so nothing stays selected. + None => self.selection.clear(), + } + self.sync_selected_rows(); + cx.emit(GraphViewEvent::SelectionChanged); if let Some(worktree_idx) = self .worktree_row_at_list_index(list_index) .map(|position| position.worktree_idx) @@ -843,6 +1243,137 @@ impl GraphView { cx.notify(); } + /// The commit the cursor sits on, or `None` when it sits on a worktree row. + fn cursor_commit_index(&self) -> Option { + self.selected_index + .and_then(|list_index| self.commit_index_for_list_index(list_index)) + } + + /// Adds or removes one row without disturbing the rest of the selection — + /// the secondary-click gesture. + /// + /// Worktree rows carry no commit, so a toggle on one is ignored rather than + /// letting a virtual row into the selection. + pub fn toggle_selection_at_list_index(&mut self, list_index: usize, cx: &mut Context) { + let Some(commit_index) = self.commit_index_for_list_index(list_index) else { + return; + }; + if commit_index >= self.commits.len() { + return; + } + self.selection.toggle(commit_index); + if self.selection.contains(commit_index) { + // Move the cursor onto the row that was just added, so a following + // extension grows from where the user last clicked. + self.selected_index = Some(list_index); + self.selected_oid = self.commits.get(commit_index).map(|commit| commit.oid); + } + self.sync_selected_rows(); + self.emit_single_selection(cx); + cx.emit(GraphViewEvent::SelectionChanged); + cx.notify(); + } + + /// Extends the selection from the anchor to `list_index` — the shift-click + /// gesture. A worktree row has no commit to extend to, so it is ignored. + pub fn extend_selection_to_list_index(&mut self, list_index: usize, cx: &mut Context) { + let Some(commit_index) = self.commit_index_for_list_index(list_index) else { + return; + }; + self.extend_selection_to_commit(commit_index, None, cx); + } + + /// Extends the selection down one commit, keeping the anchor put. + pub fn extend_selection_next(&mut self, cx: &mut Context) { + self.extend_selection_by_one(true, cx); + } + + /// Extends the selection up one commit, keeping the anchor put. + pub fn extend_selection_prev(&mut self, cx: &mut Context) { + self.extend_selection_by_one(false, cx); + } + + /// Moves the cursor one commit and extends the selection to it. + /// + /// The step is taken in *commit* space, so a virtual worktree row between two + /// commits is stepped straight over: it can never be part of the selection. + fn extend_selection_by_one(&mut self, forward: bool, cx: &mut Context) { + let commit_count = self.commits.len().min(self.graph_rows.len()); + if commit_count == 0 { + return; + } + let Some(cursor) = self.cursor_commit_index() else { + // The cursor is on a worktree row (or nowhere): there is no commit to + // extend from, so fall back to plain movement. + if forward { + self.select_next_row(cx); + } else { + self.select_prev_row(cx); + } + return; + }; + let target = if forward { + cursor + 1 + } else { + match cursor.checked_sub(1) { + Some(target) => target, + None => return, + } + }; + if target >= commit_count { + return; + } + self.extend_selection_to_commit(target, Some(ScrollStrategy::Center), cx); + } + + fn extend_selection_to_commit( + &mut self, + commit_index: usize, + scroll: Option, + cx: &mut Context, + ) { + let Some(list_index) = self.list_index_for_commit_index(commit_index) else { + return; + }; + if self.selection.anchor().is_none() { + // Nothing anchored yet: anchor where the cursor is, so shift-j from a + // fresh selection grows from the current row rather than jumping. + let anchor = self.cursor_commit_index().unwrap_or(commit_index); + self.selection.replace(anchor); + } + self.selection.extend_to(commit_index); + self.selected_index = Some(list_index); + self.selected_oid = self.commits.get(commit_index).map(|commit| commit.oid); + self.sync_selected_rows(); + self.emit_single_selection(cx); + cx.emit(GraphViewEvent::SelectionChanged); + if let Some(strategy) = scroll { + self.scroll_handle.scroll_to_item(list_index, strategy); + } + cx.notify(); + } + + /// Emits `CommitSelected` only while exactly one commit is selected. + /// + /// Growing a multi-selection must not fire a diff computation per row, so the + /// diff and detail panels keep showing whatever single commit was last chosen. + fn emit_single_selection(&mut self, cx: &mut Context) { + if self.selection.len() != 1 { + return; + } + let Some(commit_index) = self.selection.iter().next() else { + return; + }; + let Some(oid) = self.commits.get(commit_index).map(|commit| commit.oid) else { + return; + }; + if let Some(list_index) = self.list_index_for_commit_index(commit_index) { + self.selected_index = Some(list_index); + } + self.selected_oid = Some(oid); + cx.emit(GraphViewEvent::CommitSelected(oid)); + } + /// Toggle the search bar visibility. Clears query when hiding. pub fn toggle_search(&mut self, cx: &mut Context) { self.show_search = !self.show_search; @@ -997,107 +1528,90 @@ impl GraphView { } /// Handle key events on the focused search input. - fn handle_graph_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - if self - .search_editor - .read(cx) - .focus_handle(cx) - .is_focused(window) - { - if event.keystroke.key.as_str() == "escape" { - // Cancel any in-progress drag-to-rebase. - self.dragging_oid = None; - self.drag_start_position = None; - self.drag_moved = false; - self.suppress_next_click = false; - self.show_search = false; - self.search_editor - .update(cx, |e: &mut rgitui_ui::TextInput, cx| e.clear(cx)); - self.filter_matches.clear(); - self.filter_match_set_arc = Arc::new(HashSet::new()); - self.current_match = 0; - self.graph_focus.focus(window, cx); - cx.notify(); - } - return; + /// Moves the selection by one row, scrolling it into view. + /// + /// The workspace drives this from the `graph::*` actions: this crate sits + /// below `rgitui_workspace` in the dependency graph and so cannot name them. + pub fn select_next_row(&mut self, cx: &mut Context) { + let total = self.total_list_items(); + let next = match self.selected_index { + Some(index) if index + 1 < total => index + 1, + None if total > 0 => 0, + _ => return, + }; + self.select_row(next, ScrollStrategy::Center, cx); + } + + /// Moves the selection up one row, scrolling it into view. + pub fn select_prev_row(&mut self, cx: &mut Context) { + let next = match self.selected_index { + Some(index) if index > 0 => index - 1, + None if self.total_list_items() > 0 => 0, + _ => return, + }; + self.select_row(next, ScrollStrategy::Center, cx); + } + + /// Selects the newest commit. + pub fn select_first_row(&mut self, cx: &mut Context) { + if self.total_list_items() > 0 { + self.select_row(0, ScrollStrategy::Top, cx); } - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - // The platform's primary modifier: Command on macOS, Control elsewhere. - // Treating both as interchangeable made the Windows key act as Control. - let primary = keystroke.modifiers.secondary(); + } + /// Selects the oldest loaded commit. + pub fn select_last_row(&mut self, cx: &mut Context) { let total = self.total_list_items(); - match key { - "j" | "down" if !primary => { - let next = match self.selected_index { - Some(i) if i + 1 < total => i + 1, - None if total > 0 => 0, - _ => return, - }; - self.select_list_index(next, cx); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Center); - } - "k" | "up" if !primary => { - let next = match self.selected_index { - Some(i) if i > 0 => i - 1, - None if total > 0 => 0, - _ => return, - }; - self.select_list_index(next, cx); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Center); - } - "g" if !primary && !keystroke.modifiers.shift - && total > 0 => { - self.select_list_index(0, cx); - self.scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - } - "g" if keystroke.modifiers.shift - && total > 0 => { - let last = total - 1; - self.select_list_index(last, cx); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Center); - } - "end" - if total > 0 => { - let last = total - 1; - self.select_list_index(last, cx); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Center); - } - "home" - if total > 0 => { - self.select_list_index(0, cx); - self.scroll_handle - .scroll_to_item(0, ScrollStrategy::Top); - } - "/" if !primary => { - self.show_search = true; - self.search_editor.update(cx, |e: &mut rgitui_ui::TextInput, cx| e.focus(window, cx)); - cx.notify(); - } - "escape" - // Dismiss context menu or deselect - if self.context_menu.is_some() => { - self.dismiss_context_menu(cx); - } - "y" | "Y" if !primary && !keystroke.modifiers.shift => { - // Copy SHA of selected commit (standard GitKraken shortcut) - if let Some(commit) = self.selected_commit() { - let sha = format!("{}", commit.oid); - cx.emit(GraphViewEvent::CopyCommitSha(sha)); - } - } - _ => {} + if total > 0 { + self.select_row(total - 1, ScrollStrategy::Center, cx); + } + } + + fn select_row(&mut self, index: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.select_list_index(index, cx); + self.scroll_handle.scroll_to_item(index, strategy); + } + + /// Closes the search field, or dismisses the context menu. + /// + /// Also cancels an in-progress drag-to-rebase, which a stray Esc must not + /// leave half-applied. + pub fn cancel(&mut self, window: &mut Window, cx: &mut Context) { + self.dragging_oid = None; + self.drag_start_position = None; + self.drag_moved = false; + self.suppress_next_click = false; + + if self.show_search { + self.show_search = false; + self.search_editor + .update(cx, |editor: &mut rgitui_ui::TextInput, cx| editor.clear(cx)); + self.filter_matches.clear(); + self.filter_match_set_arc = Arc::new(HashSet::new()); + self.current_match = 0; + self.graph_focus.focus(window, cx); + cx.notify(); + return; + } + + if self.context_menu.is_some() { + self.dismiss_context_menu(cx); + } + } + + /// Copies the selected commit's SHA to the clipboard. + pub fn copy_selected_sha(&mut self, cx: &mut Context) { + if let Some(commit) = self.selected_commit() { + let sha = commit.oid.to_string(); + cx.emit(GraphViewEvent::CopyCommitSha(sha)); + } + } + + /// Copies the selected commit's message to the clipboard. + pub fn copy_selected_message(&mut self, cx: &mut Context) { + if let Some(commit) = self.selected_commit() { + let message = commit.message.clone(); + cx.emit(GraphViewEvent::CopyCommitMessage(message)); } } } @@ -1205,6 +1719,7 @@ impl Render for GraphView { let commits = self.commits.clone(); let graph_rows = self.graph_rows.clone(); let selected_index = self.selected_index; + let selected_rows = Arc::clone(&self.selected_rows); let worktree_infos = self.worktree_infos.clone(); let worktree_row_positions = self.worktree_row_positions.clone(); let virtual_rows_prefix = self.virtual_rows_prefix.clone(); @@ -1601,7 +2116,9 @@ impl Render for GraphView { let commit = &commits[commit_idx]; let oid = commit.oid; let graph_row = &graph_rows[commit_idx]; - let selected = selected_index == Some(i); + // Every member of a multi-selection is highlighted the same + // way as a lone selection; the cursor is `selected_index`. + let selected = selected_index == Some(i) || selected_rows.contains(&i); let is_current_match = current_match_index == Some(commit_idx); let is_any_match = has_search_query && filter_match_set.contains(&commit_idx); let is_head_row = graph_row.is_head; @@ -1726,9 +2243,14 @@ impl Render for GraphView { return; } this.dismiss_context_menu(cx); + let modifiers = event.modifiers(); if event.click_count() >= 2 { // Double-click: checkout this commit cx.emit(GraphViewEvent::CheckoutCommit(oid)); + } else if modifiers.shift { + this.extend_selection_to_list_index(i, cx); + } else if modifiers.secondary() { + this.toggle_selection_at_list_index(i, cx); } else { this.select_list_index(i, cx); } @@ -2216,8 +2738,9 @@ impl Render for GraphView { let mut container = div() .id("graph-view") .track_focus(&self.graph_focus) + // Bindings scoped to `GraphView` resolve to `graph::*` actions the + // workspace root handles; this crate cannot name them itself. .key_context("GraphView") - .on_key_down(cx.listener(Self::handle_graph_key_down)) .relative() .v_flex() .size_full() @@ -2431,14 +2954,8 @@ impl Render for GraphView { // Context menu overlay if let Some(ref menu_state) = self.context_menu { if let Some(commit) = self.commits.get(menu_state.commit_index) { - let oid = commit.oid; - let sha = format!("{}", oid); - let msg_clone = commit.message.clone(); - let author_name_clone = commit.author.name.clone(); - let date_clone = commit.time.format("%Y-%m-%d %H:%M:%S").to_string(); let pos = menu_state.position; let weak = cx.weak_entity(); - let sha_clone = sha.clone(); let menu_bg = colors.elevated_surface_background; let menu_border = colors.border; @@ -2446,22 +2963,7 @@ impl Render for GraphView { let menu_active = colors.ghost_element_active; let menu_accent = colors.text_accent; - let menu_items: Vec<(&str, IconName)> = vec![ - ("Cherry-pick commit", IconName::GitCommit), - ("Revert commit", IconName::Undo), - ("Checkout commit", IconName::Check), - ("Create branch here", IconName::GitBranch), - ("Create tag here", IconName::Tag), - ("Mark as good (bisect)", IconName::Check), - ("Mark as bad (bisect)", IconName::X), - ("Reset to here", IconName::Trash), - ("Interactive Rebase", IconName::GitMerge), - ("Copy SHA", IconName::Copy), - ("Copy commit message", IconName::Edit), - ("Copy author name", IconName::User), - ("Copy date", IconName::Clock), - ("View on GitHub", IconName::ExternalLink), - ]; + let items = self.context_menu_items(); // Placement and visible height, clamped to the container. Shared // with the dismiss hit-test so both agree on the menu rectangle. @@ -2501,13 +3003,8 @@ impl Render for GraphView { cx.stop_propagation(); }); - for (idx, (label_text, icon_name)) in menu_items.iter().enumerate() { - let label: SharedString = (*label_text).into(); - let icon = *icon_name; - - // Add separator before bisect options, before destructive "Reset", - // before Interactive Rebase, and before clipboard ops - if idx == 5 || idx == 7 || idx == 8 || idx == 12 { + for (idx, item) in items.iter().enumerate() { + if item.separator_before { menu = menu.child( div() .w_full() @@ -2518,237 +3015,51 @@ impl Render for GraphView { ); } - let mut item = div() - .id(ElementId::NamedInteger("ctx-action".into(), idx as u64)) - .h_flex() - .w_full() - .h(px(26.)) - .px(px(8.)) - .mx(px(4.)) - .gap(px(6.)) - .items_center() - .cursor_pointer() - .rounded(px(3.)) - .hover(move |s| s.bg(menu_hover).border_l_2().border_color(menu_accent)) - .active(move |s| s.bg(menu_active)); - - match idx { - 0 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CherryPick(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 1 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::RevertCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 2 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CheckoutCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 3 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CreateBranchAtCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 4 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CreateTagAtCommit(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 5 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::BisectGood(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 6 => { - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::BisectBad(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 7 => { - let w = weak.clone(); - let sha_for_reset = sha_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let sha_val = sha_for_reset.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::ResetToCommit(oid, sha_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 8 => { - // Interactive Rebase — emit event with the right-clicked commit's OID. - // The workspace handler will build the commit list from HEAD down - // to (including) this commit and open the rebase editor. - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::InteractiveRebase(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - 9 => { - let w = weak.clone(); - let sha_for_click = sha_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let sha_val = sha_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyCommitSha(sha_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 10 => { - let w = weak.clone(); - let msg_for_click = msg_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let msg_val = msg_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyCommitMessage(msg_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 11 => { - let w = weak.clone(); - let author_for_click = author_name_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let author_val = author_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyAuthorName(author_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 12 => { - let w = weak.clone(); - let date_for_click = date_clone.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - let date_val = date_for_click.clone(); - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::CopyDate(date_val)); - cx.notify(); - }) - .ok(); - }, - ); - } - 13 => { - // View on GitHub — emit OID; workspace handler constructs the URL. - let w = weak.clone(); - item = item.on_click( - move |_: &ClickEvent, _: &mut Window, cx: &mut App| { - w.update(cx, |this: &mut GraphView, cx| { - this.context_menu = None; - cx.emit(GraphViewEvent::ViewOnGithub(oid)); - cx.notify(); - }) - .ok(); - }, - ); - } - _ => {} - } - - // Destructive actions render in error color (Bad and Reset) - let item_color = if idx == 6 || idx == 7 { - Color::Error + // Built here rather than in the click handler so the row + // captures the commit the menu was opened on, even if the + // selection moves before the click lands. + let event = Self::menu_event(item.action, commit); + let weak = weak.clone(); + let (icon_color, label_color) = if item.destructive { + (Color::Error, Color::Error) } else { - Color::Muted + (Color::Muted, Color::Default) }; - let label_color = if idx == 6 || idx == 7 { - Color::Error - } else { - Color::Default - }; - - item = item - .child(Icon::new(icon).size(IconSize::XSmall).color(item_color)) - .child(Label::new(label).size(LabelSize::XSmall).color(label_color)); - menu = menu.child(item); + menu = menu.child( + div() + .id(ElementId::NamedInteger("ctx-action".into(), idx as u64)) + .h_flex() + .w_full() + .h(px(CONTEXT_MENU_ITEM_HEIGHT)) + .px(px(8.)) + .mx(px(4.)) + .gap(px(6.)) + .items_center() + .cursor_pointer() + .rounded(px(3.)) + .hover(move |s| s.bg(menu_hover).border_l_2().border_color(menu_accent)) + .active(move |s| s.bg(menu_active)) + .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + let event = event.clone(); + weak.update(cx, |this: &mut GraphView, cx| { + this.context_menu = None; + cx.emit(event); + cx.notify(); + }) + .ok(); + }) + .child( + Icon::new(item.icon) + .size(IconSize::XSmall) + .color(icon_color), + ) + .child( + Label::new(SharedString::from(item.label)) + .size(LabelSize::XSmall) + .color(label_color), + ), + ); } let menu = menu.with_animation( @@ -3625,6 +3936,72 @@ mod tests { git2::Oid::from_bytes(&bytes).unwrap() } + use menu_items_for_selection as menu_items; + + #[test] + fn squash_is_offered_only_once_two_commits_are_selected() { + for selected in [0, 1] { + assert!( + !menu_items(selected) + .iter() + .any(|item| item.action == GraphMenuAction::SquashSelected), + "squash offered with {selected} commit(s) selected" + ); + } + for selected in [2, 5] { + assert!( + menu_items(selected) + .iter() + .any(|item| item.action == GraphMenuAction::SquashSelected), + "squash missing with {selected} commits selected" + ); + } + } + + /// The bug the hard-coded `CONTEXT_MENU_ITEM_COUNT` used to cause: an item + /// added without touching the constant left the menu sized for the old list, + /// clipping the last row. + #[test] + fn the_menu_height_follows_the_rows_it_actually_has() { + let without = context_menu_height(&menu_items(1)); + let with = context_menu_height(&menu_items(2)); + assert_eq!( + with - without, + CONTEXT_MENU_ITEM_HEIGHT, + "showing squash must add exactly one row's worth of height" + ); + + let rows = menu_items(2).len() as f32; + let separators = menu_items(2) + .iter() + .filter(|item| item.separator_before) + .count() as f32; + assert_eq!( + with, + rows * CONTEXT_MENU_ITEM_HEIGHT + + separators * CONTEXT_MENU_SEPARATOR_HEIGHT + + CONTEXT_MENU_VERTICAL_PADDING + ); + } + + #[test] + fn every_menu_row_is_distinct_and_no_group_starts_the_menu() { + let mut seen: Vec = Vec::new(); + for item in GRAPH_MENU_ITEMS { + assert!(!item.label.is_empty()); + assert!( + !seen.contains(&item.action), + "{:?} appears twice in the menu", + item.action + ); + seen.push(item.action); + } + assert!( + !GRAPH_MENU_ITEMS[0].separator_before, + "a separator above the first row draws a stray line inside the border" + ); + } + fn make_commit(oid: u8, parents: &[u8], refs: Vec) -> CommitInfo { CommitInfo { oid: make_oid(oid), @@ -3920,4 +4297,229 @@ mod tests { let result = format_relative_time(&t, now); assert_eq!(result, "1y ago"); } + + // ── Row ↔ commit index mapping ──────────────────────────────────── + + /// The list indices worktree rows occupy for a real layout, so the mapping + /// tests below are pinned to what `compute_worktree_row_positions` produces + /// rather than to a hand-guessed arrangement. + fn worktree_rows_for(commits: &[CommitInfo], worktrees: &[WorktreeGraphInfo]) -> Vec { + let graph_rows = compute_graph(commits); + compute_worktree_row_positions( + commits, + &graph_rows, + worktrees, + graph_lane_count(&graph_rows), + ) + .iter() + .map(|position| position.list_index) + .collect() + } + + #[test] + fn rows_map_to_commits_one_to_one_without_worktree_rows() { + for commit_index in 0..3 { + assert_eq!( + row_for_commit_index(&[], 3, commit_index), + Some(commit_index) + ); + assert_eq!(commit_index_for_row(&[], commit_index), Some(commit_index)); + } + assert_eq!(row_for_commit_index(&[], 3, 3), None); + } + + #[test] + fn an_interleaved_worktree_row_shifts_every_commit_below_it() { + // 3 -> 2 -> 1 with a dirty worktree on commit 2 (commit index 1), whose + // pseudo-node therefore takes the row commit 2 used to sit on. + let commits = vec![ + make_commit(3, &[2], vec![RefLabel::Head]), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![dirty_worktree("wip", Some(make_oid(2)), false)]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows, vec![1]); + + // The commit above the worktree row keeps its row; the ones below shift. + assert_eq!(row_for_commit_index(&worktree_rows, 3, 0), Some(0)); + assert_eq!(row_for_commit_index(&worktree_rows, 3, 1), Some(2)); + assert_eq!(row_for_commit_index(&worktree_rows, 3, 2), Some(3)); + + assert_eq!(commit_index_for_row(&worktree_rows, 0), Some(0)); + // The worktree row itself is not a commit. + assert_eq!(commit_index_for_row(&worktree_rows, 1), None); + assert_eq!(commit_index_for_row(&worktree_rows, 2), Some(1)); + assert_eq!(commit_index_for_row(&worktree_rows, 3), Some(2)); + } + + #[test] + fn several_worktree_rows_round_trip_through_the_mapping() { + let commits = vec![ + make_commit(4, &[3], vec![RefLabel::Head]), + make_commit(3, &[2], Vec::new()), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![ + dirty_worktree("current", Some(make_oid(4)), true), + dirty_worktree("other", Some(make_oid(2)), false), + ]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows.len(), 2); + + for commit_index in 0..commits.len() { + let row = row_for_commit_index(&worktree_rows, commits.len(), commit_index) + .expect("every commit has a row"); + assert!( + !worktree_rows.contains(&row), + "commit {commit_index} was mapped onto worktree row {row}" + ); + assert_eq!( + commit_index_for_row(&worktree_rows, row), + Some(commit_index) + ); + } + for row in &worktree_rows { + assert_eq!(commit_index_for_row(&worktree_rows, *row), None); + } + } + + // ── Selection set mechanics ─────────────────────────────────────── + + fn members(selection: &CommitSelection) -> Vec { + selection.iter().collect() + } + + #[test] + fn a_plain_selection_holds_one_commit_and_anchors_there() { + let mut selection = CommitSelection::default(); + selection.replace(4); + assert_eq!(members(&selection), vec![4]); + assert_eq!(selection.anchor(), Some(4)); + assert_eq!(selection.len(), 1); + + // A second plain selection replaces the first — this is the click and + // `j`/`k` path, which must behave exactly as it did before multi-select. + selection.replace(7); + assert_eq!(members(&selection), vec![7]); + assert_eq!(selection.anchor(), Some(7)); + } + + #[test] + fn toggling_adds_and_removes_single_commits() { + let mut selection = CommitSelection::default(); + selection.replace(2); + selection.toggle(5); + selection.toggle(8); + assert_eq!(members(&selection), vec![2, 5, 8]); + assert!(selection.contains(5)); + + selection.toggle(5); + assert_eq!(members(&selection), vec![2, 8]); + assert!(!selection.contains(5)); + // The anchor follows the toggled row even when it was removed. + assert_eq!(selection.anchor(), Some(5)); + } + + #[test] + fn extending_grows_a_range_from_the_anchor_in_both_directions() { + let mut selection = CommitSelection::default(); + selection.replace(3); + selection.extend_to(5); + assert_eq!(members(&selection), vec![3, 4, 5]); + // Re-extending replaces the previous range instead of accumulating. + selection.extend_to(4); + assert_eq!(members(&selection), vec![3, 4]); + // Crossing the anchor selects the other side of it, anchor unmoved. + selection.extend_to(1); + assert_eq!(members(&selection), vec![1, 2, 3]); + assert_eq!(selection.anchor(), Some(3)); + } + + #[test] + fn extending_keeps_what_was_selected_when_the_anchor_was_set() { + let mut selection = CommitSelection::default(); + selection.replace(0); + selection.toggle(4); + selection.extend_to(6); + assert_eq!(members(&selection), vec![0, 4, 5, 6]); + } + + #[test] + fn extending_without_an_anchor_selects_just_the_target() { + let mut selection = CommitSelection::default(); + selection.extend_to(9); + assert_eq!(members(&selection), vec![9]); + assert_eq!(selection.anchor(), Some(9)); + } + + #[test] + fn a_plain_selection_resets_the_anchor_and_drops_the_rest() { + let mut selection = CommitSelection::default(); + selection.replace(2); + selection.extend_to(6); + assert_eq!(selection.len(), 5); + + selection.replace(6); + assert_eq!(members(&selection), vec![6]); + assert_eq!(selection.anchor(), Some(6)); + // Extending after the reset grows from the new anchor only. + selection.extend_to(8); + assert_eq!(members(&selection), vec![6, 7, 8]); + } + + #[test] + fn clearing_drops_the_selection_and_the_anchor() { + let mut selection = CommitSelection::default(); + selection.replace(1); + selection.extend_to(3); + selection.clear(); + assert_eq!(members(&selection), Vec::::new()); + assert_eq!(selection.anchor(), None); + assert_eq!(selection.len(), 0); + } + + #[test] + fn a_restored_selection_keeps_its_members_and_anchor() { + let selection = CommitSelection::from_parts([5, 2, 3], Some(2)); + assert_eq!(members(&selection), vec![2, 3, 5]); + assert_eq!(selection.anchor(), Some(2)); + + // Everything the reload dropped leaves nothing to anchor to. + let emptied = CommitSelection::from_parts([], Some(2)); + assert_eq!(emptied.anchor(), None); + } + + /// A keyboard extension steps in commit space, so a worktree row between two + /// commits is stepped straight over and the selection stays contiguous — which + /// is what the squash validator needs. + #[test] + fn a_selection_spanning_a_worktree_row_is_contiguous_in_commit_space() { + let commits = vec![ + make_commit(3, &[2], vec![RefLabel::Head]), + make_commit(2, &[1], Vec::new()), + make_commit(1, &[], Vec::new()), + ]; + let worktrees = vec![dirty_worktree("wip", Some(make_oid(2)), false)]; + let worktree_rows = worktree_rows_for(&commits, &worktrees); + assert_eq!(worktree_rows, vec![1]); + + let mut selection = CommitSelection::default(); + selection.replace(0); + selection.extend_to(1); + assert_eq!(members(&selection), vec![0, 1]); + + // The two selected commits are two rows apart because the worktree row + // sits between them, and neither maps onto that row. + let rows: Vec = selection + .iter() + .map(|commit_index| { + row_for_commit_index(&worktree_rows, commits.len(), commit_index) + .expect("selected commits have rows") + }) + .collect(); + assert_eq!(rows, vec![0, 2]); + assert!(rows.iter().all(|row| !worktree_rows.contains(row))); + } } diff --git a/crates/rgitui_settings/src/lib.rs b/crates/rgitui_settings/src/lib.rs index 601b75c7..497a4992 100644 --- a/crates/rgitui_settings/src/lib.rs +++ b/crates/rgitui_settings/src/lib.rs @@ -947,6 +947,22 @@ pub fn config_dir() -> PathBuf { .join("rgitui") } +/// File name of the settings file inside [`config_dir`]. +const SETTINGS_FILE_NAME: &str = "settings.json"; + +/// File name of the user keymap inside [`config_dir`]. +const KEYMAP_FILE_NAME: &str = "keymap.json"; + +/// Path of the settings file. +pub fn settings_path() -> PathBuf { + config_dir().join(SETTINGS_FILE_NAME) +} + +/// Path of the user keymap, alongside the settings file. +pub fn keymap_path() -> PathBuf { + config_dir().join(KEYMAP_FILE_NAME) +} + /// A single queued settings write. `json` is pre-serialized on the caller's /// thread so the snapshot reflects state at call time; `ack`, when present, /// is fired after the write attempt completes so a blocking caller can wait. @@ -1017,7 +1033,7 @@ fn write_settings_file(config_path: &Path, json: &str) { /// Initialize settings. Must be called during app init. pub fn init(cx: &mut App) { - let config_path = config_dir().join("settings.json"); + let config_path = settings_path(); let mut load_warnings = Vec::new(); let settings = if config_path.exists() { match std::fs::read_to_string(&config_path) { diff --git a/crates/rgitui_ui/src/text_input.rs b/crates/rgitui_ui/src/text_input.rs index adfdcf8b..b2e1ea91 100644 --- a/crates/rgitui_ui/src/text_input.rs +++ b/crates/rgitui_ui/src/text_input.rs @@ -602,6 +602,9 @@ impl Render for TextInput { } else { "native-text-input" }) + // Lets keybindings exclude text fields with `!TextInput`, so an + // unmodified single-key shortcut cannot steal a typed character. + .key_context("TextInput") .track_focus(&self.focus_handle) .on_key_down(cx.listener(Self::handle_key_down)) .on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down)) diff --git a/crates/rgitui_workspace/Cargo.toml b/crates/rgitui_workspace/Cargo.toml index 21a09a88..8d667ecf 100644 --- a/crates/rgitui_workspace/Cargo.toml +++ b/crates/rgitui_workspace/Cargo.toml @@ -23,7 +23,9 @@ anyhow.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true +serde_json_lenient.workspace = true smallvec.workspace = true +notify.workspace = true dirs.workspace = true md5.workspace = true http_client.workspace = true diff --git a/crates/rgitui_workspace/src/bisect_view.rs b/crates/rgitui_workspace/src/bisect_view.rs index fdcfc621..70e43a65 100644 --- a/crates/rgitui_workspace/src/bisect_view.rs +++ b/crates/rgitui_workspace/src/bisect_view.rs @@ -4,15 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - InteractiveElement, KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, - ParentElement, Render, ScrollStrategy, SharedString, Styled, UniformListScrollHandle, - WeakEntity, Window, + InteractiveElement, ListSizingBehavior, MouseButton, MouseDownEvent, ParentElement, Render, + ScrollStrategy, SharedString, Styled, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{BisectDecision, BisectLogEntry}; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + const BISECT_ICON: IconName = IconName::GitMerge; /// Events emitted by the bisect view. @@ -90,71 +92,44 @@ impl BisectView { (good, bad, skip, start) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.entries.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(entry) = self.entries.get(row) { - cx.emit(BisectViewEvent::CommitSelected(entry.sha.clone())); - } - } - cx.stop_propagation(); - } - "escape" => { - cx.emit(BisectViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + match cmd { + CommandId::Cancel => cx.emit(BisectViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(entry) = self.highlighted_row.and_then(|row| self.entries.get(row)) { + cx.emit(BisectViewEvent::CommitSelected(entry.sha.clone())); } - cx.notify(); - cx.stop_propagation(); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_header(&self, cx: &mut Context, count: usize) -> gpui::Div { let colors = cx.colors(); let (good, bad, skip, start) = self.decision_counts(); @@ -440,8 +415,15 @@ impl Render for BisectView { div() .id("bisect-view") .track_focus(&self.focus_handle) - .key_context("BisectView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "BisectView List", + &["Menu", "BisectView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/blame_view.rs b/crates/rgitui_workspace/src/blame_view.rs index c45e1fe0..c031f4a4 100644 --- a/crates/rgitui_workspace/src/blame_view.rs +++ b/crates/rgitui_workspace/src/blame_view.rs @@ -5,14 +5,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::BlameLine; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the blame view. #[derive(Debug, Clone, PartialEq, Eq)] pub enum BlameViewEvent { @@ -97,78 +100,48 @@ impl BlameView { map } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `BlameView` or to the shared `List` + /// group. Esc is a *navigation* here — back to the diff — which is why the + /// view owns `BlameShowDiff` instead of leaning on `menu::Cancel`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let line_count = self.lines.len(); - if line_count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(line_count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(line) = self.lines.get(row) { - let oid = line.entry.oid.to_string(); - cx.emit(BlameViewEvent::CommitSelected(oid)); - } - } - cx.stop_propagation(); - } - "escape" | "d" => { - cx.emit(BlameViewEvent::SwitchToDiff); - cx.stop_propagation(); - } - "h" => { - cx.emit(BlameViewEvent::SwitchToHistory); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last line - let last = line_count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first line - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + match cmd { + CommandId::BlameShowDiff => cx.emit(BlameViewEvent::SwitchToDiff), + CommandId::BlameShowHistory => cx.emit(BlameViewEvent::SwitchToHistory), + CommandId::Cancel => cx.emit(BlameViewEvent::Dismissed), + _ if line_count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(line_count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(line_count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(line) = self.highlighted_row.and_then(|row| self.lines.get(row)) { + cx.emit(BlameViewEvent::CommitSelected(line.entry.oid.to_string())); } - cx.notify(); - cx.stop_propagation(); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -233,7 +206,7 @@ impl BlameView { impl Render for BlameView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.lines.is_empty() { return self.render_empty_state(cx); @@ -460,8 +433,15 @@ impl Render for BlameView { div() .id("blame-view") .track_focus(&self.focus_handle) - .key_context("BlameView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "BlameView List", + &["Menu", "BlameView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/branch_dialog.rs b/crates/rgitui_workspace/src/branch_dialog.rs index 4f0620da..11539d33 100644 --- a/crates/rgitui_workspace/src/branch_dialog.rs +++ b/crates/rgitui_workspace/src/branch_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the branch creation dialog. #[derive(Debug, Clone)] pub enum BranchDialogEvent { @@ -158,14 +160,14 @@ impl BranchDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `BranchDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -181,7 +183,7 @@ impl Render for BranchDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -197,7 +199,9 @@ impl Render for BranchDialog { let mut modal = div() .id("branch-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "BranchDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/command_palette.rs b/crates/rgitui_workspace/src/command_palette.rs index 6815a582..de49f397 100644 --- a/crates/rgitui_workspace/src/command_palette.rs +++ b/crates/rgitui_workspace/src/command_palette.rs @@ -4,12 +4,15 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, - FontWeight, KeyDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, - Window, + FontWeight, Render, ScrollStrategy, SharedString, UniformListScrollHandle, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, Tooltip}; +use crate::keymap; + +use crate::CommandId; + /// Pre-computed git context used for context-sensitive command filtering. /// Computed from GitProject state and passed to predicates. #[derive(Debug, Clone, Copy, Default)] @@ -30,6 +33,9 @@ pub struct CommandContext { pub in_progress_operation: bool, /// True when the user has a GitHub token configured. pub has_github_token: bool, + /// True when the commit graph has more than one commit selected, which is + /// what the multi-commit operations (squash, for one) need. + pub has_multi_commit_selection: bool, } impl CommandContext { @@ -45,9 +51,19 @@ impl CommandContext { has_staged: false, in_progress_operation: false, has_github_token: false, + has_multi_commit_selection: false, } } + /// Records whether the commit graph has more than one commit selected. + /// + /// Kept off [`Self::from_parts`], which maps *repository* state: this flag + /// comes from the graph view's selection and changes far more often. + pub fn with_multi_commit_selection(mut self, selected: bool) -> Self { + self.has_multi_commit_selection = selected; + self + } + /// Build a context from primitive inputs. Keeps the `RepoState` → /// `worktree_clean` / `is_bisecting` / `in_progress_operation` mapping in /// one place so `open_repo`, refresh, and future callers cannot drift. @@ -78,335 +94,107 @@ impl CommandContext { | rgitui_git::RepoState::RevertSequence ), has_github_token, + has_multi_commit_selection: false, } } } /// A no-op predicate that always shows the command. -const fn always_show(_: CommandContext) -> bool { +pub(crate) const fn always_show(_: CommandContext) -> bool { true } /// Show only when the user has a GitHub token configured (for PR creation). -const fn has_github_token(ctx: CommandContext) -> bool { +pub(crate) const fn has_github_token(ctx: CommandContext) -> bool { ctx.has_github_token } /// Show only when the repository has at least one remote configured. -const fn has_remotes(ctx: CommandContext) -> bool { +pub(crate) const fn has_remotes(ctx: CommandContext) -> bool { ctx.has_remotes } /// Show only when there are unstaged and/or staged file changes. -const fn has_changes(ctx: CommandContext) -> bool { +pub(crate) const fn has_changes(ctx: CommandContext) -> bool { ctx.has_changes } /// Show only when the repository worktree is clean (no uncommitted changes). -const fn worktree_clean(ctx: CommandContext) -> bool { +pub(crate) const fn worktree_clean(ctx: CommandContext) -> bool { ctx.worktree_clean } /// Show only when the repository is currently bisecting. -const fn is_bisecting(ctx: CommandContext) -> bool { +pub(crate) const fn is_bisecting(ctx: CommandContext) -> bool { ctx.is_bisecting } /// Show only when there is at least one stash entry. -const fn has_stashes(ctx: CommandContext) -> bool { +pub(crate) const fn has_stashes(ctx: CommandContext) -> bool { ctx.has_stashes } /// Show only when there are staged files to commit. -const fn has_staged(ctx: CommandContext) -> bool { +pub(crate) const fn has_staged(ctx: CommandContext) -> bool { ctx.has_staged } /// Show only when in a merge, rebase, cherry-pick, or revert in-progress state. -const fn in_progress_operation(ctx: CommandContext) -> bool { +pub(crate) const fn in_progress_operation(ctx: CommandContext) -> bool { ctx.in_progress_operation } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum CommandId { - Fetch, - Pull, - Push, - PushAll, - PullAll, - ForcePush, - Commit, - StageAll, - UnstageAll, - StashSave, - StashPop, - StashApply, - StashDrop, - CreateBranch, - DeleteBranch, - RenameBranch, - MergeBranch, - CreateTag, - CreateWorktree, - CreatePr, - CherryPick, - RevertCommit, - InteractiveRebase, - DiscardAll, - CleanUntracked, - ResetHard, - AbortOperation, - ContinueMerge, - ToggleDiffMode, - Search, - AiMessage, - Refresh, - Settings, - OpenRepo, - WorkspaceHome, - RestoreLastWorkspace, - Shortcuts, - SwitchBranch, - Blame, - Undo, - FileHistory, - Reflog, - Submodules, - Bisect, - BisectStart, - BisectGood, - BisectBad, - BisectReset, - BisectSkip, - GlobalSearch, - ToggleIssues, - TogglePullRequests, - ToggleBranchHealth, - ToggleStashes, - StashBranch, - OpenThemeEditor, +/// Show only when the commit graph has two or more commits selected. +pub(crate) const fn has_multi_commit_selection(ctx: CommandContext) -> bool { + ctx.has_multi_commit_selection } +/// The command identifier, its gpui action and its default keybinding are +/// generated by the `commands!` macro in `keymap::registry`. impl CommandId { - pub fn as_str(self) -> &'static str { - match self { - Self::Fetch => "fetch", - Self::Pull => "pull", - Self::Push => "push", - Self::PushAll => "push_all", - Self::PullAll => "pull_all", - Self::ForcePush => "force_push", - Self::Commit => "commit", - Self::StageAll => "stage_all", - Self::UnstageAll => "unstage_all", - Self::StashSave => "stash_save", - Self::StashPop => "stash_pop", - Self::StashApply => "stash_apply", - Self::StashDrop => "stash_drop", - Self::CreateBranch => "create_branch", - Self::DeleteBranch => "delete_branch", - Self::RenameBranch => "rename_branch", - Self::MergeBranch => "merge_branch", - Self::CreateTag => "create_tag", - Self::CreateWorktree => "create_worktree", - Self::CreatePr => "create_pr", - Self::CherryPick => "cherry_pick", - Self::RevertCommit => "revert_commit", - Self::InteractiveRebase => "interactive_rebase", - Self::DiscardAll => "discard_all", - Self::CleanUntracked => "clean_untracked", - Self::ResetHard => "reset_hard", - Self::AbortOperation => "abort_operation", - Self::ContinueMerge => "continue_merge", - Self::ToggleDiffMode => "toggle_diff_mode", - Self::Search => "search", - Self::AiMessage => "ai_message", - Self::Refresh => "refresh", - Self::Settings => "settings", - Self::OpenRepo => "open_repo", - Self::WorkspaceHome => "workspace_home", - Self::RestoreLastWorkspace => "restore_last_workspace", - Self::Shortcuts => "shortcuts", - Self::SwitchBranch => "switch_branch", - Self::Blame => "blame", - Self::Undo => "undo", - Self::FileHistory => "file_history", - Self::Reflog => "reflog", - Self::Submodules => "submodules", - Self::Bisect => "bisect", - Self::BisectStart => "bisect_start", - Self::BisectGood => "bisect_good", - Self::BisectBad => "bisect_bad", - Self::BisectReset => "bisect_reset", - Self::BisectSkip => "bisect_skip", - Self::GlobalSearch => "global_search", - Self::ToggleIssues => "toggle_issues", - Self::TogglePullRequests => "toggle_pull_requests", - Self::ToggleBranchHealth => "toggle_branch_health", - Self::ToggleStashes => "toggle_stashes", - Self::StashBranch => "stash_branch", - Self::OpenThemeEditor => "open_theme_editor", - } - } - - pub fn display_label(self) -> &'static str { - match self { - Self::Fetch => "fetch", - Self::Pull => "pull", - Self::Push => "push", - Self::PushAll => "push all", - Self::PullAll => "pull all", - Self::ForcePush => "force push", - Self::Commit => "commit", - Self::StageAll => "stage all", - Self::UnstageAll => "unstage all", - Self::StashSave => "stash save", - Self::StashPop => "stash pop", - Self::StashApply => "stash apply", - Self::StashDrop => "stash drop", - Self::CreateBranch => "create branch", - Self::DeleteBranch => "delete branch", - Self::RenameBranch => "rename branch", - Self::MergeBranch => "merge branch", - Self::CreateTag => "create tag", - Self::CreateWorktree => "create worktree", - Self::CreatePr => "create pull request", - Self::CherryPick => "cherry pick", - Self::RevertCommit => "revert commit", - Self::InteractiveRebase => "interactive rebase", - Self::DiscardAll => "discard all", - Self::CleanUntracked => "clean untracked", - Self::ResetHard => "reset hard", - Self::AbortOperation => "abort operation", - Self::ContinueMerge => "continue merge", - Self::ToggleDiffMode => "toggle diff mode", - Self::Search => "search", - Self::AiMessage => "ai message", - Self::Refresh => "refresh", - Self::Settings => "settings", - Self::OpenRepo => "open repo", - Self::WorkspaceHome => "workspace home", - Self::RestoreLastWorkspace => "restore last workspace", - Self::Shortcuts => "shortcuts", - Self::SwitchBranch => "switch branch", - Self::Blame => "blame file", - Self::Undo => "undo last operation", - Self::FileHistory => "file history", - Self::Reflog => "reflog", - Self::Submodules => "submodules", - Self::Bisect => "bisect log", - Self::BisectStart => "bisect start", - Self::BisectGood => "bisect good (current)", - Self::BisectBad => "bisect bad (current)", - Self::BisectReset => "bisect reset", - Self::BisectSkip => "bisect skip (current)", - Self::GlobalSearch => "global search", - Self::ToggleIssues => "toggle issues panel", - Self::TogglePullRequests => "toggle pull requests panel", - Self::ToggleBranchHealth => "toggle branch health panel", - Self::ToggleStashes => "toggle stashes panel", - Self::StashBranch => "create branch from stash", - Self::OpenThemeEditor => "edit theme", - } - } -} - -impl std::fmt::Display for CommandId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(self.as_str()) - } -} - -impl TryFrom<&str> for CommandId { - type Error = (); - - fn try_from(s: &str) -> Result { - match s { - "fetch" => Ok(Self::Fetch), - "pull" => Ok(Self::Pull), - "push" => Ok(Self::Push), - "push_all" => Ok(Self::PushAll), - "pull_all" => Ok(Self::PullAll), - "force_push" => Ok(Self::ForcePush), - "commit" => Ok(Self::Commit), - "stage_all" => Ok(Self::StageAll), - "unstage_all" => Ok(Self::UnstageAll), - "stash_save" => Ok(Self::StashSave), - "stash_pop" => Ok(Self::StashPop), - "stash_apply" => Ok(Self::StashApply), - "stash_drop" => Ok(Self::StashDrop), - "create_branch" => Ok(Self::CreateBranch), - "delete_branch" => Ok(Self::DeleteBranch), - "rename_branch" => Ok(Self::RenameBranch), - "merge_branch" => Ok(Self::MergeBranch), - "create_tag" => Ok(Self::CreateTag), - "create_worktree" => Ok(Self::CreateWorktree), - "create_pr" => Ok(Self::CreatePr), - "cherry_pick" => Ok(Self::CherryPick), - "revert_commit" => Ok(Self::RevertCommit), - "interactive_rebase" => Ok(Self::InteractiveRebase), - "discard_all" => Ok(Self::DiscardAll), - "clean_untracked" => Ok(Self::CleanUntracked), - "reset_hard" => Ok(Self::ResetHard), - "abort_operation" => Ok(Self::AbortOperation), - "continue_merge" => Ok(Self::ContinueMerge), - "toggle_diff_mode" => Ok(Self::ToggleDiffMode), - "search" => Ok(Self::Search), - "ai_message" => Ok(Self::AiMessage), - "refresh" => Ok(Self::Refresh), - "settings" => Ok(Self::Settings), - "open_repo" => Ok(Self::OpenRepo), - "workspace_home" => Ok(Self::WorkspaceHome), - "restore_last_workspace" => Ok(Self::RestoreLastWorkspace), - "shortcuts" => Ok(Self::Shortcuts), - "switch_branch" => Ok(Self::SwitchBranch), - "blame" => Ok(Self::Blame), - "undo" => Ok(Self::Undo), - "file_history" => Ok(Self::FileHistory), - "reflog" => Ok(Self::Reflog), - "submodules" => Ok(Self::Submodules), - "bisect" => Ok(Self::Bisect), - "bisect_start" => Ok(Self::BisectStart), - "bisect_good" => Ok(Self::BisectGood), - "bisect_bad" => Ok(Self::BisectBad), - "bisect_reset" => Ok(Self::BisectReset), - "bisect_skip" => Ok(Self::BisectSkip), - "global_search" => Ok(Self::GlobalSearch), - "toggle_issues" => Ok(Self::ToggleIssues), - "toggle_pull_requests" => Ok(Self::TogglePullRequests), - "toggle_branch_health" => Ok(Self::ToggleBranchHealth), - "toggle_stashes" => Ok(Self::ToggleStashes), - "stash_branch" => Ok(Self::StashBranch), - "open_theme_editor" => Ok(Self::OpenThemeEditor), - _ => Err(()), - } + /// A lower-case, human-readable name for use inside a sentence. + /// + /// Derived from the stable command id rather than hand-maintained, so a new + /// command cannot be added without one. + pub fn display_label(self) -> String { + self.as_str().replace('_', " ") } } +/// A palette row. +/// +/// Deliberately carries no shortcut field: the keystroke hint is read from +/// [`crate::keymap::shortcut`] at render time, so it always shows the binding +/// the user's `keymap.json` produced rather than a literal that goes stale the +/// moment either the registry or the user's keymap changes. #[derive(Clone)] pub struct PaletteCommand { pub id: CommandId, pub label: &'static str, pub description: Option<&'static str>, - pub shortcut: Option<&'static str>, pub category: &'static str, /// Context predicate — evaluated at filter-time to determine visibility. predicate: fn(CommandContext) -> bool, } impl PaletteCommand { + /// The availability predicate. Must agree with the `commands!` registry; + /// `palette_predicates_match_the_registry` enforces that. + #[cfg(test)] + pub(crate) fn predicate(&self) -> fn(CommandContext) -> bool { + self.predicate + } + fn new( id: CommandId, label: &'static str, description: Option<&'static str>, - shortcut: Option<&'static str>, category: &'static str, ) -> Self { Self { id, label, description, - shortcut, category, predicate: always_show, } @@ -439,441 +227,402 @@ pub struct CommandPalette { impl EventEmitter for CommandPalette {} +/// Every command offered in the command palette, with its label, category +/// and availability predicate. +/// +/// Pure so the registry tests can check it against the `commands!` table +/// without a window: `hidden_commands_are_the_ones_absent_from_the_palette` +/// pins membership and `palette_predicates_match_the_registry` pins the +/// predicates. +pub(crate) fn palette_commands() -> Vec { + // Context-sensitive predicates: + // has_remotes — only when remotes are configured + // has_staged — only when files are staged + // has_changes — only when worktree has unstaged/staged changes + // has_stashes — only when stash entries exist + // worktree_clean — only when no uncommitted changes + // is_bisecting — only when a bisect is in progress + // in_progress_operation — only during merge/rebase/cherry-pick + // always_show — no context restriction + vec![ + PaletteCommand::new( + CommandId::Fetch, + "Git: Fetch", + Some("Download objects and refs from another repository"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Pull, + "Git: Pull", + Some("Fetch from and integrate with another repository or a local branch"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Push, + "Git: Push", + Some("Update remote refs along with associated objects"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::PushAll, + "Git: Push All", + Some("Push all branches to their configured remotes"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::PullAll, + "Git: Pull All", + Some("Pull updates for all configured branches"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::ForcePush, + "Git: Force Push", + Some("Force update remote refs (can overwrite history)"), + "Git", + ) + .with_predicate(has_remotes), + PaletteCommand::new( + CommandId::Commit, + "Git: Commit", + Some("Record changes to the repository"), + "Git", + ) + .with_predicate(has_staged), + PaletteCommand::new( + CommandId::StageAll, + "Git: Stage All", + Some("Add all changes to the staging area"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::UnstageAll, + "Git: Unstage All", + Some("Remove all changes from the staging area"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::StashSave, + "Git: Stash", + Some("Save your local modifications to a new stash"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::StashPop, + "Git: Pop Stash", + Some("Apply the latest stash and remove it"), + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::StashApply, + "Git: Apply Stash (keep)", + Some("Apply the latest stash but keep it in the list"), + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::StashDrop, + "Git: Drop Stash", + Some("Delete the latest stash entry"), + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::CreateBranch, + "Git: Create Branch", + Some("Create a new branch"), + "Git", + ), + PaletteCommand::new( + CommandId::SwitchBranch, + "Git: Switch Branch", + Some("Switch to an existing branch"), + "Git", + ), + PaletteCommand::new( + CommandId::DeleteBranch, + "Git: Delete Branch", + Some("Delete a local branch"), + "Git", + ), + PaletteCommand::new( + CommandId::RenameBranch, + "Git: Rename Branch", + Some("Rename the current or selected branch"), + "Git", + ), + PaletteCommand::new( + CommandId::MergeBranch, + "Git: Merge Branch", + Some("Join two or more development histories together"), + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::CreateTag, + "Git: Create Tag", + Some("Create a tag at the selected commit"), + "Git", + ), + PaletteCommand::new( + CommandId::CreateWorktree, + "Git: Create Worktree", + Some("Check out a branch into a separate working tree"), + "Git", + ), + PaletteCommand::new( + CommandId::CreatePr, + "Git: Create Pull Request", + Some("Open a pull request for the current branch"), + "Git", + ) + .with_predicate(has_github_token), + PaletteCommand::new( + CommandId::CherryPick, + "Git: Cherry-pick Commit", + Some("Apply the changes introduced by some existing commits"), + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::RevertCommit, + "Git: Revert Commit", + Some("Revert an existing commit"), + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::InteractiveRebase, + "Git: Interactive Rebase", + Some("Reapply commits on top of another base tip interactively"), + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::DiscardAll, + "Git: Discard All Changes", + Some("Discard every unstaged and staged working tree change"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::CleanUntracked, + "Git: Clean Untracked Files", + Some("Remove untracked files from the working tree"), + "Git", + ), + PaletteCommand::new( + CommandId::ResetHard, + "Git: Reset Hard (to HEAD)", + Some("Discard all local changes, staged and unstaged"), + "Git", + ) + .with_predicate(has_changes), + PaletteCommand::new( + CommandId::AbortOperation, + "Git: Abort Merge/Rebase", + Some("Abort the in-progress merge, rebase, cherry-pick, or revert"), + "Git", + ) + .with_predicate(in_progress_operation), + PaletteCommand::new( + CommandId::ContinueMerge, + "Git: Continue Merge", + Some("Continue the in-progress merge, rebase, cherry-pick, or revert"), + "Git", + ) + .with_predicate(in_progress_operation), + PaletteCommand::new( + CommandId::ToggleDiffMode, + "View: Toggle Diff Mode", + Some("Switch between inline and side-by-side diff"), + "View", + ), + PaletteCommand::new( + CommandId::Search, + "View: Search Commits", + Some("Filter the commit graph by message, author, or hash"), + "View", + ), + PaletteCommand::new( + CommandId::AiMessage, + "AI: Generate Commit Message", + Some("Use AI to generate a commit message based on staged changes"), + "AI", + ) + .with_predicate(has_staged), + PaletteCommand::new( + CommandId::Refresh, + "Git: Refresh", + Some("Reload repository status, branches, and graph data"), + "Git", + ), + PaletteCommand::new( + CommandId::Settings, + "Preferences: Open Settings", + Some("Open the application settings window"), + "Preferences", + ), + PaletteCommand::new( + CommandId::OpenRepo, + "File: Open Repository", + Some("Choose a repository to open"), + "File", + ), + PaletteCommand::new( + CommandId::WorkspaceHome, + "Workspace: Home", + Some("Return to the workspace home screen"), + "Workspace", + ), + PaletteCommand::new( + CommandId::RestoreLastWorkspace, + "Workspace: Restore Last", + Some("Reopen the last active workspace"), + "Workspace", + ), + PaletteCommand::new( + CommandId::Shortcuts, + "Help: Keyboard Shortcuts", + Some("Show available keyboard shortcuts"), + "Help", + ), + PaletteCommand::new( + CommandId::OpenKeymap, + "Preferences: Open Keymap File", + Some("Edit keymap.json to rebind keyboard shortcuts"), + "Preferences", + ), + PaletteCommand::new( + CommandId::Blame, + "View: Blame File", + Some("Show what revision and author last modified each line"), + "View", + ), + PaletteCommand::new( + CommandId::FileHistory, + "View: File History", + Some("Show commit history for the selected file"), + "View", + ), + PaletteCommand::new( + CommandId::Undo, + "Edit: Undo Last Operation", + Some("Undo the most recent git operation when possible"), + "Edit", + ), + PaletteCommand::new( + CommandId::BisectStart, + "Git: Bisect Start", + Some("Start a binary search for the commit that introduced a bug"), + "Git", + ) + .with_predicate(worktree_clean), + PaletteCommand::new( + CommandId::BisectGood, + "Git: Bisect Good (mark current)", + Some("Mark the current revision as good"), + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectBad, + "Git: Bisect Bad (mark current)", + Some("Mark the current revision as bad"), + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectReset, + "Git: Bisect Reset", + Some("Stop bisecting and return to the original branch"), + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::BisectSkip, + "Git: Bisect Skip (skip this commit)", + Some("Skip the current revision during bisect"), + "Git", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::Reflog, + "View: Reflog", + Some("Show recent branch and HEAD movements"), + "View", + ), + PaletteCommand::new( + CommandId::Submodules, + "View: Submodules", + Some("Inspect configured git submodules"), + "View", + ), + PaletteCommand::new( + CommandId::Bisect, + "View: Bisect Log", + Some("Show current bisect progress and selected revisions"), + "View", + ) + .with_predicate(is_bisecting), + PaletteCommand::new( + CommandId::GlobalSearch, + "Search: Global Search", + Some("Search for text across the entire repository"), + "Search", + ), + PaletteCommand::new( + CommandId::ToggleIssues, + "View: Issues Panel", + Some("Open the GitHub issues panel"), + "View", + ), + PaletteCommand::new( + CommandId::TogglePullRequests, + "View: Pull Requests Panel", + Some("Open the GitHub pull requests panel"), + "View", + ), + PaletteCommand::new( + CommandId::ToggleBranchHealth, + "View: Branch Health Panel", + Some("Open branch recency and merge-status checks"), + "View", + ), + PaletteCommand::new( + CommandId::ToggleStashes, + "View: Stashes Panel", + Some("Open the stash management panel"), + "View", + ), + PaletteCommand::new( + CommandId::StashBranch, + "Git: Create Branch from Stash", + Some("Create a new branch starting from a stash entry"), + "Git", + ) + .with_predicate(has_stashes), + PaletteCommand::new( + CommandId::OpenThemeEditor, + "View: Edit Theme", + Some("Customize and save the active theme"), + "View", + ), + ] +} + impl CommandPalette { pub fn new(cx: &mut Context) -> Self { - // Context-sensitive predicates: - // has_remotes — only when remotes are configured - // has_staged — only when files are staged - // has_changes — only when worktree has unstaged/staged changes - // has_stashes — only when stash entries exist - // worktree_clean — only when no uncommitted changes - // is_bisecting — only when a bisect is in progress - // in_progress_operation — only during merge/rebase/cherry-pick - // always_show — no context restriction - let commands: Vec = vec![ - PaletteCommand::new( - CommandId::Fetch, - "Git: Fetch", - Some("Download objects and refs from another repository"), - Some("Ctrl+Shift+R"), - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Pull, - "Git: Pull", - Some("Fetch from and integrate with another repository or a local branch"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Push, - "Git: Push", - Some("Update remote refs along with associated objects"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::PushAll, - "Git: Push All", - Some("Push all branches to their configured remotes"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::PullAll, - "Git: Pull All", - Some("Pull updates for all configured branches"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::ForcePush, - "Git: Force Push", - Some("Force update remote refs (can overwrite history)"), - None, - "Git", - ) - .with_predicate(has_remotes), - PaletteCommand::new( - CommandId::Commit, - "Git: Commit", - Some("Record changes to the repository"), - Some("Ctrl+Enter"), - "Git", - ) - .with_predicate(has_staged), - PaletteCommand::new( - CommandId::StageAll, - "Git: Stage All", - Some("Add all changes to the staging area"), - Some("Ctrl+S"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::UnstageAll, - "Git: Unstage All", - Some("Remove all changes from the staging area"), - Some("Ctrl+U"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::StashSave, - "Git: Stash", - Some("Save your local modifications to a new stash"), - Some("Ctrl+Z"), - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::StashPop, - "Git: Pop Stash", - Some("Apply the latest stash and remove it"), - Some("Ctrl+Shift+Z"), - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::StashApply, - "Git: Apply Stash (keep)", - Some("Apply the latest stash but keep it in the list"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::StashDrop, - "Git: Drop Stash", - Some("Delete the latest stash entry"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::CreateBranch, - "Git: Create Branch", - Some("Create a new branch"), - Some("Ctrl+B"), - "Git", - ), - PaletteCommand::new( - CommandId::SwitchBranch, - "Git: Switch Branch", - Some("Switch to an existing branch"), - Some("Ctrl+Shift+B"), - "Git", - ), - PaletteCommand::new( - CommandId::DeleteBranch, - "Git: Delete Branch", - Some("Delete a local branch"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::RenameBranch, - "Git: Rename Branch", - Some("Rename the current or selected branch"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::MergeBranch, - "Git: Merge Branch", - Some("Join two or more development histories together"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::CreateTag, - "Git: Create Tag", - Some("Create a tag at the selected commit"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::CreateWorktree, - "Git: Create Worktree", - Some("Check out a branch into a separate working tree"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::CreatePr, - "Git: Create Pull Request", - Some("Open a pull request for the current branch"), - None, - "Git", - ) - .with_predicate(has_github_token), - PaletteCommand::new( - CommandId::CherryPick, - "Git: Cherry-pick Commit", - Some("Apply the changes introduced by some existing commits"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::RevertCommit, - "Git: Revert Commit", - Some("Revert an existing commit"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::InteractiveRebase, - "Git: Interactive Rebase", - Some("Reapply commits on top of another base tip interactively"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::DiscardAll, - "Git: Discard All Changes", - Some("Discard every unstaged and staged working tree change"), - None, - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::CleanUntracked, - "Git: Clean Untracked Files", - Some("Remove untracked files from the working tree"), - None, - "Git", - ), - PaletteCommand::new( - CommandId::ResetHard, - "Git: Reset Hard (to HEAD)", - Some("Discard all local changes, staged and unstaged"), - None, - "Git", - ) - .with_predicate(has_changes), - PaletteCommand::new( - CommandId::AbortOperation, - "Git: Abort Merge/Rebase", - Some("Abort the in-progress merge, rebase, cherry-pick, or revert"), - None, - "Git", - ) - .with_predicate(in_progress_operation), - PaletteCommand::new( - CommandId::ContinueMerge, - "Git: Continue Merge", - Some("Continue the in-progress merge, rebase, cherry-pick, or revert"), - None, - "Git", - ) - .with_predicate(in_progress_operation), - PaletteCommand::new( - CommandId::ToggleDiffMode, - "View: Toggle Diff Mode", - Some("Switch between inline and side-by-side diff"), - Some("d"), - "View", - ), - PaletteCommand::new( - CommandId::Search, - "View: Search Commits", - Some("Filter the commit graph by message, author, or hash"), - Some("Ctrl+F"), - "View", - ), - PaletteCommand::new( - CommandId::AiMessage, - "AI: Generate Commit Message", - Some("Use AI to generate a commit message based on staged changes"), - Some("Ctrl+G"), - "AI", - ) - .with_predicate(has_staged), - PaletteCommand::new( - CommandId::Refresh, - "Git: Refresh", - Some("Reload repository status, branches, and graph data"), - Some("F5"), - "Git", - ), - PaletteCommand::new( - CommandId::Settings, - "Preferences: Open Settings", - Some("Open the application settings window"), - Some("Ctrl+,"), - "Preferences", - ), - PaletteCommand::new( - CommandId::OpenRepo, - "File: Open Repository", - Some("Choose a repository to open"), - Some("Ctrl+O"), - "File", - ), - PaletteCommand::new( - CommandId::WorkspaceHome, - "Workspace: Home", - Some("Return to the workspace home screen"), - None, - "Workspace", - ), - PaletteCommand::new( - CommandId::RestoreLastWorkspace, - "Workspace: Restore Last", - Some("Reopen the last active workspace"), - None, - "Workspace", - ), - PaletteCommand::new( - CommandId::Shortcuts, - "Help: Keyboard Shortcuts", - Some("Show available keyboard shortcuts"), - Some("?"), - "Help", - ), - PaletteCommand::new( - CommandId::Blame, - "View: Blame File", - Some("Show what revision and author last modified each line"), - Some("b"), - "View", - ), - PaletteCommand::new( - CommandId::FileHistory, - "View: File History", - Some("Show commit history for the selected file"), - Some("h"), - "View", - ), - PaletteCommand::new( - CommandId::Undo, - "Edit: Undo Last Operation", - Some("Undo the most recent git operation when possible"), - None, - "Edit", - ), - PaletteCommand::new( - CommandId::BisectStart, - "Git: Bisect Start", - Some("Start a binary search for the commit that introduced a bug"), - None, - "Git", - ) - .with_predicate(worktree_clean), - PaletteCommand::new( - CommandId::BisectGood, - "Git: Bisect Good (mark current)", - Some("Mark the current revision as good"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectBad, - "Git: Bisect Bad (mark current)", - Some("Mark the current revision as bad"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectReset, - "Git: Bisect Reset", - Some("Stop bisecting and return to the original branch"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::BisectSkip, - "Git: Bisect Skip (skip this commit)", - Some("Skip the current revision during bisect"), - None, - "Git", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::Reflog, - "View: Reflog", - Some("Show recent branch and HEAD movements"), - None, - "View", - ), - PaletteCommand::new( - CommandId::Submodules, - "View: Submodules", - Some("Inspect configured git submodules"), - None, - "View", - ), - PaletteCommand::new( - CommandId::Bisect, - "View: Bisect Log", - Some("Show current bisect progress and selected revisions"), - None, - "View", - ) - .with_predicate(is_bisecting), - PaletteCommand::new( - CommandId::GlobalSearch, - "Search: Global Search", - Some("Search for text across the entire repository"), - Some("Ctrl+Shift+F"), - "Search", - ), - PaletteCommand::new( - CommandId::ToggleIssues, - "View: Issues Panel", - Some("Open the GitHub issues panel"), - Some("Alt+5"), - "View", - ), - PaletteCommand::new( - CommandId::TogglePullRequests, - "View: Pull Requests Panel", - Some("Open the GitHub pull requests panel"), - Some("Alt+6"), - "View", - ), - PaletteCommand::new( - CommandId::ToggleBranchHealth, - "View: Branch Health Panel", - Some("Open branch recency and merge-status checks"), - Some("Alt+7"), - "View", - ), - PaletteCommand::new( - CommandId::ToggleStashes, - "View: Stashes Panel", - Some("Open the stash management panel"), - Some("Alt+8"), - "View", - ), - PaletteCommand::new( - CommandId::StashBranch, - "Git: Create Branch from Stash", - Some("Create a new branch starting from a stash entry"), - None, - "Git", - ) - .with_predicate(has_stashes), - PaletteCommand::new( - CommandId::OpenThemeEditor, - "View: Edit Theme", - Some("Customize and save the active theme"), - Some("Ctrl+Shift+T"), - "View", - ), - ]; + let commands = palette_commands(); let filtered_indices: Vec<(usize, usize)> = (0..commands.len()).map(|i| (i, 0)).collect(); @@ -1031,39 +780,37 @@ impl CommandPalette { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - - match key { - "escape" => { - self.dismiss(cx); - cx.stop_propagation(); - } - "up" => { - if self.selected_index > 0 { - self.selected_index -= 1; - self.scroll_handle - .scroll_to_item(self.selected_index, ScrollStrategy::Nearest); - cx.notify(); - } - cx.stop_propagation(); - } - "down" => { - if self.selected_index + 1 < self.filtered_indices.len() { - self.selected_index += 1; - self.scroll_handle - .scroll_to_item(self.selected_index, ScrollStrategy::Nearest); - cx.notify(); - } - cx.stop_propagation(); + /// Runs a keyboard command scoped to `CommandPalette` or to the shared + /// `List` group. + /// + /// Enter is propagated so the query field's own submission runs the + /// highlighted command exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::SelectPrev => self.select_row(self.selected_index.saturating_sub(1), cx), + CommandId::SelectNext => self.select_row(self.selected_index + 1, cx), + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => { + self.select_row(self.filtered_indices.len().saturating_sub(1), cx) } - _ => {} + _ => cx.propagate(), + } + } + + /// Moves the highlight, clamped to the filtered list, and scrolls to it. + fn select_row(&mut self, row: usize, cx: &mut Context) { + if self.filtered_indices.is_empty() { + return; + } + let row = row.min(self.filtered_indices.len() - 1); + if row == self.selected_index { + return; } + self.selected_index = row; + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); } } @@ -1073,15 +820,22 @@ impl Render for CommandPalette { return div().id("command-palette").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let query_is_empty = self.query_editor.read(cx).is_empty(); let filtered_count = self.filtered_indices.len(); let mut modal = div() .id("command-palette-modal") .track_focus(&self.focus_handle) - .key_context("CommandPalette") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "CommandPalette List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(720.)) .max_h(px(500.)) @@ -1139,7 +893,10 @@ impl Render for CommandPalette { let list = uniform_list( "palette-results", filtered_count, - move |range: Range, _window: &mut Window, _cx: &mut App| { + move |range: Range, _window: &mut Window, cx: &mut App| { + // Read once per rendered range: the hint has to be the + // binding in force, not a literal baked into the row. + let summary = keymap::summary(cx); range .map(|display_idx| { let (cmd_idx, _score) = filtered[display_idx]; @@ -1149,7 +906,7 @@ impl Render for CommandPalette { let label: SharedString = cmd.label.into(); let cmd_id = cmd.id; let icon = CommandPalette::category_icon(cmd.category); - let shortcut = cmd.shortcut; + let shortcut = summary.display(cmd_id); let description = cmd.description; let view_click = view.clone(); @@ -1216,6 +973,13 @@ impl Render for CommandPalette { } if let Some(shortcut_text) = shortcut { + // A binding the user defined is tinted, matching + // the badge in the shortcut reference. + let hint_color = if summary.is_user_defined(cmd_id) { + Color::Info + } else { + Color::Muted + }; row = row.child( div() .h_flex() @@ -1228,7 +992,7 @@ impl Render for CommandPalette { .child( Label::new(SharedString::from(shortcut_text)) .size(LabelSize::XSmall) - .color(Color::Muted) + .color(hint_color) .weight(FontWeight::MEDIUM), ), ); @@ -1421,10 +1185,7 @@ mod tests { fn command_id_stash_branch() { use super::CommandId; assert_eq!(CommandId::StashBranch.as_str(), "stash_branch"); - assert_eq!( - CommandId::StashBranch.display_label(), - "create branch from stash" - ); + assert_eq!(CommandId::StashBranch.display_label(), "stash branch"); } #[test] @@ -1574,22 +1335,54 @@ mod tests { CommandId::Fetch, "Git: Fetch", Some("Download objects and refs"), - Some("Ctrl+Shift+R"), "Git", ); assert_eq!(cmd.label, "Git: Fetch"); assert_eq!(cmd.description, Some("Download objects and refs")); - assert_eq!(cmd.shortcut, Some("Ctrl+Shift+R")); assert_eq!(cmd.category, "Git"); } #[test] fn palette_command_new_without_description() { use super::{CommandId, PaletteCommand}; - let cmd = PaletteCommand::new(CommandId::Refresh, "Git: Refresh", None, Some("F5"), "Git"); + let cmd = PaletteCommand::new(CommandId::Refresh, "Git: Refresh", None, "Git"); assert_eq!(cmd.label, "Git: Refresh"); assert_eq!(cmd.description, None); - assert_eq!(cmd.shortcut, Some("F5")); + } + + /// The row's keystroke hint is whatever the keymap holds, which is what makes + /// the old `Ctrl+Shift+F`-for-Fetch drift impossible rather than merely fixed. + #[test] + fn a_palette_hint_is_the_keymap_binding() { + use super::CommandId; + use crate::keymap::display::KeystrokeStyle; + use crate::keymap::KeymapSummary; + + let summary = KeymapSummary::defaults(KeystrokeStyle::Words); + for command in super::palette_commands() { + let hint = summary.display(command.id); + let expected = crate::keymap::display::join_bindings( + command + .id + .default_bindings() + .iter() + .filter_map(|(keystrokes, _)| { + crate::keymap::humanize_sequence(keystrokes, KeystrokeStyle::Words) + }) + .collect::>() + .iter() + .map(String::as_str), + ); + assert_eq!( + hint, expected, + "{}'s palette hint disagrees with its registry binding", + command.id + ); + } + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); } #[test] @@ -1599,68 +1392,10 @@ mod tests { CommandId::StashPop, "Git: Pop Stash", Some("Apply and remove stash"), - None, "Git", ) .with_predicate(super::has_stashes); // The command was constructed — verify no panic on creation assert_eq!(cmd.label, "Git: Pop Stash"); } - - /// View-level harness test: opens the palette in a real (headless) GPUI - /// test window and drives it entirely through simulated keystrokes, - /// proving that focus, key dispatch, and render-driven state updates work - /// end to end. This is the reference pattern for writing gpui view tests - /// in this codebase (see README_TESTING.md). - #[test] - fn palette_keyboard_navigation_in_test_window() { - use rgitui_test_support::ViewTest; - - use super::{CommandContext, CommandPalette}; - - let mut window = ViewTest::open(|_window, cx| CommandPalette::new(cx)); - - // Open the palette; `toggle` focuses the query editor. - window.update(|palette, window, cx| { - palette.set_context(CommandContext { - has_remotes: true, - ..CommandContext::none() - }); - palette.toggle(window, cx); - }); - let unfiltered_count = window.read(|palette, _| { - assert!(palette.is_visible(), "toggle should show the palette"); - palette.filtered_indices.len() - }); - assert!(unfiltered_count > 1); - - // Type into the focused query editor; the palette re-filters on change. - window.simulate_input("push"); - window.read(|palette, cx| { - assert_eq!(palette.query_editor.read(cx).text(), "push"); - let filtered_count = palette.filtered_indices.len(); - assert!(filtered_count > 0, "matching commands should remain"); - assert!( - filtered_count < unfiltered_count, - "typing should narrow the list ({filtered_count} vs {unfiltered_count})" - ); - let top = &palette.commands[palette.filtered_indices[0].0]; - assert!( - top.label.to_lowercase().contains("push"), - "expected a push command at the top, got {:?}", - top.label - ); - assert_eq!(palette.selected_index, 0); - }); - - // Arrow keys bubble past the text input to the palette's key handler. - window.simulate_keystroke("down"); - window.read(|palette, _| assert_eq!(palette.selected_index, 1)); - window.simulate_keystroke("up"); - window.read(|palette, _| assert_eq!(palette.selected_index, 0)); - - // Escape dismisses the palette. - window.simulate_keystroke("escape"); - window.read(|palette, _| assert!(!palette.is_visible())); - } } diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index 1b8c23cf..9f0d31c6 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -394,7 +394,6 @@ impl Render for CommitPanel { div() .id("commit-content-area") .track_focus(&self.focus_handle) - .key_context("CommitPanel") .v_flex() .flex_1() .min_h(px(120.)) @@ -686,13 +685,17 @@ impl Render for CommitPanel { .color(Color::Warning), ) }) - .when(can_commit, |el| { - el.child( - Label::new("Ctrl+Enter") - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }) + .when_some( + crate::keymap::shortcut(crate::CommandId::Commit, cx) + .filter(|_| can_commit), + |el, keystrokes| { + el.child( + Label::new(SharedString::from(keystrokes)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + }, + ) .child( Button::new("commit-btn", commit_label) .icon(IconName::GitCommit) diff --git a/crates/rgitui_workspace/src/confirm_dialog.rs b/crates/rgitui_workspace/src/confirm_dialog.rs index b8a12530..63d6f0ac 100644 --- a/crates/rgitui_workspace/src/confirm_dialog.rs +++ b/crates/rgitui_workspace/src/confirm_dialog.rs @@ -1,11 +1,11 @@ use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, EventEmitter, FocusHandle, KeyDownEvent, Render, SharedString, - Window, -}; +use gpui::{div, px, ClickEvent, Context, EventEmitter, FocusHandle, Render, SharedString, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize}; +use crate::keymap; +use crate::CommandId; + /// The action that was confirmed (so the workspace knows what to do). #[derive(Debug, Clone, PartialEq)] pub enum ConfirmAction { @@ -107,16 +107,12 @@ impl ConfirmDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => self.cancel(cx), - "enter" => self.confirm(cx), - _ => {} + /// Runs a keyboard command scoped to `ConfirmDialog`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.cancel(cx), + CommandId::Confirm => self.confirm(cx), + _ => cx.propagate(), } } @@ -184,7 +180,7 @@ impl Render for ConfirmDialog { self.focus_handle.focus(window, cx); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let title: SharedString = self.title.clone().into(); let message: SharedString = self.message.clone().into(); let icon = self.severity_icon(); @@ -218,7 +214,15 @@ impl Render for ConfirmDialog { div() .id("confirm-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ConfirmDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(420.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/create_pr_dialog.rs b/crates/rgitui_workspace/src/create_pr_dialog.rs index 81e050a9..4009af18 100644 --- a/crates/rgitui_workspace/src/create_pr_dialog.rs +++ b/crates/rgitui_workspace/src/create_pr_dialog.rs @@ -4,8 +4,7 @@ use futures::AsyncReadExt; use gpui::http_client::AsyncBody; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use http_client::HttpClient; use rgitui_theme::{ActiveTheme, Color, StyledExt}; @@ -14,6 +13,9 @@ use rgitui_ui::{ LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the PR creation dialog. #[derive(Debug, Clone)] pub enum CreatePrDialogEvent { @@ -38,6 +40,10 @@ pub struct CreatePrDialog { github_token: Option, github_owner: String, github_repo: String, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. gpui dispatches actions only along the focus path, so a + /// dialog that never focuses cannot receive `menu::Cancel` (Esc). + pending_focus: bool, focus_handle: FocusHandle, } @@ -79,6 +85,7 @@ impl CreatePrDialog { base_branch: String::new(), draft: false, visible: false, + pending_focus: false, is_loading: false, error_message: None, github_token: None, @@ -130,6 +137,7 @@ impl CreatePrDialog { cx: &mut Context, ) { self.visible = true; + self.pending_focus = true; self.head_branch = head_branch; self.base_branch = base_branch; self.is_loading = false; @@ -223,16 +231,15 @@ impl CreatePrDialog { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => self.cancel(cx), - "enter" if event.keystroke.modifiers.shift => self.submit(cx), - _ => {} + /// Runs a keyboard command scoped to `CreatePrDialog`. + /// + /// Plain Enter is propagated so it inserts a newline in the multi-line body; + /// submitting is `shift-enter`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.cancel(cx), + CommandId::SubmitPullRequest => self.submit(cx), + _ => cx.propagate(), } } @@ -243,12 +250,17 @@ impl CreatePrDialog { } impl Render for CreatePrDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.visible { return div().id("create-pr-dialog").into_any_element(); } - let colors = cx.colors(); + if self.pending_focus { + self.pending_focus = false; + self.title_input.update(cx, |e, cx| e.focus(window, cx)); + } + + let colors = cx.colors().clone(); let head_label: SharedString = self.head_branch.clone().into(); let base_label: SharedString = self.base_branch.clone().into(); @@ -275,7 +287,15 @@ impl Render for CreatePrDialog { div() .id("create-pr-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "CreatePrDialog", + &["Menu", "CreatePrDialog"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(520.)) .max_h(px(600.)) diff --git a/crates/rgitui_workspace/src/detail_panel.rs b/crates/rgitui_workspace/src/detail_panel.rs index c6ae18e8..50fc23fd 100644 --- a/crates/rgitui_workspace/src/detail_panel.rs +++ b/crates/rgitui_workspace/src/detail_panel.rs @@ -4,8 +4,8 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{ - div, img, px, uniform_list, App, ClickEvent, ClipboardItem, Context, ElementId, EventEmitter, - FocusHandle, KeyDownEvent, ListSizingBehavior, ObjectFit, Render, SharedString, WeakEntity, + div, img, px, uniform_list, App, ClickEvent, ClipboardItem, Context, ElementId, Entity, + EventEmitter, FocusHandle, ListSizingBehavior, ObjectFit, Render, SharedString, WeakEntity, Window, }; use rgitui_diff::DiffSource; @@ -16,10 +16,12 @@ use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ AvatarCache, Badge, ButtonSize, ButtonStyle, DiffStat, Icon, IconButton, IconName, IconSize, - Label, LabelSize, + Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; use crate::markdown_view::render_markdown; +use crate::CommandId; /// Rows the changed-files list keeps when the header and commit message leave it /// little room. Once it is this short the panel scrolls as one region instead. @@ -446,6 +448,12 @@ pub struct DetailPanel { selected_file_index: Option, focus_handle: FocusHandle, copied_field: Option<(&'static str, Instant)>, + /// The changed-files filter field. Owns the text, the cursor and the + /// selection, so the panel's own key handling never has to ask whether the + /// user is typing. + file_search_editor: Entity, + /// Mirror of the filter field's text, kept in sync by its `Changed` event so + /// the pure filtering helpers can stay `cx`-free. file_search_query: Option, file_search_active: bool, file_view_mode: FileViewMode, @@ -459,6 +467,22 @@ impl EventEmitter for DetailPanel {} impl DetailPanel { pub fn new(cx: &mut Context) -> Self { + let file_search_editor = cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Filter files..."); + input + }); + cx.subscribe( + &file_search_editor, + |this: &mut Self, _, event: &TextInputEvent, cx| { + if let TextInputEvent::Changed(text) = event { + this.file_search_query = (!text.is_empty()).then(|| text.clone()); + cx.notify(); + } + }, + ) + .detach(); + Self { commit: None, commit_diff: None, @@ -468,6 +492,7 @@ impl DetailPanel { selected_file_index: None, focus_handle: cx.focus_handle(), copied_field: None, + file_search_editor, file_search_query: None, file_search_active: false, file_view_mode: FileViewMode::default(), @@ -525,121 +550,81 @@ impl DetailPanel { .unwrap_or(0) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `DetailPanel` or to the shared `List` + /// group. + /// + /// There is no "am I typing?" branch: the filter field is a real + /// [`TextInput`], so the `!TextInput` half of the bare-letter contexts keeps + /// `v`, `[`, `]` and `/` out of the way while it has focus, and the arrow + /// keys keep working because they are not scoped that way. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { let file_count = self.file_count(); - // Commit prev/next navigation — works regardless of file count - match key { - "[" => { + match cmd { + // Commit navigation works regardless of file count. + CommandId::PrevCommitDetails => { if self.commit.is_some() { cx.emit(DetailPanelEvent::NavigatePrevCommit); } } - "]" => { + CommandId::NextCommitDetails => { if self.commit.is_some() { cx.emit(DetailPanelEvent::NavigateNextCommit); } } - _ => {} - } - - // File search toggle: / or Ctrl+F - if (key == "/" && !modifiers.secondary()) || (modifiers.secondary() && key == "f") { - self.file_search_active = true; - cx.notify(); - return; - } - - // Escape clears search - if key == "escape" { - if self.file_search_query.is_some() || self.file_search_active { - self.file_search_query = None; - self.file_search_active = false; + CommandId::FileSearch => { + self.file_search_active = true; + self.file_search_editor + .update(cx, |editor, cx| editor.focus(window, cx)); cx.notify(); } - return; + CommandId::Cancel => self.clear_file_search(cx), + _ if file_count == 0 => {} + CommandId::SelectNext => self.select_file( + match self.selected_file_index { + Some(i) if i + 1 < file_count => Some(i + 1), + None => Some(0), + other => other, + }, + cx, + ), + CommandId::SelectPrev => self.select_file( + match self.selected_file_index { + Some(i) if i > 0 => Some(i - 1), + None => Some(0), + other => other, + }, + cx, + ), + CommandId::SelectFirst => self.select_file(Some(0), cx), + CommandId::SelectLast => self.select_file(Some(file_count - 1), cx), + CommandId::ToggleFileTree => self.request_file_view_mode_toggle(cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } + } - // When search is active, capture printable characters as query - if self.file_search_active { - // Use key_char for printable input (same as TextInput) - if let Some(kc) = &event.keystroke.key_char { - let ch = kc.to_lowercase().chars().next().unwrap_or(' '); - let new_query = match &mut self.file_search_query { - Some(q) => { - q.push(ch); - q.clone() - } - None => ch.to_string(), - }; - self.file_search_query = Some(new_query); - cx.notify(); - } else if key == "backspace" { - if let Some(q) = &mut self.file_search_query { - q.pop(); - if q.is_empty() { - self.file_search_query = None; - } - } - cx.notify(); - } + /// Moves the changed-files selection, emitting only on an actual change. + fn select_file(&mut self, index: Option, cx: &mut Context) { + if index == self.selected_file_index { return; } + self.selected_file_index = index; + self.emit_file_selected(cx); + cx.notify(); + } - if file_count == 0 { + /// Closes the changed-files filter and clears its query. + fn clear_file_search(&mut self, cx: &mut Context) { + if self.file_search_query.is_none() && !self.file_search_active { return; } - - match key { - "j" | "down" => { - let next = match self.selected_file_index { - Some(i) if i + 1 < file_count => Some(i + 1), - None => Some(0), - other => other, - }; - if next != self.selected_file_index { - self.selected_file_index = next; - self.emit_file_selected(cx); - cx.notify(); - } - } - "k" | "up" => { - let next = match self.selected_file_index { - Some(i) if i > 0 => Some(i - 1), - None if file_count > 0 => Some(0), - other => other, - }; - if next != self.selected_file_index { - self.selected_file_index = next; - self.emit_file_selected(cx); - cx.notify(); - } - } - "home" | "g" if self.selected_file_index != Some(0) => { - self.selected_file_index = Some(0); - self.emit_file_selected(cx); - cx.notify(); - } - "end" => { - let last = file_count.saturating_sub(1); - if self.selected_file_index != Some(last) { - self.selected_file_index = Some(last); - self.emit_file_selected(cx); - cx.notify(); - } - } - "v" => { - self.request_file_view_mode_toggle(cx); - } - _ => {} - } + self.file_search_query = None; + self.file_search_active = false; + self.file_search_editor + .update(cx, |editor, cx| editor.clear(cx)); + cx.notify(); } fn request_file_view_mode_toggle(&mut self, cx: &mut Context) { @@ -1335,8 +1320,15 @@ impl Render for DetailPanel { let mut panel = div() .id("detail-panel") .track_focus(&self.focus_handle) - .key_context("DetailPanel") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "DetailPanel List", + &["Menu", "DetailPanel"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(colors.panel_background); @@ -1819,38 +1811,24 @@ impl Render for DetailPanel { ]; if is_searching { - let query_clone = query_str.clone(); let search_input: gpui::AnyElement = div() .flex_1() .h_flex() .items_center() - .px_2() .gap_1() - .bg(colors.ghost_element_selected) - .border_1() - .border_color(colors.text_accent) - .rounded(px(4.)) .child( Icon::new(IconName::Search) .size(IconSize::XSmall) .color(Color::Muted), ) - .child( - div().flex_1().child( - Label::new(query_clone.clone()) - .size(LabelSize::XSmall) - .color(Color::Default), - ), - ) + .child(div().flex_1().child(self.file_search_editor.clone())) .child( IconButton::new("clear-search", IconName::X) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) .tooltip("Clear search (Esc)") .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.file_search_query = None; - this.file_search_active = false; - cx.notify(); + this.clear_file_search(cx); })) .into_any_element(), ) diff --git a/crates/rgitui_workspace/src/file_history_view.rs b/crates/rgitui_workspace/src/file_history_view.rs index ddcc9e25..0c6c02a8 100644 --- a/crates/rgitui_workspace/src/file_history_view.rs +++ b/crates/rgitui_workspace/src/file_history_view.rs @@ -4,14 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::CommitInfo; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the file history view. #[derive(Debug, Clone, PartialEq)] pub enum FileHistoryViewEvent { @@ -84,78 +87,48 @@ impl FileHistoryView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to `FileHistoryView` or to the shared + /// `List` group. Esc navigates back to the diff rather than dismissing, so + /// the view owns `HistoryShowDiff` instead of leaning on `menu::Cancel`. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.commits.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(commit) = self.commits.get(row) { - let oid = commit.oid.to_string(); - cx.emit(FileHistoryViewEvent::CommitSelected(oid)); - } + match cmd { + CommandId::HistoryShowDiff => cx.emit(FileHistoryViewEvent::SwitchToDiff), + CommandId::HistoryShowBlame => cx.emit(FileHistoryViewEvent::SwitchToBlame), + CommandId::Cancel => cx.emit(FileHistoryViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(commit) = self.highlighted_row.and_then(|row| self.commits.get(row)) { + cx.emit(FileHistoryViewEvent::CommitSelected(commit.oid.to_string())); } - cx.stop_propagation(); - } - "escape" | "d" => { - cx.emit(FileHistoryViewEvent::SwitchToDiff); - cx.stop_propagation(); } - "b" => { - cx.emit(FileHistoryViewEvent::SwitchToBlame); - cx.stop_propagation(); - } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last commit - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first commit - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - } - cx.notify(); - cx.stop_propagation(); - } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -220,7 +193,7 @@ impl FileHistoryView { impl Render for FileHistoryView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.commits.is_empty() { return self.render_empty_state(cx); @@ -398,8 +371,15 @@ impl Render for FileHistoryView { div() .id("file-history-view") .track_focus(&self.focus_handle) - .key_context("FileHistoryView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "FileHistoryView List", + &["Menu", "FileHistoryView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/interactive_rebase.rs b/crates/rgitui_workspace/src/interactive_rebase.rs index d2940ff1..905b9798 100644 --- a/crates/rgitui_workspace/src/interactive_rebase.rs +++ b/crates/rgitui_workspace/src/interactive_rebase.rs @@ -1,11 +1,14 @@ use gpui::prelude::*; use gpui::{ - canvas, div, px, App, Bounds, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - FontWeight, KeyDownEvent, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Render, + canvas, div, px, App, Bounds, ClickEvent, Context, ElementId, Entity, EventEmitter, + FocusHandle, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, Pixels, Render, SharedString, WeakEntity, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; -use rgitui_ui::{Button, ButtonSize, ButtonStyle, Label, LabelSize, Tooltip}; +use rgitui_ui::{Button, ButtonSize, ButtonStyle, Label, LabelSize, TextInput, Tooltip}; + +use crate::keymap; +use crate::CommandId; /// The action to perform on a commit during interactive rebase. #[derive(Debug, Clone, PartialEq)] @@ -53,7 +56,7 @@ impl RebaseAction { } /// A single commit entry in the interactive rebase list. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct RebaseEntry { pub oid: String, pub original_message: String, @@ -74,8 +77,20 @@ pub struct InteractiveRebase { entries: Vec, target_ref: String, selected_index: usize, - /// When editing a reword message, this holds (entry index, message, cursor position). - editing_reword: Option<(usize, String, usize)>, + /// Index of the entry whose reword message is being edited, if any. + /// + /// The message itself lives in [`Self::reword_editor`]: reusing the shared + /// text field means normal mode's `p`/`r`/`s`/`f`/`d` are simply scoped + /// `!TextInput` instead of being swallowed by a hand-rolled editor. + editing_reword: Option, + /// Text field backing reword editing. + reword_editor: Entity, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. gpui dispatches actions only along the focus path, so until + /// this dialog focuses, neither `menu::Cancel` (Esc) nor its own + /// `p`/`r`/`s`/`f`/`d` action bindings reach it — and the graph underneath + /// keeps consuming those keys instead. + pending_focus: bool, focus_handle: FocusHandle, // Drag-to-reorder state @@ -105,10 +120,16 @@ impl InteractiveRebase { pub fn new(cx: &mut Context) -> Self { Self { visible: false, + pending_focus: false, entries: Vec::new(), target_ref: String::new(), selected_index: 0, editing_reword: None, + reword_editor: cx.new(|cx| { + let mut input = TextInput::new(cx); + input.set_placeholder("Enter new commit message..."); + input + }), focus_handle: cx.focus_handle(), dragging_index: None, drag_hover_index: None, @@ -138,7 +159,8 @@ impl InteractiveRebase { cx.notify(); } - /// Show without focusing (for contexts where Window is unavailable). + /// Show the dialog, taking focus on the next render (for contexts where + /// `Window` is unavailable). pub fn show_visible( &mut self, entries: Vec, @@ -152,6 +174,7 @@ impl InteractiveRebase { self.dragging_index = None; self.drag_hover_index = None; self.visible = true; + self.pending_focus = true; cx.notify(); } @@ -170,13 +193,7 @@ impl InteractiveRebase { } fn execute(&mut self, cx: &mut Context) { - // Finalize any in-progress reword editing - if let Some((idx, ref msg, _)) = self.editing_reword { - if idx < self.entries.len() { - self.entries[idx].action = RebaseAction::Reword(msg.clone()); - } - } - self.editing_reword = None; + self.commit_reword(cx); // Cancel any in-progress drag self.dragging_index = None; @@ -189,53 +206,83 @@ impl InteractiveRebase { cx.notify(); } - fn set_action_on_selected(&mut self, action: RebaseAction, cx: &mut Context) { - if let Some(entry) = self.entries.get_mut(self.selected_index) { - // If switching to reword, start editing - if matches!(&action, RebaseAction::Reword(_)) { - let msg = entry.original_message.clone(); - let len = msg.len(); - self.editing_reword = Some((self.selected_index, msg, len)); - } else { - // If we were editing this entry's reword, cancel editing - if let Some((edit_idx, _, _)) = &self.editing_reword { - if *edit_idx == self.selected_index { - self.editing_reword = None; - } + fn set_action_on_selected( + &mut self, + action: RebaseAction, + window: Option<&mut Window>, + cx: &mut Context, + ) { + self.set_action_on(self.selected_index, action, window, cx); + } + + fn cycle_action(&mut self, index: usize, cx: &mut Context) { + let Some(entry) = self.entries.get(index) else { + return; + }; + let next = entry.action.next(); + self.set_action_on(index, next, None, cx); + } + + /// Applies `action` to the entry at `index`, starting or stopping reword + /// editing to match. Passing a `window` focuses the reword field, which is + /// what makes the bare-letter action shortcuts stand down while typing. + fn set_action_on( + &mut self, + index: usize, + action: RebaseAction, + window: Option<&mut Window>, + cx: &mut Context, + ) { + let Some(entry) = self.entries.get_mut(index) else { + return; + }; + let original_message = entry.original_message.clone(); + let starts_reword = matches!(&action, RebaseAction::Reword(_)); + entry.action = action; + + if starts_reword { + self.editing_reword = Some(index); + self.reword_editor.update(cx, |editor, cx| { + editor.set_text(original_message, cx); + if let Some(window) = window { + editor.focus(window, cx); } - } - entry.action = action; - cx.notify(); + }); + } else if self.editing_reword == Some(index) { + self.editing_reword = None; } + cx.notify(); } - fn cycle_action(&mut self, index: usize, cx: &mut Context) { + /// Writes the reword field's text back into the entry being edited and + /// leaves reword mode. + fn commit_reword(&mut self, cx: &mut Context) { + let Some(index) = self.editing_reword.take() else { + return; + }; + let message = self.reword_editor.read(cx).text().to_string(); if let Some(entry) = self.entries.get_mut(index) { - let next = entry.action.next(); - // If cycling to reword, start editing - if matches!(&next, RebaseAction::Reword(_)) { - let msg = entry.original_message.clone(); - let len = msg.len(); - self.editing_reword = Some((index, msg, len)); - } else { - // If we were editing this entry's reword, cancel editing - if let Some((edit_idx, _, _)) = &self.editing_reword { - if *edit_idx == index { - self.editing_reword = None; - } - } - } - entry.action = next; - cx.notify(); + entry.action = RebaseAction::Reword(message); } } + /// Abandons reword editing, restoring the entry to `pick`. + fn abandon_reword(&mut self, cx: &mut Context) { + let Some(index) = self.editing_reword.take() else { + return; + }; + if let Some(entry) = self.entries.get_mut(index) { + entry.action = RebaseAction::Pick; + } + cx.notify(); + } + fn move_entry_up(&mut self, cx: &mut Context) { if self.selected_index > 0 && self.selected_index < self.entries.len() { self.entries .swap(self.selected_index, self.selected_index - 1); // Update editing index if needed - if let Some((ref mut edit_idx, _, _)) = self.editing_reword { + if let Some(edit_idx) = self.editing_reword.as_mut() { if *edit_idx == self.selected_index { *edit_idx -= 1; } else if *edit_idx == self.selected_index - 1 { @@ -252,7 +299,7 @@ impl InteractiveRebase { self.entries .swap(self.selected_index, self.selected_index + 1); // Update editing index if needed - if let Some((ref mut edit_idx, _, _)) = self.editing_reword { + if let Some(edit_idx) = self.editing_reword.as_mut() { if *edit_idx == self.selected_index { *edit_idx += 1; } else if *edit_idx == self.selected_index + 1 { @@ -340,13 +387,9 @@ impl InteractiveRebase { }; if drag_idx != hover_idx { - let mut edit_idx: Option = self.editing_reword.as_ref().map(|(i, _, _)| *i); + let mut edit_idx = self.editing_reword; Self::apply_drag_reorder(&mut self.entries, drag_idx, hover_idx, &mut edit_idx); - if let Some(ref mut editing) = self.editing_reword { - if let Some(idx) = edit_idx { - editing.0 = idx; - } - } + self.editing_reword = edit_idx; // Keep selection on the moved entry self.selected_index = hover_idx; } @@ -357,105 +400,21 @@ impl InteractiveRebase { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - let modifiers = &keystroke.modifiers; - - // If editing a reword message, handle text input - if let Some((_, ref mut msg, ref mut cursor)) = self.editing_reword { - match key { - "escape" => { - let Some((idx, _, _)) = self.editing_reword.take() else { - return; - }; - if let Some(entry) = self.entries.get_mut(idx) { - entry.action = RebaseAction::Pick; - } - cx.notify(); - return; - } - "enter" => { - let Some((idx, msg, _)) = self.editing_reword.take() else { - return; - }; - if let Some(entry) = self.entries.get_mut(idx) { - entry.action = RebaseAction::Reword(msg); - } - cx.notify(); - return; - } - "backspace" => { - if *cursor > 0 { - *cursor -= 1; - msg.remove(*cursor); - } - cx.notify(); - return; - } - "delete" => { - if *cursor < msg.len() { - msg.remove(*cursor); - } - cx.notify(); - return; - } - "left" => { - if *cursor > 0 { - *cursor -= 1; - } - cx.notify(); - return; - } - "right" => { - if *cursor < msg.len() { - *cursor += 1; - } - cx.notify(); - return; - } - "home" => { - *cursor = 0; - cx.notify(); - return; - } - "end" => { - *cursor = msg.len(); - cx.notify(); - return; - } - _ => { - if let Some(key_char) = &keystroke.key_char { - msg.insert_str(*cursor, key_char); - *cursor += key_char.len(); - cx.notify(); - return; - } else if key.len() == 1 && !modifiers.control && !modifiers.platform { - let Some(ch) = key.chars().next() else { - return; - }; - if ch.is_ascii_graphic() || ch == ' ' { - msg.insert(*cursor, ch); - *cursor += 1; - cx.notify(); - return; - } - } - return; - } - } - } - - // Normal mode key handling - match key { - "escape" => { - // Cancel any in-progress drag, then dismiss - if self.dragging_index.is_some() { + /// Runs a keyboard command scoped to `InteractiveRebase` or to the shared + /// `List` group. + /// + /// Reword editing needs no branch here: the message field is a real + /// [`TextInput`], so while it has focus the `!TextInput` half of the + /// bare-letter contexts keeps `p`/`r`/`s`/`f`/`d` out of the way, and Esc and + /// Enter fall through to the arms below only after it has taken what it needs. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => { + if self.editing_reword.is_some() { + // Leaving reword mode is the first meaning of Esc here. + self.abandon_reword(cx); + self.focus_handle.focus(window, cx); + } else if self.dragging_index.is_some() { self.dragging_index = None; self.drag_hover_index = None; cx.notify(); @@ -463,56 +422,62 @@ impl InteractiveRebase { self.dismiss(cx); } } - "enter" => { - self.execute(cx); + CommandId::Confirm => { + if self.editing_reword.is_some() { + self.commit_reword(cx); + self.focus_handle.focus(window, cx); + cx.notify(); + } else { + self.execute(cx); + } } - "up" | "k" if !modifiers.secondary() => { + CommandId::SelectPrev => { if self.selected_index > 0 { self.selected_index -= 1; cx.notify(); } } - "down" | "j" if !modifiers.secondary() => { + CommandId::SelectNext => { if self.selected_index + 1 < self.entries.len() { self.selected_index += 1; cx.notify(); } } - "up" if modifiers.secondary() => { - self.move_entry_up(cx); + CommandId::SelectFirst => { + self.selected_index = 0; + cx.notify(); } - "down" if modifiers.secondary() => { - self.move_entry_down(cx); + CommandId::SelectLast => { + self.selected_index = self.entries.len().saturating_sub(1); + cx.notify(); } - _ => { - // Action shortcuts - if !modifiers.control && !modifiers.platform { - if let Some(key_char) = keystroke.key_char.as_deref().or(Some(key)) { - match key_char { - "p" => self.set_action_on_selected(RebaseAction::Pick, cx), - "r" => { - self.set_action_on_selected(RebaseAction::Reword(String::new()), cx) - } - "s" => self.set_action_on_selected(RebaseAction::Squash, cx), - "f" => self.set_action_on_selected(RebaseAction::Fixup, cx), - "d" => self.set_action_on_selected(RebaseAction::Drop, cx), - _ => {} - } - } - } + CommandId::RebaseMoveUp => self.move_entry_up(cx), + CommandId::RebaseMoveDown => self.move_entry_down(cx), + CommandId::RebasePick => self.set_action_on_selected(RebaseAction::Pick, None, cx), + CommandId::RebaseReword => { + self.set_action_on_selected(RebaseAction::Reword(String::new()), Some(window), cx) } + CommandId::RebaseSquash => self.set_action_on_selected(RebaseAction::Squash, None, cx), + CommandId::RebaseFixup => self.set_action_on_selected(RebaseAction::Fixup, None, cx), + CommandId::RebaseDrop => self.set_action_on_selected(RebaseAction::Drop, None, cx), + _ => cx.propagate(), } } } impl Render for InteractiveRebase { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); if !self.visible { return div().id("interactive-rebase").into_any_element(); } + if self.pending_focus { + self.pending_focus = false; + self.focus_handle.focus(window, cx); + } + let entry_count = self.entries.len(); let header_text: SharedString = format!("Rebasing {} commits onto {}", entry_count, self.target_ref).into(); @@ -578,20 +543,9 @@ impl Render for InteractiveRebase { let is_dropped = matches!(entry.action, RebaseAction::Drop); // Determine what message to display - let is_editing = self - .editing_reword - .as_ref() - .is_some_and(|(edit_idx, _, _)| *edit_idx == idx); + let is_editing = self.editing_reword == Some(idx); - let display_msg: SharedString = if let Some((_, ref msg, _)) = - self.editing_reword.as_ref().filter(|_| is_editing) - { - if msg.is_empty() { - "Enter new commit message...".into() - } else { - SharedString::from(msg.clone()) - } - } else if let RebaseAction::Reword(ref msg) = entry.action { + let display_msg: SharedString = if let RebaseAction::Reword(ref msg) = entry.action { if msg.is_empty() { SharedString::from(entry.original_message.clone()) } else { @@ -603,16 +557,6 @@ impl Render for InteractiveRebase { let msg_color = if is_dropped { Color::Disabled - } else if is_editing { - if self - .editing_reword - .as_ref() - .is_some_and(|(_, msg, _)| msg.is_empty()) - { - Color::Placeholder - } else { - Color::Default - } } else { Color::Default }; @@ -746,80 +690,7 @@ impl Render for InteractiveRebase { // Message (or reword editor) if is_editing { - let Some((_, ref msg, cursor)) = self.editing_reword.as_ref() else { - continue; - }; - let cursor = *cursor; - let text_color = colors.text; - let editor_bg = colors.editor_background; - let border_focused = colors.border_focused; - - let mut input_row = div().h_flex().items_center().flex_1(); - - if msg.is_empty() { - input_row = input_row - .child(div().w(px(2.)).h(px(14.)).bg(text_color)) - .child( - Label::new("Enter new commit message...") - .size(LabelSize::Small) - .color(Color::Placeholder), - ); - } else { - let before = &msg[..cursor]; - let cursor_char = if cursor < msg.len() { - &msg[cursor..cursor + 1] - } else { - "" - }; - let after = if cursor + 1 < msg.len() { - &msg[cursor + 1..] - } else { - "" - }; - - if !before.is_empty() { - input_row = input_row.child( - Label::new(SharedString::from(before.to_string())) - .size(LabelSize::Small), - ); - } - if !cursor_char.is_empty() { - input_row = input_row.child( - div().bg(text_color).child( - Label::new(SharedString::from(cursor_char.to_string())) - .size(LabelSize::Small) - .color(Color::Custom(gpui::Hsla { - h: 0.0, - s: 0.0, - l: 0.0, - a: 1.0, - })), - ), - ); - } else { - input_row = input_row.child(div().w(px(2.)).h(px(14.)).bg(text_color)); - } - if !after.is_empty() { - input_row = input_row.child( - Label::new(SharedString::from(after.to_string())) - .size(LabelSize::Small), - ); - } - } - - row = row.child( - div() - .flex_1() - .h(px(24.)) - .px_1() - .bg(editor_bg) - .border_1() - .border_color(border_focused) - .rounded(px(4.)) - .h_flex() - .items_center() - .child(input_row), - ); + row = row.child(div().flex_1().child(self.reword_editor.clone())); } else { // Strikethrough for dropped commits if is_dropped { @@ -857,7 +728,15 @@ impl Render for InteractiveRebase { let modal = div() .id("interactive-rebase-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "InteractiveRebase List", + &["Menu", "InteractiveRebase"], + cx, + Self::dispatch_command, + ) + }) .on_mouse_down( gpui::MouseButton::Left, |_: &gpui::MouseDownEvent, _, cx| { diff --git a/crates/rgitui_workspace/src/issues_panel.rs b/crates/rgitui_workspace/src/issues_panel.rs index d4952b3e..43a2b02e 100644 --- a/crates/rgitui_workspace/src/issues_panel.rs +++ b/crates/rgitui_workspace/src/issues_panel.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, Window, + div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Render, + ScrollStrategy, SharedString, UniformListScrollHandle, Window, }; use crate::github_api::GithubCollectionError; @@ -19,6 +19,9 @@ use rgitui_ui::{ TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Clone, Debug)] pub struct Issue { pub number: u64, @@ -661,66 +664,57 @@ impl IssuesPanel { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { if self.view_mode == IssuesPanelView::Detail { - if key == "escape" { + if cmd == CommandId::Cancel { self.go_back(cx); - cx.stop_propagation(); } return; } - // While searching the text input owns the keyboard; list navigation - // must not steal j/k or hijack Enter from the search submission. - if self.is_searching { + // While the search field is open it owns Enter; propagating lets the + // field's own submission run the search instead of opening a row. + if self.is_searching && cmd == CommandId::Confirm { + cx.propagate(); return; } let count = self.issues.len(); if count == 0 { + cx.propagate(); return; } - match key { - "down" | "j" => { - let next = self - .selected_index - .map(|i| (i + 1).min(count - 1)) - .unwrap_or(0); - self.selected_index = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "up" | "k" => { - let prev = self - .selected_index - .map(|i| i.saturating_sub(1)) - .unwrap_or(0); - self.selected_index = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); + match cmd { + CommandId::SelectNext => self.select_row( + self.selected_index.map_or(0, |i| (i + 1).min(count - 1)), + cx, + ), + CommandId::SelectPrev => { + self.select_row(self.selected_index.map_or(0, |i| i.saturating_sub(1)), cx) } - "enter" => { + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(count - 1, cx), + CommandId::Confirm => { if let Some(index) = self.selected_index { self.select_issue(index, cx); - cx.stop_propagation(); } } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.selected_index = Some(row); + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors(); @@ -1207,7 +1201,15 @@ impl Render for IssuesPanel { let mut panel = div() .id("issues-panel") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "IssuesPanel List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(panel_bg); diff --git a/crates/rgitui_workspace/src/keymap/conflict.rs b/crates/rgitui_workspace/src/keymap/conflict.rs new file mode 100644 index 00000000..95fade5f --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/conflict.rs @@ -0,0 +1,489 @@ +//! Keybinding conflict detection. +//! +//! gpui resolves ambiguity silently: when two bindings match the same keystroke +//! in overlapping contexts the later one wins and the earlier one simply never +//! fires. That is fine for the *intended* case — a user binding replacing a +//! default — but it hides genuine mistakes, so rgitui detects and reports them +//! before handing anything to gpui. +//! +//! Everything here is pure: [`detect_conflicts`] takes a list of +//! `(keystrokes, context, action)` triples and returns a report, so it is +//! unit-testable without a window or an `App`. +//! +//! # Rules +//! +//! A pair of bindings conflicts when +//! +//! 1. **Overlap** — they resolve to the same keystroke sequence and their +//! contexts overlap. Contexts overlap when they are equal, when either is +//! absent (an absent context matches everywhere), or when one predicate is a +//! superset of the other per [`KeyBindingContextPredicate::is_superset`]. +//! 2. **Chord prefix** — one binding's keystroke sequence is a strict prefix of +//! the other's and their contexts overlap, e.g. `ctrl-k` alongside +//! `ctrl-k ctrl-o`. Typing the prefix would resolve immediately and the +//! chord could never be reached. +//! +//! It is *not* a conflict when +//! +//! * a [`BindingSource::User`] binding overlaps a [`BindingSource::Default`] +//! one — that is the whole point of a user keymap; or +//! * either binding is an unbind (`null` in `keymap.json`, gpui's `NoAction`), +//! which is a deliberate instruction to remove a binding. +//! +//! # Resolution +//! +//! For an overlap the later entry wins and the earlier one is dropped, matching +//! gpui's own precedence. For a chord prefix the *prefix* is dropped whichever +//! order it appears in, so the longer chord stays reachable. Either way the +//! losing entry is reported rather than silently applied. + +use std::fmt; + +use gpui::{KeyBindingContextPredicate, Keystroke}; + +/// Where a binding came from. Defaults are always ordered before user bindings. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BindingSource { + /// Declared by `commands!` in the registry. + Default, + /// Read from the user's `keymap.json`. + User, +} + +impl fmt::Display for BindingSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BindingSource::Default => f.write_str("default"), + BindingSource::User => f.write_str("keymap.json"), + } + } +} + +/// The gpui action name that removes a binding. Bound to `null` in `keymap.json`. +pub const NO_ACTION: &str = "zed::NoAction"; + +/// One candidate key binding, before it is handed to gpui. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BindingSpec { + /// Whitespace-separated keystrokes, exactly as written. + pub keystrokes: String, + /// Context predicate source, or `None` for "matches everywhere". + pub context: Option, + /// Full gpui action name, or [`NO_ACTION`] for an unbind. + pub action: String, + /// Where this binding came from. + pub source: BindingSource, +} + +impl BindingSpec { + /// A binding declared by the registry. + pub fn default_binding(keystrokes: &str, context: &str, action: &str) -> Self { + Self { + keystrokes: keystrokes.to_owned(), + context: (!context.is_empty()).then(|| context.to_owned()), + action: action.to_owned(), + source: BindingSource::Default, + } + } + + /// A binding read from the user's `keymap.json`. + pub fn user_binding(keystrokes: &str, context: Option<&str>, action: &str) -> Self { + Self { + keystrokes: keystrokes.to_owned(), + context: context.filter(|c| !c.is_empty()).map(str::to_owned), + action: action.to_owned(), + source: BindingSource::User, + } + } + + /// Whether this entry removes a binding rather than adding one. + pub fn is_unbind(&self) -> bool { + self.action == NO_ACTION + } + + fn context_label(&self) -> &str { + self.context.as_deref().unwrap_or("") + } +} + +/// Why two bindings conflict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictKind { + /// Same keystroke sequence, overlapping contexts. + Overlap, + /// One keystroke sequence is a strict prefix of the other's chord. + ChordPrefix, +} + +/// A detected conflict between two candidate bindings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Conflict { + /// Why the two bindings conflict. + pub kind: ConflictKind, + /// Index of the binding that is kept. + pub winner: usize, + /// Index of the binding that is dropped. + pub ignored: usize, + /// Human-readable explanation, shown to the user. + pub message: String, +} + +/// The outcome of [`detect_conflicts`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConflictReport { + /// Every conflict found, in the order the losing binding appears. + pub conflicts: Vec, + /// Indices of bindings that must not be applied. Sorted and deduplicated. + pub dropped: Vec, +} + +impl ConflictReport { + /// Whether the binding at `index` survived conflict resolution. + pub fn is_kept(&self, index: usize) -> bool { + !self.dropped.contains(&index) + } + + /// One message per conflict, ready to surface as a toast. + pub fn messages(&self) -> Vec { + self.conflicts + .iter() + .map(|conflict| conflict.message.clone()) + .collect() + } +} + +/// A keystroke reduced to the parts gpui matches on, so that different spellings +/// of the same keystroke (`ctrl-s` and `secondary-s` off macOS) compare equal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NormalizedKeystroke { + control: bool, + alt: bool, + shift: bool, + platform: bool, + function: bool, + key: String, +} + +impl From for NormalizedKeystroke { + fn from(keystroke: Keystroke) -> Self { + Self { + control: keystroke.modifiers.control, + alt: keystroke.modifiers.alt, + shift: keystroke.modifiers.shift, + platform: keystroke.modifiers.platform, + function: keystroke.modifiers.function, + key: keystroke.key, + } + } +} + +/// Parses a whitespace-separated keystroke sequence. +/// +/// Returns `None` when any keystroke is unparseable — such entries are reported +/// by the loader and excluded from conflict detection. +pub fn normalize_sequence(keystrokes: &str) -> Option> { + let sequence: Vec = keystrokes + .split_whitespace() + .map(|source| Keystroke::parse(source).ok().map(NormalizedKeystroke::from)) + .collect::>()?; + (!sequence.is_empty()).then_some(sequence) +} + +/// Whether two context predicates can both be satisfied by the same element. +/// +/// An absent predicate matches everywhere, so it overlaps everything. Two +/// present predicates overlap when either is a superset of the other. A +/// predicate that fails to parse is reported by the loader; here it is treated +/// as overlapping nothing so a single bad predicate cannot suppress unrelated +/// bindings. +pub fn contexts_overlap(left: Option<&str>, right: Option<&str>) -> bool { + match (left, right) { + (None, _) | (_, None) => true, + (Some(left), Some(right)) => { + if left == right { + return true; + } + let Ok(left) = KeyBindingContextPredicate::parse(left) else { + return false; + }; + let Ok(right) = KeyBindingContextPredicate::parse(right) else { + return false; + }; + left.is_superset(&right) || right.is_superset(&left) + } + } +} + +/// Whether `prefix` is a strict prefix of `sequence`. +fn is_strict_prefix(prefix: &[NormalizedKeystroke], sequence: &[NormalizedKeystroke]) -> bool { + prefix.len() < sequence.len() && sequence.starts_with(prefix) +} + +/// Finds every conflict in `bindings`, which must be ordered by precedence: +/// earlier entries lose to later ones. See the [module docs](self) for the rules. +pub fn detect_conflicts(bindings: &[BindingSpec]) -> ConflictReport { + let sequences: Vec>> = bindings + .iter() + .map(|binding| normalize_sequence(&binding.keystrokes)) + .collect(); + + let mut conflicts = Vec::new(); + let mut dropped = Vec::new(); + + for later in 0..bindings.len() { + for earlier in 0..later { + let (Some(earlier_keys), Some(later_keys)) = (&sequences[earlier], &sequences[later]) + else { + continue; + }; + let (earlier_binding, later_binding) = (&bindings[earlier], &bindings[later]); + + // Unbinds are deliberate removals, not mistakes. + if earlier_binding.is_unbind() || later_binding.is_unbind() { + continue; + } + if !contexts_overlap( + earlier_binding.context.as_deref(), + later_binding.context.as_deref(), + ) { + continue; + } + + if earlier_keys == later_keys { + // A user binding replacing a default is the feature, not a bug. + if earlier_binding.source == BindingSource::Default + && later_binding.source == BindingSource::User + { + continue; + } + conflicts.push(Conflict { + kind: ConflictKind::Overlap, + winner: later, + ignored: earlier, + message: format!( + "`{}` is bound twice in overlapping contexts: `{}` ({}, context `{}`) \ + is ignored in favour of `{}` ({}, context `{}`).", + later_binding.keystrokes, + earlier_binding.action, + earlier_binding.source, + earlier_binding.context_label(), + later_binding.action, + later_binding.source, + later_binding.context_label(), + ), + }); + dropped.push(earlier); + } else if is_strict_prefix(earlier_keys, later_keys) { + conflicts.push(prefix_conflict(earlier, later, bindings)); + dropped.push(earlier); + } else if is_strict_prefix(later_keys, earlier_keys) { + conflicts.push(prefix_conflict(later, earlier, bindings)); + dropped.push(later); + } + } + } + + dropped.sort_unstable(); + dropped.dedup(); + ConflictReport { conflicts, dropped } +} + +/// Builds the report entry for a chord-prefix conflict. The prefix loses so the +/// longer chord stays reachable. +fn prefix_conflict(prefix: usize, chord: usize, bindings: &[BindingSpec]) -> Conflict { + let (prefix_binding, chord_binding) = (&bindings[prefix], &bindings[chord]); + Conflict { + kind: ConflictKind::ChordPrefix, + winner: chord, + ignored: prefix, + message: format!( + "`{}` ({}, context `{}`) shadows the chord `{}` ({}, context `{}`); \ + the shorter binding is ignored so the chord stays reachable.", + prefix_binding.keystrokes, + prefix_binding.source, + prefix_binding.context_label(), + chord_binding.keystrokes, + chord_binding.source, + chord_binding.context_label(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_binding(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::default_binding(keystrokes, context, action) + } + + fn user_binding(keystrokes: &str, context: Option<&str>, action: &str) -> BindingSpec { + BindingSpec::user_binding(keystrokes, context, action) + } + + #[test] + fn distinct_keystrokes_do_not_conflict() { + let report = detect_conflicts(&[ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-u", "Workspace", "rgitui::UnstageAll"), + ]); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn disjoint_contexts_do_not_conflict() { + let report = detect_conflicts(&[ + default_binding("y", "GraphView", "graph::CopySha"), + default_binding("y", "DiffViewer", "diff::CopyLine"), + ]); + assert!(report.conflicts.is_empty(), "{:?}", report.conflicts); + } + + #[test] + fn same_context_duplicate_is_detected_and_the_later_wins() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-s", "Workspace", "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::Overlap); + assert_eq!(report.conflicts[0].winner, 1); + assert_eq!(report.conflicts[0].ignored, 0); + assert_eq!(report.dropped, vec![0]); + assert!(!report.is_kept(0)); + assert!(report.is_kept(1)); + } + + #[test] + fn superset_context_overlap_is_detected() { + // `Workspace` matches everything `Workspace && !modal` matches. + let bindings = [ + default_binding("ctrl-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("ctrl-s", "Workspace", "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::Overlap); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn a_missing_context_overlaps_every_context() { + let bindings = [ + user_binding("ctrl-s", None, "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn chord_prefix_shadowing_drops_the_prefix() { + let bindings = [ + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + user_binding("ctrl-k", Some("Workspace"), "rgitui::StageAll"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::ChordPrefix); + // The prefix loses even though it comes later, so the chord stays reachable. + assert_eq!(report.conflicts[0].ignored, 1); + assert_eq!(report.conflicts[0].winner, 0); + assert_eq!(report.dropped, vec![1]); + } + + #[test] + fn chord_prefix_shadowing_is_order_independent() { + let bindings = [ + user_binding("ctrl-k", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.conflicts[0].kind, ConflictKind::ChordPrefix); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn chord_prefix_in_a_disjoint_context_is_fine() { + let bindings = [ + user_binding("ctrl-k ctrl-o", Some("GraphView"), "graph::CopySha"), + user_binding("ctrl-k", Some("DiffViewer"), "diff::CopyLine"), + ]; + let report = detect_conflicts(&bindings); + assert!(report.conflicts.is_empty(), "{:?}", report.conflicts); + } + + #[test] + fn a_user_binding_may_replace_a_default() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn two_user_bindings_on_one_keystroke_do_conflict() { + let bindings = [ + user_binding("ctrl-s", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1); + assert_eq!(report.dropped, vec![0]); + } + + #[test] + fn unbinds_are_never_conflicts() { + let bindings = [ + default_binding("ctrl-s", "Workspace", "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), NO_ACTION), + user_binding("ctrl-k", Some("Workspace"), NO_ACTION), + user_binding("ctrl-k ctrl-o", Some("Workspace"), "rgitui::OpenRepo"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report, ConflictReport::default()); + } + + #[test] + fn equivalent_spellings_of_one_keystroke_are_compared_equal() { + let native = if cfg!(target_os = "macos") { + "cmd-s" + } else { + "ctrl-s" + }; + let bindings = [ + user_binding("secondary-s", Some("Workspace"), "rgitui::StageAll"), + user_binding(native, Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert_eq!(report.conflicts.len(), 1, "{:?}", report.conflicts); + } + + #[test] + fn unparseable_keystrokes_are_skipped() { + let bindings = [ + user_binding("ctrl-a-b", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-a-b", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + assert!(report.conflicts.is_empty()); + } + + #[test] + fn messages_name_both_bindings() { + let bindings = [ + user_binding("ctrl-s", Some("Workspace"), "rgitui::StageAll"), + user_binding("ctrl-s", Some("Workspace"), "rgitui::Commit"), + ]; + let report = detect_conflicts(&bindings); + let message = &report.messages()[0]; + assert!(message.contains("rgitui::StageAll"), "{message}"); + assert!(message.contains("rgitui::Commit"), "{message}"); + assert!(message.contains("ctrl-s"), "{message}"); + } +} diff --git a/crates/rgitui_workspace/src/keymap/display.rs b/crates/rgitui_workspace/src/keymap/display.rs new file mode 100644 index 00000000..6920f3b4 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/display.rs @@ -0,0 +1,547 @@ +//! Humanised keystroke rendering. +//! +//! Turns the keystroke spelling used in the registry and in `keymap.json` +//! (`secondary-shift-r`, `ctrl-k ctrl-o`) into the text shown to the user: +//! `Ctrl+Shift+R` on Windows and Linux, `⌘⇧R` on macOS. +//! +//! Parsing is [`gpui::Keystroke::parse`], so `secondary` resolves to the +//! platform's primary modifier and `shift-g`/`G` normalise the same way the +//! keymap does — the display can therefore never disagree with what gpui +//! actually matches. Only the final spelling is ours: gpui's own [`Display`] +//! renders `ctrl-shift-R`, which is the keymap syntax rather than a label. +//! +//! [`KeystrokeStyle`] is passed in rather than read from `cfg!`, so both +//! spellings are unit-testable on any platform. +//! +//! [`Display`]: std::fmt::Display + +use gpui::{Keystroke, Modifiers}; + +/// How a keystroke is spelled for the user. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeystrokeStyle { + /// `Ctrl+Shift+R` — Windows and Linux. + Words, + /// `⌘⇧R` — macOS, where modifier glyphs are the platform convention. + Symbols, +} + +impl KeystrokeStyle { + /// The spelling this platform's users expect. + pub const fn platform() -> Self { + if cfg!(target_os = "macos") { + KeystrokeStyle::Symbols + } else { + KeystrokeStyle::Words + } + } +} + +/// Separates the keystrokes of a chord, e.g. `Ctrl+K Ctrl+O`. +const CHORD_SEPARATOR: &str = " "; + +/// Separates alternative bindings for one command, e.g. `Ctrl+Shift+S or Ctrl+U`. +pub const BINDING_SEPARATOR: &str = " or "; + +/// Shown in place of a keystroke for a command that has no binding. +pub const UNBOUND: &str = "unbound"; + +/// Renders one keystroke, e.g. `secondary-shift-r` → `Ctrl+Shift+R`. +/// +/// Returns `None` when the keystroke does not parse; the loader reports those +/// separately and they are never applied, so there is nothing to display. +pub fn humanize_keystroke(source: &str, style: KeystrokeStyle) -> Option { + let keystroke = Keystroke::parse(source).ok()?; + Some(render(&keystroke.modifiers, &keystroke.key, style)) +} + +/// Renders a whitespace-separated keystroke sequence, e.g. `ctrl-k ctrl-o` → +/// `Ctrl+K Ctrl+O`. +/// +/// Returns `None` when the sequence is empty or any keystroke in it is +/// unparseable — a chord is all-or-nothing, since half of one is meaningless. +pub fn humanize_sequence(keystrokes: &str, style: KeystrokeStyle) -> Option { + let rendered: Option> = keystrokes + .split_whitespace() + .map(|keystroke| humanize_keystroke(keystroke, style)) + .collect(); + let rendered = rendered?; + (!rendered.is_empty()).then(|| rendered.join(CHORD_SEPARATOR)) +} + +/// Joins the alternative bindings of one command, e.g. `UnstageAll` → +/// `Ctrl+Shift+S or Ctrl+U`. +/// +/// Duplicates are collapsed: one command often binds the same keystroke in two +/// contexts, which is one thing to press and so one thing to show. +pub fn join_bindings<'a>(displays: impl IntoIterator) -> Option { + let mut unique: Vec<&str> = Vec::new(); + for display in displays { + if !unique.contains(&display) { + unique.push(display); + } + } + (!unique.is_empty()).then(|| unique.join(BINDING_SEPARATOR)) +} + +/// Spells out modifiers followed by the key. +fn render(modifiers: &Modifiers, key: &str, style: KeystrokeStyle) -> String { + let mut out = String::new(); + match style { + // Word order follows `Keystroke::unparse`, so the label reads in the + // same order as the keymap entry it came from. + KeystrokeStyle::Words => { + if modifiers.function { + out.push_str("Fn+"); + } + if modifiers.control { + out.push_str("Ctrl+"); + } + if modifiers.alt { + out.push_str("Alt+"); + } + if modifiers.platform { + out.push_str(if cfg!(target_os = "windows") { + "Win+" + } else { + "Super+" + }); + } + if modifiers.shift { + out.push_str("Shift+"); + } + } + // The glyphs and their order are gpui's, so a label matches what the + // rest of the platform shows in its menus. + KeystrokeStyle::Symbols => { + if modifiers.function { + out.push_str("fn"); + } + if modifiers.control { + out.push('^'); + } + if modifiers.alt { + out.push('⌥'); + } + if modifiers.platform { + out.push('⌘'); + } + if modifiers.shift { + out.push('⇧'); + } + } + } + out.push_str(&render_key(key, style)); + out +} + +/// Spells out the key itself. +/// +/// A single character is upper-cased — the keymap stores `r` for what the user +/// sees printed on the key as `R`, with shift tracked as a modifier. +fn render_key(key: &str, style: KeystrokeStyle) -> String { + if let Some(named) = named_key(key, style) { + return named.to_owned(); + } + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(single), None) => single.to_uppercase().collect(), + // `f5`, or a key name this build does not know: show it verbatim rather + // than inventing a spelling. + _ => title_case(key), + } +} + +/// The display name of a key gpui spells with a word. +/// +/// macOS shows glyphs for these; elsewhere they get conventional capitalisation. +fn named_key(key: &str, style: KeystrokeStyle) -> Option<&'static str> { + let symbols = matches!(style, KeystrokeStyle::Symbols); + Some(match key { + "enter" => { + if symbols { + "↩" + } else { + "Enter" + } + } + "escape" => { + if symbols { + "⎋" + } else { + "Esc" + } + } + "tab" => { + if symbols { + "⇥" + } else { + "Tab" + } + } + "space" => { + if symbols { + "␣" + } else { + "Space" + } + } + "backspace" => { + if symbols { + "⌫" + } else { + "Backspace" + } + } + "delete" => { + if symbols { + "⌦" + } else { + "Delete" + } + } + "up" => { + if symbols { + "↑" + } else { + "Up" + } + } + "down" => { + if symbols { + "↓" + } else { + "Down" + } + } + "left" => { + if symbols { + "←" + } else { + "Left" + } + } + "right" => { + if symbols { + "→" + } else { + "Right" + } + } + "home" => { + if symbols { + "↖" + } else { + "Home" + } + } + "end" => { + if symbols { + "↘" + } else { + "End" + } + } + "pageup" => { + if symbols { + "⇞" + } else { + "PageUp" + } + } + "pagedown" => { + if symbols { + "⇟" + } else { + "PageDown" + } + } + "insert" => "Insert", + // A modifier bound as the key in its own right. + "shift" => { + if symbols { + "⇧" + } else { + "Shift" + } + } + "control" => { + if symbols { + "^" + } else { + "Ctrl" + } + } + "alt" => { + if symbols { + "⌥" + } else { + "Alt" + } + } + "platform" => { + if symbols { + "⌘" + } else if cfg!(target_os = "windows") { + "Win" + } else { + "Super" + } + } + "function" => { + if symbols { + "fn" + } else { + "Fn" + } + } + _ => return None, + }) +} + +/// Upper-cases the first character, leaving the rest — `f5` → `F5`. +fn title_case(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_primary_modifier_follows_the_platform() { + assert_eq!( + humanize_keystroke("secondary-shift-r", KeystrokeStyle::Words).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + humanize_keystroke("secondary-shift-r", KeystrokeStyle::Symbols).as_deref(), + // `secondary` is `ctrl` off macOS, which is `^` in the glyph style. + Some(if cfg!(target_os = "macos") { + "⌘⇧R" + } else { + "^⇧R" + }) + ); + } + + #[test] + fn an_explicit_platform_modifier_renders_the_same_either_way() { + assert_eq!( + humanize_keystroke("cmd-shift-r", KeystrokeStyle::Symbols).as_deref(), + Some("⌘⇧R") + ); + } + + #[test] + fn named_keys_get_conventional_names() { + for (source, words) in [ + ("secondary-enter", "Ctrl+Enter"), + ("escape", "Esc"), + ("f5", "F5"), + ("shift-tab", "Shift+Tab"), + ("secondary-up", "Ctrl+Up"), + ("alt-5", "Alt+5"), + ("space", "Space"), + ("delete", "Delete"), + ("secondary-,", "Ctrl+,"), + ("secondary-[", "Ctrl+["), + ] { + assert_eq!( + humanize_keystroke(source, KeystrokeStyle::Words).as_deref(), + Some(words), + "{source}" + ); + } + } + + /// `?` and `G` reach gpui as shift plus a base key; the label has to show + /// the character the user actually types. + #[test] + fn shifted_characters_keep_the_shift_modifier_visible() { + assert_eq!( + humanize_keystroke("?", KeystrokeStyle::Words).as_deref(), + Some("?") + ); + assert_eq!( + humanize_keystroke("shift-g", KeystrokeStyle::Words).as_deref(), + Some("Shift+G") + ); + // `G` parses to shift-g, so both spellings render identically. + assert_eq!( + humanize_keystroke("G", KeystrokeStyle::Words), + humanize_keystroke("shift-g", KeystrokeStyle::Words) + ); + } + + #[test] + fn a_bare_letter_is_upper_cased() { + assert_eq!( + humanize_keystroke("j", KeystrokeStyle::Words).as_deref(), + Some("J") + ); + assert_eq!( + humanize_keystroke("j", KeystrokeStyle::Symbols).as_deref(), + Some("J") + ); + } + + #[test] + fn a_chord_renders_every_keystroke() { + assert_eq!( + humanize_sequence("ctrl-k ctrl-o", KeystrokeStyle::Words).as_deref(), + Some("Ctrl+K Ctrl+O") + ); + assert_eq!( + humanize_sequence("ctrl-k ctrl-o", KeystrokeStyle::Symbols).as_deref(), + Some("^K ^O") + ); + } + + #[test] + fn an_unparseable_keystroke_has_no_label() { + assert_eq!(humanize_keystroke("ctrl-a-b", KeystrokeStyle::Words), None); + assert_eq!(humanize_sequence("", KeystrokeStyle::Words), None); + // One bad keystroke discards the whole chord. + assert_eq!( + humanize_sequence("ctrl-k ctrl-a-b", KeystrokeStyle::Words), + None + ); + } + + #[test] + fn alternative_bindings_are_joined_and_deduplicated() { + assert_eq!( + join_bindings(["Ctrl+Shift+S", "Ctrl+U"]).as_deref(), + Some("Ctrl+Shift+S or Ctrl+U") + ); + // `down` and `j` in two contexts is still one thing to press. + assert_eq!(join_bindings(["Down", "Down"]).as_deref(), Some("Down")); + assert_eq!(join_bindings(std::iter::empty()), None); + } + + /// The source files that must show shortcuts the keymap decided, not + /// shortcuts a developer typed. Each is scanned by + /// [`no_surface_hardcodes_a_chord`]; add a file here when you route a new + /// shortcut display through [`super::shortcut`]. + const SURFACES: &[(&str, &str)] = &[ + ("shortcuts_help.rs", include_str!("../shortcuts_help.rs")), + ("command_palette.rs", include_str!("../command_palette.rs")), + ( + "settings_window/view.rs", + include_str!("../settings_window/view.rs"), + ), + ("toolbar.rs", include_str!("../toolbar.rs")), + ("title_bar.rs", include_str!("../title_bar.rs")), + ( + "workspace/layout.rs", + include_str!("../workspace/layout.rs"), + ), + ( + "workspace/commands.rs", + include_str!("../workspace/commands.rs"), + ), + ]; + + /// Spellings that only ever appear in a hand-written shortcut label. + /// + /// A modifier joined to something with `+` or `-` cannot occur in Rust code + /// outside a string, so this needs no quote handling. Built at runtime so + /// this test's own source does not trip it. + /// + /// It does not catch a bare `"?"` or `"j / k"` — a single character is + /// indistinguishable from ordinary text — so it is a floor, not a proof. + fn chord_needles() -> Vec { + ["Ctrl", "Cmd", "Alt", "Shift", "Win", "Super"] + .into_iter() + .flat_map(|modifier| ["+", "-"].map(move |joiner| format!("{modifier}{joiner}"))) + .chain(["⌘".to_owned(), "⌥".to_owned(), "⇧".to_owned()]) + .collect() + } + + /// The `Ctrl+Shift+F`-for-Fetch drift happened because a shortcut label was a + /// literal in a list nobody re-checked. Deleting the literals fixed it once; + /// this test is what stops the next one being written, by failing the build if + /// a user-facing surface spells a chord out again instead of asking the + /// keymap. + /// + /// Doc comments and the test modules are skipped — prose may name a chord, + /// and an assertion has to spell out what it expects. + #[test] + fn no_surface_hardcodes_a_chord() { + let needles = chord_needles(); + for (name, source) in SURFACES { + let code = source + .split_once("\n#[cfg(test)]") + .map_or(*source, |(before, _)| before); + for (number, line) in code.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") { + continue; + } + for needle in &needles { + assert!( + !line.contains(needle.as_str()), + "{name}:{} spells a keystroke out instead of reading it from the \ + keymap — use `keymap::shortcut` or `keymap::command_tooltip`:\n {}", + number + 1, + line.trim() + ); + } + } + } + } + + /// The scanner has to actually catch the string the old code contained, + /// otherwise it is a test that can never fail. + #[test] + fn the_chord_scanner_catches_the_label_that_drifted() { + let needles = chord_needles(); + for offender in [ + // The literal the shortcut help used to carry for Fetch. + r#"("Ctrl+Shift+F", "Fetch"),"#, + // The palette's hint field. + r#"Some("Ctrl+Shift+F"),"#, + // A chord tucked inside a longer sentence. + r#"tooltip_text: "Fetch from remote (Ctrl+Shift+R)","#, + // The macOS spelling. + r#"Label::new("⌘⇧R")"#, + ] { + assert!( + needles + .iter() + .any(|needle| offender.contains(needle.as_str())), + "the scanner would not have caught {offender}" + ); + } + } + + /// The glyph style exists to match what macOS shows elsewhere, so on macOS + /// it must agree with gpui's own rendering for the cases gpui spells with + /// glyphs. rgitui extends the set to keys gpui leaves as bare words + /// (`enter`, `space`, …), which is why the comparison is limited. + #[test] + #[cfg(target_os = "macos")] + fn the_glyph_style_matches_gpui_for_the_keys_gpui_spells_with_glyphs() { + for source in [ + "cmd-shift-r", + "cmd-s", + "ctrl-alt-cmd-shift-a", + "escape", + "cmd-up", + "shift-tab", + "j", + ] { + let keystroke = Keystroke::parse(source).expect("the test keystrokes parse"); + assert_eq!( + humanize_keystroke(source, KeystrokeStyle::Symbols).as_deref(), + Some(keystroke.to_string().as_str()), + "{source} drifted from gpui's rendering" + ); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/generate.rs b/crates/rgitui_workspace/src/keymap/generate.rs new file mode 100644 index 00000000..22f8db6b --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/generate.rs @@ -0,0 +1,478 @@ +//! Generates the committed keybinding artifacts from the registry. +//! +//! Two files are derived from `commands!` and checked in: +//! +//! * `docs/KEYBINDINGS.md` — the user-facing reference, including the +//! nested-context overlaps [`super::shadow`] finds in the defaults, +//! * `docs/keymap.schema.json` — a JSON Schema enumerating every action name so +//! editors can complete `keymap.json`. +//! +//! Both are golden-tested: the tests below regenerate the content and compare it +//! with the committed file, failing with a line diff when it is stale. Run +//! `cargo test -p rgitui_workspace keymap::generate` after changing `commands!`, +//! then copy the regenerated content in (the failure message tells you which +//! file drifted). + +use std::fmt::Write as _; + +use super::registry::{CommandMeta, ALL_COMMANDS}; + +/// Header written into both generated files so nobody hand-edits them. +const GENERATED_NOTICE: &str = "Generated from the `commands!` declaration in \ + `crates/rgitui_workspace/src/keymap/registry.rs`. Do not edit by hand."; + +/// The distinct view names in the registry, in declaration order. +fn views() -> Vec<&'static str> { + let mut views: Vec<&'static str> = Vec::new(); + for meta in ALL_COMMANDS { + if !views.contains(&meta.view) { + views.push(meta.view); + } + } + views +} + +/// Escapes a value for a Markdown table cell. +/// +/// GFM splits table rows on `|` even inside a code span, so an `||` context +/// predicate would silently break the row it sits in. +fn table_cell(value: &str) -> String { + value.replace('|', "\\|") +} + +/// Renders a command's default keystrokes for a Markdown table cell. +fn keystroke_cell(meta: &CommandMeta) -> String { + if meta.default_bindings.is_empty() { + return "_unbound_".to_owned(); + } + meta.default_bindings + .iter() + .map(|(keystroke, _)| format!("`{}`", table_cell(keystroke))) + .collect::>() + .join(" or ") +} + +/// Renders the key contexts a command's default bindings are scoped to. +/// +/// Keystrokes of one command may differ here — a vim-style letter usually +/// carries `&& !TextInput` where its arrow-key twin does not — so each distinct +/// context is listed once, in binding order. +fn context_cell(meta: &CommandMeta) -> String { + let mut contexts: Vec<&'static str> = Vec::new(); + for (_, context) in meta.default_bindings { + if !contexts.contains(context) { + contexts.push(context); + } + } + if contexts.is_empty() { + return "—".to_owned(); + } + contexts + .iter() + .map(|context| format!("`{}`", table_cell(context))) + .collect::>() + .join(" or ") +} + +/// Renders `docs/KEYBINDINGS.md`. +pub fn keybindings_markdown() -> String { + let mut out = String::new(); + + out.push_str("# Keyboard shortcuts\n\n"); + out.push_str("\n\n"); + out.push_str( + "Every shortcut below is rebindable, and press `?` in rgitui to see the ones actually \ + in force — that reference is generated from the same declaration as this page, so it \ + follows your own keybindings rather than the defaults.\n\n", + ); + out.push_str( + "`secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere \ + else. Commands marked _unbound_ have no default keystroke and are reached from the \ + command palette (`secondary-shift-p`).\n\n", + ); + out.push_str("## Customising\n\n"); + out.push_str( + "Keybindings live in `keymap.json`, next to `settings.json` in rgitui's config \ + directory:\n\n\ + | Platform | Path |\n\ + | --- | --- |\n\ + | Linux | `~/.config/rgitui/keymap.json` |\n\ + | macOS | `~/Library/Application Support/rgitui/keymap.json` |\n\ + | Windows | `%APPDATA%\\rgitui\\keymap.json` |\n\n\ + Run the **Open keymap.json** command from the palette, or use the button in the \ + shortcut reference, to create the file with a commented example already in it and \ + open it in your editor.\n\n", + ); + out.push_str( + "```jsonc\n\ + [\n \ + {\n \ + \"context\": \"Workspace && !modal\",\n \ + \"bindings\": {\n \ + // Rebind staging.\n \ + \"ctrl-alt-s\": \"rgitui::StageAll\",\n \ + // Remove a default binding.\n \ + \"secondary-s\": null\n \ + }\n \ + }\n\ + ]\n\ + ```\n\n", + ); + out.push_str( + "The file is reloaded when you save it. Bindings you add win over the defaults. \ + Two bindings on the same keystroke in overlapping contexts, or a binding that \ + shadows the prefix of a chord, are reported as a toast and the losing binding is \ + dropped rather than silently ignored.\n\n", + ); + out.push_str( + "`docs/keymap.schema.json` lists every action name with its description; associate it \ + with `keymap.json` in your editor's JSON schema settings for completion and hovers.\n", + ); + + for view in views() { + let _ = write!(out, "\n## {view}\n\n"); + out.push_str("| Keystroke | Context | Action | Description |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + for meta in ALL_COMMANDS.iter().filter(|meta| meta.view == view) { + let _ = writeln!( + out, + "| {} | {} | `{}` | {} |", + keystroke_cell(meta), + context_cell(meta), + meta.action_name, + meta.description() + ); + } + } + + let _ = write!( + out, + "\n## Key contexts\n\n\ + A binding fires when its context matches somewhere on the path from the \ + focused element to the window root, and the deepest matching binding \ + wins. That is what lets one keystroke mean different things in different \ + panels without any `if focused` checks.\n\n\ + | Context | Set on |\n\ + | --- | --- |\n\ + | `Workspace` | the workspace root, so it is always in scope |\n\ + | `SettingsWindow` | the settings window root |\n\ + | `modal` | added to the workspace root while any overlay or dialog is open |\n\ + | `TextInput` | any text field, so single-key shortcuts do not steal typing |\n\ + | `List` | every panel, picker and dialog that owns a row selection |\n\ + | a view name | the panel, overlay or dialog of that name — see the tables above |\n\ + \n\ + Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. \ + `!TextInput` is false whenever a text field is anywhere on the focus \ + path, which is why the vim-style letters carry it and the arrow keys \ + do not.\n" + ); + + out.push_str(&shadowing_section()); + + out +} + +/// Renders the "Where a panel wins a keystroke" section. +/// +/// Derived from the same analysis the shortcut reference shows on the affected +/// row, so the page cannot claim a keystroke works somewhere it does not. +fn shadowing_section() -> String { + let specs = super::loader::default_specs(); + let shadows = super::shadow::detect_shadowing(&specs); + + let mut out = String::from("\n## Where a panel wins a keystroke\n\n"); + out.push_str( + "The deepest match wins, so a few of the shortcuts above cannot be reached while a \ + particular panel has focus. That is intended — the alternative would be a panel \ + unable to give a letter its own meaning. Both bindings stay active; only one of them \ + is what the keystroke does in that panel.\n\n", + ); + + if shadows.is_empty() { + out.push_str("The defaults currently have no such overlaps.\n"); + return out; + } + + out.push_str("| Keystroke | Runs | While focused | So this is out of reach |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + for shadow in &shadows { + let node = super::registry::context_node(shadow.context); + let _ = writeln!( + out, + "| `{}` | `{}` | {} | `{}` |", + table_cell(&specs[shadow.inner].keystrokes), + specs[shadow.inner].action, + node.map_or(shadow.context, |node| node.label), + specs[shadow.outer].action, + ); + } + out +} + +/// Renders `docs/keymap.schema.json`. +pub fn keymap_json_schema() -> String { + let action_names: Vec = std::iter::once(json_action_name( + super::conflict::NO_ACTION, + "Remove the binding this keystroke would otherwise have.", + )) + .chain(ALL_COMMANDS.iter().map(|meta| { + json_action_name( + meta.action_name, + &format!("{} Command id: `{}`.", meta.description(), meta.id.as_str()), + ) + })) + .collect(); + + let schema = serde_json::json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/noahbclarkson/rgitui/main/docs/keymap.schema.json", + "title": "rgitui keymap", + "description": GENERATED_NOTICE, + "type": "array", + "items": { "$ref": "#/$defs/section" }, + "$defs": { + "section": { + "type": "object", + "additionalProperties": false, + "properties": { + "context": { + "type": "string", + "description": + "When these bindings are active, e.g. `Workspace && !modal`. \ + Combine identifiers with `&&`, `||` and `!`; `>` matches a \ + descendant. Omit to bind everywhere.", + }, + "use_key_equivalents": { + "type": "boolean", + "description": + "Interpret keystrokes by their position on a QWERTY keyboard. \ + macOS only.", + "default": false, + }, + "bindings": { + "type": "object", + "description": + "Keystrokes to actions. A keystroke is modifiers then a key joined \ + by `-` (`secondary-shift-p`); separate the keystrokes of a chord \ + with spaces (`ctrl-k ctrl-o`). Later entries win.", + "additionalProperties": { "$ref": "#/$defs/action" }, + }, + }, + }, + "action": { + "description": + "An action name, a two-element `[name, input]` array, or `null` to unbind.", + "oneOf": [ + { "$ref": "#/$defs/actionName" }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [{ "$ref": "#/$defs/actionName" }, true], + }, + { "type": "null", "description": "Remove this binding." }, + ], + }, + "actionName": { + "type": "string", + "anyOf": action_names, + }, + }, + }); + + let mut out = serde_json::to_string_pretty(&schema).expect("the schema is serializable"); + out.push('\n'); + out +} + +/// One `anyOf` branch pinning a single action name, carrying its description so +/// editors show it during completion. +fn json_action_name(name: &str, description: &str) -> serde_json::Value { + serde_json::json!({ "const": name, "description": description.trim() }) +} + +/// Repository-relative path of the generated keybinding reference. +#[cfg(test)] +const MARKDOWN_PATH: &str = "docs/KEYBINDINGS.md"; +/// Repository-relative path of the generated keymap schema. +#[cfg(test)] +const SCHEMA_PATH: &str = "docs/keymap.schema.json"; + +/// Setting this environment variable makes the golden tests rewrite the +/// committed artifacts instead of failing: +/// +/// ```text +/// RGITUI_BLESS=1 cargo test -p rgitui_workspace keymap::generate +/// ``` +/// +/// Re-run the tests afterwards to confirm the files now match. +#[cfg(test)] +const BLESS_ENV: &str = "RGITUI_BLESS"; + +/// Renders a minimal line diff so a stale golden file is easy to fix. +#[cfg(test)] +fn line_diff(expected: &str, actual: &str) -> String { + let expected: Vec<&str> = expected.lines().collect(); + let actual: Vec<&str> = actual.lines().collect(); + let mut out = String::new(); + for index in 0..expected.len().max(actual.len()) { + match (expected.get(index), actual.get(index)) { + (Some(left), Some(right)) if left == right => {} + (left, right) => { + let _ = writeln!(out, "line {}:", index + 1); + let _ = writeln!(out, " committed: {}", left.unwrap_or(&"")); + let _ = writeln!(out, " generated: {}", right.unwrap_or(&"")); + } + } + } + out +} + +/// Fails with a diff when a committed artifact no longer matches the registry. +/// +/// With [`BLESS_ENV`] set, rewrites the file instead so the artifacts can be +/// regenerated without a separate binary. +#[cfg(test)] +fn assert_golden(path: &str, committed: &str, generated: &str) { + if std::env::var_os(BLESS_ENV).is_some() { + let absolute = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path); + std::fs::write(&absolute, generated) + .unwrap_or_else(|error| panic!("could not write {}: {error}", absolute.display())); + return; + } + assert!( + committed == generated, + "{path} is stale — regenerate it with `{BLESS_ENV}=1 cargo test -p rgitui_workspace \ + keymap::generate`.\n\n{}", + line_diff(committed, generated) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keybindings_doc_is_up_to_date() { + assert_golden( + MARKDOWN_PATH, + include_str!("../../../../docs/KEYBINDINGS.md"), + &keybindings_markdown(), + ); + } + + #[test] + fn keymap_schema_is_up_to_date() { + assert_golden( + SCHEMA_PATH, + include_str!("../../../../docs/keymap.schema.json"), + &keymap_json_schema(), + ); + } + + /// A `||` context predicate must not split the table row it sits in. + #[test] + fn table_cells_escape_the_pipe_that_would_split_a_row() { + assert_eq!( + table_cell("Workspace || SettingsWindow"), + "Workspace \\|\\| SettingsWindow" + ); + + let markdown = keybindings_markdown(); + for line in markdown.lines().filter(|line| line.starts_with('|')) { + let without_escapes = line.replace("\\|", ""); + assert!( + !without_escapes.contains("||"), + "this table row contains an unescaped `||`, which splits it into empty cells: \ + {line}" + ); + } + } + + #[test] + fn the_doc_lists_every_command_exactly_once() { + // Only the per-view tables: the shadowing table further down names some + // commands again on purpose. + let markdown = keybindings_markdown(); + let tables = markdown + .split_once("\n## Key contexts") + .map_or(markdown.as_str(), |(tables, _)| tables); + for meta in ALL_COMMANDS { + let row = format!("| `{}` |", meta.action_name); + assert_eq!( + tables.matches(&row).count(), + 1, + "{} appears {} times in the command tables", + meta.action_name, + tables.matches(&row).count() + ); + } + } + + /// The shadowing table is only useful if it names real actions and real + /// contexts, so it is checked against the registry rather than eyeballed. + #[test] + fn the_shadowing_table_names_registry_actions() { + let section = shadowing_section(); + let rows: Vec<&str> = section + .lines() + .filter(|line| line.starts_with("| `")) + .collect(); + assert!(!rows.is_empty(), "{section}"); + + for row in rows { + let cells: Vec<&str> = row.trim_matches('|').split('|').map(str::trim).collect(); + let [_keystroke, inner, label, outer] = cells.as_slice() else { + panic!("a shadowing row has the wrong shape: {row}"); + }; + for action in [inner, outer] { + let name = action.trim_matches('`'); + assert!( + super::super::registry::command_for_action(name).is_some(), + "`{name}` in the shadowing table is not a registry action" + ); + } + assert!( + super::super::registry::CONTEXT_TREE + .iter() + .any(|node| node.label == *label && !node.modal), + "`{label}` in the shadowing table is not a non-modal element label" + ); + } + } + + #[test] + fn the_schema_enumerates_every_action_name() { + let schema: serde_json::Value = + serde_json::from_str(&keymap_json_schema()).expect("generated schema is valid JSON"); + let names: Vec<&str> = schema["$defs"]["actionName"]["anyOf"] + .as_array() + .expect("anyOf is an array") + .iter() + .map(|branch| branch["const"].as_str().expect("const is a string")) + .collect(); + + assert!(names.contains(&super::super::conflict::NO_ACTION)); + for meta in ALL_COMMANDS { + assert!( + names.contains(&meta.action_name), + "{} is missing from the schema", + meta.action_name + ); + } + assert_eq!(names.len(), ALL_COMMANDS.len() + 1); + } + + #[test] + fn command_ids_are_discoverable_from_the_schema_descriptions() { + let schema = keymap_json_schema(); + for id in super::super::registry::CommandId::ALL { + let needle = format!("Command id: `{}`.", id.as_str()); + assert!(schema.contains(&needle), "{needle} is missing"); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/loader.rs b/crates/rgitui_workspace/src/keymap/loader.rs new file mode 100644 index 00000000..dfa79ed3 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/loader.rs @@ -0,0 +1,772 @@ +//! Reads `keymap.json` and turns it, together with the registry defaults, into +//! gpui key bindings. +//! +//! # File format +//! +//! Zed-shaped: an array of sections, each with a context predicate and a map of +//! keystrokes to action names. Comments and trailing commas are allowed +//! (parsed with `serde_json_lenient`, the same crate Zed uses). +//! +//! ```jsonc +//! [ +//! { +//! "context": "Workspace && !modal", +//! "bindings": { +//! // A plain action name. +//! "ctrl-alt-s": "rgitui::StageAll", +//! // An action name plus JSON input. +//! "ctrl-alt-r": ["rgitui::Refresh", {}], +//! // `null` removes the binding entirely. +//! "ctrl-s": null +//! } +//! } +//! ] +//! ``` +//! +//! # Precedence +//! +//! Defaults are collected first, then the user's file, and gpui prefers the +//! binding added last — so the user always wins. Anything ambiguous *within* +//! one source is reported by [`conflict::detect_conflicts`] and dropped rather +//! than silently shadowed. +//! +//! # Error handling +//! +//! Every problem is accumulated: one unparseable context predicate, unknown +//! action name or malformed keystroke does not discard the rest of the file. +//! The collected messages are returned so the workspace can toast them. + +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use gpui::{App, KeyBinding, KeyBindingContextPredicate, SharedString}; +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; + +use super::conflict::{self, BindingSource, BindingSpec, ConflictReport, NO_ACTION}; +use super::display::KeystrokeStyle; +use super::registry::ALL_COMMANDS; +use super::summary::KeymapSummary; + +/// A keystroke-to-action map that keeps the order the entries were written in. +/// +/// Order is load-bearing: within one section the last binding on a keystroke +/// wins, so the entries must reach gpui in file order. +#[derive(Debug, Default)] +struct OrderedBindings(Vec<(String, serde_json::Value)>); + +impl<'de> Deserialize<'de> for OrderedBindings { + fn deserialize>(deserializer: D) -> Result { + struct OrderedBindingsVisitor; + + impl<'de> Visitor<'de> for OrderedBindingsVisitor { + type Value = OrderedBindings; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a map of keystrokes to action names") + } + + fn visit_map>(self, mut map: M) -> Result { + let mut entries = Vec::with_capacity(map.size_hint().unwrap_or(0)); + while let Some((keystrokes, action)) = + map.next_entry::()? + { + entries.push((keystrokes, action)); + } + Ok(OrderedBindings(entries)) + } + } + + deserializer.deserialize_map(OrderedBindingsVisitor) + } +} + +/// One section of `keymap.json`. +#[derive(Debug, Default, Deserialize)] +struct KeymapSection { + /// Context predicate. Empty means the bindings apply everywhere. + #[serde(default)] + context: String, + /// Position-equivalent keystrokes for non-QWERTY layouts (macOS only). + #[serde(default)] + use_key_equivalents: bool, + /// Keystroke to action mapping, in file order. + #[serde(default)] + bindings: Option, +} + +/// A candidate binding plus the action input needed to build it. +#[derive(Debug, Clone)] +struct PendingBinding { + spec: BindingSpec, + input: Option, + use_key_equivalents: bool, +} + +/// The result of assembling the default and user keymaps. +#[derive(Debug, Default)] +pub struct LoadedKeymap { + /// Bindings to hand to [`gpui::App::bind_keys`], in precedence order. + pub bindings: Vec, + /// Problems the user should fix: parse errors, unknown actions, conflicts. + pub problems: Vec, + /// The same bindings, labelled for display. Everything the UI shows about a + /// shortcut is read from here, so no surface can drift from the keymap. + pub summary: KeymapSummary, +} + +/// Path of the user keymap, alongside `settings.json` in rgitui's config dir. +pub fn keymap_path() -> PathBuf { + rgitui_settings::keymap_path() +} + +/// The starter `keymap.json` written when the user first opens the file. +/// +/// The example bindings name real actions at their real default keystrokes, +/// taken from the registry, so the stub cannot advertise something that does not +/// exist. It is commented out, so writing it changes no binding. +pub fn keymap_stub() -> String { + let example = ALL_COMMANDS + .iter() + .find(|meta| !meta.default_bindings.is_empty()) + .expect("the registry binds at least one command"); + let (keystrokes, context) = example.default_bindings[0]; + + format!( + "// rgitui keybindings. Saving this file reloads them immediately.\n\ + //\n\ + // Every action name is listed in docs/KEYBINDINGS.md. For completion\n\ + // while editing, point your editor at docs/keymap.schema.json for this\n\ + // filename — the file itself is a JSON array, so it has nowhere to put\n\ + // a \"$schema\" key.\n\ + //\n\ + // `secondary` is the platform's primary modifier: cmd on macOS, ctrl\n\ + // elsewhere. A binding you add wins over the default it replaces.\n\ + [\n \ + {{\n \ + \"context\": \"{context}\",\n \ + \"bindings\": {{\n \ + // Bind an action to a keystroke.\n \ + // \"ctrl-alt-s\": \"{action}\",\n \ + // Remove a default binding.\n \ + // \"{keystrokes}\": null\n \ + }}\n \ + }}\n\ + ]\n", + action = example.action_name, + ) +} + +/// Makes sure `keymap.json` exists, creating it with [`keymap_stub`] if not. +/// +/// Returns the path either way, so the caller can hand it to an editor. +pub fn ensure_keymap_file() -> std::io::Result { + let path = keymap_path(); + if path.exists() { + return Ok(path); + } + if let Some(directory) = path.parent() { + std::fs::create_dir_all(directory)?; + } + std::fs::write(&path, keymap_stub())?; + Ok(path) +} + +/// The default bindings declared by `commands!`, in registry order. +/// +/// Pure, so the default set can be validated without an `App`. +pub fn default_specs() -> Vec { + ALL_COMMANDS + .iter() + .flat_map(|meta| { + meta.default_bindings.iter().map(|(keystrokes, context)| { + BindingSpec::default_binding(keystrokes, context, meta.action_name) + }) + }) + .collect() +} + +/// Parses `keymap.json` content into candidate bindings, accumulating errors. +/// +/// A section whose `context` does not parse is skipped, but the rest of the file +/// still loads. +fn parse_user_specs(content: &str) -> (Vec, Vec) { + let mut problems = Vec::new(); + + if content.trim().is_empty() { + return (Vec::new(), problems); + } + + let sections: Vec = match serde_json_lenient::from_str(content) { + Ok(sections) => sections, + Err(error) => { + problems.push(format!( + "keymap.json is not valid JSON, so no custom keybindings were loaded: {error}. \ + Fix the syntax error and save the file to reload." + )); + return (Vec::new(), problems); + } + }; + + let mut pending = Vec::new(); + for section in §ions { + let context = (!section.context.trim().is_empty()).then(|| section.context.clone()); + + if let Some(context) = context.as_deref() { + if let Err(error) = KeyBindingContextPredicate::parse(context) { + problems.push(format!( + "keymap.json: the context `{context}` could not be parsed, so that section \ + was skipped: {error}." + )); + continue; + } + } + + for (keystrokes, action) in section.bindings.iter().flat_map(|bindings| &bindings.0) { + match parse_action(action) { + Ok((name, input)) => pending.push(PendingBinding { + spec: BindingSpec { + keystrokes: keystrokes.clone(), + context: context.clone(), + action: name, + source: BindingSource::User, + }, + input, + use_key_equivalents: section.use_key_equivalents, + }), + Err(error) => problems.push(format!( + "keymap.json: the binding for `{keystrokes}` was skipped: {error}" + )), + } + } + } + + (pending, problems) +} + +/// Splits a `keymap.json` action value into an action name and optional input. +/// +/// `null` becomes gpui's `NoAction`, which removes the binding. +fn parse_action(action: &serde_json::Value) -> Result<(String, Option), String> { + match action { + serde_json::Value::Null => Ok((NO_ACTION.to_owned(), None)), + serde_json::Value::String(name) => Ok((name.clone(), None)), + serde_json::Value::Array(items) => { + let [serde_json::Value::String(name), input] = items.as_slice() else { + return Err( + "expected a two-element array of `[\"namespace::Action\", input]`.".to_owned(), + ); + }; + Ok((name.clone(), Some(input.clone()))) + } + other => Err(format!( + "expected an action name, a two-element `[name, input]` array, or `null`, \ + but found `{other}`." + )), + } +} + +/// Checks whether an action name (with its optional JSON input) can be built. +/// +/// Injected so the validation pass stays pure and testable without an `App`. +type ActionResolver<'a> = &'a dyn Fn(&str, Option<&serde_json::Value>) -> Result<(), String>; + +/// Which candidate bindings survive validation, and what went wrong with the rest. +#[derive(Debug, Default, PartialEq, Eq)] +struct BindingPlan { + /// Indices into the candidate list, in precedence order. + keep: Vec, + /// One message per rejected binding. + problems: Vec, +} + +/// Validates every candidate binding that survived conflict resolution. +/// +/// Pure apart from `resolve_action`. Each rejection is recorded and the +/// remaining candidates are still planned, so one bad entry never aborts the +/// load. +fn plan_bindings( + pending: &[PendingBinding], + report: &ConflictReport, + resolve_action: ActionResolver<'_>, +) -> BindingPlan { + let mut plan = BindingPlan::default(); + + for (index, entry) in pending.iter().enumerate() { + if !report.is_kept(index) { + continue; + } + + if let Err(error) = resolve_action(&entry.spec.action, entry.input.as_ref()) { + plan.problems.push(format!( + "{}: the binding for `{}` was skipped: {error} \ + See docs/KEYBINDINGS.md for the list of action names.", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + + if let Some(context) = entry.spec.context.as_deref() { + if let Err(error) = KeyBindingContextPredicate::parse(context) { + plan.problems.push(format!( + "{}: the context `{context}` could not be parsed, so the binding for `{}` \ + was skipped: {error}.", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + } + + if let Err(error) = parse_keystrokes(&entry.spec.keystrokes) { + plan.problems.push(format!( + "{}: the binding for `{}` was skipped: {error}", + entry.spec.source, entry.spec.keystrokes + )); + continue; + } + + plan.keep.push(index); + } + + plan +} + +/// Validates a whitespace-separated keystroke sequence. +fn parse_keystrokes(keystrokes: &str) -> Result<(), String> { + if keystrokes.split_whitespace().next().is_none() { + return Err("the keystroke is empty.".to_owned()); + } + for keystroke in keystrokes.split_whitespace() { + gpui::Keystroke::parse(keystroke).map_err(|error| error.to_string())?; + } + Ok(()) +} + +/// Assembles the default bindings and the given user keymap content. +/// +/// Exposed separately from [`load`] so tests can drive it with inline content. +pub fn load_from_content(content: &str, cx: &App) -> LoadedKeymap { + let (user_pending, mut problems) = parse_user_specs(content); + + let mut pending: Vec = default_specs() + .into_iter() + .map(|spec| PendingBinding { + spec, + input: None, + use_key_equivalents: false, + }) + .collect(); + pending.extend(user_pending); + + let specs: Vec = pending.iter().map(|entry| entry.spec.clone()).collect(); + let report = conflict::detect_conflicts(&specs); + problems.extend(report.messages()); + + let plan = plan_bindings(&pending, &report, &|name, input| { + cx.build_action(name, input.cloned()) + .map(|_| ()) + .map_err(|error| format!("{error}.")) + }); + problems.extend(plan.problems); + + // Derived from the specs that are about to be bound, so the labels the UI + // shows and the bindings gpui matches cannot disagree. + let summary = KeymapSummary::build(&specs, &plan.keep, &report, KeystrokeStyle::platform()); + + let bindings = build_bindings(&pending, &plan.keep, &mut problems, cx); + LoadedKeymap { + bindings, + problems, + summary, + } +} + +/// Constructs the gpui bindings for the planned candidates. +fn build_bindings( + pending: &[PendingBinding], + keep: &[usize], + problems: &mut Vec, + cx: &App, +) -> Vec { + let mut bindings = Vec::with_capacity(keep.len()); + + for entry in keep.iter().map(|&index| &pending[index]) { + // `plan_bindings` already proved the name, context and keystrokes are good. + let Ok(action) = cx.build_action(&entry.spec.action, entry.input.clone()) else { + continue; + }; + let context = match entry.spec.context.as_deref() { + Some(context) => KeyBindingContextPredicate::parse(context).ok().map(Rc::new), + None => None, + }; + let input = entry + .input + .as_ref() + .map(|input| SharedString::from(input.to_string())); + + match KeyBinding::load( + &entry.spec.keystrokes, + action, + context, + entry.use_key_equivalents, + input, + cx.keyboard_mapper().as_ref(), + ) { + Ok(binding) => bindings.push(binding), + Err(error) => problems.push(format!( + "{}: the binding for `{}` was skipped: {error}", + entry.spec.source, entry.spec.keystrokes + )), + } + } + + bindings +} + +/// Reads the user keymap from disk, returning empty content when absent. +fn read_user_keymap(path: &Path) -> (String, Vec) { + match std::fs::read_to_string(path) { + Ok(content) => (content, Vec::new()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => (String::new(), Vec::new()), + Err(error) => ( + String::new(), + vec![format!( + "Could not read {}: {error}. Custom keybindings were not applied.", + path.display() + )], + ), + } +} + +/// Loads the defaults plus the user's `keymap.json` from disk. +pub fn load(cx: &App) -> LoadedKeymap { + let (content, mut problems) = read_user_keymap(&keymap_path()); + let mut loaded = load_from_content(&content, cx); + problems.extend(loaded.problems); + loaded.problems = problems; + loaded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_default_keystroke_parses() { + for spec in default_specs() { + for keystroke in spec.keystrokes.split_whitespace() { + assert!( + gpui::Keystroke::parse(keystroke).is_ok(), + "default keystroke `{keystroke}` for {} does not parse", + spec.action + ); + } + } + } + + #[test] + fn every_default_context_parses() { + for spec in default_specs() { + let context = spec.context.as_deref().expect("defaults declare a context"); + assert!( + KeyBindingContextPredicate::parse(context).is_ok(), + "default context `{context}` for {} does not parse", + spec.action + ); + } + } + + /// The stub is written into `keymap.json`, so it must load cleanly — an + /// example that does not parse would greet the user with an error toast. + #[test] + fn the_starter_keymap_parses_and_binds_nothing() { + let stub = keymap_stub(); + let (pending, problems) = parse_user_specs(&stub); + assert!(problems.is_empty(), "{problems:?}"); + assert!( + pending.is_empty(), + "the starter keymap must leave every binding at its default: {pending:?}" + ); + // The commented examples name a real action and a real default keystroke. + let example = ALL_COMMANDS + .iter() + .find(|meta| !meta.default_bindings.is_empty()) + .expect("the registry binds at least one command"); + assert!(stub.contains(example.action_name), "{stub}"); + assert!(stub.contains(example.default_bindings[0].0), "{stub}"); + } + + #[test] + fn the_default_keymap_has_no_conflicts() { + let report = conflict::detect_conflicts(&default_specs()); + assert!( + report.conflicts.is_empty(), + "default keymap has conflicts:\n{}", + report.messages().join("\n") + ); + } + + #[test] + fn parses_jsonc_with_comments_and_trailing_commas() { + let content = r#" + [ + // A section. + { + "context": "Workspace", + "bindings": { + "ctrl-alt-s": "rgitui::StageAll", /* inline */ + }, + }, + ] + "#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::StageAll"); + assert_eq!(pending[0].spec.context.as_deref(), Some("Workspace")); + } + + #[test] + fn bindings_keep_their_file_order() { + let content = r#"[{ "bindings": { + "ctrl-1": "rgitui::Fetch", + "ctrl-2": "rgitui::Pull", + "ctrl-3": "rgitui::Push" + }}]"#; + let (pending, _) = parse_user_specs(content); + let keystrokes: Vec<&str> = pending + .iter() + .map(|entry| entry.spec.keystrokes.as_str()) + .collect(); + assert_eq!(keystrokes, ["ctrl-1", "ctrl-2", "ctrl-3"]); + } + + #[test] + fn null_becomes_an_unbind() { + let content = r#"[{ "context": "Workspace", "bindings": { "ctrl-s": null } }]"#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, NO_ACTION); + } + + #[test] + fn an_action_may_carry_json_input() { + let content = r#"[{ "bindings": { "ctrl-s": ["rgitui::StageAll", { "all": true }] } }]"#; + let (pending, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + assert_eq!(pending[0].spec.action, "rgitui::StageAll"); + assert!(pending[0].input.is_some()); + } + + #[test] + fn a_user_binding_overrides_a_default_and_is_ordered_last() { + let content = r#"[{ + "context": "Workspace && !modal", + "bindings": { "secondary-s": "rgitui::Commit" } + }]"#; + let (user, problems) = parse_user_specs(content); + assert!(problems.is_empty(), "{problems:?}"); + + let mut specs = default_specs(); + let default_index = specs + .iter() + .position(|spec| spec.action == "rgitui::StageAll") + .expect("StageAll is bound by default"); + specs.extend(user.iter().map(|entry| entry.spec.clone())); + let user_index = specs.len() - 1; + + assert!( + default_index < user_index, + "the user binding must come last" + ); + let report = conflict::detect_conflicts(&specs); + assert!( + report.conflicts.is_empty(), + "overriding a default must not be reported: {:?}", + report.messages() + ); + assert!(report.is_kept(default_index)); + assert!(report.is_kept(user_index)); + } + + #[test] + fn a_bad_section_context_does_not_discard_the_rest_of_the_file() { + let content = r#"[ + { "context": "Workspace &&", "bindings": { "ctrl-1": "rgitui::Fetch" } }, + { "context": "Workspace", "bindings": { "ctrl-2": "rgitui::Pull" } } + ]"#; + let (pending, problems) = parse_user_specs(content); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("could not be parsed"), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::Pull"); + } + + #[test] + fn a_malformed_action_value_does_not_discard_the_rest_of_the_section() { + let content = r#"[{ "bindings": { + "ctrl-1": 42, + "ctrl-2": "rgitui::Pull" + }}]"#; + let (pending, problems) = parse_user_specs(content); + assert_eq!(problems.len(), 1, "{problems:?}"); + assert!(problems[0].contains("ctrl-1"), "{problems:?}"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].spec.action, "rgitui::Pull"); + } + + #[test] + fn a_syntax_error_is_reported_once() { + let (pending, problems) = parse_user_specs("[{ \"context\": }]"); + assert!(pending.is_empty()); + assert_eq!(problems.len(), 1); + assert!(problems[0].contains("not valid JSON"), "{problems:?}"); + } + + #[test] + fn an_empty_file_loads_the_defaults_only() { + let (pending, problems) = parse_user_specs(" \n "); + assert!(pending.is_empty()); + assert!(problems.is_empty()); + } + + /// Stands in for `App::build_action`: every registry action name resolves, + /// plus `zed::NoAction`. + fn resolve_known_actions(name: &str, _input: Option<&serde_json::Value>) -> Result<(), String> { + let known = name == NO_ACTION || ALL_COMMANDS.iter().any(|meta| meta.action_name == name); + if known { + Ok(()) + } else { + Err(format!("didn't find an action named `{name}`.")) + } + } + + fn plan(content: &str) -> BindingPlan { + let (user, _) = parse_user_specs(content); + let mut pending: Vec = default_specs() + .into_iter() + .map(|spec| PendingBinding { + spec, + input: None, + use_key_equivalents: false, + }) + .collect(); + pending.extend(user); + let specs: Vec = pending.iter().map(|entry| entry.spec.clone()).collect(); + let report = conflict::detect_conflicts(&specs); + plan_bindings(&pending, &report, &resolve_known_actions) + } + + #[test] + fn every_default_binding_survives_planning() { + let plan = plan(""); + assert!(plan.problems.is_empty(), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len()); + } + + /// The planning tests above substitute [`resolve_known_actions`] for + /// `App::build_action`, so they would still pass if a generated action name + /// did not match what gpui actually registered — and every binding would + /// then fail to load at runtime, leaving the app with no shortcuts and a + /// toast per command. This drives the real resolver instead. + #[gpui::test] + fn the_defaults_load_against_the_real_action_registry(cx: &mut gpui::TestAppContext) { + let loaded = cx.update(|cx| load_from_content("", cx)); + + assert!( + loaded.problems.is_empty(), + "the shipped defaults do not load cleanly: {:?}", + loaded.problems + ); + assert_eq!( + loaded.bindings.len(), + default_specs().len(), + "some default bindings were dropped on the way to gpui" + ); + } + + /// A user binding naming an action gpui does not know must be reported and + /// skipped, not silently dropped along with the rest of the file. + #[gpui::test] + fn an_unknown_user_action_is_reported_by_the_real_resolver(cx: &mut gpui::TestAppContext) { + let loaded = cx.update(|cx| { + load_from_content( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-1": "rgitui::NoSuchCommand" + }}]"#, + cx, + ) + }); + + assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems); + assert!( + loaded.problems[0].contains("rgitui::NoSuchCommand"), + "{:?}", + loaded.problems + ); + assert_eq!( + loaded.bindings.len(), + default_specs().len(), + "the defaults must survive one bad user binding" + ); + } + + #[test] + fn an_unknown_action_name_is_reported_and_the_load_continues() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-1": "rgitui::NoSuchCommand", + "ctrl-alt-2": "rgitui::Pull" + }}]"#, + ); + assert_eq!(plan.problems.len(), 1, "{:?}", plan.problems); + assert!( + plan.problems[0].contains("rgitui::NoSuchCommand"), + "{:?}", + plan.problems + ); + // The defaults plus the one good user binding. + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn an_unparseable_keystroke_is_reported_and_the_load_continues() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-a-b": "rgitui::Fetch", + "ctrl-alt-2": "rgitui::Pull" + }}]"#, + ); + assert_eq!(plan.problems.len(), 1, "{:?}", plan.problems); + assert!(plan.problems[0].contains("ctrl-a-b"), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn an_unbind_is_planned_so_gpui_can_apply_it() { + let plan = + plan(r#"[{ "context": "Workspace && !modal", "bindings": { "secondary-s": null } }]"#); + assert!(plan.problems.is_empty(), "{:?}", plan.problems); + assert_eq!(plan.keep.len(), default_specs().len() + 1); + } + + #[test] + fn a_conflicting_pair_of_user_bindings_drops_the_earlier_one() { + let plan = plan( + r#"[{ "context": "Workspace", "bindings": { + "ctrl-alt-9": "rgitui::Fetch" + }}, { "context": "Workspace", "bindings": { + "ctrl-alt-9": "rgitui::Pull" + }}]"#, + ); + // One of the two user bindings was dropped, so only one was added. + assert_eq!(plan.keep.len(), default_specs().len() + 1); + let last = *plan.keep.last().expect("at least one binding"); + assert_eq!( + last, + default_specs().len() + 1, + "the later binding must win" + ); + } +} diff --git a/crates/rgitui_workspace/src/keymap/macros.rs b/crates/rgitui_workspace/src/keymap/macros.rs new file mode 100644 index 00000000..97c92ab1 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/macros.rs @@ -0,0 +1,300 @@ +//! The `commands!` macro — the single source of truth for rgitui commands. +//! +//! One declaration per command produces, all at once: +//! +//! * a [`CommandId`](crate::CommandId) variant, +//! * a `gpui` [`Action`](gpui::Action) unit struct in the declared namespace, +//! * the canonical keymap name (`namespace::PascalCase` — gpui panics if an +//! action name contains `::` itself, so the namespace is the only separator), +//! * the default binding(s) — keystrokes paired with the key context each one +//! is scoped to, +//! * the command-palette availability predicate, +//! * whether the command is offered in the command palette at all, +//! * and the doc comment, reused as the description in `docs/KEYBINDINGS.md` +//! and in the `keymap.json` JSON schema. +//! +//! Plus, for the whole set, [`ALL_COMMANDS`](crate::keymap::ALL_COMMANDS) — +//! which drives binding, validation, doc generation and the shortcuts UI. +//! +//! # Syntax +//! +//! ```ignore +//! commands! { +//! view Workspace in rgitui context "Workspace && !modal" { +//! /// Toggle the command palette. +//! CommandPalette "secondary-shift-p" in "Workspace" [hidden]; +//! /// Stage every change in the working tree. +//! StageAll "secondary-s" if has_changes; +//! /// Unstage everything. +//! UnstageAll ["secondary-shift-s", "secondary-u"] if has_changes; +//! /// Move the selection down. +//! SelectNext ["down", "j" in "List && !TextInput"] in "List"; +//! /// Pull from the tracked remote. +//! Pull unbound if has_remotes; +//! } +//! } +//! ``` +//! +//! * `view in context ""` opens a block. `` +//! groups the commands in the generated docs; `context` is the default key +//! context for every binding in the block. +//! * The keystroke slot takes one string, a bracketed list of strings (several +//! default keystrokes for one command), or the bare word `unbound` for a +//! command that is reachable only from the palette. +//! * `in ""` overrides the block's key context. Written after the +//! keystroke slot it applies to the whole command; written after one keystroke +//! *inside* the bracketed list it applies to that keystroke alone. The latter +//! is what lets `down` stay live inside a text field while the vim-style `j` +//! for the same command stands down (`!TextInput`). +//! * `if ` names a `fn(CommandContext) -> bool` in scope; +//! the default is `always_show`. +//! * `[hidden]` keeps the command out of the command palette. + +/// Picks the more specific of two key contexts: the override if one was +/// written, otherwise the inherited default. +macro_rules! command_context { + ($inherited:literal) => { + $inherited + }; + ($inherited:literal, $override:literal) => { + $override + }; +} + +/// Expands the keystroke slot of a `commands!` entry into the command's default +/// bindings: `&'static [(keystrokes, key context)]`. +/// +/// The leading argument(s) are the inherited context — the `view` block default, +/// then the per-command `in "..."` when one was written — and each keystroke may +/// narrow it further with its own `in "..."`. The two arities are spelled out +/// separately because `macro_rules!` cannot expand a repetition of the inherited +/// context inside the repetition over keystrokes. +macro_rules! command_bindings { + ($inherited:literal, unbound) => { + &[] as &[(&'static str, &'static str)] + }; + ($inherited:literal, [$($keystroke:literal $(in $context:literal)?),* $(,)?]) => { + &[$( + ($keystroke, command_context!($inherited $(, $context)?)), + )*] as &[(&'static str, &'static str)] + }; + ($inherited:literal, $keystroke:literal) => { + &[($keystroke, $inherited)] as &[(&'static str, &'static str)] + }; + ($view:literal, $command:literal, unbound) => { + &[] as &[(&'static str, &'static str)] + }; + ( + $view:literal, $command:literal, + [$($keystroke:literal $(in $context:literal)?),* $(,)?] + ) => { + &[$( + ($keystroke, command_context!($command $(, $context)?)), + )*] as &[(&'static str, &'static str)] + }; + ($view:literal, $command:literal, $keystroke:literal) => { + &[($keystroke, $command)] as &[(&'static str, &'static str)] + }; +} + +/// Resolves a command's palette availability predicate. +macro_rules! command_predicate { + () => { + $crate::command_palette::always_show + }; + ($predicate:ident) => { + $predicate + }; +} + +/// Resolves whether a command is offered in the command palette. +/// +/// Only the literal marker `[hidden]` is accepted, so a typo is a compile error +/// rather than a silently ignored flag. +macro_rules! command_in_palette { + () => { + true + }; + ([hidden]) => { + false + }; +} + +/// Number of bytes a PascalCase identifier occupies once converted to snake_case. +pub(crate) const fn snake_case_len(name: &str) -> usize { + let bytes = name.as_bytes(); + let mut len = 0usize; + let mut i = 0usize; + while i < bytes.len() { + if bytes[i].is_ascii_uppercase() && i > 0 { + len += 1; + } + len += 1; + i += 1; + } + len +} + +/// Converts a PascalCase identifier to snake_case at compile time. +/// +/// `N` must equal [`snake_case_len`] of `name`; [`command_id_str`] computes it. +pub(crate) const fn snake_case_bytes(name: &str) -> [u8; N] { + let bytes = name.as_bytes(); + let mut out = [0u8; N]; + let mut i = 0usize; + let mut out_i = 0usize; + while i < bytes.len() { + let byte = bytes[i]; + if byte.is_ascii_uppercase() { + if i > 0 { + out[out_i] = b'_'; + out_i += 1; + } + out[out_i] = byte.to_ascii_lowercase(); + } else { + out[out_i] = byte; + } + out_i += 1; + i += 1; + } + out +} + +/// Compile-time snake_case rendering of a command variant name. +/// +/// These strings are the stable on-disk identifiers used by the command palette +/// and by settings, so they must never drift from the historical hand-written +/// values — `command_id_strings_are_stable` in `registry.rs` pins all of them. +macro_rules! command_id_str { + ($name:ident) => {{ + const SOURCE: &str = stringify!($name); + const LEN: usize = $crate::keymap::macros::snake_case_len(SOURCE); + const BYTES: &[u8; LEN] = &$crate::keymap::macros::snake_case_bytes::(SOURCE); + match ::core::str::from_utf8(BYTES) { + Ok(id) => id, + // Unreachable: the input is an ASCII Rust identifier. + Err(_) => panic!("command id is not valid UTF-8"), + } + }}; +} + +/// Declares every rgitui command. See the [module docs](self) for the syntax. +macro_rules! commands { + ( + $( + view $view:ident in $namespace:ident context $view_context:literal { + $( + $(#[doc = $doc:literal])* + $name:ident $keystrokes:tt + $(in $context:literal)? + $(if $predicate:ident)? + $([$hidden:ident])? + ; + )* + } + )* + ) => { + /// Every user-invokable rgitui command. + /// + /// Generated by [`commands!`]. [`CommandId::as_str`] is the stable + /// identifier used on disk; the gpui action name is + /// [`CommandId::action_name`]. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum CommandId { + $($( + $(#[doc = $doc])* + $name, + )*)* + } + + /// The gpui [`Action`](gpui::Action) structs bound by the keymap. + /// + /// One unit struct per command, registered with gpui under + /// `namespace::Name` so it can be named from `keymap.json`. + pub mod actions { + $($( + $(#[doc = $doc])* + #[derive(Clone, PartialEq, Default, Debug, gpui::Action)] + #[action(namespace = $namespace)] + pub struct $name; + )*)* + } + + impl CommandId { + /// Every command, in declaration order. + pub const ALL: &'static [CommandId] = &[ + $($( CommandId::$name, )*)* + ]; + + /// The stable snake_case identifier for this command. + /// + /// Persisted in settings and matched by the command palette, so + /// these strings are frozen; see `command_id_strings_are_stable`. + pub const fn as_str(self) -> &'static str { + match self { + $($( CommandId::$name => command_id_str!($name), )*)* + } + } + + /// The gpui action name used to refer to this command from `keymap.json`. + pub const fn action_name(self) -> &'static str { + match self { + $($( + CommandId::$name => + concat!(stringify!($namespace), "::", stringify!($name)), + )*)* + } + } + } + + /// Static metadata for every command, in declaration order. + /// + /// Drives default key binding, keymap validation, `docs/KEYBINDINGS.md`, + /// the `keymap.json` schema and the shortcuts UI. + pub const ALL_COMMANDS: &[CommandMeta] = &[ + $($( + CommandMeta { + id: CommandId::$name, + view: stringify!($view), + namespace: stringify!($namespace), + action_name: concat!(stringify!($namespace), "::", stringify!($name)), + default_bindings: command_bindings!( + $view_context $(, $context)?, $keystrokes + ), + description: concat!("" $(, $doc)*), + in_palette: command_in_palette!($([$hidden])?), + availability: command_predicate!($($predicate)?), + }, + )*)* + ]; + + /// Registers one `on_action` handler per command belonging to `view`. + /// + /// Every handler forwards its [`CommandId`] to `dispatch`, so the view + /// keeps a single entry point for keyboard-invoked commands. + pub fn attach_actions( + element: E, + view: &str, + cx: &mut gpui::Context, + dispatch: fn(&mut V, CommandId, &mut gpui::Window, &mut gpui::Context), + ) -> E + where + E: gpui::InteractiveElement, + V: 'static, + { + let mut element = element; + $( + if view == stringify!($view) { + $( + element = element.on_action(cx.listener( + move |view, _: &actions::$name, window, cx| { + dispatch(view, CommandId::$name, window, cx); + }, + )); + )* + } + )* + element + } + }; +} diff --git a/crates/rgitui_workspace/src/keymap/mod.rs b/crates/rgitui_workspace/src/keymap/mod.rs new file mode 100644 index 00000000..9e5f2996 --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/mod.rs @@ -0,0 +1,293 @@ +//! User-definable keybindings. +//! +//! * [`macros`] defines `commands!`, the single source of truth. +//! * [`registry`] declares every command with it, producing [`CommandId`], the +//! gpui action structs and [`ALL_COMMANDS`]. +//! * [`conflict`] detects ambiguous bindings before gpui silently resolves them. +//! * [`shadow`] reports, informationally, where a panel binding wins a keystroke +//! from a global one — legitimate scoping, but worth knowing about. +//! * [`loader`] reads `keymap.json` and assembles the final binding list. +//! * [`display`] renders a keystroke the way the user reads it. +//! * [`summary`] records what the load produced — the effective binding of every +//! command, where it came from and what went wrong — and is what every +//! shortcut shown anywhere in the UI is read from. +//! * [`generate`] renders the committed `docs/KEYBINDINGS.md` and +//! `docs/keymap.schema.json`. +//! +//! [`init`] wires it up: it applies the bindings and watches `keymap.json` so +//! saving the file reloads it. Reload outcomes land in [`KeymapState`], which the +//! workspace observes in order to toast problems. + +#[macro_use] +pub(crate) mod macros; + +pub mod conflict; +pub mod display; +pub mod generate; +pub mod loader; +pub mod registry; +pub mod shadow; +pub mod summary; + +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +use gpui::{App, Global}; + +pub use display::{humanize_sequence, KeystrokeStyle}; +pub use loader::{ensure_keymap_file, keymap_path, keymap_stub}; +pub use registry::{actions, attach_actions, CommandId, CommandMeta, ALL_COMMANDS}; +pub use summary::{ + CommandBindings, CommandGroup, EffectiveBinding, KeymapNote, KeymapSummary, NoteSeverity, +}; + +/// Declares a view's key context and attaches the commands it handles. +/// +/// `key_context` is the space-separated identifier list gpui matches binding +/// contexts against — a view's own name plus any group it joins, e.g. +/// `"BlameView List"`. `views` names the `commands!` blocks whose `on_action` +/// handlers to install, typically the shared `"Menu"` block plus the view's own. +/// +/// Every handler funnels into `dispatch`, so a view keeps one entry point for +/// keyboard-invoked commands. gpui stops propagating an action at the first +/// handler it reaches walking outwards from the focused element, which is what +/// makes `Esc`, `Enter` and `j`/`k` mean the right thing per view without any +/// `if focused` checks. +pub fn bind_actions( + element: E, + key_context: &'static str, + views: &[&'static str], + cx: &mut gpui::Context, + dispatch: fn(&mut V, CommandId, &mut gpui::Window, &mut gpui::Context), +) -> E +where + E: gpui::InteractiveElement, + V: 'static, +{ + let mut element = element.key_context(key_context); + for view in views { + element = attach_actions(element, view, cx, dispatch); + } + element +} + +/// Debounce applied after a `keymap.json` change before reloading, so an editor +/// writing the file in several steps triggers one reload. +const RELOAD_DEBOUNCE: Duration = Duration::from_millis(200); + +/// The outcome of the most recent keymap load. +/// +/// A gpui [`Global`], so the workspace can `observe_global` it and surface +/// problems as toasts — including for reloads that happen long after startup. +pub struct KeymapState { + /// Problems from the most recent load, ready to show to the user. + pub problems: Vec, + /// Number of bindings currently applied. + pub binding_count: usize, + /// Incremented on every load, so observers can tell reloads apart. + pub generation: usize, + /// The bindings in force, labelled for display. Read at render time by every + /// surface that shows a shortcut; `Arc` because those reads happen while the + /// rest of the app state is already borrowed. + pub summary: Arc, +} + +impl Global for KeymapState {} + +impl KeymapState { + /// Takes the pending problems, leaving the state empty. + pub fn take_problems(&mut self) -> Vec { + std::mem::take(&mut self.problems) + } + + /// Whether there is anything left to report. + /// + /// The workspace observes this global and reports problems by *taking* them, + /// but taking goes through `update_global`, which notifies global observers + /// again. Checking this first — and doing nothing when it is false — is what + /// stops that from being an endless cycle, so + /// `take_problems()` must always leave it `false`. + pub fn has_problems(&self) -> bool { + !self.problems.is_empty() + } +} + +/// The bindings in force, for rendering a shortcut anywhere in the UI. +/// +/// Falls back to the registry defaults when the keymap has not loaded yet, so a +/// caller never has to decide what to show in that case. +pub fn summary(cx: &App) -> Arc { + cx.try_global::() + .map(|state| state.summary.clone()) + .unwrap_or_else(summary::fallback) +} + +/// The humanised keystrokes for a command, or `None` when it is unbound. +/// +/// The one accessor every shortcut hint in the UI goes through — see +/// [`summary`] for why a literal would be a lie. +pub fn shortcut(id: CommandId, cx: &App) -> Option { + summary(cx).display(id) +} + +/// A tooltip captioned `text` and annotated with the command's current keystroke. +/// +/// The keystroke is looked up when the tooltip is built rather than when the +/// button is, so it follows a `keymap.json` reload without the button having to +/// re-render. An unbound command gets a plain tooltip instead of an empty chip. +pub fn command_tooltip( + text: impl Into, + id: CommandId, +) -> impl Fn(&mut gpui::Window, &mut App) -> gpui::AnyView { + let text = text.into(); + move |window, cx| match shortcut(id, cx) { + Some(keystrokes) => rgitui_ui::Tooltip::with_shortcut(text.clone(), keystrokes)(window, cx), + None => rgitui_ui::Tooltip::text(text.clone())(window, cx), + } +} + +/// Keeps the `keymap.json` watcher alive for the lifetime of the app. +struct KeymapWatcher { + /// Never read: the watcher stops delivering notifications when dropped, so + /// this field exists purely to keep it alive as long as the app is. + _watcher: Box, +} + +impl Global for KeymapWatcher {} + +/// Applies the default bindings followed by the user's `keymap.json`. +/// +/// Every binding is cleared first, so this doubles as the reload path. Defaults +/// go in before user bindings because gpui prefers the binding added last. +pub fn reload(cx: &mut App) { + let loaded = loader::load(cx); + let binding_count = loaded.bindings.len(); + + cx.clear_key_bindings(); + cx.bind_keys(loaded.bindings); + + for problem in &loaded.problems { + log::warn!("keymap: {problem}"); + } + log::info!("keymap: applied {binding_count} key bindings"); + + let generation = cx + .try_global::() + .map_or(0, |state| state.generation + 1); + cx.set_global(KeymapState { + problems: loaded.problems, + binding_count, + generation, + summary: Arc::new(loaded.summary), + }); +} + +/// Loads the keymap and starts watching `keymap.json` for changes. +/// +/// Call once during startup, after settings are initialised (the config +/// directory must exist for the watcher to attach). +pub fn init(cx: &mut App) { + reload(cx); + watch_keymap_file(cx); +} + +/// Watches the config directory and reloads when `keymap.json` changes. +/// +/// The directory rather than the file is watched, so a keymap created after +/// startup — or replaced by an editor's atomic save — is still noticed. +fn watch_keymap_file(cx: &mut App) { + use notify::{RecursiveMode, Watcher as _}; + + let path = keymap_path(); + let Some(directory) = path.parent().map(PathBuf::from) else { + return; + }; + if let Err(error) = std::fs::create_dir_all(&directory) { + log::warn!( + "keymap: could not create {}, so keymap.json will not be watched: {error}", + directory.display() + ); + return; + } + + let (tx, rx) = async_channel::unbounded::<()>(); + let watched = path.clone(); + let watcher = notify::recommended_watcher(move |event: notify::Result| { + let Ok(event) = event else { + return; + }; + if event.paths.iter().any(|changed| changed == &watched) { + let _ = tx.try_send(()); + } + }); + + let mut watcher = match watcher { + Ok(watcher) => watcher, + Err(error) => { + log::warn!("keymap: could not start the keymap.json watcher: {error}"); + return; + } + }; + if let Err(error) = watcher.watch(&directory, RecursiveMode::NonRecursive) { + log::warn!( + "keymap: could not watch {}: {error}. Restart rgitui to pick up keymap.json changes.", + directory.display() + ); + return; + } + + cx.set_global(KeymapWatcher { + _watcher: Box::new(watcher), + }); + + cx.spawn(async move |cx: &mut gpui::AsyncApp| { + while rx.recv().await.is_ok() { + // Coalesce the burst of events an editor emits for one save. + cx.background_executor().timer(RELOAD_DEBOUNCE).await; + while rx.try_recv().is_ok() {} + cx.update(reload); + } + }) + .detach(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state_with_problems(problems: &[&str]) -> KeymapState { + KeymapState { + problems: problems.iter().map(|p| (*p).to_string()).collect(), + binding_count: 0, + generation: 1, + summary: Arc::new(KeymapSummary::default()), + } + } + + /// The workspace reports keymap problems from inside a global observer, and + /// reporting them goes through `update_global`, which notifies that same + /// observer again. Convergence rests entirely on taking the problems making + /// `has_problems` false, so the next pass returns without touching the + /// global. If this ever stops holding, the app spins at 100% CPU on startup + /// and on every keymap.json save. + #[test] + fn taking_the_problems_leaves_nothing_to_report() { + let mut state = state_with_problems(&["bad binding", "unknown action"]); + assert!(state.has_problems()); + + let taken = state.take_problems(); + + assert_eq!(taken.len(), 2); + assert!( + !state.has_problems(), + "take_problems must drain the state, or the observer cycle never ends" + ); + assert!(state.take_problems().is_empty()); + } + + #[test] + fn a_clean_load_has_nothing_to_report() { + assert!(!state_with_problems(&[]).has_problems()); + } +} diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs new file mode 100644 index 00000000..a51ac2ef --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -0,0 +1,1132 @@ +//! The command registry: one `commands!` declaration that defines every +//! rgitui command, its gpui action and its default key binding. +//! +//! # Key contexts +//! +//! `Workspace` is set on the workspace root element. The root adds `modal` +//! whenever an overlay or dialog is open, so `Workspace && !modal` is the +//! scope for shortcuts that must not fire while a modal is up — it replaces +//! the hand-rolled `any_overlay_active` gate for the commands listed here. +//! `TextInput` is set by [`rgitui_ui::TextInput`], so `!TextInput` keeps +//! unmodified single-key shortcuts from stealing typed characters. +//! +//! Every panel, picker and dialog that owns a row selection also sets `List` +//! alongside its own name, e.g. `key_context("BlameView List")`. The shared +//! `menu` commands below are bound once against `List`, and each view registers +//! its own handler for them; gpui dispatches an action from the focused element +//! outwards and the first handler consumes it, so the view that owns the +//! selection is the one that moves. The same mechanism resolves `menu::Cancel` +//! and `menu::Confirm` — there is no cascade of `if visible` checks anywhere. +//! +//! # Views in other crates +//! +//! `GraphView` (`rgitui_graph`) and `DiffViewer` (`rgitui_diff`) sit *below* +//! this crate in the dependency graph, so they cannot name these actions. Their +//! commands are therefore declared here in the `graph` and `diff` namespaces and +//! handled on the workspace root, which is an ancestor of both on every dispatch +//! path; the views themselves only declare their key context. That also means +//! they do not join the `List` group: a shared `menu` command has to be handled +//! by the element that owns the selection, and these two cannot handle anything. + +// The `if ` clause of `commands!` resolves these by name at the +// invocation site; `always_show` is the default and is referenced through +// `$crate`, so it is not imported here. +use crate::command_palette::{ + has_changes, has_github_token, has_multi_commit_selection, has_remotes, has_staged, + has_stashes, in_progress_operation, is_bisecting, worktree_clean, CommandContext, +}; + +/// Static description of a single command. +/// +/// Generated by `commands!`; see [`ALL_COMMANDS`]. +pub struct CommandMeta { + /// The command this metadata describes. + pub id: CommandId, + /// The view whose `commands!` block declared it. Groups the generated docs. + pub view: &'static str, + /// The gpui action namespace. + pub namespace: &'static str, + /// The full gpui action name, as written in `keymap.json`. + pub action_name: &'static str, + /// Default bindings as `(keystrokes, key context)`. Empty when the command + /// has no default binding. One command may bind several keystrokes, each in + /// its own context — that is how `down` stays live inside a text field while + /// the vim-style `j` for the same command stands down. + pub default_bindings: &'static [(&'static str, &'static str)], + /// Doc comment from the declaration. Leading whitespace is not trimmed. + pub description: &'static str, + /// Whether the command is offered in the command palette. + pub in_palette: bool, + /// Command-palette availability predicate. + pub availability: fn(CommandContext) -> bool, +} + +impl CommandMeta { + /// The description with doc-comment padding removed. + pub fn description(&self) -> &'static str { + self.description.trim() + } +} + +commands! { + view Workspace in rgitui context "Workspace && !modal" { + /// Download objects and refs from the tracked remote. + Fetch "secondary-shift-r" if has_remotes; + /// Fetch from and integrate with the tracked remote branch. + Pull unbound if has_remotes; + /// Update the remote ref along with associated objects. + Push unbound if has_remotes; + /// Push every open repository. + PushAll unbound if has_remotes; + /// Pull every open repository. + PullAll unbound if has_remotes; + /// Overwrite the remote branch with the local one. + ForcePush unbound if has_remotes; + /// Commit the staged changes using the message in the commit panel. + Commit "secondary-enter" if has_staged; + /// Stage every change in the working tree. + StageAll "secondary-s" if has_changes; + /// Unstage everything currently staged. + UnstageAll ["secondary-shift-s", "secondary-u"] if has_changes; + /// Stash the working tree and index. + StashSave "secondary-z" if has_changes; + /// Apply the latest stash entry and drop it. + StashPop "secondary-shift-z" if has_stashes; + /// Apply the latest stash entry and keep it. + StashApply unbound if has_stashes; + /// Delete a stash entry. + StashDrop unbound if has_stashes; + /// Create a new branch. + CreateBranch "secondary-b"; + /// Delete a branch. + DeleteBranch unbound; + /// Rename a branch. + RenameBranch unbound; + /// Merge another branch into the current one. + MergeBranch unbound if worktree_clean; + /// Create a tag. + CreateTag unbound; + /// Create a linked worktree. + CreateWorktree unbound; + /// Open a pull request for the current branch. + CreatePr unbound if has_github_token; + /// Cherry-pick a commit onto the current branch. + CherryPick unbound if worktree_clean; + /// Revert a commit. + RevertCommit unbound if worktree_clean; + /// Start an interactive rebase. + InteractiveRebase unbound if worktree_clean; + /// Discard every uncommitted change. + DiscardAll unbound if has_changes; + /// Delete untracked files. + CleanUntracked unbound; + /// Reset the working tree and index to HEAD. + ResetHard unbound if has_changes; + /// Abort the merge, rebase, cherry-pick or revert in progress. + AbortOperation unbound if in_progress_operation; + /// Continue the merge, rebase, cherry-pick or revert in progress. + ContinueMerge unbound if in_progress_operation; + /// Switch the diff viewer between unified and side-by-side. + ToggleDiffMode "shift-d" in "Workspace && !modal && !TextInput"; + /// Search the commit graph. + Search ["secondary-f", "/" in "Workspace && !modal && !TextInput"]; + /// Generate a commit message with the configured AI provider. + AiMessage "secondary-g" if has_staged; + /// Reload the repository state from disk. + Refresh "f5"; + /// Open the settings window. + Settings "secondary-," in "Workspace"; + /// Open the repository picker. + OpenRepo "secondary-o" in "Workspace"; + // Control rather than the primary modifier: macOS reserves Cmd+H for + // Hide Application, so a `secondary-` binding would never fire there. + /// Close every tab and return to the workspace home screen. + WorkspaceHome "ctrl-h"; + /// Reopen the most recently saved workspace. + RestoreLastWorkspace unbound; + /// Show the keyboard shortcut reference. + Shortcuts "?" in "Workspace && !TextInput"; + /// Focus the sidebar to switch branches. + SwitchBranch "secondary-shift-b"; + /// Blame the selected file. + Blame unbound; + /// Undo the last git operation. + Undo unbound; + /// Show the commit history of the selected file. + FileHistory unbound; + /// Show the reflog. + Reflog unbound; + /// Show the submodule list. + Submodules unbound; + /// Show the bisect log. + Bisect unbound if is_bisecting; + /// Start a bisect session. + BisectStart unbound if worktree_clean; + /// Mark the current bisect commit as good. + BisectGood unbound if is_bisecting; + /// Mark the current bisect commit as bad. + BisectBad unbound if is_bisecting; + /// End the bisect session and restore HEAD. + BisectReset unbound if is_bisecting; + /// Skip the current bisect commit. + BisectSkip unbound if is_bisecting; + /// Search the contents of the working tree. + GlobalSearch "secondary-shift-f"; + /// Toggle the issues panel. + ToggleIssues "alt-5"; + /// Toggle the pull requests panel. + TogglePullRequests "alt-6"; + /// Toggle the branch health panel. + ToggleBranchHealth "alt-7"; + /// Toggle the stashes panel. + ToggleStashes "alt-8"; + /// Create a branch from a stash entry. + StashBranch unbound if has_stashes; + /// Open the theme editor. + OpenThemeEditor ["secondary-shift-t", "alt-9"] in "Workspace"; + /// Toggle the command palette. + CommandPalette "secondary-shift-p" in "Workspace" [hidden]; + // Control rather than the primary modifier: macOS reserves Cmd+Tab for + // the application switcher, and the WindowServer swallows it before the + // app sees it. Ctrl+Tab is also the native chord there. + /// Activate the next repository tab. + NextTab "ctrl-tab" [hidden]; + /// Activate the previous repository tab. + PrevTab "ctrl-shift-tab" [hidden]; + /// Close the active repository tab. + CloseTab "secondary-w" [hidden]; + /// Move keyboard focus to the sidebar. + FocusSidebar "alt-1" [hidden]; + /// Move keyboard focus to the commit graph. + FocusGraph "alt-2" [hidden]; + /// Move keyboard focus to the commit detail panel. + FocusDetailPanel "alt-3" [hidden]; + /// Move keyboard focus to the diff viewer. + FocusDiffViewer "alt-4" [hidden]; + /// Move keyboard focus to the next panel. + FocusNextPanel "tab" in "Workspace && !modal && !TextInput" [hidden]; + /// Move keyboard focus to the previous panel. + FocusPrevPanel "shift-tab" in "Workspace && !modal && !TextInput" [hidden]; + /// Narrow the detail panel. + ShrinkDetailPanel "secondary-[" [hidden]; + /// Widen the detail panel. + GrowDetailPanel "secondary-]" [hidden]; + /// Shorten the diff viewer. + ShrinkDiffViewer "secondary-up" [hidden]; + /// Heighten the diff viewer. + GrowDiffViewer "secondary-down" [hidden]; + /// Open keymap.json in your editor to rebind shortcuts. + OpenKeymap unbound; + } + + // Bound once and handled by whichever element owns the selection or the + // dismissal. See the module docs for how gpui resolves that. + view Menu in menu context "List" { + /// Dismiss the focused overlay, dialog, search or selection. + Cancel "escape" in "Workspace || SettingsWindow" [hidden]; + /// Activate the selected row, or submit the focused dialog. + Confirm ["enter" in "Workspace || SettingsWindow", "space" in "List && !TextInput"] + [hidden]; + /// Move the selection down one row. + SelectNext ["down", "j" in "List && !TextInput"] [hidden]; + /// Move the selection up one row. + SelectPrev ["up", "k" in "List && !TextInput"] [hidden]; + /// Move the selection to the first row. + SelectFirst ["home", "g" in "List && !TextInput"] [hidden]; + /// Move the selection to the last row. + SelectLast ["end", "shift-g" in "List && !TextInput"] [hidden]; + } + + view GraphView in graph context "GraphView && !modal && !TextInput" { + /// Select the next commit in the graph. + GraphSelectNext ["down" in "GraphView && !modal", "j"] [hidden]; + /// Select the previous commit in the graph. + GraphSelectPrev ["up" in "GraphView && !modal", "k"] [hidden]; + /// Select the newest commit in the graph. + GraphSelectFirst ["home" in "GraphView && !modal", "g"] [hidden]; + /// Select the oldest loaded commit in the graph. + GraphSelectLast ["end" in "GraphView && !modal", "shift-g"] [hidden]; + /// Add the next commit in the graph to the selection. + GraphExtendSelectionNext ["shift-down", "shift-j"] [hidden]; + /// Add the previous commit in the graph to the selection. + GraphExtendSelectionPrev ["shift-up", "shift-k"] [hidden]; + // Bare `s`, matching the rebase editor below where `s` already means + // squash. `secondary-shift-s` would be dispatched ahead of the + // workspace's `rgitui::UnstageAll`, taking that keystroke away from it + // whenever the graph held focus. + /// Squash the selected commits into the oldest of them. + SquashSelected "s" if has_multi_commit_selection [hidden]; + /// Close the graph search, or dismiss the graph context menu. + GraphCancel "escape" in "GraphView && !modal" [hidden]; + /// Copy the selected commit's SHA to the clipboard. + CopyCommitSha "y" [hidden]; + /// Copy the selected commit's message to the clipboard. + CopyCommitMessage "shift-c" [hidden]; + } + + // The diff viewer hosts no text field, but the bare letters below still carry + // `!TextInput` so that `bare_character_bindings_stand_down_for_text_input` + // holds for every binding in the registry without exceptions. + view DiffViewer in diff context "DiffViewer && !modal && !TextInput" { + /// Move the diff cursor down one row. + DiffSelectNext ["down", "j"] [hidden]; + /// Move the diff cursor up one row. + DiffSelectPrev ["up", "k"] [hidden]; + /// Move the diff cursor to the first row. + DiffSelectFirst ["home", "g"] [hidden]; + /// Move the diff cursor to the last row. + DiffSelectLast ["end", "shift-g"] [hidden]; + /// Jump to the next hunk. + NextHunk "]" [hidden]; + /// Jump to the previous hunk. + PrevHunk "[" [hidden]; + /// Cycle the diff viewer's display mode. + ToggleDiffDisplayMode "d" [hidden]; + /// Toggle line-level selection in the diff viewer. + TogglePartialSelection "p" [hidden]; + /// Stage the hunks or lines under the diff selection. + StageSelection ["s", "shift-s"] [hidden]; + /// Unstage the hunks or lines under the diff selection. + UnstageSelection ["u", "shift-u"] [hidden]; + /// Stage the hunk under the diff cursor. + StageCurrentHunk "alt-s" [hidden]; + /// Unstage the hunk under the diff cursor. + UnstageCurrentHunk "alt-u" [hidden]; + /// Copy the selected diff lines to the clipboard. + CopyDiffSelection "secondary-c" [hidden]; + /// Select every line in the diff. + SelectAllDiffLines "secondary-a" [hidden]; + } + + view DetailPanel in detail context "DetailPanel && !modal && !TextInput" { + /// Switch the changed-files list between the flat and tree layouts. + ToggleFileTree "v" [hidden]; + /// Show the previous commit's details. + PrevCommitDetails "[" [hidden]; + /// Show the next commit's details. + NextCommitDetails "]" [hidden]; + /// Filter the changed-files list. + FileSearch ["/", "secondary-f" in "DetailPanel && !modal"] [hidden]; + } + + view Sidebar in sidebar context "Sidebar && !modal && !TextInput" { + /// Stage or unstage the selected file. + ToggleStageRow "s" [hidden]; + /// Discard the selected change, or delete the selected branch, tag or stash. + DiscardRow ["x", "delete" in "Sidebar && !modal"] [hidden]; + /// Filter the branch list. + FilterBranches ["/", "secondary-f" in "Sidebar && !modal"] [hidden]; + } + + view BlameView in blame context "BlameView && !modal && !TextInput" { + /// Leave the blame view and go back to the diff. + BlameShowDiff ["escape" in "BlameView && !modal", "d"] [hidden]; + /// Show the blamed file's commit history. + BlameShowHistory "h" [hidden]; + } + + view FileHistoryView in history context "FileHistoryView && !modal && !TextInput" { + /// Leave the file history and go back to the diff. + HistoryShowDiff ["escape" in "FileHistoryView && !modal", "d"] [hidden]; + /// Blame the file whose history is shown. + HistoryShowBlame "b" [hidden]; + } + + view InteractiveRebase in rebase context "InteractiveRebase && !TextInput" { + /// Move the selected commit earlier in the rebase plan. + RebaseMoveUp "secondary-up" in "InteractiveRebase" [hidden]; + /// Move the selected commit later in the rebase plan. + RebaseMoveDown "secondary-down" in "InteractiveRebase" [hidden]; + /// Keep the selected commit as it is. + RebasePick "p" [hidden]; + /// Reword the selected commit's message. + RebaseReword "r" [hidden]; + /// Squash the selected commit into the previous one. + RebaseSquash "s" [hidden]; + /// Squash the selected commit into the previous one, discarding its message. + RebaseFixup "f" [hidden]; + /// Drop the selected commit. + RebaseDrop "d" [hidden]; + } + + view ThemeEditor in theme context "ThemeEditor" { + /// Focus the next field in the theme editor. + ThemeEditorNextField "tab" [hidden]; + /// Focus the previous field in the theme editor. + ThemeEditorPrevField "shift-tab" [hidden]; + } + + view CreatePrDialog in pr context "CreatePrDialog" { + /// Open the pull request described in the dialog. + SubmitPullRequest "shift-enter" [hidden]; + } +} + +/// One element of the key-context tree — a `key_context(...)` call and where the +/// element carrying it sits. +/// +/// gpui evaluates a binding's context predicate against every prefix of the path +/// from the window root down to the focused element and keeps the *deepest* match +/// ([`gpui::KeyBindingContextPredicate::depth_of`]); the deepest match wins. A +/// binding scoped to a panel therefore takes a keystroke away from a global +/// binding whenever that panel has focus. +/// +/// `commands!` cannot see that: a `view` block says which context a binding is +/// scoped *to*, never where that context sits. So the shape is written out below +/// and [`super::shadow`] walks it to reconstruct the focus paths. +pub struct KeyContextNode { + /// The element's key context, verbatim as passed to `key_context`. The first + /// identifier names the element; any others are groups it joins, like `List`. + pub context: &'static str, + /// The enclosing element's [`Self::name`], or `None` for a window root. + pub parent: Option<&'static str>, + /// Whether the workspace root carries `modal` while this element is up, which + /// is what makes `Workspace && !modal` bindings stand down. Mirrors + /// `Workspace::any_overlay_active`, the gate that actually sets it. + pub modal: bool, + /// How to name the element in a message to the user, e.g. "the commit graph". + pub label: &'static str, +} + +impl KeyContextNode { + /// The identifier naming this element — the first one in [`Self::context`]. + pub fn name(&self) -> &'static str { + self.context + .split_whitespace() + .next() + // Unreachable: `context_tree_is_well_formed` rejects an empty context. + .expect("every node names its element") + } + + /// Every identifier this element's key context sets. + pub fn identifiers(&self) -> impl Iterator { + self.context.split_whitespace() + } +} + +/// The element tree the key contexts hang off, roots first. +/// +/// Hand-written because the `key_context` calls it mirrors are spread across +/// three crates and only run at render time, so there is nothing to derive it +/// from. `every_registry_context_is_in_the_tree` fails when a `commands!` block +/// names a context that is missing here, so a new view cannot slip past +/// [`super::shadow`]'s analysis unnoticed. +pub const CONTEXT_TREE: &[KeyContextNode] = &[ + // Window roots. Each owns its own dispatch path — a binding scoped to one is + // never reachable from the other, which is why `menu::Cancel` names both. + KeyContextNode { + context: "Workspace", + parent: None, + modal: false, + label: "the main window", + }, + KeyContextNode { + context: "SettingsWindow", + parent: None, + modal: false, + label: "the settings window", + }, + // Panels. Always present, so a binding of theirs competes with a global one. + KeyContextNode { + context: "Sidebar List", + parent: Some("Workspace"), + modal: false, + label: "the sidebar", + }, + KeyContextNode { + context: "GraphView", + parent: Some("Workspace"), + modal: false, + label: "the commit graph", + }, + KeyContextNode { + context: "DetailPanel List", + parent: Some("Workspace"), + modal: false, + label: "the commit detail panel", + }, + KeyContextNode { + context: "DiffViewer", + parent: Some("Workspace"), + modal: false, + label: "the diff viewer", + }, + KeyContextNode { + context: "BlameView List", + parent: Some("Workspace"), + modal: false, + label: "the blame view", + }, + KeyContextNode { + context: "FileHistoryView List", + parent: Some("Workspace"), + modal: false, + label: "the file history", + }, + KeyContextNode { + context: "ReflogView List", + parent: Some("Workspace"), + modal: false, + label: "the reflog", + }, + KeyContextNode { + context: "SubmoduleView List", + parent: Some("Workspace"), + modal: false, + label: "the submodule list", + }, + KeyContextNode { + context: "BisectView List", + parent: Some("Workspace"), + modal: false, + label: "the bisect log", + }, + KeyContextNode { + context: "IssuesPanel List", + parent: Some("Workspace"), + modal: false, + label: "the issues panel", + }, + KeyContextNode { + context: "PrsPanel List", + parent: Some("Workspace"), + modal: false, + label: "the pull requests panel", + }, + // Overlays and dialogs. `modal: true` marks the ones + // `Workspace::any_overlay_active` counts; the three that it does not are + // spelled out below, so this table stays a description of what the app does + // rather than of what it ought to do. + KeyContextNode { + context: "CommandPalette List", + parent: Some("Workspace"), + modal: true, + label: "the command palette", + }, + KeyContextNode { + context: "SearchPanel List", + parent: Some("Workspace"), + modal: true, + label: "the working-tree search", + }, + KeyContextNode { + context: "RepoOpener List", + parent: Some("Workspace"), + modal: true, + label: "the repository picker", + }, + KeyContextNode { + context: "ShortcutsHelp", + parent: Some("Workspace"), + modal: true, + label: "the shortcut reference", + }, + KeyContextNode { + context: "InteractiveRebase List", + parent: Some("Workspace"), + modal: true, + label: "the interactive rebase editor", + }, + KeyContextNode { + context: "ThemeEditor", + parent: Some("Workspace"), + modal: true, + label: "the theme editor", + }, + KeyContextNode { + context: "BranchDialog", + parent: Some("Workspace"), + modal: true, + label: "the branch dialog", + }, + KeyContextNode { + context: "TagDialog", + parent: Some("Workspace"), + modal: true, + label: "the tag dialog", + }, + KeyContextNode { + context: "WorktreeDialog", + parent: Some("Workspace"), + modal: true, + label: "the worktree dialog", + }, + KeyContextNode { + context: "RenameDialog", + parent: Some("Workspace"), + modal: true, + label: "the rename dialog", + }, + KeyContextNode { + context: "ConfirmDialog", + parent: Some("Workspace"), + modal: true, + label: "the confirmation dialog", + }, + KeyContextNode { + context: "StashBranchDialog", + parent: Some("Workspace"), + modal: true, + label: "the stash-branch dialog", + }, + // These three are not in `any_overlay_active`, so global shortcuts stay live + // behind them. + KeyContextNode { + context: "CreatePrDialog", + parent: Some("Workspace"), + modal: false, + label: "the pull request dialog", + }, + KeyContextNode { + context: "RepoCloneDialog", + parent: Some("Workspace"), + modal: false, + label: "the clone dialog", + }, + KeyContextNode { + context: "StashSaveDialog", + parent: Some("Workspace"), + modal: false, + label: "the stash dialog", + }, +]; + +/// Identifiers that flag an element rather than name a place in the tree, so they +/// are absent from [`CONTEXT_TREE`] on purpose. +/// +/// `modal` is added to the workspace root by whichever overlay is up — see +/// [`KeyContextNode::modal`]. `TextInput` is set by [`rgitui_ui::TextInput`], +/// which can appear under any of the nodes above. +pub const CONTEXT_MARKERS: &[&str] = &["modal", "TextInput"]; + +/// The node whose element sets `name`, if any. +pub fn context_node(name: &str) -> Option<&'static KeyContextNode> { + CONTEXT_TREE.iter().find(|node| node.name() == name) +} + +impl CommandId { + /// Static metadata for this command. + pub fn meta(self) -> &'static CommandMeta { + ALL_COMMANDS + .iter() + .find(|meta| meta.id == self) + // Unreachable: `ALL_COMMANDS` and `CommandId` are generated together. + .expect("every CommandId has metadata") + } + + /// This command's default bindings, or an empty slice when it has none. + pub fn default_bindings(self) -> &'static [(&'static str, &'static str)] { + self.meta().default_bindings + } + + /// A one-line description of what this command does. + pub fn description(self) -> &'static str { + self.meta().description() + } + + /// Whether this command is offered in the command palette. + pub fn in_palette(self) -> bool { + self.meta().in_palette + } + + /// The command-palette availability predicate for this command. + pub fn availability(self) -> fn(CommandContext) -> bool { + self.meta().availability + } +} + +impl std::fmt::Display for CommandId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TryFrom<&str> for CommandId { + type Error = (); + + fn try_from(value: &str) -> Result { + CommandId::ALL + .iter() + .copied() + .find(|id| id.as_str() == value) + .ok_or(()) + } +} + +/// Looks up a command by its gpui action name (`namespace::Name`). +pub fn command_for_action(action_name: &str) -> Option { + ALL_COMMANDS + .iter() + .find(|meta| meta.action_name == action_name) + .map(|meta| meta.id) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::KeyBindingContextPredicate; + use std::collections::HashSet; + + /// The historical `CommandId::as_str()` values. These are persisted and + /// matched by the command palette, so the generated snake_case conversion + /// must reproduce them byte for byte. + const LEGACY_IDS: &[(CommandId, &str)] = &[ + (CommandId::Fetch, "fetch"), + (CommandId::Pull, "pull"), + (CommandId::Push, "push"), + (CommandId::PushAll, "push_all"), + (CommandId::PullAll, "pull_all"), + (CommandId::ForcePush, "force_push"), + (CommandId::Commit, "commit"), + (CommandId::StageAll, "stage_all"), + (CommandId::UnstageAll, "unstage_all"), + (CommandId::StashSave, "stash_save"), + (CommandId::StashPop, "stash_pop"), + (CommandId::StashApply, "stash_apply"), + (CommandId::StashDrop, "stash_drop"), + (CommandId::CreateBranch, "create_branch"), + (CommandId::DeleteBranch, "delete_branch"), + (CommandId::RenameBranch, "rename_branch"), + (CommandId::MergeBranch, "merge_branch"), + (CommandId::CreateTag, "create_tag"), + (CommandId::CreateWorktree, "create_worktree"), + (CommandId::CreatePr, "create_pr"), + (CommandId::CherryPick, "cherry_pick"), + (CommandId::RevertCommit, "revert_commit"), + (CommandId::InteractiveRebase, "interactive_rebase"), + (CommandId::DiscardAll, "discard_all"), + (CommandId::CleanUntracked, "clean_untracked"), + (CommandId::ResetHard, "reset_hard"), + (CommandId::AbortOperation, "abort_operation"), + (CommandId::ContinueMerge, "continue_merge"), + (CommandId::ToggleDiffMode, "toggle_diff_mode"), + (CommandId::Search, "search"), + (CommandId::AiMessage, "ai_message"), + (CommandId::Refresh, "refresh"), + (CommandId::Settings, "settings"), + (CommandId::OpenRepo, "open_repo"), + (CommandId::WorkspaceHome, "workspace_home"), + (CommandId::RestoreLastWorkspace, "restore_last_workspace"), + (CommandId::Shortcuts, "shortcuts"), + (CommandId::SwitchBranch, "switch_branch"), + (CommandId::Blame, "blame"), + (CommandId::Undo, "undo"), + (CommandId::FileHistory, "file_history"), + (CommandId::Reflog, "reflog"), + (CommandId::Submodules, "submodules"), + (CommandId::Bisect, "bisect"), + (CommandId::BisectStart, "bisect_start"), + (CommandId::BisectGood, "bisect_good"), + (CommandId::BisectBad, "bisect_bad"), + (CommandId::BisectReset, "bisect_reset"), + (CommandId::BisectSkip, "bisect_skip"), + (CommandId::GlobalSearch, "global_search"), + (CommandId::ToggleIssues, "toggle_issues"), + (CommandId::TogglePullRequests, "toggle_pull_requests"), + (CommandId::ToggleBranchHealth, "toggle_branch_health"), + (CommandId::ToggleStashes, "toggle_stashes"), + (CommandId::StashBranch, "stash_branch"), + (CommandId::OpenThemeEditor, "open_theme_editor"), + ]; + + #[test] + fn command_id_strings_are_stable() { + for (id, expected) in LEGACY_IDS { + assert_eq!(id.as_str(), *expected, "as_str drifted for {id:?}"); + } + } + + #[test] + fn command_ids_round_trip_through_strings() { + for id in CommandId::ALL { + assert_eq!( + CommandId::try_from(id.as_str()), + Ok(*id), + "{id:?} did not round-trip" + ); + } + assert_eq!(CommandId::try_from("not_a_command"), Err(())); + } + + #[test] + fn command_ids_are_unique() { + let ids: HashSet<&str> = CommandId::ALL.iter().map(|id| id.as_str()).collect(); + assert_eq!(ids.len(), CommandId::ALL.len()); + } + + #[test] + fn registry_covers_every_command() { + assert_eq!(ALL_COMMANDS.len(), CommandId::ALL.len()); + for (meta, id) in ALL_COMMANDS.iter().zip(CommandId::ALL) { + assert_eq!(meta.id, *id); + } + } + + #[test] + fn action_names_are_namespaced_and_unique() { + let mut names = HashSet::new(); + for meta in ALL_COMMANDS { + assert!( + meta.action_name + .starts_with(&format!("{}::", meta.namespace)), + "{} is not namespaced", + meta.action_name + ); + let bare = meta + .action_name + .trim_start_matches(&format!("{}::", meta.namespace)); + assert!( + !bare.contains("::"), + "gpui panics on action names containing `::`: {}", + meta.action_name + ); + assert!( + names.insert(meta.action_name), + "duplicate {}", + meta.action_name + ); + assert_eq!(command_for_action(meta.action_name), Some(meta.id)); + } + } + + /// Guards the runtime path: `keymap.json` resolves actions by name through + /// `App::build_action`, which only knows the actions gpui collected from the + /// `inventory` registry at link time. If the generated structs were ever + /// dropped by the linker every binding would silently fail to load. + #[test] + fn every_action_is_registered_with_gpui() { + let registered: HashSet<&str> = gpui::generate_list_of_all_registered_actions() + .map(|action| action.name) + .collect(); + for meta in ALL_COMMANDS { + assert!( + registered.contains(meta.action_name), + "{} is not registered with gpui, so keymap.json could not name it", + meta.action_name + ); + } + } + + /// The doc comment on each command becomes the action's `documentation()`, + /// which is what a future keybinding editor would show. + #[test] + fn action_documentation_comes_from_the_doc_comment() { + let documented: Vec<(&str, Option<&str>)> = gpui::generate_list_of_all_registered_actions() + .map(|action| (action.name, action.documentation)) + .collect(); + for meta in ALL_COMMANDS { + let (_, documentation) = documented + .iter() + .find(|(name, _)| *name == meta.action_name) + .expect("every action is registered"); + assert_eq!( + *documentation, + Some(meta.description()), + "{} documentation drifted from its doc comment", + meta.action_name + ); + } + } + + #[test] + fn every_command_is_documented() { + for meta in ALL_COMMANDS { + assert!( + !meta.description().is_empty(), + "{} has no doc comment", + meta.action_name + ); + } + } + + /// Every `(keystrokes, context, action)` triple the registry declares. + fn default_bindings() -> Vec<(&'static str, &'static str, &'static str)> { + ALL_COMMANDS + .iter() + .flat_map(|meta| { + meta.default_bindings + .iter() + .map(move |(keystrokes, context)| (*keystrokes, *context, meta.action_name)) + }) + .collect() + } + + /// The actions a keystroke is bound to, paired with the context of each. + fn bindings_for(keystrokes: &str) -> Vec<(&'static str, &'static str)> { + default_bindings() + .into_iter() + .filter(|(keys, _, _)| *keys == keystrokes) + .map(|(_, context, action)| (action, context)) + .collect() + } + + /// Whether a keystroke is one a user could be typing into a text field: no + /// modifier beyond shift, and a single printable key. + fn is_typeable(keystrokes: &str) -> bool { + let mut parts = keystrokes.split_whitespace(); + let (Some(single), None) = (parts.next(), parts.next()) else { + // A chord always starts with a modifier in this registry. + return false; + }; + let Ok(keystroke) = gpui::Keystroke::parse(single) else { + return false; + }; + let modifiers = keystroke.modifiers; + if modifiers.control || modifiers.alt || modifiers.platform || modifiers.function { + return false; + } + keystroke.key.chars().count() == 1 || keystroke.key == "space" + } + + /// The whole point of Phase B: gpui dispatches bindings deepest-context + /// first, so one letter can mean different things in different panels. Each + /// of these is bound several times over, and every binding must name a + /// distinct action in a distinct context — otherwise one of them is dead. + #[test] + fn ambiguous_letters_resolve_to_one_action_per_context() { + for keystrokes in ["d", "s", "p", "b", "h", "j", "k", "g", "y", "/", "[", "]"] { + let bindings = bindings_for(keystrokes); + assert!( + !bindings.is_empty(), + "`{keystrokes}` is documented as context-sensitive but is not bound at all" + ); + + let actions: HashSet<&str> = bindings.iter().map(|(action, _)| *action).collect(); + assert_eq!( + actions.len(), + bindings.len(), + "`{keystrokes}` binds the same action twice: {bindings:?}" + ); + + let contexts: HashSet<&str> = bindings.iter().map(|(_, context)| *context).collect(); + assert_eq!( + contexts.len(), + bindings.len(), + "`{keystrokes}` binds two actions in the same context, so one never fires: \ + {bindings:?}" + ); + } + } + + /// The four letters the migration called out by name, pinned to the view that + /// owns each so a future block cannot quietly steal one. + #[test] + fn the_overloaded_letters_are_owned_by_the_expected_views() { + let expected: &[(&str, &[&str])] = &[ + ( + "d", + &[ + "diff::ToggleDiffDisplayMode", + "blame::BlameShowDiff", + "history::HistoryShowDiff", + "rebase::RebaseDrop", + ], + ), + ( + "s", + &[ + "graph::SquashSelected", + "diff::StageSelection", + "sidebar::ToggleStageRow", + "rebase::RebaseSquash", + ], + ), + ("p", &["diff::TogglePartialSelection", "rebase::RebasePick"]), + ("b", &["history::HistoryShowBlame"]), + ("h", &["blame::BlameShowHistory"]), + ]; + + for (keystrokes, actions) in expected { + let bound: HashSet<&str> = bindings_for(keystrokes) + .into_iter() + .map(|(action, _)| action) + .collect(); + let want: HashSet<&str> = actions.iter().copied().collect(); + assert_eq!(bound, want, "the owners of `{keystrokes}` changed"); + } + } + + /// gpui dispatches keymap bindings *before* `on_key_down`, so any binding a + /// user could type must stand down while a text field is focused. `!TextInput` + /// is false whenever a text field is anywhere on the focus path, which is + /// exactly the guarantee needed. + #[test] + fn bare_character_bindings_stand_down_for_text_input() { + for (keystrokes, context, action) in default_bindings() { + if !is_typeable(keystrokes) { + continue; + } + assert!( + context.contains("!TextInput"), + "`{keystrokes}` is bound to {action} in context `{context}`, which would swallow \ + the character while a text field has focus; add `&& !TextInput`" + ); + } + } + + /// The shared dismissal must be reachable from both windows, since each owns + /// its own Esc handling and Esc never crosses windows. + #[test] + fn cancel_and_confirm_are_bound_across_both_window_roots() { + for (id, keystrokes) in [(CommandId::Cancel, "escape"), (CommandId::Confirm, "enter")] { + let context = id + .default_bindings() + .iter() + .find(|(keys, _)| *keys == keystrokes) + .map(|(_, context)| *context) + .unwrap_or_else(|| panic!("{id} is not bound to `{keystrokes}`")); + assert!(context.contains("Workspace"), "{id}: `{context}`"); + assert!(context.contains("SettingsWindow"), "{id}: `{context}`"); + } + } + + /// The per-keystroke `in "..."` override: `down` stays live inside a text + /// field while the vim-style `j` for the same command stands down. + #[test] + fn one_command_can_bind_keystrokes_in_different_contexts() { + assert_eq!( + CommandId::SelectNext.default_bindings(), + &[("down", "List"), ("j", "List && !TextInput")] + ); + assert_eq!( + CommandId::SelectLast.default_bindings(), + &[("end", "List"), ("shift-g", "List && !TextInput")] + ); + } + + /// A view block's `context` is the default for its commands, and a + /// per-command `in "..."` overrides it. + #[test] + fn a_view_context_is_inherited_unless_overridden() { + assert_eq!( + CommandId::BlameShowDiff.default_bindings(), + &[ + ("escape", "BlameView && !modal"), + ("d", "BlameView && !modal && !TextInput") + ] + ); + assert_eq!( + CommandId::BlameShowHistory.default_bindings(), + &[("h", "BlameView && !modal && !TextInput")] + ); + } + + /// Every identifier a context predicate mentions, negated ones included — + /// `!TextInput` still has to be accounted for by the tree or the markers. + fn predicate_identifiers(predicate: &gpui::KeyBindingContextPredicate, out: &mut Vec) { + use gpui::KeyBindingContextPredicate as P; + match predicate { + P::Identifier(name) => out.push(name.to_string()), + P::Equal(key, _) | P::NotEqual(key, _) => out.push(key.to_string()), + P::Not(inner) => predicate_identifiers(inner, out), + P::Descendant(left, right) | P::And(left, right) | P::Or(left, right) => { + predicate_identifiers(left, out); + predicate_identifiers(right, out); + } + } + } + + #[test] + fn context_tree_is_well_formed() { + let mut names = HashSet::new(); + for node in CONTEXT_TREE { + assert!( + !node.context.trim().is_empty(), + "a node has no key context at all" + ); + assert!( + !node.label.trim().is_empty(), + "{} has no user-facing label", + node.name() + ); + assert!(names.insert(node.name()), "{} appears twice", node.name()); + } + + for node in CONTEXT_TREE { + let Some(parent) = node.parent else { + continue; + }; + assert!( + names.contains(&parent), + "{}'s parent `{parent}` is not in the tree", + node.name() + ); + // Walking up must terminate at a root rather than loop. + let mut current = node; + let mut hops = 0; + while let Some(parent) = current.parent { + current = context_node(parent).expect("the parent is in the tree"); + hops += 1; + assert!( + hops <= CONTEXT_TREE.len(), + "{} sits in a parent cycle", + node.name() + ); + } + } + + let roots: Vec<&str> = CONTEXT_TREE + .iter() + .filter(|node| node.parent.is_none()) + .map(KeyContextNode::name) + .collect(); + assert_eq!( + roots, + ["Workspace", "SettingsWindow"], + "each window root owns its own dispatch path; adding one is a decision, \ + not an accident" + ); + } + + /// The guard that keeps [`CONTEXT_TREE`] honest: a new `commands!` block + /// naming a context nobody placed in the tree would escape the shadowing + /// analysis silently, so it fails the build instead. + #[test] + fn every_registry_context_is_in_the_tree() { + let mut known: HashSet<&str> = CONTEXT_MARKERS.iter().copied().collect(); + known.extend(CONTEXT_TREE.iter().flat_map(KeyContextNode::identifiers)); + + for (keystrokes, context, action) in default_bindings() { + let predicate = KeyBindingContextPredicate::parse(context) + .unwrap_or_else(|error| panic!("`{context}` does not parse: {error}")); + let mut identifiers = Vec::new(); + predicate_identifiers(&predicate, &mut identifiers); + for identifier in identifiers { + assert!( + known.contains(identifier.as_str()), + "`{identifier}` (from the `{context}` binding of `{keystrokes}` to {action}) \ + is in no CONTEXT_TREE node and is not a marker, so shadowing against it \ + cannot be detected. Add the element to CONTEXT_TREE in this file." + ); + } + } + } + + #[test] + fn hidden_commands_are_the_ones_absent_from_the_palette() { + let palette: HashSet = crate::command_palette::palette_commands() + .iter() + .map(|command| command.id) + .collect(); + for meta in ALL_COMMANDS { + assert_eq!( + palette.contains(&meta.id), + meta.in_palette, + "{} palette membership disagrees with the `[hidden]` marker", + meta.action_name + ); + } + } + + #[test] + fn palette_predicates_match_the_registry() { + for command in crate::command_palette::palette_commands() { + let expected = command.id.availability(); + assert!( + std::ptr::fn_addr_eq(command.predicate(), expected), + "{} availability predicate disagrees with the registry", + command.id + ); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/shadow.rs b/crates/rgitui_workspace/src/keymap/shadow.rs new file mode 100644 index 00000000..c2d786ad --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/shadow.rs @@ -0,0 +1,420 @@ +//! Nested-context shadowing: a panel binding that masks a global one. +//! +//! [`super::conflict`] compares two bindings' context predicates for textual or +//! [`is_superset`] overlap, which cannot see that `GraphView && !TextInput` sits +//! *inside* `Workspace && !modal` at dispatch time. So a binding scoped to a +//! panel taking a keystroke away from a global binding used to go completely +//! unreported. +//! +//! It is reported here, and deliberately at a lower severity than a conflict: +//! deeper-wins scoping is usually the whole point — the shipped defaults rely on +//! it, which is why `Esc` means "back to the diff" in the blame view and +//! "dismiss" everywhere else. Nothing is dropped, both bindings stay applied, and +//! the finding never becomes a toast or a startup warning. It shows up in the +//! shortcuts panel and in `docs/KEYBINDINGS.md`, next to the command that lost +//! the keystroke. +//! +//! # How it is decided +//! +//! [`super::registry::CONTEXT_TREE`] says which element each key context is set +//! on and what encloses it, so every focus path the app can produce can be +//! reconstructed. Both predicates are then evaluated against that path with +//! gpui's own [`depth_of`] — the same function the keymap uses to pick a winner — +//! so the verdict cannot drift from what actually happens when the key is +//! pressed. A binding that matches at a greater depth shadows one that matches at +//! a shallower depth; two that match at the same depth are a +//! [`super::conflict`], not a shadow; and two on unrelated paths never meet. +//! +//! Everything here is pure: [`detect_shadowing`] takes the same +//! `(keystrokes, context, action)` specs the loader hands to gpui. +//! +//! [`is_superset`]: gpui::KeyBindingContextPredicate::is_superset +//! [`depth_of`]: gpui::KeyBindingContextPredicate::depth_of + +use gpui::{KeyBindingContextPredicate, KeyContext}; + +use super::conflict::{normalize_sequence, BindingSpec, NormalizedKeystroke}; +use super::registry::{context_node, KeyContextNode, CONTEXT_TREE}; + +/// A binding that another, deeper binding takes a keystroke away from. +/// +/// Both bindings stay applied — this is an observation about which one wins +/// where, not a rejection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Shadowed { + /// Index of the deeper binding, which wins while its element has focus. + pub inner: usize, + /// Index of the binding that cannot be reached there. + pub outer: usize, + /// The element that has to have focus for this to happen. + pub context: &'static str, + /// Informational explanation, filed against the [`Self::outer`] command. + pub message: String, +} + +/// The focus path ending at `node`, root first, as gpui would see it. +/// +/// The root carries `modal` when anything on the path is an overlay, because that +/// is what the workspace root does while one is open — which is in turn what makes +/// a `Workspace && !modal` binding stand down rather than be shadowed. +fn focus_path(node: &'static KeyContextNode) -> Vec { + let mut chain: Vec<&'static KeyContextNode> = vec![node]; + while let Some(parent) = chain.last().and_then(|node| node.parent) { + let Some(parent) = context_node(parent) else { + break; + }; + chain.push(parent); + // `context_tree_is_well_formed` rules cycles out; belt and braces so a + // malformed table cannot hang the keymap load. + if chain.len() > CONTEXT_TREE.len() { + break; + } + } + chain.reverse(); + + let modal = chain.iter().any(|node| node.modal); + chain + .iter() + .enumerate() + .map(|(depth, node)| { + let mut context = KeyContext::default(); + for identifier in node.identifiers() { + context.add(identifier); + } + if modal && depth == 0 { + context.add("modal"); + } + context + }) + .collect() +} + +/// A binding reduced to what shadowing cares about. +struct Candidate { + /// Index into the caller's binding list. + index: usize, + /// Parsed context predicate. `None` means "matches everywhere". + predicate: Option, + /// Normalised keystroke sequence. + keys: Vec, +} + +/// Reports every binding a deeper one masks. See the [module docs](self). +/// +/// `bindings` is in application order and the returned indices point back into +/// it, matching [`super::conflict::ConflictReport`]. Unbinds and bindings whose +/// keystrokes or context do not parse are skipped — the loader reports those, and +/// they never reach gpui. +pub fn detect_shadowing(bindings: &[BindingSpec]) -> Vec { + let candidates: Vec = bindings + .iter() + .enumerate() + .filter(|(_, binding)| !binding.is_unbind()) + .filter_map(|(index, binding)| { + let predicate = match binding.context.as_deref() { + Some(context) => Some(KeyBindingContextPredicate::parse(context).ok()?), + None => None, + }; + Some(Candidate { + index, + predicate, + keys: normalize_sequence(&binding.keystrokes)?, + }) + }) + .collect(); + + let mut found: Vec = Vec::new(); + + for node in CONTEXT_TREE { + let path = focus_path(node); + // A binding with no context is treated as if it were on the focused + // element, exactly as gpui's `binding_enabled` does. + let live: Vec<(&Candidate, usize)> = candidates + .iter() + .filter_map(|candidate| { + let depth = match &candidate.predicate { + Some(predicate) => predicate.depth_of(&path)?, + None => path.len(), + }; + Some((candidate, depth)) + }) + .collect(); + + for (inner, inner_depth) in &live { + for (outer, outer_depth) in &live { + if outer_depth >= inner_depth + || inner.keys != outer.keys + || bindings[inner.index].action == bindings[outer.index].action + { + continue; + } + // One pair can be shadowed on several paths — a `List` binding + // masked in every panel that joins the group, say. The first + // path is enough to explain it. + if found + .iter() + .any(|found| found.inner == inner.index && found.outer == outer.index) + { + continue; + } + found.push(Shadowed { + inner: inner.index, + outer: outer.index, + context: node.name(), + message: message(&bindings[inner.index], &bindings[outer.index], node), + }); + } + } + } + + found +} + +/// Explains which binding wins where, in the keystroke spelling `keymap.json` +/// uses so the sentence reads the same on every platform. +fn message(inner: &BindingSpec, outer: &BindingSpec, node: &KeyContextNode) -> String { + format!( + "`{}` runs `{}` while {} is focused, so `{}` is not reachable there. \ + Both bindings stay active.", + inner.keystrokes, inner.action, node.label, outer.action, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keymap::conflict::{detect_conflicts, BindingSpec, NO_ACTION}; + use crate::keymap::loader::default_specs; + + fn default_binding(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::default_binding(keystrokes, context, action) + } + + /// `(keystrokes, inner action, outer action)` for every finding, which is what + /// the assertions below care about. + fn findings(bindings: &[BindingSpec]) -> Vec<(&str, &str, &str)> { + detect_shadowing(bindings) + .into_iter() + .map(|shadow| { + ( + bindings[shadow.inner].keystrokes.as_str(), + bindings[shadow.inner].action.as_str(), + bindings[shadow.outer].action.as_str(), + ) + }) + .collect() + } + + /// The case that motivated this: `SquashSelected` used to default to + /// `secondary-shift-s`, which the graph dispatched ahead of the workspace's + /// `UnstageAll` — invisible to conflict detection, because neither predicate + /// is a superset of the other. + #[test] + fn a_panel_binding_masking_a_global_one_is_reported() { + let bindings = [ + default_binding( + "secondary-shift-s", + "Workspace && !modal", + "rgitui::UnstageAll", + ), + default_binding( + "secondary-shift-s", + "GraphView && !TextInput", + "graph::SquashSelected", + ), + ]; + let found = detect_shadowing(&bindings); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].inner, 1); + assert_eq!(found[0].outer, 0); + assert_eq!(found[0].context, "GraphView"); + assert_eq!( + found[0].message, + "`secondary-shift-s` runs `graph::SquashSelected` while the commit graph is \ + focused, so `rgitui::UnstageAll` is not reachable there. Both bindings stay active." + ); + + // And conflict detection still sees nothing, which is the gap being filled. + assert!(detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// Two bindings on the same element are a conflict, not a shadow: one of them + /// is genuinely dead and gets dropped. + #[test] + fn a_same_context_duplicate_is_left_to_conflict_detection() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("secondary-s", "Workspace && !modal", "rgitui::Commit"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert_eq!(detect_conflicts(&bindings).conflicts.len(), 1); + } + + /// Nor is a superset overlap a shadow — the same depth, so `conflict` owns it. + #[test] + fn a_superset_overlap_at_one_depth_is_not_a_shadow() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + default_binding("secondary-s", "Workspace", "rgitui::Commit"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert!(!detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// Two panels can own the same letter without either losing anything, which is + /// exactly what the per-view contexts are for. + #[test] + fn sibling_panels_sharing_a_keystroke_are_not_reported() { + let bindings = [ + default_binding("s", "GraphView && !TextInput", "graph::SquashSelected"), + default_binding("s", "Sidebar && !TextInput", "sidebar::ToggleStageRow"), + default_binding("s", "DiffViewer && !TextInput", "diff::StageSelection"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + assert!(detect_conflicts(&bindings).conflicts.is_empty()); + } + + /// The two window roots never see each other's bindings. + #[test] + fn bindings_in_separate_windows_are_not_reported() { + let bindings = [ + default_binding("escape", "Workspace", "menu::Cancel"), + default_binding("escape", "SettingsWindow", "menu::Cancel"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + /// A global binding a modal already suppresses is not shadowed by the modal's + /// own binding: `!modal` turned it off, nothing took it away. + #[test] + fn a_modal_does_not_shadow_what_the_modal_gate_already_disabled() { + let bindings = [ + default_binding( + "tab", + "Workspace && !modal && !TextInput", + "rgitui::FocusNextPanel", + ), + default_binding("tab", "ThemeEditor", "theme::ThemeEditorNextField"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + + // Without the `!modal` gate the same pair is a shadow, so it is the gate + // doing the work rather than the overlay being skipped. + let ungated = [ + default_binding("tab", "Workspace", "rgitui::FocusNextPanel"), + default_binding("tab", "ThemeEditor", "theme::ThemeEditorNextField"), + ]; + assert_eq!(detect_shadowing(&ungated).len(), 1); + } + + #[test] + fn a_user_binding_can_shadow_a_default() { + let mut bindings = vec![default_binding( + "ctrl-alt-9", + "Workspace && !modal", + "rgitui::Fetch", + )]; + bindings.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("DiffViewer"), + "diff::NextHunk", + )); + let found = detect_shadowing(&bindings); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].message.contains("the diff viewer"), "{found:?}"); + } + + /// One command bound in two contexts is one behaviour, not a shadow of itself. + #[test] + fn a_command_does_not_shadow_itself() { + let bindings = [ + default_binding("/", "Workspace && !modal && !TextInput", "rgitui::Search"), + default_binding("/", "GraphView && !TextInput", "rgitui::Search"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + #[test] + fn an_unbind_is_not_a_shadow() { + let bindings = [ + default_binding("secondary-s", "Workspace && !modal", "rgitui::StageAll"), + BindingSpec::user_binding("secondary-s", Some("GraphView"), NO_ACTION), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + #[test] + fn an_unparseable_binding_is_skipped_rather_than_reported() { + let bindings = [ + default_binding("ctrl-a-b", "Workspace", "rgitui::StageAll"), + default_binding("ctrl-a-b", "GraphView", "graph::CopyCommitSha"), + default_binding("ctrl-alt-1", "Workspace &&", "rgitui::Fetch"), + default_binding("ctrl-alt-1", "GraphView", "graph::CopyCommitMessage"), + ]; + assert!(detect_shadowing(&bindings).is_empty()); + } + + /// The shipped defaults must not conflict — that is already pinned in the + /// loader — and the shadowing they do have is deliberate, so it is spelled out + /// here rather than merely counted. + #[test] + fn the_shipped_defaults_shadow_only_these() { + let specs = default_specs(); + assert!( + detect_conflicts(&specs).conflicts.is_empty(), + "the defaults must never conflict" + ); + + // Esc means "back to the diff" or "close the search" in the three views + // below and dismisses the topmost overlay everywhere else; `/` and Ctrl+F + // filter the focused list instead of searching the commit graph. + let mut expected = vec![ + ("escape", "blame::BlameShowDiff", "menu::Cancel"), + ("escape", "graph::GraphCancel", "menu::Cancel"), + ("escape", "history::HistoryShowDiff", "menu::Cancel"), + ("/", "detail::FileSearch", "rgitui::Search"), + ("/", "sidebar::FilterBranches", "rgitui::Search"), + ("secondary-f", "detail::FileSearch", "rgitui::Search"), + ("secondary-f", "sidebar::FilterBranches", "rgitui::Search"), + ]; + expected.sort_unstable(); + + let mut found = findings(&specs); + found.sort_unstable(); + assert_eq!( + found, expected, + "the defaults' shadowing changed — check it is still intentional" + ); + } + + /// Bare `s` for squash has to be reachable in the graph, which means nothing + /// shallower may claim it and nothing deeper may take it away. + #[test] + fn bare_s_in_the_graph_collides_with_nothing() { + let specs = default_specs(); + let squash: Vec<&BindingSpec> = specs + .iter() + .filter(|spec| spec.action == "graph::SquashSelected") + .collect(); + assert_eq!( + squash + .iter() + .map(|spec| (spec.keystrokes.as_str(), spec.context.as_deref())) + .collect::>(), + [("s", Some("GraphView && !modal && !TextInput"))] + ); + + for shadow in detect_shadowing(&specs) { + assert_ne!( + specs[shadow.outer].action, "graph::SquashSelected", + "something takes `s` away from squash: {}", + shadow.message + ); + assert_ne!( + specs[shadow.inner].action, "graph::SquashSelected", + "squash takes a keystroke away from another command: {}", + shadow.message + ); + } + } +} diff --git a/crates/rgitui_workspace/src/keymap/summary.rs b/crates/rgitui_workspace/src/keymap/summary.rs new file mode 100644 index 00000000..183edccf --- /dev/null +++ b/crates/rgitui_workspace/src/keymap/summary.rs @@ -0,0 +1,729 @@ +//! What the keymap load actually produced, in a form the UI can render. +//! +//! Every shortcut rgitui shows the user comes from here: the shortcut +//! reference, the command palette hints, the settings quick reference, the +//! toolbar tooltips and the home screen. One source means the surfaces cannot +//! disagree, and it means they follow the user's `keymap.json` rather than +//! advertising a default the user has rebound. +//! +//! # Why not ask gpui +//! +//! [`gpui::Keymap::bindings_for_action`] returns the bindings gpui holds for an +//! action, which is close but not enough: +//! +//! * it cannot say whether a binding came from the registry or from +//! `keymap.json`, which is exactly what the user wants flagged; +//! * it only filters bindings an explicit `null` removed, so after +//! `"ctrl-s": "rgitui::Commit"` it would still report `ctrl-s` for +//! `StageAll`, a keystroke that can no longer reach it; and +//! * conflicts are rgitui's own analysis ([`super::conflict`]) and gpui knows +//! nothing about them. +//! +//! So [`KeymapSummary::build`] is handed the very list of [`BindingSpec`]s that +//! is turned into `gpui::KeyBinding`s, plus the indices that survived, and +//! derives the display from that. It agrees with the live keymap by +//! construction, and it is pure — no `App`, no window, unit-testable. +//! +//! # Shadowing +//! +//! A binding is *shadowed* when a later applied binding claims the same +//! keystroke in an overlapping context. gpui resolves that silently in the +//! later binding's favour, so the earlier one is dropped from the display and +//! the affected command gets a warning saying where its keystroke went. That is +//! how a rebind shows up as "this command lost its shortcut" instead of as a +//! shortcut that does nothing. +//! +//! # Severity +//! +//! Not everything worth telling the user is a problem, so each note carries a +//! [`NoteSeverity`]. A [`NoteSeverity::Warning`] means something was dropped or a +//! command lost a keystroke it will not get back. A [`NoteSeverity::Info`] is the +//! nested-context shadowing [`super::shadow`] finds: both bindings are applied +//! and the scoping is very likely deliberate — the shipped defaults produce +//! several — so it must never become a toast or a startup warning. Keeping both +//! on one list means the shortcuts panel shows them in the same place, styled by +//! severity, with no second channel to keep in step. + +use std::sync::Arc; + +use super::conflict::{ + self, BindingSource, BindingSpec, ConflictReport, NormalizedKeystroke, NO_ACTION, +}; +use super::display::{self, KeystrokeStyle}; +use super::registry::{command_for_action, CommandId, ALL_COMMANDS}; +use super::shadow; + +/// One binding of one command, as it will actually fire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveBinding { + /// Keystrokes as written, e.g. `secondary-shift-r`. + pub keystrokes: String, + /// Humanised label, e.g. `Ctrl+Shift+R`. + pub display: String, + /// Key context the binding is scoped to, or `None` for "everywhere". + pub context: Option, + /// Whether the registry or the user's `keymap.json` supplied it. + pub source: BindingSource, +} + +impl EffectiveBinding { + /// Whether this binding came from the user's `keymap.json`. + pub fn is_user_defined(&self) -> bool { + self.source == BindingSource::User + } +} + +/// How much a note matters. See the [module docs](self#severity). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoteSeverity { + /// Something was dropped, or the command lost a keystroke for good. + Warning, + /// A deeper binding wins the keystroke somewhere. Nothing was dropped. + Info, +} + +/// One thing worth telling the user about a command's bindings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeymapNote { + /// How much it matters. + pub severity: NoteSeverity, + /// The explanation, ready to render. + pub message: String, +} + +impl KeymapNote { + /// Whether this note reports an actual problem. + pub fn is_warning(&self) -> bool { + self.severity == NoteSeverity::Warning + } +} + +/// Everything the UI needs to show about one command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandBindings { + /// The command. + pub command: CommandId, + /// Bindings that will fire, in precedence order (last wins). + pub bindings: Vec, + /// What the user should know: a binding of theirs that was dropped, a + /// keystroke this command lost to another, or where a panel wins one of its + /// keystrokes. Warnings first, then info, each in detection order. + pub notes: Vec, +} + +impl CommandBindings { + /// The humanised keystrokes, or `None` when the command is unbound. + pub fn display(&self) -> Option { + display::join_bindings(self.bindings.iter().map(|binding| binding.display.as_str())) + } + + /// Whether any of this command's bindings came from `keymap.json`. + pub fn is_user_defined(&self) -> bool { + self.bindings.iter().any(EffectiveBinding::is_user_defined) + } + + /// The messages of this command's notes at one severity. + pub fn messages(&self, severity: NoteSeverity) -> Vec<&str> { + self.notes + .iter() + .filter(|note| note.severity == severity) + .map(|note| note.message.as_str()) + .collect() + } +} + +/// A group of commands sharing a `commands!` view block, which is also the key +/// context they are scoped to. Drives the layout of the shortcut reference. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandGroup { + /// The view name from the registry, e.g. `DiffViewer`. + pub view: &'static str, + /// The distinct key contexts the group's bindings use. + pub contexts: Vec<&'static str>, + /// The group's commands: bound ones first, each in registry order. + pub commands: Vec, +} + +impl CommandGroup { + /// A sentence describing when the group's shortcuts are live, derived from + /// the key contexts rather than written by hand. + pub fn description(&self) -> String { + match self.contexts.as_slice() { + [] => "Reachable from the command palette only.".to_owned(), + contexts => format!("Active while `{}` matches.", contexts.join("`, `")), + } + } +} + +/// The bindings in force, grouped and labelled for display. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct KeymapSummary { + /// Every command, in registry order. + commands: Vec, + /// Warnings that name no registry command — a `keymap.json` binding for an + /// action rgitui does not know, for instance. Surfaced so nothing is lost. + pub unattributed_warnings: Vec, +} + +impl KeymapSummary { + /// Derives the summary from the specs handed to gpui. + /// + /// `specs` must be in the order they are applied (defaults then user + /// bindings) and `applied` the indices that survived conflict detection and + /// validation, exactly as the loader computed them. + pub fn build( + specs: &[BindingSpec], + applied: &[usize], + report: &ConflictReport, + style: KeystrokeStyle, + ) -> Self { + let mut commands: Vec = ALL_COMMANDS + .iter() + .map(|meta| CommandBindings { + command: meta.id, + bindings: Vec::new(), + notes: Vec::new(), + }) + .collect(); + let mut unattributed_warnings = Vec::new(); + + for conflict in &report.conflicts { + let ignored = &specs[conflict.ignored]; + note( + &mut commands, + &mut unattributed_warnings, + &ignored.action, + NoteSeverity::Warning, + conflict.message.clone(), + ); + } + + // Only applied specs can fire, and only in the order they were applied. + let applied_specs: Vec<&BindingSpec> = applied.iter().map(|&index| &specs[index]).collect(); + let sequences: Vec>> = applied_specs + .iter() + .map(|spec| conflict::normalize_sequence(&spec.keystrokes)) + .collect(); + + for (index, spec) in applied_specs.iter().enumerate() { + if spec.is_unbind() { + continue; + } + let Some(display) = display::humanize_sequence(&spec.keystrokes, style) else { + continue; + }; + + if let Some(shadow) = shadow_of(index, &applied_specs, &sequences) { + note( + &mut commands, + &mut unattributed_warnings, + &spec.action, + NoteSeverity::Warning, + shadow_message(&display, applied_specs[shadow]), + ); + continue; + } + + let Some(id) = command_for_action(&spec.action) else { + continue; + }; + let Some(entry) = commands.iter_mut().find(|entry| entry.command == id) else { + continue; + }; + entry.bindings.push(EffectiveBinding { + keystrokes: spec.keystrokes.clone(), + display, + context: spec.context.clone(), + source: spec.source, + }); + } + + // Last, so every command's warnings come before its info notes: a panel + // binding that wins a keystroke from a global one. Both bindings are + // applied — the finding is filed against the command that cannot reach + // its keystroke while that panel has focus. + for found in shadow::detect_shadowing(specs) { + if applied.binary_search(&found.inner).is_err() + || applied.binary_search(&found.outer).is_err() + { + continue; + } + note( + &mut commands, + &mut unattributed_warnings, + &specs[found.outer].action, + NoteSeverity::Info, + found.message, + ); + } + + Self { + commands, + unattributed_warnings, + } + } + + /// The registry defaults on their own, with no user keymap. + /// + /// Used as the fallback before the keymap has loaded, and by the tests that + /// pin the default labels. + pub fn defaults(style: KeystrokeStyle) -> Self { + let specs = super::loader::default_specs(); + let report = conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + Self::build(&specs, &applied, &report, style) + } + + /// Every command, in registry order. + pub fn commands(&self) -> &[CommandBindings] { + &self.commands + } + + /// What is known about one command. + pub fn command(&self, id: CommandId) -> Option<&CommandBindings> { + self.commands.iter().find(|entry| entry.command == id) + } + + /// The humanised keystrokes for one command, or `None` when it is unbound. + /// + /// This is the accessor every shortcut hint in the UI goes through. + pub fn display(&self, id: CommandId) -> Option { + self.command(id).and_then(CommandBindings::display) + } + + /// Whether the user rebound this command in `keymap.json`. + pub fn is_user_defined(&self, id: CommandId) -> bool { + self.command(id) + .is_some_and(CommandBindings::is_user_defined) + } + + /// Everything worth saying about one command's bindings, warnings first. + pub fn notes(&self, id: CommandId) -> &[KeymapNote] { + self.command(id).map_or(&[], |entry| &entry.notes) + } + + /// Problems with one command: dropped bindings and lost keystrokes. + pub fn warnings(&self, id: CommandId) -> Vec<&str> { + self.command(id) + .map_or_else(Vec::new, |entry| entry.messages(NoteSeverity::Warning)) + } + + /// Informational notes for one command: keystrokes a panel wins from it. + pub fn infos(&self, id: CommandId) -> Vec<&str> { + self.command(id) + .map_or_else(Vec::new, |entry| entry.messages(NoteSeverity::Info)) + } + + /// Number of bindings that came from `keymap.json`. + pub fn user_binding_count(&self) -> usize { + self.commands + .iter() + .flat_map(|entry| &entry.bindings) + .filter(|binding| binding.is_user_defined()) + .count() + } + + /// Number of commands carrying at least one warning. + /// + /// Informational notes are excluded on purpose: the shipped defaults produce + /// several, and counting them would tell every user their keymap has problems. + pub fn warning_count(&self) -> usize { + self.commands + .iter() + .filter(|entry| entry.notes.iter().any(KeymapNote::is_warning)) + .count() + + usize::from(!self.unattributed_warnings.is_empty()) + } + + /// Number of commands that have at least one binding. + pub fn bound_command_count(&self) -> usize { + self.commands + .iter() + .filter(|entry| !entry.bindings.is_empty()) + .count() + } + + /// The commands grouped by `commands!` view block, bound ones first. + /// + /// Bound before unbound because the reference is read to look a keystroke + /// up; the unbound tail is kept so the palette-only commands are still + /// discoverable, and so a command whose binding the user removed does not + /// silently vanish. + pub fn groups(&self) -> Vec { + let mut groups: Vec = Vec::new(); + + for meta in ALL_COMMANDS { + let Some(entry) = self.command(meta.id) else { + continue; + }; + let group = match groups.iter_mut().find(|group| group.view == meta.view) { + Some(group) => group, + None => { + groups.push(CommandGroup { + view: meta.view, + contexts: Vec::new(), + commands: Vec::new(), + }); + groups.last_mut().expect("just pushed") + } + }; + for (_, context) in meta.default_bindings { + if !group.contexts.contains(context) { + group.contexts.push(context); + } + } + group.commands.push(entry.clone()); + } + + for group in &mut groups { + group + .commands + .sort_by_key(|entry| entry.bindings.is_empty()); + } + groups + } +} + +/// Files a note against the command that owns `action`. +/// +/// A *warning* about an action no registry command owns goes to `unattributed` +/// instead, so nothing the user has to fix is lost. An *informational* note about +/// one is dropped: there is no row for it to sit next to and nothing to fix. +fn note( + commands: &mut [CommandBindings], + unattributed: &mut Vec, + action: &str, + severity: NoteSeverity, + message: String, +) { + match command_for_action(action) + .and_then(|id| commands.iter_mut().find(|entry| entry.command == id)) + { + Some(entry) => entry.notes.push(KeymapNote { severity, message }), + None if severity == NoteSeverity::Warning => unattributed.push(message), + None => {} + } +} + +/// Index of the applied binding that takes `index`'s keystroke away, if any. +/// +/// A later binding shadows an earlier one when the keystroke sequences match and +/// the contexts overlap — including an unbind, which is a deliberate removal. +fn shadow_of( + index: usize, + applied: &[&BindingSpec], + sequences: &[Option>], +) -> Option { + let keys = sequences[index].as_ref()?; + ((index + 1)..applied.len()).rev().find(|&later| { + sequences[later].as_ref() == Some(keys) + && applied[later].action != applied[index].action + && conflict::contexts_overlap( + applied[index].context.as_deref(), + applied[later].context.as_deref(), + ) + }) +} + +/// Explains where a command's keystroke went. +fn shadow_message(display: &str, winner: &BindingSpec) -> String { + if winner.action == NO_ACTION { + return format!("`{display}` was removed by keymap.json."); + } + let winner_label = command_for_action(&winner.action) + .map(|id| format!("`{}`", id.description())) + .unwrap_or_else(|| format!("`{}`", winner.action)); + format!( + "`{display}` now runs {winner_label} ({}), so it no longer reaches this command.", + winner.source + ) +} + +/// The summary shown before the keymap has loaded, built once. +pub fn fallback() -> Arc { + use std::sync::OnceLock; + static FALLBACK: OnceLock> = OnceLock::new(); + FALLBACK + .get_or_init(|| Arc::new(KeymapSummary::defaults(KeystrokeStyle::platform()))) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The defaults in the word style, which is what the assertions below spell. + fn defaults() -> KeymapSummary { + KeymapSummary::defaults(KeystrokeStyle::Words) + } + + /// Builds a summary from the defaults plus the given user bindings, applying + /// the same conflict resolution the loader does. + fn with_user(user: &[BindingSpec]) -> KeymapSummary { + let mut specs = super::super::loader::default_specs(); + specs.extend(user.iter().cloned()); + let report = conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + KeymapSummary::build(&specs, &applied, &report, KeystrokeStyle::Words) + } + + fn user(keystrokes: &str, context: &str, action: &str) -> BindingSpec { + BindingSpec::user_binding(keystrokes, Some(context), action) + } + + /// The drift that motivated this work: the help used to advertise + /// `Ctrl+Shift+F` for Fetch while the registry bound `Ctrl+Shift+R`. + #[test] + fn a_default_label_comes_from_the_registry_keystroke() { + let summary = defaults(); + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + summary.display(CommandId::Fetch), + display::humanize_sequence( + CommandId::Fetch.default_bindings()[0].0, + KeystrokeStyle::Words + ) + ); + assert!(!summary.is_user_defined(CommandId::Fetch)); + } + + #[test] + fn every_default_binding_is_labelled() { + let summary = defaults(); + for meta in ALL_COMMANDS { + let entry = summary + .command(meta.id) + .expect("every command is summarised"); + assert_eq!( + entry.bindings.len(), + meta.default_bindings.len(), + "{} lost a binding: {entry:?}", + meta.action_name + ); + for binding in &entry.bindings { + assert!( + !binding.display.is_empty(), + "{} has an empty label", + meta.action_name + ); + assert_eq!(binding.source, BindingSource::Default); + } + assert!( + entry.messages(NoteSeverity::Warning).is_empty(), + "{} warns about the defaults: {:?}", + meta.action_name, + entry.notes + ); + } + assert!(summary.unattributed_warnings.is_empty()); + assert_eq!(summary.user_binding_count(), 0); + assert_eq!(summary.warning_count(), 0); + } + + /// The defaults rely on deeper-wins scoping, so they legitimately produce + /// informational notes — and none of them may be a warning, because that is + /// what the load-time toast is driven from. + #[test] + fn the_defaults_note_where_a_panel_wins_a_keystroke() { + let summary = defaults(); + let infos = summary.infos(CommandId::Search); + assert!( + infos.iter().any(|message| { + message.contains("sidebar::FilterBranches") && message.contains("the sidebar") + }), + "{infos:?}" + ); + assert!(summary + .infos(CommandId::Cancel) + .iter() + .any(|message| message.contains("graph::GraphCancel"))); + + // Info notes must not inflate the "you have a problem" count. + assert_eq!(summary.warning_count(), 0); + assert!( + summary + .commands() + .iter() + .any(|entry| !entry.messages(NoteSeverity::Info).is_empty()), + "the defaults should have at least one info note" + ); + } + + #[test] + fn a_command_with_two_keystrokes_lists_both() { + assert_eq!( + defaults().display(CommandId::UnstageAll).as_deref(), + Some("Ctrl+Shift+S or Ctrl+U") + ); + } + + /// One command binding one keystroke in two contexts is one label. + #[test] + fn keystrokes_bound_twice_in_different_contexts_show_once() { + assert_eq!( + defaults().display(CommandId::SelectNext).as_deref(), + Some("Down or J") + ); + } + + #[test] + fn an_unbound_command_has_no_label() { + let summary = defaults(); + assert_eq!(summary.display(CommandId::Pull), None); + assert!(summary + .command(CommandId::Pull) + .expect("Pull is summarised") + .bindings + .is_empty()); + } + + #[test] + fn a_user_binding_is_labelled_and_marked() { + let summary = with_user(&[user("ctrl-alt-p", "Workspace", "rgitui::Pull")]); + assert_eq!( + summary.display(CommandId::Pull).as_deref(), + Some("Ctrl+Alt+P") + ); + assert!(summary.is_user_defined(CommandId::Pull)); + assert_eq!(summary.user_binding_count(), 1); + // The defaults it did not touch are untouched. + assert!(!summary.is_user_defined(CommandId::Fetch)); + } + + /// Rebinding a default keystroke moves the label to the new command and + /// tells the old one where its keystroke went. + #[test] + fn rebinding_a_keystroke_moves_the_label() { + let summary = with_user(&[user("secondary-s", "Workspace && !modal", "rgitui::Commit")]); + + let commit = summary + .command(CommandId::Commit) + .expect("Commit is summarised"); + assert!(commit + .bindings + .iter() + .any(|binding| binding.display == "Ctrl+S" && binding.is_user_defined())); + + assert_eq!(summary.display(CommandId::StageAll), None); + let warnings = summary.warnings(CommandId::StageAll); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("Ctrl+S"), "{warnings:?}"); + assert!(warnings[0].contains("keymap.json"), "{warnings:?}"); + } + + #[test] + fn an_unbind_removes_the_label_and_says_so() { + let summary = with_user(&[user("secondary-s", "Workspace && !modal", NO_ACTION)]); + assert_eq!(summary.display(CommandId::StageAll), None); + let warnings = summary.warnings(CommandId::StageAll); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!( + warnings[0].contains("was removed by keymap.json"), + "{warnings:?}" + ); + } + + /// The headline: the user must be able to see which of their bindings was + /// ignored and why, without reading a log. + #[test] + fn a_conflict_is_reported_against_the_command_that_lost() { + let summary = with_user(&[ + user("ctrl-alt-9", "Workspace", "rgitui::Fetch"), + user("ctrl-alt-9", "Workspace", "rgitui::Pull"), + ]); + + let warnings = summary.warnings(CommandId::Fetch); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("ctrl-alt-9"), "{warnings:?}"); + assert!(warnings[0].contains("rgitui::Fetch"), "{warnings:?}"); + assert!(warnings[0].contains("is ignored"), "{warnings:?}"); + // Fetch keeps its own default; only the extra binding was dropped. + assert_eq!( + summary.display(CommandId::Fetch).as_deref(), + Some("Ctrl+Shift+R") + ); + assert_eq!( + summary.display(CommandId::Pull).as_deref(), + Some("Ctrl+Alt+9") + ); + assert_eq!(summary.warning_count(), 1); + } + + #[test] + fn a_chord_shadowed_by_its_prefix_is_reported() { + let summary = with_user(&[ + user("ctrl-alt-k ctrl-alt-o", "Workspace", "rgitui::OpenRepo"), + user("ctrl-alt-k", "Workspace", "rgitui::Pull"), + ]); + let warnings = summary.warnings(CommandId::Pull); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("shadows the chord"), "{warnings:?}"); + assert_eq!(summary.display(CommandId::Pull), None); + // The default comes first: defaults are applied before user bindings. + assert_eq!( + summary.display(CommandId::OpenRepo).as_deref(), + Some("Ctrl+O or Ctrl+Alt+K Ctrl+Alt+O") + ); + } + + #[test] + fn a_binding_for_an_unknown_action_is_not_silently_dropped() { + let summary = with_user(&[ + user("ctrl-alt-9", "Workspace", "rgitui::Nonexistent"), + user("ctrl-alt-9", "Workspace", "rgitui::Pull"), + ]); + assert_eq!(summary.unattributed_warnings.len(), 1, "{summary:?}"); + assert!(summary.unattributed_warnings[0].contains("rgitui::Nonexistent")); + assert!(summary.warning_count() > 0); + } + + #[test] + fn groups_follow_the_registry_views_and_put_bound_commands_first() { + let groups = defaults().groups(); + let views: Vec<&str> = groups.iter().map(|group| group.view).collect(); + assert_eq!(views.first(), Some(&"Workspace")); + assert!(views.contains(&"DiffViewer"), "{views:?}"); + + let total: usize = groups.iter().map(|group| group.commands.len()).sum(); + assert_eq!( + total, + ALL_COMMANDS.len(), + "every command is in exactly one group" + ); + + for group in &groups { + assert!(!group.description().is_empty()); + let first_unbound = group + .commands + .iter() + .position(|entry| entry.bindings.is_empty()); + if let Some(first_unbound) = first_unbound { + assert!( + group.commands[first_unbound..] + .iter() + .all(|entry| entry.bindings.is_empty()), + "{}: a bound command follows an unbound one", + group.view + ); + } + } + } + + #[test] + fn a_group_description_names_its_key_contexts() { + let groups = defaults().groups(); + let diff = groups + .iter() + .find(|group| group.view == "DiffViewer") + .expect("the diff viewer is a group"); + assert!( + diff.description() + .contains("DiffViewer && !modal && !TextInput"), + "{}", + diff.description() + ); + } +} diff --git a/crates/rgitui_workspace/src/lib.rs b/crates/rgitui_workspace/src/lib.rs index f6574500..570a6ea0 100644 --- a/crates/rgitui_workspace/src/lib.rs +++ b/crates/rgitui_workspace/src/lib.rs @@ -17,6 +17,7 @@ mod github_data_service; mod github_device_flow; mod interactive_rebase; mod issues_panel; +pub mod keymap; mod markdown_view; mod prs_panel; mod reflog_view; @@ -28,6 +29,7 @@ mod settings_window; mod shortcuts_help; mod sidebar; mod splash_screen; +mod squash; mod stash_branch_dialog; mod stash_save_dialog; mod status_bar; @@ -55,6 +57,9 @@ pub use detail_panel::*; pub use file_history_view::*; pub use interactive_rebase::*; pub use issues_panel::*; +// `CommandId` and the command metadata are generated by `keymap`'s `commands!` +// macro; re-exported at the crate root where they used to live. +pub use keymap::{CommandId, CommandMeta, ALL_COMMANDS}; pub use markdown_view::*; pub use prs_panel::*; pub use reflog_view::*; diff --git a/crates/rgitui_workspace/src/prs_panel.rs b/crates/rgitui_workspace/src/prs_panel.rs index 8d73fb2d..2fbd5852 100644 --- a/crates/rgitui_workspace/src/prs_panel.rs +++ b/crates/rgitui_workspace/src/prs_panel.rs @@ -6,8 +6,8 @@ use futures::AsyncReadExt; use gpui::prelude::*; use gpui::{ div, http_client::AsyncBody, px, uniform_list, App, ClickEvent, Context, ElementId, Entity, - EventEmitter, FocusHandle, KeyDownEvent, Render, ScrollStrategy, SharedString, - UniformListScrollHandle, WeakEntity, Window, + EventEmitter, FocusHandle, Render, ScrollStrategy, SharedString, UniformListScrollHandle, + WeakEntity, Window, }; use http_client::HttpClient; @@ -23,6 +23,9 @@ use rgitui_ui::{ TextInput, TintColor, }; +use crate::keymap; +use crate::CommandId; + #[derive(Clone, Debug)] pub struct PullRequest { pub number: u64, @@ -733,60 +736,50 @@ impl PrsPanel { .child(el) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { if self.view_mode == PrsPanelView::Detail { - if key == "escape" { + if cmd == CommandId::Cancel { self.go_back(cx); - cx.stop_propagation(); } return; } let count = self.prs.len(); if count == 0 { + cx.propagate(); return; } - match key { - "down" | "j" => { - let next = self - .selected_index - .map(|i| (i + 1).min(count - 1)) - .unwrap_or(0); - self.selected_index = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "up" | "k" => { - let prev = self - .selected_index - .map(|i| i.saturating_sub(1)) - .unwrap_or(0); - self.selected_index = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); + match cmd { + CommandId::SelectNext => self.select_row( + self.selected_index.map_or(0, |i| (i + 1).min(count - 1)), + cx, + ), + CommandId::SelectPrev => { + self.select_row(self.selected_index.map_or(0, |i| i.saturating_sub(1)), cx) } - "enter" => { + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(count - 1, cx), + CommandId::Confirm => { if let Some(index) = self.selected_index { self.select_pr(index, cx); - cx.stop_propagation(); } } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.selected_index = Some(row); + self.scroll_handle + .scroll_to_item(row, ScrollStrategy::Nearest); + cx.notify(); + } + fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { if self.view_mode == PrsPanelView::Detail { return div() @@ -1272,7 +1265,9 @@ impl Render for PrsPanel { let mut panel = div() .id("prs-panel") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "PrsPanel List", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .size_full() .bg(panel_bg); diff --git a/crates/rgitui_workspace/src/reflog_view.rs b/crates/rgitui_workspace/src/reflog_view.rs index da8c3a10..d0fc040b 100644 --- a/crates/rgitui_workspace/src/reflog_view.rs +++ b/crates/rgitui_workspace/src/reflog_view.rs @@ -4,14 +4,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ div, px, uniform_list, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, - KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, - SharedString, UniformListScrollHandle, WeakEntity, Window, + ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, + UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::ReflogEntryInfo; use rgitui_settings::SettingsState; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + // Use Clock icon for reflog since History icon doesn't exist const REFLOG_ICON: IconName = IconName::Clock; @@ -77,74 +80,44 @@ impl ReflogView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let modifiers = &event.keystroke.modifiers; + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.entries.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(entry) = self.entries.get(row) { - let oid = entry.new_oid.to_string(); - cx.emit(ReflogViewEvent::CommitSelected(oid)); - } + match cmd { + CommandId::Cancel => cx.emit(ReflogViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + CommandId::Confirm => { + if let Some(entry) = self.highlighted_row.and_then(|row| self.entries.get(row)) { + cx.emit(ReflogViewEvent::CommitSelected(entry.new_oid.to_string())); } - cx.stop_propagation(); - } - "escape" => { - cx.emit(ReflogViewEvent::Dismissed); - cx.stop_propagation(); } - "g" => { - if modifiers.shift { - // G (Shift+G) — jump to last entry - let last = count - 1; - self.highlighted_row = Some(last); - self.scroll_handle - .scroll_to_item(last, ScrollStrategy::Bottom); - } else { - // g — jump to first entry - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - } - cx.notify(); - cx.stop_propagation(); - } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); @@ -209,7 +182,7 @@ impl ReflogView { impl Render for ReflogView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.entries.is_empty() { return self.render_empty_state(cx); @@ -394,8 +367,15 @@ impl Render for ReflogView { div() .id("reflog-view") .track_focus(&self.focus_handle) - .key_context("ReflogView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ReflogView List", + &["Menu", "ReflogView"], + cx, + Self::dispatch_command, + ) + }) .on_mouse_down( MouseButton::Left, move |_: &MouseDownEvent, _: &mut Window, _cx: &mut App| { diff --git a/crates/rgitui_workspace/src/rename_dialog.rs b/crates/rgitui_workspace/src/rename_dialog.rs index a5e852a5..157ddd58 100644 --- a/crates/rgitui_workspace/src/rename_dialog.rs +++ b/crates/rgitui_workspace/src/rename_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the rename dialog. #[derive(Debug, Clone)] pub enum RenameDialogEvent { @@ -151,14 +153,14 @@ impl RenameDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `RenameDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -174,7 +176,7 @@ impl Render for RenameDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let new_name = self.editor.read(cx).text().to_string(); let is_empty = new_name.is_empty(); let has_error = self.error_message.is_some(); @@ -192,7 +194,9 @@ impl Render for RenameDialog { let mut modal = div() .id("rename-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RenameDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/repo_clone_dialog.rs b/crates/rgitui_workspace/src/repo_clone_dialog.rs index ca2648f5..773decd4 100644 --- a/crates/rgitui_workspace/src/repo_clone_dialog.rs +++ b/crates/rgitui_workspace/src/repo_clone_dialog.rs @@ -2,14 +2,16 @@ use std::path::PathBuf; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, FontWeight, KeyDownEvent, - Render, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, FontWeight, Render, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Debug, Clone)] pub enum RepoCloneEvent { CloneRepo { url: String, path: PathBuf }, @@ -179,11 +181,14 @@ impl RepoCloneDialog { .detach(); } - fn handle_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - // Enter is handled solely via each editor's `Submit` event so it fires - // exactly once; here we only need the modal-level Escape-to-dismiss. - if event.keystroke.key.as_str() == "escape" { - self.hide(cx); + /// Runs a keyboard command scoped to `RepoCloneDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.hide(cx), + _ => cx.propagate(), } } } @@ -218,7 +223,7 @@ impl Render for RepoCloneDialog { self.url_editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let url = self.url_editor.read(cx).text().trim().to_string(); let path = self.path_editor.read(cx).text().trim().to_string(); let can_clone = !url.is_empty() && !path.is_empty(); @@ -232,7 +237,9 @@ impl Render for RepoCloneDialog { let mut modal = div() .id("repo-clone-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RepoCloneDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(500.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/repo_opener.rs b/crates/rgitui_workspace/src/repo_opener.rs index 6fd314d4..44d05cc7 100644 --- a/crates/rgitui_workspace/src/repo_opener.rs +++ b/crates/rgitui_workspace/src/repo_opener.rs @@ -2,14 +2,17 @@ use std::path::PathBuf; use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, FontWeight, - KeyDownEvent, Render, SharedString, Window, + div, px, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, FontWeight, Render, + SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + #[derive(Debug, Clone)] pub enum RepoOpenerEvent { OpenRepo(PathBuf), @@ -182,46 +185,46 @@ impl RepoOpener { .detach(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - - match key { - "escape" => { - self.dismiss(cx); - cx.stop_propagation(); - } - "up" => { - if self.filtered_indices.is_empty() { - return; - } - self.selected_index = Some(match self.selected_index { + /// Runs a keyboard command scoped to `RepoOpener` or to the shared `List` + /// group. + /// + /// Enter is propagated so the path field's own submission opens the + /// highlighted repository exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::SelectPrev => self.select_row( + match self.selected_index { Some(index) if index > 0 => index - 1, Some(index) => index, None => self.filtered_indices.len().saturating_sub(1), - }); - cx.notify(); - cx.stop_propagation(); - } - "down" => { - if self.filtered_indices.is_empty() { - return; - } - self.selected_index = Some(match self.selected_index { + }, + cx, + ), + CommandId::SelectNext => self.select_row( + match self.selected_index { Some(index) if index + 1 < self.filtered_indices.len() => index + 1, Some(index) => index, None => 0, - }); - cx.notify(); - cx.stop_propagation(); + }, + cx, + ), + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => { + self.select_row(self.filtered_indices.len().saturating_sub(1), cx) } - _ => {} + _ => cx.propagate(), } } + + /// Moves the highlight within the filtered list. + fn select_row(&mut self, row: usize, cx: &mut Context) { + if self.filtered_indices.is_empty() { + return; + } + self.selected_index = Some(row.min(self.filtered_indices.len() - 1)); + cx.notify(); + } } impl Render for RepoOpener { @@ -230,12 +233,14 @@ impl Render for RepoOpener { return div().id("repo-opener").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let mut modal = div() .id("repo-opener-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "RepoOpener List", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(500.)) .max_h(px(480.)) diff --git a/crates/rgitui_workspace/src/search_panel.rs b/crates/rgitui_workspace/src/search_panel.rs index cf2eca80..1f082a6e 100644 --- a/crates/rgitui_workspace/src/search_panel.rs +++ b/crates/rgitui_workspace/src/search_panel.rs @@ -4,13 +4,16 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, KeyDownEvent, - Render, ScrollHandle, SharedString, Window, + div, px, App, ClickEvent, Context, ElementId, Entity, EventEmitter, FocusHandle, Render, + ScrollHandle, SharedString, Window, }; use rgitui_git::SearchResult; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent}; +use crate::keymap; +use crate::CommandId; + const SEARCH_RESULT_ROW_HEIGHT: f32 = 28.0; const SEARCH_RESULTS_PAGE_SIZE: usize = 250; @@ -269,81 +272,57 @@ impl GlobalSearchView { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); + /// Runs a keyboard command scoped to the shared `List` group. + /// + /// Enter opens the highlighted match; with nothing highlighted it is + /// propagated so the query field's own submission runs the search. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.results.len(); - match key { - "j" | "down" => { + match cmd { + CommandId::Cancel => cx.emit(GlobalSearchViewEvent::Dismissed), + CommandId::Confirm => match self.highlighted_row.and_then(|r| self.results.get(r)) { + Some(result) => cx.emit(GlobalSearchViewEvent::ResultSelected { + path: result.path.to_string_lossy().to_string(), + line_number: result.line_number, + }), + None => cx.propagate(), + }, + _ if count == 0 => {} + CommandId::SelectNext => { let next = self .highlighted_row - .map(|r| (r + 1).min(count.saturating_sub(1))) - .unwrap_or(0); - if count > 0 { - if next >= self.visible_result_count { - self.visible_result_count = - expanded_visible_result_count(self.visible_result_count, count); - } - self.highlighted_row = Some(next); - self.scroll_handle.scroll_to_item(next); - cx.notify(); + .map_or(0, |row| (row + 1).min(count - 1)); + // Walking past the rendered page is an explicit request for more. + if next >= self.visible_result_count { + self.visible_result_count = + expanded_visible_result_count(self.visible_result_count, count); } - cx.stop_propagation(); + self.highlight_row(next, cx); } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - if count > 0 { - self.highlighted_row = Some(prev); - self.scroll_handle.scroll_to_item(prev); - cx.notify(); - } - cx.stop_propagation(); + CommandId::SelectPrev => { + let prev = self.highlighted_row.map_or(0, |row| row.saturating_sub(1)); + self.highlight_row(prev, cx); } - "enter" => { - if let Some(row) = self.highlighted_row { - if let Some(result) = self.results.get(row) { - cx.emit(GlobalSearchViewEvent::ResultSelected { - path: result.path.to_string_lossy().to_string(), - line_number: result.line_number, - }); - } - } - cx.stop_propagation(); - } - "escape" => { - cx.emit(GlobalSearchViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - if event.keystroke.modifiers.shift { - // Shift+G — jump to last - if count > 0 { - // Jumping to the final match is an explicit request to - // materialize the remaining pages. - self.visible_result_count = count; - self.highlighted_row = Some(count - 1); - self.scroll_handle.scroll_to_item(count - 1); - cx.notify(); - } - } else if count > 0 { - // g — jump to first - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0); - cx.notify(); - } - cx.stop_propagation(); + CommandId::SelectFirst => self.highlight_row(0, cx), + CommandId::SelectLast => { + // Jumping to the final match is an explicit request to + // materialize the remaining pages. + self.visible_result_count = count; + self.highlight_row(count - 1, cx); } - _ => {} + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row); + cx.notify(); + } } impl Render for GlobalSearchView { @@ -366,7 +345,16 @@ impl Render for GlobalSearchView { .min_h_0() .overflow_hidden() .bg(colors.editor_background) - .on_key_down(cx.listener(Self::handle_key_down)) + .track_focus(&self.focus_handle) + .map(|el| { + keymap::bind_actions( + el, + "SearchPanel List", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .child(self.render_toolbar(result_count, loading, cx)); if content_state == SearchContentState::Results { diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 1dad27a2..23a6be9f 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -31,6 +31,24 @@ use rgitui_ui::{ use super::events::SettingsViewEvent; use super::{SettingsWindowAction, SettingsWindowActionGlobal}; use crate::github_device_flow::{self, DeviceFlowStatus}; +use crate::CommandId; + +/// The commands the General section shows a keystroke for. +/// +/// Only the command *ids* are listed — the label is the command's doc comment and +/// the keystroke comes from the keymap, so this cannot drift from what the keys +/// actually do. It is a short list on purpose; the full reference lives in the +/// workspace's shortcut panel. +const QUICK_REFERENCE_COMMANDS: &[CommandId] = &[ + CommandId::CommandPalette, + CommandId::Search, + CommandId::StageAll, + CommandId::UnstageAll, + CommandId::Commit, + CommandId::OpenRepo, + CommandId::Refresh, + CommandId::Settings, +]; /// Which section of the settings is currently active. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3846,29 +3864,23 @@ impl SettingsView { section = section.child(tools_card); section = section.child(Self::section_divider(cx)); - // Keyboard shortcuts info card + // Keyboard shortcuts quick reference, read from the keymap in force so + // it follows the user's keymap.json instead of quoting a default. let mut shortcuts_card = Self::setting_card(cx); - shortcuts_card = shortcuts_card.child( - div() - .v_flex() - .gap(px(8.)) - .child(Self::setting_label( - "Keyboard Shortcuts", - "Quick reference for common actions.", - )) - .child(self.render_shortcut_row("Command Palette", "Ctrl+Shift+P", cx)) - .child(self.render_shortcut_row("Search Commits", "Ctrl+F", cx)) - .child(self.render_shortcut_row("Stage All", "Ctrl+S", cx)) - .child(self.render_shortcut_row("Unstage All", "Ctrl+Shift+S", cx)) - .child(self.render_shortcut_row("Commit", "Ctrl+Enter (in message)", cx)) - .child(self.render_shortcut_row("Open Repository", "Ctrl+O", cx)) - .child(self.render_shortcut_row("Refresh", "F5", cx)) - .child(self.render_shortcut_row("Settings", "Ctrl+,", cx)), - ); + let mut shortcuts_list = div().v_flex().gap(px(8.)).child(Self::setting_label( + "Keyboard Shortcuts", + "The bindings in force for a few common actions. Open the full \ + reference from the workspace for all of them.", + )); + for id in QUICK_REFERENCE_COMMANDS { + shortcuts_list = shortcuts_list.child(self.render_shortcut_row(*id, cx)); + } + shortcuts_list = shortcuts_list.child(Self::keymap_file_row(cx)); + shortcuts_card = shortcuts_card.child(shortcuts_list); section = section.child(shortcuts_card); section = section.child(Self::section_divider(cx)); - let config_path = config_dir().join("settings.json"); + let config_path = rgitui_settings::settings_path(); let config_path_display = config_path.display().to_string(); let config_dir_path = config_dir(); let mut config_card = Self::setting_card(cx); @@ -3926,24 +3938,43 @@ impl SettingsView { section } - fn render_shortcut_row( - &self, - action: &str, - shortcut: &str, - cx: &Context, - ) -> impl IntoElement { + /// One quick-reference row: the command's description and the keystroke the + /// keymap currently binds it to. + /// + /// A binding the user defined is tinted and labelled, matching the badge in + /// the full shortcut reference. + fn render_shortcut_row(&self, id: CommandId, cx: &Context) -> impl IntoElement { let colors = cx.colors(); + let summary = crate::keymap::summary(cx); + let is_user_defined = summary.is_user_defined(id); + let shortcut = summary + .display(id) + .unwrap_or_else(|| crate::keymap::display::UNBOUND.to_owned()); + let color = if is_user_defined { + Color::Info + } else { + Color::Muted + }; + div() .h_flex() .w_full() .items_center() + .gap(px(6.)) .py(px(4.)) .child( - Label::new(SharedString::from(action.to_string())) + Label::new(SharedString::from(id.description())) .size(LabelSize::XSmall) .color(Color::Muted), ) .child(div().flex_1()) + .when(is_user_defined, |row| { + row.child( + Label::new("keymap.json") + .size(LabelSize::XSmall) + .color(Color::Info), + ) + }) .child( div() .h_flex() @@ -3953,13 +3984,55 @@ impl SettingsView { .bg(colors.hint_background) .items_center() .child( - Label::new(SharedString::from(shortcut.to_string())) + Label::new(SharedString::from(shortcut)) .size(LabelSize::XSmall) - .color(Color::Muted) + .color(color) .weight(FontWeight::SEMIBOLD), ), ) } + + /// The `keymap.json` path plus a button that opens it, creating the file with + /// a commented starter when it does not exist yet. + fn keymap_file_row(cx: &Context) -> impl IntoElement { + let path = crate::keymap::keymap_path(); + let path_display = path.display().to_string(); + let editor_command = cx + .try_global::() + .map(|state| state.settings().editor_command.clone()) + .unwrap_or_default(); + + div() + .h_flex() + .w_full() + .items_center() + .gap(px(8.)) + .pt(px(8.)) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(path_display)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child( + Button::new("open-keymap-file", "Edit keymap.json") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Settings) + .on_click( + move |_: &ClickEvent, _, _cx| match crate::keymap::ensure_keymap_file() { + Ok(path) => { + crate::workspace::open_editor(&path, &editor_command); + } + Err(error) => log::error!( + "keymap: could not create {}: {error}", + crate::keymap::keymap_path().display() + ), + }, + ), + ) + } } impl SettingsView { diff --git a/crates/rgitui_workspace/src/settings_window/window.rs b/crates/rgitui_workspace/src/settings_window/window.rs index 7da5d975..1f96f650 100644 --- a/crates/rgitui_workspace/src/settings_window/window.rs +++ b/crates/rgitui_workspace/src/settings_window/window.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, Bounds, ClickEvent, Context, Entity, FocusHandle, KeyDownEvent, Pixels, Render, - Subscription, Window, + div, px, Bounds, ClickEvent, Context, Entity, FocusHandle, Pixels, Render, Subscription, Window, }; use rgitui_settings::{SavedWindowBounds, SettingsState}; use rgitui_theme::{ActiveTheme, StyledExt, ThemeState}; @@ -23,6 +22,9 @@ pub struct SettingsWindow { _view_subscription: Subscription, } +use crate::keymap; +use crate::CommandId; + impl SettingsWindow { /// Construct a `SettingsWindow`. Must be called inside the new window's /// `cx.open_window` callback so the inner `SettingsView` (and its @@ -92,14 +94,14 @@ impl SettingsWindow { &self.view } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - _cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - window.remove_window(); + /// Runs a keyboard command scoped to `SettingsWindow`. + /// + /// Each window owns its own dismissal: workspace overlays are not visible + /// from here and cannot be dismissed from here. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => window.remove_window(), + _ => cx.propagate(), } } } @@ -117,7 +119,9 @@ impl Render for SettingsWindow { div() .id("settings-window-root") .track_focus(&self.focus) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "SettingsWindow", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .size_full() .bg(background) diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index d020c36c..cff36fff 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -1,53 +1,39 @@ -use std::borrow::Cow; +//! The keyboard shortcut reference, rendered from the keymap in force. +//! +//! Nothing here is a literal list of shortcuts. Every row comes from +//! [`crate::keymap::summary`]: the description is the command's doc comment, the +//! keystroke is what the user's `keymap.json` actually produced, a binding the +//! user supplied is badged as such, and a binding that was dropped — because two +//! of theirs collided, or because a keystroke now belongs to another command — +//! is shown as a warning on the row it affects. Opening this panel is therefore +//! how a user finds out *which* of their bindings was ignored and why. +//! +//! Rows also carry the informational notes: a panel binding winning a keystroke +//! from a global one is legitimate scoping, so it is styled as an aside and is +//! deliberately absent from the "with a problem" count in the header. use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, KeyDownEvent, Render, + div, px, ClickEvent, Context, EventEmitter, FocusHandle, FontWeight, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; -use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; +use rgitui_ui::{ + Badge, Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, +}; + +use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary, NoteSeverity}; +use crate::CommandId; #[derive(Debug, Clone)] pub enum ShortcutsHelpEvent { Dismissed, + /// The user asked to edit `keymap.json`; the workspace opens it. + OpenKeymapFile, } -struct ShortcutCategory { - title: &'static str, - description: &'static str, - shortcuts: &'static [(&'static str, &'static str)], -} - -/// Name of the platform's primary shortcut modifier, matching -/// `gpui::Modifiers::secondary`: the Command key on macOS, Control elsewhere. -const PRIMARY_MODIFIER: &str = if cfg!(target_os = "macos") { - "Cmd" -} else { - "Ctrl" -}; - -/// Labels that stay on Control everywhere because macOS reserves the Command -/// equivalent: Cmd+Tab is the application switcher and Cmd+H is Hide -/// Application, both swallowed by the WindowServer before the app sees them. -/// `key_handler` binds these to `modifiers.control` rather than the primary -/// modifier, so the help must not promise Cmd. -const PLATFORM_FIXED_LABELS: &[&str] = &["Ctrl+H", "Ctrl+Tab / Ctrl+Shift+Tab"]; - -/// Free-form help copy that names a chord, and so has to track the platform's -/// primary modifier just like the shortcut table does. -const PALETTE_TIP: &str = "Tip: plain-letter shortcuts like d, b, h, j, and k depend on which panel is focused. Use Ctrl+Shift+P for less common actions like reflog, submodules, bisect, and stash management."; -const MORE_ACTIONS_HINT: &str = "More actions: Ctrl+Shift+P"; - -/// Rewrites the `Ctrl` chords in a shortcut label to the platform's primary -/// modifier, so macOS shows `Cmd+Shift+P` rather than `Ctrl+Shift+P`. -fn with_primary_modifier<'a>(label: &'a str, primary: &str) -> Cow<'a, str> { - if primary == "Ctrl" || !label.contains("Ctrl+") || PLATFORM_FIXED_LABELS.contains(&label) { - Cow::Borrowed(label) - } else { - Cow::Owned(label.replace("Ctrl+", &format!("{primary}+"))) - } -} +/// Badge text marking a binding that came from the user's `keymap.json`. +const USER_BINDING_BADGE: &str = "keymap.json"; pub struct ShortcutsHelp { visible: bool, @@ -87,113 +73,123 @@ impl ShortcutsHelp { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); - cx.stop_propagation(); + /// Runs a keyboard command scoped to `ShortcutsHelp`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } - fn shortcut_categories() -> Vec { - vec![ - ShortcutCategory { - title: "Workspace", - description: "App-level actions available from anywhere outside active overlays.", - shortcuts: &[ - ("Ctrl+Shift+P", "Open command palette"), - ("Ctrl+O", "Open repository"), - ("Ctrl+H", "Go to workspace home"), - ("Ctrl+W", "Close current tab"), - ("Ctrl+,", "Open settings"), - ("F5", "Refresh repository state"), - ("?", "Open this help"), - ], - }, - ShortcutCategory { - title: "Navigation", - description: "Focus a panel first. Plain-letter shortcuts are context-sensitive.", - shortcuts: &[ - ("j / k", "Move up / down in the focused panel"), - ("g / G", "Jump to first / last item"), - ("Tab / Shift+Tab", "Cycle focused panel"), - ("Alt+1 / 2 / 3 / 4", "Focus sidebar / graph / detail / diff"), - ( - "Alt+5 / 6 / 7", - "Toggle issues / PRs / branch health panels", - ), - ("Alt+8", "Toggle stashes panel"), - ("Ctrl+Shift+T / Alt+9", "Open theme editor"), - ("Ctrl+Tab / Ctrl+Shift+Tab", "Next / previous tab"), - ("v", "Toggle changed-files view (flat / tree)"), - ("Enter / Space", "Activate selected sidebar item"), - ], - }, - ShortcutCategory { - title: "Views & Search", - description: "Fast access to panels and graph-specific tools.", - shortcuts: &[ - ("Ctrl+F", "Toggle commit graph search"), - ("Ctrl+Shift+F", "Toggle global search across the repository"), - ("/", "Start in-graph search"), - ("d", "Toggle diff mode (unified / split)"), - ("b", "Toggle blame view for selected file"), - ("h", "Toggle file history view for selected file"), - ("y", "Copy SHA of selected commit"), - ("Shift+C", "Copy commit message of selected commit"), - ( - "Mouse drag / Shift+click", - "Select lines in the diff viewer", - ), - ("Ctrl+C", "Copy selected diff lines"), - ("Esc", "Close the active overlay or modal"), - ], - }, - ShortcutCategory { - title: "Git & AI", - description: - "Common write actions. More advanced operations live in the command palette.", - shortcuts: &[ - ("Ctrl+Shift+R", "Fetch"), - ("Ctrl+S", "Stage all changes"), - ("Ctrl+Shift+S / Ctrl+U", "Unstage all changes"), - ("Ctrl+Enter", "Commit"), - ("Ctrl+B", "Create branch"), - ("Ctrl+Shift+B", "Switch branch (focus sidebar)"), - ("Ctrl+Z / Ctrl+Shift+Z", "Stash changes / pop stash"), - ("Ctrl+G", "Generate AI commit message"), - ("s", "Stage / unstage selected file in sidebar"), - ( - "Alt+S / Alt+U", - "Stage / unstage current hunk in diff viewer", + /// Renders one command's row: what it does, what it is bound to, where that + /// binding came from and anything wrong with it. + fn render_command(&self, entry: &CommandBindings, cx: &mut Context) -> impl IntoElement { + let colors = cx.colors().clone(); + let hover_bg = colors.ghost_element_hover; + let is_user_defined = entry.is_user_defined(); + let keystrokes = entry.display(); + + let mut row = div() + .v_flex() + .w_full() + .py(px(5.)) + .px(px(4.)) + .rounded(px(4.)) + .gap(px(3.)) + .hover(move |s| s.bg(hover_bg)); + + let mut headline = div() + .flex() + .flex_row() + .w_full() + .items_center() + .gap(px(10.)) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(entry.command.description())) + .size(LabelSize::Small) + .color(if keystrokes.is_some() { + Color::Default + } else { + Color::Muted + }), + ), + ); + + if is_user_defined { + headline = headline.child( + div() + .flex_shrink_0() + .child(Badge::new(USER_BINDING_BADGE).color(Color::Info)), + ); + } + + headline = match keystrokes { + Some(keystrokes) => headline.child( + div() + .h_flex() + .flex_shrink_0() + .h(px(24.)) + .px(px(10.)) + .gap_1() + .rounded(px(5.)) + .border_1() + .border_color(if is_user_defined { + Color::Info.color(cx) + } else { + colors.border + }) + .bg(colors.hint_background) + .items_center() + .child( + Label::new(SharedString::from(keystrokes)) + .size(LabelSize::Small) + .weight(FontWeight::BOLD) + .color(Color::Default), ), - ("p", "Toggle partial (line-selection) staging mode"), - ( - "s / u", - "Stage / unstage hunks under selection (or cursor hunk)", + ), + None => headline.child( + div().flex_shrink_0().child( + Label::new(keymap::display::UNBOUND) + .size(LabelSize::XSmall) + .color(Color::Placeholder), + ), + ), + }; + + row = row.child(headline); + + for note in &entry.notes { + // Info notes are the deliberate deeper-wins scoping the defaults rely + // on, so they read as an aside rather than as something to fix. + let (icon, color) = match note.severity { + NoteSeverity::Warning => (IconName::AlertTriangle, Color::Warning), + NoteSeverity::Info => (IconName::Info, Color::Muted), + }; + row = row.child( + div() + .h_flex() + .w_full() + .gap(px(6.)) + .items_start() + .child(Icon::new(icon).size(IconSize::XSmall).color(color)) + .child( + div().flex_1().min_w_0().child( + Label::new(SharedString::from(note.message.clone())) + .size(LabelSize::XSmall) + .color(color), + ), ), - ("x / Delete", "Discard selected sidebar item"), - ], - }, - ] - } + ); + } - fn shortcut_count(categories: &[ShortcutCategory]) -> usize { - categories - .iter() - .map(|category| category.shortcuts.len()) - .sum() + row } - fn render_category( - &self, - category: &ShortcutCategory, - cx: &mut Context, - ) -> impl IntoElement { + fn render_group(&self, group: &CommandGroup, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let border_variant = colors.border_variant; @@ -208,64 +204,45 @@ impl ShortcutsHelp { .v_flex() .gap(px(4.)) .child( - Label::new(category.title) + Label::new(group.view) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD) .color(Color::Accent), ) .child( - Label::new(category.description) - .size(LabelSize::Small) + Label::new(SharedString::from(group.description())) + .size(LabelSize::XSmall) .color(Color::Muted), ), ), ); - for (key, desc) in category.shortcuts { - let hover_bg = colors.ghost_element_hover; - col = col.child( - div() - .h_flex() - .w_full() - .py(px(5.)) - .px(px(4.)) - .rounded(px(4.)) - .items_center() - .gap(px(16.)) - .hover(move |s| s.bg(hover_bg)) - .child( - div().flex_1().min_w_0().child( - Label::new(*desc) - .size(LabelSize::Small) - .color(Color::Default), - ), - ) - .child( - div() - .h_flex() - .flex_shrink_0() - .h(px(24.)) - .px(px(10.)) - .gap_1() - .rounded(px(5.)) - .border_1() - .border_color(colors.border) - .bg(colors.hint_background) - .items_center() - .child( - Label::new( - with_primary_modifier(key, PRIMARY_MODIFIER).into_owned(), - ) - .size(LabelSize::Small) - .weight(FontWeight::BOLD) - .color(Color::Default), - ), - ), - ); + for entry in &group.commands { + col = col.child(self.render_command(entry, cx)); } col } + + /// The header summary line, counted from the keymap rather than written out. + fn subtitle(summary: &KeymapSummary) -> String { + let mut parts = vec![format!( + "{} of {} commands are bound", + summary.bound_command_count(), + summary.commands().len() + )]; + let user_bindings = summary.user_binding_count(); + if user_bindings > 0 { + parts.push(format!("{user_bindings} from your keymap.json",)); + } + let warnings = summary.warning_count(); + if warnings > 0 { + parts.push(format!( + "{warnings} with a problem — see the warnings below", + )); + } + parts.join(", ") + } } impl Render for ShortcutsHelp { @@ -274,8 +251,11 @@ impl Render for ShortcutsHelp { return div().id("shortcuts-help").into_any_element(); } - let categories = Self::shortcut_categories(); - let total_shortcuts = Self::shortcut_count(&categories); + let summary = keymap::summary(cx); + let groups = summary.groups(); + let subtitle = Self::subtitle(&summary); + let palette_hint = keymap::shortcut(CommandId::CommandPalette, cx); + let keymap_file = keymap::keymap_path().display().to_string(); let colors = cx.colors().clone(); let viewport = window.viewport_size(); @@ -285,15 +265,28 @@ impl Render for ShortcutsHelp { let modal_height = px((viewport_height - 32.0).clamp(280.0, 720.0)); let use_two_columns = viewport_width >= 960.0; + // Split the groups into two balanced columns by row count, so a long + // block like `Workspace` does not leave the second column empty. let body = if use_two_columns { + let total: usize = groups.iter().map(|group| group.commands.len()).sum(); + let mut running = 0usize; + let split = groups + .iter() + .position(|group| { + running += group.commands.len(); + running * 2 >= total + }) + .map_or(groups.len(), |index| index + 1) + .min(groups.len()); + let mut left_col = div().v_flex().flex_1().min_w_0().gap(px(16.)); - for category in &categories[..2] { - left_col = left_col.child(self.render_category(category, cx)); + for group in &groups[..split] { + left_col = left_col.child(self.render_group(group, cx)); } let mut right_col = div().v_flex().flex_1().min_w_0().gap(px(16.)); - for category in &categories[2..] { - right_col = right_col.child(self.render_category(category, cx)); + for group in &groups[split..] { + right_col = right_col.child(self.render_group(group, cx)); } div() @@ -312,8 +305,8 @@ impl Render for ShortcutsHelp { .into_any_element() } else { let mut column = div().v_flex().w_full().gap(px(16.)); - for category in &categories { - column = column.child(self.render_category(category, cx)); + for group in &groups { + column = column.child(self.render_group(group, cx)); } div() @@ -351,7 +344,9 @@ impl Render for ShortcutsHelp { let modal = div() .id("shortcuts-help-container") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "ShortcutsHelp", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(modal_width) .h(modal_height) @@ -374,6 +369,7 @@ impl Render for ShortcutsHelp { .border_b_1() .border_color(colors.border_variant) .justify_between() + .gap(px(10.)) .child( div() .h_flex() @@ -397,16 +393,24 @@ impl Render for ShortcutsHelp { .weight(FontWeight::SEMIBOLD), ) .child( - Label::new(format!( - "{} shortcuts across navigation, views, workspace, and git actions", - total_shortcuts - )) - .size(LabelSize::XSmall) - .truncate() - .color(Color::Muted), + Label::new(SharedString::from(subtitle)) + .size(LabelSize::XSmall) + .truncate() + .color(Color::Muted), ), ), ) + .child( + Button::new("shortcuts-open-keymap", "Edit keymap.json") + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .icon(IconName::Settings) + .tooltip(SharedString::from(keymap_file)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + cx.emit(ShortcutsHelpEvent::OpenKeymapFile); + this.dismiss(cx); + })), + ) .child( div() .id("shortcuts-close-btn") @@ -429,28 +433,32 @@ impl Render for ShortcutsHelp { ), ) .child( - div() - .w_full() - .px(px(16.)) - .pt(px(12.)) - .child( - div() - .w_full() - .rounded(px(8.)) - .bg(colors.surface_background) - .border_1() - .border_color(colors.border_variant) - .px(px(12.)) - .py(px(10.)) - .child( - Label::new( - with_primary_modifier(PALETTE_TIP, PRIMARY_MODIFIER) - .into_owned(), - ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ), + div().w_full().px(px(16.)).pt(px(12.)).child( + div() + .w_full() + .rounded(px(8.)) + .bg(colors.surface_background) + .border_1() + .border_color(colors.border_variant) + .px(px(12.)) + .py(px(10.)) + .child( + Label::new(SharedString::from(format!( + "Every shortcut below is rebindable, and each is shown as your \ + keymap.json leaves it. Plain-letter shortcuts only act on the \ + panel that has focus — that is what each group's key context \ + means. Commands marked {} have no keystroke and are reached \ + from the command palette{}.", + keymap::display::UNBOUND, + palette_hint + .as_deref() + .map(|hint| format!(" ({hint})")) + .unwrap_or_default(), + ))) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ), ) .child(body) .child( @@ -469,16 +477,16 @@ impl Render for ShortcutsHelp { .size(LabelSize::XSmall) .color(Color::Placeholder), ) - .when(viewport_width >= 520.0, |footer| { - footer.child( - Label::new( - with_primary_modifier(MORE_ACTIONS_HINT, PRIMARY_MODIFIER) - .into_owned(), + .when_some( + palette_hint.filter(|_| viewport_width >= 520.0), + |footer, hint| { + footer.child( + Label::new(SharedString::from(format!("More actions: {hint}"))) + .size(LabelSize::XSmall) + .color(Color::Muted), ) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - }), + }, + ), ); backdrop.child(modal).into_any_element() @@ -488,6 +496,12 @@ impl Render for ShortcutsHelp { #[cfg(test)] mod tests { use super::*; + use crate::keymap::conflict::BindingSpec; + use crate::keymap::display::KeystrokeStyle; + + fn defaults() -> KeymapSummary { + KeymapSummary::defaults(KeystrokeStyle::Words) + } #[test] fn test_shortcuts_help_event_debug() { @@ -497,79 +511,87 @@ mod tests { #[test] fn test_shortcuts_help_event_match() { - let event = ShortcutsHelpEvent::Dismissed; - match event { + match ShortcutsHelpEvent::Dismissed { ShortcutsHelpEvent::Dismissed => {} + ShortcutsHelpEvent::OpenKeymapFile => unreachable!(), } } + /// Every group the view renders has a heading, a derived key-context blurb + /// and at least one row, so no group can render as an empty box. #[test] - fn primary_modifier_rewrites_every_ctrl_chord_on_macos() { - assert_eq!(with_primary_modifier("Ctrl+Shift+P", "Cmd"), "Cmd+Shift+P"); - assert_eq!( - with_primary_modifier("Ctrl+Z / Ctrl+Shift+Z", "Cmd"), - "Cmd+Z / Cmd+Shift+Z" - ); + fn every_group_has_a_heading_and_rows() { + let summary = defaults(); + let groups = summary.groups(); + assert!(!groups.is_empty()); + for group in &groups { + assert!(!group.view.is_empty()); + assert!(!group.description().is_empty()); + assert!(!group.commands.is_empty(), "{} is empty", group.view); + for entry in &group.commands { + assert!( + !entry.command.description().is_empty(), + "{} has no description to render", + entry.command + ); + } + } } + /// The old hand-written table advertised `Ctrl+Shift+F` for Fetch while the + /// keymap bound `Ctrl+Shift+R`. The row now comes from the keymap, so the + /// two cannot differ. #[test] - fn primary_modifier_leaves_labels_untouched_elsewhere() { + fn a_row_shows_the_keystroke_the_keymap_binds() { + let summary = defaults(); assert_eq!( - with_primary_modifier("Ctrl+Shift+P", "Ctrl"), - "Ctrl+Shift+P" - ); - assert_eq!( - with_primary_modifier("Alt+1 / 2 / 3 / 4", "Cmd"), - "Alt+1 / 2 / 3 / 4" + summary.display(CommandId::Fetch), + crate::keymap::humanize_sequence( + CommandId::Fetch.default_bindings()[0].0, + KeystrokeStyle::Words + ) ); - assert_eq!(with_primary_modifier("j / k", "Cmd"), "j / k"); - } - - #[test] - fn every_documented_shortcut_label_is_platform_correct() { - let categories = ShortcutsHelp::shortcut_categories(); - let table = categories - .iter() - .flat_map(|category| category.shortcuts.iter().map(|(label, _)| *label)); - - for label in table.chain([PALETTE_TIP, MORE_ACTIONS_HINT]) { - let rendered = with_primary_modifier(label, PRIMARY_MODIFIER); - if PRIMARY_MODIFIER == "Cmd" && !PLATFORM_FIXED_LABELS.contains(&label) { - assert!( - !rendered.contains("Ctrl+"), - "label {label:?} still advertises Ctrl on a Cmd platform" - ); - } else { - assert_eq!(rendered, label); - } - } } #[test] - fn os_reserved_chords_keep_control_on_every_platform() { - for label in PLATFORM_FIXED_LABELS { - assert_eq!( - with_primary_modifier(label, "Cmd"), - *label, - "{label:?} is bound to Control in key_handler, so the help must not promise Cmd" - ); - } + fn the_subtitle_counts_what_the_keymap_holds() { + let summary = defaults(); + let subtitle = ShortcutsHelp::subtitle(&summary); + assert!( + subtitle.contains(&summary.bound_command_count().to_string()), + "{subtitle}" + ); + // Nothing to warn about and nothing user-defined in the defaults. + assert!(!subtitle.contains("keymap.json"), "{subtitle}"); + assert!(!subtitle.contains("problem"), "{subtitle}"); } + /// A user binding and a conflict both have to be visible in the panel: the + /// badge comes from `is_user_defined`, the warning row from `warnings`. #[test] - fn platform_fixed_labels_all_appear_in_the_shortcut_table() { - let categories = ShortcutsHelp::shortcut_categories(); - let documented: Vec<&str> = categories - .iter() - .flat_map(|category| category.shortcuts.iter().map(|(label, _)| *label)) - .collect(); - - for label in PLATFORM_FIXED_LABELS { - assert!( - documented.contains(label), - "{label:?} is exempted from the Cmd rewrite but no longer appears in the table; \ - the exemption is stale" - ); - } + fn the_subtitle_and_rows_surface_user_bindings_and_conflicts() { + let mut specs = crate::keymap::loader::default_specs(); + specs.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("Workspace"), + "rgitui::Pull", + )); + specs.push(BindingSpec::user_binding( + "ctrl-alt-9", + Some("Workspace"), + "rgitui::Push", + )); + let report = crate::keymap::conflict::detect_conflicts(&specs); + let applied: Vec = (0..specs.len()).filter(|i| report.is_kept(*i)).collect(); + let summary = KeymapSummary::build(&specs, &applied, &report, KeystrokeStyle::Words); + + let subtitle = ShortcutsHelp::subtitle(&summary); + assert!(subtitle.contains("keymap.json"), "{subtitle}"); + assert!(subtitle.contains("problem"), "{subtitle}"); + + // Push won, so it carries the badge; Pull lost, so it carries the warning. + assert!(summary.is_user_defined(CommandId::Push)); + assert_eq!(summary.warnings(CommandId::Pull).len(), 1); + assert!(summary.warnings(CommandId::Pull)[0].contains("ignored")); } } diff --git a/crates/rgitui_workspace/src/sidebar.rs b/crates/rgitui_workspace/src/sidebar.rs index 021960d1..0762c070 100644 --- a/crates/rgitui_workspace/src/sidebar.rs +++ b/crates/rgitui_workspace/src/sidebar.rs @@ -9,9 +9,9 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{ canvas, div, px, uniform_list, App, Bounds, ClickEvent, Context, ElementId, Entity, - EventEmitter, FocusHandle, KeyDownEvent, ListSizingBehavior, MouseButton, MouseDownEvent, - MouseMoveEvent, Pixels, Point, Render, ScrollStrategy, SharedString, Size, - UniformListScrollHandle, WeakEntity, Window, + EventEmitter, FocusHandle, ListSizingBehavior, MouseButton, MouseDownEvent, MouseMoveEvent, + Pixels, Point, Render, ScrollStrategy, SharedString, Size, UniformListScrollHandle, WeakEntity, + Window, }; use rgitui_git::{ BranchInfo, FileChangeKind, FileStatus, RemoteInfo, StashEntry, TagInfo, WorktreeInfo, @@ -23,6 +23,9 @@ use rgitui_ui::{ TextInputEvent, Tooltip, }; +use crate::keymap; +use crate::CommandId; + /// Events from the sidebar. #[derive(Debug, Clone)] pub enum SidebarEvent { @@ -645,149 +648,118 @@ impl Sidebar { } /// Handle keyboard events for sidebar navigation. - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); - let primary = event.keystroke.modifiers.secondary(); - - if self.cached_nav_items.is_empty() { - return; - } - - let wants_filter = (key == "/" && !primary) || (key == "f" && primary); - if wants_filter && !self.branch_filter_active { + /// Runs a keyboard command scoped to `Sidebar` or to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + if cmd == CommandId::FilterBranches { self.branch_filter_active = true; - self.branch_filter_editor.update(cx, |editor, cx| { - editor.focus(_window, cx); - }); + self.branch_filter_editor + .update(cx, |editor, cx| editor.focus(window, cx)); cx.notify(); - cx.stop_propagation(); return; } - // Block Ctrl+F (graph search) when branch filter is active - // so Ctrl+F re-focuses the filter input instead. - if primary && self.branch_filter_active { - cx.stop_propagation(); + if self.cached_nav_items.is_empty() { + cx.propagate(); return; } - if primary { + let last = self.cached_nav_items.len().saturating_sub(1); + match cmd { + CommandId::Cancel => self.clear_branch_filter(cx), + // Bounded section lists follow keyboard selection through their own + // scroll handles; the outer sidebar still owns movement between + // section viewports. + CommandId::SelectPrev => { + self.select_row(self.keyboard_index.map_or(0, |i| i.saturating_sub(1)), cx) + } + CommandId::SelectNext => { + self.select_row(self.keyboard_index.map_or(0, |i| (i + 1).min(last)), cx) + } + CommandId::SelectFirst => self.select_row(0, cx), + CommandId::SelectLast => self.select_row(last, cx), + CommandId::Confirm => self.activate_keyboard_item(cx), + CommandId::ToggleStageRow => self.toggle_stage_selected_row(cx), + CommandId::DiscardRow => self.discard_selected_row(cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), + } + } + + /// Moves the keyboard selection and scrolls it into view. + fn select_row(&mut self, row: usize, cx: &mut Context) { + self.keyboard_index = Some(row); + self.scroll_keyboard_row_into_view(); + cx.notify(); + } + + /// Closes the branch filter and restores the unfiltered lists. + fn clear_branch_filter(&mut self, cx: &mut Context) { + if !self.branch_filter_active && self.branch_filter.is_empty() { return; } + self.branch_filter_active = false; + self.branch_filter.clear(); + self.branch_filter_editor + .update(cx, |editor, cx| editor.clear(cx)); + self.rebuild_flattened_branches(); + self.rebuild_nav_items(); + cx.notify(); + } - match key { - "escape" => { - if self.branch_filter_active || !self.branch_filter.is_empty() { - self.branch_filter_active = false; - self.branch_filter.clear(); - self.branch_filter_editor.update(cx, |editor, cx| { - editor.clear(cx); - }); - self.rebuild_flattened_branches(); - self.rebuild_nav_items(); - cx.notify(); - cx.stop_propagation(); + /// Stages the selected unstaged file, or unstages the selected staged one. + fn toggle_stage_selected_row(&mut self, cx: &mut Context) { + let Some(item) = self.selected_nav_item() else { + return; + }; + match item { + SidebarItem::StagedFile(i) => { + if let Some(file) = self.staged.get(i) { + cx.emit(SidebarEvent::UnstageFile(file.path.display().to_string())); } } - // Bounded section lists follow keyboard selection through - // their own scroll handles; the outer sidebar still owns movement - // between section viewports. - "up" | "k" => { - let new_idx = match self.keyboard_index { - Some(i) if i > 0 => i - 1, - Some(_) => 0, - None => 0, - }; - self.keyboard_index = Some(new_idx); - self.scroll_keyboard_row_into_view(); - cx.notify(); - } - "down" | "j" => { - let max = self.cached_nav_items.len().saturating_sub(1); - let new_idx = match self.keyboard_index { - Some(i) => (i + 1).min(max), - None => 0, - }; - self.keyboard_index = Some(new_idx); - self.scroll_keyboard_row_into_view(); - cx.notify(); - } - "enter" | " " => { - self.activate_keyboard_item(cx); - } - "home" => { - self.keyboard_index = Some(0); - self.scroll_keyboard_row_into_view(); - cx.notify(); + SidebarItem::UnstagedFile(i) => { + if let Some(file) = self.unstaged.get(i) { + cx.emit(SidebarEvent::StageFile(file.path.display().to_string())); + } } - "end" => { - self.keyboard_index = Some(self.cached_nav_items.len().saturating_sub(1)); - self.scroll_keyboard_row_into_view(); - cx.notify(); + _ => {} + } + } + + /// Discards the selected change, or deletes the selected branch, tag or stash. + fn discard_selected_row(&mut self, cx: &mut Context) { + let Some(item) = self.selected_nav_item() else { + return; + }; + match item { + SidebarItem::Tag(i) => { + if let Some(tag) = self.tags.get(i) { + cx.emit(SidebarEvent::TagDelete(tag.name.clone())); + } } - "s" => { - if let Some(idx) = self.keyboard_index { - if let Some(item) = self.cached_nav_items.get(idx).cloned() { - match item { - SidebarItem::StagedFile(i) => { - if let Some(file) = self.staged.get(i) { - cx.emit(SidebarEvent::UnstageFile( - file.path.display().to_string(), - )); - } - } - SidebarItem::UnstagedFile(i) => { - if let Some(file) = self.unstaged.get(i) { - cx.emit(SidebarEvent::StageFile( - file.path.display().to_string(), - )); - } - } - _ => {} - } + SidebarItem::Stash(i) => cx.emit(SidebarEvent::StashDrop(i)), + SidebarItem::LocalBranch(i) => { + if let Some(branch) = self.local_branches.get(i) { + if !branch.is_head { + cx.emit(SidebarEvent::BranchDelete(branch.name.clone())); } } } - "x" | "delete" => { - if let Some(idx) = self.keyboard_index { - if let Some(item) = self.cached_nav_items.get(idx).cloned() { - match item { - SidebarItem::Tag(i) => { - if let Some(tag) = self.tags.get(i) { - cx.emit(SidebarEvent::TagDelete(tag.name.clone())); - } - } - SidebarItem::Stash(i) => { - cx.emit(SidebarEvent::StashDrop(i)); - } - SidebarItem::LocalBranch(i) => { - if let Some(branch) = self.local_branches.get(i) { - if !branch.is_head { - cx.emit(SidebarEvent::BranchDelete(branch.name.clone())); - } - } - } - SidebarItem::UnstagedFile(i) => { - if let Some(file) = self.unstaged.get(i) { - cx.emit(SidebarEvent::DiscardFile( - file.path.display().to_string(), - )); - } - } - _ => {} - } - } + SidebarItem::UnstagedFile(i) => { + if let Some(file) = self.unstaged.get(i) { + cx.emit(SidebarEvent::DiscardFile(file.path.display().to_string())); } } _ => {} } } + /// The nav item the keyboard selection points at, if any. + fn selected_nav_item(&self) -> Option { + self.cached_nav_items.get(self.keyboard_index?).cloned() + } + /// Focus the sidebar for keyboard navigation. pub fn focus(&self, window: &mut Window, cx: &mut Context) { self.focus_handle.focus(window, cx); @@ -1486,8 +1458,15 @@ impl Render for Sidebar { let panel = div() .id("sidebar-panel") .track_focus(&self.focus_handle) - .key_context("Sidebar") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "Sidebar List", + &["Menu", "Sidebar"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w_full() .h_full() @@ -3700,7 +3679,7 @@ impl Render for Sidebar { .text_color(Color::Deleted.color(cx)) .hover(|s| s.bg(colors.ghost_element_hover)) .cursor_pointer() - .tooltip(Tooltip::text("Discard changes (Ctrl+Z)")) + .tooltip(crate::keymap::command_tooltip("Discard changes", CommandId::DiscardRow)) .on_click({ let w_dis = w.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { diff --git a/crates/rgitui_workspace/src/squash.rs b/crates/rgitui_workspace/src/squash.rs new file mode 100644 index 00000000..ad9da6e5 --- /dev/null +++ b/crates/rgitui_workspace/src/squash.rs @@ -0,0 +1,412 @@ +//! Turning a graph multi-selection into an interactive-rebase plan that squashes +//! the selected commits together. +//! +//! [`GitProject::rebase_interactive`](rgitui_git::GitProject::rebase_interactive) +//! accepts a plan only when its commit set is *exactly* HEAD's contiguous +//! first-parent range `base..HEAD`, and hard-fails otherwise — it derives the +//! range from HEAD itself rather than from the plan, so a cross-branch or stale +//! plan cannot replay commits from an unrelated branch. The commit graph, on the +//! other hand, lists every ref in date order, so a plausible-looking selection +//! can easily straddle two branches. +//! +//! Everything here is therefore validated *before* anything is executed, and the +//! rejection carries an actionable message. It is all pure — no gpui, no git2 +//! repository — so the rules are unit-tested without a display or a fixture repo. + +use git2::Oid; +use rgitui_git::CommitInfo; + +use crate::interactive_rebase::{RebaseAction, RebaseEntry}; + +/// A validated squash, ready to pre-fill the interactive rebase dialog. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct SquashPlan { + /// Every commit that will be replayed, newest first — the order the dialog + /// lists them in. Covers HEAD down to the oldest selected commit inclusive. + pub entries: Vec, + /// Short id of the commit the range is replayed onto, for the dialog's title. + pub base_label: String, +} + +/// Why a selection cannot be squashed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SquashRejection { + /// Fewer than two distinct commits are selected. + TooFewSelected(usize), + /// At least one selected commit is not on HEAD's first-parent chain. + NotOnHeadChain, + /// The selected commits are all on HEAD's chain but skip one in between. + NotContiguous, + /// A merge commit sits inside the range that would be rewritten. + CrossesMerge(String), + /// The range reaches the first commit in the repository, which has no parent + /// to rebase onto. + ReachesRootCommit, +} + +impl SquashRejection { + /// An actionable, user-facing explanation for a toast. + pub(crate) fn message(&self) -> String { + match self { + SquashRejection::TooFewSelected(_) => "Select two or more commits to squash them: \ + hold shift to extend the selection by row, or the primary modifier to add \ + single rows." + .to_owned(), + SquashRejection::NotOnHeadChain => "Squash only rewrites the checked-out branch. \ + The graph lists every branch in date order, so at least one selected commit is \ + not on this branch's history — select commits from the current branch instead." + .to_owned(), + SquashRejection::NotContiguous => "Squash needs an unbroken run of commits. The \ + selection skips at least one commit in between: either include it, or narrow \ + the selection." + .to_owned(), + SquashRejection::CrossesMerge(short_id) => format!( + "Squash would have to replay merge commit {short_id}, which sits between the \ + selection and HEAD. Select commits newer than that merge instead." + ), + SquashRejection::ReachesRootCommit => "Squash cannot include the repository's first \ + commit, which has no parent to rebase onto." + .to_owned(), + } + } +} + +/// HEAD's first-parent chain, newest first, restricted to the loaded commits. +/// +/// Stops at the first commit the graph has not loaded: the plan may only name +/// commits that are actually on screen, and the rebase layer would reject a plan +/// referring to anything else anyway. +fn first_parent_chain(commits: &[CommitInfo], head_oid: Oid) -> Vec<&CommitInfo> { + let mut chain: Vec<&CommitInfo> = Vec::new(); + let mut next = Some(head_oid); + while let Some(oid) = next { + let Some(commit) = commits.iter().find(|commit| commit.oid == oid) else { + break; + }; + chain.push(commit); + // A history longer than the list it is drawn from means the parent links + // loop; bail rather than spinning on the UI thread. + if chain.len() > commits.len() { + break; + } + next = commit.parent_oids.first().copied(); + } + chain +} + +/// Builds the plan that squashes `selected` together, or explains why it cannot. +/// +/// `commits` is the graph's display list (date-ordered, every ref), `head_oid` +/// the commit the branch is actually on. On success the plan replays HEAD down to +/// the oldest selected commit: the oldest selected commit stays a `pick` and the +/// rest of the selection becomes `squash`, because git melds a `squash` into the +/// todo line above it. Commits newer than the selection are replayed unchanged. +pub(crate) fn plan_squash( + commits: &[CommitInfo], + head_oid: Oid, + selected: &[Oid], +) -> Result { + if selected.len() < 2 { + return Err(SquashRejection::TooFewSelected(selected.len())); + } + + let chain = first_parent_chain(commits, head_oid); + let mut positions = Vec::with_capacity(selected.len()); + for oid in selected { + let Some(position) = chain.iter().position(|commit| commit.oid == *oid) else { + return Err(SquashRejection::NotOnHeadChain); + }; + positions.push(position); + } + positions.sort_unstable(); + positions.dedup(); + if positions.len() < 2 { + return Err(SquashRejection::TooFewSelected(positions.len())); + } + + // The chain runs newest first, so the smallest position is the newest commit. + let newest = positions[0]; + let oldest = positions[positions.len() - 1]; + if oldest - newest + 1 != positions.len() { + return Err(SquashRejection::NotContiguous); + } + + let covered = &chain[..=oldest]; + if let Some(merge) = covered + .iter() + .find(|commit| commit.parent_oids.len() > 1) + .map(|commit| commit.short_id.clone()) + { + return Err(SquashRejection::CrossesMerge(merge)); + } + let base_oid = covered + .last() + .and_then(|commit| commit.parent_oids.first()) + .copied(); + let Some(base_oid) = base_oid else { + return Err(SquashRejection::ReachesRootCommit); + }; + + let entries = covered + .iter() + .enumerate() + .map(|(position, commit)| RebaseEntry { + oid: commit.oid.to_string(), + original_message: commit.summary.clone(), + author: commit.author.name.clone(), + // The oldest selected commit carries the squash, so it stays a pick. + action: if position >= newest && position < oldest { + RebaseAction::Squash + } else { + RebaseAction::Pick + }, + }) + .collect(); + + // The base itself is usually loaded, which gives a nicer label than an abbrev + // of the raw OID; fall back to that when it is out of the loaded window. + let base_label = commits + .iter() + .find(|commit| commit.oid == base_oid) + .map(|commit| commit.short_id.clone()) + .unwrap_or_else(|| base_oid.to_string()[..7].to_owned()); + + Ok(SquashPlan { + entries, + base_label, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use rgitui_git::{RefLabel, Signature}; + + fn oid(byte: u8) -> Oid { + let mut bytes = [0_u8; 20]; + bytes[0] = byte; + Oid::from_bytes(&bytes).unwrap() + } + + fn commit(id: u8, parents: &[u8]) -> CommitInfo { + CommitInfo { + oid: oid(id), + short_id: format!("{id:07x}"), + summary: format!("Commit {id}"), + message: format!("Commit {id}"), + author: Signature { + name: "Test".to_owned(), + email: "test@example.com".to_owned(), + }, + committer: Signature { + name: "Test".to_owned(), + email: "test@example.com".to_owned(), + }, + co_authors: Vec::new(), + time: Utc::now(), + parent_oids: parents.iter().map(|parent| oid(*parent)).collect(), + refs: Vec::new(), + is_signed: false, + } + } + + /// A linear branch: 5 (HEAD) -> 4 -> 3 -> 2 -> 1, plus a root commit 0 so the + /// range never runs off the end of history. + fn linear_history() -> Vec { + vec![ + commit(5, &[4]), + commit(4, &[3]), + commit(3, &[2]), + commit(2, &[1]), + commit(1, &[0]), + commit(0, &[]), + ] + } + + fn actions(plan: &SquashPlan) -> Vec<(&str, &RebaseAction)> { + plan.entries + .iter() + .map(|entry| (entry.oid.as_str(), &entry.action)) + .collect() + } + + #[test] + fn three_of_five_commits_squash_into_the_oldest_selected() { + let commits = linear_history(); + // Select 4, 3 and 2 — the middle of the branch, with 5 newer than them. + let plan = plan_squash(&commits, oid(5), &[oid(4), oid(3), oid(2)]) + .expect("a contiguous run on HEAD's chain is squashable"); + + // The plan covers HEAD down to the oldest selected commit, newest first. + assert_eq!( + actions(&plan), + vec![ + (oid(5).to_string().as_str(), &RebaseAction::Pick), + (oid(4).to_string().as_str(), &RebaseAction::Squash), + (oid(3).to_string().as_str(), &RebaseAction::Squash), + // `squash` melds into the line above, so the oldest selected + // commit stays the pick that carries the others. + (oid(2).to_string().as_str(), &RebaseAction::Pick), + ] + ); + // Nothing older than the selection is replayed. + assert_eq!(plan.entries.len(), 4); + // Replayed onto the parent of the oldest covered commit. + assert_eq!(plan.base_label, commits[4].short_id); + } + + #[test] + fn selecting_the_two_newest_commits_squashes_them_alone() { + let commits = linear_history(); + let plan = plan_squash(&commits, oid(5), &[oid(5), oid(4)]).expect("squashable"); + assert_eq!( + actions(&plan), + vec![ + (oid(5).to_string().as_str(), &RebaseAction::Squash), + (oid(4).to_string().as_str(), &RebaseAction::Pick), + ] + ); + } + + #[test] + fn the_selection_order_does_not_matter() { + let commits = linear_history(); + let ascending = plan_squash(&commits, oid(5), &[oid(2), oid(3), oid(4)]).unwrap(); + let descending = plan_squash(&commits, oid(5), &[oid(4), oid(3), oid(2)]).unwrap(); + assert_eq!(ascending, descending); + } + + #[test] + fn a_gap_in_the_selection_is_rejected() { + let commits = linear_history(); + // 4 and 2 are on the chain but 3 sits between them. + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(2)]), + Err(SquashRejection::NotContiguous) + ); + } + + #[test] + fn fewer_than_two_commits_cannot_be_squashed() { + let commits = linear_history(); + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4)]), + Err(SquashRejection::TooFewSelected(1)) + ); + assert_eq!( + plan_squash(&commits, oid(5), &[]), + Err(SquashRejection::TooFewSelected(0)) + ); + // The same commit twice is still one commit. + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(4)]), + Err(SquashRejection::TooFewSelected(1)) + ); + } + + /// The graph is date-ordered across every ref, so a run of adjacent *rows* + /// can easily include a commit from another branch. + #[test] + fn a_commit_from_another_branch_is_rejected() { + // 5 (HEAD) -> 4 -> 1, and 9 -> 1 on a side branch that the date order + // interleaves between 5 and 4. + let commits = vec![ + commit(5, &[4]), + commit(9, &[1]), + commit(4, &[1]), + commit(1, &[0]), + commit(0, &[]), + ]; + assert_eq!( + plan_squash(&commits, oid(5), &[oid(5), oid(9)]), + Err(SquashRejection::NotOnHeadChain) + ); + // Its own two commits are fine, because both are on HEAD's chain. + assert!(plan_squash(&commits, oid(5), &[oid(5), oid(4)]).is_ok()); + } + + #[test] + fn a_commit_that_is_not_loaded_is_rejected() { + let commits = linear_history(); + assert_eq!( + plan_squash(&commits, oid(5), &[oid(4), oid(42)]), + Err(SquashRejection::NotOnHeadChain) + ); + } + + #[test] + fn a_merge_inside_the_replayed_range_is_rejected() { + // 5 (HEAD) is a merge of 4 and 9; squashing 3 into 2 would have to replay + // it, which `git rebase -i` would flatten. + let commits = vec![ + commit(5, &[4, 9]), + commit(9, &[3]), + commit(4, &[3]), + commit(3, &[2]), + commit(2, &[1]), + commit(1, &[]), + ]; + assert_eq!( + plan_squash(&commits, oid(5), &[oid(3), oid(2)]), + Err(SquashRejection::CrossesMerge(commits[0].short_id.clone())) + ); + } + + #[test] + fn a_range_reaching_the_root_commit_is_rejected() { + let commits = vec![commit(3, &[2]), commit(2, &[1]), commit(1, &[])]; + assert_eq!( + plan_squash(&commits, oid(3), &[oid(2), oid(1)]), + Err(SquashRejection::ReachesRootCommit) + ); + } + + /// The base is only a label, so an unloaded parent must not fail the plan. + #[test] + fn an_unloaded_base_falls_back_to_the_abbreviated_oid() { + // The parent of commit 1 is not in the list. + let commits = vec![commit(3, &[2]), commit(2, &[1]), commit(1, &[7])]; + let plan = plan_squash(&commits, oid(3), &[oid(2), oid(1)]).unwrap(); + assert_eq!(plan.base_label, oid(7).to_string()[..7]); + } + + /// The plan must match what `rebase_interactive` recomputes from HEAD: exactly + /// the last N first-parent commits, one entry each, no duplicates. + #[test] + fn the_plan_matches_heads_first_parent_range_exactly() { + let commits = linear_history(); + let plan = plan_squash(&commits, oid(5), &[oid(3), oid(2)]).unwrap(); + + let expected: Vec = [5_u8, 4, 3, 2] + .iter() + .map(|id| oid(*id).to_string()) + .collect(); + let planned: Vec = plan.entries.iter().map(|e| e.oid.clone()).collect(); + assert_eq!(planned, expected); + } + + #[test] + fn a_refs_label_on_head_does_not_affect_the_plan() { + // The chain is walked from the OID, not from the HEAD ref label. + let mut commits = linear_history(); + commits[2].refs = vec![RefLabel::Head]; + let plan = plan_squash(&commits, oid(5), &[oid(5), oid(4)]).unwrap(); + assert_eq!(plan.entries.len(), 2); + } + + #[test] + fn every_rejection_explains_itself() { + for rejection in [ + SquashRejection::TooFewSelected(1), + SquashRejection::NotOnHeadChain, + SquashRejection::NotContiguous, + SquashRejection::CrossesMerge("abc1234".to_owned()), + SquashRejection::ReachesRootCommit, + ] { + let message = rejection.message(); + assert!(message.ends_with('.'), "{message}"); + assert!(!message.contains(" "), "double space in {message}"); + } + } +} diff --git a/crates/rgitui_workspace/src/stash_branch_dialog.rs b/crates/rgitui_workspace/src/stash_branch_dialog.rs index 0994d075..0ff96295 100644 --- a/crates/rgitui_workspace/src/stash_branch_dialog.rs +++ b/crates/rgitui_workspace/src/stash_branch_dialog.rs @@ -5,8 +5,7 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -14,6 +13,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the stash branch dialog. #[derive(Debug, Clone)] pub enum StashBranchDialogEvent { @@ -143,9 +145,14 @@ impl StashBranchDialog { None } - fn handle_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `StashBranchDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } @@ -183,7 +190,7 @@ impl Render for StashBranchDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -203,7 +210,15 @@ impl Render for StashBranchDialog { let mut modal = div() .id("stash-branch-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "StashBranchDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/stash_save_dialog.rs b/crates/rgitui_workspace/src/stash_save_dialog.rs index 713406d7..d6bb2429 100644 --- a/crates/rgitui_workspace/src/stash_save_dialog.rs +++ b/crates/rgitui_workspace/src/stash_save_dialog.rs @@ -4,15 +4,16 @@ //! field; pressing Enter with an empty field creates `git stash push` (default "WIP" message). use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, Window, -}; +use gpui::{div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, TintColor, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the stash save dialog. #[derive(Debug, Clone, PartialEq)] pub enum StashSaveDialogEvent { @@ -110,16 +111,14 @@ impl StashSaveDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - // Enter is handled solely via the editor's `Submit` event so it fires - // exactly once; here we only need the modal-level Escape-to-dismiss. - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `StashSaveDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } @@ -142,7 +141,7 @@ impl Render for StashSaveDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let accent_color = Color::Accent.color(cx); let icon_bg = gpui::Hsla { @@ -173,7 +172,15 @@ impl Render for StashSaveDialog { div() .id("stash-save-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "StashSaveDialog", + &["Menu"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .w(px(480.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/submodule_view.rs b/crates/rgitui_workspace/src/submodule_view.rs index 0a4d6201..088c91ed 100644 --- a/crates/rgitui_workspace/src/submodule_view.rs +++ b/crates/rgitui_workspace/src/submodule_view.rs @@ -3,14 +3,17 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - div, px, uniform_list, App, Context, ElementId, EventEmitter, FocusHandle, KeyDownEvent, - ListSizingBehavior, MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, - UniformListScrollHandle, WeakEntity, Window, + div, px, uniform_list, App, Context, ElementId, EventEmitter, FocusHandle, ListSizingBehavior, + MouseButton, MouseDownEvent, Render, ScrollStrategy, SharedString, UniformListScrollHandle, + WeakEntity, Window, }; use rgitui_git::SubmoduleInfo; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; +use crate::keymap; +use crate::CommandId; + const SUBMODULE_ICON: IconName = IconName::GitBranch; /// Events emitted by the submodule view. @@ -69,55 +72,39 @@ impl SubmoduleView { self.focus_handle.is_focused(window) } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let key = event.keystroke.key.as_str(); + /// Runs a keyboard command scoped to the shared `List` group. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { let count = self.submodules.len(); - if count == 0 { - return; - } - match key { - "j" | "down" => { - let next = self - .highlighted_row - .map(|r| (r + 1).min(count - 1)) - .unwrap_or(0); - self.highlighted_row = Some(next); - self.scroll_handle - .scroll_to_item(next, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "k" | "up" => { - let prev = self - .highlighted_row - .map(|r| r.saturating_sub(1)) - .unwrap_or(0); - self.highlighted_row = Some(prev); - self.scroll_handle - .scroll_to_item(prev, ScrollStrategy::Nearest); - cx.notify(); - cx.stop_propagation(); - } - "escape" => { - cx.emit(SubmoduleViewEvent::Dismissed); - cx.stop_propagation(); - } - "g" => { - self.highlighted_row = Some(0); - self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); - cx.notify(); - cx.stop_propagation(); - } - _ => {} + match cmd { + CommandId::Cancel => cx.emit(SubmoduleViewEvent::Dismissed), + _ if count == 0 => {} + CommandId::SelectNext => self.highlight_row( + self.highlighted_row + .map_or(0, |row| (row + 1).min(count - 1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectPrev => self.highlight_row( + self.highlighted_row.map_or(0, |row| row.saturating_sub(1)), + ScrollStrategy::Nearest, + cx, + ), + CommandId::SelectFirst => self.highlight_row(0, ScrollStrategy::Top, cx), + CommandId::SelectLast => self.highlight_row(count - 1, ScrollStrategy::Bottom, cx), + // A command this view does not own falls through to the next handler + // out, and finally to the focused text field. + _ => cx.propagate(), } } + /// Moves the keyboard highlight and scrolls it into view. + fn highlight_row(&mut self, row: usize, strategy: ScrollStrategy, cx: &mut Context) { + self.highlighted_row = Some(row); + self.scroll_handle.scroll_to_item(row, strategy); + cx.notify(); + } + fn format_status(sub: &SubmoduleInfo) -> String { sub.status().to_string() } @@ -186,7 +173,7 @@ impl SubmoduleView { impl Render for SubmoduleView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let colors = cx.colors(); + let colors = cx.colors().clone(); if self.submodules.is_empty() { return self.render_empty_state(cx); @@ -367,8 +354,15 @@ impl Render for SubmoduleView { div() .id("submodule-view") .track_focus(&self.focus_handle) - .key_context("SubmoduleView") - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "SubmoduleView List", + &["Menu", "SubmoduleView"], + cx, + Self::dispatch_command, + ) + }) .v_flex() .size_full() .bg(editor_bg) diff --git a/crates/rgitui_workspace/src/tag_dialog.rs b/crates/rgitui_workspace/src/tag_dialog.rs index f8f8f350..e38bdd43 100644 --- a/crates/rgitui_workspace/src/tag_dialog.rs +++ b/crates/rgitui_workspace/src/tag_dialog.rs @@ -1,7 +1,6 @@ use gpui::prelude::*; use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, - SharedString, Window, + div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, SharedString, Window, }; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ @@ -9,6 +8,9 @@ use rgitui_ui::{ TextInputEvent, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the tag creation dialog. #[derive(Debug, Clone)] pub enum TagDialogEvent { @@ -151,14 +153,14 @@ impl TagDialog { } } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `TagDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } @@ -174,7 +176,7 @@ impl Render for TagDialog { self.editor.update(cx, |e, cx| e.focus(window, cx)); } - let colors = cx.colors(); + let colors = cx.colors().clone(); let tag_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !tag_name.is_empty() && !has_error; @@ -190,7 +192,7 @@ impl Render for TagDialog { let mut modal = div() .id("tag-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| keymap::bind_actions(el, "TagDialog", &["Menu"], cx, Self::dispatch_command)) .v_flex() .w(px(440.)) .elevation_3(cx) diff --git a/crates/rgitui_workspace/src/theme_editor_dialog.rs b/crates/rgitui_workspace/src/theme_editor_dialog.rs index e22628b4..7eeb2eb2 100644 --- a/crates/rgitui_workspace/src/theme_editor_dialog.rs +++ b/crates/rgitui_workspace/src/theme_editor_dialog.rs @@ -1,7 +1,7 @@ use gpui::prelude::*; use gpui::{ div, px, relative, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, - InteractiveElement, IntoElement, KeyDownEvent, ParentElement, Render, Window, + InteractiveElement, IntoElement, ParentElement, Render, Window, }; use rgitui_theme::{ hex_to_hsla_strict, hsla_to_hex, json_theme::save_theme_to_file, ActiveTheme, Appearance, @@ -9,6 +9,9 @@ use rgitui_theme::{ }; use rgitui_theme::{StatusColors, ThemeColors}; use rgitui_ui::{Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput}; + +use crate::keymap; +use crate::CommandId; use std::sync::Arc; #[derive(Debug, Clone)] @@ -272,23 +275,14 @@ impl ThemeEditorDialog { self.status_inputs = status_inputs; } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "escape" => { - self.dismiss(cx); - } - "enter" => { - self.save(cx); - } - "tab" => { - self.advance_focus(event.keystroke.modifiers.shift, window, cx); - } - _ => {} + /// Runs a keyboard command scoped to `ThemeEditor`. + fn dispatch_command(&mut self, cmd: CommandId, window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + CommandId::Confirm => self.save(cx), + CommandId::ThemeEditorNextField => self.advance_focus(false, window, cx), + CommandId::ThemeEditorPrevField => self.advance_focus(true, window, cx), + _ => cx.propagate(), } } @@ -412,7 +406,7 @@ impl Render for ThemeEditorDialog { } } - let colors = cx.colors(); + let colors = cx.colors().clone(); let invalid_border = cx.status().error; let valid_border = colors.border_transparent; let theme = self.editable_theme.clone(); @@ -442,7 +436,15 @@ impl Render for ThemeEditorDialog { div() .id("theme-editor-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions( + el, + "ThemeEditor", + &["Menu", "ThemeEditor"], + cx, + Self::dispatch_command, + ) + }) .on_click(|_: &ClickEvent, _, cx| { cx.stop_propagation(); }) diff --git a/crates/rgitui_workspace/src/title_bar.rs b/crates/rgitui_workspace/src/title_bar.rs index 43ac95a4..ea08e844 100644 --- a/crates/rgitui_workspace/src/title_bar.rs +++ b/crates/rgitui_workspace/src/title_bar.rs @@ -5,6 +5,9 @@ use rgitui_ui::{ Icon, IconName, IconSize, Label, LabelSize, Tooltip, WindowControl, WindowControlType, }; +use crate::keymap; +use crate::CommandId; + type ClickHandler = Box; /// The application title bar. @@ -61,9 +64,23 @@ impl TitleBar { .bg(colors.border_variant) } + /// A `key — what it does` hint, with the keystroke read from the keymap. + /// + /// Returns nothing when the command is unbound, so removing a binding in + /// `keymap.json` removes the hint rather than leaving a lie in the title bar. fn render_keyboard_hint( colors: &rgitui_theme::ThemeColors, - key: &'static str, + command: CommandId, + label_text: &'static str, + cx: &App, + ) -> Option { + let key = keymap::shortcut(command, cx)?; + Some(Self::keyboard_hint(colors, key, label_text)) + } + + fn keyboard_hint( + colors: &rgitui_theme::ThemeColors, + key: String, label_text: &'static str, ) -> gpui::Div { div() @@ -79,7 +96,7 @@ impl TitleBar { .flex() .items_center() .child( - Label::new(key) + Label::new(SharedString::from(key)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::MEDIUM), @@ -260,12 +277,18 @@ impl RenderOnce for TitleBar { .h_flex() .gap(px(12.)) .items_center() - .child(Self::render_keyboard_hint( + .children(Self::render_keyboard_hint( &colors, - "Ctrl+Shift+P", + CommandId::CommandPalette, "Commands", + cx, )) - .child(Self::render_keyboard_hint(&colors, "?", "Help")), + .children(Self::render_keyboard_hint( + &colors, + CommandId::Shortcuts, + "Help", + cx, + )), ) } } diff --git a/crates/rgitui_workspace/src/toolbar.rs b/crates/rgitui_workspace/src/toolbar.rs index 2f5d45a1..7a971b4a 100644 --- a/crates/rgitui_workspace/src/toolbar.rs +++ b/crates/rgitui_workspace/src/toolbar.rs @@ -8,13 +8,18 @@ use rgitui_ui::{ VerticalDivider, }; +use crate::keymap; +use crate::CommandId; + type TooltipFactory = Box gpui::AnyView>; struct ToolbarButtonState { disabled: bool, loading: bool, tooltip_text: &'static str, - shortcut: Option<&'static str>, + /// The command the button runs, if it is one a keystroke can reach. The + /// tooltip shows that command's current binding rather than a literal. + command: Option, } /// Events emitted by the toolbar. @@ -131,11 +136,10 @@ impl Toolbar { cx.notify(); } - fn build_tooltip(tooltip_text: &'static str, shortcut: Option<&'static str>) -> TooltipFactory { - if let Some(sc) = shortcut { - Box::new(Tooltip::with_shortcut(tooltip_text, sc)) - } else { - Box::new(Tooltip::text(tooltip_text)) + fn build_tooltip(tooltip_text: &'static str, command: Option) -> TooltipFactory { + match command { + Some(command) => Box::new(keymap::command_tooltip(tooltip_text, command)), + None => Box::new(Tooltip::text(tooltip_text)), } } @@ -176,7 +180,7 @@ impl Toolbar { Color::Default }; - let tooltip_fn = Self::build_tooltip(state.tooltip_text, state.shortcut); + let tooltip_fn = Self::build_tooltip(state.tooltip_text, state.command); div() .id(id) @@ -234,7 +238,7 @@ impl Toolbar { disabled: self.is_fetching, loading: self.is_fetching, tooltip_text: "Fetch from remote", - shortcut: Some("Ctrl+Shift+R"), + command: Some(CommandId::Fetch), }, cx, ) @@ -252,7 +256,7 @@ impl Toolbar { disabled: !self.can_pull, loading: self.is_pulling, tooltip_text: "Pull from remote", - shortcut: None, + command: Some(CommandId::Pull), }, cx, ) @@ -277,7 +281,7 @@ impl Toolbar { disabled: !self.can_push, loading: self.is_pushing, tooltip_text: "Push to remote", - shortcut: None, + command: Some(CommandId::Push), }, cx, ) @@ -307,7 +311,10 @@ impl Toolbar { .child( Button::new("tb-branch", "Branch") .icon(IconName::GitBranch) - .tooltip_fn(Tooltip::with_shortcut("Create new branch", "Ctrl+B")) + .tooltip_fn(keymap::command_tooltip( + "Create new branch", + CommandId::CreateBranch, + )) .on_click( cx.listener(|_, _: &ClickEvent, _, cx| cx.emit(ToolbarEvent::Branch)), ), @@ -323,7 +330,10 @@ impl Toolbar { Button::new("tb-stash", "Stash") .icon(IconName::Stash) .disabled(!self.has_changes) - .tooltip_fn(Tooltip::with_shortcut("Stash working changes", "Ctrl+Z")) + .tooltip_fn(keymap::command_tooltip( + "Stash working changes", + CommandId::StashSave, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::StashSave) })), @@ -332,9 +342,9 @@ impl Toolbar { Button::new("tb-pop", "Pop") .icon(IconName::Undo) .disabled(!self.has_stashes) - .tooltip_fn(Tooltip::with_shortcut( + .tooltip_fn(keymap::command_tooltip( "Pop top stash entry", - "Ctrl+Shift+Z", + CommandId::StashPop, )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::StashPop) @@ -347,7 +357,10 @@ impl Toolbar { Button::new("tb-pr", "Create PR") .icon(IconName::GitPullRequest) .disabled(!self.has_github_token) - .tooltip_fn(Tooltip::text("Create GitHub pull request")) + .tooltip_fn(keymap::command_tooltip( + "Create GitHub pull request", + CommandId::CreatePr, + )) .on_click( cx.listener(|_, _: &ClickEvent, _, cx| cx.emit(ToolbarEvent::CreatePr)), ), @@ -400,7 +413,10 @@ impl Toolbar { .child( IconButton::new("tb-search", IconName::Search) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Search commits", "Ctrl+F")) + .tooltip_fn(keymap::command_tooltip( + "Search commits", + CommandId::Search, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Search) })), @@ -408,7 +424,7 @@ impl Toolbar { .child( IconButton::new("tb-refresh", IconName::Refresh) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Refresh", "F5")) + .tooltip_fn(keymap::command_tooltip("Refresh", CommandId::Refresh)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Refresh) })), @@ -416,7 +432,7 @@ impl Toolbar { .child( IconButton::new("tb-settings", IconName::Settings) .color(Color::Muted) - .tooltip_fn(Tooltip::with_shortcut("Settings", "Ctrl+,")) + .tooltip_fn(keymap::command_tooltip("Settings", CommandId::Settings)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Settings) })), diff --git a/crates/rgitui_workspace/src/workspace/commands.rs b/crates/rgitui_workspace/src/workspace/commands.rs index ab2fb769..b840fa77 100644 --- a/crates/rgitui_workspace/src/workspace/commands.rs +++ b/crates/rgitui_workspace/src/workspace/commands.rs @@ -1,13 +1,241 @@ -use gpui::Context; +use gpui::{Context, Window}; use crate::{CommandId, CommitPanelEvent, ConfirmAction, ToastKind}; +use super::layout::{ + MAX_DETAIL_PANEL_WIDTH, MAX_DIFF_VIEWER_HEIGHT, MIN_DETAIL_PANEL_WIDTH, MIN_DIFF_VIEWER_HEIGHT, +}; use super::{ - BottomPanelMode, ProjectTab, RightPanelMode, ViewCacheEntry, ViewCacheKey, ViewCaches, - Workspace, + BottomPanelMode, FocusedPanel, ProjectTab, RightPanelMode, ViewCacheEntry, ViewCacheKey, + ViewCaches, Workspace, }; +/// Pixels the detail panel grows or shrinks by per keystroke. +const DETAIL_PANEL_STEP: f32 = 20.0; +/// Pixels the diff viewer grows or shrinks by per keystroke. +const DIFF_VIEWER_STEP: f32 = 30.0; + impl Workspace { + /// Entry point for keyboard-invoked commands. + /// + /// The generated `on_action` handlers (see [`crate::keymap::attach_actions`]) + /// all land here. Commands that need a [`Window`] — to move focus or to + /// focus an overlay's input — are handled directly; everything else goes to + /// [`Self::execute_command`], which is also what the command palette calls. + pub(super) fn dispatch_command( + &mut self, + cmd: CommandId, + window: &mut Window, + cx: &mut Context, + ) { + match cmd { + CommandId::CommandPalette => { + self.save_focus(window, cx); + self.overlays.command_palette.update(cx, |palette, cx| { + palette.toggle(window, cx); + }); + } + CommandId::Settings => { + self.save_focus(window, cx); + self.open_or_focus_settings(cx); + } + CommandId::OpenRepo => { + self.save_focus(window, cx); + self.overlays.repo_opener.update(cx, |opener, cx| { + opener.toggle(window, cx); + }); + } + CommandId::Shortcuts => { + self.save_focus(window, cx); + self.overlays.shortcuts_help.update(cx, |help, cx| { + help.toggle(window, cx); + }); + } + // Switching branches means focusing the sidebar's branch list. + CommandId::SwitchBranch => { + self.focus_panel(FocusedPanel::Sidebar, window, cx); + } + CommandId::FocusSidebar => self.focus_panel(FocusedPanel::Sidebar, window, cx), + CommandId::FocusGraph => self.focus_panel(FocusedPanel::Graph, window, cx), + CommandId::FocusDetailPanel => self.focus_panel(FocusedPanel::DetailPanel, window, cx), + CommandId::FocusDiffViewer => self.focus_panel(FocusedPanel::DiffViewer, window, cx), + CommandId::FocusNextPanel => self.focus_next_panel(window, cx), + CommandId::FocusPrevPanel => self.focus_prev_panel(window, cx), + CommandId::Search => self.toggle_graph_search(window, cx), + CommandId::GlobalSearch => self.toggle_global_search(window, cx), + cmd => self.execute_command(cmd, cx), + } + } + + /// Runs a `graph::*` command against the active tab's commit graph. + /// + /// `GraphView` lives in `rgitui_graph`, which cannot depend on this crate and + /// therefore cannot name the actions. The workspace root is an ancestor of + /// the graph on every dispatch path, so handling them here still means the + /// bindings only fire while the graph holds focus — that is what the + /// `GraphView` key context on its root element is for. + pub(super) fn dispatch_graph_command( + &mut self, + cmd: CommandId, + window: &mut Window, + cx: &mut Context, + ) { + let Some(graph) = self.tabs.get(self.active_tab).map(|tab| tab.graph.clone()) else { + cx.propagate(); + return; + }; + // Squashing needs the project, the rebase dialog and the toast queue, so it + // is handled on the workspace rather than inside `graph.update`. + if cmd == CommandId::SquashSelected { + self.squash_selected_commits(cx); + return; + } + graph.update(cx, |graph, cx| match cmd { + CommandId::GraphSelectNext => graph.select_next_row(cx), + CommandId::GraphSelectPrev => graph.select_prev_row(cx), + CommandId::GraphSelectFirst => graph.select_first_row(cx), + CommandId::GraphSelectLast => graph.select_last_row(cx), + CommandId::GraphExtendSelectionNext => graph.extend_selection_next(cx), + CommandId::GraphExtendSelectionPrev => graph.extend_selection_prev(cx), + CommandId::GraphCancel => graph.cancel(window, cx), + CommandId::CopyCommitSha => graph.copy_selected_sha(cx), + CommandId::CopyCommitMessage => graph.copy_selected_message(cx), + _ => cx.propagate(), + }); + } + + /// Pre-fills the interactive rebase dialog with a plan that squashes the + /// commits selected in the graph into the oldest of them. + /// + /// Nothing is executed here: the plan goes into the dialog so the user can + /// review it, adjust the actions and confirm through the dialog's own + /// `Execute` path. Validation happens first — see [`crate::squash`] for why — + /// and a rejection becomes a toast that names the condition that failed. + /// + /// Both routes to squashing end here: the `graph::SquashSelected` keystroke + /// and the graph's "Squash selected commits" context-menu item, which only + /// checks that two rows are selected before emitting. + pub(super) fn squash_selected_commits(&mut self, cx: &mut Context) { + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + let graph = tab.graph.clone(); + let project = tab.project.clone(); + let selected = graph.read(cx).selected_commit_oids(); + + let planned = { + let proj = project.read(cx); + proj.head_oid_at(proj.repo_path()).map(|head_oid| { + crate::squash::plan_squash(proj.recent_commits(), head_oid, &selected) + }) + }; + + match planned { + Some(Ok(plan)) => { + self.overlays.interactive_rebase.update(cx, |ir, cx| { + ir.show_visible( + plan.entries, + format!("{} (rebase onto)", plan.base_label), + cx, + ); + }); + } + Some(Err(rejection)) => { + self.show_toast(rejection.message(), ToastKind::Warning, cx); + } + None => { + self.show_toast( + "Could not resolve HEAD, so there is nothing to squash onto. Refresh and \ + try again.", + ToastKind::Warning, + cx, + ); + } + } + } + + /// Runs a `diff::*` command against the active tab's diff viewer. + /// + /// Handled here for the same reason as [`Self::dispatch_graph_command`]: + /// `rgitui_diff` sits below this crate and cannot name the actions. + pub(super) fn dispatch_diff_command( + &mut self, + cmd: CommandId, + _window: &mut Window, + cx: &mut Context, + ) { + let Some(diff) = self + .tabs + .get(self.active_tab) + .map(|tab| tab.diff_viewer.clone()) + else { + cx.propagate(); + return; + }; + diff.update(cx, |diff, cx| match cmd { + CommandId::DiffSelectNext => diff.select_next_row(cx), + CommandId::DiffSelectPrev => diff.select_prev_row(cx), + CommandId::DiffSelectFirst => diff.select_first_row(cx), + CommandId::DiffSelectLast => diff.select_last_row(cx), + CommandId::NextHunk => diff.select_next_hunk(cx), + CommandId::PrevHunk => diff.select_prev_hunk(cx), + CommandId::ToggleDiffDisplayMode => diff.toggle_display_mode(cx), + CommandId::TogglePartialSelection => diff.toggle_partial_mode(cx), + CommandId::StageSelection => diff.stage_selection(cx), + CommandId::UnstageSelection => diff.unstage_selection(cx), + CommandId::StageCurrentHunk => diff.stage_current_hunk(cx), + CommandId::UnstageCurrentHunk => diff.unstage_current_hunk(cx), + CommandId::CopyDiffSelection => diff.copy_selection(cx), + CommandId::SelectAllDiffLines => diff.select_all_lines(cx), + _ => cx.propagate(), + }); + } + + /// Toggles the commit graph's search field, focusing it when it opens. + fn toggle_graph_search(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + let graph = tab.graph.clone(); + graph.update(cx, |graph, cx| { + graph.toggle_search_focused(window, cx); + }); + } + + /// Widens (positive `delta`) or narrows the right-hand detail panel. + fn resize_detail_panel(&mut self, delta: f32, cx: &mut Context) { + self.layout.detail_panel_width = (self.layout.detail_panel_width + delta) + .clamp(MIN_DETAIL_PANEL_WIDTH, MAX_DETAIL_PANEL_WIDTH); + self.schedule_layout_save(cx); + cx.notify(); + } + + /// Heightens (positive `delta`) or shortens the bottom diff viewer. + fn resize_diff_viewer(&mut self, delta: f32, cx: &mut Context) { + self.layout.diff_viewer_height = (self.layout.diff_viewer_height + delta) + .clamp(MIN_DIFF_VIEWER_HEIGHT, MAX_DIFF_VIEWER_HEIGHT); + self.schedule_layout_save(cx); + cx.notify(); + } + + /// Swaps the bottom panel between the diff viewer and the working-tree search. + fn toggle_global_search(&mut self, window: &mut Window, cx: &mut Context) { + let Some(tab) = self.tabs.get_mut(self.active_tab) else { + return; + }; + if tab.bottom_panel_mode == BottomPanelMode::GlobalSearch { + tab.global_search_view + .update(cx, |search, cx| search.hide(cx)); + tab.bottom_panel_mode = BottomPanelMode::Diff; + } else { + tab.bottom_panel_mode = BottomPanelMode::GlobalSearch; + tab.global_search_view.update(cx, |search, cx| { + search.show(window, cx); + }); + } + cx.notify(); + } + pub(super) fn execute_command(&mut self, cmd: CommandId, cx: &mut Context) { match cmd { CommandId::Settings => { @@ -28,6 +256,9 @@ impl Workspace { sh.toggle_visible(cx); }); } + CommandId::OpenKeymap => { + self.open_keymap_file(cx); + } CommandId::WorkspaceHome => { self.go_home(cx); } @@ -74,6 +305,42 @@ impl Workspace { te.show_for_active_theme(cx); }); } + CommandId::NextTab => { + if !self.tabs.is_empty() { + self.active_tab = (self.active_tab + 1) % self.tabs.len(); + cx.notify(); + } + } + CommandId::PrevTab => { + if !self.tabs.is_empty() { + self.active_tab = if self.active_tab == 0 { + self.tabs.len() - 1 + } else { + self.active_tab - 1 + }; + cx.notify(); + } + } + CommandId::CloseTab => { + if !self.tabs.is_empty() { + self.close_tab(self.active_tab, cx); + } + } + CommandId::ShrinkDetailPanel => self.resize_detail_panel(-DETAIL_PANEL_STEP, cx), + CommandId::GrowDetailPanel => self.resize_detail_panel(DETAIL_PANEL_STEP, cx), + CommandId::ShrinkDiffViewer => self.resize_diff_viewer(-DIFF_VIEWER_STEP, cx), + CommandId::GrowDiffViewer => self.resize_diff_viewer(DIFF_VIEWER_STEP, cx), + // Toggling the palette needs a `Window`, so it is handled in + // `dispatch_command`. It is `[hidden]`, so the palette never + // dispatches it to itself. The panel-focus commands likewise need a + // `Window` and are `[hidden]`. + CommandId::CommandPalette + | CommandId::FocusSidebar + | CommandId::FocusGraph + | CommandId::FocusDetailPanel + | CommandId::FocusDiffViewer + | CommandId::FocusNextPanel + | CommandId::FocusPrevPanel => {} cmd => { let Some(tab) = self.tabs.get(self.active_tab).cloned() else { return; @@ -326,8 +593,11 @@ impl Workspace { self.show_toast(msg, ToastKind::Info, cx); } CommandId::SwitchBranch => { + let hint = crate::keymap::shortcut(CommandId::FocusSidebar, cx) + .map(|keystrokes| format!("Press {keystrokes} to ")) + .unwrap_or_else(|| "Use the sidebar to ".to_owned()); self.show_toast( - "Press Ctrl+Shift+B or use Alt+1 to focus the sidebar for branch switching", + format!("{hint}focus the sidebar for branch switching"), ToastKind::Info, cx, ); @@ -494,10 +764,83 @@ impl Workspace { | CommandId::CreateBranch | CommandId::OpenRepo | CommandId::Shortcuts + | CommandId::OpenKeymap | CommandId::WorkspaceHome | CommandId::RestoreLastWorkspace | CommandId::Undo - | CommandId::OpenThemeEditor => {} + | CommandId::OpenThemeEditor + | CommandId::CommandPalette + | CommandId::NextTab + | CommandId::PrevTab + | CommandId::CloseTab + | CommandId::FocusSidebar + | CommandId::FocusGraph + | CommandId::FocusDetailPanel + | CommandId::FocusDiffViewer + | CommandId::FocusNextPanel + | CommandId::FocusPrevPanel + | CommandId::ShrinkDetailPanel + | CommandId::GrowDetailPanel + | CommandId::ShrinkDiffViewer + | CommandId::GrowDiffViewer => {} + // View-owned commands. Each is handled by the panel, overlay or + // dialog whose key context scopes it — the shared `menu` commands on + // whichever element holds the selection, the `graph` and `diff` ones + // on the workspace root (see `dispatch_view_command`) because those + // two views live in crates that cannot name these actions. They are + // listed rather than swept up by a wildcard so that adding a command + // forces a decision about where it is handled. + CommandId::Cancel + | CommandId::Confirm + | CommandId::SelectNext + | CommandId::SelectPrev + | CommandId::SelectFirst + | CommandId::SelectLast + | CommandId::GraphSelectNext + | CommandId::GraphSelectPrev + | CommandId::GraphSelectFirst + | CommandId::GraphSelectLast + | CommandId::GraphExtendSelectionNext + | CommandId::GraphExtendSelectionPrev + | CommandId::SquashSelected + | CommandId::GraphCancel + | CommandId::CopyCommitSha + | CommandId::CopyCommitMessage + | CommandId::DiffSelectNext + | CommandId::DiffSelectPrev + | CommandId::DiffSelectFirst + | CommandId::DiffSelectLast + | CommandId::NextHunk + | CommandId::PrevHunk + | CommandId::ToggleDiffDisplayMode + | CommandId::TogglePartialSelection + | CommandId::StageSelection + | CommandId::UnstageSelection + | CommandId::StageCurrentHunk + | CommandId::UnstageCurrentHunk + | CommandId::CopyDiffSelection + | CommandId::SelectAllDiffLines + | CommandId::ToggleFileTree + | CommandId::PrevCommitDetails + | CommandId::NextCommitDetails + | CommandId::FileSearch + | CommandId::ToggleStageRow + | CommandId::DiscardRow + | CommandId::FilterBranches + | CommandId::BlameShowDiff + | CommandId::BlameShowHistory + | CommandId::HistoryShowDiff + | CommandId::HistoryShowBlame + | CommandId::RebaseMoveUp + | CommandId::RebaseMoveDown + | CommandId::RebasePick + | CommandId::RebaseReword + | CommandId::RebaseSquash + | CommandId::RebaseFixup + | CommandId::RebaseDrop + | CommandId::ThemeEditorNextField + | CommandId::ThemeEditorPrevField + | CommandId::SubmitPullRequest => {} } } diff --git a/crates/rgitui_workspace/src/workspace/events.rs b/crates/rgitui_workspace/src/workspace/events.rs index 4296ad48..4722614d 100644 --- a/crates/rgitui_workspace/src/workspace/events.rs +++ b/crates/rgitui_workspace/src/workspace/events.rs @@ -703,7 +703,12 @@ pub(super) fn subscribe_shortcuts_help( ) { cx.subscribe( shortcuts_help, - |_this, _sh, _event: &ShortcutsHelpEvent, _cx| {}, + |this, _sh, event: &ShortcutsHelpEvent, cx| match event { + // Handled here rather than in the panel so a failure to create the + // file surfaces as a toast like every other error. + ShortcutsHelpEvent::OpenKeymapFile => this.open_keymap_file(cx), + ShortcutsHelpEvent::Dismissed => {} + }, ) .detach(); } @@ -1699,6 +1704,12 @@ pub(super) fn subscribe_graph( cx.subscribe(graph, { move |this, _graph, event: &GraphViewEvent, cx| { match event { + // Availability of the multi-commit operations follows the graph + // selection, so the palette context is refreshed here rather than + // only after a repository refresh. + GraphViewEvent::SelectionChanged => { + this.update_command_context(cx); + } GraphViewEvent::CommitSelected(oid) => { let commit_oid = *oid; log::info!("CommitSelected: oid={:.7}", commit_oid); @@ -2130,6 +2141,10 @@ pub(super) fn subscribe_graph( ir.show_visible(entries, format!("{} (rebase onto)", base_short), cx); }); } + // The context-menu route to squashing. It lands on the same + // helper as the `graph::SquashSelected` keystroke, so the + // validation and the rejection wording cannot drift apart. + GraphViewEvent::SquashSelected => this.squash_selected_commits(cx), } } }) diff --git a/crates/rgitui_workspace/src/workspace/key_handler.rs b/crates/rgitui_workspace/src/workspace/key_handler.rs index 3ecc9699..4ffe7cd2 100644 --- a/crates/rgitui_workspace/src/workspace/key_handler.rs +++ b/crates/rgitui_workspace/src/workspace/key_handler.rs @@ -1,143 +1,45 @@ -//! Workspace-window keyboard handler. +//! How the workspace window resolves keystrokes. //! -//! Esc precedence: each window owns its Esc handling and Esc never bubbles -//! cross-window. The handler in this file dismisses the topmost overlay or -//! dialog within the workspace window — command palette, branch dialog, -//! confirm dialog, etc. +//! There is no keyboard handler here any more. Every shortcut is declared by +//! `commands!` in [`crate::keymap::registry`], bound to a gpui action scoped to a +//! key context, and handled by an `on_action` listener on the element that owns +//! the behaviour. Two mechanisms replace what used to be hand-rolled here: //! -//! The settings window has its own Esc handler in -//! `SettingsWindow::handle_key_down` (see -//! [`crate::SettingsWindow`]) that calls `window.remove_window()`. -//! Workspace overlays are not visible from there and cannot be dismissed -//! from there. +//! * **Esc and Enter.** `menu::Cancel` and `menu::Confirm` are each bound once. +//! gpui dispatches an action outwards from the focused element and the first +//! listener consumes it, so the innermost open overlay, dialog or panel is the +//! one that responds — no ordered cascade of `if visible` checks. A view that +//! does not want a command calls `cx.propagate()` and it carries on outwards, +//! ending at the focused text field. Blame and file history deliberately keep +//! their own `escape` binding, because there Esc is a navigation (back to the +//! diff) rather than a dismissal, and a deeper context wins. //! -//! When introducing a new dialog or overlay, decide which window it lives -//! in and add Esc dismissal to that window's handler. - -use gpui::{ClipboardItem, Context, KeyDownEvent, Window}; +//! * **Bare letters.** Each is scoped to the view that owns it, and the ones that +//! collide across views (`d`, `s`, `p`, `b`, `h`) simply appear in more than one +//! block with different contexts. They also carry `!TextInput`, which gpui +//! evaluates false whenever a text field is anywhere on the focus path, so a +//! shortcut can never steal a typed character. +//! +//! When adding a dialog or overlay, give its root element a key context and bind +//! its commands in a `commands!` block; there is nothing to register here. +//! +//! The settings window has its own root context (`SettingsWindow`) and its own +//! `menu::Cancel` listener that calls `window.remove_window()`. Each window owns +//! its dismissal and Esc never crosses windows: workspace overlays are not +//! visible from the settings window and cannot be dismissed from it. -use crate::{CommandId, ToastKind}; +use gpui::Context; -use super::{BottomPanelMode, FocusedPanel, Workspace}; +use super::Workspace; impl Workspace { - pub(super) fn handle_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - let keystroke = &event.keystroke; - let key = keystroke.key.as_str(); - let modifiers = &keystroke.modifiers; - - // Dismiss interactive rebase dialog on Escape - if key == "escape" && self.overlays.interactive_rebase.read(cx).is_visible() { - self.overlays.interactive_rebase.update(cx, |ir, cx| { - ir.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss confirm dialog on Escape - if key == "escape" && self.dialogs.confirm_dialog.read(cx).is_visible() { - self.dialogs.confirm_dialog.update(cx, |cd, cx| { - cd.cancel(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss branch dialog on Escape - if key == "escape" && self.dialogs.branch_dialog.read(cx).is_visible() { - self.dialogs.branch_dialog.update(cx, |bd, cx| { - bd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss tag dialog on Escape - if key == "escape" && self.dialogs.tag_dialog.read(cx).is_visible() { - self.dialogs.tag_dialog.update(cx, |td, cx| { - td.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss stash branch dialog on Escape - if key == "escape" && self.dialogs.stash_branch_dialog.read(cx).is_visible() { - self.dialogs.stash_branch_dialog.update(cx, |d, cx| { - d.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss worktree dialog on Escape - if key == "escape" && self.dialogs.worktree_dialog.read(cx).is_visible() { - self.dialogs.worktree_dialog.update(cx, |wd, cx| { - wd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss rename dialog on Escape - if key == "escape" && self.dialogs.rename_dialog.read(cx).is_visible() { - self.dialogs.rename_dialog.update(cx, |rd, cx| { - rd.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss repo opener on Escape - if key == "escape" && self.overlays.repo_opener.read(cx).is_visible() { - self.overlays.repo_opener.update(cx, |ro, cx| { - ro.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss shortcuts help on Escape - if key == "escape" && self.overlays.shortcuts_help.read(cx).is_visible() { - self.overlays.shortcuts_help.update(cx, |sh, cx| { - sh.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Dismiss theme editor on Escape - if key == "escape" && self.overlays.theme_editor.read(cx).is_visible() { - self.overlays.theme_editor.update(cx, |te, cx| { - te.dismiss(cx); - }); - self.restore_focus(window, cx); - return; - } - - // Ctrl+Shift+T to open theme editor (Ctrl+9 as alternative) - if modifiers.secondary() && modifiers.shift && key == "t" { - self.execute_command(CommandId::OpenThemeEditor, cx); - return; - } - if modifiers.alt && !modifiers.secondary() && key == "9" { - self.execute_command(CommandId::OpenThemeEditor, cx); - return; - } - - // When an overlay is active, only allow modal toggle shortcuts (below) and Escape (above). - // Block all panel-specific shortcuts (j/k, Alt+1/2/3/4, Tab, resize, etc.) - // TODO(audit): QUAL-10 — this hand-rolled dispatcher (plus the ~30 per-view - // on_key_down string-matchers) should migrate to GPUI actions!/KeyBinding with a - // data-driven, user-rebindable keymap, letting the focus/key_context tree resolve - // overlay precedence instead of this manual `any_overlay_active` gate. - let any_overlay_active = self.overlays.command_palette.read(cx).is_visible() + /// Whether any overlay or dialog that suppresses panel shortcuts is open. + /// + /// Drives the `modal` key context on the workspace root, which is how + /// `Workspace && !modal` keeps rebindable global shortcuts from firing while + /// a modal is up. + pub(super) fn any_overlay_active(&self, cx: &Context) -> bool { + self.overlays.command_palette.read(cx).is_visible() || self.overlays.interactive_rebase.read(cx).is_visible() || self.overlays.theme_editor.read(cx).is_visible() || self.dialogs.branch_dialog.read(cx).is_visible() @@ -148,428 +50,6 @@ impl Workspace { || self.dialogs.confirm_dialog.read(cx).is_visible() || self.dialogs.stash_branch_dialog.read(cx).is_visible() || self.overlays.global_search.read(cx).is_visible() - || self.overlays.shortcuts_help.read(cx).is_visible(); - - // Ctrl+Shift+F to toggle global search - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "f" { - if let Some(tab) = self.tabs.get_mut(self.active_tab) { - if tab.bottom_panel_mode == BottomPanelMode::GlobalSearch { - tab.global_search_view - .update(cx, |search, cx| search.hide(cx)); - tab.bottom_panel_mode = BottomPanelMode::Diff; - } else { - tab.bottom_panel_mode = BottomPanelMode::GlobalSearch; - tab.global_search_view.update(cx, |search, cx| { - search.show(window, cx); - }); - } - cx.notify(); - } - return; - } - - // Ctrl+Shift+R to fetch - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "r" { - self.execute_command(CommandId::Fetch, cx); - return; - } - - // Ctrl+F to toggle graph search - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "f" { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - g.toggle_search_focused(window, cx); - }); - } - return; - } - - // / to start graph search - if !any_overlay_active && key == "/" { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - g.toggle_search_focused(window, cx); - }); - } - return; - } - - // Ctrl+G to generate AI commit message - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "g" { - self.execute_command(CommandId::AiMessage, cx); - return; - } - - // Ctrl+Shift+P or Cmd+Shift+P to open command palette - if modifiers.secondary() && modifiers.shift && key == "p" { - self.save_focus(window, cx); - self.overlays.command_palette.update(cx, |cp, cx| { - cp.toggle(window, cx); - }); - return; - } - - // Ctrl+, to open settings - if modifiers.secondary() && key == "," { - self.save_focus(window, cx); - self.open_or_focus_settings(cx); - return; - } - - // F5 to refresh - if !any_overlay_active && key == "f5" { - self.execute_command(CommandId::Refresh, cx); - } - - // Ctrl+O to open repo opener - if modifiers.secondary() && key == "o" { - self.save_focus(window, cx); - self.overlays.repo_opener.update(cx, |ro, cx| { - ro.toggle(window, cx); - }); - return; - } - - // ? to toggle shortcuts help (without modifiers) — works even when - // command palette is open, since the palette shows '?' as a hint. - if key == "?" && !modifiers.control && !modifiers.platform && !modifiers.alt { - self.save_focus(window, cx); - self.overlays.shortcuts_help.update(cx, |sh, cx| { - sh.toggle(window, cx); - }); - return; - } - - // j/k vim-style navigation in the commit graph (skip when graph or detail panel - // is focused, since they handle their own j/k to avoid double-movement) - if !any_overlay_active - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let panel_has_focus = self - .tabs - .get(self.active_tab) - .map(|tab| { - tab.graph.read(cx).is_focused(window) - || tab.detail_panel.read(cx).is_focused(window) - || tab.diff_viewer.read(cx).is_focused(window) - || tab.blame_view.read(cx).is_focused(window) - }) - .unwrap_or(false); - - if !panel_has_focus { - match key { - "j" => { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - let next = g - .selected_index() - .map(|i| (i + 1).min(g.row_count().saturating_sub(1))) - .unwrap_or(0); - g.select_index(next, cx); - }); - } - } - "k" => { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - graph.update(cx, |g, cx| { - if let Some(i) = g.selected_index() { - if i > 0 { - g.select_index(i - 1, cx); - } - } - }); - } - } - _ => {} - } - } - } - - // 'd' to switch to diff view (from blame/history) - if !any_overlay_active - && key == "d" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let sidebar_has_focus = self - .tabs - .get(self.active_tab) - .map(|tab| tab.sidebar.read(cx).is_focused(window)) - .unwrap_or(false); - if !sidebar_has_focus { - if let Some(tab) = self.tabs.get_mut(self.active_tab) { - if tab.bottom_panel_mode != BottomPanelMode::Diff { - tab.bottom_panel_mode = BottomPanelMode::Diff; - cx.notify(); - return; - } - } - } - } - // Shift+D to toggle diff display mode (unified/side-by-side) - if !any_overlay_active - && key == "d" - && !modifiers.control - && !modifiers.alt - && modifiers.shift - && !modifiers.platform - { - self.execute_command(CommandId::ToggleDiffMode, cx); - return; - } - - // 'b' to toggle blame view (not when sidebar has focus — user might be typing) - if !any_overlay_active - && key == "b" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - let sidebar_focused = self - .tabs - .get(self.active_tab) - .map(|tab| tab.sidebar.read(cx).is_focused(window)) - .unwrap_or(false); - if !sidebar_focused { - self.execute_command(CommandId::Blame, cx); - return; - } - } - - // 'y' to copy SHA of selected commit - if !any_overlay_active - && key == "y" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - if let Some(commit) = graph.read(cx).selected_commit() { - let sha = commit.oid.to_string(); - cx.write_to_clipboard(ClipboardItem::new_string(sha.clone())); - let short = &sha[..7.min(sha.len())]; - self.show_toast(format!("Copied SHA: {}", short), ToastKind::Success, cx); - } - } - return; - } - - // 'Shift+C' to copy commit message of selected commit - if !any_overlay_active - && key == "c" - && !modifiers.control - && !modifiers.alt - && modifiers.shift - && !modifiers.platform - { - if let Some(tab) = self.tabs.get(self.active_tab) { - let graph = tab.graph.clone(); - if let Some(commit) = graph.read(cx).selected_commit() { - let msg = commit.message.clone(); - cx.write_to_clipboard(ClipboardItem::new_string(msg.clone())); - let first_line = msg.lines().next().unwrap_or(&msg); - let preview = if first_line.chars().count() > 40 { - format!("{}...", first_line.chars().take(40).collect::()) - } else { - first_line.to_string() - }; - self.show_toast(format!("Copied: {}", preview), ToastKind::Success, cx); - } - } - return; - } - - // 'h' to toggle file history view for selected file - if !any_overlay_active - && key == "h" - && !modifiers.control - && !modifiers.alt - && !modifiers.shift - && !modifiers.platform - { - self.execute_command(CommandId::FileHistory, cx); - return; - } - - // Ctrl+[ / Ctrl+] to resize detail panel width - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && !modifiers.alt { - match key { - "[" | "bracketleft" => { - self.layout.detail_panel_width = (self.layout.detail_panel_width - 20.0) - .max(super::layout::MIN_DETAIL_PANEL_WIDTH); - self.schedule_layout_save(cx); - cx.notify(); - } - "]" | "bracketright" => { - self.layout.detail_panel_width = (self.layout.detail_panel_width + 20.0) - .min(super::layout::MAX_DETAIL_PANEL_WIDTH); - self.schedule_layout_save(cx); - cx.notify(); - } - // Ctrl+Up / Ctrl+Down to resize diff viewer height - "up" => { - self.layout.diff_viewer_height = (self.layout.diff_viewer_height - 30.0) - .max(super::layout::MIN_DIFF_VIEWER_HEIGHT); - self.schedule_layout_save(cx); - cx.notify(); - } - "down" => { - self.layout.diff_viewer_height = (self.layout.diff_viewer_height + 30.0) - .min(super::layout::MAX_DIFF_VIEWER_HEIGHT); - self.schedule_layout_save(cx); - cx.notify(); - } - _ => {} - } - } - - // Ctrl+S to stage all - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "s" { - self.execute_command(CommandId::StageAll, cx); - return; - } - - // Ctrl+Shift+S to unstage all - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "s" { - self.execute_command(CommandId::UnstageAll, cx); - return; - } - - // Ctrl+U to unstage all (alternative) - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "u" { - self.execute_command(CommandId::UnstageAll, cx); - return; - } - - // Ctrl+B to create branch - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "b" { - self.execute_command(CommandId::CreateBranch, cx); - return; - } - - // Ctrl+Shift+B to switch branch (focus sidebar for branch navigation) - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "b" { - self.focus_panel(FocusedPanel::Sidebar, window, cx); - return; - } - - // Ctrl+Enter to commit - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "enter" { - self.execute_command(CommandId::Commit, cx); - return; - } - - // Ctrl+Z to stash save - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "z" { - self.execute_command(CommandId::StashSave, cx); - return; - } - - // Ctrl+Shift+Z to stash pop - if !any_overlay_active && modifiers.secondary() && modifiers.shift && key == "z" { - self.execute_command(CommandId::StashPop, cx); - return; - } - - // Ctrl+Tab to switch to next tab. Deliberately not the primary - // modifier: macOS reserves Cmd+Tab for the application switcher, so the - // WindowServer swallows it before the app sees it. Ctrl+Tab is also the - // native tab-cycling chord there. - if !any_overlay_active && modifiers.control && !modifiers.shift && key == "tab" { - if !self.tabs.is_empty() { - self.active_tab = (self.active_tab + 1) % self.tabs.len(); - cx.notify(); - } - return; - } - - // Ctrl+Shift+Tab to switch to previous tab. Ctrl for the same reason as - // Ctrl+Tab above. - if !any_overlay_active && modifiers.control && modifiers.shift && key == "tab" { - if !self.tabs.is_empty() { - if self.active_tab == 0 { - self.active_tab = self.tabs.len() - 1; - } else { - self.active_tab -= 1; - } - cx.notify(); - } - return; - } - - // Ctrl+W to close current tab - if !any_overlay_active && modifiers.secondary() && !modifiers.shift && key == "w" { - if !self.tabs.is_empty() { - self.close_tab(self.active_tab, cx); - } - return; - } - - // Ctrl+H to return to workspace home. Deliberately not the primary - // modifier: macOS reserves Cmd+H for Hide Application. - if !any_overlay_active && modifiers.control && !modifiers.shift && key == "h" { - self.go_home(cx); - return; - } - - // Alt+1/2/3/4 to focus sidebar/graph/detail/diff panel - if !any_overlay_active && modifiers.alt && !modifiers.secondary() { - match key { - "1" => { - self.focus_panel(FocusedPanel::Sidebar, window, cx); - return; - } - "2" => { - self.focus_panel(FocusedPanel::Graph, window, cx); - return; - } - "3" => { - self.focus_panel(FocusedPanel::DetailPanel, window, cx); - return; - } - "4" => { - self.focus_panel(FocusedPanel::DiffViewer, window, cx); - return; - } - "5" => { - self.execute_command(CommandId::ToggleIssues, cx); - return; - } - "6" => { - self.execute_command(CommandId::TogglePullRequests, cx); - return; - } - "7" => { - self.execute_command(CommandId::ToggleBranchHealth, cx); - return; - } - "8" => { - self.execute_command(CommandId::ToggleStashes, cx); - return; - } - _ => {} - } - } - - // Tab / Shift+Tab to cycle between panels (only when no overlay is active) - if !any_overlay_active && !modifiers.secondary() && !modifiers.alt && key == "tab" { - if modifiers.shift { - self.focus_prev_panel(window, cx); - } else { - self.focus_next_panel(window, cx); - } - } + || self.overlays.shortcuts_help.read(cx).is_visible() } } diff --git a/crates/rgitui_workspace/src/workspace/layout.rs b/crates/rgitui_workspace/src/workspace/layout.rs index 80a3b2e8..c7669aa7 100644 --- a/crates/rgitui_workspace/src/workspace/layout.rs +++ b/crates/rgitui_workspace/src/workspace/layout.rs @@ -10,6 +10,7 @@ use rgitui_ui::{ Spinner, SpinnerSize, Tab, TabBar, Tooltip, }; +use crate::keymap; use crate::{CommandId, StatusBar, TitleBar, ToastKind}; use super::{ @@ -17,6 +18,15 @@ use super::{ SidebarResize, ViewAvailability, Workspace, }; +/// Key context identifier for the workspace root. +/// +/// Global bindings are scoped to it (see `commands!` in +/// [`crate::keymap::registry`]); `WORKSPACE_MODAL_KEY_CONTEXT` adds `modal` so +/// `Workspace && !modal` bindings stand down while an overlay is up. +const WORKSPACE_KEY_CONTEXT: &str = "Workspace"; +/// Key context for the workspace root while an overlay or dialog is open. +const WORKSPACE_MODAL_KEY_CONTEXT: &str = "Workspace modal"; + /// Resize bounds for the right detail panel, shared by the drag handle and the /// Ctrl+[ / Ctrl+] keyboard shortcuts so both input paths clamp identically. pub(super) const MIN_DETAIL_PANEL_WIDTH: f32 = 180.0; @@ -45,6 +55,15 @@ const BASELINE_REM_SIZE: f32 = 16.0; const MIN_UI_FONT_SIZE: u32 = 8; const MAX_UI_FONT_SIZE: u32 = 24; +/// The commands the home screen offers a keystroke for. Only the ids are listed: +/// the label and the keystroke both come from the keymap. +const WELCOME_SHORTCUTS: &[CommandId] = &[ + CommandId::OpenRepo, + CommandId::WorkspaceHome, + CommandId::CommandPalette, + CommandId::Settings, +]; + impl Workspace { /// Translate the configured base font size into a window rem size. /// @@ -57,6 +76,55 @@ impl Workspace { let clamped = font_size.clamp(MIN_UI_FONT_SIZE, MAX_UI_FONT_SIZE); px(clamped as f32 * BASELINE_REM_SIZE / DEFAULT_UI_FONT_SIZE as f32) } + + /// Key context for the workspace root element. + /// + /// `modal` is added while any overlay or dialog is open. Deriving it here + /// rather than from the overlays' own contexts means `!modal` holds even for + /// dialogs that do not take focus, matching the old `any_overlay_active` + /// gate exactly. + fn workspace_key_context(&self, cx: &Context) -> &'static str { + if self.any_overlay_active(cx) { + WORKSPACE_MODAL_KEY_CONTEXT + } else { + WORKSPACE_KEY_CONTEXT + } + } + + /// The workspace root element, carrying the `Workspace` key context and one + /// `on_action` handler per command in the `Workspace`, `GraphView` and + /// `DiffViewer` blocks of `commands!`. + /// + /// Being on the root means the handlers are in the dispatch path of whatever + /// child holds focus, so a global shortcut works from any panel. The last two + /// blocks are bound here rather than on those views' own elements because + /// they live in crates that cannot name the generated actions (see + /// [`crate::keymap::registry`]); their bindings are still scoped to the + /// `GraphView` and `DiffViewer` key contexts, so they only fire when those + /// panels hold focus. + fn workspace_root( + &self, + cx: &mut Context, + ui_font: gpui::Font, + background: gpui::Hsla, + ) -> gpui::Stateful { + let mut root = div() + .id("workspace-root") + .key_context(self.workspace_key_context(cx)) + .size_full() + .font(ui_font) + .bg(background); + + root = keymap::attach_actions(root, "Workspace", cx, |workspace, cmd, window, cx| { + workspace.dispatch_command(cmd, window, cx); + }); + root = keymap::attach_actions(root, "GraphView", cx, |workspace, cmd, window, cx| { + workspace.dispatch_graph_command(cmd, window, cx); + }); + keymap::attach_actions(root, "DiffViewer", cx, |workspace, cmd, window, cx| { + workspace.dispatch_diff_command(cmd, window, cx); + }) + } } impl Render for Workspace { @@ -113,12 +181,8 @@ impl Render for Workspace { // If no tabs, show welcome screen if self.tabs.is_empty() { - return div() - .id("workspace-root") - .size_full() - .font(ui_font.clone()) - .bg(colors.background) - .on_key_down(cx.listener(Self::handle_key_down)) + let root = self.workspace_root(cx, ui_font.clone(), colors.background); + return root .child(self.render_welcome_interactive(cx)) .child(self.toast_layer.clone()) .child(self.overlays.command_palette.clone()) @@ -406,13 +470,9 @@ impl Render for Workspace { let operation_output_bar = self.render_operation_output_bar(cx); let update_banner = self.render_update_banner(cx); - div() - .id("workspace-root") - .v_flex() - .size_full() - .font(ui_font) - .bg(colors.background) - .on_key_down(cx.listener(Self::handle_key_down)) + let root = self.workspace_root(cx, ui_font, colors.background).v_flex(); + + root // Title bar .child({ let sidebar = active_tab.sidebar.clone(); @@ -1667,10 +1727,11 @@ impl Workspace { .mt(px(8.)) .w_full() .items_center() - .child(self.shortcut_hint("Open Repository", "Ctrl+O", colors)) - .child(self.shortcut_hint("Go Home", "Ctrl+H", colors)) - .child(self.shortcut_hint("Command Palette", "Ctrl+Shift+P", colors)) - .child(self.shortcut_hint("Settings", "Ctrl+,", colors)), + .children( + WELCOME_SHORTCUTS + .iter() + .filter_map(|command| self.shortcut_hint(*command, colors, cx)), + ), ); // Scrollable outer container with an inner wrapper that fills at least @@ -1696,36 +1757,44 @@ impl Workspace { ) } + /// One home-screen hint, or nothing when the command has no keystroke. + /// + /// The label is the command's description and the keystroke comes from the + /// keymap, so a rebind in `keymap.json` shows up here too. fn shortcut_hint( &self, - action: &str, - shortcut: &str, + command: CommandId, colors: &rgitui_theme::ThemeColors, - ) -> impl IntoElement { - div() - .h_flex() - .w(px(260.)) - .justify_between() - .items_center() - .child( - Label::new(SharedString::from(action.to_string())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ) - .child( - div() - .h_flex() - .h(px(22.)) - .px(px(8.)) - .rounded(px(4.)) - .bg(colors.element_background) - .items_center() - .child( - Label::new(SharedString::from(shortcut.to_string())) - .size(LabelSize::XSmall) - .color(Color::Muted), - ), - ) + cx: &gpui::App, + ) -> Option { + let shortcut = crate::keymap::shortcut(command, cx)?; + let action = command.description(); + Some( + div() + .h_flex() + .w(px(260.)) + .justify_between() + .items_center() + .child( + Label::new(SharedString::from(action.to_string())) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child( + div() + .h_flex() + .h(px(22.)) + .px(px(8.)) + .rounded(px(4.)) + .bg(colors.element_background) + .items_center() + .child( + Label::new(SharedString::from(shortcut)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ), + ) } /// Schedule a debounced layout save (avoids writing to disk on every resize pixel). diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index a8474e1c..cde6eb96 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -8,6 +8,7 @@ mod tabs; mod undo; mod update_checker; +pub(crate) use layout::open_editor; pub(crate) use state::*; pub(crate) use undo::{UndoAction, UndoStack}; @@ -278,6 +279,9 @@ pub struct Workspace { pub(super) update_notification: Option, pub(super) settings_window: Option>, pub(super) _settings_window_closed_subscription: Option, + /// Keeps the [`crate::keymap::KeymapState`] observer alive so `keymap.json` + /// reload problems are toasted as they happen. + pub(super) _keymap_subscription: gpui::Subscription, } /// Smallest width the left sidebar may take, whether reached by dragging the @@ -422,6 +426,10 @@ impl Workspace { update_notification: None, settings_window: None, _settings_window_closed_subscription: None, + // Fires on every keymap.json reload, including ones long after startup. + _keymap_subscription: cx.observe_global::( + |workspace, cx| workspace.show_keymap_problems(cx), + ), } } @@ -638,6 +646,63 @@ impl Workspace { } } + /// Surface anything wrong with the user's `keymap.json` as a toast. + /// + /// Drains the problems so each is shown once, whether it came from the + /// startup load or from a later reload triggered by saving the file. + pub fn show_keymap_problems(&mut self, cx: &mut Context) { + // Read before writing. This runs as a `KeymapState` global observer, and + // `update_global` notifies global observers on release — so taking the + // problems unconditionally re-enters this method forever, pinning the UI + // thread at 100% CPU on startup and on every keymap.json save. Bailing + // out when there is nothing left to report ends the cycle after one pass. + let has_problems = cx + .try_global::() + .is_some_and(crate::keymap::KeymapState::has_problems); + if !has_problems { + return; + } + let problems = + cx.update_global::(|state, _| state.take_problems()); + for problem in problems { + self.show_toast(problem, ToastKind::Error, cx); + } + } + + /// Open `keymap.json` in the user's editor, creating it first if it does not + /// exist yet. + /// + /// The file is created with a commented starter (see + /// [`crate::keymap::keymap_stub`]) rather than left absent, so the editor + /// opens on something that explains the format instead of an empty buffer. + /// The editor is the one configured in settings, falling back to the same + /// detection the rest of the app uses; if none can be launched the path is + /// toasted so it can still be found by hand. + pub(crate) fn open_keymap_file(&mut self, cx: &mut Context) { + let path = match crate::keymap::ensure_keymap_file() { + Ok(path) => path, + Err(error) => { + self.show_toast( + format!( + "Could not create {}: {error}. Create it by hand to customise \ + keybindings.", + crate::keymap::keymap_path().display() + ), + ToastKind::Error, + cx, + ); + return; + } + }; + + let editor_command = cx + .try_global::() + .map(|settings| settings.settings().editor_command.clone()) + .unwrap_or_default(); + layout::open_editor(&path, &editor_command); + self.show_toast(format!("Opening {}", path.display()), ToastKind::Info, cx); + } + /// Mark a clean exit when the user explicitly closes or goes home. pub fn mark_clean_exit(&self, cx: &mut Context) { cx.update_global::(|settings, _| { diff --git a/crates/rgitui_workspace/src/workspace/tabs.rs b/crates/rgitui_workspace/src/workspace/tabs.rs index 1626eab1..1ff8520d 100644 --- a/crates/rgitui_workspace/src/workspace/tabs.rs +++ b/crates/rgitui_workspace/src/workspace/tabs.rs @@ -257,6 +257,7 @@ impl Workspace { !proj.status().staged.is_empty(), has_token, ) + .with_multi_commit_selection(tab.graph.read(cx).selected_commit_count() > 1) } /// Update the command palette's context with fresh data from the active tab. diff --git a/crates/rgitui_workspace/src/worktree_dialog.rs b/crates/rgitui_workspace/src/worktree_dialog.rs index 9c25cd58..a6ff6a47 100644 --- a/crates/rgitui_workspace/src/worktree_dialog.rs +++ b/crates/rgitui_workspace/src/worktree_dialog.rs @@ -1,13 +1,14 @@ use gpui::prelude::*; -use gpui::{ - div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, KeyDownEvent, Render, Window, -}; +use gpui::{div, px, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Render, Window}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInputEvent, TintColor, }; +use crate::keymap; +use crate::CommandId; + /// Events emitted by the worktree creation dialog. #[derive(Debug, Clone)] pub enum WorktreeDialogEvent { @@ -26,6 +27,11 @@ pub struct WorktreeDialog { branch_editor: Entity, error_message: Option, visible: bool, + /// Set when the dialog is opened without a `Window`, so the next render can + /// take focus. Without it the dialog never enters the focus path, and gpui + /// dispatches actions — including `menu::Cancel` — only along that path, so + /// Esc would not dismiss it. + pending_focus: bool, focus_handle: FocusHandle, } @@ -87,6 +93,7 @@ impl WorktreeDialog { branch_editor, error_message: None, visible: false, + pending_focus: false, focus_handle, } } @@ -105,9 +112,10 @@ impl WorktreeDialog { cx.notify(); } - /// Show the dialog without focusing. + /// Show the dialog, taking focus on the next render. pub fn show_visible(&mut self, branch: Option, cx: &mut Context) { self.visible = true; + self.pending_focus = true; self.name_editor.update(cx, |e, cx| e.clear(cx)); self.path_editor.update(cx, |e, cx| e.clear(cx)); self.branch_editor.update(cx, |e, cx| e.clear(cx)); @@ -188,25 +196,30 @@ impl WorktreeDialog { cx.notify(); } - fn handle_key_down( - &mut self, - event: &KeyDownEvent, - _window: &mut Window, - cx: &mut Context, - ) { - if event.keystroke.key.as_str() == "escape" { - self.dismiss(cx); + /// Runs a keyboard command scoped to `WorktreeDialog`. + /// + /// Enter is not handled here: it is propagated so the focused field's own + /// submission fires exactly once. + fn dispatch_command(&mut self, cmd: CommandId, _window: &mut Window, cx: &mut Context) { + match cmd { + CommandId::Cancel => self.dismiss(cx), + _ => cx.propagate(), } } } impl Render for WorktreeDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.visible { return div().id("worktree-dialog").into_any_element(); } - let colors = cx.colors(); + if self.pending_focus { + self.pending_focus = false; + self.name_editor.update(cx, |e, cx| e.focus(window, cx)); + } + + let colors = cx.colors().clone(); let name_text = self.name_editor.read(cx).text().to_string(); let path_text = self.path_editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); @@ -221,7 +234,9 @@ impl Render for WorktreeDialog { let mut modal = div() .id("worktree-dialog-modal") .track_focus(&self.focus_handle) - .on_key_down(cx.listener(Self::handle_key_down)) + .map(|el| { + keymap::bind_actions(el, "WorktreeDialog", &["Menu"], cx, Self::dispatch_command) + }) .v_flex() .w(px(480.)) .elevation_3(cx) diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md new file mode 100644 index 00000000..71a23899 --- /dev/null +++ b/docs/KEYBINDINGS.md @@ -0,0 +1,243 @@ +# Keyboard shortcuts + + + +Every shortcut below is rebindable, and press `?` in rgitui to see the ones actually in force — that reference is generated from the same declaration as this page, so it follows your own keybindings rather than the defaults. + +`secondary` is the platform's primary modifier: `cmd` on macOS, `ctrl` everywhere else. Commands marked _unbound_ have no default keystroke and are reached from the command palette (`secondary-shift-p`). + +## Customising + +Keybindings live in `keymap.json`, next to `settings.json` in rgitui's config directory: + +| Platform | Path | +| --- | --- | +| Linux | `~/.config/rgitui/keymap.json` | +| macOS | `~/Library/Application Support/rgitui/keymap.json` | +| Windows | `%APPDATA%\rgitui\keymap.json` | + +Run the **Open keymap.json** command from the palette, or use the button in the shortcut reference, to create the file with a commented example already in it and open it in your editor. + +```jsonc +[ + { + "context": "Workspace && !modal", + "bindings": { + // Rebind staging. + "ctrl-alt-s": "rgitui::StageAll", + // Remove a default binding. + "secondary-s": null + } + } +] +``` + +The file is reloaded when you save it. Bindings you add win over the defaults. Two bindings on the same keystroke in overlapping contexts, or a binding that shadows the prefix of a chord, are reported as a toast and the losing binding is dropped rather than silently ignored. + +`docs/keymap.schema.json` lists every action name with its description; associate it with `keymap.json` in your editor's JSON schema settings for completion and hovers. + +## Workspace + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `secondary-shift-r` | `Workspace && !modal` | `rgitui::Fetch` | Download objects and refs from the tracked remote. | +| _unbound_ | — | `rgitui::Pull` | Fetch from and integrate with the tracked remote branch. | +| _unbound_ | — | `rgitui::Push` | Update the remote ref along with associated objects. | +| _unbound_ | — | `rgitui::PushAll` | Push every open repository. | +| _unbound_ | — | `rgitui::PullAll` | Pull every open repository. | +| _unbound_ | — | `rgitui::ForcePush` | Overwrite the remote branch with the local one. | +| `secondary-enter` | `Workspace && !modal` | `rgitui::Commit` | Commit the staged changes using the message in the commit panel. | +| `secondary-s` | `Workspace && !modal` | `rgitui::StageAll` | Stage every change in the working tree. | +| `secondary-shift-s` or `secondary-u` | `Workspace && !modal` | `rgitui::UnstageAll` | Unstage everything currently staged. | +| `secondary-z` | `Workspace && !modal` | `rgitui::StashSave` | Stash the working tree and index. | +| `secondary-shift-z` | `Workspace && !modal` | `rgitui::StashPop` | Apply the latest stash entry and drop it. | +| _unbound_ | — | `rgitui::StashApply` | Apply the latest stash entry and keep it. | +| _unbound_ | — | `rgitui::StashDrop` | Delete a stash entry. | +| `secondary-b` | `Workspace && !modal` | `rgitui::CreateBranch` | Create a new branch. | +| _unbound_ | — | `rgitui::DeleteBranch` | Delete a branch. | +| _unbound_ | — | `rgitui::RenameBranch` | Rename a branch. | +| _unbound_ | — | `rgitui::MergeBranch` | Merge another branch into the current one. | +| _unbound_ | — | `rgitui::CreateTag` | Create a tag. | +| _unbound_ | — | `rgitui::CreateWorktree` | Create a linked worktree. | +| _unbound_ | — | `rgitui::CreatePr` | Open a pull request for the current branch. | +| _unbound_ | — | `rgitui::CherryPick` | Cherry-pick a commit onto the current branch. | +| _unbound_ | — | `rgitui::RevertCommit` | Revert a commit. | +| _unbound_ | — | `rgitui::InteractiveRebase` | Start an interactive rebase. | +| _unbound_ | — | `rgitui::DiscardAll` | Discard every uncommitted change. | +| _unbound_ | — | `rgitui::CleanUntracked` | Delete untracked files. | +| _unbound_ | — | `rgitui::ResetHard` | Reset the working tree and index to HEAD. | +| _unbound_ | — | `rgitui::AbortOperation` | Abort the merge, rebase, cherry-pick or revert in progress. | +| _unbound_ | — | `rgitui::ContinueMerge` | Continue the merge, rebase, cherry-pick or revert in progress. | +| `shift-d` | `Workspace && !modal && !TextInput` | `rgitui::ToggleDiffMode` | Switch the diff viewer between unified and side-by-side. | +| `secondary-f` or `/` | `Workspace && !modal` or `Workspace && !modal && !TextInput` | `rgitui::Search` | Search the commit graph. | +| `secondary-g` | `Workspace && !modal` | `rgitui::AiMessage` | Generate a commit message with the configured AI provider. | +| `f5` | `Workspace && !modal` | `rgitui::Refresh` | Reload the repository state from disk. | +| `secondary-,` | `Workspace` | `rgitui::Settings` | Open the settings window. | +| `secondary-o` | `Workspace` | `rgitui::OpenRepo` | Open the repository picker. | +| `ctrl-h` | `Workspace && !modal` | `rgitui::WorkspaceHome` | Close every tab and return to the workspace home screen. | +| _unbound_ | — | `rgitui::RestoreLastWorkspace` | Reopen the most recently saved workspace. | +| `?` | `Workspace && !TextInput` | `rgitui::Shortcuts` | Show the keyboard shortcut reference. | +| `secondary-shift-b` | `Workspace && !modal` | `rgitui::SwitchBranch` | Focus the sidebar to switch branches. | +| _unbound_ | — | `rgitui::Blame` | Blame the selected file. | +| _unbound_ | — | `rgitui::Undo` | Undo the last git operation. | +| _unbound_ | — | `rgitui::FileHistory` | Show the commit history of the selected file. | +| _unbound_ | — | `rgitui::Reflog` | Show the reflog. | +| _unbound_ | — | `rgitui::Submodules` | Show the submodule list. | +| _unbound_ | — | `rgitui::Bisect` | Show the bisect log. | +| _unbound_ | — | `rgitui::BisectStart` | Start a bisect session. | +| _unbound_ | — | `rgitui::BisectGood` | Mark the current bisect commit as good. | +| _unbound_ | — | `rgitui::BisectBad` | Mark the current bisect commit as bad. | +| _unbound_ | — | `rgitui::BisectReset` | End the bisect session and restore HEAD. | +| _unbound_ | — | `rgitui::BisectSkip` | Skip the current bisect commit. | +| `secondary-shift-f` | `Workspace && !modal` | `rgitui::GlobalSearch` | Search the contents of the working tree. | +| `alt-5` | `Workspace && !modal` | `rgitui::ToggleIssues` | Toggle the issues panel. | +| `alt-6` | `Workspace && !modal` | `rgitui::TogglePullRequests` | Toggle the pull requests panel. | +| `alt-7` | `Workspace && !modal` | `rgitui::ToggleBranchHealth` | Toggle the branch health panel. | +| `alt-8` | `Workspace && !modal` | `rgitui::ToggleStashes` | Toggle the stashes panel. | +| _unbound_ | — | `rgitui::StashBranch` | Create a branch from a stash entry. | +| `secondary-shift-t` or `alt-9` | `Workspace` | `rgitui::OpenThemeEditor` | Open the theme editor. | +| `secondary-shift-p` | `Workspace` | `rgitui::CommandPalette` | Toggle the command palette. | +| `ctrl-tab` | `Workspace && !modal` | `rgitui::NextTab` | Activate the next repository tab. | +| `ctrl-shift-tab` | `Workspace && !modal` | `rgitui::PrevTab` | Activate the previous repository tab. | +| `secondary-w` | `Workspace && !modal` | `rgitui::CloseTab` | Close the active repository tab. | +| `alt-1` | `Workspace && !modal` | `rgitui::FocusSidebar` | Move keyboard focus to the sidebar. | +| `alt-2` | `Workspace && !modal` | `rgitui::FocusGraph` | Move keyboard focus to the commit graph. | +| `alt-3` | `Workspace && !modal` | `rgitui::FocusDetailPanel` | Move keyboard focus to the commit detail panel. | +| `alt-4` | `Workspace && !modal` | `rgitui::FocusDiffViewer` | Move keyboard focus to the diff viewer. | +| `tab` | `Workspace && !modal && !TextInput` | `rgitui::FocusNextPanel` | Move keyboard focus to the next panel. | +| `shift-tab` | `Workspace && !modal && !TextInput` | `rgitui::FocusPrevPanel` | Move keyboard focus to the previous panel. | +| `secondary-[` | `Workspace && !modal` | `rgitui::ShrinkDetailPanel` | Narrow the detail panel. | +| `secondary-]` | `Workspace && !modal` | `rgitui::GrowDetailPanel` | Widen the detail panel. | +| `secondary-up` | `Workspace && !modal` | `rgitui::ShrinkDiffViewer` | Shorten the diff viewer. | +| `secondary-down` | `Workspace && !modal` | `rgitui::GrowDiffViewer` | Heighten the diff viewer. | +| _unbound_ | — | `rgitui::OpenKeymap` | Open keymap.json in your editor to rebind shortcuts. | + +## Menu + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` | `Workspace \|\| SettingsWindow` | `menu::Cancel` | Dismiss the focused overlay, dialog, search or selection. | +| `enter` or `space` | `Workspace \|\| SettingsWindow` or `List && !TextInput` | `menu::Confirm` | Activate the selected row, or submit the focused dialog. | +| `down` or `j` | `List` or `List && !TextInput` | `menu::SelectNext` | Move the selection down one row. | +| `up` or `k` | `List` or `List && !TextInput` | `menu::SelectPrev` | Move the selection up one row. | +| `home` or `g` | `List` or `List && !TextInput` | `menu::SelectFirst` | Move the selection to the first row. | +| `end` or `shift-g` | `List` or `List && !TextInput` | `menu::SelectLast` | Move the selection to the last row. | + +## GraphView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `down` or `j` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectNext` | Select the next commit in the graph. | +| `up` or `k` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectPrev` | Select the previous commit in the graph. | +| `home` or `g` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectFirst` | Select the newest commit in the graph. | +| `end` or `shift-g` | `GraphView && !modal` or `GraphView && !modal && !TextInput` | `graph::GraphSelectLast` | Select the oldest loaded commit in the graph. | +| `shift-down` or `shift-j` | `GraphView && !modal && !TextInput` | `graph::GraphExtendSelectionNext` | Add the next commit in the graph to the selection. | +| `shift-up` or `shift-k` | `GraphView && !modal && !TextInput` | `graph::GraphExtendSelectionPrev` | Add the previous commit in the graph to the selection. | +| `s` | `GraphView && !modal && !TextInput` | `graph::SquashSelected` | Squash the selected commits into the oldest of them. | +| `escape` | `GraphView && !modal` | `graph::GraphCancel` | Close the graph search, or dismiss the graph context menu. | +| `y` | `GraphView && !modal && !TextInput` | `graph::CopyCommitSha` | Copy the selected commit's SHA to the clipboard. | +| `shift-c` | `GraphView && !modal && !TextInput` | `graph::CopyCommitMessage` | Copy the selected commit's message to the clipboard. | + +## DiffViewer + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `down` or `j` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectNext` | Move the diff cursor down one row. | +| `up` or `k` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectPrev` | Move the diff cursor up one row. | +| `home` or `g` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectFirst` | Move the diff cursor to the first row. | +| `end` or `shift-g` | `DiffViewer && !modal && !TextInput` | `diff::DiffSelectLast` | Move the diff cursor to the last row. | +| `]` | `DiffViewer && !modal && !TextInput` | `diff::NextHunk` | Jump to the next hunk. | +| `[` | `DiffViewer && !modal && !TextInput` | `diff::PrevHunk` | Jump to the previous hunk. | +| `d` | `DiffViewer && !modal && !TextInput` | `diff::ToggleDiffDisplayMode` | Cycle the diff viewer's display mode. | +| `p` | `DiffViewer && !modal && !TextInput` | `diff::TogglePartialSelection` | Toggle line-level selection in the diff viewer. | +| `s` or `shift-s` | `DiffViewer && !modal && !TextInput` | `diff::StageSelection` | Stage the hunks or lines under the diff selection. | +| `u` or `shift-u` | `DiffViewer && !modal && !TextInput` | `diff::UnstageSelection` | Unstage the hunks or lines under the diff selection. | +| `alt-s` | `DiffViewer && !modal && !TextInput` | `diff::StageCurrentHunk` | Stage the hunk under the diff cursor. | +| `alt-u` | `DiffViewer && !modal && !TextInput` | `diff::UnstageCurrentHunk` | Unstage the hunk under the diff cursor. | +| `secondary-c` | `DiffViewer && !modal && !TextInput` | `diff::CopyDiffSelection` | Copy the selected diff lines to the clipboard. | +| `secondary-a` | `DiffViewer && !modal && !TextInput` | `diff::SelectAllDiffLines` | Select every line in the diff. | + +## DetailPanel + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `v` | `DetailPanel && !modal && !TextInput` | `detail::ToggleFileTree` | Switch the changed-files list between the flat and tree layouts. | +| `[` | `DetailPanel && !modal && !TextInput` | `detail::PrevCommitDetails` | Show the previous commit's details. | +| `]` | `DetailPanel && !modal && !TextInput` | `detail::NextCommitDetails` | Show the next commit's details. | +| `/` or `secondary-f` | `DetailPanel && !modal && !TextInput` or `DetailPanel && !modal` | `detail::FileSearch` | Filter the changed-files list. | + +## Sidebar + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `s` | `Sidebar && !modal && !TextInput` | `sidebar::ToggleStageRow` | Stage or unstage the selected file. | +| `x` or `delete` | `Sidebar && !modal && !TextInput` or `Sidebar && !modal` | `sidebar::DiscardRow` | Discard the selected change, or delete the selected branch, tag or stash. | +| `/` or `secondary-f` | `Sidebar && !modal && !TextInput` or `Sidebar && !modal` | `sidebar::FilterBranches` | Filter the branch list. | + +## BlameView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` or `d` | `BlameView && !modal` or `BlameView && !modal && !TextInput` | `blame::BlameShowDiff` | Leave the blame view and go back to the diff. | +| `h` | `BlameView && !modal && !TextInput` | `blame::BlameShowHistory` | Show the blamed file's commit history. | + +## FileHistoryView + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `escape` or `d` | `FileHistoryView && !modal` or `FileHistoryView && !modal && !TextInput` | `history::HistoryShowDiff` | Leave the file history and go back to the diff. | +| `b` | `FileHistoryView && !modal && !TextInput` | `history::HistoryShowBlame` | Blame the file whose history is shown. | + +## InteractiveRebase + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `secondary-up` | `InteractiveRebase` | `rebase::RebaseMoveUp` | Move the selected commit earlier in the rebase plan. | +| `secondary-down` | `InteractiveRebase` | `rebase::RebaseMoveDown` | Move the selected commit later in the rebase plan. | +| `p` | `InteractiveRebase && !TextInput` | `rebase::RebasePick` | Keep the selected commit as it is. | +| `r` | `InteractiveRebase && !TextInput` | `rebase::RebaseReword` | Reword the selected commit's message. | +| `s` | `InteractiveRebase && !TextInput` | `rebase::RebaseSquash` | Squash the selected commit into the previous one. | +| `f` | `InteractiveRebase && !TextInput` | `rebase::RebaseFixup` | Squash the selected commit into the previous one, discarding its message. | +| `d` | `InteractiveRebase && !TextInput` | `rebase::RebaseDrop` | Drop the selected commit. | + +## ThemeEditor + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `tab` | `ThemeEditor` | `theme::ThemeEditorNextField` | Focus the next field in the theme editor. | +| `shift-tab` | `ThemeEditor` | `theme::ThemeEditorPrevField` | Focus the previous field in the theme editor. | + +## CreatePrDialog + +| Keystroke | Context | Action | Description | +| --- | --- | --- | --- | +| `shift-enter` | `CreatePrDialog` | `pr::SubmitPullRequest` | Open the pull request described in the dialog. | + +## Key contexts + +A binding fires when its context matches somewhere on the path from the focused element to the window root, and the deepest matching binding wins. That is what lets one keystroke mean different things in different panels without any `if focused` checks. + +| Context | Set on | +| --- | --- | +| `Workspace` | the workspace root, so it is always in scope | +| `SettingsWindow` | the settings window root | +| `modal` | added to the workspace root while any overlay or dialog is open | +| `TextInput` | any text field, so single-key shortcuts do not steal typing | +| `List` | every panel, picker and dialog that owns a row selection | +| a view name | the panel, overlay or dialog of that name — see the tables above | + +Contexts combine with `&&`, `||` and `!`, and `>` matches a descendant. `!TextInput` is false whenever a text field is anywhere on the focus path, which is why the vim-style letters carry it and the arrow keys do not. + +## Where a panel wins a keystroke + +The deepest match wins, so a few of the shortcuts above cannot be reached while a particular panel has focus. That is intended — the alternative would be a panel unable to give a letter its own meaning. Both bindings stay active; only one of them is what the keystroke does in that panel. + +| Keystroke | Runs | While focused | So this is out of reach | +| --- | --- | --- | --- | +| `/` | `sidebar::FilterBranches` | the sidebar | `rgitui::Search` | +| `secondary-f` | `sidebar::FilterBranches` | the sidebar | `rgitui::Search` | +| `escape` | `graph::GraphCancel` | the commit graph | `menu::Cancel` | +| `/` | `detail::FileSearch` | the commit detail panel | `rgitui::Search` | +| `secondary-f` | `detail::FileSearch` | the commit detail panel | `rgitui::Search` | +| `escape` | `blame::BlameShowDiff` | the blame view | `menu::Cancel` | +| `escape` | `history::HistoryShowDiff` | the file history | `menu::Cancel` | diff --git a/docs/keymap.schema.json b/docs/keymap.schema.json new file mode 100644 index 00000000..5d1255d3 --- /dev/null +++ b/docs/keymap.schema.json @@ -0,0 +1,554 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/noahbclarkson/rgitui/main/docs/keymap.schema.json", + "title": "rgitui keymap", + "description": "Generated from the `commands!` declaration in `crates/rgitui_workspace/src/keymap/registry.rs`. Do not edit by hand.", + "type": "array", + "items": { + "$ref": "#/$defs/section" + }, + "$defs": { + "section": { + "type": "object", + "additionalProperties": false, + "properties": { + "context": { + "type": "string", + "description": "When these bindings are active, e.g. `Workspace && !modal`. Combine identifiers with `&&`, `||` and `!`; `>` matches a descendant. Omit to bind everywhere." + }, + "use_key_equivalents": { + "type": "boolean", + "description": "Interpret keystrokes by their position on a QWERTY keyboard. macOS only.", + "default": false + }, + "bindings": { + "type": "object", + "description": "Keystrokes to actions. A keystroke is modifiers then a key joined by `-` (`secondary-shift-p`); separate the keystrokes of a chord with spaces (`ctrl-k ctrl-o`). Later entries win.", + "additionalProperties": { + "$ref": "#/$defs/action" + } + } + } + }, + "action": { + "description": "An action name, a two-element `[name, input]` array, or `null` to unbind.", + "oneOf": [ + { + "$ref": "#/$defs/actionName" + }, + { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": [ + { + "$ref": "#/$defs/actionName" + }, + true + ] + }, + { + "type": "null", + "description": "Remove this binding." + } + ] + }, + "actionName": { + "type": "string", + "anyOf": [ + { + "const": "zed::NoAction", + "description": "Remove the binding this keystroke would otherwise have." + }, + { + "const": "rgitui::Fetch", + "description": "Download objects and refs from the tracked remote. Command id: `fetch`." + }, + { + "const": "rgitui::Pull", + "description": "Fetch from and integrate with the tracked remote branch. Command id: `pull`." + }, + { + "const": "rgitui::Push", + "description": "Update the remote ref along with associated objects. Command id: `push`." + }, + { + "const": "rgitui::PushAll", + "description": "Push every open repository. Command id: `push_all`." + }, + { + "const": "rgitui::PullAll", + "description": "Pull every open repository. Command id: `pull_all`." + }, + { + "const": "rgitui::ForcePush", + "description": "Overwrite the remote branch with the local one. Command id: `force_push`." + }, + { + "const": "rgitui::Commit", + "description": "Commit the staged changes using the message in the commit panel. Command id: `commit`." + }, + { + "const": "rgitui::StageAll", + "description": "Stage every change in the working tree. Command id: `stage_all`." + }, + { + "const": "rgitui::UnstageAll", + "description": "Unstage everything currently staged. Command id: `unstage_all`." + }, + { + "const": "rgitui::StashSave", + "description": "Stash the working tree and index. Command id: `stash_save`." + }, + { + "const": "rgitui::StashPop", + "description": "Apply the latest stash entry and drop it. Command id: `stash_pop`." + }, + { + "const": "rgitui::StashApply", + "description": "Apply the latest stash entry and keep it. Command id: `stash_apply`." + }, + { + "const": "rgitui::StashDrop", + "description": "Delete a stash entry. Command id: `stash_drop`." + }, + { + "const": "rgitui::CreateBranch", + "description": "Create a new branch. Command id: `create_branch`." + }, + { + "const": "rgitui::DeleteBranch", + "description": "Delete a branch. Command id: `delete_branch`." + }, + { + "const": "rgitui::RenameBranch", + "description": "Rename a branch. Command id: `rename_branch`." + }, + { + "const": "rgitui::MergeBranch", + "description": "Merge another branch into the current one. Command id: `merge_branch`." + }, + { + "const": "rgitui::CreateTag", + "description": "Create a tag. Command id: `create_tag`." + }, + { + "const": "rgitui::CreateWorktree", + "description": "Create a linked worktree. Command id: `create_worktree`." + }, + { + "const": "rgitui::CreatePr", + "description": "Open a pull request for the current branch. Command id: `create_pr`." + }, + { + "const": "rgitui::CherryPick", + "description": "Cherry-pick a commit onto the current branch. Command id: `cherry_pick`." + }, + { + "const": "rgitui::RevertCommit", + "description": "Revert a commit. Command id: `revert_commit`." + }, + { + "const": "rgitui::InteractiveRebase", + "description": "Start an interactive rebase. Command id: `interactive_rebase`." + }, + { + "const": "rgitui::DiscardAll", + "description": "Discard every uncommitted change. Command id: `discard_all`." + }, + { + "const": "rgitui::CleanUntracked", + "description": "Delete untracked files. Command id: `clean_untracked`." + }, + { + "const": "rgitui::ResetHard", + "description": "Reset the working tree and index to HEAD. Command id: `reset_hard`." + }, + { + "const": "rgitui::AbortOperation", + "description": "Abort the merge, rebase, cherry-pick or revert in progress. Command id: `abort_operation`." + }, + { + "const": "rgitui::ContinueMerge", + "description": "Continue the merge, rebase, cherry-pick or revert in progress. Command id: `continue_merge`." + }, + { + "const": "rgitui::ToggleDiffMode", + "description": "Switch the diff viewer between unified and side-by-side. Command id: `toggle_diff_mode`." + }, + { + "const": "rgitui::Search", + "description": "Search the commit graph. Command id: `search`." + }, + { + "const": "rgitui::AiMessage", + "description": "Generate a commit message with the configured AI provider. Command id: `ai_message`." + }, + { + "const": "rgitui::Refresh", + "description": "Reload the repository state from disk. Command id: `refresh`." + }, + { + "const": "rgitui::Settings", + "description": "Open the settings window. Command id: `settings`." + }, + { + "const": "rgitui::OpenRepo", + "description": "Open the repository picker. Command id: `open_repo`." + }, + { + "const": "rgitui::WorkspaceHome", + "description": "Close every tab and return to the workspace home screen. Command id: `workspace_home`." + }, + { + "const": "rgitui::RestoreLastWorkspace", + "description": "Reopen the most recently saved workspace. Command id: `restore_last_workspace`." + }, + { + "const": "rgitui::Shortcuts", + "description": "Show the keyboard shortcut reference. Command id: `shortcuts`." + }, + { + "const": "rgitui::SwitchBranch", + "description": "Focus the sidebar to switch branches. Command id: `switch_branch`." + }, + { + "const": "rgitui::Blame", + "description": "Blame the selected file. Command id: `blame`." + }, + { + "const": "rgitui::Undo", + "description": "Undo the last git operation. Command id: `undo`." + }, + { + "const": "rgitui::FileHistory", + "description": "Show the commit history of the selected file. Command id: `file_history`." + }, + { + "const": "rgitui::Reflog", + "description": "Show the reflog. Command id: `reflog`." + }, + { + "const": "rgitui::Submodules", + "description": "Show the submodule list. Command id: `submodules`." + }, + { + "const": "rgitui::Bisect", + "description": "Show the bisect log. Command id: `bisect`." + }, + { + "const": "rgitui::BisectStart", + "description": "Start a bisect session. Command id: `bisect_start`." + }, + { + "const": "rgitui::BisectGood", + "description": "Mark the current bisect commit as good. Command id: `bisect_good`." + }, + { + "const": "rgitui::BisectBad", + "description": "Mark the current bisect commit as bad. Command id: `bisect_bad`." + }, + { + "const": "rgitui::BisectReset", + "description": "End the bisect session and restore HEAD. Command id: `bisect_reset`." + }, + { + "const": "rgitui::BisectSkip", + "description": "Skip the current bisect commit. Command id: `bisect_skip`." + }, + { + "const": "rgitui::GlobalSearch", + "description": "Search the contents of the working tree. Command id: `global_search`." + }, + { + "const": "rgitui::ToggleIssues", + "description": "Toggle the issues panel. Command id: `toggle_issues`." + }, + { + "const": "rgitui::TogglePullRequests", + "description": "Toggle the pull requests panel. Command id: `toggle_pull_requests`." + }, + { + "const": "rgitui::ToggleBranchHealth", + "description": "Toggle the branch health panel. Command id: `toggle_branch_health`." + }, + { + "const": "rgitui::ToggleStashes", + "description": "Toggle the stashes panel. Command id: `toggle_stashes`." + }, + { + "const": "rgitui::StashBranch", + "description": "Create a branch from a stash entry. Command id: `stash_branch`." + }, + { + "const": "rgitui::OpenThemeEditor", + "description": "Open the theme editor. Command id: `open_theme_editor`." + }, + { + "const": "rgitui::CommandPalette", + "description": "Toggle the command palette. Command id: `command_palette`." + }, + { + "const": "rgitui::NextTab", + "description": "Activate the next repository tab. Command id: `next_tab`." + }, + { + "const": "rgitui::PrevTab", + "description": "Activate the previous repository tab. Command id: `prev_tab`." + }, + { + "const": "rgitui::CloseTab", + "description": "Close the active repository tab. Command id: `close_tab`." + }, + { + "const": "rgitui::FocusSidebar", + "description": "Move keyboard focus to the sidebar. Command id: `focus_sidebar`." + }, + { + "const": "rgitui::FocusGraph", + "description": "Move keyboard focus to the commit graph. Command id: `focus_graph`." + }, + { + "const": "rgitui::FocusDetailPanel", + "description": "Move keyboard focus to the commit detail panel. Command id: `focus_detail_panel`." + }, + { + "const": "rgitui::FocusDiffViewer", + "description": "Move keyboard focus to the diff viewer. Command id: `focus_diff_viewer`." + }, + { + "const": "rgitui::FocusNextPanel", + "description": "Move keyboard focus to the next panel. Command id: `focus_next_panel`." + }, + { + "const": "rgitui::FocusPrevPanel", + "description": "Move keyboard focus to the previous panel. Command id: `focus_prev_panel`." + }, + { + "const": "rgitui::ShrinkDetailPanel", + "description": "Narrow the detail panel. Command id: `shrink_detail_panel`." + }, + { + "const": "rgitui::GrowDetailPanel", + "description": "Widen the detail panel. Command id: `grow_detail_panel`." + }, + { + "const": "rgitui::ShrinkDiffViewer", + "description": "Shorten the diff viewer. Command id: `shrink_diff_viewer`." + }, + { + "const": "rgitui::GrowDiffViewer", + "description": "Heighten the diff viewer. Command id: `grow_diff_viewer`." + }, + { + "const": "rgitui::OpenKeymap", + "description": "Open keymap.json in your editor to rebind shortcuts. Command id: `open_keymap`." + }, + { + "const": "menu::Cancel", + "description": "Dismiss the focused overlay, dialog, search or selection. Command id: `cancel`." + }, + { + "const": "menu::Confirm", + "description": "Activate the selected row, or submit the focused dialog. Command id: `confirm`." + }, + { + "const": "menu::SelectNext", + "description": "Move the selection down one row. Command id: `select_next`." + }, + { + "const": "menu::SelectPrev", + "description": "Move the selection up one row. Command id: `select_prev`." + }, + { + "const": "menu::SelectFirst", + "description": "Move the selection to the first row. Command id: `select_first`." + }, + { + "const": "menu::SelectLast", + "description": "Move the selection to the last row. Command id: `select_last`." + }, + { + "const": "graph::GraphSelectNext", + "description": "Select the next commit in the graph. Command id: `graph_select_next`." + }, + { + "const": "graph::GraphSelectPrev", + "description": "Select the previous commit in the graph. Command id: `graph_select_prev`." + }, + { + "const": "graph::GraphSelectFirst", + "description": "Select the newest commit in the graph. Command id: `graph_select_first`." + }, + { + "const": "graph::GraphSelectLast", + "description": "Select the oldest loaded commit in the graph. Command id: `graph_select_last`." + }, + { + "const": "graph::GraphExtendSelectionNext", + "description": "Add the next commit in the graph to the selection. Command id: `graph_extend_selection_next`." + }, + { + "const": "graph::GraphExtendSelectionPrev", + "description": "Add the previous commit in the graph to the selection. Command id: `graph_extend_selection_prev`." + }, + { + "const": "graph::SquashSelected", + "description": "Squash the selected commits into the oldest of them. Command id: `squash_selected`." + }, + { + "const": "graph::GraphCancel", + "description": "Close the graph search, or dismiss the graph context menu. Command id: `graph_cancel`." + }, + { + "const": "graph::CopyCommitSha", + "description": "Copy the selected commit's SHA to the clipboard. Command id: `copy_commit_sha`." + }, + { + "const": "graph::CopyCommitMessage", + "description": "Copy the selected commit's message to the clipboard. Command id: `copy_commit_message`." + }, + { + "const": "diff::DiffSelectNext", + "description": "Move the diff cursor down one row. Command id: `diff_select_next`." + }, + { + "const": "diff::DiffSelectPrev", + "description": "Move the diff cursor up one row. Command id: `diff_select_prev`." + }, + { + "const": "diff::DiffSelectFirst", + "description": "Move the diff cursor to the first row. Command id: `diff_select_first`." + }, + { + "const": "diff::DiffSelectLast", + "description": "Move the diff cursor to the last row. Command id: `diff_select_last`." + }, + { + "const": "diff::NextHunk", + "description": "Jump to the next hunk. Command id: `next_hunk`." + }, + { + "const": "diff::PrevHunk", + "description": "Jump to the previous hunk. Command id: `prev_hunk`." + }, + { + "const": "diff::ToggleDiffDisplayMode", + "description": "Cycle the diff viewer's display mode. Command id: `toggle_diff_display_mode`." + }, + { + "const": "diff::TogglePartialSelection", + "description": "Toggle line-level selection in the diff viewer. Command id: `toggle_partial_selection`." + }, + { + "const": "diff::StageSelection", + "description": "Stage the hunks or lines under the diff selection. Command id: `stage_selection`." + }, + { + "const": "diff::UnstageSelection", + "description": "Unstage the hunks or lines under the diff selection. Command id: `unstage_selection`." + }, + { + "const": "diff::StageCurrentHunk", + "description": "Stage the hunk under the diff cursor. Command id: `stage_current_hunk`." + }, + { + "const": "diff::UnstageCurrentHunk", + "description": "Unstage the hunk under the diff cursor. Command id: `unstage_current_hunk`." + }, + { + "const": "diff::CopyDiffSelection", + "description": "Copy the selected diff lines to the clipboard. Command id: `copy_diff_selection`." + }, + { + "const": "diff::SelectAllDiffLines", + "description": "Select every line in the diff. Command id: `select_all_diff_lines`." + }, + { + "const": "detail::ToggleFileTree", + "description": "Switch the changed-files list between the flat and tree layouts. Command id: `toggle_file_tree`." + }, + { + "const": "detail::PrevCommitDetails", + "description": "Show the previous commit's details. Command id: `prev_commit_details`." + }, + { + "const": "detail::NextCommitDetails", + "description": "Show the next commit's details. Command id: `next_commit_details`." + }, + { + "const": "detail::FileSearch", + "description": "Filter the changed-files list. Command id: `file_search`." + }, + { + "const": "sidebar::ToggleStageRow", + "description": "Stage or unstage the selected file. Command id: `toggle_stage_row`." + }, + { + "const": "sidebar::DiscardRow", + "description": "Discard the selected change, or delete the selected branch, tag or stash. Command id: `discard_row`." + }, + { + "const": "sidebar::FilterBranches", + "description": "Filter the branch list. Command id: `filter_branches`." + }, + { + "const": "blame::BlameShowDiff", + "description": "Leave the blame view and go back to the diff. Command id: `blame_show_diff`." + }, + { + "const": "blame::BlameShowHistory", + "description": "Show the blamed file's commit history. Command id: `blame_show_history`." + }, + { + "const": "history::HistoryShowDiff", + "description": "Leave the file history and go back to the diff. Command id: `history_show_diff`." + }, + { + "const": "history::HistoryShowBlame", + "description": "Blame the file whose history is shown. Command id: `history_show_blame`." + }, + { + "const": "rebase::RebaseMoveUp", + "description": "Move the selected commit earlier in the rebase plan. Command id: `rebase_move_up`." + }, + { + "const": "rebase::RebaseMoveDown", + "description": "Move the selected commit later in the rebase plan. Command id: `rebase_move_down`." + }, + { + "const": "rebase::RebasePick", + "description": "Keep the selected commit as it is. Command id: `rebase_pick`." + }, + { + "const": "rebase::RebaseReword", + "description": "Reword the selected commit's message. Command id: `rebase_reword`." + }, + { + "const": "rebase::RebaseSquash", + "description": "Squash the selected commit into the previous one. Command id: `rebase_squash`." + }, + { + "const": "rebase::RebaseFixup", + "description": "Squash the selected commit into the previous one, discarding its message. Command id: `rebase_fixup`." + }, + { + "const": "rebase::RebaseDrop", + "description": "Drop the selected commit. Command id: `rebase_drop`." + }, + { + "const": "theme::ThemeEditorNextField", + "description": "Focus the next field in the theme editor. Command id: `theme_editor_next_field`." + }, + { + "const": "theme::ThemeEditorPrevField", + "description": "Focus the previous field in the theme editor. Command id: `theme_editor_prev_field`." + }, + { + "const": "pr::SubmitPullRequest", + "description": "Open the pull request described in the dialog. Command id: `submit_pull_request`." + } + ] + } + } +}