feat(tools): offer updates for the language servers and debuggers the IDE installed (#3247)

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.
This commit is contained in:
l0ng-ai
2026-11-14 03:38:51 +08:00
committed by GitHub
parent 504ffac10a
commit 88f73630a6
12 changed files with 256 additions and 13 deletions
+12 -1
View File
@@ -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<Vec<ToolUpdate>> {
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<ToolInfo> {
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
+82
View File
@@ -104,6 +104,17 @@ pub enum InstallEvent {
Progress { fraction: Option<f64>, 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<String>,
@@ -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<Vec<ToolUpdate>> {
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<String> {
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<Option<String>> {
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<ToolInfo> {
match registry::find(name) {
+23
View File
@@ -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![]);
}
+1
View File
@@ -458,6 +458,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_list,
tools::tools_install,
tools::tools_uninstall,
tools::tools_updates,
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
+13 -1
View File
@@ -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<Vec<ToolUpdate>> {
ide_host::ops::tools::tools_updates(&state.host, window.label(), request).await
}
/// Removes a tool the IDE installed.
#[tauri::command]
#[specta::specta]
+14
View File
@@ -966,6 +966,11 @@ export const commands = {
toolsInstall: (name: string, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_install", { name, request, onEvent })),
/** Removes a tool the IDE installed. */
toolsUninstall: (name: string) => typedError<ToolInfo, IdeError>(__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<ToolUpdate[], IdeError>(__TAURI_INVOKE("tools_updates", { request })),
/** JDK distributions Download JDK offers. */
jdkDownloadVendors: () => __TAURI_INVOKE<JdkVendor[]>("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,
@@ -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);
+31 -7
View File
@@ -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}
>
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
<Badge dot className={styles.rowBadge} tone={pending ? "accent" : STATUS_TONE[tool.status]}>
{pending ? tx("New") : STATUS_LABEL[tool.status]}
</Badge>
<span className={styles.rowName}>{tool.displayName}</span>
@@ -135,6 +138,11 @@ function ToolRow({
<T>Disabled</T>
</span>
)}
{update && (
<span className={clsx(styles.kindTag, styles.updateTag)}>
<T>Update</T>
</span>
)}
<span className={styles.rowLanguages}>{tool.languages.join(", ")}</span>
</button>
</li>
@@ -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 && (
<Button
size="small"
variant={tool.status === "missing" ? "primary" : "secondary"}
variant={tool.status === "missing" || update ? "primary" : "secondary"}
disabled={busy}
title={
tool.installMethod ? tx`Installs into the IDE's own tools folder via ${tool.installMethod}` : undefined
}
onClick={() => void installTool(tool.name)}
aria-label={
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
update
? tx`Update ${tool.displayName}`
: tool.status === "installed"
? tx`Reinstall ${tool.displayName}`
: tx`Install ${tool.displayName}`
}
>
{tool.status === "installed" ? "Reinstall" : "Install"}
{update ? "Update" : tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
@@ -205,6 +218,11 @@ function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
)}
</div>
</div>
{update && !job && (
<div className={styles.notice} role="note">
{tx`Version ${update.latest} is available.`}
</div>
)}
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
<div className={styles.error} role="alert">
@@ -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<Filter>("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)}
/>
<span className={styles.toolbarSep} aria-hidden />
{loading ? (
{loading || checkingUpdates ? (
<Spinner size={14} label="Checking tools" />
) : (
<IconButton
icon="refresh"
size="small"
title="Re-check installed tools"
onClick={() => void refreshTools()}
onClick={() => {
void refreshTools();
void checkToolUpdates();
}}
/>
)}
<SearchField
+27 -1
View File
@@ -19,7 +19,17 @@ beforeEach(() => {
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();
+10 -1
View File
@@ -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<ToolInfo[]>;
install(name: string, request: RequestId, onEvent: (e: InstallEvent) => void): Promise<ToolInfo>;
uninstall(name: string): Promise<ToolInfo>;
/** Newer versions of the tools the IDE installed (release downloads and npm packages). */
updates(request: RequestId): Promise<ToolUpdate[]>;
cancel(request: RequestId): void;
/** Restarts the language servers named `name` in every open project (after an install). */
restartServers(name: string): Promise<void>;
@@ -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<RequestId>();
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 };
+32 -1
View File
@@ -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<Record<string, InstallJob>>;
/** Output of the last finished install per tool (kept for the log view). */
logs: Readonly<Record<string, { lines: string[]; error: string | null }>>;
/** Newer versions of the tools the IDE installed, from the last check. */
updates: Readonly<Record<string, ToolUpdate>>;
checkingUpdates: boolean;
/** Whether updates were looked for in this session (the Language Servers page looks once). */
updatesChecked: boolean;
}
export const useTools = create<ToolsState>(() => ({
@@ -37,6 +42,9 @@ export const useTools = create<ToolsState>(() => ({
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<void> {
return refreshing;
}
let checkingUpdates: Promise<void> | null = null;
/** Looks for newer versions of the tools the IDE installed; offline, none are shown. */
export function checkToolUpdates(): Promise<void> {
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(() => {});
+2
View File
@@ -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}",