aboutsummaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/events.rs12
-rw-r--r--src/api/schema/tests.rs22
-rw-r--r--src/api/schema/workspaces.rs7
-rw-r--r--src/api/server.rs1
-rw-r--r--src/api/subscriptions.rs4
-rw-r--r--src/app/actions.rs120
-rw-r--r--src/app/api.rs3
-rw-r--r--src/app/api/plugins/context.rs6
-rw-r--r--src/app/api/workspaces.rs126
-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
-rw-r--r--src/app/runtime_mutations.rs14
-rw-r--r--src/app/state.rs19
-rw-r--r--src/ui.rs10
-rw-r--r--src/ui/sidebar.rs277
19 files changed, 879 insertions, 131 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 994b2d87..7520d83c 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -29,6 +29,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::WorkspaceFocus(_)
| Method::WorkspaceRename(_)
| Method::WorkspaceMove(_)
+ | Method::WorkspaceMoveBlock(_)
| Method::WorkspaceReportMetadata(_)
| Method::WorkspaceClose(_)
| Method::WorktreeCreate(_)
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 72d9e53a..42179c06 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -75,6 +75,8 @@ pub enum Method {
WorkspaceRename(WorkspaceRenameParams),
#[serde(rename = "workspace.move")]
WorkspaceMove(WorkspaceMoveParams),
+ #[serde(rename = "workspace.move_block")]
+ WorkspaceMoveBlock(WorkspaceMoveBlockParams),
#[serde(rename = "workspace.report_metadata")]
WorkspaceReportMetadata(WorkspaceReportMetadataParams),
#[serde(rename = "workspace.close")]
diff --git a/src/api/schema/events.rs b/src/api/schema/events.rs
index 50b5d522..52ac6c96 100644
--- a/src/api/schema/events.rs
+++ b/src/api/schema/events.rs
@@ -26,6 +26,8 @@ pub enum Subscription {
WorkspaceRenamed {},
#[serde(rename = "workspace.moved")]
WorkspaceMoved {},
+ #[serde(rename = "workspace.reordered")]
+ WorkspaceReordered {},
#[serde(rename = "workspace.closed")]
WorkspaceClosed {},
#[serde(rename = "workspace.focused")]
@@ -196,6 +198,7 @@ pub enum EventKind {
WorkspaceClosed,
WorkspaceRenamed,
WorkspaceMoved,
+ WorkspaceReordered,
WorkspaceFocused,
WorktreeCreated,
WorktreeOpened,
@@ -226,6 +229,7 @@ impl EventKind {
EventKind::WorkspaceClosed => "workspace.closed",
EventKind::WorkspaceRenamed => "workspace.renamed",
EventKind::WorkspaceMoved => "workspace.moved",
+ EventKind::WorkspaceReordered => "workspace.reordered",
EventKind::WorkspaceFocused => "workspace.focused",
EventKind::WorktreeCreated => "worktree.created",
EventKind::WorktreeOpened => "worktree.opened",
@@ -257,6 +261,7 @@ pub const KNOWN_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
EventKind::WorkspaceMoved,
+ EventKind::WorkspaceReordered,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -284,6 +289,7 @@ pub const PLUGIN_HOOK_EVENT_KINDS: &[EventKind] = &[
EventKind::WorkspaceClosed,
EventKind::WorkspaceRenamed,
EventKind::WorkspaceMoved,
+ EventKind::WorkspaceReordered,
EventKind::WorkspaceFocused,
EventKind::WorktreeCreated,
EventKind::WorktreeOpened,
@@ -437,6 +443,12 @@ pub enum EventData {
insert_index: usize,
workspaces: Vec<WorkspaceInfo>,
},
+ WorkspaceReordered {
+ workspace_ids: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ before_workspace_id: Option<String>,
+ workspaces: Vec<WorkspaceInfo>,
+ },
WorkspaceFocused {
workspace_id: String,
},
diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs
index 9fcc03b0..16894dbf 100644
--- a/src/api/schema/tests.rs
+++ b/src/api/schema/tests.rs
@@ -470,6 +470,14 @@ fn event_envelope_round_trips() {
},
},
EventEnvelope {
+ event: EventKind::WorkspaceReordered,
+ data: EventData::WorkspaceReordered {
+ workspace_ids: vec!["w_1".into(), "w_2".into()],
+ before_workspace_id: Some("w_3".into()),
+ workspaces: vec![],
+ },
+ },
+ EventEnvelope {
event: EventKind::TabMoved,
data: EventData::TabMoved {
tab_id: "w_1:1".into(),
@@ -1063,6 +1071,18 @@ fn authority_mutation_requests_round_trip() {
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, workspace_move);
+ let workspace_move_block = Request {
+ id: "move_ws_block".into(),
+ method: Method::WorkspaceMoveBlock(WorkspaceMoveBlockParams {
+ workspace_ids: vec!["w1".into(), "w2".into()],
+ before_workspace_id: Some("w3".into()),
+ }),
+ };
+ let json = serde_json::to_value(&workspace_move_block).unwrap();
+ assert_eq!(json["method"], "workspace.move_block");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, workspace_move_block);
+
let tab_move = Request {
id: "move_tab".into(),
method: Method::TabMove(TabMoveParams {
@@ -1105,6 +1125,7 @@ fn authority_mutation_requests_round_trip() {
method: Method::EventsSubscribe(EventsSubscribeParams {
subscriptions: vec![
Subscription::WorkspaceMoved {},
+ Subscription::WorkspaceReordered {},
Subscription::TabMoved {},
Subscription::LayoutUpdated {},
],
@@ -1112,6 +1133,7 @@ fn authority_mutation_requests_round_trip() {
};
let json = serde_json::to_string(&subscription).unwrap();
assert!(json.contains("\"type\":\"workspace.moved\""));
+ assert!(json.contains("\"type\":\"workspace.reordered\""));
assert!(json.contains("\"type\":\"tab.moved\""));
assert!(json.contains("\"type\":\"layout.updated\""));
let restored: Request = serde_json::from_str(&json).unwrap();
diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs
index a6177982..f9ad4ced 100644
--- a/src/api/schema/workspaces.rs
+++ b/src/api/schema/workspaces.rs
@@ -29,6 +29,13 @@ pub struct WorkspaceMoveParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct WorkspaceMoveBlockParams {
+ pub workspace_ids: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub before_workspace_id: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct WorkspaceReportMetadataParams {
pub workspace_id: String,
pub source: String,
diff --git a/src/api/server.rs b/src/api/server.rs
index 5bc90ef9..0598faea 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -359,6 +359,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::WorkspaceFocus(_) => "workspace.focus",
Method::WorkspaceRename(_) => "workspace.rename",
Method::WorkspaceMove(_) => "workspace.move",
+ Method::WorkspaceMoveBlock(_) => "workspace.move_block",
Method::WorkspaceReportMetadata(_) => "workspace.report_metadata",
Method::WorkspaceClose(_) => "workspace.close",
Method::WorktreeList(_) => "worktree.list",
diff --git a/src/api/subscriptions.rs b/src/api/subscriptions.rs
index 13a340b6..7bcc7b77 100644
--- a/src/api/subscriptions.rs
+++ b/src/api/subscriptions.rs
@@ -132,6 +132,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceMoved,
last_sequence: 0,
})),
+ Subscription::WorkspaceReordered {} => Ok(Self::Event(ActiveEventSubscription {
+ event_kind: crate::api::schema::EventKind::WorkspaceReordered,
+ last_sequence: 0,
+ })),
Subscription::WorkspaceClosed {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::WorkspaceClosed,
last_sequence: 0,
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 0709ed4c..10d28054 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1390,6 +1390,73 @@ impl AppState {
true
}
+ pub fn move_workspace_block(
+ &mut self,
+ workspace_ids: &[String],
+ before_workspace_id: Option<&str>,
+ ) -> bool {
+ let moved_ids = workspace_ids
+ .iter()
+ .map(String::as_str)
+ .collect::<std::collections::HashSet<_>>();
+ if moved_ids.is_empty()
+ || moved_ids.len() != workspace_ids.len()
+ || !workspace_ids
+ .iter()
+ .all(|id| self.workspaces.iter().any(|workspace| workspace.id == *id))
+ || before_workspace_id.is_some_and(|id| {
+ moved_ids.contains(id)
+ || !self.workspaces.iter().any(|workspace| workspace.id == id)
+ })
+ {
+ return false;
+ }
+
+ let mut desired_ids = self
+ .workspaces
+ .iter()
+ .filter(|workspace| !moved_ids.contains(workspace.id.as_str()))
+ .map(|workspace| workspace.id.clone())
+ .collect::<Vec<_>>();
+ let insert_idx = before_workspace_id
+ .and_then(|id| desired_ids.iter().position(|candidate| candidate == id))
+ .unwrap_or(desired_ids.len());
+ desired_ids.splice(insert_idx..insert_idx, workspace_ids.iter().cloned());
+ if self
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.id.as_str())
+ .eq(desired_ids.iter().map(String::as_str))
+ {
+ return false;
+ }
+
+ let active_id = self.active.map(|idx| self.workspaces[idx].id.clone());
+ let selected_id = self
+ .workspaces
+ .get(self.selected)
+ .map(|workspace| workspace.id.clone());
+ let desired_positions = desired_ids
+ .iter()
+ .enumerate()
+ .map(|(index, id)| (id.clone(), index))
+ .collect::<std::collections::HashMap<_, _>>();
+
+ self.mark_session_dirty();
+ self.workspaces.sort_by_key(|workspace| {
+ desired_positions
+ .get(&workspace.id)
+ .copied()
+ .unwrap_or(usize::MAX)
+ });
+ self.active = active_id.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id));
+ self.selected = selected_id
+ .and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
+ .unwrap_or(0);
+ self.ensure_workspace_visible(self.selected);
+ true
+ }
+
pub fn scroll_tabs_left(&mut self) {
self.tab_scroll_follow_active = false;
self.tab_scroll = self.tab_scroll.saturating_sub(1);
@@ -4434,6 +4501,59 @@ mod tests {
}
#[test]
+ fn move_workspace_block_collects_non_contiguous_members() {
+ let mut state =
+ app_with_workspaces(&["child-one", "normal", "parent", "child-two", "tail"]);
+ let parent_id = state.workspaces[2].id.clone();
+ let child_one_id = state.workspaces[0].id.clone();
+ let child_two_id = state.workspaces[3].id.clone();
+ let tail_id = state.workspaces[4].id.clone();
+ state.active = Some(0);
+ state.selected = 4;
+
+ assert!(state.move_workspace_block(
+ &[parent_id, child_one_id.clone(), child_two_id],
+ Some(&tail_id),
+ ));
+
+ let names = state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.display_name())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ names,
+ ["normal", "parent", "child-one", "child-two", "tail"]
+ );
+ assert_eq!(state.workspaces[state.active.unwrap()].id, child_one_id);
+ assert_eq!(state.workspaces[state.selected].id, tail_id);
+ }
+
+ #[test]
+ fn move_workspace_block_rejects_invalid_and_noop_orders() {
+ let mut state = app_with_workspaces(&["a", "b", "c"]);
+ let ids = state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.id.clone())
+ .collect::<Vec<_>>();
+
+ assert!(!state.move_workspace_block(&[], None));
+ assert!(!state.move_workspace_block(&[ids[0].clone(), ids[0].clone()], None));
+ assert!(!state.move_workspace_block(&["missing".into()], None));
+ assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[0])));
+ assert!(!state.move_workspace_block(&[ids[0].clone()], Some(&ids[1])));
+ assert_eq!(
+ state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.display_name())
+ .collect::<Vec<_>>(),
+ ["a", "b", "c"]
+ );
+ }
+
+ #[test]
fn close_workspace_adjusts_indices() {
let mut state = app_with_workspaces(&["a", "b", "c"]);
state.selected = 1;
diff --git a/src/app/api.rs b/src/app/api.rs
index 37b1f455..c46be676 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -988,6 +988,9 @@ impl App {
Method::WorkspaceMove(params) => {
return self.handle_workspace_move(request.id, params);
}
+ Method::WorkspaceMoveBlock(params) => {
+ return self.handle_workspace_move_block(request.id, params);
+ }
Method::WorkspaceReportMetadata(params) => {
return self.handle_workspace_report_metadata(request.id, params);
}
diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs
index 16d2fdb1..efef83b9 100644
--- a/src/app/api/plugins/context.rs
+++ b/src/app/api/plugins/context.rs
@@ -63,6 +63,12 @@ impl App {
context
})
}),
+ EventData::WorkspaceReordered { workspace_ids, .. } => workspace_ids
+ .first()
+ .and_then(|workspace_id| {
+ self.plugin_context_for_workspace_id(workspace_id, correlation_id)
+ })
+ .unwrap_or_else(|| empty_plugin_context(correlation_id)),
EventData::WorkspaceRenamed { workspace_id, .. }
| EventData::WorkspaceMoved { workspace_id, .. }
| EventData::WorkspaceFocused { workspace_id } => self
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
index b7260c17..cf50d3ad 100644
--- a/src/app/api/workspaces.rs
+++ b/src/app/api/workspaces.rs
@@ -2,7 +2,8 @@ use std::path::PathBuf;
use crate::api::schema::{
EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams,
- WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceReportMetadataParams, WorkspaceTarget,
+ WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams,
+ WorkspaceReportMetadataParams, WorkspaceTarget,
};
use crate::app::App;
@@ -154,6 +155,76 @@ impl App {
encode_success(id, ResponseResult::WorkspaceList { workspaces })
}
+ pub(super) fn handle_workspace_move_block(
+ &mut self,
+ id: String,
+ params: WorkspaceMoveBlockParams,
+ ) -> String {
+ if params.workspace_ids.is_empty() {
+ return encode_error(
+ id,
+ "workspace_move_block_failed",
+ "workspace_ids must not be empty",
+ );
+ }
+
+ let mut workspace_ids = Vec::with_capacity(params.workspace_ids.len());
+ let mut seen_ids = std::collections::HashSet::new();
+ for requested_id in &params.workspace_ids {
+ let Some(index) = self.parse_workspace_id(requested_id) else {
+ return workspace_not_found(id, requested_id);
+ };
+ let Some(workspace) = self.state.workspaces.get(index) else {
+ return workspace_not_found(id, requested_id);
+ };
+ if !seen_ids.insert(workspace.id.clone()) {
+ return encode_error(
+ id,
+ "workspace_move_block_failed",
+ format!("workspace {requested_id} appears more than once"),
+ );
+ }
+ workspace_ids.push(workspace.id.clone());
+ }
+
+ let before_workspace_id = match params.before_workspace_id {
+ Some(requested_id) => {
+ let Some(index) = self.parse_workspace_id(&requested_id) else {
+ return workspace_not_found(id, &requested_id);
+ };
+ let Some(workspace) = self.state.workspaces.get(index) else {
+ return workspace_not_found(id, &requested_id);
+ };
+ if seen_ids.contains(&workspace.id) {
+ return encode_error(
+ id,
+ "workspace_move_block_failed",
+ "before_workspace_id must not be part of workspace_ids",
+ );
+ }
+ Some(workspace.id.clone())
+ }
+ None => None,
+ };
+
+ let moved = self
+ .state
+ .move_workspace_block(&workspace_ids, before_workspace_id.as_deref());
+ let workspaces = self.workspace_list_info();
+ if moved {
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceReordered,
+ data: EventData::WorkspaceReordered {
+ workspace_ids,
+ before_workspace_id,
+ workspaces: workspaces.clone(),
+ },
+ });
+ }
+
+ encode_success(id, ResponseResult::WorkspaceList { workspaces })
+ }
+
pub(super) fn handle_workspace_report_metadata(
&mut self,
id: String,
@@ -561,6 +632,59 @@ mod tests {
}
#[test]
+ fn api_workspace_move_block_reorders_atomically() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
+ app.state.workspaces = vec![
+ Workspace::test_new("child"),
+ Workspace::test_new("normal"),
+ Workspace::test_new("parent"),
+ Workspace::test_new("tail"),
+ ];
+ let parent_id = app.public_workspace_id(2);
+ let child_id = app.public_workspace_id(0);
+ let tail_id = app.public_workspace_id(3);
+
+ let response = app.handle_workspace_move_block(
+ "req".into(),
+ WorkspaceMoveBlockParams {
+ workspace_ids: vec![parent_id.clone(), child_id.clone()],
+ before_workspace_id: Some(tail_id.clone()),
+ },
+ );
+
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ let ResponseResult::WorkspaceList { workspaces } = success.result else {
+ panic!("expected workspace list");
+ };
+ assert_eq!(
+ app.state
+ .workspaces
+ .iter()
+ .map(|workspace| workspace.display_name())
+ .collect::<Vec<_>>(),
+ ["normal", "parent", "child", "tail"]
+ );
+ assert_eq!(workspaces[1].workspace_id, parent_id);
+ assert_eq!(workspaces[2].workspace_id, child_id);
+ let events = event_hub.events_after(0);
+ assert_eq!(events.len(), 1);
+ assert!(matches!(
+ &events[0].1.data,
+ EventData::WorkspaceReordered {
+ workspace_ids,
+ before_workspace_id,
+ workspaces,
+ } if workspace_ids.first() == Some(&parent_id)
+ && workspace_ids.get(1) == Some(&child_id)
+ && workspace_ids.len() == 2
+ && before_workspace_id.as_deref() == Some(tail_id.as_str())
+ && workspaces[1].workspace_id == parent_id
+ ));
+ }
+
+ #[test]
fn api_workspace_move_noop_does_not_emit_event() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
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();
diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs
index 77cd1f10..f765bf20 100644
--- a/src/app/runtime_mutations.rs
+++ b/src/app/runtime_mutations.rs
@@ -1,9 +1,9 @@
use crate::api::schema::{
EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneRenameParams,
PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, TabCreateParams,
- TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveParams,
- WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams,
- WorktreeRemoveParams,
+ TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveBlockParams,
+ WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams,
+ WorktreeOpenParams, WorktreeRemoveParams,
};
use super::App;
@@ -53,6 +53,14 @@ impl App {
self.dispatch_runtime_mutation(id, Method::WorkspaceMove(params))
}
+ pub(crate) fn runtime_workspace_move_block(
+ &mut self,
+ id: &'static str,
+ params: WorkspaceMoveBlockParams,
+ ) -> String {
+ self.dispatch_runtime_mutation(id, Method::WorkspaceMoveBlock(params))
+ }
+
pub(crate) fn runtime_workspace_close(
&mut self,
id: &'static str,
diff --git a/src/app/state.rs b/src/app/state.rs
index 6069b8a6..17b3ae4c 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1141,10 +1141,16 @@ pub struct SettingsState {
pub original_theme: Option<String>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum WorkspaceDropTarget {
+ Before(usize),
+ End,
+}
+
pub(crate) enum DragTarget {
WorkspaceReorder {
source_ws_idx: usize,
- insert_idx: Option<usize>,
+ drop_target: Option<WorkspaceDropTarget>,
},
TabReorder {
ws_idx: usize,
@@ -2211,16 +2217,11 @@ impl AppState {
match &drag.target {
DragTarget::WorkspaceReorder {
source_ws_idx,
- insert_idx,
+ drop_target,
} => {
assert_workspace_index(*source_ws_idx, "workspace drag source");
- if let Some(insert_idx) = insert_idx {
- assert!(
- *insert_idx <= self.workspaces.len(),
- "workspace drag insert index {} out of bounds for {} workspaces",
- insert_idx,
- self.workspaces.len()
- );
+ if let Some(WorkspaceDropTarget::Before(ws_idx)) = drop_target {
+ assert_workspace_index(*ws_idx, "workspace drag target");
}
}
DragTarget::TabReorder {
diff --git a/src/ui.rs b/src/ui.rs
index ed1f6a7d..59145dd2 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -52,6 +52,8 @@ pub(crate) use self::scrollbar::{
scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar,
};
use self::settings::render_settings_overlay;
+#[cfg(test)]
+pub(crate) use self::sidebar::workspace_drop_indicator_row;
use self::sidebar::{render_sidebar, render_sidebar_collapsed};
use self::status::{
copy_feedback_rect, render_config_diagnostic, render_copy_feedback, render_toast_notification,
@@ -79,11 +81,13 @@ pub(crate) use self::{
agent_panel_toggle_rect, all_agent_panel_entries, collapsed_sidebar_sections,
collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
expanded_sidebar_toggle_rect, normalized_workspace_scroll, sidebar_section_divider_rect,
- workspace_drop_indicator_row, workspace_list_entries, workspace_list_entries_expanded,
- workspace_list_rect, workspace_list_scroll_metrics, workspace_list_scrollbar_rect,
- workspace_parent_group_state, AgentPanelEntry, WorkspaceListEntry,
+ workspace_drop_slots, workspace_group_chevron_rect, workspace_list_entries,
+ workspace_list_entries_expanded, workspace_list_rect, workspace_list_scroll_metrics,
+ workspace_list_scrollbar_rect, workspace_parent_group_state, AgentPanelEntry,
+ WorkspaceListEntry,
},
};
+
pub(crate) use self::{
keybind_help::keybind_help_lines,
mobile::{
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index c7da990a..09078c17 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -718,6 +718,19 @@ pub(crate) fn compute_workspace_card_areas(
compute_workspace_list_areas(app, area).0
}
+pub(crate) fn workspace_group_chevron_rect(card: &crate::app::state::WorkspaceCardArea) -> Rect {
+ if card.rect.width == 0 || card.rect.height == 0 {
+ return Rect::default();
+ }
+
+ Rect::new(
+ card.rect.x + card.rect.width.saturating_sub(1),
+ card.rect.y,
+ 1,
+ 1,
+ )
+}
+
/// Auto-scale sidebar width based on workspace identity + agent summary.
pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
@@ -851,35 +864,100 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area:
render_sidebar_toggle(app, frame, area, true, p);
}
-pub(crate) fn workspace_drop_indicator_row(
+pub(crate) fn workspace_drop_slots(
+ app: &AppState,
cards: &[crate::app::state::WorkspaceCardArea],
area: Rect,
- insert_idx: usize,
-) -> Option<u16> {
- if area.height == 0 {
- return None;
+) -> Vec<(crate::app::state::WorkspaceDropTarget, u16)> {
+ if area.height == 0 || cards.is_empty() {
+ return Vec::new();
}
let list_bottom = area.y + area.height.saturating_sub(1);
+ let entries = workspace_list_entries(app);
+ let entry_position = |ws_idx| {
+ entries.iter().position(|entry| {
+ matches!(
+ entry,
+ WorkspaceListEntry::Workspace {
+ ws_idx: entry_ws_idx,
+ ..
+ } if *entry_ws_idx == ws_idx
+ )
+ })
+ };
+ let block_root_at = |entry_idx: usize| {
+ entries[..=entry_idx]
+ .iter()
+ .rev()
+ .find_map(|entry| match entry {
+ WorkspaceListEntry::Workspace {
+ ws_idx,
+ indented: false,
+ } => Some(*ws_idx),
+ WorkspaceListEntry::Workspace { .. } => None,
+ })
+ };
- let first = cards.first()?;
- if insert_idx == first.ws_idx {
- return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ let mut slots = Vec::new();
+ let mut previous_root = None;
+ for card in cards {
+ let Some(entry_idx) = entry_position(card.ws_idx) else {
+ continue;
+ };
+ let Some(root_idx) = block_root_at(entry_idx) else {
+ continue;
+ };
+ if previous_root == Some(root_idx) {
+ continue;
+ }
+ previous_root = Some(root_idx);
+ if let Some(row) = card.rect.y.checked_sub(1).filter(|row| *row < list_bottom) {
+ slots.push((
+ crate::app::state::WorkspaceDropTarget::Before(root_idx),
+ row,
+ ));
+ }
}
- if let Some(row) = cards
- .last()
- .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
- .map(|card| card.rect.y.saturating_add(card.rect.height))
- .filter(|y| *y < list_bottom)
- {
- return Some(row);
+ let Some(last) = cards.last() else {
+ return slots;
+ };
+ let Some(last_entry_idx) = entry_position(last.ws_idx) else {
+ return slots;
+ };
+ let next_entry = entries.get(last_entry_idx.saturating_add(1));
+ if matches!(
+ next_entry,
+ Some(WorkspaceListEntry::Workspace { indented: true, .. })
+ ) {
+ return slots;
}
-
- if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
- return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ let target = match next_entry {
+ Some(WorkspaceListEntry::Workspace { ws_idx, .. }) => {
+ crate::app::state::WorkspaceDropTarget::Before(*ws_idx)
+ }
+ None => crate::app::state::WorkspaceDropTarget::End,
+ };
+ let row = last.rect.y.saturating_add(last.rect.height);
+ if row < list_bottom
+ && slots
+ .last()
+ .is_none_or(|(last_target, _)| *last_target != target)
+ {
+ slots.push((target, row));
}
+ slots
+}
- None
+pub(crate) fn workspace_drop_indicator_row(
+ app: &AppState,
+ cards: &[crate::app::state::WorkspaceCardArea],
+ area: Rect,
+ target: crate::app::state::WorkspaceDropTarget,
+) -> Option<u16> {
+ workspace_drop_slots(app, cards, area)
+ .into_iter()
+ .find_map(|(candidate, row)| (candidate == target).then_some(row))
}
pub(super) fn render_sidebar(
@@ -1123,9 +1201,9 @@ fn render_workspace_list(
};
let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
Some(crate::app::state::DragTarget::WorkspaceReorder {
- insert_idx: Some(insert_idx),
+ drop_target: Some(drop_target),
..
- }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
+ }) => workspace_drop_indicator_row(app, &app.view.workspace_card_areas, area, *drop_target),
_ => None,
};
@@ -1143,6 +1221,7 @@ fn render_workspace_list(
let metrics = workspace_list_scroll_metrics(app, area);
let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
let cards = &app.view.workspace_card_areas;
+ let entries = workspace_list_entries(app);
for card in cards {
let i = card.ws_idx;
@@ -1189,6 +1268,16 @@ fn render_workspace_list(
let parent_group = (!card.indented)
.then(|| workspace_parent_group_state(app, i))
.flatten();
+ let is_last_child = card.indented
+ && entries
+ .iter()
+ .position(|entry| {
+ matches!(
+ entry,
+ WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == i
+ )
+ })
+ .is_none_or(|entry_idx| !next_entry_is_indented_workspace(&entries, entry_idx));
let (display_state, display_seen) = parent_group
.as_ref()
.filter(|(_, collapsed)| *collapsed)
@@ -1221,34 +1310,34 @@ fn render_workspace_list(
break;
}
let mut spans = Vec::new();
- if row_index == 0 {
- if card.indented {
- spans.push(Span::raw(" "));
- } else if let Some((_, collapsed)) = parent_group.as_ref() {
+ let prefix_width = if card.indented {
+ spans.push(Span::raw(" "));
+ if row_index == 0 {
spans.push(Span::styled(
- if *collapsed { "▸" } else { "▾" },
- Style::default().fg(p.accent),
+ if is_last_child { "└─ " } else { "├─ " },
+ Style::default().fg(p.overlay0),
));
- spans.push(Span::raw(" "));
- } else {
- spans.push(Span::raw(" "));
- }
- } else {
- spans.push(Span::raw(if card.indented { " " } else { " " }));
- }
- let prefix_width = if row_index == 0 {
- if card.indented {
- 3
- } else if parent_group.is_some() {
- 2
+ 6
+ } else if is_last_child {
+ spans.push(Span::raw(" "));
+ 8
} else {
- 1
+ spans.push(Span::styled("│", Style::default().fg(p.overlay0)));
+ spans.push(Span::raw(" "));
+ 8
}
- } else if card.indented {
- 5
+ } else if row_index == 0 {
+ spans.push(Span::raw(" "));
+ 1
} else {
+ spans.push(Span::raw(" "));
3
};
+ let trailing_width = if row_index == 0 && parent_group.is_some() {
+ 2
+ } else {
+ 0
+ };
spans.extend(resolved_token_spans(
resolved,
state_icon,
@@ -1257,13 +1346,25 @@ fn render_workspace_list(
branch_style,
branch_style,
p,
- card.rect.width.saturating_sub(prefix_width) as usize,
+ card.rect
+ .width
+ .saturating_sub(prefix_width + trailing_width) as usize,
));
frame.render_widget(
Paragraph::new(Line::from(spans)),
Rect::new(card.rect.x, row_y + row_index as u16, card.rect.width, 1),
);
}
+
+ if let Some((_, collapsed)) = parent_group {
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ if collapsed { "▸" } else { "▾" },
+ Style::default().fg(p.accent),
+ )),
+ workspace_group_chevron_rect(card),
+ );
+ }
}
if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
@@ -2293,6 +2394,85 @@ rows = [[{ token = "git_status", fg = "#123456" }]]
}
#[test]
+ fn desktop_worktree_tree_aligns_parents_and_marks_children() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
+ Workspace::test_new("notes"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![
+ crate::config::SpaceSidebarToken::StateIcon,
+ crate::config::SpaceSidebarToken::Workspace,
+ ]];
+ app.sidebar_spaces.row_gap = 0;
+ let area = Rect::new(0, 0, 30, 20);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ let list_area = workspace_list_rect(area, app.sidebar_section_split);
+
+ let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
+ terminal
+ .draw(|frame| {
+ render_workspace_list(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ frame,
+ list_area,
+ false,
+ )
+ })
+ .unwrap();
+
+ let buffer = terminal.backend().buffer();
+ let cards = &app.view.workspace_card_areas;
+ let parent_name_x = find_symbol_x(buffer, cards[0].rect.y, cards[0].rect.width, "m");
+ let plain_name_x = find_symbol_x(buffer, cards[3].rect.y, cards[3].rect.width, "n");
+ assert_eq!(parent_name_x, plain_name_x);
+ assert_eq!(buffer[(cards[1].rect.x + 3, cards[1].rect.y)].symbol(), "├");
+ assert_eq!(buffer[(cards[2].rect.x + 3, cards[2].rect.y)].symbol(), "└");
+ assert_eq!(
+ buffer[(cards[0].rect.x + cards[0].rect.width - 1, cards[0].rect.y)].symbol(),
+ "▾"
+ );
+ }
+
+ #[test]
+ fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() {
+ let mut app = AppState::test_new();
+ app.workspaces = vec![
+ workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"),
+ workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"),
+ workspace_with_worktree_space("review", Some("repo-key"), "/repo/herdr-review"),
+ ];
+ app.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]];
+ app.sidebar_spaces.row_gap = 0;
+ let area = Rect::new(0, 0, 30, 10);
+ app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
+ assert_eq!(app.view.workspace_card_areas.len(), 2);
+ let list_area = workspace_list_rect(area, app.sidebar_section_split);
+
+ let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
+ terminal
+ .draw(|frame| {
+ render_workspace_list(
+ &app,
+ &TerminalRuntimeRegistry::new(),
+ frame,
+ list_area,
+ false,
+ )
+ })
+ .unwrap();
+
+ let child = app.view.workspace_card_areas[1];
+ assert_eq!(
+ terminal.backend().buffer()[(child.rect.x + 3, child.rect.y)].symbol(),
+ "├"
+ );
+ }
+
+ #[test]
fn parent_workspace_row_stays_clickable_when_grouped() {
let mut app = AppState::test_new();
app.workspaces = vec![
@@ -2363,13 +2543,18 @@ rows = [[{ token = "git_status", fg = "#123456" }]]
let area = Rect::new(0, 0, 30, 20);
app.view.workspace_card_areas = compute_workspace_card_areas(&app, area);
let list_area = workspace_list_rect(area, app.sidebar_section_split);
- let indicator_row =
- workspace_drop_indicator_row(&app.view.workspace_card_areas, list_area, 2).unwrap();
+ let indicator_row = workspace_drop_indicator_row(
+ &app,
+ &app.view.workspace_card_areas,
+ list_area,
+ crate::app::state::WorkspaceDropTarget::Before(2),
+ )
+ .unwrap();
assert_eq!(indicator_row, app.view.workspace_card_areas[1].rect.y);
app.drag = Some(crate::app::state::DragState {
target: crate::app::state::DragTarget::WorkspaceReorder {
source_ws_idx: 0,
- insert_idx: Some(2),
+ drop_target: Some(crate::app::state::WorkspaceDropTarget::Before(2)),
},
});