diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-26 02:45:44 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-26 02:45:44 +0300 |
| commit | 8843bbb0c4dc4e6b52ca5d0a6592ce80be56a19c (patch) | |
| tree | 9387a75a8ae34fd864de83355412331c4f22a2b9 /src/app/input | |
| parent | 547aba8e0ab310623f1986d3026658f708d432bd (diff) | |
fix: improve sidebar worktree hierarchy and reordering (#1873)
* fix: clarify sidebar worktree hierarchy
refs #1694
* feat: reorder worktree groups as a unit
refs #1694
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 54 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 7 | ||||
| -rw-r--r-- | src/app/input/sidebar.rs | 322 |
4 files changed, 317 insertions, 69 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 3d20adcd..570e8b8c 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -366,6 +366,9 @@ impl App { source_ws_idx, insert_idx, } => self.move_workspace_via_api(source_ws_idx, insert_idx), + MouseAction::MoveWorkspaceBlock { params } => { + self.move_workspace_block_via_api(params) + } MouseAction::MoveTab { ws_idx, source_tab_idx, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index e0f89387..56b9b676 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -43,6 +43,9 @@ pub(super) enum MouseAction { source_ws_idx: usize, insert_idx: usize, }, + MoveWorkspaceBlock { + params: crate::api::schema::WorkspaceMoveBlockParams, + }, MoveTab { ws_idx: usize, source_tab_idx: usize, @@ -561,9 +564,8 @@ impl AppState { self.view.workspace_card_areas.clone() }; if let Some(card) = cards.iter().find(|card| { - mouse.row == card.rect.y - && mouse.column == card.rect.x - && mouse.column < card.rect.x + card.rect.width + let chevron = crate::ui::workspace_group_chevron_rect(card); + mouse.row == chevron.y && mouse.column == chevron.x && chevron.width > 0 }) { if let Some((key, collapsed)) = crate::ui::workspace_parent_group_state(self, card.ws_idx) @@ -671,21 +673,21 @@ impl AppState { } } - let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row); + let workspace_drop_target = self.workspace_drop_target_at_row(mouse.row); let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row); if self.drag.is_none() { if let Some(press) = &self.workspace_press { let delta_col = mouse.column.abs_diff(press.start_col); let delta_row = mouse.row.abs_diff(press.start_row); - let can_reorder = self - .workspaces - .get(press.ws_idx) - .is_some_and(|ws| ws.worktree_space().is_none()); + let can_reorder = self.workspaces.get(press.ws_idx).is_some_and(|ws| { + ws.worktree_space() + .is_none_or(|space| !space.is_linked_worktree) + }); if can_reorder && delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD { self.drag = Some(DragState { target: DragTarget::WorkspaceReorder { source_ws_idx: press.ws_idx, - insert_idx: workspace_drop_index, + drop_target: workspace_drop_target, }, }); } @@ -705,10 +707,10 @@ impl AppState { } if let Some(DragState { - target: DragTarget::WorkspaceReorder { insert_idx, .. }, + target: DragTarget::WorkspaceReorder { drop_target, .. }, }) = &mut self.drag { - *insert_idx = workspace_drop_index; + *drop_target = workspace_drop_target; } else if let Some(DragState { target: DragTarget::TabReorder { @@ -837,13 +839,33 @@ impl AppState { target: DragTarget::WorkspaceReorder { source_ws_idx, - insert_idx: Some(insert_idx), + drop_target: Some(drop_target), }, }) => { - return Some(MouseAction::MoveWorkspace { - source_ws_idx, - insert_idx, - }); + if let Some(params) = + self.workspace_move_block_params(source_ws_idx, drop_target) + { + if self + .workspaces + .get(source_ws_idx) + .is_some_and(|workspace| workspace.worktree_space().is_some()) + { + return Some(MouseAction::MoveWorkspaceBlock { params }); + } + let insert_idx = params + .before_workspace_id + .as_ref() + .and_then(|id| { + self.workspaces + .iter() + .position(|workspace| workspace.id == *id) + }) + .unwrap_or(self.workspaces.len()); + return Some(MouseAction::MoveWorkspace { + source_ws_idx, + insert_idx, + }); + } } Some(DragState { target: diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index d151b94d..a5300461 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -434,6 +434,13 @@ impl App { ); } + pub(crate) fn move_workspace_block_via_api( + &mut self, + params: crate::api::schema::WorkspaceMoveBlockParams, + ) { + self.runtime_workspace_move_block("tui.workspace.move_block", params); + } + pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { let Some(ws_idx) = self.state.active else { return; diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 85de8aac..1c3a3b58 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -358,7 +358,10 @@ impl AppState { Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) } - pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> { + pub(super) fn workspace_drop_target_at_row( + &self, + row: u16, + ) -> Option<crate::app::state::WorkspaceDropTarget> { let area = self.workspace_list_rect(); let footer = self.sidebar_footer_rect(); if area == Rect::default() || row < area.y || row >= footer.y { @@ -370,46 +373,96 @@ impl AppState { } else { self.view.workspace_card_areas.clone() }; - if cards.is_empty() { - return Some(0); + crate::ui::workspace_drop_slots(self, &cards, area) + .into_iter() + .enumerate() + .min_by_key(|(slot_idx, (_, slot_row))| (row.abs_diff(*slot_row), *slot_idx)) + .map(|(_, (target, _))| target) + } + + pub(super) fn workspace_move_block_params( + &self, + source_ws_idx: usize, + drop_target: crate::app::state::WorkspaceDropTarget, + ) -> Option<crate::api::schema::WorkspaceMoveBlockParams> { + let source = self.workspaces.get(source_ws_idx)?; + if source + .worktree_space() + .is_some_and(|space| space.is_linked_worktree) + { + return None; } - let mut insert_indices = Vec::with_capacity(cards.len() + 1); - for (idx, card) in cards.iter().enumerate() { - let card_group = self - .workspaces - .get(card.ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.as_str()); - let previous_group = idx.checked_sub(1).and_then(|prev_idx| { - self.workspaces - .get(cards[prev_idx].ws_idx) - .and_then(|ws| ws.worktree_space()) - .map(|space| space.key.as_str()) - }); - let inside_group_gap = card_group.is_some() && card_group == previous_group; - if !inside_group_gap { - insert_indices.push(card.ws_idx); - } + let roots = crate::ui::workspace_list_entries_expanded(self) + .into_iter() + .filter_map(|entry| match entry { + crate::ui::WorkspaceListEntry::Workspace { + ws_idx, + indented: false, + } => Some(ws_idx), + crate::ui::WorkspaceListEntry::Workspace { .. } => None, + }) + .collect::<Vec<_>>(); + let source_pos = roots.iter().position(|ws_idx| *ws_idx == source_ws_idx)?; + let remaining_roots = roots + .iter() + .copied() + .filter(|ws_idx| *ws_idx != source_ws_idx) + .collect::<Vec<_>>(); + let insert_pos = match drop_target { + crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => remaining_roots + .iter() + .position(|ws_idx| *ws_idx == target_ws_idx)?, + crate::app::state::WorkspaceDropTarget::End => remaining_roots.len(), + }; + if insert_pos == source_pos { + return None; } - insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0)); - - let mut best: Option<(usize, u16)> = None; - for insert_idx in insert_indices { - let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx) - else { - continue; - }; - let distance = row.abs_diff(slot_row); - match best { - Some((best_idx, best_distance)) - if distance > best_distance - || (distance == best_distance && insert_idx < best_idx) => {} - _ => best = Some((insert_idx, distance)), + + let workspace_ids = match source.worktree_space() { + Some(source_space) => { + let mut ids = vec![source.id.clone()]; + ids.extend( + self.workspaces + .iter() + .filter(|workspace| workspace.id != source.id) + .filter(|workspace| { + workspace + .worktree_space() + .is_some_and(|space| space.key == source_space.key) + }) + .map(|workspace| workspace.id.clone()), + ); + ids } - } + None => vec![source.id.clone()], + }; + let before_workspace_id = match drop_target { + crate::app::state::WorkspaceDropTarget::Before(target_ws_idx) => { + let target = self.workspaces.get(target_ws_idx)?; + let anchor = match crate::ui::workspace_parent_group_state(self, target_ws_idx) + .and_then(|_| target.worktree_space()) + { + Some(target_space) => self + .workspaces + .iter() + .find(|workspace| { + workspace + .worktree_space() + .is_some_and(|space| space.key == target_space.key) + }) + .unwrap_or(target), + None => target, + }; + Some(anchor.id.clone()) + } + crate::app::state::WorkspaceDropTarget::End => None, + }; - best.map(|(insert_idx, _)| insert_idx) + Some(crate::api::schema::WorkspaceMoveBlockParams { + workspace_ids, + before_workspace_id, + }) } pub(super) fn on_agent_panel_sort_toggle(&self, col: u16, row: u16) -> bool { @@ -1209,12 +1262,13 @@ mod tests { app.state.active = None; app.state.mode = Mode::Terminal; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - let parent = app.state.view.workspace_card_areas[0].rect; + let parent = app.state.view.workspace_card_areas[0]; + let chevron = crate::ui::workspace_group_chevron_rect(&parent); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), - parent.x, - parent.y, + chevron.x, + chevron.y, )); assert_eq!(app.state.active, None); @@ -1223,8 +1277,8 @@ mod tests { app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), - parent.x, - parent.y, + chevron.x, + chevron.y, )); assert!(!app.state.collapsed_space_keys.contains("repo-key")); @@ -1279,15 +1333,16 @@ mod tests { crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); let packed_boundary_row = app.state.view.workspace_card_areas[1].rect.y; assert_eq!( - app.state.workspace_drop_index_at_row(packed_boundary_row), - Some(2) + app.state.workspace_drop_target_at_row(packed_boundary_row), + Some(crate::app::state::WorkspaceDropTarget::Before(2)) ); let source_row = app.state.view.workspace_card_areas[1].rect.y; let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, &app.state.view.workspace_card_areas, app.state.workspace_list_rect(), - 0, + crate::app::state::WorkspaceDropTarget::Before(0), ) .unwrap(); @@ -1305,7 +1360,7 @@ mod tests { app.state.drag.as_ref().map(|drag| &drag.target), Some(DragTarget::WorkspaceReorder { source_ws_idx: 1, - insert_idx: Some(0), + drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(0)), }) )); app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); @@ -1321,6 +1376,15 @@ mod tests { assert_eq!(app.state.selected, 2); assert_eq!(app.state.workspaces[0].id, active_id); assert_eq!(app.state.workspaces[2].id, selected_id); + let events = app.event_hub.events_after(0); + assert!(events.iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::WorkspaceMoved { .. } + ))); + assert!(!events.iter().any(|(_, event)| matches!( + event.data, + crate::api::schema::EventData::WorkspaceReordered { .. } + ))); let snapshot = capture_snapshot(&app.state); let captured_names: Vec<_> = snapshot .workspaces @@ -1516,10 +1580,22 @@ mod tests { app.state.sidebar_spaces.row_gap = 1; crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); - assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0)); - assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1)); + assert_eq!( + app.state.workspace_drop_target_at_row(0), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(1), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(2), + Some(crate::app::state::WorkspaceDropTarget::Before(0)) + ); + assert_eq!( + app.state.workspace_drop_target_at_row(3), + Some(crate::app::state::WorkspaceDropTarget::Before(1)) + ); let _ = fs::remove_dir_all(first_repo); let _ = fs::remove_dir_all(second_repo); @@ -1537,9 +1613,10 @@ mod tests { let cards = &app.state.view.workspace_card_areas; let bottom_slot = crate::ui::workspace_drop_indicator_row( + &app.state, cards, app.state.workspace_list_rect(), - cards.len(), + crate::app::state::WorkspaceDropTarget::End, ) .unwrap(); @@ -1566,14 +1643,152 @@ mod tests { let issue = cards.iter().find(|card| card.ws_idx == 2).unwrap(); let normal = cards.iter().find(|card| card.ws_idx == 1).unwrap(); - assert_eq!(app.state.workspace_drop_index_at_row(issue.rect.y), Some(1)); assert_eq!( - crate::ui::workspace_drop_indicator_row(cards, app.state.workspace_list_rect(), 2), + app.state.workspace_drop_target_at_row(issue.rect.y), + Some(crate::app::state::WorkspaceDropTarget::Before(1)) + ); + assert_eq!( + crate::ui::workspace_drop_indicator_row( + &app.state, + cards, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ), Some(normal.rect.y + normal.rect.height) ); } #[test] + fn plain_drag_anchors_to_the_selected_parentless_linked_workspace() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("one", "repo-key"), + workspace_with_space("two", "repo-key"), + Workspace::test_new("normal"), + ]; + let target_id = app.state.workspaces[1].id.clone(); + + let params = app + .state + .workspace_move_block_params(2, crate::app::state::WorkspaceDropTarget::Before(1)) + .unwrap(); + + assert_eq!(params.workspace_ids, [app.state.workspaces[2].id.clone()]); + assert_eq!( + params.before_workspace_id.as_deref(), + Some(target_id.as_str()) + ); + } + + #[test] + fn dragging_worktree_parent_reorders_the_complete_group() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("main", "repo-key"), + Workspace::test_new("normal"), + workspace_with_space("issue", "repo-key"), + ]; + app.state.active = Some(2); + app.state.selected = 1; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); + + let parent = app + .state + .view + .workspace_card_areas + .iter() + .find(|card| card.ws_idx == 0) + .unwrap() + .rect; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ) + .unwrap(); + let active_id = app.state.workspaces[2].id.clone(); + let selected_id = app.state.workspaces[1].id.clone(); + + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + target_row, + )); + assert!(matches!( + app.state.drag.as_ref().map(|drag| &drag.target), + Some(DragTarget::WorkspaceReorder { + source_ws_idx: 0, + drop_target: Some(crate::app::state::WorkspaceDropTarget::End), + }) + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::<Vec<_>>(), + ["normal", "main", "issue"] + ); + assert_eq!( + app.state.workspaces[app.state.active.unwrap()].id, + active_id + ); + assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); + } + + #[test] + fn dragging_collapsed_worktree_parent_still_moves_hidden_children() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![ + workspace_with_space("issue", "repo-key"), + Workspace::test_new("normal"), + workspace_with_space("main", "repo-key"), + workspace_with_space("review", "repo-key"), + ]; + app.state.active = Some(0); + app.state.selected = 1; + app.state.collapsed_space_keys.insert("repo-key".into()); + let active_id = app.state.workspaces[0].id.clone(); + let selected_id = app.state.workspaces[1].id.clone(); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 40)); + assert_eq!(app.state.view.workspace_card_areas.len(), 3); + + let parent = app.state.view.workspace_card_areas[0].rect; + let target_row = crate::ui::workspace_drop_indicator_row( + &app.state, + &app.state.view.workspace_card_areas, + app.state.workspace_list_rect(), + crate::app::state::WorkspaceDropTarget::End, + ) + .unwrap(); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, parent.y)); + app.handle_mouse(mouse( + MouseEventKind::Drag(MouseButton::Left), + 2, + target_row, + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row)); + + assert_eq!( + app.state + .workspaces + .iter() + .map(|workspace| workspace.display_name()) + .collect::<Vec<_>>(), + ["normal", "main", "issue", "review"] + ); + assert_eq!( + app.state.workspaces[app.state.active.unwrap()].id, + active_id + ); + assert_eq!(app.state.workspaces[app.state.selected].id, selected_id); + } + + #[test] fn dragging_worktree_space_member_does_not_reorder_workspaces() { let mut app = app_for_mouse_test(); app.state.workspaces = vec