aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/modal.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/input/modal.rs')
-rw-r--r--src/app/input/modal.rs2421
1 files changed, 0 insertions, 2421 deletions
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
deleted file mode 100644
index e710459b..00000000
--- a/src/app/input/modal.rs
+++ /dev/null
@@ -1,2421 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-#[cfg(test)]
-use ratatui::layout::Direction;
-use ratatui::layout::Rect;
-
-use crate::{
- app::{
- state::{
- AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter,
- },
- App,
- },
- input::TerminalKey,
- layout::NavDirection,
-};
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(super) enum ModalAction {
- Continue,
- Save,
- Clear,
- Cancel,
- Confirm,
- Apply,
- Close,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(super) enum ModalKeyBinding {
- Enter,
- Esc,
- CtrlC,
-}
-
-impl ModalKeyBinding {
- fn matches(self, key: &KeyEvent) -> bool {
- match self {
- Self::Enter => key.code == KeyCode::Enter,
- Self::Esc => key.code == KeyCode::Esc,
- Self::CtrlC => {
- key.code == KeyCode::Char('c')
- && key.modifiers == crossterm::event::KeyModifiers::CONTROL
- }
- }
- }
-}
-
-#[derive(Debug, Clone, Copy)]
-pub(super) struct ModalActionSpec<A> {
- pub action: A,
- pub bindings: &'static [ModalKeyBinding],
-}
-
-pub(super) fn modal_action_from_key<A: Copy>(
- key: &KeyEvent,
- specs: &[ModalActionSpec<A>],
-) -> Option<A> {
- specs
- .iter()
- .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key)))
- .map(|spec| spec.action)
-}
-
-pub(super) fn modal_action_from_buttons<A: Copy>(
- col: u16,
- row: u16,
- buttons: &[(Rect, A)],
-) -> Option<A> {
- buttons.iter().find_map(|(rect, action)| {
- (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height)
- .then_some(*action)
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) enum GlobalMenuAction {
- Detach,
- WhatsNew,
- Keybinds,
- ReloadConfig,
- Settings,
-}
-
-pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
- let mut actions = vec![
- GlobalMenuAction::Settings,
- GlobalMenuAction::Keybinds,
- GlobalMenuAction::ReloadConfig,
- ];
- if state.update_available.is_some() || state.latest_release_notes_available {
- actions.push(GlobalMenuAction::WhatsNew);
- }
- actions.push(GlobalMenuAction::Detach);
- actions
-}
-
-pub(super) fn open_global_menu(state: &mut AppState) {
- state.global_menu = MenuListState::new(0);
- state.mode = Mode::GlobalMenu;
-}
-
-pub(super) fn open_keybind_help(state: &mut AppState) {
- state.keybind_help.scroll = 0;
- state.keybind_help.query.clear();
- state.keybind_help.search_focused = false;
- state.mode = Mode::KeybindHelp;
-}
-
-fn open_update_release_notes(state: &mut AppState) {
- let Some(notes) = crate::release_notes::load_latest() else {
- return;
- };
-
- state.release_notes = Some(crate::app::state::ReleaseNotesState {
- version: notes.version,
- body: notes.body,
- scroll: 0,
- preview: notes.preview,
- });
- state.mode = Mode::ReleaseNotes;
-}
-
-pub(super) fn request_detach(state: &mut AppState) {
- if state.detach_exits {
- state.should_quit = true;
- } else {
- state.detach_requested = true;
- }
-}
-
-pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
- match action {
- GlobalMenuAction::Detach => {
- leave_modal(state);
- request_detach(state);
- }
- GlobalMenuAction::WhatsNew => open_update_release_notes(state),
- GlobalMenuAction::Keybinds => open_keybind_help(state),
- GlobalMenuAction::ReloadConfig => {
- state.request_reload_config = true;
- leave_modal(state);
- }
- GlobalMenuAction::Settings => super::settings::open_settings(state),
- }
-}
-
-pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
- let actions = global_menu_actions(state);
- match key.code {
- KeyCode::Esc => leave_modal(state),
- KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
- KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
- KeyCode::Enter => {
- if let Some(action) = actions.get(state.global_menu.highlighted).copied() {
- apply_global_menu_action(state, action);
- }
- }
- _ => {}
- }
-}
-
-pub(crate) fn handle_navigator_key(
- state: &mut AppState,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- key: KeyEvent,
-) {
- if state.navigator.search_focused {
- match key.code {
- KeyCode::Esc => {
- state.navigator.search_focused = false;
- }
- KeyCode::Enter => {
- state.accept_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Backspace => {
- state.navigator.state_filter = None;
- state.navigator.query.pop();
- state.select_first_navigator_match_from(terminal_runtimes);
- }
- KeyCode::Up => state.move_navigator_selection_from(terminal_runtimes, -1),
- KeyCode::Down => state.move_navigator_selection_from(terminal_runtimes, 1),
- KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => {
- state.move_navigator_selection_from(terminal_runtimes, 1)
- }
- KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => {
- state.move_navigator_selection_from(terminal_runtimes, -1)
- }
- KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
- state.navigator.query.clear();
- state.navigator.state_filter = None;
- state.clamp_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Char(c)
- if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
- {
- insert_navigator_search_text(state, terminal_runtimes, &c.to_string());
- }
- _ => {}
- }
- return;
- }
-
- match key.code {
- KeyCode::Esc => {
- leave_modal(state);
- }
- KeyCode::Enter => {
- state.accept_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Char('/') => {
- state.navigator.state_filter = None;
- state.navigator.search_focused = true;
- state.clamp_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Backspace if state.navigator.state_filter.is_some() => {
- state.navigator.state_filter = None;
- state.clamp_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Char('a') if key.modifiers.is_empty() => {
- state.navigator.query.clear();
- state.navigator.state_filter = None;
- state.clamp_navigator_selection_from(terminal_runtimes);
- }
- KeyCode::Char('b') if key.modifiers.is_empty() => {
- state.navigator.query.clear();
- state.navigator.state_filter = Some(NavigatorStateFilter::Blocked);
- state.select_first_navigator_match_from(terminal_runtimes);
- }
- KeyCode::Char('w') if key.modifiers.is_empty() => {
- state.navigator.query.clear();
- state.navigator.state_filter = Some(NavigatorStateFilter::Working);
- state.select_first_navigator_match_from(terminal_runtimes);
- }
- KeyCode::Char('i') if key.modifiers.is_empty() => {
- state.navigator.query.clear();
- state.navigator.state_filter = Some(NavigatorStateFilter::Idle);
- state.select_first_navigator_match_from(terminal_runtimes);
- }
- KeyCode::Char('d') if key.modifiers.is_empty() => {
- state.navigator.query.clear();
- state.navigator.state_filter = Some(NavigatorStateFilter::Done);
- state.select_first_navigator_match_from(terminal_runtimes);
- }
- KeyCode::Char('j') | KeyCode::Down if key.modifiers.is_empty() => {
- state.move_navigator_selection_from(terminal_runtimes, 1)
- }
- KeyCode::Char('k') | KeyCode::Up if key.modifiers.is_empty() => {
- state.move_navigator_selection_from(terminal_runtimes, -1)
- }
- KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => state
- .move_navigator_selection_by_lines_from(
- terminal_runtimes,
- (state.navigator_body_rect().height / 2).max(1) as isize,
- ),
- KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => state
- .move_navigator_selection_by_lines_from(
- terminal_runtimes,
- -((state.navigator_body_rect().height / 2).max(1) as isize),
- ),
- KeyCode::Char(' ') => state.toggle_selected_navigator_workspace_from(terminal_runtimes),
- KeyCode::Home => {
- state.navigator.selected = 0;
- state.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
- KeyCode::End | KeyCode::Char('G') => {
- state.navigator.selected = state
- .navigator_rows_from(terminal_runtimes)
- .len()
- .saturating_sub(1);
- state.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
- _ => {}
- }
-}
-
-pub(crate) fn insert_navigator_search_text(
- state: &mut AppState,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- text: &str,
-) {
- if !state.navigator.search_focused {
- return;
- }
- state.navigator.state_filter = None;
- state.navigator.query.push_str(text);
- state.select_first_navigator_match_from(terminal_runtimes);
-}
-
-pub(crate) fn insert_keybind_help_query_text(state: &mut AppState, text: &str) {
- if !state.keybind_help.search_focused {
- return;
- }
- state
- .keybind_help
- .query
- .extend(text.chars().filter(|ch| !ch.is_control()));
- state.keybind_help.scroll = 0;
-}
-
-pub(super) fn keybind_help_back(state: &mut AppState) {
- if state.keybind_help.search_focused {
- state.keybind_help.query.clear();
- state.keybind_help.search_focused = false;
- state.keybind_help.scroll = 0;
- } else {
- leave_modal(state);
- }
-}
-
-pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: TerminalKey) {
- if state.keybind_help.search_focused {
- let text_char = keybind_help_text_char(key.clone());
- match key.code {
- KeyCode::Up => state.scroll_keybind_help(-1),
- KeyCode::Down => state.scroll_keybind_help(1),
- KeyCode::PageUp => state.scroll_keybind_help(-8),
- KeyCode::PageDown => state.scroll_keybind_help(8),
- KeyCode::Home => state.keybind_help.scroll = 0,
- KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
- KeyCode::Backspace => {
- state.keybind_help.query.pop();
- state.keybind_help.scroll = 0;
- }
- KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL => {
- state.keybind_help.query.clear();
- state.keybind_help.scroll = 0;
- }
- KeyCode::Esc => keybind_help_back(state),
- KeyCode::Enter => leave_modal(state),
- _ => {
- if let Some(character) = text_char {
- insert_keybind_help_query_text(state, &character.to_string());
- }
- }
- }
- return;
- }
-
- match key.code {
- KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
- KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1),
- KeyCode::PageUp => state.scroll_keybind_help(-8),
- KeyCode::PageDown => state.scroll_keybind_help(8),
- KeyCode::Home => state.keybind_help.scroll = 0,
- KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
- _ if keybind_help_text_char(key.clone()) == Some('/') => {
- state.keybind_help.search_focused = true;
- state.keybind_help.scroll = 0;
- }
- KeyCode::Esc => keybind_help_back(state),
- KeyCode::Enter => leave_modal(state),
- _ if keybind_help_text_char(key.clone()) == Some('?') => leave_modal(state),
- _ => {}
- }
-}
-
-fn keybind_help_text_char(key: TerminalKey) -> Option<char> {
- if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() {
- return None;
- }
- if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) {
- return Some(character);
- }
- let KeyCode::Char(character) = key.code else {
- return None;
- };
- Some(character)
-}
-
-pub(super) fn open_rename_workspace(
- state: &mut AppState,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ws_idx: usize,
-) {
- state.pending_workspace_create_cwd = None;
- state.selected = ws_idx;
- state.rename_pane_target = None;
- state.name_input =
- state.workspaces[ws_idx].display_name_from(&state.terminals, terminal_runtimes);
- state.name_input_replace_on_type = false;
- state.mode = Mode::RenameWorkspace;
-}
-
-pub(crate) fn open_new_workspace_dialog(state: &mut AppState, cwd: std::path::PathBuf) {
- let suggested_name = crate::workspace::derive_label_from_cwd(&cwd);
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = Some(cwd);
- state.rename_pane_target = None;
- state.name_input = suggested_name;
- state.name_input_replace_on_type = true;
- state.mode = Mode::RenameWorkspace;
-}
-
-pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) {
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = None;
- if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) {
- if let Some(name) = ws.active_tab_display_name() {
- state.name_input = name;
- state.name_input_replace_on_type = replace_on_type;
- state.mode = Mode::RenameTab;
- }
- }
-}
-
-pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::PaneId) {
- let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) else {
- return;
- };
- let Some(pane) = ws.pane_state(pane_id) else {
- return;
- };
- let terminal = state.terminals.get(&pane.attached_terminal_id);
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = Some(pane_id);
- state.name_input = terminal
- .and_then(|t| t.manual_label.clone())
- .unwrap_or_default();
- state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none();
- state.mode = Mode::RenamePane;
-}
-
-fn workspace_create_label(input: &str, suggested_name: &str) -> Option<String> {
- let name = input.trim();
- (!name.is_empty() && name != suggested_name).then(|| name.to_string())
-}
-
-fn next_new_tab_default_name(state: &AppState) -> String {
- state
- .active
- .and_then(|i| state.workspaces.get(i))
- .map(|ws| (ws.tabs.len() + 1).to_string())
- .unwrap_or_else(|| "1".to_string())
-}
-
-pub(super) fn open_new_tab_dialog(state: &mut AppState) {
- state.creating_new_tab = true;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = None;
- state.name_input = next_new_tab_default_name(state);
- state.name_input_replace_on_type = true;
- state.mode = Mode::RenameTab;
-}
-
-pub(super) fn leave_modal(state: &mut AppState) {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- } else {
- state.mode = Mode::Navigate;
- }
-}
-
-pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
- action: ModalAction::Continue,
- bindings: &[ModalKeyBinding::Enter],
-}];
-
-pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
- action: ModalAction::Close,
- bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc],
-}];
-
-pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Save,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Clear,
- bindings: &[ModalKeyBinding::CtrlC],
- },
- ModalActionSpec {
- action: ModalAction::Cancel,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Confirm,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Cancel,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Apply,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Close,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-#[cfg(test)]
-pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) {
- match action {
- ModalAction::Save => {
- let new_name = if state.name_input.trim().is_empty() {
- state.name_input.clone()
- } else {
- state.name_input.trim().to_string()
- };
- match state.mode {
- Mode::RenameWorkspace
- if state.pending_workspace_create_cwd.is_none()
- && !state.workspaces.is_empty()
- && !new_name.is_empty() =>
- {
- let workspace_id = state.workspaces[state.selected].id.clone();
- state.workspaces[state.selected].set_custom_name(new_name);
- crate::logging::workspace_renamed(&workspace_id);
- state.mark_session_dirty();
- }
- Mode::RenameTab if state.creating_new_tab => {
- state.request_new_tab = true;
- let default_name = next_new_tab_default_name(state);
- state.requested_new_tab_name =
- if new_name.is_empty() || new_name == default_name {
- None
- } else {
- Some(new_name)
- };
- }
- Mode::RenameTab => {
- if let Some(ws_idx) = state.active {
- if let Some(ws) = state.workspaces.get_mut(ws_idx) {
- let workspace_id = ws.id.clone();
- let active_tab = ws.active_tab;
- let keep_auto_name = ws
- .tabs
- .get(active_tab)
- .is_some_and(|tab| tab.is_auto_named())
- && ws
- .tab_display_name(active_tab)
- .is_some_and(|name| new_name == name);
- if let Some(tab) = ws.active_tab_mut() {
- if !new_name.is_empty() && !keep_auto_name {
- tab.set_custom_name(new_name);
- let tab_id = ws
- .public_tab_number(active_tab)
- .map(|number| {
- crate::workspace::public_tab_id_for_number(
- &workspace_id,
- number,
- )
- })
- .unwrap_or_else(|| workspace_id.clone());
- crate::logging::tab_renamed(&workspace_id, &tab_id);
- state.mark_session_dirty();
- }
- }
- }
- }
- }
- Mode::RenamePane => {
- if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target)
- {
- if let Some(ws) = state.workspaces.get(ws_idx) {
- if let Some(pane) = ws.pane_state(pane_id) {
- let terminal_id = pane.attached_terminal_id.clone();
- if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
- terminal.set_manual_label(new_name);
- state.mark_session_dirty();
- }
- }
- }
- }
- }
- _ => {}
- }
- state.creating_new_tab = false;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = None;
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- leave_modal(state);
- }
- ModalAction::Clear => {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- }
- ModalAction::Cancel => {
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = None;
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- leave_modal(state);
- }
- _ => {}
- }
-}
-
-fn clear_rename_input(state: &mut AppState) {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
-}
-
-pub(crate) fn insert_rename_input_text(state: &mut AppState, text: &str) {
- if state.name_input_replace_on_type {
- clear_rename_input(state);
- }
- state.name_input.push_str(text);
-}
-
-fn delete_rename_input_char(state: &mut AppState) {
- if state.name_input_replace_on_type {
- clear_rename_input(state);
- } else {
- state.name_input.pop();
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RenameWordDeleteClass {
- Word,
- Separator,
-}
-
-fn rename_word_delete_class(ch: char) -> RenameWordDeleteClass {
- if ch.is_alphanumeric() || ch == '_' {
- RenameWordDeleteClass::Word
- } else {
- RenameWordDeleteClass::Separator
- }
-}
-
-fn delete_rename_input_word(state: &mut AppState) {
- if state.name_input_replace_on_type {
- clear_rename_input(state);
- return;
- }
-
- while state
- .name_input
- .chars()
- .last()
- .is_some_and(char::is_whitespace)
- {
- state.name_input.pop();
- }
-
- let Some(class) = state
- .name_input
- .chars()
- .last()
- .map(rename_word_delete_class)
- else {
- return;
- };
-
- while state
- .name_input
- .chars()
- .last()
- .is_some_and(|ch| !ch.is_whitespace() && rename_word_delete_class(ch) == class)
- {
- state.name_input.pop();
- }
-}
-
-fn handle_rename_edit_key(state: &mut AppState, key: KeyEvent) {
- match key.code {
- KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
- clear_rename_input(state);
- }
- KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => {
- clear_rename_input(state);
- }
- KeyCode::Backspace
- if key.modifiers.contains(KeyModifiers::CONTROL)
- || key.modifiers.contains(KeyModifiers::ALT) =>
- {
- delete_rename_input_word(state);
- }
- KeyCode::Char('h' | 'w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
- delete_rename_input_word(state);
- }
- KeyCode::Backspace => delete_rename_input_char(state),
- KeyCode::Char(c) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
- insert_rename_input_text(state, &c.to_string());
- }
- _ => {}
- }
-}
-
-#[cfg(test)]
-pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
- if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
- apply_rename_action(state, action);
- return;
- }
-
- handle_rename_edit_key(state, key);
-}
-
-#[cfg(test)]
-pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
- let key = raw_key.as_key_event();
- if key.code == KeyCode::Esc
- || key.code == KeyCode::Enter
- || state.keybinds.resize_mode.matches_prefix_key(&raw_key)
- || state.keybinds.resize_mode.matches_direct_key(&raw_key)
- {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- } else {
- state.mode = Mode::Navigate;
- }
- return;
- }
-
- match key.code {
- KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
- KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
- KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
- KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up),
- _ => {}
- }
-}
-
-pub(super) fn open_confirm_close(state: &mut AppState) {
- state.begin_workspace_close_confirmation(state.selected);
-}
-
-#[cfg(test)]
-pub(super) fn confirm_close_accept(state: &mut AppState) {
- if let Some(ws_idx) = state.take_confirmed_workspace_close_index() {
- state.selected = ws_idx;
- state.close_selected_workspace();
- }
- if state.workspaces.is_empty() {
- state.mode = Mode::Navigate;
- } else {
- state.mode = Mode::Terminal;
- }
-}
-
-pub(super) fn confirm_close_cancel(state: &mut AppState) {
- state.confirm_close_workspace_id = None;
- state.mode = Mode::Navigate;
-}
-
-#[cfg(test)]
-pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
- match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
- Some(ModalAction::Confirm) => confirm_close_accept(state),
- Some(ModalAction::Cancel) => confirm_close_cancel(state),
- _ => {}
- }
-}
-
-#[cfg(test)]
-pub(super) fn apply_context_menu_action(
- state: &mut AppState,
- terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
- menu: ContextMenuState,
- idx: usize,
-) {
- let item = menu.items().get(idx).copied();
- match (menu.kind, item) {
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => {
- state.request_new_linked_worktree = Some(ws_idx);
- leave_modal(state);
- }
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => {
- state.request_remove_linked_worktree = Some(ws_idx);
- leave_modal(state);
- }
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => {
- state.request_open_existing_worktree = Some(ws_idx);
- leave_modal(state);
- }
- (
- ContextMenuKind::GitWorkspace {
- ws_idx, collapsed, ..
- },
- Some("Collapse" | "Expand"),
- ) => {
- if let Some(key) = state
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.worktree_space())
- .map(|space| space.key.clone())
- {
- if collapsed {
- state.collapsed_space_keys.remove(&key);
- } else {
- state.collapsed_space_keys.insert(key);
- }
- state.mark_session_dirty();
- }
- leave_modal(state);
- }
- (
- ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. },
- Some("Rename"),
- ) => {
- open_rename_workspace(state, terminal_runtimes, ws_idx);
- }
- (
- ContextMenuKind::Workspace { ws_idx } | ContextMenuKind::GitWorkspace { ws_idx, .. },
- Some("Close" | "Close group"),
- ) => {
- state.selected = ws_idx;
- if state.confirm_close {
- open_confirm_close(state);
- } else {
- state.close_selected_workspace();
- state.mode = Mode::Navigate;
- }
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- open_new_tab_dialog(state);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- open_rename_active_tab(state, false);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- if !state.close_tab() {
- state.mode = if state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- }
- (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => {
- open_rename_pane(state, pane_id);
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Clear pane name"),
- ) => {
- if let Some(ws) = state.workspaces.get(ws_idx) {
- if let Some(pane) = ws.pane_state(pane_id) {
- let terminal_id = pane.attached_terminal_id.clone();
- if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
- terminal.clear_manual_label();
- state.mark_session_dirty();
- }
- }
- }
- state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- source_pane_id,
- ..
- },
- Some("Swap with focused pane"),
- ) => {
- if let Some(source_pane_id) = source_pane_id {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- if let Some(tab) = state
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- {
- if tab.layout.swap_panes(source_pane_id, pane_id) {
- tab.layout.focus_pane(source_pane_id);
- state.mark_session_dirty();
- }
- }
- }
- state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- ..
- },
- Some("Split right"),
- ) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- state.focus_pane_in_workspace(ws_idx, pane_id);
- state.split_pane(terminal_runtimes, Direction::Horizontal);
- state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- ..
- },
- Some("Split down"),
- ) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- state.focus_pane_in_workspace(ws_idx, pane_id);
- state.split_pane(terminal_runtimes, Direction::Vertical);
- state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- ..
- },
- Some("Zoom"),
- ) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- state.focus_pane_in_workspace(ws_idx, pane_id);
- state.toggle_zoom();
- state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- ..
- },
- Some("Close pane"),
- ) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- state.focus_pane_in_workspace(ws_idx, pane_id);
- if !state.close_pane() {
- state.mode = if state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- }
- _ => leave_modal(state),
- }
-}
-
-#[cfg(test)]
-pub(crate) fn handle_context_menu_key(
- state: &mut AppState,
- terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
- key: KeyEvent,
-) {
- match key.code {
- KeyCode::Esc => {
- state.context_menu = None;
- leave_modal(state);
- }
- KeyCode::Up => {
- if let Some(menu) = &mut state.context_menu {
- menu.list.move_prev();
- }
- }
- KeyCode::Down => {
- if let Some(menu) = &mut state.context_menu {
- menu.list.move_next(menu.items().len());
- }
- }
- KeyCode::Enter => {
- if let Some(menu) = state.context_menu.take() {
- let idx = menu.list.highlighted;
- apply_context_menu_action(state, terminal_runtimes, menu, idx);
- }
- }
- _ => {}
- }
-}
-
-impl App {
- pub(crate) fn handle_rename_key_via_api(&mut self, key: KeyEvent) {
- if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
- self.apply_rename_mouse_action_via_api(action);
- return;
- }
-
- handle_rename_edit_key(&mut self.state, key);
- }
-
- fn save_rename_modal_via_api(&mut self) {
- let new_name = if self.state.name_input.trim().is_empty() {
- self.state.name_input.clone()
- } else {
- self.state.name_input.trim().to_string()
- };
-
- match self.state.mode {
- Mode::RenameWorkspace => {
- if let Some(cwd) = self.state.pending_workspace_create_cwd.take() {
- let suggested_name = crate::workspace::derive_label_from_cwd(&cwd);
- let label = workspace_create_label(&new_name, &suggested_name);
- self.runtime_workspace_create(
- "tui.workspace.create_named",
- crate::api::schema::WorkspaceCreateParams {
- cwd: Some(cwd.display().to_string()),
- focus: true,
- label,
- env: Default::default(),
- },
- );
- } else if !self.state.workspaces.is_empty() && !new_name.is_empty() {
- let workspace_id = self.public_workspace_id(self.state.selected);
- self.runtime_workspace_rename(
- "tui.workspace.rename",
- crate::api::schema::WorkspaceRenameParams {
- workspace_id,
- label: new_name,
- },
- );
- }
- }
- Mode::RenameTab if self.state.creating_new_tab => {
- let default_name = next_new_tab_default_name(&self.state);
- let label = if new_name.is_empty() || new_name == default_name {
- None
- } else {
- Some(new_name)
- };
- self.runtime_tab_create(
- "tui.tab.create_named",
- crate::api::schema::TabCreateParams {
- workspace_id: None,
- cwd: None,
- focus: true,
- label,
- env: Default::default(),
- },
- );
- }
- Mode::RenameTab if !new_name.is_empty() => {
- let Some(ws_idx) = self.state.active else {
- cancel_rename_modal(&mut self.state);
- return;
- };
- let tab_idx = self.state.workspaces[ws_idx].active_tab;
- let keep_auto_name = self.state.workspaces[ws_idx]
- .tabs
- .get(tab_idx)
- .is_some_and(|tab| tab.is_auto_named())
- && self.state.workspaces[ws_idx]
- .tab_display_name(tab_idx)
- .is_some_and(|name| new_name == name);
- if !keep_auto_name {
- if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
- self.runtime_tab_rename(
- "tui.tab.rename",
- crate::api::schema::TabRenameParams {
- tab_id,
- label: new_name,
- },
- );
- }
- }
- }
- Mode::RenamePane => {
- if let (Some(ws_idx), Some(pane_id)) =
- (self.state.active, self.state.rename_pane_target)
- {
- if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) {
- self.runtime_pane_rename(
- "tui.pane.rename",
- crate::api::schema::PaneRenameParams {
- pane_id,
- label: Some(new_name),
- },
- );
- }
- }
- }
- _ => {}
- }
-
- cancel_rename_modal(&mut self.state);
- }
-
- pub(super) fn apply_rename_mouse_action_via_api(&mut self, action: ModalAction) {
- match action {
- ModalAction::Save => self.save_rename_modal_via_api(),
- ModalAction::Clear => {
- self.state.name_input.clear();
- self.state.name_input_replace_on_type = false;
- }
- ModalAction::Cancel => cancel_rename_modal(&mut self.state),
- _ => {}
- }
- }
-
- pub(super) fn confirm_close_accept_via_api(&mut self) {
- if let Some(ws_idx) = self.state.take_confirmed_workspace_close_index() {
- self.close_workspace_idx_with_group_via_api(ws_idx);
- }
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
-
- pub(crate) fn handle_resize_key_via_api(&mut self, raw_key: TerminalKey) {
- let key = raw_key.as_key_event();
- if key.code == KeyCode::Esc
- || key.code == KeyCode::Enter
- || self.state.keybinds.resize_mode.matches_prefix_key(&raw_key)
- || self.state.keybinds.resize_mode.matches_direct_key(&raw_key)
- {
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- return;
- }
-
- let direction = match key.code {
- KeyCode::Char('h') | KeyCode::Left => Some(NavDirection::Left),
- KeyCode::Char('l') | KeyCode::Right => Some(NavDirection::Right),
- KeyCode::Char('j') | KeyCode::Down => Some(NavDirection::Down),
- KeyCode::Char('k') | KeyCode::Up => Some(NavDirection::Up),
- _ => None,
- };
- if let Some(direction) = direction {
- self.runtime_pane_resize(
- "tui.pane.resize",
- crate::api::schema::PaneResizeParams {
- pane_id: None,
- direction: super::navigate::api_pane_direction(direction),
- amount: None,
- },
- );
- }
- }
-
- pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) {
- match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
- Some(ModalAction::Confirm) => {
- self.confirm_close_accept_via_api();
- }
- Some(ModalAction::Cancel) => confirm_close_cancel(&mut self.state),
- _ => {}
- }
- }
-
- pub(crate) fn handle_context_menu_key_via_api(&mut self, key: KeyEvent) {
- match key.code {
- KeyCode::Esc => {
- self.state.context_menu = None;
- leave_modal(&mut self.state);
- }
- KeyCode::Up => {
- if let Some(menu) = &mut self.state.context_menu {
- menu.list.move_prev();
- }
- }
- KeyCode::Down => {
- if let Some(menu) = &mut self.state.context_menu {
- menu.list.move_next(menu.items().len());
- }
- }
- KeyCode::Enter => {
- if let Some(menu) = self.state.context_menu.take() {
- let idx = menu.list.highlighted;
- self.apply_context_menu_action_via_api(menu, idx);
- }
- }
- _ => {}
- }
- }
-
- pub(crate) fn apply_context_menu_action_via_api(&mut self, menu: ContextMenuState, idx: usize) {
- let item = menu.items().get(idx).copied();
- match (menu.kind, item) {
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => {
- self.state.request_new_linked_worktree = Some(ws_idx);
- leave_modal(&mut self.state);
- }
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => {
- self.state.request_remove_linked_worktree = Some(ws_idx);
- leave_modal(&mut self.state);
- }
- (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => {
- self.state.request_open_existing_worktree = Some(ws_idx);
- leave_modal(&mut self.state);
- }
- (
- ContextMenuKind::GitWorkspace {
- ws_idx, collapsed, ..
- },
- Some("Collapse" | "Expand"),
- ) => {
- if let Some(key) = self
- .state
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.worktree_space())
- .map(|space| space.key.clone())
- {
- if collapsed {
- self.state.collapsed_space_keys.remove(&key);
- } else {
- self.state.collapsed_space_keys.insert(key);
- }
- self.state.mark_session_dirty();
- }
- leave_modal(&mut self.state);
- }
- (
- ContextMenuKind::Workspace { ws_idx }
- | ContextMenuKind::GitWorkspace { ws_idx, .. },
- Some("Rename"),
- ) => open_rename_workspace(&mut self.state, &self.terminal_runtimes, ws_idx),
- (
- ContextMenuKind::Workspace { ws_idx }
- | ContextMenuKind::GitWorkspace { ws_idx, .. },
- Some("Close" | "Close group"),
- ) => {
- self.state.selected = ws_idx;
- if self.state.confirm_close {
- open_confirm_close(&mut self.state);
- } else {
- self.close_workspace_idx_with_group_via_api(ws_idx);
- self.state.mode = Mode::Navigate;
- }
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
- self.focus_workspace_idx_via_api(ws_idx);
- self.focus_tab_idx_via_api(tab_idx);
- open_new_tab_dialog(&mut self.state);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
- self.focus_workspace_idx_via_api(ws_idx);
- self.focus_tab_idx_via_api(tab_idx);
- open_rename_active_tab(&mut self.state, false);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
- self.focus_workspace_idx_via_api(ws_idx);
- self.focus_tab_idx_via_api(tab_idx);
- if !self.close_active_tab_via_api_requires_confirmation() {
- leave_modal(&mut self.state);
- }
- }
- (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => {
- open_rename_pane(&mut self.state, pane_id);
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Clear pane name"),
- ) => {
- if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) {
- self.runtime_pane_rename(
- "tui.pane.clear_name",
- crate::api::schema::PaneRenameParams {
- pane_id,
- label: None,
- },
- );
- }
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")),
- ) => {
- if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) {
- self.runtime_pane_input_set(
- "tui.pane.input.set",
- crate::api::schema::PaneInputSetParams {
- pane_id,
- right_click: if action == "Send right-clicks to pane" {
- crate::api::schema::PaneRightClickTarget::Pane
- } else {
- crate::api::schema::PaneRightClickTarget::Herdr
- },
- },
- );
- }
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx,
- pane_id,
- source_pane_id: Some(source_pane_id),
- ..
- },
- Some("Swap with focused pane"),
- ) => {
- let source_public_id = self.public_pane_id(ws_idx, source_pane_id);
- let target_public_id = self.public_pane_id(ws_idx, pane_id);
- if let (Some(source_public_id), Some(target_public_id)) =
- (source_public_id, target_public_id)
- {
- self.runtime_pane_swap(
- "tui.pane.swap_exact",
- crate::api::schema::PaneSwapParams {
- pane_id: None,
- direction: None,
- source_pane_id: Some(source_public_id),
- target_pane_id: Some(target_public_id),
- },
- );
- self.focus_pane_internal_via_api(ws_idx, source_pane_id);
- }
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Split right"),
- ) => {
- self.focus_pane_internal_via_api(ws_idx, pane_id);
- self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right);
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Split down"),
- ) => {
- self.focus_pane_internal_via_api(ws_idx, pane_id);
- self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down);
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Zoom"),
- ) => {
- self.focus_pane_internal_via_api(ws_idx, pane_id);
- self.zoom_focused_pane_via_api();
- self.state.mode = Mode::Terminal;
- }
- (
- ContextMenuKind::Pane {
- ws_idx, pane_id, ..
- },
- Some("Close pane"),
- ) => {
- self.focus_pane_internal_via_api(ws_idx, pane_id);
- if !self.close_focused_pane_via_api_requires_confirmation() {
- self.state.mode = if self.state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- }
- _ => leave_modal(&mut self.state),
- }
- }
-}
-
-fn cancel_rename_modal(state: &mut AppState) {
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.pending_workspace_create_cwd = None;
- state.rename_pane_target = None;
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- leave_modal(state);
-}
-
-impl AppState {
- pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> {
- let rect = self.global_menu_rect();
- if col <= rect.x
- || col >= rect.x + rect.width.saturating_sub(1)
- || row <= rect.y
- || row >= rect.y + rect.height.saturating_sub(1)
- {
- return None;
- }
- let idx = (row - rect.y - 1) as usize;
- global_menu_actions(self).get(idx).copied()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
- use ratatui::layout::Rect;
-
- use super::super::{capture_snapshot, state_with_workspaces};
- use super::*;
- use crate::workspace::Workspace;
-
- fn config_env_lock() -> &'static std::sync::Mutex<()> {
- crate::config::test_config_env_lock()
- }
-
- fn temp_config_path(name: &str) -> std::path::PathBuf {
- let unique = format!(
- "herdr-modal-{name}-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- std::env::temp_dir().join(unique).join("config.toml")
- }
-
- fn app_with_test_workspaces(names: &[&str]) -> App {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &crate::config::Config::default(),
- true,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
- app.state.ensure_test_terminals();
- app.state.active = (!app.state.workspaces.is_empty()).then_some(0);
- app.state.selected = 0;
- app
- }
-
- #[test]
- fn workspace_create_label_preserves_auto_name_for_suggestion_or_blank() {
- assert_eq!(workspace_create_label("project", "project"), None);
- assert_eq!(workspace_create_label("", "project"), None);
- assert_eq!(workspace_create_label(" ", "project"), None);
- assert_eq!(
- workspace_create_label(" logs ", "project").as_deref(),
- Some("logs")
- );
- }
-
- fn mark_worktree_space_member(state: &mut AppState, ws_idx: usize, key: &str) {
- state.workspaces[ws_idx].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: key.into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: format!("/repo/worktree-{ws_idx}").into(),
- is_linked_worktree: ws_idx != 0,
- });
- }
-
- #[test]
- fn custom_resize_key_exits_resize_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::Resize;
- state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g");
-
- handle_resize_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn direct_resize_key_exits_resize_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::Resize;
- state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r");
-
- handle_resize_key(
- &mut state,
- TerminalKey::new(
- KeyCode::Char('r'),
- KeyModifiers::CONTROL | KeyModifiers::ALT,
- ),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn resize_key_exit_matches_enhanced_shifted_punctuation() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::Resize;
- state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?");
-
- handle_resize_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn detach_requests_client_detach_in_persistence_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.detach_exits = false;
-
- request_detach(&mut state);
-
- assert!(state.detach_requested);
- assert!(!state.should_quit);
- }
-
- #[test]
- fn detach_exits_in_no_session_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.detach_exits = true;
-
- request_detach(&mut state);
-
- assert!(state.should_quit);
- assert!(!state.detach_requested);
- }
-
- #[test]
- fn global_menu_whats_new_opens_saved_release_notes() {
- let _guard = config_env_lock().lock().unwrap();
- let path = temp_config_path("whats-new-saved-release-notes");
- std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
- crate::release_notes::save_pending(env!("CARGO_PKG_VERSION"), "### Changed\n- Menu")
- .unwrap();
-
- let mut state = state_with_workspaces(&["test"]);
- state.latest_release_notes_available = true;
-
- assert!(global_menu_actions(&state).contains(&GlobalMenuAction::WhatsNew));
-
- apply_global_menu_action(&mut state, GlobalMenuAction::WhatsNew);
-
- assert_eq!(state.mode, Mode::ReleaseNotes);
- assert_eq!(
- state
- .release_notes
- .as_ref()
- .map(|notes| notes.body.as_str()),
- Some("### Changed\n- Menu")
- );
-
- std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
- let _ = std::fs::remove_dir_all(path.parent().unwrap());
- }
-
- #[test]
- fn rename_modal_keyboard_and_mouse_share_actions() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "hello".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
- );
- assert!(state.name_input.is_empty());
-
- state.name_input = "renamed".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::Terminal);
- assert_eq!(state.workspaces[0].display_name(), "renamed");
- let snapshot = capture_snapshot(&state);
- assert_eq!(
- snapshot.workspaces[0].custom_name.as_deref(),
- Some("renamed")
- );
-
- state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
- state.view.terminal_area = Rect::new(26, 0, 80, 20);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "mouse".into();
- let inner = state.rename_modal_inner().unwrap();
- let (save, _, _) = crate::ui::rename_button_rects(inner);
- let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]);
- assert_eq!(action, Some(ModalAction::Save));
- }
-
- #[test]
- fn tab_rename_updates_captured_snapshot() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "logs".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- let snapshot = capture_snapshot(&state);
- assert_eq!(
- snapshot.workspaces[0].tabs[0].custom_name.as_deref(),
- Some("logs")
- );
- }
-
- #[test]
- fn rename_cancel_returns_to_terminal_when_workspace_is_active() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "test".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(state.name_input.is_empty());
- }
-
- #[test]
- fn rename_modal_replaces_prefilled_text_on_first_type() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "2".into();
- state.name_input_replace_on_type = true;
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()),
- );
- assert_eq!(state.name_input, "n");
- assert!(!state.name_input_replace_on_type);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()),
- );
- assert_eq!(state.name_input, "ne");
- }
-
- #[test]
- fn rename_modal_replaces_prefilled_text_on_paste() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "2".into();
- state.name_input_replace_on_type = true;
-
- insert_rename_input_text(&mut state, "feature/logs");
-
- assert_eq!(state.name_input, "feature/logs");
- assert!(!state.name_input_replace_on_type);
-
- insert_rename_input_text(&mut state, "-copy");
-
- assert_eq!(state.name_input, "feature/logs-copy");
- }
-
- #[test]
- fn rename_modal_handles_line_editing_shortcuts() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "website zero".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()),
- );
- assert_eq!(state.name_input, "website zer");
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Backspace, KeyModifiers::CONTROL),
- );
- assert_eq!(state.name_input, "website ");
-
- state.name_input = "website-zero".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Backspace, KeyModifiers::ALT),
- );
- assert_eq!(state.name_input, "website-");
-
- state.name_input = "website-zero".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL),
- );
- assert_eq!(state.name_input, "website-");
-
- state.name_input = "website-zero".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
- );
- assert_eq!(state.name_input, "website-");
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Backspace, KeyModifiers::SUPER),
- );
- assert!(state.name_input.is_empty());
-
- state.name_input = "website zero".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
- );
- assert!(state.name_input.is_empty());
- }
-
- #[test]
- fn rename_modal_does_not_insert_modified_shortcut_chars() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "website".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
- );
- assert_eq!(state.name_input, "website");
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('Z'), KeyModifiers::SHIFT),
- );
- assert_eq!(state.name_input, "websiteZ");
- }
-
- #[test]
- fn keybind_help_slash_focuses_filter_and_preserves_vim_scroll() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybind_help.query = "stale".into();
- state.keybind_help.search_focused = true;
- state.view.terminal_area = Rect::new(0, 0, 100, 30);
-
- open_keybind_help(&mut state);
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty()),
- );
- assert_eq!(state.keybind_help.scroll, 1);
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('k'), KeyModifiers::empty()),
- );
- assert_eq!(state.keybind_help.scroll, 0);
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('w'), KeyModifiers::empty()),
- );
- assert!(state.keybind_help.query.is_empty());
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()),
- );
- for character in "work".chars() {
- state.keybind_help.scroll = 2;
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char(character), KeyModifiers::empty()),
- );
- }
-
- assert!(state.keybind_help.search_focused);
- assert_eq!(state.keybind_help.query, "work");
- assert_eq!(state.keybind_help.scroll, 0);
- }
-
- #[test]
- fn keybind_help_query_supports_backspace_clear_and_sanitized_paste() {
- let mut state = state_with_workspaces(&["test"]);
- open_keybind_help(&mut state);
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()),
- );
-
- insert_keybind_help_query_text(&mut state, "work\nspace");
- assert_eq!(state.keybind_help.query, "workspace");
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Backspace, KeyModifiers::empty()),
- );
- assert_eq!(state.keybind_help.query, "workspac");
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
- );
- assert!(state.keybind_help.query.is_empty());
- }
-
- #[test]
- fn keybind_help_escape_leaves_search_before_closing() {
- let mut state = state_with_workspaces(&["test"]);
- open_keybind_help(&mut state);
- state.keybind_help.search_focused = true;
- state.keybind_help.query = "work".into();
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::KeybindHelp);
- assert!(!state.keybind_help.search_focused);
- assert!(state.keybind_help.query.is_empty());
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn enhanced_shifted_slash_focuses_keybind_help_filter() {
- let mut state = state_with_workspaces(&["test"]);
- open_keybind_help(&mut state);
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('7'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('/' as u32),
- );
-
- assert!(state.keybind_help.search_focused);
- }
-
- #[test]
- fn enhanced_shifted_question_mark_closes_keybind_help_when_not_searching() {
- let mut state = state_with_workspaces(&["test"]);
- open_keybind_help(&mut state);
-
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
-
- open_keybind_help(&mut state);
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::empty()),
- );
- handle_keybind_help_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32),
- );
-
- assert_eq!(state.keybind_help.query, "?");
- }
-
- #[test]
- fn navigator_search_accepts_pasted_text_when_focused() {
- let mut state = state_with_workspaces(&["alpha", "beta"]);
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.mode = Mode::Navigator;
- state.navigator.search_focused = true;
- state.navigator.state_filter = Some(NavigatorStateFilter::Working);
-
- insert_navigator_search_text(&mut state, &terminal_runtimes, "beta");
-
- assert_eq!(state.navigator.query, "beta");
- assert_eq!(state.navigator.state_filter, None);
- }
-
- #[test]
- fn navigator_search_ignores_paste_when_search_is_not_focused() {
- let mut state = state_with_workspaces(&["alpha", "beta"]);
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.mode = Mode::Navigator;
- state.navigator.search_focused = false;
-
- insert_navigator_search_text(&mut state, &terminal_runtimes, "beta");
-
- assert!(state.navigator.query.is_empty());
- }
-
- #[test]
- fn navigator_empty_search_escape_returns_to_commands() {
- let mut state = state_with_workspaces(&["alpha", "beta"]);
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.mode = Mode::Navigator;
- state.navigator.search_focused = true;
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Navigator);
- assert!(!state.navigator.search_focused);
- assert!(state.navigator.query.is_empty());
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('w'), KeyModifiers::empty()),
- );
-
- assert_eq!(
- state.navigator.state_filter,
- Some(NavigatorStateFilter::Working)
- );
- assert!(state.navigator.query.is_empty());
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn navigator_search_escape_blurs_then_next_escape_closes() {
- let mut state = state_with_workspaces(&["alpha", "beta"]);
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.mode = Mode::Navigator;
- state.navigator.search_focused = true;
- state.navigator.query = "a".into();
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Navigator);
- assert!(!state.navigator.search_focused);
- assert_eq!(state.navigator.query, "a");
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.navigator.selected, 1);
- assert_eq!(state.navigator.query, "a");
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('/'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Navigator);
- assert!(state.navigator.search_focused);
- assert_eq!(state.navigator.query, "a");
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('l'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.navigator.query, "al");
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Navigator);
- assert!(!state.navigator.search_focused);
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn navigator_ignores_modified_j_and_k() {
- let mut state = state_with_workspaces(&["alpha", "beta"]);
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.mode = Mode::Navigator;
- state.navigator.selected = 1;
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL),
- );
-
- assert_eq!(state.navigator.selected, 1);
-
- handle_navigator_key(
- &mut state,
- &terminal_runtimes,
- KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
- );
-
- assert_eq!(state.navigator.selected, 1);
- }
-
- #[test]
- fn open_rename_active_tab_can_prefill_default_new_tab_name() {
- let mut state = state_with_workspaces(&["test"]);
- state.workspaces[0].test_add_tab(None);
- state.workspaces[0].switch_tab(1);
-
- open_rename_active_tab(&mut state, true);
-
- assert_eq!(state.mode, Mode::RenameTab);
- assert_eq!(state.name_input, "2");
- assert!(state.name_input_replace_on_type);
- }
-
- #[test]
- fn cancel_new_tab_dialog_leaves_workspace_unchanged() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(!state.request_new_tab);
- assert!(state.requested_new_tab_name.is_none());
- assert_eq!(state.workspaces[0].tabs.len(), 1);
- }
-
- #[test]
- fn saving_new_tab_dialog_requests_creation_with_name() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
- state.name_input = "logs".into();
- state.name_input_replace_on_type = false;
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(state.request_new_tab);
- assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs"));
- }
-
- #[test]
- fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(state.request_new_tab);
- assert!(state.requested_new_tab_name.is_none());
- }
-
- #[test]
- fn closing_first_auto_tab_compacts_remaining_auto_tab_label_and_next_prompt() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref());
- state.request_new_tab = false;
- state.requested_new_tab_name = None;
-
- state.workspaces[0].close_tab(0);
- state.workspaces[0].switch_tab(0);
-
- assert_eq!(
- state.workspaces[0].tab_display_name(0).as_deref(),
- Some("1")
- );
- assert!(state.workspaces[0].tabs[0].custom_name.is_none());
-
- open_new_tab_dialog(&mut state);
- assert_eq!(state.name_input, "2");
- }
-
- #[test]
- fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() {
- let mut state = state_with_workspaces(&["test"]);
- state.workspaces[0].test_add_tab(None);
- state.workspaces[0].switch_tab(1);
-
- open_rename_active_tab(&mut state, false);
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(state.workspaces[0].tabs[1].custom_name.is_none());
- assert_eq!(
- state.workspaces[0].tab_display_name(1).as_deref(),
- Some("2")
- );
- }
-
- #[test]
- fn confirm_close_keyboard_actions_are_direct_not_focused() {
- let mut state = state_with_workspaces(&["a", "b"]);
- state.selected = 1;
- open_confirm_close(&mut state);
-
- handle_confirm_close_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::Navigate);
- assert_eq!(state.workspaces.len(), 2);
-
- open_confirm_close(&mut state);
- handle_confirm_close_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
- assert_eq!(state.workspaces.len(), 1);
- }
-
- #[test]
- fn confirm_close_for_linked_worktree_closes_workspace_only() {
- let mut state = state_with_workspaces(&["main", "issue"]);
- state.selected = 1;
- state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
-
- open_confirm_close(&mut state);
- handle_confirm_close_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.request_remove_linked_worktree, None);
- assert_eq!(state.workspaces.len(), 1);
- assert_eq!(state.workspaces[0].display_name(), "main");
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn context_menu_close_group_opens_group_close_confirmation() {
- let mut state = state_with_workspaces(&["main", "issue"]);
- state.active = Some(0);
- state.selected = 1;
- state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- });
- state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
- let menu = ContextMenuState {
- kind: ContextMenuKind::GitWorkspace {
- ws_idx: 0,
- is_linked_worktree: false,
- has_worktree_children: true,
- collapsed: false,
- },
- x: 0,
- y: 0,
- list: MenuListState::new(0),
- };
- let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
-
- apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, 1);
-
- assert_eq!(state.selected, 0);
- assert_eq!(state.mode, Mode::ConfirmClose);
-
- confirm_close_accept(&mut state);
-
- assert!(state.workspaces.is_empty());
- assert_eq!(state.mode, Mode::Navigate);
- }
-
- #[test]
- fn context_menu_toggles_pane_right_click_passthrough() {
- let mut app = app_with_test_workspaces(&["main"]);
- app.state.active = Some(0);
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let menu = ContextMenuState {
- kind: ContextMenuKind::Pane {
- ws_idx: 0,
- tab_idx: 0,
- pane_id,
- source_pane_id: None,
- has_manual_label: false,
- right_click_passthrough: false,
- },
- x: 0,
- y: 0,
- list: MenuListState::new(0),
- };
- let idx = menu
- .items()
- .iter()
- .position(|item| *item == "Send right-clicks to pane")
- .unwrap();
- app.apply_context_menu_action_via_api(menu, idx);
-
- assert!(
- app.state.workspaces[0]
- .pane_state(pane_id)
- .unwrap()
- .right_click_passthrough
- );
- }
-
- #[test]
- fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() {
- let mut state = state_with_workspaces(&["main", "issue"]);
- state.active = Some(0);
- state.selected = 1;
- state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr".into(),
- is_linked_worktree: false,
- });
- state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership {
- key: "repo-key".into(),
- label: "herdr".into(),
- repo_root: "/repo/herdr".into(),
- checkout_path: "/repo/herdr-issue".into(),
- is_linked_worktree: true,
- });
- let pane_id = state.workspaces[0].tabs[0].root_pane;
- let menu = ContextMenuState {
- kind: ContextMenuKind::Pane {
- ws_idx: 0,
- tab_idx: 0,
- pane_id,
- source_pane_id: None,
- has_manual_label: false,
- right_click_passthrough: false,
- },
- x: 0,
- y: 0,
- list: MenuListState::new(0),
- };
- let idx = menu
- .items()
- .iter()
- .position(|item| *item == "Close pane")
- .expect("close pane item");
- let mut terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
-
- apply_context_menu_action(&mut state, &mut terminal_runtimes, menu, idx);
-
- assert_eq!(state.selected, 0);
- assert_eq!(state.mode, Mode::ConfirmClose);
- assert_eq!(state.workspaces.len(), 2);
- }
-
- #[test]
- fn api_confirm_close_accept_closes_parent_worktree_group() {
- let mut app = app_with_test_workspaces(&["main", "issue"]);
- mark_worktree_space_member(&mut app.state, 0, "repo-key");
- mark_worktree_space_member(&mut app.state, 1, "repo-key");
- app.state.selected = 0;
- open_confirm_close(&mut app.state);
-
- app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- assert!(app.state.workspaces.is_empty());
- assert_eq!(app.state.mode, Mode::Navigate);
- assert_eq!(app.event_hub.events_after(0).len(), 2);
- }
-
- #[test]
- fn api_confirm_close_accept_keeps_the_original_workspace_target() {
- let mut app = app_with_test_workspaces(&["main", "issue", "other"]);
- mark_worktree_space_member(&mut app.state, 0, "repo-key");
- mark_worktree_space_member(&mut app.state, 1, "repo-key");
- app.state.selected = 0;
- open_confirm_close(&mut app.state);
-
- app.focus_workspace_idx_via_api(2);
- assert_eq!(app.state.selected, 2);
- assert_eq!(app.state.mode, Mode::ConfirmClose);
-
- app.handle_confirm_close_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- assert_eq!(app.state.workspaces.len(), 1);
- assert_eq!(app.state.workspaces[0].display_name(), "other");
- assert_eq!(
- app.event_hub
- .events_after(0)
- .iter()
- .filter(|(_, event)| matches!(
- event.event,
- crate::api::schema::EventKind::WorkspaceClosed
- ))
- .count(),
- 2
- );
- }
-
- #[test]
- fn api_context_menu_close_tab_last_parent_group_workspace_keeps_confirmation_mode() {
- let mut app = app_with_test_workspaces(&["main", "issue"]);
- mark_worktree_space_member(&mut app.state, 0, "repo-key");
- mark_worktree_space_member(&mut app.state, 1, "repo-key");
- app.state.active = Some(0);
- app.state.selected = 1;
- app.state.mode = Mode::ContextMenu;
- let menu = ContextMenuState {
- kind: ContextMenuKind::Tab {
- ws_idx: 0,
- tab_idx: 0,
- },
- x: 0,
- y: 0,
- list: MenuListState::new(0),
- };
- let idx = menu
- .items()
- .iter()
- .position(|item| *item == "Close")
- .expect("close tab item");
-
- app.apply_context_menu_action_via_api(menu, idx);
-
- assert_eq!(app.state.selected, 0);
- assert_eq!(app.state.mode, Mode::ConfirmClose);
- assert_eq!(app.state.workspaces.len(), 2);
- }
-
- #[test]
- fn api_context_menu_enter_close_pane_last_parent_group_pane_keeps_confirmation_mode() {
- let mut app = app_with_test_workspaces(&["main", "issue"]);
- mark_worktree_space_member(&mut app.state, 0, "repo-key");
- mark_worktree_space_member(&mut app.state, 1, "repo-key");
- app.state.active = Some(0);
- app.state.selected = 1;
- app.state.mode = Mode::ContextMenu;
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let mut menu = ContextMenuState {
- kind: ContextMenuKind::Pane {
- ws_idx: 0,
- tab_idx: 0,
- pane_id,
- source_pane_id: None,
- has_manual_label: false,
- right_click_passthrough: false,
- },
- x: 0,
- y: 0,
- list: MenuListState::new(0),
- };
- let close_idx = menu
- .items()
- .iter()
- .position(|item| *item == "Close pane")
- .expect("close pane item");
- menu.list.highlighted = close_idx;
- app.state.context_menu = Some(menu);
-
- app.handle_context_menu_key_via_api(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
-
- assert_eq!(app.state.selected, 0);
- assert_eq!(app.state.mode, Mode::ConfirmClose);
- assert_eq!(app.state.workspaces.len(), 2);
- assert!(app.state.context_menu.is_none());
- }
-}