diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 2 | ||||
| -rw-r--r-- | src/api/schema/events.rs | 12 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 22 | ||||
| -rw-r--r-- | src/api/schema/workspaces.rs | 7 | ||||
| -rw-r--r-- | src/api/server.rs | 1 | ||||
| -rw-r--r-- | src/api/subscriptions.rs | 4 | ||||
| -rw-r--r-- | src/app/actions.rs | 120 | ||||
| -rw-r--r-- | src/app/api.rs | 3 | ||||
| -rw-r--r-- | src/app/api/plugins/context.rs | 6 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 126 | ||||
| -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 | ||||
| -rw-r--r-- | src/app/runtime_mutations.rs | 14 | ||||
| -rw-r--r-- | src/app/state.rs | 19 | ||||
| -rw-r--r-- | src/ui.rs | 10 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 277 |
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 ¶ms.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 { @@ -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)), }, }); |
