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