Compare commits

...
Author SHA1 Message Date
l0ng-ai 961344c0f9 fix(debugger): run debug adapters on the tool search path
Debug adapters were found on the tool search path but ran with the
app's own PATH. When the login shell's PATH didn't reach the app, what an
adapter starts in turn was missing although Run found it: js-debug's
wrapper failed with "exec: node: not found", and Delve's go build with
"exec: go: executable file not found in $PATH". Adapters now get the
search path language servers get, unless the launch sets its own PATH
(a selected SDK's bin first), and adapter binaries are resolved on it.

Fixes #1690
Fixes #1707
2026-11-06 14:40:08 +08:00
l0ng-ai f76c297fe9 fix(terminal): keep Windows shell paths intact (#1723)
The terminal's Shell path setting was split with POSIX rules, where a backslash escapes the next character, so C:\Windows\system32\cmd.exe reached the backend as C:Windowssystem32cmd.exe and every new terminal failed to start. On Windows a backslash is now a path separator, and an unquoted path up to its .exe/.cmd/.bat/.com is the program even with spaces; paths picked from the Installed Shells menu or Browse are quoted when they contain whitespace. This was the only place the setting is parsed.

Fixes #1327
2026-11-06 14:24:59 +08:00
l0ng-ai c75a630ede test(vcs): check the files a commit takes from its options
Commits take the checked files through vcs_commit's options instead of
staging them first; the Commit e2e tests now read them from there. Also
translates "Remote connection cancelled".
2026-11-06 14:14:44 +08:00
l0ng-ai 266e4eeb03 feat(updates): choose the update source, with a CNB mirror (#1673)
* feat(updates): choose the update source, with a CNB mirror

System Settings › Updates › Update source: Automatic, GitHub or CNB (China
mirror). Automatic uses CNB for the Chinese UI and GitHub otherwise. The
chosen source is tried first and the other one when it fails or has no
manifest, for the check, the startup check and the install.

* feat(updates): offer the other update source after a failed download

A failed or cancelled update download now offers "Retry with CNB" (or
"Retry with GitHub"): it switches the update source and downloads again
from there. Checks need no such action: they already try both sources.

* fix(updates): keep GitHub untranslated in the text catalog
2026-11-06 13:30:19 +08:00
l0ng-ai c1aa092d75 fix(tools): install GitHub releases when the API is rate-limited (#1715)
Language servers and debuggers published as GitHub releases were looked up only through api.github.com, which allows 60 unauthenticated requests an hour per IP address; behind a shared address installs failed with 403. When the API request fails, the release is now resolved from its web pages (latest-release redirect for the tag, the asset list for files and SHA-256 digests, the release notes for linked builds). This covers every GitHub-sourced tool, not only Kotlin. If both fail, the error names the release page and the Language Servers Path setting for a manual install.

Fixes #1651
2026-11-06 13:24:59 +08:00
23 changed files with 618 additions and 77 deletions

No files matched your search

+3
View File
@@ -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(),
}
}
}
+27 -1
View File
@@ -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();
+41 -4
View File
@@ -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(&notes), assets, self.platform)
}
result => result,
}
}
fn install_release(
&self,
spec: &ToolSpec,
+27 -5
View File
@@ -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
View File
@@ -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("&quot;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;").replace('>', "&gt;")
}
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&amp;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
View File
@@ -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();
});
+6 -10
View File
@@ -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(),
+65 -17
View File
@@ -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
View File
@@ -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,
+75 -7
View File
@@ -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({
+51 -9
View File
@@ -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),
+19
View File
@@ -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" }],
+3 -2
View File
@@ -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"
+3 -3
View File
@@ -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(
+1
View File
@@ -11,6 +11,7 @@ export const CLIENT_SETTINGS_KEYS = [
"general.reopenLastProject",
"general.confirmExit",
"general.openProjectIn",
"general.updateSource",
"general.dateFormat",
"editor.fontFamily",
"editor.fontSize",
+54
View File
@@ -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"]);
});
});
+22
View File
@@ -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(" ");
+1
View File
@@ -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",
+7 -1
View 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"
}
+6 -1
View File
@@ -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": "远程连接已取消"
}
+1
View File
@@ -94,6 +94,7 @@ const UNTRANSLATED = new Set<string>([
"oxfmt",
"Ruff",
"IntelliJ IDEA",
"GitHub",
"PHPStan",
// Docker run target placeholder values
"compose.yaml",
+1
View File
@@ -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": "打开文件",