aboutsummaryrefslogtreecommitdiff
path: root/src/server/client_shell.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/client_shell.rs')
-rw-r--r--src/server/client_shell.rs616
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
+ );
+ }
+}