Skip to main content

fresh_core/
hooks.rs

1//! Hook System: Event subscription and notification for plugins
2//!
3//! Hooks allow plugins to subscribe to editor events and react to them.
4
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use crate::action::Action;
10use crate::api::ViewTokenWire;
11use crate::{BufferId, CursorId, SplitId};
12
13/// Arguments passed to hook callbacks
14#[derive(Debug, Clone, serde::Serialize)]
15#[serde(untagged)]
16pub enum HookArgs {
17    /// Before a file is opened
18    BeforeFileOpen { path: PathBuf },
19
20    /// After a file is successfully opened
21    AfterFileOpen { buffer_id: BufferId, path: PathBuf },
22
23    /// Before a buffer is saved to disk
24    BeforeFileSave { buffer_id: BufferId, path: PathBuf },
25
26    /// After a buffer is successfully saved
27    AfterFileSave { buffer_id: BufferId, path: PathBuf },
28
29    /// A buffer was closed
30    BufferClosed { buffer_id: BufferId },
31
32    /// Before text is inserted
33    BeforeInsert {
34        buffer_id: BufferId,
35        position: usize,
36        text: String,
37    },
38
39    /// After text was inserted
40    AfterInsert {
41        buffer_id: BufferId,
42        position: usize,
43        text: String,
44        /// Byte position where the affected range starts
45        affected_start: usize,
46        /// Byte position where the affected range ends (after the inserted text)
47        affected_end: usize,
48        /// Line number where insertion occurred (0-indexed)
49        start_line: usize,
50        /// Line number where insertion ended (0-indexed)
51        end_line: usize,
52        /// Number of lines added by this insertion
53        lines_added: usize,
54    },
55
56    /// Before text is deleted
57    BeforeDelete {
58        buffer_id: BufferId,
59        start: usize,
60        end: usize,
61    },
62
63    /// After text was deleted
64    AfterDelete {
65        buffer_id: BufferId,
66        start: usize,
67        end: usize,
68        deleted_text: String,
69        /// Byte position where the deletion occurred
70        affected_start: usize,
71        /// Length of the deleted content in bytes
72        deleted_len: usize,
73        /// Line number where deletion started (0-indexed)
74        start_line: usize,
75        /// Line number where deletion ended (0-indexed, in original buffer)
76        end_line: usize,
77        /// Number of lines removed by this deletion
78        lines_removed: usize,
79    },
80
81    /// Cursor moved to a new position
82    CursorMoved {
83        buffer_id: BufferId,
84        cursor_id: CursorId,
85        old_position: usize,
86        new_position: usize,
87        /// Line number at new position (1-indexed)
88        line: usize,
89        /// Text properties at the new cursor position
90        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
91    },
92
93    /// Buffer became active
94    BufferActivated { buffer_id: BufferId },
95
96    /// Buffer was deactivated
97    BufferDeactivated { buffer_id: BufferId },
98
99    /// LSP diagnostics were updated for a file
100    DiagnosticsUpdated {
101        /// The URI of the file that was updated
102        uri: String,
103        /// Number of diagnostics in the update
104        count: usize,
105    },
106
107    /// Before a command/action is executed
108    PreCommand { action: Action },
109
110    /// After a command/action was executed
111    PostCommand { action: Action },
112
113    /// Editor has been idle for N milliseconds (no input)
114    Idle { milliseconds: u64 },
115
116    /// Editor is initializing
117    EditorInitialized {},
118
119    /// All plugin packages + init.ts have been loaded. Fires after the
120    /// plugin discovery loop and before session restore — the lifecycle
121    /// hook for code that wants to configure a plugin via its
122    /// getPluginApi(...) surface. See design §3.3 (phase 2).
123    PluginsLoaded {},
124
125    /// Editor has completed startup: plugins are loaded, session is
126    /// restored, and the active buffer exists. Design §3.3 (phase 3).
127    Ready {},
128
129    /// The editor's active authority changed (e.g. local → container,
130    /// container → local). Fires after the new authority is in place
131    /// and the plugin state snapshot has been refreshed, so handlers
132    /// can read the new label via `editor.getAuthorityLabel()`.
133    /// Plugins use this to re-register state-dependent commands
134    /// that should only appear in one authority mode (e.g. dev
135    /// container `Detach` only when attached). In production a
136    /// transition triggers a full editor restart that re-runs plugin
137    /// init from scratch; this hook lets plugins react inline
138    /// without that, which keeps the harness in sync too.
139    AuthorityChanged { label: String },
140
141    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
142    RenderStart { buffer_id: BufferId },
143
144    /// A line is being rendered (called during the rendering pass)
145    RenderLine {
146        buffer_id: BufferId,
147        line_number: usize,
148        byte_start: usize,
149        byte_end: usize,
150        content: String,
151    },
152
153    /// Lines have changed and need processing (batched for efficiency)
154    LinesChanged {
155        buffer_id: BufferId,
156        lines: Vec<LineInfo>,
157    },
158
159    /// Prompt input changed (user typed/edited)
160    PromptChanged { prompt_type: String, input: String },
161
162    /// Prompt was confirmed (user pressed Enter)
163    PromptConfirmed {
164        prompt_type: String,
165        input: String,
166        selected_index: Option<usize>,
167    },
168
169    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
170    PromptCancelled { prompt_type: String, input: String },
171
172    /// Prompt suggestion selection changed (user navigated with Up/Down)
173    PromptSelectionChanged {
174        prompt_type: String,
175        selected_index: usize,
176    },
177
178    /// Request keyboard shortcuts data (key, action) for the help buffer
179    KeyboardShortcuts { bindings: Vec<(String, String)> },
180
181    /// LSP find references response received
182    LspReferences {
183        /// The symbol name being queried
184        symbol: String,
185        /// The locations where the symbol is referenced
186        locations: Vec<LspLocation>,
187    },
188
189    /// View transform request
190    ViewTransformRequest {
191        buffer_id: BufferId,
192        split_id: SplitId,
193        /// Byte offset of the viewport start
194        viewport_start: usize,
195        /// Byte offset of the viewport end
196        viewport_end: usize,
197        /// Base tokens (Text, Newline, Space) from the source
198        tokens: Vec<ViewTokenWire>,
199        /// Byte positions of all cursors in this buffer
200        cursor_positions: Vec<usize>,
201    },
202
203    /// Mouse click event
204    MouseClick {
205        /// Column (x coordinate) in screen cells
206        column: u16,
207        /// Row (y coordinate) in screen cells
208        row: u16,
209        /// Mouse button: "left", "right", "middle"
210        button: String,
211        /// Modifier keys
212        modifiers: String,
213        /// Content area X offset
214        content_x: u16,
215        /// Content area Y offset
216        content_y: u16,
217        /// Buffer under the click (None when the click is outside any
218        /// buffer panel).
219        buffer_id: Option<u64>,
220        /// 0-indexed buffer row (line number) of the click, accounting
221        /// for scroll. None when the click is outside any buffer.
222        buffer_row: Option<u32>,
223        /// 0-indexed byte column inside the buffer row. None when the
224        /// click is outside any buffer.
225        buffer_col: Option<u32>,
226    },
227
228    /// Mouse move/hover event
229    MouseMove {
230        /// Column (x coordinate) in screen cells
231        column: u16,
232        /// Row (y coordinate) in screen cells
233        row: u16,
234        /// Content area X offset
235        content_x: u16,
236        /// Content area Y offset
237        content_y: u16,
238    },
239
240    /// LSP server request (server -> client)
241    LspServerRequest {
242        /// The language/server that sent the request
243        language: String,
244        /// The JSON-RPC method name
245        method: String,
246        /// The server command used to spawn this LSP
247        server_command: String,
248        /// The request parameters as a JSON string
249        params: Option<String>,
250    },
251
252    /// Viewport changed (scrolled or resized)
253    ViewportChanged {
254        split_id: SplitId,
255        buffer_id: BufferId,
256        top_byte: usize,
257        top_line: Option<usize>,
258        width: u16,
259        height: u16,
260    },
261
262    /// LSP server failed to start or crashed
263    LspServerError {
264        /// The language that failed
265        language: String,
266        /// The server command that failed
267        server_command: String,
268        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
269        error_type: String,
270        /// Human-readable error message
271        message: String,
272    },
273
274    /// User clicked the LSP status indicator
275    LspStatusClicked {
276        /// The language of the current buffer
277        language: String,
278        /// Whether there's an active error
279        has_error: bool,
280        /// Commands of configured servers whose binaries are not on `$PATH`
281        /// (or absolute-path equivalents). Empty when every configured
282        /// server is installed. Plugins can inspect this to show tailored
283        /// install hints without waiting for a failed spawn.
284        missing_servers: Vec<String>,
285        /// Whether the user previously dismissed the LSP pill for this
286        /// language (via the popup's "Disable" action). Plugins seeing
287        /// this as `true` should offer "Enable" / "Install" rather than
288        /// "Start".
289        user_dismissed: bool,
290    },
291
292    /// User selected an action from an action popup
293    ActionPopupResult {
294        /// The popup ID
295        popup_id: String,
296        /// The action ID selected, or "dismissed"
297        action_id: String,
298    },
299
300    /// Background process output (streaming)
301    ProcessOutput {
302        /// The process ID
303        process_id: u64,
304        /// The output data
305        data: String,
306    },
307
308    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
309    LanguageChanged {
310        buffer_id: BufferId,
311        /// The new language identifier (e.g., "markdown", "rust", "text")
312        language: String,
313    },
314
315    /// Request to inspect a theme key in the theme editor
316    ThemeInspectKey {
317        /// The name of the current theme
318        theme_name: String,
319        /// The theme key to inspect (e.g. "editor.bg")
320        key: String,
321    },
322
323    /// Mouse scroll event (wheel up/down)
324    MouseScroll {
325        buffer_id: BufferId,
326        /// Scroll delta: negative = up, positive = down (typically ±3)
327        delta: i32,
328        /// Mouse column (0-based, terminal origin top-left)
329        col: u16,
330        /// Mouse row (0-based, terminal origin top-left)
331        row: u16,
332    },
333
334    /// Terminal was resized
335    Resize { width: u16, height: u16 },
336
337    /// Terminal focus was gained (e.g. user switched back to the editor)
338    FocusGained {},
339}
340
341/// Information about a single line for the LinesChanged hook
342#[derive(Debug, Clone, serde::Serialize)]
343pub struct LineInfo {
344    /// Line number (0-based)
345    pub line_number: usize,
346    /// Byte offset where the line starts in the buffer
347    pub byte_start: usize,
348    /// Byte offset where the line ends (exclusive)
349    pub byte_end: usize,
350    /// The content of the line
351    pub content: String,
352}
353
354/// Location information for LSP references
355#[derive(Debug, Clone, serde::Serialize)]
356pub struct LspLocation {
357    /// File path
358    pub file: String,
359    /// Line number (1-based)
360    pub line: u32,
361    /// Column number (1-based)
362    pub column: u32,
363}
364
365/// Type for hook callbacks
366pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
367
368/// Registry for managing hooks
369pub struct HookRegistry {
370    /// Map from hook name to list of callbacks
371    hooks: HashMap<String, Vec<HookCallback>>,
372}
373
374impl HookRegistry {
375    /// Create a new hook registry
376    pub fn new() -> Self {
377        Self {
378            hooks: HashMap::new(),
379        }
380    }
381
382    /// Add a hook callback for a specific hook name
383    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
384        self.hooks
385            .entry(name.to_string())
386            .or_default()
387            .push(callback);
388    }
389
390    /// Remove all hooks for a specific name
391    pub fn remove_hooks(&mut self, name: &str) {
392        self.hooks.remove(name);
393    }
394
395    /// Run all hooks for a specific name
396    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
397        if let Some(hooks) = self.hooks.get(name) {
398            for callback in hooks {
399                if !callback(args) {
400                    return false;
401                }
402            }
403        }
404        true
405    }
406
407    /// Get count of registered callbacks for a hook
408    pub fn hook_count(&self, name: &str) -> usize {
409        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
410    }
411
412    /// Get all registered hook names
413    pub fn hook_names(&self) -> Vec<String> {
414        self.hooks.keys().cloned().collect()
415    }
416}
417
418impl Default for HookRegistry {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424/// Convert HookArgs to a serde_json::Value for plugin communication.
425///
426/// `HookArgs` is `#[serde(untagged)]`, so each variant serializes as its
427/// fields only — no discriminant wrapper. Empty struct variants (`{}`) produce
428/// an empty JSON object rather than `null`.
429pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
430    Ok(serde_json::to_value(args)?)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use std::sync::atomic::{AtomicUsize, Ordering};
437    use std::sync::Arc;
438
439    fn noop_true() -> HookCallback {
440        Box::new(|_| true)
441    }
442
443    /// Adding, listing, counting, and removing hooks behave consistently:
444    /// counts match the number added, names reflect the keys, and removal
445    /// purges all callbacks for that key.
446    #[test]
447    fn add_count_list_remove_round_trip() {
448        let mut reg = HookRegistry::new();
449        assert_eq!(reg.hook_count("a"), 0);
450        assert!(reg.hook_names().is_empty());
451
452        reg.add_hook("a", noop_true());
453        reg.add_hook("a", noop_true());
454        reg.add_hook("b", noop_true());
455
456        assert_eq!(reg.hook_count("a"), 2);
457        assert_eq!(reg.hook_count("b"), 1);
458        assert_eq!(reg.hook_count("missing"), 0);
459
460        let mut names = reg.hook_names();
461        names.sort();
462        assert_eq!(names, vec!["a".to_string(), "b".to_string()]);
463
464        reg.remove_hooks("a");
465        assert_eq!(reg.hook_count("a"), 0);
466        assert_eq!(reg.hook_count("b"), 1);
467        assert_eq!(reg.hook_names(), vec!["b".to_string()]);
468    }
469
470    /// `run_hooks` returns true iff every callback returned true, short-circuits
471    /// on the first `false`, and returns true for hook names with no callbacks.
472    #[test]
473    fn run_hooks_all_true_and_short_circuits_on_false() {
474        let mut reg = HookRegistry::new();
475        let args = HookArgs::EditorInitialized {};
476
477        // Unknown hook: treated as "no callbacks" → true.
478        assert!(reg.run_hooks("unknown", &args));
479
480        // All-true chain returns true and calls every callback.
481        let calls = Arc::new(AtomicUsize::new(0));
482        for _ in 0..3 {
483            let c = calls.clone();
484            reg.add_hook(
485                "all_true",
486                Box::new(move |_| {
487                    c.fetch_add(1, Ordering::SeqCst);
488                    true
489                }),
490            );
491        }
492        assert!(reg.run_hooks("all_true", &args));
493        assert_eq!(calls.load(Ordering::SeqCst), 3);
494
495        // Short-circuits on the first `false` — the second callback must not run.
496        let calls = Arc::new(AtomicUsize::new(0));
497        let c1 = calls.clone();
498        reg.add_hook(
499            "short",
500            Box::new(move |_| {
501                c1.fetch_add(1, Ordering::SeqCst);
502                false
503            }),
504        );
505        let c2 = calls.clone();
506        reg.add_hook(
507            "short",
508            Box::new(move |_| {
509                c2.fetch_add(1, Ordering::SeqCst);
510                true
511            }),
512        );
513        assert!(!reg.run_hooks("short", &args));
514        assert_eq!(calls.load(Ordering::SeqCst), 1);
515    }
516
517    /// `hook_args_to_json` produces an object with the expected field for
518    /// a representative variant — ensuring the function actually serializes
519    /// the payload instead of returning a default (null) value.
520    #[test]
521    fn hook_args_to_json_serializes_payload_fields() {
522        let json = hook_args_to_json(&HookArgs::DiagnosticsUpdated {
523            uri: "file:///x.rs".into(),
524            count: 7,
525        })
526        .unwrap();
527        assert_eq!(json["uri"], "file:///x.rs");
528        assert_eq!(json["count"], 7);
529    }
530
531    #[test]
532    fn hook_args_to_json_empty_variants_produce_empty_object() {
533        for args in [
534            HookArgs::EditorInitialized {},
535            HookArgs::PluginsLoaded {},
536            HookArgs::Ready {},
537            HookArgs::FocusGained {},
538        ] {
539            let json = hook_args_to_json(&args).unwrap();
540            assert_eq!(
541                json,
542                serde_json::json!({}),
543                "variant should serialize as {{}}"
544            );
545        }
546    }
547
548    #[test]
549    fn hook_args_to_json_delete_fields_are_flat() {
550        let json = hook_args_to_json(&HookArgs::BeforeDelete {
551            buffer_id: crate::BufferId(1),
552            start: 10,
553            end: 20,
554        })
555        .unwrap();
556        assert_eq!(json["start"], 10);
557        assert_eq!(json["end"], 20);
558        assert!(json.get("range").is_none(), "range must not be nested");
559    }
560}