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;
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 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 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 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 pub fn run_cmd_async(&mut self, term: &mut Term, input: &[u8], mut cmd: Command) -> Res<()> {
303 cmd.env("CLICOLOR_FORCE", "1"); 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 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"); 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 cmd.stderr(Stdio::piped());
382
383 term.backend().disable_raw_mode()?;
386
387 term.show_cursor().map_err(Error::Term)?;
390
391 let mut child = cmd.spawn().map_err(Error::SpawnCmd)?;
392
393 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 term.backend().enable_raw_mode()?;
416
417 term.hide_cursor().map_err(Error::Term)?;
419
420 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}