aboutsummaryrefslogtreecommitdiff
path: root/src/app/input/navigate.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:21:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-22 02:23:35 +0300
commit0148c1379b76c5c0a9d05588c07dcb06ec32f93f (patch)
tree554766d959c738ca2aa433c3d4b4a7de25568008 /src/app/input/navigate.rs
parenta9ff4012bdca0dbdbc77bc6e53a430689fc899be (diff)
feat: add git worktree workspace management
refs #137
Diffstat (limited to 'src/app/input/navigate.rs')
-rw-r--r--src/app/input/navigate.rs285
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();