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) =
219            UpdateOutcome::Failed("network unreachable".into()).lines("Acme", "1.31.0");
220        assert_eq!(detail, "network unreachable");
221    }
222}