diff --git a/README.md b/README.md index 6bd8b01..3043120 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A full-screen TUI for managing git worktrees across multiple repositories. The l - Rust toolchain - `gh` CLI (for PR info) -- `zed` (optional, for the editor shortcut) +- `$EDITOR` env var (optional, for the editor shortcut) ## Install diff --git a/src/app.rs b/src/app.rs index 5366ac3..7dfd292 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use crossterm::{ use futures::StreamExt; use ratatui::{backend::CrosstermBackend, Terminal}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tokio::task::JoinHandle; use crate::config::{Config, RepoConfig, save_config}; use crate::events::{handler::handle_event, AppEvent}; use crate::pty::manager::PtyManager; @@ -78,23 +79,7 @@ impl App { let mut terminal = setup_terminal()?; // Spawn crossterm event reader - let tx_crossterm = tx.clone(); - tokio::spawn(async move { - let mut events = EventStream::new(); - while let Some(event) = events.next().await { - match event { - Ok(e) => { - if tx_crossterm.send(AppEvent::Crossterm(e)).is_err() { - break; - } - } - Err(e) => { - tracing::error!("Crossterm event error: {}", e); - break; - } - } - } - }); + let crossterm_handle = spawn_crossterm_reader(tx.clone()); // Spawn 1-second tick for idle indicators in the worktree panel. let tx_tick = tx.clone(); @@ -116,7 +101,7 @@ impl App { // Initial refresh self.trigger_refresh(tx.clone()); - let result = self.event_loop(&mut terminal, rx, tx).await; + let result = self.event_loop(&mut terminal, rx, tx, crossterm_handle).await; restore_terminal(&mut terminal)?; result @@ -127,6 +112,7 @@ impl App { terminal: &mut Terminal>, mut rx: UnboundedReceiver, tx: UnboundedSender, + mut crossterm_handle: JoinHandle<()>, ) -> Result<()> { // Initial draw — capture exact inner size and sync PTYs immediately. let inner = std::cell::Cell::new((0u16, 0u16)); @@ -134,6 +120,23 @@ impl App { self.sync_pty_sizes(inner.get()); while let Some(event) = rx.recv().await { + // OpenEditor needs direct access to the terminal for suspend/restore, + // so handle it here rather than in process_event. + if let AppEvent::OpenEditor(path) = event { + // Stop the crossterm reader so it doesn't compete for stdin + // with TUI editors like vim. + crossterm_handle.abort(); + + self.open_editor(terminal, &path)?; + + // Restart the crossterm reader now that the editor has exited. + crossterm_handle = spawn_crossterm_reader(tx.clone()); + + terminal.draw(|f| { inner.set(draw(f, self)); })?; + self.sync_pty_sizes(inner.get()); + continue; + } + let needs_redraw = self.process_event(event, &tx); if self.state.should_quit { break; @@ -158,6 +161,40 @@ impl App { self.pty_manager.resize_all(rows, cols); } + /// Suspend the TUI, launch `$EDITOR`, then restore the terminal so + /// navigation keys work immediately without requiring a refocus. + fn open_editor( + &self, + terminal: &mut Terminal>, + path: &std::path::Path, + ) -> Result<()> { + let editor = match std::env::var("EDITOR") { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + // Leave TUI state so the editor gets a clean terminal. + restore_terminal(terminal)?; + + // Spawn and wait — works for both TUI editors (vim) and GUI editors + // (zed/code, which return immediately). + let _ = std::process::Command::new(&editor) + .arg(path) + .status(); + + // Re-enter TUI state. + enable_raw_mode()?; + execute!( + terminal.backend_mut(), + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; + terminal.clear()?; + + Ok(()) + } + fn process_event(&mut self, event: AppEvent, tx: &UnboundedSender) -> bool { match event { AppEvent::Crossterm(e) => { @@ -294,6 +331,10 @@ impl App { // Redraw to update idle indicators, but only when sessions exist. self.pty_manager.has_any_sessions() } + AppEvent::OpenEditor(_) => { + // Handled directly in event_loop; should never reach here. + false + } AppEvent::Quit => { self.state.should_quit = true; false @@ -302,6 +343,25 @@ impl App { } } +fn spawn_crossterm_reader(tx: UnboundedSender) -> JoinHandle<()> { + tokio::spawn(async move { + let mut events = EventStream::new(); + while let Some(event) = events.next().await { + match event { + Ok(e) => { + if tx.send(AppEvent::Crossterm(e)).is_err() { + break; + } + } + Err(e) => { + tracing::error!("Crossterm event error: {}", e); + break; + } + } + } + }) +} + fn setup_terminal() -> Result>> { enable_raw_mode()?; let mut stdout = io::stdout(); diff --git a/src/events/handler.rs b/src/events/handler.rs index e9e0589..db94623 100644 --- a/src/events/handler.rs +++ b/src/events/handler.rs @@ -483,10 +483,7 @@ fn handle_list_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender) (KeyCode::Char('e'), _) => { if let Some(wt) = app.state.selected_worktree() { let path = wt.path.clone(); - std::process::Command::new("zed") - .arg(&path) - .spawn() - .ok(); + let _ = tx.send(AppEvent::OpenEditor(path)); } } (KeyCode::Char('r'), _) => { diff --git a/src/events/mod.rs b/src/events/mod.rs index 868066a..5d5e5da 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -21,6 +21,9 @@ pub enum AppEvent { WorktreeDeleteError(String), RepoAdded(PathBuf), RepoAddError(String), + /// Open an external editor for the given worktree path. + /// Handled in the event loop so the terminal can be suspended/restored. + OpenEditor(PathBuf), /// Periodic 1-second heartbeat used to update idle indicators. Tick, Quit, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5e3785f..6e17791 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -75,7 +75,7 @@ fn render_status_bar(frame: &mut Frame, area: Rect, app: &App) { } parts.push(Span::styled( - " j/k: navigate Enter/Space: terminal Ctrl+Space: unfocus e: zed n: new d: delete A: add repo r: refresh q: quit", + " j/k: navigate Enter/Space: terminal Ctrl+Space: unfocus e: editor n: new d: delete A: add repo r: refresh q: quit", Style::default().fg(Color::DarkGray), ));