mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
feat: add resizable sidebar sections
This commit is contained in:
@@ -106,6 +106,12 @@ impl AppState {
|
||||
if idx < self.workspaces.len() {
|
||||
self.active = Some(idx);
|
||||
self.selected = idx;
|
||||
if matches!(
|
||||
self.agent_panel_scope,
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
) {
|
||||
self.agent_panel_scroll = 0;
|
||||
}
|
||||
self.ensure_workspace_visible(idx);
|
||||
if let Some(ws) = self.workspaces.get_mut(idx) {
|
||||
ws.switch_tab(ws.active_tab);
|
||||
|
||||
+308
-25
@@ -1431,7 +1431,17 @@ impl AppState {
|
||||
if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
|
||||
return Rect::default();
|
||||
}
|
||||
crate::ui::workspace_list_rect(sidebar)
|
||||
crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split)
|
||||
}
|
||||
|
||||
fn agent_panel_rect(&self) -> Rect {
|
||||
let sidebar = self.view.sidebar_rect;
|
||||
if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
|
||||
return Rect::default();
|
||||
}
|
||||
let (_, detail_area) =
|
||||
crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split);
|
||||
detail_area
|
||||
}
|
||||
|
||||
fn workspace_list_scrollbar_target_at(
|
||||
@@ -1498,6 +1508,61 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_panel_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
|
||||
let area = self.agent_panel_rect();
|
||||
let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
|
||||
let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
|
||||
if col < track.x
|
||||
|| col >= track.x + track.width
|
||||
|| row < track.y
|
||||
|| row >= track.y + track.height
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
|
||||
Some(ScrollbarClickTarget::Thumb { grab_row_offset })
|
||||
} else {
|
||||
Some(ScrollbarClickTarget::Track {
|
||||
offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_panel_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
|
||||
let area = self.agent_panel_rect();
|
||||
let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
|
||||
let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
|
||||
Some(crate::ui::scrollbar_offset_from_drag_row(
|
||||
metrics,
|
||||
track,
|
||||
row,
|
||||
grab_row_offset,
|
||||
))
|
||||
}
|
||||
|
||||
fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) {
|
||||
let area = self.agent_panel_rect();
|
||||
let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
|
||||
self.agent_panel_scroll = metrics
|
||||
.max_offset_from_bottom
|
||||
.saturating_sub(offset_from_bottom);
|
||||
}
|
||||
|
||||
fn scroll_agent_panel(&mut self, delta: i16) {
|
||||
let area = self.agent_panel_rect();
|
||||
let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom;
|
||||
if delta.is_negative() {
|
||||
self.agent_panel_scroll = self
|
||||
.agent_panel_scroll
|
||||
.saturating_sub(delta.unsigned_abs() as usize);
|
||||
} else {
|
||||
self.agent_panel_scroll = self
|
||||
.agent_panel_scroll
|
||||
.saturating_add(delta as usize)
|
||||
.min(max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_footer_rect(&self) -> Rect {
|
||||
let ws_area = self.workspace_list_rect();
|
||||
if ws_area == Rect::default() {
|
||||
@@ -1923,6 +1988,14 @@ impl AppState {
|
||||
return None;
|
||||
}
|
||||
|
||||
if self.on_sidebar_section_divider(mouse.column, mouse.row) {
|
||||
self.drag = Some(DragState {
|
||||
target: DragTarget::SidebarSectionDivider,
|
||||
});
|
||||
self.set_sidebar_section_split(mouse.row);
|
||||
return None;
|
||||
}
|
||||
|
||||
if !in_sidebar {
|
||||
if let Some(border) = self.find_border_at(mouse.column, mouse.row) {
|
||||
self.drag = Some(DragState {
|
||||
@@ -2035,6 +2108,23 @@ impl AppState {
|
||||
AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
|
||||
AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
|
||||
};
|
||||
self.agent_panel_scroll = 0;
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(target) =
|
||||
self.agent_panel_scrollbar_target_at(mouse.column, mouse.row)
|
||||
{
|
||||
match target {
|
||||
ScrollbarClickTarget::Thumb { grab_row_offset } => {
|
||||
self.drag = Some(DragState {
|
||||
target: DragTarget::AgentPanelScrollbar { grab_row_offset },
|
||||
});
|
||||
}
|
||||
ScrollbarClickTarget::Track { offset_from_bottom } => {
|
||||
self.set_agent_panel_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -2125,6 +2215,13 @@ impl AppState {
|
||||
self.set_workspace_list_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
DragTarget::AgentPanelScrollbar { grab_row_offset } => {
|
||||
if let Some(offset_from_bottom) =
|
||||
self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset)
|
||||
{
|
||||
self.set_agent_panel_offset_from_bottom(offset_from_bottom);
|
||||
}
|
||||
}
|
||||
DragTarget::PaneSplit {
|
||||
path,
|
||||
direction,
|
||||
@@ -2161,6 +2258,9 @@ impl AppState {
|
||||
DragTarget::SidebarDivider => {
|
||||
self.set_manual_sidebar_width(mouse.column);
|
||||
}
|
||||
DragTarget::SidebarSectionDivider => {
|
||||
self.set_sidebar_section_split(mouse.row);
|
||||
}
|
||||
DragTarget::ReleaseNotesScrollbar { .. }
|
||||
| DragTarget::KeybindHelpScrollbar { .. } => {}
|
||||
}
|
||||
@@ -2224,10 +2324,19 @@ impl AppState {
|
||||
}
|
||||
|
||||
MouseEventKind::ScrollUp if in_sidebar => {
|
||||
if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics(
|
||||
self,
|
||||
self.workspace_list_rect(),
|
||||
)) {
|
||||
let agent_area = self.agent_panel_rect();
|
||||
let over_agent_panel = agent_area != Rect::default()
|
||||
&& mouse.row >= agent_area.y
|
||||
&& mouse.row < agent_area.y + agent_area.height;
|
||||
if over_agent_panel {
|
||||
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
|
||||
self, agent_area,
|
||||
)) {
|
||||
self.scroll_agent_panel(-1);
|
||||
}
|
||||
} else if crate::ui::should_show_scrollbar(
|
||||
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
|
||||
) {
|
||||
self.scroll_workspace_list(-1);
|
||||
} else if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
@@ -2235,10 +2344,19 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
MouseEventKind::ScrollDown if in_sidebar => {
|
||||
if crate::ui::should_show_scrollbar(crate::ui::workspace_list_scroll_metrics(
|
||||
self,
|
||||
self.workspace_list_rect(),
|
||||
)) {
|
||||
let agent_area = self.agent_panel_rect();
|
||||
let over_agent_panel = agent_area != Rect::default()
|
||||
&& mouse.row >= agent_area.y
|
||||
&& mouse.row < agent_area.y + agent_area.height;
|
||||
if over_agent_panel {
|
||||
if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
|
||||
self, agent_area,
|
||||
)) {
|
||||
self.scroll_agent_panel(1);
|
||||
}
|
||||
} else if crate::ui::should_show_scrollbar(
|
||||
crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
|
||||
) {
|
||||
self.scroll_workspace_list(1);
|
||||
} else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
|
||||
self.selected += 1;
|
||||
@@ -2340,6 +2458,32 @@ impl AppState {
|
||||
width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
|
||||
}
|
||||
|
||||
fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool {
|
||||
if self.sidebar_collapsed {
|
||||
return false;
|
||||
}
|
||||
let rect = crate::ui::sidebar_section_divider_rect(
|
||||
self.view.sidebar_rect,
|
||||
self.sidebar_section_split,
|
||||
);
|
||||
rect.width > 0
|
||||
&& col >= rect.x
|
||||
&& col < rect.x + rect.width
|
||||
&& row >= rect.y
|
||||
&& row < rect.y + rect.height
|
||||
}
|
||||
|
||||
fn set_sidebar_section_split(&mut self, row: u16) {
|
||||
let sidebar = self.view.sidebar_rect;
|
||||
let content_height = sidebar.height;
|
||||
if content_height < 6 {
|
||||
return;
|
||||
}
|
||||
let relative_y = row.saturating_sub(sidebar.y);
|
||||
let ratio = (relative_y as f32) / (content_height as f32);
|
||||
self.sidebar_section_split = ratio.clamp(0.1, 0.9);
|
||||
}
|
||||
|
||||
/// Find which workspace index a sidebar row belongs to (two-section layout).
|
||||
fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
|
||||
self.view
|
||||
@@ -2487,7 +2631,10 @@ impl AppState {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect);
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(
|
||||
self.view.sidebar_rect,
|
||||
self.sidebar_section_split,
|
||||
);
|
||||
let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope);
|
||||
rect.width > 0
|
||||
&& col >= rect.x
|
||||
@@ -2501,23 +2648,33 @@ impl AppState {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect);
|
||||
if detail_area.height < 4
|
||||
|| row < detail_area.y + 3
|
||||
|| row >= detail_area.y + detail_area.height
|
||||
{
|
||||
let detail_area = self.agent_panel_rect();
|
||||
let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
|
||||
let body = crate::ui::agent_panel_body_rect(
|
||||
detail_area,
|
||||
crate::ui::should_show_scrollbar(metrics),
|
||||
);
|
||||
if body.height < 2 || row < body.y || row >= body.y + body.height {
|
||||
return None;
|
||||
}
|
||||
|
||||
let relative_row = row - (detail_area.y + 3);
|
||||
let entry_height = 3;
|
||||
if relative_row % entry_height == 2 {
|
||||
return None;
|
||||
let mut row_y = body.y;
|
||||
for detail in crate::ui::agent_panel_entries(self)
|
||||
.into_iter()
|
||||
.skip(self.agent_panel_scroll)
|
||||
{
|
||||
if row_y.saturating_add(1) >= body.y + body.height {
|
||||
break;
|
||||
}
|
||||
if row == row_y || row == row_y + 1 {
|
||||
return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
|
||||
}
|
||||
row_y = row_y.saturating_add(2);
|
||||
if row_y < body.y + body.height {
|
||||
row_y = row_y.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let detail_idx = (relative_row / entry_height) as usize;
|
||||
let details = crate::ui::agent_panel_entries(self);
|
||||
let detail = details.get(detail_idx)?;
|
||||
Some((detail.ws_idx, detail.tab_idx, detail.pane_id))
|
||||
None
|
||||
}
|
||||
|
||||
fn screen_rect(&self) -> Rect {
|
||||
@@ -3543,8 +3700,12 @@ mod tests {
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.agent_panel_scroll = 3;
|
||||
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect);
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(
|
||||
app.state.view.sidebar_rect,
|
||||
app.state.sidebar_section_split,
|
||||
);
|
||||
let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope);
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
@@ -3553,6 +3714,7 @@ mod tests {
|
||||
));
|
||||
|
||||
assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces);
|
||||
assert_eq!(app.state.agent_panel_scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3580,7 +3742,10 @@ mod tests {
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
|
||||
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect);
|
||||
let (_, detail_area) = crate::ui::expanded_sidebar_sections(
|
||||
app.state.view.sidebar_rect,
|
||||
app.state.sidebar_section_split,
|
||||
);
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
detail_area.x + 2,
|
||||
@@ -3596,6 +3761,102 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let first_pane = ws.tabs[0].root_pane;
|
||||
ws.tabs[0]
|
||||
.panes
|
||||
.get_mut(&first_pane)
|
||||
.unwrap()
|
||||
.detected_agent = Some(Agent::Pi);
|
||||
|
||||
for (tab_name, agent) in [
|
||||
("logs", Agent::Claude),
|
||||
("review", Agent::Codex),
|
||||
("ops", Agent::Gemini),
|
||||
] {
|
||||
let tab_idx = ws.test_add_tab(Some(tab_name));
|
||||
let pane_id = ws.tabs[tab_idx].root_pane;
|
||||
ws.tabs[tab_idx]
|
||||
.panes
|
||||
.get_mut(&pane_id)
|
||||
.unwrap()
|
||||
.detected_agent = Some(agent);
|
||||
}
|
||||
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
|
||||
let detail_area = app.state.agent_panel_rect();
|
||||
assert!(crate::ui::should_show_scrollbar(
|
||||
crate::ui::agent_panel_scroll_metrics(&app.state, detail_area)
|
||||
));
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::ScrollDown,
|
||||
detail_area.x + 1,
|
||||
detail_area.y + 4,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.agent_panel_scroll, 1);
|
||||
assert_eq!(app.state.selected, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let first_pane = ws.tabs[0].root_pane;
|
||||
ws.tabs[0]
|
||||
.panes
|
||||
.get_mut(&first_pane)
|
||||
.unwrap()
|
||||
.detected_agent = Some(Agent::Pi);
|
||||
|
||||
let second_tab = ws.test_add_tab(Some("logs"));
|
||||
let second_pane = ws.tabs[second_tab].root_pane;
|
||||
ws.tabs[second_tab]
|
||||
.panes
|
||||
.get_mut(&second_pane)
|
||||
.unwrap()
|
||||
.detected_agent = Some(Agent::Claude);
|
||||
|
||||
for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] {
|
||||
let tab_idx = ws.test_add_tab(Some(tab_name));
|
||||
let pane_id = ws.tabs[tab_idx].root_pane;
|
||||
ws.tabs[tab_idx]
|
||||
.panes
|
||||
.get_mut(&pane_id)
|
||||
.unwrap()
|
||||
.detected_agent = Some(agent);
|
||||
}
|
||||
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.agent_panel_scroll = 1;
|
||||
|
||||
let detail_area = app.state.agent_panel_rect();
|
||||
let body = crate::ui::agent_panel_body_rect(detail_area, true);
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
body.x + 1,
|
||||
body.y,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.workspaces[0].active_tab, second_tab);
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].tabs[second_tab].layout.focused(),
|
||||
second_pane
|
||||
);
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() {
|
||||
let mut app = app_for_mouse_test();
|
||||
@@ -3775,6 +4036,28 @@ mod tests {
|
||||
assert_eq!(app.state.sidebar_width, 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_sidebar_section_divider_sets_split_ratio() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let divider = crate::ui::sidebar_section_divider_rect(
|
||||
app.state.view.sidebar_rect,
|
||||
app.state.sidebar_section_split,
|
||||
);
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
divider.x + 1,
|
||||
divider.y,
|
||||
));
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Drag(MouseButton::Left),
|
||||
divider.x + 1,
|
||||
divider.y + 4,
|
||||
));
|
||||
|
||||
assert!(app.state.sidebar_section_split > 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_sidebar_divider_resets_default_width() {
|
||||
let mut app = app_for_mouse_test();
|
||||
|
||||
+48
-40
@@ -157,54 +157,59 @@ impl App {
|
||||
let render_dirty = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Try to restore previous session
|
||||
let (workspaces, active, selected, agent_panel_scope, sidebar_width) = if no_session {
|
||||
(
|
||||
Vec::new(),
|
||||
None,
|
||||
0,
|
||||
state::AgentPanelScope::CurrentWorkspace,
|
||||
config.ui.sidebar_width,
|
||||
)
|
||||
} else if let Some(snap) = crate::persist::load() {
|
||||
let ws = crate::persist::restore(
|
||||
&snap,
|
||||
24,
|
||||
80,
|
||||
config.advanced.scrollback_limit_bytes,
|
||||
event_tx.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
);
|
||||
if ws.is_empty() {
|
||||
info!("session file found but no workspaces restored");
|
||||
let (workspaces, active, selected, agent_panel_scope, sidebar_width, sidebar_section_split) =
|
||||
if no_session {
|
||||
(
|
||||
Vec::new(),
|
||||
None,
|
||||
0,
|
||||
snap.agent_panel_scope,
|
||||
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
|
||||
state::AgentPanelScope::CurrentWorkspace,
|
||||
config.ui.sidebar_width,
|
||||
0.5_f32,
|
||||
)
|
||||
} else if let Some(snap) = crate::persist::load() {
|
||||
let ws = crate::persist::restore(
|
||||
&snap,
|
||||
24,
|
||||
80,
|
||||
config.advanced.scrollback_limit_bytes,
|
||||
event_tx.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
);
|
||||
if ws.is_empty() {
|
||||
info!("session file found but no workspaces restored");
|
||||
(
|
||||
Vec::new(),
|
||||
None,
|
||||
0,
|
||||
snap.agent_panel_scope,
|
||||
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
|
||||
snap.sidebar_section_split.unwrap_or(0.5),
|
||||
)
|
||||
} else {
|
||||
info!(count = ws.len(), "session restored");
|
||||
let active = snap.active.filter(|&i| i < ws.len());
|
||||
let selected = snap.selected.min(ws.len().saturating_sub(1));
|
||||
(
|
||||
ws,
|
||||
active,
|
||||
selected,
|
||||
snap.agent_panel_scope,
|
||||
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
|
||||
snap.sidebar_section_split.unwrap_or(0.5),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
info!(count = ws.len(), "session restored");
|
||||
let active = snap.active.filter(|&i| i < ws.len());
|
||||
let selected = snap.selected.min(ws.len().saturating_sub(1));
|
||||
(
|
||||
ws,
|
||||
active,
|
||||
selected,
|
||||
snap.agent_panel_scope,
|
||||
snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
|
||||
Vec::new(),
|
||||
None,
|
||||
0,
|
||||
state::AgentPanelScope::CurrentWorkspace,
|
||||
config.ui.sidebar_width,
|
||||
0.5_f32,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(
|
||||
Vec::new(),
|
||||
None,
|
||||
0,
|
||||
state::AgentPanelScope::CurrentWorkspace,
|
||||
config.ui.sidebar_width,
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
info!(
|
||||
pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes,
|
||||
@@ -246,6 +251,7 @@ impl App {
|
||||
}),
|
||||
keybind_help: state::KeybindHelpState { scroll: 0 },
|
||||
workspace_scroll: 0,
|
||||
agent_panel_scroll: 0,
|
||||
view: state::ViewState {
|
||||
sidebar_rect: Rect::default(),
|
||||
workspace_card_areas: Vec::new(),
|
||||
@@ -271,6 +277,7 @@ impl App {
|
||||
sidebar_width,
|
||||
sidebar_width_auto: false,
|
||||
sidebar_collapsed: false,
|
||||
sidebar_section_split,
|
||||
agent_panel_scope,
|
||||
confirm_close: config.ui.confirm_close,
|
||||
pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes,
|
||||
@@ -457,6 +464,7 @@ impl App {
|
||||
self.state.selected,
|
||||
self.state.agent_panel_scope,
|
||||
self.state.sidebar_width,
|
||||
self.state.sidebar_section_split,
|
||||
);
|
||||
crate::persist::save(&snap);
|
||||
}
|
||||
|
||||
@@ -476,6 +476,9 @@ pub(crate) enum DragTarget {
|
||||
WorkspaceListScrollbar {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
AgentPanelScrollbar {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
PaneSplit {
|
||||
path: Vec<bool>,
|
||||
direction: Direction,
|
||||
@@ -492,6 +495,7 @@ pub(crate) enum DragTarget {
|
||||
grab_row_offset: u16,
|
||||
},
|
||||
SidebarDivider,
|
||||
SidebarSectionDivider,
|
||||
}
|
||||
|
||||
/// Active mouse drag on a split border or sidebar divider.
|
||||
@@ -580,6 +584,7 @@ pub struct AppState {
|
||||
pub release_notes: Option<ReleaseNotesState>,
|
||||
pub keybind_help: KeybindHelpState,
|
||||
pub workspace_scroll: usize,
|
||||
pub agent_panel_scroll: usize,
|
||||
// View geometry (computed before render, consumed by render + mouse)
|
||||
pub view: ViewState,
|
||||
pub(crate) drag: Option<DragState>,
|
||||
@@ -599,6 +604,8 @@ pub struct AppState {
|
||||
pub sidebar_width: u16,
|
||||
pub sidebar_width_auto: bool,
|
||||
pub sidebar_collapsed: bool,
|
||||
/// Ratio of sidebar height allocated to the workspaces section.
|
||||
pub sidebar_section_split: f32,
|
||||
pub agent_panel_scope: AgentPanelScope,
|
||||
pub confirm_close: bool,
|
||||
pub pane_scrollback_limit_bytes: usize,
|
||||
@@ -684,6 +691,7 @@ impl AppState {
|
||||
release_notes: None,
|
||||
keybind_help: KeybindHelpState { scroll: 0 },
|
||||
workspace_scroll: 0,
|
||||
agent_panel_scroll: 0,
|
||||
view: ViewState {
|
||||
sidebar_rect: Rect::default(),
|
||||
workspace_card_areas: Vec::new(),
|
||||
@@ -709,6 +717,7 @@ impl AppState {
|
||||
sidebar_width: 26,
|
||||
sidebar_width_auto: false,
|
||||
sidebar_collapsed: false,
|
||||
sidebar_section_split: 0.5,
|
||||
agent_panel_scope: AgentPanelScope::CurrentWorkspace,
|
||||
confirm_close: true,
|
||||
pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES,
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct SessionSnapshot {
|
||||
pub agent_panel_scope: crate::app::state::AgentPanelScope,
|
||||
#[serde(default)]
|
||||
pub sidebar_width: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub sidebar_section_split: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -133,6 +135,8 @@ struct RawSessionSnapshot {
|
||||
agent_panel_scope: crate::app::state::AgentPanelScope,
|
||||
#[serde(default)]
|
||||
sidebar_width: Option<u16>,
|
||||
#[serde(default)]
|
||||
sidebar_section_split: Option<f32>,
|
||||
}
|
||||
|
||||
fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> {
|
||||
@@ -147,6 +151,7 @@ fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String>
|
||||
selected: raw.selected,
|
||||
agent_panel_scope: raw.agent_panel_scope,
|
||||
sidebar_width: raw.sidebar_width,
|
||||
sidebar_section_split: raw.sidebar_section_split,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -205,6 +210,7 @@ pub fn capture(
|
||||
selected: usize,
|
||||
agent_panel_scope: crate::app::state::AgentPanelScope,
|
||||
sidebar_width: u16,
|
||||
sidebar_section_split: f32,
|
||||
) -> SessionSnapshot {
|
||||
SessionSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
@@ -213,6 +219,7 @@ pub fn capture(
|
||||
selected,
|
||||
agent_panel_scope,
|
||||
sidebar_width: Some(sidebar_width),
|
||||
sidebar_section_split: Some(sidebar_section_split),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,12 +587,14 @@ mod tests {
|
||||
selected: 0,
|
||||
agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace,
|
||||
sidebar_width: Some(26),
|
||||
sidebar_section_split: Some(0.5),
|
||||
};
|
||||
let json = serde_json::to_string(&snap).unwrap();
|
||||
let restored = parse_snapshot(&json).unwrap();
|
||||
assert!(restored.workspaces.is_empty());
|
||||
assert_eq!(restored.active, None);
|
||||
assert_eq!(restored.sidebar_width, Some(26));
|
||||
assert_eq!(restored.sidebar_section_split, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -651,6 +660,7 @@ mod tests {
|
||||
selected: 0,
|
||||
agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace,
|
||||
sidebar_width: Some(26),
|
||||
sidebar_section_split: Some(0.5),
|
||||
version: SNAPSHOT_VERSION,
|
||||
};
|
||||
|
||||
@@ -674,6 +684,7 @@ mod tests {
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
);
|
||||
assert_eq!(restored.sidebar_width, Some(26));
|
||||
assert_eq!(restored.sidebar_section_split, Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -689,6 +700,7 @@ mod tests {
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
);
|
||||
assert_eq!(snap.sidebar_width, None);
|
||||
assert_eq!(snap.sidebar_section_split, None);
|
||||
assert_eq!(snap.workspaces[0].tabs.len(), 2);
|
||||
assert_eq!(
|
||||
snap.workspaces[1].identity_cwd,
|
||||
@@ -706,6 +718,7 @@ mod tests {
|
||||
snap.agent_panel_scope,
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
);
|
||||
assert_eq!(snap.sidebar_section_split, Some(0.4));
|
||||
assert_eq!(snap.workspaces[0].active_tab, 1);
|
||||
assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2);
|
||||
}
|
||||
@@ -727,6 +740,7 @@ mod tests {
|
||||
crate::app::state::AgentPanelScope::CurrentWorkspace
|
||||
);
|
||||
assert_eq!(restored.sidebar_width, None);
|
||||
assert_eq!(restored.sidebar_section_split, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,26 +37,45 @@ pub(crate) struct AgentPanelEntry {
|
||||
pub seen: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn expanded_sidebar_sections(area: Rect) -> (Rect, Rect) {
|
||||
fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
|
||||
if total_h == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
if total_h < 6 {
|
||||
let ws_h = (total_h + 1) / 2;
|
||||
return (ws_h, total_h.saturating_sub(ws_h));
|
||||
}
|
||||
|
||||
let ratio = split_ratio.clamp(0.1, 0.9);
|
||||
let ws_h = ((total_h as f32) * ratio).round() as u16;
|
||||
let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
|
||||
let detail_h = total_h.saturating_sub(ws_h);
|
||||
(ws_h, detail_h)
|
||||
}
|
||||
|
||||
pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
|
||||
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
|
||||
if content.width == 0 || content.height == 0 {
|
||||
return (Rect::default(), Rect::default());
|
||||
}
|
||||
|
||||
let total_h = content.height as usize;
|
||||
let ws_h = (total_h + 1) / 2;
|
||||
let detail_h = total_h.saturating_sub(ws_h);
|
||||
|
||||
let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
|
||||
let detail_area = Rect::new(
|
||||
content.x,
|
||||
content.y + ws_h as u16,
|
||||
content.width,
|
||||
detail_h as u16,
|
||||
);
|
||||
let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
|
||||
let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
|
||||
let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
|
||||
(ws_area, detail_area)
|
||||
}
|
||||
|
||||
pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
|
||||
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
|
||||
if content.width == 0 || content.height < 6 {
|
||||
return Rect::default();
|
||||
}
|
||||
|
||||
let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
|
||||
Rect::new(content.x, content.y + ws_h, content.width, 1)
|
||||
}
|
||||
|
||||
fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
|
||||
if matches!(
|
||||
app.mode,
|
||||
@@ -228,6 +247,13 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
|
||||
app.workspace_scroll = app
|
||||
.workspace_scroll
|
||||
.min(app.workspaces.len().saturating_sub(1));
|
||||
if !app.sidebar_collapsed {
|
||||
let (_, detail_area) = expanded_sidebar_sections(sidebar_area, app.sidebar_section_split);
|
||||
let max_agent_scroll = agent_panel_scroll_metrics(app, detail_area).max_offset_from_bottom;
|
||||
app.agent_panel_scroll = app.agent_panel_scroll.min(max_agent_scroll);
|
||||
} else {
|
||||
app.agent_panel_scroll = 0;
|
||||
}
|
||||
|
||||
let workspace_card_areas = if app.sidebar_collapsed {
|
||||
Vec::new()
|
||||
@@ -311,6 +337,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
|
||||
const MIN_TAB_WIDTH: u16 = 8;
|
||||
const NEW_TAB_WIDTH: u16 = 3;
|
||||
const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
|
||||
const AGENT_PANEL_HEADER_ROWS: u16 = 3;
|
||||
|
||||
fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
|
||||
if ws.branch().is_some() {
|
||||
@@ -320,15 +347,9 @@ fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_list_rect(area: Rect) -> Rect {
|
||||
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
|
||||
if content.width == 0 || content.height == 0 {
|
||||
return Rect::default();
|
||||
}
|
||||
|
||||
let total_h = content.height as usize;
|
||||
let ws_h = (total_h + 1) / 2;
|
||||
Rect::new(content.x, content.y, content.width, ws_h as u16)
|
||||
pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
|
||||
let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
|
||||
ws_area
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
|
||||
@@ -391,11 +412,66 @@ pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Optio
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
|
||||
if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
|
||||
return Rect::default();
|
||||
}
|
||||
|
||||
let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
|
||||
let body_height = (area.y + area.height).saturating_sub(body_y);
|
||||
let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
|
||||
Rect::new(area.x, body_y, body_width, body_height)
|
||||
}
|
||||
|
||||
fn agent_panel_visible_count(area: Rect) -> usize {
|
||||
let body = agent_panel_body_rect(area, false);
|
||||
if body.width == 0 || body.height < 2 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let mut used_rows = 0u16;
|
||||
let mut visible = 0usize;
|
||||
while used_rows.saturating_add(2) <= body.height {
|
||||
used_rows = used_rows.saturating_add(2);
|
||||
visible += 1;
|
||||
if used_rows < body.height {
|
||||
used_rows = used_rows.saturating_add(1);
|
||||
}
|
||||
}
|
||||
visible
|
||||
}
|
||||
|
||||
pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
|
||||
let viewport_rows = agent_panel_visible_count(area);
|
||||
let total_rows = agent_panel_entries(app).len();
|
||||
let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
|
||||
let offset_from_bottom = total_rows
|
||||
.saturating_sub(app.agent_panel_scroll)
|
||||
.saturating_sub(viewport_rows);
|
||||
|
||||
crate::pane::ScrollMetrics {
|
||||
offset_from_bottom,
|
||||
max_offset_from_bottom,
|
||||
viewport_rows,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
|
||||
let metrics = agent_panel_scroll_metrics(app, area);
|
||||
let body = agent_panel_body_rect(area, true);
|
||||
(should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
|
||||
area.x + area.width.saturating_sub(1),
|
||||
body.y,
|
||||
1,
|
||||
body.height,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn compute_workspace_card_areas(
|
||||
app: &AppState,
|
||||
area: Rect,
|
||||
) -> Vec<crate::app::state::WorkspaceCardArea> {
|
||||
let ws_area = workspace_list_rect(area);
|
||||
let ws_area = workspace_list_rect(area, app.sidebar_section_split);
|
||||
if ws_area == Rect::default() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -760,7 +836,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
buf[(sep_x, y)].set_style(sep_style);
|
||||
}
|
||||
|
||||
let (ws_area, detail_area) = expanded_sidebar_sections(area);
|
||||
let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
|
||||
|
||||
// --- Top section: Workspaces ---
|
||||
render_workspace_list(app, frame, ws_area, is_navigating);
|
||||
@@ -970,22 +1046,18 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut row_y = area.y;
|
||||
|
||||
// Horizontal separator
|
||||
let sep_line = "─".repeat(area.width as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
|
||||
Rect::new(area.x, row_y, area.width, 1),
|
||||
Rect::new(area.x, area.y, area.width, 1),
|
||||
);
|
||||
row_y += 1;
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
" agents",
|
||||
Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
Rect::new(area.x, row_y, area.width, 1),
|
||||
Rect::new(area.x, area.y + 1, area.width, 1),
|
||||
);
|
||||
let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
|
||||
if toggle_rect != Rect::default() {
|
||||
@@ -998,13 +1070,19 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
toggle_rect,
|
||||
);
|
||||
}
|
||||
row_y += 1;
|
||||
|
||||
// Blank line for breathing room
|
||||
row_y += 1;
|
||||
let details = agent_panel_entries(app);
|
||||
let metrics = agent_panel_scroll_metrics(app, area);
|
||||
let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
|
||||
let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
|
||||
if body == Rect::default() {
|
||||
return;
|
||||
}
|
||||
|
||||
for detail in &agent_panel_entries(app) {
|
||||
if row_y + 1 >= area.y + area.height {
|
||||
let mut row_y = body.y;
|
||||
let body_bottom = body.y + body.height;
|
||||
for detail in details.iter().skip(app.agent_panel_scroll) {
|
||||
if row_y.saturating_add(1) >= body_bottom {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1017,7 +1095,7 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
|
||||
|
||||
let primary_label =
|
||||
format_agent_panel_primary_label(detail, area.width.saturating_sub(3) as usize);
|
||||
format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
|
||||
let name_line = Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(icon, icon_style),
|
||||
@@ -1026,14 +1104,10 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(name_line),
|
||||
Rect::new(area.x, row_y, area.width, 1),
|
||||
Rect::new(body.x, row_y, body.width, 1),
|
||||
);
|
||||
row_y += 1;
|
||||
|
||||
if row_y >= area.y + area.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut status_spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(label, status_style),
|
||||
@@ -1044,14 +1118,18 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(status_spans)),
|
||||
Rect::new(area.x, row_y, area.width, 1),
|
||||
Rect::new(body.x, row_y, body.width, 1),
|
||||
);
|
||||
row_y += 1;
|
||||
|
||||
if row_y < area.y + area.height {
|
||||
if row_y < body_bottom {
|
||||
row_y += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(track) = scrollbar_rect {
|
||||
render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
|
||||
@@ -3004,6 +3082,21 @@ mod tests {
|
||||
assert_eq!(label, "agent-bro… · test…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_sidebar_sections_handle_tiny_heights() {
|
||||
let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
|
||||
|
||||
assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
|
||||
assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_section_divider_is_hidden_for_tiny_heights() {
|
||||
let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
|
||||
|
||||
assert_eq!(divider, Rect::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_scrollbar_rect_uses_rightmost_inner_column() {
|
||||
let info = PaneInfo {
|
||||
|
||||
+2
-1
@@ -74,5 +74,6 @@
|
||||
],
|
||||
"active": 0,
|
||||
"selected": 0,
|
||||
"agent_panel_scope": "CurrentWorkspace"
|
||||
"agent_panel_scope": "CurrentWorkspace",
|
||||
"sidebar_section_split": 0.4
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user