diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:21:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-22 02:23:35 +0300 |
| commit | 0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch) | |
| tree | 554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/input/navigate.rs | |
| parent | a9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff) | |
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/input/navigate.rs')
| -rw-r--r-- | src/app/input/navigate.rs | 285 |
1 files changed, 270 insertions, 15 deletions
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 9256bbbb..da3d49bf 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -383,24 +383,18 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) } KeyCode::Char(c @ '1'..='9') => { let idx = (c as usize) - ('1' as usize); - if idx < state.workspaces.len() { - state.switch_workspace(idx); + if let Some(ws_idx) = state.workspace_at_visible_position(idx) { + state.switch_workspace(ws_idx); leave_navigate_mode(state); } true } KeyCode::Up => { - if state.selected > 0 { - state.selected -= 1; - state.ensure_workspace_visible(state.selected); - } + state.move_selected_workspace_by_visible_delta(-1); true } KeyCode::Down => { - if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 { - state.selected += 1; - state.ensure_workspace_visible(state.selected); - } + state.move_selected_workspace_by_visible_delta(1); true } KeyCode::Char('h') | KeyCode::Left => { @@ -459,6 +453,9 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NavigateAction { NewWorkspace, + NewWorktree, + OpenWorktree, + RemoveWorktree, RenameWorkspace, CloseWorkspace, SwitchWorkspace(usize), @@ -557,6 +554,9 @@ fn action_for_key( (&kb.settings, NavigateAction::Settings), (&kb.workspace_picker, NavigateAction::WorkspacePicker), (&kb.new_workspace, NavigateAction::NewWorkspace), + (&kb.new_worktree, NavigateAction::NewWorktree), + (&kb.open_worktree, NavigateAction::OpenWorktree), + (&kb.remove_worktree, NavigateAction::RemoveWorktree), (&kb.rename_workspace, NavigateAction::RenameWorkspace), (&kb.close_workspace, NavigateAction::CloseWorkspace), (&kb.previous_workspace, NavigateAction::PreviousWorkspace), @@ -619,13 +619,36 @@ pub(super) fn execute_navigate_action_in_context( state.request_new_workspace = true; leave_navigate_mode(state); } + NavigateAction::NewWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) + .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) + { + state.request_new_linked_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } + NavigateAction::OpenWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) + .filter(|idx| workspace_can_start_worktree_action(state, terminal_runtimes, *idx)) + { + state.request_open_existing_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } + NavigateAction::RemoveWorktree => { + if let Some(ws_idx) = workspace_action_target(state, context) { + state.request_remove_linked_worktree = Some(ws_idx); + leave_navigate_mode(state); + } + } NavigateAction::RenameWorkspace => { - if !state.workspaces.is_empty() { - super::modal::open_rename_workspace(state, state.selected); + if let Some(ws_idx) = workspace_action_target(state, context) { + super::modal::open_rename_workspace(state, ws_idx); } } NavigateAction::CloseWorkspace => { - if !state.workspaces.is_empty() { + if let Some(ws_idx) = workspace_action_target(state, context) { + state.selected = ws_idx; if state.confirm_close { super::modal::open_confirm_close(state); } else { @@ -635,8 +658,8 @@ pub(super) fn execute_navigate_action_in_context( } } NavigateAction::SwitchWorkspace(idx) => { - if idx < state.workspaces.len() { - state.switch_workspace(idx); + if let Some(ws_idx) = state.workspace_at_visible_position(idx) { + state.switch_workspace(ws_idx); leave_navigate_mode(state); } } @@ -762,6 +785,36 @@ pub(super) fn execute_navigate_action_in_context( finish_action_context(state, context, previous_mode); } +fn workspace_action_target(state: &AppState, context: ActionContext) -> Option<usize> { + let idx = match context { + ActionContext::Direct | ActionContext::Prefix => state.active.unwrap_or(state.selected), + ActionContext::Navigate => state.selected, + }; + (idx < state.workspaces.len()).then_some(idx) +} + +fn workspace_can_start_worktree_action( + state: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + ws_idx: usize, +) -> bool { + let Some(ws) = state.workspaces.get(ws_idx) else { + return false; + }; + if ws + .worktree_space() + .is_some_and(|space| space.is_linked_worktree) + { + return false; + } + let git_space = ws.git_space().cloned().or_else(|| { + ws.resolved_identity_cwd_from(&state.terminals, terminal_runtimes) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + !git_space.is_some_and(|space| space.is_linked_worktree) +} + fn leave_navigate_mode(state: &mut AppState) { if state.active.is_some() { state.mode = Mode::Terminal; @@ -866,6 +919,16 @@ mod tests { use super::*; use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace}; + 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_rename_key_enters_rename_mode() { let mut state = state_with_workspaces(&["test"]); @@ -881,6 +944,55 @@ mod tests { } #[test] + fn prefix_rename_workspace_targets_active_workspace_not_stale_selection() { + let mut state = state_with_workspaces(&["main", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + state.active = Some(1); + state.selected = 0; + state.mode = Mode::Prefix; + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::RenameWorkspace, + ActionContext::Prefix, + ); + + assert_eq!(state.mode, Mode::RenameWorkspace); + assert_eq!(state.selected, 1); + assert_eq!(state.name_input, "issue"); + } + + #[test] + fn prefix_close_workspace_targets_active_linked_worktree_without_removing_checkout() { + let mut state = state_with_workspaces(&["main", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + state.active = Some(1); + state.selected = 0; + state.mode = Mode::Prefix; + state.confirm_close = 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, + }); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::CloseWorkspace, + ActionContext::Prefix, + ); + + 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 custom_new_workspace_key_requests_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g"); @@ -895,6 +1007,126 @@ mod tests { } #[test] + fn custom_new_worktree_key_requests_selected_workspace() { + let mut state = state_with_workspaces(&["main", "scratch"]); + state.workspaces[1].identity_cwd = unique_temp_path("navigate-new-worktree-selected"); + state.mode = Mode::Navigate; + state.selected = 1; + state.active = Some(0); + state.keybinds.new_worktree = crate::config::ActionKeybinds::prefix("g"); + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), + ); + + assert_eq!(state.request_new_linked_worktree, Some(1)); + assert_eq!(state.mode, Mode::Terminal); + } + + #[test] + fn worktree_actions_do_not_start_from_linked_child_workspace() { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + let mut state = state_with_workspaces(&["main", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 1, "repo-key"); + state.mode = Mode::Navigate; + state.selected = 1; + state.active = Some(0); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::NewWorktree, + ActionContext::Navigate, + ); + assert_eq!(state.request_new_linked_worktree, None); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::OpenWorktree, + ActionContext::Navigate, + ); + assert_eq!(state.request_open_existing_worktree, None); + } + + #[test] + fn direct_new_worktree_action_targets_active_workspace() { + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + let mut state = state_with_workspaces(&["main", "scratch"]); + state.workspaces[0].identity_cwd = unique_temp_path("navigate-new-worktree-active"); + state.mode = Mode::Terminal; + state.selected = 1; + state.active = Some(0); + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::NewWorktree, + ActionContext::Direct, + ); + + assert_eq!(state.request_new_linked_worktree, Some(0)); + } + + #[test] + fn navigate_down_follows_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Navigate; + state.active = Some(0); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Down, KeyModifiers::empty()), + ); + + assert_eq!(state.selected, 2); + } + + #[test] + fn navigate_number_keys_follow_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Navigate; + state.active = Some(0); + state.selected = 0; + + handle_navigate_key( + &mut state, + KeyEvent::new(KeyCode::Char('2'), KeyModifiers::empty()), + ); + + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] + fn indexed_switch_workspace_keybind_follows_grouped_sidebar_visual_order() { + let mut state = state_with_workspaces(&["main", "normal", "issue"]); + let mut terminal_runtimes = TerminalRuntimeRegistry::new(); + mark_worktree_space_member(&mut state, 0, "repo-key"); + mark_worktree_space_member(&mut state, 2, "repo-key"); + state.mode = Mode::Prefix; + state.active = Some(0); + state.selected = 0; + + execute_navigate_action_in_context( + &mut state, + &mut terminal_runtimes, + NavigateAction::SwitchWorkspace(1), + ActionContext::Prefix, + ); + + assert_eq!(state.active, Some(2)); + assert_eq!(state.selected, 2); + } + + #[test] fn custom_sidebar_toggle_key_toggles_and_exits_navigate() { let mut state = state_with_workspaces(&["test"]); state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g"); @@ -1226,6 +1458,29 @@ mod tests { assert!(state.workspaces.is_empty()); } + #[test] + fn closing_linked_worktree_closes_workspace_without_removing_checkout() { + let mut state = state_with_workspaces(&["main", "issue"]); + state.selected = 1; + state.active = Some(1); + state.mode = Mode::Navigate; + state.confirm_close = 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, + }); + + execute_navigate_action(&mut state, NavigateAction::CloseWorkspace); + + 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); + } + #[tokio::test] async fn custom_command_runs_from_prefix_key_in_navigate_mode() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); |
