Skip to main content

gravityfile_plugin/
hooks.rs

1//! Hook definitions for plugin event system.
2//!
3//! The explorer fires these events. Every hook but [`Hook::OnDeleteStart`]
4//! is an observer: it runs in the background and cannot hold up the
5//! explorer. `on_delete_start` is awaited, and a plugin can veto the delete
6//! by returning `{ prevent_default = true }`.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::types::Value;
14
15/// Events that plugins can hook into.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum Hook {
19    // ==================== Navigation Events ====================
20    /// The explorer moved to another directory.
21    OnNavigate {
22        /// Previous directory path.
23        from: PathBuf,
24        /// New directory path.
25        to: PathBuf,
26    },
27
28    // ==================== Scan Events ====================
29    /// A full scan started.
30    OnScanStart {
31        /// Root path being scanned.
32        path: PathBuf,
33    },
34
35    /// A full scan finished.
36    OnScanComplete {
37        /// Root path that was scanned.
38        path: PathBuf,
39        /// Total files found.
40        total_files: u64,
41        /// Total directories found.
42        total_dirs: u64,
43        /// Total size in bytes.
44        total_size: u64,
45    },
46
47    /// A full scan failed.
48    OnScanError {
49        /// Root path that failed.
50        path: PathBuf,
51        /// Error message.
52        error: String,
53    },
54
55    // ==================== File Operation Events ====================
56    /// A delete is about to start. Awaited; a plugin can veto it.
57    OnDeleteStart {
58        /// Items to be deleted.
59        items: Vec<PathBuf>,
60        /// Whether using trash (recoverable).
61        use_trash: bool,
62    },
63
64    /// A delete finished.
65    OnDeleteComplete {
66        /// Number of items successfully deleted.
67        deleted: usize,
68        /// Number of items that failed to delete.
69        failed: usize,
70        /// Total bytes freed.
71        bytes_freed: u64,
72    },
73
74    /// A copy finished.
75    OnCopyComplete {
76        /// Number of items successfully copied.
77        succeeded: usize,
78        /// Number of items that failed.
79        failed: usize,
80        /// Total bytes copied.
81        bytes_copied: u64,
82    },
83
84    /// A move finished.
85    OnMoveComplete {
86        /// Number of items successfully moved.
87        succeeded: usize,
88        /// Number of items that failed.
89        failed: usize,
90    },
91
92    /// A rename finished.
93    OnRenameComplete {
94        /// Original path.
95        source: PathBuf,
96        /// New path after rename.
97        new_path: PathBuf,
98    },
99
100    // ==================== Analysis Events ====================
101    /// Duplicate analysis finished.
102    OnDuplicatesFound {
103        /// Number of duplicate groups found.
104        group_count: usize,
105        /// Total wasted space in bytes.
106        wasted_bytes: u64,
107    },
108
109    /// Age analysis finished.
110    OnAgeAnalysisComplete {
111        /// Number of stale directories found.
112        stale_dirs: usize,
113        /// Files whose ages were counted.
114        files: u64,
115        /// Their average age, in seconds.
116        average_age_secs: u64,
117    },
118
119    // ==================== Lifecycle Events ====================
120    /// The explorer started.
121    OnStartup,
122
123    /// The explorer is about to quit; awaited briefly.
124    OnShutdown,
125}
126
127impl Hook {
128    /// The function a plugin defines to receive this hook.
129    pub fn name(&self) -> &'static str {
130        match self {
131            Self::OnNavigate { .. } => "on_navigate",
132            Self::OnScanStart { .. } => "on_scan_start",
133            Self::OnScanComplete { .. } => "on_scan_complete",
134            Self::OnScanError { .. } => "on_scan_error",
135            Self::OnDeleteStart { .. } => "on_delete_start",
136            Self::OnDeleteComplete { .. } => "on_delete_complete",
137            Self::OnCopyComplete { .. } => "on_copy_complete",
138            Self::OnMoveComplete { .. } => "on_move_complete",
139            Self::OnRenameComplete { .. } => "on_rename_complete",
140            Self::OnDuplicatesFound { .. } => "on_duplicates_found",
141            Self::OnAgeAnalysisComplete { .. } => "on_age_analysis_complete",
142            Self::OnStartup => "on_startup",
143            Self::OnShutdown => "on_shutdown",
144        }
145    }
146}
147
148/// How prominent a plugin's notice is.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "lowercase")]
151pub enum NoticeLevel {
152    #[default]
153    Info,
154    Warn,
155    Error,
156}
157
158impl NoticeLevel {
159    /// The level named `name`; anything unknown is `Info`.
160    pub fn parse(name: &str) -> Self {
161        match name {
162            "warn" | "warning" => Self::Warn,
163            "error" => Self::Error,
164            _ => Self::Info,
165        }
166    }
167}
168
169/// A message a plugin asked the explorer to show, with `gf.notify`.
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct Notice {
172    pub level: NoticeLevel,
173    pub message: String,
174}
175
176impl Notice {
177    /// The most notices one hook call can raise.
178    pub const MAX_PER_CALL: usize = 8;
179    /// The longest notice, in bytes; longer ones are cut at a character.
180    pub const MAX_BYTES: usize = 500;
181
182    /// A notice, its message cut to [`Self::MAX_BYTES`].
183    pub fn new(level: NoticeLevel, message: &str) -> Self {
184        let mut end = message.len().min(Self::MAX_BYTES);
185        while !message.is_char_boundary(end) {
186            end -= 1;
187        }
188        Self {
189            level,
190            message: message[..end].to_string(),
191        }
192    }
193}
194
195/// Context provided to plugins when a hook is invoked.
196#[derive(Debug, Clone, Default)]
197pub struct HookContext {
198    /// Additional data passed to the hook.
199    pub data: HashMap<String, Value>,
200
201    /// Current working directory.
202    pub cwd: Option<PathBuf>,
203
204    /// Current view root (drill-down location).
205    pub view_root: Option<PathBuf>,
206
207    /// The colour theme's name.
208    pub theme: Option<String>,
209}
210
211impl HookContext {
212    /// Create a new empty context.
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Set a value in the context.
218    pub fn set(&mut self, key: impl Into<String>, value: impl Into<Value>) -> &mut Self {
219        self.data.insert(key.into(), value.into());
220        self
221    }
222
223    /// Get a value from the context.
224    pub fn get(&self, key: &str) -> Option<&Value> {
225        self.data.get(key)
226    }
227
228    /// Set the current working directory.
229    pub fn with_cwd(mut self, cwd: PathBuf) -> Self {
230        self.cwd = Some(cwd);
231        self
232    }
233
234    /// Set the view root.
235    pub fn with_view_root(mut self, view_root: PathBuf) -> Self {
236        self.view_root = Some(view_root);
237        self
238    }
239
240    /// Set the theme.
241    pub fn with_theme(mut self, theme: impl Into<String>) -> Self {
242        self.theme = Some(theme.into());
243        self
244    }
245}
246
247/// Result returned by a plugin hook handler.
248#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
249pub struct HookResult {
250    /// Whether the hook was handled.
251    pub handled: bool,
252
253    /// Whether to prevent default behavior.
254    pub prevent_default: bool,
255
256    /// Whether to stop propagation to other plugins.
257    pub stop_propagation: bool,
258
259    /// Return value from the hook (if any).
260    pub value: Option<Value>,
261
262    /// Error message (if hook failed).
263    pub error: Option<String>,
264
265    /// Messages the plugin asked the explorer to show.
266    #[serde(default)]
267    pub notices: Vec<Notice>,
268
269    /// The plugin that produced this result, filled in by the manager.
270    #[serde(default)]
271    pub plugin: Option<String>,
272}
273
274impl HookResult {
275    /// Create a successful result.
276    pub fn ok() -> Self {
277        Self {
278            handled: true,
279            ..Default::default()
280        }
281    }
282
283    /// Create a result with a return value.
284    pub fn with_value(value: impl Into<Value>) -> Self {
285        Self {
286            handled: true,
287            value: Some(value.into()),
288            ..Default::default()
289        }
290    }
291
292    /// Create an error result.
293    pub fn error(message: impl Into<String>) -> Self {
294        Self {
295            handled: true,
296            error: Some(message.into()),
297            ..Default::default()
298        }
299    }
300
301    /// Mark this result as preventing default behavior.
302    pub fn prevent_default(mut self) -> Self {
303        self.prevent_default = true;
304        self
305    }
306
307    /// Mark this result as stopping propagation.
308    pub fn stop_propagation(mut self) -> Self {
309        self.stop_propagation = true;
310        self
311    }
312
313    /// Check if the hook execution was successful.
314    pub fn is_ok(&self) -> bool {
315        self.error.is_none()
316    }
317
318    /// Check if the hook had an error.
319    pub fn is_err(&self) -> bool {
320        self.error.is_some()
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    /// One of every hook. The match makes a new variant fail to compile here
329    /// until it is listed, and the test then checks plugin discovery knows it.
330    fn every_hook() -> Vec<Hook> {
331        let all = vec![
332            Hook::OnNavigate {
333                from: PathBuf::new(),
334                to: PathBuf::new(),
335            },
336            Hook::OnScanStart {
337                path: PathBuf::new(),
338            },
339            Hook::OnScanComplete {
340                path: PathBuf::new(),
341                total_files: 0,
342                total_dirs: 0,
343                total_size: 0,
344            },
345            Hook::OnScanError {
346                path: PathBuf::new(),
347                error: String::new(),
348            },
349            Hook::OnDeleteStart {
350                items: Vec::new(),
351                use_trash: true,
352            },
353            Hook::OnDeleteComplete {
354                deleted: 0,
355                failed: 0,
356                bytes_freed: 0,
357            },
358            Hook::OnCopyComplete {
359                succeeded: 0,
360                failed: 0,
361                bytes_copied: 0,
362            },
363            Hook::OnMoveComplete {
364                succeeded: 0,
365                failed: 0,
366            },
367            Hook::OnRenameComplete {
368                source: PathBuf::new(),
369                new_path: PathBuf::new(),
370            },
371            Hook::OnDuplicatesFound {
372                group_count: 0,
373                wasted_bytes: 0,
374            },
375            Hook::OnAgeAnalysisComplete {
376                stale_dirs: 0,
377                files: 0,
378                average_age_secs: 0,
379            },
380            Hook::OnStartup,
381            Hook::OnShutdown,
382        ];
383        for hook in &all {
384            match hook {
385                Hook::OnNavigate { .. }
386                | Hook::OnScanStart { .. }
387                | Hook::OnScanComplete { .. }
388                | Hook::OnScanError { .. }
389                | Hook::OnDeleteStart { .. }
390                | Hook::OnDeleteComplete { .. }
391                | Hook::OnCopyComplete { .. }
392                | Hook::OnMoveComplete { .. }
393                | Hook::OnRenameComplete { .. }
394                | Hook::OnDuplicatesFound { .. }
395                | Hook::OnAgeAnalysisComplete { .. }
396                | Hook::OnStartup
397                | Hook::OnShutdown => {}
398            }
399        }
400        all
401    }
402
403    #[test]
404    fn plugin_discovery_knows_every_hook_and_nothing_else() {
405        let names: Vec<&str> = every_hook().iter().map(Hook::name).collect();
406        assert_eq!(names, crate::runtime::HOOK_NAMES);
407    }
408
409    #[test]
410    fn long_notices_are_cut_at_a_character_boundary() {
411        let notice = Notice::new(NoticeLevel::Info, &"é".repeat(Notice::MAX_BYTES));
412        assert!(notice.message.len() <= Notice::MAX_BYTES);
413        assert!(notice.message.chars().all(|c| c == 'é'));
414        assert_eq!(NoticeLevel::parse("warning"), NoticeLevel::Warn);
415        assert_eq!(NoticeLevel::parse("loud"), NoticeLevel::Info);
416    }
417}