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 disabled_items_never_reach_the_host() {
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: "chat".to_string(),
291 placement: crate::SidebarActionPlacement::Footer,
292 label: "Chat".to_string(),
293 icon: "icons/chat.svg".to_string(),
294 disabled: true,
295 }])
296 .unwrap();
297
298 assert_eq!(
299 activate_sidebar_action(SidebarActionIntent {
300 id: "chat".to_string(),
301 generation: 1,
302 }),
303 Err(ShellError::SidebarActionDisabled {
304 id: "chat".to_string()
305 })
306 );
307 assert!(host.activated.lock().unwrap().is_empty());
308 }
309
310 #[test]
311 fn stale_generation_never_retargets_a_replaced_action() {
312 let _guard = test_guard();
313 reset_for_test();
314 let dir = tempfile::tempdir().unwrap();
315 let host = Arc::new(TestHost::default());
316 initialize(dir.path(), host.clone()).unwrap();
317 let manager = manager().unwrap();
318 manager
319 .replace_sidebar_actions(vec![ShellSidebarAction {
320 id: "settings".to_string(),
321 placement: crate::SidebarActionPlacement::Header,
322 label: "Settings".to_string(),
323 icon: "icons/settings.svg".to_string(),
324 disabled: false,
325 }])
326 .unwrap();
327 manager.clear_sidebar_actions().unwrap();
328
329 assert_eq!(
330 activate_sidebar_action(SidebarActionIntent {
331 id: "settings".to_string(),
332 generation: 1,
333 }),
334 Err(ShellError::StaleSidebarActionIntent {
335 generation: 1,
336 current: 2,
337 })
338 );
339 assert!(host.activated.lock().unwrap().is_empty());
340 }
341
342 #[test]
343 fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
344 let _guard = test_guard();
345 reset_for_test();
346 let dir = tempfile::tempdir().unwrap();
347 let host = Arc::new(TestHost::default());
348 initialize(dir.path(), host.clone()).unwrap();
349 for index in 0..crate::MAX_SHELL_PINS {
350 let target = if index % 2 == 0 {
351 ShellPinTarget::Lxapp {
352 key: format!("app.{index}"),
353 }
354 } else {
355 ShellPinTarget::Bookmark {
356 key: format!("bookmark-{index}"),
357 }
358 };
359 set_pinned(target, true).unwrap();
360 }
361
362 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
363 assert_eq!(
364 set_pinned(
365 ShellPinTarget::Lxapp {
366 key: "app.overflow".to_string(),
367 },
368 true,
369 ),
370 Err(ShellError::LimitReached {
371 max: crate::MAX_SHELL_PINS,
372 })
373 );
374 assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
375 }
376
377 #[test]
378 fn failed_pin_apply_rolls_back_memory_and_disk() {
379 let _guard = test_guard();
380 reset_for_test();
381 let dir = tempfile::tempdir().unwrap();
382 let host = Arc::new(TestHost::default());
383 initialize(dir.path(), host.clone()).unwrap();
384 host.reject_pins.store(true, Ordering::Relaxed);
385
386 assert_eq!(
387 set_pinned(
388 ShellPinTarget::Lxapp {
389 key: "app.chat".to_string(),
390 },
391 true,
392 ),
393 Err(ShellError::Host("rejected Pins".to_string()))
394 );
395 assert!(manager().unwrap().snapshot().pins.items.is_empty());
396 assert!(
397 ShellManager::open(dir.path())
398 .unwrap()
399 .snapshot()
400 .pins
401 .items
402 .is_empty()
403 );
404 }
405}