mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de12bf1189 | ||
|
|
4c71891924 |
No files matched your search
@@ -393,13 +393,11 @@ fn scan_versions(dir: &Path, typed: &str, budget: &mut u32) -> Vec<PomCompletion
|
||||
out
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
/// 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`.
|
||||
fn repo_dir(repo: &Path, coordinates: &[&str]) -> Option<PathBuf> {
|
||||
let (group, artifact) = coordinates.split_first()?;
|
||||
let segments: Vec<&str> = group.split('.').chain(artifact.iter().copied()).collect();
|
||||
let segments: Vec<&str> = coordinates.iter().flat_map(|c| c.split('.')).collect();
|
||||
segments.iter().all(|s| segment(s)).then(|| segments.iter().fold(repo.to_path_buf(), |p, s| p.join(s)))
|
||||
}
|
||||
|
||||
@@ -462,7 +460,6 @@ 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",
|
||||
@@ -573,10 +570,9 @@ mod tests {
|
||||
};
|
||||
// Only directories holding artifacts are group ids: `net` and `org`
|
||||
// alone never receive an install, their deeper levels do.
|
||||
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(""), ["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"]);
|
||||
assert_eq!(options("org.spr"), Vec::<String>::new());
|
||||
assert_eq!(options("net."), ["net.nu"]);
|
||||
}
|
||||
@@ -627,19 +623,6 @@ 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]
|
||||
|
||||
@@ -61,16 +61,13 @@ 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.
|
||||
@@ -524,10 +521,6 @@ 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());
|
||||
|
||||
@@ -239,9 +239,6 @@ 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! {
|
||||
|
||||
@@ -120,8 +120,6 @@ 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 {
|
||||
@@ -160,8 +158,6 @@ struct DocSnap {
|
||||
language: LanguageId,
|
||||
text: Arc<DocText>,
|
||||
servers: Vec<Arc<Server>>,
|
||||
/// [`DocState::changed`].
|
||||
changed: std::time::Instant,
|
||||
}
|
||||
|
||||
impl DocSnap {
|
||||
@@ -1174,7 +1170,6 @@ 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();
|
||||
@@ -1222,7 +1217,6 @@ 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
|
||||
@@ -1398,7 +1392,6 @@ impl LspManager {
|
||||
language: d.language.clone(),
|
||||
text: d.text.clone(),
|
||||
servers,
|
||||
changed: d.changed,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -213,7 +213,6 @@ 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));
|
||||
@@ -235,7 +234,6 @@ 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));
|
||||
@@ -261,7 +259,6 @@ 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 }],
|
||||
|
||||
@@ -2204,12 +2204,6 @@ 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")]
|
||||
|
||||
+15
-69
@@ -17,18 +17,11 @@ 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]` (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) || {}; \
|
||||
/// 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) || {}; \
|
||||
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);
|
||||
@@ -49,33 +42,24 @@ pub(crate) fn tsserver_plugin(package: &Path) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// `compilerOptions.paths` for the webpack aliases of a Vue project at `root` (`@/*` →
|
||||
/// `compilerOptions.paths` for the webpack aliases of a Vue CLI 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 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.
|
||||
/// 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.
|
||||
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() };
|
||||
let dependencies = package_dependencies(&manifest);
|
||||
let cli = dependencies.iter().any(|d| d == CLI_SERVICE);
|
||||
if !cli && !dependencies.iter().any(|d| d == "vue") {
|
||||
if !package_dependencies(&manifest).iter().any(|d| d == CLI_SERVICE) {
|
||||
return Paths::new();
|
||||
}
|
||||
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();
|
||||
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();
|
||||
inputs.extend(config.iter().cloned());
|
||||
inputs.extend(node.map(Path::to_path_buf));
|
||||
let now = stamp(&inputs);
|
||||
@@ -87,7 +71,6 @@ 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()));
|
||||
@@ -198,43 +181,6 @@ 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,9 +61,8 @@ 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> {
|
||||
// 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("/");
|
||||
(part(group, 200) && part(artifact, 100)).then(|| {
|
||||
let path = group.split('.').chain(artifact.split('.')).collect::<Vec<_>>().join("/");
|
||||
format!("https://repo1.maven.org/maven2/{path}/maven-metadata.xml")
|
||||
})
|
||||
}
|
||||
@@ -147,11 +146,6 @@ 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]
|
||||
|
||||
@@ -6,11 +6,6 @@
|
||||
//! 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};
|
||||
@@ -27,25 +22,6 @@ 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");
|
||||
@@ -81,15 +57,6 @@ 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) => {
|
||||
@@ -137,14 +104,4 @@ 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}\"")));
|
||||
}
|
||||
}
|
||||
@@ -79,17 +79,6 @@ 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 = [
|
||||
|
||||
@@ -81,8 +81,8 @@ export function lensTargets(items: readonly OutlineItem[]): LensTarget[] {
|
||||
}
|
||||
|
||||
export function usagesLabel(n: number): string {
|
||||
if (n === 0) return tx("no usages");
|
||||
return n === 1 ? tx("1 usage") : tx`${n} usages`;
|
||||
if (n === 0) return "no usages";
|
||||
return n === 1 ? "1 usage" : `${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: "plugin-diff:/service.toml",
|
||||
path: "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,26 +239,18 @@ describe("plugin-backed content editor tabs", () => {
|
||||
openPluginDiffRequest({ left: "", right: "b\n", title: "MR !12" }, 10);
|
||||
expect(diffs).toEqual([
|
||||
{
|
||||
path: "plugin-diff:/review.ts",
|
||||
path: "src/review.ts",
|
||||
title: "src/review.ts",
|
||||
left: { type: "text", text: "a\n", label: "Target" },
|
||||
right: { type: "text", text: "b\n", label: "After" },
|
||||
},
|
||||
{
|
||||
path: "plugin-diff:/MR !12",
|
||||
path: "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();
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ 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,
|
||||
@@ -35,9 +34,9 @@ import {
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { pluginsBackend, webSchemeUrl } from "../backend";
|
||||
import { pluginsBackend } from "../backend";
|
||||
import { subscribeTheme, themeKey } from "@/platform/themeWatch";
|
||||
import { FRAME_HOST_PATH, FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { FRAME_TAG, frameDocument, frameTheme, frameThemeMessage } from "./frame";
|
||||
import { localizeView, usePluginT } from "../pluginText";
|
||||
import { currentViewLocation, onViewMessage, useViewLocation, useViewRevision } from "./viewStore";
|
||||
import { ServicePane } from "./ServicePane";
|
||||
@@ -675,10 +674,7 @@ 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 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 srcDoc = useMemo(() => frameDocument(html, frameTheme()), [html]);
|
||||
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
|
||||
const postTheme = useCallback(
|
||||
() => frame.current?.contentWindow?.postMessage(frameThemeMessage(frameTheme()), "*"),
|
||||
@@ -689,10 +685,8 @@ function HtmlBody({
|
||||
useEffect(() => {
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.source !== frame.current?.contentWindow) return;
|
||||
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 });
|
||||
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 });
|
||||
};
|
||||
window.addEventListener("message", onMessage);
|
||||
const off = onViewMessage(pluginId, view, (text) =>
|
||||
@@ -702,22 +696,18 @@ function HtmlBody({
|
||||
window.removeEventListener("message", onMessage);
|
||||
off();
|
||||
};
|
||||
}, [pluginId, view, send, doc]);
|
||||
}, [pluginId, view, send]);
|
||||
|
||||
// No `allow-same-origin`: an opaque origin without access to the IDE.
|
||||
// `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 ? (
|
||||
// `onLoad` re-sends the theme in case it changed between building `srcDoc` and the frame loading.
|
||||
return (
|
||||
<iframe
|
||||
key={doc}
|
||||
ref={frame}
|
||||
className={styles.frame}
|
||||
title={view}
|
||||
sandbox="allow-scripts"
|
||||
src={host}
|
||||
srcDoc={srcDoc}
|
||||
onLoad={postTheme}
|
||||
/>
|
||||
) : (
|
||||
<iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={doc} onLoad={postTheme} />
|
||||
);
|
||||
}
|
||||
@@ -176,22 +176,9 @@ function validDiff(v: unknown): ContentTabDiff | undefined {
|
||||
}
|
||||
|
||||
/** Opens a plugin's two texts in the diff viewer; `path` picks the syntax highlighting. */
|
||||
/**
|
||||
* 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) {
|
||||
export function openPluginDiff(diff: ContentTabDiff, path: string, title: string) {
|
||||
void executeAction("vcs.openDiff", {
|
||||
path: pluginDiffPath(name),
|
||||
path,
|
||||
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") },
|
||||
|
||||
@@ -19,13 +19,6 @@ 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.
|
||||
|
||||
@@ -471,7 +471,7 @@ function BranchesPanel({ onClose, actions: baseActions, root, fallback, onBack }
|
||||
<>
|
||||
<div className={styles.search}>
|
||||
<SearchField
|
||||
className={styles.searchGrow}
|
||||
className={root ? styles.searchGrow : undefined}
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ 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";
|
||||
@@ -820,8 +819,7 @@ export function DiffEditor({
|
||||
const fileSource = sideFile(left, path) ?? sideFile(right, path);
|
||||
const [onDisk, setOnDisk] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
// A virtual path (a plugin's text diff) names no file on disk.
|
||||
if (fileSource || isVirtualUri(path)) return;
|
||||
if (fileSource) return;
|
||||
let alive = true;
|
||||
void call(commands.fsProbe(path)).then(
|
||||
() => alive && setOnDisk(path),
|
||||
|
||||
@@ -138,16 +138,6 @@ 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 ★"]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Branches popup model: grouping, ordering and filtering (pure). */
|
||||
import type { Branch } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { wordMatch } from "@/ui/match";
|
||||
import { fuzzyMatch } 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 wordMatch(text, q) ?? false;
|
||||
return fuzzyMatch(text, q) ?? false;
|
||||
};
|
||||
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
|
||||
const rows: BranchRow[] = actionRows(actions, match, !q);
|
||||
|
||||
@@ -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, wordMatch } from "@/ui/match";
|
||||
import { fuzzyMatch } 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 ? (wordMatch(text, q) ?? false) : null);
|
||||
const match = (text: string): number[] | null | false => (q ? (fuzzyMatch(text, q) ?? false) : null);
|
||||
const open = (key: string, byDefault: boolean) => !!q || (expanded.get(key) ?? byDefault);
|
||||
const blocks: RepoRow[][] = [];
|
||||
|
||||
@@ -181,8 +181,7 @@ export function buildMultiRepoRows({
|
||||
|
||||
const repoRows: RepoRow[] = [];
|
||||
repos.forEach((repo, i) => {
|
||||
// Repository names keep the loose match: the list is short and abbreviations like `deps` help.
|
||||
const m = q ? (fuzzyMatch(repo.name, q) ?? false) : null;
|
||||
const m = match(repo.name);
|
||||
if (m !== false)
|
||||
repoRows.push({ type: "repo", key: `repo:${repo.root}`, repo, color: repoColorIndex(i), matches: m });
|
||||
});
|
||||
|
||||
@@ -1469,8 +1469,5 @@
|
||||
"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})",
|
||||
"no usages": "no usages",
|
||||
"1 usage": "1 usage",
|
||||
"{n} usages": "{n} usages"
|
||||
"Archived ({count})": "Archived ({count})"
|
||||
}
|
||||
@@ -7867,8 +7867,5 @@
|
||||
"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})",
|
||||
"no usages": "无用法",
|
||||
"1 usage": "1 个用法",
|
||||
"{n} usages": "{n} 个用法"
|
||||
"Archived ({count})": "归档({count})"
|
||||
}
|
||||
Reference in new issue
Block a user