do not hide scrollbars automatically, automatic version checker
This commit is contained in:
parent
9a56e2fe33
commit
abe18e75e7
8 changed files with 433 additions and 6 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
349
crates/gitcomet-ui-gpui/src/view/update_check.rs
Normal file
349
crates/gitcomet-ui-gpui/src/view/update_check.rs
Normal 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(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue