Skip to content

Instantly share code, notes, and snippets.

@jbachhardie
Created March 9, 2026 17:52
Show Gist options
  • Select an option

  • Save jbachhardie/0d1fd7137e34c106880e0061ff316da2 to your computer and use it in GitHub Desktop.

Select an option

Save jbachhardie/0d1fd7137e34c106880e0061ff316da2 to your computer and use it in GitHub Desktop.
gitopiary - Full-screen TUI worktree manager in Rust
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::state::types::AddRepoDialog;
pub fn render_add_repo_dialog(frame: &mut Frame, area: Rect, dialog: &AddRepoDialog) {
let dialog_width = 70u16.min(area.width.saturating_sub(4));
let dialog_height = 8u16;
let x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect { x, y, width: dialog_width, height: dialog_height };
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Add Repository ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let [label_area, input_area, hint2_area, error_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner)[..] else {
return;
};
frame.render_widget(
Paragraph::new("Repository path (absolute or ~/...):"),
label_area,
);
let input_text = if dialog.is_adding {
format!("Adding {}...", dialog.path_input)
} else {
let mut s = dialog.path_input.clone();
if dialog.cursor_pos <= s.len() {
s.insert(dialog.cursor_pos, '│');
}
s
};
let input_style = if dialog.is_adding {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Green)),
Span::styled(input_text, input_style),
])),
input_area,
);
frame.render_widget(
Paragraph::new(Span::styled(
"The repo will be added to ~/.config/gitopiary/config.toml",
Style::default().fg(Color::DarkGray),
)),
hint2_area,
);
if let Some(err) = &dialog.error {
frame.render_widget(
Paragraph::new(Span::styled(err.as_str(), Style::default().fg(Color::Red))),
error_area,
);
}
frame.render_widget(
Paragraph::new(Span::styled(
"Enter: add Esc: cancel",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right),
hint_area,
);
}
use std::io::{self, Stdout};
use anyhow::Result;
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture, EventStream},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures::StreamExt;
use ratatui::{backend::CrosstermBackend, Terminal};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::config::{Config, RepoConfig, save_config};
use crate::events::{handler::handle_event, AppEvent};
use crate::pty::manager::PtyManager;
use crate::state::{refresh::run_refresh, types::AppState};
use crate::ui::draw;
pub struct App {
pub state: AppState,
pub pty_manager: PtyManager,
pub terminal_size: (u16, u16),
pub config: Config,
/// Exact inner size of the terminal panel from the last rendered frame.
/// Use this (when non-zero) for new PTY sessions instead of the approximation
/// from compute_terminal_pty_size, so TUI programs see the right size immediately.
pub last_synced_inner: (u16, u16),
}
impl App {
pub fn new(state: AppState, config: Config) -> Self {
let shell = config.shell.clone();
Self {
state,
pty_manager: PtyManager::new(shell),
terminal_size: (80, 24),
config,
last_synced_inner: (0, 0),
}
}
fn clamp_selection(&mut self) {
if self.state.repos.is_empty() {
self.state.selected_repo_idx = 0;
self.state.selected_worktree_idx = 0;
return;
}
self.state.selected_repo_idx =
self.state.selected_repo_idx.min(self.state.repos.len() - 1);
let wt_count = self.state.repos[self.state.selected_repo_idx]
.worktrees
.len();
if wt_count > 0 {
self.state.selected_worktree_idx =
self.state.selected_worktree_idx.min(wt_count - 1);
} else {
self.state.selected_worktree_idx = 0;
}
}
pub fn trigger_refresh(&mut self, tx: UnboundedSender<AppEvent>) {
if self.state.is_refreshing {
return;
}
self.state.is_refreshing = true;
let config = self.config.clone();
tokio::spawn(async move {
crate::state::refresh::refresh_once(&config, &tx).await;
});
}
pub async fn run(mut self) -> Result<()> {
let (tx, rx) = mpsc::unbounded_channel::<AppEvent>();
// Initial terminal size
let size = crossterm::terminal::size()?;
self.terminal_size = size;
// Setup terminal
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;
}
}
}
});
// Spawn 1-second tick for idle indicators in the worktree panel.
let tx_tick = tx.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
loop {
interval.tick().await;
if tx_tick.send(AppEvent::Tick).is_err() {
break;
}
}
});
// Spawn background refresh
let tx_refresh = tx.clone();
let config = self.config.clone();
tokio::spawn(run_refresh(config, tx_refresh));
// Initial refresh
self.trigger_refresh(tx.clone());
let result = self.event_loop(&mut terminal, rx, tx).await;
restore_terminal(&mut terminal)?;
result
}
async fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
mut rx: UnboundedReceiver<AppEvent>,
tx: UnboundedSender<AppEvent>,
) -> 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 {
let needs_redraw = self.process_event(event, &tx);
if self.state.should_quit {
break;
}
if needs_redraw {
terminal.draw(|f| { inner.set(draw(f, self)); })?;
self.sync_pty_sizes(inner.get());
}
}
Ok(())
}
/// Resize all PTY sessions to match the actual rendered inner area,
/// but only when the size has actually changed so we don't spam SIGWINCH.
fn sync_pty_sizes(&mut self, inner: (u16, u16)) {
let (cols, rows) = inner;
if cols == 0 || rows == 0 || inner == self.last_synced_inner {
return;
}
self.last_synced_inner = inner;
self.pty_manager.resize_all(rows, cols);
}
fn process_event(&mut self, event: AppEvent, tx: &UnboundedSender<AppEvent>) -> bool {
match event {
AppEvent::Crossterm(e) => {
handle_event(self, e, tx);
true
}
AppEvent::PtyOutput { worktree_path } => {
// Only redraw when the output is from the session currently
// shown in the terminal panel. Background sessions updating
// silently does not require a frame.
self.state
.selected_worktree_path()
.map_or(false, |active| *active == worktree_path)
}
AppEvent::RepoLoaded(mut repo) => {
// Preserve expansion state from any existing entry for this path.
if let Some(existing) = self
.state
.repos
.iter()
.find(|r| r.config.path == repo.config.path)
{
repo.is_expanded = existing.is_expanded;
}
match self
.state
.repos
.iter()
.position(|r| r.config.path == repo.config.path)
{
Some(idx) => self.state.repos[idx] = repo,
None => self.state.repos.push(repo),
}
self.clamp_selection();
true
}
AppEvent::PrsFetched { repo_path, prs } => {
use crate::state::types::{PullRequest, PrState};
if let Some(repo) = self
.state
.repos
.iter_mut()
.find(|r| r.config.path == repo_path)
{
for wt in &mut repo.worktrees {
wt.pr = prs.iter().find(|p| p.head_ref == wt.branch).map(|p| {
PullRequest {
number: p.number,
title: p.title.clone(),
state: match p.state.as_str() {
"MERGED" => PrState::Merged,
"CLOSED" => PrState::Closed,
_ => PrState::Open,
},
is_draft: p.is_draft,
url: p.url.clone(),
}
});
}
}
true
}
AppEvent::RefreshDone => {
self.state.is_refreshing = false;
// Persist fresh data so the next startup is instant.
let repos = self.state.repos.clone();
tokio::task::spawn_blocking(move || crate::cache::save(&repos));
true
}
AppEvent::RefreshError(e) => {
tracing::error!("Refresh error: {}", e);
// Don't clear is_refreshing here — RefreshDone will arrive for the
// overall cycle; this is a per-repo warning.
true
}
AppEvent::WorktreeCreated {
repo_path,
worktree_path,
} => {
tracing::info!(
"Worktree created at {:?} for repo {:?}",
worktree_path,
repo_path
);
self.state.new_worktree_dialog = None;
self.trigger_refresh(tx.clone());
true
}
AppEvent::WorktreeCreateError(e) => {
if let Some(dialog) = self.state.new_worktree_dialog.as_mut() {
dialog.is_creating = false;
dialog.error = Some(e);
}
true
}
AppEvent::WorktreeDeleted { repo_path: _, worktree_path } => {
// Kill any PTY session for the deleted worktree.
self.pty_manager.remove(&worktree_path);
self.state.delete_worktree_dialog = None;
self.trigger_refresh(tx.clone());
true
}
AppEvent::WorktreeDeleteError(e) => {
if let Some(dialog) = self.state.delete_worktree_dialog.as_mut() {
dialog.is_deleting = false;
dialog.error = Some(e);
}
true
}
AppEvent::RepoAdded(path) => {
self.state.add_repo_dialog = None;
self.config.repos.push(RepoConfig { path, name: None });
if let Err(e) = save_config(&self.config) {
tracing::error!("Failed to save config: {}", e);
}
// Add the repo to state immediately so it appears before the refresh completes
let new_repo = crate::state::types::Repository::new(
self.config.repos.last().unwrap().clone(),
);
self.state.repos.push(new_repo);
self.trigger_refresh(tx.clone());
true
}
AppEvent::RepoAddError(msg) => {
if let Some(dialog) = self.state.add_repo_dialog.as_mut() {
dialog.is_adding = false;
dialog.error = Some(msg);
}
true
}
AppEvent::Tick => {
// Redraw to update idle indicators, but only when sessions exist.
self.pty_manager.has_any_sessions()
}
AppEvent::Quit => {
self.state.should_quit = true;
false
}
}
}
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
Ok(())
}
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::RepoConfig;
use crate::state::types::{PrState, PullRequest, Repository, Worktree, WorktreeStatus};
// ---------------------------------------------------------------------------
// Serialisable mirror types
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Cache {
pub repos: Vec<CachedRepo>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedRepo {
pub path: PathBuf,
pub worktrees: Vec<CachedWorktree>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedWorktree {
pub name: String,
pub path: PathBuf,
pub branch: String,
pub is_main: bool,
pub uncommitted_changes: u32,
pub ahead: u32,
pub behind: u32,
pub is_dirty: bool,
pub pr: Option<CachedPr>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CachedPr {
pub number: u64,
pub title: String,
/// "open" | "closed" | "merged"
pub state: String,
pub is_draft: bool,
pub url: String,
}
// ---------------------------------------------------------------------------
// Persistence
// ---------------------------------------------------------------------------
pub fn cache_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("gitopiary")
.join("worktrees.json")
}
/// Load the cache from disk. Returns an empty cache on any error so startup
/// is never blocked.
pub fn load() -> Cache {
let path = cache_path();
let Ok(bytes) = std::fs::read(&path) else { return Cache::default() };
serde_json::from_slice(&bytes).unwrap_or_default()
}
/// Persist fresh repo data to disk. Errors are logged and ignored — the
/// cache is best-effort and must never break the app.
pub fn save(repos: &[Repository]) {
let cache = Cache {
repos: repos.iter().map(repo_to_cached).collect(),
};
let Ok(json) = serde_json::to_vec_pretty(&cache) else {
tracing::warn!("Failed to serialise cache");
return;
};
let path = cache_path();
if let Some(dir) = path.parent() {
if let Err(e) = std::fs::create_dir_all(dir) {
tracing::warn!("Failed to create cache dir: {}", e);
return;
}
}
if let Err(e) = std::fs::write(&path, json) {
tracing::warn!("Failed to write cache: {}", e);
}
}
// ---------------------------------------------------------------------------
// Conversion: Cache → state types
// ---------------------------------------------------------------------------
/// Build a `Repository` pre-populated with cached worktrees for `config`.
/// If there is no cached entry for this path, returns an empty repository.
pub fn hydrate_repo(config: RepoConfig, cache: &Cache) -> Repository {
let mut repo = Repository::new(config.clone());
if let Some(cached) = cache.repos.iter().find(|r| r.path == config.path) {
repo.worktrees = cached.worktrees.iter().map(cached_to_worktree).collect();
}
repo
}
fn cached_to_worktree(w: &CachedWorktree) -> Worktree {
Worktree {
name: w.name.clone(),
path: w.path.clone(),
branch: w.branch.clone(),
is_main: w.is_main,
status: WorktreeStatus {
uncommitted_changes: w.uncommitted_changes,
ahead: w.ahead,
behind: w.behind,
is_dirty: w.is_dirty,
},
pr: w.pr.as_ref().map(cached_to_pr),
}
}
fn cached_to_pr(p: &CachedPr) -> PullRequest {
PullRequest {
number: p.number,
title: p.title.clone(),
state: match p.state.as_str() {
"merged" => PrState::Merged,
"closed" => PrState::Closed,
_ => PrState::Open,
},
is_draft: p.is_draft,
url: p.url.clone(),
}
}
// ---------------------------------------------------------------------------
// Conversion: state types → Cache
// ---------------------------------------------------------------------------
fn repo_to_cached(repo: &Repository) -> CachedRepo {
CachedRepo {
path: repo.config.path.clone(),
worktrees: repo.worktrees.iter().map(worktree_to_cached).collect(),
}
}
fn worktree_to_cached(w: &Worktree) -> CachedWorktree {
CachedWorktree {
name: w.name.clone(),
path: w.path.clone(),
branch: w.branch.clone(),
is_main: w.is_main,
uncommitted_changes: w.status.uncommitted_changes,
ahead: w.status.ahead,
behind: w.status.behind,
is_dirty: w.status.is_dirty,
pr: w.pr.as_ref().map(pr_to_cached),
}
}
fn pr_to_cached(p: &PullRequest) -> CachedPr {
CachedPr {
number: p.number,
title: p.title.clone(),
state: match p.state {
PrState::Open => "open",
PrState::Closed => "closed",
PrState::Merged => "merged",
}
.to_string(),
is_draft: p.is_draft,
url: p.url.clone(),
}
}
[package]
name = "gitopiary"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "gitopiary"
path = "src/main.rs"
[dependencies]
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
tui-term = "0.3"
portable-pty = "0.8"
vt100 = "0.15"
git2 = "0.19"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
serde_json = "1"
dirs = "5"
anyhow = "1"
thiserror = "1"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
futures = "0.3"
arboard = "3"
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use anyhow::{Context, Result};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default = "default_refresh_interval")]
pub refresh_interval_secs: u64,
#[serde(default = "default_shell")]
pub shell: String,
#[serde(default)]
pub repos: Vec<RepoConfig>,
}
fn default_refresh_interval() -> u64 {
30
}
fn default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
impl Default for Config {
fn default() -> Self {
Self {
refresh_interval_secs: default_refresh_interval(),
shell: default_shell(),
repos: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RepoConfig {
pub path: PathBuf,
pub name: Option<String>,
}
pub fn load_config() -> Result<Config> {
let config_path = config_path();
if !config_path.exists() {
tracing::info!("No config file found at {:?}, using defaults", config_path);
return Ok(Config::default());
}
let content = std::fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {:?}", config_path))?;
let config: Config = toml::from_str(&content)
.with_context(|| format!("Failed to parse config from {:?}", config_path))?;
Ok(config)
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("gitopiary")
.join("config.toml")
}
pub fn save_config(config: &Config) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory {:?}", parent))?;
}
let content = toml::to_string_pretty(config)
.with_context(|| "Failed to serialize config")?;
std::fs::write(&path, content)
.with_context(|| format!("Failed to write config to {:?}", path))?;
Ok(())
}
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::state::types::DeleteWorktreeDialog;
pub fn render_delete_worktree_dialog(
frame: &mut Frame,
area: Rect,
dialog: &DeleteWorktreeDialog,
) {
let dialog_width = 60u16.min(area.width.saturating_sub(4));
let dialog_height = 6u16;
let x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect { x, y, width: dialog_width, height: dialog_height };
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" Delete Worktree ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let [msg_area, _blank, error_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner)[..] else {
return;
};
if dialog.is_deleting {
let msg = Paragraph::new(Span::styled(
format!("Deleting {}...", dialog.branch_name),
Style::default().fg(Color::Yellow),
));
frame.render_widget(msg, msg_area);
} else {
let msg = Paragraph::new(Line::from(vec![
Span::raw("Delete worktree "),
Span::styled(&dialog.branch_name, Style::default().fg(Color::White)),
Span::raw("?"),
]));
frame.render_widget(msg, msg_area);
}
if let Some(err) = &dialog.error {
let error = Paragraph::new(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
));
frame.render_widget(error, error_area);
}
let hint = Paragraph::new(Span::styled(
"y/Enter: confirm any other key: cancel",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
frame.render_widget(hint, hint_area);
}
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Git error: {0}")]
Git(#[from] git2::Error),
#[error("Config error: {0}")]
Config(String),
#[error("PTY error: {0}")]
Pty(String),
#[error("Crossterm error: {0}")]
Crossterm(String),
}
pub type Result<T> = std::result::Result<T, AppError>;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use tokio::sync::mpsc::UnboundedSender;
use portable_pty::PtySize;
use crate::app::App;
use crate::events::AppEvent;
use crate::state::types::{AddRepoDialog, DeleteWorktreeDialog, PanelFocus, NewWorktreeDialog, TextSelection};
pub fn handle_event(app: &mut App, event: Event, tx: &UnboundedSender<AppEvent>) {
match event {
Event::Key(key) => handle_key(app, key, tx),
Event::Resize(cols, rows) => handle_resize(app, cols, rows),
Event::Mouse(mouse) => handle_mouse(app, mouse, tx),
_ => {}
}
}
fn handle_mouse(app: &mut App, event: MouseEvent, tx: &UnboundedSender<AppEvent>) {
let (total_cols, total_rows) = app.terminal_size;
// Mirror the layout from ui/mod.rs: left = 40%, right = 60%, status bar = 1 row.
let left_width = (total_cols as u32 * 40 / 100) as u16;
let in_left_panel = event.column < left_width;
let in_status_bar = event.row >= total_rows.saturating_sub(1);
// Terminal panel inner area: right panel minus borders.
// Right panel starts at `left_width`, borders consume 1 on each side.
let term_inner_left = left_width + 1;
let term_inner_top: u16 = 1;
let term_inner_right = total_cols.saturating_sub(1);
let term_inner_bottom = total_rows.saturating_sub(2); // -1 status, -1 bottom border
let in_terminal_content = event.column >= term_inner_left
&& event.column < term_inner_right
&& event.row >= term_inner_top
&& event.row < term_inner_bottom;
match event.kind {
MouseEventKind::Down(MouseButton::Left) if !in_status_bar => {
if in_left_panel {
app.state.terminal_selection = None;
app.state.focus = PanelFocus::WorktreeList;
} else if in_terminal_content {
// Start a text selection in terminal content area.
let row = event.row - term_inner_top;
let col = event.column - term_inner_left;
app.state.terminal_selection = Some(TextSelection {
start: (row, col),
end: (row, col),
dragging: true,
});
// Also focus the terminal panel and create session if needed.
if let Some(path) = app.state.selected_worktree_path().cloned() {
let size = exact_or_approx_pty_size(app);
if let Err(e) = app.pty_manager.get_or_create(&path, size, tx.clone()) {
tracing::error!("Failed to create PTY session on click: {}", e);
} else {
app.state.focus = PanelFocus::Terminal;
}
} else {
app.state.focus = PanelFocus::Terminal;
}
} else {
app.state.terminal_selection = None;
app.state.focus = PanelFocus::Terminal;
}
}
MouseEventKind::Down(_) if !in_status_bar => {
app.state.terminal_selection = None;
if in_left_panel {
app.state.focus = PanelFocus::WorktreeList;
} else {
app.state.focus = PanelFocus::Terminal;
}
}
MouseEventKind::Drag(MouseButton::Left) => {
if let Some(ref mut sel) = app.state.terminal_selection {
if sel.dragging {
let row = event.row.saturating_sub(term_inner_top);
let col = event.column.saturating_sub(term_inner_left);
let max_row = term_inner_bottom.saturating_sub(term_inner_top).saturating_sub(1);
let max_col = term_inner_right.saturating_sub(term_inner_left).saturating_sub(1);
sel.end = (row.min(max_row), col.min(max_col));
}
}
}
MouseEventKind::Up(MouseButton::Left) => {
if let Some(ref mut sel) = app.state.terminal_selection {
sel.dragging = false;
// If start == end it was just a click, clear selection.
if sel.start == sel.end {
app.state.terminal_selection = None;
} else {
// Extract selected text from vt100 screen and copy to clipboard.
copy_selection_to_clipboard(app);
}
}
}
// Scroll events: check if the program has enabled mouse reporting.
// If yes, forward using the encoding the program requested (SGR or X10).
// If no, send arrow keys so scrolling works in less/man/git-log etc.
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
if app.state.focus == PanelFocus::Terminal =>
{
app.state.terminal_selection = None;
let is_up = matches!(event.kind, MouseEventKind::ScrollUp);
// Mouse position relative to the terminal content area (1-based for protocols).
let col_1 = event.column.saturating_sub(term_inner_left) + 1;
let row_1 = event.row.saturating_sub(term_inner_top) + 1;
if let Some(path) = app.state.selected_worktree_path().cloned() {
let (mouse_mode, mouse_enc) = app.pty_manager.get(&path)
.map(|s| {
let parser = s.parser.lock().unwrap();
(parser.screen().mouse_protocol_mode(),
parser.screen().mouse_protocol_encoding())
})
.unwrap_or((vt100::MouseProtocolMode::None, vt100::MouseProtocolEncoding::Default));
if let Some(session) = app.pty_manager.get_mut(&path) {
if mouse_mode != vt100::MouseProtocolMode::None {
let button = if is_up { 64u8 } else { 65u8 };
let bytes = match mouse_enc {
vt100::MouseProtocolEncoding::Sgr => {
// SGR format: \x1b[<button;col;rowM
format!("\x1b[<{};{};{}M", button, col_1, row_1)
.into_bytes()
}
_ => {
// X10/Default format: \x1b[M + (32+button) + (32+col) + (32+row)
vec![0x1b, b'[', b'M',
32 + button,
32 + (col_1 as u8).min(223),
32 + (row_1 as u8).min(223)]
}
};
session.write_input(&bytes).ok();
} else {
// Send 3 arrow key presses per scroll tick for usable speed.
let arrow: &[u8] = if is_up { b"\x1b[A" } else { b"\x1b[B" };
for _ in 0..3 {
session.write_input(arrow).ok();
}
}
}
}
}
_ => {}
}
}
fn copy_selection_to_clipboard(app: &App) {
let sel = match &app.state.terminal_selection {
Some(s) => s,
None => return,
};
let path = match app.state.selected_worktree_path() {
Some(p) => p,
None => return,
};
let session = match app.pty_manager.get(path) {
Some(s) => s,
None => return,
};
let parser = session.parser.lock().unwrap();
let screen = parser.screen();
let ((sr, sc), (er, ec)) = sel.ordered();
let mut text = String::new();
for row in sr..=er {
let col_start = if row == sr { sc } else { 0 };
let col_end = if row == er { ec } else { screen.size().1.saturating_sub(1) };
let mut line = String::new();
for col in col_start..=col_end {
let cell = screen.cell(row, col);
match cell {
Some(c) => line.push_str(&c.contents()),
None => line.push(' '),
}
}
// Trim trailing whitespace on each line.
let trimmed = line.trim_end();
text.push_str(trimmed);
if row < er {
text.push('\n');
}
}
if text.is_empty() {
return;
}
// Copy to system clipboard.
match arboard::Clipboard::new() {
Ok(mut clipboard) => {
if let Err(e) = clipboard.set_text(&text) {
tracing::warn!("Failed to copy to clipboard: {}", e);
}
}
Err(e) => {
tracing::warn!("Failed to access clipboard: {}", e);
}
}
}
fn handle_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
if app.state.delete_worktree_dialog.is_some() {
handle_delete_dialog_key(app, key, tx);
return;
}
if app.state.add_repo_dialog.is_some() {
handle_add_repo_dialog_key(app, key, tx);
return;
}
if app.state.new_worktree_dialog.is_some() {
handle_dialog_key(app, key, tx);
return;
}
match app.state.focus {
PanelFocus::WorktreeList => handle_list_key(app, key, tx),
PanelFocus::Terminal => handle_terminal_key(app, key, tx),
}
}
fn handle_add_repo_dialog_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
let dialog = match app.state.add_repo_dialog.as_mut() {
Some(d) => d,
None => return,
};
if dialog.is_adding {
return;
}
match key.code {
KeyCode::Esc => {
app.state.add_repo_dialog = None;
}
KeyCode::Enter => {
let path = dialog.expanded_path();
if dialog.path_input.is_empty() {
dialog.error = Some("Path cannot be empty".to_string());
return;
}
// Check this path isn't already tracked
let already_tracked = app.state.repos.iter().any(|r| r.config.path == path);
if already_tracked {
dialog.error = Some("This repository is already tracked".to_string());
return;
}
dialog.is_adding = true;
dialog.error = None;
let tx = tx.clone();
tokio::spawn(async move {
validate_and_add_repo(path, tx).await;
});
}
KeyCode::Backspace => {
let dialog = app.state.add_repo_dialog.as_mut().unwrap();
if dialog.cursor_pos > 0 {
dialog.cursor_pos -= 1;
dialog.path_input.remove(dialog.cursor_pos);
}
}
KeyCode::Delete => {
let dialog = app.state.add_repo_dialog.as_mut().unwrap();
if dialog.cursor_pos < dialog.path_input.len() {
dialog.path_input.remove(dialog.cursor_pos);
}
}
KeyCode::Left => {
let dialog = app.state.add_repo_dialog.as_mut().unwrap();
if dialog.cursor_pos > 0 {
dialog.cursor_pos -= 1;
}
}
KeyCode::Right => {
let dialog = app.state.add_repo_dialog.as_mut().unwrap();
if dialog.cursor_pos < dialog.path_input.len() {
dialog.cursor_pos += 1;
}
}
KeyCode::Home => {
app.state.add_repo_dialog.as_mut().unwrap().cursor_pos = 0;
}
KeyCode::End => {
let len = app.state.add_repo_dialog.as_ref().unwrap().path_input.len();
app.state.add_repo_dialog.as_mut().unwrap().cursor_pos = len;
}
KeyCode::Char(c) => {
let dialog = app.state.add_repo_dialog.as_mut().unwrap();
dialog.path_input.insert(dialog.cursor_pos, c);
dialog.cursor_pos += 1;
}
_ => {}
}
}
async fn validate_and_add_repo(path: std::path::PathBuf, tx: UnboundedSender<AppEvent>) {
// Validate on a blocking thread since git2 is sync
let result = tokio::task::spawn_blocking(move || -> Result<std::path::PathBuf, String> {
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}
git2::Repository::open(&path)
.map_err(|e| format!("Not a git repository: {}", e))?;
Ok(path)
})
.await;
match result {
Ok(Ok(path)) => { tx.send(AppEvent::RepoAdded(path)).ok(); }
Ok(Err(msg)) => { tx.send(AppEvent::RepoAddError(msg)).ok(); }
Err(e) => { tx.send(AppEvent::RepoAddError(e.to_string())).ok(); }
}
}
fn handle_delete_dialog_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
let dialog = match app.state.delete_worktree_dialog.as_ref() {
Some(d) => d,
None => return,
};
if dialog.is_deleting {
return;
}
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
let repo_path = dialog.repo_path.clone();
let worktree_path = dialog.worktree_path.clone();
if let Some(d) = app.state.delete_worktree_dialog.as_mut() {
d.is_deleting = true;
d.error = None;
}
// Kill the PTY session before deleting so files aren't locked.
app.pty_manager.remove(&worktree_path);
let tx = tx.clone();
tokio::spawn(async move {
let result = crate::git::worktree::remove_worktree(&repo_path, &worktree_path).await;
match result {
Ok(()) => {
tx.send(AppEvent::WorktreeDeleted { repo_path, worktree_path }).ok();
}
Err(e) => {
tx.send(AppEvent::WorktreeDeleteError(e.to_string())).ok();
}
}
});
}
_ => {
// Any other key (including Esc, n, N) cancels.
app.state.delete_worktree_dialog = None;
}
}
}
fn handle_dialog_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
let dialog = match app.state.new_worktree_dialog.as_mut() {
Some(d) => d,
None => return,
};
if dialog.is_creating {
return;
}
match key.code {
KeyCode::Esc => {
app.state.new_worktree_dialog = None;
}
KeyCode::Enter => {
let branch_name = dialog.branch_name.clone();
let repo_idx = dialog.repo_idx;
if branch_name.is_empty() {
if let Some(d) = app.state.new_worktree_dialog.as_mut() {
d.error = Some("Branch name cannot be empty".to_string());
}
return;
}
let repo_path = match app.state.repos.get(repo_idx) {
Some(r) => r.config.path.clone(),
None => {
app.state.new_worktree_dialog = None;
return;
}
};
if let Some(d) = app.state.new_worktree_dialog.as_mut() {
d.is_creating = true;
d.error = None;
}
let tx = tx.clone();
tokio::spawn(async move {
crate::state::refresh::create_worktree(repo_path, branch_name, tx).await;
});
}
KeyCode::Backspace => {
let dialog = app.state.new_worktree_dialog.as_mut().unwrap();
if dialog.cursor_pos > 0 {
dialog.cursor_pos -= 1;
dialog.branch_name.remove(dialog.cursor_pos);
}
}
KeyCode::Delete => {
let dialog = app.state.new_worktree_dialog.as_mut().unwrap();
if dialog.cursor_pos < dialog.branch_name.len() {
dialog.branch_name.remove(dialog.cursor_pos);
}
}
KeyCode::Left => {
let dialog = app.state.new_worktree_dialog.as_mut().unwrap();
if dialog.cursor_pos > 0 {
dialog.cursor_pos -= 1;
}
}
KeyCode::Right => {
let dialog = app.state.new_worktree_dialog.as_mut().unwrap();
if dialog.cursor_pos < dialog.branch_name.len() {
dialog.cursor_pos += 1;
}
}
KeyCode::Char(c) => {
let dialog = app.state.new_worktree_dialog.as_mut().unwrap();
dialog.branch_name.insert(dialog.cursor_pos, c);
dialog.cursor_pos += 1;
}
_ => {}
}
}
fn handle_list_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
match (key.code, key.modifiers) {
(KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
app.state.should_quit = true;
}
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
app.state.move_selection_down();
}
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
app.state.move_selection_up();
}
(KeyCode::Tab, _) => {
app.state.move_selection_down();
}
(KeyCode::Enter, _) => {
if let Some(path) = app.state.selected_worktree_path().cloned() {
// Prefer the exact inner size from the last rendered frame so
// TUI programs get the right terminal dimensions immediately via
// TIOCGWINSZ, before SIGWINCH would arrive from sync_pty_sizes.
let size = exact_or_approx_pty_size(app);
if let Err(e) = app.pty_manager.get_or_create(&path, size, tx.clone()) {
tracing::error!("Failed to create PTY session: {}", e);
} else {
app.state.focus = PanelFocus::Terminal;
}
}
}
(KeyCode::Char('n'), _) => {
let repo_idx = app.state.selected_repo_idx;
if !app.state.repos.is_empty() {
app.state.new_worktree_dialog = Some(NewWorktreeDialog::new(repo_idx));
}
}
(KeyCode::Char('a'), KeyModifiers::SHIFT) | (KeyCode::Char('A'), _) => {
app.state.add_repo_dialog = Some(AddRepoDialog::new());
}
(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();
}
}
(KeyCode::Char('r'), _) => {
app.trigger_refresh(tx.clone());
}
(KeyCode::Char('d'), _) => {
// Open delete confirmation for the selected worktree.
if let Some(repo) = app.state.repos.get(app.state.selected_repo_idx) {
if let Some(wt) = repo.worktrees.get(app.state.selected_worktree_idx) {
if wt.is_main {
// Don't allow deleting the main worktree.
return;
}
app.state.delete_worktree_dialog = Some(DeleteWorktreeDialog {
repo_idx: app.state.selected_repo_idx,
worktree_idx: app.state.selected_worktree_idx,
repo_path: repo.config.path.clone(),
worktree_path: wt.path.clone(),
branch_name: wt.branch.clone(),
is_deleting: false,
error: None,
});
}
}
}
(KeyCode::Char(' '), _) => {
// Toggle repo expansion
if let Some(repo) = app.state.repos.get_mut(app.state.selected_repo_idx) {
repo.is_expanded = !repo.is_expanded;
}
}
_ => {}
}
}
fn handle_terminal_key(app: &mut App, key: KeyEvent, tx: &UnboundedSender<AppEvent>) {
// Any key press clears the text selection.
app.state.terminal_selection = None;
tracing::debug!("terminal key: code={:?} modifiers={:?}", key.code, key.modifiers);
// Ctrl+\ (0x1c) unfocuses back to the worktree list.
// Crossterm may report this in multiple ways depending on the terminal:
// (a) KeyCode::Char('\\') with KeyModifiers::CONTROL
// (b) KeyCode::Char('\x1c') with no modifiers (raw byte passthrough)
// (c) KeyCode::Char('\x1c') with KeyModifiers::CONTROL
let is_unfocus = match key.code {
KeyCode::Char('\\') if key.modifiers.contains(KeyModifiers::CONTROL) => true,
KeyCode::Char('\x1c') => true,
_ => false,
};
if is_unfocus {
app.state.focus = PanelFocus::WorktreeList;
return;
}
// All other keys are forwarded to the PTY.
// Check application cursor mode so arrow keys are encoded correctly.
let app_cursor = app
.state
.selected_worktree_path()
.and_then(|p| app.pty_manager.get(p))
.map(|s| s.parser.lock().unwrap().screen().application_cursor())
.unwrap_or(false);
let bytes = crossterm_key_to_pty_bytes(key, app_cursor);
if let Some(path) = app.state.selected_worktree_path().cloned() {
if let Some(session) = app.pty_manager.get_mut(&path) {
if let Err(e) = session.write_input(&bytes) {
tracing::error!("Failed to write to PTY: {}", e);
}
}
}
let _ = tx;
}
fn handle_resize(app: &mut App, cols: u16, rows: u16) {
app.terminal_size = (cols, rows);
let size = compute_terminal_pty_size(&(cols, rows));
// Resize every session, not just the active one — otherwise switching
// worktrees after a resize leaves the new session with wrong dimensions.
app.pty_manager.resize_all(size.rows, size.cols);
}
/// Returns the exact inner size from the last rendered frame if known,
/// otherwise falls back to an approximation from the raw terminal size.
fn exact_or_approx_pty_size(app: &crate::app::App) -> PtySize {
let (cols, rows) = app.last_synced_inner;
if cols > 0 && rows > 0 {
return PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
}
compute_terminal_pty_size(&app.terminal_size)
}
fn compute_terminal_pty_size(terminal_size: &(u16, u16)) -> PtySize {
let (cols, rows) = *terminal_size;
// Right panel ≈ 60% of total width; Borders::ALL consumes 2 columns (left+right).
// Status bar (1 row) + top border (1) + bottom border (1) = 3 rows.
// sync_pty_sizes replaces this with the exact value after the first frame.
let inner_cols = (cols as u32 * 60 / 100).saturating_sub(2) as u16;
let inner_rows = rows.saturating_sub(3);
PtySize {
rows: inner_rows.max(10),
cols: inner_cols.max(20),
pixel_width: 0,
pixel_height: 0,
}
}
pub fn crossterm_key_to_pty_bytes(key: KeyEvent, app_cursor: bool) -> Vec<u8> {
match key.code {
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Standard formula for any character: Ctrl+X = X & 0x1F.
// The previous (c - 'a' + 1) formula only worked for a–z;
// for other characters (e.g. '\') it produced garbage bytes.
let ctrl_byte = (c as u8) & 0x1f;
vec![ctrl_byte]
} else if key.modifiers.contains(KeyModifiers::ALT) {
vec![0x1b, c as u8]
} else {
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
s.as_bytes().to_vec()
}
}
KeyCode::Enter => vec![b'\r'],
KeyCode::Backspace => vec![0x7f],
KeyCode::Delete => vec![0x1b, b'[', b'3', b'~'],
KeyCode::Tab => vec![b'\t'],
KeyCode::BackTab => vec![0x1b, b'[', b'Z'],
KeyCode::Esc => vec![0x1b],
// Application cursor mode uses SS3 (ESC O) prefix; normal mode uses CSI (ESC [).
KeyCode::Up => if app_cursor { vec![0x1b, b'O', b'A'] } else { vec![0x1b, b'[', b'A'] },
KeyCode::Down => if app_cursor { vec![0x1b, b'O', b'B'] } else { vec![0x1b, b'[', b'B'] },
KeyCode::Right => if app_cursor { vec![0x1b, b'O', b'C'] } else { vec![0x1b, b'[', b'C'] },
KeyCode::Left => if app_cursor { vec![0x1b, b'O', b'D'] } else { vec![0x1b, b'[', b'D'] },
KeyCode::Home => vec![0x1b, b'[', b'H'],
KeyCode::End => vec![0x1b, b'[', b'F'],
KeyCode::PageUp => vec![0x1b, b'[', b'5', b'~'],
KeyCode::PageDown => vec![0x1b, b'[', b'6', b'~'],
KeyCode::Insert => vec![0x1b, b'[', b'2', b'~'],
KeyCode::F(1) => vec![0x1b, b'O', b'P'],
KeyCode::F(2) => vec![0x1b, b'O', b'Q'],
KeyCode::F(3) => vec![0x1b, b'O', b'R'],
KeyCode::F(4) => vec![0x1b, b'O', b'S'],
KeyCode::F(5) => vec![0x1b, b'[', b'1', b'5', b'~'],
KeyCode::F(6) => vec![0x1b, b'[', b'1', b'7', b'~'],
KeyCode::F(7) => vec![0x1b, b'[', b'1', b'8', b'~'],
KeyCode::F(8) => vec![0x1b, b'[', b'1', b'9', b'~'],
KeyCode::F(9) => vec![0x1b, b'[', b'2', b'0', b'~'],
KeyCode::F(10) => vec![0x1b, b'[', b'2', b'1', b'~'],
KeyCode::F(11) => vec![0x1b, b'[', b'2', b'3', b'~'],
KeyCode::F(12) => vec![0x1b, b'[', b'2', b'4', b'~'],
_ => vec![],
}
}
mod app;
mod cache;
mod config;
mod error;
mod events;
mod git;
mod github;
mod pty;
mod state;
mod ui;
use std::panic;
use anyhow::Result;
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
use crossterm::execute;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{fmt, EnvFilter};
use crate::app::App;
use crate::config::load_config;
use crate::state::types::AppState;
#[tokio::main]
async fn main() -> Result<()> {
setup_logging();
setup_panic_handler();
tracing::info!("Starting gitopiary");
let config = load_config().unwrap_or_else(|e| {
tracing::warn!("Failed to load config: {}, using defaults", e);
crate::config::Config::default()
});
tracing::info!("Loaded config with {} repos", config.repos.len());
// Seed initial state from the on-disk cache so worktrees are visible
// immediately, before the background git refresh completes.
let cache = cache::load();
let initial_repos = config
.repos
.iter()
.map(|rc| cache::hydrate_repo(rc.clone(), &cache))
.collect();
tracing::info!(
"Loaded {} repos from cache",
cache.repos.len(),
);
let state = AppState::new(initial_repos);
let app = App::new(state, config);
app.run().await?;
Ok(())
}
fn setup_logging() {
let log_dir = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("gitopiary");
std::fs::create_dir_all(&log_dir).ok();
let file_appender = RollingFileAppender::new(Rotation::DAILY, log_dir, "gitopiary.log");
let subscriber = fmt::Subscriber::builder()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(file_appender)
.with_ansi(false)
.finish();
tracing::subscriber::set_global_default(subscriber).ok();
}
fn setup_panic_handler() {
let default_hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
// Always restore terminal on panic
let _ = disable_raw_mode();
let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
default_hook(info);
}));
}
use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result;
use portable_pty::PtySize;
use tokio::sync::mpsc::UnboundedSender;
use crate::events::AppEvent;
use crate::pty::session::PtySession;
pub struct PtyManager {
sessions: HashMap<PathBuf, PtySession>,
shell: String,
}
impl PtyManager {
pub fn new(shell: String) -> Self {
Self {
sessions: HashMap::new(),
shell,
}
}
pub fn get_or_create(
&mut self,
worktree_path: &PathBuf,
size: PtySize,
tx: UnboundedSender<AppEvent>,
) -> Result<&mut PtySession> {
if !self.sessions.contains_key(worktree_path) {
let session = PtySession::new(
worktree_path.clone(),
&self.shell,
size,
tx,
)?;
self.sessions.insert(worktree_path.clone(), session);
}
Ok(self.sessions.get_mut(worktree_path).unwrap())
}
pub fn get(&self, worktree_path: &PathBuf) -> Option<&PtySession> {
self.sessions.get(worktree_path)
}
pub fn get_mut(&mut self, worktree_path: &PathBuf) -> Option<&mut PtySession> {
self.sessions.get_mut(worktree_path)
}
pub fn resize_all(&mut self, rows: u16, cols: u16) {
for session in self.sessions.values_mut() {
if let Err(e) = session.resize(rows, cols) {
tracing::warn!("Failed to resize PTY session: {}", e);
}
}
}
pub fn resize_session(&mut self, worktree_path: &PathBuf, rows: u16, cols: u16) {
if let Some(session) = self.sessions.get_mut(worktree_path) {
if let Err(e) = session.resize(rows, cols) {
tracing::warn!("Failed to resize PTY session at {:?}: {}", worktree_path, e);
}
}
}
pub fn remove(&mut self, worktree_path: &PathBuf) {
self.sessions.remove(worktree_path);
}
pub fn has_any_sessions(&self) -> bool {
!self.sessions.is_empty()
}
}
pub mod handler;
use std::path::PathBuf;
use crate::github::pr::PrInfo;
use crate::state::types::Repository;
#[derive(Debug)]
pub enum AppEvent {
Crossterm(crossterm::event::Event),
PtyOutput { worktree_path: PathBuf },
/// Git status for a single repo is ready — show it immediately.
RepoLoaded(Repository),
/// PR data for a repo arrived — patch badges onto already-visible worktrees.
PrsFetched { repo_path: PathBuf, prs: Vec<PrInfo> },
/// All repos in a refresh cycle have finished (git + PRs).
RefreshDone,
RefreshError(String),
WorktreeCreated { repo_path: PathBuf, worktree_path: PathBuf },
WorktreeCreateError(String),
WorktreeDeleted { repo_path: PathBuf, worktree_path: PathBuf },
WorktreeDeleteError(String),
RepoAdded(PathBuf),
RepoAddError(String),
/// Periodic 1-second heartbeat used to update idle indicators.
Tick,
Quit,
}
use ratatui::{
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use crate::state::types::NewWorktreeDialog;
pub fn render_new_worktree_dialog(
frame: &mut Frame,
area: Rect,
dialog: &NewWorktreeDialog,
) {
let dialog_width = 60u16.min(area.width.saturating_sub(4));
let dialog_height = 7u16;
let x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
let y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
let dialog_area = Rect {
x,
y,
width: dialog_width,
height: dialog_height,
};
// Clear background
frame.render_widget(Clear, dialog_area);
let block = Block::default()
.title(" New Worktree ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(dialog_area);
frame.render_widget(block, dialog_area);
let [label_area, input_area, error_area, hint_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner)[..] else {
return;
};
// Label
let label = Paragraph::new("Branch name:");
frame.render_widget(label, label_area);
// Input field
let input_text = if dialog.is_creating {
format!("Creating {}...", dialog.branch_name)
} else {
let mut s = dialog.branch_name.clone();
// Insert cursor indicator
if dialog.cursor_pos <= s.len() {
s.insert(dialog.cursor_pos, '│');
}
s
};
let input_style = if dialog.is_creating {
Style::default().fg(Color::DarkGray)
} else {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
};
let input = Paragraph::new(Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Cyan)),
Span::styled(input_text, input_style),
]));
frame.render_widget(input, input_area);
// Error
if let Some(err) = &dialog.error {
let error = Paragraph::new(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
));
frame.render_widget(error, error_area);
}
// Hint
let hint = Paragraph::new(Span::styled(
"Enter: create Esc: cancel",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right);
frame.render_widget(hint, hint_area);
}
use std::path::PathBuf;
use serde::Deserialize;
use tokio::process::Command;
use anyhow::Result;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GhPullRequest {
pub number: u64,
pub title: String,
pub state: String,
pub is_draft: bool,
pub head_ref_name: String,
pub url: String,
}
#[derive(Debug)]
pub struct PrInfo {
pub number: u64,
pub title: String,
pub state: String,
pub is_draft: bool,
pub head_ref: String,
pub url: String,
}
pub async fn fetch_prs(repo_path: &PathBuf) -> Result<Vec<PrInfo>> {
let output = Command::new("gh")
.arg("pr")
.arg("list")
.arg("--json")
.arg("number,title,state,isDraft,headRefName,url")
.arg("--limit")
.arg("50")
.current_dir(repo_path)
.output()
.await;
let output = match output {
Ok(o) => o,
Err(_) => return Ok(vec![]),
};
if !output.status.success() {
return Ok(vec![]);
}
let prs: Vec<GhPullRequest> = match serde_json::from_slice(&output.stdout) {
Ok(p) => p,
Err(_) => return Ok(vec![]),
};
Ok(prs
.into_iter()
.map(|p| PrInfo {
number: p.number,
title: p.title,
state: p.state,
is_draft: p.is_draft,
head_ref: p.head_ref_name,
url: p.url,
})
.collect())
}
use std::path::PathBuf;
use tokio::sync::mpsc::UnboundedSender;
use tokio::task::JoinSet;
use crate::config::{Config, RepoConfig};
use crate::events::AppEvent;
use crate::git::repo::{list_worktree_paths, load_worktree_info};
use crate::github::pr::fetch_prs;
use crate::state::types::Repository;
pub async fn run_refresh(config: Config, tx: UnboundedSender<AppEvent>) {
let mut interval = tokio::time::interval(
std::time::Duration::from_secs(config.refresh_interval_secs),
);
loop {
interval.tick().await;
do_refresh(&config, &tx).await;
}
}
pub async fn refresh_once(config: &Config, tx: &UnboundedSender<AppEvent>) {
do_refresh(config, tx).await;
}
/// Loads all repos in parallel. For each repo:
/// 1. List worktree paths (fast, blocking)
/// 2. Load each worktree's git status in parallel (blocking, one thread per worktree)
/// 3. Send RepoLoaded — UI shows git data immediately
/// 4. Fetch PRs (async, overlaps with other repos still loading)
/// 5. Send PrsFetched — UI patches in PR badges
/// Sends RefreshDone when everything is complete.
async fn do_refresh(config: &Config, tx: &UnboundedSender<AppEvent>) {
let mut set: JoinSet<()> = JoinSet::new();
for repo_config in &config.repos {
let repo_cfg = repo_config.clone();
let tx = tx.clone();
set.spawn(load_repo_streaming(repo_cfg, tx));
}
while set.join_next().await.is_some() {}
tx.send(AppEvent::RefreshDone).ok();
}
async fn load_repo_streaming(config: RepoConfig, tx: UnboundedSender<AppEvent>) {
// Phase 1: enumerate worktree paths — open the repo once, quickly.
let config_for_list = config.clone();
let paths = match tokio::task::spawn_blocking(move || {
list_worktree_paths(&config_for_list)
})
.await
{
Ok(Ok(p)) => p,
Ok(Err(e)) => {
tracing::warn!("Failed to list worktrees for {:?}: {}", config.path, e);
tx.send(AppEvent::RefreshError(e.to_string())).ok();
return;
}
Err(e) => {
tracing::warn!("Join error for {:?}: {}", config.path, e);
return;
}
};
// Phase 2: load each worktree's status on its own blocking thread so they
// all run in parallel (each opens an independent git2::Repository).
let mut wt_set: JoinSet<anyhow::Result<crate::state::types::Worktree>> = JoinSet::new();
for (path, is_main) in paths {
wt_set.spawn_blocking(move || load_worktree_info(path, is_main));
}
let mut worktrees = vec![];
while let Some(result) = wt_set.join_next().await {
match result {
Ok(Ok(wt)) => worktrees.push(wt),
Ok(Err(e)) => tracing::warn!("Failed to load worktree status: {}", e),
Err(e) => tracing::warn!("Worktree thread error: {}", e),
}
}
// Keep main worktree first, then sort the rest alphabetically.
worktrees.sort_by(|a, b| b.is_main.cmp(&a.is_main).then(a.name.cmp(&b.name)));
let mut repo = Repository::new(config.clone());
repo.worktrees = worktrees;
// Phase 3: send git status — the UI renders this immediately.
tx.send(AppEvent::RepoLoaded(repo)).ok();
// Phase 4: fetch PRs. This runs concurrently with other repos still in
// phases 1-3, so it doesn't block the display of other repos.
let prs = fetch_prs(&config.path).await.unwrap_or_default();
tx.send(AppEvent::PrsFetched {
repo_path: config.path,
prs,
})
.ok();
}
pub async fn create_worktree(
repo_path: PathBuf,
branch_name: String,
tx: UnboundedSender<AppEvent>,
) {
let result = crate::git::worktree::create_worktree(&repo_path, &branch_name).await;
match result {
Ok(worktree_path) => {
tx.send(AppEvent::WorktreeCreated { repo_path, worktree_path }).ok();
}
Err(e) => {
tx.send(AppEvent::WorktreeCreateError(e.to_string())).ok();
}
}
}
use std::path::PathBuf;
use git2::{BranchType, Repository as GitRepo, StatusOptions};
use crate::config::RepoConfig;
use crate::state::types::{Repository, Worktree, WorktreeStatus};
use anyhow::{Context, Result};
/// Fast first pass: open the repo just long enough to enumerate worktree paths.
/// Returns (path, is_main) pairs. Does not compute status.
pub fn list_worktree_paths(config: &RepoConfig) -> Result<Vec<(PathBuf, bool)>> {
let git_repo = GitRepo::open(&config.path)
.with_context(|| format!("Failed to open git repo at {:?}", config.path))?;
let mut paths = vec![(config.path.clone(), true)];
let linked = git_repo
.worktrees()
.with_context(|| "Failed to list worktrees")?;
for name in linked.iter() {
let name = match name {
Some(n) => n,
None => continue,
};
let wt_obj = match git_repo.find_worktree(name) {
Ok(w) => w,
Err(_) => continue,
};
paths.push((wt_obj.path().to_path_buf(), false));
}
Ok(paths)
}
/// Load a single worktree's branch name and git status. Each call opens its
/// own independent git2::Repository so calls can run on separate threads.
pub fn load_worktree_info(path: PathBuf, is_main: bool) -> Result<Worktree> {
let git_repo = GitRepo::open(&path)
.with_context(|| format!("Failed to open worktree at {:?}", path))?;
let branch = get_branch_name(&git_repo).unwrap_or_else(|| "HEAD".to_string());
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| branch.clone());
let status = get_worktree_status(&git_repo)?;
Ok(Worktree {
name,
path,
branch,
is_main,
status,
pr: None,
})
}
fn get_branch_name(repo: &GitRepo) -> Option<String> {
let head = repo.head().ok()?;
if head.is_branch() {
head.shorthand().map(|s| s.to_string())
} else {
head.target()
.map(|oid| oid.to_string()[..8].to_string())
}
}
fn get_worktree_status(repo: &GitRepo) -> Result<WorktreeStatus> {
let mut opts = StatusOptions::new();
opts.include_untracked(true);
opts.exclude_submodules(true);
let statuses = repo
.statuses(Some(&mut opts))
.with_context(|| "Failed to get repo statuses")?;
let uncommitted_changes = statuses
.iter()
.filter(|s| {
s.status().intersects(
git2::Status::INDEX_NEW
| git2::Status::INDEX_MODIFIED
| git2::Status::INDEX_DELETED
| git2::Status::INDEX_RENAMED
| git2::Status::INDEX_TYPECHANGE
| git2::Status::WT_NEW
| git2::Status::WT_MODIFIED
| git2::Status::WT_DELETED
| git2::Status::WT_RENAMED
| git2::Status::WT_TYPECHANGE,
)
})
.count() as u32;
let is_dirty = uncommitted_changes > 0;
let (ahead, behind) = get_ahead_behind(repo).unwrap_or((0, 0));
Ok(WorktreeStatus {
uncommitted_changes,
ahead,
behind,
is_dirty,
})
}
fn get_ahead_behind(repo: &GitRepo) -> Option<(u32, u32)> {
let head = repo.head().ok()?;
let branch_name = head.shorthand()?;
let local_branch = repo.find_branch(branch_name, BranchType::Local).ok()?;
let upstream = local_branch.upstream().ok()?;
let local_oid = head.target()?;
let upstream_oid = upstream.get().target()?;
let (ahead, behind) = repo.graph_ahead_behind(local_oid, upstream_oid).ok()?;
Some((ahead as u32, behind as u32))
}
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use tokio::sync::mpsc::UnboundedSender;
use crate::events::AppEvent;
pub struct PtySession {
pub parser: Arc<Mutex<vt100::Parser>>,
pub writer: Box<dyn Write + Send>,
pub master: Box<dyn MasterPty + Send>,
/// Child process handle — kept alive so we can kill the shell on drop.
child: Box<dyn portable_pty::Child + Send + Sync>,
pub stop_flag: Arc<AtomicBool>,
pub reader_thread: Option<JoinHandle<()>>,
pub size: PtySize,
/// Milliseconds since UNIX_EPOCH when the reader last received output.
/// Zero means no output received yet.
pub last_output_ms: Arc<AtomicU64>,
/// Set once the reader has seen any output at all (so we can distinguish
/// "never ran anything" from "ran something and it finished").
pub has_had_output: Arc<AtomicBool>,
}
impl PtySession {
/// Returns true when the shell has had activity and has been quiet for
/// at least 2 seconds — a strong signal it's back at a prompt.
pub fn is_idle(&self) -> bool {
if !self.has_had_output.load(Ordering::Relaxed) {
return false;
}
let last_ms = self.last_output_ms.load(Ordering::Relaxed);
if last_ms == 0 {
return false;
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
now_ms.saturating_sub(last_ms) > 2_000
}
}
impl PtySession {
pub fn new(
worktree_path: PathBuf,
shell: &str,
size: PtySize,
tx: UnboundedSender<AppEvent>,
) -> Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(size.clone())
.map_err(|e| anyhow::anyhow!("Failed to open PTY: {}", e))?;
let mut cmd = CommandBuilder::new(shell);
cmd.cwd(&worktree_path);
cmd.env("TERM", "xterm-256color");
cmd.env("COLORTERM", "truecolor");
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| anyhow::anyhow!("Failed to spawn shell: {}", e))?;
let writer = pair
.master
.take_writer()
.map_err(|e| anyhow::anyhow!("Failed to get PTY writer: {}", e))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| anyhow::anyhow!("Failed to get PTY reader: {}", e))?;
let parser = Arc::new(Mutex::new(vt100::Parser::new(size.rows, size.cols, 0)));
let parser_clone = Arc::clone(&parser);
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_clone = Arc::clone(&stop_flag);
let last_output_ms = Arc::new(AtomicU64::new(0));
let last_output_ms_clone = Arc::clone(&last_output_ms);
let has_had_output = Arc::new(AtomicBool::new(false));
let has_had_output_clone = Arc::clone(&has_had_output);
let reader_thread = std::thread::spawn(move || {
let mut translator = AcsTranslator::new();
let mut buf = [0u8; 4096];
loop {
if stop_flag_clone.load(Ordering::Relaxed) {
break;
}
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let translated = translator.process(&buf[..n]);
parser_clone.lock().unwrap().process(&translated);
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
last_output_ms_clone.store(now_ms, Ordering::Relaxed);
has_had_output_clone.store(true, Ordering::Relaxed);
tx.send(AppEvent::PtyOutput {
worktree_path: worktree_path.clone(),
})
.ok();
}
Err(_) => break,
}
}
});
Ok(Self {
parser,
writer,
master: pair.master,
child,
stop_flag,
reader_thread: Some(reader_thread),
size,
last_output_ms,
has_had_output,
})
}
pub fn write_input(&mut self, data: &[u8]) -> Result<()> {
self.writer.write_all(data).context("Failed to write to PTY")?;
self.writer.flush().context("Failed to flush PTY writer")?;
Ok(())
}
pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> {
let new_size = PtySize { rows, cols, pixel_width: 0, pixel_height: 0 };
self.master
.resize(new_size.clone())
.map_err(|e| anyhow::anyhow!("Failed to resize PTY: {}", e))?;
self.parser.lock().unwrap().set_size(rows, cols);
self.size = new_size;
Ok(())
}
}
impl Drop for PtySession {
fn drop(&mut self) {
// Kill the child process first. This closes the slave end of the PTY,
// which causes reader.read() to return EIO and unblocks the reader thread.
// Without this the reader thread blocks indefinitely on read() and the
// app hangs on quit whenever a terminal session is open.
let _ = self.child.kill();
self.stop_flag.store(true, Ordering::Relaxed);
if let Some(thread) = self.reader_thread.take() {
let _ = thread.join();
}
}
}
// ---------------------------------------------------------------------------
// ACS → Unicode translator
//
// vt100 0.15 parses ESC(0 / ESC(B but explicitly does not translate the DEC
// special graphics characters. We intercept the byte stream here, track the
// graphics-mode state ourselves, and replace each ACS byte with its UTF-8
// box-drawing / symbol equivalent before vt100 ever sees it.
//
// Sequences handled:
// ESC ( 0 — designate G0 as DEC special graphics → graphics on
// ESC ( B — designate G0 as ASCII → graphics off
// ESC ) 0 — designate G1 as DEC special graphics (tracked for SO/SI)
// ESC ) B — designate G1 as ASCII
// SO (0x0E) — shift out: activate G1
// SI (0x0F) — shift in: activate G0
// ---------------------------------------------------------------------------
struct AcsTranslator {
state: AcsState,
g0_is_graphics: bool,
g1_is_graphics: bool,
use_g1: bool,
}
#[derive(PartialEq)]
enum AcsState {
Normal,
Esc,
EscParen,
EscRParen,
}
impl AcsTranslator {
fn new() -> Self {
Self {
state: AcsState::Normal,
g0_is_graphics: false,
g1_is_graphics: false,
use_g1: false,
}
}
fn in_graphics(&self) -> bool {
if self.use_g1 { self.g1_is_graphics } else { self.g0_is_graphics }
}
fn process(&mut self, input: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(input.len() * 3);
for &b in input {
match self.state {
AcsState::Normal => match b {
0x1b => self.state = AcsState::Esc,
0x0e => { self.use_g1 = true; }
0x0f => { self.use_g1 = false; }
_ if self.in_graphics() => {
match acs_to_utf8(b) {
Some(s) => out.extend_from_slice(s.as_bytes()),
None => out.push(b),
}
}
_ => out.push(b),
},
AcsState::Esc => match b {
b'(' => self.state = AcsState::EscParen,
b')' => self.state = AcsState::EscRParen,
_ => {
out.push(0x1b);
out.push(b);
self.state = AcsState::Normal;
}
},
AcsState::EscParen => {
match b {
b'0' => self.g0_is_graphics = true,
b'B' => self.g0_is_graphics = false,
_ => {
out.push(0x1b);
out.push(b'(');
out.push(b);
}
}
self.state = AcsState::Normal;
}
AcsState::EscRParen => {
match b {
b'0' => self.g1_is_graphics = true,
b'B' => self.g1_is_graphics = false,
_ => {
out.push(0x1b);
out.push(b')');
out.push(b);
}
}
self.state = AcsState::Normal;
}
}
}
out
}
}
fn acs_to_utf8(b: u8) -> Option<&'static str> {
match b {
b'`' => Some("◆"),
b'a' => Some("▒"),
b'f' => Some("°"),
b'g' => Some("±"),
b'i' => Some("␋"),
b'j' => Some("┘"),
b'k' => Some("┐"),
b'l' => Some("┌"),
b'm' => Some("└"),
b'n' => Some("┼"),
b'o' => Some("⎺"),
b'p' => Some("⎻"),
b'q' => Some("─"),
b'r' => Some("⎼"),
b's' => Some("⎽"),
b't' => Some("├"),
b'u' => Some("┤"),
b'v' => Some("┴"),
b'w' => Some("┬"),
b'x' => Some("│"),
b'y' => Some("≤"),
b'z' => Some("≥"),
b'{' => Some("π"),
b'|' => Some("≠"),
b'}' => Some("£"),
b'~' => Some("·"),
_ => None,
}
}
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
use tui_term::widget::PseudoTerminal;
use crate::pty::session::PtySession;
use crate::state::types::TextSelection;
use crate::ui::theme;
/// Renders the terminal panel and returns the inner area so the caller can
/// keep PTY sizes exactly in sync with the rendered dimensions.
pub fn render_terminal_panel(
frame: &mut Frame,
area: Rect,
session: Option<&PtySession>,
focused: bool,
selection: Option<&TextSelection>,
) -> Rect {
let title = if focused {
" Terminal [Ctrl+\\: back] "
} else {
" Terminal [Enter: focus] "
};
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(theme::border_style(focused));
let inner = block.inner(area);
match session {
Some(session) => {
let parser = session.parser.lock().unwrap();
let widget = PseudoTerminal::new(parser.screen()).block(block);
frame.render_widget(widget, area);
}
None => {
frame.render_widget(block, area);
let help = Paragraph::new(Line::from(vec![
Span::styled(
"Select a worktree and press ",
Style::default().fg(theme::COLOR_DIM),
),
Span::styled(
"Enter",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" to open a terminal",
Style::default().fg(theme::COLOR_DIM),
),
]));
frame.render_widget(help, inner);
}
}
// Draw selection highlight overlay on top of the rendered terminal.
if let Some(sel) = selection {
render_selection_overlay(frame.buffer_mut(), inner, sel);
}
inner
}
fn render_selection_overlay(buf: &mut Buffer, inner: Rect, sel: &TextSelection) {
let ((sr, sc), (er, ec)) = sel.ordered();
for row in sr..=er {
let col_start = if row == sr { sc } else { 0 };
let col_end = if row == er {
ec
} else {
inner.width.saturating_sub(1)
};
for col in col_start..=col_end {
let x = inner.x + col;
let y = inner.y + row;
if x < inner.x + inner.width && y < inner.y + inner.height {
let cell = &mut buf[(x, y)];
cell.set_style(Style::default().fg(Color::Black).bg(Color::Rgb(100, 150, 220)));
}
}
}
}
use ratatui::style::{Color, Modifier, Style};
pub const COLOR_SELECTED_BG: Color = Color::Rgb(40, 60, 80);
pub const COLOR_DIRTY: Color = Color::Yellow;
pub const COLOR_AHEAD: Color = Color::Blue;
pub const COLOR_BEHIND: Color = Color::Red;
pub const COLOR_PR_DRAFT: Color = Color::DarkGray;
pub const COLOR_PR_OPEN: Color = Color::Green;
pub const COLOR_PR_CLOSED: Color = Color::Red;
pub const COLOR_PR_MERGED: Color = Color::Magenta;
pub const COLOR_REPO_HEADER: Color = Color::Cyan;
pub const COLOR_BRANCH: Color = Color::White;
pub const COLOR_DIM: Color = Color::DarkGray;
pub const COLOR_BORDER_FOCUSED: Color = Color::Cyan;
pub const COLOR_BORDER_UNFOCUSED: Color = Color::DarkGray;
pub const COLOR_STATUS_BAR: Color = Color::Rgb(30, 30, 30);
pub fn selected_style() -> Style {
Style::default().bg(COLOR_SELECTED_BG)
}
pub fn bold() -> Style {
Style::default().add_modifier(Modifier::BOLD)
}
pub fn dim() -> Style {
Style::default().fg(COLOR_DIM)
}
pub fn repo_header_style() -> Style {
Style::default().fg(COLOR_REPO_HEADER).add_modifier(Modifier::BOLD)
}
pub fn border_style(focused: bool) -> Style {
if focused {
Style::default().fg(COLOR_BORDER_FOCUSED)
} else {
Style::default().fg(COLOR_BORDER_UNFOCUSED)
}
}
use std::path::PathBuf;
use crate::config::RepoConfig;
#[derive(Debug, Clone)]
pub struct TextSelection {
/// Start position in terminal content coordinates (row, col).
pub start: (u16, u16),
/// Current end position (row, col).
pub end: (u16, u16),
/// Whether the user is still dragging.
pub dragging: bool,
}
impl TextSelection {
/// Returns (start, end) in normalized order (top-left to bottom-right).
pub fn ordered(&self) -> ((u16, u16), (u16, u16)) {
let (sr, sc) = self.start;
let (er, ec) = self.end;
if sr < er || (sr == er && sc <= ec) {
((sr, sc), (er, ec))
} else {
((er, ec), (sr, sc))
}
}
}
#[derive(Debug, Clone)]
pub struct AppState {
pub repos: Vec<Repository>,
pub selected_repo_idx: usize,
pub selected_worktree_idx: usize,
pub focus: PanelFocus,
pub new_worktree_dialog: Option<NewWorktreeDialog>,
pub add_repo_dialog: Option<AddRepoDialog>,
pub is_refreshing: bool,
pub should_quit: bool,
pub terminal_selection: Option<TextSelection>,
pub delete_worktree_dialog: Option<DeleteWorktreeDialog>,
}
impl AppState {
pub fn new(repos: Vec<Repository>) -> Self {
Self {
repos,
selected_repo_idx: 0,
selected_worktree_idx: 0,
focus: PanelFocus::WorktreeList,
new_worktree_dialog: None,
add_repo_dialog: None,
is_refreshing: false,
should_quit: false,
terminal_selection: None,
delete_worktree_dialog: None,
}
}
pub fn selected_worktree(&self) -> Option<&Worktree> {
self.repos
.get(self.selected_repo_idx)
.and_then(|r| r.worktrees.get(self.selected_worktree_idx))
}
pub fn selected_worktree_path(&self) -> Option<&PathBuf> {
self.selected_worktree().map(|w| &w.path)
}
pub fn flat_list_items(&self) -> Vec<FlatListItem> {
let mut items = vec![];
for (repo_idx, repo) in self.repos.iter().enumerate() {
items.push(FlatListItem::Repo {
idx: repo_idx,
is_selected: repo_idx == self.selected_repo_idx,
});
if repo.is_expanded {
for (wt_idx, _wt) in repo.worktrees.iter().enumerate() {
items.push(FlatListItem::Worktree {
repo_idx,
worktree_idx: wt_idx,
is_selected: repo_idx == self.selected_repo_idx
&& wt_idx == self.selected_worktree_idx,
});
}
}
}
items
}
pub fn selected_flat_idx(&self) -> usize {
let items = self.flat_list_items();
items.iter().position(|item| match item {
FlatListItem::Worktree { repo_idx, worktree_idx, .. } => {
*repo_idx == self.selected_repo_idx
&& *worktree_idx == self.selected_worktree_idx
}
_ => false,
}).unwrap_or(0)
}
pub fn move_selection_down(&mut self) {
let items = self.flat_list_items();
let current = self.selected_flat_idx();
for i in (current + 1)..items.len() {
if let FlatListItem::Worktree { repo_idx, worktree_idx, .. } = &items[i] {
self.selected_repo_idx = *repo_idx;
self.selected_worktree_idx = *worktree_idx;
return;
}
}
// wrap around
for item in &items {
if let FlatListItem::Worktree { repo_idx, worktree_idx, .. } = item {
self.selected_repo_idx = *repo_idx;
self.selected_worktree_idx = *worktree_idx;
return;
}
}
}
pub fn move_selection_up(&mut self) {
let items = self.flat_list_items();
let current = self.selected_flat_idx();
for i in (0..current).rev() {
if let FlatListItem::Worktree { repo_idx, worktree_idx, .. } = &items[i] {
self.selected_repo_idx = *repo_idx;
self.selected_worktree_idx = *worktree_idx;
return;
}
}
// wrap around
for item in items.iter().rev() {
if let FlatListItem::Worktree { repo_idx, worktree_idx, .. } = item {
self.selected_repo_idx = *repo_idx;
self.selected_worktree_idx = *worktree_idx;
return;
}
}
}
}
#[derive(Debug, Clone)]
pub enum FlatListItem {
Repo {
idx: usize,
is_selected: bool,
},
Worktree {
repo_idx: usize,
worktree_idx: usize,
is_selected: bool,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum PanelFocus {
WorktreeList,
Terminal,
}
#[derive(Debug, Clone)]
pub struct Repository {
pub config: RepoConfig,
pub display_name: String,
pub worktrees: Vec<Worktree>,
pub is_expanded: bool,
}
impl Repository {
pub fn new(config: RepoConfig) -> Self {
let display_name = config
.name
.clone()
.unwrap_or_else(|| {
config
.path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| config.path.to_string_lossy().to_string())
});
Self {
config,
display_name,
worktrees: vec![],
is_expanded: true,
}
}
}
#[derive(Debug, Clone)]
pub struct Worktree {
pub name: String,
pub path: PathBuf,
pub branch: String,
pub is_main: bool,
pub status: WorktreeStatus,
pub pr: Option<PullRequest>,
}
#[derive(Debug, Clone, Default)]
pub struct WorktreeStatus {
pub uncommitted_changes: u32,
pub ahead: u32,
pub behind: u32,
pub is_dirty: bool,
}
#[derive(Debug, Clone)]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub state: PrState,
pub is_draft: bool,
pub url: String,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PrState {
Open,
Closed,
Merged,
}
#[derive(Debug, Clone)]
pub struct NewWorktreeDialog {
pub repo_idx: usize,
pub branch_name: String,
pub cursor_pos: usize,
pub error: Option<String>,
pub is_creating: bool,
}
impl NewWorktreeDialog {
pub fn new(repo_idx: usize) -> Self {
Self {
repo_idx,
branch_name: String::new(),
cursor_pos: 0,
error: None,
is_creating: false,
}
}
}
#[derive(Debug, Clone)]
pub struct AddRepoDialog {
pub path_input: String,
pub cursor_pos: usize,
pub error: Option<String>,
pub is_adding: bool,
}
impl AddRepoDialog {
pub fn new() -> Self {
Self {
path_input: String::new(),
cursor_pos: 0,
error: None,
is_adding: false,
}
}
/// Expand a leading `~` to the home directory.
pub fn expanded_path(&self) -> std::path::PathBuf {
if self.path_input.starts_with('~') {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
home.join(self.path_input.trim_start_matches("~/").trim_start_matches('~'))
} else {
std::path::PathBuf::from(&self.path_input)
}
}
}
#[derive(Debug, Clone)]
pub struct DeleteWorktreeDialog {
pub repo_idx: usize,
pub worktree_idx: usize,
pub repo_path: PathBuf,
pub worktree_path: PathBuf,
pub branch_name: String,
pub is_deleting: bool,
pub error: Option<String>,
}
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use tokio::process::Command;
pub async fn create_worktree(repo_path: &PathBuf, branch_name: &str) -> Result<PathBuf> {
// Worktree goes in a sibling directory: <parent>/<branch_name>
let parent = repo_path
.parent()
.with_context(|| "Repo path has no parent")?;
let worktree_path = parent.join(branch_name);
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("worktree")
.arg("add")
.arg("-b")
.arg(branch_name)
.arg(&worktree_path)
.output()
.await
.with_context(|| "Failed to run git worktree add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git worktree add failed: {}", stderr.trim());
}
Ok(worktree_path)
}
pub async fn remove_worktree(repo_path: &PathBuf, worktree_path: &PathBuf) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.arg("worktree")
.arg("remove")
.arg("--force")
.arg(worktree_path)
.output()
.await
.with_context(|| "Failed to run git worktree remove")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git worktree remove failed: {}", stderr.trim());
}
Ok(())
}
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
Frame,
};
use crate::pty::manager::PtyManager;
use crate::state::types::{AppState, FlatListItem, PanelFocus, PrState, Worktree};
use crate::ui::theme;
pub fn render_worktree_panel(
frame: &mut Frame,
area: Rect,
state: &AppState,
pty_manager: &PtyManager,
) {
let focused = state.focus == PanelFocus::WorktreeList;
let block = Block::default()
.title(" Worktrees ")
.borders(Borders::ALL)
.border_style(theme::border_style(focused));
// Inner width after block borders — used to budget the branch name width.
let inner_width = area.width.saturating_sub(2) as usize;
let items = build_list_items(state, pty_manager, inner_width);
let selected_flat = state.selected_flat_idx();
let mut list_state = ListState::default();
list_state.select(Some(selected_flat));
let list = List::new(items)
.block(block)
.highlight_style(theme::selected_style());
frame.render_stateful_widget(list, area, &mut list_state);
}
fn build_list_items(
state: &AppState,
pty_manager: &PtyManager,
inner_width: usize,
) -> Vec<ListItem<'static>> {
let flat_items = state.flat_list_items();
let mut list_items = vec![];
for item in flat_items {
match item {
FlatListItem::Repo { idx, .. } => {
if let Some(repo) = state.repos.get(idx) {
let icon = if repo.is_expanded { "▼" } else { "▶" };
let wt_count = repo.worktrees.len();
let line = Line::from(vec![
Span::styled(
format!("{} {} ", icon, repo.display_name),
theme::repo_header_style(),
),
Span::styled(
format!("({} worktree{})", wt_count, if wt_count == 1 { "" } else { "s" }),
theme::dim(),
),
]);
list_items.push(ListItem::new(line));
}
}
FlatListItem::Worktree { repo_idx, worktree_idx, is_selected } => {
if let Some(repo) = state.repos.get(repo_idx) {
if let Some(wt) = repo.worktrees.get(worktree_idx) {
let idle = pty_manager
.get(&wt.path)
.map_or(false, |s| s.is_idle());
let line = build_worktree_line(wt, is_selected, idle, inner_width);
list_items.push(ListItem::new(line));
}
}
}
}
}
list_items
}
fn build_worktree_line(
wt: &Worktree,
_is_selected: bool,
idle: bool,
inner_width: usize,
) -> Line<'static> {
// Calculate how many display columns the stats portion needs so we can
// give the branch name exactly what's left.
let stats_cols = stats_display_width(wt);
// Layout: 2 cols for indicator ("✓ " / " ") + branch + stats.
let branch_budget = inner_width.saturating_sub(2).saturating_sub(stats_cols);
let branch = fit_branch(&wt.branch, branch_budget);
let mut spans = vec![];
if idle {
spans.push(Span::styled(
"✓ ",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
branch,
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
));
if wt.status.is_dirty {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("● {}", wt.status.uncommitted_changes),
Style::default().fg(theme::COLOR_DIRTY),
));
}
if wt.status.ahead > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("↑{}", wt.status.ahead),
Style::default().fg(theme::COLOR_AHEAD),
));
}
if wt.status.behind > 0 {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("↓{}", wt.status.behind),
Style::default().fg(theme::COLOR_BEHIND),
));
}
if let Some(pr) = &wt.pr {
spans.push(Span::raw(" "));
let (color, label) = match pr.state {
PrState::Open if pr.is_draft => (theme::COLOR_PR_DRAFT, "draft"),
PrState::Open => (theme::COLOR_PR_OPEN, "open"),
PrState::Closed => (theme::COLOR_PR_CLOSED, "closed"),
PrState::Merged => (theme::COLOR_PR_MERGED, "merged"),
};
spans.push(Span::styled(
format!("[#{} {}]", pr.number, label),
Style::default().fg(color),
));
}
Line::from(spans)
}
/// Count the display columns consumed by every element that comes *after* the
/// branch name (dirty marker, ahead/behind, PR badge). All the special
/// characters used here (●, ↑, ↓, etc.) are narrow (1 column) in every
/// standard monospace terminal font.
fn stats_display_width(wt: &Worktree) -> usize {
let mut w = 0;
if wt.status.is_dirty {
// " ● N…" = 2 spaces + ● + space + digits
w += 2 + 1 + 1 + digit_count(wt.status.uncommitted_changes as u64);
}
if wt.status.ahead > 0 {
// " ↑N…" = space + ↑ + digits
w += 1 + 1 + digit_count(wt.status.ahead as u64);
}
if wt.status.behind > 0 {
w += 1 + 1 + digit_count(wt.status.behind as u64);
}
if let Some(pr) = &wt.pr {
let label_len = match pr.state {
PrState::Open if pr.is_draft => "draft".len(),
PrState::Open => "open".len(),
PrState::Closed => "closed".len(),
PrState::Merged => "merged".len(),
};
// " [#N label]" = 2 + 1 + 1 + digits + 1 + label + 1
w += 2 + 1 + 1 + digit_count(pr.number) + 1 + label_len + 1;
}
w
}
fn digit_count(n: u64) -> usize {
if n == 0 { 1 } else { n.ilog10() as usize + 1 }
}
/// Truncate `branch` to fit within `budget` display columns.
/// Appends "…" when truncation occurs.
fn fit_branch(branch: &str, budget: usize) -> String {
if budget == 0 {
return String::new();
}
// Branch names are always ASCII in practice, so char count == byte count.
// Using char_indices keeps us safe for any edge cases.
if branch.chars().count() <= budget {
return branch.to_string();
}
// Reserve 1 column for "…".
let keep = budget.saturating_sub(1);
let cutoff = branch
.char_indices()
.nth(keep)
.map(|(i, _)| i)
.unwrap_or(branch.len());
format!("{}…", &branch[..cutoff])
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment