Skip to main content

guise/update/
notice.rs

1//! `UpdateNotice` — the answer to a check that had nothing to install
2//! (gpui entity).
3//!
4//! A panel rather than a desktop notification: a notification is silently dropped
5//! when the user has denied the app permission to post one, and a "Check for
6//! Updates…" that appears to do nothing at all is worse than the answer being
7//! unwelcome. [`UpdatePrompt`](super::UpdatePrompt) already works this way; this
8//! is the other half of it.
9
10use gpui::prelude::*;
11use gpui::{
12  div, px, App, Context, EventEmitter, FocusHandle, Focusable, FontWeight, IntoElement,
13  KeyDownEvent, MouseButton, SharedString, Window, WindowControlArea,
14};
15
16use super::Updater;
17use crate::devtools::Probed;
18use crate::theme::{theme, Size};
19use crate::{Button, Variant};
20
21/// Height of the strip that drags the window, and the padding that clears a
22/// transparent titlebar. A platform metric, not a themed one.
23const TITLEBAR: f32 = 34.0;
24
25/// The outcome of a check that has nothing to install — everything the prompt
26/// cannot represent, because it exists to run an install.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum UpdateOutcome {
29  /// Nothing newer is published.
30  UpToDate,
31  /// Something newer exists but hasn't uploaded what this machine installs.
32  Pending(String),
33  /// The check itself failed (offline, the host down, a parse error).
34  Failed(String),
35}
36
37impl UpdateOutcome {
38  /// Headline and detail. Both are needed: "You're up to date" alone leaves a
39  /// user who half-expected an update wondering whether the check ran at all.
40  pub fn lines(&self, app: &str, current: &str) -> (String, String) {
41    match self {
42      UpdateOutcome::UpToDate => (
43        "You're up to date".to_string(),
44        format!("{app} {current} is the latest version."),
45      ),
46      UpdateOutcome::Pending(version) => (
47        format!("{app} {version} is on the way"),
48        "It is still building for this platform. Check again shortly.".to_string(),
49      ),
50      UpdateOutcome::Failed(why) => ("Couldn't check for updates".to_string(), why.clone()),
51    }
52  }
53}
54
55/// Emitted when the notice is done with.
56#[derive(Debug, Clone)]
57pub enum UpdateNoticeEvent {
58  /// The user acknowledged it (the button or Escape). Whoever owns the window
59  /// closes it.
60  Dismissed,
61}
62
63/// The short answer to a manual update check.
64pub struct UpdateNotice {
65  updater: Updater,
66  outcome: UpdateOutcome,
67  window_root: bool,
68  focus: FocusHandle,
69}
70
71impl UpdateNotice {
72  pub fn new(updater: Updater, outcome: UpdateOutcome, cx: &mut Context<Self>) -> Self {
73    UpdateNotice {
74      updater,
75      outcome,
76      window_root: false,
77      focus: cx.focus_handle(),
78    }
79  }
80
81  /// Whether this notice is the root view of its own window: draws the titlebar
82  /// drag strip and pads for a transparent titlebar. [`super::check_now`] sets
83  /// it; leave it off when embedding the notice in a window of your own.
84  pub fn window_root(mut self, window_root: bool) -> Self {
85    self.window_root = window_root;
86    self
87  }
88
89  /// What the check found.
90  pub fn outcome(&self) -> &UpdateOutcome {
91    &self.outcome
92  }
93
94  /// Acknowledge the notice.
95  pub fn dismiss(&mut self, cx: &mut Context<Self>) {
96    cx.emit(UpdateNoticeEvent::Dismissed);
97  }
98
99  fn key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
100    if event.keystroke.key == "escape" {
101      self.dismiss(cx);
102    }
103  }
104}
105
106impl Focusable for UpdateNotice {
107  fn focus_handle(&self, _cx: &App) -> FocusHandle {
108    self.focus.clone()
109  }
110}
111
112impl EventEmitter<UpdateNoticeEvent> for UpdateNotice {}
113
114impl Render for UpdateNotice {
115  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
116    let t = theme(cx);
117    let bg = t.body().hsla();
118    let text = t.text().hsla();
119    let dim = t.dimmed().hsla();
120    let pad = t.spacing(Size::Lg);
121    let gap = t.spacing(Size::Xs);
122    let headline_size = t.font_size(Size::Md);
123    let small = t.font_size(Size::Xs);
124    let (headline, detail) = self
125      .outcome
126      .lines(self.updater.app(), self.updater.version());
127
128    div()
129      .size_full()
130      .flex()
131      .flex_col()
132      .track_focus(&self.focus)
133      .on_key_down(cx.listener(Self::key_down))
134      .bg(bg)
135      .text_color(text)
136      .pt(px(if self.window_root { TITLEBAR } else { pad }))
137      .px(px(pad))
138      .pb(px(pad))
139      .gap(px(gap))
140      .when(self.window_root, |this| this.child(drag_strip()))
141      .child(
142        div()
143          .text_size(px(headline_size))
144          .font_weight(FontWeight::BOLD)
145          .child(SharedString::from(headline)),
146      )
147      .child(
148        div()
149          .text_size(px(small))
150          .text_color(dim)
151          .child(SharedString::from(detail)),
152      )
153      .child(div().flex_1())
154      .child(
155        div().flex().items_center().justify_end().child(
156          Button::new("guise-update-ok", "OK")
157            .variant(Variant::Filled)
158            .on_click(cx.listener(|this, _, _, cx| this.dismiss(cx))),
159        ),
160      )
161      .probe("UpdateNotice")
162  }
163}
164
165/// The strip along the top of an update window that drags it, kept clear of the
166/// macOS traffic lights.
167fn drag_strip() -> impl IntoElement {
168  let lead = if cfg!(target_os = "macos") { 70.0 } else { 0.0 };
169  div()
170    .absolute()
171    .top_0()
172    .left(px(lead))
173    .right_0()
174    .h(px(TITLEBAR - 6.0))
175    .window_control_area(WindowControlArea::Drag)
176    .on_mouse_down(MouseButton::Left, |_, window, _| window.start_window_move())
177}
178
179#[cfg(test)]
180mod tests {
181  use super::*;
182
183  /// Every outcome names both what happened and why. A headline alone leaves
184  /// someone who half-expected an update unsure the check even ran.
185  #[test]
186  fn every_outcome_says_what_happened_and_why() {
187    for outcome in [
188      UpdateOutcome::UpToDate,
189      UpdateOutcome::Pending("1.32.0".into()),
190      UpdateOutcome::Failed("network unreachable".into()),
191    ] {
192      let (headline, detail) = outcome.lines("Acme", "1.31.0");
193      assert!(!headline.trim().is_empty());
194      assert!(!detail.trim().is_empty());
195    }
196  }
197
198  #[test]
199  fn up_to_date_names_the_version_you_are_on() {
200    let (_, detail) = UpdateOutcome::UpToDate.lines("Acme", "1.31.0");
201    assert!(detail.contains("1.31.0"), "{detail}");
202    assert!(detail.contains("Acme"), "{detail}");
203  }
204
205  /// A release still uploading must not read as "up to date" — that is the case
206  /// [`UpdateOutcome::Pending`] exists to distinguish.
207  #[test]
208  fn a_pending_release_is_not_reported_as_up_to_date() {
209    let (headline, detail) = UpdateOutcome::Pending("1.32.0".into()).lines("Acme", "1.31.0");
210    assert!(headline.contains("1.32.0"), "{headline}");
211    assert!(!headline.contains("up to date"), "{headline}");
212    assert!(detail.contains("building"), "{detail}");
213  }
214
215  /// A failed check reports the reason rather than a generic apology.
216  #[test]
217  fn a_failed_check_surfaces_its_reason() {
218    let (_, detail) = UpdateOutcome::Failed("network unreachable".into()).lines("Acme", "1.31.0");
219    assert_eq!(detail, "network unreachable");
220  }
221}