//! The Usage page, laid out like T3 Code's: tokens and cost over a range with //! a daily chart and a breakdown by model or day, and the plan's limits. Also //! the "Usage providers" section of the Providers page, for adding hubs. use std::rc::Rc; use chrono::{DateTime, Local}; use gpui_kit::component::{ Icon, IconName, Sizable as _, chart::AreaChart, h_flex, input::{Input, InputState}, menu::{DropdownMenu as _, PopupMenuItem}, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use serde_json::Value; use crate::settings::{self, UsageHub}; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; use crate::ui::pickers::CLAUDE_ORANGE; use crate::ui::settings::{card, section, section_title}; use crate::usage::{self, Limit, Line, Span, Summary, UsageReport, UsageSources, format_cost, format_tokens}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] enum Tab { Cost, Tokens, Limits, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Breakdown { Model, Day, } /// One Claude instance's limits, from its `get_usage` reply. struct PlanLimits { label: SharedString, plan: Option, limits: Vec, } pub struct UsageView { sources: Option, report: Option>, loading: bool, tab: Tab, span: Span, /// `None` shows all providers. provider: Option, breakdown: Breakdown, limits: Vec, /// The summary of the current report, range and provider, until one changes. summary: Option>, _collect: Option>, } impl UsageView { pub fn new(_window: &mut Window, _cx: &mut Context) -> Self { Self { sources: None, report: None, loading: false, tab: Tab::Tokens, span: Span::Month, provider: None, breakdown: Breakdown::Model, limits: Vec::new(), summary: None, _collect: None, } } /// Reads usage again from `sources` in the background. The last report /// stays up until the new one is in. pub fn refresh(&mut self, sources: UsageSources, cx: &mut Context) { self.sources = Some(sources.clone()); self.loading = true; // Always read the longest range, so switching ranges needs no reload. let since = Span::Quarter.since(Local::now()); let task = cx.background_executor().spawn(async move { usage::collect(&sources, since) }); self._collect = Some(cx.spawn(async move |this, cx| { let report = task.await; let _ = this.update(cx, |this, cx| { this.loading = false; if this.provider.as_ref().is_some_and(|provider| !report.providers().contains(provider)) { this.provider = None; } this.report = Some(Rc::new(report)); this.summary = None; cx.notify(); }); })); cx.notify(); } /// Takes each Claude instance's reply to `get_usage`, labelled. pub fn set_limits(&mut self, limits: Vec<(SharedString, Value)>, cx: &mut Context) { self.limits = limits .into_iter() .map(|(label, reply)| PlanLimits { label, plan: reply.get("subscription_type").and_then(Value::as_str).map(plan_name), limits: usage::limits_from_claude(&reply), }) .collect(); cx.notify(); } fn reload(&mut self, cx: &mut Context) { if let Some(sources) = self.sources.clone() { self.refresh(sources, cx); } } /// Reads usage from every provider instance's home folder and the usage /// hubs, and shows each Claude instance's plan limits from its last /// health check. pub fn refresh_all(&mut self, cx: &mut Context) { let catalog = crate::models::Catalog::global(cx); let mut sources = UsageSources { hubs: settings::get(|settings| settings.usage_hubs.clone()), ..Default::default() }; let mut limits = Vec::new(); for state in &catalog.instances { use crate::models::Provider; let home = match (state.config.home.trim(), state.kind()) { ("", Provider::Claude) => settings::expand_home("~/.claude"), ("", Provider::Pi) => settings::expand_home("~/.pi/agent"), (home, _) => settings::expand_home(home), }; let label = match state.kind() { Provider::Claude if state.label() == "Claude" => "Claude Code".to_string(), _ => state.label(), }; match state.kind() { Provider::Claude => sources.claude_dirs.push((label.clone(), home)), Provider::Pi => sources.pi_dirs.push((label.clone(), home)), // jcode keeps no usage logs this page reads. Provider::Jcode => {} } if let Some(usage) = &state.usage { limits.push((SharedString::from(label), usage.clone())); } } // Instances sharing a home folder share their logs; read them once. sources.claude_dirs.dedup_by(|a, b| a.1 == b.1); sources.pi_dirs.dedup_by(|a, b| a.1 == b.1); self.refresh(sources, cx); self.set_limits(limits, cx); } fn summary(&mut self) -> Rc { let report = self.report.clone().unwrap_or_default(); self.summary .get_or_insert_with(|| { Rc::new(usage::summarize(&report, self.span, self.provider.as_deref(), Local::now().naive_local())) }) .clone() } // ---- header -------------------------------------------------------------- fn render_header(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let view = cx.weak_entity(); let providers = self.report.as_ref().map(|report| report.providers()).unwrap_or_default(); let current = self.provider.clone(); let provider_menu = MetalButton::new("usage-provider") .ghost() .compact() .label(current.clone().unwrap_or_else(|| "All providers".to_string())) .caret() .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, _| { let pick = |provider: Option| { let view = view.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let provider = provider.clone(); let _ = view.update(cx, |this, cx| { this.provider = provider; this.summary = None; cx.notify(); }); } }; menu = menu .min_w(px(200.)) .item(PopupMenuItem::new("All providers").checked(current.is_none()).on_click(pick(None))); if !providers.is_empty() { menu = menu.separator(); } for provider in providers.clone() { let checked = current.as_deref() == Some(provider.as_str()); menu = menu.item(PopupMenuItem::new(provider.clone()).checked(checked).on_click(pick(Some(provider)))); } menu }); let view = cx.weak_entity(); let tabs = metal::segmented( "usage-tab", &[(Tab::Cost, "Cost"), (Tab::Tokens, "Tokens"), (Tab::Limits, "Limits")], self.tab, p, move |tab, _, cx| { let _ = view.update(cx, |this, cx| { this.tab = tab; cx.notify(); }); }, ); let view = cx.weak_entity(); let spans: Vec<(Span, &'static str)> = Span::ALL.iter().map(|span| (*span, span.label())).collect(); let ranges = metal::segmented("usage-range", &spans, self.span, p, move |span, _, cx| { let _ = view.update(cx, |this, cx| { this.span = span; this.summary = None; cx.notify(); }); }); let updated = self.report.as_ref().and_then(|report| report.collected_at).filter(|_| !self.loading); let breadcrumb = h_flex() .gap(px(8.)) .map(|this| { if self.tab == Tab::Limits { this.child(div().pl(px(4.)).text_size(px(14.)).text_color(p.ink2).child("Plan limits")) } else { this.child(provider_menu) } }) .child(div().flex_1()) .when(self.loading, |this| { this.child( h_flex() .gap(px(6.)) .child(metal::led("usage-loading", Led::Waiting, true, p)) .child(metal::engraved("Reading", p)), ) }) .when_some(updated, |this, at| this.child(metal::engraved(format!("Updated {}", at.format("%H:%M")), p))) .child( MetalButton::new("usage-refresh") .compact() .icon(IconName::RefreshCw) .label("Refresh") .disabled(self.loading || self.sources.is_none()) .on_click(cx.listener(|this, _, _, cx| this.reload(cx))), ); v_flex() .gap(px(14.)) .child(breadcrumb) .child( h_flex() .flex_wrap() .gap(px(10.)) .child(tabs) .child(div().flex_1()) .when(self.tab != Tab::Limits, |this| this.child(ranges)), ) } // ---- cost and tokens ------------------------------------------------------- fn render_totals_page(&mut self, cx: &mut Context) -> Vec { let p = metal::palette(cx); let summary = self.summary(); let cost_tab = self.tab == Tab::Cost; let s = &*summary; if self.report.is_none() { let note = if self.loading { "Reading the session logs…" } else { "Nothing read yet." }; return vec![card(vec![empty_row(note, p)], p).into_any_element()]; } let total_cost = s.cost.total(); let total_tokens = s.tokens.total(); let headline = if cost_tab { format_cost(total_cost) } else { format_tokens(total_tokens) }; let sessions = match s.sessions { 1 => "1 session".to_string(), n => format!("{n} sessions"), }; let mut caption = format!("{sessions} · {}", self.span.label().to_lowercase()); if cost_tab && s.unpriced > 0 { caption.push_str(&format!(" · {} tokens without a price", format_tokens(s.unpriced))); } let legend: Vec = s .providers .iter() .enumerate() .map(|(ix, provider)| { let share = if cost_tab { ratio(provider.cost, total_cost) } else { ratio(provider.tokens as f64, total_tokens as f64) }; let detail = if cost_tab { format!("{:.1}% of cost · {} tokens", share * 100., format_tokens(provider.tokens)) } else { format!("{:.1}% of tokens · {}", share * 100., format_cost(provider.cost)) }; h_flex() .gap(px(10.)) .child(div().flex_none().size(px(8.)).rounded_full().bg(provider_color(ix, &provider.label, p))) .child(div().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(provider.label.clone())) .child(div().text_size(px(12.5)).text_color(p.ink3).child(match provider.sessions { 1 => "1 session".to_string(), n => format!("{n} sessions"), })) .child(div().flex_1()) .child(div().font_family(metal::MONO).text_size(px(12.5)).child(if cost_tab { format_cost(provider.cost) } else { format_tokens(provider.tokens) })) .child(div().w(px(200.)).text_size(px(12.5)).text_color(p.ink3).text_right().child(detail)) .into_any_element() }) .collect(); let hero = v_flex() .px(px(22.)) .py(px(20.)) .gap(px(18.)) .rounded(px(18.)) .raised(p) .child( v_flex() .gap(px(6.)) .child(metal::engraved(if cost_tab { "Total cost" } else { "Processed tokens" }, p)) .child(div().font_family(metal::PIXEL).text_size(px(40.)).line_height(px(44.)).child(headline)) .child(div().text_size(px(12.5)).text_color(p.ink3).child(caption)), ) .map(|this| { if legend.is_empty() { this.child(div().text_size(px(13.)).text_color(p.ink3).child("No usage in this range.")) } else { this.child(v_flex().gap(px(10.)).children(legend)) } }); let chart_title = match (self.span.hourly(), cost_tab) { (true, true) => "Hourly cost", (true, false) => "Hourly processed tokens", (false, true) => "Daily cost", (false, false) => "Daily processed tokens", }; let chart = v_flex() .px(px(18.)) .pt(px(16.)) .pb(px(12.)) .gap(px(12.)) .rounded(px(18.)) .raised(p) .child( h_flex() .child(div().flex_1().text_size(px(13.5)).font_weight(FontWeight::MEDIUM).child(chart_title)) .child(metal::engraved(self.span.label(), p)), ) .child(div().h(px(200.)).child(self.chart(&summary, cost_tab))); let totals = if cost_tab { vec![ stat("Total cost", format_cost(total_cost), p), stat("Input", format_cost(s.cost.input), p), stat("Cache write", format_cost(s.cost.cache_write), p), stat("Cache read", format_cost(s.cost.cache_read), p), stat("Output", format_cost(s.cost.output), p), ] } else { vec![ stat("Processed tokens", format_tokens(total_tokens), p), stat("Cached input", format_tokens(s.tokens.cache_read), p), stat("Uncached input", format_tokens(s.tokens.input + s.tokens.cache_write), p), stat("Output", format_tokens(s.tokens.output), p), stat("Cache savings", format_cost(s.savings), p), ] }; let totals = v_flex() .gap(px(10.)) .child(div().px(px(4.)).child(section_title("Totals", p))) .child( h_flex() .rounded(px(18.)) .raised(p) .children(totals.into_iter().enumerate().flat_map(|(ix, stat)| { let rule = (ix > 0).then(|| div().flex_none().w(px(1.)).h(px(36.)).bg(p.rule).into_any_element()); rule.into_iter().chain(std::iter::once(stat)) })), ); let parts = if cost_tab { [ ("Input", s.cost.input), ("Cache read", s.cost.cache_read), ("Cache write", s.cost.cache_write), ("Output", s.cost.output), ] } else { [ ("Input", s.tokens.input as f64), ("Cache read", s.tokens.cache_read as f64), ("Cache write", s.tokens.cache_write as f64), ("Output", s.tokens.output as f64), ] }; let by_type = section( if cost_tab { "Cost by type" } else { "Tokens by type" }, None, vec![type_bar(&parts, cost_tab, p)], p, ); vec![ hero.into_any_element(), chart.into_any_element(), totals.into_any_element(), by_type, self.render_breakdown(&summary, cx), ] } fn chart(&self, summary: &Summary, cost_tab: bool) -> AnyElement { let orange: Hsla = rgba(CLAUDE_ORANGE).into(); let ticks = match self.span { Span::Day => 6, Span::Week => 7, Span::Month => 6, Span::Quarter => 7, }; let value = move |point: &usage::Point| if cost_tab { point.cost } else { point.tokens as f64 }; let format = move |value: f64| -> SharedString { if cost_tab { format_cost(value).into() } else { format_tokens(value.max(0.) as u64).into() } }; AreaChart::new(summary.series.clone()) .id("usage-chart") .appear_key((self.tab, self.span)) .x(|point| SharedString::from(point.label.clone())) .y(value) .name(if cost_tab { "Cost" } else { "Tokens" }) .stroke(orange) .fill(linear_gradient( 180., linear_color_stop(orange.opacity(0.22), 0.), linear_color_stop(orange.opacity(0.02), 1.), )) .natural() .x_tick_count(ticks) .y_axis(true) .y_tick_count(4) .y_tick_format(format) .tooltip_title(|point| point.title.clone().into()) .tooltip_value(move |_, _, value| format(value)) .into_any_element() } fn render_breakdown(&self, summary: &Summary, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let cost_tab = self.tab == Tab::Cost; let lines: &[Line] = match self.breakdown { Breakdown::Model => &summary.models, Breakdown::Day => &summary.days, }; let total = if cost_tab { summary.cost.total() } else { summary.tokens.total() as f64 }; let orange: Hsla = rgba(CLAUDE_ORANGE).into(); let view = cx.weak_entity(); let head = h_flex() .px(px(18.)) .pt(px(12.)) .pb(px(8.)) .gap(px(12.)) .child(metal::engraved("#", p).w(px(20.))) .child(metal::engraved(if self.breakdown == Breakdown::Model { "Model" } else { "Day" }, p).flex_1()) .child(metal::engraved("Cost", p).w(px(84.)).text_right()) .child(metal::engraved("Share", p).w(px(60.)).text_right()) .child(metal::engraved("Tokens", p).w(px(70.)).text_right()); let mut rows = vec![head.into_any_element()]; if lines.is_empty() { rows.push(empty_row("No usage in this range.", p)); } rows.extend(lines.iter().enumerate().map(|(ix, line)| { let share = if cost_tab { ratio(line.cost.unwrap_or(0.), total) } else { ratio(line.tokens as f64, total) }; h_flex() .px(px(18.)) .py(px(10.)) .gap(px(12.)) .text_size(px(13.)) .child(div().w(px(20.)).font_family(metal::MONO).text_size(px(11.5)).text_color(p.ink3).child((ix + 1).to_string())) .child( v_flex() .flex_1() .min_w_0() .gap(px(5.)) .child(div().truncate().font_weight(FontWeight::MEDIUM).child(line.label.clone())) .child( div() .h(px(3.)) .w_full() .rounded_full() .bg(p.rule) .child(div().h_full().rounded_full().bg(orange).w(relative(share as f32))), ), ) .child( div() .w(px(84.)) .text_right() .font_family(metal::MONO) .text_size(px(12.)) .text_color(if line.cost.is_some() { p.ink } else { p.ink3 }) .child(line.cost.map(format_cost).unwrap_or_else(|| "Unpriced".to_string())), ) .child( div() .w(px(60.)) .text_right() .font_family(metal::MONO) .text_size(px(12.)) .text_color(p.ink2) .child(format!("{:.1}%", share * 100.)), ) .child( div() .w(px(70.)) .text_right() .font_family(metal::MONO) .text_size(px(12.)) .child(format_tokens(line.tokens)), ) .into_any_element() })); v_flex() .gap(px(10.)) .child( h_flex() .px(px(4.)) .child(div().flex_1().child(section_title("Breakdown", p))) .child(metal::segmented( "usage-breakdown", &[(Breakdown::Model, "Model"), (Breakdown::Day, "Day")], self.breakdown, p, move |breakdown, _, cx| { let _ = view.update(cx, |this, cx| { this.breakdown = breakdown; cx.notify(); }); }, )), ) .child(card(rows, p)) .into_any_element() } // ---- limits ---------------------------------------------------------------- fn render_limits(&self, cx: &mut Context) -> Vec { let p = metal::palette(cx); if self.limits.is_empty() { return vec![section( "Plan limits", None, vec![empty_row("Limits show once Claude Code has reported them.", p)], p, )]; } let now = Local::now(); self.limits .iter() .map(|plan| { let rows = if plan.limits.is_empty() { vec![empty_row("This account reports no limits.", p)] } else { plan.limits.iter().map(|limit| limit_row(limit, now, p)).collect() }; v_flex() .gap(px(10.)) .child( h_flex() .px(px(4.)) .gap(px(8.)) .child( div() .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .text_color(p.ink2) .child(plan.label.clone()), ) .when_some(plan.plan.clone(), |this, plan| this.child(metal::tag(plan, p))), ) .child(card(rows, p)) .into_any_element() }) .collect() } } impl Render for UsageView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let errors = self.report.as_ref().map(|report| report.errors.clone()).unwrap_or_default(); let body = match self.tab { Tab::Limits => self.render_limits(cx), Tab::Cost | Tab::Tokens => self.render_totals_page(cx), }; v_flex() .w_full() .gap(px(24.)) .child(self.render_header(cx)) .when(!errors.is_empty() && self.tab != Tab::Limits, |this| { this.child(card( errors .into_iter() .map(|error| { h_flex() .px(px(18.)) .py(px(12.)) .gap(px(10.)) .child(Icon::new(IconName::TriangleAlert).size(px(14.)).text_color(p.red)) .child(div().flex_1().min_w_0().text_size(px(12.5)).text_color(p.ink2).child(error)) .into_any_element() }) .collect(), p, )) }) .children(body) } } // ---- building blocks --------------------------------------------------------- fn ratio(part: f64, whole: f64) -> f64 { if whole > 0. { (part / whole).clamp(0., 1.) } else { 0. } } /// Claude Code in Claude's orange, everything else in MetalUI's tints. fn provider_color(ix: usize, label: &str, p: &Palette) -> Hsla { if label.starts_with("Claude") { rgba(CLAUDE_ORANGE).into() } else { p.tints[(ix + 2) % p.tints.len()] } } /// `team` → `Team plan`. fn plan_name(kind: &str) -> String { let mut chars = kind.chars(); match chars.next() { Some(first) => format!("{}{} plan", first.to_uppercase(), chars.as_str()), None => "Plan".to_string(), } } fn empty_row(text: &'static str, p: &Palette) -> AnyElement { div().px(px(18.)).py(px(22.)).text_size(px(13.)).text_color(p.ink3).child(text).into_any_element() } /// One figure in the totals strip. fn stat(label: &'static str, value: String, p: &Palette) -> AnyElement { v_flex() .flex_1() .min_w_0() .px(px(18.)) .py(px(14.)) .gap(px(6.)) .child(metal::engraved(label, p)) .child(div().font_family(metal::MONO).text_size(px(16.)).font_weight(FontWeight::MEDIUM).child(value)) .into_any_element() } /// The stacked bar of input, cache reads, cache writes and output, with its legend. fn type_bar(parts: &[(&'static str, f64); 4], cost: bool, p: &Palette) -> AnyElement { let total: f64 = parts.iter().map(|(_, value)| value).sum(); let colors = [p.tints[4], p.tints[2], p.tints[5], rgba(CLAUDE_ORANGE).into()]; v_flex() .px(px(18.)) .py(px(16.)) .gap(px(14.)) .child( h_flex() .h(px(10.)) .w_full() .rounded_full() .overflow_hidden() .well(p) .children(parts.iter().zip(colors).filter(|((_, value), _)| *value > 0.).map(|((_, value), color)| { div().h_full().w(relative(ratio(*value, total) as f32)).bg(color) })), ) .child(h_flex().flex_wrap().gap_x(px(22.)).gap_y(px(8.)).children(parts.iter().zip(colors).map( |((label, value), color)| { h_flex() .gap(px(7.)) .text_size(px(12.5)) .child(div().size(px(8.)).rounded_full().bg(color)) .child(div().text_color(p.ink2).child(*label)) .child(div().font_family(metal::MONO).text_size(px(12.)).child(if cost { format_cost(*value) } else { format_tokens(*value as u64) })) .child(div().text_color(p.ink3).child(format!("{:.1}%", ratio(*value, total) * 100.))) }, ))) .into_any_element() } fn limit_row(limit: &Limit, now: DateTime, p: &Palette) -> AnyElement { let color = match limit.severity.as_str() { "critical" => p.red, "warning" => p.amber, _ => p.green_deep, }; let resets = limit.resets_at.map(|at| { let left = at.signed_duration_since(now); let when = at.format("%a, %b %-d, %H:%M"); if left.num_seconds() <= 0 { format!("Reset {when}") } else if left.num_hours() >= 24 { format!("Resets in {}d {}h · {when}", left.num_days(), left.num_hours() % 24) } else if left.num_minutes() >= 60 { format!("Resets in {}h {}m · {when}", left.num_hours(), left.num_minutes() % 60) } else { format!("Resets in {}m · {when}", left.num_minutes().max(1)) } }); v_flex() .px(px(18.)) .py(px(14.)) .gap(px(9.)) .child( h_flex() .gap(px(8.)) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(limit.label.clone())) .when(limit.active, |this| this.child(metal::tag("Active", p))) .child(div().flex_1()) .child( div() .font_family(metal::MONO) .text_size(px(12.5)) .child(format!("{}% used", limit.percent.round())), ), ) .child( div().h(px(8.)).w_full().rounded_full().well(p).child( div().h_full().rounded_full().bg(color).w(relative((limit.percent / 100.).clamp(0., 1.) as f32)), ), ) .when_some(resets, |this, resets| { this.child(div().text_size(px(12.5)).text_color(p.ink3).child(resets)) }) .into_any_element() } // ---- usage hubs ----------------------------------------------------------------- /// The "Usage providers" section of the Providers page: hubs the Usage page /// also reads, and a form to add one. pub struct HubSettings { adding: bool, name: Entity, url: Entity, key: Entity, error: Option, } impl HubSettings { pub fn new(window: &mut Window, cx: &mut Context) -> Self { Self { adding: false, name: cx.new(|cx| InputState::new(window, cx).placeholder("CLIProxyAPI")), url: cx.new(|cx| InputState::new(window, cx).placeholder("http://localhost:8317")), key: cx.new(|cx| InputState::new(window, cx).placeholder("Management key").masked(true)), error: None, } } fn start_adding(&mut self, window: &mut Window, cx: &mut Context) { for input in [&self.name, &self.url, &self.key] { input.update(cx, |input, cx| input.set_value("", window, cx)); } self.adding = true; self.error = None; self.name.update(cx, |input, cx| input.focus(window, cx)); cx.notify(); } fn save(&mut self, cx: &mut Context) { let value = |input: &Entity| input.read(cx).value().trim().to_string(); let (name, url, key) = (value(&self.name), value(&self.url), value(&self.key)); if url.is_empty() { self.error = Some("The hub needs a URL.".into()); cx.notify(); return; } let name = if name.is_empty() { "CLIProxyAPI".to_string() } else { name }; settings::update(|settings| { settings.usage_hubs.push(UsageHub { name, kind: "cliproxy".into(), url, key }); }); self.adding = false; self.error = None; cx.notify(); } fn remove(&mut self, ix: usize, cx: &mut Context) { settings::update(|settings| { if ix < settings.usage_hubs.len() { settings.usage_hubs.remove(ix); } }); cx.notify(); } fn render_form(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let field = |label: &'static str, input: &Entity| { h_flex() .gap(px(16.)) .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child(label)) .child( h_flex() .flex_1() .h(px(32.)) .px(px(11.)) .rounded(px(12.)) .well(p) .text_size(px(12.5)) .text_color(p.ink) .child(div().flex_1().min_w_0().child(Input::new(input).appearance(false).small())), ) }; v_flex() .px(px(18.)) .py(px(16.)) .gap(px(10.)) .child(field("Name", &self.name)) .child(field("URL", &self.url)) .child(field("Management key", &self.key)) .child( h_flex() .gap(px(16.)) .child(div().w(px(120.)).text_size(px(13.)).font_weight(FontWeight::MEDIUM).child("Kind")) .child(metal::tag("CLI Proxy", p)), ) .child( h_flex() .pt(px(4.)) .gap(px(8.)) .when_some(self.error.clone(), |this, error| { this.child(div().text_size(px(12.5)).text_color(p.red).child(error)) }) .child(div().flex_1()) .child( MetalButton::new("hub-cancel") .ghost() .compact() .label("Cancel") .on_click(cx.listener(|this, _, _, cx| { this.adding = false; this.error = None; cx.notify(); })), ) .child( MetalButton::new("hub-save") .primary() .compact() .label("Save") .on_click(cx.listener(|this, _, _, cx| this.save(cx))), ), ) .into_any_element() } } impl Render for HubSettings { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let hubs = settings::get(|settings| settings.usage_hubs.clone()); let mut rows: Vec = hubs .iter() .enumerate() .map(|(ix, hub)| { let kind = if hub.kind == "cliproxy" || hub.kind.is_empty() { "CLI Proxy" } else { hub.kind.as_str() }; h_flex() .px(px(18.)) .py(px(14.)) .gap(px(12.)) .child(Icon::new(IconName::Network).size(px(16.)).text_color(p.ink2)) .child( v_flex() .flex_1() .min_w_0() .gap(px(3.)) .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(hub.name.clone())) .child( div() .truncate() .text_size(px(12.5)) .text_color(p.ink3) .child(format!("{kind} · {}", hub.url)), ), ) .child( MetalButton::new(("remove-hub", ix)) .ghost() .compact() .label("Remove") .on_click(cx.listener(move |this, _, _, cx| this.remove(ix, cx))), ) .into_any_element() }) .collect(); if self.adding { rows.push(self.render_form(cx)); } else if rows.is_empty() { rows.push(empty_row("No hubs configured.", p)); } v_flex() .gap(px(10.)) .child( h_flex() .px(px(16.)) .gap(px(8.)) .child( v_flex() .flex_1() .gap(px(3.)) .child(h_flex().min_h(px(28.)).child(section_title("Usage providers", p))) .child( div() .text_size(px(12.)) .line_height(px(18.)) .text_color(p.ink3) .child("Hubs whose usage the Usage page adds to the session logs."), ), ) .child( MetalButton::new("add-hub") .compact() .icon(IconName::Plus) .label("Add hub") .disabled(self.adding) .on_click(cx.listener(|this, _, window, cx| this.start_adding(window, cx))), ), ) .child(card(rows, p)) } }