gnostr_asyncgit/gitui/state/
mod.rs1use 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
42pub struct State {
44 pub repo: Rc<Repository>,
46 pub config: Rc<Config>,
48 pub bindings: Bindings,
50 pub pending_keys: Vec<(KeyModifiers, KeyCode)>,
52 pub quit: bool,
54 pub screens: Vec<Screen>,
56 pub pending_menu: Option<PendingMenu>,
58 pub pending_cmd: Option<(Child, Arc<RwLock<CmdLogEntry>>)>,
60 pub enable_async_cmds: bool,
62 pub current_cmd_log: CmdLog,
64 pub prompt: prompt::Prompt,
66 pub clipboard: Option<Clipboard>,
68 pub needs_redraw: bool,
70 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 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 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 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 pub fn run_cmd_async(&mut self, term: &mut Term, input: &[u8], mut cmd: Command) -> Res<()> {
329 cmd.env("CLICOLOR_FORCE", "1"); 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 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"); 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 cmd.stderr(Stdio::piped());
408
409 term.backend().disable_raw_mode()?;
412
413 term.show_cursor().map_err(Error::Term)?;
416
417 let mut child = cmd.spawn().map_err(Error::SpawnCmd)?;
418
419 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 term.backend().enable_raw_mode()?;
442
443 term.hide_cursor().map_err(Error::Term)?;
445
446 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}