Compare commits

...
Author SHA1 Message Date
l0ng-ai b1ae3ca91d fix(maven): complete versions of artifacts whose id has dots
Version completion split the artifact id on dots like the group id, so
org.eclipse.platform:org.eclipse.core.runtime was looked up under
.../org/eclipse/core/runtime locally and on Central and found nothing. Only
the group id is split now.
2026-11-13 15:28:33 +08:00
l0ng-ai 11b42041d3 fix(mcp): check_files no longer takes diagnostics from before the last text change
check_files counted a server's last publication as fresh unless this call
itself reloaded the file, so when the file watcher had already applied an
outside edit, diagnostics of the old text came back as checked. The LSP
document now records when its servers last got new text, and only
publications after that are fresh.
2026-11-13 15:28:33 +08:00
l0ng-ai 4c811ba4f5 fix(plugins): keep a plugin's text diff off the disk
views.open-diff used the plugin's file name (or title) as the diff's path, and
the diff viewer probes that path on disk to offer the source file, so a
plugin without file system access could make the IDE touch any path, a UNC
share included. Plugin diffs get a virtual plugin-diff: path that only keeps
the file name for syntax highlighting, and the viewer doesn't probe virtual
paths.
2026-11-13 15:28:33 +08:00
l0ng-ai 573f98627e fix(python): run only trusted recent projects' interpreters when listing them
All Interpreters probed the interpreter of every recent project, so one last
opened in Restricted Mode had its .venv python (or the program its settings
name) executed from another, trusted window. Only projects trusted in their
own right are probed now.
2026-11-13 15:28:32 +08:00
10 changed files with 93 additions and 21 deletions

No files matched your search

+24 -7
View File
@@ -393,11 +393,13 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
out
}
/// The repository directory of the `/`-separated `coordinates` (a group id, or
/// a group and an artifact id), each segment validated against the layout's
/// alphabet so POM text cannot walk outside `repo`.
/// The repository directory of `coordinates` (a group id, or a group and an artifact
/// id): the group's dots are directories, an artifact id is one directory whatever dots
/// it has (`org/eclipse/platform/org.eclipse.core.runtime`). Each segment is validated
/// against the layout's alphabet so POM text cannot walk outside `repo`.
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
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::<String>::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::<Vec<_>>(), ["3.31.0"]);
}
#[test]
+13 -6
View File
@@ -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<PathBuf> = 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<PathBuf> = 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());
+3
View File
@@ -239,6 +239,9 @@ impl LspManager {
cancel: &CancellationToken,
) -> IdeResult<DocumentCheck> {
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! {
+7
View File
@@ -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<PathBuf>,
/// 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<DocText>,
servers: Vec<Arc<Server>>,
/// [`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<Server>, Running, SyncKind, Encoding)> = d
@@ -1392,6 +1398,7 @@ impl LspManager {
language: d.language.clone(),
text: d.text.clone(),
servers,
changed: d.changed,
})
}
+3
View File
@@ -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 }],
+6
View File
@@ -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")]
@@ -61,8 +61,9 @@ pub fn artifacts_url(group: &str, prefix: &str) -> Option<String> {
/// The published version metadata of one artifact on Central.
pub fn metadata_url(group: &str, artifact: &str) -> Option<String> {
(part(group, 200) && part(artifact, 100)).then(|| {
let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().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::<Vec<_>>().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]
@@ -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();
+15 -2
View File
@@ -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") },
+3 -1
View File
@@ -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<string | null>(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),