//! ProjectBox: individual project card with header, tab bar, content area. //! //! Each project gets a card in the grid with: //! - Header: name, status dot, CWD //! - Tab bar: Model / Docs / Files //! - Content area: AgentPane (Model tab), placeholder (Docs/Files) //! - Terminal section in Model tab use gpui::*; use crate::CachedView; use crate::components::project_box_element::ProjectBoxHeaderElement; use crate::state::{AgentStatus, Project, ProjectTab}; use crate::theme; // blink_state used via fully-qualified path in init_subviews + render // ── Accent Colors by Index ────────────────────────────────────────── fn accent_color(index: usize) -> Rgba { const ACCENTS: [Rgba; 8] = [ theme::BLUE, theme::MAUVE, theme::GREEN, theme::PEACH, theme::PINK, theme::TEAL, theme::SAPPHIRE, theme::LAVENDER, ]; ACCENTS[index % ACCENTS.len()] } // ── Status Indicator ──────────────────────────────────────────────── fn project_status_dot(status: AgentStatus) -> Div { let color = match status { AgentStatus::Idle => theme::OVERLAY0, AgentStatus::Running => theme::GREEN, AgentStatus::Done => theme::BLUE, AgentStatus::Error => theme::RED, }; div() .w(px(8.0)) .h(px(8.0)) .rounded(px(4.0)) .bg(color) } // ── Tab Button ────────────────────────────────────────────────────── fn tab_button(label: &'static str, active: bool, accent: Rgba) -> Div { let mut tab = div() .px(px(8.0)) .py(px(4.0)) .text_color(if active { accent } else { theme::SUBTEXT0 }) .cursor_pointer(); if active { tab = tab.border_b_2().border_color(accent); } tab.child(label) // &'static str → no allocation } // ── ProjectBox View ───────────────────────────────────────────────── pub struct ProjectBox { pub project: Project, pub agent_pane: Option>, pub terminal_view: Option>, pub shared_blink: Option, // Cached strings to avoid allocation on every render id_project: SharedString, id_model: SharedString, id_resize: SharedString, id_docs: SharedString, id_files: SharedString, cached_name: SharedString, cached_cwd: SharedString, } impl ProjectBox { pub fn new(project: Project) -> Self { let id = &project.id; Self { id_project: SharedString::from(format!("project-{id}")), id_model: SharedString::from(format!("model-{id}")), id_resize: SharedString::from(format!("resize-{id}")), id_docs: SharedString::from(format!("docs-{id}")), id_files: SharedString::from(format!("files-{id}")), cached_name: SharedString::from(project.name.clone()), cached_cwd: SharedString::from(project.cwd.clone()), project, agent_pane: None, terminal_view: None, shared_blink: None, } } /// Initialize sub-views. Must be called after the ProjectBox entity is created. pub fn init_subviews(&mut self, cx: &mut Context) { // Initialize sub-views after entity registration // SharedBlink: Arc toggled by background timer. // Timer calls cx.notify() on THIS ProjectBox directly — no intermediate entities. // mark_view_dirty walks: ProjectBox → Workspace (2 levels only). let should_pulse = matches!(self.project.agent.status, AgentStatus::Running) && self.project.accent_index == 0; if should_pulse { let blink = crate::components::blink_state::SharedBlink::new(); // Get our own entity handle to pass to the timer let self_entity = cx.entity().downgrade(); let visible = blink.visible.clone(); cx.spawn(async move |_: WeakEntity, cx: &mut AsyncApp| { loop { cx.background_executor().timer(std::time::Duration::from_millis(500)).await; visible.fetch_xor(true, std::sync::atomic::Ordering::Relaxed); let ok = self_entity.update(cx, |_, cx| cx.notify()); if ok.is_err() { break; } } }).detach(); self.shared_blink = Some(blink); } // Create agent pane with demo messages let agent_pane = cx.new(|_cx| { crate::components::agent_pane::AgentPane::with_demo_messages() }); self.agent_pane = Some(agent_pane); // Create terminal view with demo content let terminal_view = cx.new(|_cx| { let mut tv = crate::terminal::renderer::TerminalView::new(120, 10); tv.feed_demo(); tv }); self.terminal_view = Some(terminal_view); } } impl Render for ProjectBox { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let accent = accent_color(self.project.accent_index); let active_tab = self.project.active_tab; // Build content area based on active tab let content = match active_tab { ProjectTab::Model => { let mut model_content = div() .id(self.id_model.clone()) .flex_1() .w_full() .flex() .flex_col(); // Agent pane — cached (0% on blink) if let Some(ref pane) = self.agent_pane { model_content = model_content.child(pane.clone().into_cached_flex()); } // Resize handle (1 div) model_content = model_content.child( div().id(self.id_resize.clone()).w_full().h(px(4.0)) .bg(theme::SURFACE0).cursor_pointer() .hover(|s| s.bg(theme::SURFACE1)), ); // Terminal — cached (0% on blink) if let Some(ref term) = self.terminal_view { model_content = model_content.child(term.clone().into_cached_flex()); } model_content } ProjectTab::Docs => { div() .id(self.id_docs.clone()) .flex_1() .w_full() .flex() .items_center() .justify_center() .text_size(px(14.0)) .text_color(theme::OVERLAY0) .child("Documentation viewer — renders project markdown files") } ProjectTab::Files => { div() .id(self.id_files.clone()) .flex_1() .w_full() .flex() .flex_col() .p(px(12.0)) .gap(px(4.0)) .child( div() .text_size(px(12.0)) .text_color(theme::SUBTEXT0) .child("src/"), ) .child( div() .pl(px(16.0)) .text_size(px(12.0)) .text_color(theme::TEXT) .child("main.rs"), ) .child( div() .pl(px(16.0)) .text_size(px(12.0)) .text_color(theme::TEXT) .child("lib.rs"), ) .child( div() .text_size(px(12.0)) .text_color(theme::SUBTEXT0) .child("Cargo.toml"), ) } }; div() .id(self.id_project.clone()) .flex_1() .min_w(px(400.0)) .min_h(px(300.0)) .flex() .flex_col() .bg(theme::BASE) .rounded(px(8.0)) .border_1() .border_color(theme::SURFACE0) .overflow_hidden() // ── Header — custom Element: 5 paint_quad + 2 text runs, zero Taffy nodes ── .child(ProjectBoxHeaderElement { id: self.id_project.clone().into(), name: self.cached_name.clone(), cwd: self.cached_cwd.clone(), accent, status: self.project.agent.status, blink_visible: self.shared_blink.as_ref().map(|b| b.visible.clone()), }) // ── Tab Bar (1 div + 3 inline tab labels) ── .child( div() .w_full() .h(px(28.0)) .flex() .items_center() .px(px(8.0)) .gap(px(2.0)) .bg(theme::MANTLE) .border_b_1() .border_color(theme::SURFACE0) .text_size(px(11.0)) .child(tab_button("Model", active_tab == ProjectTab::Model, accent)) .child(tab_button("Docs", active_tab == ProjectTab::Docs, accent)) .child(tab_button("Files", active_tab == ProjectTab::Files, accent)), ) // ── Content Area ──────────────────────────────────── .child(content) } }