gitkraft_tui/lib.rs
1//! GitKraft TUI — terminal user interface built with Ratatui.
2//!
3//! This crate provides a full-featured Git IDE experience in the terminal,
4//! powered by [`gitkraft_core`] for all Git operations and [`ratatui`] for
5//! rendering.
6//!
7//! # Entry point
8//!
9//! Call [`run`] with an optional repository path to start the TUI application.
10
11pub mod app;
12pub mod events;
13pub mod features;
14pub mod layout;
15pub mod utils;
16pub mod widgets;
17
18use std::io;
19use std::panic;
20use std::path::PathBuf;
21use std::time::Duration;
22
23use anyhow::Result;
24use crossterm::event::{self, Event};
25use crossterm::execute;
26use crossterm::terminal::{
27 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
28};
29use ratatui::backend::CrosstermBackend;
30use ratatui::Terminal;
31
32use crate::app::App;
33
34/// Run the TUI application.
35///
36/// If `repo_path` is `Some`, the repository at that path is opened immediately.
37/// Otherwise the Welcome screen is shown, letting the user choose a repository.
38pub fn run(mut repo_path: Option<PathBuf>) -> Result<()> {
39 // If no repo path was given, try loading the last-opened repo from settings.
40 if repo_path.is_none() {
41 repo_path = gitkraft_core::features::persistence::get_last_tui_repo()
42 .ok()
43 .flatten();
44 }
45
46 // Install a panic hook that restores the terminal before printing the
47 // panic message. Without this the user is left with a broken terminal
48 // after an unexpected panic.
49 let default_hook = panic::take_hook();
50 panic::set_hook(Box::new(move |info| {
51 let _ = restore_terminal();
52 default_hook(info);
53 }));
54
55 enable_raw_mode()?;
56 let mut stdout = io::stdout();
57 execute!(stdout, EnterAlternateScreen)?;
58
59 // Always attempt to enable enhanced keyboard input.
60 //
61 // `PushKeyboardEnhancementFlags` writes a short escape sequence
62 // (`\x1b[>flags u`). Terminals that understand the Kitty keyboard
63 // protocol (Kitty, Alacritty, WezTerm, recent iTerm2, …) will honour it
64 // and start sending Shift+arrow keys with the SHIFT modifier flag, making
65 // Shift+↑/↓ range-selection work natively. Terminals that don't
66 // understand the sequence simply ignore it — no garbage is produced.
67 //
68 // We unconditionally push (ignoring the result) and unconditionally pop on
69 // exit so the terminal is always left in a clean state.
70 let _ = execute!(
71 stdout,
72 crossterm::event::PushKeyboardEnhancementFlags(
73 crossterm::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
74 )
75 );
76
77 let backend = CrosstermBackend::new(stdout);
78 let mut terminal = Terminal::new(backend)?;
79
80 let result = run_app(&mut terminal, repo_path);
81
82 // Always pop the keyboard enhancement flags so the terminal is left clean.
83 let _ = execute!(io::stdout(), crossterm::event::PopKeyboardEnhancementFlags);
84
85 restore_terminal()?;
86 terminal.show_cursor()?;
87 result
88}
89
90/// The inner event loop — draw, poll for input, dispatch, repeat.
91pub fn run_app<B: ratatui::backend::Backend>(
92 terminal: &mut Terminal<B>,
93 repo_path: Option<PathBuf>,
94) -> Result<()>
95where
96 B::Error: Send + Sync + 'static,
97{
98 let mut app = App::new();
99
100 if let Some(path) = repo_path {
101 // CLI argument takes priority — open in the first tab
102 app.open_repo(path);
103 } else {
104 // Try to restore the saved session (multiple tabs)
105 if let Ok(settings) = gitkraft_core::features::persistence::load_tui_settings() {
106 let paths: Vec<PathBuf> = settings
107 .open_tabs
108 .into_iter()
109 .filter(|p| p.exists())
110 .collect();
111 let active = settings.active_tab_index;
112 if !paths.is_empty() {
113 app.tabs.clear();
114 for _ in &paths {
115 app.tabs.push(crate::app::RepoTab::new());
116 }
117 // Open each repo in its corresponding tab
118 for (i, path) in paths.into_iter().enumerate() {
119 app.active_tab_index = i;
120 app.open_repo(path);
121 }
122 // Restore the originally active tab
123 app.active_tab_index = active.min(app.tabs.len().saturating_sub(1));
124 app.screen = crate::app::AppScreen::Main;
125 }
126 }
127 }
128
129 loop {
130 app.tick_count = app.tick_count.wrapping_add(1);
131
132 // Drain any results from background tasks (open_repo, refresh,
133 // stage, commit, etc.) before drawing so the UI reflects the
134 // latest state.
135 app.poll_background();
136 app.maybe_auto_refresh();
137
138 terminal.draw(|frame| layout::render(&mut app, frame))?;
139
140 // Use a shorter poll interval (33 ms ≈ 30 fps) so background-task
141 // results are picked up promptly and the loading indicator updates
142 // without a noticeable lag.
143 if event::poll(Duration::from_millis(33))? {
144 if let Event::Key(key) = event::read()? {
145 // Ignore key-release events on platforms that send them
146 if key.kind == crossterm::event::KeyEventKind::Press {
147 events::handle_key(&mut app, key);
148 }
149 }
150 }
151
152 // ── Terminal-editor handoff ───────────────────────────────────────
153 // If a key handler set `pending_editor_open`, suspend the TUI now,
154 // run the terminal editor synchronously (it inherits the real TTY),
155 // then restore the TUI. This works for Helix, Neovim, Vim, etc.
156 if let Some(paths) = app.pending_editor_open.take() {
157 // 1. Suspend: leave the alternate screen and restore the terminal
158 // to its normal (cooked, echo) state so the editor can use it.
159 disable_raw_mode()?;
160 execute!(io::stdout(), LeaveAlternateScreen)?;
161 terminal.show_cursor()?;
162
163 // 2. Try each binary candidate in order.
164 let candidates = app.editor.binary_candidates();
165 tracing::debug!(
166 "[gitkraft] opening {:?} with {} (candidates: {})",
167 paths,
168 app.editor,
169 candidates.join(", ")
170 );
171
172 let mut opened = false;
173 let mut error_msg: Option<String> = None;
174
175 for bin in &candidates {
176 let parts: Vec<&str> = bin.split_whitespace().collect();
177 if let Some((cmd, args)) = parts.split_first() {
178 tracing::debug!("[gitkraft] trying binary: {cmd}");
179 match std::process::Command::new(cmd)
180 .args(args.iter())
181 .args(paths.iter()) // ← pass ALL paths
182 .stdin(std::process::Stdio::inherit())
183 .stdout(std::process::Stdio::inherit())
184 .stderr(std::process::Stdio::inherit())
185 .status()
186 {
187 Ok(status) => {
188 tracing::debug!("[gitkraft] editor exited with {status}");
189 opened = true;
190 break;
191 }
192 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
193 tracing::debug!("[gitkraft] binary '{cmd}' not found, trying next");
194 continue;
195 }
196 Err(e) => {
197 let msg = format!("Editor '{cmd}' failed to launch: {e}");
198 tracing::warn!("[gitkraft] {msg}");
199 error_msg = Some(msg);
200 break;
201 }
202 }
203 }
204 }
205
206 if !opened {
207 let msg = error_msg.unwrap_or_else(|| {
208 format!(
209 "Could not find {} in PATH — tried: {} \
210 (check that the binary is installed and in your $PATH)",
211 app.editor,
212 candidates.join(", ")
213 )
214 });
215 tracing::warn!("[gitkraft] {msg}");
216 app.tab_mut().error_message = Some(msg);
217 } else {
218 let count = paths.len();
219 app.tab_mut().status_message = Some(format!(
220 "Returned from {} — {} file(s) edited",
221 app.editor, count
222 ));
223 }
224
225 // 3. Resume: re-enter the alternate screen and raw mode.
226 enable_raw_mode()?;
227 execute!(io::stdout(), EnterAlternateScreen)?;
228 terminal.clear()?;
229 }
230 // ─────────────────────────────────────────────────────────────────
231
232 if app.should_quit {
233 break;
234 }
235 }
236
237 Ok(())
238}
239
240/// Restore the terminal to its original state (disable raw mode, leave the
241/// alternate screen).
242fn restore_terminal() -> Result<()> {
243 disable_raw_mode()?;
244 execute!(io::stdout(), LeaveAlternateScreen)?;
245 Ok(())
246}