Skip to main content

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    // Watcher thread handle — restarted whenever it exits or the repo changes.
130    let mut git_watcher_thread: Option<std::thread::JoinHandle<()>> = None;
131
132    loop {
133        app.tick_count = app.tick_count.wrapping_add(1);
134
135        // Drain any results from background tasks before drawing.
136        app.poll_background();
137
138        // ── Reactive git watcher ──────────────────────────────────────────
139        // Spawn a background thread to watch the .git directory whenever:
140        //   • no watcher is running yet, or
141        //   • the previous watcher thread exited (e.g., repo was closed).
142        // The thread sends GitStateChanged via bg_tx on every file-system
143        // event (debounced 300 ms) and on a 5-second fallback poll.
144        // It exits automatically when bg_tx.send() fails (TUI exited).
145        if git_watcher_thread
146            .as_ref()
147            .map(|t| t.is_finished())
148            .unwrap_or(true)
149        {
150            if let Some(ref path) = app.tab().repo_path.clone() {
151                let git_dir = path.join(".git");
152                let tx = app.bg_tx.clone();
153                git_watcher_thread = Some(gitkraft_core::spawn_git_watcher(git_dir, move || {
154                    tx.send(crate::app::BackgroundResult::GitStateChanged)
155                        .is_ok()
156                }));
157            }
158        }
159        // ─────────────────────────────────────────────────────────────────
160
161        terminal.draw(|frame| layout::render(&mut app, frame))?;
162
163        // Use a shorter poll interval (33 ms ≈ 30 fps) so background-task
164        // results are picked up promptly and the loading indicator updates
165        // without a noticeable lag.
166        if event::poll(Duration::from_millis(33))? {
167            if let Event::Key(key) = event::read()? {
168                // Ignore key-release events on platforms that send them
169                if key.kind == crossterm::event::KeyEventKind::Press {
170                    events::handle_key(&mut app, key);
171                }
172            }
173        }
174
175        // ── Terminal-editor handoff ───────────────────────────────────────
176        // If a key handler set `pending_editor_open`, suspend the TUI now,
177        // run the terminal editor synchronously (it inherits the real TTY),
178        // then restore the TUI.  This works for Helix, Neovim, Vim, etc.
179        if let Some(paths) = app.pending_editor_open.take() {
180            // 1. Suspend: leave the alternate screen and restore the terminal
181            //    to its normal (cooked, echo) state so the editor can use it.
182            disable_raw_mode()?;
183            execute!(io::stdout(), LeaveAlternateScreen)?;
184            terminal.show_cursor()?;
185
186            // 2. Try each binary candidate in order.
187            let candidates = app.editor.binary_candidates();
188            tracing::debug!(
189                "[gitkraft] opening {:?} with {} (candidates: {})",
190                paths,
191                app.editor,
192                candidates.join(", ")
193            );
194
195            let mut opened = false;
196            let mut error_msg: Option<String> = None;
197
198            for bin in &candidates {
199                let parts: Vec<&str> = bin.split_whitespace().collect();
200                if let Some((cmd, args)) = parts.split_first() {
201                    tracing::debug!("[gitkraft] trying binary: {cmd}");
202                    match std::process::Command::new(cmd)
203                        .args(args.iter())
204                        .args(paths.iter()) // ← pass ALL paths
205                        .stdin(std::process::Stdio::inherit())
206                        .stdout(std::process::Stdio::inherit())
207                        .stderr(std::process::Stdio::inherit())
208                        .status()
209                    {
210                        Ok(status) => {
211                            tracing::debug!("[gitkraft] editor exited with {status}");
212                            opened = true;
213                            break;
214                        }
215                        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
216                            tracing::debug!("[gitkraft] binary '{cmd}' not found, trying next");
217                            continue;
218                        }
219                        Err(e) => {
220                            let msg = format!("Editor '{cmd}' failed to launch: {e}");
221                            tracing::warn!("[gitkraft] {msg}");
222                            error_msg = Some(msg);
223                            break;
224                        }
225                    }
226                }
227            }
228
229            if !opened {
230                let msg = error_msg.unwrap_or_else(|| {
231                    format!(
232                        "Could not find {} in PATH — tried: {} \
233                         (check that the binary is installed and in your $PATH)",
234                        app.editor,
235                        candidates.join(", ")
236                    )
237                });
238                tracing::warn!("[gitkraft] {msg}");
239                app.tab_mut().error_message = Some(msg);
240            } else {
241                let count = paths.len();
242                app.tab_mut().status_message = Some(format!(
243                    "Returned from {} — {} file(s) edited",
244                    app.editor, count
245                ));
246            }
247
248            // 3. Resume: re-enter the alternate screen and raw mode.
249            enable_raw_mode()?;
250            execute!(io::stdout(), EnterAlternateScreen)?;
251            terminal.clear()?;
252        }
253        // ─────────────────────────────────────────────────────────────────
254
255        if app.should_quit {
256            break;
257        }
258    }
259
260    Ok(())
261}
262
263/// Restore the terminal to its original state (disable raw mode, leave the
264/// alternate screen).
265fn restore_terminal() -> Result<()> {
266    disable_raw_mode()?;
267    execute!(io::stdout(), LeaveAlternateScreen)?;
268    Ok(())
269}