diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 15:23:29 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 15:28:13 +0300 |
| commit | 7380e0ed16470c570984485930048a80034ba381 (patch) | |
| tree | a4dea216baad978f1f10d6af034f3aef883f9246 /src/app | |
| parent | f18f26d1c0fd8101baee946f71bdd77b9743df41 (diff) | |
refactor workspace identity and configurable navigate actions
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 17 | ||||
| -rw-r--r-- | src/app/input.rs | 321 | ||||
| -rw-r--r-- | src/app/mod.rs | 19 | ||||
| -rw-r--r-- | src/app/state.rs | 19 |
4 files changed, 269 insertions, 107 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 787f6869..7c323da5 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -46,7 +46,7 @@ impl AppState { if self.workspaces.is_empty() { return; } - let name = self.workspaces[self.selected].name.clone(); + let name = self.workspaces[self.selected].display_name(); info!(workspace = %name, "workspace closed"); self.workspaces.remove(self.selected); if self.workspaces.is_empty() { @@ -236,16 +236,7 @@ impl AppState { } } } else { - if ws.layout.focused() == pane_id { - ws.layout.close_focused(); - } else { - let prev_focus = ws.layout.focused(); - ws.layout.focus_pane(pane_id); - ws.layout.close_focused(); - ws.layout.focus_pane(prev_focus); - } - ws.panes.remove(&pane_id); - ws.runtimes.remove(&pane_id); + ws.remove_pane(pane_id); } } } @@ -311,7 +302,7 @@ mod tests { assert_eq!(state.workspaces.len(), 2); assert_eq!(state.selected, 1); assert_eq!(state.active, Some(1)); - assert_eq!(state.workspaces[1].name, "c"); + assert_eq!(state.workspaces[1].custom_name.as_deref(), Some("c")); } #[test] @@ -346,7 +337,7 @@ mod tests { state.handle_pane_died(pane_id); assert_eq!(state.workspaces.len(), 1); - assert_eq!(state.workspaces[0].name, "b"); + assert_eq!(state.workspaces[0].custom_name.as_deref(), Some("b")); } #[test] diff --git a/src/app/input.rs b/src/app/input.rs index 254b7916..7e7f5877 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -20,7 +20,6 @@ impl App { match self.state.mode { Mode::Navigate => handle_navigate_key(&mut self.state, key), Mode::Terminal => self.handle_terminal_key(key).await, - Mode::CreateSession => handle_create_key(self, key), Mode::RenameSession => handle_rename_key(&mut self.state, key), Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), @@ -78,55 +77,28 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.update_dismissed = true; if state.is_prefix(&key) || key.code == KeyCode::Esc { - if state.active.is_some() { - state.mode = Mode::Terminal; - } + leave_navigate_mode(state); return; } - // Configurable keybinds (checked before fixed binds) - let kb = &state.keybinds; - if key_matches(&key, kb.split_vertical.0, kb.split_vertical.1) { - state.split_pane(Direction::Horizontal); - return; - } - if key_matches(&key, kb.split_horizontal.0, kb.split_horizontal.1) { - state.split_pane(Direction::Vertical); - return; - } - if key_matches(&key, kb.close_pane.0, kb.close_pane.1) { - state.close_pane(); - return; - } - if key_matches(&key, kb.fullscreen.0, kb.fullscreen.1) { - state.toggle_fullscreen(); + if let Some(action) = navigate_action_for_key(state, &key) { + execute_navigate_action(state, action); return; } match key.code { KeyCode::Char('q') => state.should_quit = true, - KeyCode::Char('n') => { - state.mode = Mode::CreateSession; - state.name_input.clear(); - } - KeyCode::Char('N') => { + KeyCode::Enter => { if !state.workspaces.is_empty() { - state.name_input = state.workspaces[state.selected].name.clone(); - state.mode = Mode::RenameSession; + state.switch_workspace(state.selected); + leave_navigate_mode(state); } } - KeyCode::Char('d') => { - if !state.workspaces.is_empty() { - if state.confirm_close { - state.mode = Mode::ConfirmClose; - } else { - state.close_selected_workspace(); - if state.workspaces.is_empty() { - state.mode = Mode::Navigate; - } else { - state.mode = Mode::Terminal; - } - } + KeyCode::Char(c @ '1'..='9') => { + let idx = (c as usize) - ('1' as usize); + if idx < state.workspaces.len() { + state.switch_workspace(idx); + leave_navigate_mode(state); } } KeyCode::Up => { @@ -139,53 +111,110 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { state.selected += 1; } } - KeyCode::Enter => { - if !state.workspaces.is_empty() { - state.switch_workspace(state.selected); - state.mode = Mode::Terminal; - } - } KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left), KeyCode::Char('j') => state.navigate_pane(NavDirection::Down), KeyCode::Char('k') => state.navigate_pane(NavDirection::Up), KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right), KeyCode::Tab => state.cycle_pane(false), KeyCode::BackTab => state.cycle_pane(true), - KeyCode::Char('r') => state.mode = Mode::Resize, - KeyCode::Char('b') => state.sidebar_collapsed = !state.sidebar_collapsed, - KeyCode::Char(c @ '1'..='9') => { - let idx = (c as usize) - ('1' as usize); - if idx < state.workspaces.len() { - state.switch_workspace(idx); - state.mode = Mode::Terminal; - } - } _ => {} } } -fn handle_create_key(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Enter => { - let name = if app.state.name_input.trim().is_empty() { - format!("workspace-{}", app.state.workspaces.len() + 1) - } else { - app.state.name_input.trim().to_string() - }; - app.state.name_input.clear(); - app.create_workspace(name); +#[derive(Debug, Clone, Copy)] +enum NavigateAction { + NewWorkspace, + RenameWorkspace, + CloseWorkspace, + SplitVertical, + SplitHorizontal, + ClosePane, + Fullscreen, + EnterResizeMode, + ToggleSidebar, +} + +fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> { + let kb = &state.keybinds; + if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) { + return Some(NavigateAction::NewWorkspace); + } + if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) { + return Some(NavigateAction::RenameWorkspace); + } + if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) { + return Some(NavigateAction::CloseWorkspace); + } + if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { + return Some(NavigateAction::SplitVertical); + } + if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) { + return Some(NavigateAction::SplitHorizontal); + } + if key_matches(key, kb.close_pane.0, kb.close_pane.1) { + return Some(NavigateAction::ClosePane); + } + if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) { + return Some(NavigateAction::Fullscreen); + } + if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) { + return Some(NavigateAction::EnterResizeMode); + } + if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) { + return Some(NavigateAction::ToggleSidebar); + } + None +} + +fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { + match action { + NavigateAction::NewWorkspace => { + state.request_new_workspace = true; + leave_navigate_mode(state); } - KeyCode::Esc => { - app.state.name_input.clear(); - app.state.mode = Mode::Navigate; + NavigateAction::RenameWorkspace => { + if !state.workspaces.is_empty() { + state.name_input = state.workspaces[state.selected].display_name(); + state.mode = Mode::RenameSession; + } } - KeyCode::Backspace => { - app.state.name_input.pop(); + NavigateAction::CloseWorkspace => { + if !state.workspaces.is_empty() { + if state.confirm_close { + state.mode = Mode::ConfirmClose; + } else { + state.close_selected_workspace(); + leave_navigate_mode(state); + } + } } - KeyCode::Char(c) => { - app.state.name_input.push(c); + NavigateAction::SplitVertical => { + state.split_pane(Direction::Horizontal); + leave_navigate_mode(state); } - _ => {} + NavigateAction::SplitHorizontal => { + state.split_pane(Direction::Vertical); + leave_navigate_mode(state); + } + NavigateAction::ClosePane => { + state.close_pane(); + leave_navigate_mode(state); + } + NavigateAction::Fullscreen => { + state.toggle_fullscreen(); + leave_navigate_mode(state); + } + NavigateAction::EnterResizeMode => state.mode = Mode::Resize, + NavigateAction::ToggleSidebar => { + state.sidebar_collapsed = !state.sidebar_collapsed; + leave_navigate_mode(state); + } + } +} + +fn leave_navigate_mode(state: &mut AppState) { + if state.active.is_some() { + state.mode = Mode::Terminal; } } @@ -198,7 +227,7 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { state.name_input.trim().to_string() }; if !new_name.is_empty() && !state.workspaces.is_empty() { - state.workspaces[state.selected].name = new_name; + state.workspaces[state.selected].set_custom_name(new_name); } state.name_input.clear(); state.mode = Mode::Navigate; @@ -218,14 +247,19 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { } fn handle_resize_key(state: &mut AppState, key: KeyEvent) { - match key.code { - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('r') => { - if state.active.is_some() { - state.mode = Mode::Terminal; - } else { - state.mode = Mode::Navigate; - } + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || key_matches(&key, state.keybinds.resize_mode.0, state.keybinds.resize_mode.1) + { + 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), @@ -275,7 +309,7 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { match CONTEXT_MENU_ITEMS[menu.selected] { "Rename" => { state.selected = menu.ws_idx; - state.name_input = state.workspaces[menu.ws_idx].name.clone(); + state.name_input = state.workspaces[menu.ws_idx].display_name(); state.mode = Mode::RenameSession; } "Close" => { @@ -320,7 +354,7 @@ impl AppState { match CONTEXT_MENU_ITEMS[idx] { "Rename" => { self.selected = ws_idx; - self.name_input = self.workspaces[ws_idx].name.clone(); + self.name_input = self.workspaces[ws_idx].display_name(); self.mode = Mode::RenameSession; } "Close" => { @@ -355,14 +389,21 @@ impl AppState { if in_sidebar { let bottom_y = sidebar.y + sidebar.height.saturating_sub(1); + let new_row_y = bottom_y.saturating_sub(1); + if mouse.row == bottom_y { self.sidebar_collapsed = !self.sidebar_collapsed; return; } - let inner_y = sidebar.y + 1; - if mouse.row >= inner_y { - let idx = (mouse.row - inner_y) as usize; + if !self.sidebar_collapsed && mouse.row == new_row_y { + self.request_new_workspace = true; + return; + } + + let inner_y = sidebar.y; + if mouse.row >= inner_y && mouse.row < new_row_y { + let idx = ((mouse.row - inner_y) / 2) as usize; if idx < self.workspaces.len() { self.switch_workspace(idx); self.mode = Mode::Terminal; @@ -467,9 +508,11 @@ impl AppState { } MouseEventKind::Down(MouseButton::Right) if in_sidebar => { - let inner_y = sidebar.y + 1; - if mouse.row >= inner_y { - let idx = (mouse.row - inner_y) as usize; + let inner_y = sidebar.y; + let bottom_y = sidebar.y + sidebar.height.saturating_sub(1); + let new_row_y = bottom_y.saturating_sub(1); + if mouse.row >= inner_y && mouse.row < new_row_y { + let idx = ((mouse.row - inner_y) / 2) as usize; if idx < self.workspaces.len() { self.selected = idx; self.context_menu = Some(ContextMenuState { @@ -552,3 +595,105 @@ impl AppState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyModifiers; + use crate::workspace::Workspace; + + fn state_with_workspaces(names: &[&str]) -> AppState { + let mut state = AppState::test_new(); + state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect(); + if !state.workspaces.is_empty() { + state.active = Some(0); + state.selected = 0; + state.mode = Mode::Navigate; + } + state + } + + #[test] + fn custom_rename_key_enters_rename_mode() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.rename_workspace_label = "g".into(); + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert_eq!(state.mode, Mode::RenameSession); + assert_eq!(state.name_input, "test"); + } + + #[test] + fn custom_new_workspace_key_requests_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.new_workspace_label = "g".into(); + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert!(state.request_new_workspace); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.toggle_sidebar_label = "g".into(); + assert!(!state.sidebar_collapsed); + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert!(state.sidebar_collapsed); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn custom_resize_key_enters_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert_eq!(state.mode, Mode::Resize); + } + + #[test] + fn movement_action_stays_in_navigate_mode() { + let mut state = state_with_workspaces(&["a", "b"]); + state.selected = 0; + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::empty())); + + assert_eq!(state.selected, 1); + assert_eq!(state.mode, Mode::Navigate); + } + + #[test] + fn fullscreen_action_exits_navigate_mode() { + let mut state = state_with_workspaces(&["test"]); + state.workspaces[0].test_split(Direction::Horizontal); + state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.fullscreen_label = "g".into(); + + handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert!(state.workspaces[0].zoomed); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn custom_resize_key_exits_resize_mode() { + let mut state = state_with_workspaces(&["test"]); + state.mode = Mode::Resize; + state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty()); + state.keybinds.resize_mode_label = "g".into(); + + handle_resize_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())); + + assert_eq!(state.mode, Mode::Terminal); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index b3fab4a9..ad704460 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -66,6 +66,7 @@ impl App { selected, mode, should_quit: false, + request_new_workspace: false, name_input: String::new(), view: state::ViewState { sidebar_rect: Rect::default(), @@ -80,7 +81,6 @@ impl App { update_dismissed: false, prefix_code, prefix_mods, - prefix_label: config.prefix_label(), sidebar_width: config.ui.sidebar_width, sidebar_collapsed: false, confirm_close: config.ui.confirm_close, @@ -126,6 +126,11 @@ impl App { _ => {} } } + + if self.state.request_new_workspace { + self.state.request_new_workspace = false; + self.create_workspace(); + } } // Save session on exit (skip in --no-session mode) @@ -142,9 +147,17 @@ impl App { } /// Create a workspace with a real PTY (needs event_tx). - fn create_workspace(&mut self, name: String) { + fn create_workspace(&mut self) { let (rows, cols) = self.state.estimate_pane_size(); - match Workspace::new(name, rows, cols, self.event_tx.clone()) { + let initial_cwd = self + .state + .active + .and_then(|i| self.state.workspaces.get(i)) + .and_then(|ws| ws.focused_runtime()) + .and_then(|rt| rt.cwd()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + match Workspace::new(initial_cwd, rows, cols, self.event_tx.clone()) { Ok(ws) => { self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; diff --git a/src/app/state.rs b/src/app/state.rs index 7a577bb8..140a1513 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -20,7 +20,6 @@ pub struct ViewState { pub enum Mode { Navigate, Terminal, - CreateSession, RenameSession, Resize, ConfirmClose, @@ -52,6 +51,7 @@ pub struct AppState { pub selected: usize, pub mode: Mode, pub should_quit: bool, + pub request_new_workspace: bool, pub name_input: String, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, @@ -64,7 +64,6 @@ pub struct AppState { // Config pub prefix_code: KeyCode, pub prefix_mods: KeyModifiers, - pub prefix_label: String, pub sidebar_width: u16, pub sidebar_collapsed: bool, pub confirm_close: bool, @@ -120,6 +119,7 @@ impl AppState { selected: 0, mode: Mode::Navigate, should_quit: false, + request_new_workspace: false, name_input: String::new(), view: ViewState { sidebar_rect: Rect::default(), @@ -134,7 +134,6 @@ impl AppState { update_dismissed: false, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, - prefix_label: "ctrl+b".into(), sidebar_width: 26, sidebar_collapsed: false, confirm_close: true, @@ -144,10 +143,24 @@ impl AppState { ..SoundConfig::default() }, keybinds: Keybinds { + new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), + new_workspace_label: "n".into(), + rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT), + rename_workspace_label: "shift+n".into(), + close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()), + close_workspace_label: "d".into(), split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), + split_vertical_label: "v".into(), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), + split_horizontal_label: "-".into(), close_pane: (KeyCode::Char('x'), KeyModifiers::empty()), + close_pane_label: "x".into(), fullscreen: (KeyCode::Char('f'), KeyModifiers::empty()), + fullscreen_label: "f".into(), + resize_mode: (KeyCode::Char('r'), KeyModifiers::empty()), + resize_mode_label: "r".into(), + toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()), + toggle_sidebar_label: "b".into(), }, } } |
