diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index c16db7a193..4dd9223c56 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -775,6 +775,16 @@ "priority" ] }, + { + "key": "ui.spaces_sort", + "type": "enum", + "default": "\"manual\"", + "description": "Spaces sidebar ordering. Saved values are \"manual\" or \"priority\". Manual keeps the order in which spaces were added or reordered.", + "values": [ + "manual", + "priority" + ] + }, { "key": "ui.status_indicators", "type": "enum", diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 9f7445b920..03a70f099d 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -125,4 +125,25 @@ impl App { self.apply_config_from_disk(false); } } + + pub(super) fn save_spaces_sort(&mut self, sort: crate::app::state::SpacesSort) { + let value = match sort { + crate::app::state::SpacesSort::Manual => { + crate::config::SpacesSortConfig::Manual.as_str() + } + crate::app::state::SpacesSort::Priority => { + crate::config::SpacesSortConfig::Priority.as_str() + } + }; + if self.update_config_file("spaces sort", |content| { + crate::config::upsert_section_value( + content, + "ui", + "spaces_sort", + &format!("\"{value}\""), + ) + }) { + self.apply_config_from_disk(false); + } + } } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 45124107c9..4a066313d7 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -390,6 +390,7 @@ impl App { } let previous_agent_panel_sort = self.state.agent_panel_sort; + let previous_spaces_sort = self.state.spaces_sort; let previous_settings_section = self.state.settings.section; if !handled_pane_double_click { if let Some(action) = @@ -466,6 +467,9 @@ impl App { if self.state.agent_panel_sort != previous_agent_panel_sort { self.save_agent_panel_sort(self.state.agent_panel_sort); } + if self.state.spaces_sort != previous_spaces_sort { + self.save_spaces_sort(self.state.spaces_sort); + } self.dispatch_pending_clipboard_write(); diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 3066c2a4c2..4e19b2c18f 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -6,7 +6,7 @@ use tracing::warn; use crate::{ app::state::{ AgentPanelSort, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, - MenuListState, Mode, RightClickPassthroughGesture, TabPressState, ViewLayout, + MenuListState, Mode, RightClickPassthroughGesture, SpacesSort, TabPressState, ViewLayout, WorkspacePressState, }, layout::{PaneInfo, SplitBorder}, @@ -541,6 +541,16 @@ impl AppState { return None; } + if self.on_spaces_sort_toggle(mouse.column, mouse.row) { + self.spaces_sort = match self.spaces_sort { + SpacesSort::Manual => SpacesSort::Priority, + SpacesSort::Priority => SpacesSort::Manual, + }; + self.workspace_scroll = 0; + self.mark_session_dirty(); + return None; + } + let new_button = self.sidebar_new_button_rect(); let on_new_button = mouse.row >= new_button.y && mouse.row < new_button.y + new_button.height diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs index 0923eef6d2..144f496e8e 100644 --- a/src/app/input/sidebar.rs +++ b/src/app/input/sidebar.rs @@ -326,8 +326,9 @@ impl AppState { return None; } + let order = crate::ui::workspace_index_order(self); let idx = (row - ws_area.y) as usize; - (idx < self.workspaces.len()).then_some(idx) + order.get(idx).copied() } pub(super) fn collapsed_agent_detail_target_at( @@ -482,6 +483,23 @@ impl AppState { && row < rect.y + rect.height } + pub(super) fn on_spaces_sort_toggle(&self, col: u16, row: u16) -> bool { + if self.sidebar_collapsed { + return false; + } + + let (ws_area, _) = crate::ui::expanded_sidebar_sections( + self.view.sidebar_rect, + self.sidebar_section_split, + ); + let rect = crate::ui::spaces_sort_toggle_rect(ws_area, self.spaces_sort); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + pub(super) fn agent_detail_target_at( &self, row: u16, @@ -530,7 +548,7 @@ mod tests { use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path}; use crate::{ - app::state::{AgentPanelSort, DragTarget, Mode}, + app::state::{AgentPanelSort, DragTarget, Mode, SpacesSort}, config::SidebarCollapsedModeConfig, detect::{Agent, AgentState}, workspace::Workspace, @@ -867,6 +885,30 @@ mod tests { assert_eq!(app.state.agent_panel_scroll, 0); } + #[test] + fn clicking_spaces_toggle_switches_sort() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.workspace_scroll = 3; + + let (ws_area, _) = crate::ui::expanded_sidebar_sections( + app.state.view.sidebar_rect, + app.state.sidebar_section_split, + ); + let toggle = crate::ui::spaces_sort_toggle_rect(ws_area, app.state.spaces_sort); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert_eq!(app.state.spaces_sort, SpacesSort::Priority); + assert_eq!(app.state.workspace_scroll, 0); + } + #[test] fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { let mut app = app_for_mouse_test(); @@ -1137,6 +1179,51 @@ mod tests { ); } + #[test] + fn clicking_collapsed_workspace_glance_uses_priority_spaces_sort() { + let mut app = app_for_mouse_test(); + let idle = Workspace::test_new("idle"); + let idle_pane = idle.tabs[0].root_pane; + let working = Workspace::test_new("working"); + let working_pane = working.tabs[0].root_pane; + let blocked = Workspace::test_new("blocked"); + let blocked_pane = blocked.tabs[0].root_pane; + + app.state.workspaces = vec![idle, working, blocked]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.sidebar_collapsed = true; + app.state.spaces_sort = SpacesSort::Priority; + app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20); + app.state.view.terminal_area = Rect::new(4, 0, 80, 20); + + let set_state = |app: &mut crate::app::App, ws_idx: usize, pane_id, state| { + let terminal_id = app.state.workspaces[ws_idx].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.state.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(Agent::Claude); + terminal.state = state; + }; + set_state(&mut app, 0, idle_pane, AgentState::Idle); + set_state(&mut app, 1, working_pane, AgentState::Working); + set_state(&mut app, 2, blocked_pane, AgentState::Blocked); + + let (ws_area, _, _) = + crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect); + // Top glance row is the blocked workspace (idx 2) under priority sort. + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + ws_area.x, + ws_area.y, + )); + + assert_eq!(app.state.active, Some(2)); + assert_eq!(app.state.selected, 2); + } + #[test] fn clicking_collapsed_sidebar_toggle_expands_sidebar() { let mut app = app_for_mouse_test(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 49a905ae32..79de53c5ce 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -256,6 +256,13 @@ fn agent_panel_sort_from_config( } } +fn spaces_sort_from_config(sort: crate::config::SpacesSortConfig) -> state::SpacesSort { + match sort { + crate::config::SpacesSortConfig::Manual => state::SpacesSort::Manual, + crate::config::SpacesSortConfig::Priority => state::SpacesSort::Priority, + } +} + /// Parse the configured agent name list into a deduplicated set of `Agent` /// values. Unknown agent names are silently dropped so a typo cannot disable /// other valid entries. @@ -476,6 +483,7 @@ impl App { }; let agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + let spaces_sort = spaces_sort_from_config(config.ui.spaces_sort); // Validate sidebar bounds before they reach any `u16::clamp(min, max)` // call: `clamp` panics when `min > max`. On bad config, fall back to @@ -630,6 +638,7 @@ impl App { sidebar_collapsed_mode: config.ui.sidebar_collapsed_mode, sidebar_section_split, agent_panel_sort, + spaces_sort, status_indicators: config.ui.status_indicators, agent_view_override: None, sidebar_agents: config.ui.sidebar.agents.clone(), @@ -1501,6 +1510,7 @@ impl App { self.configure_window_title(&config.ui.window_title); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); + self.state.spaces_sort = spaces_sort_from_config(config.ui.spaces_sort); self.state.status_indicators = config.ui.status_indicators; self.state.sidebar_agents = config.ui.sidebar.agents.clone(); self.state.sidebar_spaces = config.ui.sidebar.spaces.clone(); @@ -2738,6 +2748,17 @@ mod tests { assert_eq!(app.state.agent_panel_sort, state::AgentPanelSort::Priority); } + #[test] + fn startup_uses_configured_spaces_sort() { + let mut config = Config::default(); + config.ui.spaces_sort = crate::config::SpacesSortConfig::Priority; + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + + let app = App::new(&config, true, None, api_rx, crate::api::EventHub::default()); + + assert_eq!(app.state.spaces_sort, state::SpacesSort::Priority); + } + #[test] fn startup_uses_configured_sidebar_state() { let mut config = Config::default(); @@ -3586,6 +3607,28 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + #[test] + fn save_spaces_sort_persists_then_applies_live_config() { + let _guard = config_env_lock().lock().unwrap(); + let path = temp_config_path("save-spaces-sort"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "onboarding = false\n").unwrap(); + std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); + + let mut app = test_app(); + assert_eq!(app.state.spaces_sort, state::SpacesSort::Manual); + + app.save_spaces_sort(state::SpacesSort::Priority); + + assert_eq!(app.state.spaces_sort, state::SpacesSort::Priority); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("spaces_sort = \"priority\"")); + assert!(app.state.config_diagnostic.is_none()); + + std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); + } + #[test] fn save_agent_panel_sort_persists_then_applies_live_config() { let _guard = config_env_lock().lock().unwrap(); diff --git a/src/app/state.rs b/src/app/state.rs index ea550472ac..9166c84b2b 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -991,6 +991,17 @@ pub enum AgentPanelSort { Priority, } +/// How entries in the sidebar spaces section are ordered. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SpacesSort { + /// Manual order (the order in which spaces/workspaces were added or + /// reordered by the user). This is the default behavior. + #[default] + Manual, + /// Sort by agent attention priority, most urgent first. + Priority, +} + // --------------------------------------------------------------------------- // Settings UI state // --------------------------------------------------------------------------- @@ -1440,6 +1451,8 @@ pub struct AppState { /// Ratio of sidebar height allocated to the workspaces section. pub sidebar_section_split: f32, pub agent_panel_sort: AgentPanelSort, + /// How entries in the sidebar spaces section are ordered. + pub spaces_sort: SpacesSort, pub status_indicators: crate::config::StatusIndicatorStyle, /// Transient session-wide projection override for the built-in Agents view. pub agent_view_override: Option, @@ -1811,6 +1824,7 @@ impl AppState { sidebar_collapsed_mode: crate::config::SidebarCollapsedModeConfig::Compact, sidebar_section_split: 0.5, agent_panel_sort: AgentPanelSort::Spaces, + spaces_sort: SpacesSort::Manual, status_indicators: crate::config::StatusIndicatorStyle::Dots, agent_view_override: None, sidebar_agents: crate::config::AgentsSidebarConfig::default(), diff --git a/src/config.rs b/src/config.rs index 7e7aecd215..ef2f0f6378 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,7 +23,7 @@ pub use self::{ model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, ConfigReloadStatus, HostCursorModeConfig, NewTerminalCwdConfig, ShellModeConfig, - SidebarCollapsedModeConfig, StatusIndicatorStyle, TabBarPositionConfig, + SidebarCollapsedModeConfig, SpacesSortConfig, StatusIndicatorStyle, TabBarPositionConfig, ToastClipboardPosition, ToastConfig, ToastDelivery, ToastHerdrPosition, UpdateChannelConfig, MAX_TOAST_DELAY_SECONDS, }, diff --git a/src/config/model.rs b/src/config/model.rs index 6d6083ad77..8a3170e5cc 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -106,6 +106,23 @@ impl AgentPanelSortConfig { } } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpacesSortConfig { + #[default] + Manual, + Priority, +} + +impl SpacesSortConfig { + pub fn as_str(self) -> &'static str { + match self { + Self::Manual => "manual", + Self::Priority => "priority", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] enum LegacyAgentPanelScopeConfig { @@ -895,6 +912,8 @@ pub struct UiConfig { pub window_title: String, /// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces". pub agent_panel_sort: AgentPanelSortConfig, + /// Spaces sidebar ordering. Saved values are "manual" or "priority". Default: "manual". + pub spaces_sort: SpacesSortConfig, /// Retired setting that Herdr wrote before the workspace filter was removed. #[serde(rename = "agent_panel_scope")] _legacy_agent_panel_scope: Option, @@ -1113,6 +1132,7 @@ impl Default for UiConfig { tab_bar_right_separator: " ".into(), window_title: super::window_title::default_window_title(), agent_panel_sort: AgentPanelSortConfig::Spaces, + spaces_sort: SpacesSortConfig::Manual, _legacy_agent_panel_scope: None, status_indicators: StatusIndicatorStyle::Dots, sidebar: SidebarConfig::default(), diff --git a/src/ui.rs b/src/ui.rs index 8adc31ef3b..9550bf52e1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -81,10 +81,11 @@ 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_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, + spaces_sort_toggle_rect, workspace_drop_slots, workspace_group_chevron_rect, + workspace_index_order, + workspace_list_entries, workspace_list_entries_expanded, workspace_list_rect, + workspace_list_scroll_metrics, workspace_list_scrollbar_rect, workspace_parent_group_state, + AgentPanelEntry, WorkspaceListEntry, }, }; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 252fc50790..4ca2f67e13 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -12,7 +12,7 @@ use self::tokens::{ResolvedToken, ResolvedTokenKind, SpaceTokenContext}; use super::scrollbar::{render_scrollbar, should_show_scrollbar}; use super::status::{state_icon, state_label, state_label_color}; use super::text::{display_width, display_width_u16, truncate_end}; -use crate::app::state::{AgentPanelSort, Palette}; +use crate::app::state::{AgentPanelSort, Palette, SpacesSort}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; use crate::terminal::TerminalRuntimeRegistry; @@ -86,23 +86,34 @@ fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { } pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { - agent_panel_header_label_rect(area, agent_panel_sort_label(sort)) + agent_panel_header_label_rect(area, agent_panel_sort_label(sort), 1) } -fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect { - if area.width == 0 || area.height < 2 { +fn agent_panel_header_label_rect(area: Rect, label: &str, row_offset: u16) -> Rect { + if area.width == 0 || area.height <= row_offset { return Rect::default(); } let width = display_width_u16(label).min(area.width); Rect::new( area.x + area.width.saturating_sub(width), - area.y + 1, + area.y + row_offset, width, 1, ) } +fn spaces_sort_label(sort: SpacesSort) -> &'static str { + match sort { + SpacesSort::Manual => "manual", + SpacesSort::Priority => "priority", + } +} + +pub(crate) fn spaces_sort_toggle_rect(area: Rect, sort: SpacesSort) -> Rect { + agent_panel_header_label_rect(area, spaces_sort_label(sort), 0) +} + fn active_agent_view_label(app: &AppState) -> Option<&str> { app.agent_view_override .as_ref() @@ -248,6 +259,31 @@ fn workspace_attention_priority(state: AgentState, seen: bool) -> u8 { } } +fn workspace_priority_rank(app: &AppState, ws_idx: usize) -> u8 { + let Some(ws) = app.workspaces.get(ws_idx) else { + return 0; + }; + let (state, seen) = ws.aggregate_state(&app.terminals); + workspace_attention_priority(state, seen) +} + +/// Workspace indices in sidebar display order. In `Priority` sort mode the +/// order follows agent attention (most urgent first); otherwise natural index +/// order. Shared by the expanded list and the collapsed sidebar glance. +pub(crate) fn workspace_index_order(app: &AppState) -> Vec { + if matches!(app.spaces_sort, SpacesSort::Priority) { + let mut order = (0..app.workspaces.len()).collect::>(); + order.sort_by(|&a, &b| { + workspace_priority_rank(app, b) + .cmp(&workspace_priority_rank(app, a)) + .then(a.cmp(&b)) + }); + order + } else { + (0..app.workspaces.len()).collect() + } +} + fn space_aggregate_state(app: &AppState, key: &str) -> (AgentState, bool) { app.workspaces .iter() @@ -373,7 +409,9 @@ fn workspace_list_entries_inner(app: &AppState, force_expanded: bool) -> Vec::new(); let mut entries = Vec::new(); - for (ws_idx, ws) in app.workspaces.iter().enumerate() { + let order = workspace_index_order(app); + for ws_idx in order { + let ws = &app.workspaces[ws_idx]; let Some(space) = ws .worktree_space() .filter(|space| grouped_keys.contains(&space.key)) @@ -778,15 +816,16 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: return; } - for (visible_idx, ws) in app.workspaces.iter().enumerate() { + for (visible_idx, ws_idx) in workspace_index_order(app).iter().enumerate() { let y = ws_area.y + visible_idx as u16; if y >= ws_area.y + ws_area.height { break; } + let ws = &app.workspaces[*ws_idx]; let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); - let is_selected = visible_idx == app.selected && is_navigating; - let is_active = Some(visible_idx) == app.active; + let is_selected = *ws_idx == app.selected && is_navigating; + let is_active = Some(*ws_idx) == app.active; let row_style = if is_selected { Style::default().bg(p.surface0) } else if is_active { @@ -1228,6 +1267,18 @@ fn render_workspace_list( )])), Rect::new(area.x, area.y, area.width, 1), ); + let sort_label = spaces_sort_label(app.spaces_sort); + let sort_rect = spaces_sort_toggle_rect(area, app.spaces_sort); + if sort_rect != Rect::default() { + frame.render_widget( + Paragraph::new(Span::styled( + sort_label, + Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + sort_rect, + ); + } } let metrics = workspace_list_scroll_metrics(app, area); @@ -1447,7 +1498,7 @@ fn render_agent_detail( ); let control_label = active_agent_view_label(app) .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort)); - let toggle_rect = agent_panel_header_label_rect(area, control_label); + let toggle_rect = agent_panel_header_label_rect(area, control_label, 1); if toggle_rect != Rect::default() { let color = if app.agent_view_override.is_some() { p.accent @@ -2204,6 +2255,61 @@ rows = [[{ token = "git_status", fg = "#123456" }]] assert_eq!(buffer[(detail_area.x, detail_area.y + 1)].symbol(), "2"); } + #[test] + fn collapsed_sidebar_glance_follows_priority_spaces_sort() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + Workspace::test_new("idle"), + Workspace::test_new("working"), + Workspace::test_new("blocked"), + ]; + app.ensure_test_terminals(); + + let mut set_state = |ws_idx: usize, state: crate::detect::AgentState| { + let ws = &app.workspaces[ws_idx]; + let pane = ws.tabs[0].root_pane; + let terminal_id = ws.terminal_id(pane).expect("pane terminal").clone(); + let mut terminal = crate::terminal::TerminalState::new( + terminal_id.clone(), + std::path::PathBuf::from("/tmp"), + ); + terminal.state = state; + app.terminals.insert(terminal_id, terminal); + }; + set_state(0, crate::detect::AgentState::Idle); + set_state(1, crate::detect::AgentState::Working); + set_state(2, crate::detect::AgentState::Blocked); + + // Manual (default) keeps natural order. + app.spaces_sort = SpacesSort::Manual; + assert_eq!(workspace_index_order(&app), vec![0, 1, 2]); + + // Priority sorts most urgent first: blocked (2), working (1), idle (0). + app.spaces_sort = SpacesSort::Priority; + assert_eq!(workspace_index_order(&app), vec![2, 1, 0]); + + // The selected blocked workspace is rendered at the top glance row and + // numbered 1, so the glance is not purely numeric. + app.active = None; + app.selected = 2; + app.mode = Mode::Navigate; + app.palette.sidebar_bg = ratatui::style::Color::Rgb(12, 34, 56); + let area = Rect::new(0, 0, 4, 12); + let (ws_area, _, _) = collapsed_sidebar_sections(area); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)) + .expect("test terminal should initialize"); + terminal + .draw(|frame| render_sidebar_collapsed(&app, frame, area)) + .expect("collapsed sidebar should render"); + + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(ws_area.x, ws_area.y)].symbol(), "1"); + assert_eq!(buffer[(ws_area.x, ws_area.y + 1)].symbol(), "2"); + assert_eq!(buffer[(ws_area.x, ws_area.y + 2)].symbol(), "3"); + assert_eq!(buffer[(ws_area.x, ws_area.y)].bg, app.palette.surface0); + assert_eq!(buffer[(ws_area.x, ws_area.y + 2)].bg, app.palette.sidebar_bg); + } + /// Two agent panes in one workspace plus a second workspace, so the /// assertions can tell pane-level highlighting apart from workspace-level. fn collapsed_agent_app() -> (crate::app::state::AppState, PaneId, PaneId) { @@ -2587,6 +2693,40 @@ rows = [[{ token = "git_status", fg = "#123456" }]] .expect("workspace list should render"); } + #[test] + fn spaces_header_keeps_title_and_sort_toggle_on_the_same_row() { + let mut app = crate::app::state::AppState::test_new(); + app.workspaces = vec![Workspace::test_new("test")]; + app.active = Some(0); + app.selected = 0; + app.mode = Mode::Terminal; + app.view.workspace_card_areas = vec![crate::app::state::WorkspaceCardArea { + ws_idx: 0, + rect: Rect::new(0, 2, 20, 2), + indented: false, + }]; + + let mut terminal = Terminal::new(TestBackend::new(20, 6)).expect("test terminal"); + let runtimes = crate::terminal::TerminalRuntimeRegistry::new(); + terminal + .draw(|frame| { + render_workspace_list(&app, &runtimes, frame, Rect::new(0, 0, 20, 6), false) + }) + .expect("workspace list should render"); + + let buffer = terminal.backend().buffer(); + let header_row = row_text(buffer, 0, 20); + assert!( + header_row.contains("spaces") && header_row.contains("manual"), + "expected title and sort toggle on the same header row, got {header_row:?}" + ); + let second_row = row_text(buffer, 1, 20); + assert!( + !second_row.contains("manual"), + "sort toggle must not sit on its own row below the title, got {second_row:?}" + ); + } + fn workspace_with_worktree_space( name: &str, key: Option<&str>, @@ -2991,6 +3131,71 @@ rows = [[{ token = "git_status", fg = "#123456" }]] ); } + #[test] + fn workspace_list_priority_sort_orders_by_attention() { + let mut app = AppState::test_new(); + let ws_idle = Workspace::test_new("idle"); + let ws_working = Workspace::test_new("working"); + let ws_blocked = Workspace::test_new("blocked"); + // Manual order is reversed relative to attention so the sort is visible. + app.workspaces = vec![ws_idle, ws_working, ws_blocked]; + + let mut set_state = |ws_idx: usize, state: crate::detect::AgentState| { + let ws = &app.workspaces[ws_idx]; + let pane = ws.tabs[0].root_pane; + let terminal_id = ws.terminal_id(pane).expect("pane terminal").clone(); + let mut terminal = crate::terminal::TerminalState::new( + terminal_id.clone(), + std::path::PathBuf::from("/tmp"), + ); + terminal.state = state; + app.terminals.insert(terminal_id, terminal); + }; + set_state(1, crate::detect::AgentState::Working); + set_state(2, crate::detect::AgentState::Blocked); + set_state(0, crate::detect::AgentState::Idle); + + // Manual (default) keeps workspace order. + app.spaces_sort = SpacesSort::Manual; + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false + }, + WorkspaceListEntry::Workspace { + ws_idx: 2, + indented: false + }, + ] + ); + + // Priority sorts most urgent first: blocked, working, idle. + app.spaces_sort = SpacesSort::Priority; + assert_eq!( + workspace_list_entries(&app), + vec![ + WorkspaceListEntry::Workspace { + ws_idx: 2, + indented: false + }, + WorkspaceListEntry::Workspace { + ws_idx: 1, + indented: false + }, + WorkspaceListEntry::Workspace { + ws_idx: 0, + indented: false + }, + ] + ); + } + #[test] fn workspace_list_entries_leave_single_git_and_non_git_workspaces_flat() { let mut app = AppState::test_new(); diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index dd6277116c..afb6254c82 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -304,7 +304,7 @@ mod tests { assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); assert_eq!( frame_digest(&frame), - "f692e425877ef32cd0f3435dd8e252f33fff4e6dd5088a9324620895a6bd4c13" + "460e8ae1a77fc86c73c44d733da882d12f10e772d6e9010fe0c09d0e93996f54" ); }