use crate::error::TmuxFzfError;
use crate::status::{self, PaneStatus, PaneMonitor};
use crate::tmux::{TmuxClient, TmuxPane, TmuxSession, TmuxWindow};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use std::io::{self, Stdout};
use std::time::{Duration, Instant};
const TICK_RATE: Duration = Duration::from_millis(250);
const REFRESH_RATE: Duration = Duration::from_secs(2);
const PREVIEW_REFRESH: Duration = Duration::from_secs(1);
const MIN_PREVIEW_WIDTH: u16 = 80;
const STATUS_REFRESH: Duration = Duration::from_secs(2);
fn parse_ansi_line(line: &str) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut current_style = Style::default();
let mut current_text = String::new();
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if !current_text.is_empty() {
spans.push(Span::styled(std::mem::take(&mut current_text), current_style));
}
if chars.peek() == Some(&'[') {
chars.next(); let mut seq = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_ascii_alphabetic() {
chars.next();
if ch == 'm' {
current_style = parse_sgr_sequence(&seq, current_style);
}
break;
}
seq.push(chars.next().unwrap());
}
}
} else {
current_text.push(c);
}
}
if !current_text.is_empty() {
spans.push(Span::styled(current_text, current_style));
}
if spans.is_empty() {
Line::from("")
} else {
Line::from(spans)
}
}
fn parse_sgr_sequence(seq: &str, mut style: Style) -> Style {
if seq.is_empty() {
return Style::default();
}
let params: Vec<u8> = seq
.split(';')
.filter_map(|s| s.parse().ok())
.collect();
let mut i = 0;
while i < params.len() {
match params[i] {
0 => style = Style::default(),
1 => style = style.add_modifier(Modifier::BOLD),
2 => style = style.add_modifier(Modifier::DIM),
3 => style = style.add_modifier(Modifier::ITALIC),
4 => style = style.add_modifier(Modifier::UNDERLINED),
7 => style = style.add_modifier(Modifier::REVERSED),
22 => style = style.remove_modifier(Modifier::BOLD | Modifier::DIM),
23 => style = style.remove_modifier(Modifier::ITALIC),
24 => style = style.remove_modifier(Modifier::UNDERLINED),
27 => style = style.remove_modifier(Modifier::REVERSED),
30 => style = style.fg(Color::Black),
31 => style = style.fg(Color::Red),
32 => style = style.fg(Color::Green),
33 => style = style.fg(Color::Yellow),
34 => style = style.fg(Color::Blue),
35 => style = style.fg(Color::Magenta),
36 => style = style.fg(Color::Cyan),
37 => style = style.fg(Color::Gray),
39 => style = style.fg(Color::Reset),
90 => style = style.fg(Color::DarkGray),
91 => style = style.fg(Color::LightRed),
92 => style = style.fg(Color::LightGreen),
93 => style = style.fg(Color::LightYellow),
94 => style = style.fg(Color::LightBlue),
95 => style = style.fg(Color::LightMagenta),
96 => style = style.fg(Color::LightCyan),
97 => style = style.fg(Color::White),
40 => style = style.bg(Color::Black),
41 => style = style.bg(Color::Red),
42 => style = style.bg(Color::Green),
43 => style = style.bg(Color::Yellow),
44 => style = style.bg(Color::Blue),
45 => style = style.bg(Color::Magenta),
46 => style = style.bg(Color::Cyan),
47 => style = style.bg(Color::Gray),
49 => style = style.bg(Color::Reset),
100 => style = style.bg(Color::DarkGray),
101 => style = style.bg(Color::LightRed),
102 => style = style.bg(Color::LightGreen),
103 => style = style.bg(Color::LightYellow),
104 => style = style.bg(Color::LightBlue),
105 => style = style.bg(Color::LightMagenta),
106 => style = style.bg(Color::LightCyan),
107 => style = style.bg(Color::White),
38 => {
if i + 2 < params.len() && params[i + 1] == 5 {
style = style.fg(Color::Indexed(params[i + 2]));
i += 2;
} else if i + 4 < params.len() && params[i + 1] == 2 {
style = style.fg(Color::Rgb(params[i + 2], params[i + 3], params[i + 4]));
i += 4;
}
}
48 => {
if i + 2 < params.len() && params[i + 1] == 5 {
style = style.bg(Color::Indexed(params[i + 2]));
i += 2;
} else if i + 4 < params.len() && params[i + 1] == 2 {
style = style.bg(Color::Rgb(params[i + 2], params[i + 3], params[i + 4]));
i += 4;
}
}
_ => {}
}
i += 1;
}
style
}
#[derive(Debug, Clone, PartialEq)]
enum TreeNode {
Session(usize),
Window { session_idx: usize, window_idx: usize },
Pane { session_idx: usize, window_idx: usize, pane_idx: usize },
}
#[derive(Debug, Clone)]
struct WindowState {
window: TmuxWindow,
expanded: bool,
panes: Option<Vec<TmuxPane>>,
}
#[derive(Debug, Clone)]
struct SessionState {
session: TmuxSession,
expanded: bool,
windows: Option<Vec<WindowState>>,
}
#[derive(Debug, Clone)]
enum AttachTarget {
Session(String),
Window { session: String, window_index: u32 },
Pane { session: String, window_index: u32, pane_index: u32 },
}
#[derive(Debug, Clone)]
enum KillTarget {
Session { name: String },
Window { session: String, window_index: u32, window_name: String },
Pane { session: String, window_index: u32, pane_index: u32, command: String },
}
impl KillTarget {
fn display_name(&self) -> String {
match self {
KillTarget::Session { name } => format!("session '{}'", name),
KillTarget::Window { window_name, .. } => format!("window '{}'", window_name),
KillTarget::Pane { command, .. } => format!("pane '{}'", command),
}
}
}
#[derive(Debug, Clone)]
enum RenameTarget {
Session { name: String },
Window { session: String, window_index: u32, window_name: String },
}
impl RenameTarget {
fn display_name(&self) -> String {
match self {
RenameTarget::Session { name } => format!("session '{}'", name),
RenameTarget::Window { window_name, .. } => format!("window '{}'", window_name),
}
}
}
#[derive(Clone)]
enum Mode {
Normal,
CreateSession,
Rename(RenameTarget),
ConfirmKill(KillTarget),
}
pub struct App {
tmux_client: TmuxClient,
session_states: Vec<SessionState>,
visible_nodes: Vec<TreeNode>,
selected_index: usize,
list_state: ListState,
mode: Mode,
input_buffer: String,
should_quit: bool,
message: Option<(String, Instant)>,
preview_content: Option<String>,
preview_target: Option<String>,
preview_last_update: Option<Instant>,
preview_scroll: u16,
preview_enabled: bool,
status_monitor: PaneMonitor,
status_last_update: Option<Instant>,
status_enabled: bool,
}
impl App {
pub fn new() -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
Self {
tmux_client: TmuxClient::new(),
session_states: Vec::new(),
visible_nodes: Vec::new(),
selected_index: 0,
list_state,
mode: Mode::Normal,
input_buffer: String::new(),
should_quit: false,
message: None,
preview_content: None,
preview_target: None,
preview_last_update: None,
preview_scroll: 0,
preview_enabled: true,
status_monitor: PaneMonitor::new(),
status_last_update: None,
status_enabled: true,
}
}
pub fn run(&mut self) -> Result<(), TmuxFzfError> {
let mut terminal = setup_terminal()?;
self.refresh_sessions();
let result = self.main_loop(&mut terminal);
restore_terminal(&mut terminal)?;
result
}
fn main_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), TmuxFzfError> {
let mut last_refresh = Instant::now();
loop {
self.update_preview();
self.update_statuses();
terminal.draw(|f| self.render(f))?;
if event::poll(TICK_RATE)? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press {
continue;
}
match self.handle_key(key.code, key.modifiers, terminal)? {
KeyAction::Continue => {}
KeyAction::Quit => break,
KeyAction::Attach(target) => {
restore_terminal(terminal)?;
let attach_result = match &target {
AttachTarget::Session(session) => {
self.tmux_client.attach_session(session)
}
AttachTarget::Window { session, window_index } => {
self.tmux_client.attach_window(session, *window_index)
}
AttachTarget::Pane { session, window_index, pane_index } => {
self.tmux_client.attach_pane(session, *window_index, *pane_index)
}
};
*terminal = setup_terminal()?;
self.tmux_client.clear_cache();
self.refresh_sessions();
if let Err(e) = attach_result {
self.set_message(format!("Error: {}", e));
}
}
}
}
}
if last_refresh.elapsed() >= REFRESH_RATE {
self.refresh_sessions();
last_refresh = Instant::now();
}
if self.should_quit {
break;
}
self.clear_expired_message();
}
Ok(())
}
fn refresh_sessions(&mut self) {
match self.tmux_client.list_sessions() {
Ok(sessions) => {
let old_states: std::collections::HashMap<String, SessionState> = self
.session_states
.drain(..)
.map(|s| (s.session.name.to_string(), s))
.collect();
self.session_states = sessions
.into_iter()
.map(|session| {
let (windows, expanded) = if let Some(old_state) = old_states.get(session.name.as_ref()) {
let windows = if old_state.expanded {
self.fetch_windows_state(&session.name, old_state.windows.as_ref())
} else {
None
};
(windows, old_state.expanded)
} else {
(None, false)
};
SessionState {
session,
expanded,
windows,
}
})
.collect();
self.rebuild_visible_nodes();
if self.selected_index >= self.visible_nodes.len() && !self.visible_nodes.is_empty() {
self.selected_index = self.visible_nodes.len() - 1;
}
self.sync_list_state();
}
Err(TmuxFzfError::NoSessions) => {
self.session_states.clear();
self.visible_nodes.clear();
self.selected_index = 0;
self.list_state.select(None);
}
Err(e) => {
self.set_message(format!("Error refreshing: {}", e));
}
}
}
fn fetch_windows_state(
&self,
session_name: &str,
old_windows: Option<&Vec<WindowState>>,
) -> Option<Vec<WindowState>> {
let windows = self.tmux_client.list_windows(session_name).ok()?;
let old_window_map: std::collections::HashMap<u32, &WindowState> = old_windows
.map(|ws| ws.iter().map(|w| (w.window.index, w)).collect())
.unwrap_or_default();
Some(
windows
.into_iter()
.map(|window| {
let old_state = old_window_map.get(&window.index);
let expanded = old_state.map_or(false, |s| s.expanded);
let panes = if expanded {
self.tmux_client
.list_panes(session_name, window.index)
.ok()
} else {
None
};
WindowState {
window,
expanded,
panes,
}
})
.collect(),
)
}
fn rebuild_visible_nodes(&mut self) {
self.visible_nodes.clear();
for session_idx in 0..self.session_states.len() {
self.visible_nodes.push(TreeNode::Session(session_idx));
let session_state = &self.session_states[session_idx];
if session_state.expanded {
if let Some(ref windows) = session_state.windows {
let window_count = windows.len();
for window_idx in 0..window_count {
let window_state = &windows[window_idx];
self.visible_nodes.push(TreeNode::Window { session_idx, window_idx });
if window_state.expanded {
if let Some(ref panes) = window_state.panes {
for pane_idx in 0..panes.len() {
self.visible_nodes.push(TreeNode::Pane {
session_idx,
window_idx,
pane_idx,
});
}
}
}
}
}
}
}
}
fn sync_list_state(&mut self) {
if self.visible_nodes.is_empty() {
self.list_state.select(None);
} else {
self.list_state.select(Some(self.selected_index));
}
}
fn toggle_expand(&mut self) {
if self.visible_nodes.is_empty() {
return;
}
let node = self.visible_nodes[self.selected_index].clone();
match node {
TreeNode::Session(session_idx) => {
let session_state = &mut self.session_states[session_idx];
if session_state.expanded {
session_state.expanded = false;
} else {
if session_state.windows.is_none() {
match self.tmux_client.list_windows(&session_state.session.name) {
Ok(windows) => {
session_state.windows = Some(
windows
.into_iter()
.map(|w| WindowState {
window: w,
expanded: false,
panes: None,
})
.collect(),
);
}
Err(e) => {
self.set_message(format!("Error loading windows: {}", e));
return;
}
}
}
session_state.expanded = true;
}
}
TreeNode::Window { session_idx, window_idx } => {
let session_state = &mut self.session_states[session_idx];
if let Some(ref mut windows) = session_state.windows {
let window_state = &mut windows[window_idx];
if window_state.expanded {
window_state.expanded = false;
} else {
if window_state.panes.is_none() {
match self.tmux_client.list_panes(
&session_state.session.name,
window_state.window.index,
) {
Ok(panes) => {
window_state.panes = Some(panes);
}
Err(e) => {
self.set_message(format!("Error loading panes: {}", e));
return;
}
}
}
window_state.expanded = true;
}
}
}
TreeNode::Pane { .. } => {
}
}
self.rebuild_visible_nodes();
self.sync_list_state();
}
fn collapse_or_go_parent(&mut self) {
if self.visible_nodes.is_empty() {
return;
}
let node = self.visible_nodes[self.selected_index].clone();
match node {
TreeNode::Session(session_idx) => {
if self.session_states[session_idx].expanded {
self.session_states[session_idx].expanded = false;
self.rebuild_visible_nodes();
self.sync_list_state();
}
}
TreeNode::Window { session_idx, window_idx } => {
let session_state = &mut self.session_states[session_idx];
if let Some(ref mut windows) = session_state.windows {
if windows[window_idx].expanded {
windows[window_idx].expanded = false;
self.rebuild_visible_nodes();
self.sync_list_state();
} else {
if let Some(pos) = self.visible_nodes.iter().position(|n| *n == TreeNode::Session(session_idx)) {
self.selected_index = pos;
self.sync_list_state();
}
}
}
}
TreeNode::Pane { session_idx, window_idx, .. } => {
if let Some(pos) = self.visible_nodes.iter().position(|n| *n == TreeNode::Window { session_idx, window_idx }) {
self.selected_index = pos;
self.sync_list_state();
}
}
}
}
fn expand_or_enter_child(&mut self) {
if self.visible_nodes.is_empty() {
return;
}
let node = self.visible_nodes[self.selected_index].clone();
match node {
TreeNode::Session(session_idx) => {
let needs_expand = !self.session_states[session_idx].expanded;
if needs_expand {
self.toggle_expand();
}
let has_windows = self.session_states[session_idx]
.windows
.as_ref()
.map_or(false, |w| !w.is_empty());
if self.session_states[session_idx].expanded && has_windows {
if let Some(pos) = self.visible_nodes.iter().position(|n| {
*n == TreeNode::Window { session_idx, window_idx: 0 }
}) {
self.selected_index = pos;
self.sync_list_state();
}
}
}
TreeNode::Window { session_idx, window_idx } => {
let needs_expand = self.session_states[session_idx]
.windows
.as_ref()
.map_or(false, |w| !w[window_idx].expanded);
if needs_expand {
self.toggle_expand();
}
let has_panes = self.session_states[session_idx]
.windows
.as_ref()
.and_then(|w| w[window_idx].panes.as_ref())
.map_or(false, |p| !p.is_empty());
let is_expanded = self.session_states[session_idx]
.windows
.as_ref()
.map_or(false, |w| w[window_idx].expanded);
if is_expanded && has_panes {
if let Some(pos) = self.visible_nodes.iter().position(|n| {
*n == TreeNode::Pane { session_idx, window_idx, pane_idx: 0 }
}) {
self.selected_index = pos;
self.sync_list_state();
}
}
}
TreeNode::Pane { .. } => {
}
}
}
fn get_selected_attach_target(&self) -> Option<AttachTarget> {
if self.visible_nodes.is_empty() {
return None;
}
let node = &self.visible_nodes[self.selected_index];
match node {
TreeNode::Session(session_idx) => {
let session = &self.session_states[*session_idx].session;
Some(AttachTarget::Session(session.name.to_string()))
}
TreeNode::Window { session_idx, window_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
Some(AttachTarget::Window {
session: session.name.to_string(),
window_index: window.index,
})
} else {
None
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
if let Some(ref panes) = windows[*window_idx].panes {
let pane = &panes[*pane_idx];
Some(AttachTarget::Pane {
session: session.name.to_string(),
window_index: window.index,
pane_index: pane.index,
})
} else {
None
}
} else {
None
}
}
}
}
fn get_selected_kill_target(&self) -> Option<KillTarget> {
if self.visible_nodes.is_empty() {
return None;
}
let node = &self.visible_nodes[self.selected_index];
match node {
TreeNode::Session(session_idx) => {
let session = &self.session_states[*session_idx].session;
Some(KillTarget::Session { name: session.name.to_string() })
}
TreeNode::Window { session_idx, window_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
Some(KillTarget::Window {
session: session.name.to_string(),
window_index: window.index,
window_name: window.name.to_string(),
})
} else {
None
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
if let Some(ref panes) = windows[*window_idx].panes {
let pane = &panes[*pane_idx];
Some(KillTarget::Pane {
session: session.name.to_string(),
window_index: window.index,
pane_index: pane.index,
command: pane.command.to_string(),
})
} else {
None
}
} else {
None
}
}
}
}
fn get_selected_rename_target(&self) -> Option<RenameTarget> {
if self.visible_nodes.is_empty() {
return None;
}
let node = &self.visible_nodes[self.selected_index];
match node {
TreeNode::Session(session_idx) => {
let session = &self.session_states[*session_idx].session;
Some(RenameTarget::Session { name: session.name.to_string() })
}
TreeNode::Window { session_idx, window_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
Some(RenameTarget::Window {
session: session.name.to_string(),
window_index: window.index,
window_name: window.name.to_string(),
})
} else {
None
}
}
TreeNode::Pane { .. } => {
None
}
}
}
fn get_preview_pane_target(&self) -> Option<String> {
if self.visible_nodes.is_empty() {
return None;
}
let node = &self.visible_nodes[self.selected_index];
match node {
TreeNode::Session(session_idx) => {
let session = &self.session_states[*session_idx].session;
self.tmux_client.get_active_pane_target(&session.name).ok()
}
TreeNode::Window { session_idx, window_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
self.tmux_client
.get_window_active_pane_target(&session.name, window.index)
.ok()
} else {
None
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
if let Some(ref windows) = self.session_states[*session_idx].windows {
if let Some(ref panes) = windows[*window_idx].panes {
Some(panes[*pane_idx].pane_id.to_string())
} else {
None
}
} else {
None
}
}
}
}
fn calculate_tree_width(&self) -> usize {
if self.session_states.is_empty() {
return 40; }
self.visible_nodes
.iter()
.map(|node| self.get_tree_node_width(node))
.max()
.unwrap_or(20)
}
fn get_tree_node_width(&self, node: &TreeNode) -> usize {
let indicator_width = if self.status_enabled { 2 } else { 0 };
match node {
TreeNode::Session(session_idx) => {
let session_state = &self.session_states[*session_idx];
let session = &session_state.session;
let status_len = if session.is_attached() { 11 } else { 0 };
indicator_width + 2 + session.name.len() + 3 + format!("{}", session.windows).len() + 8 + status_len
}
TreeNode::Window { session_idx, window_idx } => {
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window_state = &windows[*window_idx];
let window = &window_state.window;
let active_len = if window.is_active { 2 } else { 0 };
4 + 4 + indicator_width + 2 + window.name.len() + 3 + format!("{}", window.pane_count).len() + 6 + active_len
} else {
0
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
if let Some(ref windows) = self.session_states[*session_idx].windows {
if let Some(ref panes) = windows[*window_idx].panes {
let pane = &panes[*pane_idx];
let active_len = if pane.is_active { 2 } else { 0 };
4 + 4 + 4 + 4 + indicator_width + pane.command.len() + active_len
} else {
0
}
} else {
0
}
}
}
}
fn get_preview_title(&self) -> String {
if self.visible_nodes.is_empty() {
return " Preview ".to_string();
}
let node = &self.visible_nodes[self.selected_index];
match node {
TreeNode::Session(session_idx) => {
let session = &self.session_states[*session_idx].session;
format!(" Preview: {} ", session.name)
}
TreeNode::Window { session_idx, window_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
format!(" Preview: {}:{} ", session.name, window.name)
} else {
" Preview ".to_string()
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
let session = &self.session_states[*session_idx].session;
if let Some(ref windows) = self.session_states[*session_idx].windows {
let window = &windows[*window_idx].window;
if let Some(ref panes) = windows[*window_idx].panes {
let pane = &panes[*pane_idx];
format!(" Preview: {}:{}:{} ", session.name, window.name, pane.command)
} else {
" Preview ".to_string()
}
} else {
" Preview ".to_string()
}
}
}
}
fn set_message(&mut self, msg: String) {
self.message = Some((msg, Instant::now()));
}
fn clear_expired_message(&mut self) {
if let Some((_, time)) = &self.message {
if time.elapsed() > Duration::from_secs(3) {
self.message = None;
}
}
}
fn update_preview(&mut self) {
if !self.preview_enabled {
return;
}
let new_target = self.get_preview_pane_target();
let target_changed = self.preview_target != new_target;
let needs_refresh = self.preview_last_update
.map_or(true, |t| t.elapsed() >= PREVIEW_REFRESH);
if target_changed {
self.preview_scroll = 0;
self.preview_target = new_target.clone();
}
if !target_changed && !needs_refresh {
return;
}
if let Some(ref target) = new_target {
match self.tmux_client.capture_pane(target) {
Ok(content) => {
self.preview_content = Some(content);
self.preview_last_update = Some(Instant::now());
}
Err(_) => {
self.preview_content = None;
}
}
} else {
self.preview_content = None;
}
}
fn scroll_preview_down(&mut self) {
if let Some(ref content) = self.preview_content {
let line_count = content.lines().count() as u16;
if self.preview_scroll < line_count.saturating_sub(1) {
self.preview_scroll += 1;
}
}
}
fn scroll_preview_up(&mut self) {
if self.preview_scroll > 0 {
self.preview_scroll -= 1;
}
}
fn toggle_preview(&mut self) {
self.preview_enabled = !self.preview_enabled;
if self.preview_enabled {
self.preview_last_update = None;
}
}
fn update_statuses(&mut self) {
if !self.status_enabled {
return;
}
if self.status_last_update.map_or(false, |t| t.elapsed() < STATUS_REFRESH) {
return;
}
let pane_infos = match self.tmux_client.list_all_panes_info() {
Ok(infos) => infos,
Err(_) => return,
};
self.status_monitor.begin_update();
for info in &pane_infos {
let content = self.tmux_client.capture_pane_plain(&info.pane_id).ok();
let status = status::classify(&info.command, content.as_deref(), &info.pane_id);
self.status_monitor.set(&info.pane_id, &info.session_name, info.window_index, status);
}
self.status_last_update = Some(Instant::now());
}
fn get_pane_status(&self, pane_id: &str) -> PaneStatus {
self.status_monitor.get_pane(pane_id)
}
fn get_session_status(&self, session_idx: usize) -> PaneStatus {
let name = &self.session_states[session_idx].session.name;
self.status_monitor.get_session(name)
}
fn get_window_status(&self, session_idx: usize, window_idx: usize) -> PaneStatus {
let state = &self.session_states[session_idx];
if let Some(ref windows) = state.windows {
let ws = &windows[window_idx];
self.status_monitor.get_window(&state.session.name, ws.window.index)
} else {
PaneStatus::Unknown
}
}
fn handle_key(
&mut self,
code: KeyCode,
modifiers: KeyModifiers,
_terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<KeyAction, TmuxFzfError> {
match &self.mode.clone() {
Mode::Normal => self.handle_normal_key(code, modifiers),
Mode::CreateSession => self.handle_input_key(code, InputMode::Create),
Mode::Rename(target) => self.handle_input_key(code, InputMode::Rename(target.clone())),
Mode::ConfirmKill(target) => self.handle_confirm_key(code, target.clone()),
}
}
fn handle_normal_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Result<KeyAction, TmuxFzfError> {
match code {
KeyCode::Char('q') | KeyCode::Esc => Ok(KeyAction::Quit),
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => Ok(KeyAction::Quit),
KeyCode::Up => {
self.select_previous();
Ok(KeyAction::Continue)
}
KeyCode::Down | KeyCode::Char('j') => {
self.select_next();
Ok(KeyAction::Continue)
}
KeyCode::Char('k') if modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(target) = self.get_selected_kill_target() {
self.mode = Mode::ConfirmKill(target);
}
Ok(KeyAction::Continue)
}
KeyCode::Char('k') => {
self.select_previous();
Ok(KeyAction::Continue)
}
KeyCode::Enter => {
if let Some(target) = self.get_selected_attach_target() {
Ok(KeyAction::Attach(target))
} else {
Ok(KeyAction::Continue)
}
}
KeyCode::Tab | KeyCode::Char(' ') => {
self.toggle_expand();
Ok(KeyAction::Continue)
}
KeyCode::Char('h') | KeyCode::Left => {
self.collapse_or_go_parent();
Ok(KeyAction::Continue)
}
KeyCode::Char('l') | KeyCode::Right => {
self.expand_or_enter_child();
Ok(KeyAction::Continue)
}
KeyCode::Char('n') => {
self.mode = Mode::CreateSession;
self.input_buffer.clear();
Ok(KeyAction::Continue)
}
KeyCode::Char('r') => {
if let Some(target) = self.get_selected_rename_target() {
self.mode = Mode::Rename(target);
self.input_buffer.clear();
} else {
self.set_message("Panes cannot be renamed".to_string());
}
Ok(KeyAction::Continue)
}
KeyCode::Char('d') | KeyCode::Char('x') => {
if let Some(target) = self.get_selected_kill_target() {
self.mode = Mode::ConfirmKill(target);
}
Ok(KeyAction::Continue)
}
KeyCode::Char('p') => {
self.toggle_preview();
Ok(KeyAction::Continue)
}
KeyCode::Char('s') => {
self.status_enabled = !self.status_enabled;
if !self.status_enabled {
self.status_monitor.clear();
} else {
self.status_last_update = None;
}
Ok(KeyAction::Continue)
}
KeyCode::Char('J') => {
self.scroll_preview_down();
Ok(KeyAction::Continue)
}
KeyCode::Char('K') => {
self.scroll_preview_up();
Ok(KeyAction::Continue)
}
_ => Ok(KeyAction::Continue),
}
}
fn handle_input_key(&mut self, code: KeyCode, input_mode: InputMode) -> Result<KeyAction, TmuxFzfError> {
match code {
KeyCode::Esc => {
self.mode = Mode::Normal;
self.input_buffer.clear();
}
KeyCode::Enter => {
let input = self.input_buffer.trim().to_string();
self.input_buffer.clear();
self.mode = Mode::Normal;
match input_mode {
InputMode::Create => {
let name = if input.is_empty() { None } else { Some(input.as_str()) };
match self.tmux_client.new_session(name) {
Ok(session_name) => {
self.set_message(format!("Created session '{}'", session_name));
self.refresh_sessions();
}
Err(e) => {
self.set_message(format!("Error: {}", e));
}
}
}
InputMode::Rename(target) => {
if !input.is_empty() {
let result = match &target {
RenameTarget::Session { name } => {
self.tmux_client.rename_session(name, &input)
}
RenameTarget::Window { session, window_index, .. } => {
self.tmux_client.rename_window(session, *window_index, &input)
}
};
match result {
Ok(()) => {
self.set_message(format!("Renamed {} to '{}'", target.display_name(), input));
self.refresh_sessions();
}
Err(e) => {
self.set_message(format!("Error: {}", e));
}
}
}
}
}
}
KeyCode::Backspace => {
self.input_buffer.pop();
}
KeyCode::Char(c) => {
self.input_buffer.push(c);
}
_ => {}
}
Ok(KeyAction::Continue)
}
fn handle_confirm_key(&mut self, code: KeyCode, target: KillTarget) -> Result<KeyAction, TmuxFzfError> {
match code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
let result = match &target {
KillTarget::Session { name } => {
self.tmux_client.kill_session(name)
}
KillTarget::Window { session, window_index, .. } => {
self.tmux_client.kill_window(session, *window_index)
}
KillTarget::Pane { session, window_index, pane_index, .. } => {
self.tmux_client.kill_pane(session, *window_index, *pane_index)
}
};
match result {
Ok(()) => {
self.set_message(format!("Killed {}", target.display_name()));
self.refresh_sessions();
}
Err(e) => {
self.set_message(format!("Error: {}", e));
}
}
self.mode = Mode::Normal;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.mode = Mode::Normal;
}
_ => {}
}
Ok(KeyAction::Continue)
}
fn select_next(&mut self) {
if self.visible_nodes.is_empty() {
return;
}
if self.selected_index >= self.visible_nodes.len() - 1 {
self.selected_index = 0;
} else {
self.selected_index += 1;
}
self.sync_list_state();
}
fn select_previous(&mut self) {
if self.visible_nodes.is_empty() {
return;
}
if self.selected_index == 0 {
self.selected_index = self.visible_nodes.len() - 1;
} else {
self.selected_index -= 1;
}
self.sync_list_state();
}
fn render(&mut self, frame: &mut Frame) {
let area = frame.area();
let show_preview = self.preview_enabled && area.width >= MIN_PREVIEW_WIDTH;
let chunks = Layout::vertical([
Constraint::Length(1),
Constraint::Min(5),
Constraint::Length(2),
])
.split(area);
self.render_title(frame, chunks[0]);
if show_preview {
let tree_width = self.calculate_tree_width();
let tree_width_with_padding = (tree_width + 6) as u16;
let max_tree_width = chunks[1].width / 2;
let actual_tree_width = tree_width_with_padding.max(20).min(max_tree_width);
let content_chunks = Layout::horizontal([
Constraint::Length(actual_tree_width),
Constraint::Min(MIN_PREVIEW_WIDTH.min(chunks[1].width.saturating_sub(actual_tree_width))),
])
.split(chunks[1]);
self.render_tree(frame, content_chunks[0]);
self.render_preview(frame, content_chunks[1]);
} else {
self.render_tree(frame, chunks[1]);
}
self.render_help(frame, chunks[2]);
match &self.mode {
Mode::CreateSession => {
self.render_input_modal(frame, "New Session", "Enter session name (empty for auto):");
}
Mode::Rename(target) => {
let title = match target {
RenameTarget::Session { .. } => "Rename Session",
RenameTarget::Window { .. } => "Rename Window",
};
self.render_input_modal(frame, title, &format!("New name for {}:", target.display_name()));
}
Mode::ConfirmKill(target) => {
self.render_confirm_modal(frame, &format!("Kill {}?", target.display_name()));
}
Mode::Normal => {}
}
}
fn render_title(&self, frame: &mut Frame, area: Rect) {
let title = if let Some((msg, _)) = &self.message {
Paragraph::new(msg.as_str())
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center)
} else {
Paragraph::new("TmuxTango - Dance between your sessions!")
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.alignment(Alignment::Center)
};
frame.render_widget(title, area);
}
fn render_tree(&mut self, frame: &mut Frame, area: Rect) {
if self.session_states.is_empty() {
let empty = Paragraph::new("No tmux sessions found. Press 'n' to create one.")
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL).title(" Sessions "));
frame.render_widget(empty, area);
return;
}
let selected_idx = self.list_state.selected();
let items: Vec<ListItem> = self
.visible_nodes
.iter()
.enumerate()
.map(|(i, node)| self.render_tree_node(node, selected_idx == Some(i)))
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(" Sessions "))
.highlight_style(
Style::default()
.bg(Color::Gray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(list, area, &mut self.list_state);
}
fn render_tree_node(&self, node: &TreeNode, selected: bool) -> ListItem<'static> {
let info_color = if selected { Color::White } else { Color::Gray };
match node {
TreeNode::Session(session_idx) => {
let session_state = &self.session_states[*session_idx];
let session = &session_state.session;
let expand_symbol = if session_state.expanded { "▼" } else { "▶" };
let attached = if session.is_attached() { " (attached)" } else { "" };
let mut spans = Vec::new();
if self.status_enabled {
spans.push(status::status_indicator(self.get_session_status(*session_idx)));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(format!("{} ", expand_symbol)));
spans.push(Span::styled(session.name.to_string(), Style::default().fg(Color::Cyan)));
spans.push(Span::styled(
format!(" : {} windows{}", session.windows, attached),
Style::default().fg(info_color),
));
ListItem::new(Line::from(spans))
}
TreeNode::Window { session_idx, window_idx } => {
let session_state = &self.session_states[*session_idx];
if let Some(ref windows) = session_state.windows {
let window_state = &windows[*window_idx];
let window = &window_state.window;
let is_last_window = *window_idx == windows.len() - 1;
let branch = if is_last_window { "└──" } else { "├──" };
let expand_symbol = if window_state.expanded { "▼" } else { "▶" };
let active_marker = if window.is_active { " *" } else { "" };
let mut spans = vec![
Span::raw(format!(" {} ", branch)),
];
if self.status_enabled {
spans.push(status::status_indicator(self.get_window_status(*session_idx, *window_idx)));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(format!("{} ", expand_symbol)));
spans.push(Span::styled(window.name.to_string(), Style::default().fg(Color::Yellow)));
spans.push(Span::styled(
format!(" : {} panes{}", window.pane_count, active_marker),
Style::default().fg(info_color),
));
ListItem::new(Line::from(spans))
} else {
ListItem::new(Line::from(""))
}
}
TreeNode::Pane { session_idx, window_idx, pane_idx } => {
let session_state = &self.session_states[*session_idx];
if let Some(ref windows) = session_state.windows {
let window_state = &windows[*window_idx];
let is_last_window = *window_idx == windows.len() - 1;
let continuation = if is_last_window { " " } else { "│ " };
if let Some(ref panes) = window_state.panes {
let pane = &panes[*pane_idx];
let is_last_pane = *pane_idx == panes.len() - 1;
let branch = if is_last_pane { "└──" } else { "├──" };
let active_marker = if pane.is_active { " *" } else { "" };
let mut spans = vec![
Span::raw(format!(" {} {} ", continuation, branch)),
];
if self.status_enabled {
spans.push(status::status_indicator(self.get_pane_status(&pane.pane_id)));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(pane.command.to_string(), Style::default().fg(Color::Green)));
spans.push(Span::styled(
active_marker.to_string(),
Style::default().fg(info_color),
));
ListItem::new(Line::from(spans))
} else {
ListItem::new(Line::from(""))
}
} else {
ListItem::new(Line::from(""))
}
}
}
}
fn render_preview(&self, frame: &mut Frame, area: Rect) {
let title = self.get_preview_title();
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.style(Style::default());
let inner = block.inner(area);
frame.render_widget(block, area);
match &self.preview_content {
Some(content) => {
let lines: Vec<Line> = content
.lines()
.skip(self.preview_scroll as usize)
.map(parse_ansi_line)
.collect();
let total_lines = content.lines().count();
let visible_lines = inner.height as usize;
let scroll_info = if total_lines > visible_lines {
let pos = self.preview_scroll as usize + 1;
let max = total_lines.saturating_sub(visible_lines) + 1;
format!(" [{}/{}] ", pos, max)
} else {
String::new()
};
if !scroll_info.is_empty() {
let scroll_indicator = Paragraph::new(scroll_info)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Right);
let indicator_area = Rect::new(
area.x + area.width.saturating_sub(12),
area.y,
11,
1,
);
frame.render_widget(scroll_indicator, indicator_area);
}
let preview_text = Paragraph::new(lines);
frame.render_widget(preview_text, inner);
}
None => {
let message = if self.visible_nodes.is_empty() {
"No session selected"
} else {
"Preview unavailable"
};
let empty = Paragraph::new(message)
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(empty, inner);
}
}
}
fn render_help(&self, frame: &mut Frame, area: Rect) {
let (line1, line2) = match &self.mode {
Mode::Normal => (
"j/k: Nav | Tab: Expand | h/l: Collapse/Expand | Enter: Attach",
"n: New | r: Rename | d: Kill | p: Preview | s: Status | J/K: Scroll | q: Quit",
),
Mode::CreateSession | Mode::Rename(_) => (
"Enter: Confirm | Esc: Cancel",
"",
),
Mode::ConfirmKill(_) => (
"y: Confirm | n/Esc: Cancel",
"",
),
};
let help = Paragraph::new(vec![
Line::from(line1),
Line::from(Span::styled(line2, Style::default().fg(Color::DarkGray))),
])
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(help, area);
}
fn render_input_modal(&self, frame: &mut Frame, title: &str, prompt: &str) {
let area = centered_rect(50, 7, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", title))
.style(Style::default().bg(Color::Black));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1), Constraint::Length(1)])
.margin(1)
.split(inner);
let prompt_widget = Paragraph::new(prompt).style(Style::default().fg(Color::White));
frame.render_widget(prompt_widget, chunks[0]);
let input_display = format!("{}_", self.input_buffer);
let input_widget = Paragraph::new(input_display)
.style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
frame.render_widget(input_widget, chunks[1]);
}
fn render_confirm_modal(&self, frame: &mut Frame, message: &str) {
let area = centered_rect(40, 5, frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.title(" Confirm ")
.style(Style::default().bg(Color::Black));
let inner = block.inner(area);
frame.render_widget(block, area);
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
.margin(1)
.split(inner);
let msg = Paragraph::new(message)
.style(Style::default().fg(Color::Yellow))
.alignment(Alignment::Center);
frame.render_widget(msg, chunks[0]);
let hint = Paragraph::new("y/n")
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
frame.render_widget(hint, chunks[1]);
}
}
enum KeyAction {
Continue,
Quit,
Attach(AttachTarget),
}
enum InputMode {
Create,
Rename(RenameTarget),
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>, TmuxFzfError> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(terminal)
}
fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<(), TmuxFzfError> {
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
Ok(())
}
fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect {
let popup_width = area.width * percent_x / 100;
let popup_x = (area.width.saturating_sub(popup_width)) / 2;
let popup_y = (area.height.saturating_sub(height)) / 2;
Rect::new(
area.x + popup_x,
area.y + popup_y,
popup_width.min(area.width),
height.min(area.height),
)
}
pub struct InteractiveSelector {
app: App,
}
impl InteractiveSelector {
pub fn new() -> Self {
Self { app: App::new() }
}
pub fn run(&mut self) -> Result<(), TmuxFzfError> {
self.app.run()
}
}