From 88f73630a63d0acbbce34cbb018ac46360e7729e Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 14 Nov 2026 03:38:51 +0800 Subject: [PATCH] feat(tools): offer updates for the language servers and debuggers the IDE installed (#3247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Languages & Frameworks › Language Servers looks for newer versions of the tools in the IDE's tools folder once per session and from its refresh button: release downloads resolve their latest release as an install does, npm packages ask npm view through the user's registry. A tool with a newer version gets an Update tag, the available version in its details and an Update button that installs it. --- crates/ide-host/src/ops/tools.rs | 13 ++- crates/ide-tools/src/lib.rs | 82 +++++++++++++++++++ crates/ide-tools/src/tests.rs | 23 ++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/tools.rs | 14 +++- src/bindings.ts | 14 ++++ .../tools/LanguageServersPage.module.css | 10 ++- src/features/tools/LanguageServersPage.tsx | 38 +++++++-- src/features/tools/__tests__/tools.test.tsx | 28 ++++++- src/features/tools/backend.ts | 11 ++- src/features/tools/store.ts | 33 +++++++- src/platform/locales/text/zh.json | 2 + 12 files changed, 256 insertions(+), 13 deletions(-) diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index d1e80491d..b372c84f9 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -6,7 +6,7 @@ use crate::rpc::Dispatcher; use ide_core::{IdeError, IdeResult, RequestId}; use ide_sdk::Sdk; use ide_tools::jdk::{JdkReleases, JdkVendor}; -use ide_tools::{InstallEvent, ToolInfo, Tools}; +use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools}; use std::path::PathBuf; /// The installer, reporting the executables chosen in the window's settings. @@ -89,6 +89,16 @@ pub async fn tools_install( Ok(info) } +/// Newer versions of the tools the IDE installed (release downloads and npm packages). +/// Cancel with `cancel_request(request)`. +pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult> { + let tools = tools(host, window)?; + let cancel = host.begin_request(request); + let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err); + host.end_request(request); + result? +} + /// Removes a tool the IDE installed. pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult { let tools = tools(host, window)?; @@ -185,6 +195,7 @@ pub(crate) fn register(d: &mut Dispatcher) { tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await }); d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await }); + d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await }); d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) }); d.add("jdk_download_releases", |c| async move { jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 91f7454df..726898059 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -104,6 +104,17 @@ pub enum InstallEvent { Progress { fraction: Option, message: String }, } +/// A newer version of a tool the IDE installed, as its install source offers it now. +#[derive(Clone, Debug, PartialEq, Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ToolUpdate { + pub name: String, + /// The installed version. + pub current: String, + /// The version installing the tool again would get. + pub latest: String, +} + #[derive(Serialize, Deserialize)] struct Manifest { version: Option, @@ -1437,6 +1448,77 @@ impl Tools { self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) }) } + /// Newer versions of the tools the IDE installed into its tools folder, for the install + /// sources that can be asked: release downloads (GitHub, Open VSX, Maven Central, …) and + /// npm packages. A tool whose lookup fails (offline, rate limited) is left out. + pub fn updates(&self, cancel: &CancellationToken) -> IdeResult> { + let installed: Vec<(&ToolSpec, Method, String)> = TOOLS + .iter() + .filter_map(|spec| { + let method = self.install_method(spec); + Some((spec, method, self.installed_version(spec, method)?)) + }) + .collect(); + let updates = std::thread::scope(|scope| { + let lookups: Vec<_> = installed + .iter() + .map(|(spec, method, current)| { + scope.spawn(move || match self.for_method(*method).latest_version(*method, cancel) { + Ok(Some(latest)) if latest != *current => { + Some(ToolUpdate { name: spec.name.into(), current: current.clone(), latest }) + } + Ok(_) => None, + Err(e) => { + tracing::info!("update check for {}: {}", spec.name, e.message); + None + } + }) + }) + .collect(); + lookups.into_iter().filter_map(|lookup| lookup.join().ok().flatten()).collect() + }); + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + Ok(updates) + } + + /// The version of `spec` in the IDE's tools folder, when `method` (how it was installed) + /// can tell the latest one. + fn installed_version(&self, spec: &ToolSpec, method: Method) -> Option { + match method { + Method::Release { .. } => { + managed_bin_dir(&self.root, spec, self.platform).and_then(|dir| self.exe_in(&dir, spec.commands[0]))?; + self.manifest(spec.name)?.version + } + // A pinned package (`typescript@6`) stays on its major version. + Method::Npm { packages } if npm_name(packages[0]) == packages[0] => self.npm_package_version(packages[0]), + _ => None, + } + } + + /// The version installing with `method` would get now. + fn latest_version(&self, method: Method, cancel: &CancellationToken) -> IdeResult> { + match method { + Method::Release { source, assets, .. } => { + let http = Http::new(&self.system_path)?; + Ok(Some(self.resolve_release(&http, source, assets, cancel)?.version)) + } + Method::Npm { packages } => { + let (manager, _) = self.npm_manager()?; + let mut c = self.command(manager); + // The registry the user's npm configuration names (a mirror, a proxy). A cold npm + // on a slow registry takes several seconds per package, more when run side by side. + c.args(["view", packages[0], "version"]).current_dir(&self.root); + match run_quiet(c, Duration::from_secs(60)) { + Some((true, out)) => Ok(out.lines().map(str::trim).rfind(|l| !l.is_empty()).map(String::from)), + _ => Err(IdeError::unavailable(format!("cannot look up the latest {}", packages[0]))), + } + } + _ => Ok(None), + } + } + /// Removes a tool the IDE installed. pub fn uninstall(&self, name: &str, langs: &[LanguageConfig]) -> IdeResult { match registry::find(name) { diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index 766962b8a..474db79f4 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -935,3 +935,26 @@ fn a_cancelled_install_leaves_the_previous_one_in_place() { assert!(dest.join("new").exists() && !dest.join("old").exists()); assert!(aside.join("old").exists(), "the previous install is moved aside"); } + +/// An npm tool the IDE installed is offered the version the user's npm registry has now. +#[cfg(unix)] +#[test] +fn npm_tools_report_a_newer_registry_version() { + let env = Env::new(); + let sdk = env._tmp.path().join("node-sdk/bin"); + let modules = env.root.join("node_modules"); + env.script(&modules.join(".bin"), "bash-language-server", "echo 5.4.0"); + fs::create_dir_all(modules.join("bash-language-server")).unwrap(); + fs::write(modules.join("bash-language-server/package.json"), r#"{"version":"5.4.0"}"#).unwrap(); + env.script(&sdk, "node", "exit 0"); + let tools = |latest: &str| { + env.script(&sdk, "npm", &format!(r#"[ "$1 $2 $3" = "view bash-language-server version" ] && echo {latest}"#)); + env.tools().with_runtime(BTreeMap::from([("node".into(), sdk.join("node"))]), BTreeMap::new()) + }; + let cancel = CancellationToken::new(); + assert_eq!( + tools("5.6.1").updates(&cancel).unwrap(), + vec![ToolUpdate { name: "bash-language-server".into(), current: "5.4.0".into(), latest: "5.6.1".into() }] + ); + assert_eq!(tools("5.4.0").updates(&cancel).unwrap(), vec![]); +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3220c9864..f8e85cd6b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -458,6 +458,7 @@ pub fn builder() -> tauri_specta::Builder { tools::tools_list, tools::tools_install, tools::tools_uninstall, + tools::tools_updates, tools::jdk_download_vendors, tools::jdk_download_releases, tools::jdk_download_location, diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index 8da00d742..c02d939d1 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -6,7 +6,7 @@ use ide_core::{IdeResult, RequestId}; pub use ide_host::ops::tools::*; use ide_sdk::Sdk; use ide_tools::jdk::{JdkReleases, JdkVendor}; -use ide_tools::{InstallEvent, ToolInfo}; +use ide_tools::{InstallEvent, ToolInfo, ToolUpdate}; use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; @@ -33,6 +33,18 @@ pub async fn tools_install( .await } +/// Newer versions of the tools the IDE installed (release downloads and npm packages). +/// Cancel with `cancel_request(request)`. +#[tauri::command] +#[specta::specta] +pub async fn tools_updates( + window: Window, + state: State<'_, AppState>, + request: RequestId, +) -> IdeResult> { + ide_host::ops::tools::tools_updates(&state.host, window.label(), request).await +} + /// Removes a tool the IDE installed. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 9523967ba..442de8551 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -966,6 +966,11 @@ export const commands = { toolsInstall: (name: string, request: RequestId, onEvent: Channel) => typedError(__TAURI_INVOKE("tools_install", { name, request, onEvent })), /** Removes a tool the IDE installed. */ toolsUninstall: (name: string) => typedError(__TAURI_INVOKE("tools_uninstall", { name })), + /** + * Newer versions of the tools the IDE installed (release downloads and npm packages). + * Cancel with `cancel_request(request)`. + */ + toolsUpdates: (request: RequestId) => typedError(__TAURI_INVOKE("tools_updates", { request })), /** JDK distributions Download JDK offers. */ jdkDownloadVendors: () => __TAURI_INVOKE("jdk_download_vendors"), /** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */ @@ -10222,6 +10227,15 @@ export type ToolStatus = /** The executable chosen in settings. */ "custom" | "missing"; +/** A newer version of a tool the IDE installed, as its install source offers it now. */ +export type ToolUpdate = { + name: string, + /** The installed version. */ + current: string, + /** The version installing the tool again would get. */ + latest: string, +}; + export type TransferError = { path: string, message: string, diff --git a/src/features/tools/LanguageServersPage.module.css b/src/features/tools/LanguageServersPage.module.css index ab20b9d7d..7b15e5566 100644 --- a/src/features/tools/LanguageServersPage.module.css +++ b/src/features/tools/LanguageServersPage.module.css @@ -124,7 +124,8 @@ color: var(--fg-muted); } .rowName { - flex: none; + flex: 0 1 auto; + min-width: 0; max-width: 60%; overflow: hidden; color: var(--fg-strong); @@ -150,6 +151,13 @@ font-size: var(--fs-tiny); line-height: 16px; } +.rowBadge { + flex: none; +} +.updateTag { + background: color-mix(in srgb, var(--accent) 18%, transparent); + color: var(--accent); +} .listEmpty { padding: 8px 12px; color: var(--fg-muted); diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index cdc9957ae..2af707d6a 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -16,6 +16,7 @@ import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLangua import { serverKey, type ServerEntry } from "./serverConfig"; import { cancelInstall, + checkToolUpdates, installTool, refreshTools, STATUS_LABEL, @@ -98,12 +99,14 @@ function ToolRow({ selected, off, pending, + update, onSelect, }: { tool: ToolInfo; selected: boolean; off: boolean; pending: boolean; + update: boolean; onSelect: () => void; }) { return ( @@ -116,7 +119,7 @@ function ToolRow({ title={`${tool.displayName} — ${tool.languages.join(", ")}`} onClick={onSelect} > - + {pending ? tx("New") : STATUS_LABEL[tool.status]} {tool.displayName} @@ -135,6 +138,11 @@ function ToolRow({ Disabled )} + {update && ( + + Update + + )} {tool.languages.join(", ")} @@ -144,6 +152,7 @@ function ToolRow({ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) { const job = useTools((s) => s.jobs[tool.name]); const last = useTools((s) => s.logs[tool.name]); + const update = useTools((s) => (tool.status === "installed" ? s.updates[tool.name] : undefined)); const [logOpen, setLogOpen] = useState(false); const log = job?.lines ?? last?.lines ?? []; const lastError = !job && last?.error !== "Cancelled" ? last?.error : null; @@ -185,17 +194,21 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) { {tool.canInstall && ( )} {tool.canUninstall && ( @@ -205,6 +218,11 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) { )} + {update && !job && ( +
+ {tx`Version ${update.latest} is available.`} +
+ )} {job && } {lastError && (
@@ -244,7 +262,7 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) { } export function LanguageServersPage() { - const { tools, loaded, loading, error } = useTools(); + const { tools, loaded, loading, error, updates, checkingUpdates } = useTools(); const form = useSettingsForm(); const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); @@ -257,6 +275,8 @@ export function LanguageServersPage() { useEffect(() => { void refreshTools(); + // Asks each install source once per session; the refresh button asks again. + if (!useTools.getState().updatesChecked) void checkToolUpdates(); // Applied paths and servers change what is found and listed. return onIdeEvent("settingsChanged", (e) => { if (!e.key || /^(tools|languageServers)(\.|$)/.test(e.key)) void refreshTools(); @@ -307,6 +327,7 @@ export function LanguageServersPage() { selected={t === selected} off={servers[t.name]?.enabled === false} pending={pending.has(t.name)} + update={t.status === "installed" && !!updates[t.name]} onSelect={() => setSelectedName(t.name)} /> ))} @@ -337,14 +358,17 @@ export function LanguageServersPage() { onClick={() => selected && form.set(serverKey(selected.name), null)} /> - {loading ? ( + {loading || checkingUpdates ? ( ) : ( void refreshTools()} + onClick={() => { + void refreshTools(); + void checkToolUpdates(); + }} /> )} { provideNotificationService({ notify: (n) => void notes.push(n) }); backend = new MemoryToolsBackend(sampleTools(), 1); setToolsBackend(backend); - useTools.setState({ tools: [], loaded: false, loading: false, error: null, jobs: {}, logs: {} }); + useTools.setState({ + tools: [], + loaded: false, + loading: false, + error: null, + jobs: {}, + logs: {}, + updates: {}, + checkingUpdates: false, + updatesChecked: false, + }); }); afterEach(() => setToolsBackend(null)); @@ -264,6 +274,22 @@ describe("LanguageServersPage", () => { expect(screen.getByLabelText("gopls install log")).toHaveTextContent("Installing gopls"); }); + it("offers an update when a newer version of an installed tool is out", async () => { + const user = userEvent.setup(); + backend.available = [{ name: "rust-analyzer", current: "1.85.0", latest: "1.86.0" }]; + renderPage(); + await waitFor(() => expect(listItem("rust-analyzer")).toHaveTextContent("Update")); + expect(listItem("gopls")).not.toHaveTextContent("Update"); + await user.click(listItem("rust-analyzer")); + const details = screen.getByLabelText("rust-analyzer details"); + expect(details).toHaveTextContent("Version 1.86.0 is available."); + await user.click(within(details).getByRole("button", { name: "Update rust-analyzer" })); + await waitFor(() => expect(listItem("rust-analyzer")).not.toHaveTextContent("Update")); + expect( + within(screen.getByLabelText("rust-analyzer details")).getByRole("button", { name: "Reinstall rust-analyzer" }), + ).toBeInTheDocument(); + }); + it("explains why a tool cannot be installed", async () => { const user = userEvent.setup(); renderPage(); diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index 6e221373a..7616026e9 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -3,7 +3,7 @@ * simulation in plain-browser development and tests (no network, ever). */ import { Channel } from "@tauri-apps/api/core"; -import type { InstallEvent, RequestId, ToolInfo } from "@/bindings"; +import type { InstallEvent, RequestId, ToolInfo, ToolUpdate } from "@/bindings"; import { call, commands, IpcError, isTauri } from "@/platform/ipc"; export interface LanguageChoice { @@ -15,6 +15,8 @@ export interface ToolsBackend { list(): Promise; install(name: string, request: RequestId, onEvent: (e: InstallEvent) => void): Promise; uninstall(name: string): Promise; + /** Newer versions of the tools the IDE installed (release downloads and npm packages). */ + updates(request: RequestId): Promise; cancel(request: RequestId): void; /** Restarts the language servers named `name` in every open project (after an install). */ restartServers(name: string): Promise; @@ -32,6 +34,7 @@ export const tauriToolsBackend: ToolsBackend = { return call(commands.toolsInstall(name, request, channel)); }, uninstall: (name) => call(commands.toolsUninstall(name)), + updates: (request) => call(commands.toolsUpdates(request)), cancel: (request) => void commands.cancelRequest(request).catch(() => {}), // Every open project: Settings belongs to one project window, but all of them run the tool. restartServers: async (name) => { @@ -150,6 +153,8 @@ export class MemoryToolsBackend implements ToolsBackend { /** Delay between simulated steps (ms). */ stepMs: number; failNext: string | null = null; + /** What `updates` reports, for the tools that are installed. */ + available: ToolUpdate[] = []; private cancelled = new Set(); constructor(tools: ToolInfo[] = sampleTools(), stepMs = 120) { @@ -161,6 +166,10 @@ export class MemoryToolsBackend implements ToolsBackend { return this.tools.map((t) => ({ ...t })); } + async updates() { + return this.available.filter((u) => this.tools.some((t) => t.name === u.name && t.status === "installed")); + } + private replace(next: ToolInfo) { this.tools = this.tools.map((t) => (t.name === next.name ? next : t)); return { ...next }; diff --git a/src/features/tools/store.ts b/src/features/tools/store.ts index 6afffcf45..be924cb34 100644 --- a/src/features/tools/store.ts +++ b/src/features/tools/store.ts @@ -1,6 +1,6 @@ /** Tool list + running installs. Installs show in the status-bar task list too. */ import { create } from "zustand"; -import type { RequestId, ToolInfo } from "@/bindings"; +import type { RequestId, ToolInfo, ToolUpdate } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { isCancelled, nextRequestId } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; @@ -28,6 +28,11 @@ interface ToolsState { jobs: Readonly>; /** Output of the last finished install per tool (kept for the log view). */ logs: Readonly>; + /** Newer versions of the tools the IDE installed, from the last check. */ + updates: Readonly>; + checkingUpdates: boolean; + /** Whether updates were looked for in this session (the Language Servers page looks once). */ + updatesChecked: boolean; } export const useTools = create(() => ({ @@ -37,6 +42,9 @@ export const useTools = create(() => ({ error: null, jobs: {}, logs: {}, + updates: {}, + checkingUpdates: false, + updatesChecked: false, })); const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)); @@ -59,6 +67,25 @@ export function refreshTools(): Promise { return refreshing; } +let checkingUpdates: Promise | null = null; + +/** Looks for newer versions of the tools the IDE installed; offline, none are shown. */ +export function checkToolUpdates(): Promise { + checkingUpdates ??= (async () => { + useTools.setState({ checkingUpdates: true }); + try { + const found = await toolsBackend().updates(nextRequestId()); + useTools.setState({ updates: Object.fromEntries(found.map((u) => [u.name, u])) }); + } catch { + // A failed lookup leaves the last result. + } finally { + useTools.setState({ checkingUpdates: false, updatesChecked: true }); + checkingUpdates = null; + } + })(); + return checkingUpdates; +} + function upsertTool(info: ToolInfo) { useTools.setState((s) => { const i = s.tools.findIndex((t) => t.name === info.name); @@ -124,6 +151,10 @@ export function installTool(name: string, opts: { notificationKey?: string } = { }); upsertTool(info); endJob(name, null); + useTools.setState((s) => { + const { [name]: _updated, ...updates } = s.updates; + return { updates }; + }); void toolsBackend() .restartServers(name) .catch(() => {}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c5a117de2..2c3ddd3fd 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4786,6 +4786,8 @@ "{count} file": "{count} 个文件", "{count} files": "{count} 个文件", "Reinstall {name}": "重新安装 {name}", + "Update {displayName}": "更新 {displayName}", + "Version {latest} is available.": "有新版本 {latest} 可用。", "Installed in {path}; it is used for projects targeting Java {version}.": "已安装到 {path};用于以 Java {version} 为目标的项目。", "Code insight for {languages} needs {command}. Install it via {method}?": "{languages} 的代码洞察需要 {command}。是否通过 {method} 安装?", "Version {version}": "版本 {version}",