aboutsummaryrefslogtreecommitdiff
path: root/src/ui
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/ui
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/ui')
-rw-r--r--src/ui/sidebar.rs277
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)),
},
});