Mountain/Environment/
UserInterfaceProvider.rs

1// File: Mountain/Source/Environment/UserInterfaceProvider.rs
2// Role: Implements the `UserInterfaceProvider` trait for the
3// `MountainEnvironment`. Responsibilities:
4//   - Orchestrate all modal UI interactions (dialogs, messages, quick picks).
5//   - Use the `tauri-plugin-dialog` for native file dialogs.
6//   - Use a custom request-response event pattern for web-based UI elements.
7
8//! # UserInterfaceProvider Implementation
9//!
10//! Implements the `UserInterfaceProvider` trait for the `MountainEnvironment`.
11//! This provider orchestrates all modal UI interactions like dialogs, messages,
12//! and quick picks by communicating with the `Sky` frontend.
13
14#![allow(non_snake_case, non_camel_case_types)]
15
16use std::path::PathBuf;
17
18use Common::{
19	Error::CommonError::CommonError,
20	UserInterface::{
21		DTO::{
22			InputBoxOptionsDTO::InputBoxOptionsDTO,
23			MessageSeverity::MessageSeverity,
24			OpenDialogOptionsDTO::OpenDialogOptionsDTO,
25			QuickPickItemDTO::QuickPickItemDTO,
26			QuickPickOptionsDTO::QuickPickOptionsDTO,
27			SaveDialogOptionsDTO::SaveDialogOptionsDTO,
28		},
29		UserInterfaceProvider::UserInterfaceProvider,
30	},
31};
32use async_trait::async_trait;
33use log::{info, warn};
34use serde::Serialize;
35use serde_json::{Value, json};
36use tauri::Emitter;
37use tauri_plugin_dialog::{DialogExt, FilePath};
38use tokio::time::{Duration, timeout};
39use uuid::Uuid;
40
41use super::{MountainEnvironment::MountainEnvironment, Utility};
42
43#[derive(Serialize, Clone)]
44struct UserInterfaceRequest<TPayload:Serialize + Clone> {
45	pub RequestIdentifier:String,
46
47	pub Payload:TPayload,
48}
49
50#[async_trait]
51impl UserInterfaceProvider for MountainEnvironment {
52	/// Shows a message to the user with a given severity and optional action
53	/// buttons.
54	async fn ShowMessage(
55		&self,
56
57		Severity:MessageSeverity,
58
59		Message:String,
60
61		Options:Option<Value>,
62	) -> Result<Option<String>, CommonError> {
63		info!("[UserInterfaceProvider] Showing interactive message: {}", Message);
64
65		let Payload = json!({ "Severity": Severity, "Message": Message, "Options": Options });
66
67		let ResponseValue = SendUserInterfaceRequest(self, "sky://ui/show-message-request", Payload).await?;
68
69		Ok(ResponseValue.as_str().map(String::from))
70	}
71
72	/// Shows a dialog for opening files or folders using the
73	/// tauri-plugin-dialog.
74	async fn ShowOpenDialog(&self, Options:Option<OpenDialogOptionsDTO>) -> Result<Option<Vec<PathBuf>>, CommonError> {
75		info!("[UserInterfaceProvider] Showing open dialog.");
76
77		let mut Builder = self.ApplicationHandle.dialog().file();
78
79		let (CanSelectMany, CanSelectFolders, CanSelectFiles) = if let Some(ref opts) = Options {
80			if let Some(title) = &opts.Base.Title {
81				Builder = Builder.set_title(title);
82			}
83
84			if let Some(path_string) = &opts.Base.DefaultPath {
85				Builder = Builder.set_directory(PathBuf::from(path_string));
86			}
87
88			if let Some(filters) = &opts.Base.FilterList {
89				for filter in filters {
90					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
91
92					Builder = Builder.add_filter(&filter.Name, &extensions);
93				}
94			}
95
96			(
97				opts.CanSelectMany.unwrap_or(false),
98				opts.CanSelectFolders.unwrap_or(false),
99				opts.CanSelectFiles.unwrap_or(true),
100			)
101		} else {
102			(false, false, true)
103		};
104
105		let PickedPaths:Option<Vec<FilePath>> = tokio::task::spawn_blocking(move || {
106			if CanSelectFolders {
107				if CanSelectMany {
108					Builder.blocking_pick_folders()
109				} else {
110					Builder.blocking_pick_folder().map(|p| vec![p])
111				}
112			} else if CanSelectFiles {
113				if CanSelectMany {
114					Builder.blocking_pick_files()
115				} else {
116					Builder.blocking_pick_file().map(|p| vec![p])
117				}
118			} else {
119				None
120			}
121		})
122		.await
123		.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) })?;
124
125		Ok(PickedPaths.map(|paths| paths.into_iter().filter_map(|p| p.into_path().ok()).collect()))
126	}
127
128	/// Shows a dialog for saving a file using the tauri-plugin-dialog.
129	async fn ShowSaveDialog(&self, Options:Option<SaveDialogOptionsDTO>) -> Result<Option<PathBuf>, CommonError> {
130		info!("[UserInterfaceProvider] Showing save dialog.");
131
132		let mut Builder = self.ApplicationHandle.dialog().file();
133
134		if let Some(options) = Options {
135			if let Some(title) = options.Base.Title {
136				Builder = Builder.set_title(title);
137			}
138
139			if let Some(path_string) = options.Base.DefaultPath {
140				let path = PathBuf::from(path_string);
141
142				if let Some(parent) = path.parent() {
143					Builder = Builder.set_directory(parent);
144				}
145
146				if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
147					Builder = Builder.set_file_name(file_name);
148				}
149			}
150
151			if let Some(filters) = options.Base.FilterList {
152				for filter in filters {
153					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
154
155					Builder = Builder.add_filter(filter.Name, &extensions);
156				}
157			}
158		}
159
160		let PickedFile = tokio::task::spawn_blocking(move || Builder.blocking_save_file())
161			.await
162			.map_err(|Error| {
163				CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) }
164			})?;
165
166		Ok(PickedFile.and_then(|p| p.into_path().ok()))
167	}
168
169	/// Shows a quick pick list to the user.
170	async fn ShowQuickPick(
171		&self,
172
173		Items:Vec<QuickPickItemDTO>,
174
175		Options:Option<QuickPickOptionsDTO>,
176	) -> Result<Option<Vec<String>>, CommonError> {
177		info!("[UserInterfaceProvider] Showing quick pick with {} items.", Items.len());
178
179		let Payload = json!({ "Items": Items, "Options": Options });
180
181		let ResponseValue = SendUserInterfaceRequest(self, "sky://ui/show-quick-pick-request", Payload).await?;
182
183		serde_json::from_value(ResponseValue).map_err(|Error| {
184			CommonError::SerializationError {
185				Description:format!("Failed to deserialize quick pick response: {}", Error),
186			}
187		})
188	}
189
190	/// Shows an input box to solicit a string input from the user.
191	async fn ShowInputBox(&self, Options:Option<InputBoxOptionsDTO>) -> Result<Option<String>, CommonError> {
192		info!("[UserInterfaceProvider] Showing input box.");
193
194		let ResponseValue = SendUserInterfaceRequest(self, "sky://ui/show-input-box-request", Options).await?;
195
196		serde_json::from_value(ResponseValue).map_err(|Error| {
197			CommonError::SerializationError {
198				Description:format!("Failed to deserialize input box response: {}", Error),
199			}
200		})
201	}
202}
203
204// --- Internal Helper Functions ---
205
206/// A generic helper function to send a request to the Sky UI and wait for a
207/// response.
208async fn SendUserInterfaceRequest<TPayload:Serialize + Clone>(
209	Environment:&MountainEnvironment,
210
211	EventName:&str,
212
213	Payload:TPayload,
214) -> Result<Value, CommonError> {
215	let RequestIdentifier = Uuid::new_v4().to_string();
216
217	let (Sender, Receiver) = tokio::sync::oneshot::channel();
218
219	{
220		let mut PendingRequestsGuard = Environment
221			.ApplicationState
222			.PendingUserInterfaceRequests
223			.lock()
224			.map_err(Utility::MapApplicationStateLockErrorToCommonError)?;
225
226		PendingRequestsGuard.insert(RequestIdentifier.clone(), Sender);
227	}
228
229	let EventPayload = UserInterfaceRequest { RequestIdentifier:RequestIdentifier.clone(), Payload };
230
231	Environment.ApplicationHandle.emit(EventName, EventPayload).map_err(|Error| {
232		CommonError::UserInterfaceInteraction {
233			Reason:format!("Failed to emit UI request '{}': {}", EventName, Error.to_string()),
234		}
235	})?;
236
237	match timeout(Duration::from_secs(300), Receiver).await {
238		Ok(Ok(Ok(Value))) => Ok(Value),
239
240		Ok(Ok(Err(Error))) => Err(Error),
241
242		Ok(Err(_)) => {
243			Err(CommonError::UserInterfaceInteraction {
244				Reason:format!("UI response channel closed for request ID: {}", RequestIdentifier),
245			})
246		},
247
248		Err(_) => {
249			warn!(
250				"[UserInterfaceProvider] UI request '{}' with ID {} timed out.",
251				EventName, RequestIdentifier
252			);
253
254			let mut Guard = Environment
255				.ApplicationState
256				.PendingUserInterfaceRequests
257				.lock()
258				.map_err(Utility::MapApplicationStateLockErrorToCommonError)?;
259
260			Guard.remove(&RequestIdentifier);
261
262			Err(CommonError::UserInterfaceInteraction {
263				Reason:format!("UI request timed out for request ID: {}", RequestIdentifier),
264			})
265		},
266	}
267}