gnostr_asyncgit/gitui/state/
mod.rs

1use std::borrow::Cow;
2use std::io::Read;
3use std::io::Write;
4use std::ops::DerefMut;
5use std::process::Child;
6use std::process::Command;
7use std::process::Stdio;
8use std::rc::Rc;
9use std::sync::Arc;
10use std::sync::RwLock;
11use std::time::Duration;
12
13use arboard::Clipboard;
14use crossterm::event;
15use crossterm::event::Event;
16use crossterm::event::KeyCode;
17use crossterm::event::KeyModifiers;
18use git2::Repository;
19use ratatui::layout::Size;
20use tui_prompts::State as _;
21
22use crate::gitui::bindings::Bindings;
23use crate::gitui::cli;
24use crate::gitui::cmd_log::CmdLog;
25use crate::gitui::cmd_log::CmdLogEntry;
26use crate::gitui::config::Config;
27use crate::gitui::file_watcher::FileWatcher;
28use crate::gitui::gitui_error::Error;
29use crate::gitui::items::TargetData;
30use crate::gitui::menu::Menu;
31use crate::gitui::menu::PendingMenu;
32use crate::gitui::ops::Op;
33use crate::gitui::prompt;
34use crate::gitui::prompt::PromptData;
35use crate::gitui::screen;
36use crate::gitui::screen::{Screen, ScreenType};
37use crate::gitui::term::Term;
38use crate::gitui::ui;
39
40use super::Res;
41
42/// The main application state, holding all the necessary data for the UI and Git operations.
43pub struct State {
44    /// The Git repository being managed.
45    pub repo: Rc<Repository>,
46    /// Application configuration settings.
47    pub config: Rc<Config>,
48    /// Key bindings for various actions.
49    pub bindings: Bindings,
50    /// Keys that have been pressed but not yet matched to a binding.
51    pub pending_keys: Vec<(KeyModifiers, KeyCode)>,
52    /// Flag to indicate if the application should quit.
53    pub quit: bool,
54    /// A stack of currently active screens in the UI.
55    pub screens: Vec<Screen>,
56    /// The currently pending menu, if any.
57    pub pending_menu: Option<PendingMenu>,
58    /// A command that is currently running asynchronously.
59    pub pending_cmd: Option<(Child, Arc<RwLock<CmdLogEntry>>)>, 
60    /// Flag to enable or disable asynchronous command execution.
61    pub enable_async_cmds: bool,
62    /// Log of commands executed.
63    pub current_cmd_log: CmdLog,
64    /// The prompt used for user input.
65    pub prompt: prompt::Prompt,
66    /// The system clipboard, if available.
67    pub clipboard: Option<Clipboard>,
68    /// Flag to indicate if the UI needs to be redrawn.
69    pub needs_redraw: bool,
70    /// Watches for file system changes to trigger UI updates.
71    pub file_watcher: Option<FileWatcher>,
72}
73
74impl State {
75    pub fn create(
76        repo: Rc<Repository>,
77        size: Size,
78        args: &cli::Args,
79        config: Rc<Config>,
80        enable_async_cmds: bool,
81    ) -> Res<Self> {
82        let screens = match args.command {
83            Some(cli::Commands::Show { ref reference }) => {
84                vec![screen::show::create(
85                    Rc::clone(&config),
86                    Rc::clone(&repo),
87                    size,
88                    reference.clone(),
89                )?]
90            }
91            None => vec![
92                screen::gnostr::create(Rc::clone(&config), size)?,
93                screen::status::create(
94                    Rc::clone(&config),
95                    Rc::clone(&repo),
96                    size,
97                )?,
98            ],
99        };
100
101        let bindings = Bindings::from(&config.bindings);
102        let pending_menu = root_menu(&config).map(PendingMenu::init);
103
104        let clipboard = Clipboard::new()
105            .inspect_err(|e| log::warn!("Couldn't initialize clipboard: {}", e))
106            .ok();
107
108        let mut state = Self {
109            repo,
110            config,
111            bindings,
112            pending_keys: vec![],
113            enable_async_cmds,
114            quit: false,
115            screens,
116            pending_cmd: None,
117            pending_menu,
118            current_cmd_log: CmdLog::new(),
119            prompt: prompt::Prompt::new(),
120            clipboard,
121            file_watcher: None,
122            needs_redraw: true,
123        };
124
125        state.file_watcher = state.init_file_watcher()?;
126        Ok(state)
127    }
128
129    fn init_file_watcher(&mut self) -> Res<Option<FileWatcher>> {
130        if !self.config.general.refresh_on_file_change.enabled {
131            return Ok(None);
132        }
133
134        let hiding_untracked_files = !self
135            .repo
136            .config()
137            .map_err(Error::ReadGitConfig)?
138            .get_bool("status.showUntrackedFiles")
139            .unwrap_or(true);
140
141        if hiding_untracked_files {
142            self.display_info("File watcher disabled (status.showUntrackedFiles is off)");
143
144            return Ok(None);
145        }
146
147        Ok(
148            FileWatcher::new(self.repo.workdir().expect("Bare repos unhandled"))
149                .inspect_err(|err| {
150                    self.display_error(err.to_string());
151                    self.display_info("File watcher disabled");
152                })
153                .ok(),
154        )
155    }
156
157    pub fn run(&mut self, term: &mut Term, max_tick_delay: Duration) -> Res<()> {
158        while !self.quit {
159            term.backend_mut().poll_event(max_tick_delay)?;
160            self.update(term)?;
161        }
162
163        Ok(())
164    }
165
166    pub fn update(&mut self, term: &mut Term) -> Res<()> {
167        if term.backend_mut().poll_event(Duration::ZERO)? {
168            let event = term.backend_mut().read_event()?;
169            self.handle_event(term, event)?;
170        }
171
172        if let Some(file_watcher) = &mut self.file_watcher {
173            if file_watcher.pending_updates() {
174                self.screen_mut().update()?;
175                self.stage_redraw();
176            }
177        }
178
179        let handle_pending_cmd_result = self.handle_pending_cmd();
180        self.handle_result(handle_pending_cmd_result)?;
181
182        if self.needs_redraw {
183            self.redraw_now(term)?;
184        }
185
186        Ok(())
187    }
188
189    pub fn handle_event(&mut self, term: &mut Term, event: Event) -> Res<()> {
190        log::debug!("{:?}", event);
191
192        match event {
193            Event::Resize(w, h) => {
194                for screen in self.screens.iter_mut() {
195                    screen.size = Size::new(w, h);
196                }
197
198                self.stage_redraw();
199                Ok(())
200            }
201            Event::Key(key) => {
202                if self.pending_cmd.is_none() {
203                    self.current_cmd_log.clear();
204                }
205
206                if self.prompt.state.is_focused() {
207                    self.prompt.state.handle_key_event(key);
208                } else if key.code == KeyCode::Esc && self.screen().screen_type == ScreenType::Show {
209                    self.screens.pop();
210                } else {
211                    self.handle_key_input(term, key)?;
212                }
213
214                self.stage_redraw();
215                Ok(())
216            }
217            _ => Ok(()),
218        }
219    }
220
221    pub fn redraw_now(&mut self, term: &mut Term) -> Res<()> {
222        if self.screens.last_mut().is_some() {
223            term.draw(|frame| ui::ui(frame, self))
224                .map_err(Error::Term)?;
225
226            self.needs_redraw = false;
227        };
228
229        Ok(())
230    }
231
232    pub fn stage_redraw(&mut self) {
233        self.needs_redraw = true;
234    }
235
236    fn handle_key_input(&mut self, term: &mut Term, key: event::KeyEvent) -> Res<()> {
237        let menu = match &self.pending_menu {
238            None => Menu::Root,
239            Some(menu) if menu.menu == Menu::Help => Menu::Root,
240            Some(menu) => menu.menu,
241        };
242
243        self.pending_keys.push((key.modifiers, key.code));
244        let matching_bindings = self
245            .bindings
246            .match_bindings(&menu, &self.pending_keys)
247            .collect::<Vec<_>>();
248
249        match matching_bindings[..] {
250            [binding] => {
251                if binding.keys == self.pending_keys {
252                    self.handle_op(binding.op.clone(), term)?;
253                    self.pending_keys.clear();
254                }
255            }
256            [] => self.pending_keys.clear(),
257            [_, ..] => (),
258        }
259
260        Ok(())
261    }
262
263    pub(crate) fn handle_op(&mut self, op: Op, term: &mut Term) -> Res<()> {
264        let screen = self.screen();
265        let target = if screen.is_empty() {
266            None
267        } else {
268            screen.get_selected_item().target_data.as_ref()
269        };
270
271        if let Some(mut action) = op.clone().implementation().get_action(target) {
272            let result = Rc::get_mut(&mut action).unwrap()(self, term);
273            self.handle_result(result)?;
274        }
275
276        Ok(())
277    }
278
279    fn handle_result<T>(&mut self, result: Res<T>) -> Res<()> {
280        match result {
281            Ok(_) => Ok(()),
282            Err(Error::NoMoreEvents) => Err(Error::NoMoreEvents),
283            Err(Error::PromptAborted) => Ok(()),
284            Err(error) => {
285                self.current_cmd_log
286                    .push(CmdLogEntry::Error(error.to_string()));
287
288                Ok(())
289            }
290        }
291    }
292
293    pub fn close_menu(&mut self) {
294        self.pending_menu = root_menu(&self.config).map(PendingMenu::init)
295    }
296
297    pub fn screen_mut(&mut self) -> &mut Screen {
298        self.screens.last_mut().expect("No screen")
299    }
300
301    pub fn screen(&self) -> &Screen {
302        self.screens.last().expect("No screen")
303    }
304
305    /// Displays an `Info` message to the CmdLog.
306    pub fn display_info<S: Into<Cow<'static, str>>>(&mut self, message: S) {
307        self.current_cmd_log
308            .push(CmdLogEntry::Info(message.into().into_owned()));
309    }
310
311    /// Displays an `Error` message to the CmdLog.
312    pub fn display_error<S: Into<Cow<'static, str>>>(&mut self, message: S) {
313        self.current_cmd_log
314            .push(CmdLogEntry::Error(message.into().into_owned()));
315    }
316
317    /// Runs a `Command` and handles its output.
318    /// Will block awaiting its completion.
319    pub fn run_cmd(&mut self, term: &mut Term, input: &[u8], cmd: Command) -> Res<()> {
320        self.run_cmd_async(term, input, cmd)?;
321        self.await_pending_cmd()?;
322        self.handle_pending_cmd()?;
323        Ok(())
324    }
325
326    /// Runs a `Command` and handles its output asynchronously (if async commands are enabled).
327    /// Will return `Ok(())` if one is already running.
328    pub fn run_cmd_async(&mut self, term: &mut Term, input: &[u8], mut cmd: Command) -> Res<()> {
329        cmd.env("CLICOLOR_FORCE", "1"); // No guarantee, but modern tools seem to implement this
330
331        if self.pending_cmd.is_some() {
332            return Err(Error::CmdAlreadyRunning);
333        }
334
335        cmd.current_dir(self.repo.workdir().expect("No workdir"));
336
337        cmd.stdin(Stdio::piped());
338        cmd.stdout(Stdio::piped());
339        cmd.stderr(Stdio::piped());
340
341        let log_entry = self.current_cmd_log.push_cmd(&cmd);
342        term.draw(|frame| ui::ui(frame, self))
343            .map_err(Error::Term)?;
344
345        let mut child = cmd.spawn().map_err(Error::SpawnCmd)?;
346
347        use std::io::Write;
348        child
349            .stdin
350            .take()
351            .unwrap()
352            .write_all(input)
353            .map_err(Error::Term)?;
354
355        self.pending_cmd = Some((child, log_entry));
356
357        if !self.enable_async_cmds {
358            self.await_pending_cmd()?;
359        }
360
361        Ok(())
362    }
363
364    fn await_pending_cmd(&mut self) -> Res<()> {
365        if let Some((child, _)) = &mut self.pending_cmd {
366            child.wait().map_err(Error::CouldntAwaitCmd)?;
367        }
368        Ok(())
369    }
370
371    /// Handles any pending_cmd in State without blocking. Returns `true` if a cmd was handled.
372    fn handle_pending_cmd(&mut self) -> Res<()> {
373        let Some((ref mut child, ref mut log_rwlock)) = self.pending_cmd else {
374            return Ok(());
375        };
376
377        let Some(status) = child.try_wait().map_err(Error::CouldntAwaitCmd)? else {
378            return Ok(());
379        };
380
381        log::debug!("pending cmd finished with {:?}", status);
382
383        let result = write_child_output_to_log(log_rwlock, child, status);
384        self.pending_cmd = None;
385        self.screen_mut().update()?;
386        self.stage_redraw();
387        result?;
388
389        Ok(())
390    }
391
392    pub fn run_cmd_interactive(&mut self, term: &mut Term, mut cmd: Command) -> Res<()> {
393        cmd.env("CLICOLOR_FORCE", "1"); // No guarantee, but modern tools seem to implement this
394
395        if self.pending_cmd.is_some() {
396            return Err(Error::CmdAlreadyRunning);
397        }
398
399        cmd.current_dir(self.repo.workdir().ok_or(Error::NoRepoWorkdir)?);
400
401        self.current_cmd_log.push_cmd_with_output(&cmd, "\n".into());
402        self.redraw_now(term)?;
403
404        eprint!("\r");
405
406        // Redirect stderr so we can capture it via `Child::wait_with_output()`
407        cmd.stderr(Stdio::piped());
408
409        // git will have staircased output in raw mode (issue #290)
410        // disable raw mode temporarily for the git command
411        term.backend().disable_raw_mode()?;
412
413        // If we don't show the cursor prior spawning (thus restore the default
414        // state), the cursor may be missing in $EDITOR.
415        term.show_cursor().map_err(Error::Term)?;
416
417        let mut child = cmd.spawn().map_err(Error::SpawnCmd)?;
418
419        // Drop stdin as `Child::wait_with_output` would
420        drop(child.stdin.take());
421
422        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
423
424        tee(child.stdout.as_mut(), &mut [&mut stdout]).map_err(Error::Term)?;
425
426        tee(
427            child.stderr.as_mut(),
428            &mut [&mut std::io::stderr(), &mut stderr],
429        )
430        .map_err(Error::Term)?;
431
432        let status = child.wait().map_err(Error::CouldntAwaitCmd)?;
433        let out_utf8 = String::from_utf8(strip_ansi_escapes::strip(stderr.clone()))
434            .expect("Error turning command output to String")
435            .into();
436
437        self.current_cmd_log.clear();
438        self.current_cmd_log.push_cmd_with_output(&cmd, out_utf8);
439
440        // restore the raw mode
441        term.backend().enable_raw_mode()?;
442
443        // Prevents cursor flash when exiting editor
444        term.hide_cursor().map_err(Error::Term)?;
445
446        // In case the command left the alternate screen (editors would)
447        term.backend_mut().enter_alternate_screen()?;
448
449        term.clear().map_err(Error::Term)?;
450        self.screen_mut().update()?;
451
452        if !status.success() {
453            return Err(Error::CmdBadExit(
454                format!(
455                    "{} {}",
456                    cmd.get_program().to_string_lossy(),
457                    cmd.get_args()
458                        .map(|arg| arg.to_string_lossy())
459                        .collect::<String>()
460                ),
461                status.code(),
462            ));
463        }
464
465        Ok(())
466    }
467
468    pub fn hide_menu(&mut self) {
469        if let Some(ref mut menu) = self.pending_menu {
470            menu.is_hidden = true;
471        }
472    }
473
474    pub fn unhide_menu(&mut self) {
475        if let Some(ref mut menu) = self.pending_menu {
476            menu.is_hidden = false;
477        }
478    }
479
480    pub fn selected_rev(&self) -> Option<String> {
481        match &self.screen().get_selected_item().target_data {
482            Some(TargetData::Branch(branch)) => Some(branch.to_owned()),
483            Some(TargetData::Commit(commit)) => Some(commit.to_owned()),
484            _ => None,
485        }
486    }
487
488    pub fn prompt(&mut self, term: &mut Term, params: &PromptParams) -> Res<String> {
489        let prompt_text = if let Some(default) = (params.create_default_value)(self) {
490            format!("{} (default {}):", params.prompt, default).into()
491        } else {
492            format!("{}:", params.prompt).into()
493        };
494
495        if params.hide_menu {
496            self.hide_menu();
497        }
498
499        self.prompt.set(PromptData { prompt_text });
500        self.redraw_now(term)?;
501
502        loop {
503            let event = term.backend_mut().read_event()?;
504            self.handle_event(term, event)?;
505
506            if self.prompt.state.status().is_done() {
507                let value = get_prompt_result(params, self)?;
508
509                self.unhide_menu();
510                self.prompt.reset(term)?;
511
512                return Ok(value);
513            } else if self.prompt.state.status().is_aborted() {
514                self.unhide_menu();
515                self.prompt.reset(term)?;
516
517                return Err(Error::PromptAborted);
518            }
519
520            self.redraw_now(term)?;
521        }
522    }
523
524    pub fn confirm(&mut self, term: &mut Term, prompt: &'static str) -> Res<()> {
525        self.hide_menu();
526        self.prompt.set(PromptData {
527            prompt_text: prompt.into(),
528        });
529        self.redraw_now(term)?;
530
531        loop {
532            let event = term.backend_mut().read_event()?;
533            self.handle_event(term, event)?;
534
535            match self.prompt.state.value() {
536                "y" => {
537                    self.prompt.reset(term)?;
538                    return Ok(());
539                }
540                "" => (),
541                _ => {
542                    self.prompt.reset(term)?;
543                    return Err(Error::PromptAborted);
544                }
545            }
546
547            self.redraw_now(term)?;
548        }
549    }
550}
551
552fn get_prompt_result(params: &PromptParams, state: &mut State) -> Res<String> {
553    let input = state.prompt.state.value();
554    let default_value = (params.create_default_value)(state);
555
556    let value = match (input, &default_value) {
557        ("", None) => "",
558        ("", Some(selected)) => selected,
559        (value, _) => value,
560    };
561
562    Ok(value.to_string())
563}
564
565fn tee(maybe_input: Option<&mut impl Read>, outputs: &mut [&mut dyn Write]) -> std::io::Result<()> {
566    let Some(input) = maybe_input else {
567        return Ok(());
568    };
569
570    let mut buf = [0u8; 1024];
571
572    loop {
573        let num_read = input.read(&mut buf)?;
574        if num_read == 0 {
575            break;
576        }
577
578        let buf = &buf[..num_read];
579        for output in &mut *outputs {
580            output.write_all(buf)?;
581        }
582    }
583
584    Ok(())
585}
586
587pub(crate) fn root_menu(config: &Config) -> Option<Menu> {
588    if config.general.always_show_help.enabled {
589        Some(Menu::Help)
590    } else {
591        None
592    }
593}
594
595fn write_child_output_to_log(
596    log_rwlock: &mut Arc<RwLock<CmdLogEntry>>,
597    child: &mut Child,
598    status: std::process::ExitStatus,
599) -> Res<()> {
600    let mut log = log_rwlock.write().unwrap();
601
602    let CmdLogEntry::Cmd { args, out: out_log } = log.deref_mut() else {
603        unreachable!("pending_cmd is always CmdLogEntry::Cmd variant");
604    };
605
606    drop(child.stdin.take());
607
608    let mut out_bytes = vec![];
609    log::debug!("Reading stderr");
610
611    child
612        .stderr
613        .take()
614        .unwrap()
615        .read_to_end(&mut out_bytes)
616        .map_err(Error::CouldntReadCmdOutput)?;
617
618    child
619        .stdout
620        .take()
621        .unwrap()
622        .read_to_end(&mut out_bytes)
623        .map_err(Error::CouldntReadCmdOutput)?;
624
625    let out_string = String::from_utf8_lossy(&out_bytes).to_string();
626    *out_log = Some(out_string.into());
627
628    if !status.success() {
629        return Err(Error::CmdBadExit(args.to_string(), status.code()));
630    }
631
632    Ok(())
633}
634
635type DefaultFn = Box<dyn Fn(&State) -> Option<String>>;
636
637pub struct PromptParams {
638    pub prompt: &'static str,
639    pub create_default_value: DefaultFn,
640    pub hide_menu: bool,
641}
642
643impl Default for PromptParams {
644    fn default() -> Self {
645        Self {
646            prompt: "",
647            create_default_value: Box::new(|_| None),
648            hide_menu: true,
649        }
650    }
651}