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    /// Accept only a permutation of the current mixed list.
110    pub fn reorder(&mut self, items: Vec<ShellPin>) -> ShellResult<PinMutation> {
111        let next = Self {
112            version: self.version,
113            items,
114        }
115        .restore()?;
116        if next.items.len() != self.items.len()
117            || next.items.iter().any(|pin| !self.items.contains(pin))
118        {
119            return Err(ShellError::InvalidState(
120                "reorder requires every current Pin exactly once".to_string(),
121            ));
122        }
123        if next == *self {
124            return Ok(PinMutation::Unchanged);
125        }
126        *self = next;
127        Ok(PinMutation::Changed)
128    }
129
130    pub fn is_pinned(&self, target: &ShellPinTarget) -> bool {
131        self.items.iter().any(|pin| &pin.0 == target)
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn lxapp(index: usize) -> ShellPinTarget {
140        ShellPinTarget::Lxapp {
141            key: format!("app.{index}"),
142        }
143    }
144
145    #[test]
146    fn mixed_pin_order_is_preserved() {
147        let mut pins = PinCollection::default();
148        pins.pin(lxapp(1)).unwrap();
149        pins.pin(ShellPinTarget::Bookmark {
150            key: "bookmark-a".to_string(),
151        })
152        .unwrap();
153        pins.pin(lxapp(2)).unwrap();
154
155        assert!(matches!(pins.items[0].0, ShellPinTarget::Lxapp { .. }));
156        assert!(matches!(pins.items[1].0, ShellPinTarget::Bookmark { .. }));
157        assert!(matches!(pins.items[2].0, ShellPinTarget::Lxapp { .. }));
158    }
159
160    #[test]
161    fn reorder_rejects_non_permutations_without_mutating() {
162        let mut pins = PinCollection::default();
163        pins.pin(lxapp(1)).unwrap();
164        pins.pin(ShellPinTarget::Bookmark { key: "site".into() })
165            .unwrap();
166        let before = pins.clone();
167        for invalid in [
168            vec![],
169            vec![before.items[0].clone(); 2],
170            vec![before.items[0].clone(), ShellPin(lxapp(9))],
171        ] {
172            assert!(pins.reorder(invalid).is_err());
173            assert_eq!(pins, before);
174        }
175        let reversed = before.items.iter().rev().cloned().collect();
176        assert_eq!(pins.reorder(reversed), Ok(PinMutation::Changed));
177        assert_eq!(pins.items[0], before.items[1]);
178        assert_eq!(pins.reorder(pins.items.clone()), Ok(PinMutation::Unchanged));
179    }
180
181    #[test]
182    fn ninth_pin_returns_typed_limit_error() {
183        let mut pins = PinCollection::default();
184        for index in 0..MAX_SHELL_PINS {
185            pins.pin(lxapp(index)).unwrap();
186        }
187
188        assert_eq!(
189            pins.pin(lxapp(MAX_SHELL_PINS)),
190            Err(ShellError::LimitReached {
191                max: MAX_SHELL_PINS
192            })
193        );
194        assert_eq!(pins.items.len(), MAX_SHELL_PINS);
195    }
196
197    #[test]
198    fn restore_rejects_overflow_instead_of_migrating_it() {
199        let stored = PinCollection {
200            version: PIN_STATE_VERSION,
201            items: (0..=MAX_SHELL_PINS)
202                .map(|index| ShellPin(lxapp(index)))
203                .collect(),
204        };
205
206        assert_eq!(
207            stored.restore(),
208            Err(ShellError::LimitReached {
209                max: MAX_SHELL_PINS,
210            })
211        );
212    }
213}