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