aboutsummaryrefslogtreecommitdiff
path: root/src/app/input
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-07-26 02:45:44 +0300
committerGitHub <noreply@github.com>2026-07-26 02:45:44 +0300
commit8843bbb0c4dc4e6b52ca5d0a6592ce80be56a19c (patch)
tree9387a75a8ae34fd864de83355412331c4f22a2b9 /src/app/input
parent547aba8e0ab310623f1986d3026658f708d432bd (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.rs3
-rw-r--r--src/app/input/mouse.rs54
-rw-r--r--src/app/input/navigate.rs7
-rw-r--r--src/app/input/sidebar.rs322
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![
@@ -1594,9 +1809,10 @@ mod tests {
.unwrap()
.rect;
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();