1use crate::{
2 ResolvedShellSidebarAction, ShellError, ShellManager, ShellPin, ShellPinTarget, ShellResult,
3 ShellSidebarAction,
4};
5use std::path::PathBuf;
6use std::sync::{Arc, Mutex, OnceLock};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SidebarActionIntent {
10 pub id: String,
11 pub generation: u64,
12}
13
14pub trait ShellHost: Send + Sync + 'static {
15 fn resolve_sidebar_actions(
16 &self,
17 generation: u64,
18 items: &[ShellSidebarAction],
19 ) -> ShellResult<Vec<ResolvedShellSidebarAction>>;
20
21 fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()>;
22
23 fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()>;
24
25 fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()>;
26}
27
28#[derive(Clone)]
29struct ActiveShell {
30 manager: Arc<ShellManager>,
31 host: Arc<dyn ShellHost>,
32}
33
34fn active_slot() -> &'static Mutex<Option<ActiveShell>> {
35 static ACTIVE: OnceLock<Mutex<Option<ActiveShell>>> = OnceLock::new();
36 ACTIVE.get_or_init(|| Mutex::new(None))
37}
38
39pub fn initialize(root: impl Into<PathBuf>, host: Arc<dyn ShellHost>) -> ShellResult<()> {
40 let manager = Arc::new(ShellManager::open(root)?);
41 let mut active = active_slot()
42 .lock()
43 .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
44 *active = Some(ActiveShell { manager, host });
45 Ok(())
46}
47
48pub fn manager() -> ShellResult<Arc<ShellManager>> {
49 active_slot()
50 .lock()
51 .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?
52 .as_ref()
53 .map(|active| active.manager.clone())
54 .ok_or(ShellError::NotInitialized)
55}
56
57pub fn resolved_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
58 with_active(|active| {
59 let snapshot = active.manager.snapshot();
60 active.host.resolve_sidebar_actions(
61 snapshot.sidebar_actions.generation(),
62 snapshot.sidebar_actions.items(),
63 )
64 })
65}
66
67pub fn apply_current_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
68 with_active(|active| {
69 let snapshot = active.manager.snapshot();
70 let resolved = active.host.resolve_sidebar_actions(
71 snapshot.sidebar_actions.generation(),
72 snapshot.sidebar_actions.items(),
73 )?;
74 active.host.apply_sidebar_actions(&resolved)?;
75 Ok(resolved)
76 })
77}
78
79pub fn sidebar_chrome() -> crate::SidebarChrome {
82 manager()
83 .map(|manager| manager.sidebar_chrome())
84 .unwrap_or_default()
85}
86
87pub fn set_sidebar_chrome(chrome: crate::SidebarChrome) -> ShellResult<()> {
91 manager()?.set_sidebar_chrome(chrome)
92}
93
94pub fn window_frame() -> Option<crate::WindowFrame> {
95 manager().ok().and_then(|manager| manager.window_frame())
96}
97
98pub fn set_window_frame(frame: crate::WindowFrame) -> ShellResult<()> {
99 manager()?.set_window_frame(frame)
100}
101
102pub fn pins() -> ShellResult<Vec<ShellPin>> {
103 Ok(manager()?.snapshot().pins.items)
104}
105
106pub fn apply_current_pins() -> ShellResult<Vec<ShellPin>> {
107 with_active(|active| {
108 let items = active.manager.snapshot().pins.items;
109 active.host.apply_pins(&items)?;
110 Ok(items)
111 })
112}
113
114pub fn is_pinned(target: &ShellPinTarget) -> ShellResult<bool> {
115 Ok(manager()?.snapshot().pins.is_pinned(target))
116}
117
118pub fn set_pinned(target: ShellPinTarget, pinned: bool) -> ShellResult<crate::PinMutation> {
119 let _mutation = pin_mutation_lock()
120 .lock()
121 .map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
122 with_active(|active| {
123 let previous = active.manager.snapshot().pins;
124 let (mutation, snapshot) = if pinned {
125 active.manager.pin(target)?
126 } else {
127 active.manager.unpin(&target)?
128 };
129 if mutation == crate::PinMutation::Changed
130 && let Err(error) = active.host.apply_pins(&snapshot.pins.items)
131 {
132 let _ = active.manager.commit_pins(&snapshot.pins, previous.clone());
133 let _ = active.host.apply_pins(&previous.items);
134 return Err(error);
135 }
136 Ok(mutation)
137 })
138}
139
140pub fn reorder_pins(items: Vec<ShellPin>) -> ShellResult<crate::PinMutation> {
141 let _mutation = pin_mutation_lock()
142 .lock()
143 .map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
144 with_active(|active| {
145 let previous = active.manager.snapshot().pins;
146 let mut next = previous.clone();
147 let mutation = next.reorder(items)?;
148 if mutation == crate::PinMutation::Unchanged {
149 return Ok(mutation);
150 }
151 let snapshot = active.manager.commit_pins(&previous, next)?;
152 if let Err(error) = active.host.apply_pins(&snapshot.pins.items) {
153 active
154 .manager
155 .commit_pins(&snapshot.pins, previous.clone())?;
156 let _ = active.host.apply_pins(&previous.items);
157 return Err(error);
158 }
159 Ok(mutation)
160 })
161}
162
163fn pin_mutation_lock() -> &'static Mutex<()> {
164 static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
165 MUTATION.get_or_init(|| Mutex::new(()))
166}
167
168pub fn activate_sidebar_action(mut intent: SidebarActionIntent) -> ShellResult<()> {
169 let id = intent.id.trim().to_string();
170 if id.is_empty() {
171 return Err(ShellError::EmptySidebarActionId);
172 }
173 intent.id = id.clone();
174 with_active(|active| {
175 let snapshot = active.manager.snapshot();
176 let current_generation = snapshot.sidebar_actions.generation();
177 if intent.generation != current_generation {
178 let still_live = snapshot
182 .sidebar_actions
183 .items()
184 .iter()
185 .any(|item| item.id == id);
186 if still_live {
187 intent.generation = current_generation;
188 } else {
189 return Err(ShellError::StaleSidebarActionIntent {
190 generation: intent.generation,
191 current: current_generation,
192 });
193 }
194 }
195 let Some(item) = snapshot
196 .sidebar_actions
197 .items()
198 .iter()
199 .find(|item| item.id == id)
200 else {
201 return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
202 };
203 if item.disabled {
204 return Err(ShellError::SidebarActionDisabled { id: id.to_string() });
205 }
206 active.host.activate(intent)
207 })
208}
209
210fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
211 let active = {
212 let slot = active_slot()
213 .lock()
214 .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
215 slot.clone().ok_or(ShellError::NotInitialized)?
216 };
217 run(&active)
218}
219
220#[cfg(test)]
221pub(crate) fn reset_for_test() {
222 *active_slot().lock().unwrap() = None;
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use std::sync::atomic::{AtomicBool, Ordering};
229
230 fn test_guard() -> std::sync::MutexGuard<'static, ()> {
231 static TEST_LOCK: Mutex<()> = Mutex::new(());
232 TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
233 }
234
235 #[derive(Default)]
236 struct TestHost {
237 activated: Mutex<Vec<SidebarActionIntent>>,
238 applied: Mutex<Vec<Vec<ResolvedShellSidebarAction>>>,
239 applied_pins: Mutex<Vec<Vec<ShellPin>>>,
240 reject_pins: AtomicBool,
241 }
242
243 impl ShellHost for TestHost {
244 fn resolve_sidebar_actions(
245 &self,
246 generation: u64,
247 items: &[ShellSidebarAction],
248 ) -> ShellResult<Vec<ResolvedShellSidebarAction>> {
249 Ok(items
250 .iter()
251 .map(|item| ResolvedShellSidebarAction {
252 generation,
253 id: item.id.clone(),
254 placement: item.placement,
255 label: item.label.clone(),
256 icon_path: Some(item.icon.clone()),
257 disabled: item.disabled,
258 })
259 .collect())
260 }
261
262 fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()> {
263 self.applied.lock().unwrap().push(items.to_vec());
264 Ok(())
265 }
266
267 fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
268 self.applied_pins.lock().unwrap().push(items.to_vec());
269 if self.reject_pins.load(Ordering::Relaxed) {
270 return Err(ShellError::Host("rejected Pins".to_string()));
271 }
272 Ok(())
273 }
274
275 fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()> {
276 self.activated.lock().unwrap().push(intent);
277 Ok(())
278 }
279 }
280
281 #[test]
282 fn stable_id_activation_routes_the_current_generation() {
283 let _guard = test_guard();
284 reset_for_test();
285 let dir = tempfile::tempdir().unwrap();
286 let host = Arc::new(TestHost::default());
287 initialize(dir.path(), host.clone()).unwrap();
288 manager()
289 .unwrap()
290 .replace_sidebar_actions(vec![ShellSidebarAction {
291 id: "sync".to_string(),
292 placement: crate::SidebarActionPlacement::Footer,
293 label: "Sync".to_string(),
294 icon: "icons/sync.svg".to_string(),
295 disabled: false,
296 }])
297 .unwrap();
298
299 activate_sidebar_action(SidebarActionIntent {
300 id: "sync".to_string(),
301 generation: 1,
302 })
303 .unwrap();
304
305 assert_eq!(
306 host.activated.lock().unwrap().as_slice(),
307 &[SidebarActionIntent {
308 id: "sync".to_string(),
309 generation: 1,
310 }]
311 );
312 assert!(host.applied.lock().unwrap().is_empty());
313 }
314
315 #[test]
316 fn settings_shaped_runtime_action_stays_on_the_generic_host_channel() {
317 let _guard = test_guard();
318 reset_for_test();
319 let dir = tempfile::tempdir().unwrap();
320 let host = Arc::new(TestHost::default());
321 initialize(dir.path(), host.clone()).unwrap();
322 manager()
323 .unwrap()
324 .replace_sidebar_actions(vec![ShellSidebarAction {
325 id: "settings".to_string(),
326 placement: crate::SidebarActionPlacement::Footer,
327 label: "Settings".to_string(),
328 icon: "settings".to_string(),
329 disabled: false,
330 }])
331 .unwrap();
332
333 activate_sidebar_action(SidebarActionIntent {
334 id: "settings".to_string(),
335 generation: 1,
336 })
337 .unwrap();
338
339 assert_eq!(
340 host.activated.lock().unwrap().as_slice(),
341 &[SidebarActionIntent {
342 id: "settings".to_string(),
343 generation: 1,
344 }]
345 );
346 }
347
348 #[test]
349 fn disabled_items_never_reach_the_host() {
350 let _guard = test_guard();
351 reset_for_test();
352 let dir = tempfile::tempdir().unwrap();
353 let host = Arc::new(TestHost::default());
354 initialize(dir.path(), host.clone()).unwrap();
355 manager()
356 .unwrap()
357 .replace_sidebar_actions(vec![ShellSidebarAction {
358 id: "chat".to_string(),
359 placement: crate::SidebarActionPlacement::Footer,
360 label: "Chat".to_string(),
361 icon: "icons/chat.svg".to_string(),
362 disabled: true,
363 }])
364 .unwrap();
365
366 assert_eq!(
367 activate_sidebar_action(SidebarActionIntent {
368 id: "chat".to_string(),
369 generation: 1,
370 }),
371 Err(ShellError::SidebarActionDisabled {
372 id: "chat".to_string()
373 })
374 );
375 assert!(host.activated.lock().unwrap().is_empty());
376 }
377
378 #[test]
379 fn stale_generation_retargets_when_the_id_is_still_live() {
380 let _guard = test_guard();
381 reset_for_test();
382 let dir = tempfile::tempdir().unwrap();
383 let host = Arc::new(TestHost::default());
384 initialize(dir.path(), host.clone()).unwrap();
385 let manager = manager().unwrap();
386 manager
387 .replace_sidebar_actions(vec![ShellSidebarAction {
388 id: "brand".to_string(),
389 placement: crate::SidebarActionPlacement::Footer,
390 label: "Brand".to_string(),
391 icon: "icons/brand.svg".to_string(),
392 disabled: false,
393 }])
394 .unwrap();
395 manager
396 .replace_sidebar_actions(vec![ShellSidebarAction {
397 id: "brand".to_string(),
398 placement: crate::SidebarActionPlacement::Footer,
399 label: "Brand 2".to_string(),
400 icon: "icons/brand.svg".to_string(),
401 disabled: false,
402 }])
403 .unwrap();
404
405 activate_sidebar_action(SidebarActionIntent {
406 id: "brand".to_string(),
407 generation: 1,
408 })
409 .unwrap();
410
411 assert_eq!(
412 host.activated.lock().unwrap().as_slice(),
413 &[SidebarActionIntent {
414 id: "brand".to_string(),
415 generation: 2,
416 }]
417 );
418 }
419
420 #[test]
421 fn stale_generation_never_retargets_a_replaced_action() {
422 let _guard = test_guard();
423 reset_for_test();
424 let dir = tempfile::tempdir().unwrap();
425 let host = Arc::new(TestHost::default());
426 initialize(dir.path(), host.clone()).unwrap();
427 let manager = manager().unwrap();
428 manager
429 .replace_sidebar_actions(vec![ShellSidebarAction {
430 id: "settings".to_string(),
431 placement: crate::SidebarActionPlacement::Header,
432 label: "Settings".to_string(),
433 icon: "icons/settings.svg".to_string(),
434 disabled: false,
435 }])
436 .unwrap();
437 manager.clear_sidebar_actions().unwrap();
438
439 assert_eq!(
440 activate_sidebar_action(SidebarActionIntent {
441 id: "settings".to_string(),
442 generation: 1,
443 }),
444 Err(ShellError::StaleSidebarActionIntent {
445 generation: 1,
446 current: 2,
447 })
448 );
449 assert!(host.activated.lock().unwrap().is_empty());
450 }
451
452 #[test]
453 fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
454 let _guard = test_guard();
455 reset_for_test();
456 let dir = tempfile::tempdir().unwrap();
457 let host = Arc::new(TestHost::default());
458 initialize(dir.path(), host.clone()).unwrap();
459 for index in 0..crate::MAX_SHELL_PINS {
460 let target = if index % 2 == 0 {
461 ShellPinTarget::Lxapp {
462 key: format!("app.{index}"),
463 }
464 } else {
465 ShellPinTarget::Bookmark {
466 key: format!("bookmark-{index}"),
467 }
468 };
469 set_pinned(target, true).unwrap();
470 }
471
472 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
473 assert_eq!(
474 set_pinned(
475 ShellPinTarget::Lxapp {
476 key: "app.overflow".to_string(),
477 },
478 true,
479 ),
480 Err(ShellError::LimitReached {
481 max: crate::MAX_SHELL_PINS,
482 })
483 );
484 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
485 }
486
487 #[test]
488 fn reorder_persists_mixed_order_and_rolls_back_rejected_projection() {
489 let _guard = test_guard();
490 reset_for_test();
491 let dir = tempfile::tempdir().unwrap();
492 let host = Arc::new(TestHost::default());
493 initialize(dir.path(), host.clone()).unwrap();
494 set_pinned(ShellPinTarget::Lxapp { key: "chat".into() }, true).unwrap();
495 set_pinned(ShellPinTarget::Bookmark { key: "site".into() }, true).unwrap();
496 let original = pins().unwrap();
497 let reversed: Vec<_> = original.iter().rev().cloned().collect();
498 reorder_pins(reversed.clone()).unwrap();
499 assert_eq!(pins().unwrap(), reversed);
500 assert_eq!(
501 ShellManager::open(dir.path())
502 .unwrap()
503 .snapshot()
504 .pins
505 .items,
506 reversed
507 );
508 host.reject_pins.store(true, Ordering::Relaxed);
509 assert!(reorder_pins(original).is_err());
510 assert_eq!(pins().unwrap(), reversed);
511 assert_eq!(
512 ShellManager::open(dir.path())
513 .unwrap()
514 .snapshot()
515 .pins
516 .items,
517 reversed
518 );
519 }
520
521 #[test]
522 fn failed_pin_apply_rolls_back_memory_and_disk() {
523 let _guard = test_guard();
524 reset_for_test();
525 let dir = tempfile::tempdir().unwrap();
526 let host = Arc::new(TestHost::default());
527 initialize(dir.path(), host.clone()).unwrap();
528 host.reject_pins.store(true, Ordering::Relaxed);
529
530 assert_eq!(
531 set_pinned(
532 ShellPinTarget::Lxapp {
533 key: "app.chat".to_string(),
534 },
535 true,
536 ),
537 Err(ShellError::Host("rejected Pins".to_string()))
538 );
539 assert!(manager().unwrap().snapshot().pins.items.is_empty());
540 assert!(
541 ShellManager::open(dir.path())
542 .unwrap()
543 .snapshot()
544 .pins
545 .items
546 .is_empty()
547 );
548 }
549}