Compare commits

..
Author SHA1 Message Date
l0ng-ai de12bf1189 chore(plugins): rebuild components for set-file-note 2026-11-13 14:15:46 +08:00
l0ng-ai 4c71891924 feat(plugins): notes after file names in the project tree (API 1.7)
Tree decorations could only carry a badge of at most two characters and a
tooltip, so a plugin could not label files and folders with a visible
description.

decorations.set-file-note shows one line of muted text (at most 200
characters, optional tooltip) after a file or folder name, as the project
tree already does for a root's VCS branch. It replaces that tree set's
item for the file, shares the tree-set limits, and is cleared with
clear-file. The SDK wraps it as host::set_file_note with a mock.
2026-11-13 14:08:19 +08:00
23 changed files with 55 additions and 312 deletions

No files matched your search

+7 -24
View File
@@ -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]
+6 -13
View File
@@ -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());
-3
View File
@@ -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! {
-7
View File
@@ -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,
})
}
-3
View File
@@ -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 }],
-6
View File
@@ -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
View File
@@ -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]
-43
View File
@@ -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}\"")));
}
}
-11
View File
@@ -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 = [
+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 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");
});
});
+9 -19
View File
@@ -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} />
);
}
+2 -15
View File
@@ -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") },
-7
View File
@@ -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.
+1 -1
View File
@@ -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) => {
+1 -3
View File
@@ -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 ★"]);
});
+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 { 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);
+3 -4
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, 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 });
});
+1 -4
View File
@@ -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})"
}
+1 -4
View File
@@ -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})"
}