//! The Device panel, as T3 Code's: a thread's simulators and emulators in //! the right panel, one tab per device. A tab without a device lists them to //! open or start; one with a device shows it live. A device can float over //! the chat instead, in a small window of its own. mod backend; mod setup; mod view; pub use backend::{Backend, RemoteDevices, answer as answer_device_call}; pub use setup::{AGENT_DESCRIPTION, DeviceSetup, HUB_DESCRIPTION, agent_status, hub_status, platform_line}; pub use view::{DeviceView, DeviceViewEvent}; use gpui_kit::component::{ Icon, IconName, h_flex, input::{Input, InputEvent, InputState}, menu::{ContextMenuExt as _, PopupMenuItem}, v_flex, Sizable as _, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::device::{self, DeviceSummary, HostStatus, Platform, State}; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; /// The panel's share of the sheet until the user drags it. const SHARE: f32 = 0.42; const MIN_WIDTH: f32 = 380.; /// The device service's state, as the UI last heard of it, and each /// connected device's, by its connection's id. pub struct DeviceWatch { pub state: State, pub remote: std::collections::HashMap, } impl Global for DeviceWatch {} impl DeviceWatch { pub fn init(cx: &mut App) { cx.set_global(DeviceWatch { state: device::service().state(), remote: Default::default() }); let events = device::service().subscribe(); cx.spawn(async move |cx| { while let Ok(event) = events.recv().await { while events.try_recv().is_ok() {} let _ = event; cx.update(|cx| { cx.global_mut::().state = device::service().state(); cx.refresh_windows(); }); } }) .detach(); } /// This machine's devices. pub fn state(cx: &App) -> &State { &cx.global::().state } } /// Runs blocking device work off the UI thread, then hands the result back. pub(crate) fn background( cx: &mut Context, work: impl FnOnce() -> T + Send + 'static, then: impl FnOnce(&mut V, T, &mut Context) + 'static, ) { cx.spawn(async move |this, cx| { let result = cx.background_executor().spawn(async move { work() }).await; let _ = this.update(cx, |this, cx| then(this, result, cx)); }) .detach(); } /// What stands in for a device while it opens, starts or connects. pub(crate) fn loading_view(name: &str, description: &str, message: SharedString, error: bool, p: &Palette) -> Div { v_flex() .size_full() .items_center() .justify_center() .gap(px(10.)) .px(px(24.)) .child(Icon::new(Lucide::Smartphone).size(px(24.)).text_color(p.ink3)) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(name.to_string())) .when(!description.is_empty(), |this| { this.child(div().text_size(px(12.)).text_color(p.ink3).child(description.to_string())) }) .child( h_flex() .gap(px(8.)) .max_w(px(360.)) .text_size(px(12.5)) .text_color(if error { p.red } else { p.ink2 }) .when(!error, |this| this.child(metal::led("device-loading", Led::Waiting, true, p))) .child(div().text_center().child(message)), ) } /// The panel's width once the user drags its edge, shared by all threads. #[derive(Default)] struct DeviceWidth(Option); impl Global for DeviceWidth {} struct ResizeDevice; pub enum DevicePanelEvent { /// Close the right panel. Close, /// A device floats over the chat now: the right panel makes way. Floated, /// A floating device went back into the right panel. Docked, } struct Tab { id: u64, /// A name the user gave the tab. name: Option, view: Option>, } pub struct DevicePanel { /// The thread, by the id its devices' machine knows it by. thread_id: String, backend: Backend, tabs: Vec, active: u64, next_id: u64, /// The device being opened from the list. pending: Option, error: Option, renaming: Option<(u64, Entity)>, /// The tab floating over the chat. floating: Option, visible: bool, float_visible: bool, /// Whether the devices were listed since the panel was last shown. listed: bool, _subscriptions: Vec, } impl EventEmitter for DevicePanel {} impl DevicePanel { pub fn new(thread_id: String, backend: Backend, _: &mut Window, cx: &mut Context) -> Self { let subscriptions = vec![cx.observe_global::(|this, cx| this.sync(cx))]; Self { thread_id, backend, tabs: vec![Tab { id: 1, name: None, view: None }], active: 1, next_id: 2, pending: None, error: None, renaming: None, floating: None, visible: false, float_visible: false, listed: false, _subscriptions: subscriptions, } } /// Shows the panel (`panel`) and the floating device (`floating`); only /// the device on screen streams. pub fn set_visible(&mut self, panel: bool, floating: bool, window: &mut Window, cx: &mut Context) { if panel && !self.visible { self.listed = false; } self.visible = panel; self.float_visible = floating; if panel && !self.listed { self.refresh(cx); } let docked = (panel && self.floating != Some(self.active)).then_some(self.active); let floated = floating.then_some(self.floating).flatten(); for tab in &self.tabs { if let Some(view) = &tab.view { let shown = Some(tab.id) == docked || Some(tab.id) == floated; let compact = Some(tab.id) == self.floating; view.update(cx, |view, cx| { view.compact = compact; view.set_visible(shown, window, cx); }); } } } /// Lists the devices; never installs or starts anything before setup. fn refresh(&mut self, cx: &mut Context) { let state = self.backend.state(cx); if state.host_status == HostStatus::Disabled || !state.onboarding_completed { return; } self.listed = true; let backend = self.backend.clone(); background(cx, move || backend.list(), |_, _, _| {}); } pub fn backend(&self) -> &Backend { &self.backend } pub fn thread_id(&self) -> &str { &self.thread_id } /// The thread's id where its devices are, once a new remote thread has one. pub fn set_thread_id(&mut self, thread_id: String) { self.thread_id = thread_id; } /// Keeps open views' devices current. fn sync(&mut self, cx: &mut Context) { let state = self.backend.state(cx); for tab in &self.tabs { if let Some(view) = &tab.view { let current = view.read(cx).device.clone(); if let Some(device) = state.device(¤t.host_id, ¤t.id).cloned() { view.update(cx, |view, cx| view.update_device(device, cx)); } } } cx.notify(); } pub fn floating_view(&self) -> Option> { let id = self.floating?; self.tabs.iter().find(|tab| tab.id == id)?.view.clone() } /// Brings the floating device back into the right panel. pub fn dock(&mut self, cx: &mut Context) { if let Some(id) = self.floating.take() { self.active = id; cx.emit(DevicePanelEvent::Docked); cx.notify(); } } /// Stops floating the device; its tab stays. pub fn unfloat(&mut self, cx: &mut Context) { if self.floating.take().is_some() { cx.notify(); } } fn view_for(&self, device_id: &str, cx: &App) -> Option { self.tabs.iter().find(|tab| tab.view.as_ref().is_some_and(|view| view.read(cx).device.id == device_id)).map(|tab| tab.id) } /// Shows a device: in its tab if it has one, else in the empty tab /// showing, else in a new tab. `float` floats it over the chat. pub fn show_device(&mut self, device: DeviceSummary, float: bool, window: &mut Window, cx: &mut Context) { let id = match self.view_for(&device.id, cx) { Some(id) => id, None => { let host_label = self.backend.state(cx).host_label(&device.host_id); let backend = self.backend.clone(); let view = cx.new(|cx| DeviceView::new(device, host_label, backend, window, cx)); self._subscriptions.push(cx.subscribe_in(&view, window, Self::on_view_event)); let empty = self.tabs.iter_mut().find(|tab| tab.id == self.active && tab.view.is_none()); match empty { Some(tab) => { tab.view = Some(view); tab.id } None => { let id = self.next_id; self.next_id += 1; self.tabs.push(Tab { id, name: None, view: Some(view) }); id } } } }; if float { self.floating = Some(id); } else { self.active = id; if self.floating == Some(id) { self.floating = None; } } cx.notify(); } /// Drops the tabs showing a device (all of them for `None`), as when an agent closes it. pub fn forget_device(&mut self, device_id: Option<&str>, cx: &mut Context) { let ids: Vec = self .tabs .iter() .filter(|tab| tab.view.as_ref().is_some_and(|view| device_id.is_none_or(|id| view.read(cx).device.id == id))) .map(|tab| tab.id) .collect(); for id in ids { self.remove_tab(id, cx); } } fn on_view_event(&mut self, view: &Entity, event: &DeviceViewEvent, window: &mut Window, cx: &mut Context) { let Some(id) = self.tabs.iter().find(|tab| tab.view.as_ref() == Some(view)).map(|tab| tab.id) else { return }; match event { DeviceViewEvent::Float => { self.floating = Some(id); cx.emit(DevicePanelEvent::Floated); } DeviceViewEvent::Close => { self.remove_tab(id, cx); } DeviceViewEvent::PowerOff => { let device = view.read(cx).device.clone(); let thread = self.thread_id.clone(); let backend = self.backend.clone(); self.error = None; background( cx, move || backend.close(&thread, Some(&device.id), true), move |this, result, cx| match result { Ok(()) => this.remove_tab(id, cx), Err(err) => { this.error = Some(err.into()); cx.notify(); } }, ); } } let _ = window; cx.notify(); } fn remove_tab(&mut self, id: u64, cx: &mut Context) { let Some(ix) = self.tabs.iter().position(|tab| tab.id == id) else { return }; // Dropping the view lets go of its stream. self.tabs.remove(ix); if self.floating == Some(id) { self.floating = None; } if self.tabs.is_empty() { let id = self.next_id; self.next_id += 1; self.tabs.push(Tab { id, name: None, view: None }); self.active = id; if self.visible { cx.emit(DevicePanelEvent::Close); } } else if self.active == id { self.active = self.tabs[ix.min(self.tabs.len() - 1)].id; } cx.notify(); } fn new_tab(&mut self, cx: &mut Context) { let id = self.next_id; self.next_id += 1; self.tabs.push(Tab { id, name: None, view: None }); self.active = id; self.listed = false; self.refresh(cx); cx.notify(); } fn start_rename(&mut self, id: u64, window: &mut Window, cx: &mut Context) { let current = self.tab_title(id, cx); let input = cx.new(|cx| InputState::new(window, cx).default_value(current)); input.update(cx, |input, cx| input.focus(window, cx)); self._subscriptions.push(cx.subscribe_in(&input, window, move |this, input, event: &InputEvent, _, cx| match event { InputEvent::PressEnter { .. } | InputEvent::Blur => { if this.renaming.as_ref().is_some_and(|(renaming, _)| *renaming == id) { let name = input.read(cx).value().trim().to_string(); if let Some(tab) = this.tabs.iter_mut().find(|tab| tab.id == id) { tab.name = (!name.is_empty()).then_some(name); } this.renaming = None; cx.notify(); } } _ => {} })); self.renaming = Some((id, input)); cx.notify(); } fn tab_title(&self, id: u64, cx: &App) -> String { let Some(tab) = self.tabs.iter().find(|tab| tab.id == id) else { return String::new() }; tab.name .clone() .or_else(|| tab.view.as_ref().map(|view| view.read(cx).device.name.clone())) .unwrap_or_else(|| "Device".into()) } /// Opens a device picked from the list, starting it if it's off. fn select_device(&mut self, device: DeviceSummary, window: &mut Window, cx: &mut Context) { if self.pending.is_some() { return; } self.error = None; self.pending = Some(device.clone()); let thread = self.thread_id.clone(); let id = device.id.clone(); let backend = self.backend.clone(); cx.spawn_in(window, async move |this, cx| { let result = cx.background_executor().spawn(async move { backend.open(&thread, &id, true) }).await; let _ = this.update_in(cx, |this, window, cx| { this.pending = None; match result { Ok(session) => { let opened = this.backend.state(cx).device(&session.host_id, &session.device_id).cloned().unwrap_or(device); this.show_device(opened, false, window, cx); let (panel, floating) = (this.visible, this.float_visible); this.set_visible(panel, floating, window, cx); } Err(err) => this.error = Some(err.into()), } cx.notify(); }); }) .detach(); cx.notify(); } // ---- rendering ---- fn render_tabs(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let tabs = self.tabs.iter().map(|tab| { let id = tab.id; let active = id == self.active; let title = self.tab_title(id, cx); let renaming = self.renaming.as_ref().filter(|(renaming, _)| *renaming == id).map(|(_, input)| input.clone()); let floating = self.floating == Some(id); h_flex() .id(("device-tab", id as usize)) .flex_none() .max_w(px(180.)) .h(px(28.)) .pl(px(10.)) .pr(px(4.)) .gap(px(6.)) .rounded(px(9.)) .text_size(px(12.5)) .map(|this| if active { this.bg(vertical(p)).shadow(p.btn.clone()).text_color(p.ink) } else { this.text_color(p.ink3).hover(|style| style.bg(p.row_hover)) }) .cursor_pointer() .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| { if event.click_count() >= 2 { this.start_rename(id, window, cx); } else { this.active = id; this.listed = false; this.refresh(cx); cx.notify(); } })) .child(Icon::new(if floating { Lucide::PictureInPicture2 } else { Lucide::Smartphone }).size(px(13.)).text_color(p.ink3)) .child(match renaming { Some(input) => div().w(px(110.)).child(Input::new(&input).appearance(false).small()).into_any_element(), None => div().min_w_0().truncate().child(title).into_any_element(), }) .child( MetalButton::new(("device-tab-close", id as usize)) .ghost() .mini() .icon(Lucide::X) .tooltip("Close tab") .on_click(cx.listener(move |this, _, _, cx| this.remove_tab(id, cx))), ) .context_menu({ let weak = cx.weak_entity(); move |menu, _, _| { let rename = weak.clone(); let close = weak.clone(); menu.item(PopupMenuItem::new("Rename").on_click(move |_, window, cx| { let _ = rename.update(cx, |this, cx| this.start_rename(id, window, cx)); })) .item(PopupMenuItem::new("Close").on_click(move |_, _, cx| { let _ = close.update(cx, |this, cx| this.remove_tab(id, cx)); })) } }) }); h_flex() .h(px(44.)) .flex_none() .px(px(8.)) .gap(px(4.)) .child(h_flex().id("device-tabs").flex_1().min_w_0().gap(px(4.)).overflow_x_scroll().children(tabs)) .child( MetalButton::new("device-new-tab") .ghost() .mini() .icon(Lucide::Plus) .tooltip("Open another device") .on_click(cx.listener(|this, _, _, cx| this.new_tab(cx))), ) .child( MetalButton::new("device-panel-close") .ghost() .mini() .icon(IconName::Close) .tooltip("Close panel") .on_click(cx.listener(|_, _, _, cx| cx.emit(DevicePanelEvent::Close))), ) } fn render_body(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let state = self.backend.state(cx); let tab = self.tabs.iter().find(|tab| tab.id == self.active); let view = tab.and_then(|tab| tab.view.clone()).filter(|_| self.floating != Some(self.active)); if let Some(view) = view { return div().size_full().child(view).into_any_element(); } if tab.is_some_and(|tab| tab.view.is_some()) { // The active tab floats over the chat. return v_flex() .size_full() .items_center() .justify_center() .gap(px(10.)) .child(Icon::new(Lucide::PictureInPicture2).size(px(22.)).text_color(p.ink3)) .child(div().text_size(px(12.5)).text_color(p.ink3).child("This device floats over the chat.")) .child( MetalButton::new("device-dock-here") .compact() .label("Open in right panel") .on_click(cx.listener(|this, _, _, cx| { this.floating = None; cx.emit(DevicePanelEvent::Docked); cx.notify(); })), ) .into_any_element(); } if state.host_status == HostStatus::Disabled || !state.onboarding_completed { return v_flex() .size_full() .items_center() .justify_center() .gap(px(12.)) .px(px(24.)) .child(Icon::new(Lucide::Smartphone).size(px(24.)).text_color(p.ink3)) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child("Set up devices")) .child( div() .max_w(px(320.)) .text_center() .text_size(px(12.5)) .line_height(px(18.)) .text_color(p.ink3) .child("Review what runs on this machine before using simulators and emulators."), ) .child( MetalButton::new("device-open-setup") .primary() .compact() .label("Set up devices") .on_click({ let backend = self.backend.clone(); move |_, window, cx| DeviceSetup::open(backend.clone(), window, cx) }), ) .into_any_element(); } let ready = state.host_status == HostStatus::Ready; if let Some(pending) = &self.pending { return loading_view( &pending.name, &format!("{} · {}", state.host_label(&pending.host_id), pending.version), if pending.booted { "Opening device…" } else { "Starting device…" }.into(), false, p, ) .into_any_element(); } if state.busy() { let message: SharedString = if state.host_status == HostStatus::Installing { state.host_detail.clone().unwrap_or_else(|| "Installing device support…".into()).into() } else { "Finding devices…".into() }; return loading_view("Devices", "", message, false, p).into_any_element(); } self.render_list(&state, ready, cx) } fn render_list(&self, state: &State, ready: bool, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let mut groups = Vec::new(); for platform in [Platform::Ios, Platform::Android] { let mut devices: Vec = state.devices.iter().filter(|device| device.platform == platform).cloned().collect(); devices.sort_by(|a, b| b.booted.cmp(&a.booted).then_with(|| a.name.cmp(&b.name))); if !devices.is_empty() { groups.push((platform, devices)); } } let empty = groups.is_empty(); let android_missing = ready && !state.devices.iter().any(|device| device.platform == Platform::Android) && !state.hosts.iter().flat_map(|host| &host.platforms).any(|platform| platform.platform == Platform::Android && !platform.available); let mut content = v_flex().w_full().max_w(px(560.)).gap(px(22.)).when(empty, |this| this.my_auto().items_center()); if empty { content = content.child(Icon::new(Lucide::Smartphone).size(px(22.)).text_color(p.ink3)).child( div().max_w(px(340.)).text_center().text_size(px(13.)).line_height(px(19.)).text_color(p.ink3).child( if state.host_status == HostStatus::Failed { state.host_detail.clone().unwrap_or_else(|| "The device hub failed to start.".into()) } else { "No simulators or emulators were found on this machine.".into() }, ), ); if state.host_status == HostStatus::Failed { content = content.child( MetalButton::new("device-retry") .compact() .label("Retry") .on_click(cx.listener(|this, _, _, cx| { let backend = this.backend.clone(); background(cx, move || backend.retry(), |_, _, _| {}) })), ); } } if ready { for (platform, devices) in groups { let rows = devices.into_iter().map(|device| { let key = format!("device-row-{}", device.id); let description = format!( "{} · {} · {}", state.host_label(&device.host_id), device.version, if device.booted { "Running" } else { "Stopped" } ); let action = if device.booted { "Open" } else { "Start" }; let picked = device.clone(); h_flex() .id(SharedString::from(key)) .gap(px(12.)) .px(px(10.)) .py(px(8.)) .rounded(px(10.)) .cursor_pointer() .hover(|style| style.bg(p.row_hover)) .on_click(cx.listener(move |this, _, window, cx| this.select_device(picked.clone(), window, cx))) .child( div() .flex_none() .size(px(32.)) .flex() .items_center() .justify_center() .rounded(px(8.)) .well(p) .child(Icon::new(Lucide::Smartphone).size(px(15.)).text_color(p.ink2)), ) .child( v_flex() .flex_1() .min_w_0() .child(div().truncate().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(device.name.clone())) .child(div().truncate().text_size(px(11.5)).text_color(p.ink3).child(description)), ) .child(div().flex_none().text_size(px(11.5)).text_color(p.ink3).child(action)) }); content = content.child( v_flex() .gap(px(8.)) .child( h_flex() .gap(px(8.)) .text_size(px(12.5)) .text_color(p.ink3) .child(Icon::new(Lucide::Smartphone).size(px(14.))) .child(div().font_weight(FontWeight::MEDIUM).child(platform.group_label())), ) .child(v_flex().p(px(4.)).rounded(px(14.)).well(p).children(rows)), ); } } if android_missing { content = content.child( div() .max_w(px(340.)) .text_size(px(11.5)) .line_height(px(16.)) .text_color(p.ink3) .child("No Android virtual devices found. Create one in Android Studio's Device Manager, then refresh."), ); } content = content.child( div().when(empty, |this| this.flex().justify_center()).child( MetalButton::new("device-refresh") .compact() .when(!empty, |this| this.ghost()) .label("Refresh devices") .on_click(cx.listener(|this, _, _, cx| { this.listed = false; this.refresh(cx); })), ), ); div().id("device-list").size_full().overflow_y_scroll().px(px(20.)).py(px(28.)).flex().flex_col().items_center().child(content).into_any_element() } fn render_notices(&self, cx: &mut Context) -> Vec { let p = metal::palette(cx); let state = self.backend.state(cx); let state = &state; let mut notices = Vec::new(); let notice = |text: String, p: &Palette| { div() .px(px(14.)) .py(px(7.)) .border_b_1() .border_color(p.rule) .text_size(px(11.5)) .line_height(px(16.)) .text_color(p.ink3) .child(text) }; let active_has_device = self.tabs.iter().any(|tab| tab.id == self.active && tab.view.is_some()); if state.host_status == HostStatus::Ready && !active_has_device && let Some(detail) = &state.host_detail { notices.push(notice(detail.clone(), p).into_any_element()); } let booting: Vec = state.booting.iter().filter(|(_, thread)| *thread == self.thread_id).map(|(device, _)| device.name.clone()).collect(); if !booting.is_empty() { notices.push(notice(format!("Starting {}… This can take a minute.", booting.join(", ")), p).into_any_element()); } if let Some(error) = &self.error { notices.push( h_flex() .items_start() .gap(px(8.)) .px(px(14.)) .py(px(7.)) .border_b_1() .border_color(p.rule) .bg(p.red.opacity(0.06)) .child(div().flex_1().min_w_0().text_size(px(11.5)).line_height(px(16.)).text_color(p.red).child(error.clone())) .child( MetalButton::new("device-error-dismiss") .ghost() .mini() .icon(Lucide::X) .tooltip("Dismiss device error") .on_click(cx.listener(|this, _, _, cx| { this.error = None; cx.notify(); })), ) .into_any_element(), ); } notices } } fn vertical(p: &Palette) -> Background { metal::vertical(p.cap) } impl Render for DevicePanel { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let width = cx.default_global::().0; let notices = self.render_notices(cx); v_flex() .relative() .h_full() .min_w(px(MIN_WIDTH)) .map(|this| match width { Some(width) => this.w(width), None => this.flex_basis(relative(SHARE)), }) .p(px(12.)) .pl(px(0.)) .on_drag_move(cx.listener(|_, event: &DragMoveEvent, _, cx| { let right = event.bounds.right(); let width = (right - event.event.position.x).max(px(MIN_WIDTH)); cx.set_global(DeviceWidth(Some(width))); cx.notify(); })) .child( div() .id("device-resize") .absolute() .left(px(-6.)) .top_0() .bottom_0() .w(px(8.)) .cursor_col_resize() .on_drag(ResizeDevice, |_, _, _, cx| cx.new(|_| EmptyView)), ) .child( v_flex() .size_full() .rounded(px(18.)) .raised(p) .overflow_hidden() .child(self.render_tabs(cx)) .children(notices) .child( div() .flex_1() .min_h_0() .m(px(6.)) .mt(px(0.)) .rounded(px(12.)) .overflow_hidden() .well(p) .child(self.render_body(cx)), ), ) } }