From f3837e0fe7de7aa161ac7c97d3c61614de829b87 Mon Sep 17 00:00:00 2001 From: Arnaud Ritti <77437157+arnaud-ritti@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:02:38 +0100 Subject: [PATCH] feat(plugins): let window commands choose folders and open projects (#1998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds API 1.3 `ui.pick-directory` (native folder chooser, same window/budget rules as `pick`) and `workspace.open-project(path, new-window)`, which goes through the normal File › Open flow with its trust prompt, window reuse and unsaved-change handling. Paths are absolute on the UI machine and validated by the host whatever its OS. Closes #1959. Co-authored-by: arnaud-ritti <77437157+arnaud-ritti@users.noreply.github.com> --- crates/ide-host/src/plugins/api.rs | 8 ++ crates/ide-host/src/plugins/remote_host.rs | 1 + crates/ide-host/src/plugins/types.rs | 3 + crates/ide-host/tests/plugins.rs | 37 +++++++++ crates/ide-plugin/src/runtime.rs | 4 + crates/ide-plugin/src/types.rs | 2 + crates/ide-plugin/src/wit_host.rs | 53 ++++++++++++ .../fixtures/commit/dist/plugin.wasm.sources | 2 +- .../fixtures/hello/dist/plugin.wasm.sources | 2 +- .../fixtures/hover/dist/plugin.wasm.sources | 2 +- .../fixtures/indexer/dist/plugin.wasm.sources | 2 +- .../inlay-hints/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/dist/plugin.wasm | Bin 145216 -> 147175 bytes .../fixtures/jobs/dist/plugin.wasm.sources | 2 +- .../tests/fixtures/jobs/plugin.toml | 10 +++ .../ide-plugin/tests/fixtures/jobs/src/lib.rs | 5 ++ .../misbehave/dist/plugin.wasm.sources | 2 +- .../semantic-tokens/dist/plugin.wasm.sources | 2 +- .../fixtures/storage/dist/plugin.wasm.sources | 2 +- .../fixtures/tables/dist/plugin.wasm.sources | 2 +- plugins/README.md | 14 ++++ .../ai-completion/dist/plugin.wasm.sources | 2 +- .../checkstyle/dist/plugin.wasm.sources | 2 +- .../csv-editor/dist/plugin.wasm.sources | 2 +- .../git-toolbox/dist/plugin.wasm.sources | 2 +- .../graphviz/dist/plugin.wasm.sources | 2 +- .../key-promoter/dist/plugin.wasm.sources | 2 +- .../license-header/dist/plugin.wasm.sources | 2 +- .../official/mybatis/dist/plugin.wasm.sources | 2 +- .../regex-tester/dist/plugin.wasm.sources | 2 +- .../solidity/dist/plugin.wasm.sources | 2 +- .../string-tools/dist/plugin.wasm.sources | 2 +- sdk/rust/openidea-plugin/src/host.rs | 21 +++++ sdk/rust/openidea-plugin/src/mock.rs | 52 ++++++++++++ sdk/rust/openidea-plugin/wit/host.wit | 3 + sdk/rust/openidea-plugin/wit/workspace.wit | 5 ++ src/bindings.ts | 4 +- src/features/app/__tests__/windows.test.ts | 33 ++++++++ src/features/app/windows.ts | 10 ++- .../plugins/__tests__/projectUi.test.tsx | 76 ++++++++++++++++++ src/features/plugins/runtimeUi.tsx | 25 ++++++ src/platform/locales/text/en.json | 3 +- src/platform/locales/text/zh.json | 3 +- wit/host.wit | 3 + wit/workspace.wit | 5 ++ 45 files changed, 397 insertions(+), 25 deletions(-) create mode 100644 src/features/plugins/__tests__/projectUi.test.tsx 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 f6d907d3d1d8d3b65fba58fec36598a328c24fe2..1becbb272460a10f00f4ec7b9339e46e8d966e5e 100644 GIT binary patch delta 23803 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 V*}XhP8rkEyM&Sb{{Y{d)=2;W delta 22584 zcmd742Xqxh7dXCm_SL+XWYc@{-b+Fn={>Y8y@Op4lolW%B$xyNTLOY2Mal?+B27R* z0nwlVMF9&U3J7)qKh%f_e28K}0r}sV-S_em(C>eK=lst3k;CqkJ9qBfKC}DwhpU!7 z=PlcO4IyTK=n4kOJgKI-qHJ`X3_b}UG3Y=X#3skq$P-`<#@aYdnz& znCL^s{$OrAV6;N}NwqK`5Go)Dj8IC9b>(%HWwplgy0VG2{!*|(vQ8==Jt2MK$f{`& zLX*rf#i0htzr1SFW=D|Ki)cyWH2KiO=G(wV6D@auojzoF4txwqzuEp=~IH(z93&qrvtbNXAfm zABbpNXEy;vQh&b~h@$C!iS)d$1ENJe2J1Z_cF7tNdjyb$H=ffWe0b$oy|QeT2I)4qZ6ZhkeVFoh>2tivxz?&5lnobmd3?o zTE>s88dF&&)nTQ8Om?x4vPrBdzGiZjbRV0PM$(8hg_Q=ARG7LrHqi||ZE2B|{X{YXQD zB-tEBHMqjzqvCp0P#&Rw$K}J;#yrQpGB7$gA=0u#1K;@nKqWeHa_Skn2dPI=UuZ!3 z9kTM+bnSjZ+==pE{eG6do>&Wy(9Cu>Co*lVV*%<(DJgY8GLh8!p1zWDjbl;mszJF- z`z3XSr|1(&c`?VeHNT+^i${zIv{7q(hW;mMzRi1_Qs{trbXjsGyhL|(%AtiR0m0{5 zVNeaKU3rQQONmUpqIKxpZ-aUU&Li2*gwo|HF_vJ5*+B}3Lhs!XM31C6V&r5=Qhz$V zWVyMvriZWOAiVzF*E`pmYaKH6OKpg>RH&UCMr6u6oN|Xbnb@6#-z;=}>aD(ibNs&G z00&WaQ&U>sl<*3N>@X@Ha&W;JbGj6+LNCN_R*Nb)qyRWbr=_K#TUwu%0AJ7pX<6te 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 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,