Created
March 9, 2026 17:52
-
-
Save jbachhardie/0d1fd7137e34c106880e0061ff316da2 to your computer and use it in GitHub Desktop.
gitopiary - Full-screen TUI worktree manager in Rust
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| ); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| [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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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![], | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| })); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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()) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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))); | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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>, | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(()) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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