diff --git a/assets/icons/mast.svg b/assets/icons/mast.svg
new file mode 100644
index 0000000000..e735ba01a3
--- /dev/null
+++ b/assets/icons/mast.svg
@@ -0,0 +1,20 @@
+
diff --git a/crates/sing_project/src/client.rs b/crates/sing_project/src/client.rs
index 25a12b4fe3..5cf3292b77 100644
--- a/crates/sing_project/src/client.rs
+++ b/crates/sing_project/src/client.rs
@@ -15,16 +15,16 @@ pub trait SingProjectClient: Send + Sync {
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")
+ anyhow::bail!("spec board is unavailable from this Sail client")
}
async fn agent_status(&self, _project: &str) -> Result {
- anyhow::bail!("agent status is unavailable from this project client")
+ anyhow::bail!("agent status is unavailable from this Sail client")
}
async fn agent_log(&self, _project: &str, _tail: u32) -> Result {
- anyhow::bail!("agent log is unavailable from this project client")
+ anyhow::bail!("agent log is unavailable from this Sail client")
}
async fn agent_report(&self, _project: &str) -> Result {
- anyhow::bail!("agent report is unavailable from this project client")
+ anyhow::bail!("agent report is unavailable from this Sail client")
}
}
diff --git a/crates/sing_project/src/panel.rs b/crates/sing_project/src/panel.rs
index 150bed6cdf..1ef54a5a49 100644
--- a/crates/sing_project/src/panel.rs
+++ b/crates/sing_project/src/panel.rs
@@ -5,9 +5,9 @@ use anyhow::{Context as _, Result, anyhow};
use db::kvp::KeyValueStore;
use editor::{Editor, EditorEvent};
use gpui::{
- Action, AnyElement, App, AsyncWindowContext, Context, Entity, EventEmitter, FocusHandle,
- Focusable, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled,
- Task, WeakEntity, Window, actions, px,
+ Action, AnyElement, App, AsyncWindowContext, ClickEvent, Context, ElementId, Entity,
+ EventEmitter, FocusHandle, Focusable, FontWeight, ParentElement, Pixels, Render, SharedString,
+ StatefulInteractiveElement, Styled, Task, WeakEntity, Window, actions, hsla, px,
};
use recent_projects::open_remote_project;
use serde::{Deserialize, Serialize};
@@ -16,8 +16,8 @@ use sing_bridge::{
SpecStatus,
};
use ui::{
- Button, Chip, Color, Icon, IconButtonShape, IconName, IconSize, Indicator, Label, LabelSize,
- ListItem, ListItemSpacing, SpinnerLabel, TintColor, Tooltip, prelude::*,
+ Button, Chip, Color, CopyButton, Icon, IconButtonShape, IconName, IconSize, Indicator, Label,
+ LabelSize, SpinnerLabel, TintColor, Tooltip, prelude::*,
};
use util::{ResultExt, TryFutureExt};
use workspace::{
@@ -163,7 +163,7 @@ impl SingProjectPanel {
cx.new(|cx| {
let search_bar = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
- editor.set_placeholder_text("Filter projects...", window, cx);
+ editor.set_placeholder_text("Filter sails...", window, cx);
editor
});
cx.subscribe(&search_bar, |_this, _, event: &EditorEvent, cx| {
@@ -466,6 +466,7 @@ impl SingProjectPanel {
.as_deref()
.is_some_and(|value| contains_query(value, query))
|| contains_query(project.status_label(), query)
+ || contains_query(&project.list_summary(), query)
|| contains_query(&project.agent_summary(), query)
|| contains_query(&project.spec_summary(), query)
|| contains_query(&project.agent_detail(), query)
@@ -491,22 +492,10 @@ impl SingProjectPanel {
})
}
- fn project_status_chip(&self, project: &ProjectRow) -> Chip {
- Self::badge(project.status_label(), status_color(project.status))
- }
-
- fn badge(label: impl Into, color: Color) -> Chip {
- Chip::new(label.into()).label_color(color)
- }
-
- 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(),
+ Some(refreshed) => format!("Refresh Sail state ({refreshed})"),
+ None => "Refresh Sail state".to_string(),
};
if self.loading {
@@ -521,14 +510,21 @@ impl SingProjectPanel {
.into_any_element();
}
- IconButton::new("sing-project-refresh", IconName::RotateCw)
- .shape(IconButtonShape::Square)
- .icon_size(IconSize::Small)
- .style(ButtonStyle::Subtle)
+ h_flex()
+ .id("sing-project-refresh-slot")
+ .size(px(28.))
+ .items_center()
+ .justify_center()
.tooltip(Tooltip::text(tooltip))
- .on_click(cx.listener(|this, _, window, cx| {
- this.refresh(window, cx);
- }))
+ .child(
+ IconButton::new("sing-project-refresh", IconName::RotateCw)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ .style(ButtonStyle::Subtle)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.refresh(window, cx);
+ })),
+ )
.into_any_element()
}
@@ -549,7 +545,7 @@ impl SingProjectPanel {
.items_center()
.justify_between()
.gap_2()
- .child(Label::new("Projects"))
+ .child(Label::new("Sails").weight(FontWeight::SEMIBOLD))
.child(self.render_refresh_control(cx)),
)
.child(
@@ -593,6 +589,139 @@ impl SingProjectPanel {
})
}
+ fn render_sail_row(
+ &self,
+ project: &ProjectRow,
+ selected: bool,
+ project_name: String,
+ cx: &mut Context,
+ ) -> AnyElement {
+ let theme = cx.theme();
+ let row_background = if selected {
+ theme.colors().ghost_element_selected
+ } else {
+ theme.colors().panel_background
+ };
+ let running_or_stopped = project.status_label();
+ let in_progress = project
+ .specs
+ .counts
+ .as_ref()
+ .map(|counts| counts.in_progress)
+ .unwrap_or_default();
+ let ready = project.ready_count().unwrap_or_default();
+ let blocked = project.blocked_count().unwrap_or_default();
+ let sync_label = self
+ .refresh_status_label()
+ .map(|label| label.replacen("Updated ", "", 1))
+ .unwrap_or_else(|| "not synced".to_string());
+
+ h_flex()
+ .id(format!("sing-project-row-{project_name}"))
+ .group("sail-row")
+ .w_full()
+ .h(px(76.))
+ .min_h(px(76.))
+ .flex_none()
+ .items_stretch()
+ .overflow_hidden()
+ .rounded_sm()
+ .border_1()
+ .border_color(if selected {
+ mast_accent().opacity(0.42)
+ } else {
+ theme.colors().border_variant
+ })
+ .bg(row_background)
+ .hover(|style| style.bg(theme.colors().ghost_element_hover))
+ .cursor_pointer()
+ .child(div().w(px(3.)).h_full().flex_none().bg(if selected {
+ mast_accent()
+ } else {
+ theme.colors().border_variant.opacity(0.0)
+ }))
+ .child(
+ v_flex()
+ .min_w(px(0.))
+ .flex_1()
+ .gap_1()
+ .px_2()
+ .py_1p5()
+ .child(
+ h_flex()
+ .w_full()
+ .min_w(px(0.))
+ .items_center()
+ .justify_between()
+ .gap_2()
+ .child(
+ h_flex()
+ .min_w(px(0.))
+ .gap_1p5()
+ .items_center()
+ .child(Indicator::dot().color(status_color(project.status)))
+ .child(
+ Label::new(project.name.clone())
+ .size(LabelSize::Small)
+ .weight(FontWeight::SEMIBOLD)
+ .truncate(),
+ ),
+ )
+ .child(
+ Label::new(running_or_stopped)
+ .size(LabelSize::XSmall)
+ .color(status_color(project.status))
+ .flex_shrink_0(),
+ ),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .min_w(px(0.))
+ .gap_1()
+ .flex_wrap()
+ .child(sail_count_pill("Ready", ready, Color::Success, cx))
+ .child(sail_count_pill(
+ "In progress",
+ in_progress,
+ Color::Accent,
+ cx,
+ ))
+ .child(sail_count_pill("Blocked", blocked, Color::Warning, cx)),
+ )
+ .child(
+ h_flex()
+ .w_full()
+ .min_w(px(0.))
+ .items_center()
+ .justify_between()
+ .gap_2()
+ .child(
+ Label::new(project.agent_summary())
+ .size(LabelSize::XSmall)
+ .color(Color::Muted)
+ .truncate(),
+ )
+ .child(
+ Label::new(sync_label)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted)
+ .flex_shrink_0(),
+ ),
+ ),
+ )
+ .tooltip(Tooltip::text(format!(
+ "{} Sail · {} · {}",
+ project.name,
+ project.status_label(),
+ project.spec_detail()
+ )))
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.open_project_home(project_name.clone(), window, cx);
+ }))
+ .into_any_element()
+ }
+
fn render_projects(&self, cx: &mut Context) -> AnyElement {
if self.loading && self.projects.is_empty() {
return v_flex()
@@ -602,7 +731,7 @@ impl SingProjectPanel {
.gap_2()
.child(SpinnerLabel::dots_variant().size(LabelSize::Large))
.child(
- Label::new("Loading projects")
+ Label::new("Loading Sails")
.size(LabelSize::Small)
.color(Color::Muted),
)
@@ -615,9 +744,9 @@ impl SingProjectPanel {
.justify_center()
.items_center()
.gap_2()
- .child(Icon::new(IconName::Server).color(Color::Muted))
+ .child(mast_mark(px(32.)))
.child(
- Label::new("No projects found")
+ Label::new("No Sails found")
.size(LabelSize::Small)
.color(Color::Muted),
)
@@ -633,7 +762,7 @@ impl SingProjectPanel {
.gap_2()
.child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
.child(
- Label::new("No projects match this filter")
+ Label::new("No Sails match this filter")
.size(LabelSize::Small)
.color(Color::Muted),
)
@@ -645,37 +774,12 @@ impl SingProjectPanel {
.into_iter()
.map(|project| {
let project_name = project.name.clone();
- let status_color = status_color(project.status);
- let secondary_line = self.project_secondary_line(project);
- ListItem::new(format!("sing-project-row-{project_name}"))
- .inset(true)
- .spacing(ListItemSpacing::Sparse)
- .toggle_state(selected_project == Some(project_name.as_str()))
- .start_slot(Indicator::dot().color(status_color))
- .end_slot(self.project_status_chip(project))
- .child(
- v_flex()
- .w_full()
- .gap_1()
- .child(
- Label::new(project.name.clone())
- .size(LabelSize::Small)
- .truncate(),
- )
- .when_some(secondary_line, |element, line| {
- element.child(
- Label::new(line)
- .size(LabelSize::Small)
- .color(Color::Muted)
- .truncate(),
- )
- }),
- )
- .tooltip(Tooltip::text(project.name.clone()))
- .on_click(cx.listener(move |this, _, window, cx| {
- this.open_project_home(project_name.clone(), window, cx);
- }))
- .into_any_element()
+ self.render_sail_row(
+ project,
+ selected_project == Some(project_name.as_str()),
+ project_name,
+ cx,
+ )
})
.collect::>();
@@ -690,21 +794,19 @@ impl SingProjectPanel {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProjectHomeMode {
- Summary,
+ Overview,
Specs,
- ArchivedSpecs,
- Kanban,
+ Board,
Agents,
Activity,
Settings,
}
impl ProjectHomeMode {
- const ALL: [Self; 7] = [
- Self::Summary,
+ const ALL: [Self; 6] = [
+ Self::Overview,
Self::Specs,
- Self::ArchivedSpecs,
- Self::Kanban,
+ Self::Board,
Self::Agents,
Self::Activity,
Self::Settings,
@@ -712,15 +814,25 @@ impl ProjectHomeMode {
fn label(self) -> &'static str {
match self {
- Self::Summary => "Summary",
+ Self::Overview => "Overview",
Self::Specs => "Specs",
- Self::ArchivedSpecs => "Archived",
- Self::Kanban => "Kanban",
+ Self::Board => "Board",
Self::Agents => "Agents",
Self::Activity => "Activity",
Self::Settings => "Settings",
}
}
+
+ fn icon(self) -> IconName {
+ match self {
+ Self::Overview => IconName::Info,
+ Self::Specs => IconName::ListTodo,
+ Self::Board => IconName::FileTree,
+ Self::Agents => IconName::ZedAssistant,
+ Self::Activity => IconName::Clock,
+ Self::Settings => IconName::Settings,
+ }
+ }
}
struct ProjectHomeRefresh {
@@ -767,7 +879,7 @@ impl ProjectHomeItem {
client: None,
project_name: project.name.clone(),
project: Some(project),
- selected_mode: ProjectHomeMode::Summary,
+ selected_mode: ProjectHomeMode::Overview,
loading: false,
pending_action: None,
last_error: None,
@@ -796,7 +908,7 @@ impl ProjectHomeItem {
fn mark_missing(&mut self, cx: &mut Context) {
self.project = None;
- self.last_error = Some("Project is no longer present after refresh".to_string());
+ self.last_error = Some("Sail is no longer present after refresh".to_string());
cx.notify();
}
@@ -830,7 +942,7 @@ impl ProjectHomeItem {
let project = projects
.into_iter()
.find(|project| project.name == project_name)
- .ok_or_else(|| anyhow!("project `{project_name}` was not found"))?;
+ .ok_or_else(|| anyhow!("Sail `{project_name}` was not found"))?;
let spec_board = project.specs.available.then(|| {
let client = client.clone();
@@ -965,11 +1077,11 @@ impl ProjectHomeItem {
match action {
ProjectActionKind::Start => {
let result = client.start_project(&project).await?;
- Ok(format!("Started {}", result.name))
+ Ok(format!("Started {} Sail", result.name))
}
ProjectActionKind::Stop => {
client.stop_project(&project).await?;
- Ok(format!("Stopped {project}"))
+ Ok(format!("Stopped {project} Sail"))
}
ProjectActionKind::Open => {
let target = client.project_remote_target(&project).await?;
@@ -1077,11 +1189,18 @@ impl ProjectHomeItem {
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);
+ let description = project
+ .and_then(|project| project.description.as_ref())
+ .cloned()
+ .unwrap_or_else(|| "SAIL-managed development environment".to_string());
+ let last_sync = self
+ .refresh_status_label()
+ .unwrap_or_else(|| "Not synced yet".to_string());
v_flex()
.w_full()
- .gap_2()
- .p_3()
+ .gap_3()
+ .p_4()
.border_b_1()
.border_color(theme.colors().border_variant)
.bg(theme.colors().editor_background)
@@ -1093,26 +1212,33 @@ impl ProjectHomeItem {
.gap_3()
.child(
v_flex()
+ .min_w(px(0.))
+ .flex_1()
.gap_1()
.child(
h_flex()
.gap_2()
.items_center()
- .child(Icon::new(IconName::Server).color(Color::Muted))
+ .child(mast_mark(px(28.)))
.child(
- Label::new(self.project_name.clone())
- .size(LabelSize::Large),
+ Label::new(format!("{} Sail", self.project_name))
+ .size(LabelSize::Large)
+ .weight(FontWeight::SEMIBOLD)
+ .truncate(),
),
)
- .when_some(
- project.and_then(|project| project.description.as_ref()),
- |element, description| {
- element
- .child(Label::new(description.clone()).color(Color::Muted))
- },
- )
+ .child(copyable_text(
+ format!("sing-project-copy-description-{}", self.project_name),
+ description,
+ Color::Muted,
+ LabelSize::Small,
+ ))
.when_some(self.last_error.as_ref(), |element, error| {
- element.child(Label::new(error.clone()).color(Color::Warning))
+ element.child(readable_error_row(
+ "Sail state could not be refreshed",
+ error.clone(),
+ cx,
+ ))
}),
)
.when_some(project, |element, project| {
@@ -1125,16 +1251,106 @@ impl ProjectHomeItem {
.w_full()
.gap_2()
.flex_wrap()
+ .child(home_metadata_pill(
+ IconName::Server,
+ project.status_label(),
+ status_color(project.status),
+ cx,
+ ))
+ .child(home_metadata_pill(
+ IconName::ZedAssistant,
+ project.agent_badge(),
+ agent_color(project),
+ cx,
+ ))
+ .child(home_metadata_pill(
+ IconName::ListTodo,
+ project.spec_detail(),
+ Color::Muted,
+ cx,
+ ))
+ .when_some(project.branch(), |element, branch| {
+ element.child(home_metadata_pill(
+ IconName::GitBranch,
+ branch.to_string(),
+ Color::Accent,
+ cx,
+ ))
+ })
+ .child(home_metadata_pill(
+ IconName::Clock,
+ last_sync.clone(),
+ Color::Muted,
+ cx,
+ )),
+ )
+ })
+ .when_some(project, |element, project| {
+ let ip = project.ip.clone();
+ let cpu = project.cpu_summary();
+ let memory = project.memory_summary();
+ element.child(
+ h_flex()
+ .w_full()
+ .gap_2()
+ .flex_wrap()
+ .child(header_metric(
+ "IP",
+ ip.clone().unwrap_or_else(|| "Unavailable".to_string()),
+ if ip.is_some() {
+ Color::Default
+ } else {
+ Color::Muted
+ },
+ cx,
+ ))
+ .child(header_metric(
+ "CPU",
+ cpu.clone().unwrap_or_else(|| "Not reported".to_string()),
+ if cpu.is_some() {
+ Color::Default
+ } else {
+ Color::Muted
+ },
+ cx,
+ ))
+ .child(header_metric(
+ "Memory",
+ memory.clone().unwrap_or_else(|| "Not reported".to_string()),
+ if memory.is_some() {
+ Color::Default
+ } else {
+ Color::Muted
+ },
+ cx,
+ ))
+ .child(header_metric(
+ "Agent",
+ project.agent_detail(),
+ agent_color(project),
+ cx,
+ )),
+ )
+ })
+ .when_some(project, |element, project| {
+ element.child(
+ h_flex()
+ .w_full()
+ .items_center()
+ .gap_1p5()
+ .flex_wrap()
.child(
Button::new(
format!("sing-project-home-open-{}", self.project_name),
- "Open remote",
+ "Open Remote",
)
.style(ButtonStyle::Filled)
+ .size(ButtonSize::Default)
.label_size(LabelSize::Small)
+ .start_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
.loading(open_pending)
.disabled(!project.can_open() || start_pending || stop_pending)
- .tooltip(Tooltip::text("Open this project in a remote workspace"))
+ .tooltip(Tooltip::text("Open this Sail in a remote workspace"))
.on_click(cx.listener(
|this, _, window, cx| {
this.open_project(window, cx);
@@ -1148,10 +1364,12 @@ impl ProjectHomeItem {
"Start",
)
.style(ButtonStyle::Tinted(TintColor::Success))
+ .size(ButtonSize::Default)
.label_size(LabelSize::Small)
+ .start_icon(Icon::new(IconName::PlayOutlined).size(IconSize::Small))
.loading(start_pending)
.disabled(open_pending || stop_pending)
- .tooltip(Tooltip::text("Run sail up for this project"))
+ .tooltip(Tooltip::text("Run sail up for this Sail"))
.on_click(cx.listener(
|this, _, window, cx| {
this.start_project(window, cx);
@@ -1166,10 +1384,12 @@ impl ProjectHomeItem {
"Stop",
)
.style(ButtonStyle::Tinted(TintColor::Warning))
+ .size(ButtonSize::Default)
.label_size(LabelSize::Small)
+ .start_icon(Icon::new(IconName::Stop).size(IconSize::Small))
.loading(stop_pending)
.disabled(open_pending || start_pending)
- .tooltip(Tooltip::text("Run sail down for this project"))
+ .tooltip(Tooltip::text("Run sail down for this Sail"))
.on_click(cx.listener(
|this, _, window, cx| {
this.stop_project(window, cx);
@@ -1177,25 +1397,37 @@ impl ProjectHomeItem {
)),
)
})
- .child(
- Button::new(
- format!("sing-project-home-refresh-{}", self.project_name),
- "Refresh",
- )
- .style(ButtonStyle::Outlined)
- .label_size(LabelSize::Small)
- .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(
- |this, _, window, cx| {
- this.refresh(window, cx);
- },
- )),
- ),
+ .child(toolbar_icon_button(
+ format!("sing-project-home-refresh-{}", self.project_name),
+ if self.loading {
+ IconName::LoadCircle
+ } else {
+ IconName::RotateCw
+ },
+ "Refresh Sail Home",
+ self.pending_action.is_some(),
+ cx.listener(|this, _, window, cx| {
+ this.refresh(window, cx);
+ }),
+ ))
+ .child(toolbar_icon_button(
+ format!("sing-project-home-activity-{}", self.project_name),
+ IconName::ToolTerminal,
+ "Show Activity",
+ false,
+ cx.listener(|this, _, _, cx| {
+ this.select_mode(ProjectHomeMode::Activity, cx);
+ }),
+ ))
+ .child(toolbar_icon_button(
+ format!("sing-project-home-settings-{}", self.project_name),
+ IconName::Settings,
+ "Show Settings",
+ false,
+ cx.listener(|this, _, _, cx| {
+ this.select_mode(ProjectHomeMode::Settings, cx);
+ }),
+ )),
)
})
.into_any_element()
@@ -1204,8 +1436,9 @@ impl ProjectHomeItem {
fn render_mode_picker(&self, cx: &mut Context) -> AnyElement {
h_flex()
.w_full()
- .gap_1()
- .p_2()
+ .gap_1p5()
+ .px_3()
+ .py_2()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.bg(cx.theme().colors().panel_background)
@@ -1214,11 +1447,13 @@ impl ProjectHomeItem {
format!("sing-project-home-mode-{}-{mode:?}", self.project_name),
mode.label(),
)
+ .start_icon(Icon::new(mode.icon()).size(IconSize::Small))
.style(if self.selected_mode == mode {
- ButtonStyle::Filled
+ ButtonStyle::Tinted(TintColor::Accent)
} else {
ButtonStyle::Subtle
})
+ .size(ButtonSize::Default)
.label_size(LabelSize::Small)
.on_click(cx.listener(move |this, _, _, cx| {
this.select_mode(mode, cx);
@@ -1236,7 +1471,7 @@ impl ProjectHomeItem {
.items_center()
.gap_2()
.child(Icon::new(IconName::Warning).color(Color::Warning))
- .child(Label::new("Project unavailable").color(Color::Warning))
+ .child(Label::new("Sail unavailable").color(Color::Warning))
.into_any_element();
};
@@ -1245,10 +1480,9 @@ impl ProjectHomeItem {
.flex_1()
.overflow_y_scroll()
.child(match self.selected_mode {
- ProjectHomeMode::Summary => self.render_summary(project, cx),
+ ProjectHomeMode::Overview => self.render_overview(project, cx),
ProjectHomeMode::Specs => self.render_specs(project, cx),
- ProjectHomeMode::ArchivedSpecs => self.render_archived_specs(cx),
- ProjectHomeMode::Kanban => self.render_kanban(cx),
+ ProjectHomeMode::Board => self.render_board(cx),
ProjectHomeMode::Agents => self.render_agents(project, cx),
ProjectHomeMode::Activity => self.render_activity(cx),
ProjectHomeMode::Settings => self.render_settings(project, cx),
@@ -1256,7 +1490,7 @@ impl ProjectHomeItem {
.into_any_element()
}
- fn render_summary(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement {
+ fn render_overview(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement {
let activity = project.agent_activity();
let runtime_summary = project
.runtime_summary()
@@ -1306,7 +1540,7 @@ impl ProjectHomeItem {
)),
)
.child(section_panel(
- "Project details",
+ "Sail details",
v_flex()
.w_full()
.gap_1p5()
@@ -1356,6 +1590,11 @@ impl ProjectHomeItem {
.iter()
.filter(|spec| spec.spec.status != SpecStatus::Done)
.collect::>();
+ let archived_specs = board
+ .specs
+ .iter()
+ .filter(|spec| spec.spec.status == SpecStatus::Done)
+ .collect::>();
v_flex()
.w_full()
@@ -1393,72 +1632,65 @@ impl ProjectHomeItem {
)
.child(section_panel(
"Active specs",
- specs_list(active_specs, "No active specs", cx),
+ specs_list(active_specs, "No specs loaded", 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(),
+ "Archived specs",
+ specs_list(archived_specs, "No archived specs", cx),
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();
-
+ fn render_spec_unavailable(&self, project: &ProjectRow, cx: &mut Context) -> AnyElement {
v_flex()
.w_full()
.gap_3()
.p_3()
.child(section_panel(
- "Archived specs",
- specs_list(archived, "No archived specs", cx),
+ "Specs",
+ if let Some(error) = self.spec_error.clone() {
+ readable_error_row("Specs could not be loaded", error, cx)
+ } else {
+ v_flex()
+ .w_full()
+ .gap_2()
+ .child(Label::new("No specs loaded").color(Color::Muted))
+ .child(
+ Label::new(project.spec_detail())
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .into_any_element()
+ },
cx,
))
.into_any_element()
}
- fn render_kanban(&self, cx: &mut Context) -> AnyElement {
+ fn render_board(&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(),
+ "Board",
+ if let Some(error) = self.spec_error.clone() {
+ readable_error_row("Board could not be loaded", error, cx)
+ } else {
+ v_flex()
+ .w_full()
+ .gap_2()
+ .child(Label::new("No specs loaded").color(Color::Muted))
+ .child(
+ Label::new("Refresh this Sail or start it to load the board.")
+ .size(LabelSize::Small)
+ .color(Color::Muted),
+ )
+ .into_any_element()
+ },
cx,
))
.into_any_element();
@@ -1484,19 +1716,24 @@ impl ProjectHomeItem {
.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()
+ .when_some(self.agent_error.clone(), |element, error| {
+ element.child(section_panel(
+ "Agent API",
+ readable_error_row("Agent status could not be loaded", error, cx),
+ cx,
+ ))
+ })
.child(section_panel(
"Agent status",
v_flex()
@@ -1511,9 +1748,21 @@ impl ProjectHomeItem {
.into_any_element(),
cx,
))
+ .when_some(self.agent_report_error.clone(), |element, error| {
+ element.child(section_panel(
+ "Report API",
+ readable_error_row("Agent report could not be loaded", error, cx),
+ cx,
+ ))
+ })
.child(section_panel(
"Agent report",
- Label::new(report).color(Color::Muted).into_any_element(),
+ copyable_text(
+ format!("sing-project-copy-agent-report-{}", self.project_name),
+ report,
+ Color::Muted,
+ LabelSize::Small,
+ ),
cx,
))
.into_any_element()
@@ -1538,7 +1787,7 @@ impl ProjectHomeItem {
.child(section_panel(
"Activity log",
if let Some(error) = error {
- Label::new(error).color(Color::Warning).into_any_element()
+ readable_error_row("Activity could not be loaded", error, cx)
} else if log.is_empty() {
Label::new("No recent agent log lines")
.color(Color::Muted)
@@ -1547,11 +1796,29 @@ impl ProjectHomeItem {
v_flex()
.w_full()
.gap_1()
- .children(log.into_iter().map(|line| {
- Label::new(line)
- .size(LabelSize::Small)
- .color(Color::Muted)
- .buffer_font(cx)
+ .children(log.into_iter().enumerate().map(|(index, line)| {
+ h_flex()
+ .w_full()
+ .min_w(px(0.))
+ .justify_between()
+ .gap_2()
+ .child(
+ Label::new(line.clone())
+ .size(LabelSize::Small)
+ .color(Color::Muted)
+ .buffer_font(cx)
+ .truncate(),
+ )
+ .child(
+ CopyButton::new(
+ format!(
+ "sing-project-copy-log-{}-{index}",
+ self.project_name
+ ),
+ line,
+ )
+ .icon_size(IconSize::XSmall),
+ )
.into_any_element()
}))
.into_any_element()
@@ -1567,7 +1834,7 @@ impl ProjectHomeItem {
.gap_3()
.p_3()
.child(section_panel(
- "Project settings",
+ "Sail settings",
v_flex()
.w_full()
.gap_1p5()
@@ -1639,15 +1906,15 @@ impl Item for ProjectHomeItem {
}
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
- format!("{} Home", self.project_name).into()
+ format!("{} Sail", self.project_name).into()
}
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option {
- Some(Icon::new(IconName::Server))
+ Some(Icon::from_path("icons/mast.svg"))
}
fn tab_tooltip_text(&self, _: &App) -> Option {
- Some(format!("Project home: {}", self.project_name).into())
+ Some(format!("Sail Home: {}", self.project_name).into())
}
}
@@ -1655,6 +1922,187 @@ fn project_status_chip(project: &ProjectRow) -> Chip {
Chip::new(project.status_label()).label_color(status_color(project.status))
}
+fn mast_accent() -> gpui::Hsla {
+ hsla(10. / 360., 0.97, 0.57, 1.)
+}
+
+fn mast_mark(size: Pixels) -> AnyElement {
+ div()
+ .size(size)
+ .flex_none()
+ .rounded_sm()
+ .overflow_hidden()
+ .child(Icon::from_path("icons/mast.svg").size(IconSize::Medium))
+ .into_any_element()
+}
+
+fn sail_count_pill(label: &'static str, count: u32, color: Color, cx: &mut App) -> AnyElement {
+ h_flex()
+ .h(px(18.))
+ .items_center()
+ .gap_0p5()
+ .px_1()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().editor_background)
+ .child(
+ Label::new(label)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted)
+ .single_line(),
+ )
+ .child(
+ Label::new(count.to_string())
+ .size(LabelSize::XSmall)
+ .color(color)
+ .single_line(),
+ )
+ .into_any_element()
+}
+
+fn agent_color(project: &ProjectRow) -> Color {
+ match project.status {
+ ProjectStatus::Running if project.agent_session.running => Color::Accent,
+ ProjectStatus::Running if project.agent_session.available => Color::Muted,
+ ProjectStatus::Running => Color::Warning,
+ ProjectStatus::Stopped | ProjectStatus::NotCreated => Color::Muted,
+ ProjectStatus::Error => Color::Error,
+ }
+}
+
+fn home_metadata_pill(
+ icon: IconName,
+ label: impl Into,
+ color: Color,
+ cx: &mut App,
+) -> AnyElement {
+ h_flex()
+ .h(px(24.))
+ .max_w(px(260.))
+ .items_center()
+ .gap_1()
+ .px_1p5()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().panel_background)
+ .child(Icon::new(icon).size(IconSize::XSmall).color(color))
+ .child(
+ Label::new(label.into())
+ .size(LabelSize::XSmall)
+ .color(color)
+ .truncate(),
+ )
+ .into_any_element()
+}
+
+fn header_metric(
+ label: &'static str,
+ value: impl Into,
+ color: Color,
+ cx: &mut App,
+) -> AnyElement {
+ v_flex()
+ .min_w(px(128.))
+ .gap_0p5()
+ .p_2()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().panel_background)
+ .child(
+ Label::new(label)
+ .size(LabelSize::XSmall)
+ .color(Color::Muted),
+ )
+ .child(
+ Label::new(value.into())
+ .size(LabelSize::Small)
+ .color(color)
+ .truncate(),
+ )
+ .into_any_element()
+}
+
+fn toolbar_icon_button(
+ id: impl Into,
+ icon: IconName,
+ tooltip: &'static str,
+ disabled: bool,
+ handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+) -> AnyElement {
+ IconButton::new(id, icon)
+ .shape(IconButtonShape::Square)
+ .icon_size(IconSize::Small)
+ .style(ButtonStyle::Subtle)
+ .disabled(disabled)
+ .tooltip(Tooltip::text(tooltip))
+ .on_click(handler)
+ .into_any_element()
+}
+
+fn readable_error_row(summary: &'static str, details: String, cx: &mut App) -> AnyElement {
+ h_flex()
+ .w_full()
+ .items_start()
+ .justify_between()
+ .gap_2()
+ .p_2()
+ .rounded_sm()
+ .border_1()
+ .border_color(cx.theme().colors().border_variant)
+ .bg(cx.theme().colors().panel_background)
+ .child(
+ h_flex()
+ .min_w(px(0.))
+ .gap_2()
+ .items_start()
+ .child(
+ Icon::new(IconName::Warning)
+ .size(IconSize::Small)
+ .color(Color::Warning),
+ )
+ .child(
+ v_flex()
+ .min_w(px(0.))
+ .gap_0p5()
+ .child(
+ Label::new(summary)
+ .size(LabelSize::Small)
+ .color(Color::Warning),
+ )
+ .child(
+ Label::new("Use the debug copy action for the raw response.")
+ .size(LabelSize::XSmall)
+ .color(Color::Muted)
+ .truncate(),
+ ),
+ ),
+ )
+ .child(
+ CopyButton::new(format!("sing-project-copy-error-{summary}"), details)
+ .tooltip_label("Copy debug details"),
+ )
+ .into_any_element()
+}
+
+fn copyable_text(
+ id: impl Into,
+ value: impl Into,
+ color: Color,
+ size: LabelSize,
+) -> AnyElement {
+ let value = value.into();
+ h_flex()
+ .min_w(px(0.))
+ .gap_1()
+ .items_center()
+ .child(Label::new(value.clone()).size(size).color(color).truncate())
+ .child(CopyButton::new(id, value).icon_size(IconSize::XSmall))
+ .into_any_element()
+}
+
fn project_home_badges(project: &ProjectRow) -> Vec {
let mut badges = vec![
Chip::new(project.agent_badge())
@@ -1730,6 +2178,7 @@ fn section_panel(title: &'static str, content: AnyElement, cx: &mut App) -> AnyE
}
fn home_detail_line(label: &'static str, value: impl Into, color: Color) -> AnyElement {
+ let value = value.into();
h_flex()
.w_full()
.items_start()
@@ -1739,7 +2188,12 @@ fn home_detail_line(label: &'static str, value: impl Into, color: Color)
.w(px(96.))
.child(Label::new(label).size(LabelSize::Small).color(Color::Muted)),
)
- .child(Label::new(value.into()).size(LabelSize::Small).color(color))
+ .child(copyable_text(
+ format!("sing-project-copy-detail-{label}-{value}"),
+ value,
+ color,
+ LabelSize::Small,
+ ))
.into_any_element()
}
@@ -1796,8 +2250,20 @@ fn spec_row(spec: &BoardSpecRecord, cx: &mut App) -> AnyElement {
}),
)
.child(
- Chip::new(spec_status_label(spec.spec.status))
- .label_color(spec_status_color(spec.spec.status)),
+ h_flex()
+ .gap_1()
+ .items_center()
+ .child(
+ CopyButton::new(
+ format!("sing-project-copy-spec-{}", spec.spec.id),
+ spec_title(spec),
+ )
+ .icon_size(IconSize::XSmall),
+ )
+ .child(
+ Chip::new(spec_status_label(spec.spec.status))
+ .label_color(spec_status_color(spec.spec.status)),
+ ),
)
.tooltip(Tooltip::text(format!(
"{}: {}",
@@ -1976,7 +2442,7 @@ impl Render for SingProjectPanel {
impl Panel for SingProjectPanel {
fn persistent_name() -> &'static str {
- "Projects"
+ "Sails"
}
fn panel_key() -> &'static str {
@@ -2013,7 +2479,7 @@ impl Panel for SingProjectPanel {
}
fn icon_tooltip(&self, _: &Window, _: &App) -> Option<&'static str> {
- Some("Projects")
+ Some("Sails")
}
fn toggle_action(&self) -> Box {
diff --git a/crates/sing_project/src/state.rs b/crates/sing_project/src/state.rs
index d17e418b4b..85df772544 100644
--- a/crates/sing_project/src/state.rs
+++ b/crates/sing_project/src/state.rs
@@ -4,8 +4,8 @@ use std::sync::Arc;
use anyhow::Result;
use futures::future::join_all;
use sing_bridge::{
- AgentSessionInfo, ProjectConfig, ProjectRuntimes, ProjectSpecAvailability, ProjectStatus,
- ProjectSummary,
+ AgentSessionInfo, ProjectConfig, ProjectContainerLimits, ProjectResources, ProjectRuntimes,
+ ProjectSpecAvailability, ProjectStatus, ProjectSummary,
};
use crate::client::SingProjectClient;
@@ -38,6 +38,8 @@ pub struct ProjectRow {
pub status: ProjectStatus,
pub ip: Option,
pub description: Option,
+ pub resources: Option,
+ pub container_limits: Option,
pub runtimes: Option,
pub agent_session: AgentSessionInfo,
pub specs: ProjectSpecAvailability,
@@ -52,6 +54,8 @@ impl ProjectRow {
status: config.container_status,
ip: config.container_ip.or(summary.ip),
description: config.description,
+ resources: config.resources,
+ container_limits: config.container_limits,
runtimes: config.runtimes,
agent_session: config.agent_session,
specs: config.specs,
@@ -64,6 +68,8 @@ impl ProjectRow {
status: summary.status,
ip: summary.ip,
description: None,
+ resources: None,
+ container_limits: None,
runtimes: None,
agent_session: AgentSessionInfo {
available: false,
@@ -232,7 +238,7 @@ impl ProjectRow {
}
} else if let Some(reason) = self.specs.reason.as_deref() {
match reason {
- "project_stopped" => "Start project to load specs".to_string(),
+ "project_stopped" => "Start Sail to load specs".to_string(),
_ => format!("Specs need attention · {}", humanize_reason(reason)),
}
} else {
@@ -257,7 +263,7 @@ impl ProjectRow {
parts.join(" | ")
} else if let Some(reason) = self.specs.reason.as_deref() {
match reason {
- "project_stopped" => "Start the project to load its spec board".to_string(),
+ "project_stopped" => "Start the Sail to load its spec board".to_string(),
_ => format!("Check spec setup: {}", humanize_reason(reason)),
}
} else {
@@ -322,6 +328,28 @@ impl ProjectRow {
(!parts.is_empty()).then(|| parts.join(" | "))
}
+
+ pub fn cpu_summary(&self) -> Option {
+ self.container_limits
+ .as_ref()
+ .and_then(|limits| limits.cpu.clone())
+ .or_else(|| {
+ self.resources
+ .as_ref()
+ .map(|resources| format!("{} CPU", resources.cpu))
+ })
+ }
+
+ pub fn memory_summary(&self) -> Option {
+ self.container_limits
+ .as_ref()
+ .and_then(|limits| limits.memory.clone())
+ .or_else(|| {
+ self.resources
+ .as_ref()
+ .map(|resources| resources.memory.clone())
+ })
+ }
}
pub fn agent_activity_events(
@@ -620,6 +648,8 @@ mod tests {
status: ProjectStatus::Running,
ip: None,
description: None,
+ resources: None,
+ container_limits: None,
runtimes: None,
agent_session: AgentSessionInfo::default(),
specs: ProjectSpecAvailability::default(),
@@ -630,6 +660,8 @@ mod tests {
status: ProjectStatus::Stopped,
ip: None,
description: None,
+ resources: None,
+ container_limits: None,
runtimes: None,
agent_session: AgentSessionInfo::default(),
specs: ProjectSpecAvailability::default(),
@@ -652,6 +684,8 @@ mod tests {
status: ProjectStatus::Stopped,
ip: None,
description: None,
+ resources: None,
+ container_limits: None,
runtimes: None,
agent_session: AgentSessionInfo {
available: false,
@@ -676,11 +710,8 @@ mod tests {
assert_eq!(row.agent_summary(), "Agent unavailable | Project stopped");
assert_eq!(row.agent_detail(), "Agent unavailable | Project stopped");
- assert_eq!(row.spec_summary(), "Start project to load specs");
- assert_eq!(
- row.spec_detail(),
- "Start the project to load its spec board"
- );
+ assert_eq!(row.spec_summary(), "Start Sail to load specs");
+ assert_eq!(row.spec_detail(), "Start the Sail to load its spec board");
}
#[test]
@@ -728,6 +759,8 @@ mod tests {
status: ProjectStatus::Running,
ip: None,
description: None,
+ resources: None,
+ container_limits: None,
runtimes: None,
agent_session: AgentSessionInfo {
available,
diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs
index 53b89d9b2e..b0c7b05cf5 100644
--- a/crates/workspace/src/workspace.rs
+++ b/crates/workspace/src/workspace.rs
@@ -7731,7 +7731,9 @@ impl Workspace {
let size_state = dock.stored_panel_size_state(panel.as_ref());
let min_size = panel.min_size(window, cx);
container = container
- .rounded_lg()
+ .when(position == DockPosition::Bottom, |container| {
+ container.rounded_lg()
+ })
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().panel_background);
diff --git a/crates/zed/resources/app-icon-dev.png b/crates/zed/resources/app-icon-dev.png
index 45d00feafc..66cb6db59f 100644
Binary files a/crates/zed/resources/app-icon-dev.png and b/crates/zed/resources/app-icon-dev.png differ
diff --git a/crates/zed/resources/app-icon-dev@2x.png b/crates/zed/resources/app-icon-dev@2x.png
index 2de987c4a8..a48c617691 100644
Binary files a/crates/zed/resources/app-icon-dev@2x.png and b/crates/zed/resources/app-icon-dev@2x.png differ
diff --git a/crates/zed/resources/app-icon-nightly.png b/crates/zed/resources/app-icon-nightly.png
index 45d00feafc..66cb6db59f 100644
Binary files a/crates/zed/resources/app-icon-nightly.png and b/crates/zed/resources/app-icon-nightly.png differ
diff --git a/crates/zed/resources/app-icon-nightly@2x.png b/crates/zed/resources/app-icon-nightly@2x.png
index 2de987c4a8..a48c617691 100644
Binary files a/crates/zed/resources/app-icon-nightly@2x.png and b/crates/zed/resources/app-icon-nightly@2x.png differ
diff --git a/crates/zed/resources/app-icon-preview.png b/crates/zed/resources/app-icon-preview.png
index 45d00feafc..66cb6db59f 100644
Binary files a/crates/zed/resources/app-icon-preview.png and b/crates/zed/resources/app-icon-preview.png differ
diff --git a/crates/zed/resources/app-icon-preview@2x.png b/crates/zed/resources/app-icon-preview@2x.png
index 2de987c4a8..a48c617691 100644
Binary files a/crates/zed/resources/app-icon-preview@2x.png and b/crates/zed/resources/app-icon-preview@2x.png differ
diff --git a/crates/zed/resources/app-icon.png b/crates/zed/resources/app-icon.png
index 45d00feafc..66cb6db59f 100644
Binary files a/crates/zed/resources/app-icon.png and b/crates/zed/resources/app-icon.png differ
diff --git a/crates/zed/resources/app-icon@2x.png b/crates/zed/resources/app-icon@2x.png
index 2de987c4a8..a48c617691 100644
Binary files a/crates/zed/resources/app-icon@2x.png and b/crates/zed/resources/app-icon@2x.png differ
diff --git a/crates/zed/resources/windows/app-icon-dev.ico b/crates/zed/resources/windows/app-icon-dev.ico
index b48bb874d9..9bc2467110 100644
Binary files a/crates/zed/resources/windows/app-icon-dev.ico and b/crates/zed/resources/windows/app-icon-dev.ico differ
diff --git a/crates/zed/resources/windows/app-icon-nightly.ico b/crates/zed/resources/windows/app-icon-nightly.ico
index b48bb874d9..9bc2467110 100644
Binary files a/crates/zed/resources/windows/app-icon-nightly.ico and b/crates/zed/resources/windows/app-icon-nightly.ico differ
diff --git a/crates/zed/resources/windows/app-icon-preview.ico b/crates/zed/resources/windows/app-icon-preview.ico
index b48bb874d9..9bc2467110 100644
Binary files a/crates/zed/resources/windows/app-icon-preview.ico and b/crates/zed/resources/windows/app-icon-preview.ico differ
diff --git a/crates/zed/resources/windows/app-icon.ico b/crates/zed/resources/windows/app-icon.ico
index b48bb874d9..9bc2467110 100644
Binary files a/crates/zed/resources/windows/app-icon.ico and b/crates/zed/resources/windows/app-icon.ico differ