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
l0ng-ai c942b87abb fix(editor): translate the code vision usage counts (#3088)
The usages hint above declarations was always English; it now goes through the text catalog, with Chinese entries.
2026-11-13 14:54:48 +08:00
l0ng-ai cdecb5916c fix(plugins): run the scripts of plugin html views in release builds (#3087)
A view's html body was loaded as a srcdoc frame, which inherits the workbench CSP; its script-src 'self' blocks inline scripts, so in every build that serves the CSP html views rendered but ran no script. In the app the body is now written into a page of the openidea-plugin scheme (.frame) whose response carries only the frame's own policy, sandboxed as before. Web views already load from the scheme; notebook HTML output never runs scripts.
2026-11-13 14:44:26 +08:00
Clarence 835c44615b fix(vcs): Branches popup search matches words, not scattered letters (#3076)
The Branches popup filtered with fuzzyMatch, whose last fallback accepts any subsequence, so `test` listed names like `sprint-1.1-device-reset`. It now uses wordMatch, as Search Everywhere and the Keymap page do: a substring, or fragments at word / camel-hump starts. Repository names in a multi-repository project keep the loose match. The search field takes the bar's free width, so the clear, Fetch and Options buttons sit at the right edge.

Co-authored-by: Clarence404 <[email protected]>
2026-11-13 14:19:19 +08:00
l0ng-ai df79c80aa8 feat(plugins): notes after file names in the project tree (API 1.7) (#3085)
Tree decorations could carry only a two-character badge and a tooltip. decorations.set-file-note (API 1.7) shows one line of muted text (at most 200 characters, optional tooltip) after a file or folder name in the project tree, replacing that tree set's item for the file and sharing the tree-set limits. The SDK wraps it as host::set_file_note with a mock; checked-in components rebuilt.
2026-11-13 14:18:04 +08:00
l0ng-ai 73df48db7c fix(vue): resolve the @ alias in Vue projects without Vue CLI (#3080)
Only projects depending on @vue/cli-service had their webpack aliases passed to tsserver, so @/ imports resolved to nothing in Vue CLI 2, custom webpack or Vite projects without jsconfig.json. Those now get their own webpack configuration's aliases (function / list / ES module exports handled), else the @ -> src alias Vue templates set up.
2026-11-13 14:01:38 +08:00
78 changed files with 476 additions and 107 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());
+30 -2
View File
@@ -4125,6 +4125,11 @@ impl PluginApi {
if items.len() > 10_000 || file.as_ref().and_then(|f| f.badge.as_ref()).is_some_and(|b| b.chars().count() > 2) {
return Err(HostError::Invalid("decoration limit exceeded".into()));
}
if let Some(note) = file.as_ref().and_then(|f| f.note.as_ref())
&& (note.trim().is_empty() || note.chars().count() > 200 || note.contains(['\n', '\r']))
{
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
items.retain(|d| decoration_style(&d.style));
if items.iter().any(|d| d.range.start > d.range.end) {
return Err(HostError::Invalid("reversed decoration range".into()));
@@ -4143,7 +4148,7 @@ impl PluginApi {
set_id: set.into(),
tree,
items,
file: file.filter(|f| decoration_style(&f.style)),
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
})
} else {
None
@@ -4315,10 +4320,33 @@ mod workbench_tests {
api.file_decorations(
Some(&path),
"tree",
Some(ide_plugin::FileDecoration { badge: Some("abc".into()), style: "info".into(), tooltip: None })
Some(ide_plugin::FileDecoration {
badge: Some("abc".into()),
style: "info".into(),
tooltip: None,
note: None
})
)
.is_err()
);
let note = |text: &str| ide_plugin::FileDecoration {
badge: None,
style: String::new(),
tooltip: Some("why".into()),
note: Some(text.into()),
};
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
let notes = |api: &PluginApi| {
api.shared.decoration_sets()["test"]
.iter()
.filter(|s| s.tree && s.set_id == "notes")
.map(|s| s.file.as_ref().and_then(|f| f.note.clone()))
.collect::<Vec<_>>()
};
assert_eq!(notes(&api), [Some("entry point".to_string())], "kept without a style");
for bad in ["", "two\nlines", &"x".repeat(201)] {
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
}
api.shared.clear_plugin("test", true);
assert!(api.shared.decoration_sets().is_empty());
}
+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")]
+3
View File
@@ -922,6 +922,9 @@ pub struct FileDecoration {
pub badge: Option<String>,
pub style: String,
pub tooltip: Option<String>,
/// Text shown after the name (API 1.7 `set-file-note`).
#[serde(default)]
pub note: Option<String>,
}
/// Commit metadata, date in Unix seconds.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
+12 -1
View File
@@ -2497,7 +2497,7 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
.file_decorations(
Some(&real),
&set_id,
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip }),
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
)
.map_err(to_wit))
}
@@ -2507,6 +2507,17 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
}
Ok(self.host.file_decorations(None, &set_id, None).map_err(to_wit))
}
fn set_file_note(&mut self, path: String, set_id: String, note: String, tooltip: Option<String>) -> Fallible<()> {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
let real = match self.decoration_path(&path) {
Ok(real) => real,
Err(e) => return Ok(Err(e)),
};
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
}
}
pub(crate) mod language_features_bindings {
@@ -1 +1 @@
3c7f35facd15ee8380ccb6475b7c051d500e38346b711e1805077becd0c75f75
d6c855f1f91aeb02eec5d6c8a07be4eb64bae0704619f44cb67f3b832daf01ee
@@ -1 +1 @@
3879822d975695c6258e95286b0e64c5ee1e3265876501efaf0e577b38f8222b
51d00702c42c7167083caea70cf2df022e512bcaf89af8addf526421ff45a15f
@@ -1 +1 @@
228ae888bf556ce5cedc7843eb20e92f221d9e1f640f3c6c595bed3fd6be6051
40d2fd76ba96b0c93ea0a034c6749709ddc00fc119046e653a50b180a9c4718d
@@ -1 +1 @@
41de239b6f723020b38eb2b67f642b7c88cc88c79117c3072ba122633afc418c
08822287f3649412340f9dbbe2f0ea5a5cd2f543a3c242c5d0a29c422c4c5367
@@ -1 +1 @@
78c5ed3c756498c7619b46c3622ae7a59461c61c747b45caecd79b341ee654de
43eba23cc14cf3c74bfc934099cd58df20f07ee5df96cc10100b90016c544a7a
@@ -1 +1 @@
ee848321e53cb181536e6158570dbb4ab02a6fd2a1a064b068803ac7da7f3448
520d9a95328dc772bbb913a2e5e22af8243c95ca45744cbb65b25747653d2a34
@@ -1 +1 @@
b587de945875a98f1d4ca20fab7cf94fb4dc6d848136aa19ab0e0e5319155a80
acd3d0ee3f886c6a271ce3139200ce5bdb46e40f9c68e495c5ff9b4db98a88ac
@@ -1 +1 @@
1c8776dcae35d2d49dd6c1e32b0cdd354de1eb0f72a473245a2c47368b89ec30
0cf3bc2197f0bd5339fe3c2199bd973e68b3637b8c5ae07902e62659160275e3
@@ -1 +1 @@
2a517cbffd6d3cdda37117d8ca8815978a09995923fc3d5aa5af7020afdac56a
17f837c8d6f58800728e00060afe68b2a093085d816d809c9e67065fdd165352
@@ -1 +1 @@
dbd912b1f8488ecd446af07b5dcaef55d44bce57c1a55be5c0b0fb33e2697903
2cca80220fab6764a8e1f51db8ed2f55b603000c8d0dbf5913a441876b7857ba
@@ -1 +1 @@
efdc84cd2bedb3989b03d19b569d09958f26e502db65db4dd786eb3438a7c407
54211437002ad83a9d3d642f2dca5f37cadd89d7d77ddc7de545db87b99fb8b7
@@ -1 +1 @@
be6bc15ee42a5e21781ba54f548007351a46b669882ee4491da20ff556927a06
4326d9cdefe71f4bb9aab043ccef883abbd13145b1579352b97c66889f514c68
@@ -1 +1 @@
e5c3a3beda750dff27d7b823676d53f15b7c76b8ad539594cd913bd178d750da
2469023f4f29b9abdaa27c9d96774229c4dd002366421dc508926c3fed1fba1b
@@ -1 +1 @@
322de3299f0413101c02358836e7df7e06e58cbb22c11897664564abbb8af090
233461ab52a12e27fa600e108e83cab1b930b3e61812e0c048c99c1e18c6d646
@@ -1 +1 @@
d768ff6a4a466cc6f1d72499df3ccb4ae76872436b8eddff27f62aa156331a37
bfc654523da608bbc008ea6a15a0cfd0b129ffab70088bf6b610786b63b5352e
@@ -1 +1 @@
57aea107b2c10e4d30fa3785ac4c6566f1b40908a18a3859aca8be047fac9518
c8536be072d27aa89f5e92b5b96e38ed65b7f06c35c05ecbd4a151218203c5f7
@@ -1 +1 @@
c98bcc89750e26e738d7115a7c0454fde25367a0a23bdc2ff95fb5a5dacc9f98
7c0c1f5035e12c794d48cb75a8fc259d11dd99ba3188030fe1d9956720ee6ad5
@@ -1 +1 @@
a4c23b93a14eda705cb18c88bbc2373d1ef3f96031ff2df21b8b092b50633996
3cf5fd6ab0ce909b806230e20a7fa46264f85937018cfa68865ec0eeb92d4b72
@@ -1 +1 @@
aec002b3dd310068e69a6ae67a49d30655bf8baea9153a110a407e9d408e6880
d349977fd3cbfba92d1d953ac09f65dae5b83b7070723b59553d791c638acf81
@@ -1 +1 @@
08ece563c3b55c2b86e68fbb0815ddcbb63beb6f1f6c32b8dbdbee558a4a66c1
e796039ae2f8e534ffc8c2ed2e47e910aaf593c464525287699cf1daf3e195ef
@@ -1 +1 @@
450845edf234a1cc78cd6cfc0b812a89f3a2e41484689cc1e1b05be66873d4eb
5e084fa409bb0641409110d91f203a6f99b451b95cd8067bd1ee68140bf8a892
@@ -1 +1 @@
61b735de5ef61dcd0ea8e640c2ad004e84a63fe4dfd7872ffb5e04d6d81dc9a9
521557c1a062a9b82afa14547bc54b57255cdbed3b2ab4b1b86424dfb8accbdc
@@ -1 +1 @@
36776367782eaa4b8b8121f4a665dd8e68525a4836bff1c92bc16770cce14929
5280990269e944aef81ec91b356fadf4aa2fc7e7bb10e4b6842e1dc1c669704a
@@ -1 +1 @@
f6311d232a99356b02a398b2ce3db0865c425f435a8d201157719178ba2b97c7
ff1391d3c9338b6270fef8af7d7c4a5bc595c391997c220a3a827b4b8a068de8
@@ -1 +1 @@
fe1d26665e3c816ed43fc720acabad03a37b7924bccd4dd8c357fd156947b3c7
b78ac0b6fa14401a6379f3e6d07d1398a63d70e882930b3747ec843c0fdb5de7
@@ -1 +1 @@
76bab8e48235f9f066f5b9bbfdd9398916b6c3ed73c072c543d80595130519f7
55331477c1d5322b0dc35d13fc7f8223bf697f5f21328d340f75519dd1098727
@@ -1 +1 @@
f9ed2eac306229f76b05d0c4a4fb2f0ed8d81a1dfc5eb50d044bbe10c9206a5f
0ae6b7edd0d7cd5025ead4a4afacf0fbe3ebc9548d847f94be0e63585a19747e
@@ -1 +1 @@
f2d83d9f56da072d38a1552195af8a9d4edc11888c645627d5dd1e4a46afc4df
5d909d6ab2e73fabba2d213a944546ef7e425552852cf7f74e00eb94c9d167ac
+69 -15
View File
@@ -17,11 +17,18 @@ pub(crate) const EXTENSION: &str = "vue";
const CLI_SERVICE: &str = "@vue/cli-service";
const CLI_CONFIG_TIMEOUT: Duration = Duration::from_secs(30);
const ALIASES_MARKER: &str = "openidea-webpack-aliases:";
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]`, after whatever
/// the configuration itself prints.
const PRINT_ALIASES: &str = "const c = require(process.argv[1]); const a = (c.resolve && c.resolve.alias) || {}; \
/// Prints the `resolve.alias` of the webpack configuration module `argv[1]` (an object, an
/// ES module's default, a function of the environment, or a list of configurations), after
/// whatever the configuration itself prints.
const PRINT_ALIASES: &str = "let c = require(process.argv[1]); if (c && c.__esModule) c = c.default; \
if (typeof c === 'function') c = c({}, { mode: 'development' }); if (Array.isArray(c)) c = c[0]; \
const a = (c && c.resolve && c.resolve.alias) || {}; \
const e = Array.isArray(a) ? a.map((x) => [x.name, x.alias]) : Object.entries(a); \
process.stdout.write('\\n' + process.argv[2] + JSON.stringify(e));";
/// Webpack configurations of Vue projects without Vue CLI: the project's own, and the
/// `vue init webpack` (Vue CLI 2) template's.
const WEBPACK_CONFIGS: &[&str] =
&["webpack.config.js", "webpack.config.cjs", "build/webpack.base.conf.js", "build/webpack.dev.conf.js"];
type Paths = BTreeMap<String, Vec<String>>;
static CLI_PATHS: LazyLock<Mutex<HashMap<PathBuf, (Stamp, Paths)>>> = LazyLock::new(Default::default);
@@ -42,24 +49,33 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
})
}
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI project at `root` (`@/*` →
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` →
/// `<root>/src/*`), for its tsserver project when it has no `jsconfig.json`: tsserver can't
/// read webpack configurations, so `@/` imports resolve to nothing, and a mixin imported
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the
/// configuration `@vue/cli-service` resolves with the project's `vue.config.js` (the project
/// is trusted: language servers only run then); else the alias Vue CLI always adds.
/// Empty for other projects.
/// that way makes the component's whole `this` `any`. Like IntelliJ, evaluates the webpack
/// configuration (the project is trusted: language servers only run then): the one
/// `@vue/cli-service` resolves with the project's `vue.config.js`, else the project's own
/// (`webpack.config.js`, Vue CLI 2's `build/webpack.base.conf.js`). Without one (Vite, or
/// no Node), the `@` → `src` alias every Vue template sets up. Empty for other projects.
pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let Ok(manifest) = std::fs::read_to_string(root.join("package.json")) else { return Paths::new() };
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
let dependencies = package_dependencies(&manifest);
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
if !cli && !dependencies.iter().any(|d| d == "vue") {
return Paths::new();
}
let config = root
.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file());
let mut inputs: Vec<PathBuf> =
["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"].iter().map(|f| root.join(f)).collect();
let config = if cli {
root.ancestors()
.map(|dir| dir.join("node_modules").join(CLI_SERVICE).join("webpack.config.js"))
.find(|f| f.is_file())
} else {
WEBPACK_CONFIGS.iter().map(|f| root.join(f)).find(|f| f.is_file())
};
let mut inputs: Vec<PathBuf> = ["package.json", "vue.config.js", "vue.config.cjs", "vue.config.mjs"]
.iter()
.chain(WEBPACK_CONFIGS)
.map(|f| root.join(f))
.collect();
inputs.extend(config.iter().cloned());
inputs.extend(node.map(Path::to_path_buf));
let now = stamp(&inputs);
@@ -71,6 +87,7 @@ pub(crate) fn cli_paths(root: &Path, node: Option<&Path>) -> Paths {
let aliases = config
.zip(node)
.and_then(|(config, node)| webpack_aliases(root, &config, node))
.filter(|aliases| !aliases.is_empty())
.unwrap_or_else(|| vec![("@".into(), root.join("src").to_string_lossy().into_owned())]);
let paths = alias_paths(&aliases);
CLI_PATHS.lock().unwrap_or_else(|e| e.into_inner()).insert(root.to_path_buf(), (now, paths.clone()));
@@ -181,6 +198,43 @@ mod tests {
);
}
#[test]
fn other_vue_projects_get_their_webpack_aliases_else_the_at_alias() {
let tmp = tempfile::tempdir().unwrap();
// As `__dirname` sees it (macOS: /private/var).
let t = &dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(t.join("package.json"), r#"{"dependencies":{"react":"^18.0.0"}}"#).unwrap();
std::fs::create_dir_all(t.join("src/views")).unwrap();
assert!(cli_paths(t, None).is_empty(), "not a Vue project");
std::fs::write(t.join("package.json"), r#"{"dependencies":{"vue":"^2.6.14"}}"#).unwrap();
let slash = |p: PathBuf| p.to_string_lossy().replace('\\', "/");
let (src, views) = (slash(t.join("src")), slash(t.join("src/views")));
let at = Paths::from([("@".into(), vec![src.clone()]), ("@/*".into(), vec![format!("{src}/*")])]);
assert_eq!(cli_paths(t, None), at, "Vite or no Node: the template alias");
let Some(node) = crate::exec::which("node") else { return };
// `vue init webpack`: a function of the environment is called, its aliases taken.
std::fs::create_dir_all(t.join("build")).unwrap();
std::fs::write(
t.join("build/webpack.base.conf.js"),
r#"const path = require('path');
const resolve = (dir) => path.join(__dirname, '..', dir);
module.exports = (env) => ({ resolve: { alias: { vue$: 'vue/dist/vue.esm.js', '@': resolve('src'), views: resolve('src/views') } } });"#,
)
.unwrap();
assert_eq!(
cli_paths(t, Some(&node)),
Paths::from([
("@".into(), vec![src.clone()]),
("@/*".into(), vec![format!("{src}/*")]),
("views".into(), vec![views.clone()]),
("views/*".into(), vec![format!("{views}/*")]),
])
);
// A configuration that can't be loaded (dependencies not installed): the template alias.
std::fs::write(t.join("build/webpack.base.conf.js"), "require('not-installed-plugin');").unwrap();
assert_eq!(cli_paths(t, Some(&node)), at);
}
#[test]
fn vue_cli_aliases_come_from_the_resolved_webpack_configuration() {
let Some(node) = crate::exec::which("node") else { return };
@@ -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]
+5 -2
View File
@@ -598,6 +598,7 @@ then run `openidea-plugin check`.
| `TextEditor::set_selections(ranges)`, `reveal(range)` | `editor.text-editor` | API 1.4: selections or scrolling in this window's already open editor; no capability. Commands/events only, UTF-16 coordinates. |
| `host::set_decorations`, `clear_decorations` | `decorations.{set,clear}` | API 1.4: project-scoped editor annotations cleared on plugin stop. Requires `project.read`. |
| `host::set_file_decoration`, `clear_file_decorations` | `decorations.{set-file,clear-file}` | API 1.4: project-tree badges/styles. Requires `project.read`. |
| `host::set_file_note` | `decorations.set-file-note` | API 1.7: a note after the name in the project tree (one line, at most 200 characters). Requires `project.read`. |
| `Project::search_text(query, limit)` | `workspace.project.search-text` | API 1.4: cancellable Find in Files with unsaved documents and project excludes, at most 10,000 matches. Requires `project.read`. |
| `host::problems(path)` | `diagnostics.problems` | API 1.4: project Problems reports grouped by file/source, optional file filter. Requires `project.read` and an invoking window. Empty for remote projects. |
| `Project::vcs_log(path, limit)` | `vcs.log` | API 1.4: newest first, at most 1000 commits, optional file history. Requires `vcs.read` and trust. |
@@ -851,8 +852,10 @@ Decorations are plain text, with fixed styles mapped to theme tokens:
`highlight`, `search`, `added`, `modified`, `removed`, `warning`, `error`, `info`,
`underline`, `strikethrough`, `muted`. Unknown styles are ignored. Editor items
have `{ range, style, hover, after }`; tree items have `{ badge, style, tooltip }`,
where a badge has at most two Unicode characters. Paths must resolve inside an
open project; absolute paths select that project, relative paths use the invoking
where a badge has at most two Unicode characters. A tree item can instead be a note
(`set-file-note`, API 1.7): muted text after the file or folder name, one line of at
most 200 characters, with an optional tooltip; it replaces that set's item for the file.
Paths must resolve inside an open project; absolute paths select that project, relative paths use the invoking
project. Sets are isolated by plugin and project and shared by all windows of
that project. Setting replaces a set's items for one file; clearing removes that
set in all projects. Each plugin has at most 50 editor sets and 50 tree sets,
+1 -1
View File
@@ -1 +1 @@
e8e6dbe6dba09de2fd71a9d84eed8c01b16253f9247bf882361c6f49b8a4d51d
b064bd91f44d088c778a0b7e207f9682a56156724588ff500c0c864d22085205
@@ -1 +1 @@
784a4e01725063fc2949a88bd209e7fcc8ecb0f721acd5f03d92db39ece091a9
4145b677141c592dbd9130000eae707615ec36069b78ea3e25fe598dc642bf14
+1 -1
View File
@@ -1 +1 @@
73670a049d8c108511af35b5030d08f1e04a9198cba0da54268e75a06747d953
52f7ccad8966c991505e7a377c59dbf24d0660df66eca97e7a2a8c5d58e3ce68
+1 -1
View File
@@ -1 +1 @@
a17332b7d5becc4ddccffdba1f9b009e07f75f171bc2519b6c1038fdda2bfd71
a438e04a1e179469eec1ee16522b031fea9bbb669d34c77973ee6fd85885a088
+1 -1
View File
@@ -1 +1 @@
3db5b81c6562e2532c9408d749cf65164247c0ef6d51e4195a44ebdc9a697924
7ff4e304120c9c7bd316b95ce7526fda74adf00867642fcc66311effa1567d76
+1 -1
View File
@@ -1 +1 @@
2c31b614b8c942fdfc075aa58da2000c85598f0c5031d565ffafeb60c75eeac5
63847067fc7b9931a3d697d8e6649cea04a0af7ad02d57e26a38f9acb2e6fcdd
+1 -1
View File
@@ -1 +1 @@
08ab43e65c6904d48a969cb0c4247ece1257b51600962fbc64e67d9a1a78e96a
78b71cba21352bfc91cd631523e4b69a6a2df2d5f6bfbf8164e63698a4625594
+1 -1
View File
@@ -1 +1 @@
c7aa8a41a0b142fac374470597316500e2f1b0dfb2c2e58c8e4bcbe05dd87c1f
6d5ea800a0bcbf26e4770a985b1bc8ef09ca71e7e5c94d59f510dbd108c79ece
+1 -1
View File
@@ -1 +1 @@
e8469311f7ccf2ed4ced5bfea4987cd3ff13aede56ee4b18652d3ab466316392
21061c0875e8213efe809e95b0a4d4b0dd73962398fd793d176a443163e73e0e
+1 -1
View File
@@ -1 +1 @@
79d714b6dcbd34cfe24f9b643c65e91c695d6ed3bd68d752a16c0b253ba27384
6a95d38a5f41dbed72dca23584f715db9a12192d19222460cee8669b4a352770
+1 -1
View File
@@ -1 +1 @@
e13b635a78df5d7de5bcc8bf2a75ab5699c6f06b97bd36cbfcfb110c9b332ff5
bfab2d2fa19bdaac6890a117aa055c4caf1393065f87b6c104ff46114d9189c8
+1 -1
View File
@@ -1 +1 @@
7debd0f77bdcd1eddaf84450f8070142e3622d1b826770826232f3211c008fc1
1e860aa8277c6cc5ffd887e1bc5c3107feb8c7f856d2c4df7294497879308ce7
+1 -1
View File
@@ -1 +1 @@
de2139c370c23f70bf2778b78967eea7617bc6c63f400bf7de5ad26a57f6da66
cbe64480e49895004af403c165735d58cfe657910b45cea2d86d7eadcd93b93b
+1 -1
View File
@@ -1 +1 @@
2d14371febb308fe8d92ab3497dbba8df297912566754fcc1723980824ea3cd7
8186e9cfb58fedf1add5288eb30010b8a376dd3dbd2aee39da5ddf067f5b0aae
+1 -1
View File
@@ -1 +1 @@
7a7cf4af3a6fe381ae4e3046116a0d5a5f3996d4df7f4cab1c35852c52a489e6
75d6e447d1d54ad254845b423c38d3c395f07f2e31ee2dd376edc1ed7079ae06
+8
View File
@@ -416,6 +416,9 @@ mod imp {
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
wit::decorations::clear_file(set).map_err(error)
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
wit::decorations::set_file_note(path, set, note, tooltip).map_err(error)
}
pub fn vcs_log(p: &ProjectRes, path: Option<&str>, limit: u32) -> Result<Vec<CommitInfo>, Error> {
wit::vcs::log(p, path, limit)
.map(|v| {
@@ -1842,6 +1845,11 @@ pub fn set_file_decoration(path: &str, set: &str, item: &FileDecoration) -> Resu
pub fn clear_file_decorations(set: &str) -> Result<(), Error> {
imp::clear_file_decorations(set)
}
/// Set a note shown after a file's name in the project tree, replacing this tree set's
/// annotation for the file (API 1.7). One line of at most 200 characters. Requires project.read.
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
imp::set_file_note(path, set, note, tooltip)
}
/// Starts an owned run configuration in the invoking window. Its id receives run-exited.
pub fn start_run(config: &str, args: &[String], debug: bool) -> Result<String, Error> {
+30 -2
View File
@@ -156,6 +156,7 @@ struct State {
restricted: bool,
decorations: BTreeMap<(String, String), Vec<Decoration>>,
file_decorations: BTreeMap<(String, String), FileDecoration>,
file_notes: BTreeMap<(String, String), (String, Option<String>)>,
revealed_ranges: Vec<Range>,
loopback_queries: VecDeque<Option<String>>,
net: Option<NetHandler>,
@@ -300,6 +301,10 @@ impl MockHost {
pub fn file_decorations(&self) -> BTreeMap<(String, String), FileDecoration> {
self.state.borrow().file_decorations.clone()
}
/// `(set, path)` → `(note, tooltip)` set with `set_file_note`.
pub fn file_notes(&self) -> BTreeMap<(String, String), (String, Option<String>)> {
self.state.borrow().file_notes.clone()
}
/// Ranges requested to scroll into view.
pub fn revealed_ranges(&self) -> Vec<Range> {
self.state.borrow().revealed_ranges.clone()
@@ -1655,9 +1660,9 @@ pub mod current {
let mut next = s.file_decorations.clone();
next.remove(&(set.into(), path.clone()));
if style(&item.style) {
next.insert((set.into(), path), item.clone());
next.insert((set.into(), path.clone()), item.clone());
}
let names: BTreeSet<_> = next.keys().map(|(set, _)| set).collect();
let names: BTreeSet<_> = next.keys().chain(s.file_notes.keys()).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
@@ -1666,6 +1671,7 @@ pub mod current {
return Err(Error::Invalid("file decoration limit".into()));
}
s.file_decorations = next;
s.file_notes.remove(&(set.into(), path));
Ok(())
})
}
@@ -1673,6 +1679,28 @@ pub mod current {
with(|s| {
require(s, "project.read")?;
s.file_decorations.retain(|(id, _), _| id != set);
s.file_notes.retain(|(id, _), _| id != set);
Ok(())
})
}
pub fn set_file_note(path: &str, set: &str, note: &str, tooltip: Option<&str>) -> Result<(), Error> {
with(|s| {
require(s, "project.read")?;
let path = decoration_path(s, path)?;
let key = (set.to_string(), path);
let names: BTreeSet<_> =
s.file_decorations.keys().chain(s.file_notes.keys()).chain([&key]).map(|(set, _)| set).collect();
if set.is_empty()
|| set.len() > 128
|| names.len() > 50
|| note.trim().is_empty()
|| note.chars().count() > 200
|| note.contains(['\n', '\r'])
{
return Err(Error::Invalid("a file note is one non-empty line of at most 200 characters".into()));
}
s.file_decorations.remove(&key);
s.file_notes.insert(key, (note.into(), tooltip.map(Into::into)));
Ok(())
})
}
@@ -67,6 +67,14 @@ fn decorations_replace_clear_validate_styles_badges_paths_and_limits() {
host::set_file_decoration("a.txt", "test", &file("😀a")).unwrap();
host::clear_file_decorations("test").unwrap();
assert!(mock.file_decorations().is_empty());
host::set_file_decoration("a.txt", "notes", &file("A")).unwrap();
host::set_file_note("a.txt", "notes", "entry point", Some("why")).unwrap();
assert!(mock.file_decorations().is_empty(), "a note replaces the set's annotation");
assert_eq!(mock.file_notes().values().next(), Some(&("entry point".to_string(), Some("why".to_string()))));
assert!(host::set_file_note("a.txt", "notes", "two\nlines", None).is_err());
assert!(host::set_file_note("a.txt", "notes", &"x".repeat(201), None).is_err());
host::clear_file_decorations("notes").unwrap();
assert!(mock.file_notes().is_empty());
for i in 0..50 {
host::set_decorations("a.txt", &format!("set{i}"), &[]).unwrap();
}
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}
+43
View File
@@ -6,6 +6,11 @@
//! and is immutable: the revision in the URL changes with the installed
//! package. HTML gets a CSP of its own, in case a page navigates its frame to
//! one: still sandboxed, still no network.
//!
//! [`FRAME_HOST_PATH`] is the page that the `html` bodies plugin code renders are
//! written into. A `srcdoc` frame would inherit the workbench's CSP, whose
//! `script-src` blocks the inline scripts of those bodies; a page of this scheme
//! has only the policy of its own response.
use crate::state::AppState;
use tauri::http::{Request, Response, StatusCode, header};
@@ -22,6 +27,25 @@ const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \
media-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
connect-src openidea-plugin: http://openidea-plugin.localhost data: blob:";
/// The host page of `html` bodies: it tells the workbench it is ready and writes
/// the document it gets back (`frameDocument` in src/features/plugins/views/frame.ts).
pub const FRAME_HOST_PATH: &str = ".frame";
/// The policy of rendered `html` bodies (`FRAME_CSP` in frame.ts), sandboxed as their frame is.
const FRAME_HOST_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; \
style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
const FRAME_HOST: &str = r#"<!doctype html><meta charset="utf-8"><script>
addEventListener("message", (e) => {
const d = e.data;
if (e.source !== parent || !d || d.tag !== "openidea-view" || typeof d.document !== "string") return;
document.open();
document.write(d.document);
document.close();
});
parent.postMessage({ tag: "openidea-view", ready: true }, "*");
</script>"#;
/// `window.openidea` (one function expression taking the initial theme), the same
/// source the workbench inlines into rendered `html` bodies.
const BRIDGE: &str = include_str!("../../src/features/plugins/views/frame-bridge.js");
@@ -57,6 +81,15 @@ pub fn handle<R: Runtime>(ctx: UriSchemeContext<'_, R>, request: Request<Vec<u8>
fn serve(plugins: &ide_host::plugins::PluginHost, path: &str) -> Response<Vec<u8>> {
let decoded = percent_encoding::percent_decode_str(path.trim_start_matches('/')).decode_utf8_lossy();
if decoded == FRAME_HOST_PATH {
return Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CONTENT_SECURITY_POLICY, FRAME_HOST_CSP)
.header(header::CACHE_CONTROL, "no-cache")
.header("X-Content-Type-Options", "nosniff")
.body(FRAME_HOST.as_bytes().to_vec())
.unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR));
}
let Some((plugin, rest)) = decoded.split_once('/') else { return status(StatusCode::NOT_FOUND) };
match plugins.web_file(plugin, rest) {
Ok(file) => {
@@ -104,4 +137,14 @@ mod tests {
let bare = String::from_utf8(with_bridge(b"<p>x</p>")).unwrap();
assert!(bare.starts_with("<script>(") && bare.ends_with("<p>x</p>"));
}
#[test]
fn the_frame_host_follows_the_workbench_frame() {
let frame = include_str!("../../src/features/plugins/views/frame.ts");
let csp = frame.split("FRAME_CSP =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
assert_eq!(FRAME_HOST_CSP.strip_prefix("sandbox allow-scripts; ").unwrap(), csp);
let tag = frame.split("FRAME_TAG =").nth(1).and_then(|s| s.split('"').nth(1)).unwrap();
assert!(FRAME_HOST.contains(&format!("d.tag !== \"{tag}\"")), "{FRAME_HOST}");
assert!(frame.contains(&format!("FRAME_HOST_PATH = \"{FRAME_HOST_PATH}\"")));
}
}
+2
View File
@@ -4932,6 +4932,8 @@ export type FileDecoration = {
badge: string | null,
style: string,
tooltip: string | null,
/** Text shown after the name (API 1.7 `set-file-note`). */
note?: string | null,
};
export type FileEdit = { kind: "edit"; path: IpcPath; edits: TextEdit[];
+11
View File
@@ -79,6 +79,17 @@ const item = (name: string, kind: OutlineItem["kind"], line: number, children: O
return { name, kind, detail: null, range: r, selection_range: r, children };
};
describe("usages label", () => {
it("is translated", async () => {
const { usagesLabel } = await import("./codeLens");
const { setLocale } = await import("@/platform/i18n");
expect([0, 1, 3].map(usagesLabel)).toEqual(["no usages", "1 usage", "3 usages"]);
await setLocale("zh");
expect([0, 1, 3].map(usagesLabel)).toEqual(["无用法", "1 个用法", "3 个用法"]);
await setLocale("en");
});
});
describe("usages lens targets", () => {
it("skips callbacks and anonymous functions", () => {
const items = [
+2 -2
View File
@@ -81,8 +81,8 @@ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
}
export function usagesLabel(n: number): string {
if (n === 0) return "no usages";
return n === 1 ? "1 usage" : `${n} usages`;
if (n === 0) return tx("no usages");
return n === 1 ? tx("1 usage") : tx`${n} usages`;
}
const setLenses = StateEffect.define<CodeLens[]>();
@@ -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();
@@ -66,6 +66,19 @@ it("filters project roots and unknown styles and replaces tree badges", () => {
expect(getFileDecoration("/p/a.txt")?.badge).toBeUndefined();
});
it("shows a tree note after the name without colouring the file", () => {
const note = {
...set(),
tree: true,
items: [],
file: { style: "", badge: null, tooltip: "why", note: "entry point" },
};
setPluginDecorations("test", [note]);
expect(getFileDecoration("/p/a.txt")).toMatchObject({ suffix: [{ text: "entry point" }], tooltip: "why" });
expect(getFileDecoration("/p/a.txt")?.color).toBeUndefined();
setPluginDecorations("test", []);
});
it("selects UTF-16 ranges and reveals without changing text", () => {
handleRuntimeEvent({ type: "editorRanges", path: ctx.path, ranges: [range], reveal: false });
expect(editor.view.state.selection.main.from).toBe(2);
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import type { View } from "@/bindings";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { FRAME_TAG } from "../views/frame";
import { PluginView } from "../views/PluginView";
vi.mock("@/platform/ipc", async (original) => ({ ...(await original()), isTauri: () => true }));
describe("html bodies in the app", () => {
it("writes the document into the plugin scheme's host page, not a srcdoc that inherits the workbench CSP", async () => {
const view: View = {
toolbar: [],
body: { kind: "html", value: "<p>chart</p><script>draw()</script>" },
status: null,
};
setPluginsBackend({ ...createMemoryPluginsBackend([]), renderView: vi.fn(async () => view) });
const { container } = render(<PluginView pluginId="charts" view="chart" />);
await waitFor(() => expect(container.querySelector("iframe")).not.toBeNull());
const frame = container.querySelector("iframe")!;
expect(frame.hasAttribute("srcdoc")).toBe(false);
expect(frame.getAttribute("src")).toMatch(
/^(openidea-plugin:\/\/localhost|http:\/\/openidea-plugin\.localhost)\/\.frame$/,
);
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
const post = vi.spyOn(frame.contentWindow!, "postMessage");
// Not from the frame: ignored.
window.dispatchEvent(new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: window }));
expect(post).not.toHaveBeenCalled();
window.dispatchEvent(
new MessageEvent("message", { data: { tag: FRAME_TAG, ready: true }, source: frame.contentWindow }),
);
expect(post).toHaveBeenCalledWith(
{ tag: FRAME_TAG, document: expect.stringContaining("<p>chart</p><script>draw()</script>") },
"*",
);
const doc = (post.mock.calls[0]![0] as { document: string }).document;
expect(doc).toContain('<meta http-equiv="Content-Security-Policy"');
expect(doc).toContain("window.openidea");
});
});
+4 -2
View File
@@ -32,13 +32,15 @@ function currentSets(sets: DecorationSet[]) {
function updateTree(plugin: string, sets: DecorationSet[]) {
const items = new Map<string, FileDecoration>();
for (const s of currentSets(sets))
if (s.tree && s.file && Object.hasOwn(colors, s.file.style)) {
if (s.tree && s.file && (Object.hasOwn(colors, s.file.style) || s.file.note)) {
items.set(s.path, {
color: colors[s.file.style],
color: Object.hasOwn(colors, s.file.style) ? colors[s.file.style] : undefined,
badge: s.file.badge ?? undefined,
tooltip: s.file.tooltip ?? undefined,
faded: s.file.style === "muted",
error: s.file.style === "error",
// A note: muted text after the name, like the VCS branch of a root.
suffix: s.file.note ? [{ text: s.file.note }] : undefined,
});
}
setFileDecorations(`plugin:${plugin}`, items);
+19 -9
View File
@@ -18,6 +18,7 @@ import {
} from "react";
import clsx from "clsx";
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
import { isTauri } from "@/platform/ipc";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import {
Checkbox,
@@ -34,9 +35,9 @@ import {
showContextMenu,
type MenuItem,
} from "@/ui";
import { pluginsBackend } from "../backend";
import { pluginsBackend, webSchemeUrl } from "../backend";
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
import { FRAME_HOST_PATH, FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
import { localizeView, usePluginT } from "../pluginText";
import { currentViewLocation, onViewMessage, useViewLocation, useViewRevision } from "./viewStore";
import { ServicePane } from "./ServicePane";
@@ -674,7 +675,10 @@ function HtmlBody({
const frame = useRef<HTMLIFrameElement>(null);
// The theme at load time is in the document; later changes are posted, so a
// theme switch doesn't reload the frame and lose its state.
const srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
const doc = useMemo(() => frameDocument(html, frameTheme()), [html]);
// In the app the document is written into a page of the plugin scheme: a `srcdoc` frame
// would inherit the workbench's CSP, which blocks the document's inline scripts.
const host = isTauri() ? webSchemeUrl(FRAME_HOST_PATH) : null;
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
const postTheme = useCallback(
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
@@ -685,8 +689,10 @@ function HtmlBody({
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== frame.current?.contentWindow) return;
const data = e.data as { tag?: unknown; text?: unknown } | null;
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
const data = e.data as { tag?: unknown; text?: unknown; ready?: unknown } | null;
if (data?.tag !== FRAME_TAG) return;
if (data.ready === true) frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, document: doc }, "*");
else if (typeof data.text === "string") send({ kind: "message", text: data.text });
};
window.addEventListener("message", onMessage);
const off = onViewMessage(pluginId, view, (text) =>
@@ -696,18 +702,22 @@ function HtmlBody({
window.removeEventListener("message", onMessage);
off();
};
}, [pluginId, view, send]);
}, [pluginId, view, send, doc]);
// No `allow-same-origin`: an opaque origin without access to the IDE.
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
return (
// `onLoad` re-sends the theme in case it changed between building the document and the frame loading.
// A new document loads a new frame (`key`), as a changed `srcdoc` does.
return host ? (
<iframe
key={doc}
ref={frame}
className={styles.frame}
title={view}
sandbox="allow-scripts"
srcDoc={srcDoc}
src={host}
onLoad={postTheme}
/>
) : (
<iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={doc} onLoad={postTheme} />
);
}
+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") },
+7
View File
@@ -19,6 +19,13 @@ export const FRAME_CSP =
/** Marks frame ↔ workbench messages. */
export const FRAME_TAG = "openidea-view";
/**
* The page of the `openidea-plugin` scheme that a rendered document is written into
* (src-tauri/src/plugin_web.rs). A `srcdoc` frame would inherit the workbench's CSP,
* which blocks inline scripts; this page has only {@link FRAME_CSP}.
*/
export const FRAME_HOST_PATH = ".frame";
/**
* The theme variables a frame gets: public name → IDE token. Part of the
* plugin API: names are only ever added, so the IDE's own tokens can change.
+1 -1
View File
@@ -471,7 +471,7 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
<>
<div className={styles.search}>
<SearchField
className={root ? styles.searchGrow : undefined}
className={styles.searchGrow}
data-autofocus
value={query}
onChange={(v) => {
+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),
@@ -138,6 +138,16 @@ describe("buildBranchRows", () => {
expect(summary(buildBranchRows(branches, [], "push", actions))).toEqual(["> Push…"]);
});
it("matches a query as a substring or at word starts, not as letters scattered through the name", () => {
const list = ["sprint-1.1-device-reset", "feature/test-paging", "fix/TestRunner", "bugfix/totals-reset"].map((n) =>
b(n),
);
const search = (q: string) => summary(buildBranchRows(list, [], q, [], null, [], { groupByPrefix: false }));
expect(search("test")).toEqual(["v Local", " local:feature/test-paging", " local:fix/TestRunner"]);
// Word starts still work: `ftp` finds feature/test-paging.
expect(search("ftp")).toEqual(["v Local", " local:feature/test-paging"]);
});
it("omits empty groups", () => {
expect(summary(buildBranchRows([b("main", { current: true })], [], "", []))).toEqual(["v Local", " local:main ★"]);
});
+2 -2
View File
@@ -1,7 +1,7 @@
/** Branches popup model: grouping, ordering and filtering (pure). */
import type { Branch } from "@/bindings";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui/match";
import { wordMatch } from "@/ui/match";
export interface PopupAction {
id: string;
@@ -213,7 +213,7 @@ export function buildBranchRows(
const q = query.trim();
const match = (text: string): number[] | null | false => {
if (!q) return null;
return fuzzyMatch(text, q) ?? false;
return wordMatch(text, q) ?? false;
};
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const rows: BranchRow[] = actionRows(actions, match, !q);
+4 -3
View File
@@ -1,7 +1,7 @@
/** Branches popup model for a project with several repositories (pure). */
import type { Branch } from "@/bindings";
import { tx } from "@/platform/i18n";
import { fuzzyMatch } from "@/ui/match";
import { fuzzyMatch, wordMatch } from "@/ui/match";
import { rootOf } from "./model";
import {
actionRows,
@@ -173,7 +173,7 @@ export function buildMultiRepoRows({
showTags = true,
}: MultiRepoInput): RepoRow[] {
const q = query.trim();
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
const match = (text: string): number[] | null | false => (q ? (wordMatch(text, q) ?? false) : null);
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
const blocks: RepoRow[][] = [];
@@ -181,7 +181,8 @@ export function buildMultiRepoRows({
const repoRows: RepoRow[] = [];
repos.forEach((repo, i) => {
const m = match(repo.name);
// Repository names keep the loose match: the list is short and abbreviations like `deps` help.
const m = q ? (fuzzyMatch(repo.name, q) ?? false) : null;
if (m !== false)
repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m });
});
+4 -1
View File
@@ -1469,5 +1469,8 @@
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.",
"Archived agent tabs to keep": "Archived agent tabs to keep",
"Beyond this many archived agent tabs, the oldest are closed.": "Beyond this many archived agent tabs, the oldest are closed.",
"Archived ({count})": "Archived ({count})"
"Archived ({count})": "Archived ({count})",
"no usages": "no usages",
"1 usage": "1 usage",
"{n} usages": "{n} usages"
}
+4 -1
View File
@@ -7867,5 +7867,8 @@
"Commands agents run appear in the Run tool window as _agent_ tabs. Beyond this many finished ones, the oldest move to the archive at the end of the tab strip. Running commands are never archived.": "智能体运行的命令以 _agent_ 标签显示在运行工具窗口中。已结束的标签超过这个数量时,最旧的会移入标签栏末尾的归档。正在运行的命令永远不会被归档。",
"Archived agent tabs to keep": "保留的已归档智能体标签数",
"Beyond this many archived agent tabs, the oldest are closed.": "已归档的智能体标签超过这个数量时,最旧的会被关闭。",
"Archived ({count})": "归档({count})"
"Archived ({count})": "归档({count})",
"no usages": "无用法",
"1 usage": "1 个用法",
"{n} usages": "{n} 个用法"
}
+3
View File
@@ -19,4 +19,7 @@ interface decorations {
set-file: func(path: string, set-id: string, item: file-decoration) -> result<_, error>;
/// Remove this tree set from every file/project.
clear-file: func(set-id: string) -> result<_, error>;
/// Replace this set's tree annotation for a file with a note shown after its name (API 1.7):
/// one line of at most 200 characters, with an optional tooltip.
set-file-note: func(path: string, set-id: string, note: string, tooltip: option<string>) -> result<_, error>;
}