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 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}