do not hide scrollbars automatically, automatic version checker

This commit is contained in:
Sampo Kivistö 2026-03-08 22:16:58 +02:00
parent 9a56e2fe33
commit abe18e75e7
No known key found for this signature in database
GPG key ID: 3B426F446F481CFF
8 changed files with 433 additions and 6 deletions

3
Cargo.lock generated
View file

@ -2321,6 +2321,7 @@ dependencies = [
"gpui",
"resvg 0.47.0",
"rustc-hash 2.1.1",
"semver",
"serde",
"serde_json",
"smol",
@ -2336,6 +2337,7 @@ dependencies = [
"tree-sitter-typescript",
"tree-sitter-yaml",
"unicode-segmentation",
"zed-reqwest",
]
[[package]]
@ -8874,6 +8876,7 @@ dependencies = [
"base64",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",

View file

@ -24,7 +24,7 @@ default-members = [
edition = "2024"
license = "AGPL-3.0-only"
authors = ["AutoExplore Oy <info@autoexplore.ai>"]
repository = "https://github.com/GitComet/gitcomet"
repository = "https://github.com/Auto-Explore/GitComet"
[workspace.dependencies]
# Internal crates
@ -47,6 +47,7 @@ notify = "8"
regex = "1"
resvg = "0.47.0"
rustc-hash = "2.1"
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
smol = "2"
@ -62,6 +63,7 @@ tree-sitter-rust = "0.24"
tree-sitter-typescript = "0.23.2"
tree-sitter-yaml = "0.7.2"
unicode-segmentation = "1.12"
zed-reqwest = { version = "0.12.15-zed", default-features = false, features = ["blocking", "json", "rustls-tls-native-roots"] }
[workspace.lints.rust]
unsafe_code = "forbid"

View file

@ -16,6 +16,7 @@ gitcomet-ui = { workspace = true }
gpui = { workspace = true }
resvg = { workspace = true }
rustc-hash = { workspace = true }
semver = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
smol = { workspace = true }
@ -30,6 +31,7 @@ tree-sitter-rust = { workspace = true }
tree-sitter-typescript = { workspace = true }
tree-sitter-yaml = { workspace = true }
unicode-segmentation = { workspace = true }
zed-reqwest = { workspace = true }
[dev-dependencies]
gpui = { workspace = true, features = ["test-support"] }

View file

@ -76,7 +76,7 @@ impl Scrollbar {
handle,
axis: ScrollbarAxis::Vertical,
markers: Vec::new(),
always_visible: false,
always_visible: true,
#[cfg(test)]
debug_selector: None,
}
@ -88,7 +88,7 @@ impl Scrollbar {
handle,
axis: ScrollbarAxis::Horizontal,
markers: Vec::new(),
always_visible: false,
always_visible: true,
#[cfg(test)]
debug_selector: None,
}

View file

@ -61,6 +61,7 @@ mod state_apply;
mod toast_host;
mod tooltip;
mod tooltip_host;
mod update_check;
mod word_diff;
use app_model::AppUiModel;
@ -532,6 +533,7 @@ impl GitCometView {
view.maybe_auto_install_linux_desktop_integration(cx);
view.drive_focused_mergetool_bootstrap();
view.maybe_check_for_updates_on_startup(cx);
view
}
@ -732,6 +734,20 @@ impl GitCometView {
.update(cx, |host, cx| host.push_toast(kind, message, cx));
}
#[cfg_attr(test, allow(dead_code))]
fn push_toast_with_link(
&mut self,
kind: components::ToastKind,
message: String,
link_url: String,
link_label: String,
cx: &mut gpui::Context<Self>,
) {
self.toast_host.update(cx, |host, cx| {
host.push_toast_with_link(kind, message, link_url, link_label, cx)
});
}
fn open_external_url(&mut self, url: &str) -> Result<(), std::io::Error> {
platform_open::open_url(url)
}

View file

@ -259,6 +259,8 @@ pub(super) struct ToastState {
pub(super) kind: components::ToastKind,
pub(super) input: Entity<components::TextInput>,
pub(super) is_code_message: bool,
pub(super) action_url: Option<String>,
pub(super) action_label: Option<String>,
pub(super) ttl: Option<Duration>,
}

View file

@ -50,7 +50,24 @@ impl ToastHost {
components::ToastKind::Warning => Duration::from_secs(10),
components::ToastKind::Success => Duration::from_secs(6),
};
let _ = self.push_toast_inner(kind, message, Some(ttl), cx);
let _ = self.push_toast_inner(kind, message, None, Some(ttl), cx);
}
#[cfg_attr(test, allow(dead_code))]
pub(super) fn push_toast_with_link(
&mut self,
kind: components::ToastKind,
message: String,
link_url: String,
link_label: String,
cx: &mut gpui::Context<Self>,
) {
let ttl = match kind {
components::ToastKind::Error => Duration::from_secs(15),
components::ToastKind::Warning => Duration::from_secs(10),
components::ToastKind::Success => Duration::from_secs(6),
};
let _ = self.push_toast_inner(kind, message, Some((link_url, link_label)), Some(ttl), cx);
}
pub(super) fn push_persistent_toast(
@ -59,13 +76,14 @@ impl ToastHost {
message: String,
cx: &mut gpui::Context<Self>,
) -> u64 {
self.push_toast_inner(kind, message, None, cx)
self.push_toast_inner(kind, message, None, None, cx)
}
fn push_toast_inner(
&mut self,
kind: components::ToastKind,
message: String,
action: Option<(String, String)>,
ttl: Option<Duration>,
cx: &mut gpui::Context<Self>,
) -> u64 {
@ -99,11 +117,16 @@ impl ToastHost {
input.set_read_only(true, cx);
});
let (action_url, action_label) = action
.map(|(url, label)| (Some(url), Some(label)))
.unwrap_or((None, None));
self.toasts.push(ToastState {
id,
kind,
input,
is_code_message,
action_url,
action_label,
ttl,
});
cx.notify();
@ -312,7 +335,7 @@ impl Render for ToastHost {
}
}));
let message = div()
let message_scroll = div()
.id(("toast_message_scroll", t.id))
.max_h(px(200.0))
.overflow_y_scroll()
@ -331,6 +354,36 @@ impl Render for ToastHost {
.child(t.input.clone()),
);
let action_button =
t.action_url
.clone()
.zip(t.action_label.clone())
.map(|(url, label)| {
components::Button::new(format!("toast_action_{}", t.id), label)
.style(components::ButtonStyle::Outlined)
.on_click(theme, cx, move |this, _e, _w, cx| {
match super::platform_open::open_url(&url) {
Ok(()) => {
this.remove_toast(t.id, cx);
}
Err(err) => {
this.push_toast(
components::ToastKind::Error,
format!("Failed to open link: {err}"),
cx,
);
}
}
})
});
let message = div()
.flex()
.flex_col()
.gap_1()
.child(message_scroll)
.when_some(action_button, |this, button| this.child(button));
div()
.relative()
.child(components::toast(theme, t.kind, message))

View file

@ -0,0 +1,349 @@
use super::*;
use semver::Version;
#[cfg(not(test))]
use serde::Deserialize;
const UPDATE_CHECK_DISABLE_ENV: &str = "GITCOMET_NO_UPDATE_CHECK";
#[cfg(not(test))]
const UPDATE_CHECK_REPO_ENV: &str = "GITCOMET_UPDATE_REPO";
#[cfg(not(test))]
const DEFAULT_UPDATE_REPO: &str = "GitComet/gitcomet";
#[derive(Clone, Debug, Eq, PartialEq)]
struct UpdateNotice {
latest_version: String,
current_version: String,
releases_url: String,
}
#[cfg(not(test))]
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
#[serde(default)]
html_url: Option<String>,
}
#[cfg(not(test))]
#[derive(Debug, Deserialize)]
struct GitHubTag {
name: String,
}
#[cfg_attr(test, allow(dead_code))]
#[derive(Clone, Debug, Eq, PartialEq)]
struct GitHubRepo {
owner: String,
repo: String,
}
impl GitCometView {
pub(in crate::view) fn maybe_check_for_updates_on_startup(
&mut self,
cx: &mut gpui::Context<Self>,
) {
if self.view_mode != GitCometViewMode::Normal
|| std::env::var_os(UPDATE_CHECK_DISABLE_ENV).is_some()
{
return;
}
#[cfg(test)]
let _ = cx;
#[cfg(not(test))]
cx.spawn(
async move |view: WeakEntity<GitCometView>, cx: &mut gpui::AsyncApp| {
let notice = smol::unblock(|| fetch_update_notice(env!("CARGO_PKG_VERSION"))).await;
let Some(notice) = notice else {
return;
};
let _ = view.update(cx, |this, cx| {
this.push_toast_with_link(
components::ToastKind::Warning,
format!(
"A newer GitComet version is available: {} (current {}).",
notice.latest_version, notice.current_version
),
notice.releases_url,
"Open Releases".to_string(),
cx,
);
});
},
)
.detach();
}
}
#[cfg(not(test))]
fn fetch_update_notice(current_version: &'static str) -> Option<UpdateNotice> {
const UPDATE_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4);
let repo = resolve_update_repo();
let user_agent = format!(
"GitComet/{current_version} (+{})",
env!("CARGO_PKG_REPOSITORY")
);
let client = zed_reqwest::blocking::Client::builder()
.timeout(UPDATE_CHECK_TIMEOUT)
.build()
.ok()?;
let release_response = client
.get(repo.releases_latest_api_url())
.header(zed_reqwest::header::ACCEPT, "application/vnd.github+json")
.header(zed_reqwest::header::USER_AGENT, user_agent.clone())
.send()
.ok()?;
let (release_tag, release_html_url): (Option<String>, Option<String>) =
if release_response.status().is_success() {
let release = release_response.json::<GitHubRelease>().ok()?;
(Some(release.tag_name), release.html_url)
} else {
(None, None)
};
let tag_names: Vec<String> = client
.get(repo.tags_api_url())
.header(zed_reqwest::header::ACCEPT, "application/vnd.github+json")
.header(zed_reqwest::header::USER_AGENT, user_agent)
.send()
.ok()
.and_then(|response| response.error_for_status().ok())
.and_then(|response| response.json::<Vec<GitHubTag>>().ok())
.map(|tags| tags.into_iter().map(|tag| tag.name).collect())
.unwrap_or_default();
build_update_notice(
current_version,
release_tag.as_deref(),
release_html_url.as_deref(),
&tag_names,
&repo,
)
}
fn build_update_notice(
current_version: &str,
release_tag: Option<&str>,
release_html_url: Option<&str>,
tag_names: &[String],
repo: &GitHubRepo,
) -> Option<UpdateNotice> {
let current = parse_semver_tag(current_version)?;
let mut latest: Option<(Version, String)> = release_tag.and_then(|tag| {
parse_semver_tag(tag).map(|version| {
let url = release_html_url
.map(str::trim)
.filter(|url| !url.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| repo.releases_page_url());
(version, url)
})
});
for tag_name in tag_names {
let Some(version) = parse_semver_tag(tag_name) else {
continue;
};
let should_promote = latest
.as_ref()
.map(|(best, _)| version > *best)
.unwrap_or(true);
if should_promote {
latest = Some((version, repo.releases_page_url()));
}
}
let Some((latest_version, latest_url)) = latest else {
return None;
};
if latest_version <= current {
return None;
}
Some(UpdateNotice {
latest_version: latest_version.to_string(),
current_version: current.to_string(),
releases_url: latest_url,
})
}
fn parse_semver_tag(raw: &str) -> Option<Version> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
Version::parse(trimmed)
.ok()
.or_else(|| {
trimmed
.strip_prefix('v')
.and_then(|rest| Version::parse(rest).ok())
})
.or_else(|| {
trimmed
.strip_prefix('V')
.and_then(|rest| Version::parse(rest).ok())
})
}
#[cfg(not(test))]
fn resolve_update_repo() -> GitHubRepo {
std::env::var(UPDATE_CHECK_REPO_ENV)
.ok()
.as_deref()
.and_then(parse_repo_slug)
.or_else(|| parse_repo_slug(env!("CARGO_PKG_REPOSITORY")))
.unwrap_or_else(|| GitHubRepo::from_slug(DEFAULT_UPDATE_REPO))
}
#[cfg(not(test))]
fn parse_repo_slug(raw: &str) -> Option<GitHubRepo> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if let Some(repo) = parse_github_repo_from_url(trimmed) {
return Some(repo);
}
if trimmed.split('/').count() == 2 {
return Some(GitHubRepo::from_slug(trimmed));
}
None
}
fn parse_github_repo_from_url(raw: &str) -> Option<GitHubRepo> {
let without_scheme = raw
.strip_prefix("https://github.com/")
.or_else(|| raw.strip_prefix("http://github.com/"))
.or_else(|| raw.strip_prefix("git@github.com:"))
.or_else(|| raw.strip_prefix("ssh://git@github.com/"))?;
Some(GitHubRepo::from_slug(without_scheme))
}
impl GitHubRepo {
fn from_slug(raw: &str) -> Self {
let mut normalized = raw.trim().trim_end_matches('/').to_string();
if let Some(stripped) = normalized.strip_suffix(".git") {
normalized = stripped.to_string();
}
let mut parts = normalized.splitn(2, '/');
let owner = parts.next().unwrap_or_default().trim().to_string();
let repo = parts.next().unwrap_or_default().trim().to_string();
Self { owner, repo }
}
#[cfg(not(test))]
fn releases_latest_api_url(&self) -> String {
format!(
"https://api.github.com/repos/{}/{}/releases/latest",
self.owner, self.repo
)
}
#[cfg(not(test))]
fn tags_api_url(&self) -> String {
format!(
"https://api.github.com/repos/{}/{}/tags?per_page=20",
self.owner, self.repo
)
}
fn releases_page_url(&self) -> String {
format!("https://github.com/{}/{}/releases", self.owner, self.repo)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_semver_tag_accepts_plain_and_prefixed_versions() {
assert_eq!(parse_semver_tag("1.2.3"), Some(Version::new(1, 2, 3)));
assert_eq!(parse_semver_tag("v1.2.3"), Some(Version::new(1, 2, 3)));
assert_eq!(parse_semver_tag("V1.2.3"), Some(Version::new(1, 2, 3)));
}
#[test]
fn build_update_notice_returns_none_when_release_is_not_newer() {
let repo = GitHubRepo::from_slug("Auto-Explore/GitComet");
let tags = vec!["0.0.9".to_string()];
assert!(build_update_notice("0.1.0", Some("v0.1.0"), None, &tags, &repo).is_none());
}
#[test]
fn build_update_notice_returns_notice_when_new_release_exists() {
let repo = GitHubRepo::from_slug("Auto-Explore/GitComet");
let notice = build_update_notice(
"0.2.0",
Some("v0.2.1"),
Some("https://example.invalid/releases/0.2.1"),
&[],
&repo,
)
.expect("update notice expected");
assert_eq!(notice.current_version, "0.2.0");
assert_eq!(notice.latest_version, "0.2.1");
assert_eq!(
notice.releases_url,
"https://example.invalid/releases/0.2.1"
);
}
#[test]
fn build_update_notice_falls_back_to_repo_releases_page_when_no_release_url() {
let repo = GitHubRepo::from_slug("Auto-Explore/GitComet");
let notice = build_update_notice("0.2.0", Some("0.2.1"), None, &[], &repo)
.expect("update notice expected");
assert_eq!(
notice.releases_url,
"https://github.com/Auto-Explore/GitComet/releases"
);
}
#[test]
fn build_update_notice_promotes_newer_tag_over_older_release() {
let repo = GitHubRepo::from_slug("Auto-Explore/GitComet");
let tags = vec!["v0.2.0".to_string()];
let notice = build_update_notice("0.1.0", Some("v0.1.0"), None, &tags, &repo)
.expect("update notice expected");
assert_eq!(notice.latest_version, "0.2.0");
assert_eq!(
notice.releases_url,
"https://github.com/Auto-Explore/GitComet/releases"
);
}
#[test]
fn parse_github_repo_from_url_supports_https_and_ssh_forms() {
assert_eq!(
parse_github_repo_from_url("https://github.com/Auto-Explore/GitComet.git"),
Some(GitHubRepo {
owner: "Auto-Explore".to_string(),
repo: "GitComet".to_string(),
})
);
assert_eq!(
parse_github_repo_from_url("git@github.com:Auto-Explore/GitComet.git"),
Some(GitHubRepo {
owner: "Auto-Explore".to_string(),
repo: "GitComet".to_string(),
})
);
}
}