diff options
Diffstat (limited to 'src')
| -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 | ||||
| -rw-r--r-- | src/config.rs | 127 | ||||
| -rw-r--r-- | src/main.rs | 7 | ||||
| -rw-r--r-- | src/persist.rs | 31 | ||||
| -rw-r--r-- | src/ui.rs | 352 | ||||
| -rw-r--r-- | src/workspace.rs | 179 |
9 files changed, 703 insertions, 369 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(), }, } } diff --git a/src/config.rs b/src/config.rs index 0bd280ca..754ede0a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,12 @@ pub struct Config { pub struct KeysConfig { /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc"). pub prefix: String, + /// Create a new workspace. Default: "n" + pub new_workspace: String, + /// Rename the selected workspace. Default: "shift+n" + pub rename_workspace: String, + /// Close the selected workspace. Default: "d" + pub close_workspace: String, /// Split pane vertically (side by side). Default: "v" pub split_vertical: String, /// Split pane horizontally (stacked). Default: "-" @@ -26,6 +32,10 @@ pub struct KeysConfig { pub close_pane: String, /// Toggle fullscreen for the focused pane. Default: "f" pub fullscreen: String, + /// Enter resize mode. Default: "r" + pub resize_mode: String, + /// Toggle sidebar collapse. Default: "b" + pub toggle_sidebar: String, } #[derive(Debug, Deserialize)] @@ -106,10 +116,15 @@ impl Default for KeysConfig { fn default() -> Self { Self { prefix: "ctrl+b".into(), + new_workspace: "n".into(), + rename_workspace: "shift+n".into(), + close_workspace: "d".into(), split_vertical: "v".into(), split_horizontal: "-".into(), close_pane: "x".into(), fullscreen: "f".into(), + resize_mode: "r".into(), + toggle_sidebar: "b".into(), } } } @@ -175,38 +190,63 @@ impl Config { ) } - /// Human-readable label for the prefix key (shown in status bar). - pub fn prefix_label(&self) -> String { - if parse_key_combo(&self.keys.prefix).is_some() { - self.keys.prefix.clone() - } else { - "ctrl+b".into() - } - } - /// Parsed keybinds for navigate mode actions. pub fn keybinds(&self) -> Keybinds { Keybinds { + new_workspace: parse_key_combo_or_warn( + &self.keys.new_workspace, + "keys.new_workspace", + (KeyCode::Char('n'), KeyModifiers::empty()), + ), + new_workspace_label: self.keys.new_workspace.clone(), + rename_workspace: parse_key_combo_or_warn( + &self.keys.rename_workspace, + "keys.rename_workspace", + (KeyCode::Char('n'), KeyModifiers::SHIFT), + ), + rename_workspace_label: self.keys.rename_workspace.clone(), + close_workspace: parse_key_combo_or_warn( + &self.keys.close_workspace, + "keys.close_workspace", + (KeyCode::Char('d'), KeyModifiers::empty()), + ), + close_workspace_label: self.keys.close_workspace.clone(), split_vertical: parse_key_combo_or_warn( &self.keys.split_vertical, "keys.split_vertical", (KeyCode::Char('v'), KeyModifiers::empty()), ), + split_vertical_label: self.keys.split_vertical.clone(), split_horizontal: parse_key_combo_or_warn( &self.keys.split_horizontal, "keys.split_horizontal", (KeyCode::Char('-'), KeyModifiers::empty()), ), + split_horizontal_label: self.keys.split_horizontal.clone(), close_pane: parse_key_combo_or_warn( &self.keys.close_pane, "keys.close_pane", (KeyCode::Char('x'), KeyModifiers::empty()), ), + close_pane_label: self.keys.close_pane.clone(), fullscreen: parse_key_combo_or_warn( &self.keys.fullscreen, "keys.fullscreen", (KeyCode::Char('f'), KeyModifiers::empty()), ), + fullscreen_label: self.keys.fullscreen.clone(), + resize_mode: parse_key_combo_or_warn( + &self.keys.resize_mode, + "keys.resize_mode", + (KeyCode::Char('r'), KeyModifiers::empty()), + ), + resize_mode_label: self.keys.resize_mode.clone(), + toggle_sidebar: parse_key_combo_or_warn( + &self.keys.toggle_sidebar, + "keys.toggle_sidebar", + (KeyCode::Char('b'), KeyModifiers::empty()), + ), + toggle_sidebar_label: self.keys.toggle_sidebar.clone(), } } } @@ -214,10 +254,24 @@ impl Config { /// Parsed keybinds for navigate mode actions. #[derive(Debug, Clone)] pub struct Keybinds { + pub new_workspace: (KeyCode, KeyModifiers), + pub new_workspace_label: String, + pub rename_workspace: (KeyCode, KeyModifiers), + pub rename_workspace_label: String, + pub close_workspace: (KeyCode, KeyModifiers), + pub close_workspace_label: String, pub split_vertical: (KeyCode, KeyModifiers), + pub split_vertical_label: String, pub split_horizontal: (KeyCode, KeyModifiers), + pub split_horizontal_label: String, pub close_pane: (KeyCode, KeyModifiers), + pub close_pane_label: String, pub fullscreen: (KeyCode, KeyModifiers), + pub fullscreen_label: String, + pub resize_mode: (KeyCode, KeyModifiers), + pub resize_mode_label: String, + pub toggle_sidebar: (KeyCode, KeyModifiers), + pub toggle_sidebar_label: String, } /// Parse a color string into a ratatui Color. @@ -319,13 +373,22 @@ fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { let key_str = key_str?; - let code = match key_str.to_lowercase().as_str() { + let lower = key_str.to_lowercase(); + let code = match lower.as_str() { "space" | " " => KeyCode::Char(' '), "enter" | "return" => KeyCode::Enter, "esc" | "escape" => KeyCode::Esc, "tab" => KeyCode::Tab, "backspace" | "bs" => KeyCode::Backspace, - s if s.len() == 1 => KeyCode::Char(key_str.chars().next().unwrap()), + s if s.len() == 1 => { + let ch = key_str.chars().next().unwrap(); + if ch.is_ascii_uppercase() { + modifiers |= KeyModifiers::SHIFT; + KeyCode::Char(ch.to_ascii_lowercase()) + } else { + KeyCode::Char(ch) + } + } s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?, _ => return None, }; @@ -409,6 +472,22 @@ mod tests { } #[test] + fn uppercase_char_implies_shift() { + assert_eq!( + parse_key_combo("D"), + Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + ); + } + + #[test] + fn explicit_shift_and_uppercase_do_not_double_apply_shift() { + assert_eq!( + parse_key_combo("shift+D"), + Some((KeyCode::Char('d'), KeyModifiers::SHIFT)) + ); + } + + #[test] fn invalid_keybinding_is_rejected() { assert_eq!(parse_key_combo("ctrl+foo+bar"), None); assert_eq!(parse_key_combo("ctrl+"), None); @@ -418,10 +497,15 @@ mod tests { fn default_keybinds_parse() { let config = Config::default(); let kb = config.keybinds(); + assert_eq!(kb.new_workspace.0, KeyCode::Char('n')); + assert_eq!(kb.rename_workspace, (KeyCode::Char('n'), KeyModifiers::SHIFT)); + assert_eq!(kb.close_workspace.0, KeyCode::Char('d')); assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); assert_eq!(kb.close_pane.0, KeyCode::Char('x')); assert_eq!(kb.fullscreen.0, KeyCode::Char('f')); + assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); + assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); } #[test] @@ -429,10 +513,15 @@ mod tests { let toml = r#" [keys] prefix = "ctrl+a" +new_workspace = "c" +rename_workspace = "shift+r" +close_workspace = "ctrl+d" split_vertical = "s" split_horizontal = "shift+s" close_pane = "ctrl+w" fullscreen = "z" +resize_mode = "ctrl+r" +toggle_sidebar = "tab" "#; let config: Config = toml::from_str(toml).unwrap(); let (code, mods) = config.prefix_key(); @@ -440,6 +529,9 @@ fullscreen = "z" assert_eq!(mods, KeyModifiers::CONTROL); let kb = config.keybinds(); + assert_eq!(kb.new_workspace, (KeyCode::Char('c'), KeyModifiers::empty())); + assert_eq!(kb.rename_workspace, (KeyCode::Char('r'), KeyModifiers::SHIFT)); + assert_eq!(kb.close_workspace, (KeyCode::Char('d'), KeyModifiers::CONTROL)); assert_eq!(kb.split_vertical.0, KeyCode::Char('s')); assert_eq!( kb.split_horizontal, @@ -447,6 +539,19 @@ fullscreen = "z" ); assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL)); assert_eq!(kb.fullscreen.0, KeyCode::Char('z')); + assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty())); + } + + #[test] + fn uppercase_keybind_from_toml_flows_into_shift_combo() { + let toml = r#" +[keys] +split_horizontal = "D" +"#; + let config: Config = toml::from_str(toml).unwrap(); + let kb = config.keybinds(); + assert_eq!(kb.split_horizontal, (KeyCode::Char('d'), KeyModifiers::SHIFT)); } #[test] diff --git a/src/main.rs b/src/main.rs index 8f23287d..93d79b2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,11 +71,16 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration # alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup. # prefix = "ctrl+b" -# Pane controls (in navigate mode) +# Navigate-mode actions +# new_workspace = "n" +# rename_workspace = "shift+n" +# close_workspace = "d" # split_vertical = "v" # split_horizontal = "-" # close_pane = "x" # fullscreen = "f" +# resize_mode = "r" +# toggle_sidebar = "b" [ui] # Sidebar width (auto-scaled based on workspace names, this sets the default) diff --git a/src/persist.rs b/src/persist.rs index 3528dcaf..2660cc8f 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -17,7 +17,7 @@ use crate::pane::{PaneRuntime, PaneState}; use crate::workspace::Workspace; /// Current snapshot format version. -const SNAPSHOT_VERSION: u32 = 1; +const SNAPSHOT_VERSION: u32 = 2; /// Serializable snapshot of the entire herdr session. #[derive(Serialize, Deserialize)] @@ -32,13 +32,17 @@ pub struct SessionSnapshot { #[derive(Serialize, Deserialize)] pub struct WorkspaceSnapshot { - pub name: String, + #[serde(default)] + pub custom_name: Option<String>, pub layout: LayoutSnapshot, pub panes: HashMap<u32, PaneSnapshot>, pub zoomed: bool, /// Raw pane ID that was focused when saved. #[serde(default)] pub focused: Option<u32>, + /// Raw pane ID used as the workspace identity source. + #[serde(default)] + pub root_pane: Option<u32>, } #[derive(Serialize, Deserialize)] @@ -91,11 +95,12 @@ fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot { panes.insert(id.raw(), PaneSnapshot { cwd }); } WorkspaceSnapshot { - name: ws.name.clone(), + custom_name: ws.custom_name.clone(), layout: capture_node(ws.layout.root()), panes, zoomed: ws.zoomed, focused: Some(ws.layout.focused().raw()), + root_pane: Some(ws.root_pane.raw()), } } @@ -170,14 +175,21 @@ fn restore_workspace( runtimes.insert(*id, runtime); } Err(e) => { - error!(workspace = %snap.name, err = %e, "failed to restore pane"); + error!(workspace = ?snap.custom_name, err = %e, "failed to restore pane"); return None; } } } + let root_pane = snap + .root_pane + .and_then(|old_raw| id_map.get(&old_raw).copied()) + .or_else(|| pane_ids.first().copied()) + .unwrap_or(PaneId::from_raw(0)); + Some(Workspace { - name: snap.name.clone(), + custom_name: snap.custom_name.clone(), + root_pane, layout, panes, runtimes, @@ -321,7 +333,7 @@ mod tests { #[test] fn round_trip_empty_session() { let snap = SessionSnapshot { - version: 1, + version: SNAPSHOT_VERSION, workspaces: vec![], active: None, selected: 0, @@ -373,7 +385,7 @@ mod tests { let snap = SessionSnapshot { workspaces: vec![WorkspaceSnapshot { - name: "pi-mono".to_string(), + custom_name: Some("pi-mono".to_string()), layout: LayoutSnapshot::Split { direction: DirectionSnapshot::Horizontal, ratio: 0.5, @@ -383,17 +395,18 @@ mod tests { panes, zoomed: false, focused: Some(0), + root_pane: Some(0), }], active: Some(0), selected: 0, - version: 1, + version: SNAPSHOT_VERSION, }; let json = serde_json::to_string_pretty(&snap).unwrap(); let restored: SessionSnapshot = serde_json::from_str(&json).unwrap(); assert_eq!(restored.workspaces.len(), 1); - assert_eq!(restored.workspaces[0].name, "pi-mono"); + assert_eq!(restored.workspaces[0].custom_name.as_deref(), Some("pi-mono")); assert_eq!(restored.workspaces[0].panes.len(), 2); assert_eq!( restored.workspaces[0].panes[&0].cwd, @@ -2,7 +2,7 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph}, Frame, }; use tui_term::widget::PseudoTerminal; @@ -67,7 +67,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_navigate_overlay(app, frame, terminal_area); render_context_menu(app, frame); } - Mode::CreateSession | Mode::RenameSession => {} + Mode::RenameSession => {} Mode::Terminal => {} } @@ -129,39 +129,37 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> { pane_infos } -/// Auto-scale sidebar width based on longest workspace name. +/// Auto-scale sidebar width based on workspace identity + agent summary. fn compute_sidebar_width(app: &AppState) -> u16 { if app.workspaces.is_empty() { - return app.sidebar_width; // config default for empty state + return app.sidebar_width; } - let max_name = app + let max_line = app .workspaces .iter() .map(|ws| { - let pane_count = ws.layout.pane_count(); - let bracket = if pane_count > 1 { 3 + pane_count } else { 0 }; // " [●●]" - ws.name.len() + bracket + let name_len = ws.display_name().len(); + let pane_count = if ws.layout.pane_count() > 1 { + ws.layout.pane_count() + } else { + 0 + }; + let line1 = 4 + name_len + pane_count; // marker + dot + spaces + pane dots + let line2 = ws.agent_summary().map(|s| s.len() + 2).unwrap_or(0); + line1.max(line2) }) .max() - .unwrap_or(8); - // marker(2) + dot(2) + name + bracket + padding(2) + borders(2) - let needed = (max_name as u16) + 8; - needed.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) + .unwrap_or(12); + ((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH) } -/// Collapsed sidebar: borderless strip of state dots with selection highlight. +/// Collapsed sidebar: pure glance mode. fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, - Mode::Navigate - | Mode::CreateSession - | Mode::RenameSession - | Mode::Resize - | Mode::ConfirmClose - | Mode::ContextMenu + Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu ); - // Thin vertical separator line on the right edge let sep_style = if is_navigating { Style::default().fg(app.accent) } else { @@ -174,93 +172,60 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) { buf[(sep_x, y)].set_style(sep_style); } - let content_w = area.width.saturating_sub(1); // leave room for separator + let content_w = area.width.saturating_sub(1); + let bottom_y = area.y + area.height.saturating_sub(1); for (i, ws) in app.workspaces.iter().enumerate() { - if i as u16 >= area.height { + let y = area.y + i as u16; + if y >= bottom_y { break; } let (agg_state, agg_seen) = ws.aggregate_state(); let (icon, icon_style) = state_icon_style(agg_state, agg_seen); let is_selected = i == app.selected && is_navigating; - - let num_label = format!("{}", i + 1); - let row_style = if is_selected { Style::default().bg(app.accent).fg(Color::Black) } else { Style::default() }; - let dim_style = if is_selected { + let num_style = if is_selected { row_style } else { Style::default().fg(Color::DarkGray) }; - let y = area.y + i as u16; - let row_area = Rect::new(area.x, y, content_w, 1); - - // Fill background for selected row if is_selected { let buf = frame.buffer_mut(); - for x in row_area.x..row_area.x + row_area.width { + for x in area.x..area.x + content_w { buf[(x, y)].set_style(row_style); } } - let line = Line::from(vec![ - Span::styled(&num_label, dim_style), - Span::styled( - " ", - if is_selected { - row_style - } else { - Style::default() - }, - ), - Span::styled( - icon, - if is_selected { - // Keep icon's fg color, just add the highlight bg - icon_style.bg(app.accent) - } else { - icon_style - }, - ), - ]); - frame.render_widget(Paragraph::new(line), row_area); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{}", i + 1), num_style), + Span::styled(" ", row_style), + Span::styled(icon, if is_selected { icon_style.bg(app.accent) } else { icon_style }), + ])), + Rect::new(area.x, y, content_w, 1), + ); } - // Toggle button at bottom render_sidebar_toggle(frame, area, true); } fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { let is_navigating = matches!( app.mode, - Mode::Navigate - | Mode::CreateSession - | Mode::RenameSession - | Mode::Resize - | Mode::ConfirmClose - | Mode::ContextMenu + Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu ); - - let highlight_style = if is_navigating { - Style::default() - .fg(Color::Black) - .bg(app.accent) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::Black).bg(Color::DarkGray) - }; - - // Thin separator on right edge (consistent with collapsed) + let highlight = Style::default().bg(app.accent).fg(Color::Black); let sep_style = if is_navigating { Style::default().fg(app.accent) } else { Style::default().fg(Color::DarkGray) }; + let sep_x = area.x + area.width.saturating_sub(1); let buf = frame.buffer_mut(); for y in area.y..area.y + area.height { @@ -268,134 +233,89 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { buf[(sep_x, y)].set_style(sep_style); } - // Content area (left of separator) let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + let bottom_y = content.y + content.height.saturating_sub(1); + let new_row_y = bottom_y.saturating_sub(1); + let mut row_y = content.y; - // Title line - let title = match app.mode { - Mode::Terminal => format!(" {} ⏎", app.prefix_label), - Mode::Navigate | Mode::ContextMenu => " NAVIGATE".to_string(), - Mode::Resize => " RESIZE".to_string(), - Mode::CreateSession => " NEW".to_string(), - Mode::RenameSession => " RENAME".to_string(), - Mode::ConfirmClose => " CLOSE?".to_string(), - }; - let title_style = if is_navigating { - Style::default().fg(app.accent).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::DarkGray) - }; - frame.render_widget( - Paragraph::new(Span::styled(&title, title_style)), - Rect::new(content.x, content.y, content.width, 1), - ); - - // Workspace list (below title) - let list_area = Rect::new( - content.x, - content.y + 1, - content.width, - content.height.saturating_sub(1), - ); - - let items: Vec<ListItem> = app - .workspaces - .iter() - .enumerate() - .map(|(i, ws)| { - let selected = i == app.selected && is_navigating; - // When highlighted: text goes black on highlight bg, but icons keep their color - let text_style = if selected { - highlight_style - } else { - Style::default() - }; - let dim_style = if selected { - highlight_style - } else { - Style::default().fg(Color::DarkGray) - }; - - let marker = if Some(i) == app.active { "▸" } else { " " }; - let (agg_state, agg_seen) = ws.aggregate_state(); - let (rollup_icon, rollup_style) = state_icon_style(agg_state, agg_seen); - // Icon keeps its fg color, but picks up the highlight bg when selected - let icon_style = if selected { - rollup_style.bg(highlight_style.bg.unwrap_or(app.accent)) - } else { - rollup_style - }; + for (i, ws) in app.workspaces.iter().enumerate() { + if row_y + 1 >= new_row_y { + break; + } + let selected = i == app.selected && is_navigating; + let marker = if Some(i) == app.active { "▸" } else { " " }; + let (agg_state, agg_seen) = ws.aggregate_state(); + let (icon, icon_style) = state_icon_style(agg_state, agg_seen); + let text_style = if selected { highlight } else { Style::default() }; + let dim_style = if selected { + highlight + } else { + Style::default().fg(Color::DarkGray) + }; + let mut line1 = vec![Span::styled(marker, text_style)]; - let num_label = format!("{}", i + 1); - let num_style = if selected { - highlight_style - } else { - Style::default().fg(Color::DarkGray) - }; + if ws.layout.pane_count() == 1 { + line1.push(Span::styled( + icon, + if selected { icon_style.bg(app.accent) } else { icon_style }, + )); + line1.push(Span::styled(" ", text_style)); + line1.push(Span::styled( + ws.display_name(), + text_style.add_modifier(Modifier::BOLD), + )); + } else { + line1.push(Span::styled( + ws.display_name(), + text_style.add_modifier(Modifier::BOLD), + )); + line1.push(Span::styled(" ", dim_style)); + for (pane_state, pane_seen) in ws.pane_states() { + let (pane_icon, pane_style) = state_icon_style(pane_state, pane_seen); + line1.push(Span::styled( + pane_icon, + if selected { pane_style.bg(app.accent) } else { pane_style }, + )); + } + } - let mut spans = vec![ - Span::styled(num_label, num_style), - Span::styled(format!("{marker}"), text_style), - Span::styled(rollup_icon, icon_style), - Span::styled(format!(" {}", ws.name), text_style), - ]; - - let pane_states = ws.pane_states(); - let has_any_agent = pane_states.iter().any(|(s, _)| *s != AgentState::Unknown); - - if ws.layout.pane_count() > 1 { - if has_any_agent { - spans.push(Span::styled(" [", dim_style)); - for (pane_state, pane_seen) in &pane_states { - let (icon, style) = state_icon_style(*pane_state, *pane_seen); - let pane_icon_style = if selected { - style.bg(highlight_style.bg.unwrap_or(app.accent)) - } else { - style - }; - spans.push(Span::styled(icon, pane_icon_style)); - } - spans.push(Span::styled("]", dim_style)); - } else { - let count = ws.layout.pane_count(); - spans.push(Span::styled(format!(" [{count}]"), dim_style)); + if selected { + let buf = frame.buffer_mut(); + for y in row_y..=row_y + 1 { + for x in content.x..content.x + content.width { + buf[(x, y)].set_style(highlight); } } + } - ListItem::new(Line::from(spans)) - }) - .collect(); - - // No highlight_style on List — we handle it per-span above to preserve icon colors - let list = List::new(items); - let mut state = ListState::default().with_selected(Some(app.selected)); - frame.render_stateful_widget(list, list_area, &mut state); + frame.render_widget(Paragraph::new(Line::from(line1)), Rect::new(content.x, row_y, content.width, 1)); + + if app.mode == Mode::RenameSession && i == app.selected { + let text = format!(" {}\u{2588}", app.name_input); + frame.render_widget(Clear, Rect::new(content.x, row_y + 1, content.width, 1)); + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::Yellow)), + Rect::new(content.x, row_y + 1, content.width, 1), + ); + } else if let Some(summary) = ws.agent_summary() { + let line2 = Line::from(vec![ + Span::styled(" ", dim_style), + Span::styled(summary, dim_style), + ]); + frame.render_widget( + Paragraph::new(line2), + Rect::new(content.x, row_y + 1, content.width, 1), + ); + } - if app.mode == Mode::CreateSession { - let input_y = list_area.y + list_area.height.saturating_sub(1); - let input_area = Rect::new(content.x, input_y, content.width, 1); - let text = format!(" Name: {}\u{2588}", app.name_input); - frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::Yellow)), - input_area, - ); + row_y += 2; } - if app.mode == Mode::RenameSession { - let input_y = list_area.y + app.selected as u16; - let input_area = Rect::new(content.x, input_y, content.width, 1); - let text = format!(" ✎ {}\u{2588}", app.name_input); - frame.render_widget(Clear, input_area); - frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::Yellow)), - input_area, - ); - } - - // Toggle button at bottom (skip when input field occupies that row) - if app.mode != Mode::CreateSession { - render_sidebar_toggle(frame, area, false); - } + frame.render_widget( + Paragraph::new(Span::styled("new", Style::default().fg(Color::DarkGray))), + Rect::new(content.x, new_row_y, content.width, 1), + ); + render_sidebar_toggle(frame, area, false); } fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) { @@ -547,7 +467,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { Line::from(vec![ Span::styled(" Press ", Style::default().fg(Color::DarkGray)), Span::styled( - "n", + "new", Style::default().fg(accent).add_modifier(Modifier::BOLD), ), Span::styled(" to create one", Style::default().fg(Color::DarkGray)), @@ -569,41 +489,29 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let dim = Style::default().fg(Color::DarkGray); let label = Style::default().fg(Color::White); - let is_full = app - .active - .and_then(|i| app.workspaces.get(i)) - .is_some_and(|ws| ws.zoomed); - - let full_key = if is_full { "unfull" } else { "full" }; - + let kb = &app.keybinds; let line1 = Line::from(vec![ - Span::styled(" n", key), + Span::styled(format!(" {}", kb.new_workspace_label), key), Span::styled(" new ", dim), - Span::styled("N", key), + Span::styled(kb.rename_workspace_label.as_str(), key), Span::styled(" rename ", dim), - Span::styled("d", key), - Span::styled(" close ", dim), - Span::styled("v", key), + Span::styled(kb.close_workspace_label.as_str(), key), + Span::styled(" close ws ", dim), + Span::styled(kb.split_vertical_label.as_str(), key), Span::styled(" split│ ", dim), - Span::styled("-", key), + Span::styled(kb.split_horizontal_label.as_str(), key), Span::styled(" split─ ", dim), - Span::styled("⇥", key), - Span::styled(" pane ", dim), - Span::styled("f", key), - Span::styled(format!(" {full_key} "), dim), - Span::styled("r", key), - Span::styled(" resize ", dim), - Span::styled("b", key), - Span::styled(" sidebar ", dim), - Span::styled("q", key), - Span::styled(" quit", dim), + Span::styled(kb.close_pane_label.as_str(), key), + Span::styled(" close pane ", dim), + Span::styled(kb.fullscreen_label.as_str(), key), + Span::styled(" fullscreen", dim), ]); let ws_name = app .active .and_then(|i| app.workspaces.get(i)) - .map(|ws| ws.name.as_str()) - .unwrap_or("—"); + .map(|ws| ws.display_name()) + .unwrap_or_else(|| "—".to_string()); let pane_info = app .active @@ -633,9 +541,17 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { Span::styled("esc", key), Span::styled(" back ", dim), Span::styled("↑↓", key), - Span::styled(" select ", dim), + Span::styled(" ws ", dim), + Span::styled("⇥", key), + Span::styled(" pane ", dim), + Span::styled(kb.resize_mode_label.as_str(), key), + Span::styled(" resize ", dim), + Span::styled(kb.toggle_sidebar_label.as_str(), key), + Span::styled(" sidebar ", dim), Span::styled("⏎", key), - Span::styled(" open", dim), + Span::styled(" open ", dim), + Span::styled("q", key), + Span::styled(" quit", dim), ]); let overlay_height = 2; @@ -697,8 +613,8 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let ws_name = app .workspaces .get(app.selected) - .map(|ws| ws.name.as_str()) - .unwrap_or("?"); + .map(|ws| ws.display_name()) + .unwrap_or_else(|| "?".to_string()); let pane_count = app .workspaces .get(app.selected) diff --git a/src/workspace.rs b/src/workspace.rs index a4cdfcc4..9a584bd3 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,17 +1,21 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use ratatui::layout::Direction; use tokio::sync::mpsc; use tracing::info; -use crate::detect::{self, AgentState}; +use crate::detect::{self, Agent, AgentState}; use crate::events::AppEvent; use crate::layout::{PaneId, TileLayout}; use crate::pane::{PaneRuntime, PaneState}; /// A named workspace containing tiled terminal panes. pub struct Workspace { - pub name: String, + /// User-provided override. If set, auto-derived identity stops updating. + pub custom_name: Option<String>, + /// Identity source for this workspace. + pub root_pane: PaneId, pub layout: TileLayout, /// Pane state — always present, testable without PTYs. pub panes: HashMap<PaneId, PaneState>, @@ -23,24 +27,23 @@ pub struct Workspace { impl Workspace { pub fn new( - name: String, + initial_cwd: PathBuf, rows: u16, cols: u16, events: mpsc::Sender<AppEvent>, ) -> std::io::Result<Self> { let (layout, root_id) = TileLayout::new(); - - let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into()); - let runtime = PaneRuntime::spawn(root_id, rows, cols, cwd, events.clone())?; + let runtime = PaneRuntime::spawn(root_id, rows, cols, initial_cwd, events.clone())?; let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); let mut runtimes = HashMap::new(); runtimes.insert(root_id, runtime); - info!(workspace = %name, root_pane = root_id.raw(), "workspace created"); + info!(root_pane = root_id.raw(), "workspace created"); Ok(Self { - name, + custom_name: None, + root_pane: root_id, layout, panes, runtimes, @@ -55,11 +58,10 @@ impl Workspace { direction: Direction, rows: u16, cols: u16, - cwd: Option<std::path::PathBuf>, + cwd: Option<PathBuf>, ) -> std::io::Result<PaneId> { let new_id = self.layout.split_focused(direction); - let actual_cwd = - cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); + let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?; self.panes.insert(new_id, PaneState::new()); self.runtimes.insert(new_id, runtime); @@ -69,18 +71,42 @@ impl Workspace { /// Close the focused pane. Returns the removed pane id, or None if last pane. pub fn close_focused(&mut self) -> Option<PaneId> { + let pane_id = self.layout.focused(); + self.remove_pane(pane_id) + } + + /// Remove a specific pane from this workspace. + /// Returns None if it's the last pane and the whole workspace should close. + pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<PaneId> { if self.layout.pane_count() <= 1 { return None; } - let closed = self.layout.focused(); - if self.layout.close_focused() { - self.panes.remove(&closed); - self.runtimes.remove(&closed); - self.zoomed = false; - Some(closed) + + let next_root = self.promoted_root_if_needed(pane_id); + + if self.layout.focused() == pane_id { + self.layout.close_focused(); } else { - None + let prev_focus = self.layout.focused(); + self.layout.focus_pane(pane_id); + self.layout.close_focused(); + self.layout.focus_pane(prev_focus); } + + self.panes.remove(&pane_id); + self.runtimes.remove(&pane_id); + self.zoomed = false; + if let Some(next_root) = next_root { + self.root_pane = next_root; + } + Some(pane_id) + } + + fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> { + if self.root_pane != closing { + return None; + } + self.layout.pane_ids().into_iter().find(|id| *id != closing) } /// Get the runtime for the focused pane. @@ -88,6 +114,52 @@ impl Workspace { self.runtimes.get(&self.layout.focused()) } + pub fn set_custom_name(&mut self, name: String) { + self.custom_name = Some(name); + } + + pub fn display_name(&self) -> String { + if let Some(name) = &self.custom_name { + return name.clone(); + } + + self.root_cwd() + .as_deref() + .map(derive_label_from_cwd) + .unwrap_or_else(|| "shell".to_string()) + } + + pub fn root_cwd(&self) -> Option<PathBuf> { + self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd()) + } + + pub fn agent_summary(&self) -> Option<String> { + let mut names = Vec::new(); + + for id in self.layout.pane_ids() { + if let Some(agent) = self.panes.get(&id).and_then(|p| p.detected_agent) { + let name = agent_name(agent); + if !names.iter().any(|n| *n == name) { + names.push(name); + } + } + } + + if names.is_empty() { + if self.root_cwd().is_some() { + Some("shell".to_string()) + } else { + None + } + } else if names.len() == 1 { + Some(names[0].to_string()) + } else if names.len() == 2 { + Some(format!("{} + {}", names[0], names[1])) + } else { + Some(format!("{} + {} +{}", names[0], names[1], names.len() - 2)) + } + } + /// Aggregate state + seen across all panes. /// Returns the highest-priority state and the worst-case seen flag. pub fn aggregate_state(&self) -> (AgentState, bool) { @@ -112,6 +184,60 @@ impl Workspace { } } +fn agent_name(agent: Agent) -> &'static str { + match agent { + Agent::Pi => "pi", + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Gemini => "gemini", + Agent::Cursor => "cursor", + Agent::Cline => "cline", + Agent::OpenCode => "opencode", + Agent::GithubCopilot => "copilot", + Agent::Kimi => "kimi", + Agent::Droid => "droid", + Agent::Amp => "amp", + } +} + +fn derive_label_from_cwd(cwd: &Path) -> String { + if let Some(repo_root) = git_repo_root(cwd) { + if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) { + return name.to_string(); + } + } + + if let Ok(home) = std::env::var("HOME") { + let home = Path::new(&home); + if cwd == home { + return "~".to_string(); + } + } + + cwd.file_name() + .and_then(|n| n.to_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| cwd.display().to_string()) +} + +fn git_repo_root(start: &Path) -> Option<PathBuf> { + let mut current = if start.is_dir() { + start.to_path_buf() + } else { + start.parent()?.to_path_buf() + }; + + loop { + if current.join(".git").exists() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + // --------------------------------------------------------------------------- // Test helpers — construct workspaces without PTYs // --------------------------------------------------------------------------- @@ -125,7 +251,8 @@ impl Workspace { let mut panes = HashMap::new(); panes.insert(root_id, PaneState::new()); Self { - name: name.to_string(), + custom_name: Some(name.to_string()), + root_pane: root_id, layout, panes, runtimes: HashMap::new(), @@ -160,13 +287,11 @@ mod tests { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - // Set one pane to Busy, one to Idle let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap(); ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle; ws.panes.get_mut(&id2).unwrap().state = AgentState::Busy; let (state, _) = ws.aggregate_state(); - // Busy should be higher priority than Idle assert_eq!(state, AgentState::Busy); } @@ -207,13 +332,21 @@ mod tests { let mut ws = Workspace::test_new("test"); let id2 = ws.test_split(Direction::Horizontal); - // Set second pane to Waiting ws.panes.get_mut(&id2).unwrap().state = AgentState::Waiting; let states = ws.pane_states(); assert_eq!(states.len(), 2); - // One should be Unknown, one Waiting assert!(states.iter().any(|(s, _)| *s == AgentState::Waiting)); assert!(states.iter().any(|(s, _)| *s == AgentState::Unknown)); } + + #[test] + fn closing_root_promotes_another_pane() { + let mut ws = Workspace::test_new("test"); + let root = ws.root_pane; + let other = ws.test_split(Direction::Horizontal); + ws.layout.focus_pane(root); + ws.remove_pane(root); + assert_eq!(ws.root_pane, other); + } } |
