diff options
Diffstat (limited to 'src/server/client_shell.rs')
| -rw-r--r-- | src/server/client_shell.rs | 616 |
1 files changed, 616 insertions, 0 deletions
diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs new file mode 100644 index 00000000..8f0a90ee --- /dev/null +++ b/src/server/client_shell.rs @@ -0,0 +1,616 @@ +use ratatui::layout::Rect; + +use crate::app; +use crate::protocol::{self, FrameData}; + +pub(super) fn snapshot( + app: &app::App, + boot_id: &str, + revision: u64, + config_diagnostic: Option<&str>, +) -> protocol::ClientShellSnapshot { + let snapshot = app.session_snapshot(); + let workspaces = snapshot + .workspaces + .into_iter() + .zip(&app.state.workspaces) + .enumerate() + .map(|(workspace_index, (workspace, state))| { + let mut tokens = workspace.tokens.into_iter().collect::<Vec<_>>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellWorkspace { + workspace_id: workspace.workspace_id, + active_tab_id: workspace.active_tab_id, + new_workspace_cwd: app + .resolved_new_workspace_cwd_from(workspace_index) + .display() + .to_string(), + number: workspace.number, + label: workspace.label, + custom_label: state.custom_name.is_some(), + branch: state.branch(), + git_ahead_behind: state.git_ahead_behind(), + tokens, + worktree: workspace + .worktree + .map(|worktree| protocol::ClientShellWorktree { + key: worktree.repo_key, + label: worktree.repo_name, + is_linked_worktree: worktree.is_linked_worktree, + }), + focused: workspace.focused, + agent_status: workspace.agent_status, + } + }) + .collect(); + let tabs = snapshot + .tabs + .into_iter() + .zip( + app.state + .workspaces + .iter() + .flat_map(|workspace| workspace.tabs.iter()), + ) + .map(|(tab, state)| protocol::ClientShellTab { + tab_id: tab.tab_id, + workspace_id: tab.workspace_id, + number: tab.number, + label: tab.label, + custom_label: !state.is_auto_named(), + zoomed: state.zoomed, + focused: tab.focused, + agent_status: tab.agent_status, + }) + .collect(); + let panes = snapshot + .panes + .into_iter() + .map(|pane| { + let right_click_passthrough = app + .parse_pane_id(&pane.pane_id) + .and_then(|(workspace_index, pane_id)| { + app.state + .workspaces + .get(workspace_index)? + .pane_state(pane_id) + }) + .is_some_and(|pane| pane.right_click_passthrough); + protocol::ClientShellPane { + pane_id: pane.pane_id, + workspace_id: pane.workspace_id, + tab_id: pane.tab_id, + label: pane.label, + cwd: pane.cwd, + foreground_cwd: pane.foreground_cwd, + focused: pane.focused, + right_click_passthrough, + } + }) + .collect(); + let agents = snapshot + .agents + .into_iter() + .map(|agent| { + let mut state_labels = agent.state_labels.into_iter().collect::<Vec<_>>(); + state_labels.sort_by(|left, right| left.0.cmp(&right.0)); + let mut tokens = agent.tokens.into_iter().collect::<Vec<_>>(); + tokens.sort_by(|left, right| left.0.cmp(&right.0)); + protocol::ClientShellAgent { + pane_id: agent.pane_id, + workspace_id: agent.workspace_id, + tab_id: agent.tab_id, + name: agent.name, + display_agent: agent.display_agent, + agent: agent.agent, + title: agent.title, + terminal_title: agent.terminal_title, + terminal_title_stripped: agent.terminal_title_stripped, + agent_status: agent.agent_status, + state_change_seq: agent.state_change_seq, + state_labels, + tokens, + focused: agent.focused, + } + }) + .collect(); + + let agent_view_label = app + .state + .agent_view_override + .as_ref() + .map(|view| view.label.clone().unwrap_or_else(|| "filtered".to_owned())); + let agent_order = crate::ui::agent_panel_entries_from(&app.state, &app.terminal_runtimes) + .into_iter() + .filter_map(|entry| app.public_pane_id(entry.ws_idx, entry.pane_id)) + .collect(); + + let zoomed = app + .state + .active + .and_then(|index| app.state.workspaces.get(index)) + .is_some_and(|workspace| workspace.zoomed); + let tab_bar_right = app + .state + .tab_bar_right + .iter() + .filter_map(|segment| match segment { + crate::app::state::TabBarStatusSegment::Zoom if zoomed => { + Some(protocol::ClientShellTabStatusSegment { + text: "ZOOM".to_owned(), + accent: true, + }) + } + crate::app::state::TabBarStatusSegment::Text(Some(text)) if !text.is_empty() => { + Some(protocol::ClientShellTabStatusSegment { + text: text.clone(), + accent: false, + }) + } + crate::app::state::TabBarStatusSegment::Zoom + | crate::app::state::TabBarStatusSegment::Text(_) => None, + }) + .collect(); + + let product_announcement = app.state.product_announcement.as_ref().map(|announcement| { + protocol::ClientShellProductAnnouncement { + version: announcement.version.clone(), + id: announcement.id.clone(), + title: announcement.title.clone(), + body: announcement.body.clone(), + preview: announcement.preview, + } + }); + let release_notes = + app.state + .latest_release_notes + .as_ref() + .map(|notes| protocol::ClientShellReleaseNotes { + version: notes.version.clone(), + body: notes.body.clone(), + preview: notes.preview, + }); + + protocol::ClientShellSnapshot { + boot_id: boot_id.to_owned(), + revision, + config_diagnostic: config_diagnostic.map(str::to_owned), + product_announcement, + update_available: app.state.update_available.clone(), + update_install_command: app.state.update_install_command.clone(), + server_keybindings_toml: app.client_shell_keybindings_profile().map(str::to_owned), + latest_release_notes_available: app.state.latest_release_notes_available, + integration_updates_available: app.state.integration_updates_available(), + worktree_directory: app.state.worktree_directory.to_string_lossy().into_owned(), + release_notes, + focused_workspace_id: snapshot.focused_workspace_id, + focused_tab_id: snapshot.focused_tab_id, + focused_pane_id: snapshot.focused_pane_id, + tab_bar_right, + tab_bar_right_separator: app.state.tab_bar_right_separator.clone(), + agent_view_label, + agent_order, + workspaces, + tabs, + panes, + agents, + commands: app.client_shell_command_manifest(), + } +} + +pub(super) struct RenderedPaneSurface { + pub(super) frame: FrameData, + pub(super) panes: Vec<protocol::PaneSurfacePane>, + pub(super) splits: Vec<protocol::PaneSurfaceSplit>, + pub(super) popup: Option<Box<protocol::ClientShellPopupSurface>>, + pub(super) graphics: protocol::SurfaceGraphicsScene, + pub(super) graphics_delivery: crate::kitty_graphics::surface::DeliveryCache, +} + +pub(super) fn render_pane_surface( + app: &mut app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, + graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, + client_id: u64, +) -> RenderedPaneSurface { + let content_revisions_before = app + .state + .active + .and_then(|workspace_index| { + let workspace = app.state.workspaces.get(workspace_index)?; + let tab = workspace.tabs.get(workspace.active_tab)?; + Some( + tab.layout + .pane_ids() + .into_iter() + .filter_map(|pane_id| { + app.state + .runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane_id, + ) + .map(|runtime| (pane_id, runtime.content_seq())) + }) + .collect::<std::collections::HashMap<_, _>>(), + ) + }) + .unwrap_or_default(); + let (buffer, cursor, hyperlinks, layout) = + crate::server::render_stream::render_tab_surface_virtual( + &app.state, + &app.terminal_runtimes, + area, + is_foreground, + cell_size, + ); + let panes = app + .state + .active + .map(|workspace_index| { + layout + .pane_infos + .iter() + .filter_map(|pane| { + app.public_pane_id(workspace_index, pane.id).map(|pane_id| { + let runtime = app.state.runtime_for_pane_in_workspace( + &app.terminal_runtimes, + workspace_index, + pane.id, + ); + let mouse_reporting = + runtime.is_some_and(|runtime| runtime.mouse_reporting_enabled()); + let sgr_pixel_mouse = + runtime.is_some_and(|runtime| runtime.sgr_pixel_mouse_enabled()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(pane.inner_rect.width) * cell_size.width_px, + u32::from(pane.inner_rect.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + let content_revision = runtime.map_or(0, |runtime| { + let after = runtime.content_seq(); + if content_revisions_before.get(&pane.id).copied() == Some(after) + && after.is_multiple_of(2) + { + after + } else { + after | 1 + } + }); + protocol::PaneSurfacePane { + pane_id, + content_revision, + rect: pane.rect.into(), + inner_rect: pane.inner_rect.into(), + scrollbar_rect: pane.scrollbar_rect.map(Into::into), + scroll: runtime.and_then(|runtime| runtime.scroll_metrics()).map( + |metrics| protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: metrics.offset_from_bottom as u64, + max_offset_from_bottom: metrics.max_offset_from_bottom as u64, + viewport_rows: metrics.viewport_rows as u64, + }, + ), + focused: pane.is_focused, + mouse_reporting, + sgr_pixel_mouse, + alternate_screen_active: runtime + .is_some_and(|runtime| runtime.alternate_screen_active()), + pixel_width, + pixel_height, + } + }) + }) + .collect() + }) + .unwrap_or_default(); + let pane_frames = layout + .pane_infos + .iter() + .map(|pane| pane.rect) + .collect::<Vec<_>>(); + let splits = layout + .split_borders + .iter() + .filter_map(|split| { + let hit_rect = split_hit_rect( + split, + app.state.pane_borders, + app.state.pane_gaps, + &pane_frames, + )?; + let direction = match split.direction { + ratatui::layout::Direction::Horizontal => { + protocol::PaneSurfaceSplitDirection::Horizontal + } + ratatui::layout::Direction::Vertical => { + protocol::PaneSurfaceSplitDirection::Vertical + } + }; + Some(protocol::PaneSurfaceSplit { + direction, + pos: split.pos, + area: split.area.into(), + hit_rect: hit_rect.into(), + path: split.path.clone(), + }) + }) + .collect(); + let popup = render_popup_surface(app, area, is_foreground, cell_size); + let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect( + app, + &layout.pane_infos, + &layout.split_borders, + popup.as_deref(), + cell_size, + graphics_delivery, + client_id, + ); + RenderedPaneSurface { + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + panes, + splits, + popup, + graphics, + graphics_delivery: next_graphics_delivery, + } +} + +fn render_popup_surface( + app: &app::App, + area: Rect, + is_foreground: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option<Box<protocol::ClientShellPopupSurface>> { + let popup = app.state.popup_pane.as_ref()?; + let geometry = if is_foreground { + resize_popup_runtime(app, area, cell_size)? + } else { + crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)? + }; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + let content_area = Rect::new(0, 0, geometry.inner.width, geometry.inner.height); + let (buffer, mut cursor) = + crate::server::render_stream::render_terminal_virtual(runtime, content_area); + if !is_foreground { + cursor = None; + } + let hyperlinks = runtime.visible_hyperlinks(content_area); + let title = app + .state + .terminals + .get(&popup.terminal_id) + .and_then(|terminal| terminal.manual_label.clone()) + .unwrap_or_else(|| "popup".to_owned()); + let (pixel_width, pixel_height) = if cell_size.is_known() { + ( + u32::from(content_area.width) * cell_size.width_px, + u32::from(content_area.height) * cell_size.height_px, + ) + } else { + (0, 0) + }; + Some(Box::new(protocol::ClientShellPopupSurface { + terminal_id: popup.terminal_id.to_string(), + title, + width: popup.width.map(client_popup_size), + height: popup.height.map(client_popup_size), + frame: FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks), + mouse_reporting: runtime.mouse_reporting_enabled(), + sgr_pixel_mouse: runtime.sgr_pixel_mouse_enabled(), + pixel_width, + pixel_height, + })) +} + +pub(super) fn resize_popup_runtime( + app: &app::App, + area: Rect, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Option<crate::popup_size::PopupResolvedGeometry> { + let popup = app.state.popup_pane.as_ref()?; + let geometry = crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)?; + let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; + if !app + .state + .direct_attach_resize_locks + .contains(&popup.terminal_id) + { + runtime.resize( + geometry.inner.height, + geometry.inner.width, + cell_size.width_px, + cell_size.height_px, + ); + } + Some(geometry) +} + +fn client_popup_size(size: crate::popup_size::PopupSize) -> protocol::ClientShellPopupSize { + match size { + crate::popup_size::PopupSize::Cells(cells) => protocol::ClientShellPopupSize::Cells(cells), + crate::popup_size::PopupSize::Percent(percent) => { + protocol::ClientShellPopupSize::Percent(percent) + } + } +} + +fn split_hit_rect( + split: &crate::layout::SplitBorder, + pane_borders: bool, + pane_gaps: bool, + pane_frames: &[Rect], +) -> Option<Rect> { + let hit = match (split.direction, pane_borders, pane_gaps) { + (ratatui::layout::Direction::Horizontal, true, false) => { + Rect::new(split.pos, split.area.y, 1, split.area.height) + } + (ratatui::layout::Direction::Horizontal, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + start, + split.area.y, + split.pos.saturating_sub(start).saturating_add(1), + split.area.height, + ) + } + (ratatui::layout::Direction::Horizontal, false, true) => Rect::new( + split.pos.checked_sub(1)?, + split.area.y, + 1, + split.area.height, + ), + (ratatui::layout::Direction::Vertical, true, false) => { + Rect::new(split.area.x, split.pos, split.area.width, 1) + } + (ratatui::layout::Direction::Vertical, true, true) => { + let start = split.pos.saturating_sub(1); + Rect::new( + split.area.x, + start, + split.area.width, + split.pos.saturating_sub(start).saturating_add(1), + ) + } + (ratatui::layout::Direction::Vertical, false, true) => { + Rect::new(split.area.x, split.pos.checked_sub(1)?, split.area.width, 1) + } + (_, false, false) => return None, + }; + if !pane_borders + && pane_frames.iter().any(|pane| { + hit.x < pane.right() + && hit.right() > pane.x + && hit.y < pane.bottom() + && hit.bottom() > pane.y + }) + { + return None; + } + Some(hit) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn snapshot_projects_cached_release_and_update_facts() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations.clear(); + app.state.update_available = Some("0.8.3".into()); + app.state.update_install_command = "herdr update".into(); + app.state.latest_release_notes_available = true; + app.state.latest_release_notes = Some(crate::release_notes::ReleaseNotes { + version: "0.8.3".into(), + body: "### Changed\n- Client shell".into(), + preview: true, + }); + + let snapshot = snapshot(&app, "boot", 7, None); + + assert_eq!(snapshot.update_available.as_deref(), Some("0.8.3")); + assert_eq!(snapshot.update_install_command, "herdr update"); + assert!(snapshot.latest_release_notes_available); + assert!(!snapshot.integration_updates_available); + assert_eq!( + snapshot.release_notes.as_ref().map(|notes| ( + notes.version.as_str(), + notes.body.as_str(), + notes.preview + )), + Some(("0.8.3", "### Changed\n- Client shell", true)) + ); + } + + #[test] + fn snapshot_badges_only_outdated_integrations() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = crate::app::App::new( + &crate::config::Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.integration_recommendations = + vec![crate::integration::IntegrationRecommendation { + target: crate::api::schema::IntegrationTarget::Claude, + label: "claude", + command: "claude", + available: true, + path: std::path::PathBuf::from("claude-hook"), + state: crate::integration::IntegrationStatusKind::NotInstalled, + }]; + + assert!(!snapshot(&app, "boot", 1, None).integration_updates_available); + + app.state.integration_recommendations[0].state = + crate::integration::IntegrationStatusKind::Outdated; + assert!(snapshot(&app, "boot", 2, None).integration_updates_available); + } + + #[test] + fn split_hits_follow_released_border_and_gap_geometry() { + let horizontal = crate::layout::SplitBorder { + pos: 20, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![false], + }; + assert_eq!( + split_hit_rect(&horizontal, true, false, &[]), + Some(Rect::new(20, 3, 1, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, true, true, &[]), + Some(Rect::new(19, 3, 2, 12)) + ); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[]), + Some(Rect::new(19, 3, 1, 12)) + ); + assert_eq!(split_hit_rect(&horizontal, false, false, &[]), None); + + let vertical = crate::layout::SplitBorder { + pos: 9, + direction: ratatui::layout::Direction::Vertical, + ratio: 0.5, + area: Rect::new(2, 3, 40, 12), + path: vec![true], + }; + assert_eq!( + split_hit_rect(&vertical, true, true, &[]), + Some(Rect::new(2, 8, 40, 2)) + ); + + let edge = crate::layout::SplitBorder { + pos: 0, + direction: ratatui::layout::Direction::Horizontal, + ratio: 0.5, + area: Rect::new(0, 0, 1, 4), + path: Vec::new(), + }; + assert_eq!( + split_hit_rect(&edge, true, true, &[]), + Some(Rect::new(0, 0, 1, 4)) + ); + assert_eq!(split_hit_rect(&edge, false, true, &[]), None); + assert_eq!( + split_hit_rect(&horizontal, false, true, &[Rect::new(19, 3, 1, 12)]), + None + ); + } +} |
