diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-06 02:27:06 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-06 02:27:06 +0300 |
| commit | 9e9bc8a144667a6b7debfd4f0c7a31ff0e11493a (patch) | |
| tree | 03f1259f91fd9edeacfd4b904f2e84d4acf25232 /src/client/shell/mobile.rs | |
| parent | 35b0dff96579f099e0436290085024424ffb289c (diff) | |
feat: manage multiple ssh machines from one client (#3670)
Diffstat (limited to 'src/client/shell/mobile.rs')
| -rw-r--r-- | src/client/shell/mobile.rs | 399 |
1 files changed, 263 insertions, 136 deletions
diff --git a/src/client/shell/mobile.rs b/src/client/shell/mobile.rs index 014f51a0..abe50590 100644 --- a/src/client/shell/mobile.rs +++ b/src/client/shell/mobile.rs @@ -202,6 +202,16 @@ fn render_header_button( } } +fn mobile_endpoint_state(status: ClientEndpointStatus) -> &'static str { + match status { + ClientEndpointStatus::Connecting => "connecting", + ClientEndpointStatus::Online => "online", + ClientEndpointStatus::Reconnecting => "reconnecting", + ClientEndpointStatus::Attention => "attention", + ClientEndpointStatus::Disabled => "disabled", + } +} + fn compact_tab_status(snapshot: &ClientShellSnapshot, workspace: &ClientShellWorkspace) -> String { let tabs = snapshot .tabs @@ -354,6 +364,8 @@ pub(super) fn render_mobile_switcher( buffer: &mut Buffer, area: Rect, snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, scroll: &mut usize, @@ -426,6 +438,8 @@ pub(super) fn render_mobile_switcher( let items = mobile_items( snapshot, + endpoints, + active_endpoint_id, config, selected_workspace_id, viewport.width.saturating_sub(1), @@ -440,8 +454,10 @@ pub(super) fn render_mobile_switcher( let end = start.saturating_add(item.lines.len()); if matches!( item.target.as_ref(), - Some(ClientMobileTarget::Workspace(workspace_id)) - if workspace_id == selected_workspace_id + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) if endpoint_id == active_endpoint_id && workspace_id == selected_workspace_id ) { if start < *scroll { *scroll = start; @@ -558,22 +574,51 @@ fn render_close_button(buffer: &mut Buffer, area: Rect, palette: &Palette) { fn mobile_items( snapshot: &ClientShellSnapshot, + endpoints: &[ClientShellEndpoint], + active_endpoint_id: &ClientEndpointId, config: &ClientShellConfig, selected_workspace_id: Option<&str>, content_width: u16, ) -> Vec<MobileItem> { let palette = &config.palette; let mut items = Vec::new(); - let ordered_agents = - super::agent_sidebar::ordered_agent_pane_ids(snapshot, config.agent_panel_sort); - if !ordered_agents.is_empty() || snapshot.agent_view_label.is_some() { - let title = snapshot - .agent_view_label - .as_deref() + if endpoints.len() > 1 { + items.push(MobileItem::section("machines", palette)); + for endpoint in endpoints { + let background = palette.panel_bg; + let (symbol, state, color) = endpoint_status_presentation(endpoint.status, palette); + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled(" ", Style::default().bg(background)), + Span::styled(symbol, Style::default().fg(color).bg(background)), + Span::styled( + format!(" {}", endpoint.label), + Style::default() + .fg(palette.text) + .bg(background) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(Span::styled( + format!(" {state}"), + Style::default().fg(palette.overlay0).bg(background), + )), + ], + background, + target: Some(ClientMobileTarget::Machine(endpoint.endpoint_id.clone())), + }); + } + } + let agents = + super::aggregate_navigation::aggregate_agent_rows(endpoints, config.agent_panel_sort); + let agent_view_label = snapshot.agent_view_label.as_deref(); + if !agents.is_empty() || agent_view_label.is_some() { + let title = agent_view_label .map(|label| format!("agents · {label}")) .unwrap_or_else(|| "agents".to_owned()); items.push(MobileItem::section(title, palette)); - if ordered_agents.is_empty() { + if agents.is_empty() { items.push(MobileItem { lines: vec![Line::from(Span::styled( " no matching agents", @@ -586,19 +631,19 @@ fn mobile_items( target: None, }); } - for pane_id in ordered_agents { - let Some(agent) = snapshot - .agents - .iter() - .find(|agent| agent.pane_id == pane_id) - else { - continue; - }; - let workspace = snapshot + for row in agents { + let endpoint = row.endpoint; + let agent = row.agent; + let workspace = endpoint + .snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == agent.workspace_id); - let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); + let tab = endpoint + .snapshot + .tabs + .iter() + .find(|tab| tab.tab_id == agent.tab_id); let agent_label = agent .display_agent .as_deref() @@ -609,7 +654,8 @@ fn mobile_items( .map(|workspace| workspace.label.as_str()) .unwrap_or(agent_label); let mut detail = Vec::new(); - let workspace_tab_count = snapshot + let workspace_tab_count = endpoint + .snapshot .tabs .iter() .filter(|candidate| candidate.workspace_id == agent.workspace_id) @@ -633,11 +679,24 @@ fn mobile_items( }), ); detail.push(agent_label.to_owned()); - let background = if agent.focused { + if endpoint.stale() { + detail.push(mobile_endpoint_state(endpoint.status).to_owned()); + } + let background = if endpoint.endpoint_id == active_endpoint_id && agent.focused { palette.surface_dim } else { palette.panel_bg }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; items.push(MobileItem { lines: vec![ Line::from(vec![ @@ -645,19 +704,24 @@ fn mobile_items( Span::styled( status_icon(agent.agent_status, config.status_indicators), Style::default() - .fg(status_color(agent.agent_status, palette)) - .bg(background), + .fg(if endpoint.stale() { + palette.overlay0 + } else { + status_color(agent.agent_status, palette) + }) + .bg(background) + .add_modifier(dim), ), Span::styled(" ", Style::default().bg(background)), Span::styled( crate::ui::truncate_end( - primary, + &format!("{} · {primary}", endpoint.label), usize::from(content_width.saturating_sub(5)), ), Style::default() - .fg(palette.text) + .fg(foreground) .bg(background) - .add_modifier(Modifier::BOLD), + .add_modifier(Modifier::BOLD | dim), ), ]), Line::from(Span::styled( @@ -665,11 +729,17 @@ fn mobile_items( &format!(" {}", detail.join(" · ")), usize::from(content_width), ), - Style::default().fg(palette.overlay0).bg(background), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), )), ], background, - target: Some(ClientMobileTarget::Agent(agent.pane_id.clone())), + target: Some(ClientMobileTarget::Agent { + endpoint_id: endpoint.endpoint_id.clone(), + pane_id: agent.pane_id.clone(), + }), }); } } @@ -680,91 +750,119 @@ fn mobile_items( ClientMobileTarget::NewWorkspace, palette, )); - for entry in super::render::workspace_entries(snapshot, &HashSet::new()) { - let Some(workspace) = snapshot.workspaces.get(entry.index) else { - continue; - }; - let selected = selected_workspace_id == Some(workspace.workspace_id.as_str()); - let background = if selected { - palette.surface0 - } else if workspace.focused { - palette.surface_dim - } else { - palette.panel_bg - }; - let connector = if entry.indented { - if entry.last_child { - "└─ " + for endpoint in super::aggregate_navigation::cached_endpoint_snapshots(endpoints) { + for entry in super::render::workspace_entries(endpoint.snapshot, &HashSet::new()) { + let Some(workspace) = endpoint.snapshot.workspaces.get(entry.index) else { + continue; + }; + let selected = endpoint.endpoint_id == active_endpoint_id + && selected_workspace_id == Some(workspace.workspace_id.as_str()); + let background = if selected { + palette.surface0 + } else if endpoint.endpoint_id == active_endpoint_id && workspace.focused { + palette.surface_dim } else { - "├─ " - } - } else { - "" - }; - let name = if entry.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 branch = workspace.branch.as_deref().unwrap_or("shell"); - let detail_prefix = if entry.indented { - if entry.last_child { - " " + palette.panel_bg + }; + let connector = if entry.indented { + if entry.last_child { + "└─ " + } else { + "├─ " + } } else { - " │ " - } - } else { - " " - }; - items.push(MobileItem { - lines: vec![ - Line::from(vec![ - Span::styled( - format!(" {connector}"), - Style::default().fg(palette.overlay0).bg(background), - ), - Span::styled( - status_icon(workspace.agent_status, config.status_indicators), - Style::default() - .fg(status_color(workspace.agent_status, palette)) - .bg(background), - ), - Span::styled(" ", Style::default().bg(background)), - Span::styled( + "" + }; + let name = if entry.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 branch = workspace.branch.as_deref().unwrap_or("shell"); + let detail_prefix = if entry.indented { + if entry.last_child { + " " + } else { + " │ " + } + } else { + " " + }; + let dim = if endpoint.stale() { + Modifier::DIM + } else { + Modifier::empty() + }; + let foreground = if endpoint.stale() { + palette.overlay0 + } else { + palette.text + }; + let status = if endpoint.stale() { + palette.overlay0 + } else { + status_color(workspace.agent_status, palette) + }; + let stale_detail = if endpoint.stale() { + format!(" · {}", mobile_endpoint_state(endpoint.status)) + } else { + String::new() + }; + items.push(MobileItem { + lines: vec![ + Line::from(vec![ + Span::styled( + format!(" {connector}"), + Style::default() + .fg(palette.overlay0) + .bg(background) + .add_modifier(dim), + ), + Span::styled( + status_icon(workspace.agent_status, config.status_indicators), + Style::default().fg(status).bg(background).add_modifier(dim), + ), + Span::styled(" ", Style::default().bg(background)), + Span::styled( + crate::ui::truncate_end( + &format!("{} · {name}", endpoint.label), + usize::from(content_width.saturating_sub(if entry.indented { + 8 + } else { + 5 + })), + ), + Style::default() + .fg(foreground) + .bg(background) + .add_modifier(Modifier::BOLD | dim), + ), + ]), + Line::from(Span::styled( crate::ui::truncate_end( - name, - usize::from(content_width.saturating_sub(if entry.indented { - 8 - } else { - 5 - })), + &format!( + "{detail_prefix}{branch} · {}{stale_detail}", + compact_tab_status(endpoint.snapshot, workspace) + ), + usize::from(content_width), ), Style::default() - .fg(palette.text) + .fg(palette.overlay0) .bg(background) - .add_modifier(Modifier::BOLD), - ), - ]), - Line::from(Span::styled( - crate::ui::truncate_end( - &format!( - "{detail_prefix}{branch} · {}", - compact_tab_status(snapshot, workspace) - ), - usize::from(content_width), - ), - Style::default().fg(palette.overlay0).bg(background), - )), - ], - background, - target: Some(ClientMobileTarget::Workspace( - workspace.workspace_id.clone(), - )), - }); + .add_modifier(dim), + )), + ], + background, + target: Some(ClientMobileTarget::Workspace { + endpoint_id: endpoint.endpoint_id.clone(), + workspace_id: workspace.workspace_id.clone(), + }), + }); + } } if let Some(workspace_id) = snapshot.focused_workspace_id.as_deref() { @@ -803,7 +901,10 @@ fn mobile_items( .add_modifier(Modifier::BOLD), ))], background, - target: Some(ClientMobileTarget::Tab(tab.tab_id.clone())), + target: Some(ClientMobileTarget::Tab { + endpoint_id: active_endpoint_id.clone(), + tab_id: tab.tab_id.clone(), + }), }); } } @@ -890,6 +991,22 @@ impl ClientShellState { .find(|(rect, _)| super::contains(*rect, point)) .map(|(_, target)| target.clone()); match target { + Some(ClientMobileTarget::Machine(endpoint_id)) => { + if endpoint_id == self.active_endpoint_id { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } else if self.endpoint_is_online(&endpoint_id) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + outcome.actions.push(ClientShellAction::ActivateEndpoint { + endpoint_id, + target: None, + }); + } else { + let label = self.endpoint_label(&endpoint_id).to_owned(); + self.receive_endpoint_unavailable(format!("{label} is not ready")); + } + } Some(ClientMobileTarget::NewWorkspace) => { self.mobile_switcher_suspended = true; self.record_binding( @@ -897,34 +1014,44 @@ impl ClientShellState { outcome, ); } - Some( - target @ (ClientMobileTarget::Workspace(_) - | ClientMobileTarget::Tab(_) - | ClientMobileTarget::Agent(_)), - ) => { - let method = match target { - ClientMobileTarget::Workspace(workspace_id) => { - crate::api::schema::Method::WorkspaceFocus( - crate::api::schema::WorkspaceTarget { workspace_id }, - ) - } - ClientMobileTarget::Tab(tab_id) => { - crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { - tab_id, - }) - } - ClientMobileTarget::Agent(pane_id) => { - crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { - pane_id, - }) - } - ClientMobileTarget::NewWorkspace - | ClientMobileTarget::NewTab - | ClientMobileTarget::Menu(_) => return true, - }; - self.mode = ClientShellMode::Terminal; - self.navigate_workspace_id = None; - self.push_endpoint_method(method, outcome); + Some(ClientMobileTarget::Workspace { + endpoint_id, + workspace_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Workspace(workspace_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Agent { + endpoint_id, + pane_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Pane(pane_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } + } + Some(ClientMobileTarget::Tab { + endpoint_id, + tab_id, + }) => { + if self.focus_or_activate( + endpoint_id, + ClientEndpointFocusTarget::Tab(tab_id), + outcome, + ) { + self.mode = ClientShellMode::Terminal; + self.navigate_workspace_id = None; + } } Some(ClientMobileTarget::NewTab) => { self.mobile_switcher_suspended = true; |
