mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b4d53f2d8 |
No files matched your search
@@ -268,8 +268,6 @@ 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 {
|
||||
@@ -284,7 +282,6 @@ impl Default for GeneralSettings {
|
||||
date_format: DateFormatSettings::default(),
|
||||
power_save_mode: false,
|
||||
project_name: String::new(),
|
||||
update_source: "auto".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,18 +196,10 @@ 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::split_paths(&ide_core::tool_path::search_path()));
|
||||
dirs.extend(std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default());
|
||||
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"]
|
||||
{
|
||||
@@ -472,9 +464,6 @@ 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)))
|
||||
@@ -603,21 +592,6 @@ 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();
|
||||
|
||||
+2
-3
@@ -69,8 +69,7 @@ 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" });
|
||||
// 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);
|
||||
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
|
||||
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
@@ -8,11 +8,12 @@ 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_commit` takes the files it commits out of the status. */
|
||||
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones 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}`,
|
||||
@@ -32,10 +33,14 @@ async function stubTwentyChanges(page: Page) {
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
|
||||
h.vcs_stage = () => null;
|
||||
h.vcs_stage = (args) => {
|
||||
for (const path of args.paths as string[]) staged.add(path);
|
||||
return null;
|
||||
};
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = (args) => {
|
||||
for (const path of (args.options as { stage: string[] }).stage) committed.add(path);
|
||||
h.vcs_commit = () => {
|
||||
for (const path of staged) committed.add(path);
|
||||
staged.clear();
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
@@ -66,8 +71,7 @@ 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();
|
||||
const [commit] = await ipcCalls(page, "vcs_commit");
|
||||
expect([...(commit!.options as { stage: string[] }).stage].sort()).toEqual(
|
||||
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
|
||||
NAMES.slice(0, 5)
|
||||
.map((rel) => `${ROOT}/${rel}`)
|
||||
.sort(),
|
||||
|
||||
@@ -330,74 +330,36 @@ 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 {
|
||||
!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()
|
||||
let in_config = app
|
||||
.config()
|
||||
.plugins
|
||||
.0
|
||||
.get("updater")
|
||||
.and_then(|u| u.get("endpoints"))
|
||||
.and_then(|e| e.as_array())
|
||||
.map(|e| e.iter().filter_map(|u| u.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default()
|
||||
.is_some_and(|e| !e.is_empty());
|
||||
in_config || update_endpoint_env().is_some()
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
fn updater(app: &AppHandle) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
if !updates_configured(app) {
|
||||
return Ok(None);
|
||||
}
|
||||
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 env = update_endpoint_env();
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
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()))
|
||||
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()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app, source)? else {
|
||||
pub async fn app_check_update(app: AppHandle) -> IdeResult<UpdateCheck> {
|
||||
let Some(updater) = updater(&app)? else {
|
||||
return Ok(UpdateCheck { enabled: false, update: None });
|
||||
};
|
||||
let update = updater.check().await.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
@@ -417,12 +379,12 @@ pub async fn app_check_update(app: AppHandle, source: UpdateSource) -> IdeResult
|
||||
/// not reported: the user didn't ask for this check.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_startup_update_check(app: AppHandle, source: UpdateSource) -> Option<UpdateInfo> {
|
||||
pub async fn app_startup_update_check(app: AppHandle) -> Option<UpdateInfo> {
|
||||
static DONE: AtomicBool = AtomicBool::new(false);
|
||||
if cfg!(debug_assertions) || DONE.swap(true, Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
match app_check_update(app, source).await {
|
||||
match app_check_update(app).await {
|
||||
Ok(check) => check.update,
|
||||
Err(e) => {
|
||||
tracing::warn!("startup update check failed: {e}");
|
||||
@@ -490,7 +452,6 @@ impl Drop for InstallingGuard {
|
||||
pub async fn app_install_update(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
source: UpdateSource,
|
||||
request: RequestId,
|
||||
on_progress: Channel<UpdateProgress>,
|
||||
) -> IdeResult<()> {
|
||||
@@ -498,7 +459,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, source)?.ok_or_else(|| IdeError::unavailable("updates are not configured"))?;
|
||||
let updater = updater(&app)?.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()),
|
||||
@@ -561,15 +522,6 @@ 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");
|
||||
|
||||
+4
-13
@@ -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: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
|
||||
appCheckUpdate: () => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update")),
|
||||
/**
|
||||
* 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: (source: UpdateSource) => __TAURI_INVOKE<{
|
||||
appStartupUpdateCheck: () => __TAURI_INVOKE<{
|
||||
version: string,
|
||||
currentVersion: string,
|
||||
notes: string | null,
|
||||
date: string | null,
|
||||
} | null>("app_startup_update_check", { source }),
|
||||
} | null>("app_startup_update_check"),
|
||||
/**
|
||||
* 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: (source: UpdateSource, request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { source, request, onProgress })),
|
||||
appInstallUpdate: (request: RequestId, onProgress: Channel<UpdateProgress>) => typedError<null, IdeError>(__TAURI_INVOKE("app_install_update", { 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,8 +3866,6 @@ 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). */
|
||||
@@ -7600,13 +7598,6 @@ 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 { Settings, UpdateCheck, UpdateProgress } from "@/bindings";
|
||||
import type { 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((_source: string, _request: number, ch: Channel) => {
|
||||
const appInstallUpdate = vi.fn((_request: number, ch: Channel) => {
|
||||
channel = ch;
|
||||
return install.promise;
|
||||
});
|
||||
@@ -28,35 +28,18 @@ 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,
|
||||
settingsSet,
|
||||
},
|
||||
commands: { ...actual.commands, appInstallUpdate, appCheckUpdate, appRestart, cancelRequest, appBuildInfo },
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
notifyUpdate,
|
||||
progressText,
|
||||
updateSource,
|
||||
CHECK_TASK_ID,
|
||||
INSTALL_TASK_ID,
|
||||
UPDATE_KEY,
|
||||
} = await import("../updates");
|
||||
const { checkForUpdates, installUpdate, notifyUpdate, progressText, 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");
|
||||
@@ -77,7 +60,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, settings: null });
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -145,33 +128,12 @@ 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)![1]);
|
||||
expect(cancelRequest).toHaveBeenCalledWith(appInstallUpdate.mock.calls.at(-1)![0]);
|
||||
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", () => {
|
||||
@@ -222,36 +184,6 @@ 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 { resolveLocale, t } from "@/platform/i18n";
|
||||
import { t } from "@/platform/i18n";
|
||||
import {
|
||||
call,
|
||||
commands,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
nextRequestId,
|
||||
type UpdateInfo,
|
||||
type UpdateProgress,
|
||||
type UpdateSource,
|
||||
} from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
@@ -31,39 +30,6 @@ 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;
|
||||
|
||||
@@ -90,7 +56,7 @@ async function runCheck() {
|
||||
key: UPDATE_KEY,
|
||||
});
|
||||
try {
|
||||
const r = await Promise.race([call(commands.appCheckUpdate(updateSource())), abandoned]);
|
||||
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
|
||||
if (r === "cancelled") return;
|
||||
if (!r.enabled) {
|
||||
notifications().notify({
|
||||
@@ -119,7 +85,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(updateSource()).catch(() => null);
|
||||
const update = await commands.appStartupUpdateCheck().catch(() => null);
|
||||
if (update) notifyUpdate(update);
|
||||
}
|
||||
|
||||
@@ -152,13 +118,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, source = updateSource()): Promise<void> {
|
||||
export function installUpdate(version: string): Promise<void> {
|
||||
if (!isTauri()) return Promise.resolve();
|
||||
installing ??= runInstall(version, source).finally(() => (installing = null));
|
||||
installing ??= runInstall(version).finally(() => (installing = null));
|
||||
return installing;
|
||||
}
|
||||
|
||||
async function runInstall(version: string, source: UpdateSource) {
|
||||
async function runInstall(version: string) {
|
||||
const title = t("app.update.downloading", { version });
|
||||
const request = nextRequestId();
|
||||
const cancel = () => void commands.cancelRequest(request).catch(() => {});
|
||||
@@ -194,25 +160,17 @@ async function runInstall(version: string, source: UpdateSource) {
|
||||
// Replaces the notification whose action is now spent.
|
||||
balloon([{ label: t("app.update.cancel"), run: cancel }]);
|
||||
try {
|
||||
await call(commands.appInstallUpdate(source, request, channel));
|
||||
await call(commands.appInstallUpdate(request, channel));
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: t("app.update.cancelled"),
|
||||
key: UPDATE_KEY,
|
||||
actions: [otherSourceAction(version, source)],
|
||||
});
|
||||
notifications().notify({ level: "info", title: t("app.update.cancelled"), key: UPDATE_KEY });
|
||||
} 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, source) },
|
||||
otherSourceAction(version, source),
|
||||
],
|
||||
actions: [{ label: t("app.update.retry"), run: () => void installUpdate(version) }],
|
||||
});
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -50,11 +50,8 @@ export interface DateFormatValid {
|
||||
pretty: boolean;
|
||||
}
|
||||
|
||||
export type UpdateSourceSetting = "auto" | "github" | "cnb";
|
||||
|
||||
export interface GeneralExtras {
|
||||
openProjectIn: OpenProjectIn;
|
||||
updateSource: UpdateSourceSetting;
|
||||
defaultProjectDirectory: string;
|
||||
saveOnIdle: boolean;
|
||||
idleSeconds: number;
|
||||
@@ -103,7 +100,6 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = {
|
||||
|
||||
export const DEFAULT_GENERAL_EXTRAS: GeneralExtras = {
|
||||
openProjectIn: "ask",
|
||||
updateSource: "auto",
|
||||
defaultProjectDirectory: "",
|
||||
saveOnIdle: false,
|
||||
idleSeconds: 15,
|
||||
@@ -164,7 +160,6 @@ 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,7 +26,6 @@ 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";
|
||||
@@ -382,12 +381,6 @@ 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();
|
||||
@@ -731,18 +724,6 @@ 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,7 +7,6 @@
|
||||
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,
|
||||
@@ -111,7 +110,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(quotePath(path));
|
||||
if (path) set(path);
|
||||
};
|
||||
return (
|
||||
<Row
|
||||
@@ -143,7 +142,7 @@ function ShellPathRow() {
|
||||
anchor={menuRef}
|
||||
placement="bottom-end"
|
||||
aria-label="Installed shells"
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: quotePath(p) === value, run: () => set(quotePath(p)) }))}
|
||||
items={shells.map((p) => ({ id: p, label: p, checked: p === value, run: () => set(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 { parseShellCommand } from "@/platform/commandLine";
|
||||
import { splitCommandLine } from "@/platform/commandLine";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { hostOs, useRemote } from "@/platform/remote";
|
||||
import { 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] = parseShellCommand(t.shellPath, hostOs() === "windows");
|
||||
const [shellPath, ...shellArgs] = splitCommandLine(t.shellPath);
|
||||
const shell = custom ? custom.program : shellPath;
|
||||
const args = custom ? custom.args : shellArgs;
|
||||
const info = await call(
|
||||
|
||||
@@ -11,7 +11,6 @@ export const CLIENT_SETTINGS_KEYS = [
|
||||
"general.reopenLastProject",
|
||||
"general.confirmExit",
|
||||
"general.openProjectIn",
|
||||
"general.updateSource",
|
||||
"general.dateFormat",
|
||||
"editor.fontFamily",
|
||||
"editor.fontSize",
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
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,28 +67,6 @@ 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,7 +81,6 @@
|
||||
"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,11 +37,5 @@
|
||||
"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}",
|
||||
"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"
|
||||
"Unable to load image: {path}": "Unable to load image: {path}"
|
||||
}
|
||||
@@ -6377,10 +6377,5 @@
|
||||
"Force push anyway": "仍然强制推送",
|
||||
"Stage and Retry": "暂存并重试",
|
||||
"Show Files": "显示文件",
|
||||
"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": "远程连接已取消"
|
||||
"Unable to load image: {path}": "无法加载图片:{path}"
|
||||
}
|
||||
@@ -94,7 +94,6 @@ const UNTRANSLATED = new Set<string>([
|
||||
"oxfmt",
|
||||
"Ruff",
|
||||
"IntelliJ IDEA",
|
||||
"GitHub",
|
||||
"PHPStan",
|
||||
// Docker run target placeholder values
|
||||
"compose.yaml",
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
"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