дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
коміт
5890e8abc5
51 files changed
+516
-33
No files matched your search
@@ -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>,
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
| PluginEvent::CopyToClipboard { .. }
|
||||
| PluginEvent::OpenUrl { .. }
|
||||
| PluginEvent::OpenProject { .. }
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>`).
|
||||
|
||||
@@ -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 @@
|
||||
5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07
|
||||
2ddbea56d198b52839c6506c9d03a87ea1c0cd8b518c0f3ee58f4eecf9d320e2
|
||||
@@ -1 +1 @@
|
||||
5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67
|
||||
6709ed49ec442d7deca083534c13c35a1ca0355de8d073c55609ce784fe65db7
|
||||
@@ -1 +1 @@
|
||||
9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54
|
||||
1915812d45316cf499611f471cf7abfdad47aa158295493bf81362f690ace866
|
||||
@@ -1 +1 @@
|
||||
600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10
|
||||
b55985f72b2636a84441f8effef10f8c4f638bd65be3f2f247d9a636c20e767b
|
||||
+1
-1
@@ -1 +1 @@
|
||||
c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb
|
||||
62888af0c9fb057ec4a696f0bfd5fd6fd07495a16a3021b4143de74b5cd30e26
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d
|
||||
79a67b0af9e0118961a83eecd644c2e1b87a98043d34b96bc49d4042af5d895f
|
||||
@@ -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"
|
||||
@@ -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 @@
|
||||
1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24
|
||||
6c12157f6d1040ce3f9495aaef0e0ea89818ab8af1ded6e2655e3d9304131255
|
||||
+1
-1
@@ -1 +1 @@
|
||||
8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d
|
||||
9db9019b6322a9f007ca49b6e3bf918f33df02407827ba797854e2486301733a
|
||||
@@ -1 +1 @@
|
||||
295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82
|
||||
fea7a5340fed353c8d09d7b7caba0c5ca23763fe9c6ece930c61d84e5b7dc0d5
|
||||
@@ -1 +1 @@
|
||||
5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d
|
||||
5bf76257aac231326bdcb618b511d4f94ed711f217a15ecad10e4dd15525d840
|
||||
@@ -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();
|
||||
|
||||
@@ -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 @@
|
||||
df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5
|
||||
2d93f4f7eb0529528591ac1b7057a02cae7ed978eb21fe2be2f188529560b972
|
||||
@@ -1 +1 @@
|
||||
ef4128ec97e203c3dd0c49f3bcdbfaa5eb2be59123dc4628fc31d52dc61b3186
|
||||
af52d666db0224b43e19193d90331d54c9645ebd63c183ede65abc5666c15fd7
|
||||
@@ -1 +1 @@
|
||||
14dc3f5a5356ed82a84e7802c822545cba412d3e4a3b614aab6f4f7d3686584c
|
||||
a5bcffff3f4bb1c01840bd7b16d5e90908b293ef7676ffd81264a2fa850f91c7
|
||||
@@ -1 +1 @@
|
||||
936d9fdad9752fa34a4a7ac18014c7bfc7cbd1b22f71b1501c667f4b83faad0b
|
||||
d20a458237ecdd0d34de2be8e4df527f2ed647627b784bf5df1206e18beba2b1
|
||||
@@ -1 +1 @@
|
||||
7b559f3febbd3acd56f08d530209212ab2d7da1c794346e2beaf0f19d0a3227f
|
||||
d9b33e747442c539541778b2a907fc02226e9af120962d4f384820b76674d40a
|
||||
@@ -1 +1 @@
|
||||
b2e111085be32495ac82055e8258f8841a4a44979b4052ef102bb5726efc90e5
|
||||
b8c276a0d40a1615a658dc643f81e67683cfea57eb47282ff155c8df148fd771
|
||||
@@ -1 +1 @@
|
||||
522aa68db4454f9c5511a90c74c6c26a8b95922e6725d9e411ddde7db998fa71
|
||||
c12eaf6b64a2482c83dab1958a195c939c38574d0bc15af1afcdaf811cf311d5
|
||||
@@ -1 +1 @@
|
||||
2f23364367583f9101cea4fc8f4ff190038a8b04c0f3b07a0f145c917807c237
|
||||
fa12ebc743e31fa57b31c0a623708133a232754d110bd687ef16d1037be62b19
|
||||
@@ -1 +1 @@
|
||||
028e26a83c42ee3e01781e0006e857b1234c8dd655c1619de79aaee116998e9f
|
||||
53271d32104c682e510b4b5a8edb3467c77b39ee58d9b14c891513a494526180
|
||||
@@ -1 +1 @@
|
||||
9a19f399c1aa5fbc4d80aa7d11c5b73d968d0664bbc12dea8488d5fcab846075
|
||||
2eb51fed9362021834f237f80553979a56ed1d8301d78b551e5911768b6e443c
|
||||
@@ -1 +1 @@
|
||||
73b65742330387b7dbf498c6ec275ab41a55475243d771c1274de70b939c327f
|
||||
82547cce827ac884aafed961134e5d54be3dcd937990a3c372dfcdd8102aea23
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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>>);
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 } })),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "无法选择目录"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Заблокувати користувача