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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
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(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}