Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
# The app compares its Cargo version against the tag to find updates.
- shell: bash
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
[ "v$version" = "${{ github.ref_name }}" ] || { echo "tag ${{ github.ref_name }} != Cargo.toml $version"; exit 1; }
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- run: cargo build --release
Expand Down
22 changes: 22 additions & 0 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,12 @@ pub struct Strings {
pub alert_timeout_10s: &'static str,
pub alert_timeout_manual: &'static str,
pub alert_dismiss: &'static str,
pub settings_updates: &'static str,
pub settings_updates_hint: &'static str,
pub update_none: &'static str,
pub update_unchecked: &'static str,
pub update_install: &'static str,
pub update_open_settings: &'static str,
pub settings_token_accent: &'static str,
pub settings_token_accent_hint: &'static str,
pub settings_token_background: &'static str,
Expand Down Expand Up @@ -986,6 +992,16 @@ impl Strings {
format!("Edit keymap.json to remap: {path}")
}

pub fn update_available(&self, version: &str) -> String {
format!("Spur {version} is available")
}
pub fn update_current(&self, version: &str) -> String {
format!("Spur {version}")
}
pub fn update_failed(&self, err: &str) -> String {
format!("update failed: {err}")
}

/// Settings Keyboard card status line.
pub fn keymap_custom(&self, n: usize) -> String {
if n == 1 {
Expand Down Expand Up @@ -1693,6 +1709,12 @@ pub static ENGLISH: Strings = Strings {
alert_timeout_10s: "10 seconds",
alert_timeout_manual: "Click to remove",
alert_dismiss: "Dismiss",
settings_updates: "Updates",
settings_updates_hint: "Spur checks GitHub for new releases in the background",
update_none: "No newer release found",
update_unchecked: "Not checked yet",
update_install: "Update and restart",
update_open_settings: "Settings",
settings_token_accent: "Accent",
settings_token_accent_hint: "Buttons, links, and selection",
settings_token_background: "Background",
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod settings;
mod status;
mod theme;
mod ui;
mod update;

use std::borrow::Cow;

Expand Down
148 changes: 148 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ const HEADER_H: f32 = 44.0; // Theme::HEADER_HEIGHT — section/sidebar headers
/// changes, diff, details) follows external edits without waiting for the
/// full 15 s round. Skipped while a query for the same repository is running.
const ACTIVE_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// How often to ask GitHub for a newer release (also once at startup).
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
/// Inactive per-tab history sessions kept in memory (LRU); browsing more tabs
/// than this drops the oldest session instead of retaining every commit.
const HISTORY_CACHE_MAX: usize = 4;
Expand Down Expand Up @@ -594,6 +596,13 @@ pub struct SpurShell {
undo_toast: Option<undo::UndoToast>,
/// Invalidates the undo timer when a newer undoable op lands.
undo_toast_gen: u64,
/// Newer GitHub release, if any; Settings offers to install it.
update: Option<crate::update::Release>,
/// A check has reached GitHub at least once.
update_checked: bool,
/// The update toast is up (until dismissed or Settings opens).
update_toast: bool,
updating: bool,
/// Recently-discarded backups per repository.
discarded: HashMap<String, Vec<undo::DiscardedBackup>>,
next_discarded_id: u64,
Expand Down Expand Up @@ -1137,6 +1146,10 @@ impl SpurShell {
redo_slots: HashMap::new(),
undo_toast: None,
undo_toast_gen: 0,
update: None,
update_checked: false,
update_toast: false,
updating: false,
discarded: HashMap::new(),
next_discarded_id: 0,
discarded_open: false,
Expand Down Expand Up @@ -1227,6 +1240,7 @@ impl SpurShell {
}
shell.spawn_auto_refresh_tick(cx);
shell.spawn_active_refresh_tick(cx);
shell.spawn_update_check(cx);
// First run: one wink while the empty state is up; the welcome line
// stays until a root or a repository arrives.
if !shell.welcomed {
Expand Down Expand Up @@ -1674,6 +1688,64 @@ impl SpurShell {
.detach();
}

/// Check GitHub for a newer release at startup and every few hours; each
/// new version raises the update toast once. Failures (offline) only log.
fn spawn_update_check(&mut self, cx: &mut Context<Self>) {
cx.spawn(async move |this, cx| loop {
let found = cx
.background_executor()
.spawn(async { crate::update::check() })
.await;
let alive = this
.update(cx, |this, cx| match found {
Ok(found) => {
this.update_checked = true;
if let Some(release) = found.filter(|release| {
this.update.as_ref().map(|r| &r.version) != Some(&release.version)
}) {
this.update = Some(release);
this.update_toast = true;
}
cx.notify();
}
Err(err) => log!("update check failed: {err}"),
})
.is_ok();
if !alive {
return;
}
cx.background_executor().timer(UPDATE_CHECK_INTERVAL).await;
})
.detach();
}

/// Download and swap in the available release, then restart into it.
pub(super) fn install_update(&mut self, cx: &mut Context<Self>) {
let Some(release) = self.update.clone().filter(|_| !self.updating) else {
return;
};
self.updating = true;
self.update_toast = false;
cx.notify();
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move {
crate::update::install(&release).and_then(|app| crate::update::relaunch(&app))
})
.await;
this.update(cx, |this, cx| match result {
Ok(()) => cx.quit(),
Err(err) => {
this.updating = false;
this.note_error(t().update_failed(&err), cx);
}
})
.ok();
})
.detach();
}

/// Hide repositories from the workspace (persisted exclude prefixes).
pub(super) fn exclude_paths(&mut self, paths: Vec<String>, cx: &mut Context<Self>) {
let before = self.exclude.len();
Expand Down Expand Up @@ -2774,6 +2846,81 @@ impl Render for SpurShell {
)
});

// Update offer: stays until dismissed; the action opens Settings,
// where the install button lives.
let update_card = self.update.as_ref().filter(|_| self.update_toast).map(|release| {
div()
.max_w(px(560.))
.rounded(px(PANEL_RADIUS))
.border_1()
.border_color(cx.theme().info.alpha(0.45))
.bg(cx.theme().popover)
.shadow_lg()
.px(px(12.))
.py(px(8.))
.flex()
.items_center()
.gap(px(8.))
.overflow_hidden()
.child(
Icon::new(IconName::Download)
.size(px(14.))
.flex_none()
.text_color(cx.theme().info),
)
.child(
div()
.min_w_0()
.flex_1()
.overflow_hidden()
.whitespace_nowrap()
.text_size(px(TEXT_SM))
.text_color(text_primary(cx))
.child(t().update_available(&release.version)),
)
.child(
div()
.id("update-toast-action")
.flex_none()
.cursor_pointer()
.text_size(px(TEXT_XS))
.text_color(violet(cx))
.child(t().update_open_settings)
.on_click(cx.listener(|this, _, window, cx| {
this.open_settings(window, cx);
})),
)
.child(
div()
.id("update-toast-dismiss")
.aria_label(t().alert_dismiss)
.cursor_pointer()
.flex_none()
.flex()
.items_center()
.justify_center()
.w(px(18.))
.h(px(18.))
.rounded(px(4.))
.bg(hover_blend("update-toast-dismiss", ink(0.0), ink(0.10)))
.on_hover(hover_listener("update-toast-dismiss"))
.on_click(cx.listener(|this, _, _, cx| {
this.update_toast = false;
cx.notify();
}))
.child(
Icon::new(IconName::Close)
.size(px(12.))
.text_color(text_muted(cx)),
),
)
.with_animation(
"update-toast",
motion::animation(motion::MENU_IN),
|el, t| el.opacity(t),
)
});

let root = div()
.id("root")
.track_focus(&self.root_focus)
Expand Down Expand Up @@ -2988,6 +3135,7 @@ impl Render for SpurShell {
.flex()
.flex_col()
.gap(px(8.))
.children(update_card)
.children(undo_card)
.children(error_toast),
),
Expand Down
31 changes: 31 additions & 0 deletions src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ impl SpurShell {
/// palette action closes the palette with its exit fade on top).
pub(super) fn show_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.settings_open = true;
self.update_toast = false;
self.theme_menu_open = false;
self.theme_status = None;
self.theme_save_gen = self.theme_save_gen.wrapping_add(1);
Expand Down Expand Up @@ -344,6 +345,9 @@ impl SpurShell {
)
.into_any_element(),
self.notifications_card(cx),
section_heading(t().settings_updates, t().settings_updates_hint, cx)
.into_any_element(),
self.updates_card(cx),
section_heading(t().settings_keyboard, t().settings_keyboard_hint, cx)
.into_any_element(),
self.keyboard_card(cx),
Expand Down Expand Up @@ -861,6 +865,33 @@ impl SpurShell {
)])
}

/// Updates card: current version, and the install button once the
/// background check found a newer release.
fn updates_card(&self, cx: &Context<Self>) -> gpui_kit::AnyElement {
let subtitle = match &self.update {
Some(release) => t().update_available(&release.version),
None if self.update_checked => t().update_none.to_string(),
None => t().update_unchecked.to_string(),
};
let control = match self.update {
Some(_) => Button::new("install-update")
.label(t().update_install)
.small()
.cursor_pointer()
.loading(self.updating)
.on_click(cx.listener(|this, _, _, cx| this.install_update(cx)))
.into_any_element(),
None => div().into_any_element(),
};
settings_card(vec![settings_row(
IconName::Download,
&t().update_current(env!("CARGO_PKG_VERSION")),
&subtitle,
control,
cx,
)])
}

/// Persist the alert-lifetime choice. Changing it closes any visible alert
/// so a manual one cannot outlive its close button.
fn set_alert_timeout(&mut self, mode: crate::settings::AlertTimeout, cx: &mut Context<Self>) {
Expand Down
Loading
Loading