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 like dialogs, messages, and quick
5//! picks by communicating with the `Sky` frontend.
6//!
7//! ## RESPONSIBILITIES
8//!
9//! ### 1. Modal Dialogs
10//! - Open file/folder selection dialogs (`OpenDialog`)
11//! - Save file dialogs (`SaveDialog`)
12//! - Message boxes (`ShowMessage`, `ShowErrorMessage`)
13//! - Input boxes for text entry (`InputBox`)
14//! - Quick pick lists for selection (`QuickPick`)
15//!
16//! ### 2. Request-Response Pattern
17//! - Send UI requests to Sky frontend via IPC
18//! - Track pending requests with unique IDs
19//! - Wait for responses with timeout handling
20//! - Resolve results via `ResolveUIRequest` callback
21//!
22//! ### 3. Thread Safety
23//! - All methods are async and safe for concurrent access
24//! - Pending requests stored in
25//! `ApplicationState.UI.PendingUserInterfaceRequest`
26//! - Uses `tokio::sync::oneshot` for request-response coordination
27//!
28//! ## ARCHITECTURAL ROLE
29//!
30//! UserInterfaceProvider is the **UI bridge** for Mountain:
31//!
32//! ```text
33//! Provider ──► UI Request ──► Sky Frontend ──► User Interaction ──► ResolveUIRequest
34//! ```
35//!
36//! ### Position in Mountain
37//! - `Environment` module: UI capability provider
38//! - Implements `CommonLibrary::UserInterface::UserInterfaceProvider` trait
39//! - Accessible via `Environment.Require<dyn UserInterfaceProvider>()`
40//!
41//! ### Dependencies
42//! - `ApplicationState`: Pending request tracking
43//! - `IPCProvider`: For sending messages to Sky
44//! - `tauri::AppHandle`: For window/parent references
45//!
46//! ### Dependents
47//! - Any command that needs to show UI dialogs
48//! - `DispatchLogic::ResolveUIRequest`: Completes the request-response cycle
49//! - Error handlers: Show error messages to users
50//!
51//! ## DTO STRUCTURES
52//!
53//! All UI operations use DTOs for type-safe options:
54//! - `OpenDialogOptionsDTO`: File/folder selection options
55//! - `SaveDialogOptionsDTO`: Save file dialog options
56//! - `QuickPickOptionsDTO`: Quick pick list configuration
57//! - `InputBoxOptionsDTO`: Input box configuration
58//! - `MessageSeverity`: Info, Warning, Error levels
59//!
60//! ## REQUEST FLOW
61//!
62//! 1. Provider method called (e.g., `ShowMessage`)
63//! 2. Generate unique request ID
64//! 3. Store `oneshot::Sender` in `PendingUserInterfaceRequest` map
65//! 4. Send IPC message to Sky with request ID and options
66//! 5. Sky shows UI and waits for user action
67//! 6. User responds → Sky calls `ResolveUIRequest` Tauri command
68//! 7. `ResolveUIRequest` looks up sender by ID and sends result
69//! 8. Provider method returns result to caller
70//!
71//! ## ERROR HANDLING
72//!
73//! - IPC failures: `CommonError::IPCError`
74//! - Timeout: `CommonError::RequestTimeout`
75//! - User cancellation: `None` result (not error)
76//! - Invalid arguments: `CommonError::InvalidArgument`
77//!
78//! ## PERFORMANCE
79//!
80//! - Requests are async and non-blocking
81//! - Timeouts prevent indefinite waiting (default ~30s)
82//! - Request IDs are time-based for uniqueness
83//! - Pending request map uses `Arc<Mutex<>>` for thread safety
84//!
85//! ## VS CODE REFERENCE
86//!
87//! Borrowed from VS Code's UI system:
88//! - `vs/platform/dialogs/common/dialogs.ts` - Dialog service API
89//! - `vs/platform/prompt/common/prompt.ts` - Input and quick pick
90//! - `vs/workbench/services/decorator/common/decorator.ts` - Message service
91//!
92//! ## TODO
93//!
94//! - [ ] Add support for custom dialog buttons and layouts
95//! - [ ] Implement file/folder filters with glob patterns
96//! - [ ] Add dialog position and sizing controls
97//! - [ ] Support modal vs non-modal dialogs
98//! - [ ] Add accessibility features (screen reader support)
99//! - [ ] Implement dialog theming (dark/light mode)
100//! - [ ] Add file type/extension selection in save dialog
101//! - [ ] Support multi-select in quick pick and file dialogs
102//! - [ ] Add async progress reporting during long operations
103//! - [ ] Implement custom input validation (regex, etc.)
104//!
105//! ## MODULE CONTENTS
106//!
107//! - [`UserInterfaceProvider`]: Main struct implementing the trait
108//! - Dialog-specific methods: `ShowMessage`, `OpenDialog`, `SaveDialog`
109//! - Selection methods: `QuickPick`, `InputBox`
110//! - Request-response coordination logic
111
112use std::path::PathBuf;
113
114use CommonLibrary::{
115	Error::CommonError::CommonError,
116	IPC::SkyEvent::SkyEvent,
117	UserInterface::{
118		DTO::{
119			InputBoxOptionsDTO::InputBoxOptionsDTO,
120			MessageSeverity::MessageSeverity,
121			OpenDialogOptionsDTO::OpenDialogOptionsDTO,
122			QuickPickItemDTO::QuickPickItemDTO,
123			QuickPickOptionsDTO::QuickPickOptionsDTO,
124			SaveDialogOptionsDTO::SaveDialogOptionsDTO,
125		},
126		UserInterfaceProvider::UserInterfaceProvider,
127	},
128};
129use async_trait::async_trait;
130use serde::Serialize;
131use serde_json::{Value, json};
132use tauri::Emitter;
133use tauri_plugin_dialog::{DialogExt, FilePath};
134use tokio::time::{Duration, timeout};
135use uuid::Uuid;
136
137use super::{MountainEnvironment::MountainEnvironment, Utility};
138use crate::dev_log;
139
140#[derive(Serialize, Clone)]
141struct UserInterfaceRequest<TPayload:Serialize + Clone> {
142	pub RequestIdentifier:String,
143
144	pub Payload:TPayload,
145}
146
147#[async_trait]
148impl UserInterfaceProvider for MountainEnvironment {
149	/// Shows a message to the user with a given severity and optional action
150	/// buttons.
151	async fn ShowMessage(
152		&self,
153
154		Severity:MessageSeverity,
155
156		Message:String,
157
158		Options:Option<Value>,
159	) -> Result<Option<String>, CommonError> {
160		dev_log!("window", "[UserInterfaceProvider] Showing interactive message: {}", Message);
161
162		// camelCase wire shape per the project-wide audit. Sky's listener
163		// at `SkyBridge.ts:2444` already tolerates both casings via the
164		// `?? severity` fallbacks; emit camelCase as the canonical form.
165		let Payload = json!({ "severity": Severity, "message": Message, "options": Options });
166
167		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::UIShowMessageRequest.AsStr(), Payload).await?;
168
169		Ok(ResponseValue.as_str().map(String::from))
170	}
171
172	/// Shows a dialog for opening files or folders using the
173	/// tauri-plugin-dialog.
174	async fn ShowOpenDialog(&self, Options:Option<OpenDialogOptionsDTO>) -> Result<Option<Vec<PathBuf>>, CommonError> {
175		dev_log!("window", "[UserInterfaceProvider] Showing open dialog.");
176
177		let mut Builder = self.ApplicationHandle.dialog().file();
178
179		let (CanSelectMany, CanSelectFolders, CanSelectFiles) = if let Some(ref opts) = Options {
180			if let Some(title) = &opts.Base.Title {
181				Builder = Builder.set_title(title);
182			}
183
184			if let Some(path_string) = &opts.Base.DefaultPath {
185				Builder = Builder.set_directory(PathBuf::from(path_string));
186			}
187
188			if let Some(filters) = &opts.Base.FilterList {
189				for filter in filters {
190					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
191
192					Builder = Builder.add_filter(&filter.Name, &extensions);
193				}
194			}
195
196			(
197				opts.CanSelectMany.unwrap_or(false),
198				opts.CanSelectFolders.unwrap_or(false),
199				opts.CanSelectFiles.unwrap_or(true),
200			)
201		} else {
202			(false, false, true)
203		};
204
205		let PickedPaths:Option<Vec<FilePath>> = tokio::task::spawn_blocking(move || {
206			if CanSelectFolders {
207				if CanSelectMany {
208					Builder.blocking_pick_folders()
209				} else {
210					Builder.blocking_pick_folder().map(|p| vec![p])
211				}
212			} else if CanSelectFiles {
213				if CanSelectMany {
214					Builder.blocking_pick_files()
215				} else {
216					Builder.blocking_pick_file().map(|p| vec![p])
217				}
218			} else {
219				None
220			}
221		})
222		.await
223		.map_err(|Error| CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) })?;
224
225		Ok(PickedPaths.map(|paths| paths.into_iter().filter_map(|p| p.into_path().ok()).collect()))
226	}
227
228	/// Shows a dialog for saving a file using the tauri-plugin-dialog.
229	async fn ShowSaveDialog(&self, Options:Option<SaveDialogOptionsDTO>) -> Result<Option<PathBuf>, CommonError> {
230		dev_log!("window", "[UserInterfaceProvider] Showing save dialog.");
231
232		let mut Builder = self.ApplicationHandle.dialog().file();
233
234		if let Some(options) = Options {
235			if let Some(title) = options.Base.Title {
236				Builder = Builder.set_title(title);
237			}
238
239			if let Some(path_string) = options.Base.DefaultPath {
240				let path = PathBuf::from(path_string);
241
242				if let Some(parent) = path.parent() {
243					Builder = Builder.set_directory(parent);
244				}
245
246				if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
247					Builder = Builder.set_file_name(file_name);
248				}
249			}
250
251			if let Some(filters) = options.Base.FilterList {
252				for filter in filters {
253					let extensions:Vec<&str> = filter.ExtensionList.iter().map(AsRef::as_ref).collect();
254
255					Builder = Builder.add_filter(filter.Name, &extensions);
256				}
257			}
258		}
259
260		let PickedFile = tokio::task::spawn_blocking(move || Builder.blocking_save_file())
261			.await
262			.map_err(|Error| {
263				CommonError::UserInterfaceInteraction { Reason:format!("Dialog task failed: {}", Error) }
264			})?;
265
266		Ok(PickedFile.and_then(|p| p.into_path().ok()))
267	}
268
269	/// Shows a quick pick list to the user.
270	async fn ShowQuickPick(
271		&self,
272
273		Items:Vec<QuickPickItemDTO>,
274
275		Options:Option<QuickPickOptionsDTO>,
276	) -> Result<Option<Vec<String>>, CommonError> {
277		dev_log!(
278			"window",
279			"[UserInterfaceProvider] Showing quick pick with {} items.",
280			Items.len()
281		);
282
283		// camelCase wire shape per project-wide audit.
284		let Payload = json!({ "items": Items, "options": Options });
285
286		// Use the Sky-listener-aligned channel (`sky://quickpick/show`).
287		// The legacy `UIShowQuickPickRequest` channel
288		// (`sky://ui/show-quick-pick-request`) had no Sky listener and
289		// every emit silently disappeared.
290		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::QuickPickShow.AsStr(), Payload).await?;
291
292		serde_json::from_value(ResponseValue).map_err(|Error| {
293			CommonError::SerializationError {
294				Description:format!("Failed to deserialize quick pick response: {}", Error),
295			}
296		})
297	}
298
299	/// Shows an input box to solicit a string input from the user.
300	async fn ShowInputBox(&self, Options:Option<InputBoxOptionsDTO>) -> Result<Option<String>, CommonError> {
301		dev_log!("window", "[UserInterfaceProvider] Showing input box.");
302
303		// Use the Sky-listener-aligned channel (`sky://input-box/show`).
304		// The legacy `UIShowInputBoxRequest` channel
305		// (`sky://ui/show-input-box-request`) had no Sky listener.
306		let ResponseValue = SendUserInterfaceRequest(self, SkyEvent::InputBoxShow.AsStr(), Options).await?;
307
308		serde_json::from_value(ResponseValue).map_err(|Error| {
309			CommonError::SerializationError {
310				Description:format!("Failed to deserialize input box response: {}", Error),
311			}
312		})
313	}
314}
315
316// --- Internal Helper Functions ---
317
318/// A generic helper function to send a request to the Sky UI and wait for a
319/// response.
320///
321/// Atom T1: made `pub(crate)` so Track effect creators
322/// (`applyEdit` / `showTextDocument` / `Task.Execute`, etc.) can reuse the
323/// same RequestIdentifier/oneshot pattern instead of emitting fire-and-
324/// forget events that resolve to synthetic success.
325pub(crate) async fn SendUserInterfaceRequest<TPayload:Serialize + Clone>(
326	Environment:&MountainEnvironment,
327
328	EventName:&str,
329
330	Payload:TPayload,
331) -> Result<Value, CommonError> {
332	let RequestIdentifier = Uuid::new_v4().to_string();
333
334	let (Sender, Receiver) = tokio::sync::oneshot::channel();
335
336	{
337		let mut PendingRequestsGuard = Environment
338			.ApplicationState
339			.UI
340			.PendingUserInterfaceRequest
341			.lock()
342			.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
343
344		PendingRequestsGuard.insert(RequestIdentifier.clone(), Sender);
345	}
346
347	let EventPayload = UserInterfaceRequest { RequestIdentifier:RequestIdentifier.clone(), Payload };
348
349	Environment.ApplicationHandle.emit(EventName, EventPayload).map_err(|Error| {
350		CommonError::UserInterfaceInteraction {
351			Reason:format!("Failed to emit UI request '{}': {}", EventName, Error.to_string()),
352		}
353	})?;
354
355	match timeout(Duration::from_secs(300), Receiver).await {
356		Ok(Ok(Ok(Value))) => Ok(Value),
357
358		Ok(Ok(Err(Error))) => Err(Error),
359
360		Ok(Err(_)) => {
361			Err(CommonError::UserInterfaceInteraction {
362				Reason:format!("UI response channel closed for request ID: {}", RequestIdentifier),
363			})
364		},
365
366		Err(_) => {
367			dev_log!(
368				"window",
369				"warn: [UserInterfaceProvider] UI request '{}' with ID {} timed out.",
370				EventName,
371				RequestIdentifier
372			);
373
374			let mut Guard = Environment
375				.ApplicationState
376				.UI
377				.PendingUserInterfaceRequest
378				.lock()
379				.map_err(Utility::ErrorMapping::MapApplicationStateLockErrorToCommonError)?;
380
381			Guard.remove(&RequestIdentifier);
382
383			Err(CommonError::UserInterfaceInteraction {
384				Reason:format!("UI request timed out for request ID: {}", RequestIdentifier),
385			})
386		},
387	}
388}