Skip to main content

Mountain/Environment/
UserInterfaceProvider.rs

1//! # UserInterfaceProvider (Environment)
2//!
3//! Implements the `UserInterfaceProvider` trait for `MountainEnvironment`,
4//! orchestrating all modal UI interactions (dialogs, messages, quick picks)
5//! by communicating with the `Sky` frontend.
6//!
7//! ## Request-response pattern
8//!
9//! Every blocking UI operation follows the same flow:
10//! 1. Generate a UUID request ID.
11//! 2. Insert a `tokio::sync::oneshot::Sender` in
12//!    `ApplicationState.UI.PendingUserInterfaceRequest`.
13//! 3. Emit a Tauri event to Sky with the ID and payload.
14//! 4. Await the oneshot (timeout: 300 s); `DispatchLogic::ResolveUIRequest`
15//!    resolves it when the user responds.
16//!
17//! The shared helper `SendUserInterfaceRequest` (pub-crate) is also used by
18//! effect creators (`applyEdit`, `showTextDocument`, `Task.Execute`) that need
19//! the same request-ID / oneshot pattern instead of fire-and-forget emits.
20//!
21//! ## Operations
22//!
23//! - `ShowMessage` - modal message box (`Info` / `Warning` / `Error`)
24//! - `ShowOpenDialog` - native file/folder picker (via `tauri-plugin-dialog`;
25//!   supports multi-select, folder-only, and file-type filters)
26//! - `ShowSaveDialog` - native save-file picker
27//! - `ShowQuickPick` - Sky `sky://quickpick/show` (camelCase wire shape)
28//! - `ShowInputBox` - Sky `sky://input-box/show` (camelCase wire shape)
29//!
30//! ## VS Code reference
31//!
32//! - `vs/platform/dialogs/common/dialogs.ts`
33//! - `vs/platform/prompt/common/prompt.ts`
34
35use std::path::PathBuf;
36
37use CommonLibrary::{
38	Error::CommonError::CommonError,
39	IPC::SkyEvent::SkyEvent,
40	UserInterface::{
41		DTO::{
42			InputBoxOptionsDTO::InputBoxOptionsDTO,
43			MessageSeverity::MessageSeverity,
44			OpenDialogOptionsDTO::OpenDialogOptionsDTO,
45			QuickPickItemDTO::QuickPickItemDTO,
46			QuickPickOptionsDTO::QuickPickOptionsDTO,
47			SaveDialogOptionsDTO::SaveDialogOptionsDTO,
48		},
49		UserInterfaceProvider::UserInterfaceProvider,
50	},
51};
52use async_trait::async_trait;
53use serde::Serialize;
54use serde_json::{Value, json};
55use tauri::Emitter;
56use tauri_plugin_dialog::{DialogExt, FilePath};
57use tokio::time::{Duration, timeout};
58use uuid::Uuid;
59
60use super::{MountainEnvironment::MountainEnvironment, Utility};
61use crate::dev_log;
62
63// TODO: custom dialog buttons/layouts, glob-pattern file-type filters,
64// dialog position + sizing, modal vs non-modal, accessibility (screen reader),
65// theming (dark/light auto), file-extension selection in save dialog,
66// multi-select in quick pick + file dialogs, async progress reporting,
67// custom input validation (regex).
68
69#[derive(Serialize, Clone)]
70struct UserInterfaceRequest<TPayload:Serialize + Clone> {
71	pub RequestIdentifier:String,
72
73	pub Payload:TPayload,
74}
75
76#[async_trait]
77impl UserInterfaceProvider for MountainEnvironment {
78	/// Shows a message to the user with a given severity and optional action
79	/// buttons.
80	async fn ShowMessage(
81		&self,
82
83		Severity:MessageSeverity,
84
85		Message:String,
86
87		Options:Option<Value>,
88	) -> Result<Option<String>, CommonError> {
89		dev_log!("window", "[UserInterfaceProvider] Showing interactive message: {}", Message);
90
91		// camelCase wire shape per the project-wide audit. Sky's listener
92		// at `SkyBridge.ts:2444` already tolerates both casings via the
93		// `?? severity` fallbacks; emit camelCase as the canonical form.
94		let Payload = json!({ "severity": Severity, "message": Message, "options": Options });
95
96		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::UIShowMessageRequest.AsStr(), Payload).await?;
97
98		Ok(ResponseValue.as_str().map(String::from))
99	}
100
101	/// Shows a dialog for opening files or folders using the
102	/// tauri-plugin-dialog.
103	async fn ShowOpenDialog(&self, Options:Option<OpenDialogOptionsDTO>) -> Result<Option<Vec<PathBuf>>, CommonError> {
104		dev_log!("window", "[UserInterfaceProvider] Showing open dialog.");
105
106		let mut Builder = self.ApplicationHandle.dialog().file();
107
108		let (CanSelectMany, CanSelectFolders, CanSelectFiles) = if let Some(ref opts) = Options {
109			if let Some(title) = &opts.Base.Title {
110				Builder = Builder.set_title(title);
111			}
112
113			if let Some(path_string) = &opts.Base.DefaultPath {
114				Builder = Builder.set_directory(PathBuf::from(path_string));
115			}
116
117			if let Some(filters) = &opts.Base.FilterList {
118				for filter in filters {
119					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
120
121					Builder = Builder.add_filter(&filter.Name, &extensions);
122				}
123			}
124
125			(
126				opts.CanSelectMany.unwrap_or(false),
127				opts.CanSelectFolders.unwrap_or(false),
128				opts.CanSelectFiles.unwrap_or(true),
129			)
130		} else {
131			(false, false, true)
132		};
133
134		let PickedPaths:Option<Vec<FilePath>> = tokio::task::spawn_blocking(move || {
135			if CanSelectFolders {
136				if CanSelectMany {
137					Builder.blocking_pick_folders()
138				} else {
139					Builder.blocking_pick_folder().map(|p| vec![p])
140				}
141			} else if CanSelectFiles {
142				if CanSelectMany {
143					Builder.blocking_pick_files()
144				} else {
145					Builder.blocking_pick_file().map(|p| vec![p])
146				}
147			} else {
148				None
149			}
150		})
151		.await
152		.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) })?;
153
154		Ok(PickedPaths.map(|paths| paths.into_iter().filter_map(|p| p.into_path().ok()).collect()))
155	}
156
157	/// Shows a dialog for saving a file using the tauri-plugin-dialog.
158	async fn ShowSaveDialog(&self, Options:Option<SaveDialogOptionsDTO>) -> Result<Option<PathBuf>, CommonError> {
159		dev_log!("window", "[UserInterfaceProvider] Showing save dialog.");
160
161		let mut Builder = self.ApplicationHandle.dialog().file();
162
163		if let Some(options) = Options {
164			if let Some(title) = options.Base.Title {
165				Builder = Builder.set_title(title);
166			}
167
168			if let Some(path_string) = options.Base.DefaultPath {
169				let path = PathBuf::from(path_string);
170
171				if let Some(parent) = path.parent() {
172					Builder = Builder.set_directory(parent);
173				}
174
175				if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
176					Builder = Builder.set_file_name(file_name);
177				}
178			}
179
180			if let Some(filters) = options.Base.FilterList {
181				for filter in filters {
182					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
183
184					Builder = Builder.add_filter(filter.Name, &extensions);
185				}
186			}
187		}
188
189		let PickedFile = tokio::task::spawn_blocking(move || Builder.blocking_save_file())
190			.await
191			.map_err(|Error| {
192				CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) }
193			})?;
194
195		Ok(PickedFile.and_then(|p| p.into_path().ok()))
196	}
197
198	/// Shows a quick pick list to the user.
199	async fn ShowQuickPick(
200		&self,
201
202		Items:Vec<QuickPickItemDTO>,
203
204		Options:Option<QuickPickOptionsDTO>,
205	) -> Result<Option<Vec<String>>, CommonError> {
206		dev_log!(
207			"window",
208			"[UserInterfaceProvider] Showing quick pick with {} items.",
209			Items.len()
210		);
211
212		// camelCase wire shape per project-wide audit.
213		let Payload = json!({ "items": Items, "options": Options });
214
215		// Use the Sky-listener-aligned channel (`sky://quickpick/show`).
216		// The legacy `UIShowQuickPickRequest` channel
217		// (`sky://ui/show-quick-pick-request`) had no Sky listener and
218		// every emit silently disappeared.
219		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::QuickPickShow.AsStr(), Payload).await?;
220
221		serde_json::from_value(ResponseValue).map_err(|Error| {
222			CommonError::SerializationError {
223				Description:format!("Failed to deserialize quick pick response: {}", Error),
224			}
225		})
226	}
227
228	/// Shows an input box to solicit a string input from the user.
229	async fn ShowInputBox(&self, Options:Option<InputBoxOptionsDTO>) -> Result<Option<String>, CommonError> {
230		dev_log!("window", "[UserInterfaceProvider] Showing input box.");
231
232		// Use the Sky-listener-aligned channel (`sky://input-box/show`).
233		// The legacy `UIShowInputBoxRequest` channel
234		// (`sky://ui/show-input-box-request`) had no Sky listener.
235		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::InputBoxShow.AsStr(), Options).await?;
236
237		serde_json::from_value(ResponseValue).map_err(|Error| {
238			CommonError::SerializationError {
239				Description:format!("Failed to deserialize input box response: {}", Error),
240			}
241		})
242	}
243}
244
245// --- Internal Helper Functions ---
246
247/// A generic helper function to send a request to the Sky UI and wait for a
248/// response.
249///
250/// Atom T1: made `pub(crate)` so Track effect creators
251/// (`applyEdit` / `showTextDocument` / `Task.Execute`, etc.) can reuse the
252/// same RequestIdentifier/oneshot pattern instead of emitting fire-and-
253/// forget events that resolve to synthetic success.
254pub(crate) async fn SendUserInterfaceRequest<TPayload:Serialize + Clone>(
255	Environment:&MountainEnvironment,
256
257	EventName:&str,
258
259	Payload:TPayload,
260) -> Result<Value, CommonError> {
261	let RequestIdentifier = Uuid::new_v4().to_string();
262
263	let (Sender, Receiver) = tokio::sync::oneshot::channel();
264
265	{
266		let mut PendingRequestsGuard = Environment
267			.ApplicationState
268			.UI
269			.PendingUserInterfaceRequest
270			.lock()
271			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
272
273		PendingRequestsGuard.insert(RequestIdentifier.clone(), Sender);
274	}
275
276	let EventPayload = UserInterfaceRequest { RequestIdentifier:RequestIdentifier.clone(), Payload };
277
278	Environment.ApplicationHandle.emit(EventName, EventPayload).map_err(|Error| {
279		CommonError::UserInterfaceInteraction {
280			Reason:format!("Failed to emit UI request '{}': {}", EventName, Error.to_string()),
281		}
282	})?;
283
284	match timeout(Duration::from_secs(300), Receiver).await {
285		Ok(Ok(Ok(Value))) => Ok(Value),
286
287		Ok(Ok(Err(Error))) => Err(Error),
288
289		Ok(Err(_)) => {
290			Err(CommonError::UserInterfaceInteraction {
291				Reason:format!("UI response channel closed for request ID: {}", RequestIdentifier),
292			})
293		},
294
295		Err(_) => {
296			dev_log!(
297				"window",
298				"warn: [UserInterfaceProvider] UI request '{}' with ID {} timed out.",
299				EventName,
300				RequestIdentifier
301			);
302
303			let mut Guard = Environment
304				.ApplicationState
305				.UI
306				.PendingUserInterfaceRequest
307				.lock()
308				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
309
310			Guard.remove(&RequestIdentifier);
311
312			Err(CommonError::UserInterfaceInteraction {
313				Reason:format!("UI request timed out for request ID: {}", RequestIdentifier),
314			})
315		},
316	}
317}