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/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index da095146b..53b61d1ae 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -84,16 +84,24 @@ impl WindowGroups { Ok(before) } + /// The member `detach(member)` will make active: the selection, or the selected member's neighbour. + pub fn successor(&self, member: &str) -> Option { + let group = self.group(member)?; + if group.selected != member { + return Some(group.selected.clone()); + } + let index = group.members.iter().position(|m| m == member)?; + let next = if index + 1 < group.members.len() { index + 1 } else { index.checked_sub(1)? }; + Some(group.members[next].clone()) + } + /// Returns the remaining active member, including when the last pair dissolves. pub fn detach(&mut self, member: &str) -> Option { + let selected = self.successor(member)?; let i = self.groups.iter().position(|g| g.members.iter().any(|m| m == member))?; let group = &mut self.groups[i]; - let index = group.members.iter().position(|m| m == member).unwrap(); - group.members.remove(index); - if group.selected == member { - group.selected = group.members[index.min(group.members.len() - 1)].clone(); - } - let selected = group.selected.clone(); + group.members.retain(|m| m != member); + group.selected = selected.clone(); if group.members.len() < 2 { self.groups.remove(i); } @@ -213,6 +221,19 @@ mod tests { assert!(groups.group("a").is_none()); } + #[test] + fn the_successor_is_the_member_detach_selects() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["a", "b", "c"]), "b").unwrap(); + assert_eq!(groups.successor("a"), Some("b".into())); + assert_eq!(groups.successor("b"), Some("c".into())); + groups.select("a", "c").unwrap(); + assert_eq!(groups.successor("c"), Some("b".into())); + assert_eq!(groups.successor("unrelated"), None); + assert_eq!(groups.detach("c"), Some("b".into())); + assert_eq!(groups.group("a").unwrap().selected, "b"); + } + #[test] fn merging_groups_and_reordering_keep_unique_members() { let mut groups = WindowGroups::default(); 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-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d49fbf723..f4a7f1d93 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -87,6 +87,7 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_close, window_group::window_group_close_tabs, window_group::window_group_cancel_close, + window_group::window_group_before_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index f6d4d29ae..f016e9519 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -55,6 +55,20 @@ pub fn window_group_cancel_close(window: WebviewWindow) { groups::cancel_close(window.app_handle(), window.label()); } +/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space +/// to the tab that takes over. +#[tauri::command] +#[specta::specta] +pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> { + #[cfg(target_os = "macos")] + return groups::before_close(window.app_handle(), window.label()).await; + #[cfg(not(target_os = "macos"))] + { + let _ = window; + Ok(()) + } +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 3d32a8de1..7b6a63add 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -173,6 +173,44 @@ pub fn watch_fullscreen_host(host: &WebviewWindow, left: impl Fn() + Send + 'sta }) } +thread_local! { + /// Windows being waited on to finish entering fullscreen. + static ENTERING_FULLSCREEN: std::cell::RefCell> = std::cell::RefCell::default(); +} + +/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`. +pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> { + let (tx, rx) = tokio::sync::oneshot::channel(); + let label = window.label().to_owned(); + let target = window.clone(); + on_main(window, move || unsafe { + let Ok(pointer) = target.ns_window() else { return }; + let native = &*pointer.cast::(); + let tx = std::cell::Cell::new(Some(tx)); + let block = block2::RcBlock::new(move |_| { + if let Some(tx) = tx.take() { + let _ = tx.send(()); + } + }); + let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification), + Some(native), + None, + &block, + ); + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer))); + })?; + let toggled = native_toggle_fullscreen(window); + if toggled.is_ok() { + let _ = tokio::time::timeout(timeout, rx).await; + } + let label = window.label().to_owned(); + on_main(window, move || { + ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label)); + })?; + toggled +} + /// An observer on the workspace's notification center, removed when dropped. struct WorkspaceObserver(Retained, Retained>); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 0601fb32f..70ea2dd37 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -19,6 +19,9 @@ const FRAME_SETTLE_AFTER_MOVE: Duration = Duration::from_millis(100); /// Longest wait for a closed host's fullscreen Space to go before the next tab takes the screen. #[cfg(target_os = "macos")] const SPACE_TEARDOWN: Duration = Duration::from_millis(1500); +/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space. +#[cfg(target_os = "macos")] +const SPACE_HANDOVER: Duration = Duration::from_millis(2000); /// Coalesces the move/resize events a drag emits. static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -327,6 +330,13 @@ pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { /// Makes `host` the fullscreen window of its group, or of its own Space when ungrouped. #[cfg(target_os = "macos")] fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { + prepare_host(app, host)?; + crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string())) +} + +/// `host` and the other members of its group, ready for `host` to enter fullscreen. +#[cfg(target_os = "macos")] +fn prepare_host(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { let err = |e: tauri::Error| IdeError::internal(e.to_string()); let members = app.state::().groups.lock().group(host.label()).map(|g| g.members.clone()); crate::macos::set_fullscreen_auxiliary(host, None).map_err(err)?; @@ -338,8 +348,29 @@ fn host_fullscreen(app: &AppHandle, host: &WebviewWindow) -> IdeResult<()> { if members.is_some() { watch_host(app, host)?; } - host.show().map_err(err)?; - crate::macos::native_toggle_fullscreen(host).map_err(err) + host.show().map_err(err) +} + +/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves +/// for a Space of its own first, so the screen moves straight to it; were the host closed first, +/// its Space would end and show the desktop until the next one opened. +#[cfg(target_os = "macos")] +pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> { + let state = app.state::(); + let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) }; + if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) { + return Ok(()); + } + // Detached now, the closing host leaves nothing for `destroyed` to hand over. + let next = state.groups.lock().detach(caller); + changed(app); + let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) }; + prepare_host(app, &next)?; + crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER) + .await + .map_err(|e| IdeError::internal(e.to_string()))?; + windows::focus(&next); + Ok(()) } #[cfg(target_os = "macos")] diff --git a/src/bindings.ts b/src/bindings.ts index f2e8f829d..f4431c017 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,6 +42,11 @@ export const commands = { windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), + /** + * This window closes next (its prompts passed): a fullscreen group's host first hands its Space + * to the tab that takes over. + */ + windowGroupBeforeClose: () => typedError(__TAURI_INVOKE("window_group_before_close")), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** @@ -5983,6 +5988,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, …)`. @@ -6388,7 +6395,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/closeGuard.ts b/src/features/app/closeGuard.ts index 084fb5244..448435502 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -205,6 +205,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { deps, ); if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); + else if (!ctx.quitting) await commands.windowGroupBeforeClose(); return ok; } catch (e) { maybeEditorService()?.cancelClose?.(); 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 a1893b35c..8c0e04aff 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -192,5 +192,6 @@ "Cannot install the openidea command": "Cannot install the openidea command", "Cannot uninstall the openidea command": "Cannot uninstall the openidea command", "Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.", - "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal." + "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.", + "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 018a20027..aa54d4672 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6571,5 +6571,6 @@ "Cannot install the openidea command": "无法安装 openidea 命令", "Cannot uninstall the openidea command": "无法卸载 openidea 命令", "Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。", - "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。" + "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。", + "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,