Rust · 29206 bytes Raw Blame History
1 use crate::app::App;
2 use crate::types::{AppMode, CommitStatus, FetchStatus, InputMode, PullStatus, PushStatus, SelectableItem, TagStep};
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 // Draw modal overlay if in pull mode
37 if let InputMode::Pull { remotes, selected, status } = &app.input_mode {
38 draw_pull_modal(frame, remotes, *selected, status, &app.branch_name);
39 }
40
41 // Draw modal overlay if in fetch mode
42 if let InputMode::Fetch { remotes, selected, status } = &app.input_mode {
43 draw_fetch_modal(frame, remotes, *selected, status);
44 }
45
46 // Draw modal overlay if in tag mode
47 if let InputMode::Tag { name, message, cursor, existing_tags, step } = &app.input_mode {
48 draw_tag_modal(frame, name, message, *cursor, existing_tags, step);
49 }
50
51 // Draw modal overlay if in confirm mode
52 if let InputMode::Confirm { message, .. } = &app.input_mode {
53 draw_confirm_modal(frame, message);
54 }
55 }
56
57 /// Draw commit message modal
58 fn draw_commit_modal(frame: &mut Frame, buffer: &str, cursor: usize, amend: bool, status: &CommitStatus) {
59 let area = frame.area();
60
61 // Center the modal
62 let modal_width = 60.min(area.width.saturating_sub(4));
63 let modal_height = 5;
64 let x = (area.width.saturating_sub(modal_width)) / 2;
65 let y = (area.height.saturating_sub(modal_height)) / 2;
66
67 let modal_area = Rect::new(x, y, modal_width, modal_height);
68
69 // Clear area behind modal
70 frame.render_widget(Clear, modal_area);
71
72 // Title and border color based on status
73 let (title, border_color) = match status {
74 CommitStatus::Editing => {
75 let t = if amend { " Amend Commit " } else { " Commit " };
76 (t, Color::Green)
77 }
78 CommitStatus::Committing => (" Committing... ", Color::Yellow),
79 CommitStatus::Success => (" ✓ Committed ", Color::Green),
80 CommitStatus::Failed => (" ✗ Failed ", Color::Red),
81 };
82
83 let block = Block::default()
84 .title(title)
85 .borders(Borders::ALL)
86 .border_style(Style::default().fg(border_color));
87
88 // Content based on status
89 let content = match status {
90 CommitStatus::Editing => {
91 // Build input line with cursor
92 let display_text = if cursor >= buffer.len() {
93 format!("{}█", buffer)
94 } else {
95 format!("{}{}", &buffer[..cursor], &buffer[cursor..])
96 };
97 vec![
98 Line::from(""),
99 Line::from(Span::raw(display_text)),
100 ]
101 }
102 CommitStatus::Committing => {
103 vec![
104 Line::from(""),
105 Line::from(Span::styled(
106 " Committing changes...",
107 Style::default().fg(Color::Yellow),
108 )),
109 ]
110 }
111 CommitStatus::Success => {
112 vec![
113 Line::from(""),
114 Line::from(Span::styled(
115 " ✓ Changes committed successfully!",
116 Style::default().fg(Color::Green),
117 )),
118 ]
119 }
120 CommitStatus::Failed => {
121 vec![
122 Line::from(""),
123 Line::from(Span::styled(
124 " ✗ Commit failed (nothing staged?)",
125 Style::default().fg(Color::Red),
126 )),
127 ]
128 }
129 };
130
131 let input = Paragraph::new(content).block(block);
132 frame.render_widget(input, modal_area);
133 }
134
135 /// Draw push remote selection modal
136 fn draw_push_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &PushStatus, branch: &str) {
137 let area = frame.area();
138
139 // Modal size based on content
140 let modal_height = match status {
141 PushStatus::SelectRemote => (remotes.len() + 4).min(12) as u16,
142 _ => 5,
143 };
144 let modal_width = 50.min(area.width.saturating_sub(4));
145 let x = (area.width.saturating_sub(modal_width)) / 2;
146 let y = (area.height.saturating_sub(modal_height)) / 2;
147
148 let modal_area = Rect::new(x, y, modal_width, modal_height);
149
150 // Clear area behind modal
151 frame.render_widget(Clear, modal_area);
152
153 // Title and border color based on status
154 let (title, border_color) = match status {
155 PushStatus::SelectRemote => (" Push - Select Remote ", Color::Cyan),
156 PushStatus::Pushing => (" Pushing... ", Color::Yellow),
157 PushStatus::Success => (" ✓ Pushed ", Color::Green),
158 PushStatus::Failed(_) => (" ✗ Push Failed ", Color::Red),
159 };
160
161 let block = Block::default()
162 .title(title)
163 .borders(Borders::ALL)
164 .border_style(Style::default().fg(border_color));
165
166 // Content based on status
167 let content: Vec<Line> = match status {
168 PushStatus::SelectRemote => {
169 let mut lines = vec![
170 Line::from(Span::styled(
171 format!(" Set upstream for '{}':", branch),
172 Style::default().fg(Color::White),
173 )),
174 Line::from(""),
175 ];
176 for (i, remote) in remotes.iter().enumerate() {
177 let style = if i == selected {
178 Style::default().add_modifier(Modifier::REVERSED)
179 } else {
180 Style::default()
181 };
182 lines.push(Line::from(Span::styled(format!(" {}", remote), style)));
183 }
184 lines.push(Line::from(""));
185 lines.push(Line::from(Span::styled(
186 " j/k:select Enter:push ESC:cancel",
187 Style::default().fg(Color::DarkGray),
188 )));
189 lines
190 }
191 PushStatus::Pushing => {
192 vec![
193 Line::from(""),
194 Line::from(Span::styled(
195 " Pushing changes...",
196 Style::default().fg(Color::Yellow),
197 )),
198 ]
199 }
200 PushStatus::Success => {
201 vec![
202 Line::from(""),
203 Line::from(Span::styled(
204 " ✓ Changes pushed successfully!",
205 Style::default().fg(Color::Green),
206 )),
207 ]
208 }
209 PushStatus::Failed(msg) => {
210 vec![
211 Line::from(""),
212 Line::from(Span::styled(
213 format!(" ✗ {}", msg),
214 Style::default().fg(Color::Red),
215 )),
216 ]
217 }
218 };
219
220 let widget = Paragraph::new(content).block(block);
221 frame.render_widget(widget, modal_area);
222 }
223
224 /// Draw pull remote selection modal
225 fn draw_pull_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &PullStatus, branch: &str) {
226 let area = frame.area();
227
228 let modal_height = match status {
229 PullStatus::SelectRemote => (remotes.len() + 4).min(12) as u16,
230 _ => 5,
231 };
232 let modal_width = 50.min(area.width.saturating_sub(4));
233 let x = (area.width.saturating_sub(modal_width)) / 2;
234 let y = (area.height.saturating_sub(modal_height)) / 2;
235
236 let modal_area = Rect::new(x, y, modal_width, modal_height);
237
238 frame.render_widget(Clear, modal_area);
239
240 let (title, border_color) = match status {
241 PullStatus::SelectRemote => (" Pull - Select Remote ", Color::Cyan),
242 PullStatus::Pulling => (" Pulling... ", Color::Yellow),
243 PullStatus::Success => (" ✓ Pulled ", Color::Green),
244 PullStatus::Failed(_) => (" ✗ Pull Failed ", Color::Red),
245 };
246
247 let block = Block::default()
248 .title(title)
249 .borders(Borders::ALL)
250 .border_style(Style::default().fg(border_color));
251
252 let content: Vec<Line> = match status {
253 PullStatus::SelectRemote => {
254 let mut lines = vec![
255 Line::from(Span::styled(
256 format!(" Set upstream for '{}':", branch),
257 Style::default().fg(Color::White),
258 )),
259 Line::from(""),
260 ];
261 for (i, remote) in remotes.iter().enumerate() {
262 let style = if i == selected {
263 Style::default().add_modifier(Modifier::REVERSED)
264 } else {
265 Style::default()
266 };
267 lines.push(Line::from(Span::styled(format!(" {}", remote), style)));
268 }
269 lines.push(Line::from(""));
270 lines.push(Line::from(Span::styled(
271 " j/k:select Enter:pull ESC:cancel",
272 Style::default().fg(Color::DarkGray),
273 )));
274 lines
275 }
276 PullStatus::Pulling => {
277 vec![
278 Line::from(""),
279 Line::from(Span::styled(
280 " Pulling changes...",
281 Style::default().fg(Color::Yellow),
282 )),
283 ]
284 }
285 PullStatus::Success => {
286 vec![
287 Line::from(""),
288 Line::from(Span::styled(
289 " ✓ Changes pulled successfully!",
290 Style::default().fg(Color::Green),
291 )),
292 ]
293 }
294 PullStatus::Failed(msg) => {
295 vec![
296 Line::from(""),
297 Line::from(Span::styled(
298 format!(" ✗ {}", msg),
299 Style::default().fg(Color::Red),
300 )),
301 ]
302 }
303 };
304
305 let widget = Paragraph::new(content).block(block);
306 frame.render_widget(widget, modal_area);
307 }
308
309 /// Draw fetch remote selection modal
310 fn draw_fetch_modal(frame: &mut Frame, remotes: &[String], selected: usize, status: &FetchStatus) {
311 let area = frame.area();
312
313 let modal_height = match status {
314 FetchStatus::SelectRemote => (remotes.len() + 4).min(12) as u16,
315 _ => 5,
316 };
317 let modal_width = 50.min(area.width.saturating_sub(4));
318 let x = (area.width.saturating_sub(modal_width)) / 2;
319 let y = (area.height.saturating_sub(modal_height)) / 2;
320
321 let modal_area = Rect::new(x, y, modal_width, modal_height);
322
323 frame.render_widget(Clear, modal_area);
324
325 let (title, border_color) = match status {
326 FetchStatus::SelectRemote => (" Fetch - Select Remote ", Color::Cyan),
327 FetchStatus::Fetching => (" Fetching... ", Color::Yellow),
328 FetchStatus::Success => (" ✓ Fetched ", Color::Green),
329 FetchStatus::Failed(_) => (" ✗ Fetch Failed ", Color::Red),
330 };
331
332 let block = Block::default()
333 .title(title)
334 .borders(Borders::ALL)
335 .border_style(Style::default().fg(border_color));
336
337 let content: Vec<Line> = match status {
338 FetchStatus::SelectRemote => {
339 let mut lines = vec![
340 Line::from(Span::styled(
341 " Select remote to fetch from:",
342 Style::default().fg(Color::White),
343 )),
344 Line::from(""),
345 ];
346 for (i, remote) in remotes.iter().enumerate() {
347 let style = if i == selected {
348 Style::default().add_modifier(Modifier::REVERSED)
349 } else {
350 Style::default()
351 };
352 lines.push(Line::from(Span::styled(format!(" {}", remote), style)));
353 }
354 lines.push(Line::from(""));
355 lines.push(Line::from(Span::styled(
356 " j/k:select Enter:fetch ESC:cancel",
357 Style::default().fg(Color::DarkGray),
358 )));
359 lines
360 }
361 FetchStatus::Fetching => {
362 vec![
363 Line::from(""),
364 Line::from(Span::styled(
365 " Fetching from remote...",
366 Style::default().fg(Color::Yellow),
367 )),
368 ]
369 }
370 FetchStatus::Success => {
371 vec![
372 Line::from(""),
373 Line::from(Span::styled(
374 " ✓ Fetch complete!",
375 Style::default().fg(Color::Green),
376 )),
377 ]
378 }
379 FetchStatus::Failed(msg) => {
380 vec![
381 Line::from(""),
382 Line::from(Span::styled(
383 format!(" ✗ {}", msg),
384 Style::default().fg(Color::Red),
385 )),
386 ]
387 }
388 };
389
390 let widget = Paragraph::new(content).block(block);
391 frame.render_widget(widget, modal_area);
392 }
393
394 /// Draw tag creation modal
395 fn draw_tag_modal(
396 frame: &mut Frame,
397 name: &str,
398 message: &str,
399 cursor: usize,
400 existing_tags: &[String],
401 step: &TagStep,
402 ) {
403 let area = frame.area();
404
405 // Modal size based on step
406 let modal_height = match step {
407 TagStep::EnterName | TagStep::EnterMessage => {
408 let tags_height = existing_tags.len().min(5) as u16;
409 8 + tags_height
410 }
411 TagStep::AskPush => 6,
412 _ => 5,
413 };
414 let modal_width = 55.min(area.width.saturating_sub(4));
415 let x = (area.width.saturating_sub(modal_width)) / 2;
416 let y = (area.height.saturating_sub(modal_height)) / 2;
417
418 let modal_area = Rect::new(x, y, modal_width, modal_height);
419
420 frame.render_widget(Clear, modal_area);
421
422 let (title, border_color) = match step {
423 TagStep::EnterName => (" Tag - Enter Name ", Color::Cyan),
424 TagStep::EnterMessage => (" Tag - Enter Message ", Color::Cyan),
425 TagStep::Creating => (" Creating Tag... ", Color::Yellow),
426 TagStep::AskPush => (" Push Tag? ", Color::Yellow),
427 TagStep::Pushing => (" Pushing Tag... ", Color::Yellow),
428 TagStep::Success => (" ✓ Tag Pushed ", Color::Green),
429 TagStep::Failed(_) => (" ✗ Tag Failed ", Color::Red),
430 };
431
432 let block = Block::default()
433 .title(title)
434 .borders(Borders::ALL)
435 .border_style(Style::default().fg(border_color));
436
437 let content: Vec<Line> = match step {
438 TagStep::EnterName => {
439 let mut lines = vec![];
440
441 // Show existing tags
442 if !existing_tags.is_empty() {
443 lines.push(Line::from(Span::styled(
444 " Recent tags:",
445 Style::default().fg(Color::DarkGray),
446 )));
447 for tag in existing_tags.iter().take(5) {
448 lines.push(Line::from(Span::styled(
449 format!(" {}", tag),
450 Style::default().fg(Color::DarkGray),
451 )));
452 }
453 lines.push(Line::from(""));
454 }
455
456 // Name input with cursor
457 let display_name = if cursor < name.len() {
458 format!(
459 "{}{}",
460 &name[..cursor],
461 &name[cursor..]
462 )
463 } else {
464 format!("{}█", name)
465 };
466 lines.push(Line::from(vec![
467 Span::styled(" Name: ", Style::default().fg(Color::White)),
468 Span::styled(display_name, Style::default().fg(Color::Yellow)),
469 ]));
470
471 lines.push(Line::from(""));
472 lines.push(Line::from(Span::styled(
473 " Enter:next ESC:cancel",
474 Style::default().fg(Color::DarkGray),
475 )));
476 lines
477 }
478 TagStep::EnterMessage => {
479 let mut lines = vec![];
480
481 // Show the name
482 lines.push(Line::from(vec![
483 Span::styled(" Name: ", Style::default().fg(Color::DarkGray)),
484 Span::styled(name, Style::default().fg(Color::Yellow)),
485 ]));
486 lines.push(Line::from(""));
487
488 // Message input with cursor
489 let display_msg = if cursor < message.len() {
490 format!(
491 "{}{}",
492 &message[..cursor],
493 &message[cursor..]
494 )
495 } else {
496 format!("{}█", message)
497 };
498 lines.push(Line::from(vec![
499 Span::styled(" Message: ", Style::default().fg(Color::White)),
500 Span::styled(display_msg, Style::default().fg(Color::Cyan)),
501 ]));
502
503 lines.push(Line::from(Span::styled(
504 " (optional - leave empty for lightweight tag)",
505 Style::default().fg(Color::DarkGray),
506 )));
507 lines.push(Line::from(""));
508 lines.push(Line::from(Span::styled(
509 " Enter:create ESC:back",
510 Style::default().fg(Color::DarkGray),
511 )));
512 lines
513 }
514 TagStep::Creating => {
515 vec![
516 Line::from(""),
517 Line::from(Span::styled(
518 format!(" Creating tag '{}'...", name),
519 Style::default().fg(Color::Yellow),
520 )),
521 ]
522 }
523 TagStep::AskPush => {
524 vec![
525 Line::from(""),
526 Line::from(Span::styled(
527 format!(" Tag '{}' created!", name),
528 Style::default().fg(Color::Green),
529 )),
530 Line::from(""),
531 Line::from(vec![
532 Span::raw(" Push to origin? "),
533 Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
534 Span::raw("es / "),
535 Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
536 Span::raw("o"),
537 ]),
538 ]
539 }
540 TagStep::Pushing => {
541 vec![
542 Line::from(""),
543 Line::from(Span::styled(
544 format!(" Pushing tag '{}'...", name),
545 Style::default().fg(Color::Yellow),
546 )),
547 ]
548 }
549 TagStep::Success => {
550 vec![
551 Line::from(""),
552 Line::from(Span::styled(
553 format!(" ✓ Tag '{}' pushed to origin!", name),
554 Style::default().fg(Color::Green),
555 )),
556 ]
557 }
558 TagStep::Failed(msg) => {
559 vec![
560 Line::from(""),
561 Line::from(Span::styled(
562 format!(" ✗ {}", msg),
563 Style::default().fg(Color::Red),
564 )),
565 ]
566 }
567 };
568
569 let widget = Paragraph::new(content).block(block);
570 frame.render_widget(widget, modal_area);
571 }
572
573 /// Draw confirmation modal
574 fn draw_confirm_modal(frame: &mut Frame, message: &str) {
575 let area = frame.area();
576
577 let modal_width = 50.min(area.width.saturating_sub(4));
578 let modal_height = 5;
579 let x = (area.width.saturating_sub(modal_width)) / 2;
580 let y = (area.height.saturating_sub(modal_height)) / 2;
581
582 let modal_area = Rect::new(x, y, modal_width, modal_height);
583
584 // Clear area behind modal
585 frame.render_widget(Clear, modal_area);
586
587 let block = Block::default()
588 .title(" Confirm ")
589 .borders(Borders::ALL)
590 .border_style(Style::default().fg(Color::Yellow));
591
592 let content = vec![
593 Line::from(""),
594 Line::from(Span::styled(
595 format!(" {}", message),
596 Style::default().fg(Color::White),
597 )),
598 Line::from(vec![
599 Span::raw(" "),
600 Span::styled("y", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
601 Span::raw("es / "),
602 Span::styled("n", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
603 Span::raw("o"),
604 ]),
605 ];
606
607 let widget = Paragraph::new(content).block(block);
608 frame.render_widget(widget, modal_area);
609 }
610
611 /// Draw header with repo:branch info
612 fn draw_header(frame: &mut Frame, app: &App, area: Rect) {
613 let mut spans = vec![
614 Span::styled(&app.repo_name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
615 Span::raw(":"),
616 Span::styled(&app.branch_name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
617 ];
618
619 // Show mode indicator
620 if app.mode == AppMode::Git {
621 spans.push(Span::raw(" "));
622 spans.push(Span::styled(
623 "[ GIT MODE ]",
624 Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
625 ));
626 }
627
628 // Show search buffer if active
629 if !app.search_buffer.is_empty() {
630 spans.push(Span::raw(" "));
631 spans.push(Span::styled(
632 format!("/{}", &app.search_buffer),
633 Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
634 ));
635 }
636
637 // Show status message if any
638 if let Some(ref msg) = app.status_message {
639 spans.push(Span::raw(" - "));
640 spans.push(Span::styled(msg, Style::default().fg(Color::Green)));
641 }
642
643 let header = Paragraph::new(Line::from(spans));
644 frame.render_widget(header, area);
645 }
646
647 /// Draw the file tree
648 fn draw_tree(frame: &mut Frame, app: &App, area: Rect) {
649 let visible_height = area.height as usize;
650 let total_items = app.items.len();
651
652 // Calculate viewport bounds centered on selection
653 let half_visible = visible_height / 2;
654 let viewport_start = if app.selected > half_visible {
655 (app.selected - half_visible).min(total_items.saturating_sub(visible_height))
656 } else {
657 0
658 };
659 let viewport_end = (viewport_start + visible_height).min(total_items);
660
661 // Build tree lines
662 let mut lines: Vec<Line> = Vec::new();
663
664 // Add root "." on first line if viewport starts at 0
665 if viewport_start == 0 && !app.items.is_empty() {
666 lines.push(Line::from(Span::raw(".")));
667 }
668
669 // Add visible items
670 for (i, item) in app.items.iter().enumerate().skip(viewport_start).take(viewport_end - viewport_start) {
671 let line = render_tree_item(item, i == app.selected, &app.input_mode);
672 lines.push(line);
673 }
674
675 let tree = Paragraph::new(lines).block(Block::default());
676 frame.render_widget(tree, area);
677 }
678
679 /// Render a single tree item
680 fn render_tree_item(item: &SelectableItem, is_selected: bool, input_mode: &InputMode) -> Line<'static> {
681 let mut spans: Vec<Span> = Vec::new();
682
683 // Build prefix based on depth and tree structure
684 for (depth, &is_last) in item.ancestors_are_last.iter().enumerate() {
685 if depth < item.ancestors_are_last.len() {
686 if is_last {
687 spans.push(Span::raw(" "));
688 } else {
689 spans.push(Span::raw("│ "));
690 }
691 }
692 }
693
694 // Add branch character
695 if item.is_last_sibling {
696 spans.push(Span::raw("└── "));
697 } else {
698 spans.push(Span::raw("├── "));
699 }
700
701 // Add expand/collapse indicator for directories
702 if !item.is_file {
703 if item.is_expanded {
704 spans.push(Span::raw("▼ "));
705 } else {
706 spans.push(Span::raw("▶ "));
707 }
708 }
709
710 // Handle rename mode
711 if is_selected {
712 if let InputMode::Rename { buffer, cursor } = input_mode {
713 // Show editable name with cursor
714 let name_with_cursor = if *cursor >= buffer.len() {
715 format!("{}█", buffer)
716 } else {
717 format!("{}{}", &buffer[..*cursor], &buffer[*cursor..])
718 };
719 spans.push(Span::styled(
720 name_with_cursor,
721 Style::default().add_modifier(Modifier::REVERSED),
722 ));
723 } else {
724 // Normal selection highlight
725 let name_style = if item.status.is_gitignored {
726 Style::default()
727 .fg(Color::DarkGray)
728 .add_modifier(Modifier::REVERSED)
729 } else {
730 Style::default().add_modifier(Modifier::REVERSED)
731 };
732 spans.push(Span::styled(item.name.clone(), name_style));
733 }
734 } else {
735 // Unselected item
736 let name_style = if item.status.is_gitignored {
737 Style::default().fg(Color::DarkGray)
738 } else {
739 Style::default()
740 };
741 spans.push(Span::styled(item.name.clone(), name_style));
742 }
743
744 // Add status indicators
745 if item.status.is_staged {
746 spans.push(Span::styled(" ↑", Style::default().fg(Color::Green)));
747 }
748 if item.status.is_unstaged {
749 spans.push(Span::styled(" ✗", Style::default().fg(Color::Red)));
750 }
751 if item.status.is_untracked {
752 spans.push(Span::styled(" ✗", Style::default().fg(Color::DarkGray)));
753 }
754 if item.status.has_incoming {
755 spans.push(Span::styled(" ↓", Style::default().fg(Color::Blue)));
756 }
757
758 Line::from(spans)
759 }
760
761 /// Draw help/status bar
762 fn draw_help(frame: &mut Frame, app: &App, area: Rect) {
763 let (line1, line2) = match &app.input_mode {
764 InputMode::Rename { .. } => (
765 Line::from(vec![
766 Span::styled(
767 "RENAME: ",
768 Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
769 ),
770 Span::raw("Type new name | Tab:save | ESC:cancel"),
771 ]),
772 Line::from(""),
773 ),
774 InputMode::Commit { .. } => (
775 Line::from(vec![
776 Span::styled(
777 "COMMIT: ",
778 Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
779 ),
780 Span::raw("Type message | Enter:commit | ESC:cancel"),
781 ]),
782 Line::from(""),
783 ),
784 InputMode::Search { buffer } => (
785 Line::from(vec![
786 Span::styled("SEARCH: ", Style::default().fg(Color::Cyan)),
787 Span::raw(buffer.clone()),
788 ]),
789 Line::from(""),
790 ),
791 _ => {
792 let legend = Line::from(vec![
793 Span::styled("↑", Style::default().fg(Color::Green)),
794 Span::raw("=staged "),
795 Span::styled("✗", Style::default().fg(Color::Red)),
796 Span::raw("=mod "),
797 Span::styled("✗", Style::default().fg(Color::DarkGray)),
798 Span::raw("=new "),
799 Span::styled("↓", Style::default().fg(Color::Blue)),
800 Span::raw("=incoming"),
801 ]);
802
803 let keys = if app.mode == AppMode::Git {
804 Line::from(vec![
805 Span::styled("GIT: ", Style::default().fg(Color::Yellow)),
806 Span::raw("a/u:stage S/U:all x:discard m:commit t:tag f:fetch l:pull p:push q/ESC:exit ^Q:quit"),
807 ])
808 } else {
809 Line::from("j/k:nav ←/→:tree space:toggle .:dots alt-g:git ^Q:quit")
810 };
811
812 (legend, keys)
813 }
814 };
815
816 let help = Paragraph::new(vec![line1, line2]);
817 frame.render_widget(help, area);
818 }
819
820 /// Print tree to stdout (non-interactive mode)
821 pub fn print_tree(app: &App) {
822 println!(".");
823 for item in &app.items {
824 print_tree_item(item);
825 }
826 }
827
828 fn print_tree_item(item: &SelectableItem) {
829 // Build prefix
830 let mut line = String::new();
831
832 for &is_last in &item.ancestors_are_last {
833 if is_last {
834 line.push_str(" ");
835 } else {
836 line.push_str("│ ");
837 }
838 }
839
840 // Branch character
841 if item.is_last_sibling {
842 line.push_str("└── ");
843 } else {
844 line.push_str("├── ");
845 }
846
847 // Name with color codes
848 if item.status.is_gitignored {
849 line.push_str("\x1b[90m");
850 }
851 line.push_str(&item.name);
852 if item.status.is_gitignored {
853 line.push_str("\x1b[0m");
854 }
855
856 // Status indicators
857 if item.status.is_staged {
858 line.push_str("\x1b[32m ↑\x1b[0m");
859 }
860 if item.status.is_unstaged {
861 line.push_str("\x1b[31m ✗\x1b[0m");
862 }
863 if item.status.is_untracked {
864 line.push_str("\x1b[90m ✗\x1b[0m");
865 }
866 if item.status.has_incoming {
867 line.push_str("\x1b[34m ↓\x1b[0m");
868 }
869
870 println!("{}", line);
871 }
872