diff --git a/crates/ide-host/src/plugins/api.rs b/crates/ide-host/src/plugins/api.rs index 2a20f9561..434a276be 100644 --- a/crates/ide-host/src/plugins/api.rs +++ b/crates/ide-host/src/plugins/api.rs @@ -733,6 +733,14 @@ impl HostApi for PluginApi { Ok(()) } + fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> { + let client = from_window(client, "open a project")?; + self.shared + .sink + .emit(Some(client), PluginEvent::OpenProject { plugin_id: self.id().to_owned(), path, new_window }); + Ok(()) + } + fn ask( &self, client: Option<&str>, diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index b003ab921..ca463134f 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -119,6 +119,7 @@ pub fn forwarded(event: &PluginEvent) -> bool { | PluginEvent::OpenLocation { .. } | PluginEvent::CopyToClipboard { .. } | PluginEvent::OpenUrl { .. } + | PluginEvent::OpenProject { .. } | PluginEvent::PromptRequested { .. } | PluginEvent::PromptClosed { .. } | PluginEvent::RevealView { .. } diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index 61b75abb8..0cf9b8b20 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -222,6 +222,9 @@ pub enum PluginEvent { /// Open `url` (`http(s)://`) in the system browser. #[serde(rename_all = "camelCase")] OpenUrl { plugin_id: String, url: String }, + /// A window command/event requests the normal File > Open flow. + #[serde(rename_all = "camelCase")] + OpenProject { plugin_id: String, path: String, new_window: bool }, /// A command waits for the user's answer: show `prompt`, then report it with /// `plugin_answer_prompt(prompt_id, …)`. #[serde(rename_all = "camelCase")] diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 74ed0de20..c84170442 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -3352,3 +3352,40 @@ fn invalid_plugin_icons_refuse_the_package() { assert!(err.contains(expected), "{id}: {err}"); } } + +#[test] +fn directory_prompts_and_project_requests_use_the_calling_window_without_a_capability() { + use ide_plugin::Prompt; + let f = fixture(Limits { timeout: Duration::from_millis(500), ..Limits::default() }); + f.install(&sample("jobs")); + f.host.set_capability("jobs", "project.read", false).unwrap(); + let (result, prompt) = + run_and_answer(&f, "pick-directory", Some("C:/work"), Some(&["C:/work/repo"]), Duration::from_millis(700)); + assert_eq!(result.unwrap().as_deref(), Some("C:/work/repo")); + assert_eq!(prompt, Prompt::PickDirectory { title: "Clone target".into(), initial: Some("C:/work".into()) }); + assert_eq!(run_and_answer(&f, "pick-directory", None, None, Duration::ZERO).0.unwrap().as_deref(), Some("none")); + assert!(run_and_answer(&f, "pick-directory", None, Some(&["relative"]), Duration::ZERO).0.is_err()); + assert!(f.run("jobs", "pick-directory", Some("relative")).is_err()); + for (cmd, new_window) in [("open-project", false), ("open-project-new", true)] { + f.take_events(); + let inv = Invocation { client: Some("second".into()), ..f.inv() }; + f.host.run_command("jobs", cmd, Some("C:/work/repo"), inv).unwrap(); + let requests = f + .take_events() + .into_iter() + .filter_map(|(client, event)| match event { + PluginEvent::OpenProject { plugin_id, path, new_window } => Some((client, plugin_id, path, new_window)), + _ => None, + }) + .collect::>(); + assert_eq!(requests, vec![(Some("second".into()), "jobs".into(), "C:/work/repo".into(), new_window)]); + let error = f.host.run_command("jobs", cmd, Some("/work/repo"), Invocation::default()).unwrap_err(); + assert_eq!(error.kind, ErrorKind::InvalidInput, "{error:?}"); + } + assert!(f.host.run_command("jobs", "pick-directory", None, Invocation::default()).is_err()); + for path in ["relative", "/bad\npath", ""] { + f.take_events(); + assert!(f.run("jobs", "open-project", Some(path)).is_err()); + assert!(!f.take_events().iter().any(|(_, event)| matches!(event, PluginEvent::OpenProject { .. }))); + } +} diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index d27449533..ce2a59298 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -69,6 +69,10 @@ pub trait HostApi: Send + Sync { let _ = (client, url); Ok(()) } + fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> { + let _ = (client, path, new_window); + Err(HostError::Invalid("opening projects is unavailable".into())) + } /// Shows `prompt` in `client`'s window and waits for the answer until the /// user gives one or `cancel` fires: the chosen keys (`QuickPick`), the /// text (`Input`, one element) or any value (`Confirm`: OK); `None` when diff --git a/crates/ide-plugin/src/types.rs b/crates/ide-plugin/src/types.rs index d071a247f..02e1b86dd 100644 --- a/crates/ide-plugin/src/types.rs +++ b/crates/ide-plugin/src/types.rs @@ -88,6 +88,8 @@ pub enum Prompt { Input { prompt: String, value: Option, multiline: bool }, #[serde(rename_all = "camelCase")] Confirm { title: String, message: String }, + #[serde(rename_all = "camelCase")] + PickDirectory { title: String, initial: Option }, } /// Kinds of workbench events a plugin can subscribe to (`on-event:`). diff --git a/crates/ide-plugin/src/wit_host.rs b/crates/ide-plugin/src/wit_host.rs index 368510666..acdba792d 100644 --- a/crates/ide-plugin/src/wit_host.rs +++ b/crates/ide-plugin/src/wit_host.rs @@ -350,6 +350,22 @@ impl ui::Host for StoreData { Ok(answer.map(|keys| keys.and_then(|keys| keys.into_iter().next()))) } + fn pick_directory(&mut self, title: String, initial: Option) -> Fallible> { + if let Some(path) = &initial + && let Err(error) = validate_ui_path(path) + { + return Ok(Err(error)); + } + let answer = self.ask(Prompt::PickDirectory { title: clip(title, MAX_TITLE), initial }); + Ok(answer.and_then(|answer| { + let path = answer.and_then(|items| items.into_iter().next()); + if let Some(path) = &path { + validate_ui_path(path)?; + } + Ok(path) + })) + } + fn pick_many(&mut self, title: String, items: Vec) -> Fallible>> { Ok(self.pick_prompt(title, items, true)) } @@ -407,6 +423,14 @@ impl storage::Host for StoreData { } impl workspace::Host for StoreData { + fn open_project(&mut self, path: String, new_window: bool) -> Fallible<()> { + if let Err(error) = self.require_interactive().and_then(|_| validate_ui_path(&path)) { + return Ok(Err(error)); + } + let client = self.invocation.client.clone(); + Ok(self.host.open_project(client.as_deref(), path, new_window).map_err(to_wit)) + } + fn projects(&mut self) -> Fallible>> { if let Err(e) = self.require(Capability::ProjectRead) { return Ok(Err(e)); @@ -994,3 +1018,32 @@ pub(crate) mod file_actions_bindings { with: { "openidea:plugin/types": super::bindings::openidea::plugin::types }, }); } + +/// Paths belong to the UI machine, which can have a different OS than the plugin host. +fn validate_ui_path(path: &str) -> Result<(), wit::Error> { + let b = path.as_bytes(); + let absolute = path.starts_with('/') + || path.starts_with("\\\\") + || (b.first().is_some_and(u8::is_ascii_alphabetic) + && b.get(1) == Some(&b':') + && matches!(b.get(2), Some(b'/' | b'\\'))); + if !absolute || path.len() > 8192 || path.chars().any(char::is_control) { + return Err(wit::Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into())); + } + Ok(()) +} + +#[cfg(test)] +mod project_ui_tests { + use super::validate_ui_path; + #[test] + fn validates_ui_paths_without_using_the_plugin_hosts_os() { + for path in ["/work/repo", "C:/work/repo", r"C:\work\repo", r"\\server\share\repo"] { + assert!(validate_ui_path(path).is_ok(), "{path:?}"); + } + for path in ["repo", "C:repo", "/bad\npath", ""] { + assert!(validate_ui_path(path).is_err(), "{path:?}"); + } + assert!(validate_ui_path(&format!("/{}", "x".repeat(8192))).is_err()); + } +} diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index afeba1992..fe3499425 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07 +2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 1c16ed578..b42bca3d3 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67 +6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 75d41c99a..b42508379 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54 +1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index d4079b4f3..1f0182d59 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10 +b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index fc837c71a..c39baa1ef 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb +62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm index f6d907d3d..1becbb272 100644 Binary files a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 278f01c95..850d179fd 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d +79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f diff --git a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml index 6da670a06..9f5e3ee51 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/plugin.toml +++ b/crates/ide-plugin/tests/fixtures/jobs/plugin.toml @@ -38,3 +38,13 @@ category = "Jobs" [contributes.settings] title = "Jobs" schema = "settings.schema.json" + +[[contributes.commands]] +id = "pick-directory" +title = "Pick directory" +[[contributes.commands]] +id = "open-project" +title = "Open project" +[[contributes.commands]] +id = "open-project-new" +title = "Open project in new window" diff --git a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs index 9ec2d17bb..9d03b33a2 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/jobs/src/lib.rs @@ -61,6 +61,11 @@ impl Plugin for Jobs { host::open_url(arg.unwrap_or_default())?; Ok(None) } + "pick-directory" => Ok(Some(host::pick_directory("Clone target", arg)?.unwrap_or_else(|| "none".into()))), + "open-project" | "open-project-new" => { + host::open_project(arg.unwrap_or_default(), id == "open-project-new")?; + Ok(None) + } "pick" | "pick-many" => { let long_key = "k".repeat(300); let items = [ diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 9b46a386e..78e0147a0 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24 +6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index fecbd15f9..c1d39c41d 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d +9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index ed6a8ea46..6337c0927 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82 +fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 87140907b..f58a78ab7 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d +5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840 diff --git a/plugins/README.md b/plugins/README.md index 9bada5b16..b87015afb 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1254,3 +1254,17 @@ A new host function touches: Then run `plugins/build.sh`; CI's `plugins` job runs `plugins/build.sh --check` plus fmt, clippy (host and wasm) and the tests of the SDK, the official plugins and the fixtures. + +### Directory selection and opening projects (API 1.3) + +`host::pick_directory(title, initial)` opens the window's native folder chooser +and returns an absolute path, or `None` when dismissed. Like `pick`, it is only +available during a command or event from a window; waiting for the user does not +consume the plugin's call budget. `initial` is an optional absolute directory. + +`host::open_project(path, new_window)` requests the normal File › Open flow in +that window (`false`) or a new window (`true`). Existing project windows are +reused as usual, and the normal trust prompt and unsaved-change guard apply. +The call acknowledges the request; cancellation or opening errors are handled by +the window. Neither API needs a capability. Paths belong to the UI machine, +including when plugin code runs on a remote host. diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index 07b79acd0..e57057dab 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5 +2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ab7fc8de2..e79f0de1f 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186 +af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 1e822e99b..e2b69d6f9 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c +a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 1b79b8a4e..f7fe17cce 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b +d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 678c34a17..e930ae3a1 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f +d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index fc8a91825..9d3a94175 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5 +b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 12094f2f7..1ef873589 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71 +c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index a97db01e5..6dd517787 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237 +fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index f6ed5943c..e48b05177 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f +53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 4f4a414a0..609942a06 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075 +2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 2b16319be..1d1abe1b9 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -73b65742330387b7dbf498c6ec275ab41a55475243d771c1274de70b939c327f +82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23 diff --git a/sdk/rust/openidea-plugin/src/host.rs b/sdk/rust/openidea-plugin/src/host.rs index 393858ff8..dce8a446b 100644 --- a/sdk/rust/openidea-plugin/src/host.rs +++ b/sdk/rust/openidea-plugin/src/host.rs @@ -109,6 +109,13 @@ mod imp { wit::ui::pick(title, &pick_items(items)).map_err(error) } + pub fn pick_directory(title: &str, initial: Option<&str>) -> Result, Error> { + wit::ui::pick_directory(title, initial).map_err(error) + } + pub fn open_project(path: &str, new_window: bool) -> Result<(), Error> { + wit::workspace::open_project(path, new_window).map_err(error) + } + pub fn pick_many(title: &str, items: &[PickItem]) -> Result>, Error> { wit::ui::pick_many(title, &pick_items(items)).map_err(error) } @@ -799,3 +806,17 @@ pub mod storage { #[cfg(target_arch = "wasm32")] #[doc(hidden)] pub use imp::{event as __event, range_from as __range_from, range_to as __range_to}; + +/// Chooses a directory on the UI machine. `None` when dismissed; no capability. +/// Same window/command/event rules as [`pick`]. +pub fn pick_directory(title: &str, initial: Option<&str>) -> Result, Error> { + imp::pick_directory(title, initial) +} + +/// Requests the normal File > Open flow, including project trust, in the +/// invoking window (`false`) or a new window (`true`). No capability. +/// Only commands/events of a window can request this; errors opening the +/// project are shown by that window. `path` belongs to the UI machine. +pub fn open_project(path: &str, new_window: bool) -> Result<(), Error> { + imp::open_project(path, new_window) +} diff --git a/sdk/rust/openidea-plugin/src/mock.rs b/sdk/rust/openidea-plugin/src/mock.rs index 618779787..4466a43e0 100644 --- a/sdk/rust/openidea-plugin/src/mock.rs +++ b/sdk/rust/openidea-plugin/src/mock.rs @@ -72,6 +72,7 @@ struct State { fetch_error: Option, clipboard: Option, opened_urls: Vec, + opened_projects: Vec<(String, bool)>, answers: VecDeque>>, revealed_views: Vec, diagnostics: BTreeMap>, @@ -130,6 +131,11 @@ impl MockHost { Self { state } } + /// Project-open requests, in order: (UI path, new window). + pub fn opened_projects(&self) -> Vec<(String, bool)> { + self.state.borrow().opened_projects.clone() + } + /// Grants exactly these capabilities. pub fn with_capabilities(self, caps: &[&str]) -> Self { self.state.borrow_mut().capabilities = caps.iter().map(|s| (*s).to_owned()).collect(); @@ -470,6 +476,33 @@ pub mod current { }) } + pub fn pick_directory(_title: &str, initial: Option<&str>) -> Result, Error> { + if let Some(path) = initial { + ui_path(path)?; + } + Ok(with(|s| s.answers.pop_front().flatten()).and_then(|a| a.into_iter().next())) + } + + pub fn open_project(path: &str, new_window: bool) -> Result<(), Error> { + ui_path(path)?; + with(|s| s.opened_projects.push((path.to_owned(), new_window))); + Ok(()) + } + + /// The host's check: an absolute path on the UI machine, whatever its OS. + fn ui_path(path: &str) -> Result<(), Error> { + let b = path.as_bytes(); + let absolute = path.starts_with('/') + || path.starts_with("\\\\") + || (b.first().is_some_and(u8::is_ascii_alphabetic) + && b.get(1) == Some(&b':') + && matches!(b.get(2), Some(b'/' | b'\\'))); + if !absolute || path.len() > 8192 || path.chars().any(char::is_control) { + return Err(Error::Invalid("expected an absolute UI directory path of at most 8192 bytes".into())); + } + Ok(()) + } + pub fn pick(title: &str, items: &[PickItem]) -> Result, Error> { let keys = pick_many(title, items)?; match keys { @@ -1045,3 +1078,22 @@ mod tests { assert_eq!(mock.diagnostics()["/project/a.java"].len(), 1); } } + +#[cfg(test)] +mod project_ui_tests { + use super::MockHost; + use crate::host; + #[test] + fn folder_choices_and_project_requests_need_no_capability() { + let mock = MockHost::install().with_capabilities(&[]); + mock.answer_next_prompt(Some(&["C:/work/repo"])); + assert_eq!(host::pick_directory("Target", Some("C:/work")).unwrap().as_deref(), Some("C:/work/repo")); + assert_eq!(host::pick_directory("Target", None).unwrap(), None); + host::open_project("C:/work/repo", true).unwrap(); + host::open_project("/home/user/repo", false).unwrap(); + assert_eq!(mock.opened_projects(), vec![("C:/work/repo".into(), true), ("/home/user/repo".into(), false)]); + for path in ["relative", "C:relative", "/bad\npath", ""] { + assert!(host::open_project(path, false).is_err()); + } + } +} diff --git a/sdk/rust/openidea-plugin/wit/host.wit b/sdk/rust/openidea-plugin/wit/host.wit index 3b74fc92f..06a8885c5 100644 --- a/sdk/rust/openidea-plugin/wit/host.wit +++ b/sdk/rust/openidea-plugin/wit/host.wit @@ -57,6 +57,9 @@ interface ui { /// a window (including in editor providers). Waiting doesn't count against /// the call's budget. pick: func(title: string, items: list) -> result, error>; + /// Native folder chooser; an absolute path, or `none` when dismissed. + /// Same window, command/event and budget rules as `pick`; no capability. + pick-directory: func(title: string, initial: option) -> result, error>; /// Like `pick`, with any number of items chosen. pick-many: func(title: string, items: list) -> result>, error>; /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. diff --git a/sdk/rust/openidea-plugin/wit/workspace.wit b/sdk/rust/openidea-plugin/wit/workspace.wit index bcf84e32e..b31964e8b 100644 --- a/sdk/rust/openidea-plugin/wit/workspace.wit +++ b/sdk/rust/openidea-plugin/wit/workspace.wit @@ -3,6 +3,11 @@ package openidea:plugin@1.3.0; interface workspace { use types.{error}; + /// Requests File > Open in this command/event's window (or a new window), + /// including the usual project trust prompt. Needs no capability. + /// `path` is an absolute directory path on the UI machine. + open-project: func(path: string, new-window: bool) -> result<_, error>; + /// What a path inside a project is. enum file-kind { file, diff --git a/src/bindings.ts b/src/bindings.ts index f185b0b2f..620e4676b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5979,6 +5979,8 @@ export type PluginEvent = { type: "copyToClipboard"; pluginId: string; text: string } | /** Open `url` (`http(s)://`) in the system browser. */ { type: "openUrl"; pluginId: string; url: string } | +/** A window command/event requests the normal File > Open flow. */ +{ type: "openProject"; pluginId: string; path: string; newWindow: boolean } | /** * A command waits for the user's answer: show `prompt`, then report it with * `plugin_answer_prompt(prompt_id, …)`. @@ -6384,7 +6386,7 @@ export type ProjectStructureSettings = { * What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, * `ui.input-multiline`, `ui.confirm`). */ -export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string }; +export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null }; export type ProxyKind = "http" | "socks"; diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index b2bed6627..fe15480d7 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -388,3 +388,36 @@ it("removes the first workspace member while retaining the workspace state root" expect(useProject.getState().project?.root).toBe("/workspace"); expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]); }); + +describe("explicit project-window requests", () => { + it("honours current-window requests even when a project is already open", async () => { + useProject.setState(projectState("/p/old") as never); + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + const action = windowActions().find((a) => a.id === "file.openProjectPath")!; + await action.run({ arg: { path: "/p/new", newWindow: false } } as never); + expect(windowOpenProject).toHaveBeenCalledWith("/p/new", false, []); + expect(openProject).toHaveBeenCalledWith("/p/new"); + }); + it("opens a new window without replacing the current project", async () => { + useProject.setState(projectState("/p/old") as never); + windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" })); + const action = windowActions().find((a) => a.id === "file.openProjectPath")!; + await action.run({ arg: { path: "/p/new", newWindow: true } } as never); + expect(windowOpenProject).toHaveBeenCalledWith("/p/new", true, []); + expect(openProject).not.toHaveBeenCalled(); + expect(useProject.getState().project?.root).toBe("/p/old"); + }); + it("keeps the ordinary unsaved-change guard for current-window opens", async () => { + const { provideLeaveProjectGuard } = await import("@/platform/project"); + const dispose = provideLeaveProjectGuard(async () => false); + useProject.setState(projectState("/p/old") as never); + windowOpenProject.mockResolvedValue(ok({ kind: "current" })); + try { + const action = windowActions().find((a) => a.id === "file.openProjectPath")!; + await action.run({ arg: { path: "/p/new", newWindow: false } } as never); + expect(openProject).not.toHaveBeenCalled(); + } finally { + dispose(); + } + }); +}); diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 4433deb48..4afd1feb5 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -445,7 +445,15 @@ export function windowActions(): Action[] { category: t("app.category.file"), hidden: true, run: async (c) => { - if (typeof c.arg === "string") await openProjectWithPolicy(c.arg); + if (typeof c.arg === "string") return openProjectWithPolicy(c.arg); + const arg = c.arg as { path?: unknown; newWindow?: unknown } | null; + if (!arg || typeof arg.path !== "string" || typeof arg.newWindow !== "boolean") return; + try { + const outcome = await call(commands.windowOpenProject(arg.path, arg.newWindow, [])); + if (outcome.kind === "current") await openHere(arg.path); + } catch (error) { + openFailed(arg.path, error); + } }, }, { diff --git a/src/features/plugins/__tests__/projectUi.test.tsx b/src/features/plugins/__tests__/projectUi.test.tsx new file mode 100644 index 000000000..4edb884a5 --- /dev/null +++ b/src/features/plugins/__tests__/projectUi.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { registerAction } from "@/platform/actions"; +import { provideNotificationService, type NotificationService } from "@/workbench/contributions"; +import { createMemoryPluginsBackend, setPluginsBackend } from "../backend"; +import { handleRuntimeEvent } from "../runtimeUi"; + +const nativeOpen = vi.hoisted(() => vi.fn()); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: nativeOpen })); +const answerPrompt = vi.fn().mockResolvedValue(undefined); +let notes: Parameters[0][]; +let dispose: (() => void) | undefined; +beforeEach(() => { + vi.clearAllMocks(); + dispose = undefined; + notes = []; + provideNotificationService({ notify: (note) => void notes.push(note) }); + setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt }); +}); +afterEach(() => dispose?.()); +const ask = (id: string) => + handleRuntimeEvent({ + type: "promptRequested", + pluginId: "p", + promptId: id, + prompt: { kind: "pickDirectory", title: "Clone target", initial: "C:/work" }, + }); + +describe("project UI requests", () => { + it("returns an absolute directory as an IPC path using the supplied title and initial path", async () => { + nativeOpen.mockResolvedValue("C:\\work\\repo"); + ask("directory"); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("directory", ["C:/work/repo"])); + expect(nativeOpen).toHaveBeenCalledWith({ + directory: true, + multiple: false, + title: "Clone target", + defaultPath: "C:/work", + }); + }); + it("reports cancellation and picker errors without leaving the plugin waiting", async () => { + nativeOpen.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error("picker unavailable")); + ask("cancel"); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("cancel", null)); + ask("failed"); + await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("failed", null)); + expect(notes).toMatchObject([ + { level: "error", title: "Cannot choose a directory", body: "Error: picker unavailable" }, + ]); + }); + it("ignores a native result after the host closes its prompt", async () => { + let finish!: (path: string) => void; + nativeOpen.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + ask("late"); + await waitFor(() => expect(nativeOpen).toHaveBeenCalled()); + handleRuntimeEvent({ type: "promptClosed", promptId: "late" }); + finish("C:/work/late"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(answerPrompt).not.toHaveBeenCalled(); + }); + it("routes project opens through the normal window action", async () => { + const run = vi.fn(); + dispose = registerAction({ id: "file.openProjectPath", title: "Open Project Path", run }); + for (const newWindow of [false, true]) { + handleRuntimeEvent({ type: "openProject", pluginId: "p", path: "/work/repo", newWindow }); + await waitFor(() => + expect(run).toHaveBeenCalledWith(expect.objectContaining({ arg: { path: "/work/repo", newWindow } })), + ); + } + }); +}); diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 6e6c75f65..21cd570cc 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -22,9 +22,11 @@ import { onIdeEvent } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; import { tx, VerbatimTextContext } from "@/platform/i18n"; import { useProject } from "@/platform/project"; +import { ipcPath } from "@/platform/paths"; import { shortcutLabel } from "@/platform/keymap"; import { BarButton } from "@/ui/BarButton"; import { + notifications, maybeCommitService, maybeEditorService, registerStatusItem, @@ -322,6 +324,26 @@ const openPrompts = new Map(); /** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */ async function askUser(prompt: Prompt, signal: AbortSignal): Promise { switch (prompt.kind) { + case "pickDirectory": { + if (signal.aborted) return null; + try { + const { open } = await import("@tauri-apps/plugin-dialog"); + if (signal.aborted) return null; + const selected = await open({ + directory: true, + multiple: false, + title: prompt.title, + defaultPath: prompt.initial ?? undefined, + }); + if (signal.aborted) return null; + const path = Array.isArray(selected) ? selected[0] : selected; + return path ? [ipcPath(path)] : null; + } catch (error) { + if (!signal.aborted) + notifications().notify({ level: "error", title: tx("Cannot choose a directory"), body: String(error) }); + return null; + } + } case "quickPick": return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal, verbatim: true }); case "input": { @@ -409,6 +431,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean { case "copyToClipboard": void navigator.clipboard?.writeText(e.text); return true; + case "openProject": + void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow }); + return true; case "openUrl": void openExternalUrl(e.url); return true; diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index f4911f285..0ae97e2cd 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -183,5 +183,6 @@ "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.", "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.", "Remove Module Overrides": "Remove Module Overrides", - "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}" + "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}", + "Cannot choose a directory": "Cannot choose a directory" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2cb6af139..1d9f14871 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6562,5 +6562,6 @@ "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "暂不支持模块级语言级别和编译输出覆盖设置。请在 Maven 或 Gradle 中配置。", "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。", "Remove Module Overrides": "移除模块覆盖设置", - "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}" + "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}", + "Cannot choose a directory": "无法选择目录" } diff --git a/wit/host.wit b/wit/host.wit index 3b74fc92f..06a8885c5 100644 --- a/wit/host.wit +++ b/wit/host.wit @@ -57,6 +57,9 @@ interface ui { /// a window (including in editor providers). Waiting doesn't count against /// the call's budget. pick: func(title: string, items: list) -> result, error>; + /// Native folder chooser; an absolute path, or `none` when dismissed. + /// Same window, command/event and budget rules as `pick`; no capability. + pick-directory: func(title: string, initial: option) -> result, error>; /// Like `pick`, with any number of items chosen. pick-many: func(title: string, items: list) -> result>, error>; /// Asks for a line of text, pre-filled with `value`; `none` when dismissed. diff --git a/wit/workspace.wit b/wit/workspace.wit index bcf84e32e..b31964e8b 100644 --- a/wit/workspace.wit +++ b/wit/workspace.wit @@ -3,6 +3,11 @@ package openidea:plugin@1.3.0; interface workspace { use types.{error}; + /// Requests File > Open in this command/event's window (or a new window), + /// including the usual project trust prompt. Needs no capability. + /// `path` is an absolute directory path on the UI machine. + open-project: func(path: string, new-window: bool) -> result<_, error>; + /// What a path inside a project is. enum file-kind { file,