intelli_shell/
app.rs

1use color_eyre::Result;
2use crossterm::event::MouseEventKind;
3use tokio_util::sync::CancellationToken;
4use tracing::instrument;
5
6use crate::{
7    cli::{CliProcess, CompletionProcess, Interactive},
8    component::{Component, EmptyComponent},
9    config::{Config, KeyBindingsConfig},
10    errors::AppError,
11    process::{InteractiveProcess, Process, ProcessOutput},
12    service::IntelliShellService,
13    tui::{Event, Tui},
14};
15
16/// Represents actions that components or the application can signal to change the application state or flow.
17#[derive(Default)]
18pub enum Action {
19    /// No-op action, nothing has to be done
20    #[default]
21    NoOp,
22    /// Signals that the application should quit, providing the output
23    Quit(ProcessOutput),
24    /// Signals that the active component should be switched to the provided one
25    SwitchComponent(Box<dyn Component>),
26}
27
28/// The main application struct, holding configuration and managing the application flow
29pub struct App {
30    cancellation_token: CancellationToken,
31    active_component: Box<dyn Component>,
32}
33impl App {
34    /// Creates a new instance of the application
35    pub fn new(cancellation_token: CancellationToken) -> Result<Self> {
36        Ok(Self {
37            cancellation_token,
38            active_component: Box::new(EmptyComponent),
39        })
40    }
41
42    /// Runs the main application logic based on the parsed CLI arguments.
43    ///
44    /// This method dispatches to either an interactive or non-interactive process execution based on the provided `Cli`
45    /// arguments and the specific subcommand.
46    ///
47    /// It returns the final [ProcessOutput] when the application finishes.
48    #[instrument(skip_all)]
49    pub async fn run(
50        self,
51        config: Config,
52        service: IntelliShellService,
53        process: CliProcess,
54        extra_line: bool,
55    ) -> Result<ProcessOutput> {
56        match process {
57            #[cfg(debug_assertions)]
58            CliProcess::Query(query_process) => {
59                tracing::info!("Running 'query' process");
60                tracing::debug!("Options: {:?}", query_process);
61                service.load_workspace_items().await.map_err(AppError::into_report)?;
62                self.run_non_interactive(query_process, config, service, extra_line)
63                    .await
64            }
65            CliProcess::Init(_) => unreachable!("Handled in main"),
66            CliProcess::New(bookmark_command) => {
67                tracing::info!("Running 'new' process");
68                tracing::debug!("Options: {:?}", bookmark_command);
69                self.run_interactive(bookmark_command, config, service, extra_line)
70                    .await
71            }
72            CliProcess::Search(search_commands) => {
73                tracing::info!("Running 'search' process");
74                tracing::debug!("Options: {:?}", search_commands);
75                service.load_workspace_items().await.map_err(AppError::into_report)?;
76                self.run_interactive(search_commands, config, service, extra_line).await
77            }
78            CliProcess::Replace(variable_replace) => {
79                tracing::info!("Running 'replace' process");
80                tracing::debug!("Options: {:?}", variable_replace);
81                service.load_workspace_items().await.map_err(AppError::into_report)?;
82                self.run_interactive(variable_replace, config, service, extra_line)
83                    .await
84            }
85            CliProcess::Fix(fix_command) => {
86                tracing::info!("Running 'fix' process");
87                tracing::debug!("Options: {:?}", fix_command);
88                self.run_non_interactive(fix_command, config, service, extra_line).await
89            }
90            CliProcess::Export(export_commands) => {
91                tracing::info!("Running 'export' process");
92                tracing::debug!("Options: {:?}", export_commands);
93                self.run_interactive(export_commands, config, service, extra_line).await
94            }
95            CliProcess::Import(import_commands) => {
96                tracing::info!("Running 'import' process");
97                tracing::debug!("Options: {:?}", import_commands);
98                self.run_interactive(import_commands, config, service, extra_line).await
99            }
100            #[cfg(feature = "tldr")]
101            CliProcess::Tldr(crate::cli::TldrProcess::Fetch(tldr_fetch)) => {
102                tracing::info!("Running tldr 'fetch' process");
103                tracing::debug!("Options: {:?}", tldr_fetch);
104                self.run_non_interactive(tldr_fetch, config, service, extra_line).await
105            }
106            #[cfg(feature = "tldr")]
107            CliProcess::Tldr(crate::cli::TldrProcess::Clear(tldr_clear)) => {
108                tracing::info!("Running tldr 'clear' process");
109                tracing::debug!("Options: {:?}", tldr_clear);
110                self.run_non_interactive(tldr_clear, config, service, extra_line).await
111            }
112            CliProcess::Completion(CompletionProcess::New(completion_new)) => {
113                tracing::info!("Running 'completion new' process");
114                tracing::debug!("Options: {:?}", completion_new);
115                self.run_interactive(completion_new, config, service, extra_line).await
116            }
117            CliProcess::Completion(CompletionProcess::Delete(completion_delete)) => {
118                tracing::info!("Running 'completion delete' process");
119                tracing::debug!("Options: {:?}", completion_delete);
120                self.run_non_interactive(completion_delete, config, service, extra_line)
121                    .await
122            }
123            CliProcess::Completion(CompletionProcess::List(completion_list)) => {
124                tracing::info!("Running 'completion list' process");
125                tracing::debug!("Options: {:?}", completion_list);
126                service.load_workspace_items().await.map_err(AppError::into_report)?;
127                self.run_interactive(completion_list, config, service, extra_line).await
128            }
129            #[cfg(feature = "self-update")]
130            CliProcess::Update(update) => {
131                tracing::info!("Running 'update' process");
132                tracing::debug!("Options: {:?}", update);
133                self.run_non_interactive(update, config, service, extra_line).await
134            }
135        }
136    }
137
138    /// Executes a process in non-interactive mode.
139    ///
140    /// Simply calls the `execute` method on the given [Process] implementation.
141    async fn run_non_interactive(
142        self,
143        process: impl Process,
144        config: Config,
145        service: IntelliShellService,
146        extra_line: bool,
147    ) -> Result<ProcessOutput> {
148        if extra_line {
149            println!();
150        }
151        process.execute(config, service, self.cancellation_token).await
152    }
153
154    /// Executes a process that might require an interactive TUI
155    async fn run_interactive(
156        mut self,
157        it: Interactive<impl InteractiveProcess>,
158        config: Config,
159        service: IntelliShellService,
160        extra_line: bool,
161    ) -> Result<ProcessOutput> {
162        // If the process hasn't enabled the interactive flag, just run it
163        if !it.opts.interactive {
164            return self.run_non_interactive(it.process, config, service, extra_line).await;
165        }
166
167        // Converts the process into the renderable component and initializes it
168        let inline = it.opts.inline || (!it.opts.full_screen && config.inline);
169        let keybindings = config.keybindings.clone();
170        self.active_component = it
171            .process
172            .into_component(config, service, inline, self.cancellation_token.clone())?;
173
174        // Initialize and peek into the component, in case we can give a straight result
175        let peek_action = self.active_component.init_and_peek().await?;
176        if let Some(output) = self.process_action(peek_action).await? {
177            tracing::debug!("A result was received from `init_and_peek`, returning it");
178            return Ok(output);
179        }
180
181        // Enter the TUI (inline or fullscreen)
182        let mut tui = Tui::new(self.cancellation_token.clone())?.paste(true).mouse(true);
183        if inline {
184            tracing::debug!("Displaying inline {} interactively", self.active_component.name());
185            tui.enter_inline(extra_line, self.active_component.min_inline_height())?;
186        } else {
187            tracing::debug!("Displaying full-screen {} interactively", self.active_component.name());
188            tui.enter()?;
189        }
190
191        // Main loop
192        loop {
193            tokio::select! {
194                biased;
195                // If the token is cancelled, close the main loop and return
196                _ = self.cancellation_token.cancelled() => {
197                    tracing::info!("Cancellation token received, exiting TUI loop");
198                    return Ok(ProcessOutput::fail());
199                }
200                // Otherwise, wait for the next event to come in
201                maybe_event = tui.next_event() => {
202                    let Some(tui_event) = maybe_event else {
203                        tracing::error!("TUI closed unexpectedly, no event received");
204                        break;
205                    };
206                    // Handle the event
207                    let action = self.handle_tui_event(tui_event, &mut tui, &keybindings).await?;
208                    // Process the action
209                    if let Some(output) = self.process_action(action).await? {
210                        // If the action generated an output, exit the loop by returning it
211                        return Ok(output);
212                    }
213                }
214            }
215        }
216
217        Ok(ProcessOutput::success())
218    }
219
220    /// Handles a single TUI event by dispatching it to the active component.
221    ///
222    /// Based on the type of [Event], calls the corresponding method on the currently active [Component].
223    ///
224    /// Returns an [Action] indicating the result of the event processing.
225    #[instrument(skip_all)]
226    async fn handle_tui_event(
227        &mut self,
228        event: Event,
229        tui: &mut Tui,
230        keybindings: &KeyBindingsConfig,
231    ) -> Result<Action> {
232        if event != Event::Tick
233            && event != Event::Render
234            && !matches!(event, Event::Mouse(m) if m.kind == MouseEventKind::Moved )
235        {
236            tracing::trace!("{event:?}");
237        }
238        let ac = &mut self.active_component;
239        Ok(match event {
240            // Render the active component using the TUI renderer
241            Event::Render => {
242                tui.render(|frame, area| ac.render(frame, area))?;
243                Action::NoOp
244            }
245            // Dispatch other events to the active component
246            Event::Tick => ac.tick()?,
247            Event::FocusGained => ac.focus_gained()?,
248            Event::FocusLost => ac.focus_lost()?,
249            Event::Resize(width, height) => ac.resize(width, height)?,
250            Event::Paste(content) => ac.process_paste_event(content)?,
251            Event::Key(key) => ac.process_key_event(keybindings, key).await?,
252            Event::Mouse(mouse) => ac.process_mouse_event(mouse)?,
253        })
254    }
255
256    /// Processes an [Action] returned by a component.
257    ///
258    /// Returns an optional [ProcessOutput] if the action signals the application should exit.
259    #[instrument(skip_all)]
260    async fn process_action(&mut self, action: Action) -> Result<Option<ProcessOutput>> {
261        match action {
262            Action::NoOp => (),
263            Action::Quit(output) => return Ok(Some(output)),
264            Action::SwitchComponent(next_component) => {
265                tracing::debug!(
266                    "Switching active component: {} -> {}",
267                    self.active_component.name(),
268                    next_component.name()
269                );
270                self.active_component = next_component;
271                // Initialize and peek into the new component to see if it can provide an immediate result
272                let peek_action = self.active_component.init_and_peek().await?;
273                if let Some(output) = Box::pin(self.process_action(peek_action)).await? {
274                    tracing::debug!("A result was received from `init_and_peek`, returning it");
275                    return Ok(Some(output));
276                }
277            }
278        }
279        Ok(None)
280    }
281}