//! "Set up devices", the Device panel's first-use dialog, as T3 Code has //! it: three steps (the device hub, simulator support, agent access), each //! saying what will run on this machine before anything does. Opening the //! panel alone installs and starts nothing. use gpui_kit::component::{Icon, IconName, WindowExt as _, h_flex, v_flex}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::{Backend, background}; use crate::device::{HostStatus, Platform, State}; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; pub const HUB_DESCRIPTION: &str = "Enable this machine to open simulators and emulators, and stream them into the Device panel."; pub const AGENT_DESCRIPTION: &str = "Allow new agent sessions to start and control devices, with required tools set up automatically."; const STEPS: [&str; 3] = ["Device hub", "Simulators", "Agent access"]; #[derive(Clone, Copy, PartialEq, Eq)] enum Pending { Hub, Check, Agent, Complete, } pub struct DeviceSetup { backend: Backend, step: usize, pending: Option, _watch: Subscription, } impl DeviceSetup { fn new(backend: Backend, cx: &mut Context) -> Self { Self { backend, step: 0, pending: None, _watch: cx.observe_global::(|_, cx| cx.notify()) } } /// Opens the dialog for the devices `backend` reaches. pub fn open(backend: Backend, window: &mut Window, cx: &mut App) { Self::open_with(backend, window, cx, None::); } /// Opens the dialog. `on_dismiss` runs if it closes before setup is done. pub fn open_with( backend: Backend, window: &mut Window, cx: &mut App, on_dismiss: Option, ) { let title = match &backend { Backend::Local => "Set up devices".to_string(), Backend::Remote(remote) => format!("Set up devices on {}", remote.name), }; let done_backend = backend.clone(); let setup = cx.new(|cx| Self::new(backend, cx)); let on_dismiss = on_dismiss.map(|f| std::rc::Rc::new(f) as std::rc::Rc); window.open_dialog(cx, move |dialog, _, _| { let on_dismiss = on_dismiss.clone(); let done_backend = done_backend.clone(); dialog .w(px(560.)) .title(title.clone()) .child(setup.clone()) .on_close(move |_: &ClickEvent, window: &mut Window, cx: &mut App| { let done = { let state = done_backend.state(cx); state.onboarding_completed && state.host_status != HostStatus::Disabled }; if !done && let Some(on_dismiss) = &on_dismiss { on_dismiss(window, cx); } }) }); } fn configure( &mut self, kind: Pending, enabled: Option, agent: Option, done: Option, window: &mut Window, cx: &mut Context, ) { self.pending = Some(kind); cx.notify(); let backend = self.backend.clone(); cx.spawn_in(window, async move |this, cx| { let result = cx.background_executor().spawn(async move { backend.configure(enabled, agent, done) }).await; let _ = this.update_in(cx, |this, window, cx| { this.pending = None; cx.notify(); if kind == Pending::Complete && result.is_ok() { window.close_dialog(cx); } }); }) .detach(); } fn check(&mut self, cx: &mut Context) { self.pending = Some(Pending::Check); cx.notify(); let backend = self.backend.clone(); background(cx, move || backend.list(), |this, _, cx| { this.pending = None; cx.notify(); }); } } /// "Installing device hub…" while it installs or starts, "Device hub is ready." once it runs. pub fn hub_status(state: &State, pending: bool, compact: bool, p: &Palette) -> Option { if !pending && state.host_status != HostStatus::Ready { return None; } let label = if !pending { "Device hub is ready." } else { match (state.host_status, compact) { (HostStatus::Installing, true) => "Installing…", (HostStatus::Installing, false) => "Installing device hub…", (HostStatus::Starting, true) => "Starting…", (HostStatus::Starting, false) => "Starting device hub…", (_, true) => "Updating…", (_, false) => "Updating device hub…", } }; Some(status_line("device-hub-status", label, pending, p)) } pub fn agent_status(state: &State, pending: bool, compact: bool, p: &Palette) -> Option { if pending { let label = match (state.host_status, compact) { (HostStatus::Installing, true) => "Installing…", (HostStatus::Installing, false) => "Installing agent tools…", (HostStatus::Starting, true) => "Starting…", (HostStatus::Starting, false) => "Starting agent tools…", (_, true) => "Updating…", (_, false) => "Updating agent access…", }; return Some(status_line("device-agent-status", label, true, p)); } (state.agent_access && state.host_status == HostStatus::Ready && state.hosts.iter().any(|host| host.agent_device_installed)) .then(|| status_line("device-agent-status", "Agent tools are ready.", false, p)) } fn status_line(id: &'static str, label: &'static str, pending: bool, p: &Palette) -> AnyElement { h_flex() .gap(px(8.)) .text_size(px(11.5)) .text_color(p.ink3) .child(if pending { metal::led(id, Led::Waiting, true, p) } else { Icon::new(IconName::Check).size(px(12.)).text_color(p.green_ink).into_any_element() }) .child(label) .into_any_element() } /// One platform's line: ready, or what's missing. pub fn platform_line(state: &State, platform: Platform, compact: bool, p: &Palette) -> AnyElement { let (ready, message) = state.platform_status(platform); h_flex() .items_start() .gap(px(8.)) .when(!compact, |this| this.px(px(12.)).py(px(9.)).rounded(px(10.)).well(p)) .child( Icon::new(if ready { IconName::CircleCheck } else { IconName::CircleAlert }) .size(px(15.)) .mt(px(1.)) .text_color(if ready { p.green_ink } else { p.ink3 }), ) .child( v_flex() .min_w_0() .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(platform.label())) .child( div() .text_size(px(11.5)) .line_height(px(16.)) .text_color(p.ink3) .child(if compact && ready { "Ready".to_string() } else { message }), ), ) .into_any_element() } impl Render for DeviceSetup { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let state = self.backend.state(cx); let enabled = state.host_status != HostStatus::Disabled; let busy = state.busy(); let blocked = busy || self.pending.is_some(); let local_unavailable = !state.hosts.iter().any(|host| host.platforms.iter().any(|platform| platform.available)); let heading = |text: &'static str| div().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(text); let text = |text: &'static str| div().text_size(px(12.5)).line_height(px(18.)).text_color(p.ink3).child(text); let note = |text: &'static str| div().text_size(px(11.5)).line_height(px(16.)).text_color(p.ink3).child(text); let steps = h_flex().gap(px(6.)).children(STEPS.iter().enumerate().map(|(ix, label)| { let current = ix == self.step; let reachable = ix <= self.step && !blocked; h_flex() .id(("device-setup-step", ix)) .gap(px(6.)) .px(px(10.)) .h(px(26.)) .rounded_full() .text_size(px(12.)) .map(|this| { if current { this.bg(metal::vertical(p.cap)).shadow(p.btn.clone()).text_color(p.ink) } else { this.text_color(p.ink3) } }) .when(reachable && !current, |this| { this.cursor_pointer().on_click(cx.listener(move |this, _, _, cx| { this.step = ix; cx.notify(); })) }) .child(metal::keycap((ix + 1).to_string(), p)) .child(*label) })); let mut body = v_flex().gap(px(14.)).child(text("Review what runs on this machine before using simulators and emulators.")).child(steps); if self.step == 0 { let toggle = metal::toggle("device-setup-hub", enabled, p, { let weak = cx.weak_entity(); move |on, window, cx| { let _ = weak.update(cx, |this, cx| { if this.pending.is_none() && !this.backend.state(cx).busy() { this.configure(Pending::Hub, Some(on), (!on).then_some(false), None, window, cx); } }); } }); body = body.child( v_flex() .gap(px(10.)) .child(heading("Enable the device hub")) .child(h_flex().items_start().gap(px(16.)).child(text(HUB_DESCRIPTION).flex_1().min_w_0()).child(toggle)) .children(hub_status(&state, self.pending == Some(Pending::Hub) || (busy && self.pending != Some(Pending::Agent)), false, p)), ); } if self.step == 1 || (self.step == 0 && enabled && local_unavailable) { body = body.child( v_flex() .gap(px(10.)) .child(heading("Check simulator support")) .child(platform_line(&state, Platform::Ios, false, p)) .child(platform_line(&state, Platform::Android, false, p)) .child(note("You can use either platform. Fixing a missing platform does not block the other one.")) .child( h_flex().child( MetalButton::new("device-setup-check") .compact() .label(if self.pending == Some(Pending::Check) { "Checking…" } else { "Check again" }) .disabled(!enabled || blocked) .on_click(cx.listener(|this, _, _, cx| this.check(cx))), ), ), ); } if self.step == 2 { let toggle = metal::toggle("device-setup-agent", state.agent_access, p, { let weak = cx.weak_entity(); move |on, window, cx| { let _ = weak.update(cx, |this, cx| { let state = this.backend.state(cx); if this.pending.is_none() && !state.busy() && state.host_status != HostStatus::Disabled { this.configure(Pending::Agent, None, Some(on), None, window, cx); } }); } }); body = body.child( v_flex() .gap(px(10.)) .child(heading("Allow agent control")) .child(h_flex().items_start().gap(px(16.)).child(text(AGENT_DESCRIPTION).flex_1().min_w_0()).child(toggle)) .children(agent_status(&state, self.pending == Some(Pending::Agent), false, p)) .child(note("Leave this off to keep manual device controls without giving agents access.")), ); } if state.host_status == HostStatus::Failed && let Some(detail) = &state.host_detail { body = body.child(div().text_size(px(11.5)).line_height(px(16.)).text_color(p.red).child(detail.clone())); } let ready = state.host_status == HostStatus::Ready; let footer = h_flex() .pt(px(6.)) .justify_end() .gap(px(8.)) .child(if self.step == 0 { MetalButton::new("device-setup-cancel") .ghost() .compact() .label("Cancel") .on_click(|_, window, cx| window.close_dialog(cx)) .into_any_element() } else { MetalButton::new("device-setup-back") .ghost() .compact() .label("Back") .disabled(blocked) .on_click(cx.listener(|this, _, _, cx| { this.step = this.step.saturating_sub(1); cx.notify(); })) .into_any_element() }) .child(if self.step < 2 { MetalButton::new("device-setup-continue") .primary() .compact() .label("Continue") .disabled(!ready || self.pending.is_some()) .on_click(cx.listener(|this, _, _, cx| { this.step += 1; cx.notify(); })) .into_any_element() } else { MetalButton::new("device-setup-done") .primary() .compact() .label(if self.pending == Some(Pending::Complete) { "Saving…" } else { "Done" }) .disabled(!ready || self.pending.is_some()) .on_click(cx.listener(|this, _, window, cx| this.configure(Pending::Complete, None, None, Some(true), window, cx))) .into_any_element() }); v_flex().gap(px(16.)).child(body).child(footer) } }