perf(ui-gpui): eliminate ProjectBox Entity, plain struct + direct render (2.17%)
ProjectBoxData replaces Entity<ProjectBox>. 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.
This commit is contained in:
parent
3bbaefa9a2
commit
c61262c604
3 changed files with 122 additions and 255 deletions
|
|
@ -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:
|
//! Rendered as ProjectBoxFullElement (custom Element) from Workspace::render().
|
||||||
//! - Header: name, status dot, CWD
|
//! No Entity boundary = no dispatch tree node = no ancestor dirty cascade.
|
||||||
//! - Tab bar: Model / Docs / Files
|
|
||||||
//! - Content area: AgentPane (Model tab), placeholder (Docs/Files)
|
|
||||||
//! - Terminal section in Model tab
|
|
||||||
|
|
||||||
use gpui::*;
|
use gpui::*;
|
||||||
use crate::CachedView;
|
|
||||||
|
|
||||||
use crate::components::project_box_element::ProjectBoxFullElement;
|
|
||||||
use crate::state::{AgentStatus, Project, ProjectTab};
|
use crate::state::{AgentStatus, Project, ProjectTab};
|
||||||
use crate::theme;
|
|
||||||
|
|
||||||
// ── Accent Colors by Index ──────────────────────────────────────────
|
/// Plain data struct for a project box. NOT an Entity — no view overhead.
|
||||||
|
/// Workspace owns these directly and creates custom Elements from them.
|
||||||
fn accent_color(index: usize) -> Rgba {
|
pub struct ProjectBoxData {
|
||||||
const ACCENTS: [Rgba; 8] = [
|
pub status: AgentStatus,
|
||||||
theme::BLUE,
|
pub active_tab: ProjectTab,
|
||||||
theme::MAUVE,
|
pub accent_index: usize,
|
||||||
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,
|
|
||||||
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
pub agent_pane: Option<Entity<crate::components::agent_pane::AgentPane>>,
|
||||||
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
pub terminal_view: Option<Entity<crate::terminal::renderer::TerminalView>>,
|
||||||
pub shared_blink: Option<crate::components::blink_state::SharedBlink>,
|
pub shared_blink: Option<crate::components::blink_state::SharedBlink>,
|
||||||
// Cached strings to avoid allocation on every render
|
pub id_project: SharedString,
|
||||||
id_project: SharedString,
|
pub id_content: SharedString,
|
||||||
id_model: SharedString,
|
pub cached_name: SharedString,
|
||||||
cached_name: SharedString,
|
pub cached_cwd: SharedString,
|
||||||
cached_cwd: SharedString,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProjectBox {
|
impl ProjectBoxData {
|
||||||
pub fn new(project: Project) -> Self {
|
pub fn new(project: &Project) -> Self {
|
||||||
let id = &project.id;
|
let id = &project.id;
|
||||||
Self {
|
Self {
|
||||||
id_project: SharedString::from(format!("project-{id}")),
|
status: project.agent.status,
|
||||||
id_model: SharedString::from(format!("model-{id}")),
|
active_tab: project.active_tab,
|
||||||
cached_name: SharedString::from(project.name.clone()),
|
accent_index: project.accent_index,
|
||||||
cached_cwd: SharedString::from(project.cwd.clone()),
|
|
||||||
project,
|
|
||||||
agent_pane: None,
|
agent_pane: None,
|
||||||
terminal_view: None,
|
terminal_view: None,
|
||||||
shared_blink: None,
|
shared_blink: None,
|
||||||
}
|
id_project: SharedString::from(format!("project-{id}")),
|
||||||
}
|
id_content: SharedString::from(format!("content-{id}")),
|
||||||
|
cached_name: SharedString::from(project.name.clone()),
|
||||||
/// Initialize sub-views. Must be called after the ProjectBox entity is created.
|
cached_cwd: SharedString::from(project.cwd.clone()),
|
||||||
pub fn init_subviews(&mut self, cx: &mut Context<Self>) {
|
|
||||||
// SharedBlink: Arc<AtomicBool> 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<Self>, 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<Self>) -> 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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,81 +1,2 @@
|
||||||
//! ProjectGrid: flex-wrap grid of ProjectBox cards.
|
//! ProjectGrid: DEPRECATED — replaced by inline rendering in Workspace.
|
||||||
//!
|
//! Kept for reference only.
|
||||||
//! 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<AppState>,
|
|
||||||
/// One ProjectBox entity per project.
|
|
||||||
project_boxes: Vec<Entity<ProjectBox>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProjectGrid {
|
|
||||||
pub fn new(app_state: Entity<AppState>, cx: &mut Context<Self>) -> 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<Entity<ProjectBox>> = 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<Self>) -> 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,36 @@
|
||||||
//! Workspace: root view composing sidebar + project boxes + status bar.
|
//! Workspace: root view composing sidebar + project boxes + status bar.
|
||||||
//!
|
//!
|
||||||
//! ProjectBoxes are rendered DIRECTLY as children of Workspace (no intermediate
|
//! ProjectBoxes are NOT entities — they're plain structs rendered as custom Elements.
|
||||||
//! ProjectGrid entity). This keeps the dispatch tree at 3 levels:
|
//! This eliminates the Entity boundary and dispatch tree overhead for project cards.
|
||||||
//! Workspace → ProjectBox → StatusDotView (same depth as Zed's Workspace → Pane → Editor).
|
//! 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 gpui::*;
|
||||||
|
|
||||||
use crate::components::command_palette::CommandPalette;
|
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::settings::SettingsPanel;
|
||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::components::status_bar::StatusBar;
|
use crate::components::status_bar::StatusBar;
|
||||||
use crate::state::AppState;
|
use crate::state::{AgentStatus, AppState, ProjectTab};
|
||||||
use crate::theme;
|
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 {
|
pub struct Workspace {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
app_state: Entity<AppState>,
|
app_state: Entity<AppState>,
|
||||||
sidebar: Entity<Sidebar>,
|
sidebar: Entity<Sidebar>,
|
||||||
settings_panel: Entity<SettingsPanel>,
|
settings_panel: Entity<SettingsPanel>,
|
||||||
project_boxes: Vec<Entity<ProjectBox>>,
|
project_boxes: Vec<ProjectBoxData>,
|
||||||
status_bar: Entity<StatusBar>,
|
status_bar: Entity<StatusBar>,
|
||||||
command_palette: Entity<CommandPalette>,
|
command_palette: Entity<CommandPalette>,
|
||||||
}
|
}
|
||||||
|
|
@ -43,16 +54,44 @@ impl Workspace {
|
||||||
|_cx| CommandPalette::new(state)
|
|_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 projects: Vec<_> = app_state.read(cx).projects.clone();
|
||||||
let project_boxes: Vec<Entity<ProjectBox>> = projects
|
let project_boxes: Vec<ProjectBoxData> = projects
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|proj| {
|
.map(|proj| {
|
||||||
cx.new(|cx| {
|
let mut data = ProjectBoxData::new(&proj);
|
||||||
let mut pb = ProjectBox::new(proj);
|
|
||||||
pb.init_subviews(cx);
|
// Create cached child entities for content
|
||||||
pb
|
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<Self>, 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();
|
.collect();
|
||||||
|
|
||||||
|
|
@ -81,7 +120,6 @@ impl Render for Workspace {
|
||||||
.bg(theme::CRUST)
|
.bg(theme::CRUST)
|
||||||
.font_family("Inter");
|
.font_family("Inter");
|
||||||
|
|
||||||
// Main content row
|
|
||||||
let mut main_row = div()
|
let mut main_row = div()
|
||||||
.id("main-row")
|
.id("main-row")
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|
@ -97,7 +135,7 @@ impl Render for Workspace {
|
||||||
main_row = main_row.child(self.settings_panel.clone());
|
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()
|
let mut grid = div()
|
||||||
.id("project-grid")
|
.id("project-grid")
|
||||||
.flex_1()
|
.flex_1()
|
||||||
|
|
@ -110,8 +148,51 @@ impl Render for Workspace {
|
||||||
.bg(theme::CRUST)
|
.bg(theme::CRUST)
|
||||||
.overflow_y_scroll();
|
.overflow_y_scroll();
|
||||||
|
|
||||||
for pb in &self.project_boxes {
|
for data in &self.project_boxes {
|
||||||
grid = grid.child(pb.clone());
|
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);
|
main_row = main_row.child(grid);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue