//! 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: &str, active: bool, accent: Rgba) -> Div { let fg = if active { accent } else { theme::SUBTEXT0 }; let mut tab = div() .px(px(10.0)) .py(px(4.0)) .text_size(px(11.0)) .text_color(fg) .cursor_pointer() .hover(|s| s.text_color(theme::TEXT)); if active { tab = tab.border_b_2().border_color(accent); } tab.child(label.to_string()) } // ── ProjectBox View ───────────────────────────────────────────────── pub struct ProjectBox { pub project: Project, pub agent_pane: Option>, pub terminal_view: Option>, pub blink_state: 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, blink_state: 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 // Shared blink state — separate entity, ProjectBox reads it in render. // cx.notify() on BlinkState only dirties views that .read() it (ProjectBox), // NOT ancestors (Workspace, ProjectGrid) → siblings stay cached. let should_pulse = matches!(self.project.agent.status, AgentStatus::Running); if should_pulse { let blink = cx.new(|_cx| crate::components::blink_state::BlinkState::new()); crate::components::blink_state::BlinkState::start_from_context(&blink, cx); self.blink_state = 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 (upper portion) — cached: only re-renders on new messages if let Some(ref pane) = self.agent_pane { model_content = model_content.child( div() .flex_1() .w_full() .child(pane.clone().into_cached_flex()), ); } // Resize handle 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 view — cached: only re-renders on PTY output if let Some(ref term) = self.terminal_view { model_content = model_content.child( div() .w_full() .h(px(180.0)) .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 ────────────────────────────────────────── .child( div() .w_full() .h(px(36.0)) .flex() .flex_row() .items_center() .px(px(12.0)) .gap(px(8.0)) .bg(theme::MANTLE) .border_b_1() .border_color(theme::SURFACE0) // Accent stripe on left .child( div() .w(px(3.0)) .h(px(20.0)) .rounded(px(2.0)) .bg(accent), ) // Status dot — BlinkState shared entity (2 renders/sec, 3% CPU) // NOTE: GPUI's AnimationElement uses request_animation_frame() which // runs at vsync (60fps) = 80% CPU. Fragment shaders not exposed. // The Zed BlinkManager pattern (timer + notify at 2fps) is the // correct approach for ambient animation in GPUI. .child({ let blink_visible = self.blink_state.as_ref() .map(|bs| bs.read(cx).visible) .unwrap_or(true); let color = match self.project.agent.status { AgentStatus::Running if !blink_visible => theme::SURFACE1, AgentStatus::Running => theme::GREEN, AgentStatus::Idle => theme::OVERLAY0, AgentStatus::Done => theme::BLUE, AgentStatus::Error => theme::RED, }; div() .w(px(8.0)) .h(px(8.0)) .rounded(px(4.0)) .bg(color) .flex_shrink_0() }) // Project name .child( div() .text_size(px(13.0)) .text_color(theme::TEXT) .child(self.cached_name.clone()), ) .child(div().flex_1()) // CWD (ellipsized) .child( div() .text_size(px(10.0)) .text_color(theme::OVERLAY0) .max_w(px(200.0)) .overflow_hidden() .whitespace_nowrap() .child(self.cached_cwd.clone()), ), ) // ── Tab Bar ───────────────────────────────────────── .child( div() .w_full() .h(px(32.0)) .flex() .flex_row() .items_center() .px(px(8.0)) .gap(px(2.0)) .bg(theme::MANTLE) .border_b_1() .border_color(theme::SURFACE0) .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) } }