aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/sidebar.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/sidebar.rs')
-rw-r--r--src/client/shell/sidebar.rs698
1 files changed, 698 insertions, 0 deletions
diff --git a/src/client/shell/sidebar.rs b/src/client/shell/sidebar.rs
new file mode 100644
index 00000000..f8a87043
--- /dev/null
+++ b/src/client/shell/sidebar.rs
@@ -0,0 +1,698 @@
+use super::*;
+use ratatui::{
+ text::Line,
+ widgets::{Paragraph, Widget},
+};
+
+fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.is_empty() {
+ return (Rect::default(), None, Rect::default());
+ }
+ if content.height < 7 {
+ return (content, None, Rect::default());
+ }
+ let workspace_height = content.height.div_ceil(2);
+ let divider_y = content.y + workspace_height;
+ let detail_height = content.height.saturating_sub(workspace_height + 1);
+ (
+ Rect::new(content.x, content.y, content.width, workspace_height),
+ Some(divider_y),
+ Rect::new(content.x, divider_y + 1, content.width, detail_height),
+ )
+}
+
+pub(crate) fn render_collapsed_sidebar(
+ buffer: &mut Buffer,
+ area: Rect,
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ selected_workspace_id: Option<&str>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ render_sidebar_background(buffer, area, palette);
+ let (workspace_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
+ for (index, workspace) in snapshot
+ .workspaces
+ .iter()
+ .take(workspace_area.height as usize)
+ .enumerate()
+ {
+ let rect = Rect::new(
+ workspace_area.x,
+ workspace_area.y + index as u16,
+ workspace_area.width,
+ 1,
+ );
+ let selected = selected_workspace_id == Some(workspace.workspace_id.as_str());
+ let selection_background =
+ if workspace.focused && palette.selection_bg == ratatui::style::Color::Reset {
+ palette.active_row_bg
+ } else {
+ palette.selection_bg
+ };
+ if selected {
+ buffer.set_style(rect, Style::default().bg(selection_background));
+ } else if workspace.focused {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ let number_style = if selected {
+ Style::default()
+ .fg(palette.overlay1)
+ .bg(selection_background)
+ } else if workspace.focused {
+ Style::default().fg(palette.text).bg(palette.active_row_bg)
+ } else {
+ Style::default().fg(palette.overlay0)
+ };
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.min(2),
+ &format!("{:<2}", index + 1),
+ number_style,
+ );
+ let status = workspace.agent_status;
+ put_text(
+ buffer,
+ rect.x.saturating_add(2),
+ rect.y,
+ rect.width.saturating_sub(2),
+ status_icon(status, config.status_indicators),
+ Style::default().fg(status_color(status, palette)),
+ );
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ workspace_id: workspace.workspace_id.clone(),
+ indented: false,
+ group_toggle: None,
+ });
+ }
+
+ if let Some(divider_y) = divider_y {
+ put_text(
+ buffer,
+ workspace_area.x,
+ divider_y,
+ workspace_area.width,
+ &"─".repeat(workspace_area.width as usize),
+ Style::default().fg(palette.surface_dim),
+ );
+ }
+
+ let detail_content = Rect::new(
+ detail_area.x,
+ detail_area.y,
+ detail_area.width,
+ detail_area.height.saturating_sub(1),
+ );
+ for (index, pane_id) in super::ordered_agent_pane_ids(snapshot, config.agent_panel_sort)
+ .into_iter()
+ .take(detail_content.height as usize)
+ .enumerate()
+ {
+ let Some(agent) = snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)
+ else {
+ continue;
+ };
+ let rect = Rect::new(
+ detail_content.x,
+ detail_content.y + index as u16,
+ detail_content.width,
+ 1,
+ );
+ if agent.focused {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width.min(2),
+ &format!("{:<2}", index + 1),
+ Style::default().fg(if agent.focused {
+ palette.text
+ } else {
+ palette.overlay0
+ }),
+ );
+ put_text(
+ buffer,
+ rect.x.saturating_add(2),
+ rect.y,
+ rect.width.saturating_sub(2),
+ status_icon(agent.agent_status, config.status_indicators),
+ Style::default().fg(status_color(agent.agent_status, palette)),
+ );
+ hits.agents.push((rect, pane_id));
+ }
+ hits.sidebar_toggle = if area.is_empty() || workspace_area.width == 0 {
+ Rect::default()
+ } else {
+ Rect::new(
+ workspace_area.x + workspace_area.width / 2,
+ area.bottom().saturating_sub(1),
+ 1,
+ 1,
+ )
+ };
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "»",
+ if super::super::global_menu::global_menu_attention(snapshot) {
+ Style::default()
+ .fg(palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(palette.overlay0)
+ },
+ );
+}
+
+pub(crate) fn render_sidebar(
+ buffer: &mut Buffer,
+ area: Rect,
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ state: &mut ShellRenderState<'_>,
+ hits: &mut ShellHitMap,
+) {
+ let palette = &config.palette;
+ render_sidebar_background(buffer, area, palette);
+ hits.sidebar_divider = if area.is_empty() {
+ Rect::default()
+ } else {
+ Rect::new(area.right().saturating_sub(1), area.y, 1, area.height)
+ };
+ let (workspace_area, detail_area) =
+ crate::ui::expanded_sidebar_sections(area, state.sidebar_section_split);
+ hits.sidebar_section_divider =
+ crate::ui::sidebar_section_divider_rect(area, state.sidebar_section_split);
+ put_text(
+ buffer,
+ workspace_area.x,
+ workspace_area.y,
+ workspace_area.width,
+ " spaces",
+ Style::default()
+ .fg(palette.overlay0)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let entries = workspace_entries(snapshot, state.collapsed_groups);
+ let body = Rect::new(
+ workspace_area.x,
+ workspace_area.y.saturating_add(WORKSPACE_HEADER_ROWS),
+ workspace_area.width,
+ workspace_area
+ .height
+ .saturating_sub(WORKSPACE_HEADER_ROWS + 1),
+ );
+ hits.workspace_body = body;
+ let row_heights = entries
+ .iter()
+ .map(|entry| {
+ snapshot
+ .workspaces
+ .get(entry.index)
+ .map(|workspace| {
+ workspace_rows(
+ workspace,
+ displayed_workspace_status(snapshot, workspace, state.collapsed_groups),
+ entry.indented,
+ &config.spaces,
+ )
+ .len()
+ .max(1)
+ .min(u16::MAX as usize) as u16
+ })
+ .unwrap_or(1)
+ })
+ .collect::<Vec<_>>();
+ let gaps = entries
+ .iter()
+ .enumerate()
+ .map(|(index, _)| {
+ entries
+ .get(index + 1)
+ .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap)
+ })
+ .collect::<Vec<_>>();
+ let metrics = super::scroll::list_scroll_metrics(
+ &row_heights,
+ &gaps,
+ body.height,
+ *state.workspace_scroll,
+ );
+ hits.workspace_max_scroll = metrics.max_offset_from_bottom;
+ hits.workspace_scroll_metrics = Some(metrics);
+ *state.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom);
+ let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1;
+ let content_width = body.width.saturating_sub(u16::from(show_scrollbar));
+ let mut y = body.y;
+ for (entry_position, entry) in entries.iter().enumerate().skip(*state.workspace_scroll) {
+ let Some(workspace) = snapshot.workspaces.get(entry.index) else {
+ continue;
+ };
+ let status = displayed_workspace_status(snapshot, workspace, state.collapsed_groups);
+ let rows = workspace_rows(workspace, status, entry.indented, &config.spaces);
+ let row_height = (rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height);
+ if y.saturating_add(row_height) > body.bottom() {
+ break;
+ }
+ let rect = Rect::new(body.x, y, content_width, row_height);
+ let selected = state.selected_workspace_id == Some(workspace.workspace_id.as_str());
+ let dragged = state.dragged_workspace_id == Some(workspace.workspace_id.as_str());
+ if selected {
+ buffer.set_style(rect, Style::default().bg(palette.selection_bg));
+ } else if dragged {
+ buffer.set_style(rect, Style::default().bg(palette.surface1));
+ } else if workspace.focused {
+ buffer.set_style(rect, Style::default().bg(palette.active_row_bg));
+ }
+ render_workspace_rows(
+ buffer,
+ rect,
+ workspace,
+ status,
+ config.status_indicators,
+ entry,
+ rows,
+ selected,
+ dragged,
+ palette,
+ );
+ let group_toggle = parent_group_key(snapshot, entry.index).map(|key| {
+ let rect = Rect::new(rect.right().saturating_sub(1), rect.y, 1, 1);
+ put_text(
+ buffer,
+ rect.x,
+ rect.y,
+ rect.width,
+ if state.collapsed_groups.contains(&key) {
+ "▸"
+ } else {
+ "▾"
+ },
+ Style::default().fg(palette.accent),
+ );
+ (rect, key)
+ });
+ hits.workspaces.push(WorkspaceHit {
+ rect,
+ workspace_id: workspace.workspace_id.clone(),
+ indented: entry.indented,
+ group_toggle,
+ });
+ let gap = entries
+ .get(entry_position + 1)
+ .map_or(0, |next| u16::from(!next.indented) * config.spaces.row_gap);
+ y = y.saturating_add(row_height + gap);
+ }
+
+ if show_scrollbar {
+ let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height);
+ hits.workspace_scrollbar = track;
+ super::scroll::render_list_scrollbar(buffer, track, metrics, palette);
+ }
+
+ if let Some(row) = state.workspace_drop_indicator_row.filter(|row| {
+ *row >= workspace_area.y.saturating_add(1)
+ && *row < workspace_area.bottom().saturating_sub(1)
+ }) {
+ put_text(
+ buffer,
+ body.x,
+ row,
+ body.width,
+ &"─".repeat(body.width as usize),
+ Style::default().fg(palette.accent),
+ );
+ }
+
+ let footer_y = workspace_area.bottom().saturating_sub(1);
+ if config.mouse_capture {
+ hits.new_workspace = Rect::new(
+ workspace_area.x,
+ footer_y,
+ 5.min(workspace_area.width),
+ u16::from(workspace_area.height > 0),
+ );
+ put_text(
+ buffer,
+ workspace_area.x,
+ footer_y,
+ workspace_area.width,
+ " new",
+ Style::default().fg(palette.overlay0),
+ );
+ let attention = super::super::global_menu::global_menu_attention(snapshot);
+ let launcher_width = if attention { 8 } else { 6 }.min(workspace_area.width);
+ hits.global_launcher = Rect::new(
+ workspace_area.right().saturating_sub(launcher_width),
+ footer_y,
+ launcher_width,
+ 1,
+ );
+ if attention {
+ let start_x = workspace_area.right().saturating_sub(6);
+ put_text(
+ buffer,
+ start_x,
+ footer_y,
+ 2,
+ "● ",
+ Style::default()
+ .fg(palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ put_text(
+ buffer,
+ start_x.saturating_add(2),
+ footer_y,
+ 4,
+ "menu",
+ Style::default().fg(palette.overlay0),
+ );
+ } else {
+ put_right_text(
+ buffer,
+ workspace_area,
+ footer_y,
+ "menu",
+ Style::default().fg(palette.overlay0),
+ );
+ }
+ }
+
+ super::render_agent_panel(
+ buffer,
+ detail_area,
+ snapshot,
+ config,
+ state.agent_scroll,
+ hits,
+ );
+
+ hits.sidebar_toggle = Rect::new(
+ area.right().saturating_sub(2),
+ area.bottom().saturating_sub(1),
+ u16::from(area.width > 1),
+ u16::from(area.height > 0),
+ );
+ put_text(
+ buffer,
+ hits.sidebar_toggle.x,
+ hits.sidebar_toggle.y,
+ hits.sidebar_toggle.width,
+ "«",
+ Style::default().fg(palette.overlay0),
+ );
+}
+
+pub(crate) fn render_sidebar_background(buffer: &mut Buffer, area: Rect, palette: &Palette) {
+ buffer.set_style(area, Style::default().bg(palette.sidebar_bg));
+ let separator_x = area.right().saturating_sub(1);
+ for y in area.y..area.bottom() {
+ if let Some(cell) = buffer.cell_mut((separator_x, y)) {
+ cell.set_symbol("│");
+ cell.set_style(Style::default().fg(palette.surface_dim));
+ }
+ }
+}
+
+pub(crate) fn workspace_entries(
+ snapshot: &ClientShellSnapshot,
+ collapsed_groups: &HashSet<String>,
+) -> Vec<WorkspaceEntry> {
+ let mut members = HashMap::<&str, Vec<usize>>::new();
+ for (index, workspace) in snapshot.workspaces.iter().enumerate() {
+ if let Some(worktree) = &workspace.worktree {
+ members.entry(&worktree.key).or_default().push(index);
+ }
+ }
+ let grouped = members
+ .iter()
+ .filter(|(_, indices)| {
+ indices.len() >= 2
+ && indices.iter().any(|index| {
+ snapshot.workspaces[*index]
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| !worktree.is_linked_worktree)
+ })
+ })
+ .map(|(key, _)| *key)
+ .collect::<HashSet<_>>();
+ let mut emitted = HashSet::<&str>::new();
+ let mut entries = Vec::new();
+ for (index, workspace) in snapshot.workspaces.iter().enumerate() {
+ let Some(worktree) = workspace
+ .worktree
+ .as_ref()
+ .filter(|worktree| grouped.contains(worktree.key.as_str()))
+ else {
+ entries.push(WorkspaceEntry {
+ index,
+ indented: false,
+ last_child: false,
+ });
+ continue;
+ };
+ if !emitted.insert(&worktree.key) {
+ continue;
+ }
+ let Some(group_members) = members.get(worktree.key.as_str()) else {
+ continue;
+ };
+ let parent = group_members
+ .iter()
+ .copied()
+ .find(|member| {
+ snapshot.workspaces[*member]
+ .worktree
+ .as_ref()
+ .is_some_and(|worktree| !worktree.is_linked_worktree)
+ })
+ .unwrap_or(index);
+ entries.push(WorkspaceEntry {
+ index: parent,
+ indented: false,
+ last_child: false,
+ });
+ if collapsed_groups.contains(&worktree.key) {
+ if let Some(active) = group_members
+ .iter()
+ .copied()
+ .find(|member| *member != parent && snapshot.workspaces[*member].focused)
+ {
+ entries.push(WorkspaceEntry {
+ index: active,
+ indented: true,
+ last_child: true,
+ });
+ }
+ continue;
+ }
+ let children = group_members
+ .iter()
+ .copied()
+ .filter(|member| *member != parent)
+ .collect::<Vec<_>>();
+ for (child_index, child) in children.iter().enumerate() {
+ entries.push(WorkspaceEntry {
+ index: *child,
+ indented: true,
+ last_child: child_index + 1 == children.len(),
+ });
+ }
+ }
+ entries
+}
+
+fn parent_group_key(snapshot: &ClientShellSnapshot, index: usize) -> Option<String> {
+ let workspace = snapshot.workspaces.get(index)?;
+ let worktree = workspace.worktree.as_ref()?;
+ if worktree.is_linked_worktree {
+ return None;
+ }
+ (snapshot
+ .workspaces
+ .iter()
+ .filter(|candidate| {
+ candidate
+ .worktree
+ .as_ref()
+ .is_some_and(|candidate| candidate.key == worktree.key)
+ })
+ .count()
+ >= 2)
+ .then(|| worktree.key.clone())
+}
+
+fn displayed_workspace_status(
+ snapshot: &ClientShellSnapshot,
+ workspace: &ClientShellWorkspace,
+ collapsed_groups: &HashSet<String>,
+) -> crate::api::schema::AgentStatus {
+ let Some(worktree) = workspace
+ .worktree
+ .as_ref()
+ .filter(|worktree| !worktree.is_linked_worktree)
+ else {
+ return workspace.agent_status;
+ };
+ if !collapsed_groups.contains(&worktree.key) {
+ return workspace.agent_status;
+ }
+ snapshot
+ .workspaces
+ .iter()
+ .filter(|candidate| {
+ candidate
+ .worktree
+ .as_ref()
+ .is_some_and(|candidate| candidate.key == worktree.key)
+ })
+ .map(|candidate| candidate.agent_status)
+ .max_by_key(|status| status_priority(*status))
+ .unwrap_or(workspace.agent_status)
+}
+
+fn workspace_rows(
+ workspace: &ClientShellWorkspace,
+ status: crate::api::schema::AgentStatus,
+ indented: bool,
+ config: &SpacesSidebarConfig,
+) -> Vec<Vec<crate::ui::ResolvedToken>> {
+ let label = if indented && !workspace.custom_label {
+ workspace
+ .branch
+ .as_deref()
+ .and_then(|branch| branch.strip_prefix("worktree/").or(Some(branch)))
+ .unwrap_or(&workspace.label)
+ } else {
+ &workspace.label
+ };
+ let token_values = workspace.tokens.iter().cloned().collect::<HashMap<_, _>>();
+ crate::ui::sidebar_space_rows(
+ config,
+ crate::ui::SpaceTokenContext {
+ workspace: label,
+ branch: workspace.branch.as_deref(),
+ state_text: status_text(status),
+ ahead_behind: workspace.git_ahead_behind,
+ tokens: &token_values,
+ suppress_git_details: indented,
+ },
+ )
+}
+
+fn render_workspace_rows(
+ buffer: &mut Buffer,
+ area: Rect,
+ workspace: &ClientShellWorkspace,
+ status: crate::api::schema::AgentStatus,
+ indicators: crate::config::StatusIndicatorStyle,
+ entry: &WorkspaceEntry,
+ rows: Vec<Vec<crate::ui::ResolvedToken>>,
+ selected: bool,
+ dragged: bool,
+ palette: &Palette,
+) {
+ for (row_index, row) in rows.iter().enumerate() {
+ let y = area.y + row_index as u16;
+ if y >= area.bottom() {
+ break;
+ }
+ let mut x = area.x;
+ if entry.indented {
+ let prefix = if row_index == 0 {
+ if entry.last_child {
+ " └─ "
+ } else {
+ " ├─ "
+ }
+ } else if entry.last_child {
+ " "
+ } else {
+ " │ "
+ };
+ x = put_segment(
+ buffer,
+ x,
+ y,
+ area.right(),
+ prefix,
+ Style::default().fg(palette.overlay0),
+ );
+ } else if row_index == 0 {
+ x = x.saturating_add(1);
+ } else {
+ x = x.saturating_add(3);
+ }
+ let highlighted = workspace.focused || dragged;
+ let workspace_style = Style::default()
+ .fg(if highlighted {
+ palette.text
+ } else {
+ palette.subtext0
+ })
+ .add_modifier(if highlighted {
+ Modifier::BOLD
+ } else {
+ Modifier::empty()
+ });
+ let secondary_style = Style::default().fg(if workspace.focused {
+ palette.mauve
+ } else {
+ palette.overlay0
+ });
+ let spans = crate::ui::resolved_token_spans(
+ row,
+ (
+ status_icon(status, indicators),
+ Style::default().fg(status_color(status, palette)),
+ ),
+ Style::default()
+ .fg(status_color(status, palette))
+ .add_modifier(Modifier::DIM),
+ workspace_style,
+ secondary_style,
+ Style::default().fg(palette.overlay1),
+ palette,
+ area.right().saturating_sub(2).saturating_sub(x) as usize,
+ );
+ Paragraph::new(Line::from(spans)).render(
+ Rect::new(x, y, area.right().saturating_sub(2).saturating_sub(x), 1),
+ buffer,
+ );
+ }
+
+ let background = if selected {
+ Some(palette.selection_bg)
+ } else if dragged {
+ Some(palette.surface1)
+ } else if workspace.focused {
+ Some(palette.active_row_bg)
+ } else {
+ None
+ };
+ if let Some(background) = background {
+ for y in area.y..area.bottom() {
+ for x in area.x..area.right() {
+ buffer[(x, y)].set_bg(background);
+ }
+ }
+ }
+}