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