Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms

# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
Цей коміт міститься в:
l0ng-ai committed 2026-11-07 22:24:10 +08:00
коміт 5890e8abc5
51 files changed
+516 -33

No files matched your search

+8
Переглянути файл
@@ -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>,
+1
Переглянути файл
@@ -119,6 +119,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
| PluginEvent::OpenLocation { .. }
| PluginEvent::CopyToClipboard { .. }
| PluginEvent::OpenUrl { .. }
| PluginEvent::OpenProject { .. }
| PluginEvent::PromptRequested { .. }
| PluginEvent::PromptClosed { .. }
| PluginEvent::RevealView { .. }
+3
Переглянути файл
@@ -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")]
+37
Переглянути файл
@@ -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::<Vec<_>>();
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 { .. })));
}
}
+4
Переглянути файл
@@ -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
+2
Переглянути файл
@@ -88,6 +88,8 @@ pub enum Prompt {
Input { prompt: String, value: Option<String>, multiline: bool },
#[serde(rename_all = "camelCase")]
Confirm { title: String, message: String },
#[serde(rename_all = "camelCase")]
PickDirectory { title: String, initial: Option<String> },
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
+53
Переглянути файл
@@ -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<String>) -> Fallible<Option<String>> {
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<ui::PickItem>) -> Fallible<Option<Vec<String>>> {
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<Vec<Resource<ProjectRes>>> {
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());
}
}
+1 -1
Переглянути файл
@@ -1 +1 @@
5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07
2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2
+1 -1
Переглянути файл
@@ -1 +1 @@
5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67
6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7
+1 -1
Переглянути файл
@@ -1 +1 @@
9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54
1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866
+1 -1
Переглянути файл
@@ -1 +1 @@
600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10
b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b
+1 -1
Переглянути файл
@@ -1 +1 @@
c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb
62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26
Бінарний файл не відображається.
+1 -1
Переглянути файл
@@ -1 +1 @@
de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d
79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f
+10
Переглянути файл
@@ -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"
+5
Переглянути файл
@@ -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 = [
+1 -1
Переглянути файл
@@ -1 +1 @@
1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24
6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255
+1 -1
Переглянути файл
@@ -1 +1 @@
8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d
9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a
+1 -1
Переглянути файл
@@ -1 +1 @@
295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82
fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5
+1 -1
Переглянути файл
@@ -1 +1 @@
5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d
5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840
+27 -6
Переглянути файл
@@ -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<String> {
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<String> {
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();
+14
Переглянути файл
@@ -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.
+1 -1
Переглянути файл
@@ -1 +1 @@
df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5
2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972
+1 -1
Переглянути файл
@@ -1 +1 @@
ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186
af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7
+1 -1
Переглянути файл
@@ -1 +1 @@
14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c
a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7
+1 -1
Переглянути файл
@@ -1 +1 @@
936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b
d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1
+1 -1
Переглянути файл
@@ -1 +1 @@
7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f
d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a
+1 -1
Переглянути файл
@@ -1 +1 @@
b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5
b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771
+1 -1
Переглянути файл
@@ -1 +1 @@
522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71
c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5
+1 -1
Переглянути файл
@@ -1 +1 @@
2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237
fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19
+1 -1
Переглянути файл
@@ -1 +1 @@
028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f
53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180
+1 -1
Переглянути файл
@@ -1 +1 @@
9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075
2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c
+1 -1
Переглянути файл
@@ -1 +1 @@
73b65742330387b7dbf498c6ec275ab41a55475243d771c1274de70b939c327f
82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23
+21
Переглянути файл
@@ -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<Option<String>, 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<Option<Vec<String>>, 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<Option<String>, 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)
}
+52
Переглянути файл
@@ -72,6 +72,7 @@ struct State {
fetch_error: Option<Error>,
clipboard: Option<String>,
opened_urls: Vec<String>,
opened_projects: Vec<(String, bool)>,
answers: VecDeque<Option<Vec<String>>>,
revealed_views: Vec<String>,
diagnostics: BTreeMap<String, Vec<Diagnostic>>,
@@ -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<Option<String>, 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<Option<String>, 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());
}
}
}
+3
Переглянути файл
@@ -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<pick-item>) -> result<option<string>, 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<string>) -> result<option<string>, error>;
/// Like `pick`, with any number of items chosen.
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
+5
Переглянути файл
@@ -3,6 +3,11 @@ package openidea:[email protected];
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,
+1
Переглянути файл
@@ -87,6 +87,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+14
Переглянути файл
@@ -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<String>) -> IdeResult<()> {
+38
Переглянути файл
@@ -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::collections::HashMap<String, FullscreenObserver>> = 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::<NSWindow>();
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<NSNotificationCenter>, Retained<ProtocolObject<dyn NSObjectProtocol>>);
+33 -2
Переглянути файл
@@ -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<bool> {
/// 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::<WindowsState>().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::<WindowsState>();
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")]
+8 -1
Переглянути файл
@@ -42,6 +42,11 @@ export const commands = {
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__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<void>("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<null, IdeError>(__TAURI_INVOKE("window_group_before_close")),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__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";
+33
Переглянути файл
@@ -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();
}
});
});
+1
Переглянути файл
@@ -205,6 +205,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
deps,
);
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
else if (!ctx.quitting) await commands.windowGroupBeforeClose();
return ok;
} catch (e) {
maybeEditorService()?.cancelClose?.();
+9 -1
Переглянути файл
@@ -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);
}
},
},
{
+76
Переглянути файл
@@ -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<NotificationService["notify"]>[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<string>((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 } })),
);
}
});
});
+25
Переглянути файл
@@ -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<string, AbortController>();
/** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */
async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] | null> {
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;
+2 -1
Переглянути файл
@@ -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"
}
+2 -1
Переглянути файл
@@ -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": "无法选择目录"
}
+3
Переглянути файл
@@ -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<pick-item>) -> result<option<string>, 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<string>) -> result<option<string>, error>;
/// Like `pick`, with any number of items chosen.
pick-many: func(title: string, items: list<pick-item>) -> result<option<list<string>>, error>;
/// Asks for a line of text, pre-filled with `value`; `none` when dismissed.
+5
Переглянути файл
@@ -3,6 +3,11 @@ package openidea:[email protected];
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,