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}