mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
961344c0f9 | ||
|
|
f76c297fe9 | ||
|
|
c75a630ede | ||
|
|
266e4eeb03 | ||
|
|
c1aa092d75 |
No files matched your search
@@ -268,6 +268,8 @@ pub struct GeneralSettings {
|
||||
pub power_save_mode: bool,
|
||||
/// File › Rename Project… (project layer): the name shown for the project; empty = its folder name.
|
||||
pub project_name: String,
|
||||
/// Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`.
|
||||
pub update_source: String,
|
||||
}
|
||||
|
||||
impl Default for GeneralSettings {
|
||||
@@ -282,6 +284,7 @@ impl Default for GeneralSettings {
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,10 +196,18 @@ fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
dirs
|
||||
}
|
||||
|
||||
/// The `PATH` an adapter runs with, unless its launch sets one: the search path language servers
|
||||
/// get, so what it starts in turn (js-debug's wrapper runs `node`, Delve runs `go build`) is
|
||||
/// found like Run finds it, even when the login shell's `PATH` didn't reach the app.
|
||||
fn adapter_path(env: &[(String, String)]) -> Option<std::ffi::OsString> {
|
||||
let own = env.iter().any(|(k, _)| if cfg!(windows) { k.eq_ignore_ascii_case("PATH") } else { k == "PATH" });
|
||||
(!own).then(ide_core::tool_path::search_path)
|
||||
}
|
||||
|
||||
/// Directories searched for adapter binaries: managed tools first, then `PATH` and the usual install spots.
|
||||
fn search_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = tools_root().map(|r| managed_dirs(&r)).unwrap_or_default();
|
||||
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
|
||||
dirs.extend(std::env::split_paths(&ide_core::tool_path::search_path()));
|
||||
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
|
||||
for rel in [".cargo/bin", ".local/bin", "go/bin", ".dotnet/tools", ".bun/bin", ".npm-global/bin", ".volta/bin"]
|
||||
{
|
||||
@@ -464,6 +472,9 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
@@ -592,6 +603,21 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapters_run_on_the_tool_search_path() {
|
||||
let search = ide_core::tool_path::search_path();
|
||||
assert_eq!(adapter_path(&[]), Some(search.clone()));
|
||||
assert_eq!(adapter_path(&[("GOFLAGS".into(), "-mod=mod".into())]), Some(search));
|
||||
// A launch that sets its own (a selected Go SDK's bin first) keeps it.
|
||||
assert_eq!(adapter_path(&[("PATH".into(), "/sdk/go/bin:/usr/bin".into())]), None);
|
||||
// The search path reaches the usual install spots even when this process's `PATH` doesn't.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dirs: Vec<PathBuf> = std::env::split_paths(&ide_core::tool_path::search_path()).collect();
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/local/bin")), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
||||
@@ -1221,10 +1221,26 @@ impl Tools {
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<sources::Download> {
|
||||
match source {
|
||||
ReleaseSource::GitHub { repo } => {
|
||||
let json = http.get_text(&sources::github_latest_url(repo), cancel)?;
|
||||
sources::github_pick(&json, assets, self.platform)
|
||||
}
|
||||
ReleaseSource::GitHub { repo } => match http.get_text(&sources::github_latest_url(repo), cancel) {
|
||||
Ok(json) => sources::github_pick(&json, assets, self.platform),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => Err(e),
|
||||
// Without a token the API allows 60 requests an hour per IP address, often used
|
||||
// up behind a shared one (403/429); the release's web pages have no such limit.
|
||||
Err(api) => self.resolve_github_web(http, repo, assets, cancel).map_err(|web| {
|
||||
if web.kind == ErrorKind::Cancelled {
|
||||
return web;
|
||||
}
|
||||
tracing::warn!("GitHub release lookup for {repo} failed: {api}; web pages: {web}");
|
||||
IdeError::new(
|
||||
api.kind,
|
||||
format!(
|
||||
"{}\nDownload it from {} and set its Path in Settings › Languages & Frameworks › Language Servers.",
|
||||
api.message,
|
||||
sources::github_latest_page(repo)
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
ReleaseSource::HashiCorp { product } => {
|
||||
let json = http.get_text(&sources::hashicorp_latest_url(product), cancel)?;
|
||||
sources::hashicorp_pick(&json, assets, self.platform)
|
||||
@@ -1251,6 +1267,27 @@ impl Tools {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`resolve_release`](Self::resolve_release) for a GitHub release, from its web pages.
|
||||
fn resolve_github_web(
|
||||
&self,
|
||||
http: &Http,
|
||||
repo: &str,
|
||||
assets: &[platform::AssetRule],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<sources::Download> {
|
||||
let page = http.effective_url(&sources::github_latest_page(repo), cancel)?;
|
||||
let tag = sources::github_tag_from_url(&page)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("{repo} has no published release")))?;
|
||||
let list = http.get_text(&sources::github_assets_page(repo, &tag), cancel)?;
|
||||
match sources::github_pick_web(repo, &tag, &list, None, assets, self.platform) {
|
||||
Err(e) if e.kind == ErrorKind::Unavailable => {
|
||||
let notes = http.get_text(&sources::github_tag_page(repo, &tag), cancel)?;
|
||||
sources::github_pick_web(repo, &tag, &list, Some(¬es), assets, self.platform)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_release(
|
||||
&self,
|
||||
spec: &ToolSpec,
|
||||
|
||||
@@ -263,6 +263,15 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
|
||||
(major, minor) >= (7, 70) && first.split_whitespace().any(|w| w.eq_ignore_ascii_case("schannel"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Request {
|
||||
Get,
|
||||
/// Adoptium uses 404 as well as an empty array for absent platform builds.
|
||||
GetOrEmptyOn404,
|
||||
/// Headers, then the URL redirects ended at.
|
||||
Head,
|
||||
}
|
||||
|
||||
/// HTTP via the system `curl` (shipped with macOS, Windows 10+ and every
|
||||
/// mainstream Linux), so the IDE carries no TLS stack of its own.
|
||||
pub struct Http {
|
||||
@@ -336,26 +345,39 @@ impl Http {
|
||||
self.get_text_limited(url, None, cancel)
|
||||
}
|
||||
|
||||
/// Where `url` ends up after redirects (a HEAD request).
|
||||
pub fn effective_url(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
let out = self.get_text_response(url, None, Request::Head, cancel)?;
|
||||
Ok(out.trim_end().rsplit('\n').next().unwrap_or_default().trim().to_owned())
|
||||
}
|
||||
|
||||
/// GETs `url` as text, failing when the response is larger than `max_bytes`.
|
||||
pub fn get_text_limited(&self, url: &str, max_bytes: Option<u64>, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
self.get_text_response(url, max_bytes, false, cancel)
|
||||
self.get_text_response(url, max_bytes, Request::Get, cancel)
|
||||
}
|
||||
|
||||
/// Adoptium uses 404 as well as an empty array for absent platform builds.
|
||||
pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
self.get_text_response(url, None, true, cancel)
|
||||
self.get_text_response(url, None, Request::GetOrEmptyOn404, cancel)
|
||||
}
|
||||
|
||||
fn get_text_response(
|
||||
&self,
|
||||
url: &str,
|
||||
max_bytes: Option<u64>,
|
||||
empty_on_404: bool,
|
||||
request: Request,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
if empty_on_404 {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
let empty_on_404 = request == Request::GetOrEmptyOn404;
|
||||
match request {
|
||||
Request::Get => {}
|
||||
Request::GetOrEmptyOn404 => {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
Request::Head => {
|
||||
cmd.args(["--head", "--write-out", "\n%{url_effective}"]);
|
||||
}
|
||||
}
|
||||
if let Some(max) = max_bytes {
|
||||
cmd.arg("--max-filesize").arg(max.to_string());
|
||||
|
||||
+184
-11
@@ -47,38 +47,146 @@ pub fn github_latest_url(repo: &str) -> String {
|
||||
format!("https://api.github.com/repos/{repo}/releases/latest")
|
||||
}
|
||||
|
||||
/// The latest release's web page; it redirects to `…/releases/tag/<tag>`. Web pages don't
|
||||
/// count against the API's rate limit for unauthenticated clients (60 requests an hour).
|
||||
pub fn github_latest_page(repo: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/latest")
|
||||
}
|
||||
|
||||
/// The tag `github_latest_page` redirected to.
|
||||
pub fn github_tag_from_url(url: &str) -> Option<String> {
|
||||
let (_, tag) = url.split_once("/releases/tag/")?;
|
||||
let tag = tag.split(['?', '#']).next().unwrap_or(tag).trim_end_matches('/');
|
||||
(!tag.is_empty()).then(|| percent_decode(tag))
|
||||
}
|
||||
|
||||
pub fn github_tag_page(repo: &str, tag: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/tag/{tag}")
|
||||
}
|
||||
|
||||
/// The asset list the release page loads (HTML).
|
||||
pub fn github_assets_page(repo: &str, tag: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/expanded_assets/{tag}")
|
||||
}
|
||||
|
||||
/// One file of a release, from the API or its web page.
|
||||
struct ReleaseAsset {
|
||||
name: String,
|
||||
url: String,
|
||||
size: Option<u64>,
|
||||
sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Picks the platform asset from a GitHub `releases/latest` response. Links
|
||||
/// in the release notes count as assets too (some projects host binaries on
|
||||
/// a CDN and only link them).
|
||||
pub fn github_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult<Download> {
|
||||
let rel: GhRelease = serde_json::from_str(json).map_err(|e| proto(format!("unexpected GitHub response: {e}")))?;
|
||||
let version = rel.tag_name.trim_start_matches('v').to_string();
|
||||
let names: Vec<&str> = rel.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
let assets: Vec<ReleaseAsset> = rel
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(|a| ReleaseAsset {
|
||||
sha256: a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase),
|
||||
name: a.name,
|
||||
url: a.browser_download_url,
|
||||
size: a.size,
|
||||
})
|
||||
.collect();
|
||||
pick_release(&rel.tag_name, &assets, rel.body.as_deref().unwrap_or(""), rules, platform)
|
||||
}
|
||||
|
||||
/// [`github_pick`] from the release's web pages: `assets_html` from [`github_assets_page`] and,
|
||||
/// for projects that only link their builds, `notes_html` from [`github_tag_page`].
|
||||
pub fn github_pick_web(
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
assets_html: &str,
|
||||
notes_html: Option<&str>,
|
||||
rules: &[AssetRule],
|
||||
platform: Platform,
|
||||
) -> IdeResult<Download> {
|
||||
let prefix = format!("href=\"/{repo}/releases/download/");
|
||||
let mut assets: Vec<ReleaseAsset> = Vec::new();
|
||||
let mut rest = assets_html;
|
||||
while let Some(i) = rest.find(&prefix) {
|
||||
let tail = &rest[i + "href=\"".len()..];
|
||||
let Some(end) = tail.find('"') else { break };
|
||||
let path = html_unescape(&tail[..end]);
|
||||
rest = &tail[end..];
|
||||
let name = percent_decode(file_name(&path));
|
||||
if name.is_empty() || assets.iter().any(|a| a.name == name) {
|
||||
continue;
|
||||
}
|
||||
// `<clipboard-copy aria-label="Copy to clipboard digest for <name>" … value="sha256:<hex>">`
|
||||
let sha256 = assets_html.find(&format!("digest for {}\"", html_escape(&name))).and_then(|at| {
|
||||
let tag_end = assets_html[at..].find('>').map_or(assets_html.len(), |e| at + e);
|
||||
let value = assets_html[at..tag_end].split_once("value=\"sha256:")?.1;
|
||||
let hex: String = value.chars().take_while(char::is_ascii_hexdigit).collect();
|
||||
(hex.len() == 64).then(|| hex.to_ascii_lowercase())
|
||||
});
|
||||
assets.push(ReleaseAsset { url: format!("https://github.com{path}"), name, size: None, sha256 });
|
||||
}
|
||||
pick_release(tag, &assets, &html_unescape(notes_html.unwrap_or("")), rules, platform)
|
||||
}
|
||||
|
||||
fn pick_release(
|
||||
tag: &str,
|
||||
assets: &[ReleaseAsset],
|
||||
notes: &str,
|
||||
rules: &[AssetRule],
|
||||
platform: Platform,
|
||||
) -> IdeResult<Download> {
|
||||
let version = tag.trim_start_matches('v').to_string();
|
||||
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
|
||||
if let Some(name) = select_asset(&names, rules, platform) {
|
||||
let a = rel.assets.iter().find(|a| a.name == name).expect("selected from list");
|
||||
let sha256 = a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase);
|
||||
let checksum_url = rel
|
||||
.assets
|
||||
let a = assets.iter().find(|a| a.name == name).expect("selected from list");
|
||||
let checksum_url = assets
|
||||
.iter()
|
||||
.find(|c| c.name == format!("{name}.sha256") || c.name == format!("{name}.sha256sum"))
|
||||
.map(|c| c.browser_download_url.clone());
|
||||
.map(|c| c.url.clone());
|
||||
return Ok(Download {
|
||||
version,
|
||||
name: a.name.clone(),
|
||||
url: a.browser_download_url.clone(),
|
||||
url: a.url.clone(),
|
||||
size: a.size,
|
||||
sha256,
|
||||
sha256: a.sha256.clone(),
|
||||
checksum_url,
|
||||
});
|
||||
}
|
||||
let links = extract_links(rel.body.as_deref().unwrap_or(""));
|
||||
let links = extract_links(notes);
|
||||
let link_names: Vec<&str> = links.iter().map(|u| file_name(u)).collect();
|
||||
if let Some(name) = select_asset(&link_names, rules, platform) {
|
||||
let url = links.iter().find(|u| file_name(u) == name).expect("selected from list").clone();
|
||||
let checksum_url = links.iter().find(|u| *u == &format!("{url}.sha256")).cloned();
|
||||
return Ok(Download { version, name: name.to_string(), url, size: None, sha256: None, checksum_url });
|
||||
}
|
||||
Err(IdeError::unavailable(format!("release {} has no build for this platform", rel.tag_name)))
|
||||
Err(IdeError::unavailable(format!("release {tag} has no build for this platform")))
|
||||
}
|
||||
|
||||
fn html_unescape(s: &str) -> String {
|
||||
s.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let Some(b) = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(b);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_owned())
|
||||
}
|
||||
|
||||
fn file_name(url: &str) -> &str {
|
||||
@@ -336,6 +444,71 @@ mod tests {
|
||||
assert_eq!(d.name, "kotlin-lsp-0.253.10629-linux-x64.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_tag_from_redirect() {
|
||||
let url = "https://github.com/Kotlin/kotlin-lsp/releases/tag/kotlin-lsp/v263.7084.0";
|
||||
assert_eq!(github_tag_from_url(url).as_deref(), Some("kotlin-lsp/v263.7084.0"));
|
||||
let url = "https://github.com/o/r/releases/tag/v1.0%2Bbuild";
|
||||
assert_eq!(github_tag_from_url(url).as_deref(), Some("v1.0+build"));
|
||||
// A repository without releases stays on the list.
|
||||
assert_eq!(github_tag_from_url("https://github.com/o/r/releases"), None);
|
||||
}
|
||||
|
||||
/// Trimmed from `github.com/<repo>/releases/expanded_assets/<tag>`.
|
||||
const ASSETS_HTML: &str = r#"<li><div><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz" rel="nofollow" data-turbo="false" class="wb-break-all">
|
||||
<span class="text-bold">lua-language-server-3.13.5-darwin-arm64.tar.gz</span></a></div>
|
||||
<span class="Truncate-text">sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789</span>
|
||||
<clipboard-copy id="clipboard-button-sha256:abc" aria-label="Copy to clipboard digest for lua-language-server-3.13.5-darwin-arm64.tar.gz" type="button" value="sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789" class="Button"></clipboard-copy></li>
|
||||
<li><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-linux-x64.tar.gz" rel="nofollow">lua-language-server-3.13.5-linux-x64.tar.gz</a></li>
|
||||
<li><a href="/LuaLS/lua-language-server/archive/refs/tags/3.13.5.zip" rel="nofollow">Source code (zip)</a></li>"#;
|
||||
|
||||
#[test]
|
||||
fn github_web_assets_with_digest() {
|
||||
let rules = find("lua-language-server").unwrap().asset_rules();
|
||||
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, MAC_ARM).unwrap();
|
||||
assert_eq!(d.version, "3.13.5");
|
||||
assert_eq!(d.name, "lua-language-server-3.13.5-darwin-arm64.tar.gz");
|
||||
assert_eq!(
|
||||
d.url,
|
||||
"https://github.com/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz"
|
||||
);
|
||||
assert_eq!(d.sha256.as_deref(), Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"));
|
||||
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, LINUX).unwrap();
|
||||
assert_eq!(d.sha256, None);
|
||||
let win = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
let err = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, win).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_web_sibling_checksum_and_notes_links() {
|
||||
let html = r#"<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos" rel="nofollow">
|
||||
<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256" rel="nofollow">"#;
|
||||
let d = github_pick_web(
|
||||
"artempyanykh/marksman",
|
||||
"2024-12-18",
|
||||
html,
|
||||
None,
|
||||
find("marksman").unwrap().asset_rules(),
|
||||
MAC_ARM,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
d.checksum_url.as_deref(),
|
||||
Some("https://github.com/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256")
|
||||
);
|
||||
|
||||
let notes = r#"<li><a href="https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2" rel="nofollow">macOS arm</a></li>"#;
|
||||
let rules = find("kotlin-lsp").unwrap().asset_rules();
|
||||
let d =
|
||||
github_pick_web("Kotlin/kotlin-lsp", "kotlin-lsp/v0.253.10629", "", Some(notes), rules, MAC_ARM).unwrap();
|
||||
assert_eq!(d.name, "kotlin-lsp-0.253.10629-mac-aarch64.zip");
|
||||
assert_eq!(
|
||||
d.url,
|
||||
"https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashicorp() {
|
||||
let json = r#"{"version":"0.36.4","url_shasums":"https://r/terraform-ls_0.36.4_SHA256SUMS","builds":[
|
||||
|
||||
+3
-2
@@ -69,7 +69,8 @@ test("⌘K commits the checked changes with the typed message", async ({ workben
|
||||
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
|
||||
const [committed] = await ipcCalls(page, "vcs_commit");
|
||||
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
|
||||
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
|
||||
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
// The commit takes the checked file itself; the index isn't staged beforehand.
|
||||
expect((committed!.options as { stage: string[] }).stage).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
expect(await ipcCalls(page, "vcs_stage")).toHaveLength(0);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
@@ -8,12 +8,11 @@ import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
const ROOT = "/dev/acme-api";
|
||||
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
|
||||
|
||||
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
|
||||
/** Twenty modified tracked files; `vcs_commit` takes the files it commits out of the status. */
|
||||
async function stubTwentyChanges(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ root, names }) => {
|
||||
const staged = new Set<string>();
|
||||
const committed = new Set<string>();
|
||||
const all = names.map((rel) => ({
|
||||
path: `${root}/${rel}`,
|
||||
@@ -33,14 +32,10 @@ async function stubTwentyChanges(page: Page) {
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
|
||||
h.vcs_stage = (args) => {
|
||||
for (const path of args.paths as string[]) staged.add(path);
|
||||
return null;
|
||||
};
|
||||
h.vcs_stage = () => null;
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = () => {
|
||||
for (const path of staged) committed.add(path);
|
||||
staged.clear();
|
||||
h.vcs_commit = (args) => {
|
||||
for (const path of (args.options as { stage: string[] }).stage) committed.add(path);
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
@@ -71,7 +66,8 @@ test("changes left out of a commit stay unchecked (#1539)", async ({ workbench:
|
||||
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
|
||||
await panel.getByRole("button", { name: "Commit", exact: true }).click();
|
||||
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
|
||||
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
|
||||
const [commit] = await ipcCalls(page, "vcs_commit");
|
||||
expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual(
|
||||
NAMES.slice(0, 5)
|
||||
.map((rel) => `${ROOT}/${rel}`)
|
||||
.sort(),
|
||||
|
||||
@@ -330,36 +330,74 @@ fn update_endpoint_env() -> Option<String> {
|
||||
/// tauri.conf.json, or the `OPENIDEA_UPDATE_ENDPOINT` environment variable. Empty by
|
||||
/// default, and "Check for Updates…" is hidden then.
|
||||
pub fn updates_configured<R: tauri::Runtime>(app: &AppHandle<R>) -> bool {
|
||||
let in_config = app
|
||||
.config()
|
||||
!config_endpoints(app).is_empty() || update_endpoint_env().is_some()
|
||||
}
|
||||
|
||||
/// Where updates are downloaded from (System Settings › Updates).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum UpdateSource {
|
||||
/// The GitHub releases named in tauri.conf.json.
|
||||
#[default]
|
||||
Github,
|
||||
/// The CNB mirror of the same releases, for networks where GitHub is slow.
|
||||
Cnb,
|
||||
}
|
||||
|
||||
const CNB_ENDPOINT: &str = "https://cnb.cool/l0ng-ai/openidea/-/releases/latest/download/latest.json";
|
||||
|
||||
fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
|
||||
app.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.and_then(|u| u.get("endpoints"))
|
||||
.and_then(|e| e.as_array())
|
||||
.is_some_and(|e| !e.is_empty());
|
||||
in_config || update_endpoint_env().is_some()
|
||||
.map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
/// The endpoints tried in order: `source` first, the other source when it fails or has
|
||||
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
|
||||
/// No official endpoint (a build without updates) means no mirror either.
|
||||
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
|
||||
if official.is_empty() {
|
||||
return official;
|
||||
}
|
||||
let mirror = vec![CNB_ENDPOINT.to_string()];
|
||||
match source {
|
||||
UpdateSource::Github => [official, mirror].concat(),
|
||||
UpdateSource::Cnb => [mirror, official].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if !updates_configured(app) {
|
||||
return Ok(None);
|
||||
}
|
||||
let env = update_endpoint_env();
|
||||
let endpoints = match update_endpoint_env() {
|
||||
Some(url) => vec![url],
|
||||
None => source_endpoints(config_endpoints(app), source),
|
||||
};
|
||||
let endpoints = endpoints
|
||||
.iter()
|
||||
.map(|u| u.parse().map_err(|e| IdeError::invalid(format!("update endpoint {u}: {e}"))))
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let mut builder = app.updater_builder().configure_client(move |b| ide_http::client::with_proxy(b, &proxy));
|
||||
if let Some(url) = env {
|
||||
let url = url.parse().map_err(|e| IdeError::invalid(format!("OPENIDEA_UPDATE_ENDPOINT: {e}")))?;
|
||||
builder = builder.endpoints(vec![url]).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
}
|
||||
builder.build().map(Some).map_err(|e| IdeError::unavailable(e.to_string()))
|
||||
app.updater_builder()
|
||||
.configure_client(move |b| ide_http::client::with_proxy(b, &proxy))
|
||||
.endpoints(endpoints)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.build()
|
||||
.map(Some)
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app)? else {
|
||||
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app, source)? else {
|
||||
return Ok(UpdateCheck { enabled: false, update: None });
|
||||
};
|
||||
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
@@ -379,12 +417,12 @@ pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
|
||||
/// not reported: the user didn't ask for this check.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
|
||||
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
match app_check_update(app).await {
|
||||
match app_check_update(app, source).await {
|
||||
Ok(check) => check.update,
|
||||
Err(e) => {
|
||||
tracing::warn!("startup update check failed: {e}");
|
||||
@@ -452,6 +490,7 @@ impl Drop for InstallingGuard {
|
||||
pub async fn app_install_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
source: UpdateSource,
|
||||
request: RequestId,
|
||||
on_progress: Channel<UpdateProgress>,
|
||||
) -> IdeResult<()> {
|
||||
@@ -459,7 +498,7 @@ pub async fn app_install_update(
|
||||
InstallingGuard::acquire().ok_or_else(|| IdeError::unavailable("an update is already being installed"))?;
|
||||
let token = state.host.begin_request(request);
|
||||
let result = async {
|
||||
let updater = updater(&app)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
|
||||
let updater = updater(&app, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
|
||||
let update = tokio::select! {
|
||||
r = updater.check() => r.map_err(|e| IdeError::unavailable(e.to_string()))?,
|
||||
_ = token.cancelled() => return Err(IdeError::cancelled()),
|
||||
@@ -522,6 +561,15 @@ mod tests {
|
||||
assert!(ProgressThrottle::default().chunk(1, Some(0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_source_is_tried_first_and_the_other_after_it() {
|
||||
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
|
||||
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
|
||||
// A build without an update endpoint stays without one.
|
||||
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_install_at_a_time() {
|
||||
let first = InstallingGuard::acquire().expect("first install starts");
|
||||
|
||||
+13
-4
@@ -910,24 +910,24 @@ export const commands = {
|
||||
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
|
||||
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
|
||||
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("app_build_info"),
|
||||
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
|
||||
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
|
||||
/**
|
||||
* The background check made once per process when the first window starts;
|
||||
* later calls (other windows) and debug builds get `None`. Failures are logged,
|
||||
* not reported: the user didn't ask for this check.
|
||||
*/
|
||||
appStartupUpdateCheck: () => __TAURI_INVOKE<{
|
||||
appStartupUpdateCheck: (source: UpdateSource) => __TAURI_INVOKE<{
|
||||
version: string,
|
||||
currentVersion: string,
|
||||
notes: string | null,
|
||||
date: string | null,
|
||||
} | null>("app_startup_update_check"),
|
||||
} | null>("app_startup_update_check", { source }),
|
||||
/**
|
||||
* Downloads and installs the available update, streaming progress to `on_progress`.
|
||||
* Cancellable through `request` until the download completes; the UI then restarts
|
||||
* with `app_restart`. One install runs at a time.
|
||||
*/
|
||||
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { request, onProgress })),
|
||||
appInstallUpdate: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
|
||||
debugExceptionFilters: () => typedError<SessionExceptionFilters[], IdeError>(__TAURI_INVOKE("debug_exception_filters")),
|
||||
debugSetExceptionBreakpoints: (choices: { [key in string]: boolean }) => typedError<null, IdeError>(__TAURI_INVOKE("debug_set_exception_breakpoints", { choices })),
|
||||
debugSetVariable: (session: DebugSessionId, container: number, name: string, evaluateName: string | null, frameId: number | null, value: string) => typedError<Variable, IdeError>(__TAURI_INVOKE("debug_set_variable", { session, container, name, evaluateName, frameId, value })),
|
||||
@@ -3866,6 +3866,8 @@ export type GeneralSettings = {
|
||||
powerSaveMode?: boolean,
|
||||
/** File › Rename Project… (project layer): the name shown for the project; empty = its folder name. */
|
||||
projectName?: string,
|
||||
/** Where updates come from: `auto` (the CNB mirror for the Chinese UI, else GitHub) | `github` | `cnb`. */
|
||||
updateSource?: string,
|
||||
};
|
||||
|
||||
/** A repository of the signed-in account (owned, collaborator, or organization member). */
|
||||
@@ -7598,6 +7600,13 @@ export type UpdateProgress =
|
||||
/** Download done; the new version is being written over this one. */
|
||||
{ type: "installing" };
|
||||
|
||||
/** Where updates are downloaded from (System Settings › Updates). */
|
||||
export type UpdateSource =
|
||||
/** The GitHub releases named in tauri.conf.json. */
|
||||
"github" |
|
||||
/** The CNB mirror of the same releases, for networks where GitHub is slow. */
|
||||
"cnb";
|
||||
|
||||
export type Variable = {
|
||||
name: string,
|
||||
value: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpdateCheck, UpdateProgress } from "@/bindings";
|
||||
import type { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
|
||||
import type { NotificationService } from "@/workbench/contributions";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
@@ -20,7 +20,7 @@ function deferred<T>() {
|
||||
let install: ReturnType<typeof deferred<Result<null>>>;
|
||||
let check: ReturnType<typeof deferred<Result<UpdateCheck>>>;
|
||||
let channel: Channel | null;
|
||||
const appInstallUpdate = vi.fn((_request: number, ch: Channel) => {
|
||||
const appInstallUpdate = vi.fn((_source: string, _request: number, ch: Channel) => {
|
||||
channel = ch;
|
||||
return install.promise;
|
||||
});
|
||||
@@ -28,18 +28,35 @@ const appCheckUpdate = vi.fn(() => check.promise);
|
||||
const appRestart = vi.fn(async () => null);
|
||||
const cancelRequest = vi.fn(async () => null);
|
||||
const appBuildInfo = vi.fn(async () => ({ version: "0.1.21" }));
|
||||
const settingsSet = vi.fn(async () => ({ status: "ok", data: { general: {}, appearance: {} } }));
|
||||
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...actual,
|
||||
isTauri: () => true,
|
||||
commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo },
|
||||
commands: {
|
||||
...actual.commands,
|
||||
appInstallUpdate,
|
||||
appCheckUpdate,
|
||||
appRestart,
|
||||
cancelRequest,
|
||||
appBuildInfo,
|
||||
settingsSet,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { checkForUpdates, installUpdate, notifyUpdate, progressText, CHECK_TASK_ID, INSTALL_TASK_ID, UPDATE_KEY } =
|
||||
await import("../updates");
|
||||
const {
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
notifyUpdate,
|
||||
progressText,
|
||||
updateSource,
|
||||
CHECK_TASK_ID,
|
||||
INSTALL_TASK_ID,
|
||||
UPDATE_KEY,
|
||||
} = await import("../updates");
|
||||
const { provideNotificationService } = await import("@/workbench/contributions");
|
||||
const { useProject } = await import("@/platform/project");
|
||||
const { useTasks } = await import("@/workbench/tasks");
|
||||
@@ -60,7 +77,7 @@ beforeEach(() => {
|
||||
check = deferred();
|
||||
provideNotificationService({ notify: (n) => void notes.push(n), expire: (k) => void expired.push(k) });
|
||||
useTasks.setState({ tasks: [] });
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
useProject.setState({ project: null, lightEdit: false, settings: null });
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -128,12 +145,33 @@ describe("Install and Restart", () => {
|
||||
install = deferred();
|
||||
const cancelled = installUpdate("0.1.22");
|
||||
task(INSTALL_TASK_ID)!.cancel!();
|
||||
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
|
||||
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![1]);
|
||||
install.resolve({ status: "error", error: { kind: "cancelled", message: "cancelled" } });
|
||||
await cancelled;
|
||||
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Update cancelled", key: UPDATE_KEY });
|
||||
expect(appRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a failed", "unavailable", "github", "CNB", "cnb"],
|
||||
["a failed", "unavailable", "cnb", "GitHub", "github"],
|
||||
["a cancelled (likely slow)", "cancelled", "cnb", "GitHub", "github"],
|
||||
])("offers %s download from %s again from the other source", async (_case, kind, from, label, to) => {
|
||||
useProject.setState({ settings: { general: { updateSource: from }, appearance: {} } as unknown as Settings });
|
||||
const first = installUpdate("0.1.22");
|
||||
install.resolve({ status: "error", error: { kind, message: kind } });
|
||||
await first;
|
||||
const action = notes.at(-1)!.actions!.at(-1)!;
|
||||
expect(action.label).toBe(`Retry with ${label}`);
|
||||
|
||||
install = deferred();
|
||||
action.run();
|
||||
await tick();
|
||||
expect(settingsSet).toHaveBeenCalledWith("global", "general.updateSource", JSON.stringify(to));
|
||||
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe(to);
|
||||
install.resolve(ok(null));
|
||||
await tick();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Check for Updates", () => {
|
||||
@@ -184,6 +222,36 @@ describe("Check for Updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSource", () => {
|
||||
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
|
||||
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
|
||||
|
||||
it.each([
|
||||
["auto", "zh", "cnb"],
|
||||
["auto", "zh-CN", "cnb"],
|
||||
["auto", "en", "github"],
|
||||
["auto", "ko", "github"],
|
||||
[undefined, "zh", "cnb"],
|
||||
["github", "zh", "github"],
|
||||
["cnb", "en", "cnb"],
|
||||
["bogus", "en", "github"],
|
||||
])("%s with the %s UI uses %s", (setting, locale, expected) => {
|
||||
withSettings(setting, locale);
|
||||
expect(updateSource()).toBe(expected);
|
||||
});
|
||||
|
||||
it("is passed to the check and the install", async () => {
|
||||
withSettings("cnb", "en");
|
||||
void checkForUpdates();
|
||||
expect(appCheckUpdate).toHaveBeenCalledWith("cnb");
|
||||
check.resolve(ok({ enabled: true, update: null }));
|
||||
void installUpdate("0.1.22");
|
||||
expect(appInstallUpdate.mock.calls.at(-1)![0]).toBe("cnb");
|
||||
install.resolve(ok(null));
|
||||
await tick();
|
||||
});
|
||||
});
|
||||
|
||||
describe("progressText", () => {
|
||||
it("formats known and unknown lengths", () => {
|
||||
expect(progressText({ type: "downloading", downloaded: 1024 * 1024, total: null })).toEqual({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* repeated click joins the running check or install instead of starting another.
|
||||
*/
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { resolveLocale, t } from "@/platform/i18n";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
nextRequestId,
|
||||
type UpdateInfo,
|
||||
type UpdateProgress,
|
||||
type UpdateSource,
|
||||
} from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -30,6 +31,39 @@ function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** System Settings › Updates › Update source; `auto` picks the CNB mirror for the Chinese UI. */
|
||||
export function updateSource(): UpdateSource {
|
||||
const { settings } = useProject.getState();
|
||||
const chosen = settings?.general?.updateSource;
|
||||
if (chosen === "github" || chosen === "cnb") return chosen;
|
||||
return resolveLocale(settings?.appearance?.locale) === "zh" ? "cnb" : "github";
|
||||
}
|
||||
|
||||
const SOURCE_NAMES: Record<UpdateSource, string> = { github: "GitHub", cnb: "CNB" };
|
||||
|
||||
/** Makes `source` the update source; the retry it precedes uses it even if saving fails. */
|
||||
async function chooseSource(source: UpdateSource) {
|
||||
try {
|
||||
useProject.setState({
|
||||
settings: await call(commands.settingsSet("global", "general.updateSource", JSON.stringify(source))),
|
||||
});
|
||||
} catch {
|
||||
/* the choice still applies to this retry */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offered once a download failed or was cancelled (likely too slow): the same install from
|
||||
* the other source. A check never needs it, since it already falls back to the other source.
|
||||
*/
|
||||
function otherSourceAction(version: string, source: UpdateSource) {
|
||||
const other: UpdateSource = source === "cnb" ? "github" : "cnb";
|
||||
return {
|
||||
label: t("app.update.retryWith", { source: SOURCE_NAMES[other] }),
|
||||
run: () => void chooseSource(other).then(() => installUpdate(version, other)),
|
||||
};
|
||||
}
|
||||
|
||||
let checking: Promise<void> | null = null;
|
||||
let installing: Promise<void> | null = null;
|
||||
|
||||
@@ -56,7 +90,7 @@ async function runCheck() {
|
||||
key: UPDATE_KEY,
|
||||
});
|
||||
try {
|
||||
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
|
||||
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
|
||||
if (r === "cancelled") return;
|
||||
if (!r.enabled) {
|
||||
notifications().notify({
|
||||
@@ -85,7 +119,7 @@ async function runCheck() {
|
||||
/** Silent check once per app start; only an available update is shown. */
|
||||
export async function checkForUpdatesOnStartup(): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
const update = await commands.appStartupUpdateCheck().catch(() => null);
|
||||
const update = await commands.appStartupUpdateCheck(updateSource()).catch(() => null);
|
||||
if (update) notifyUpdate(update);
|
||||
}
|
||||
|
||||
@@ -118,13 +152,13 @@ export function progressText(p: UpdateProgress): { message: string; fraction: nu
|
||||
}
|
||||
|
||||
/** Downloads and installs `version`, then restarts. A second call while one runs joins it. */
|
||||
export function installUpdate(version: string): Promise<void> {
|
||||
export function installUpdate(version: string, source = updateSource()): Promise<void> {
|
||||
if (!isTauri()) return Promise.resolve();
|
||||
installing ??= runInstall(version).finally(() => (installing = null));
|
||||
installing ??= runInstall(version, source).finally(() => (installing = null));
|
||||
return installing;
|
||||
}
|
||||
|
||||
async function runInstall(version: string) {
|
||||
async function runInstall(version: string, source: UpdateSource) {
|
||||
const title = t("app.update.downloading", { version });
|
||||
const request = nextRequestId();
|
||||
const cancel = () => void commands.cancelRequest(request).catch(() => {});
|
||||
@@ -160,17 +194,25 @@ async function runInstall(version: string) {
|
||||
// Replaces the notification whose action is now spent.
|
||||
balloon([{ label: t("app.update.cancel"), run: cancel }]);
|
||||
try {
|
||||
await call(commands.appInstallUpdate(request, channel));
|
||||
await call(commands.appInstallUpdate(source, request, channel));
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) {
|
||||
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: t("app.update.cancelled"),
|
||||
key: UPDATE_KEY,
|
||||
actions: [otherSourceAction(version, source)],
|
||||
});
|
||||
} else {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.update.installFailed"),
|
||||
body: errorText(e),
|
||||
key: UPDATE_KEY,
|
||||
actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }],
|
||||
actions: [
|
||||
{ label: t("app.update.retry"), run: () => void installUpdate(version, source) },
|
||||
otherSourceAction(version, source),
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -50,8 +50,11 @@ export interface DateFormatValid {
|
||||
pretty: boolean;
|
||||
}
|
||||
|
||||
export type UpdateSourceSetting = "auto" | "github" | "cnb";
|
||||
|
||||
export interface GeneralExtras {
|
||||
openProjectIn: OpenProjectIn;
|
||||
updateSource: UpdateSourceSetting;
|
||||
defaultProjectDirectory: string;
|
||||
saveOnIdle: boolean;
|
||||
idleSeconds: number;
|
||||
@@ -100,6 +103,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
|
||||
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
|
||||
openProjectIn: "ask",
|
||||
updateSource: "auto",
|
||||
defaultProjectDirectory: "",
|
||||
saveOnIdle: false,
|
||||
idleSeconds: 15,
|
||||
@@ -160,6 +164,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
|
||||
const df = obj(g.dateFormat);
|
||||
return {
|
||||
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
|
||||
updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource),
|
||||
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
|
||||
saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle),
|
||||
idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type NotificationDisplay,
|
||||
type NotificationGroupValid,
|
||||
type OpenProjectIn,
|
||||
type UpdateSourceSetting,
|
||||
} from "../model/behaviorSchema";
|
||||
import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema";
|
||||
import type { PageSpec } from "./spec";
|
||||
@@ -381,6 +382,12 @@ const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
|
||||
{ value: "ask", label: "Ask" },
|
||||
];
|
||||
|
||||
const UPDATE_SOURCES: SelectOption<UpdateSourceSetting>[] = [
|
||||
{ value: "auto", label: "Automatic" },
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "cnb", label: "CNB (China mirror)" },
|
||||
];
|
||||
|
||||
function OpenProjectInRow() {
|
||||
const [value, set] = useSetting<OpenProjectIn>("general.openProjectIn");
|
||||
const name = useId();
|
||||
@@ -724,6 +731,18 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Updates",
|
||||
options: [
|
||||
{
|
||||
kind: "select",
|
||||
key: "general.updateSource",
|
||||
label: "Update source",
|
||||
hint: "Where updates are downloaded from. If it can't be reached, the other source is tried.",
|
||||
options: UPDATE_SOURCES,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Exit",
|
||||
options: [{ kind: "toggle", key: "general.confirmExit", label: "Confirm before exiting the IDE" }],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { FontPicker } from "../components/FontPicker";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { quotePath } from "@/platform/commandLine";
|
||||
import { commands, isTauri } from "@/platform/ipc";
|
||||
import {
|
||||
DEFAULT_EXTERNAL_TOOL,
|
||||
@@ -110,7 +111,7 @@ function ShellPathRow() {
|
||||
const { open: pick } = await import("@tauri-apps/plugin-dialog");
|
||||
const picked = await pick({ multiple: false, title: "Shell Path" });
|
||||
const path = Array.isArray(picked) ? picked[0] : picked;
|
||||
if (path) set(path);
|
||||
if (path) set(quotePath(path));
|
||||
};
|
||||
return (
|
||||
<Row
|
||||
@@ -142,7 +143,7 @@ function ShellPathRow() {
|
||||
anchor={menuRef}
|
||||
placement="bottom-end"
|
||||
aria-label="Installed shells"
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(p) }))}
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
|
||||
@@ -16,9 +16,9 @@ import { installTerminalPaste, pasteTerminalClipboard } from "./paste";
|
||||
import { ImeCommitGate } from "./imeCommit";
|
||||
import { ownTerminalInput } from "./imeInput";
|
||||
import { findTerminalLinks, resolvePath, type TerminalLink } from "./links";
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { parseShellCommand } from "@/platform/commandLine";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { hostOs, useRemote } from "@/platform/remote";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
@@ -530,7 +530,7 @@ export class TerminalSession {
|
||||
};
|
||||
try {
|
||||
const t = workflowSettings().terminal;
|
||||
const [shellPath, ...shellArgs] = splitCommandLine(t.shellPath);
|
||||
const [shellPath, ...shellArgs] = parseShellCommand(t.shellPath, hostOs() === "windows");
|
||||
const shell = custom ? custom.program : shellPath;
|
||||
const args = custom ? custom.args : shellArgs;
|
||||
const info = await call(
|
||||
|
||||
@@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"general.reopenLastProject",
|
||||
"general.confirmExit",
|
||||
"general.openProjectIn",
|
||||
"general.updateSource",
|
||||
"general.dateFormat",
|
||||
"editor.fontFamily",
|
||||
"editor.fontSize",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseShellCommand, quotePath } from "./commandLine";
|
||||
|
||||
describe("parseShellCommand", () => {
|
||||
it("keeps Windows paths as typed", () => {
|
||||
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe`, true)).toEqual([
|
||||
String.raw`C:\Windows\system32\cmd.exe`,
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`C:\Windows\system32\cmd.exe /k C:\tools\init.bat`, true)).toEqual([
|
||||
String.raw`C:\Windows\system32\cmd.exe`,
|
||||
"/k",
|
||||
String.raw`C:\tools\init.bat`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("takes an unquoted Windows path with spaces up to its executable", () => {
|
||||
expect(parseShellCommand(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`, true)).toEqual([
|
||||
String.raw`C:\Program Files\PowerShell\7\pwsh.exe`,
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`C:\Program Files\Git\bin\bash.EXE --login -i`, true)).toEqual([
|
||||
String.raw`C:\Program Files\Git\bin\bash.EXE`,
|
||||
"--login",
|
||||
"-i",
|
||||
]);
|
||||
});
|
||||
|
||||
it("honours quotes", () => {
|
||||
expect(parseShellCommand(String.raw`"C:\Program Files\Git\bin\bash.exe" --login`, true)).toEqual([
|
||||
String.raw`C:\Program Files\Git\bin\bash.exe`,
|
||||
"--login",
|
||||
]);
|
||||
expect(parseShellCommand(`/bin/zsh --login`, false)).toEqual(["/bin/zsh", "--login"]);
|
||||
expect(parseShellCommand(`"/Applications/My Shell/bin/fish" -l`, false)).toEqual([
|
||||
"/Applications/My Shell/bin/fish",
|
||||
"-l",
|
||||
]);
|
||||
expect(parseShellCommand(String.raw`/bin/sh -c a\ b`, false)).toEqual(["/bin/sh", "-c", "a b"]);
|
||||
});
|
||||
|
||||
it("is empty for an empty setting", () => {
|
||||
expect(parseShellCommand(" ", true)).toEqual([]);
|
||||
expect(parseShellCommand("", false)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("quotePath", () => {
|
||||
it("quotes only paths with whitespace", () => {
|
||||
expect(quotePath(String.raw`C:\Windows\system32\cmd.exe`)).toBe(String.raw`C:\Windows\system32\cmd.exe`);
|
||||
expect(quotePath(String.raw`C:\Program Files\PowerShell\7\pwsh.exe`)).toBe(
|
||||
String.raw`"C:\Program Files\PowerShell\7\pwsh.exe"`,
|
||||
);
|
||||
expect(parseShellCommand(quotePath("/Applications/My Shell/fish"), false)).toEqual(["/Applications/My Shell/fish"]);
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,28 @@ export function parseArgs(input: string, windowsPaths = false): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The program and arguments of a shell command line (Settings › Terminal › Shell path). On
|
||||
* Windows a backslash is a path separator, and an unquoted path up to its `.exe` / `.cmd` /
|
||||
* `.bat` / `.com` is the program even with spaces in it (`C:\Program Files\…\pwsh.exe -NoLogo`).
|
||||
*/
|
||||
export function parseShellCommand(line: string, windows: boolean): string[] {
|
||||
const text = line.trim();
|
||||
if (windows && !/["']/.test(text)) {
|
||||
const end = /\.(?:exe|cmd|bat|com)(?=\s|$)/i.exec(text);
|
||||
if (end) {
|
||||
const stop = end.index + end[0].length;
|
||||
return [text.slice(0, stop), ...parseArgs(text.slice(stop), true)];
|
||||
}
|
||||
}
|
||||
return parseArgs(text, windows);
|
||||
}
|
||||
|
||||
/** `path` as one word of a command line: quoted when it has whitespace. */
|
||||
export function quotePath(path: string): string {
|
||||
return /\s/.test(path) ? `"${path}"` : path;
|
||||
}
|
||||
|
||||
/** Inverse of `parseArgs` (quotes only when needed). */
|
||||
export function formatArgs(args: readonly string[]): string {
|
||||
return args.map((a) => (a === "" ? "''" : /[\s'"\\$`]/.test(a) ? `'${a.replace(/'/g, `'\\''`)}'` : a)).join(" ");
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
"app.update.restart": "Restart",
|
||||
"app.update.retry": "Retry",
|
||||
"app.update.retryWith": "Retry with {source}",
|
||||
"app.window.desktopOnly": "Multiple windows require the desktop app",
|
||||
"app.window.openFailed": "Cannot open {name}",
|
||||
"app.window.pickFileTitle": "Open File",
|
||||
|
||||
@@ -37,5 +37,11 @@
|
||||
"e.g. /var/www/html": "e.g. /var/www/html",
|
||||
"Listening for Xdebug on port {port}": "Listening for Xdebug on port {port}",
|
||||
"Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.": "Port {port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.",
|
||||
"Unable to load image: {path}": "Unable to load image: {path}"
|
||||
"Unable to load image: {path}": "Unable to load image: {path}",
|
||||
"Updates": "Updates",
|
||||
"Update source": "Update source",
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "Where updates are downloaded from. If it can't be reached, the other source is tried.",
|
||||
"Automatic": "Automatic",
|
||||
"CNB (China mirror)": "CNB (China mirror)",
|
||||
"Remote connection cancelled": "Remote connection cancelled"
|
||||
}
|
||||
@@ -6377,5 +6377,10 @@
|
||||
"Force push anyway": "仍然强制推送",
|
||||
"Stage and Retry": "暂存并重试",
|
||||
"Show Files": "显示文件",
|
||||
"Unable to load image: {path}": "无法加载图片:{path}"
|
||||
"Unable to load image: {path}": "无法加载图片:{path}",
|
||||
"Updates": "更新",
|
||||
"Update source": "更新源",
|
||||
"Where updates are downloaded from. If it can't be reached, the other source is tried.": "从哪里下载更新。无法连接时会改用另一个源。",
|
||||
"CNB (China mirror)": "CNB(国内镜像)",
|
||||
"Remote connection cancelled": "远程连接已取消"
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"oxfmt",
|
||||
"Ruff",
|
||||
"IntelliJ IDEA",
|
||||
"GitHub",
|
||||
"PHPStan",
|
||||
// Docker run target placeholder values
|
||||
"compose.yaml",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
"app.update.restart": "重启",
|
||||
"app.update.retry": "重试",
|
||||
"app.update.retryWith": "改用 {source} 重试",
|
||||
"app.window.desktopOnly": "多窗口需要桌面应用",
|
||||
"app.window.openFailed": "无法打开 {name}",
|
||||
"app.window.pickFileTitle": "打开文件",
|
||||
|
||||
Reference in new issue
Block a user