diff --git a/crates/onboarding/src/basics_page.rs b/crates/onboarding/src/basics_page.rs index 7db1b1fc0a..543eaa8b74 100644 --- a/crates/onboarding/src/basics_page.rs +++ b/crates/onboarding/src/basics_page.rs @@ -24,19 +24,12 @@ use crate::{ theme_preview::{ThemePreviewStyle, ThemePreviewTile}, }; -const LIGHT_THEMES: [&str; 3] = ["One Light", "Ayu Light", "Gruvbox Light"]; -const DARK_THEMES: [&str; 3] = ["One Dark", "Ayu Dark", "Gruvbox Dark"]; -const FAMILY_NAMES: [SharedString; 3] = [ - SharedString::new_static("One"), - SharedString::new_static("Ayu"), - SharedString::new_static("Gruvbox"), -]; +const MAST_LIGHT_THEME: &str = "Mast Light"; +const MAST_DARK_THEME: &str = "Mast Dark"; fn get_theme_family_themes(theme_name: &str) -> Option<(&'static str, &'static str)> { - for i in 0..LIGHT_THEMES.len() { - if LIGHT_THEMES[i] == theme_name || DARK_THEMES[i] == theme_name { - return Some((LIGHT_THEMES[i], DARK_THEMES[i])); - } + if theme_name == MAST_LIGHT_THEME || theme_name == MAST_DARK_THEME { + return Some((MAST_LIGHT_THEME, MAST_DARK_THEME)); } None } @@ -94,14 +87,14 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement h_flex() .gap_2() .justify_between() - .children(render_theme_previews(tab_index, &theme_selection, cx)), + .child(render_theme_preview(tab_index, &theme_selection, cx)), ); - fn render_theme_previews( + fn render_theme_preview( tab_index: &mut isize, theme_selection: &ThemeSelection, cx: &mut App, - ) -> [impl IntoElement; 3] { + ) -> impl IntoElement { let system_appearance = SystemAppearance::global(cx); let theme_registry = ThemeRegistry::global(cx); @@ -118,84 +111,75 @@ fn render_theme_section(tab_index: &mut isize, cx: &mut App) -> impl IntoElement ThemeAppearanceMode::System => *system_appearance, }; let current_theme_name: SharedString = theme_selection.name(appearance).0.into(); - - let theme_names = match appearance { - Appearance::Light => LIGHT_THEMES, - Appearance::Dark => DARK_THEMES, + let light = theme_registry.get(MAST_LIGHT_THEME).unwrap(); + let dark = theme_registry.get(MAST_DARK_THEME).unwrap(); + let theme = match appearance { + Appearance::Light => light.clone(), + Appearance::Dark => dark.clone(), }; + let is_selected = + current_theme_name == MAST_LIGHT_THEME || current_theme_name == MAST_DARK_THEME; + let colors = cx.theme().colors(); - let themes = theme_names.map(|theme| theme_registry.get(theme).unwrap()); - - [0, 1, 2].map(|index| { - let theme = &themes[index]; - let is_selected = theme.name == current_theme_name; - let name = theme.name.clone(); - let colors = cx.theme().colors(); - - v_flex() - .w_full() - .items_center() - .gap_1() - .child( - h_flex() - .id(name) - .relative() - .w_full() - .border_2() - .border_color(colors.border_transparent) - .rounded(ThemePreviewTile::ROOT_RADIUS) - .map(|this| { - if is_selected { - this.border_color(colors.border_selected) - } else { - this.opacity(0.8).hover(|s| s.border_color(colors.border)) - } - }) - .tab_index({ - *tab_index += 1; - *tab_index - 1 - }) - .focus(|mut style| { - style.border_color = Some(colors.border_focused); - style - }) - .on_click({ - let theme_name = theme.name.clone(); - let current_theme_name = current_theme_name.clone(); - - move |_, _, cx| { - write_theme_change(theme_name.clone(), theme_mode, cx); - telemetry::event!( - "Welcome Theme Changed", - from = current_theme_name, - to = theme_name - ); - } - }) - .map(|this| { - if theme_mode == ThemeAppearanceMode::System { - let (light, dark) = ( - theme_registry.get(LIGHT_THEMES[index]).unwrap(), - theme_registry.get(DARK_THEMES[index]).unwrap(), - ); - this.child( - ThemePreviewTile::new(light, theme_seed) - .style(ThemePreviewStyle::SideBySide(dark)), - ) - } else { - this.child( - ThemePreviewTile::new(theme.clone(), theme_seed) - .style(ThemePreviewStyle::Bordered), - ) - } - }), - ) - .child( - Label::new(FAMILY_NAMES[index].clone()) - .color(Color::Muted) - .size(LabelSize::Small), - ) - }) + v_flex() + .w_full() + .items_center() + .gap_1() + .child( + h_flex() + .id("mast-theme-onboarding") + .relative() + .w_full() + .border_2() + .border_color(colors.border_transparent) + .rounded(ThemePreviewTile::ROOT_RADIUS) + .map(|this| { + if is_selected { + this.border_color(colors.border_selected) + } else { + this.opacity(0.8).hover(|s| s.border_color(colors.border)) + } + }) + .tab_index({ + *tab_index += 1; + *tab_index - 1 + }) + .focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + .on_click({ + let theme_name = theme.name.clone(); + let current_theme_name = current_theme_name.clone(); + + move |_, _, cx| { + write_theme_change(theme_name.clone(), theme_mode, cx); + telemetry::event!( + "Welcome Theme Changed", + from = current_theme_name, + to = theme_name + ); + } + }) + .map(|this| { + if theme_mode == ThemeAppearanceMode::System { + this.child( + ThemePreviewTile::new(light, theme_seed) + .style(ThemePreviewStyle::SideBySide(dark)), + ) + } else { + this.child( + ThemePreviewTile::new(theme.clone(), theme_seed) + .style(ThemePreviewStyle::Bordered), + ) + } + }), + ) + .child( + Label::new(SharedString::new_static("Mast")) + .color(Color::Muted) + .size(LabelSize::Small), + ) } fn write_mode_change(mode: ThemeAppearanceMode, cx: &mut App) { diff --git a/crates/sing_project/src/client.rs b/crates/sing_project/src/client.rs index 2dec6a4bfd..25a12b4fe3 100644 --- a/crates/sing_project/src/client.rs +++ b/crates/sing_project/src/client.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use sing_bridge::{ - ProjectConfig, ProjectRemoteTarget, ProjectStartResult, ProjectStopResult, ProjectSummary, - SingBridge, + AgentLog, AgentReport, ProjectAgentStatus, ProjectConfig, ProjectRemoteTarget, + ProjectStartResult, ProjectStopResult, ProjectSummary, SingBridge, SpecBoard, }; #[async_trait] @@ -14,6 +14,18 @@ pub trait SingProjectClient: Send + Sync { async fn project_remote_target(&self, project: &str) -> Result; async fn start_project(&self, project: &str) -> Result; async fn stop_project(&self, project: &str) -> Result; + async fn list_specs(&self, _project: &str) -> Result { + anyhow::bail!("spec board is unavailable from this project client") + } + async fn agent_status(&self, _project: &str) -> Result { + anyhow::bail!("agent status is unavailable from this project client") + } + async fn agent_log(&self, _project: &str, _tail: u32) -> Result { + anyhow::bail!("agent log is unavailable from this project client") + } + async fn agent_report(&self, _project: &str) -> Result { + anyhow::bail!("agent report is unavailable from this project client") + } } #[async_trait] @@ -37,6 +49,22 @@ impl SingProjectClient for SingBridge { async fn stop_project(&self, project: &str) -> Result { Ok(SingBridge::stop_project(self, project).await?) } + + async fn list_specs(&self, project: &str) -> Result { + Ok(SingBridge::list_specs(self, project).await?) + } + + async fn agent_status(&self, project: &str) -> Result { + Ok(SingBridge::project_agent_status(self, project).await?) + } + + async fn agent_log(&self, project: &str, tail: u32) -> Result { + Ok(SingBridge::project_agent_log(self, project, tail).await?) + } + + async fn agent_report(&self, project: &str) -> Result { + Ok(SingBridge::project_agent_report(self, project).await?) + } } pub trait SingProjectClientFactory: Send + Sync { diff --git a/crates/sing_project/src/panel.rs b/crates/sing_project/src/panel.rs index 4e11287948..150bed6cdf 100644 --- a/crates/sing_project/src/panel.rs +++ b/crates/sing_project/src/panel.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -7,20 +6,24 @@ use db::kvp::KeyValueStore; use editor::{Editor, EditorEvent}; use gpui::{ Action, AnyElement, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle, - Focusable, ParentElement, Pixels, Render, StatefulInteractiveElement, Styled, Task, WeakEntity, - Window, actions, px, + Focusable, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled, + Task, WeakEntity, Window, actions, px, }; use recent_projects::open_remote_project; use serde::{Deserialize, Serialize}; -use sing_bridge::ProjectStatus; +use sing_bridge::{ + AgentLog, AgentReport, BoardSpecRecord, ProjectAgentStatus, ProjectStatus, SpecBoard, + SpecStatus, +}; use ui::{ Button, Chip, Color, Icon, IconButtonShape, IconName, IconSize, Indicator, Label, LabelSize, ListItem, ListItemSpacing, SpinnerLabel, TintColor, Tooltip, prelude::*, }; use util::{ResultExt, TryFutureExt}; use workspace::{ - MultiWorkspace, OpenOptions, Toast, Workspace, + Item, MultiWorkspace, OpenOptions, Toast, Workspace, dock::{DockPosition, Panel, PanelEvent}, + item::TabContentParams, notifications::NotificationId, }; @@ -95,7 +98,6 @@ pub struct SingProjectPanel { last_refreshed_at: Option, projects: Vec, selected_project: Option, - pending_actions: HashMap, current_request_id: usize, pending_serialization: Task>, polling_task: Task<()>, @@ -188,7 +190,6 @@ impl SingProjectPanel { last_refreshed_at: None, projects: Vec::new(), selected_project: serialized.and_then(|panel| panel.selected_project.clone()), - pending_actions: HashMap::default(), current_request_id: 0, pending_serialization: Task::ready(None), polling_task: Task::ready(()), @@ -306,6 +307,7 @@ impl SingProjectPanel { self.projects = projects; self.selected_project = next_selection(self.selected_project.as_deref(), &self.projects); + self.sync_open_project_homes(cx); for event in events { self.show_agent_activity_event( event.project, @@ -327,147 +329,88 @@ impl SingProjectPanel { cx.notify(); } - fn select_project(&mut self, project: &str, cx: &mut Context) { - if self.selected_project.as_deref() == Some(project) { - return; - } - - self.selected_project = Some(project.to_string()); - self.serialize(cx); - cx.notify(); - } - - fn open_project(&mut self, project: String, window: &mut Window, cx: &mut Context) { - if self.pending_actions.contains_key(&project) { + fn sync_open_project_homes(&self, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { return; - } - - self.pending_actions - .insert(project.clone(), ProjectActionKind::Open); - cx.notify(); + }; - let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |panel, cx| { - let result = async { - let client = panel.update_in(cx, |panel, _, _| panel.ensure_client())??; - let target = client.project_remote_target(&project).await?; - let (app_state, open_options) = - workspace.update_in(cx, |workspace, window, _| { - let requesting_window = window.window_handle().downcast::(); - let open_options = OpenOptions { - requesting_window, - ..Default::default() - }; - (workspace.app_state().clone(), open_options) - })?; - open_remote_project( - target.connection_options, - vec![target.workspace_root], - app_state, - open_options, - cx, - ) - .await?; - Ok(String::new()) + workspace.update(cx, |workspace, cx| { + let homes = workspace + .items_of_type::(cx) + .collect::>(); + for home in homes { + let project_name = home.read(cx).project_name().to_string(); + if let Some(project) = self + .projects + .iter() + .find(|project| project.name == project_name) + .cloned() + { + home.update(cx, |home, cx| home.set_project(project, cx)); + } else { + home.update(cx, |home, cx| home.mark_missing(cx)); + } } - .await; - - panel - .update_in(cx, |panel, window, cx| { - panel.finish_action(&project, ProjectActionKind::Open, result, window, cx); - }) - .ok(); - }) - .detach(); - } - - fn start_project(&mut self, project: String, window: &mut Window, cx: &mut Context) { - self.run_project_action(project, ProjectActionKind::Start, window, cx); - } - - fn stop_project(&mut self, project: String, window: &mut Window, cx: &mut Context) { - self.run_project_action(project, ProjectActionKind::Stop, window, cx); + }); } - fn run_project_action( - &mut self, - project: String, - action: ProjectActionKind, - window: &mut Window, - cx: &mut Context, - ) { - if self.pending_actions.contains_key(&project) { - return; + fn select_project(&mut self, project: &str, cx: &mut Context) { + if self.selected_project.as_deref() != Some(project) { + self.selected_project = Some(project.to_string()); + self.serialize(cx); + cx.notify(); } - - self.pending_actions.insert(project.clone(), action); - cx.notify(); - - cx.spawn_in(window, async move |panel, cx| { - let result = async { - let client = panel.update_in(cx, |panel, _, _| panel.ensure_client())??; - match action { - ProjectActionKind::Start => { - let result = client.start_project(&project).await?; - Ok(format!("Started {}", result.name)) - } - ProjectActionKind::Stop => { - client.stop_project(&project).await?; - Ok(format!("Stopped {project}")) - } - ProjectActionKind::Open => Ok(String::new()), - } - } - .await; - - panel - .update_in(cx, |panel, window, cx| { - panel.finish_action(&project, action, result, window, cx); - }) - .ok(); - }) - .detach(); } - fn finish_action( + fn open_project_home( &mut self, - project: &str, - action: ProjectActionKind, - result: Result, + project_name: String, window: &mut Window, cx: &mut Context, ) { - self.pending_actions.remove(project); + self.select_project(&project_name, cx); - match result { - Ok(message) => { - self.last_error = None; - if !message.is_empty() { - self.show_toast(message, cx); - } - if matches!(action, ProjectActionKind::Start | ProjectActionKind::Stop) { - self.refresh(window, cx); - } else { - cx.notify(); - } - } - Err(error) => { - self.show_action_error(error.to_string(), cx); - } - } - } + let Some(project) = self + .projects + .iter() + .find(|project| project.name == project_name) + .cloned() + else { + return; + }; - fn show_agent_status(&mut self, project: &str, cx: &mut Context) { - let Some(project) = self.projects.iter().find(|row| row.name == project) else { + let Some(workspace) = self.workspace.upgrade() else { return; }; - let activity = project.agent_activity(); - let mut message = format!("{} | {}", project.name, activity.headline); - if let Some(detail) = activity.detail { - message.push_str(&format!(" | {detail}")); - } - self.show_toast(message, cx); + let client_factory = self.client_factory.clone(); + workspace.update(cx, |workspace, cx| { + let existing = workspace + .items_of_type::(cx) + .find(|item| item.read(cx).project_name() == project_name); + + if let Some(item) = existing { + item.update(cx, |item, cx| { + item.set_project(project.clone(), cx); + }); + workspace.activate_item(&item, true, true, window, cx); + return; + } + + let item = cx.new(|cx| { + ProjectHomeItem::new( + project.clone(), + workspace.weak_handle(), + client_factory.clone(), + cx, + ) + }); + let added_to_center = workspace.add_item_to_center(Box::new(item.clone()), window, cx); + if !added_to_center { + workspace.add_item_to_active_pane(Box::new(item.clone()), None, true, window, cx); + } + item.update(cx, |item, cx| item.refresh(window, cx)); + }); } fn show_agent_activity_event( @@ -485,16 +428,6 @@ impl SingProjectPanel { self.show_toast(format!("{prefix}: {project} ยท {message}"), cx); } - fn show_action_error(&mut self, error: String, cx: &mut Context) { - self.last_error = Some(error.clone()); - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.show_error(&anyhow!(error.clone()), cx); - }); - } - cx.notify(); - } - fn show_toast(&mut self, message: String, cx: &mut Context) { if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -509,17 +442,6 @@ impl SingProjectPanel { } } - fn selected_visible_row(&self, cx: &App) -> Option<&ProjectRow> { - let selected_project = self.selected_project.as_deref()?; - self.filtered_projects(cx) - .into_iter() - .find(|project| project.name == selected_project) - } - - fn is_action_pending(&self, project: &str, action: ProjectActionKind) -> bool { - self.pending_actions.get(project).copied() == Some(action) - } - fn search_query(&self, cx: &App) -> String { self.search_bar .read(cx) @@ -569,32 +491,6 @@ impl SingProjectPanel { }) } - fn project_badges(&self, project: &ProjectRow) -> Vec { - let mut badges = vec![Self::badge( - project.agent_badge(), - match project.status { - ProjectStatus::Running if project.agent_session.running => Color::Accent, - ProjectStatus::Running => Color::Muted, - ProjectStatus::Error => Color::Error, - ProjectStatus::Stopped | ProjectStatus::NotCreated => Color::Warning, - }, - )]; - - if let Some(ready) = project.ready_count().filter(|ready| *ready > 0) { - badges.push(Self::badge(format!("Ready {ready}"), Color::Success)); - } - - if let Some(blocked) = project.blocked_count().filter(|blocked| *blocked > 0) { - badges.push(Self::badge(format!("Blocked {blocked}"), Color::Warning)); - } - - if project.status == ProjectStatus::Running && !project.specs.available { - badges.push(Self::badge("Specs need setup", Color::Warning)); - } - - badges - } - fn project_status_chip(&self, project: &ProjectRow) -> Chip { Self::badge(project.status_label(), status_color(project.status)) } @@ -603,38 +499,50 @@ impl SingProjectPanel { Chip::new(label.into()).label_color(color) } - fn detail_line(label: &'static str, value: impl Into) -> AnyElement { - h_flex() - .w_full() - .items_start() - .gap_2() - .child( - div() - .w(px(72.)) - .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)), - ) - .child( - Label::new(value.into()) - .size(LabelSize::Small) - .color(Color::Default) - .truncate(), - ) - .into_any_element() - } - fn project_secondary_line(&self, project: &ProjectRow) -> Option { Some(project.list_summary()) } + fn render_refresh_control(&self, cx: &mut Context) -> AnyElement { + let tooltip = match self.refresh_status_label() { + Some(refreshed) => format!("Refresh project state ({refreshed})"), + None => "Refresh project state".to_string(), + }; + + if self.loading { + return h_flex() + .id("sing-project-refresh") + .size(px(28.)) + .items_center() + .justify_center() + .rounded_sm() + .tooltip(Tooltip::text(tooltip)) + .child(SpinnerLabel::dots_variant().size(LabelSize::Small)) + .into_any_element(); + } + + IconButton::new("sing-project-refresh", IconName::RotateCw) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .tooltip(Tooltip::text(tooltip)) + .on_click(cx.listener(|this, _, window, cx| { + this.refresh(window, cx); + })) + .into_any_element() + } + fn render_header(&self, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); + let border = cx.theme().colors().border_variant; + let editor_background = cx.theme().colors().editor_background; + let panel_background = cx.theme().colors().panel_background; v_flex() .w_full() .gap_1p5() .p_2() .border_b_1() - .border_color(theme.colors().border_variant) - .bg(theme.colors().editor_background) + .border_color(border) + .bg(editor_background) .child( h_flex() .w_full() @@ -642,17 +550,7 @@ impl SingProjectPanel { .justify_between() .gap_2() .child(Label::new("Projects")) - .child( - IconButton::new("sing-project-refresh", IconName::RotateCw) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .disabled(self.loading) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Refresh project state")) - .on_click(cx.listener(|this, _, window, cx| { - this.refresh(window, cx); - })), - ), + .child(self.render_refresh_control(cx)), ) .child( h_flex() @@ -660,43 +558,12 @@ impl SingProjectPanel { .px_1p5() .gap_1p5() .rounded_sm() - .bg(theme.colors().panel_background) + .bg(panel_background) .border_1() - .border_color(theme.colors().border_variant) + .border_color(border) .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted)) .child(self.search_bar.clone()), ) - .child( - h_flex() - .w_full() - .items_center() - .justify_between() - .gap_2() - .child(if self.loading { - h_flex() - .gap_1() - .items_center() - .child(SpinnerLabel::dots().size(LabelSize::Small)) - .child( - Label::new("Refreshing projects") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .into_any_element() - } else { - Label::new("Remote projects and specs") - .size(LabelSize::Small) - .color(Color::Muted) - .into_any_element() - }) - .when_some(self.refresh_status_label(), |element, refreshed| { - element.child( - Label::new(refreshed) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) .into_any_element() } @@ -805,8 +672,8 @@ impl SingProjectPanel { }), ) .tooltip(Tooltip::text(project.name.clone())) - .on_click(cx.listener(move |this, _, _, cx| { - this.select_project(&project_name, cx); + .on_click(cx.listener(move |this, _, window, cx| { + this.open_project_home(project_name.clone(), window, cx); })) .into_any_element() }) @@ -819,125 +686,465 @@ impl SingProjectPanel { .child(v_flex().w_full().gap_1().p_2().children(items)) .into_any_element() } +} - fn render_selected_project(&self, cx: &mut Context) -> Option { - let project = self.selected_visible_row(cx)?; - let theme = cx.theme(); - let open_pending = self.is_action_pending(&project.name, ProjectActionKind::Open); - let start_pending = self.is_action_pending(&project.name, ProjectActionKind::Start); - let stop_pending = self.is_action_pending(&project.name, ProjectActionKind::Stop); - let runtime_summary = project - .runtime_summary() - .unwrap_or_else(|| "Runtime metadata unavailable".to_string()); - let detail_error = project.detail_error.clone(); - let project_name = project.name.clone(); - let agent_project_name = project.name.clone(); - let can_open = project.can_open(); - let can_start = project.can_start(); - let can_stop = project.can_stop(); - let status_chip = self.project_status_chip(project); - let activity = project.agent_activity(); - let secondary_line = self.project_secondary_line(project); - let branch = project.branch().map(str::to_string); - let ip = project.ip.clone(); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectHomeMode { + Summary, + Specs, + ArchivedSpecs, + Kanban, + Agents, + Activity, + Settings, +} - Some( - v_flex() - .w_full() - .gap_2() - .p_2() - .border_t_1() - .border_color(theme.colors().border_variant) - .bg(theme.colors().editor_background) - .child( - h_flex() - .w_full() - .justify_between() - .gap_2() - .child(Label::new(project.name.clone())) - .child(status_chip), - ) - .when_some(project.description.as_ref(), |element, description| { - element.child( - Label::new(description.clone()) - .size(LabelSize::Small) - .color(Color::Muted), - ) +impl ProjectHomeMode { + const ALL: [Self; 7] = [ + Self::Summary, + Self::Specs, + Self::ArchivedSpecs, + Self::Kanban, + Self::Agents, + Self::Activity, + Self::Settings, + ]; + + fn label(self) -> &'static str { + match self { + Self::Summary => "Summary", + Self::Specs => "Specs", + Self::ArchivedSpecs => "Archived", + Self::Kanban => "Kanban", + Self::Agents => "Agents", + Self::Activity => "Activity", + Self::Settings => "Settings", + } + } +} + +struct ProjectHomeRefresh { + project: ProjectRow, + spec_board: Option>, + agent_status: Result, + agent_log: Result, + agent_report: Result, +} + +pub struct ProjectHomeItem { + workspace: WeakEntity, + focus_handle: FocusHandle, + client_factory: Arc, + client: Option>, + project_name: String, + project: Option, + selected_mode: ProjectHomeMode, + loading: bool, + pending_action: Option, + last_error: Option, + last_refreshed_at: Option, + spec_board: Option, + spec_error: Option, + agent_status: Option, + agent_error: Option, + agent_log: Option, + agent_log_error: Option, + agent_report: Option, + agent_report_error: Option, +} + +impl ProjectHomeItem { + fn new( + project: ProjectRow, + workspace: WeakEntity, + client_factory: Arc, + cx: &mut Context, + ) -> Self { + Self { + workspace, + focus_handle: cx.focus_handle(), + client_factory, + client: None, + project_name: project.name.clone(), + project: Some(project), + selected_mode: ProjectHomeMode::Summary, + loading: false, + pending_action: None, + last_error: None, + last_refreshed_at: None, + spec_board: None, + spec_error: None, + agent_status: None, + agent_error: None, + agent_log: None, + agent_log_error: None, + agent_report: None, + agent_report_error: None, + } + } + + fn project_name(&self) -> &str { + &self.project_name + } + + fn set_project(&mut self, project: ProjectRow, cx: &mut Context) { + self.project_name = project.name.clone(); + self.project = Some(project); + self.last_error = None; + cx.notify(); + } + + fn mark_missing(&mut self, cx: &mut Context) { + self.project = None; + self.last_error = Some("Project is no longer present after refresh".to_string()); + cx.notify(); + } + + fn ensure_client(&mut self) -> Result> { + if let Some(client) = &self.client { + return Ok(client.clone()); + } + + let client = self.client_factory.create()?; + self.client = Some(client.clone()); + Ok(client) + } + + fn select_mode(&mut self, mode: ProjectHomeMode, cx: &mut Context) { + if self.selected_mode != mode { + self.selected_mode = mode; + cx.notify(); + } + } + + fn refresh(&mut self, window: &mut Window, cx: &mut Context) { + self.loading = true; + self.last_error = None; + cx.notify(); + + let project_name = self.project_name.clone(); + cx.spawn_in(window, async move |item, cx| { + let result = async { + let client = item.update_in(cx, |item, _, _| item.ensure_client())??; + let projects = load_project_rows(client.clone()).await?; + let project = projects + .into_iter() + .find(|project| project.name == project_name) + .ok_or_else(|| anyhow!("project `{project_name}` was not found"))?; + + let spec_board = project.specs.available.then(|| { + let client = client.clone(); + let project_name = project_name.clone(); + async move { + client + .list_specs(&project_name) + .await + .map_err(|error| error.to_string()) + } + }); + + let spec_board = match spec_board { + Some(task) => Some(task.await), + None => None, + }; + + let agent_status = client + .agent_status(&project_name) + .await + .map_err(|error| error.to_string()); + let agent_log = client + .agent_log(&project_name, 80) + .await + .map_err(|error| error.to_string()); + let agent_report = client + .agent_report(&project_name) + .await + .map_err(|error| error.to_string()); + + anyhow::Ok(ProjectHomeRefresh { + project, + spec_board, + agent_status, + agent_log, + agent_report, }) - .child( - h_flex() - .w_full() - .gap_1() - .flex_wrap() - .children(self.project_badges(project)), - ) - .when_some(secondary_line, |element, line| { - element.child( - Label::new(line) - .size(LabelSize::Small) - .color(Color::Accent) - .truncate(), - ) - }) - .child( - v_flex() - .w_full() - .gap_1() - .p_2() - .rounded_sm() - .bg(theme.colors().panel_background) - .border_1() - .border_color(theme.colors().border_variant) - .child(Self::detail_line("Runtime", runtime_summary)) - .child(Self::detail_line("Activity", activity.headline)) - .when_some(activity.detail, |element, detail| { - element.child(Self::detail_line("Context", detail)) - }) - .when_some(activity.timestamp, |element, timestamp| { - element.child(Self::detail_line("Started", timestamp)) - }) - .child(Self::detail_line("Specs", project.spec_detail())) - .when_some(branch, |element, branch| { - element.child(Self::detail_line("Branch", branch)) - }) - .when_some(ip, |element, ip| { - element.child(Self::detail_line("Network", format!("IP {ip}"))) - }), - ) - .when_some(detail_error.as_ref(), |element, error| { - element.child( - Label::new(error.clone()) - .size(LabelSize::Small) - .color(Color::Warning) - .truncate(), + } + .await; + + item.update_in(cx, |item, _, cx| item.finish_refresh(result, cx)) + .ok(); + }) + .detach(); + } + + fn finish_refresh(&mut self, result: Result, cx: &mut Context) { + self.loading = false; + + match result { + Ok(refresh) => { + self.project_name = refresh.project.name.clone(); + self.project = Some(refresh.project); + self.last_error = None; + self.last_refreshed_at = Some(Instant::now()); + + if let Some(spec_board) = refresh.spec_board { + match spec_board { + Ok(board) => { + self.spec_board = Some(board); + self.spec_error = None; + } + Err(error) => { + self.spec_error = Some(error); + } + } + } else { + self.spec_board = None; + self.spec_error = None; + } + + match refresh.agent_status { + Ok(status) => { + self.agent_status = Some(status); + self.agent_error = None; + } + Err(error) => self.agent_error = Some(error), + } + match refresh.agent_log { + Ok(log) => { + self.agent_log = Some(log); + self.agent_log_error = None; + } + Err(error) => self.agent_log_error = Some(error), + } + match refresh.agent_report { + Ok(report) => { + self.agent_report = Some(report); + self.agent_report_error = None; + } + Err(error) => self.agent_report_error = Some(error), + } + } + Err(error) => { + self.last_error = Some(error.to_string()); + } + } + + cx.notify(); + } + + fn open_project(&mut self, window: &mut Window, cx: &mut Context) { + self.run_project_action(ProjectActionKind::Open, window, cx); + } + + fn start_project(&mut self, window: &mut Window, cx: &mut Context) { + self.run_project_action(ProjectActionKind::Start, window, cx); + } + + fn stop_project(&mut self, window: &mut Window, cx: &mut Context) { + self.run_project_action(ProjectActionKind::Stop, window, cx); + } + + fn run_project_action( + &mut self, + action: ProjectActionKind, + window: &mut Window, + cx: &mut Context, + ) { + if self.pending_action.is_some() { + return; + } + + self.pending_action = Some(action); + cx.notify(); + + let project = self.project_name.clone(); + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |item, cx| { + let result = async { + let client = item.update_in(cx, |item, _, _| item.ensure_client())??; + match action { + ProjectActionKind::Start => { + let result = client.start_project(&project).await?; + Ok(format!("Started {}", result.name)) + } + ProjectActionKind::Stop => { + client.stop_project(&project).await?; + Ok(format!("Stopped {project}")) + } + ProjectActionKind::Open => { + let target = client.project_remote_target(&project).await?; + let (app_state, open_options) = + workspace.update_in(cx, |workspace, window, _| { + let requesting_window = + window.window_handle().downcast::(); + let open_options = OpenOptions { + requesting_window, + ..Default::default() + }; + (workspace.app_state().clone(), open_options) + })?; + open_remote_project( + target.connection_options, + vec![target.workspace_root], + app_state, + open_options, + cx, + ) + .await?; + Ok(String::new()) + } + } + } + .await; + + item.update_in(cx, |item, window, cx| { + item.finish_action(action, result, window, cx); + }) + .ok(); + }) + .detach(); + } + + fn finish_action( + &mut self, + action: ProjectActionKind, + result: Result, + window: &mut Window, + cx: &mut Context, + ) { + self.pending_action = None; + + match result { + Ok(message) => { + self.last_error = None; + if !message.is_empty() { + self.show_toast(message, cx); + } + if matches!(action, ProjectActionKind::Start | ProjectActionKind::Stop) { + self.refresh(window, cx); + } else { + cx.notify(); + } + } + Err(error) => { + self.last_error = Some(error.to_string()); + self.show_error(error.to_string(), cx); + cx.notify(); + } + } + } + + fn show_toast(&self, message: String, cx: &mut Context) { + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + NotificationId::composite::("sing-project-home"), + message.clone(), + ), + cx, + ); + }); + } + } + + fn show_error(&self, error: String, cx: &mut Context) { + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.show_error(&anyhow!(error.clone()), cx); + }); + } + } + + fn refresh_status_label(&self) -> Option { + let refreshed_at = self.last_refreshed_at?; + let elapsed = refreshed_at.elapsed().as_secs(); + + Some(if elapsed < 5 { + "Updated just now".to_string() + } else if elapsed < 60 { + format!("Updated {elapsed}s ago") + } else if elapsed < 3600 { + format!("Updated {}m ago", elapsed / 60) + } else { + format!("Updated {}h ago", elapsed / 3600) + }) + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let project = self.project.as_ref(); + let open_pending = self.pending_action == Some(ProjectActionKind::Open); + let start_pending = self.pending_action == Some(ProjectActionKind::Start); + let stop_pending = self.pending_action == Some(ProjectActionKind::Stop); + + v_flex() + .w_full() + .gap_2() + .p_3() + .border_b_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().editor_background) + .child( + h_flex() + .w_full() + .items_start() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::Server).color(Color::Muted)) + .child( + Label::new(self.project_name.clone()) + .size(LabelSize::Large), + ), + ) + .when_some( + project.and_then(|project| project.description.as_ref()), + |element, description| { + element + .child(Label::new(description.clone()).color(Color::Muted)) + }, + ) + .when_some(self.last_error.as_ref(), |element, error| { + element.child(Label::new(error.clone()).color(Color::Warning)) + }), ) - }) - .child( + .when_some(project, |element, project| { + element.child(project_status_chip(project)) + }), + ) + .when_some(project, |element, project| { + element.child( h_flex() .w_full() .gap_2() .flex_wrap() .child( Button::new( - format!("sing-project-open-{}", project.name), + format!("sing-project-home-open-{}", self.project_name), "Open remote", ) .style(ButtonStyle::Filled) .label_size(LabelSize::Small) .loading(open_pending) - .disabled(!can_open || start_pending || stop_pending) + .disabled(!project.can_open() || start_pending || stop_pending) .tooltip(Tooltip::text("Open this project in a remote workspace")) .on_click(cx.listener( - move |this, _, window, cx| { - this.open_project(project_name.clone(), window, cx); + |this, _, window, cx| { + this.open_project(window, cx); }, )), ) - .when(can_start, |element| { - let project_name = project.name.clone(); + .when(project.can_start(), |element| { element.child( Button::new( - format!("sing-project-start-{}", project.name), + format!("sing-project-home-start-{}", self.project_name), "Start", ) .style(ButtonStyle::Tinted(TintColor::Success)) @@ -946,46 +1153,801 @@ impl SingProjectPanel { .disabled(open_pending || stop_pending) .tooltip(Tooltip::text("Run sail up for this project")) .on_click(cx.listener( - move |this, _, window, cx| { - this.start_project(project_name.clone(), window, cx); + |this, _, window, cx| { + this.start_project(window, cx); }, )), ) }) - .when(can_stop, |element| { - let project_name = project.name.clone(); + .when(project.can_stop(), |element| { element.child( - Button::new(format!("sing-project-stop-{}", project.name), "Stop") - .style(ButtonStyle::Tinted(TintColor::Warning)) - .label_size(LabelSize::Small) - .loading(stop_pending) - .disabled(open_pending || start_pending) - .tooltip(Tooltip::text("Run sail down for this project")) - .on_click(cx.listener(move |this, _, window, cx| { - this.stop_project(project_name.clone(), window, cx); - })), + Button::new( + format!("sing-project-home-stop-{}", self.project_name), + "Stop", + ) + .style(ButtonStyle::Tinted(TintColor::Warning)) + .label_size(LabelSize::Small) + .loading(stop_pending) + .disabled(open_pending || start_pending) + .tooltip(Tooltip::text("Run sail down for this project")) + .on_click(cx.listener( + |this, _, window, cx| { + this.stop_project(window, cx); + }, + )), ) }) .child( Button::new( - format!("sing-project-agent-{}", project.name), - "Agent status", + format!("sing-project-home-refresh-{}", self.project_name), + "Refresh", ) .style(ButtonStyle::Outlined) .label_size(LabelSize::Small) - .tooltip(Tooltip::text("Show current agent status")) + .loading(self.loading) + .disabled(self.pending_action.is_some()) + .tooltip(Tooltip::text( + self.refresh_status_label() + .unwrap_or_else(|| "Refresh project home".to_string()), + )) .on_click(cx.listener( - move |this, _, _, cx| { - this.show_agent_status(&agent_project_name, cx); + |this, _, window, cx| { + this.refresh(window, cx); }, )), ), ) + }) + .into_any_element() + } + + fn render_mode_picker(&self, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .gap_1() + .p_2() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + .children(ProjectHomeMode::ALL.into_iter().map(|mode| { + Button::new( + format!("sing-project-home-mode-{}-{mode:?}", self.project_name), + mode.label(), + ) + .style(if self.selected_mode == mode { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }) + .label_size(LabelSize::Small) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_mode(mode, cx); + })) + .into_any_element() + })) + .into_any_element() + } + + fn render_content(&self, cx: &mut Context) -> AnyElement { + let Some(project) = self.project.as_ref() else { + return v_flex() + .size_full() + .justify_center() + .items_center() + .gap_2() + .child(Icon::new(IconName::Warning).color(Color::Warning)) + .child(Label::new("Project unavailable").color(Color::Warning)) + .into_any_element(); + }; + + div() + .id("sing-project-home-content") + .flex_1() + .overflow_y_scroll() + .child(match self.selected_mode { + ProjectHomeMode::Summary => self.render_summary(project, cx), + ProjectHomeMode::Specs => self.render_specs(project, cx), + ProjectHomeMode::ArchivedSpecs => self.render_archived_specs(cx), + ProjectHomeMode::Kanban => self.render_kanban(cx), + ProjectHomeMode::Agents => self.render_agents(project, cx), + ProjectHomeMode::Activity => self.render_activity(cx), + ProjectHomeMode::Settings => self.render_settings(project, cx), + }) + .into_any_element() + } + + fn render_summary(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement { + let activity = project.agent_activity(); + let runtime_summary = project + .runtime_summary() + .unwrap_or_else(|| "Runtime metadata unavailable".to_string()); + let branch = project.branch().map(str::to_string); + let ip = project.ip.clone(); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child( + h_flex() + .w_full() + .gap_2() + .flex_wrap() + .children(project_home_badges(project)), + ) + .child( + h_flex() + .w_full() + .gap_2() + .flex_wrap() + .child(metric_tile( + "Status", + project.status_label(), + status_color(project.status), + cx, + )) + .child(metric_tile( + "Ready", + project.ready_count().unwrap_or_default().to_string(), + Color::Success, + cx, + )) + .child(metric_tile( + "Blocked", + project.blocked_count().unwrap_or_default().to_string(), + Color::Warning, + cx, + )) + .child(metric_tile( + "Agent", + project.agent_badge(), + Color::Accent, + cx, + )), + ) + .child(section_panel( + "Project details", + v_flex() + .w_full() + .gap_1p5() + .child(home_detail_line("Runtime", runtime_summary, Color::Default)) + .child(home_detail_line( + "Activity", + activity.headline, + Color::Default, + )) + .when_some(activity.detail, |element, detail| { + element.child(home_detail_line("Context", detail, Color::Muted)) + }) + .when_some(activity.timestamp, |element, timestamp| { + element.child(home_detail_line("Started", timestamp, Color::Muted)) + }) + .child(home_detail_line( + "Specs", + project.spec_detail(), + Color::Default, + )) + .when_some(branch, |element, branch| { + element.child(home_detail_line("Branch", branch, Color::Accent)) + }) + .when_some(ip, |element, ip| { + element.child(home_detail_line( + "Network", + format!("IP {ip}"), + Color::Muted, + )) + }) + .when_some(project.detail_error.as_ref(), |element, error| { + element.child(home_detail_line("Details", error.clone(), Color::Warning)) + }) + .into_any_element(), + cx, + )) + .into_any_element() + } + + fn render_specs(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement { + let Some(board) = self.spec_board.as_ref() else { + return self.render_spec_unavailable(project, cx); + }; + + let active_specs = board + .specs + .iter() + .filter(|spec| spec.spec.status != SpecStatus::Done) + .collect::>(); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child( + h_flex() + .w_full() + .gap_2() + .flex_wrap() + .child(metric_tile( + "Pending", + board.counts.pending.to_string(), + Color::Muted, + cx, + )) + .child(metric_tile( + "In progress", + board.counts.in_progress.to_string(), + Color::Accent, + cx, + )) + .child(metric_tile( + "Review", + board.counts.review.to_string(), + Color::Warning, + cx, + )) + .child(metric_tile( + "Ready", + board.summary.ready_count.to_string(), + Color::Success, + cx, + )), + ) + .child(section_panel( + "Active specs", + specs_list(active_specs, "No active specs", cx), + cx, + )) + .into_any_element() + } + + fn render_spec_unavailable(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement { + let message = self + .spec_error + .clone() + .unwrap_or_else(|| project.spec_detail()); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Specs", + v_flex() + .w_full() + .gap_2() + .child(Label::new(message).color(Color::Muted)) + .into_any_element(), + cx, + )) + .into_any_element() + } + + fn render_archived_specs(&self, cx: &mut Context) -> AnyElement { + let archived = self + .spec_board + .as_ref() + .map(|board| { + board + .specs + .iter() + .filter(|spec| spec.spec.status == SpecStatus::Done) + .collect::>() + }) + .unwrap_or_default(); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Archived specs", + specs_list(archived, "No archived specs", cx), + cx, + )) + .into_any_element() + } + + fn render_kanban(&self, cx: &mut Context) -> AnyElement { + let Some(board) = self.spec_board.as_ref() else { + return v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Kanban", + Label::new(self.spec_error.clone().unwrap_or_else(|| { + "Spec board is not loaded for this project".to_string() + })) + .color(Color::Muted) + .into_any_element(), + cx, + )) + .into_any_element(); + }; + + h_flex() + .w_full() + .items_start() + .gap_2() + .flex_wrap() + .p_3() + .children([ + kanban_column("Pending", board, SpecStatus::Pending, cx), + kanban_column("In progress", board, SpecStatus::InProgress, cx), + kanban_column("Review", board, SpecStatus::Review, cx), + kanban_column("Done", board, SpecStatus::Done, cx), + ]) + .into_any_element() + } + + fn render_agents(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement { + let status = self + .agent_status + .as_ref() + .map(format_agent_status) + .or_else(|| self.agent_error.clone()) + .unwrap_or_else(|| project.agent_detail()); + let report = self + .agent_report + .as_ref() + .map(format_agent_report) + .or_else(|| self.agent_report_error.clone()) + .unwrap_or_else(|| "Agent report has not been loaded yet".to_string()); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Agent status", + v_flex() + .w_full() + .gap_1p5() + .child(home_detail_line("State", status, Color::Default)) + .child(home_detail_line( + "Session", + project.agent_detail(), + Color::Muted, + )) + .into_any_element(), + cx, + )) + .child(section_panel( + "Agent report", + Label::new(report).color(Color::Muted).into_any_element(), + cx, + )) + .into_any_element() + } + + fn render_activity(&self, cx: &mut Context) -> AnyElement { + let log = self + .agent_log + .as_ref() + .map(|log| log.lines.clone()) + .unwrap_or_default(); + let error = self.agent_log_error.clone().or_else(|| { + self.agent_log + .as_ref() + .and_then(|log| log.error.as_ref().cloned()) + }); + + v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Activity log", + if let Some(error) = error { + Label::new(error).color(Color::Warning).into_any_element() + } else if log.is_empty() { + Label::new("No recent agent log lines") + .color(Color::Muted) + .into_any_element() + } else { + v_flex() + .w_full() + .gap_1() + .children(log.into_iter().map(|line| { + Label::new(line) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx) + .into_any_element() + })) + .into_any_element() + }, + cx, + )) + .into_any_element() + } + + fn render_settings(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement { + v_flex() + .w_full() + .gap_3() + .p_3() + .child(section_panel( + "Project settings", + v_flex() + .w_full() + .gap_1p5() + .child(home_detail_line( + "Name", + project.name.clone(), + Color::Default, + )) + .child(home_detail_line( + "Status", + project.status_label(), + status_color(project.status), + )) + .child(home_detail_line( + "Specs", + project.spec_detail(), + Color::Default, + )) + .when_some(project.description.as_ref(), |element, description| { + element.child(home_detail_line( + "Description", + description.clone(), + Color::Muted, + )) + }) + .when_some(project.runtime_summary(), |element, runtimes| { + element.child(home_detail_line("Runtimes", runtimes, Color::Muted)) + }) + .when_some(project.ip.as_ref(), |element, ip| { + element.child(home_detail_line("Container IP", ip.clone(), Color::Muted)) + }) + .into_any_element(), + cx, + )) + .into_any_element() + } +} + +impl EventEmitter<()> for ProjectHomeItem {} + +impl Focusable for ProjectHomeItem { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ProjectHomeItem { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("sing-project-home") + .track_focus(&self.focus_handle) + .size_full() + .overflow_hidden() + .bg(cx.theme().colors().background) + .child(self.render_header(cx)) + .child(self.render_mode_picker(cx)) + .child(self.render_content(cx)) + } +} + +impl Item for ProjectHomeItem { + type Event = (); + + fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement { + Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx)) + .color(params.text_color()) + .truncate() + .into_any_element() + } + + fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString { + format!("{} Home", self.project_name).into() + } + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::Server)) + } + + fn tab_tooltip_text(&self, _: &App) -> Option { + Some(format!("Project home: {}", self.project_name).into()) + } +} + +fn project_status_chip(project: &ProjectRow) -> Chip { + Chip::new(project.status_label()).label_color(status_color(project.status)) +} + +fn project_home_badges(project: &ProjectRow) -> Vec { + let mut badges = vec![ + Chip::new(project.agent_badge()) + .label_color(match project.status { + ProjectStatus::Running if project.agent_session.running => Color::Accent, + ProjectStatus::Running => Color::Muted, + ProjectStatus::Error => Color::Error, + ProjectStatus::Stopped | ProjectStatus::NotCreated => Color::Warning, + }) + .into_any_element(), + ]; + + if let Some(ready) = project.ready_count().filter(|ready| *ready > 0) { + badges.push( + Chip::new(format!("Ready {ready}")) + .label_color(Color::Success) + .into_any_element(), + ); + } + + if let Some(blocked) = project.blocked_count().filter(|blocked| *blocked > 0) { + badges.push( + Chip::new(format!("Blocked {blocked}")) + .label_color(Color::Warning) + .into_any_element(), + ); + } + + if project.status == ProjectStatus::Running && !project.specs.available { + badges.push( + Chip::new("Specs need setup") + .label_color(Color::Warning) .into_any_element(), + ); + } + + badges +} + +fn metric_tile( + label: &'static str, + value: impl Into, + color: Color, + cx: &mut App, +) -> AnyElement { + let theme = cx.theme(); + v_flex() + .min_w(px(150.)) + .gap_1() + .p_2() + .rounded_sm() + .border_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().editor_background) + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .child(Label::new(value.into()).color(color)) + .into_any_element() +} + +fn section_panel(title: &'static str, content: AnyElement, cx: &mut App) -> AnyElement { + let theme = cx.theme(); + v_flex() + .w_full() + .gap_2() + .p_3() + .rounded_sm() + .border_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().editor_background) + .child(Label::new(title)) + .child(content) + .into_any_element() +} + +fn home_detail_line(label: &'static str, value: impl Into, color: Color) -> AnyElement { + h_flex() + .w_full() + .items_start() + .gap_3() + .child( + div() + .w(px(96.)) + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)), + ) + .child(Label::new(value.into()).size(LabelSize::Small).color(color)) + .into_any_element() +} + +fn specs_list(specs: Vec<&BoardSpecRecord>, empty: &'static str, cx: &mut App) -> AnyElement { + if specs.is_empty() { + return Label::new(empty).color(Color::Muted).into_any_element(); + } + + v_flex() + .w_full() + .gap_1() + .children(specs.into_iter().map(|spec| spec_row(spec, cx))) + .into_any_element() +} + +fn spec_row(spec: &BoardSpecRecord, cx: &mut App) -> AnyElement { + let theme = cx.theme(); + h_flex() + .id(format!("sing-project-home-spec-row-{}", spec.spec.id)) + .w_full() + .items_start() + .justify_between() + .gap_3() + .p_2() + .rounded_sm() + .border_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().panel_background) + .child( + v_flex() + .min_w(px(0.)) + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Indicator::dot().color(spec_status_color(spec.spec.status))) + .child(Label::new(spec.spec.id.clone()).size(LabelSize::Small)), + ) + .child(Label::new(spec_title(spec)).color(Color::Default)) + .when(!spec.spec.depends_on.is_empty(), |element| { + element.child( + Label::new(format!("Depends on {}", spec.spec.depends_on.join(", "))) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .when(spec.blocked, |element| { + element.child( + Label::new(format!("Blocked by {}", spec.unmet_dependencies.join(", "))) + .size(LabelSize::Small) + .color(Color::Warning), + ) + }), + ) + .child( + Chip::new(spec_status_label(spec.spec.status)) + .label_color(spec_status_color(spec.spec.status)), ) + .tooltip(Tooltip::text(format!( + "{}: {}", + spec.spec.id, + spec_title(spec) + ))) + .into_any_element() +} + +fn kanban_column( + title: &'static str, + board: &SpecBoard, + status: SpecStatus, + cx: &mut App, +) -> AnyElement { + let theme = cx.theme(); + let specs = board + .specs + .iter() + .filter(|spec| spec.spec.status == status) + .collect::>(); + + v_flex() + .w(px(280.)) + .min_w(px(260.)) + .gap_2() + .p_2() + .rounded_sm() + .border_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().panel_background) + .child( + h_flex() + .w_full() + .justify_between() + .gap_2() + .child(Label::new(title)) + .child(Chip::new(specs.len().to_string()).label_color(spec_status_color(status))), + ) + .when(specs.is_empty(), |element| { + element.child( + Label::new("No specs") + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .children(specs.into_iter().map(|spec| kanban_card(spec, cx))) + .into_any_element() +} + +fn kanban_card(spec: &BoardSpecRecord, cx: &mut App) -> AnyElement { + let theme = cx.theme(); + v_flex() + .id(format!("sing-project-home-kanban-card-{}", spec.spec.id)) + .w_full() + .gap_1() + .p_2() + .rounded_sm() + .border_1() + .border_color(theme.colors().border_variant) + .bg(theme.colors().editor_background) + .child( + Label::new(spec.spec.id.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .child(Label::new(spec_title(spec)).size(LabelSize::Small)) + .when(spec.ready, |element| { + element.child(Chip::new("Ready").label_color(Color::Success)) + }) + .when(spec.blocked, |element| { + element.child(Chip::new("Blocked").label_color(Color::Warning)) + }) + .tooltip(Tooltip::text(format!( + "{}: {}", + spec.spec.id, + spec_title(spec) + ))) + .into_any_element() +} + +fn spec_title(spec: &BoardSpecRecord) -> String { + if spec.spec.title.is_empty() { + spec.spec.id.clone() + } else { + spec.spec.title.clone() + } +} + +fn spec_status_label(status: SpecStatus) -> &'static str { + match status { + SpecStatus::Pending => "Pending", + SpecStatus::InProgress => "In progress", + SpecStatus::Review => "Review", + SpecStatus::Done => "Done", + } +} + +fn spec_status_color(status: SpecStatus) -> Color { + match status { + SpecStatus::Pending => Color::Muted, + SpecStatus::InProgress => Color::Accent, + SpecStatus::Review => Color::Warning, + SpecStatus::Done => Color::Success, } } +fn format_agent_status(status: &ProjectAgentStatus) -> String { + let state = if status.agent_running { + "Agent running" + } else { + "Agent idle" + }; + let mut parts = vec![state.to_string()]; + if let Some(task) = status.task.as_deref() { + parts.push(format!("task {task}")); + } + if let Some(branch) = status.branch.as_deref() { + parts.push(format!("branch {branch}")); + } + if let Some(pid) = status.pid { + parts.push(format!("pid {pid}")); + } + if let Some(commits) = status.commits_since_launch { + parts.push(format!("{commits} commits")); + } + parts.join(" | ") +} + +fn format_agent_report(report: &AgentReport) -> String { + let mut parts = vec![format!("Session {}", report.session_status)]; + if let Some(duration) = report.duration.as_deref() { + parts.push(format!("duration {duration}")); + } + if let Some(branch) = report.branch.as_deref() { + parts.push(format!("branch {branch}")); + } + parts.push(format!("{} commits", report.commits_since_launch)); + if report.guardrail_triggered { + parts.push( + report + .guardrail_reason + .as_ref() + .map(|reason| format!("guardrail {reason}")) + .unwrap_or_else(|| "guardrail triggered".to_string()), + ); + } + parts.join(" | ") +} + impl EventEmitter for SingProjectPanel {} impl Focusable for SingProjectPanel { @@ -1009,9 +1971,6 @@ impl Render for SingProjectPanel { element.child(banner) }) .child(self.render_projects(cx)) - .when_some(self.render_selected_project(cx), |element, details| { - element.child(details) - }) } }