Skip to main content

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(_) | CliProcess::Config(_) | CliProcess::Logs(_) => 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            CliProcess::Changelog(changelog) => {
136                tracing::info!("Running 'changelog' process");
137                tracing::debug!("Options: {:?}", changelog);
138                self.run_non_interactive(changelog, config, service, extra_line).await
139            }
140        }
141    }
142
143    /// Executes a process in non-interactive mode.
144    ///
145    /// Simply calls the `execute` method on the given [Process] implementation.
146    async fn run_non_interactive(
147        self,
148        process: impl Process,
149        config: Config,
150        service: IntelliShellService,
151        extra_line: bool,
152    ) -> Result<ProcessOutput> {
153        if extra_line {
154            println!();
155        }
156        process.execute(config, service, self.cancellation_token).await
157    }
158
159    /// Executes a process that might require an interactive TUI
160    async fn run_interactive(
161        mut self,
162        it: Interactive<impl InteractiveProcess>,
163        config: Config,
164        service: IntelliShellService,
165        extra_line: bool,
166    ) -> Result<ProcessOutput> {
167        // If the process hasn't enabled the interactive flag, just run it
168        if !it.opts.interactive {
169            return self.run_non_interactive(it.process, config, service, extra_line).await;
170        }
171
172        // Converts the process into the renderable component and initializes it
173        let inline = it.opts.inline || (!it.opts.full_screen && config.inline);
174        let keybindings = config.keybindings.clone();
175        let keyboard_enhancement = config.tui.keyboard_enhancement;
176        self.active_component = it
177            .process
178            .into_component(config, service, inline, self.cancellation_token.clone())?;
179
180        // Initialize and peek into the component, in case we can give a straight result
181        let peek_action = self.active_component.init_and_peek().await?;
182        if let Some(output) = self.process_action(peek_action).await? {
183            tracing::debug!("A result was received from `init_and_peek`, returning it");
184            return Ok(output);
185        }
186
187        // Enter the TUI (inline or fullscreen)
188        let mut tui = Tui::new(self.cancellation_token.clone())?
189            .paste(true)
190            .mouse(true)
191            .keyboard_enhancement(keyboard_enhancement);
192        if inline {
193            tracing::debug!("Displaying inline {} interactively", self.active_component.name());
194            tui.enter_inline(extra_line, self.active_component.min_inline_height())?;
195        } else {
196            tracing::debug!("Displaying full-screen {} interactively", self.active_component.name());
197            tui.enter()?;
198        }
199
200        // Main loop
201        loop {
202            tokio::select! {
203                biased;
204                // If the token is cancelled, close the main loop and return
205                _ = self.cancellation_token.cancelled() => {
206                    tracing::info!("Cancellation token received, exiting TUI loop");
207                    return Ok(ProcessOutput::fail());
208                }
209                // Otherwise, wait for the next event to come in
210                maybe_event = tui.next_event() => {
211                    let Some(tui_event) = maybe_event else {
212                        tracing::error!("TUI closed unexpectedly, no event received");
213                        break;
214                    };
215                    // Handle the event
216                    let action = self.handle_tui_event(tui_event, &mut tui, &keybindings).await?;
217                    // Process the action
218                    if let Some(output) = self.process_action(action).await? {
219                        // If the action generated an output, exit the loop by returning it
220                        return Ok(output);
221                    }
222                }
223            }
224        }
225
226        Ok(ProcessOutput::success())
227    }
228
229    /// Handles a single TUI event by dispatching it to the active component.
230    ///
231    /// Based on the type of [Event], calls the corresponding method on the currently active [Component].
232    ///
233    /// Returns an [Action] indicating the result of the event processing.
234    #[instrument(skip_all)]
235    async fn handle_tui_event(
236        &mut self,
237        event: Event,
238        tui: &mut Tui,
239        keybindings: &KeyBindingsConfig,
240    ) -> Result<Action> {
241        if event != Event::Tick
242            && event != Event::Render
243            && !matches!(event, Event::Mouse(m) if m.kind == MouseEventKind::Moved )
244        {
245            tracing::trace!("{event:?}");
246        }
247        let ac = &mut self.active_component;
248        Ok(match event {
249            // Render the active component using the TUI renderer
250            Event::Render => {
251                tui.render(|frame, area| ac.render(frame, area))?;
252                Action::NoOp
253            }
254            // Dispatch other events to the active component
255            Event::Tick => ac.tick()?,
256            Event::FocusGained => ac.focus_gained()?,
257            Event::FocusLost => ac.focus_lost()?,
258            Event::Resize(width, height) => ac.resize(width, height)?,
259            Event::Paste(content) => ac.process_paste_event(content)?,
260            Event::Key(key) => ac.process_key_event(keybindings, key).await?,
261            Event::Mouse(mouse) => ac.process_mouse_event(mouse)?,
262        })
263    }
264
265    /// Processes an [Action] returned by a component.
266    ///
267    /// Returns an optional [ProcessOutput] if the action signals the application should exit.
268    #[instrument(skip_all)]
269    async fn process_action(&mut self, action: Action) -> Result<Option<ProcessOutput>> {
270        match action {
271            Action::NoOp => (),
272            Action::Quit(output) => return Ok(Some(output)),
273            Action::SwitchComponent(next_component) => {
274                tracing::debug!(
275                    "Switching active component: {} -> {}",
276                    self.active_component.name(),
277                    next_component.name()
278                );
279                self.active_component = next_component;
280                // Initialize and peek into the new component to see if it can provide an immediate result
281                let peek_action = self.active_component.init_and_peek().await?;
282                if let Some(output) = Box::pin(self.process_action(peek_action)).await? {
283                    tracing::debug!("A result was received from `init_and_peek`, returning it");
284                    return Ok(Some(output));
285                }
286            }
287        }
288        Ok(None)
289    }
290}