Skip to main content

lingxia_shell/
pin.rs

1use crate::{ShellError, ShellResult};
2use serde::{Deserialize, Serialize};
3
4pub const MAX_SHELL_PINS: usize = 8;
5pub(crate) const PIN_STATE_VERSION: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(tag = "kind", rename_all = "lowercase")]
9pub enum ShellPinTarget {
10    /// An lxapp workspace shortcut. Host activation opens or focuses it in the
11    /// host's exact main content rectangle, independently of declared aside
12    /// surfaces for that app.
13    Lxapp { key: String },
14    /// A saved website shortcut opened through the host's main browser tabs.
15    Bookmark { key: String },
16}
17
18impl ShellPinTarget {
19    fn validate(mut self) -> ShellResult<Self> {
20        let key = match &mut self {
21            Self::Lxapp { key } | Self::Bookmark { key } => key,
22        };
23        *key = key.trim().to_string();
24        if key.is_empty() {
25            return Err(ShellError::InvalidState(
26                "shell Pin target must not be empty".to_string(),
27            ));
28        }
29        Ok(self)
30    }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(transparent)]
35pub struct ShellPin(pub ShellPinTarget);
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum PinMutation {
39    Changed,
40    Unchanged,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct PinCollection {
46    pub version: u32,
47    pub items: Vec<ShellPin>,
48}
49
50impl Default for PinCollection {
51    fn default() -> Self {
52        Self {
53            version: PIN_STATE_VERSION,
54            items: Vec::new(),
55        }
56    }
57}
58
59impl PinCollection {
60    pub fn restore(mut self) -> ShellResult<Self> {
61        if self.version != PIN_STATE_VERSION {
62            return Err(ShellError::UnsupportedVersion {
63                version: self.version,
64            });
65        }
66        let mut normalized = Vec::with_capacity(self.items.len());
67        for pin in self.items {
68            let pin = ShellPin(pin.0.validate()?);
69            if normalized.contains(&pin) {
70                return Err(ShellError::InvalidState(
71                    "shell Pin store contains duplicate targets".to_string(),
72                ));
73            }
74            normalized.push(pin);
75            if normalized.len() > MAX_SHELL_PINS {
76                return Err(ShellError::LimitReached {
77                    max: MAX_SHELL_PINS,
78                });
79            }
80        }
81        self.items = normalized;
82        Ok(self)
83    }
84
85    pub fn pin(&mut self, target: ShellPinTarget) -> ShellResult<PinMutation> {
86        let pin = ShellPin(target.validate()?);
87        if self.items.contains(&pin) {
88            return Ok(PinMutation::Unchanged);
89        }
90        if self.items.len() >= MAX_SHELL_PINS {
91            return Err(ShellError::LimitReached {
92                max: MAX_SHELL_PINS,
93            });
94        }
95        self.items.push(pin);
96        Ok(PinMutation::Changed)
97    }
98
99    pub fn unpin(&mut self, target: &ShellPinTarget) -> PinMutation {
100        let before = self.items.len();
101        self.items.retain(|pin| &pin.0 != target);
102        if self.items.len() == before {
103            PinMutation::Unchanged
104        } else {
105            PinMutation::Changed
106        }
107    }
108
109    pub fn is_pinned(&self, target: &ShellPinTarget) -> bool {
110        self.items.iter().any(|pin| &pin.0 == target)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn lxapp(index: usize) -> ShellPinTarget {
119        ShellPinTarget::Lxapp {
120            key: format!("app.{index}"),
121        }
122    }
123
124    #[test]
125    fn mixed_pin_order_is_preserved() {
126        let mut pins = PinCollection::default();
127        pins.pin(lxapp(1)).unwrap();
128        pins.pin(ShellPinTarget::Bookmark {
129            key: "bookmark-a".to_string(),
130        })
131        .unwrap();
132        pins.pin(lxapp(2)).unwrap();
133
134        assert!(matches!(pins.items[0].0, ShellPinTarget::Lxapp { .. }));
135        assert!(matches!(pins.items[1].0, ShellPinTarget::Bookmark { .. }));
136        assert!(matches!(pins.items[2].0, ShellPinTarget::Lxapp { .. }));
137    }
138
139    #[test]
140    fn ninth_pin_returns_typed_limit_error() {
141        let mut pins = PinCollection::default();
142        for index in 0..MAX_SHELL_PINS {
143            pins.pin(lxapp(index)).unwrap();
144        }
145
146        assert_eq!(
147            pins.pin(lxapp(MAX_SHELL_PINS)),
148            Err(ShellError::LimitReached {
149                max: MAX_SHELL_PINS
150            })
151        );
152        assert_eq!(pins.items.len(), MAX_SHELL_PINS);
153    }
154
155    #[test]
156    fn restore_rejects_overflow_instead_of_migrating_it() {
157        let stored = PinCollection {
158            version: PIN_STATE_VERSION,
159            items: (0..=MAX_SHELL_PINS)
160                .map(|index| ShellPin(lxapp(index)))
161                .collect(),
162        };
163
164        assert_eq!(
165            stored.restore(),
166            Err(ShellError::LimitReached {
167                max: MAX_SHELL_PINS,
168            })
169        );
170    }
171}