From ff665df6ae4d785948b58b7f49d67c5180e45de0 Mon Sep 17 00:00:00 2001 From: Eric Goodwin Date: Tue, 31 Mar 2026 21:14:56 -0700 Subject: [PATCH 1/3] feat: use $EDITOR env var instead of hardcoded zed for editor shortcut Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- src/events/handler.rs | 10 ++++++---- src/ui/mod.rs | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) 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/events/handler.rs b/src/events/handler.rs index e9e0589..0112e43 100644 --- a/src/events/handler.rs +++ b/src/events/handler.rs @@ -483,10 +483,12 @@ 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(); + if let Ok(editor) = std::env::var("EDITOR") { + std::process::Command::new(&editor) + .arg(&path) + .spawn() + .ok(); + } } } (KeyCode::Char('r'), _) => { 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), )); From f23b96b6f1c9b3edf9355a2f06f2388ab88000d3 Mon Sep 17 00:00:00 2001 From: Eric Goodwin Date: Tue, 31 Mar 2026 21:34:32 -0700 Subject: [PATCH 2/3] fix: suspend/restore terminal when opening editor Move editor launching from fire-and-forget spawn() in the key handler to the main event loop via a new OpenEditor event. This lets us properly suspend TUI state before launching the editor and restore it afterward, which is necessary for terminal-based editors (vim, neovim) that need a clean terminal to function correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 47 +++++++++++++++++++++++++++++++++++++++++++ src/events/handler.rs | 7 +------ src/events/mod.rs | 3 +++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5366ac3..fc41b20 100644 --- a/src/app.rs +++ b/src/app.rs @@ -134,6 +134,15 @@ 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 { + self.open_editor(terminal, &path)?; + 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 +167,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 +337,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 diff --git a/src/events/handler.rs b/src/events/handler.rs index 0112e43..db94623 100644 --- a/src/events/handler.rs +++ b/src/events/handler.rs @@ -483,12 +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(); - if let Ok(editor) = std::env::var("EDITOR") { - std::process::Command::new(&editor) - .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, From ead491dac22b7a31370bfdac6ac06c8599b7f122 Mon Sep 17 00:00:00 2001 From: Eric Goodwin Date: Tue, 31 Mar 2026 21:46:40 -0700 Subject: [PATCH 3/3] fix: abort and restart crossterm reader around editor launches Extracts the crossterm event reader into a standalone function so it can be aborted before opening a TUI editor (e.g. vim) and restarted after, preventing stdin contention. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app.rs | 49 +++++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index fc41b20..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)); @@ -137,7 +123,15 @@ impl App { // 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; @@ -349,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();