Skip to main content

par_term_config/
snippets.rs

1//! Configuration types for snippets and custom actions.
2//!
3//! This module provides:
4//! - Snippet definitions with variable substitution
5//! - Custom action definitions (shell commands, text insertion, key sequences)
6//! - Built-in and custom variable support
7
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Default timeout for shell commands (30 seconds).
12const fn default_shell_command_timeout_secs() -> u64 {
13    30
14}
15
16/// Deadline for the `git` invocations backing the `GitBranch`/`GitCommit` variables.
17const GIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
18
19/// How often [`git_stdout`] re-checks whether `git` has exited.
20const GIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
21
22/// Run `git <args>` and return its trimmed stdout, killing it if it outlives
23/// [`GIT_TIMEOUT`].
24///
25/// Returns an empty string on spawn failure, non-zero exit, or timeout — matching
26/// how the caller already treats "not a repository". The deadline matters because
27/// `git rev-parse` is sub-millisecond against a healthy repository but unbounded
28/// against a network-mounted `.git` or a stale `index.lock`, and variable expansion
29/// runs on the caller's UI thread.
30fn git_stdout(args: &[&str]) -> String {
31    use std::io::Read;
32    use std::process::{Command, Stdio};
33    use std::time::Instant;
34
35    let mut child = match Command::new("git")
36        .args(args)
37        .stdin(Stdio::null())
38        .stdout(Stdio::piped())
39        .stderr(Stdio::null())
40        .spawn()
41    {
42        Ok(child) => child,
43        Err(e) => {
44            log::warn!("git {} failed to spawn: {e}", args.join(" "));
45            return String::new();
46        }
47    };
48
49    // Drain stdout on a helper thread: polling `try_wait` without reading would
50    // deadlock as soon as the child fills the pipe buffer.
51    let Some(mut stdout) = child.stdout.take() else {
52        return String::new();
53    };
54    let reader = std::thread::spawn(move || {
55        let mut buf = String::new();
56        let _ = stdout.read_to_string(&mut buf);
57        buf
58    });
59
60    let deadline = Instant::now() + GIT_TIMEOUT;
61    loop {
62        match child.try_wait() {
63            Ok(Some(status)) if status.success() => {
64                return reader.join().unwrap_or_default().trim().to_string();
65            }
66            Ok(Some(_)) => return String::new(),
67            Ok(None) => {
68                if Instant::now() >= deadline {
69                    log::warn!(
70                        "git {} exceeded {:.1}s, terminating",
71                        args.join(" "),
72                        GIT_TIMEOUT.as_secs_f64()
73                    );
74                    let _ = child.kill();
75                    let _ = child.wait();
76                    return String::new();
77                }
78                std::thread::sleep(GIT_POLL_INTERVAL);
79            }
80            Err(e) => {
81                log::warn!("git {} failed while waiting: {e}", args.join(" "));
82                return String::new();
83            }
84        }
85    }
86}
87
88/// A text snippet that can be inserted into the terminal.
89///
90/// Snippets support variable substitution using \(variable\) syntax.
91/// Example: "echo 'Today is \(date)'" will replace \(date) with the current date.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct SnippetConfig {
94    /// Unique identifier for the snippet
95    pub id: String,
96
97    /// Human-readable title for the snippet
98    pub title: String,
99
100    /// The text content to insert (may contain variables)
101    pub content: String,
102
103    /// Optional keyboard shortcut to trigger the snippet (e.g., "Ctrl+Shift+D")
104    #[serde(default)]
105    pub keybinding: Option<String>,
106
107    /// Whether the keybinding is enabled (default: true)
108    /// If false, the keybinding won't be registered even if keybinding is set
109    #[serde(default = "crate::defaults::bool_true")]
110    pub keybinding_enabled: bool,
111
112    /// Optional folder/collection for organization (e.g., "Git", "Docker")
113    #[serde(default)]
114    pub folder: Option<String>,
115
116    /// Whether this snippet is enabled
117    #[serde(default = "crate::defaults::bool_true")]
118    pub enabled: bool,
119
120    /// Optional description of what the snippet does
121    #[serde(default)]
122    pub description: Option<String>,
123
124    /// Whether to automatically send Enter after inserting the snippet (default: false)
125    /// If true, a newline character is appended to execute the command immediately
126    #[serde(default)]
127    pub auto_execute: bool,
128
129    /// Custom variables defined for this snippet
130    #[serde(default)]
131    pub variables: HashMap<String, String>,
132}
133
134impl SnippetConfig {
135    /// Create a new snippet with the given ID and title.
136    pub fn new(id: String, title: String, content: String) -> Self {
137        Self {
138            id,
139            title,
140            content,
141            keybinding: None,
142            keybinding_enabled: true,
143            folder: None,
144            enabled: true,
145            description: None,
146            auto_execute: false,
147            variables: HashMap::new(),
148        }
149    }
150
151    /// Add a keybinding to the snippet.
152    pub fn with_keybinding(mut self, keybinding: String) -> Self {
153        self.keybinding = Some(keybinding);
154        self
155    }
156
157    /// Disable the keybinding for this snippet.
158    pub fn with_keybinding_disabled(mut self) -> Self {
159        self.keybinding_enabled = false;
160        self
161    }
162
163    /// Add a folder to the snippet.
164    pub fn with_folder(mut self, folder: String) -> Self {
165        self.folder = Some(folder);
166        self
167    }
168
169    /// Add a custom variable to the snippet.
170    pub fn with_variable(mut self, name: String, value: String) -> Self {
171        self.variables.insert(name, value);
172        self
173    }
174
175    /// Enable auto-execute (send Enter after inserting the snippet).
176    pub fn with_auto_execute(mut self) -> Self {
177        self.auto_execute = true;
178        self
179    }
180}
181
182/// A portable snippet library for import/export.
183///
184/// Wraps a list of snippets for serialization to/from YAML files.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SnippetLibrary {
187    /// The snippets in this library
188    pub snippets: Vec<SnippetConfig>,
189}
190
191/// Default delay in ms before sending text to a newly split pane.
192const fn default_split_pane_delay_ms() -> u64 {
193    200
194}
195
196/// Normalize an action prefix character for matching and conflict detection.
197///
198/// ASCII letters are matched case-insensitively; all other characters remain exact.
199pub fn normalize_action_prefix_char(ch: char) -> char {
200    if ch.is_ascii_alphabetic() {
201        ch.to_ascii_lowercase()
202    } else {
203        ch
204    }
205}
206
207/// Default split percent: existing pane keeps 66% of the space.
208const fn default_split_percent() -> u8 {
209    66
210}
211
212/// The six fields that are identical across every [`CustomActionConfig`] variant.
213///
214/// # Note on `#[serde(flatten)]`
215///
216/// Serde does not support combining `#[serde(tag = "type")]` (internally tagged enum)
217/// with `#[serde(flatten)]` on a variant field — the combination silently produces
218/// incorrect output or a runtime error depending on the format. Therefore `ActionBase`
219/// is **not** used as a flattened serde field inside the enum variants; the six fields
220/// remain individually declared in each variant to preserve existing YAML compatibility.
221///
222/// `ActionBase` is used purely as a value-transfer helper in [`CustomActionConfig::base`]
223/// and [`CustomActionConfig::apply_base`], which together eliminate the eight-arm match
224/// repetition in every mutator method.
225#[derive(Debug, Clone, PartialEq, Default)]
226pub struct ActionBase {
227    /// Action identifier (for keybinding reference).
228    pub id: String,
229    /// Human-readable title.
230    pub title: String,
231    /// Optional keyboard shortcut.
232    pub keybinding: Option<String>,
233    /// Optional single character triggered after the global prefix key.
234    pub prefix_char: Option<char>,
235    /// Whether the keybinding is active (default: `true`).
236    pub keybinding_enabled: bool,
237    /// Optional human-readable description.
238    pub description: Option<String>,
239}
240
241impl ActionBase {
242    /// Create a minimal base with just an id and title.
243    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
244        Self {
245            id: id.into(),
246            title: title.into(),
247            keybinding: None,
248            prefix_char: None,
249            keybinding_enabled: true,
250            description: None,
251        }
252    }
253}
254
255/// Split direction for a custom action pane split.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
257#[serde(rename_all = "snake_case")]
258pub enum ActionSplitDirection {
259    /// New pane below (panes stacked top/bottom)
260    #[default]
261    Horizontal,
262    /// New pane to the right (side by side)
263    Vertical,
264}
265
266impl ActionSplitDirection {
267    /// All directions for UI dropdowns.
268    pub fn all() -> &'static [ActionSplitDirection] {
269        &[Self::Horizontal, Self::Vertical]
270    }
271
272    /// Human-readable label.
273    pub fn label(self) -> &'static str {
274        match self {
275            Self::Horizontal => "Horizontal (below)",
276            Self::Vertical => "Vertical (right)",
277        }
278    }
279}
280
281/// What to do when a sequence step "fails".
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
283#[serde(rename_all = "snake_case")]
284pub enum SequenceStepBehavior {
285    /// Halt sequence and show error toast (default).
286    #[default]
287    Abort,
288    /// Halt sequence silently.
289    Stop,
290    /// Ignore failure and continue to the next step.
291    Continue,
292}
293
294/// A single step in a Sequence action.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
296pub struct SequenceStep {
297    /// ID of the action to execute.
298    pub action_id: String,
299    /// Delay in milliseconds before this step runs (default: 0).
300    #[serde(default)]
301    pub delay_ms: u64,
302    /// What to do if this step fails (default: Abort).
303    #[serde(default)]
304    pub on_failure: SequenceStepBehavior,
305}
306
307/// Condition to check for a Condition action.
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309#[serde(tag = "kind", rename_all = "snake_case")]
310pub enum ConditionCheck {
311    /// Check the exit code of the last captured ShellCommand.
312    ExitCode { value: i32 },
313    /// Check whether the last captured output contains a pattern.
314    OutputContains {
315        pattern: String,
316        #[serde(default)]
317        case_sensitive: bool,
318    },
319    /// Check an environment variable (None value = existence check only).
320    EnvVar {
321        name: String,
322        #[serde(default)]
323        value: Option<String>,
324    },
325    /// Glob match on the current terminal CWD.
326    DirMatches { pattern: String },
327    /// Glob match on the current git branch.
328    GitBranch { pattern: String },
329}
330
331/// A custom action that can be triggered via keybinding.
332///
333/// Actions can execute shell commands, open a new tab, insert text, simulate key
334/// sequences, or split the active pane.
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
336#[serde(tag = "type", rename_all = "snake_case")]
337pub enum CustomActionConfig {
338    /// Execute a shell command
339    ShellCommand {
340        /// Action identifier (for keybinding reference)
341        id: String,
342
343        /// Human-readable title
344        title: String,
345
346        /// Command to execute (e.g., "git", "npm")
347        command: String,
348
349        /// Command arguments (e.g., ["status", "--short"])
350        #[serde(default)]
351        args: Vec<String>,
352
353        /// Whether to show command output in a notification
354        #[serde(default)]
355        notify_on_success: bool,
356
357        /// Timeout in seconds for the command (default: 30)
358        #[serde(default = "default_shell_command_timeout_secs")]
359        timeout_secs: u64,
360
361        /// Capture stdout+stderr into WorkflowContext for use by Sequence/Condition actions.
362        /// When true, output is capped at 64 KB. Default: false.
363        #[serde(default)]
364        capture_output: bool,
365
366        /// Optional keyboard shortcut to trigger the action (e.g., "Ctrl+Shift+R")
367        #[serde(default)]
368        keybinding: Option<String>,
369
370        /// Optional single character triggered after the global custom action prefix key.
371        #[serde(default)]
372        prefix_char: Option<char>,
373
374        /// Whether the keybinding is enabled (default: true)
375        #[serde(default = "crate::defaults::bool_true")]
376        keybinding_enabled: bool,
377
378        /// Optional description
379        #[serde(default)]
380        description: Option<String>,
381    },
382
383    /// Open a new tab and optionally run a command in its shell
384    NewTab {
385        /// Action identifier
386        id: String,
387
388        /// Human-readable title
389        title: String,
390
391        /// Optional command to send to the new tab's shell after it opens
392        #[serde(default)]
393        command: Option<String>,
394
395        /// Optional keyboard shortcut to trigger the action
396        #[serde(default)]
397        keybinding: Option<String>,
398
399        /// Optional single character triggered after the global custom action prefix key.
400        #[serde(default)]
401        prefix_char: Option<char>,
402
403        /// Whether the keybinding is enabled (default: true)
404        #[serde(default = "crate::defaults::bool_true")]
405        keybinding_enabled: bool,
406
407        /// Optional description
408        #[serde(default)]
409        description: Option<String>,
410    },
411
412    /// Insert text into the terminal (like a snippet but no editing UI)
413    InsertText {
414        /// Action identifier
415        id: String,
416
417        /// Human-readable title
418        title: String,
419
420        /// Text to insert (supports variable substitution)
421        text: String,
422
423        /// Custom variables for substitution
424        #[serde(default)]
425        variables: HashMap<String, String>,
426
427        /// Optional keyboard shortcut to trigger the action
428        #[serde(default)]
429        keybinding: Option<String>,
430
431        /// Optional single character triggered after the global custom action prefix key.
432        #[serde(default)]
433        prefix_char: Option<char>,
434
435        /// Whether the keybinding is enabled (default: true)
436        #[serde(default = "crate::defaults::bool_true")]
437        keybinding_enabled: bool,
438
439        /// Optional description
440        #[serde(default)]
441        description: Option<String>,
442    },
443
444    /// Simulate a key sequence
445    KeySequence {
446        /// Action identifier
447        id: String,
448
449        /// Human-readable title
450        title: String,
451
452        /// Key sequence to simulate (e.g., "Ctrl+C", "Up Up Down Down")
453        keys: String,
454
455        /// Optional keyboard shortcut to trigger the action
456        #[serde(default)]
457        keybinding: Option<String>,
458
459        /// Optional single character triggered after the global custom action prefix key.
460        #[serde(default)]
461        prefix_char: Option<char>,
462
463        /// Whether the keybinding is enabled (default: true)
464        #[serde(default = "crate::defaults::bool_true")]
465        keybinding_enabled: bool,
466
467        /// Optional description
468        #[serde(default)]
469        description: Option<String>,
470    },
471
472    /// Split the active pane and optionally send a command to the new pane
473    SplitPane {
474        /// Action identifier
475        id: String,
476
477        /// Human-readable title
478        title: String,
479
480        /// Split direction: horizontal (new pane below) or vertical (new pane right)
481        #[serde(default)]
482        direction: ActionSplitDirection,
483
484        /// Command for the new pane.
485        ///
486        /// Behaviour depends on `command_is_direct`:
487        /// - `false` (default): text is sent to the shell with a trailing newline after `delay_ms`.
488        /// - `true`: the string is split on whitespace and used as the pane's initial process
489        ///   (like running `htop` directly). The pane closes when the process exits.
490        #[serde(default)]
491        command: Option<String>,
492
493        /// When `true`, `command` is the pane's initial process (argv), not a shell command.
494        /// The pane closes when the process exits. `delay_ms` is ignored.
495        /// When `false` (default), `command` is sent as text to the shell.
496        #[serde(default)]
497        command_is_direct: bool,
498
499        /// Whether to focus the new pane after splitting (default: true)
500        #[serde(default = "crate::defaults::bool_true")]
501        focus_new_pane: bool,
502
503        /// Delay in ms before sending the command text to the new pane (default: 200).
504        /// Only used when `command_is_direct` is `false`.
505        #[serde(default = "default_split_pane_delay_ms")]
506        delay_ms: u64,
507
508        /// Percent of the current pane that the existing pane retains after the split.
509        /// Range 10–90. Default: 66 (existing pane keeps 66%, new pane gets 34%).
510        #[serde(default = "default_split_percent")]
511        split_percent: u8,
512
513        /// Optional keyboard shortcut to trigger the action
514        #[serde(default)]
515        keybinding: Option<String>,
516
517        /// Optional single character triggered after the global custom action prefix key.
518        #[serde(default)]
519        prefix_char: Option<char>,
520
521        /// Whether the keybinding is enabled (default: true)
522        #[serde(default = "crate::defaults::bool_true")]
523        keybinding_enabled: bool,
524
525        /// Optional description
526        #[serde(default)]
527        description: Option<String>,
528    },
529
530    /// Run an ordered list of actions (steps) in sequence.
531    Sequence {
532        /// Action identifier
533        id: String,
534        /// Human-readable title
535        title: String,
536        /// Optional keyboard shortcut
537        #[serde(default)]
538        keybinding: Option<String>,
539        /// Optional single character triggered after the global custom action prefix key.
540        #[serde(default)]
541        prefix_char: Option<char>,
542        /// Whether the keybinding is enabled (default: true)
543        #[serde(default = "crate::defaults::bool_true")]
544        keybinding_enabled: bool,
545        /// Optional description
546        #[serde(default)]
547        description: Option<String>,
548        /// Ordered list of steps to execute.
549        #[serde(default)]
550        steps: Vec<SequenceStep>,
551    },
552
553    /// Evaluate a condition and branch to different actions.
554    Condition {
555        /// Action identifier
556        id: String,
557        /// Human-readable title
558        title: String,
559        /// Optional keyboard shortcut
560        #[serde(default)]
561        keybinding: Option<String>,
562        /// Optional single character triggered after the global custom action prefix key.
563        #[serde(default)]
564        prefix_char: Option<char>,
565        /// Whether the keybinding is enabled (default: true)
566        #[serde(default = "crate::defaults::bool_true")]
567        keybinding_enabled: bool,
568        /// Optional description
569        #[serde(default)]
570        description: Option<String>,
571        /// The condition to evaluate.
572        check: ConditionCheck,
573        /// Action ID to execute when check is true (standalone use only; ignored in Sequence).
574        #[serde(default)]
575        on_true_id: Option<String>,
576        /// Action ID to execute when check is false (standalone use only; ignored in Sequence).
577        #[serde(default)]
578        on_false_id: Option<String>,
579    },
580
581    /// Execute an action repeatedly up to N times.
582    Repeat {
583        /// Action identifier
584        id: String,
585        /// Human-readable title
586        title: String,
587        /// Optional keyboard shortcut
588        #[serde(default)]
589        keybinding: Option<String>,
590        /// Optional single character triggered after the global custom action prefix key.
591        #[serde(default)]
592        prefix_char: Option<char>,
593        /// Whether the keybinding is enabled (default: true)
594        #[serde(default = "crate::defaults::bool_true")]
595        keybinding_enabled: bool,
596        /// Optional description
597        #[serde(default)]
598        description: Option<String>,
599        /// ID of the action to repeat.
600        action_id: String,
601        /// Maximum number of repetitions (1–100).
602        count: u32,
603        /// Delay in milliseconds between repetitions (default: 0).
604        #[serde(default)]
605        delay_ms: u64,
606        /// Stop early when the action succeeds (default: false).
607        #[serde(default)]
608        stop_on_success: bool,
609        /// Stop early when the action fails (default: false).
610        #[serde(default)]
611        stop_on_failure: bool,
612    },
613}
614
615impl CustomActionConfig {
616    /// Return a snapshot of the six shared base fields.
617    ///
618    /// Use this to read multiple base fields at once without repeated match arms.
619    /// For single-field reads, prefer the dedicated accessors (`id()`, `title()`, etc.).
620    pub fn base(&self) -> ActionBase {
621        match self {
622            Self::ShellCommand {
623                id,
624                title,
625                keybinding,
626                prefix_char,
627                keybinding_enabled,
628                description,
629                ..
630            }
631            | Self::NewTab {
632                id,
633                title,
634                keybinding,
635                prefix_char,
636                keybinding_enabled,
637                description,
638                ..
639            }
640            | Self::InsertText {
641                id,
642                title,
643                keybinding,
644                prefix_char,
645                keybinding_enabled,
646                description,
647                ..
648            }
649            | Self::KeySequence {
650                id,
651                title,
652                keybinding,
653                prefix_char,
654                keybinding_enabled,
655                description,
656                ..
657            }
658            | Self::SplitPane {
659                id,
660                title,
661                keybinding,
662                prefix_char,
663                keybinding_enabled,
664                description,
665                ..
666            }
667            | Self::Sequence {
668                id,
669                title,
670                keybinding,
671                prefix_char,
672                keybinding_enabled,
673                description,
674                ..
675            }
676            | Self::Condition {
677                id,
678                title,
679                keybinding,
680                prefix_char,
681                keybinding_enabled,
682                description,
683                ..
684            }
685            | Self::Repeat {
686                id,
687                title,
688                keybinding,
689                prefix_char,
690                keybinding_enabled,
691                description,
692                ..
693            } => ActionBase {
694                id: id.clone(),
695                title: title.clone(),
696                keybinding: keybinding.clone(),
697                prefix_char: *prefix_char,
698                keybinding_enabled: *keybinding_enabled,
699                description: description.clone(),
700            },
701        }
702    }
703
704    /// Overwrite all six shared base fields from an [`ActionBase`] snapshot.
705    ///
706    /// This is the single mutation point that replaces the eight-arm match duplication
707    /// previously found in `set_keybinding`, `set_prefix_char`, `set_keybinding_enabled`,
708    /// and `into_copy`.
709    pub fn apply_base(&mut self, base: ActionBase) {
710        match self {
711            Self::ShellCommand {
712                id,
713                title,
714                keybinding,
715                prefix_char,
716                keybinding_enabled,
717                description,
718                ..
719            }
720            | Self::NewTab {
721                id,
722                title,
723                keybinding,
724                prefix_char,
725                keybinding_enabled,
726                description,
727                ..
728            }
729            | Self::InsertText {
730                id,
731                title,
732                keybinding,
733                prefix_char,
734                keybinding_enabled,
735                description,
736                ..
737            }
738            | Self::KeySequence {
739                id,
740                title,
741                keybinding,
742                prefix_char,
743                keybinding_enabled,
744                description,
745                ..
746            }
747            | Self::SplitPane {
748                id,
749                title,
750                keybinding,
751                prefix_char,
752                keybinding_enabled,
753                description,
754                ..
755            }
756            | Self::Sequence {
757                id,
758                title,
759                keybinding,
760                prefix_char,
761                keybinding_enabled,
762                description,
763                ..
764            }
765            | Self::Condition {
766                id,
767                title,
768                keybinding,
769                prefix_char,
770                keybinding_enabled,
771                description,
772                ..
773            }
774            | Self::Repeat {
775                id,
776                title,
777                keybinding,
778                prefix_char,
779                keybinding_enabled,
780                description,
781                ..
782            } => {
783                *id = base.id;
784                *title = base.title;
785                *keybinding = base.keybinding;
786                *prefix_char = base.prefix_char;
787                *keybinding_enabled = base.keybinding_enabled;
788                *description = base.description;
789            }
790        }
791    }
792
793    /// Get the action ID (for keybinding reference).
794    pub fn id(&self) -> &str {
795        match self {
796            Self::ShellCommand { id, .. }
797            | Self::NewTab { id, .. }
798            | Self::InsertText { id, .. }
799            | Self::KeySequence { id, .. }
800            | Self::SplitPane { id, .. }
801            | Self::Sequence { id, .. }
802            | Self::Condition { id, .. }
803            | Self::Repeat { id, .. } => id,
804        }
805    }
806
807    /// Get the action title (for UI display).
808    pub fn title(&self) -> &str {
809        match self {
810            Self::ShellCommand { title, .. }
811            | Self::NewTab { title, .. }
812            | Self::InsertText { title, .. }
813            | Self::KeySequence { title, .. }
814            | Self::SplitPane { title, .. }
815            | Self::Sequence { title, .. }
816            | Self::Condition { title, .. }
817            | Self::Repeat { title, .. } => title,
818        }
819    }
820
821    /// Get the optional keybinding for this action.
822    pub fn keybinding(&self) -> Option<&str> {
823        match self {
824            Self::ShellCommand { keybinding, .. }
825            | Self::NewTab { keybinding, .. }
826            | Self::InsertText { keybinding, .. }
827            | Self::KeySequence { keybinding, .. }
828            | Self::SplitPane { keybinding, .. }
829            | Self::Sequence { keybinding, .. }
830            | Self::Condition { keybinding, .. }
831            | Self::Repeat { keybinding, .. } => keybinding.as_deref(),
832        }
833    }
834
835    /// Get the optional prefix character for this action.
836    pub fn prefix_char(&self) -> Option<char> {
837        match self {
838            Self::ShellCommand { prefix_char, .. }
839            | Self::NewTab { prefix_char, .. }
840            | Self::InsertText { prefix_char, .. }
841            | Self::KeySequence { prefix_char, .. }
842            | Self::SplitPane { prefix_char, .. }
843            | Self::Sequence { prefix_char, .. }
844            | Self::Condition { prefix_char, .. }
845            | Self::Repeat { prefix_char, .. } => *prefix_char,
846        }
847    }
848
849    /// Get the normalized prefix character for this action, if configured.
850    pub fn normalized_prefix_char(&self) -> Option<char> {
851        self.prefix_char().map(normalize_action_prefix_char)
852    }
853
854    /// Character that triggers this action after the global custom-action prefix key.
855    ///
856    /// Prefers an explicit `prefix_char`. If none is set and the action's keybinding is
857    /// enabled and is a single character with no modifiers, that character is used so a
858    /// configured prefix key still works when the follow-up was stored as `keybinding`.
859    pub fn prefix_follow_up_char(&self) -> Option<char> {
860        if let Some(ch) = self.normalized_prefix_char() {
861            return Some(ch);
862        }
863        self.single_char_keybinding_follow_up()
864    }
865
866    pub(crate) fn single_char_keybinding_follow_up(&self) -> Option<char> {
867        if !self.keybinding_enabled() {
868            return None;
869        }
870        let key = self.keybinding()?.trim();
871        let mut chars = key.chars();
872        let ch = chars.next()?;
873        if chars.next().is_some() {
874            return None;
875        }
876        Some(normalize_action_prefix_char(ch))
877    }
878
879    /// Check if the keybinding is enabled.
880    pub fn keybinding_enabled(&self) -> bool {
881        match self {
882            Self::ShellCommand {
883                keybinding_enabled, ..
884            }
885            | Self::NewTab {
886                keybinding_enabled, ..
887            }
888            | Self::InsertText {
889                keybinding_enabled, ..
890            }
891            | Self::KeySequence {
892                keybinding_enabled, ..
893            }
894            | Self::SplitPane {
895                keybinding_enabled, ..
896            }
897            | Self::Sequence {
898                keybinding_enabled, ..
899            }
900            | Self::Condition {
901                keybinding_enabled, ..
902            }
903            | Self::Repeat {
904                keybinding_enabled, ..
905            } => *keybinding_enabled,
906        }
907    }
908
909    /// Set the keybinding for this action.
910    pub fn set_keybinding(&mut self, kb: Option<String>) {
911        let mut base = self.base();
912        base.keybinding = kb;
913        self.apply_base(base);
914    }
915
916    /// Set the prefix character for this action.
917    pub fn set_prefix_char(&mut self, prefix_char: Option<char>) {
918        let mut base = self.base();
919        base.prefix_char = prefix_char;
920        self.apply_base(base);
921    }
922
923    /// Set whether the keybinding is enabled.
924    pub fn set_keybinding_enabled(&mut self, enabled: bool) {
925        let mut base = self.base();
926        base.keybinding_enabled = enabled;
927        self.apply_base(base);
928    }
929
930    /// Check if this is a shell command action.
931    pub fn is_shell_command(&self) -> bool {
932        matches!(self, Self::ShellCommand { .. })
933    }
934
935    /// Check if this is a new tab action.
936    pub fn is_new_tab(&self) -> bool {
937        matches!(self, Self::NewTab { .. })
938    }
939
940    /// Check if this is an insert text action.
941    pub fn is_insert_text(&self) -> bool {
942        matches!(self, Self::InsertText { .. })
943    }
944
945    /// Check if this is a key sequence action.
946    pub fn is_key_sequence(&self) -> bool {
947        matches!(self, Self::KeySequence { .. })
948    }
949
950    /// Check if this is a split pane action.
951    pub fn is_split_pane(&self) -> bool {
952        matches!(self, Self::SplitPane { .. })
953    }
954
955    /// Check if this is a sequence action.
956    pub fn is_sequence(&self) -> bool {
957        matches!(self, Self::Sequence { .. })
958    }
959
960    /// Check if this is a condition action.
961    pub fn is_condition(&self) -> bool {
962        matches!(self, Self::Condition { .. })
963    }
964
965    /// Check if this is a repeat action.
966    pub fn is_repeat(&self) -> bool {
967        matches!(self, Self::Repeat { .. })
968    }
969
970    /// Produce a duplicate of this action suitable for "Clone" in the settings UI.
971    ///
972    /// The returned action has:
973    /// - A fresh UUID-based `id` to avoid keybinding conflicts.
974    /// - The original `title` suffixed with `"-copy"`.
975    /// - `keybinding` and `prefix_char` cleared to prevent immediate conflicts.
976    ///
977    /// All other fields are deep-cloned from `self`.
978    ///
979    /// This replaces the `clone_action` helper that was previously inlined in
980    /// `par-term-settings-ui/src/actions_tab.rs` (see ARC-006). Keeping the logic here
981    /// ensures it stays in sync with the `Clone` derive on `CustomActionConfig`.
982    pub fn into_copy(&self) -> Self {
983        let mut cloned = self.clone();
984        // Patch the four base fields that must differ on the copy; keep the rest.
985        let mut base = cloned.base();
986        base.id = format!("action_{}", uuid::Uuid::new_v4());
987        base.title = format!("{}-copy", base.title);
988        base.keybinding = None;
989        base.prefix_char = None;
990        cloned.apply_base(base);
991        cloned
992    }
993}
994
995/// Built-in variables available for snippet substitution.
996#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
997pub enum BuiltInVariable {
998    /// Current date (YYYY-MM-DD)
999    Date,
1000    /// Current time (HH:MM:SS)
1001    Time,
1002    /// Current date and time
1003    DateTime,
1004    /// System hostname
1005    Hostname,
1006    /// Current username
1007    User,
1008    /// Current working directory
1009    Path,
1010    /// Current git branch (if in a git repository)
1011    GitBranch,
1012    /// Current git commit hash (if in a git repository)
1013    GitCommit,
1014    /// Random UUID
1015    Uuid,
1016    /// Random number (0-999999)
1017    Random,
1018}
1019
1020impl BuiltInVariable {
1021    /// Get all built-in variables for UI display.
1022    pub fn all() -> &'static [(&'static str, &'static str)] {
1023        &[
1024            ("date", "Current date (YYYY-MM-DD)"),
1025            ("time", "Current time (HH:MM:SS)"),
1026            ("datetime", "Current date and time"),
1027            ("hostname", "System hostname"),
1028            ("user", "Current username"),
1029            ("path", "Current working directory"),
1030            ("git_branch", "Current git branch"),
1031            ("git_commit", "Current git commit hash"),
1032            ("uuid", "Random UUID"),
1033            ("random", "Random number (0-999999)"),
1034        ]
1035    }
1036
1037    /// Parse a variable name into a BuiltInVariable.
1038    pub fn parse(name: &str) -> Option<Self> {
1039        match name {
1040            "date" => Some(Self::Date),
1041            "time" => Some(Self::Time),
1042            "datetime" => Some(Self::DateTime),
1043            "hostname" => Some(Self::Hostname),
1044            "user" => Some(Self::User),
1045            "path" => Some(Self::Path),
1046            "git_branch" => Some(Self::GitBranch),
1047            "git_commit" => Some(Self::GitCommit),
1048            "uuid" => Some(Self::Uuid),
1049            "random" => Some(Self::Random),
1050            _ => None,
1051        }
1052    }
1053
1054    /// Resolve the variable to its string value.
1055    pub fn resolve(&self) -> String {
1056        match self {
1057            // QA-001: Use chrono for correct calendar arithmetic. The previous
1058            // implementation derived the date from `secs / 86400` with every month
1059            // treated as 30 days, which produced month=13 in late December and a
1060            // wrong day-of-month year-round.
1061            Self::Date => chrono::Local::now().format("%Y-%m-%d").to_string(),
1062            Self::Time => {
1063                use std::time::{SystemTime, UNIX_EPOCH};
1064                let duration = SystemTime::now()
1065                    .duration_since(UNIX_EPOCH)
1066                    .unwrap_or_default();
1067                let secs = duration.as_secs();
1068                let hours = (secs % 86400) / 3600;
1069                let minutes = (secs % 3600) / 60;
1070                let seconds = secs % 60;
1071
1072                format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
1073            }
1074            Self::DateTime => {
1075                format!("{} {}", Self::Date.resolve(), Self::Time.resolve())
1076            }
1077            Self::Hostname => {
1078                std::env::var("HOSTNAME")
1079                    .or_else(|_| std::env::var("HOST"))
1080                    .unwrap_or_else(|_| {
1081                        // Fallback to system hostname
1082                        hostname::get()
1083                            .ok()
1084                            .and_then(|s| s.into_string().ok())
1085                            .unwrap_or_else(|| "unknown".to_string())
1086                    })
1087            }
1088            Self::User => std::env::var("USER")
1089                .or_else(|_| std::env::var("USERNAME"))
1090                .unwrap_or_else(|_| "unknown".to_string()),
1091            Self::Path => std::env::current_dir()
1092                .ok()
1093                .and_then(|p| p.to_str().map(|s| s.to_string()))
1094                .unwrap_or_else(|| ".".to_string()),
1095            Self::GitBranch => {
1096                // Try to get git branch from environment or command
1097                match std::env::var("GIT_BRANCH") {
1098                    Ok(branch) => branch,
1099                    Err(_) => git_stdout(&["rev-parse", "--abbrev-ref", "HEAD"]),
1100                }
1101            }
1102            Self::GitCommit => {
1103                // Try to get git commit from environment or command
1104                match std::env::var("GIT_COMMIT") {
1105                    Ok(commit) => commit,
1106                    Err(_) => git_stdout(&["rev-parse", "--short", "HEAD"]),
1107                }
1108            }
1109            Self::Uuid => uuid::Uuid::new_v4().to_string(),
1110            Self::Random => {
1111                use std::time::{SystemTime, UNIX_EPOCH};
1112                let duration = SystemTime::now()
1113                    .duration_since(UNIX_EPOCH)
1114                    .unwrap_or_default();
1115                format!("{}", (duration.as_nanos() % 1_000_000) as u32)
1116            }
1117        }
1118    }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    #[test]
1126    fn test_snippet_new() {
1127        let snippet = SnippetConfig::new(
1128            "test".to_string(),
1129            "Test Snippet".to_string(),
1130            "echo 'hello'".to_string(),
1131        );
1132
1133        assert_eq!(snippet.id, "test");
1134        assert_eq!(snippet.title, "Test Snippet");
1135        assert_eq!(snippet.content, "echo 'hello'");
1136        assert!(snippet.enabled);
1137        assert!(snippet.keybinding.is_none());
1138        assert!(snippet.folder.is_none());
1139        assert!(snippet.variables.is_empty());
1140    }
1141
1142    #[test]
1143    fn test_snippet_builder() {
1144        let snippet = SnippetConfig::new(
1145            "test".to_string(),
1146            "Test Snippet".to_string(),
1147            "echo 'hello'".to_string(),
1148        )
1149        .with_keybinding("Ctrl+Shift+T".to_string())
1150        .with_folder("Test".to_string())
1151        .with_variable("name".to_string(), "value".to_string());
1152
1153        assert_eq!(snippet.keybinding, Some("Ctrl+Shift+T".to_string()));
1154        assert_eq!(snippet.folder, Some("Test".to_string()));
1155        assert_eq!(snippet.variables.get("name"), Some(&"value".to_string()));
1156    }
1157
1158    #[test]
1159    fn test_builtin_variable_resolution() {
1160        // These should not panic
1161        let date = BuiltInVariable::Date.resolve();
1162        assert!(!date.is_empty());
1163        // QA-001: `\(date)` must be a valid `YYYY-MM-DD` string. The previous
1164        // hand-rolled arithmetic produced month=13 in late December and a wrong
1165        // day-of-month year-round; assert the shape so the regression is caught.
1166        assert_eq!(
1167            date.len(),
1168            10,
1169            "date must be 10 chars (YYYY-MM-DD), got {date:?}"
1170        );
1171        let mut parts = date.split('-');
1172        let (year, month, day) = (
1173            parts.next().unwrap(),
1174            parts.next().unwrap(),
1175            parts.next().unwrap(),
1176        );
1177        assert!(
1178            parts.next().is_none(),
1179            "date has extra components: {date:?}"
1180        );
1181        assert!(
1182            (1..=9999).contains(&(year.parse::<u32>().unwrap())),
1183            "year out of range: {year:?}"
1184        );
1185        assert!(
1186            (1..=12).contains(&(month.parse::<u32>().unwrap())),
1187            "month must be 1..=12, got {month:?} (date={date:?})"
1188        );
1189        assert!(
1190            (1..=31).contains(&(day.parse::<u32>().unwrap())),
1191            "day must be 1..=31, got {day:?} (date={date:?})"
1192        );
1193
1194        let time = BuiltInVariable::Time.resolve();
1195        assert!(!time.is_empty());
1196
1197        let user = BuiltInVariable::User.resolve();
1198        assert!(!user.is_empty());
1199
1200        let path = BuiltInVariable::Path.resolve();
1201        assert!(!path.is_empty());
1202    }
1203
1204    #[test]
1205    fn test_builtin_variable_parse() {
1206        assert_eq!(BuiltInVariable::parse("date"), Some(BuiltInVariable::Date));
1207        assert_eq!(BuiltInVariable::parse("time"), Some(BuiltInVariable::Time));
1208        assert_eq!(BuiltInVariable::parse("unknown"), None);
1209    }
1210
1211    #[test]
1212    fn test_custom_action_id() {
1213        let action = CustomActionConfig::ShellCommand {
1214            id: "test-action".to_string(),
1215            title: "Test Action".to_string(),
1216            command: "echo".to_string(),
1217            args: vec!["hello".to_string()],
1218            notify_on_success: false,
1219            timeout_secs: 30,
1220            capture_output: false,
1221            keybinding: None,
1222            prefix_char: Some('G'),
1223            keybinding_enabled: true,
1224            description: None,
1225        };
1226
1227        assert_eq!(action.id(), "test-action");
1228        assert_eq!(action.title(), "Test Action");
1229        assert!(action.is_shell_command());
1230        assert!(!action.is_new_tab());
1231        assert!(!action.is_insert_text());
1232        assert!(!action.is_key_sequence());
1233        assert!(!action.is_split_pane());
1234        assert_eq!(action.prefix_char(), Some('G'));
1235        assert_eq!(action.normalized_prefix_char(), Some('g'));
1236    }
1237
1238    #[test]
1239    fn prefix_follow_up_char_uses_single_char_keybinding_when_prefix_char_absent() {
1240        let action = CustomActionConfig::NewTab {
1241            id: "lenny1".to_string(),
1242            title: "lenny1".to_string(),
1243            command: Some("ssh root@lenny1".to_string()),
1244            keybinding: Some("1".to_string()),
1245            prefix_char: None,
1246            keybinding_enabled: true,
1247            description: None,
1248        };
1249
1250        assert_eq!(action.prefix_follow_up_char(), Some('1'));
1251    }
1252
1253    #[test]
1254    fn prefix_follow_up_char_prefers_explicit_prefix_char() {
1255        let action = CustomActionConfig::InsertText {
1256            id: "both".to_string(),
1257            title: "Both".to_string(),
1258            text: "x".to_string(),
1259            variables: HashMap::new(),
1260            keybinding: Some("1".to_string()),
1261            prefix_char: Some('G'),
1262            keybinding_enabled: true,
1263            description: None,
1264        };
1265
1266        assert_eq!(action.prefix_follow_up_char(), Some('g'));
1267    }
1268
1269    #[test]
1270    fn prefix_follow_up_char_ignores_chord_and_disabled_keybindings() {
1271        let chord = CustomActionConfig::InsertText {
1272            id: "chord".to_string(),
1273            title: "Chord".to_string(),
1274            text: "x".to_string(),
1275            variables: HashMap::new(),
1276            keybinding: Some("Ctrl+1".to_string()),
1277            prefix_char: None,
1278            keybinding_enabled: true,
1279            description: None,
1280        };
1281        let disabled = CustomActionConfig::InsertText {
1282            id: "disabled".to_string(),
1283            title: "Disabled".to_string(),
1284            text: "x".to_string(),
1285            variables: HashMap::new(),
1286            keybinding: Some("1".to_string()),
1287            prefix_char: None,
1288            keybinding_enabled: false,
1289            description: None,
1290        };
1291
1292        assert_eq!(chord.prefix_follow_up_char(), None);
1293        assert_eq!(disabled.prefix_follow_up_char(), None);
1294    }
1295
1296    #[test]
1297    fn test_split_pane_action() {
1298        let action = CustomActionConfig::SplitPane {
1299            id: "split-htop".to_string(),
1300            title: "Split and run htop".to_string(),
1301            direction: ActionSplitDirection::Vertical,
1302            command: Some("htop".to_string()),
1303            command_is_direct: true,
1304            focus_new_pane: true,
1305            delay_ms: 200,
1306            split_percent: 66,
1307            keybinding: Some("Ctrl+Shift+H".to_string()),
1308            prefix_char: None,
1309            keybinding_enabled: true,
1310            description: None,
1311        };
1312
1313        assert_eq!(action.id(), "split-htop");
1314        assert_eq!(action.title(), "Split and run htop");
1315        assert!(action.is_split_pane());
1316        assert!(!action.is_shell_command());
1317        assert_eq!(action.keybinding(), Some("Ctrl+Shift+H"));
1318    }
1319
1320    #[test]
1321    fn test_new_tab_action() {
1322        let action = CustomActionConfig::NewTab {
1323            id: "new-tab-lazygit".to_string(),
1324            title: "Open lazygit tab".to_string(),
1325            command: Some("lazygit".to_string()),
1326            keybinding: Some("Ctrl+Shift+G".to_string()),
1327            prefix_char: Some('g'),
1328            keybinding_enabled: true,
1329            description: None,
1330        };
1331
1332        assert_eq!(action.id(), "new-tab-lazygit");
1333        assert_eq!(action.title(), "Open lazygit tab");
1334        assert!(action.is_new_tab());
1335        assert!(!action.is_shell_command());
1336        assert!(!action.is_split_pane());
1337        assert_eq!(action.keybinding(), Some("Ctrl+Shift+G"));
1338        assert_eq!(action.normalized_prefix_char(), Some('g'));
1339    }
1340
1341    #[test]
1342    fn test_sequence_action_round_trip() {
1343        let action = CustomActionConfig::Sequence {
1344            id: "build-and-test".to_string(),
1345            title: "Build and Test".to_string(),
1346            keybinding: None,
1347            prefix_char: None,
1348            keybinding_enabled: true,
1349            description: None,
1350            steps: vec![
1351                SequenceStep {
1352                    action_id: "build".to_string(),
1353                    delay_ms: 0,
1354                    on_failure: SequenceStepBehavior::Abort,
1355                },
1356                SequenceStep {
1357                    action_id: "test".to_string(),
1358                    delay_ms: 500,
1359                    on_failure: SequenceStepBehavior::Continue,
1360                },
1361            ],
1362        };
1363        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1364        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1365        assert_eq!(action, roundtrip);
1366        assert_eq!(action.id(), "build-and-test");
1367        assert_eq!(action.title(), "Build and Test");
1368    }
1369
1370    #[test]
1371    fn test_condition_action_round_trip() {
1372        let action = CustomActionConfig::Condition {
1373            id: "check-main".to_string(),
1374            title: "Check Main Branch".to_string(),
1375            keybinding: None,
1376            prefix_char: None,
1377            keybinding_enabled: true,
1378            description: None,
1379            check: ConditionCheck::GitBranch {
1380                pattern: "main".to_string(),
1381            },
1382            on_true_id: Some("deploy".to_string()),
1383            on_false_id: None,
1384        };
1385        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1386        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1387        assert_eq!(action, roundtrip);
1388        assert_eq!(action.id(), "check-main");
1389    }
1390
1391    #[test]
1392    fn test_repeat_action_round_trip() {
1393        let action = CustomActionConfig::Repeat {
1394            id: "retry-deploy".to_string(),
1395            title: "Retry Deploy".to_string(),
1396            keybinding: None,
1397            prefix_char: None,
1398            keybinding_enabled: true,
1399            description: None,
1400            action_id: "deploy".to_string(),
1401            count: 3,
1402            delay_ms: 1000,
1403            stop_on_success: true,
1404            stop_on_failure: false,
1405        };
1406        let yaml = serde_yaml_ng::to_string(&action).unwrap();
1407        let roundtrip: CustomActionConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1408        assert_eq!(action, roundtrip);
1409        assert_eq!(action.id(), "retry-deploy");
1410    }
1411
1412    #[test]
1413    fn test_shell_command_capture_output_default_false() {
1414        let yaml = r#"
1415type: shell_command
1416id: test
1417title: Test
1418command: echo
1419"#;
1420        let action: CustomActionConfig = serde_yaml_ng::from_str(yaml).unwrap();
1421        if let CustomActionConfig::ShellCommand { capture_output, .. } = action {
1422            assert!(!capture_output);
1423        } else {
1424            panic!("expected ShellCommand");
1425        }
1426    }
1427}