Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 34 additions & 23 deletions src/client/shell/resource_sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,23 @@ pub(super) fn render_plugin_resource_panel(
}
}
let palette = &config.palette;
put_text(
buffer,
area.x,
area.y,
area.width,
&"─".repeat(area.width as usize),
Style::default().fg(palette.surface_dim),
);
hits.resource_section_divider = Rect::new(area.x, area.y, area.width, 1.min(area.height));
if area.height < 2 {
return;
let divider_height = u16::from(area.height > 1);
if divider_height > 0 {
put_text(
buffer,
area.x,
area.y,
area.width,
&"─".repeat(area.width as usize),
Style::default().fg(palette.surface_dim),
);
}
hits.resource_section_divider = Rect::new(area.x, area.y, area.width, divider_height);
let marker = if collapsed { "▸" } else { "▾" };
let count = resource_count_label(resource);
let title = format!(" {marker} {} · {count}", resource.label);
let header = Rect::new(area.x, area.y + 1, area.width, 1);
// A one-row collapsed (or height-constrained) panel still needs its header.
let header = Rect::new(area.x, area.y + divider_height, area.width, 1);
hits.resource_header = header;
put_text(
buffer,
Expand All @@ -60,11 +61,13 @@ pub(super) fn render_plugin_resource_panel(
.add_modifier(Modifier::BOLD),
);
let refresh_label = if resource.refreshing { "…" } else { "↻" };
let refresh_width = display_width(refresh_label) as u16;
let refresh_width = (display_width(refresh_label) as u16)
.saturating_add(1)
.min(header.width);
let refresh = Rect::new(
header.right().saturating_sub(refresh_width.saturating_add(1)),
header.right().saturating_sub(refresh_width),
header.y,
refresh_width.saturating_add(1),
refresh_width,
1,
);
hits.resource_refresh = refresh;
Expand All @@ -82,17 +85,18 @@ pub(super) fn render_plugin_resource_panel(

let body = Rect::new(
area.x,
area.y.saturating_add(2),
header.bottom(),
area.width,
area.height.saturating_sub(2),
area.bottom().saturating_sub(header.bottom()),
);
hits.resource_body = body;
if body.is_empty() {
return;
}

let status = resource_status_line(resource);
if let Some(status) = status.filter(|_| resource.items.is_empty() || resource.stale || resource.loading)
if let Some(status) =
status.filter(|_| resource.items.is_empty() || resource.stale || resource.loading)
{
put_text(
buffer,
Expand Down Expand Up @@ -156,7 +160,12 @@ pub(super) fn render_plugin_resource_panel(
if y.saturating_add(2) > list.bottom() {
break;
}
let rect = Rect::new(list.x, y, content_width, 2.min(list.bottom().saturating_sub(y)));
let rect = Rect::new(
list.x,
y,
content_width,
2.min(list.bottom().saturating_sub(y)),
);
let selected_row = selected.as_deref() == Some(item.id.as_str());
if selected_row {
buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
Expand Down Expand Up @@ -199,7 +208,11 @@ fn resource_count_label(resource: &crate::api::schema::PluginResourceCollection)
if resource.loading && resource.items.is_empty() {
return "…".into();
}
if let Some(error) = resource.error.as_ref().filter(|_| resource.items.is_empty()) {
if let Some(error) = resource
.error
.as_ref()
.filter(|_| resource.items.is_empty())
{
let _ = error;
return "!".into();
}
Expand All @@ -215,9 +228,7 @@ fn resource_count_label(resource: &crate::api::schema::PluginResourceCollection)
.unwrap_or_else(|| resource.items.len().to_string())
}

fn resource_status_line(
resource: &crate::api::schema::PluginResourceCollection,
) -> Option<String> {
fn resource_status_line(resource: &crate::api::schema::PluginResourceCollection) -> Option<String> {
if resource.loading && resource.items.is_empty() {
return Some(" loading…".into());
}
Expand Down
1 change: 1 addition & 0 deletions src/client/shell/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,5 @@ mod keybindings_settings;
mod mobile;
mod mouse_selection;
mod popup_focus_projection;
mod resource_sidebar;
mod startup_overlays;
116 changes: 116 additions & 0 deletions src/client/shell/tests/resource_sidebar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use super::*;

fn resource_state() -> ClientShellState {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
let mut snapshot = snapshot();
snapshot
.plugin_resources
.push(crate::api::schema::PluginResourceCollection {
plugin_id: "herdr-jira".into(),
resource_id: "my-issues".into(),
label: "My Jira issues".into(),
items: (0..40)
.map(|i| crate::api::schema::PluginResourceItem {
id: format!("MOCK-{i}"),
primary: "An issue".into(),
secondary: "To Do".into(),
payload: None,
})
.collect(),
matched_count: Some(40),
truncated: false,
fetched_unix_ms: Some(1),
loading: false,
refreshing: false,
error: None,
stale: false,
});
state.set_snapshot(Box::new(snapshot));
state.set_pane_surface(surface());
state.sidebar_collapsed = false;
state.sidebar_collapsed_manual = true;
state.plugin_resource_collapsed = false;
state.plugin_resource_collapsed_manual = true;
state
}

fn click(state: &mut ClientShellState, rect: Rect) {
assert!(!rect.is_empty(), "control must remain clickable");
for kind in [
MouseEventKind::Down(MouseButton::Left),
MouseEventKind::Up(MouseButton::Left),
] {
state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent {
kind,
column: rect.x,
row: rect.y,
modifiers: KeyModifiers::NONE,
})]);
}
}

#[test]
fn resource_sidebar_collapse_keeps_visible_header_and_reopens() {
let mut state = resource_state();
for _ in 0..3 {
state.compose(106, 30).expect("expanded frame");
let header = state.hits.resource_header;
click(&mut state, header);
assert!(state.plugin_resource_collapsed);
let frame = state.compose(106, 30).expect("collapsed frame");
let text: String = frame
.cells
.iter()
.map(|cell| cell.symbol.as_str())
.collect();
assert!(text.contains("▸ My Jira issues"));
assert!(state.hits.resource_items.is_empty());
let header = state.hits.resource_header;
click(&mut state, header);
assert!(!state.plugin_resource_collapsed);
}
}

#[test]
fn resource_sidebar_leaves_footer_toggle_clickable_in_both_states() {
for collapsed in [false, true] {
let mut state = resource_state();
state.plugin_resource_collapsed = collapsed;
state.compose(106, 30).expect("frame");
if !collapsed {
assert!(!state.hits.resource_scrollbar.is_empty());
}
let toggle = state.hits.sidebar_toggle;
for rect in [
state.hits.resource_body,
state.hits.resource_scrollbar,
state.hits.resource_header,
state.hits.resource_refresh,
] {
assert!(
rect.intersection(toggle).is_empty(),
"resource overlaps footer"
);
}
click(&mut state, toggle);
assert!(state.sidebar_collapsed);
assert_eq!(state.plugin_resource_collapsed, collapsed);
state.compose(106, 30).expect("collapsed sidebar frame");
let toggle = state.hits.sidebar_toggle;
click(&mut state, toggle);
assert!(!state.sidebar_collapsed);
}
}

#[test]
fn resource_sidebar_short_layout_keeps_header_above_footer() {
for height in 2..12 {
for expanded in [false, true] {
let area = Rect::new(3, 4, 30, height);
let layout = crate::ui::expanded_sidebar_layout(area, 0.5, true, expanded);
let resource = layout.resource.expect("resource header space");
assert!(resource.height >= 1);
assert!(resource.bottom() < area.bottom());
}
}
}
12 changes: 10 additions & 2 deletions src/ui/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,13 @@ pub(crate) fn expanded_sidebar_layout(
has_resource: bool,
resource_expanded: bool,
) -> ExpandedSidebarLayout {
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
// Resource scrollbars and actions must not cover the sidebar footer toggle.
let content = Rect::new(
area.x,
area.y,
area.width.saturating_sub(1),
area.height.saturating_sub(u16::from(has_resource)),
);
if content.is_empty() {
return ExpandedSidebarLayout {
spaces: Rect::default(),
Expand Down Expand Up @@ -96,7 +102,9 @@ pub(crate) fn expanded_sidebar_layout(
);
let resource = (resource_height > 0).then_some(Rect::new(
content.x,
content.y.saturating_add(workspace_height.saturating_add(detail_height)),
content
.y
.saturating_add(workspace_height.saturating_add(detail_height)),
content.width,
resource_height,
));
Expand Down