diff --git a/crates/ide-build/src/maven/completion.rs b/crates/ide-build/src/maven/completion.rs index db33911a2..c0e8b351f 100644 --- a/crates/ide-build/src/maven/completion.rs +++ b/crates/ide-build/src/maven/completion.rs @@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec Option { - let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect(); + let (group, artifact) = coordinates.split_first()?; + let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect(); segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s))) } @@ -460,6 +462,7 @@ mod tests { "org/example/lib/2.0-beta-1/lib-2.0-beta-1.jar", "org/example/lib/2.0/lib-2.0.pom", "net/nu/netty/4.1.100/netty-4.1.100.jar", + "org/eclipse/platform/org.eclipse.core.runtime/3.31.0/org.eclipse.core.runtime-3.31.0.jar", // Failed downloads (a version typed while editing): tracking files only, not installed. "org/example/lib/2/lib-2.jar.lastUpdated", "org/example/lib/3.0/lib-3.0.pom.lastUpdated", @@ -570,9 +573,10 @@ mod tests { }; // Only directories holding artifacts are group ids: `net` and `org` // alone never receive an install, their deeper levels do. - assert_eq!(options(""), ["net.nu", "org.apache.maven", "org.apache.maven.plugins", "org.example"]); - assert_eq!(options("org"), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]); - assert_eq!(options("org."), ["org.apache.maven", "org.apache.maven.plugins", "org.example"]); + let org = ["org.apache.maven", "org.apache.maven.plugins", "org.eclipse.platform", "org.example"]; + assert_eq!(options(""), [&["net.nu"][..], &org].concat()); + assert_eq!(options("org"), org); + assert_eq!(options("org."), org); assert_eq!(options("org.spr"), Vec::::new()); assert_eq!(options("net."), ["net.nu"]); } @@ -623,6 +627,19 @@ mod tests { }; assert_eq!(options(""), ["2.0", "2.0-beta-1", "1.10", "1.9.1", "1.0"]); assert_eq!(options("1."), ["1.10", "1.9.1", "1.0"]); + // An artifact id with dots is one directory. + let dotted = local_options( + &repo, + &PomCompletionTarget { + from: 0, + to: 0, + typed: "".into(), + kind: PomCompletionKind::Version, + group: Some("org.eclipse.platform".into()), + artifact: Some("org.eclipse.core.runtime".into()), + }, + ); + assert_eq!(dotted.into_iter().map(|o| o.label).collect::>(), ["3.31.0"]); } #[test] diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 421d06ca5..867bb2cfe 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -61,13 +61,16 @@ pub async fn sdk_python_project_interpreters(host: &Host, window: &str) -> IdeRe host.require_trust(window)?; let current = host.project(window).ok().map(|p| p.root.clone()); let config = host.config_dir.clone(); + // Probing runs the project's interpreter (and its settings may name any program): only + // projects trusted in their own right, not ones last opened in Restricted Mode. + let roots: Vec = ide_workspace::RecentProjects::load(&config) + .list() + .into_iter() + .map(|p| p.path.into_path_buf()) + .filter(|root| current.as_deref() != Some(root.as_path())) + .filter(|root| host.trust.window_trusted(root).unwrap_or(false)) + .collect(); tokio::task::spawn_blocking(move || { - let roots: Vec = ide_workspace::RecentProjects::load(&config) - .list() - .into_iter() - .map(|p| p.path.into_path_buf()) - .filter(|root| current.as_deref() != Some(root.as_path())) - .collect(); // Each project resolves its configured interpreter, else its own virtualenv. The // probes run `python`, so keep them off the async runtime and bound how many run at // once: the recent list can hold ~30 projects, and each probe starts a process. @@ -521,6 +524,10 @@ mod tests { host.trust.decide(&root, true, false).unwrap(); host.open_project("main", &root).unwrap(); + // A project last opened in Restricted Mode: its interpreter is never run. + host.trust.decide(&other, false, false).unwrap(); + assert!(sdk_python_project_interpreters(&host, "main").await.unwrap().is_empty()); + host.trust.decide(&other, true, false).unwrap(); let list = sdk_python_project_interpreters(&host, "main").await.unwrap(); assert_eq!(list.len(), 1, "{list:?}"); assert_eq!(&*list[0].project, other.as_path()); diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index 90c02d810..478aca1dc 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -239,6 +239,9 @@ impl LspManager { cancel: &CancellationToken, ) -> IdeResult { let snap = self.snapshot(doc)?; + // The text may have changed before this call synced it (the file watcher reloaded it): + // only diagnostics published after the servers got the current text are fresh. + let changed = Some(changed.map_or(snap.changed, |c| c.max(snap.changed))); let deadline = tokio::time::Instant::now() + timeout; let left = || deadline.saturating_duration_since(tokio::time::Instant::now()); let ready = tokio::select! { diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index c90a665e0..1b3d5f39b 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -120,6 +120,8 @@ struct DocState { compose_detected: bool, /// The notebook a code cell belongs to: synced through `notebookDocument/*`, not as a file. notebook: Option, + /// When the servers last got new text (open or change): diagnostics published before are stale. + changed: std::time::Instant, } impl DocState { @@ -158,6 +160,8 @@ struct DocSnap { language: LanguageId, text: Arc, servers: Vec>, + /// [`DocState::changed`]. + changed: std::time::Instant, } impl DocSnap { @@ -1170,6 +1174,7 @@ impl LspManager { servers: servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(), compose_detected, notebook: None, + changed: std::time::Instant::now(), }; let compose_added = { let mut docs = self.docs.lock(); @@ -1217,6 +1222,7 @@ impl LspManager { let mut docs = self.docs.lock(); let Some(d) = docs.get_mut(&doc) else { return }; d.version = version as i32; + d.changed = std::time::Instant::now(); // Servers that have this document open on their current instance. let targets: Vec<(Arc, Running, SyncKind, Encoding)> = d @@ -1392,6 +1398,7 @@ impl LspManager { language: d.language.clone(), text: d.text.clone(), servers, + changed: d.changed, }) } diff --git a/crates/ide-lsp/src/notebook.rs b/crates/ide-lsp/src/notebook.rs index add2aca76..b6496ec0a 100644 --- a/crates/ide-lsp/src/notebook.rs +++ b/crates/ide-lsp/src/notebook.rs @@ -213,6 +213,7 @@ impl LspManager { { if d.text.as_str() != cell.source { Arc::make_mut(&mut d.text).set(&cell.source); + d.changed = std::time::Instant::now(); } d.version += 1; state.cells.push((cell.id.clone(), doc)); @@ -234,6 +235,7 @@ impl LspManager { servers: state.servers.iter().map(|s| Attached { server: s.clone(), opened_gen: None }).collect(), compose_detected: false, notebook: Some(path.to_owned()), + changed: std::time::Instant::now(), }, ); state.cells.push((cell.id.clone(), doc)); @@ -259,6 +261,7 @@ impl LspManager { } Arc::make_mut(&mut d.text).set(&cell.source); d.version += 1; + d.changed = std::time::Instant::now(); content.push(json!({ "document": { "uri": d.uri, "version": d.version }, "changes": [{ "text": cell.source }], diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 7b6bdb7c2..e3837cebd 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -2204,6 +2204,12 @@ async fn checking_a_document_saves_it_and_waits_for_fresh_diagnostics() { let stale = h.mgr.check_document(doc, changed, Duration::from_millis(800), &cancel).await.unwrap(); assert!(stale.servers[0].checked && stale.servers[0].incomplete, "{:?}", stale.servers); assert_eq!(stale.diagnostics.len(), 1); + + // The text changed before the call (the file watcher reloaded it), so the caller saw no + // change of its own: the old publication is still not fresh. + h.mgr.did_change(doc, 2, &[change(rng(0, 0, 0, 3), "good")], &|| "good\n".to_owned()); + let reloaded = h.mgr.check_document(doc, None, Duration::from_millis(800), &cancel).await.unwrap(); + assert!(reloaded.servers[0].incomplete, "{:?}", reloaded.servers); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-workspace/src/dependencies/maven.rs b/crates/ide-workspace/src/dependencies/maven.rs index 55e7baea8..e4d4b9806 100644 --- a/crates/ide-workspace/src/dependencies/maven.rs +++ b/crates/ide-workspace/src/dependencies/maven.rs @@ -61,8 +61,9 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option { /// The published version metadata of one artifact on Central. pub fn metadata_url(group: &str, artifact: &str) -> Option { - (part(group, 200) && part(artifact, 100)).then(|| { - let path = group.split('.').chain(artifact.split('.')).collect::>().join("/"); + // The group's dots are directories; an artifact id is one directory, dots and all. + (part(group, 200) && part(artifact, 100) && artifact != "." && artifact != "..").then(|| { + let path = group.split('.').chain([artifact]).collect::>().join("/"); format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml") }) } @@ -146,6 +147,11 @@ mod tests { metadata_url("org.apache.maven", "maven-core").as_deref(), Some("https://repo1.maven.org/maven2/org/apache/maven/maven-core/maven-metadata.xml") ); + assert_eq!( + metadata_url("org.eclipse.platform", "org.eclipse.core.runtime").as_deref(), + Some("https://repo1.maven.org/maven2/org/eclipse/platform/org.eclipse.core.runtime/maven-metadata.xml") + ); + assert_eq!(metadata_url("org.example", ".."), None); } #[test] diff --git a/src/features/plugins/__tests__/contentEditor.test.tsx b/src/features/plugins/__tests__/contentEditor.test.tsx index dff1d38b7..5c935eef2 100644 --- a/src/features/plugins/__tests__/contentEditor.test.tsx +++ b/src/features/plugins/__tests__/contentEditor.test.tsx @@ -218,7 +218,7 @@ describe("plugin-backed content editor tabs", () => { }); await waitFor(() => expect(diffs).toHaveLength(1)); expect(diffs[0]).toEqual({ - path: "service.toml", + path: "plugin-diff:/service.toml", title: "Diff: service.toml", left: { type: "text", text: 'name = "demo"\nreplicas = 2\n', label: "Live" }, right: { type: "text", text: 'name = "demo"\nreplicas = 5\n', label: "Edited" }, @@ -239,18 +239,26 @@ describe("plugin-backed content editor tabs", () => { openPluginDiffRequest({ left: "", right: "b\n", title: "MR !12" }, 10); expect(diffs).toEqual([ { - path: "src/review.ts", + path: "plugin-diff:/review.ts", title: "src/review.ts", left: { type: "text", text: "a\n", label: "Target" }, right: { type: "text", text: "b\n", label: "After" }, }, { - path: "MR !12", + path: "plugin-diff:/MR !12", title: "MR !12", left: { type: "text", text: "", label: "Before" }, right: { type: "text", text: "b\n", label: "After" }, }, ]); + // A file name only names the syntax: never a path on disk (a UNC share, say). + openPluginDiffRequest({ left: "a", right: "b", fileName: "\\\\attacker\\share\\x.rs" }, 10); + openPluginDiffRequest({ left: "a", right: "b", title: "C:\\Users\\x\\y.ts" }, 10); + expect(diffs.slice(2).map((d) => (d as { path: string }).path)).toEqual([ + "plugin-diff:/x.rs", + "plugin-diff:/y.ts", + ]); + diffs.splice(2); expect(() => openPluginDiffRequest({ left: "a", right: 1 }, 10)).toThrow(); expect(() => openPluginDiffRequest({ left: "a", right: "b", fileName: "x\ny" }, 10)).toThrow(); expect(() => openPluginDiffRequest({ left: "a", right: "b" }, 4 << 20)).toThrow(); diff --git a/src/features/plugins/views/contentTab.ts b/src/features/plugins/views/contentTab.ts index 7a9590999..6d59c0609 100644 --- a/src/features/plugins/views/contentTab.ts +++ b/src/features/plugins/views/contentTab.ts @@ -176,9 +176,22 @@ function validDiff(v: unknown): ContentTabDiff | undefined { } /** Opens a plugin's two texts in the diff viewer; `path` picks the syntax highlighting. */ -export function openPluginDiff(diff: ContentTabDiff, path: string, title: string) { +/** + * A plugin diff's path: the file name only picks the syntax highlighting. It is virtual, so the + * diff viewer never probes or opens a file of that name (a plugin may lack file system access). + */ +export function pluginDiffPath(name: string): string { + const base = + name + .split(/[\\/]/) + .pop() + ?.replace(/[\u0000-\u001f:]/g, "") || "diff"; + return `plugin-diff:/${base}`; +} + +export function openPluginDiff(diff: ContentTabDiff, name: string, title: string) { void executeAction("vcs.openDiff", { - path, + path: pluginDiffPath(name), title: diff.title ?? title, left: { type: "text", text: diff.left, label: diff.leftLabel ?? tx("Before") }, right: { type: "text", text: diff.right, label: diff.rightLabel ?? tx("After") }, diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index c68c1604a..b10b0033d 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -37,6 +37,7 @@ import { call, commands } from "@/platform/ipc"; import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap"; import { tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; +import { isVirtualUri } from "@/platform/virtualDocs"; import { IconButton, Select, Spinner, T, showContextMenu } from "@/ui"; import { registerSoftWrapSurface } from "@/workbench/softWrap"; import { editorService, type EditorInput } from "@/workbench/contributions"; @@ -819,7 +820,8 @@ export function DiffEditor({ const fileSource = sideFile(left, path) ?? sideFile(right, path); const [onDisk, setOnDisk] = useState(null); useEffect(() => { - if (fileSource) return; + // A virtual path (a plugin's text diff) names no file on disk. + if (fileSource || isVirtualUri(path)) return; let alive = true; void call(commands.fsProbe(path)).then( () => alive && setOnDisk(path),