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::ops::Range;
8use std::path::PathBuf;
9
10use crate::action::Action;
11use crate::api::{ViewTokenWire, ViewTokenWireKind};
12use crate::{BufferId, CursorId, SplitId};
13
14/// Arguments passed to hook callbacks
15#[derive(Debug, Clone, serde::Serialize)]
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        range: Range<usize>,
60    },
61
62    /// After text was deleted
63    AfterDelete {
64        buffer_id: BufferId,
65        range: Range<usize>,
66        deleted_text: String,
67        /// Byte position where the deletion occurred
68        affected_start: usize,
69        /// Length of the deleted content in bytes
70        deleted_len: usize,
71        /// Line number where deletion started (0-indexed)
72        start_line: usize,
73        /// Line number where deletion ended (0-indexed, in original buffer)
74        end_line: usize,
75        /// Number of lines removed by this deletion
76        lines_removed: usize,
77    },
78
79    /// Cursor moved to a new position
80    CursorMoved {
81        buffer_id: BufferId,
82        cursor_id: CursorId,
83        old_position: usize,
84        new_position: usize,
85        /// Line number at new position (1-indexed)
86        line: usize,
87        /// Text properties at the new cursor position
88        text_properties: Vec<std::collections::HashMap<String, serde_json::Value>>,
89    },
90
91    /// Buffer became active
92    BufferActivated { buffer_id: BufferId },
93
94    /// Buffer was deactivated
95    BufferDeactivated { buffer_id: BufferId },
96
97    /// LSP diagnostics were updated for a file
98    DiagnosticsUpdated {
99        /// The URI of the file that was updated
100        uri: String,
101        /// Number of diagnostics in the update
102        count: usize,
103    },
104
105    /// Before a command/action is executed
106    PreCommand { action: Action },
107
108    /// After a command/action was executed
109    PostCommand { action: Action },
110
111    /// Editor has been idle for N milliseconds (no input)
112    Idle { milliseconds: u64 },
113
114    /// Editor is initializing
115    EditorInitialized,
116
117    /// Rendering is starting for a buffer (called once per buffer before render_line hooks)
118    RenderStart { buffer_id: BufferId },
119
120    /// A line is being rendered (called during the rendering pass)
121    RenderLine {
122        buffer_id: BufferId,
123        line_number: usize,
124        byte_start: usize,
125        byte_end: usize,
126        content: String,
127    },
128
129    /// Lines have changed and need processing (batched for efficiency)
130    LinesChanged {
131        buffer_id: BufferId,
132        lines: Vec<LineInfo>,
133    },
134
135    /// Prompt input changed (user typed/edited)
136    PromptChanged { prompt_type: String, input: String },
137
138    /// Prompt was confirmed (user pressed Enter)
139    PromptConfirmed {
140        prompt_type: String,
141        input: String,
142        selected_index: Option<usize>,
143    },
144
145    /// Prompt was cancelled (user pressed Escape/Ctrl+G)
146    PromptCancelled { prompt_type: String, input: String },
147
148    /// Prompt suggestion selection changed (user navigated with Up/Down)
149    PromptSelectionChanged {
150        prompt_type: String,
151        selected_index: usize,
152    },
153
154    /// Request keyboard shortcuts data (key, action) for the help buffer
155    KeyboardShortcuts { bindings: Vec<(String, String)> },
156
157    /// LSP find references response received
158    LspReferences {
159        /// The symbol name being queried
160        symbol: String,
161        /// The locations where the symbol is referenced
162        locations: Vec<LspLocation>,
163    },
164
165    /// View transform request
166    ViewTransformRequest {
167        buffer_id: BufferId,
168        split_id: SplitId,
169        /// Byte offset of the viewport start
170        viewport_start: usize,
171        /// Byte offset of the viewport end
172        viewport_end: usize,
173        /// Base tokens (Text, Newline, Space) from the source
174        tokens: Vec<ViewTokenWire>,
175        /// Byte positions of all cursors in this buffer
176        cursor_positions: Vec<usize>,
177    },
178
179    /// Mouse click event
180    MouseClick {
181        /// Column (x coordinate) in screen cells
182        column: u16,
183        /// Row (y coordinate) in screen cells
184        row: u16,
185        /// Mouse button: "left", "right", "middle"
186        button: String,
187        /// Modifier keys
188        modifiers: String,
189        /// Content area X offset
190        content_x: u16,
191        /// Content area Y offset
192        content_y: u16,
193    },
194
195    /// Mouse move/hover event
196    MouseMove {
197        /// Column (x coordinate) in screen cells
198        column: u16,
199        /// Row (y coordinate) in screen cells
200        row: u16,
201        /// Content area X offset
202        content_x: u16,
203        /// Content area Y offset
204        content_y: u16,
205    },
206
207    /// LSP server request (server -> client)
208    LspServerRequest {
209        /// The language/server that sent the request
210        language: String,
211        /// The JSON-RPC method name
212        method: String,
213        /// The server command used to spawn this LSP
214        server_command: String,
215        /// The request parameters as a JSON string
216        params: Option<String>,
217    },
218
219    /// Viewport changed (scrolled or resized)
220    ViewportChanged {
221        split_id: SplitId,
222        buffer_id: BufferId,
223        top_byte: usize,
224        top_line: Option<usize>,
225        width: u16,
226        height: u16,
227    },
228
229    /// LSP server failed to start or crashed
230    LspServerError {
231        /// The language that failed
232        language: String,
233        /// The server command that failed
234        server_command: String,
235        /// Error type: "not_found", "spawn_failed", "timeout", "crash"
236        error_type: String,
237        /// Human-readable error message
238        message: String,
239    },
240
241    /// User clicked the LSP status indicator
242    LspStatusClicked {
243        /// The language of the current buffer
244        language: String,
245        /// Whether there's an active error
246        has_error: bool,
247    },
248
249    /// User selected an action from an action popup
250    ActionPopupResult {
251        /// The popup ID
252        popup_id: String,
253        /// The action ID selected, or "dismissed"
254        action_id: String,
255    },
256
257    /// Background process output (streaming)
258    ProcessOutput {
259        /// The process ID
260        process_id: u64,
261        /// The output data
262        data: String,
263    },
264
265    /// Buffer language was changed (e.g. via "Set Language" command or Save-As)
266    LanguageChanged {
267        buffer_id: BufferId,
268        /// The new language identifier (e.g., "markdown", "rust", "text")
269        language: String,
270    },
271
272    /// Request to inspect a theme key in the theme editor
273    ThemeInspectKey {
274        /// The name of the current theme
275        theme_name: String,
276        /// The theme key to inspect (e.g. "editor.bg")
277        key: String,
278    },
279
280    /// Mouse scroll event (wheel up/down)
281    MouseScroll {
282        buffer_id: BufferId,
283        /// Scroll delta: negative = up, positive = down (typically ±3)
284        delta: i32,
285        /// Mouse column (0-based, terminal origin top-left)
286        col: u16,
287        /// Mouse row (0-based, terminal origin top-left)
288        row: u16,
289    },
290
291    /// Terminal was resized
292    Resize { width: u16, height: u16 },
293
294    /// Terminal focus was gained (e.g. user switched back to the editor)
295    FocusGained,
296}
297
298/// Information about a single line for the LinesChanged hook
299#[derive(Debug, Clone, serde::Serialize)]
300pub struct LineInfo {
301    /// Line number (0-based)
302    pub line_number: usize,
303    /// Byte offset where the line starts in the buffer
304    pub byte_start: usize,
305    /// Byte offset where the line ends (exclusive)
306    pub byte_end: usize,
307    /// The content of the line
308    pub content: String,
309}
310
311/// Location information for LSP references
312#[derive(Debug, Clone, serde::Serialize)]
313pub struct LspLocation {
314    /// File path
315    pub file: String,
316    /// Line number (1-based)
317    pub line: u32,
318    /// Column number (1-based)
319    pub column: u32,
320}
321
322/// Type for hook callbacks
323pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
324
325/// Registry for managing hooks
326pub struct HookRegistry {
327    /// Map from hook name to list of callbacks
328    hooks: HashMap<String, Vec<HookCallback>>,
329}
330
331impl HookRegistry {
332    /// Create a new hook registry
333    pub fn new() -> Self {
334        Self {
335            hooks: HashMap::new(),
336        }
337    }
338
339    /// Add a hook callback for a specific hook name
340    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
341        self.hooks
342            .entry(name.to_string())
343            .or_default()
344            .push(callback);
345    }
346
347    /// Remove all hooks for a specific name
348    pub fn remove_hooks(&mut self, name: &str) {
349        self.hooks.remove(name);
350    }
351
352    /// Run all hooks for a specific name
353    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
354        if let Some(hooks) = self.hooks.get(name) {
355            for callback in hooks {
356                if !callback(args) {
357                    return false;
358                }
359            }
360        }
361        true
362    }
363
364    /// Get count of registered callbacks for a hook
365    pub fn hook_count(&self, name: &str) -> usize {
366        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
367    }
368
369    /// Get all registered hook names
370    pub fn hook_names(&self) -> Vec<String> {
371        self.hooks.keys().cloned().collect()
372    }
373}
374
375impl Default for HookRegistry {
376    fn default() -> Self {
377        Self::new()
378    }
379}
380
381/// Convert HookArgs to a serde_json::Value for plugin communication
382pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
383    let json_value = match args {
384        HookArgs::RenderStart { buffer_id } => {
385            serde_json::json!({
386                "buffer_id": buffer_id.0,
387            })
388        }
389        HookArgs::RenderLine {
390            buffer_id,
391            line_number,
392            byte_start,
393            byte_end,
394            content,
395        } => {
396            serde_json::json!({
397                "buffer_id": buffer_id.0,
398                "line_number": line_number,
399                "byte_start": byte_start,
400                "byte_end": byte_end,
401                "content": content,
402            })
403        }
404        HookArgs::BufferActivated { buffer_id } => {
405            serde_json::json!({ "buffer_id": buffer_id.0 })
406        }
407        HookArgs::BufferDeactivated { buffer_id } => {
408            serde_json::json!({ "buffer_id": buffer_id.0 })
409        }
410        HookArgs::DiagnosticsUpdated { uri, count } => {
411            serde_json::json!({
412                "uri": uri,
413                "count": count,
414            })
415        }
416        HookArgs::BufferClosed { buffer_id } => {
417            serde_json::json!({ "buffer_id": buffer_id.0 })
418        }
419        HookArgs::CursorMoved {
420            buffer_id,
421            cursor_id,
422            old_position,
423            new_position,
424            line,
425            text_properties,
426        } => {
427            serde_json::json!({
428                "buffer_id": buffer_id.0,
429                "cursor_id": cursor_id.0,
430                "old_position": old_position,
431                "new_position": new_position,
432                "line": line,
433                "text_properties": text_properties,
434            })
435        }
436        HookArgs::BeforeInsert {
437            buffer_id,
438            position,
439            text,
440        } => {
441            serde_json::json!({
442                "buffer_id": buffer_id.0,
443                "position": position,
444                "text": text,
445            })
446        }
447        HookArgs::AfterInsert {
448            buffer_id,
449            position,
450            text,
451            affected_start,
452            affected_end,
453            start_line,
454            end_line,
455            lines_added,
456        } => {
457            serde_json::json!({
458                "buffer_id": buffer_id.0,
459                "position": position,
460                "text": text,
461                "affected_start": affected_start,
462                "affected_end": affected_end,
463                "start_line": start_line,
464                "end_line": end_line,
465                "lines_added": lines_added,
466            })
467        }
468        HookArgs::BeforeDelete { buffer_id, range } => {
469            serde_json::json!({
470                "buffer_id": buffer_id.0,
471                "start": range.start,
472                "end": range.end,
473            })
474        }
475        HookArgs::AfterDelete {
476            buffer_id,
477            range,
478            deleted_text,
479            affected_start,
480            deleted_len,
481            start_line,
482            end_line,
483            lines_removed,
484        } => {
485            serde_json::json!({
486                "buffer_id": buffer_id.0,
487                "start": range.start,
488                "end": range.end,
489                "deleted_text": deleted_text,
490                "affected_start": affected_start,
491                "deleted_len": deleted_len,
492                "start_line": start_line,
493                "end_line": end_line,
494                "lines_removed": lines_removed,
495            })
496        }
497        HookArgs::BeforeFileOpen { path } => {
498            serde_json::json!({ "path": path.to_string_lossy() })
499        }
500        HookArgs::AfterFileOpen { path, buffer_id } => {
501            serde_json::json!({
502                "path": path.to_string_lossy(),
503                "buffer_id": buffer_id.0,
504            })
505        }
506        HookArgs::BeforeFileSave { path, buffer_id } => {
507            serde_json::json!({
508                "path": path.to_string_lossy(),
509                "buffer_id": buffer_id.0,
510            })
511        }
512        HookArgs::AfterFileSave { path, buffer_id } => {
513            serde_json::json!({
514                "path": path.to_string_lossy(),
515                "buffer_id": buffer_id.0,
516            })
517        }
518        HookArgs::PreCommand { action } => {
519            serde_json::json!({ "action": format!("{:?}", action) })
520        }
521        HookArgs::PostCommand { action } => {
522            serde_json::json!({ "action": format!("{:?}", action) })
523        }
524        HookArgs::Idle { milliseconds } => {
525            serde_json::json!({ "milliseconds": milliseconds })
526        }
527        HookArgs::EditorInitialized => {
528            serde_json::json!({})
529        }
530        HookArgs::PromptChanged { prompt_type, input } => {
531            serde_json::json!({
532                "prompt_type": prompt_type,
533                "input": input,
534            })
535        }
536        HookArgs::PromptConfirmed {
537            prompt_type,
538            input,
539            selected_index,
540        } => {
541            serde_json::json!({
542                "prompt_type": prompt_type,
543                "input": input,
544                "selected_index": selected_index,
545            })
546        }
547        HookArgs::PromptCancelled { prompt_type, input } => {
548            serde_json::json!({
549                "prompt_type": prompt_type,
550                "input": input,
551            })
552        }
553        HookArgs::PromptSelectionChanged {
554            prompt_type,
555            selected_index,
556        } => {
557            serde_json::json!({
558                "prompt_type": prompt_type,
559                "selected_index": selected_index,
560            })
561        }
562        HookArgs::KeyboardShortcuts { bindings } => {
563            let entries: Vec<serde_json::Value> = bindings
564                .iter()
565                .map(|(key, action)| serde_json::json!({ "key": key, "action": action }))
566                .collect();
567            serde_json::json!({ "bindings": entries })
568        }
569        HookArgs::LspReferences { symbol, locations } => {
570            let locs: Vec<serde_json::Value> = locations
571                .iter()
572                .map(|loc| {
573                    serde_json::json!({
574                        "file": loc.file,
575                        "line": loc.line,
576                        "column": loc.column,
577                    })
578                })
579                .collect();
580            serde_json::json!({ "symbol": symbol, "locations": locs })
581        }
582        HookArgs::LinesChanged { buffer_id, lines } => {
583            let lines_json: Vec<serde_json::Value> = lines
584                .iter()
585                .map(|line| {
586                    serde_json::json!({
587                        "line_number": line.line_number,
588                        "byte_start": line.byte_start,
589                        "byte_end": line.byte_end,
590                        "content": line.content,
591                    })
592                })
593                .collect();
594            serde_json::json!({
595                "buffer_id": buffer_id.0,
596                "lines": lines_json,
597            })
598        }
599        HookArgs::ViewTransformRequest {
600            buffer_id,
601            split_id,
602            viewport_start,
603            viewport_end,
604            tokens,
605            cursor_positions,
606        } => {
607            let tokens_json: Vec<serde_json::Value> = tokens
608                .iter()
609                .map(|token| {
610                    let kind_json = match &token.kind {
611                        ViewTokenWireKind::Text(s) => serde_json::json!({ "Text": s }),
612                        ViewTokenWireKind::Newline => serde_json::json!("Newline"),
613                        ViewTokenWireKind::Space => serde_json::json!("Space"),
614                        ViewTokenWireKind::Break => serde_json::json!("Break"),
615                        ViewTokenWireKind::BinaryByte(b) => serde_json::json!({ "BinaryByte": b }),
616                    };
617                    serde_json::json!({
618                        "source_offset": token.source_offset,
619                        "kind": kind_json,
620                    })
621                })
622                .collect();
623            serde_json::json!({
624                "buffer_id": buffer_id.0,
625                "split_id": split_id.0,
626                "viewport_start": viewport_start,
627                "viewport_end": viewport_end,
628                "tokens": tokens_json,
629                "cursor_positions": cursor_positions,
630            })
631        }
632        HookArgs::MouseClick {
633            column,
634            row,
635            button,
636            modifiers,
637            content_x,
638            content_y,
639        } => {
640            serde_json::json!({
641                "column": column,
642                "row": row,
643                "button": button,
644                "modifiers": modifiers,
645                "content_x": content_x,
646                "content_y": content_y,
647            })
648        }
649        HookArgs::MouseMove {
650            column,
651            row,
652            content_x,
653            content_y,
654        } => {
655            serde_json::json!({
656                "column": column,
657                "row": row,
658                "content_x": content_x,
659                "content_y": content_y,
660            })
661        }
662        HookArgs::LspServerRequest {
663            language,
664            method,
665            server_command,
666            params,
667        } => {
668            serde_json::json!({
669                "language": language,
670                "method": method,
671                "server_command": server_command,
672                "params": params,
673            })
674        }
675        HookArgs::ViewportChanged {
676            split_id,
677            buffer_id,
678            top_byte,
679            top_line,
680            width,
681            height,
682        } => {
683            serde_json::json!({
684                "split_id": split_id.0,
685                "buffer_id": buffer_id.0,
686                "top_byte": top_byte,
687                "top_line": top_line,
688                "width": width,
689                "height": height,
690            })
691        }
692        HookArgs::LspServerError {
693            language,
694            server_command,
695            error_type,
696            message,
697        } => {
698            serde_json::json!({
699                "language": language,
700                "server_command": server_command,
701                "error_type": error_type,
702                "message": message,
703            })
704        }
705        HookArgs::LspStatusClicked {
706            language,
707            has_error,
708        } => {
709            serde_json::json!({
710                "language": language,
711                "has_error": has_error,
712            })
713        }
714        HookArgs::ActionPopupResult {
715            popup_id,
716            action_id,
717        } => {
718            serde_json::json!({
719                "popup_id": popup_id,
720                "action_id": action_id,
721            })
722        }
723        HookArgs::ProcessOutput { process_id, data } => {
724            serde_json::json!({
725                "process_id": process_id,
726                "data": data,
727            })
728        }
729        HookArgs::LanguageChanged {
730            buffer_id,
731            language,
732        } => {
733            serde_json::json!({
734                "buffer_id": buffer_id.0,
735                "language": language,
736            })
737        }
738        HookArgs::ThemeInspectKey { theme_name, key } => {
739            serde_json::json!({
740                "theme_name": theme_name,
741                "key": key,
742            })
743        }
744        HookArgs::MouseScroll {
745            buffer_id,
746            delta,
747            col,
748            row,
749        } => {
750            serde_json::json!({
751                "buffer_id": buffer_id.0,
752                "delta": delta,
753                "col": col,
754                "row": row,
755            })
756        }
757        HookArgs::Resize { width, height } => {
758            serde_json::json!({
759                "width": width,
760                "height": height,
761            })
762        }
763        HookArgs::FocusGained => {
764            serde_json::json!({})
765        }
766    };
767
768    Ok(json_value)
769}