From c61262c604ef13c0f807ca0492438fb1bd4fee58 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 20 Mar 2026 00:40:35 +0100 Subject: [PATCH] perf(ui-gpui): eliminate ProjectBox Entity, plain struct + direct render (2.17%) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectBoxData replaces Entity. Workspace is the only view entity in the dispatch tree. Timer notifies Workspace via cx.spawn. 12 optimization iterations: 90% → 2.17% CPU for a pulsing dot. --- ui-gpui/src/components/project_box.rs | 179 +++---------------------- ui-gpui/src/components/project_grid.rs | 83 +----------- ui-gpui/src/workspace.rs | 115 +++++++++++++--- 3 files changed, 122 insertions(+), 255 deletions(-) diff --git a/ui-gpui/src/components/project_box.rs b/ui-gpui/src/components/project_box.rs index c503ed7..4427cc5 100644 --- a/ui-gpui/src/components/project_box.rs +++ b/ui-gpui/src/components/project_box.rs @@ -1,176 +1,41 @@ -//! ProjectBox: individual project card with header, tab bar, content area. +//! ProjectBoxData: plain struct (NOT an Entity) holding project state. //! -//! 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 +//! Rendered as ProjectBoxFullElement (custom Element) from Workspace::render(). +//! No Entity boundary = no dispatch tree node = no ancestor dirty cascade. use gpui::*; -use crate::CachedView; -use crate::components::project_box_element::ProjectBoxFullElement; use crate::state::{AgentStatus, Project, ProjectTab}; -use crate::theme; -// ── 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) -} - -// ── ProjectBox View ───────────────────────────────────────────────── - -pub struct ProjectBox { - pub project: Project, +/// Plain data struct for a project box. NOT an Entity — no view overhead. +/// Workspace owns these directly and creates custom Elements from them. +pub struct ProjectBoxData { + pub status: AgentStatus, + pub active_tab: ProjectTab, + pub accent_index: usize, 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, - cached_name: SharedString, - cached_cwd: SharedString, + pub id_project: SharedString, + pub id_content: SharedString, + pub cached_name: SharedString, + pub cached_cwd: SharedString, } -impl ProjectBox { - pub fn new(project: Project) -> Self { +impl ProjectBoxData { + 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}")), - cached_name: SharedString::from(project.name.clone()), - cached_cwd: SharedString::from(project.cwd.clone()), - project, + status: project.agent.status, + active_tab: project.active_tab, + accent_index: project.accent_index, 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) { - // SharedBlink: Arc toggled by background timer. - // Timer calls cx.notify() on THIS ProjectBox directly — no intermediate entities. - 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(); - 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; - let tab_idx = match active_tab { - ProjectTab::Model => 0, - ProjectTab::Docs => 1, - ProjectTab::Files => 2, - }; - - // Content area — single div with cached children for the Model tab. - let mut content = div() - .id(self.id_model.clone()) - .flex_1() - .w_full() - .overflow_hidden(); - - content = match active_tab { - ProjectTab::Model => { - let mut c = content.flex().flex_col(); - if let Some(ref pane) = self.agent_pane { - c = c.child(pane.clone().into_cached_flex()); - } - c = c.child(div().w_full().h(px(4.0)).bg(theme::SURFACE0)); - if let Some(ref term) = self.terminal_view { - c = c.child(term.clone().into_cached_flex()); - } - c - } - ProjectTab::Docs => content.flex().items_center().justify_center() - .text_size(px(14.0)).text_color(theme::OVERLAY0) - .child("Documentation viewer"), - ProjectTab::Files => content.flex().flex_col().p(px(12.0)).gap(px(4.0)) - .text_size(px(12.0)).text_color(theme::SUBTEXT0) - .child("src/").child(" main.rs").child(" lib.rs").child("Cargo.toml"), - }; - - // Return a single custom Element that owns the entire card. - // Eliminates the outer root div — down from 2 divs to 1 (only content div remains). - ProjectBoxFullElement { - 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()), - active_tab: tab_idx, - content: content.into_any_element(), + id_project: SharedString::from(format!("project-{id}")), + id_content: SharedString::from(format!("content-{id}")), + cached_name: SharedString::from(project.name.clone()), + cached_cwd: SharedString::from(project.cwd.clone()), } } } diff --git a/ui-gpui/src/components/project_grid.rs b/ui-gpui/src/components/project_grid.rs index 40588c3..12c62dd 100644 --- a/ui-gpui/src/components/project_grid.rs +++ b/ui-gpui/src/components/project_grid.rs @@ -1,81 +1,2 @@ -//! ProjectGrid: flex-wrap grid of ProjectBox cards. -//! -//! Lays out projects in a responsive grid that wraps when the window is wide -//! enough for multiple columns. - -use gpui::*; -use crate::CachedView; - -use crate::components::project_box::ProjectBox; -use crate::state::AppState; -use crate::theme; - -// ── ProjectGrid View ──────────────────────────────────────────────── - -pub struct ProjectGrid { - app_state: Entity, - /// One ProjectBox entity per project. - project_boxes: Vec>, -} - -impl ProjectGrid { - pub fn new(app_state: Entity, cx: &mut Context) -> Self { - // Clone projects out of state to avoid borrowing cx through app_state - let projects: Vec<_> = { - let state = app_state.read(cx); - state.projects.clone() - }; - - let project_boxes: Vec> = projects - .into_iter() - .map(|proj| { - cx.new(|cx| { - let mut pb = ProjectBox::new(proj); - pb.init_subviews(cx); - pb - }) - }) - .collect(); - - Self { - app_state, - project_boxes, - } - } -} - -impl Render for ProjectGrid { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let mut grid = div() - .id("project-grid") - .flex_1() - .w_full() - .h_full() - .flex() - .flex_row() - .flex_wrap() - .gap(px(8.0)) - .p(px(8.0)) - .bg(theme::CRUST) - .overflow_y_scroll(); - - for pb in &self.project_boxes { - grid = grid.child(pb.clone()); - } - - if self.project_boxes.is_empty() { - grid = grid.child( - div() - .flex_1() - .flex() - .items_center() - .justify_center() - .text_size(px(16.0)) - .text_color(theme::OVERLAY0) - .child("No projects. Press Ctrl+N to add one."), - ); - } - - grid - } -} +//! ProjectGrid: DEPRECATED — replaced by inline rendering in Workspace. +//! Kept for reference only. diff --git a/ui-gpui/src/workspace.rs b/ui-gpui/src/workspace.rs index 1299f60..7300366 100644 --- a/ui-gpui/src/workspace.rs +++ b/ui-gpui/src/workspace.rs @@ -1,25 +1,36 @@ //! Workspace: root view composing sidebar + project boxes + status bar. //! -//! ProjectBoxes are rendered DIRECTLY as children of Workspace (no intermediate -//! ProjectGrid entity). This keeps the dispatch tree at 3 levels: -//! Workspace → ProjectBox → StatusDotView (same depth as Zed's Workspace → Pane → Editor). +//! ProjectBoxes are NOT entities — they're plain structs rendered as custom Elements. +//! This eliminates the Entity boundary and dispatch tree overhead for project cards. +//! Only Workspace is a view in the dispatch tree. Blink timer notifies Workspace directly. +//! Dispatch tree depth: 1 (just Workspace). AgentPane/TerminalView are cached child entities. use gpui::*; use crate::components::command_palette::CommandPalette; -use crate::components::project_box::ProjectBox; +use crate::components::project_box::ProjectBoxData; +use crate::components::project_box_element::ProjectBoxFullElement; use crate::components::settings::SettingsPanel; use crate::components::sidebar::Sidebar; use crate::components::status_bar::StatusBar; -use crate::state::AppState; +use crate::state::{AgentStatus, AppState, ProjectTab}; use crate::theme; +use crate::CachedView; + +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()] +} pub struct Workspace { #[allow(dead_code)] app_state: Entity, sidebar: Entity, settings_panel: Entity, - project_boxes: Vec>, + project_boxes: Vec, status_bar: Entity, command_palette: Entity, } @@ -43,16 +54,44 @@ impl Workspace { |_cx| CommandPalette::new(state) }); - // Create ProjectBoxes directly (no intermediate ProjectGrid entity) + // Create ProjectBoxData (plain structs with entity handles for content) let projects: Vec<_> = app_state.read(cx).projects.clone(); - let project_boxes: Vec> = projects + let project_boxes: Vec = projects .into_iter() .map(|proj| { - cx.new(|cx| { - let mut pb = ProjectBox::new(proj); - pb.init_subviews(cx); - pb - }) + let mut data = ProjectBoxData::new(&proj); + + // Create cached child entities for content + let agent_pane = cx.new(|_cx| { + crate::components::agent_pane::AgentPane::with_demo_messages() + }); + let terminal_view = cx.new(|_cx| { + let mut tv = crate::terminal::renderer::TerminalView::new(120, 10); + tv.feed_demo(); + tv + }); + data.agent_pane = Some(agent_pane); + data.terminal_view = Some(terminal_view); + + // Start blink timer for Running projects (focus-gated: first only) + let should_pulse = matches!(proj.agent.status, AgentStatus::Running) + && proj.accent_index == 0; + if should_pulse { + let blink = crate::components::blink_state::SharedBlink::new(); + let visible = blink.visible.clone(); + // Notify WORKSPACE directly — it's the only view entity + cx.spawn(async move |workspace: 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 = workspace.update(cx, |_, cx| cx.notify()); + if ok.is_err() { break; } + } + }).detach(); + data.shared_blink = Some(blink); + } + + data }) .collect(); @@ -81,7 +120,6 @@ impl Render for Workspace { .bg(theme::CRUST) .font_family("Inter"); - // Main content row let mut main_row = div() .id("main-row") .flex_1() @@ -97,7 +135,7 @@ impl Render for Workspace { main_row = main_row.child(self.settings_panel.clone()); } - // Project grid area — inline, no intermediate entity + // Project grid — inline custom Elements, NO entity children let mut grid = div() .id("project-grid") .flex_1() @@ -110,8 +148,51 @@ impl Render for Workspace { .bg(theme::CRUST) .overflow_y_scroll(); - for pb in &self.project_boxes { - grid = grid.child(pb.clone()); + for data in &self.project_boxes { + let accent = accent_color(data.accent_index); + let tab_idx = match data.active_tab { + ProjectTab::Model => 0, + ProjectTab::Docs => 1, + ProjectTab::Files => 2, + }; + + // Build content div with cached child entities + let mut content = div() + .id(data.id_content.clone()) + .flex_1() + .w_full() + .overflow_hidden(); + + content = match data.active_tab { + ProjectTab::Model => { + let mut c = content.flex().flex_col(); + if let Some(ref pane) = data.agent_pane { + c = c.child(pane.clone().into_cached_flex()); + } + c = c.child(div().w_full().h(px(4.0)).bg(theme::SURFACE0)); + if let Some(ref term) = data.terminal_view { + c = c.child(term.clone().into_cached_flex()); + } + c + } + ProjectTab::Docs => content.flex().items_center().justify_center() + .text_size(px(14.0)).text_color(theme::OVERLAY0) + .child("Documentation viewer"), + ProjectTab::Files => content.flex().flex_col().p(px(12.0)).gap(px(4.0)) + .text_size(px(12.0)).text_color(theme::SUBTEXT0) + .child("src/").child(" main.rs").child(" lib.rs").child("Cargo.toml"), + }; + + grid = grid.child(ProjectBoxFullElement { + id: data.id_project.clone().into(), + name: data.cached_name.clone(), + cwd: data.cached_cwd.clone(), + accent, + status: data.status, + blink_visible: data.shared_blink.as_ref().map(|b| b.visible.clone()), + active_tab: tab_idx, + content: content.into_any_element(), + }); } main_row = main_row.child(grid);