Skip to main content

tmx/ui/
project_selector.rs

1use std::io;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use crossterm::execute;
7use crossterm::terminal::{
8    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
9};
10use ratatui::backend::{Backend, CrosstermBackend};
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::Style;
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Paragraph};
15use ratatui::{Frame, Terminal};
16
17use crate::project::Project;
18use crate::tmux;
19use crate::ui::{fuzzy_match, theme};
20
21/// RAII guard that restores stderr's terminal state on drop. Matches the
22/// pattern used by `picker::run_picker`.
23struct StderrGuard;
24
25impl Drop for StderrGuard {
26    fn drop(&mut self) {
27        let _ = execute!(io::stderr(), LeaveAlternateScreen);
28        let _ = disable_raw_mode();
29    }
30}
31
32pub fn run_project_selector(projects: Vec<Project>) -> Result<Option<Project>> {
33    enable_raw_mode()?;
34    let _guard = StderrGuard;
35    execute!(io::stderr(), EnterAlternateScreen)?;
36    let backend = CrosstermBackend::new(io::stderr());
37    let mut terminal = Terminal::new(backend)?;
38    run_loop(&mut terminal, projects)
39}
40
41pub fn open_project(project: &Project) -> Result<()> {
42    let session_name = project.name.replace('.', "_");
43    if tmux::session_exists(&session_name) {
44        return tmux::switch_session(&session_name).context("switch tmux session");
45    }
46    let path_str = project.path.to_string_lossy();
47    tmux::create_session(&session_name, &path_str).context("create tmux session")?;
48    tmux::apply_layout(&session_name, project.project_type.as_str()).ok();
49    tmux::switch_session(&session_name).context("switch tmux session")
50}
51
52struct Model {
53    projects: Vec<Project>,
54    filtered_idx: Vec<usize>,
55    cursor: usize,
56    search: String,
57    selected: Option<usize>,
58    quit: bool,
59}
60
61impl Model {
62    fn new(projects: Vec<Project>) -> Self {
63        let filtered_idx = (0..projects.len()).collect();
64        Self {
65            projects,
66            filtered_idx,
67            cursor: 0,
68            search: String::new(),
69            selected: None,
70            quit: false,
71        }
72    }
73
74    fn refilter(&mut self) {
75        if self.search.is_empty() {
76            self.filtered_idx = (0..self.projects.len()).collect();
77        } else {
78            self.filtered_idx = self
79                .projects
80                .iter()
81                .enumerate()
82                .filter(|(_, p)| fuzzy_match(&p.name, &self.search))
83                .map(|(i, _)| i)
84                .collect();
85        }
86        if self.cursor >= self.filtered_idx.len() {
87            self.cursor = self.filtered_idx.len().saturating_sub(1);
88        }
89    }
90
91    fn handle_key(&mut self, key: KeyEvent) {
92        if key.kind != KeyEventKind::Press {
93            return;
94        }
95        match key.code {
96            KeyCode::Esc => self.quit = true,
97            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
98                self.quit = true;
99            }
100            KeyCode::Enter if !self.filtered_idx.is_empty() => {
101                self.selected = Some(self.filtered_idx[self.cursor]);
102                self.quit = true;
103            }
104            KeyCode::Up => self.move_up(),
105            KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => self.move_up(),
106            KeyCode::Down => self.move_down(),
107            KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => self.move_down(),
108            KeyCode::Backspace => {
109                self.search.pop();
110                self.refilter();
111            }
112            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
113                self.search.push(c);
114                self.refilter();
115            }
116            _ => {}
117        }
118    }
119
120    fn move_up(&mut self) {
121        if self.cursor > 0 {
122            self.cursor -= 1;
123        }
124    }
125
126    fn move_down(&mut self) {
127        if self.cursor + 1 < self.filtered_idx.len() {
128            self.cursor += 1;
129        }
130    }
131}
132
133fn run_loop<B: Backend>(
134    terminal: &mut Terminal<B>,
135    projects: Vec<Project>,
136) -> Result<Option<Project>> {
137    let mut model = Model::new(projects);
138
139    while !model.quit {
140        terminal.draw(|frame| render(frame, &model))?;
141        if event::poll(Duration::from_millis(250))?
142            && let Event::Key(key) = event::read()?
143        {
144            model.handle_key(key);
145        }
146    }
147
148    Ok(model.selected.map(|i| model.projects[i].clone()))
149}
150
151fn render(frame: &mut Frame, model: &Model) {
152    let area = frame.area();
153    let layout = Layout::default()
154        .direction(Direction::Vertical)
155        .constraints([
156            Constraint::Length(1), // title
157            Constraint::Length(1), // padding
158            Constraint::Length(1), // search input
159            Constraint::Length(1), // padding
160            Constraint::Min(1),    // list
161            Constraint::Length(1), // status
162        ])
163        .split(area);
164
165    frame.render_widget(
166        Paragraph::new(Span::styled("  tmx", theme::title_style())),
167        layout[0],
168    );
169
170    let search_line = Line::from(vec![
171        Span::raw("  "),
172        Span::styled("> ", Style::default().fg(theme::VIOLET)),
173        Span::styled(&model.search, Style::default().fg(theme::TEXT)),
174        Span::styled(
175            if model.search.is_empty() {
176                "Search projects..."
177            } else {
178                ""
179            },
180            theme::muted_style(),
181        ),
182    ]);
183    frame.render_widget(Paragraph::new(search_line), layout[2]);
184
185    render_list(frame, model, layout[4]);
186
187    let status = format!(
188        " {}/{} projects  ↑↓ navigate  ⏎ select  esc quit",
189        model.filtered_idx.len(),
190        model.projects.len()
191    );
192    frame.render_widget(
193        Paragraph::new(Span::styled(status, theme::status_bar_style())),
194        layout[5],
195    );
196}
197
198fn render_list(frame: &mut Frame, model: &Model, area: Rect) {
199    if model.filtered_idx.is_empty() {
200        frame.render_widget(
201            Paragraph::new(Span::styled("  No projects found", theme::muted_style())),
202            area,
203        );
204        return;
205    }
206
207    let max_visible = area.height.max(1) as usize;
208    let start = model.cursor.saturating_sub(max_visible.saturating_sub(1));
209    let end = (start + max_visible).min(model.filtered_idx.len());
210
211    let lines: Vec<Line> = (start..end)
212        .map(|i| {
213            let project = &model.projects[model.filtered_idx[i]];
214            let is_cursor = i == model.cursor;
215            project_line(project, is_cursor)
216        })
217        .collect();
218
219    frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
220}
221
222fn project_line(project: &Project, is_cursor: bool) -> Line<'static> {
223    let (cursor_marker, name_style) = if is_cursor {
224        (
225            Span::styled("▸ ", Style::default().fg(theme::VIOLET)),
226            theme::selected_style(),
227        )
228    } else {
229        (Span::raw("  "), theme::normal_style())
230    };
231
232    let mut spans = vec![
233        cursor_marker,
234        Span::styled(project.name.clone(), name_style),
235        Span::raw(" "),
236        Span::styled(
237            format!("[{}]", project.project_type.as_str()),
238            theme::type_style(project.project_type),
239        ),
240        Span::raw(" "),
241        Span::styled(project.git_branch.clone(), theme::branch_style()),
242    ];
243
244    if project.git_dirty {
245        spans.push(Span::styled(" ●", theme::dirty_style()));
246    }
247    if project.git_ahead > 0 {
248        spans.push(Span::styled(
249            format!(" ↑{}", project.git_ahead),
250            Style::default().fg(theme::GREEN),
251        ));
252    }
253    if project.git_behind > 0 {
254        spans.push(Span::styled(
255            format!(" ↓{}", project.git_behind),
256            Style::default().fg(theme::RED),
257        ));
258    }
259
260    Line::from(spans)
261}
262
263// Allow callers that don't need a TUI (e.g. dumb terminals) to see why we bail.
264#[allow(dead_code)]
265fn require_tty() -> io::Result<()> {
266    use std::io::IsTerminal;
267    if !std::io::stdout().is_terminal() {
268        return Err(io::Error::other(
269            "stdout is not a TTY; refusing to start TUI",
270        ));
271    }
272    Ok(())
273}