Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 78 additions & 18 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -127,13 +112,31 @@ impl App {
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
mut rx: UnboundedReceiver<AppEvent>,
tx: UnboundedSender<AppEvent>,
mut crossterm_handle: JoinHandle<()>,
) -> Result<()> {
// Initial draw — capture exact inner size and sync PTYs immediately.
let inner = std::cell::Cell::new((0u16, 0u16));
terminal.draw(|f| { inner.set(draw(f, self)); })?;
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;
Expand All @@ -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<CrosstermBackend<Stdout>>,
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<AppEvent>) -> bool {
match event {
AppEvent::Crossterm(e) => {
Expand Down Expand Up @@ -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
Expand All @@ -302,6 +343,25 @@ impl App {
}
}

fn spawn_crossterm_reader(tx: UnboundedSender<AppEvent>) -> 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<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
Expand Down
5 changes: 1 addition & 4 deletions src/events/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,10 +483,7 @@ fn handle_list_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>)
(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'), _) => {
Expand Down
3 changes: 3 additions & 0 deletions src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
));

Expand Down