diff --git a/Cargo.lock b/Cargo.lock index 6093e158e0..b47bfe2d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12650,6 +12650,7 @@ dependencies = [ "paddleboard_actions", "paddleboard_env_vars", "paddleboard_sandbox_prereqs", + "paddleboard_sandbox_prereqs_ui", "parking_lot", "paths", "picker", @@ -12763,6 +12764,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "paddleboard_sandbox_prereqs_ui" +version = "0.1.0" +dependencies = [ + "gpui", + "gpui_tokio", + "menu", + "paddleboard_sandbox_prereqs", + "ui", + "workspace", +] + [[package]] name = "page_size" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index 03af07e533..b0c945c1ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -226,6 +226,7 @@ members = [ "crates/paddleboard_credentials_provider", "crates/paddleboard_env_vars", "crates/paddleboard_sandbox_prereqs", + "crates/paddleboard_sandbox_prereqs_ui", "crates/zeta_prompt", "crates/zlog", "crates/zlog_settings", @@ -479,6 +480,7 @@ paddleboard_actions = { path = "crates/paddleboard_actions" } paddleboard_credentials_provider = { path = "crates/paddleboard_credentials_provider" } paddleboard_env_vars = { path = "crates/paddleboard_env_vars" } paddleboard_sandbox_prereqs = { path = "crates/paddleboard_sandbox_prereqs" } +paddleboard_sandbox_prereqs_ui = { path = "crates/paddleboard_sandbox_prereqs_ui" } edit_prediction = { path = "crates/edit_prediction" } zeta_prompt = { path = "crates/zeta_prompt" } zlog = { path = "crates/zlog" } diff --git a/RECAPS.md b/RECAPS.md index d9878c4976..757e3cd8b7 100644 --- a/RECAPS.md +++ b/RECAPS.md @@ -6,6 +6,14 @@ Running log of completed work sessions, newest first. Each entry summarizes a co ## 2026-05-14 +### Sandbox prerequisites UI (PR-B: visibility unit) +- New crate `paddleboard_sandbox_prereqs_ui` (~350 lines) layered on PR-A's data layer. Three pieces in one file: `SandboxPrereqs` (a `gpui::Global` holding the latest `SandboxStatus` + a `refreshing` flag), `SandboxStatusItem` (status-bar entry — colored shield icon), `SandboxPrereqsModal` (full install-guidance UI with per-step Copy buttons + Refresh). +- Async-to-GPUI bridge uses `gpui_tokio::Tokio::spawn(cx, async { check().await })`. The probe runs on tokio's pool (needed for `tokio::process::Command`); the result is written back to the global via `cx.update_global` on the foreground thread, which automatically notifies observers so the status bar + modal re-render. +- Severity model: `Unknown` while initial probe is in flight, `Ok` when podman + gVisor are both satisfied (or gVisor inapplicable on Windows), `Warning` when podman is ready but gVisor isn't configured, `Error` when podman is missing or unreachable. +- Wire-up: `paddleboard_sandbox_prereqs_ui::init(cx)` in `paddleboard/src/main.rs` right after `gpui_tokio::init` — registers the global, kicks off the first probe, and `cx.observe_new` wires the `paddleboard::OpenSandboxPrereqs` action into every workspace as it opens. Status item is registered in `paddleboard/src/zed.rs`'s `initialize_workspace`, tagged with two `// PaddleBoard:` markers (declaration site + status_bar.add_right_item). +- v0.1 followup (PR-C, future session): tool gating + settings (`sandbox.on_missing_runtime`: block / fall-back-to-host / warn-once) — the enforcement unit, requires editing three tool entry points (sandbox_tool, sandbox_service_tool, sandboxed_stdio_transport). +- Verified: `cargo check -p paddleboard` clean; `./script/clippy -p paddleboard_sandbox_prereqs_ui -p paddleboard_sandbox_prereqs` clean. The pre-existing `llm_picker` clippy failure on `main` is unrelated. + ### Sandbox prerequisites detection (PR-A of two) - New crate `paddleboard_sandbox_prereqs` with `check() -> SandboxStatus`. Probes `podman --version`, `podman info --format json`, and parses the JSON for `host.ociRuntimes.runsc`. Each probe is timeout-bounded (2s) so a stuck `podman machine` cannot stall startup. - Three variants per dimension: `PodmanStatus` (Missing / InstalledNotRunning / Ready) and `GvisorStatus` (Available / NotConfigured / NotApplicable / Unknown). On macOS the InstalledNotRunning state is the common case when `podman machine` is stopped. diff --git a/crates/paddleboard/Cargo.toml b/crates/paddleboard/Cargo.toml index a2cfe2a2c1..c88d0a49ab 100644 --- a/crates/paddleboard/Cargo.toml +++ b/crates/paddleboard/Cargo.toml @@ -224,6 +224,7 @@ workspace.workspace = true paddleboard_actions.workspace = true paddleboard_env_vars.workspace = true paddleboard_sandbox_prereqs.workspace = true +paddleboard_sandbox_prereqs_ui.workspace = true tokio = { workspace = true, features = ["rt"] } zlog.workspace = true zlog_settings.workspace = true diff --git a/crates/paddleboard/src/main.rs b/crates/paddleboard/src/main.rs index fd71ac5d9f..2b2ff8a69c 100644 --- a/crates/paddleboard/src/main.rs +++ b/crates/paddleboard/src/main.rs @@ -473,6 +473,7 @@ fn main() { release_channel::init(app_version, cx); gpui_tokio::init(cx); + paddleboard_sandbox_prereqs_ui::init(cx); if let Some(app_commit_sha) = app_commit_sha { AppCommitSha::set_global(app_commit_sha, cx); } diff --git a/crates/paddleboard/src/zed.rs b/crates/paddleboard/src/zed.rs index 2725461afc..64370ac2ce 100644 --- a/crates/paddleboard/src/zed.rs +++ b/crates/paddleboard/src/zed.rs @@ -502,6 +502,9 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { let cursor_position = cx.new(|_| go_to_line::cursor_position::CursorPosition::new(workspace)); + // PaddleBoard: surface sandbox prerequisites (Podman + gVisor) in the status bar. + let sandbox_status_item = + cx.new(paddleboard_sandbox_prereqs_ui::SandboxStatusItem::new); let line_ending_indicator = cx.new(|_| line_ending_selector::LineEndingIndicator::default()); let merge_conflict_indicator = @@ -521,6 +524,8 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut App) { status_bar.add_right_item(vim_mode_indicator, window, cx); status_bar.add_right_item(cursor_position, window, cx); status_bar.add_right_item(image_info, window, cx); + // PaddleBoard: sandbox prerequisites indicator (Podman + gVisor). + status_bar.add_right_item(sandbox_status_item, window, cx); }); let panels_task = initialize_panels(window, cx); diff --git a/crates/paddleboard_sandbox_prereqs_ui/Cargo.toml b/crates/paddleboard_sandbox_prereqs_ui/Cargo.toml new file mode 100644 index 0000000000..93d148ab55 --- /dev/null +++ b/crates/paddleboard_sandbox_prereqs_ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "paddleboard_sandbox_prereqs_ui" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/paddleboard_sandbox_prereqs_ui.rs" + +[dependencies] +gpui.workspace = true +gpui_tokio.workspace = true +menu.workspace = true +paddleboard_sandbox_prereqs.workspace = true +ui.workspace = true +workspace.workspace = true diff --git a/crates/paddleboard_sandbox_prereqs_ui/src/paddleboard_sandbox_prereqs_ui.rs b/crates/paddleboard_sandbox_prereqs_ui/src/paddleboard_sandbox_prereqs_ui.rs new file mode 100644 index 0000000000..535eb43e96 --- /dev/null +++ b/crates/paddleboard_sandbox_prereqs_ui/src/paddleboard_sandbox_prereqs_ui.rs @@ -0,0 +1,386 @@ +//! UI surface for PaddleBoard's sandbox prerequisites (Podman + gVisor `runsc`). +//! +//! Three pieces hang together here: +//! * `SandboxPrereqs` — `gpui::Global` holding the latest `SandboxStatus`. +//! Updated asynchronously via a Tokio-bridged probe; views observe it and +//! re-render on change. +//! * `SandboxStatusItem` — a status-bar entry showing a shield icon colored +//! by severity. Click dispatches `OpenSandboxPrereqs`. +//! * `SandboxPrereqsModal` — the full status + install-steps view with a +//! per-step copy-to-clipboard button and a Refresh control. + +use gpui::{ + Action, App, ClickEvent, ClipboardItem, DismissEvent, EventEmitter, FocusHandle, Focusable, + MouseDownEvent, Render, SharedString, actions, +}; +use gpui_tokio::Tokio; +use paddleboard_sandbox_prereqs::{GvisorStatus, Os, PodmanStatus, SandboxStatus}; +use ui::{Tooltip, prelude::*}; +use workspace::{ModalView, StatusItemView, Workspace}; + +actions!( + paddleboard, + [ + /// Opens the sandbox prerequisites status modal. + OpenSandboxPrereqs + ] +); + +/// Initialize the UI surface. Registers the `SandboxPrereqs` global, kicks +/// off the first probe in the background, and wires the `OpenSandboxPrereqs` +/// action to every workspace as it opens. +pub fn init(cx: &mut App) { + SandboxPrereqs::init(cx); + + cx.observe_new(|workspace: &mut Workspace, _window, _cx| { + workspace.register_action(|workspace, _: &OpenSandboxPrereqs, window, cx| { + SandboxPrereqsModal::toggle(workspace, window, cx); + }); + }) + .detach(); +} + +#[derive(Default)] +pub struct SandboxPrereqs { + status: Option, + refreshing: bool, +} + +impl gpui::Global for SandboxPrereqs {} + +impl SandboxPrereqs { + fn init(cx: &mut App) { + cx.set_global(SandboxPrereqs::default()); + Self::refresh(cx); + } + + pub fn status(cx: &App) -> Option<&SandboxStatus> { + cx.global::().status.as_ref() + } + + pub fn is_refreshing(cx: &App) -> bool { + cx.global::().refreshing + } + + pub fn refresh(cx: &mut App) { + cx.update_global::(|prereqs, _| { + prereqs.refreshing = true; + }); + + let task = Tokio::spawn(cx, async { paddleboard_sandbox_prereqs::check().await }); + + cx.spawn(async move |cx| { + let status = task.await.ok(); + cx.update(|cx| { + cx.update_global::(|prereqs, _| { + if let Some(status) = status { + prereqs.status = Some(status); + } + prereqs.refreshing = false; + }); + }); + }) + .detach(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Severity { + Unknown, + Ok, + Warning, + Error, +} + +impl Severity { + fn from_status(status: Option<&SandboxStatus>) -> Severity { + let Some(status) = status else { + return Severity::Unknown; + }; + match (&status.podman, &status.gvisor) { + (PodmanStatus::Missing, _) | (PodmanStatus::InstalledNotRunning { .. }, _) => { + Severity::Error + } + (PodmanStatus::Ready { .. }, GvisorStatus::Available) + | (PodmanStatus::Ready { .. }, GvisorStatus::NotApplicable { .. }) => Severity::Ok, + (PodmanStatus::Ready { .. }, _) => Severity::Warning, + } + } + + fn color(self) -> Color { + match self { + Severity::Unknown => Color::Muted, + Severity::Ok => Color::Success, + Severity::Warning => Color::Warning, + Severity::Error => Color::Error, + } + } +} + +pub struct SandboxStatusItem; + +impl SandboxStatusItem { + pub fn new(cx: &mut Context) -> Self { + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + Self + } +} + +impl StatusItemView for SandboxStatusItem { + fn set_active_pane_item( + &mut self, + _active_pane_item: Option<&dyn workspace::ItemHandle>, + _window: &mut Window, + _cx: &mut Context, + ) { + } +} + +impl Render for SandboxStatusItem { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let status = SandboxPrereqs::status(cx); + let severity = Severity::from_status(status); + let tooltip_text = severity_tooltip(severity, status); + + IconButton::new("sandbox-prereqs-status", IconName::Box) + .icon_size(IconSize::Small) + .icon_color(severity.color()) + .tooltip(move |_window, cx| { + Tooltip::for_action(tooltip_text.clone(), &OpenSandboxPrereqs, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(OpenSandboxPrereqs.boxed_clone(), cx); + }) + } +} + +fn severity_tooltip(severity: Severity, status: Option<&SandboxStatus>) -> SharedString { + match severity { + Severity::Unknown => "Sandbox: checking…".into(), + Severity::Ok => "Sandbox: ready".into(), + Severity::Warning => match status.map(|s| &s.gvisor) { + Some(GvisorStatus::NotConfigured) => "Sandbox: gVisor not configured".into(), + _ => "Sandbox: degraded".into(), + }, + Severity::Error => match status.map(|s| &s.podman) { + Some(PodmanStatus::Missing) => "Sandbox: Podman not installed".into(), + Some(PodmanStatus::InstalledNotRunning { .. }) => "Sandbox: Podman not running".into(), + _ => "Sandbox: unavailable".into(), + }, + } +} + +pub struct SandboxPrereqsModal { + focus_handle: FocusHandle, +} + +impl SandboxPrereqsModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + workspace.toggle_modal(window, cx, |_window, cx| { + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + SandboxPrereqsModal { + focus_handle: cx.focus_handle(), + } + }); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for SandboxPrereqsModal {} + +impl Focusable for SandboxPrereqsModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for SandboxPrereqsModal {} + +impl Render for SandboxPrereqsModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let status = SandboxPrereqs::status(cx).cloned(); + let refreshing = SandboxPrereqs::is_refreshing(cx); + let os = Os::detect(); + let instructions = status + .as_ref() + .map(|s| paddleboard_sandbox_prereqs::install_instructions(s, os)); + + // Header row with title + close button. + let header = h_flex() + .w_full() + .justify_between() + .child(Headline::new("Sandbox Prerequisites").size(HeadlineSize::Medium)) + .child( + IconButton::new("sandbox-prereqs-close", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + cx.emit(DismissEvent); + }, + )), + ); + + // Status rows for Podman + gVisor. + let podman_row = { + let (icon, color, label): (IconName, Color, SharedString) = match status + .as_ref() + .map(|s| &s.podman) + { + None => (IconName::Ellipsis, Color::Muted, "Podman: checking…".into()), + Some(PodmanStatus::Missing) => ( + IconName::XCircle, + Color::Error, + "Podman: not found on PATH".into(), + ), + Some(PodmanStatus::InstalledNotRunning { version }) => ( + IconName::Warning, + Color::Warning, + format!("Podman: {version} — daemon unreachable").into(), + ), + Some(PodmanStatus::Ready { version }) => ( + IconName::Check, + Color::Success, + format!("Podman: {version}").into(), + ), + }; + h_flex() + .gap_2() + .child(Icon::new(icon).color(color).size(IconSize::Small)) + .child(Label::new(label)) + }; + let gvisor_row = { + let (icon, color, label): (IconName, Color, SharedString) = match status + .as_ref() + .map(|s| &s.gvisor) + { + None => (IconName::Ellipsis, Color::Muted, "gVisor: checking…".into()), + Some(GvisorStatus::Available) => ( + IconName::Check, + Color::Success, + "gVisor: runsc registered with Podman".into(), + ), + Some(GvisorStatus::NotConfigured) => ( + IconName::XCircle, + Color::Warning, + "gVisor: runsc not registered with Podman".into(), + ), + Some(GvisorStatus::NotApplicable { reason }) => ( + IconName::Info, + Color::Muted, + format!("gVisor: {reason}").into(), + ), + Some(GvisorStatus::Unknown) => ( + IconName::Ellipsis, + Color::Muted, + "gVisor: status unknown (Podman unreachable)".into(), + ), + }; + h_flex() + .gap_2() + .child(Icon::new(icon).color(color).size(IconSize::Small)) + .child(Label::new(label)) + }; + let status_block = v_flex().gap_1().child(podman_row).child(gvisor_row); + + // Install steps block. Each step is a numbered description; if the + // step carries a copy-pasteable command we render it inline as a + // styled monospace block with a Copy button on the right. + let bg_color = cx.theme().colors().editor_background; + let instructions_block = instructions.map(|inst| { + let title = inst.title.clone(); + let doc_url = inst.doc_url; + v_flex() + .gap_3() + .child(Headline::new(title).size(HeadlineSize::XSmall)) + .children(inst.steps.into_iter().enumerate().map(|(i, step)| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .items_start() + .child( + Label::new(format!("{}.", i + 1)) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Label::new(step.description).size(LabelSize::Small)), + ) + .when_some(step.command, |this, command| { + let command_for_copy = command.clone(); + this.child( + h_flex() + .pl_4() + .gap_1() + .items_start() + .child( + div() + .flex_1() + .px_2() + .py_1() + .rounded_sm() + .bg(bg_color) + .child( + Label::new(SharedString::from(command)) + .size(LabelSize::Small), + ), + ) + .child( + IconButton::new( + SharedString::from(format!("sandbox-copy-{i}")), + IconName::Copy, + ) + .icon_size(IconSize::Small) + .on_click(cx.listener(move |_, _, _window, cx| { + cx.write_to_clipboard(ClipboardItem::new_string( + command_for_copy.clone(), + )); + })), + ), + ) + }) + })) + .when_some(doc_url, |this, url| { + this.child( + Label::new(format!("More: {url}")) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + }); + + let refresh_button = Button::new( + "sandbox-prereqs-refresh", + if refreshing { "Checking…" } else { "Refresh" }, + ) + .disabled(refreshing) + .on_click(|_, _window, cx| SandboxPrereqs::refresh(cx)); + + v_flex() + .id("sandbox-prereqs-modal") + .key_context("SandboxPrereqsModal") + .w(rems(36.)) + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .on_action(cx.listener(Self::cancel)) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| { + this.focus_handle.focus(window, cx); + })) + .child(v_flex().p_4().gap_3().child(header).child(status_block)) + .child(ui::Divider::horizontal()) + .child( + v_flex() + .id("sandbox-prereqs-steps") + .p_4() + .gap_3() + .max_h(rems(24.)) + .overflow_y_scroll() + .children(instructions_block), + ) + .child(ui::Divider::horizontal()) + .child(h_flex().p_3().justify_end().child(refresh_button)) + } +}