aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 15:23:29 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 15:28:13 +0300
commit7380e0ed16470c570984485930048a80034ba381 (patch)
treea4dea216baad978f1f10d6af034f3aef883f9246 /src/app
parentf18f26d1c0fd8101baee946f71bdd77b9743df41 (diff)
refactor workspace identity and configurable navigate actions
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs17
-rw-r--r--src/app/input.rs321
-rw-r--r--src/app/mod.rs19
-rw-r--r--src/app/state.rs19
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(),
},
}
}