Vectrum
Sign In
821 lines · 32.5 KB · Rust
//! 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<String, State>,
}

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::<DeviceWatch>().state = device::service().state();
                    cx.refresh_windows();
                });
            }
        })
        .detach();
    }

    /// This machine's devices.
    pub fn state(cx: &App) -> &State {
        &cx.global::<DeviceWatch>().state
    }
}

/// Runs blocking device work off the UI thread, then hands the result back.
pub(crate) fn background<V: 'static, T: Send + 'static>(
    cx: &mut Context<V>,
    work: impl FnOnce() -> T + Send + 'static,
    then: impl FnOnce(&mut V, T, &mut Context<V>) + '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<Pixels>);

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<String>,
    view: Option<Entity<DeviceView>>,
}

pub struct DevicePanel {
    /// The thread, by the id its devices' machine knows it by.
    thread_id: String,
    backend: Backend,
    tabs: Vec<Tab>,
    active: u64,
    next_id: u64,
    /// The device being opened from the list.
    pending: Option<DeviceSummary>,
    error: Option<SharedString>,
    renaming: Option<(u64, Entity<InputState>)>,
    /// The tab floating over the chat.
    floating: Option<u64>,
    visible: bool,
    float_visible: bool,
    /// Whether the devices were listed since the panel was last shown.
    listed: bool,
    _subscriptions: Vec<Subscription>,
}

impl EventEmitter<DevicePanelEvent> for DevicePanel {}

impl DevicePanel {
    pub fn new(thread_id: String, backend: Backend, _: &mut Window, cx: &mut Context<Self>) -> Self {
        let subscriptions = vec![cx.observe_global::<DeviceWatch>(|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<Self>) {
        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<Self>) {
        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<Self>) {
        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(&current.host_id, &current.id).cloned() {
                    view.update(cx, |view, cx| view.update_device(device, cx));
                }
            }
        }
        cx.notify();
    }

    pub fn floating_view(&self) -> Option<Entity<DeviceView>> {
        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<Self>) {
        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<Self>) {
        if self.floating.take().is_some() {
            cx.notify();
        }
    }

    fn view_for(&self, device_id: &str, cx: &App) -> Option<u64> {
        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<Self>) {
        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<Self>) {
        let ids: Vec<u64> = 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<DeviceView>, event: &DeviceViewEvent, window: &mut Window, cx: &mut Context<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) -> 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<Self>) -> 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<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let mut groups = Vec::new();
        for platform in [Platform::Ios, Platform::Android] {
            let mut devices: Vec<DeviceSummary> = 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<Self>) -> Vec<AnyElement> {
        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<String> =
            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<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        let width = cx.default_global::<DeviceWidth>().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<ResizeDevice>, _, 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)),
                    ),
            )
    }
}