T56: Settings › Terminal checks for updates, which the owner asked for after 0.1.5: under Ely, a switch, on by default, checks at start and once a day through curl and Gitea's API, and Check now checks at once; the row says what the check found, a newer release brings Download, which opens its page, and one an automatic check finds posts a notification whose press opens it
This commit is contained in:
13 files changed
+296
-11
No files matched your search
+6
-2
@@ -13,7 +13,7 @@ use crate::{
|
||||
blur, menus,
|
||||
settings::{self, SettingsStore},
|
||||
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
||||
themes,
|
||||
themes, updates,
|
||||
workspace::{self, TITLE, Workspace},
|
||||
};
|
||||
|
||||
@@ -45,15 +45,19 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
SettingsStore::init(cx)?;
|
||||
updates::start(cx);
|
||||
ShellIntegration::init(cx)?;
|
||||
input::bind_keys(cx);
|
||||
workspace::bind_keys(cx);
|
||||
settings::window::bind_keys(cx);
|
||||
menus::init(cx);
|
||||
cx.set_app_identity(APP_ID, TITLE);
|
||||
// A press on a notification goes to the window whose session posted it.
|
||||
// A press on a notification goes to the window whose session posted it, or to a newer release's page.
|
||||
cx.on_system_notification_response(|response, cx| {
|
||||
let tag = response.tag;
|
||||
if updates::pressed(&tag, cx) {
|
||||
return;
|
||||
}
|
||||
let pressed = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
|
||||
+2
-2
@@ -25,9 +25,9 @@ fn line<A: AsRef<OsStr>>(args: &[A]) -> String {
|
||||
args.join(" ")
|
||||
}
|
||||
|
||||
/// Keeps Windows from opening a console window for git.
|
||||
/// Keeps Windows from opening a console window for git and curl.
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
/// Runs git in `folder`: English messages, literal paths, no lock the user's own git could meet; `input` feeds stdin.
|
||||
fn git<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
|
||||
|
||||
@@ -12,4 +12,5 @@ mod testlog;
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod testx11;
|
||||
pub mod themes;
|
||||
pub mod updates;
|
||||
pub mod workspace;
|
||||
@@ -76,6 +76,8 @@ pub struct Settings {
|
||||
pub ely: bool,
|
||||
/// How far in from the pane's left edge she stands, in points, as a drag left her.
|
||||
pub ely_left: f32,
|
||||
/// A check for a newer release at start and once a day.
|
||||
pub check_updates: bool,
|
||||
pub show_sessions: bool,
|
||||
pub show_panel: bool,
|
||||
pub panel: PanelTab,
|
||||
@@ -109,6 +111,7 @@ impl Default for Settings {
|
||||
copy_on_select: false,
|
||||
ely: true,
|
||||
ely_left: 140.0,
|
||||
check_updates: true,
|
||||
show_sessions: true,
|
||||
show_panel: true,
|
||||
panel: PanelTab::Git,
|
||||
|
||||
@@ -17,7 +17,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
use super::{Settings, SettingsStore};
|
||||
use crate::{app::APP_ID, menus, term::BLINK};
|
||||
use crate::{app::APP_ID, menus, term::BLINK, updates::Updates};
|
||||
|
||||
mod appearance;
|
||||
mod terminal;
|
||||
@@ -100,6 +100,7 @@ pub struct SettingsWindow {
|
||||
lit: bool,
|
||||
_typed: Subscription,
|
||||
_settings: Subscription,
|
||||
_updates: Subscription,
|
||||
_blink: Task<()>,
|
||||
}
|
||||
|
||||
@@ -133,6 +134,7 @@ impl SettingsWindow {
|
||||
lit: true,
|
||||
_typed: typed,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
_updates: cx.observe_global::<Updates>(|_, cx| cx.notify()),
|
||||
_blink: Self::blink(cx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
buttons::{Button, SegmentedControl},
|
||||
feedback::InlineMessage,
|
||||
forms::{Copy, NumberInput, Paste, PathInput, SelectAll},
|
||||
forms::{Copy, NumberInput, Paste, PathInput, SelectAll, Switch},
|
||||
primitives::Severity,
|
||||
settings::{KeyboardShortcutsList, SettingsRow, SettingsSection, Shortcut},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
updates::{self, Updates, VERSION},
|
||||
workspace::{
|
||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||
TogglePanel, ToggleSessions,
|
||||
@@ -42,7 +43,7 @@ const PLACES: [&str; 8] = [
|
||||
];
|
||||
|
||||
impl SettingsWindow {
|
||||
/// Shell, start in, scrollback, Option as Alt on macOS and copy on select, then the shortcuts.
|
||||
/// Shell, start in, scrollback, Option as Alt on macOS, copy on select, Ely and updates, then the shortcuts.
|
||||
pub(super) fn terminal(&self, cx: &Context<Self>) -> Div {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let this = cx.entity();
|
||||
@@ -106,13 +107,70 @@ impl SettingsWindow {
|
||||
SettingsRow::new("Ely")
|
||||
.description("Peeks over the window, her face as your sessions are")
|
||||
.control(ely),
|
||||
);
|
||||
)
|
||||
.row(
|
||||
SettingsRow::new("Check for updates automatically")
|
||||
.description("At start and once a day")
|
||||
.control(auto_check(&this, settings.check_updates)),
|
||||
)
|
||||
.row(updates_row(cx));
|
||||
let keys = SettingsSection::new("Shortcuts")
|
||||
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
||||
div().flex().flex_col().gap_6().child(section).child(keys)
|
||||
}
|
||||
}
|
||||
|
||||
/// The switch for automatic checks; turning it on checks at once.
|
||||
fn auto_check(this: &Entity<SettingsWindow>, on: bool) -> Switch {
|
||||
let this = this.clone();
|
||||
Switch::new("check_updates", on).on_change(move |on, _, cx| {
|
||||
let change = format!("check_updates is {on}");
|
||||
if set(&this, change, cx, |settings| settings.check_updates = on) && on {
|
||||
updates::check(false, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// What the last check found, a button that checks now, and one that opens a newer release's page.
|
||||
fn updates_row(cx: &App) -> SettingsRow {
|
||||
let found = cx.global::<Updates>();
|
||||
let status: SharedString = match found {
|
||||
Updates::Unchecked => format!("ELYTerminal {VERSION}").into(),
|
||||
Updates::Checking => "Checking for a newer release…".into(),
|
||||
Updates::Latest => format!("ELYTerminal {VERSION} is the latest").into(),
|
||||
Updates::Newer { version, .. } => {
|
||||
format!("ELYTerminal {version} is out; this is {VERSION}").into()
|
||||
}
|
||||
Updates::Failed(why) => format!("The check failed: {why}").into(),
|
||||
};
|
||||
let page = match found {
|
||||
Updates::Newer { page, .. } => Some(page.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let check = Button::new("check-updates", "Check now")
|
||||
.size(ControlSize::Sm)
|
||||
.loading(*found == Updates::Checking)
|
||||
.on_click(|_, _, cx| updates::check(false, cx));
|
||||
let open = page.map(|page| {
|
||||
Button::new("open-update", "Download")
|
||||
.primary()
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, _, cx| {
|
||||
log::info!("updates: Settings opens {page}");
|
||||
cx.open_url(&page);
|
||||
})
|
||||
});
|
||||
let control = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(open)
|
||||
.child(check);
|
||||
SettingsRow::new("Updates")
|
||||
.description(status)
|
||||
.control(control)
|
||||
}
|
||||
|
||||
/// Where new sessions start, as the settings keep it.
|
||||
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
||||
let start = match value.as_ref() {
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
//! Checks for a newer release through curl and Gitea's API: at start and once a day while the settings ask, and whenever Settings asks at once.
|
||||
|
||||
use std::{
|
||||
process::{Command, Stdio},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::{App, Global, SystemNotification};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{settings::SettingsStore, workspace::TITLE};
|
||||
|
||||
/// The latest published release.
|
||||
const LATEST: &str =
|
||||
"https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ELYTerminal/releases/latest";
|
||||
/// How long an automatic check waits for the next.
|
||||
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
/// The tag a newer release's notification carries.
|
||||
const TAG: &str = "elyterminal-update";
|
||||
/// The running version.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// What the last check found.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Updates {
|
||||
Unchecked,
|
||||
Checking,
|
||||
Latest,
|
||||
Newer { version: String, page: String },
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl Global for Updates {}
|
||||
|
||||
/// The fields of a release the check reads.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
/// Checks at once and then once a day, each time the settings ask for it.
|
||||
pub fn start(cx: &mut App) {
|
||||
cx.set_global(Updates::Unchecked);
|
||||
cx.spawn(async move |cx| {
|
||||
loop {
|
||||
cx.update(|cx| {
|
||||
if SettingsStore::get(cx).check_updates {
|
||||
check(true, cx);
|
||||
}
|
||||
});
|
||||
cx.background_executor().timer(DAY).await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Asks for the latest release off the main thread; when `auto`, a newer one posts a notification.
|
||||
pub fn check(auto: bool, cx: &mut App) {
|
||||
if *cx.global::<Updates>() == Updates::Checking {
|
||||
return;
|
||||
}
|
||||
log::info!("updates: checking for a release newer than {VERSION}");
|
||||
cx.set_global(Updates::Checking);
|
||||
let fetched = cx.background_executor().spawn(async { latest() });
|
||||
cx.spawn(async move |cx| {
|
||||
let found = fetched.await.and_then(newer);
|
||||
cx.update(|cx| finish(found, auto, cx));
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Keeps what the check found, and tells of a newer release it found by itself.
|
||||
fn finish(found: anyhow::Result<Option<(String, String)>>, auto: bool, cx: &mut App) {
|
||||
let found = match found {
|
||||
Ok(Some((version, page))) => {
|
||||
log::info!("updates: {version} is out, newer than {VERSION}, at {page}");
|
||||
if auto {
|
||||
cx.show_system_notification(SystemNotification {
|
||||
tag: TAG.into(),
|
||||
title: TITLE.into(),
|
||||
body: format!("ELYTerminal {version} is out. Press to open its page.").into(),
|
||||
actions: Vec::new(),
|
||||
});
|
||||
}
|
||||
Updates::Newer { version, page }
|
||||
}
|
||||
Ok(None) => {
|
||||
log::info!("updates: {VERSION} is the latest");
|
||||
Updates::Latest
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("{error:#}");
|
||||
Updates::Failed(format!("{error:#}"))
|
||||
}
|
||||
};
|
||||
cx.set_global(found);
|
||||
}
|
||||
|
||||
/// Opens the newer release's page when its notification is pressed; tells whether `tag` was that notification's.
|
||||
pub fn pressed(tag: &str, cx: &mut App) -> bool {
|
||||
if tag != TAG {
|
||||
return false;
|
||||
}
|
||||
match cx.global::<Updates>().clone() {
|
||||
Updates::Newer { page, .. } => {
|
||||
log::info!("updates: the notification opens {page}");
|
||||
cx.open_url(&page);
|
||||
}
|
||||
found => {
|
||||
log::info!("updates: the notification was pressed, the last check finding {found:?}")
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The latest release, which curl fetches from Gitea's API.
|
||||
fn latest() -> anyhow::Result<Release> {
|
||||
let mut command = Command::new("curl");
|
||||
#[cfg(windows)]
|
||||
std::os::windows::process::CommandExt::creation_flags(
|
||||
&mut command,
|
||||
crate::git::CREATE_NO_WINDOW,
|
||||
);
|
||||
let output = command
|
||||
.args(["--fail", "--silent", "--show-error", "--location"])
|
||||
.args(["--max-time", "30", LATEST])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.context("updates: curl did not start")?;
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"updates: curl failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).context("updates: the latest release does not read")
|
||||
}
|
||||
|
||||
/// The release's version and page when it is newer than this one.
|
||||
fn newer(release: Release) -> anyhow::Result<Option<(String, String)>> {
|
||||
let latest = version(&release.tag_name)?;
|
||||
let shown = release.tag_name.trim_start_matches('v').to_string();
|
||||
Ok((latest > version(VERSION)?).then_some((shown, release.html_url)))
|
||||
}
|
||||
|
||||
/// `tag`'s three numbers, from `v0.1.6` or `0.1.6`.
|
||||
fn version(tag: &str) -> anyhow::Result<[u64; 3]> {
|
||||
let numbers: Vec<u64> = (tag.trim_start_matches('v').split('.'))
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.with_context(|| format!("updates: {tag:?} is no version"))?;
|
||||
numbers
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("updates: {tag:?} is no version of three numbers"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_tag_reads_as_three_numbers() {
|
||||
assert_eq!(version("v0.1.6").expect("a tag"), [0, 1, 6]);
|
||||
assert_eq!(version("1.10.0").expect("no v"), [1, 10, 0]);
|
||||
assert!(version("v0.1").is_err(), "two numbers");
|
||||
assert!(version("v0.1.6-beta").is_err(), "a suffix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_later_release_is_newer() {
|
||||
let release = |tag: &str| Release {
|
||||
tag_name: tag.into(),
|
||||
html_url: format!("https://example.org/{tag}"),
|
||||
};
|
||||
let [major, minor, patch] = version(VERSION).expect("this version");
|
||||
let later = format!("v{major}.{minor}.{}", patch + 1);
|
||||
let found = newer(release(&later)).expect("reads");
|
||||
let shown = later.trim_start_matches('v').to_string();
|
||||
assert_eq!(found, Some((shown, format!("https://example.org/{later}"))));
|
||||
assert_eq!(
|
||||
newer(release(&format!("v{VERSION}"))).expect("reads"),
|
||||
None,
|
||||
"this one"
|
||||
);
|
||||
assert_eq!(
|
||||
newer(release("v0.0.1")).expect("reads"),
|
||||
None,
|
||||
"an older one"
|
||||
);
|
||||
assert_eq!(
|
||||
newer(release(&format!("v{}.0.0", major + 1)))
|
||||
.expect("reads")
|
||||
.map(|(v, _)| v),
|
||||
Some(format!("{}.0.0", major + 1)),
|
||||
"by its numbers, not as text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitea_s_release_reads() {
|
||||
let body = r#"{"id":213,"tag_name":"v0.1.5","name":"ELYTerminal 0.1.5","html_url":"https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal/releases/tag/v0.1.5","draft":false,"assets":[]}"#;
|
||||
let release: Release = serde_json::from_str(body).expect("a release");
|
||||
assert_eq!(release.tag_name, "v0.1.5");
|
||||
assert!(release.html_url.ends_with("/releases/tag/v0.1.5"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user