дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +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();
|
||||
|
||||
Reference in new issue
Заблокувати користувача