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
295/// Information about a single line for the LinesChanged hook
296#[derive(Debug, Clone, serde::Serialize)]
297pub struct LineInfo {
298    /// Line number (0-based)
299    pub line_number: usize,
300    /// Byte offset where the line starts in the buffer
301    pub byte_start: usize,
302    /// Byte offset where the line ends (exclusive)
303    pub byte_end: usize,
304    /// The content of the line
305    pub content: String,
306}
307
308/// Location information for LSP references
309#[derive(Debug, Clone, serde::Serialize)]
310pub struct LspLocation {
311    /// File path
312    pub file: String,
313    /// Line number (1-based)
314    pub line: u32,
315    /// Column number (1-based)
316    pub column: u32,
317}
318
319/// Type for hook callbacks
320pub type HookCallback = Box<dyn Fn(&HookArgs) -> bool + Send + Sync>;
321
322/// Registry for managing hooks
323pub struct HookRegistry {
324    /// Map from hook name to list of callbacks
325    hooks: HashMap<String, Vec<HookCallback>>,
326}
327
328impl HookRegistry {
329    /// Create a new hook registry
330    pub fn new() -> Self {
331        Self {
332            hooks: HashMap::new(),
333        }
334    }
335
336    /// Add a hook callback for a specific hook name
337    pub fn add_hook(&mut self, name: &str, callback: HookCallback) {
338        self.hooks
339            .entry(name.to_string())
340            .or_default()
341            .push(callback);
342    }
343
344    /// Remove all hooks for a specific name
345    pub fn remove_hooks(&mut self, name: &str) {
346        self.hooks.remove(name);
347    }
348
349    /// Run all hooks for a specific name
350    pub fn run_hooks(&self, name: &str, args: &HookArgs) -> bool {
351        if let Some(hooks) = self.hooks.get(name) {
352            for callback in hooks {
353                if !callback(args) {
354                    return false;
355                }
356            }
357        }
358        true
359    }
360
361    /// Get count of registered callbacks for a hook
362    pub fn hook_count(&self, name: &str) -> usize {
363        self.hooks.get(name).map(|v| v.len()).unwrap_or(0)
364    }
365
366    /// Get all registered hook names
367    pub fn hook_names(&self) -> Vec<String> {
368        self.hooks.keys().cloned().collect()
369    }
370}
371
372impl Default for HookRegistry {
373    fn default() -> Self {
374        Self::new()
375    }
376}
377
378/// Convert HookArgs to a serde_json::Value for plugin communication
379pub fn hook_args_to_json(args: &HookArgs) -> Result<serde_json::Value> {
380    let json_value = match args {
381        HookArgs::RenderStart { buffer_id } => {
382            serde_json::json!({
383                "buffer_id": buffer_id.0,
384            })
385        }
386        HookArgs::RenderLine {
387            buffer_id,
388            line_number,
389            byte_start,
390            byte_end,
391            content,
392        } => {
393            serde_json::json!({
394                "buffer_id": buffer_id.0,
395                "line_number": line_number,
396                "byte_start": byte_start,
397                "byte_end": byte_end,
398                "content": content,
399            })
400        }
401        HookArgs::BufferActivated { buffer_id } => {
402            serde_json::json!({ "buffer_id": buffer_id.0 })
403        }
404        HookArgs::BufferDeactivated { buffer_id } => {
405            serde_json::json!({ "buffer_id": buffer_id.0 })
406        }
407        HookArgs::DiagnosticsUpdated { uri, count } => {
408            serde_json::json!({
409                "uri": uri,
410                "count": count,
411            })
412        }
413        HookArgs::BufferClosed { buffer_id } => {
414            serde_json::json!({ "buffer_id": buffer_id.0 })
415        }
416        HookArgs::CursorMoved {
417            buffer_id,
418            cursor_id,
419            old_position,
420            new_position,
421            line,
422            text_properties,
423        } => {
424            serde_json::json!({
425                "buffer_id": buffer_id.0,
426                "cursor_id": cursor_id.0,
427                "old_position": old_position,
428                "new_position": new_position,
429                "line": line,
430                "text_properties": text_properties,
431            })
432        }
433        HookArgs::BeforeInsert {
434            buffer_id,
435            position,
436            text,
437        } => {
438            serde_json::json!({
439                "buffer_id": buffer_id.0,
440                "position": position,
441                "text": text,
442            })
443        }
444        HookArgs::AfterInsert {
445            buffer_id,
446            position,
447            text,
448            affected_start,
449            affected_end,
450            start_line,
451            end_line,
452            lines_added,
453        } => {
454            serde_json::json!({
455                "buffer_id": buffer_id.0,
456                "position": position,
457                "text": text,
458                "affected_start": affected_start,
459                "affected_end": affected_end,
460                "start_line": start_line,
461                "end_line": end_line,
462                "lines_added": lines_added,
463            })
464        }
465        HookArgs::BeforeDelete { buffer_id, range } => {
466            serde_json::json!({
467                "buffer_id": buffer_id.0,
468                "start": range.start,
469                "end": range.end,
470            })
471        }
472        HookArgs::AfterDelete {
473            buffer_id,
474            range,
475            deleted_text,
476            affected_start,
477            deleted_len,
478            start_line,
479            end_line,
480            lines_removed,
481        } => {
482            serde_json::json!({
483                "buffer_id": buffer_id.0,
484                "start": range.start,
485                "end": range.end,
486                "deleted_text": deleted_text,
487                "affected_start": affected_start,
488                "deleted_len": deleted_len,
489                "start_line": start_line,
490                "end_line": end_line,
491                "lines_removed": lines_removed,
492            })
493        }
494        HookArgs::BeforeFileOpen { path } => {
495            serde_json::json!({ "path": path.to_string_lossy() })
496        }
497        HookArgs::AfterFileOpen { path, buffer_id } => {
498            serde_json::json!({
499                "path": path.to_string_lossy(),
500                "buffer_id": buffer_id.0,
501            })
502        }
503        HookArgs::BeforeFileSave { path, buffer_id } => {
504            serde_json::json!({
505                "path": path.to_string_lossy(),
506                "buffer_id": buffer_id.0,
507            })
508        }
509        HookArgs::AfterFileSave { path, buffer_id } => {
510            serde_json::json!({
511                "path": path.to_string_lossy(),
512                "buffer_id": buffer_id.0,
513            })
514        }
515        HookArgs::PreCommand { action } => {
516            serde_json::json!({ "action": format!("{:?}", action) })
517        }
518        HookArgs::PostCommand { action } => {
519            serde_json::json!({ "action": format!("{:?}", action) })
520        }
521        HookArgs::Idle { milliseconds } => {
522            serde_json::json!({ "milliseconds": milliseconds })
523        }
524        HookArgs::EditorInitialized => {
525            serde_json::json!({})
526        }
527        HookArgs::PromptChanged { prompt_type, input } => {
528            serde_json::json!({
529                "prompt_type": prompt_type,
530                "input": input,
531            })
532        }
533        HookArgs::PromptConfirmed {
534            prompt_type,
535            input,
536            selected_index,
537        } => {
538            serde_json::json!({
539                "prompt_type": prompt_type,
540                "input": input,
541                "selected_index": selected_index,
542            })
543        }
544        HookArgs::PromptCancelled { prompt_type, input } => {
545            serde_json::json!({
546                "prompt_type": prompt_type,
547                "input": input,
548            })
549        }
550        HookArgs::PromptSelectionChanged {
551            prompt_type,
552            selected_index,
553        } => {
554            serde_json::json!({
555                "prompt_type": prompt_type,
556                "selected_index": selected_index,
557            })
558        }
559        HookArgs::KeyboardShortcuts { bindings } => {
560            let entries: Vec<serde_json::Value> = bindings
561                .iter()
562                .map(|(key, action)| serde_json::json!({ "key": key, "action": action }))
563                .collect();
564            serde_json::json!({ "bindings": entries })
565        }
566        HookArgs::LspReferences { symbol, locations } => {
567            let locs: Vec<serde_json::Value> = locations
568                .iter()
569                .map(|loc| {
570                    serde_json::json!({
571                        "file": loc.file,
572                        "line": loc.line,
573                        "column": loc.column,
574                    })
575                })
576                .collect();
577            serde_json::json!({ "symbol": symbol, "locations": locs })
578        }
579        HookArgs::LinesChanged { buffer_id, lines } => {
580            let lines_json: Vec<serde_json::Value> = lines
581                .iter()
582                .map(|line| {
583                    serde_json::json!({
584                        "line_number": line.line_number,
585                        "byte_start": line.byte_start,
586                        "byte_end": line.byte_end,
587                        "content": line.content,
588                    })
589                })
590                .collect();
591            serde_json::json!({
592                "buffer_id": buffer_id.0,
593                "lines": lines_json,
594            })
595        }
596        HookArgs::ViewTransformRequest {
597            buffer_id,
598            split_id,
599            viewport_start,
600            viewport_end,
601            tokens,
602            cursor_positions,
603        } => {
604            let tokens_json: Vec<serde_json::Value> = tokens
605                .iter()
606                .map(|token| {
607                    let kind_json = match &token.kind {
608                        ViewTokenWireKind::Text(s) => serde_json::json!({ "Text": s }),
609                        ViewTokenWireKind::Newline => serde_json::json!("Newline"),
610                        ViewTokenWireKind::Space => serde_json::json!("Space"),
611                        ViewTokenWireKind::Break => serde_json::json!("Break"),
612                        ViewTokenWireKind::BinaryByte(b) => serde_json::json!({ "BinaryByte": b }),
613                    };
614                    serde_json::json!({
615                        "source_offset": token.source_offset,
616                        "kind": kind_json,
617                    })
618                })
619                .collect();
620            serde_json::json!({
621                "buffer_id": buffer_id.0,
622                "split_id": split_id.0,
623                "viewport_start": viewport_start,
624                "viewport_end": viewport_end,
625                "tokens": tokens_json,
626                "cursor_positions": cursor_positions,
627            })
628        }
629        HookArgs::MouseClick {
630            column,
631            row,
632            button,
633            modifiers,
634            content_x,
635            content_y,
636        } => {
637            serde_json::json!({
638                "column": column,
639                "row": row,
640                "button": button,
641                "modifiers": modifiers,
642                "content_x": content_x,
643                "content_y": content_y,
644            })
645        }
646        HookArgs::MouseMove {
647            column,
648            row,
649            content_x,
650            content_y,
651        } => {
652            serde_json::json!({
653                "column": column,
654                "row": row,
655                "content_x": content_x,
656                "content_y": content_y,
657            })
658        }
659        HookArgs::LspServerRequest {
660            language,
661            method,
662            server_command,
663            params,
664        } => {
665            serde_json::json!({
666                "language": language,
667                "method": method,
668                "server_command": server_command,
669                "params": params,
670            })
671        }
672        HookArgs::ViewportChanged {
673            split_id,
674            buffer_id,
675            top_byte,
676            top_line,
677            width,
678            height,
679        } => {
680            serde_json::json!({
681                "split_id": split_id.0,
682                "buffer_id": buffer_id.0,
683                "top_byte": top_byte,
684                "top_line": top_line,
685                "width": width,
686                "height": height,
687            })
688        }
689        HookArgs::LspServerError {
690            language,
691            server_command,
692            error_type,
693            message,
694        } => {
695            serde_json::json!({
696                "language": language,
697                "server_command": server_command,
698                "error_type": error_type,
699                "message": message,
700            })
701        }
702        HookArgs::LspStatusClicked {
703            language,
704            has_error,
705        } => {
706            serde_json::json!({
707                "language": language,
708                "has_error": has_error,
709            })
710        }
711        HookArgs::ActionPopupResult {
712            popup_id,
713            action_id,
714        } => {
715            serde_json::json!({
716                "popup_id": popup_id,
717                "action_id": action_id,
718            })
719        }
720        HookArgs::ProcessOutput { process_id, data } => {
721            serde_json::json!({
722                "process_id": process_id,
723                "data": data,
724            })
725        }
726        HookArgs::LanguageChanged {
727            buffer_id,
728            language,
729        } => {
730            serde_json::json!({
731                "buffer_id": buffer_id.0,
732                "language": language,
733            })
734        }
735        HookArgs::ThemeInspectKey { theme_name, key } => {
736            serde_json::json!({
737                "theme_name": theme_name,
738                "key": key,
739            })
740        }
741        HookArgs::MouseScroll {
742            buffer_id,
743            delta,
744            col,
745            row,
746        } => {
747            serde_json::json!({
748                "buffer_id": buffer_id.0,
749                "delta": delta,
750                "col": col,
751                "row": row,
752            })
753        }
754        HookArgs::Resize { width, height } => {
755            serde_json::json!({
756                "width": width,
757                "height": height,
758            })
759        }
760    };
761
762    Ok(json_value)
763}