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