From c720024b42a41f1acb901c5da317a232bacba4a3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:56:38 +0800 Subject: [PATCH 1/2] fix(window): open the next tab's Space before a fullscreen group's host closes (#2002) Closing the tab that hosts a fullscreen project group ended its Space first, so macOS showed the desktop for about a second before the next tab's Space opened. Once the close is confirmed, the next tab now enters its own fullscreen Space first and the host closes after it, so the screen moves straight to it. --- crates/ide-workspace/src/window_groups.rs | 33 ++++++++++++++++---- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window_group.rs | 14 +++++++++ src-tauri/src/macos.rs | 38 +++++++++++++++++++++++ src-tauri/src/window_groups.rs | 35 +++++++++++++++++++-- src/bindings.ts | 5 +++ src/features/app/closeGuard.ts | 1 + 7 files changed, 119 insertions(+), 8 deletions(-) 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/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ecb5fd3a8..9b9173e6f 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 91103e03b..f185b0b2f 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")), /** 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?.(); 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 2/2] 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,