1use std::path::Path;
4
5use crate::app::WarningLevel;
6use crate::config::{StatusBarConfig, StatusBarElement};
7use crate::primitives::display_width::{char_width, str_width};
8use crate::state::EditorState;
9use crate::view::prompt::Prompt;
10use chrono::Timelike;
11use ratatui::layout::Rect;
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15use ratatui::Frame;
16use rust_i18n::t;
17
18const SSH_PREFIX: &str = "[SSH:";
22const SSH_PREFIX_TERMINATOR: &str = "] ";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum ElementKind {
27 Normal,
29 LineEnding,
31 Encoding,
33 Language,
35 Lsp,
37 WarningBadge,
39 Update,
41 Palette,
43 Messages,
45 RemoteDisconnected,
47 Clock,
49 RemoteIndicator(RemoteIndicatorState),
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum RemoteIndicatorState {
68 #[default]
70 Local,
71 Connecting,
76 Connected,
78 FailedAttach,
82 Disconnected,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98pub enum RemoteIndicatorOverride {
99 Local,
102 Connecting {
105 #[serde(default)]
106 label: Option<String>,
107 },
108 Connected {
111 #[serde(default)]
112 label: Option<String>,
113 },
114 FailedAttach {
117 #[serde(default)]
118 error: Option<String>,
119 },
120 Disconnected {
123 #[serde(default)]
124 label: Option<String>,
125 },
126}
127
128impl RemoteIndicatorOverride {
129 pub fn state(&self) -> RemoteIndicatorState {
131 match self {
132 Self::Local => RemoteIndicatorState::Local,
133 Self::Connecting { .. } => RemoteIndicatorState::Connecting,
134 Self::Connected { .. } => RemoteIndicatorState::Connected,
135 Self::FailedAttach { .. } => RemoteIndicatorState::FailedAttach,
136 Self::Disconnected { .. } => RemoteIndicatorState::Disconnected,
137 }
138 }
139
140 pub fn label(&self) -> String {
144 match self {
145 Self::Local => "Local".to_string(),
146 Self::Connecting { label } => match label {
147 Some(s) if !s.is_empty() => format!("⠿ {}", s),
148 _ => "⠿ Connecting".to_string(),
149 },
150 Self::Connected { label } => label
151 .as_deref()
152 .filter(|s| !s.is_empty())
153 .unwrap_or("Connected")
154 .to_string(),
155 Self::FailedAttach { error } => match error {
156 Some(s) if !s.is_empty() => format!("Attach failed: {}", s),
157 _ => "Attach failed".to_string(),
158 },
159 Self::Disconnected { label } => match label {
160 Some(s) if !s.is_empty() => format!("{} (Disconnected)", s),
161 _ => "Disconnected".to_string(),
162 },
163 }
164 }
165}
166
167struct RenderedElement {
169 text: String,
170 kind: ElementKind,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum LspIndicatorState {
190 #[default]
191 None,
192 On,
193 Off,
194 OffDismissed,
195 Error,
196}
197
198pub struct StatusBarContext<'a> {
200 pub state: &'a mut EditorState,
201 pub cursors: &'a crate::model::cursor::Cursors,
202 pub status_message: &'a Option<String>,
203 pub plugin_status_message: &'a Option<String>,
204 pub lsp_status: &'a str,
205 pub lsp_indicator_state: LspIndicatorState,
210 pub theme: &'a crate::view::theme::Theme,
211 pub display_name: &'a str,
212 pub keybindings: &'a crate::input::keybindings::KeybindingResolver,
213 pub chord_state: &'a [(crossterm::event::KeyCode, crossterm::event::KeyModifiers)],
214 pub update_available: Option<&'a str>,
215 pub warning_level: WarningLevel,
216 pub general_warning_count: usize,
217 pub hover: StatusBarHover,
218 pub remote_connection: Option<&'a str>,
219 pub session_name: Option<&'a str>,
220 pub read_only: bool,
221 pub remote_state_override: Option<&'a RemoteIndicatorOverride>,
228}
229
230#[derive(Debug, Clone, Default)]
232pub struct StatusBarLayout {
233 pub lsp_indicator: Option<(u16, u16, u16)>,
235 pub warning_badge: Option<(u16, u16, u16)>,
237 pub line_ending_indicator: Option<(u16, u16, u16)>,
239 pub encoding_indicator: Option<(u16, u16, u16)>,
241 pub language_indicator: Option<(u16, u16, u16)>,
243 pub message_area: Option<(u16, u16, u16)>,
245 pub remote_indicator: Option<(u16, u16, u16)>,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
252pub enum StatusBarHover {
253 #[default]
254 None,
255 LspIndicator,
257 WarningBadge,
259 LineEndingIndicator,
261 EncodingIndicator,
263 LanguageIndicator,
265 MessageArea,
267 RemoteIndicator,
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
273pub enum SearchOptionsHover {
274 #[default]
275 None,
276 CaseSensitive,
277 WholeWord,
278 Regex,
279 ConfirmEach,
280}
281
282#[derive(Debug, Clone, Default)]
284pub struct SearchOptionsLayout {
285 pub row: u16,
287 pub case_sensitive: Option<(u16, u16)>,
289 pub whole_word: Option<(u16, u16)>,
291 pub regex: Option<(u16, u16)>,
293 pub confirm_each: Option<(u16, u16)>,
295}
296
297impl SearchOptionsLayout {
298 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
300 if y != self.row {
301 return None;
302 }
303
304 if let Some((start, end)) = self.case_sensitive {
305 if x >= start && x < end {
306 return Some(SearchOptionsHover::CaseSensitive);
307 }
308 }
309 if let Some((start, end)) = self.whole_word {
310 if x >= start && x < end {
311 return Some(SearchOptionsHover::WholeWord);
312 }
313 }
314 if let Some((start, end)) = self.regex {
315 if x >= start && x < end {
316 return Some(SearchOptionsHover::Regex);
317 }
318 }
319 if let Some((start, end)) = self.confirm_each {
320 if x >= start && x < end {
321 return Some(SearchOptionsHover::ConfirmEach);
322 }
323 }
324 None
325 }
326}
327
328#[derive(Debug, Clone)]
330pub struct TruncatedPath {
331 pub prefix: String,
333 pub truncated: bool,
335 pub suffix: String,
337}
338
339impl TruncatedPath {
340 pub fn to_string_plain(&self) -> String {
342 if self.truncated {
343 format!("{}/[...]{}", self.prefix, self.suffix)
344 } else {
345 format!("{}{}", self.prefix, self.suffix)
346 }
347 }
348
349 pub fn display_len(&self) -> usize {
351 if self.truncated {
352 self.prefix.len() + "/[...]".len() + self.suffix.len()
353 } else {
354 self.prefix.len() + self.suffix.len()
355 }
356 }
357}
358
359pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
371 let path_str = path.to_string_lossy();
372
373 if path_str.len() <= max_len {
375 return TruncatedPath {
376 prefix: String::new(),
377 truncated: false,
378 suffix: path_str.to_string(),
379 };
380 }
381
382 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
383
384 if components.is_empty() {
385 return TruncatedPath {
386 prefix: "/".to_string(),
387 truncated: false,
388 suffix: String::new(),
389 };
390 }
391
392 let prefix = if path_str.starts_with('/') {
394 format!("/{}", components.first().unwrap_or(&""))
395 } else {
396 components.first().unwrap_or(&"").to_string()
397 };
398
399 let ellipsis_len = "/[...]".len();
401
402 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
404
405 if available_for_suffix < 5 || components.len() <= 1 {
406 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
408 format!("{}...", &path_str[..max_len.saturating_sub(3)])
409 } else {
410 path_str.to_string()
411 };
412 return TruncatedPath {
413 prefix: String::new(),
414 truncated: false,
415 suffix: truncated_path,
416 };
417 }
418
419 let mut suffix_parts: Vec<&str> = Vec::new();
421 let mut suffix_len = 0;
422
423 for component in components.iter().skip(1).rev() {
424 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
426 suffix_parts.push(component);
427 suffix_len += component_len;
428 } else {
429 break;
430 }
431 }
432
433 suffix_parts.reverse();
434
435 if suffix_parts.len() == components.len() - 1 {
437 return TruncatedPath {
438 prefix: String::new(),
439 truncated: false,
440 suffix: path_str.to_string(),
441 };
442 }
443
444 let suffix = if suffix_parts.is_empty() {
445 let last = components.last().unwrap_or(&"");
447 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
449 format!("/{}...", &last[..truncate_to])
450 } else {
451 format!("/{}", last)
452 }
453 } else {
454 format!("/{}", suffix_parts.join("/"))
455 };
456
457 TruncatedPath {
458 prefix,
459 truncated: true,
460 suffix,
461 }
462}
463
464fn truncate_to_width(s: &str, max_width: usize) -> String {
466 let width = str_width(s);
467 if width <= max_width {
468 return s.to_string();
469 }
470 let truncate_at = max_width.saturating_sub(3);
471 if truncate_at == 0 {
472 return if max_width >= 3 {
473 "...".to_string()
474 } else {
475 s.chars().take(max_width).collect()
476 };
477 }
478 let mut w = 0;
479 let truncated: String = s
480 .chars()
481 .take_while(|ch| {
482 let cw = char_width(*ch);
483 if w + cw <= truncate_at {
484 w += cw;
485 true
486 } else {
487 false
488 }
489 })
490 .collect();
491 format!("{}...", truncated)
492}
493
494pub struct StatusBarRenderer;
496
497impl StatusBarRenderer {
498 pub fn render_status_bar(
502 frame: &mut Frame,
503 area: Rect,
504 ctx: &mut StatusBarContext<'_>,
505 config: &StatusBarConfig,
506 ) -> StatusBarLayout {
507 Self::render_status(frame, area, ctx, config)
508 }
509
510 pub fn render_prompt(
512 frame: &mut Frame,
513 area: Rect,
514 prompt: &Prompt,
515 theme: &crate::view::theme::Theme,
516 ) {
517 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
518
519 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
521
522 if let Some((sel_start, sel_end)) = prompt.selection_range() {
524 let input = &prompt.input;
525
526 if sel_start > 0 {
528 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
529 }
530
531 if sel_start < sel_end {
533 let selection_style = Style::default()
535 .fg(theme.prompt_selection_fg)
536 .bg(theme.prompt_selection_bg);
537 spans.push(Span::styled(
538 input[sel_start..sel_end].to_string(),
539 selection_style,
540 ));
541 }
542
543 if sel_end < input.len() {
545 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
546 }
547 } else {
548 spans.push(Span::styled(prompt.input.clone(), base_style));
550 }
551
552 let line = Line::from(spans);
553 let prompt_line = Paragraph::new(line).style(base_style);
554
555 frame.render_widget(prompt_line, area);
556
557 let message_width = str_width(&prompt.message);
562 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
563 let cursor_x = (message_width + input_width_before_cursor) as u16;
564 if cursor_x < area.width {
565 frame.set_cursor_position((area.x + cursor_x, area.y));
566 }
567 }
568
569 pub fn render_file_open_prompt(
573 frame: &mut Frame,
574 area: Rect,
575 prompt: &Prompt,
576 file_open_state: &crate::app::file_open::FileOpenState,
577 theme: &crate::view::theme::Theme,
578 ) {
579 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
580 let dir_style = Style::default()
581 .fg(theme.help_separator_fg)
582 .bg(theme.prompt_bg);
583 let ellipsis_style = Style::default()
585 .fg(theme.menu_highlight_fg)
586 .bg(theme.prompt_bg);
587
588 let mut spans = Vec::new();
589
590 let open_prompt = t!("file.open_prompt").to_string();
592 spans.push(Span::styled(open_prompt.clone(), base_style));
593
594 let prefix_len = str_width(&open_prompt);
597 let dir_path = file_open_state.current_dir.to_string_lossy();
598 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
600 let total_len = prefix_len + dir_path_len + input_len;
601 let threshold = (area.width as usize * 90) / 100;
602
603 let truncated = if total_len > threshold {
605 let available_for_path = threshold
607 .saturating_sub(prefix_len)
608 .saturating_sub(input_len);
609 truncate_path(&file_open_state.current_dir, available_for_path)
610 } else {
611 TruncatedPath {
613 prefix: String::new(),
614 truncated: false,
615 suffix: dir_path.to_string(),
616 }
617 };
618
619 if truncated.truncated {
621 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
623 spans.push(Span::styled("/[...]", ellipsis_style));
625 let suffix_with_slash = if truncated.suffix.ends_with('/') {
627 truncated.suffix.clone()
628 } else {
629 format!("{}/", truncated.suffix)
630 };
631 spans.push(Span::styled(suffix_with_slash, dir_style));
632 } else {
633 let path_display = if truncated.suffix.ends_with('/') {
635 truncated.suffix.clone()
636 } else {
637 format!("{}/", truncated.suffix)
638 };
639 spans.push(Span::styled(path_display, dir_style));
640 }
641
642 spans.push(Span::styled(prompt.input.clone(), base_style));
644
645 let line = Line::from(spans);
646 let prompt_line = Paragraph::new(line).style(base_style);
647
648 frame.render_widget(prompt_line, area);
649
650 let prefix_width = str_width(&open_prompt);
654 let dir_display_width = if truncated.truncated {
655 let suffix_with_slash = if truncated.suffix.ends_with('/') {
656 &truncated.suffix
657 } else {
658 &truncated.suffix
660 };
661 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
662 } else {
663 str_width(&truncated.suffix) + 1 };
665 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
666 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
667 if cursor_x < area.width {
668 frame.set_cursor_position((area.x + cursor_x, area.y));
669 }
670 }
671
672 fn render_element(
675 element: &StatusBarElement,
676 ctx: &mut StatusBarContext<'_>,
677 ) -> Option<RenderedElement> {
678 match element {
679 StatusBarElement::Filename => {
680 let modified = if ctx.state.buffer.is_modified() {
681 " [+]"
682 } else {
683 ""
684 };
685 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
686 let remote_disconnected = ctx
687 .remote_connection
688 .map(|conn| conn.contains("(Disconnected)"))
689 .unwrap_or(false);
690 let remote_prefix = ctx
691 .remote_connection
692 .map(|conn| {
693 if conn.starts_with("Container:") {
694 format!("[{}] ", conn)
695 } else {
696 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
697 }
698 })
699 .unwrap_or_default();
700 let session_prefix = ctx
701 .session_name
702 .map(|name| format!("[{}] ", name))
703 .unwrap_or_default();
704 let display_name = ctx.display_name;
705 let text = format!(
706 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
707 );
708 let kind = if remote_disconnected {
709 ElementKind::RemoteDisconnected
710 } else {
711 ElementKind::Normal
712 };
713 Some(RenderedElement { text, kind })
714 }
715 StatusBarElement::Cursor => {
716 if !ctx.state.show_cursors {
717 return None;
718 }
719 let cursor = *ctx.cursors.primary();
720 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
721 let text = if byte_offset_mode {
722 format!("Byte {}", cursor.position)
723 } else {
724 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
725 let line_start = cursor_iter.current_position();
726 let col = cursor.position.saturating_sub(line_start);
727 let line = ctx.state.primary_cursor_line_number.value();
728 format!("Ln {}, Col {}", line + 1, col + 1)
729 };
730 Some(RenderedElement {
731 text,
732 kind: ElementKind::Normal,
733 })
734 }
735 StatusBarElement::CursorCompact => {
736 if !ctx.state.show_cursors {
737 return None;
738 }
739 let cursor = *ctx.cursors.primary();
740 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
741 let text = if byte_offset_mode {
742 format!("{}", cursor.position)
743 } else {
744 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
745 let line_start = cursor_iter.current_position();
746 let col = cursor.position.saturating_sub(line_start);
747 let line = ctx.state.primary_cursor_line_number.value();
748 format!("{}:{}", line + 1, col + 1)
749 };
750 Some(RenderedElement {
751 text,
752 kind: ElementKind::Normal,
753 })
754 }
755 StatusBarElement::Diagnostics => {
756 let diagnostics = ctx.state.overlays.all();
757 let mut error_count = 0usize;
758 let mut warning_count = 0usize;
759 let mut info_count = 0usize;
760 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
761 for overlay in diagnostics {
762 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
763 match overlay.priority {
764 100 => error_count += 1,
765 50 => warning_count += 1,
766 _ => info_count += 1,
767 }
768 }
769 }
770 if error_count + warning_count + info_count == 0 {
771 return None;
772 }
773 let mut parts = Vec::new();
774 if error_count > 0 {
775 parts.push(format!("E:{}", error_count));
776 }
777 if warning_count > 0 {
778 parts.push(format!("W:{}", warning_count));
779 }
780 if info_count > 0 {
781 parts.push(format!("I:{}", info_count));
782 }
783 Some(RenderedElement {
784 text: parts.join(" "),
785 kind: ElementKind::Normal,
786 })
787 }
788 StatusBarElement::CursorCount => {
789 if ctx.cursors.count() <= 1 {
790 return None;
791 }
792 Some(RenderedElement {
793 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
794 kind: ElementKind::Normal,
795 })
796 }
797 StatusBarElement::Messages => {
798 let mut parts: Vec<&str> = Vec::new();
799 if let Some(msg) = ctx.status_message {
800 if !msg.is_empty() {
801 parts.push(msg);
802 }
803 }
804 if let Some(msg) = ctx.plugin_status_message {
805 if !msg.is_empty() {
806 parts.push(msg);
807 }
808 }
809 if parts.is_empty() {
810 return None;
811 }
812 Some(RenderedElement {
813 text: parts.join(" | "),
814 kind: ElementKind::Messages,
815 })
816 }
817 StatusBarElement::Chord => {
818 if ctx.chord_state.is_empty() {
819 return None;
820 }
821 let chord_str = ctx
822 .chord_state
823 .iter()
824 .map(|(code, modifiers)| {
825 crate::input::keybindings::format_keybinding(code, modifiers)
826 })
827 .collect::<Vec<_>>()
828 .join(" ");
829 Some(RenderedElement {
830 text: format!("[{}]", chord_str),
831 kind: ElementKind::Normal,
832 })
833 }
834 StatusBarElement::LineEnding => Some(RenderedElement {
835 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
836 kind: ElementKind::LineEnding,
837 }),
838 StatusBarElement::Encoding => Some(RenderedElement {
839 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
840 kind: ElementKind::Encoding,
841 }),
842 StatusBarElement::Language => {
843 let text = if ctx.state.language == "text"
844 && ctx.state.display_name != "Text"
845 && ctx.state.display_name != "Plain Text"
846 && ctx.state.display_name != "text"
847 {
848 format!(" {} [syntax only] ", &ctx.state.display_name)
849 } else {
850 format!(" {} ", &ctx.state.display_name)
851 };
852 Some(RenderedElement {
853 text,
854 kind: ElementKind::Language,
855 })
856 }
857 StatusBarElement::Lsp => {
858 if ctx.lsp_status.is_empty() {
859 return None;
860 }
861 Some(RenderedElement {
862 text: format!(" {} ", ctx.lsp_status),
863 kind: ElementKind::Lsp,
864 })
865 }
866 StatusBarElement::Warnings => {
867 if ctx.general_warning_count == 0 {
868 return None;
869 }
870 Some(RenderedElement {
871 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
872 kind: ElementKind::WarningBadge,
873 })
874 }
875 StatusBarElement::Update => {
876 let version = ctx.update_available?;
877 Some(RenderedElement {
878 text: format!(" {} ", t!("status.update_available", version = version)),
879 kind: ElementKind::Update,
880 })
881 }
882 StatusBarElement::Palette => {
883 let shortcut = ctx
884 .keybindings
885 .get_keybinding_for_action(
886 &crate::input::keybindings::Action::QuickOpen,
887 crate::input::keybindings::KeyContext::Global,
888 )
889 .unwrap_or_else(|| "?".to_string());
890 Some(RenderedElement {
891 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
892 kind: ElementKind::Palette,
893 })
894 }
895 StatusBarElement::Clock => {
896 let now = chrono::Local::now();
897 let text = format!("{:02}:{:02}", now.hour(), now.minute());
898 Some(RenderedElement {
899 text,
900 kind: ElementKind::Clock,
901 })
902 }
903 StatusBarElement::RemoteIndicator => {
904 let (text, state) = if let Some(over) = ctx.remote_state_override {
914 (format!(" {} ", over.label()), over.state())
915 } else {
916 match ctx.remote_connection {
917 None => (" Local ".to_string(), RemoteIndicatorState::Local),
918 Some(conn) if conn.contains("(Disconnected)") => {
919 (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
920 }
921 Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
922 }
923 };
924 Some(RenderedElement {
925 text,
926 kind: ElementKind::RemoteIndicator(state),
927 })
928 }
929 }
930 }
931
932 fn element_style(
934 kind: ElementKind,
935 theme: &crate::view::theme::Theme,
936 hover: StatusBarHover,
937 _warning_level: WarningLevel,
938 lsp_state: LspIndicatorState,
939 ) -> Style {
940 match kind {
941 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
942 .fg(theme.status_bar_fg)
943 .bg(theme.status_bar_bg),
944 ElementKind::RemoteDisconnected => Style::default()
945 .fg(theme.status_error_indicator_fg)
946 .bg(theme.status_error_indicator_bg),
947 ElementKind::LineEnding => {
948 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
949 let (fg, bg) = if is_hovering {
950 (theme.menu_hover_fg, theme.menu_hover_bg)
951 } else {
952 (theme.status_bar_fg, theme.status_bar_bg)
953 };
954 let mut style = Style::default().fg(fg).bg(bg);
955 if is_hovering {
956 style = style.add_modifier(Modifier::UNDERLINED);
957 }
958 style
959 }
960 ElementKind::Encoding => {
961 let is_hovering = hover == StatusBarHover::EncodingIndicator;
962 let (fg, bg) = if is_hovering {
963 (theme.menu_hover_fg, theme.menu_hover_bg)
964 } else {
965 (theme.status_bar_fg, theme.status_bar_bg)
966 };
967 let mut style = Style::default().fg(fg).bg(bg);
968 if is_hovering {
969 style = style.add_modifier(Modifier::UNDERLINED);
970 }
971 style
972 }
973 ElementKind::Language => {
974 let is_hovering = hover == StatusBarHover::LanguageIndicator;
975 let (fg, bg) = if is_hovering {
976 (theme.menu_hover_fg, theme.menu_hover_bg)
977 } else {
978 (theme.status_bar_fg, theme.status_bar_bg)
979 };
980 let mut style = Style::default().fg(fg).bg(bg);
981 if is_hovering {
982 style = style.add_modifier(Modifier::UNDERLINED);
983 }
984 style
985 }
986 ElementKind::Lsp => {
987 let is_hovering = hover == StatusBarHover::LspIndicator;
988 let (fg, bg) = match lsp_state {
994 LspIndicatorState::Error => {
995 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
996 }
997 LspIndicatorState::Off => {
998 (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
999 }
1000 LspIndicatorState::On => (theme.diagnostic_info_fg, theme.diagnostic_info_bg),
1001 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1009 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1010 };
1011 let mut style = Style::default().fg(fg).bg(bg);
1012 if is_hovering && lsp_state != LspIndicatorState::None {
1017 style = style.add_modifier(Modifier::UNDERLINED);
1018 }
1019 style
1020 }
1021 ElementKind::WarningBadge => {
1022 let is_hovering = hover == StatusBarHover::WarningBadge;
1023 let (fg, bg) = if is_hovering {
1024 (
1025 theme.status_warning_indicator_hover_fg,
1026 theme.status_warning_indicator_hover_bg,
1027 )
1028 } else {
1029 (
1030 theme.status_warning_indicator_fg,
1031 theme.status_warning_indicator_bg,
1032 )
1033 };
1034 let mut style = Style::default().fg(fg).bg(bg);
1035 if is_hovering {
1036 style = style.add_modifier(Modifier::UNDERLINED);
1037 }
1038 style
1039 }
1040 ElementKind::Update => Style::default()
1041 .fg(theme.menu_highlight_fg)
1042 .bg(theme.menu_dropdown_bg),
1043 ElementKind::Palette => Style::default()
1044 .fg(theme.help_indicator_fg)
1045 .bg(theme.help_indicator_bg),
1046 ElementKind::RemoteIndicator(state) => {
1047 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1048 let (fg, bg) = match state {
1049 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1055 (theme.help_indicator_fg, theme.help_indicator_bg)
1056 }
1057 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1061 theme.status_error_indicator_fg,
1062 theme.status_error_indicator_bg,
1063 ),
1064 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1066 };
1067 let mut style = Style::default().fg(fg).bg(bg);
1068 if is_hovering {
1069 style = style.add_modifier(Modifier::UNDERLINED);
1070 }
1071 style
1072 }
1073 }
1074 }
1075
1076 fn update_layout_for_element(
1078 layout: &mut StatusBarLayout,
1079 kind: ElementKind,
1080 row: u16,
1081 start_col: u16,
1082 end_col: u16,
1083 ) {
1084 match kind {
1085 ElementKind::LineEnding => {
1086 layout.line_ending_indicator = Some((row, start_col, end_col))
1087 }
1088 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1089 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1090 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1091 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1092 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1093 ElementKind::RemoteIndicator(_) => {
1094 layout.remote_indicator = Some((row, start_col, end_col))
1095 }
1096 _ => {}
1097 }
1098 }
1099
1100 fn element_spans(
1105 rendered: &RenderedElement,
1106 theme: &crate::view::theme::Theme,
1107 hover: StatusBarHover,
1108 warning_level: WarningLevel,
1109 lsp_state: LspIndicatorState,
1110 ) -> (Vec<Span<'static>>, usize) {
1111 let base_style = Style::default()
1112 .fg(theme.status_bar_fg)
1113 .bg(theme.status_bar_bg);
1114 let width = str_width(&rendered.text);
1115
1116 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1117 {
1118 let error_style = Style::default()
1119 .fg(theme.status_error_indicator_fg)
1120 .bg(theme.status_error_indicator_bg);
1121 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1122 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1123 let prefix = rendered.text[..split_at].to_string();
1124 let rest = rendered.text[split_at..].to_string();
1125 return (
1126 vec![
1127 Span::styled(prefix, error_style),
1128 Span::styled(rest, base_style),
1129 ],
1130 width,
1131 );
1132 }
1133 return (
1134 vec![Span::styled(rendered.text.clone(), error_style)],
1135 width,
1136 );
1137 }
1138
1139 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1140 let spans = if rendered.kind == ElementKind::Clock {
1141 vec![
1143 Span::styled(rendered.text[..2].to_string(), style),
1144 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1145 Span::styled(rendered.text[3..].to_string(), style),
1146 ]
1147 } else {
1148 vec![Span::styled(rendered.text.clone(), style)]
1149 };
1150 (spans, width)
1151 }
1152
1153 fn render_side(
1155 config_side: &[StatusBarElement],
1156 ctx: &mut StatusBarContext<'_>,
1157 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1158 let rendered: Vec<RenderedElement> = config_side
1159 .iter()
1160 .filter_map(|elem| Self::render_element(elem, ctx))
1161 .filter(|e| !e.text.is_empty())
1162 .collect();
1163
1164 let theme = ctx.theme;
1165 let hover = ctx.hover;
1166 let warning_level = ctx.warning_level;
1167 let lsp_state = ctx.lsp_indicator_state;
1168 rendered
1169 .into_iter()
1170 .map(|r| {
1171 let kind = r.kind;
1172 let (spans, width) =
1173 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1174 (spans, width, kind)
1175 })
1176 .collect()
1177 }
1178
1179 fn render_status(
1181 frame: &mut Frame,
1182 area: Rect,
1183 ctx: &mut StatusBarContext<'_>,
1184 config: &StatusBarConfig,
1185 ) -> StatusBarLayout {
1186 let mut layout = StatusBarLayout::default();
1187 let base_style = Style::default()
1188 .fg(ctx.theme.status_bar_fg)
1189 .bg(ctx.theme.status_bar_bg);
1190 let available_width = area.width as usize;
1191
1192 if available_width == 0 || area.height == 0 {
1193 return layout;
1194 }
1195
1196 let left_items = Self::render_side(&config.left, ctx);
1197 let mut right_items = Self::render_side(&config.right, ctx);
1198
1199 const SEPARATOR: &str = " | ";
1200 let separator_width = str_width(SEPARATOR);
1201
1202 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1211 let left_min_target = available_width
1212 .saturating_mul(2)
1213 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1216 if total_right_width > right_budget && right_items.len() > 1 {
1217 let mut current = total_right_width;
1218 while current > right_budget && right_items.len() > 1 {
1219 if let Some(dropped) = right_items.pop() {
1220 current = current.saturating_sub(dropped.1);
1221 } else {
1222 break;
1223 }
1224 }
1225 }
1226
1227 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1228
1229 let narrow = available_width < 15;
1230 let left_max_width = if narrow {
1231 available_width
1232 } else if available_width > right_width + 1 {
1233 available_width - right_width - 1
1234 } else {
1235 1
1236 };
1237
1238 let mut spans: Vec<Span<'static>> = Vec::new();
1242 let mut used_left: usize = 0;
1243
1244 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1245 let sep_width = if idx == 0 { 0 } else { separator_width };
1246 if used_left + sep_width >= left_max_width {
1247 break;
1248 }
1249 if sep_width > 0 {
1250 spans.push(Span::styled(SEPARATOR, base_style));
1251 used_left += sep_width;
1252 }
1253
1254 let remaining = left_max_width - used_left;
1255 let start_col = used_left;
1256
1257 if width <= remaining {
1258 spans.extend(item_spans);
1259 used_left += width;
1260
1261 Self::update_layout_for_element(
1262 &mut layout,
1263 kind,
1264 area.y,
1265 area.x + start_col as u16,
1266 area.x + (start_col + width) as u16,
1267 );
1268 } else {
1269 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1273 let truncated = truncate_to_width(&group_text, remaining);
1274 let truncated_width = str_width(&truncated);
1275 let overflow_style = Self::element_style(
1276 kind,
1277 ctx.theme,
1278 ctx.hover,
1279 ctx.warning_level,
1280 ctx.lsp_indicator_state,
1281 );
1282 spans.push(Span::styled(truncated, overflow_style));
1283 used_left += truncated_width;
1284
1285 Self::update_layout_for_element(
1286 &mut layout,
1287 kind,
1288 area.y,
1289 area.x + start_col as u16,
1290 area.x + (start_col + truncated_width) as u16,
1291 );
1292 break;
1293 }
1294 }
1295
1296 if narrow {
1297 if used_left < available_width {
1298 spans.push(Span::styled(
1299 " ".repeat(available_width - used_left),
1300 base_style,
1301 ));
1302 }
1303 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1304 return layout;
1305 }
1306
1307 let mut col_offset = used_left;
1308 if col_offset + right_width < available_width {
1309 let padding = available_width - col_offset - right_width;
1310 spans.push(Span::styled(" ".repeat(padding), base_style));
1311 col_offset = available_width - right_width;
1312 } else if col_offset < available_width {
1313 spans.push(Span::styled(" ", base_style));
1314 col_offset += 1;
1315 }
1316
1317 let mut current_col = area.x + col_offset as u16;
1318 for (item_spans, width, kind) in right_items {
1319 Self::update_layout_for_element(
1320 &mut layout,
1321 kind,
1322 area.y,
1323 current_col,
1324 current_col + width as u16,
1325 );
1326 spans.extend(item_spans);
1327 current_col += width as u16;
1328 }
1329
1330 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1331 layout
1332 }
1333
1334 #[allow(clippy::too_many_arguments)]
1345 pub fn render_search_options(
1346 frame: &mut Frame,
1347 area: Rect,
1348 case_sensitive: bool,
1349 whole_word: bool,
1350 use_regex: bool,
1351 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1353 keybindings: &crate::input::keybindings::KeybindingResolver,
1354 hover: SearchOptionsHover,
1355 ) -> SearchOptionsLayout {
1356 use crate::primitives::display_width::str_width;
1357
1358 let mut layout = SearchOptionsLayout {
1359 row: area.y,
1360 ..Default::default()
1361 };
1362
1363 let base_style = Style::default()
1365 .fg(theme.menu_dropdown_fg)
1366 .bg(theme.menu_dropdown_bg);
1367
1368 let hover_style = Style::default()
1370 .fg(theme.menu_hover_fg)
1371 .bg(theme.menu_hover_bg);
1372
1373 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1375 keybindings
1376 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1377 .or_else(|| {
1378 keybindings.get_keybinding_for_action(
1379 action,
1380 crate::input::keybindings::KeyContext::Global,
1381 )
1382 })
1383 };
1384
1385 let case_shortcut =
1387 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1388 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1389 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1390
1391 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1393 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1394 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1395
1396 let active_style = Style::default()
1398 .fg(theme.menu_highlight_fg)
1399 .bg(theme.menu_dropdown_bg);
1400
1401 let shortcut_style = Style::default()
1403 .fg(theme.help_separator_fg)
1404 .bg(theme.menu_dropdown_bg);
1405
1406 let hover_shortcut_style = Style::default()
1408 .fg(theme.menu_hover_fg)
1409 .bg(theme.menu_hover_bg);
1410
1411 let mut spans = Vec::new();
1412 let mut current_col = area.x;
1413
1414 spans.push(Span::styled(" ", base_style));
1416 current_col += 1;
1417
1418 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1420 if is_hovered {
1421 hover_style
1422 } else if is_checked {
1423 active_style
1424 } else {
1425 base_style
1426 }
1427 };
1428
1429 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1431 let case_start = current_col;
1432 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1433 let case_shortcut_text = case_shortcut
1434 .as_ref()
1435 .map(|s| format!(" ({})", s))
1436 .unwrap_or_default();
1437 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1438
1439 spans.push(Span::styled(
1440 case_label,
1441 get_checkbox_style(case_hovered, case_sensitive),
1442 ));
1443 if !case_shortcut_text.is_empty() {
1444 spans.push(Span::styled(
1445 case_shortcut_text,
1446 if case_hovered {
1447 hover_shortcut_style
1448 } else {
1449 shortcut_style
1450 },
1451 ));
1452 }
1453 current_col += case_full_width as u16;
1454 layout.case_sensitive = Some((case_start, current_col));
1455
1456 spans.push(Span::styled(" ", base_style));
1458 current_col += 3;
1459
1460 let word_hovered = hover == SearchOptionsHover::WholeWord;
1462 let word_start = current_col;
1463 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1464 let word_shortcut_text = word_shortcut
1465 .as_ref()
1466 .map(|s| format!(" ({})", s))
1467 .unwrap_or_default();
1468 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1469
1470 spans.push(Span::styled(
1471 word_label,
1472 get_checkbox_style(word_hovered, whole_word),
1473 ));
1474 if !word_shortcut_text.is_empty() {
1475 spans.push(Span::styled(
1476 word_shortcut_text,
1477 if word_hovered {
1478 hover_shortcut_style
1479 } else {
1480 shortcut_style
1481 },
1482 ));
1483 }
1484 current_col += word_full_width as u16;
1485 layout.whole_word = Some((word_start, current_col));
1486
1487 spans.push(Span::styled(" ", base_style));
1489 current_col += 3;
1490
1491 let regex_hovered = hover == SearchOptionsHover::Regex;
1493 let regex_start = current_col;
1494 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1495 let regex_shortcut_text = regex_shortcut
1496 .as_ref()
1497 .map(|s| format!(" ({})", s))
1498 .unwrap_or_default();
1499 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1500
1501 spans.push(Span::styled(
1502 regex_label,
1503 get_checkbox_style(regex_hovered, use_regex),
1504 ));
1505 if !regex_shortcut_text.is_empty() {
1506 spans.push(Span::styled(
1507 regex_shortcut_text,
1508 if regex_hovered {
1509 hover_shortcut_style
1510 } else {
1511 shortcut_style
1512 },
1513 ));
1514 }
1515 current_col += regex_full_width as u16;
1516 layout.regex = Some((regex_start, current_col));
1517
1518 if use_regex && confirm_each.is_some() {
1520 let hint = " \u{2502} $1,$2,…";
1521 spans.push(Span::styled(hint, shortcut_style));
1522 current_col += str_width(hint) as u16;
1523 }
1524
1525 if let Some(confirm_value) = confirm_each {
1527 let confirm_shortcut =
1528 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1529 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1530
1531 spans.push(Span::styled(" ", base_style));
1533 current_col += 3;
1534
1535 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1536 let confirm_start = current_col;
1537 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1538 let confirm_shortcut_text = confirm_shortcut
1539 .as_ref()
1540 .map(|s| format!(" ({})", s))
1541 .unwrap_or_default();
1542 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1543
1544 spans.push(Span::styled(
1545 confirm_label,
1546 get_checkbox_style(confirm_hovered, confirm_value),
1547 ));
1548 if !confirm_shortcut_text.is_empty() {
1549 spans.push(Span::styled(
1550 confirm_shortcut_text,
1551 if confirm_hovered {
1552 hover_shortcut_style
1553 } else {
1554 shortcut_style
1555 },
1556 ));
1557 }
1558 current_col += confirm_full_width as u16;
1559 layout.confirm_each = Some((confirm_start, current_col));
1560 }
1561
1562 let current_width = (current_col - area.x) as usize;
1564 let available_width = area.width as usize;
1565 if current_width < available_width {
1566 spans.push(Span::styled(
1567 " ".repeat(available_width.saturating_sub(current_width)),
1568 base_style,
1569 ));
1570 }
1571
1572 let options_line = Paragraph::new(Line::from(spans));
1573 frame.render_widget(options_line, area);
1574
1575 layout
1576 }
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582 use std::path::PathBuf;
1583
1584 #[test]
1585 fn test_truncate_path_short_path() {
1586 let path = PathBuf::from("/home/user/project");
1587 let result = truncate_path(&path, 50);
1588
1589 assert!(!result.truncated);
1590 assert_eq!(result.suffix, "/home/user/project");
1591 assert!(result.prefix.is_empty());
1592 }
1593
1594 #[test]
1595 fn test_truncate_path_long_path() {
1596 let path = PathBuf::from(
1597 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1598 );
1599 let result = truncate_path(&path, 40);
1600
1601 assert!(result.truncated, "Path should be truncated");
1602 assert_eq!(result.prefix, "/private");
1603 assert!(
1604 result.suffix.contains("project_root"),
1605 "Suffix should contain project_root"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_truncate_path_preserves_last_components() {
1611 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1612 let result = truncate_path(&path, 30);
1613
1614 assert!(result.truncated);
1615 assert!(
1617 result.suffix.contains("src"),
1618 "Should preserve last component 'src', got: {}",
1619 result.suffix
1620 );
1621 }
1622
1623 #[test]
1624 fn test_truncate_path_display_len() {
1625 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1626 let result = truncate_path(&path, 30);
1627
1628 let display = result.to_string_plain();
1630 assert!(
1631 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1633 display.len(),
1634 display
1635 );
1636 }
1637
1638 #[test]
1639 fn test_truncate_path_root_only() {
1640 let path = PathBuf::from("/");
1641 let result = truncate_path(&path, 50);
1642
1643 assert!(!result.truncated);
1644 assert_eq!(result.suffix, "/");
1645 }
1646
1647 #[test]
1648 fn test_truncated_path_to_string_plain() {
1649 let truncated = TruncatedPath {
1650 prefix: "/home".to_string(),
1651 truncated: true,
1652 suffix: "/project/src".to_string(),
1653 };
1654
1655 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1656 }
1657
1658 #[test]
1659 fn test_truncated_path_to_string_plain_no_truncation() {
1660 let truncated = TruncatedPath {
1661 prefix: String::new(),
1662 truncated: false,
1663 suffix: "/home/user/project".to_string(),
1664 };
1665
1666 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1667 }
1668
1669 #[test]
1670 fn test_remote_indicator_element_kind_equality() {
1671 assert_eq!(
1675 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1676 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1677 );
1678 let distinct = [
1679 RemoteIndicatorState::Local,
1680 RemoteIndicatorState::Connecting,
1681 RemoteIndicatorState::Connected,
1682 RemoteIndicatorState::FailedAttach,
1683 RemoteIndicatorState::Disconnected,
1684 ];
1685 for (i, a) in distinct.iter().enumerate() {
1686 for (j, b) in distinct.iter().enumerate() {
1687 if i == j {
1688 continue;
1689 }
1690 assert_ne!(
1691 ElementKind::RemoteIndicator(*a),
1692 ElementKind::RemoteIndicator(*b),
1693 "expected {:?} != {:?}",
1694 a,
1695 b
1696 );
1697 }
1698 }
1699 }
1700
1701 #[test]
1702 fn test_remote_indicator_state_default_is_local() {
1703 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1706 }
1707
1708 #[test]
1709 fn test_remote_indicator_override_deserializes_kind_tags() {
1710 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1714 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1715 (
1716 r#"{"kind":"connecting","label":"Building"}"#,
1717 RemoteIndicatorOverride::Connecting {
1718 label: Some("Building".into()),
1719 },
1720 ),
1721 (
1722 r#"{"kind":"connecting"}"#,
1723 RemoteIndicatorOverride::Connecting { label: None },
1724 ),
1725 (
1726 r#"{"kind":"connected","label":"Container:abc"}"#,
1727 RemoteIndicatorOverride::Connected {
1728 label: Some("Container:abc".into()),
1729 },
1730 ),
1731 (
1732 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1733 RemoteIndicatorOverride::FailedAttach {
1734 error: Some("exit 1".into()),
1735 },
1736 ),
1737 (
1738 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1739 RemoteIndicatorOverride::Disconnected {
1740 label: Some("Container:abc".into()),
1741 },
1742 ),
1743 ];
1744 for (json, expected) in cases {
1745 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1746 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1747 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1748 }
1749 }
1750
1751 #[test]
1752 fn test_remote_indicator_override_labels() {
1753 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1757 assert!(
1758 connecting.label().contains("Connecting"),
1759 "connecting default label should mention Connecting, got {:?}",
1760 connecting.label()
1761 );
1762
1763 let connecting_labeled = RemoteIndicatorOverride::Connecting {
1764 label: Some("Building".into()),
1765 };
1766 assert!(
1767 connecting_labeled.label().contains("Building"),
1768 "labeled connecting should include the label, got {:?}",
1769 connecting_labeled.label()
1770 );
1771
1772 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1773 assert_eq!(failed_bare.label(), "Attach failed");
1774
1775 let failed_detail = RemoteIndicatorOverride::FailedAttach {
1776 error: Some("exit 1".into()),
1777 };
1778 assert!(
1779 failed_detail.label().contains("exit 1"),
1780 "failed with error should include the error, got {:?}",
1781 failed_detail.label()
1782 );
1783 }
1784
1785 #[test]
1786 fn test_remote_indicator_override_state_projection() {
1787 assert_eq!(
1788 RemoteIndicatorOverride::Local.state(),
1789 RemoteIndicatorState::Local
1790 );
1791 assert_eq!(
1792 RemoteIndicatorOverride::Connecting { label: None }.state(),
1793 RemoteIndicatorState::Connecting
1794 );
1795 assert_eq!(
1796 RemoteIndicatorOverride::Connected { label: None }.state(),
1797 RemoteIndicatorState::Connected
1798 );
1799 assert_eq!(
1800 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1801 RemoteIndicatorState::FailedAttach
1802 );
1803 assert_eq!(
1804 RemoteIndicatorOverride::Disconnected { label: None }.state(),
1805 RemoteIndicatorState::Disconnected
1806 );
1807 }
1808}