Rust · 15168 bytes Raw Blame History
1 use crate::app::App;
2 use crate::types::{AppMode, CommitStatus, InputMode, PushStatus, SelectableItem};
3 use ratatui::{
4 layout::{Constraint, Direction, Layout, Rect},
5 style::{Color, Modifier, Style},
6 text::{Line, Span},
7 widgets::{Block, Borders, Clear, Paragraph},
8 Frame,
9 };
10
11 /// Draw the complete UI
12 pub fn draw(frame: &mut Frame, app: &App) {
13 let chunks = Layout::default()
14 .direction(Direction::Vertical)
15 .constraints([
16 Constraint::Length(2), // Header (repo:branch)
17 Constraint::Min(3), // Tree view
18 Constraint::Length(3), // Help/status
19 ])
20 .split(frame.area());
21
22 draw_header(frame, app, chunks[0]);
23 draw_tree(frame, app, chunks[1]);
24 draw_help(frame, app, chunks[2]);
25
26 // Draw modal overlay if in commit mode
27 if let InputMode::Commit { buffer, cursor, amend, status } = &app.input_mode {
28 draw_commit_modal(frame, buffer, *cursor, *amend, status);
29 }
30
31 // Draw modal overlay if in push mode
32 if let InputMode::Push { remotes, selected, status } = &app.input_mode {
33 draw_push_modal(frame, remotes, *selected, status, &app.branch_name);
34 }
35 }
36
37 /// Draw commit message modal
38 fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool, status: &CommitStatus) {
39 let area = frame.area();
40
41 // Center the modal
42 let modal_width = 60.min(area.width.saturating_sub(4));
43 let modal_height = 5;
44 let x = (area.width.saturating_sub(modal_width)) / 2;
45 let y = (area.height.saturating_sub(modal_height)) / 2;
46
47 let modal_area = Rect::new(x, y, modal_width, modal_height);
48
49 // Clear area behind modal
50 frame.render_widget(Clear, modal_area);
51
52 // Title and border color based on status
53 let (title, border_color) = match status {
54 CommitStatus::Editing => {
55 let t = if amend { " Amend Commit " } else { " Commit " };
56 (t, Color::Green)
57 }
58 CommitStatus::Committing => (" Committing... ", Color::Yellow),
59 CommitStatus::Success => (" ✓ Committed ", Color::Green),
60 CommitStatus::Failed => (" ✗ Failed ", Color::Red),
61 };
62
63 let block = Block::default()
64 .title(title)
65 .borders(Borders::ALL)
66 .border_style(Style::default().fg(border_color));
67
68 // Content based on status
69 let content = match status {
70 CommitStatus::Editing => {
71 // Build input line with cursor
72 let display_text = if cursor >= buffer.len() {
73 format!("{}█", buffer)
74 } else {
75 format!("{}{}", &buffer[..cursor], &buffer[cursor..])
76 };
77 vec![
78 Line::from(""),
79 Line::from(Span::raw(display_text)),
80 ]
81 }
82 CommitStatus::Committing => {
83 vec![
84 Line::from(""),
85 Line::from(Span::styled(
86 " Committing changes...",
87 Style::default().fg(Color::Yellow),
88 )),
89 ]
90 }
91 CommitStatus::Success => {
92 vec![
93 Line::from(""),
94 Line::from(Span::styled(
95 " ✓ Changes committed successfully!",
96 Style::default().fg(Color::Green),
97 )),
98 ]
99 }
100 CommitStatus::Failed => {
101 vec![
102 Line::from(""),
103 Line::from(Span::styled(
104 " ✗ Commit failed (nothing staged?)",
105 Style::default().fg(Color::Red),
106 )),
107 ]
108 }
109 };
110
111 let input = Paragraph::new(content).block(block);
112 frame.render_widget(input, modal_area);
113 }
114
115 /// Draw push remote selection modal
116 fn draw_push_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &PushStatus, branch: &str) {
117 let area = frame.area();
118
119 // Modal size based on content
120 let modal_height = match status {
121 PushStatus::SelectRemote => (remotes.len() + 4).min(12) as u16,
122 _ => 5,
123 };
124 let modal_width = 50.min(area.width.saturating_sub(4));
125 let x = (area.width.saturating_sub(modal_width)) / 2;
126 let y = (area.height.saturating_sub(modal_height)) / 2;
127
128 let modal_area = Rect::new(x, y, modal_width, modal_height);
129
130 // Clear area behind modal
131 frame.render_widget(Clear, modal_area);
132
133 // Title and border color based on status
134 let (title, border_color) = match status {
135 PushStatus::SelectRemote => (" Push - Select Remote ", Color::Cyan),
136 PushStatus::Pushing => (" Pushing... ", Color::Yellow),
137 PushStatus::Success => (" ✓ Pushed ", Color::Green),
138 PushStatus::Failed(_) => (" ✗ Push Failed ", Color::Red),
139 };
140
141 let block = Block::default()
142 .title(title)
143 .borders(Borders::ALL)
144 .border_style(Style::default().fg(border_color));
145
146 // Content based on status
147 let content: Vec<Line> = match status {
148 PushStatus::SelectRemote => {
149 let mut lines = vec![
150 Line::from(Span::styled(
151 format!(" Set upstream for '{}':", branch),
152 Style::default().fg(Color::White),
153 )),
154 Line::from(""),
155 ];
156 for (i, remote) in remotes.iter().enumerate() {
157 let style = if i == selected {
158 Style::default().add_modifier(Modifier::REVERSED)
159 } else {
160 Style::default()
161 };
162 lines.push(Line::from(Span::styled(format!(" {}", remote), style)));
163 }
164 lines.push(Line::from(""));
165 lines.push(Line::from(Span::styled(
166 " j/k:select Enter:push ESC:cancel",
167 Style::default().fg(Color::DarkGray),
168 )));
169 lines
170 }
171 PushStatus::Pushing => {
172 vec![
173 Line::from(""),
174 Line::from(Span::styled(
175 " Pushing changes...",
176 Style::default().fg(Color::Yellow),
177 )),
178 ]
179 }
180 PushStatus::Success => {
181 vec![
182 Line::from(""),
183 Line::from(Span::styled(
184 " ✓ Changes pushed successfully!",
185 Style::default().fg(Color::Green),
186 )),
187 ]
188 }
189 PushStatus::Failed(msg) => {
190 vec![
191 Line::from(""),
192 Line::from(Span::styled(
193 format!(" ✗ {}", msg),
194 Style::default().fg(Color::Red),
195 )),
196 ]
197 }
198 };
199
200 let widget = Paragraph::new(content).block(block);
201 frame.render_widget(widget, modal_area);
202 }
203
204 /// Draw header with repo:branch info
205 fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
206 let mut spans = vec![
207 Span::styled(&app.repo_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
208 Span::raw(":"),
209 Span::styled(&app.branch_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
210 ];
211
212 // Show mode indicator
213 if app.mode == AppMode::Git {
214 spans.push(Span::raw(" "));
215 spans.push(Span::styled(
216 "[ GIT MODE ]",
217 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
218 ));
219 }
220
221 // Show search buffer if active
222 if !app.search_buffer.is_empty() {
223 spans.push(Span::raw(" "));
224 spans.push(Span::styled(
225 format!("/{}", &app.search_buffer),
226 Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
227 ));
228 }
229
230 // Show status message if any
231 if let Some(ref msg) = app.status_message {
232 spans.push(Span::raw(" - "));
233 spans.push(Span::styled(msg, Style::default().fg(Color::Green)));
234 }
235
236 let header = Paragraph::new(Line::from(spans));
237 frame.render_widget(header, area);
238 }
239
240 /// Draw the file tree
241 fn draw_tree(frame: &mut Frame, app: &App, area: Rect) {
242 let visible_height = area.height as usize;
243 let total_items = app.items.len();
244
245 // Calculate viewport bounds centered on selection
246 let half_visible = visible_height / 2;
247 let viewport_start = if app.selected > half_visible {
248 (app.selected - half_visible).min(total_items.saturating_sub(visible_height))
249 } else {
250 0
251 };
252 let viewport_end = (viewport_start + visible_height).min(total_items);
253
254 // Build tree lines
255 let mut lines: Vec<Line> = Vec::new();
256
257 // Add root "." on first line if viewport starts at 0
258 if viewport_start == 0 && !app.items.is_empty() {
259 lines.push(Line::from(Span::raw(".")));
260 }
261
262 // Add visible items
263 for (i, item) in app.items.iter().enumerate().skip(viewport_start).take(viewport_end - viewport_start) {
264 let line = render_tree_item(item, i == app.selected, &app.input_mode);
265 lines.push(line);
266 }
267
268 let tree = Paragraph::new(lines).block(Block::default());
269 frame.render_widget(tree, area);
270 }
271
272 /// Render a single tree item
273 fn render_tree_item(item: &SelectableItem, is_selected: bool, input_mode: &InputMode) -> Line<'static> {
274 let mut spans: Vec<Span> = Vec::new();
275
276 // Build prefix based on depth and tree structure
277 for (depth, &is_last) in item.ancestors_are_last.iter().enumerate() {
278 if depth < item.ancestors_are_last.len() {
279 if is_last {
280 spans.push(Span::raw(" "));
281 } else {
282 spans.push(Span::raw("│ "));
283 }
284 }
285 }
286
287 // Add branch character
288 if item.is_last_sibling {
289 spans.push(Span::raw("└── "));
290 } else {
291 spans.push(Span::raw("├── "));
292 }
293
294 // Add expand/collapse indicator for directories
295 if !item.is_file {
296 if item.is_expanded {
297 spans.push(Span::raw("▼ "));
298 } else {
299 spans.push(Span::raw("▶ "));
300 }
301 }
302
303 // Handle rename mode
304 if is_selected {
305 if let InputMode::Rename { buffer, cursor } = input_mode {
306 // Show editable name with cursor
307 let name_with_cursor = if *cursor >= buffer.len() {
308 format!("{}█", buffer)
309 } else {
310 format!("{}{}", &buffer[..*cursor], &buffer[*cursor..])
311 };
312 spans.push(Span::styled(
313 name_with_cursor,
314 Style::default().add_modifier(Modifier::REVERSED),
315 ));
316 } else {
317 // Normal selection highlight
318 let name_style = if item.status.is_gitignored {
319 Style::default()
320 .fg(Color::DarkGray)
321 .add_modifier(Modifier::REVERSED)
322 } else {
323 Style::default().add_modifier(Modifier::REVERSED)
324 };
325 spans.push(Span::styled(item.name.clone(), name_style));
326 }
327 } else {
328 // Unselected item
329 let name_style = if item.status.is_gitignored {
330 Style::default().fg(Color::DarkGray)
331 } else {
332 Style::default()
333 };
334 spans.push(Span::styled(item.name.clone(), name_style));
335 }
336
337 // Add status indicators
338 if item.status.is_staged {
339 spans.push(Span::styled(" ↑", Style::default().fg(Color::Green)));
340 }
341 if item.status.is_unstaged {
342 spans.push(Span::styled(" ✗", Style::default().fg(Color::Red)));
343 }
344 if item.status.is_untracked {
345 spans.push(Span::styled(" ✗", Style::default().fg(Color::DarkGray)));
346 }
347 if item.status.has_incoming {
348 spans.push(Span::styled(" ↓", Style::default().fg(Color::Blue)));
349 }
350
351 Line::from(spans)
352 }
353
354 /// Draw help/status bar
355 fn draw_help(frame: &mut Frame, app: &App, area: Rect) {
356 let (line1, line2) = match &app.input_mode {
357 InputMode::Rename { .. } => (
358 Line::from(vec![
359 Span::styled(
360 "RENAME: ",
361 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
362 ),
363 Span::raw("Type new name | Tab:save | ESC:cancel"),
364 ]),
365 Line::from(""),
366 ),
367 InputMode::Commit { .. } => (
368 Line::from(vec![
369 Span::styled(
370 "COMMIT: ",
371 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
372 ),
373 Span::raw("Type message | Enter:commit | ESC:cancel"),
374 ]),
375 Line::from(""),
376 ),
377 InputMode::Search { buffer } => (
378 Line::from(vec![
379 Span::styled("SEARCH: ", Style::default().fg(Color::Cyan)),
380 Span::raw(buffer.clone()),
381 ]),
382 Line::from(""),
383 ),
384 _ => {
385 let legend = Line::from(vec![
386 Span::styled("↑", Style::default().fg(Color::Green)),
387 Span::raw("=staged "),
388 Span::styled("✗", Style::default().fg(Color::Red)),
389 Span::raw("=mod "),
390 Span::styled("✗", Style::default().fg(Color::DarkGray)),
391 Span::raw("=new "),
392 Span::styled("↓", Style::default().fg(Color::Blue)),
393 Span::raw("=incoming"),
394 ]);
395
396 let keys = if app.mode == AppMode::Git {
397 Line::from(vec![
398 Span::styled("GIT: ", Style::default().fg(Color::Yellow)),
399 Span::raw("a/u:stage S/U:all x:discard m:commit f:fetch l:pull p:push q/ESC:exit ^Q:quit"),
400 ])
401 } else {
402 Line::from("j/k:nav ←/→:tree space:toggle .:dots alt-g:git ^Q:quit")
403 };
404
405 (legend, keys)
406 }
407 };
408
409 let help = Paragraph::new(vec![line1, line2]);
410 frame.render_widget(help, area);
411 }
412
413 /// Print tree to stdout (non-interactive mode)
414 pub fn print_tree(app: &App) {
415 println!(".");
416 for item in &app.items {
417 print_tree_item(item);
418 }
419 }
420
421 fn print_tree_item(item: &SelectableItem) {
422 // Build prefix
423 let mut line = String::new();
424
425 for &is_last in &item.ancestors_are_last {
426 if is_last {
427 line.push_str(" ");
428 } else {
429 line.push_str("│ ");
430 }
431 }
432
433 // Branch character
434 if item.is_last_sibling {
435 line.push_str("└── ");
436 } else {
437 line.push_str("├── ");
438 }
439
440 // Name with color codes
441 if item.status.is_gitignored {
442 line.push_str("\x1b[90m");
443 }
444 line.push_str(&item.name);
445 if item.status.is_gitignored {
446 line.push_str("\x1b[0m");
447 }
448
449 // Status indicators
450 if item.status.is_staged {
451 line.push_str("\x1b[32m ↑\x1b[0m");
452 }
453 if item.status.is_unstaged {
454 line.push_str("\x1b[31m ✗\x1b[0m");
455 }
456 if item.status.is_untracked {
457 line.push_str("\x1b[90m ✗\x1b[0m");
458 }
459 if item.status.has_incoming {
460 line.push_str("\x1b[34m ↓\x1b[0m");
461 }
462
463 println!("{}", line);
464 }
465