diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-26 02:45:44 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-26 02:45:44 +0300 |
| commit | 8843bbb0c4dc4e6b52ca5d0a6592ce80be56a19c (patch) | |
| tree | 9387a75a8ae34fd864de83355412331c4f22a2b9 /src/ui/sidebar.rs | |
| parent | 547aba8e0ab310623f1986d3026658f708d432bd (diff) | |
fix: improve sidebar worktree hierarchy and reordering (#1873)
* fix: clarify sidebar worktree hierarchy
refs #1694
* feat: reorder worktree groups as a unit
refs #1694
Diffstat (limited to 'src/ui/sidebar.rs')
| -rw-r--r-- | src/ui/sidebar.rs | 277 |
1 files changed, 231 insertions, 46 deletions
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)), }, }); |
