Skip to main content

fresh_core/
command.rs

1use serde::{Deserialize, Serialize};
2
3/// Source of a command (builtin or from a plugin)
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
5#[ts(export)]
6pub enum CommandSource {
7    /// Built-in editor command
8    Builtin,
9    /// Command registered by a plugin (contains plugin filename without extension)
10    Plugin(String),
11}
12
13/// A command registered by a plugin via the service bridge.
14/// This is a simplified version that the editor converts to its internal Command type.
15#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
16#[ts(export)]
17pub struct Command {
18    /// Command name (e.g., "Open File")
19    pub name: String,
20    /// Command description
21    pub description: String,
22    /// The action name to trigger (for plugin commands, this is the function name)
23    pub action_name: String,
24    /// Plugin that registered this command
25    pub plugin_name: String,
26    /// Custom contexts required for this command (plugin-defined contexts like "vi-mode")
27    pub custom_contexts: Vec<String>,
28    /// When `true`, a key bound to this command bypasses terminal
29    /// keyboard capture — the action fires instead of the keystroke
30    /// being forwarded to the PTY child. Use for commands the user
31    /// must always be able to reach (e.g. the orchestrator picker,
32    /// "switch to other session", a global panic-exit) so a focused
33    /// terminal pane doesn't trap them. Default `false` — keys
34    /// bound to non-bypassing commands still go to the PTY when
35    /// keyboard capture is on, matching the existing UX.
36    #[serde(default)]
37    pub terminal_bypass: bool,
38}
39
40/// A single suggestion item for autocomplete
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
42#[serde(deny_unknown_fields)]
43#[ts(export, rename = "PromptSuggestion")]
44pub struct Suggestion {
45    /// The text to display
46    pub text: String,
47    /// Optional description
48    #[serde(default)]
49    #[ts(optional)]
50    pub description: Option<String>,
51    /// The value to use when selected (defaults to text if None)
52    #[serde(default)]
53    #[ts(optional)]
54    pub value: Option<String>,
55    /// Whether this suggestion is disabled (greyed out, defaults to false)
56    #[serde(default)]
57    #[ts(optional)]
58    pub disabled: Option<bool>,
59    /// Optional keyboard shortcut
60    #[serde(default)]
61    #[ts(optional)]
62    pub keybinding: Option<String>,
63    /// Source of the command (for command palette) - internal, not settable by plugins
64    #[serde(skip)]
65    #[ts(skip)]
66    pub source: Option<CommandSource>,
67}
68
69#[cfg(feature = "plugins")]
70impl<'js> rquickjs::FromJs<'js> for Suggestion {
71    fn from_js(_ctx: &rquickjs::Ctx<'js>, value: rquickjs::Value<'js>) -> rquickjs::Result<Self> {
72        rquickjs_serde::from_value(value).map_err(|e| rquickjs::Error::FromJs {
73            from: "object",
74            to: "Suggestion",
75            message: Some(e.to_string()),
76        })
77    }
78}
79
80impl Suggestion {
81    pub fn new(text: String) -> Self {
82        Self {
83            text,
84            description: None,
85            value: None,
86            disabled: None,
87            keybinding: None,
88            source: None,
89        }
90    }
91
92    /// Check if this suggestion is disabled
93    pub fn is_disabled(&self) -> bool {
94        self.disabled.unwrap_or(false)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    /// `is_disabled` mirrors the `disabled` option and treats `None` as enabled.
103    #[test]
104    fn is_disabled_reflects_disabled_field() {
105        let mut s = Suggestion::new("foo".into());
106        assert!(!s.is_disabled(), "None defaults to enabled");
107
108        s.disabled = Some(false);
109        assert!(!s.is_disabled());
110
111        s.disabled = Some(true);
112        assert!(s.is_disabled());
113    }
114
115    /// `Suggestion::from_js` round-trips user-visible fields through
116    /// `rquickjs_serde`. A mutant that returns `Ok(Default::default())`
117    /// would drop `text` (becoming empty) and `description`.
118    #[cfg(feature = "plugins")]
119    #[test]
120    fn suggestion_from_js_decodes_distinguishing_fields() {
121        use rquickjs::{Context, FromJs, Runtime, Value};
122        let rt = Runtime::new().unwrap();
123        let ctx = Context::full(&rt).unwrap();
124        ctx.with(|ctx| {
125            let v: Value = ctx
126                .eval::<Value, _>(b"({text: 'hello', description: 'world'})".as_slice())
127                .unwrap();
128            let got = Suggestion::from_js(&ctx, v).unwrap();
129            assert_eq!(got.text, "hello");
130            assert_eq!(got.description.as_deref(), Some("world"));
131        });
132    }
133}