From 5b91dae12ffa09f5842a0c852ef06299c31fe342 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 14 Jul 2026 20:40:11 +0300 Subject: [PATCH] feat: configure sidebar entry gaps --- docs/next/CHANGELOG.md | 4 + .../src/content/docs/configuration.mdx | 4 + .../src/content/docs/ja/configuration.mdx | 4 + .../src/content/docs/zh-cn/configuration.mdx | 4 + .../website/src/data/config-reference.json | 12 ++ src/app/input/sidebar.rs | 37 ++-- src/app/mod.rs | 4 +- src/config/sidebar.rs | 11 ++ src/main.rs | 4 + src/ui.rs | 2 +- src/ui/sidebar.rs | 177 +++++++++++++++--- src/ui/sidebar/tokens.rs | 9 +- 12 files changed, 230 insertions(+), 42 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 384118f6..f6c060bf 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,9 +3,13 @@ ## Unreleased ### Added +- Added independent `row_gap` settings for expanded Space and Agent sidebar entries. (#873) - Copy mode now supports literal smart-case search with `/` and `?`, repeating with `n` and `N`, match highlighting, and tmux-style cross-line `w`/`b`/`e` word motions. (#1230) - Added maki detection with idle, working, and blocked screen states. (#1301, thanks @tontinton) +### Changed +- Expanded Space and Agent sidebar entries now use a packed layout by default; set the corresponding `row_gap` to `1` to restore the previous spacing. (#873) + ### Fixed - Outer-terminal focus gained and lost reports now reach the focused pane when its application enables focus reporting, restoring Neovim file autoreload and other focus-aware terminal behavior. (#1337) - Native Windows servers now detach from the terminal console that launched them, so closing WezTerm, Windows Terminal, or another host terminal no longer stops persistent pane processes. (#1329) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 9455f8c3..01d82547 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -236,12 +236,14 @@ The expanded desktop sidebar renders each inner array in `rows` as one line. The ```toml [ui.sidebar.agents] +row_gap = 0 rows = [ ["state_icon", "workspace", "tab"], ["agent"], ] [ui.sidebar.spaces] +row_gap = 0 rows = [ ["state_icon", "workspace"], ["branch", "git_status"], @@ -271,6 +273,8 @@ Space rows accept these built-in tokens: Tokens render in their configured order. Herdr normally separates adjacent values with ` · ` and uses a single space after `state_icon`. Missing values and their separators disappear; a row disappears when none of its tokens have a value. Each layout may contain at most 16 rows, with at most 16 tokens in each row. +`row_gap` controls the blank terminal rows between entries, independently for the Agent and Space panels. It defaults to `0`, which packs entries together; set it to `1` to restore the previous spacing. It does not add spacing between the content lines declared in `rows`. Consecutive indented worktree children remain packed as one Space group. + Override the complete Agent layout for a known agent under `rows_by_agent`: ```toml diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index ffc142f4..7105efcd 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -235,12 +235,14 @@ yellow = "#f9e2af" ```toml [ui.sidebar.agents] +row_gap = 0 rows = [ ["state_icon", "workspace", "tab"], ["agent"], ] [ui.sidebar.spaces] +row_gap = 0 rows = [ ["state_icon", "workspace"], ["branch", "git_status"], @@ -270,6 +272,8 @@ Space の行では、次の組み込みトークンを使えます: トークンは設定された順序で描画されます。Herdr は通常、隣接する値を ` · ` で区切り、`state_icon` の後には空白を 1 つ入れます。値がない場合、その値と区切りは表示されません。すべてのトークンに値がない行は表示されません。各レイアウトは最大 16 行、各行は最大 16 トークンです。 +`row_gap` は、Agent パネルと Space パネルごとに、エントリ間の空白行数を指定します。デフォルトは `0` で、エントリを詰めて表示します。以前の間隔に戻すには `1` に設定します。`rows` で定義したコンテンツ行の間隔には影響しません。連続するインデントされた worktree の子は、1 つの Space グループとして詰めて表示されます。 + 既知のエージェントについて Agent の完全なレイアウトを上書きするには、`rows_by_agent` を使います: ```toml diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index ccf6c312..8a232cc7 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -235,12 +235,14 @@ yellow = "#f9e2af" ```toml [ui.sidebar.agents] +row_gap = 0 rows = [ ["state_icon", "workspace", "tab"], ["agent"], ] [ui.sidebar.spaces] +row_gap = 0 rows = [ ["state_icon", "workspace"], ["branch", "git_status"], @@ -270,6 +272,8 @@ Space 行支持以下内置 token: token 会按配置顺序渲染。Herdr 通常使用 ` · ` 分隔相邻值,并在 `state_icon` 后使用一个空格。缺失值及其分隔符会消失;当一行中的所有 token 都没有值时,该行会消失。每个布局最多可包含 16 行,每行最多可包含 16 个 token。 +`row_gap` 分别控制 Agent 和 Space 面板中条目之间的空白终端行数。默认值为 `0`,会紧密排列条目;设为 `1` 可恢复之前的间距。它不会在 `rows` 声明的内容行之间添加间距。连续缩进的 worktree 子项仍会作为一个 Space 组紧密排列。 + 在 `rows_by_agent` 下为已知智能体覆盖完整的 Agent 布局: ```toml diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index f84e6da1..65a6f29e 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -668,6 +668,12 @@ "priority" ] }, + { + "key": "ui.sidebar.agents.row_gap", + "type": "integer", + "default": "0", + "description": "Blank terminal rows between expanded Agent sidebar entries. Set to 1 to restore the previous spacing." + }, { "key": "ui.sidebar.agents.rows", "type": "list of token rows", @@ -680,6 +686,12 @@ "default": "{}", "description": "Complete Agent-row overrides keyed by strict canonical agent id. Agents without an override use ui.sidebar.agents.rows." }, + { + "key": "ui.sidebar.spaces.row_gap", + "type": "integer", + "default": "0", + "description": "Blank terminal rows between expanded Space sidebar entries. Set to 1 to restore the previous spacing. Consecutive indented worktree children remain packed as one group." + }, { "key": "ui.sidebar.spaces.rows", "type": "list of token rows", diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index acbdfb9a..9ae30763 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -448,21 +448,20 @@ impl AppState { } let mut row_y = body.y; - for detail in crate::ui::agent_panel_entries(self) - .into_iter() - .skip(self.agent_panel_scroll) - { - let height = crate::ui::agent_entry_height_in_body(self, &detail, body.height); - if row_y.saturating_add(height) > body.y + body.height { + let body_bottom = body.y + body.height; + let entries = crate::ui::agent_panel_entries(self); + for (index, detail) in entries.iter().enumerate().skip(self.agent_panel_scroll) { + let height = crate::ui::agent_entry_height_in_body(self, detail, body.height); + if row_y.saturating_add(height) > body_bottom { break; } if row >= row_y && row < row_y.saturating_add(height) { return Some((detail.ws_idx, detail.tab_idx, detail.pane_id)); } - row_y = row_y.saturating_add(height); - if row_y < body.y + body.height { - row_y = row_y.saturating_add(1); - } + row_y = row_y + .saturating_add(height) + .saturating_add(crate::ui::agent_entry_gap(self, index, entries.len())) + .min(body_bottom); } None } @@ -721,6 +720,7 @@ mod tests { vec![crate::config::AgentSidebarToken::Workspace], ], ); + app.state.sidebar_agents.row_gap = 1; let detail_area = app.state.agent_panel_rect(); let metrics = crate::ui::agent_panel_scroll_metrics(&app.state, detail_area); let body = crate::ui::agent_panel_body_rect( @@ -737,6 +737,12 @@ mod tests { app.state.agent_detail_target_at(body.y + 3), Some((1, 0, second_pane)) ); + + app.state.sidebar_agents.row_gap = 0; + assert_eq!( + app.state.agent_detail_target_at(body.y + 1), + Some((1, 0, second_pane)) + ); } #[test] @@ -1219,11 +1225,19 @@ mod tests { Workspace::test_new("b"), Workspace::test_new("c"), ]; + app.state.sidebar_spaces.rows = vec![vec![crate::config::SpaceSidebarToken::Workspace]]; + app.state.sidebar_spaces.row_gap = 0; let active_id = app.state.workspaces[1].id.clone(); let selected_id = app.state.workspaces[2].id.clone(); app.state.active = Some(1); app.state.selected = 2; 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) + ); + let source_row = app.state.view.workspace_card_areas[1].rect.y; let target_row = crate::ui::workspace_drop_indicator_row( &app.state.view.workspace_card_areas, @@ -1454,6 +1468,7 @@ mod tests { .get_mut(&second_terminal_id) .unwrap() .cwd = second_repo.clone(); + 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)); @@ -1473,7 +1488,7 @@ mod tests { Workspace::test_new("b"), Workspace::test_new("c"), ]; - crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 24)); let cards = &app.state.view.workspace_card_areas; let bottom_slot = crate::ui::workspace_drop_indicator_row( diff --git a/src/app/mod.rs b/src/app/mod.rs index 6a7470be..cd402460 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2714,7 +2714,7 @@ mod tests { std::fs::write( &path, - "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\n", + "[ui.sidebar.agents]\nrows = [[\"state_icon\", \"$summary\"]]\nrow_gap = 1\n\n[ui.sidebar.agents.rows_by_agent]\nclaude = [[\"terminal_title_stripped\"]]\n\n[ui.sidebar.spaces]\nrows = [[\"workspace\", \"$jj_status\"]]\nrow_gap = 3\n", ) .unwrap(); app.state.agent_panel_scroll = 5; @@ -2735,6 +2735,7 @@ mod tests { crate::config::AgentSidebarToken::TerminalTitleStripped, ]] ); + assert_eq!(app.state.sidebar_agents.row_gap, 1); assert_eq!( app.state.sidebar_spaces.rows, vec![vec![ @@ -2742,6 +2743,7 @@ mod tests { crate::config::SpaceSidebarToken::Custom("jj_status".into()), ]] ); + assert_eq!(app.state.sidebar_spaces.row_gap, 3); let previous_agents = app.state.sidebar_agents.clone(); std::fs::write( diff --git a/src/config/sidebar.rs b/src/config/sidebar.rs index d0ae5240..e583f5af 100644 --- a/src/config/sidebar.rs +++ b/src/config/sidebar.rs @@ -6,6 +6,7 @@ use crate::detect::Agent; const MAX_SIDEBAR_ROWS: usize = 16; const MAX_SIDEBAR_TOKENS_PER_ROW: usize = 16; +const DEFAULT_SIDEBAR_ROW_GAP: u16 = 0; fn deserialize_sidebar_rows<'de, D, T>(deserializer: D) -> Result>, D::Error> where @@ -198,6 +199,7 @@ pub struct AgentsSidebarConfig { pub rows: AgentSidebarRows, #[serde(default, deserialize_with = "deserialize_rows_by_agent")] pub rows_by_agent: BTreeMap, + pub row_gap: u16, } impl AgentsSidebarConfig { @@ -220,6 +222,7 @@ impl Default for AgentsSidebarConfig { vec![AgentSidebarToken::Agent], ], rows_by_agent: BTreeMap::new(), + row_gap: DEFAULT_SIDEBAR_ROW_GAP, } } } @@ -229,6 +232,7 @@ impl Default for AgentsSidebarConfig { pub struct SpacesSidebarConfig { #[serde(deserialize_with = "deserialize_sidebar_rows")] pub rows: SpaceSidebarRows, + pub row_gap: u16, } impl Default for SpacesSidebarConfig { @@ -238,6 +242,7 @@ impl Default for SpacesSidebarConfig { vec![SpaceSidebarToken::StateIcon, SpaceSidebarToken::Workspace], vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], ], + row_gap: DEFAULT_SIDEBAR_ROW_GAP, } } } @@ -268,6 +273,7 @@ mod tests { ] ); assert!(config.agents.rows_by_agent.is_empty()); + assert_eq!(config.agents.row_gap, 0); assert_eq!( config.spaces.rows, vec![ @@ -275,6 +281,7 @@ mod tests { vec![SpaceSidebarToken::Branch, SpaceSidebarToken::GitStatus], ] ); + assert_eq!(config.spaces.row_gap, 0); } #[test] @@ -283,12 +290,14 @@ mod tests { r#" [ui.sidebar.agents] rows = [["state_icon", "workspace"], ["state_text", "agent", "$summary"], ["terminal_title", "terminal_title_stripped", "$terminal_title"]] +row_gap = 1 [ui.sidebar.agents.rows_by_agent] claude = [["terminal_title_stripped"], ["agent", "$model"]] [ui.sidebar.spaces] rows = [["workspace"], ["$jj_status"]] +row_gap = 3 "#, ) .expect("sidebar token config"); @@ -319,10 +328,12 @@ rows = [["workspace"], ["$jj_status"]] ], ] ); + assert_eq!(config.ui.sidebar.agents.row_gap, 1); assert_eq!( config.ui.sidebar.spaces.rows[1], vec![SpaceSidebarToken::Custom("jj_status".into())] ); + assert_eq!(config.ui.sidebar.spaces.row_gap, 3); } #[test] diff --git a/src/main.rs b/src/main.rs index 06424fea..68e47401 100644 --- a/src/main.rs +++ b/src/main.rs @@ -309,6 +309,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # terminal_title, and terminal_title_stripped. # Custom values reported through pane metadata use a $name token. # [ui.sidebar.agents] +# Blank rows between agent entries. Set to 1 to restore the previous spacing. +# row_gap = 0 # rows = [["state_icon", "workspace", "tab"], ["agent"]] # Optional canonical agent IDs replace the default rows for matching agents. # [ui.sidebar.agents.rows_by_agent] @@ -317,6 +319,8 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Expanded space rows. Built-ins are state_icon, state_text, workspace, branch, and git_status. # Custom values reported through workspace metadata use a $name token, for example $jj_status. # [ui.sidebar.spaces] +# Blank rows between space entries. Set to 1 to restore the previous spacing. +# row_gap = 0 # rows = [["state_icon", "workspace"], ["branch", "git_status"]] # Accent color for highlights, borders, and navigation UI. diff --git a/src/ui.rs b/src/ui.rs index 76e1caa2..349a033a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -69,7 +69,7 @@ pub(crate) use self::{ SETTINGS_POPUP_WIDTH, }, sidebar::{ - agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries, + agent_entry_gap, agent_entry_height_in_body, agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_for_target, agent_panel_scroll_metrics, agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections, collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections, expanded_sidebar_toggle_rect, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 68e5549d..ebe7d0a1 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -218,11 +218,19 @@ fn workspace_row_height_in_body( workspace_row_height(app, workspace, indented).min(body_height) } -fn workspace_entry_gap(entries: &[WorkspaceListEntry], entry_idx: usize, indented: bool) -> u16 { - u16::from( - entry_idx + 1 < entries.len() - && !(indented && next_entry_is_indented_workspace(entries, entry_idx)), - ) +fn workspace_entry_gap( + app: &AppState, + entries: &[WorkspaceListEntry], + entry_idx: usize, + indented: bool, +) -> u16 { + if entry_idx + 1 < entries.len() + && !(indented && next_entry_is_indented_workspace(entries, entry_idx)) + { + app.sidebar_spaces.row_gap + } else { + 0 + } } fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { @@ -456,7 +464,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us }; ( workspace_row_height_in_body(app, ws, *indented, body.height), - workspace_entry_gap(&entries, entry_idx, *indented), + workspace_entry_gap(app, &entries, entry_idx, *indented), ) } }; @@ -465,9 +473,7 @@ fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> us } used_rows = used_rows.saturating_add(row_height); visible += 1; - if gap > 0 && used_rows < body.height { - used_rows = used_rows.saturating_add(1); - } + used_rows = used_rows.saturating_add(gap).min(body.height); } visible } @@ -482,7 +488,7 @@ fn workspace_list_bottom_start(app: &AppState, area: Rect) -> usize { let Some(workspace) = app.workspaces.get(*ws_idx) else { continue; }; - let gap = workspace_entry_gap(&entries, entry_idx, *indented); + let gap = workspace_entry_gap(app, &entries, entry_idx, *indented); let needed = workspace_row_height_in_body(app, workspace, *indented, body.height) .saturating_add(gap); if used_rows.saturating_add(needed) > body.height { @@ -552,6 +558,14 @@ pub(crate) fn agent_entry_height_in_body( .min(body_height) } +pub(crate) fn agent_entry_gap(app: &AppState, entry_idx: usize, entry_count: usize) -> u16 { + if entry_idx + 1 < entry_count { + app.sidebar_agents.row_gap + } else { + 0 + } +} + fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> usize { let body = agent_panel_body_rect(area, false); if body.width == 0 || body.height == 0 { @@ -560,16 +574,17 @@ fn agent_panel_visible_count_from(app: &AppState, area: Rect, scroll: usize) -> let mut used_rows = 0u16; let mut visible = 0usize; - for entry in agent_panel_entries(app).iter().skip(scroll) { + let entries = agent_panel_entries(app); + for (index, entry) in entries.iter().enumerate().skip(scroll) { let height = agent_entry_height_in_body(app, entry, body.height); if used_rows.saturating_add(height) > body.height { break; } used_rows = used_rows.saturating_add(height); visible += 1; - if used_rows < body.height { - used_rows = used_rows.saturating_add(1); - } + used_rows = used_rows + .saturating_add(agent_entry_gap(app, index, entries.len())) + .min(body.height); } visible } @@ -580,7 +595,7 @@ fn agent_panel_bottom_start(app: &AppState, area: Rect) -> usize { let mut used_rows = 0u16; let mut start = entries.len(); for (index, entry) in entries.iter().enumerate().rev() { - let gap = u16::from(index + 1 < entries.len()); + let gap = agent_entry_gap(app, index, entries.len()); let needed = agent_entry_height_in_body(app, entry, body.height).saturating_add(gap); if used_rows.saturating_add(needed) > body.height { break; @@ -664,7 +679,7 @@ pub(crate) fn compute_workspace_list_areas( continue; }; let row_height = workspace_row_height_in_body(app, ws, *indented, body.height); - let gap = workspace_entry_gap(&entries, entry_idx, *indented); + let gap = workspace_entry_gap(app, &entries, entry_idx, *indented); if row_y.saturating_add(row_height) > body_bottom { break; } @@ -673,10 +688,10 @@ pub(crate) fn compute_workspace_list_areas( rect: Rect::new(body.x, row_y, body.width, row_height), indented: *indented, }); - row_y = row_y.saturating_add(row_height); - if gap > 0 && row_y < body_bottom { - row_y = row_y.saturating_add(1); - } + row_y = row_y + .saturating_add(row_height) + .saturating_add(gap) + .min(body_bottom); } } } @@ -1298,7 +1313,7 @@ fn render_agent_detail( let mut row_y = body.y; let body_bottom = body.y + body.height; - for detail in details.iter().skip(app.agent_panel_scroll) { + for (index, detail) in details.iter().enumerate().skip(app.agent_panel_scroll) { let label_color = state_label_color(detail.state, detail.seen, p); let rows = resolved_agent_rows(app, detail); let height = (rows.len().max(1) as u16).min(body.height); @@ -1343,10 +1358,10 @@ fn render_agent_detail( Rect::new(body.x, row_y + row_index as u16, body.width, 1), ); } - row_y = row_y.saturating_add(height); - if row_y < body_bottom { - row_y += 1; - } + row_y = row_y + .saturating_add(height) + .saturating_add(agent_entry_gap(app, index, details.len())) + .min(body_bottom); } if let Some(track) = scrollbar_rect { @@ -1445,6 +1460,36 @@ mod tests { assert!(!second.contains("working")); } + #[test] + fn default_agent_row_gap_packs_rendering_and_scroll_geometry() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; + app.ensure_test_terminals(); + for (workspace, agent) in app.workspaces.iter().zip([Agent::Pi, Agent::Claude]) { + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + app.terminals.get_mut(&terminal_id).unwrap().detected_agent = Some(agent); + } + app.sidebar_agents.rows = vec![vec![crate::config::AgentSidebarToken::Agent]]; + assert_eq!(app.sidebar_agents.row_gap, 0); + + let area = Rect::new(0, 0, 20, 5); + let metrics = agent_panel_scroll_metrics(&app, area); + let body = agent_panel_body_rect(area, false); + let mut terminal = Terminal::new(TestBackend::new(20, 5)).unwrap(); + terminal + .draw(|frame| render_agent_detail(&app, &TerminalRuntimeRegistry::new(), frame, area)) + .unwrap(); + let buffer = terminal.backend().buffer(); + + assert_eq!(metrics.viewport_rows, 2); + assert_eq!(metrics.max_offset_from_bottom, 0); + assert_eq!(row_text(buffer, body.y, body.width), " pi"); + assert_eq!(row_text(buffer, body.y + 1, body.width), " claude"); + } + #[test] fn narrow_agent_rows_preserve_later_tab_tokens() { let mut app = crate::app::state::AppState::test_new(); @@ -2020,6 +2065,7 @@ mod tests { workspace_with_worktree_space("main", Some("repo-key"), "/repo/herdr"), workspace_with_worktree_space("issue", Some("repo-key"), "/repo/herdr-issue"), ]; + app.sidebar_spaces.row_gap = 1; let (cards, headers) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 20)); @@ -2031,6 +2077,87 @@ mod tests { assert_eq!(cards[1].rect.y, cards[0].rect.y + cards[0].rect.height + 1); } + #[test] + fn space_row_gap_preserves_compact_worktree_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::Workspace]]; + app.sidebar_spaces.row_gap = 2; + + let (spacious, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); + assert_eq!( + spacious[1].rect.y, + spacious[0].rect.y + spacious[0].rect.height + 2 + ); + assert_eq!( + spacious[2].rect.y, + spacious[1].rect.y + spacious[1].rect.height + ); + assert_eq!( + spacious[3].rect.y, + spacious[2].rect.y + spacious[2].rect.height + 2 + ); + let spacious_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); + assert_eq!(spacious_metrics.viewport_rows, 2); + assert_eq!(spacious_metrics.max_offset_from_bottom, 2); + + app.sidebar_spaces.row_gap = 0; + let (packed, _) = compute_workspace_list_areas(&app, Rect::new(0, 0, 30, 30)); + assert!(packed + .windows(2) + .all(|pair| pair[1].rect.y == pair[0].rect.y + pair[0].rect.height)); + let packed_metrics = workspace_list_scroll_metrics(&app, Rect::new(0, 0, 30, 7)); + assert_eq!(packed_metrics.viewport_rows, 4); + assert_eq!(packed_metrics.max_offset_from_bottom, 0); + } + + #[test] + fn packed_workspace_drag_indicator_overlays_an_internal_boundary() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + Workspace::test_new("a"), + Workspace::test_new("b"), + Workspace::test_new("c"), + ]; + app.sidebar_spaces.rows = vec![vec![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 indicator_row = + workspace_drop_indicator_row(&app.view.workspace_card_areas, list_area, 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), + }, + }); + + 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(); + + assert_eq!( + terminal.backend().buffer()[(list_area.x, indicator_row)].symbol(), + "─" + ); + } + #[test] fn linked_only_worktree_members_do_not_form_parentless_group() { let mut app = AppState::test_new(); diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index 43c24d05..5c003617 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -153,7 +153,7 @@ mod tests { vec![AgentSidebarToken::Custom("missing".into())], vec![AgentSidebarToken::Agent], ], - rows_by_agent: Default::default(), + ..Default::default() }; let rows = agent_rows(&config, &entry, "working"); @@ -174,7 +174,7 @@ mod tests { AgentSidebarToken::StateText, AgentSidebarToken::Custom("summary".into()), ]], - rows_by_agent: Default::default(), + ..Default::default() }; assert_eq!( @@ -200,7 +200,7 @@ mod tests { AgentSidebarToken::TerminalTitleStripped, AgentSidebarToken::Custom("terminal_title".into()), ]], - rows_by_agent: Default::default(), + ..Default::default() }; assert_eq!( @@ -217,7 +217,7 @@ mod tests { fn known_agent_override_replaces_default_rows() { let mut config = AgentsSidebarConfig { rows: vec![vec![AgentSidebarToken::Workspace]], - rows_by_agent: Default::default(), + ..Default::default() }; config .rows_by_agent @@ -265,6 +265,7 @@ mod tests { let tokens = std::collections::HashMap::from([("jj_status".into(), "2 changes".into())]); let config = SpacesSidebarConfig { rows: vec![vec![SpaceSidebarToken::Custom("jj_status".into())]], + ..Default::default() }; assert_eq!(