Vectrum
Sign In
330 lines · 13.8 KB · Rust
//! "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::{DeviceWatch, background};
use crate::device::{self, 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 {
    step: usize,
    pending: Option<Pending>,
    _watch: Subscription,
}

impl DeviceSetup {
    fn new(cx: &mut Context<Self>) -> Self {
        Self { step: 0, pending: None, _watch: cx.observe_global::<DeviceWatch>(|_, cx| cx.notify()) }
    }

    /// Opens the dialog. `on_dismiss` runs if it closes before setup is done.
    pub fn open(window: &mut Window, cx: &mut App) {
        Self::open_with(window, cx, None::<fn(&mut Window, &mut App)>);
    }

    pub fn open_with(window: &mut Window, cx: &mut App, on_dismiss: Option<impl Fn(&mut Window, &mut App) + 'static>) {
        let setup = cx.new(Self::new);
        let on_dismiss = on_dismiss.map(|f| std::rc::Rc::new(f) as std::rc::Rc<dyn Fn(&mut Window, &mut App)>);
        window.open_dialog(cx, move |dialog, _, _| {
            let on_dismiss = on_dismiss.clone();
            dialog
                .w(px(560.))
                .title("Set up devices")
                .child(setup.clone())
                .on_close(move |_: &ClickEvent, window: &mut Window, cx: &mut App| {
                    let done = {
                        let state = DeviceWatch::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<bool>,
        agent: Option<bool>,
        done: Option<bool>,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) {
        self.pending = Some(kind);
        cx.notify();
        cx.spawn_in(window, async move |this, cx| {
            let result = cx.background_executor().spawn(async move { device::service().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>) {
        self.pending = Some(Pending::Check);
        cx.notify();
        background(cx, || device::service().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<AnyElement> {
    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<AnyElement> {
    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<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let state = DeviceWatch::state(cx).clone();
        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() && !DeviceWatch::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 = DeviceWatch::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)
    }
}