agent-orchestrator/ui-gpui/src/components/project_box.rs
Hibryda ddec12db3f perf(ui-gpui): simplify ProjectBox render, confirm 3% is hierarchy depth cost
Each blink = ~15ms CPU (1-2 ticks/500ms). Cost is Entity dispatch tree
walk through 4 levels (Workspace→ProjectGrid→ProjectBox→StatusDotView),
not div count. Zed achieves ~5ms with 3 levels. To match: flatten hierarchy.
2026-03-19 23:31:28 +01:00

267 lines
10 KiB
Rust

//! 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<Entity<crate::components::agent_pane::AgentPane>>,
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
pub status_dot_view: Option<Entity<crate::components::blink_state::StatusDotView>>,
// 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<Self>) {
// 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<Self>) -> 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)
}
}