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