use crate::{
ResolvedShellSidebarAction, ShellError, ShellManager, ShellPin, ShellPinTarget, ShellResult,
ShellSidebarAction,
};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidebarActionIntent {
pub id: String,
pub generation: u64,
}
pub trait ShellHost: Send + Sync + 'static {
fn resolve_sidebar_actions(
&self,
generation: u64,
items: &[ShellSidebarAction],
) -> ShellResult<Vec<ResolvedShellSidebarAction>>;
fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()>;
fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()>;
fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()>;
}
#[derive(Clone)]
struct ActiveShell {
manager: Arc<ShellManager>,
host: Arc<dyn ShellHost>,
}
fn active_slot() -> &'static Mutex<Option<ActiveShell>> {
static ACTIVE: OnceLock<Mutex<Option<ActiveShell>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(None))
}
pub fn initialize(root: impl Into<PathBuf>, host: Arc<dyn ShellHost>) -> ShellResult<()> {
let manager = Arc::new(ShellManager::open(root)?);
let mut active = active_slot()
.lock()
.map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
*active = Some(ActiveShell { manager, host });
Ok(())
}
pub fn manager() -> ShellResult<Arc<ShellManager>> {
active_slot()
.lock()
.map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?
.as_ref()
.map(|active| active.manager.clone())
.ok_or(ShellError::NotInitialized)
}
pub fn resolved_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
with_active(|active| {
let snapshot = active.manager.snapshot();
active.host.resolve_sidebar_actions(
snapshot.sidebar_actions.generation(),
snapshot.sidebar_actions.items(),
)
})
}
pub fn apply_current_sidebar_actions() -> ShellResult<Vec<ResolvedShellSidebarAction>> {
with_active(|active| {
let snapshot = active.manager.snapshot();
let resolved = active.host.resolve_sidebar_actions(
snapshot.sidebar_actions.generation(),
snapshot.sidebar_actions.items(),
)?;
active.host.apply_sidebar_actions(&resolved)?;
Ok(resolved)
})
}
pub fn sidebar_chrome() -> crate::SidebarChrome {
manager()
.map(|manager| manager.sidebar_chrome())
.unwrap_or_default()
}
pub fn set_sidebar_chrome(chrome: crate::SidebarChrome) -> ShellResult<()> {
manager()?.set_sidebar_chrome(chrome)
}
pub fn window_frame() -> Option<crate::WindowFrame> {
manager().ok().and_then(|manager| manager.window_frame())
}
pub fn set_window_frame(frame: crate::WindowFrame) -> ShellResult<()> {
manager()?.set_window_frame(frame)
}
pub fn pins() -> ShellResult<Vec<ShellPin>> {
Ok(manager()?.snapshot().pins.items)
}
pub fn apply_current_pins() -> ShellResult<Vec<ShellPin>> {
with_active(|active| {
let items = active.manager.snapshot().pins.items;
active.host.apply_pins(&items)?;
Ok(items)
})
}
pub fn is_pinned(target: &ShellPinTarget) -> ShellResult<bool> {
Ok(manager()?.snapshot().pins.is_pinned(target))
}
pub fn set_pinned(target: ShellPinTarget, pinned: bool) -> ShellResult<crate::PinMutation> {
let _mutation = pin_mutation_lock()
.lock()
.map_err(|_| ShellError::Host("shell Pin mutation state is poisoned".to_string()))?;
with_active(|active| {
let previous = active.manager.snapshot().pins;
let (mutation, snapshot) = if pinned {
active.manager.pin(target)?
} else {
active.manager.unpin(&target)?
};
if mutation == crate::PinMutation::Changed
&& let Err(error) = active.host.apply_pins(&snapshot.pins.items)
{
let _ = active.manager.commit_pins(&snapshot.pins, previous.clone());
let _ = active.host.apply_pins(&previous.items);
return Err(error);
}
Ok(mutation)
})
}
fn pin_mutation_lock() -> &'static Mutex<()> {
static MUTATION: OnceLock<Mutex<()>> = OnceLock::new();
MUTATION.get_or_init(|| Mutex::new(()))
}
pub fn activate_sidebar_action(mut intent: SidebarActionIntent) -> ShellResult<()> {
let id = intent.id.trim().to_string();
if id.is_empty() {
return Err(ShellError::EmptySidebarActionId);
}
intent.id = id.clone();
with_active(|active| {
let snapshot = active.manager.snapshot();
let current_generation = snapshot.sidebar_actions.generation();
if intent.generation != current_generation {
return Err(ShellError::StaleSidebarActionIntent {
generation: intent.generation,
current: current_generation,
});
}
let Some(item) = snapshot
.sidebar_actions
.items()
.iter()
.find(|item| item.id == id)
else {
return Err(ShellError::SidebarActionNotFound { id: id.to_string() });
};
if item.disabled {
return Err(ShellError::SidebarActionDisabled { id: id.to_string() });
}
active.host.activate(intent)
})
}
fn with_active<T>(run: impl FnOnce(&ActiveShell) -> ShellResult<T>) -> ShellResult<T> {
let active = {
let slot = active_slot()
.lock()
.map_err(|_| ShellError::Host("active shell state is poisoned".to_string()))?;
slot.clone().ok_or(ShellError::NotInitialized)?
};
run(&active)
}
#[cfg(test)]
pub(crate) fn reset_for_test() {
*active_slot().lock().unwrap() = None;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
static TEST_LOCK: Mutex<()> = Mutex::new(());
TEST_LOCK.lock().unwrap_or_else(|error| error.into_inner())
}
#[derive(Default)]
struct TestHost {
activated: Mutex<Vec<SidebarActionIntent>>,
applied: Mutex<Vec<Vec<ResolvedShellSidebarAction>>>,
applied_pins: Mutex<Vec<Vec<ShellPin>>>,
reject_pins: AtomicBool,
}
impl ShellHost for TestHost {
fn resolve_sidebar_actions(
&self,
generation: u64,
items: &[ShellSidebarAction],
) -> ShellResult<Vec<ResolvedShellSidebarAction>> {
Ok(items
.iter()
.map(|item| ResolvedShellSidebarAction {
generation,
id: item.id.clone(),
placement: item.placement,
label: item.label.clone(),
icon_path: Some(item.icon.clone()),
disabled: item.disabled,
})
.collect())
}
fn apply_sidebar_actions(&self, items: &[ResolvedShellSidebarAction]) -> ShellResult<()> {
self.applied.lock().unwrap().push(items.to_vec());
Ok(())
}
fn apply_pins(&self, items: &[ShellPin]) -> ShellResult<()> {
self.applied_pins.lock().unwrap().push(items.to_vec());
if self.reject_pins.load(Ordering::Relaxed) {
return Err(ShellError::Host("rejected Pins".to_string()));
}
Ok(())
}
fn activate(&self, intent: SidebarActionIntent) -> ShellResult<()> {
self.activated.lock().unwrap().push(intent);
Ok(())
}
}
#[test]
fn stable_id_activation_routes_the_current_generation() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
manager()
.unwrap()
.replace_sidebar_actions(vec![ShellSidebarAction {
id: "sync".to_string(),
placement: crate::SidebarActionPlacement::Footer,
label: "Sync".to_string(),
icon: "icons/sync.svg".to_string(),
disabled: false,
}])
.unwrap();
activate_sidebar_action(SidebarActionIntent {
id: "sync".to_string(),
generation: 1,
})
.unwrap();
assert_eq!(
host.activated.lock().unwrap().as_slice(),
&[SidebarActionIntent {
id: "sync".to_string(),
generation: 1,
}]
);
assert!(host.applied.lock().unwrap().is_empty());
}
#[test]
fn settings_shaped_runtime_action_stays_on_the_generic_host_channel() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
manager()
.unwrap()
.replace_sidebar_actions(vec![ShellSidebarAction {
id: "settings".to_string(),
placement: crate::SidebarActionPlacement::Footer,
label: "Settings".to_string(),
icon: "settings".to_string(),
disabled: false,
}])
.unwrap();
activate_sidebar_action(SidebarActionIntent {
id: "settings".to_string(),
generation: 1,
})
.unwrap();
assert_eq!(
host.activated.lock().unwrap().as_slice(),
&[SidebarActionIntent {
id: "settings".to_string(),
generation: 1,
}]
);
}
#[test]
fn disabled_items_never_reach_the_host() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
manager()
.unwrap()
.replace_sidebar_actions(vec![ShellSidebarAction {
id: "chat".to_string(),
placement: crate::SidebarActionPlacement::Footer,
label: "Chat".to_string(),
icon: "icons/chat.svg".to_string(),
disabled: true,
}])
.unwrap();
assert_eq!(
activate_sidebar_action(SidebarActionIntent {
id: "chat".to_string(),
generation: 1,
}),
Err(ShellError::SidebarActionDisabled {
id: "chat".to_string()
})
);
assert!(host.activated.lock().unwrap().is_empty());
}
#[test]
fn stale_generation_never_retargets_a_replaced_action() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
let manager = manager().unwrap();
manager
.replace_sidebar_actions(vec![ShellSidebarAction {
id: "settings".to_string(),
placement: crate::SidebarActionPlacement::Header,
label: "Settings".to_string(),
icon: "icons/settings.svg".to_string(),
disabled: false,
}])
.unwrap();
manager.clear_sidebar_actions().unwrap();
assert_eq!(
activate_sidebar_action(SidebarActionIntent {
id: "settings".to_string(),
generation: 1,
}),
Err(ShellError::StaleSidebarActionIntent {
generation: 1,
current: 2,
})
);
assert!(host.activated.lock().unwrap().is_empty());
}
#[test]
fn pin_mutations_apply_one_mixed_order_and_reject_ninth() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
for index in 0..crate::MAX_SHELL_PINS {
let target = if index % 2 == 0 {
ShellPinTarget::Lxapp {
key: format!("app.{index}"),
}
} else {
ShellPinTarget::Bookmark {
key: format!("bookmark-{index}"),
}
};
set_pinned(target, true).unwrap();
}
assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
assert_eq!(
set_pinned(
ShellPinTarget::Lxapp {
key: "app.overflow".to_string(),
},
true,
),
Err(ShellError::LimitReached {
max: crate::MAX_SHELL_PINS,
})
);
assert_eq!(host.applied_pins.lock().unwrap().len(), 8);
}
#[test]
fn failed_pin_apply_rolls_back_memory_and_disk() {
let _guard = test_guard();
reset_for_test();
let dir = tempfile::tempdir().unwrap();
let host = Arc::new(TestHost::default());
initialize(dir.path(), host.clone()).unwrap();
host.reject_pins.store(true, Ordering::Relaxed);
assert_eq!(
set_pinned(
ShellPinTarget::Lxapp {
key: "app.chat".to_string(),
},
true,
),
Err(ShellError::Host("rejected Pins".to_string()))
);
assert!(manager().unwrap().snapshot().pins.items.is_empty());
assert!(
ShellManager::open(dir.path())
.unwrap()
.snapshot()
.pins
.items
.is_empty()
);
}
}