//! 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::state::{AgentStatus, Project, ProjectTab}; use crate::theme; use crate::components::pulsing_dot::{PulsingDot, DotStatus}; // ── 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 status_dot_view: 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, status_dot_view: 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 // StatusDotView: tiny Entity that reads BlinkState. // ProjectBox does NOT read BlinkState → doesn't re-render on blink. // Only StatusDotView (1 div) re-renders 2x/sec. // Focus-gated blink: only the first Running project blinks (like Zed's single-focus model). // In production, this would be gated on project focus state. let should_pulse = matches!(self.project.agent.status, AgentStatus::Running) && self.project.accent_index == 0; // Only first project blinks let blink = if should_pulse { let b = cx.new(|_cx| crate::components::blink_state::BlinkState::new()); crate::components::blink_state::BlinkState::start_from_context(&b, cx); Some(b) } else { None }; let status = self.project.agent.status; let dot_view = cx.new(|_cx| { crate::components::blink_state::StatusDotView::new(status, blink) }); self.status_dot_view = Some(dot_view); // 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 (minimal divs: 1 row + accent stripe + dot entity) ── .child( div() .w_full() .h(px(36.0)) .flex() .items_center() .px(px(12.0)) .gap(px(8.0)) .bg(theme::MANTLE) .border_b_1() .border_color(theme::SURFACE0) .child(div().w(px(3.0)).h(px(20.0)).rounded(px(2.0)).bg(accent)) .children(self.status_dot_view.clone()) .child(self.cached_name.clone()) .child(div().flex_1()) .child(self.cached_cwd.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) } }