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
140fn pin_mutation_lock() -> &'static Mutex<()> {
141 static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
142 MUTATION.get_or_init(|| Mutex::new(()))
143}
144
145pub fn activate_sidebar_action(mut intent: SidebarActionIntent) -> ShellResult<()> {
146 let id = intent.id.trim().to_string();
147 if id.is_empty() {
148 return Err(ShellError::EmptySidebarActionId);
149 }
150 intent.id = id.clone();
151 with_active(|active| {
152 let snapshot = active.manager.snapshot();
153 let current_generation = snapshot.sidebar_actions.generation();
154 if intent.generation != current_generation {
155 return Err(ShellError::StaleSidebarActionIntent {
156 generation: intent.generation,
157 current: current_generation,
158 });
159 }
160 let Some(item) = snapshot
161 .sidebar_actions
162 .items()
163 .iter()
164 .find(|item| item.id == id)
165 else {
166 return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
167 };
168 if item.disabled {
169 return Err(ShellError::SidebarActionDisabled { id: id.to_string() });
170 }
171 active.host.activate(intent)
172 })
173}
174
175fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
176 let active = {
177 let slot = active_slot()
178 .lock()
179 .map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
180 slot.clone().ok_or(ShellError::NotInitialized)?
181 };
182 run(&active)
183}
184
185#[cfg(test)]
186pub(crate) fn reset_for_test() {
187 *active_slot().lock().unwrap() = None;
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use std::sync::atomic::{AtomicBool, Ordering};
194
195 fn test_guard() -> std::sync::MutexGuard<'static, ()> {
196 static TEST_LOCK: Mutex<()> = Mutex::new(());
197 TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
198 }
199
200 #[derive(Default)]
201 struct TestHost {
202 activated: Mutex<Vec<SidebarActionIntent>>,
203 applied: Mutex<Vec<Vec<ResolvedShellSidebarAction>>>,
204 applied_pins: Mutex<Vec<Vec<ShellPin>>>,
205 reject_pins: AtomicBool,
206 }
207
208 impl ShellHost for TestHost {
209 fn resolve_sidebar_actions(
210 &self,
211 generation: u64,
212 items: &[ShellSidebarAction],
213 ) -> ShellResult<Vec<ResolvedShellSidebarAction>> {
214 Ok(items
215 .iter()
216 .map(|item| ResolvedShellSidebarAction {
217 generation,
218 id: item.id.clone(),
219 placement: item.placement,
220 label: item.label.clone(),
221 icon_path: Some(item.icon.clone()),
222 disabled: item.disabled,
223 })
224 .collect())
225 }
226
227 fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()> {
228 self.applied.lock().unwrap().push(items.to_vec());
229 Ok(())
230 }
231
232 fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
233 self.applied_pins.lock().unwrap().push(items.to_vec());
234 if self.reject_pins.load(Ordering::Relaxed) {
235 return Err(ShellError::Host("rejected Pins".to_string()));
236 }
237 Ok(())
238 }
239
240 fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()> {
241 self.activated.lock().unwrap().push(intent);
242 Ok(())
243 }
244 }
245
246 #[test]
247 fn stable_id_activation_routes_the_current_generation() {
248 let _guard = test_guard();
249 reset_for_test();
250 let dir = tempfile::tempdir().unwrap();
251 let host = Arc::new(TestHost::default());
252 initialize(dir.path(), host.clone()).unwrap();
253 manager()
254 .unwrap()
255 .replace_sidebar_actions(vec![ShellSidebarAction {
256 id: "sync".to_string(),
257 placement: crate::SidebarActionPlacement::Footer,
258 label: "Sync".to_string(),
259 icon: "icons/sync.svg".to_string(),
260 disabled: false,
261 }])
262 .unwrap();
263
264 activate_sidebar_action(SidebarActionIntent {
265 id: "sync".to_string(),
266 generation: 1,
267 })
268 .unwrap();
269
270 assert_eq!(
271 host.activated.lock().unwrap().as_slice(),
272 &[SidebarActionIntent {
273 id: "sync".to_string(),
274 generation: 1,
275 }]
276 );
277 assert!(host.applied.lock().unwrap().is_empty());
278 }
279
280 #[test]
281 fn settings_shaped_runtime_action_stays_on_the_generic_host_channel() {
282 let _guard = test_guard();
283 reset_for_test();
284 let dir = tempfile::tempdir().unwrap();
285 let host = Arc::new(TestHost::default());
286 initialize(dir.path(), host.clone()).unwrap();
287 manager()
288 .unwrap()
289 .replace_sidebar_actions(vec![ShellSidebarAction {
290 id: "settings".to_string(),
291 placement: crate::SidebarActionPlacement::Footer,
292 label: "Settings".to_string(),
293 icon: "settings".to_string(),
294 disabled: false,
295 }])
296 .unwrap();
297
298 activate_sidebar_action(SidebarActionIntent {
299 id: "settings".to_string(),
300 generation: 1,
301 })
302 .unwrap();
303
304 assert_eq!(
305 host.activated.lock().unwrap().as_slice(),
306 &[SidebarActionIntent {
307 id: "settings".to_string(),
308 generation: 1,
309 }]
310 );
311 }
312
313 #[test]
314 fn disabled_items_never_reach_the_host() {
315 let _guard = test_guard();
316 reset_for_test();
317 let dir = tempfile::tempdir().unwrap();
318 let host = Arc::new(TestHost::default());
319 initialize(dir.path(), host.clone()).unwrap();
320 manager()
321 .unwrap()
322 .replace_sidebar_actions(vec![ShellSidebarAction {
323 id: "chat".to_string(),
324 placement: crate::SidebarActionPlacement::Footer,
325 label: "Chat".to_string(),
326 icon: "icons/chat.svg".to_string(),
327 disabled: true,
328 }])
329 .unwrap();
330
331 assert_eq!(
332 activate_sidebar_action(SidebarActionIntent {
333 id: "chat".to_string(),
334 generation: 1,
335 }),
336 Err(ShellError::SidebarActionDisabled {
337 id: "chat".to_string()
338 })
339 );
340 assert!(host.activated.lock().unwrap().is_empty());
341 }
342
343 #[test]
344 fn stale_generation_never_retargets_a_replaced_action() {
345 let _guard = test_guard();
346 reset_for_test();
347 let dir = tempfile::tempdir().unwrap();
348 let host = Arc::new(TestHost::default());
349 initialize(dir.path(), host.clone()).unwrap();
350 let manager = manager().unwrap();
351 manager
352 .replace_sidebar_actions(vec![ShellSidebarAction {
353 id: "settings".to_string(),
354 placement: crate::SidebarActionPlacement::Header,
355 label: "Settings".to_string(),
356 icon: "icons/settings.svg".to_string(),
357 disabled: false,
358 }])
359 .unwrap();
360 manager.clear_sidebar_actions().unwrap();
361
362 assert_eq!(
363 activate_sidebar_action(SidebarActionIntent {
364 id: "settings".to_string(),
365 generation: 1,
366 }),
367 Err(ShellError::StaleSidebarActionIntent {
368 generation: 1,
369 current: 2,
370 })
371 );
372 assert!(host.activated.lock().unwrap().is_empty());
373 }
374
375 #[test]
376 fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
377 let _guard = test_guard();
378 reset_for_test();
379 let dir = tempfile::tempdir().unwrap();
380 let host = Arc::new(TestHost::default());
381 initialize(dir.path(), host.clone()).unwrap();
382 for index in 0..crate::MAX_SHELL_PINS {
383 let target = if index % 2 == 0 {
384 ShellPinTarget::Lxapp {
385 key: format!("app.{index}"),
386 }
387 } else {
388 ShellPinTarget::Bookmark {
389 key: format!("bookmark-{index}"),
390 }
391 };
392 set_pinned(target, true).unwrap();
393 }
394
395 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
396 assert_eq!(
397 set_pinned(
398 ShellPinTarget::Lxapp {
399 key: "app.overflow".to_string(),
400 },
401 true,
402 ),
403 Err(ShellError::LimitReached {
404 max: crate::MAX_SHELL_PINS,
405 })
406 );
407 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
408 }
409
410 #[test]
411 fn failed_pin_apply_rolls_back_memory_and_disk() {
412 let _guard = test_guard();
413 reset_for_test();
414 let dir = tempfile::tempdir().unwrap();
415 let host = Arc::new(TestHost::default());
416 initialize(dir.path(), host.clone()).unwrap();
417 host.reject_pins.store(true, Ordering::Relaxed);
418
419 assert_eq!(
420 set_pinned(
421 ShellPinTarget::Lxapp {
422 key: "app.chat".to_string(),
423 },
424 true,
425 ),
426 Err(ShellError::Host("rejected Pins".to_string()))
427 );
428 assert!(manager().unwrap().snapshot().pins.items.is_empty());
429 assert!(
430 ShellManager::open(dir.path())
431 .unwrap()
432 .snapshot()
433 .pins
434 .items
435 .is_empty()
436 );
437 }
438}