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 pub remote_indicator_on_bar: bool,
237}
238
239#[derive(Debug, Clone, Default)]
241pub struct StatusBarLayout {
242 pub lsp_indicator: Option<(u16, u16, u16)>,
244 pub warning_badge: Option<(u16, u16, u16)>,
246 pub line_ending_indicator: Option<(u16, u16, u16)>,
248 pub encoding_indicator: Option<(u16, u16, u16)>,
250 pub language_indicator: Option<(u16, u16, u16)>,
252 pub message_area: Option<(u16, u16, u16)>,
254 pub remote_indicator: Option<(u16, u16, u16)>,
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
261pub enum StatusBarHover {
262 #[default]
263 None,
264 LspIndicator,
266 WarningBadge,
268 LineEndingIndicator,
270 EncodingIndicator,
272 LanguageIndicator,
274 MessageArea,
276 RemoteIndicator,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub enum SearchOptionsHover {
283 #[default]
284 None,
285 CaseSensitive,
286 WholeWord,
287 Regex,
288 ConfirmEach,
289}
290
291#[derive(Debug, Clone, Default)]
293pub struct SearchOptionsLayout {
294 pub row: u16,
296 pub case_sensitive: Option<(u16, u16)>,
298 pub whole_word: Option<(u16, u16)>,
300 pub regex: Option<(u16, u16)>,
302 pub confirm_each: Option<(u16, u16)>,
304}
305
306impl SearchOptionsLayout {
307 pub fn checkbox_at(&self, x: u16, y: u16) -> Option<SearchOptionsHover> {
309 if y != self.row {
310 return None;
311 }
312
313 if let Some((start, end)) = self.case_sensitive {
314 if x >= start && x < end {
315 return Some(SearchOptionsHover::CaseSensitive);
316 }
317 }
318 if let Some((start, end)) = self.whole_word {
319 if x >= start && x < end {
320 return Some(SearchOptionsHover::WholeWord);
321 }
322 }
323 if let Some((start, end)) = self.regex {
324 if x >= start && x < end {
325 return Some(SearchOptionsHover::Regex);
326 }
327 }
328 if let Some((start, end)) = self.confirm_each {
329 if x >= start && x < end {
330 return Some(SearchOptionsHover::ConfirmEach);
331 }
332 }
333 None
334 }
335}
336
337#[derive(Debug, Clone)]
339pub struct TruncatedPath {
340 pub prefix: String,
342 pub truncated: bool,
344 pub suffix: String,
346}
347
348impl TruncatedPath {
349 pub fn to_string_plain(&self) -> String {
351 if self.truncated {
352 format!("{}/[...]{}", self.prefix, self.suffix)
353 } else {
354 format!("{}{}", self.prefix, self.suffix)
355 }
356 }
357
358 pub fn display_len(&self) -> usize {
360 if self.truncated {
361 self.prefix.len() + "/[...]".len() + self.suffix.len()
362 } else {
363 self.prefix.len() + self.suffix.len()
364 }
365 }
366}
367
368pub fn truncate_path(path: &Path, max_len: usize) -> TruncatedPath {
380 let path_str = path.to_string_lossy();
381
382 if path_str.len() <= max_len {
384 return TruncatedPath {
385 prefix: String::new(),
386 truncated: false,
387 suffix: path_str.to_string(),
388 };
389 }
390
391 let components: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect();
392
393 if components.is_empty() {
394 return TruncatedPath {
395 prefix: "/".to_string(),
396 truncated: false,
397 suffix: String::new(),
398 };
399 }
400
401 let prefix = if path_str.starts_with('/') {
403 format!("/{}", components.first().unwrap_or(&""))
404 } else {
405 components.first().unwrap_or(&"").to_string()
406 };
407
408 let ellipsis_len = "/[...]".len();
410
411 let available_for_suffix = max_len.saturating_sub(prefix.len() + ellipsis_len);
413
414 if available_for_suffix < 5 || components.len() <= 1 {
415 let truncated_path = if path_str.len() > max_len.saturating_sub(3) {
420 let cut = path_str.floor_char_boundary(max_len.saturating_sub(3));
421 format!("{}...", &path_str[..cut])
422 } else {
423 path_str.to_string()
424 };
425 return TruncatedPath {
426 prefix: String::new(),
427 truncated: false,
428 suffix: truncated_path,
429 };
430 }
431
432 let mut suffix_parts: Vec<&str> = Vec::new();
434 let mut suffix_len = 0;
435
436 for component in components.iter().skip(1).rev() {
437 let component_len = component.len() + 1; if suffix_len + component_len <= available_for_suffix {
439 suffix_parts.push(component);
440 suffix_len += component_len;
441 } else {
442 break;
443 }
444 }
445
446 suffix_parts.reverse();
447
448 if suffix_parts.len() == components.len() - 1 {
450 return TruncatedPath {
451 prefix: String::new(),
452 truncated: false,
453 suffix: path_str.to_string(),
454 };
455 }
456
457 let suffix = if suffix_parts.is_empty() {
458 let last = components.last().unwrap_or(&"");
462 let truncate_to = available_for_suffix.saturating_sub(4); if truncate_to > 0 && last.len() > truncate_to {
464 let cut = last.floor_char_boundary(truncate_to);
465 format!("/{}...", &last[..cut])
466 } else {
467 format!("/{}", last)
468 }
469 } else {
470 format!("/{}", suffix_parts.join("/"))
471 };
472
473 TruncatedPath {
474 prefix,
475 truncated: true,
476 suffix,
477 }
478}
479
480fn truncate_to_width(s: &str, max_width: usize) -> String {
482 let width = str_width(s);
483 if width <= max_width {
484 return s.to_string();
485 }
486 let truncate_at = max_width.saturating_sub(3);
487 if truncate_at == 0 {
488 return if max_width >= 3 {
489 "...".to_string()
490 } else {
491 s.chars().take(max_width).collect()
492 };
493 }
494 let mut w = 0;
495 let truncated: String = s
496 .chars()
497 .take_while(|ch| {
498 let cw = char_width(*ch);
499 if w + cw <= truncate_at {
500 w += cw;
501 true
502 } else {
503 false
504 }
505 })
506 .collect();
507 format!("{}...", truncated)
508}
509
510pub struct StatusBarRenderer;
512
513impl StatusBarRenderer {
514 pub fn render_status_bar(
518 frame: &mut Frame,
519 area: Rect,
520 ctx: &mut StatusBarContext<'_>,
521 config: &StatusBarConfig,
522 ) -> StatusBarLayout {
523 Self::render_status(frame, area, ctx, config)
524 }
525
526 pub fn render_prompt(
528 frame: &mut Frame,
529 area: Rect,
530 prompt: &Prompt,
531 theme: &crate::view::theme::Theme,
532 ) {
533 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
534
535 let mut spans = vec![Span::styled(prompt.message.clone(), base_style)];
537
538 if let Some((sel_start, sel_end)) = prompt.selection_range() {
540 let input = &prompt.input;
541
542 if sel_start > 0 {
544 spans.push(Span::styled(input[..sel_start].to_string(), base_style));
545 }
546
547 if sel_start < sel_end {
549 let selection_style = Style::default()
551 .fg(theme.prompt_selection_fg)
552 .bg(theme.prompt_selection_bg);
553 spans.push(Span::styled(
554 input[sel_start..sel_end].to_string(),
555 selection_style,
556 ));
557 }
558
559 if sel_end < input.len() {
561 spans.push(Span::styled(input[sel_end..].to_string(), base_style));
562 }
563 } else {
564 spans.push(Span::styled(prompt.input.clone(), base_style));
566 }
567
568 let line = Line::from(spans);
569 let prompt_line = Paragraph::new(line).style(base_style);
570
571 frame.render_widget(prompt_line, area);
572
573 let message_width = str_width(&prompt.message);
578 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
579 let cursor_x = (message_width + input_width_before_cursor) as u16;
580 if cursor_x < area.width {
581 frame.set_cursor_position((area.x + cursor_x, area.y));
582 }
583 }
584
585 pub fn render_file_open_prompt(
589 frame: &mut Frame,
590 area: Rect,
591 prompt: &Prompt,
592 file_open_state: &crate::app::file_open::FileOpenState,
593 theme: &crate::view::theme::Theme,
594 ) {
595 let base_style = Style::default().fg(theme.prompt_fg).bg(theme.prompt_bg);
596 let dir_style = Style::default()
597 .fg(theme.help_separator_fg)
598 .bg(theme.prompt_bg);
599 let ellipsis_style = Style::default()
601 .fg(theme.menu_highlight_fg)
602 .bg(theme.prompt_bg);
603
604 let mut spans = Vec::new();
605
606 let open_prompt = t!("file.open_prompt").to_string();
608 spans.push(Span::styled(open_prompt.clone(), base_style));
609
610 let prefix_len = str_width(&open_prompt);
613 let dir_path = file_open_state.current_dir.to_string_lossy();
614 let dir_path_len = dir_path.len() + 1; let input_len = prompt.input.len();
616 let total_len = prefix_len + dir_path_len + input_len;
617 let threshold = (area.width as usize * 90) / 100;
618
619 let truncated = if total_len > threshold {
621 let available_for_path = threshold
623 .saturating_sub(prefix_len)
624 .saturating_sub(input_len);
625 truncate_path(&file_open_state.current_dir, available_for_path)
626 } else {
627 TruncatedPath {
629 prefix: String::new(),
630 truncated: false,
631 suffix: dir_path.to_string(),
632 }
633 };
634
635 if truncated.truncated {
637 spans.push(Span::styled(truncated.prefix.clone(), dir_style));
639 spans.push(Span::styled("/[...]", ellipsis_style));
641 let suffix_with_slash = if truncated.suffix.ends_with('/') {
643 truncated.suffix.clone()
644 } else {
645 format!("{}/", truncated.suffix)
646 };
647 spans.push(Span::styled(suffix_with_slash, dir_style));
648 } else {
649 let path_display = if truncated.suffix.ends_with('/') {
651 truncated.suffix.clone()
652 } else {
653 format!("{}/", truncated.suffix)
654 };
655 spans.push(Span::styled(path_display, dir_style));
656 }
657
658 spans.push(Span::styled(prompt.input.clone(), base_style));
660
661 let line = Line::from(spans);
662 let prompt_line = Paragraph::new(line).style(base_style);
663
664 frame.render_widget(prompt_line, area);
665
666 let prefix_width = str_width(&open_prompt);
670 let dir_display_width = if truncated.truncated {
671 let suffix_with_slash = if truncated.suffix.ends_with('/') {
672 &truncated.suffix
673 } else {
674 &truncated.suffix
676 };
677 str_width(&truncated.prefix) + str_width("/[...]") + str_width(suffix_with_slash) + 1
678 } else {
679 str_width(&truncated.suffix) + 1 };
681 let input_width_before_cursor = str_width(&prompt.input[..prompt.cursor_pos]);
682 let cursor_x = (prefix_width + dir_display_width + input_width_before_cursor) as u16;
683 if cursor_x < area.width {
684 frame.set_cursor_position((area.x + cursor_x, area.y));
685 }
686 }
687
688 fn render_element(
691 element: &StatusBarElement,
692 ctx: &mut StatusBarContext<'_>,
693 ) -> Option<RenderedElement> {
694 match element {
695 StatusBarElement::Filename => {
696 let modified = if ctx.state.buffer.is_modified() {
697 " [+]"
698 } else {
699 ""
700 };
701 let read_only_indicator = if ctx.read_only { " [RO]" } else { "" };
702 let remote_disconnected = ctx
703 .remote_connection
704 .map(|conn| conn.contains("(Disconnected)"))
705 .unwrap_or(false);
706 let remote_prefix = if ctx.remote_indicator_on_bar {
713 String::new()
714 } else {
715 ctx.remote_connection
716 .map(|conn| {
717 if conn.starts_with("Container:") {
718 format!("[{}] ", conn)
719 } else {
720 format!("{SSH_PREFIX}{conn}{SSH_PREFIX_TERMINATOR}")
721 }
722 })
723 .unwrap_or_default()
724 };
725 let session_prefix = ctx
726 .session_name
727 .map(|name| format!("[{}] ", name))
728 .unwrap_or_default();
729 let display_name = ctx.display_name;
730 let text = format!(
731 "{session_prefix}{remote_prefix}{display_name}{modified}{read_only_indicator}"
732 );
733 let kind = if remote_disconnected {
734 ElementKind::RemoteDisconnected
735 } else {
736 ElementKind::Normal
737 };
738 Some(RenderedElement { text, kind })
739 }
740 StatusBarElement::Cursor => {
741 if !ctx.state.show_cursors {
742 return None;
743 }
744 let cursor = *ctx.cursors.primary();
745 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
746 let text = if byte_offset_mode {
747 format!("Byte {}", cursor.position)
748 } else {
749 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
750 let line_start = cursor_iter.current_position();
751 let col = cursor.position.saturating_sub(line_start);
752 let line = ctx.state.primary_cursor_line_number.value();
753 format!("Ln {}, Col {}", line + 1, col + 1)
754 };
755 Some(RenderedElement {
756 text,
757 kind: ElementKind::Normal,
758 })
759 }
760 StatusBarElement::CursorCompact => {
761 if !ctx.state.show_cursors {
762 return None;
763 }
764 let cursor = *ctx.cursors.primary();
765 let byte_offset_mode = ctx.state.buffer.line_count().is_none();
766 let text = if byte_offset_mode {
767 format!("{}", cursor.position)
768 } else {
769 let cursor_iter = ctx.state.buffer.line_iterator(cursor.position, 80);
770 let line_start = cursor_iter.current_position();
771 let col = cursor.position.saturating_sub(line_start);
772 let line = ctx.state.primary_cursor_line_number.value();
773 format!("{}:{}", line + 1, col + 1)
774 };
775 Some(RenderedElement {
776 text,
777 kind: ElementKind::Normal,
778 })
779 }
780 StatusBarElement::Diagnostics => {
781 let diagnostics = ctx.state.overlays.all();
782 let mut error_count = 0usize;
783 let mut warning_count = 0usize;
784 let mut info_count = 0usize;
785 let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
786 for overlay in diagnostics {
787 if overlay.namespace.as_ref() == Some(&diagnostic_ns) {
788 match overlay.priority {
789 100 => error_count += 1,
790 50 => warning_count += 1,
791 _ => info_count += 1,
792 }
793 }
794 }
795 if error_count + warning_count + info_count == 0 {
796 return None;
797 }
798 let mut parts = Vec::new();
799 if error_count > 0 {
800 parts.push(format!("E:{}", error_count));
801 }
802 if warning_count > 0 {
803 parts.push(format!("W:{}", warning_count));
804 }
805 if info_count > 0 {
806 parts.push(format!("I:{}", info_count));
807 }
808 Some(RenderedElement {
809 text: parts.join(" "),
810 kind: ElementKind::Normal,
811 })
812 }
813 StatusBarElement::CursorCount => {
814 if ctx.cursors.count() <= 1 {
815 return None;
816 }
817 Some(RenderedElement {
818 text: t!("status.cursors", count = ctx.cursors.count()).to_string(),
819 kind: ElementKind::Normal,
820 })
821 }
822 StatusBarElement::Messages => {
823 let mut parts: Vec<&str> = Vec::new();
824 if let Some(msg) = ctx.status_message {
825 if !msg.is_empty() {
826 parts.push(msg);
827 }
828 }
829 if let Some(msg) = ctx.plugin_status_message {
830 if !msg.is_empty() {
831 parts.push(msg);
832 }
833 }
834 if parts.is_empty() {
835 return None;
836 }
837 Some(RenderedElement {
838 text: parts.join(" | "),
839 kind: ElementKind::Messages,
840 })
841 }
842 StatusBarElement::Chord => {
843 if ctx.chord_state.is_empty() {
844 return None;
845 }
846 let chord_str = ctx
847 .chord_state
848 .iter()
849 .map(|(code, modifiers)| {
850 crate::input::keybindings::format_keybinding(code, modifiers)
851 })
852 .collect::<Vec<_>>()
853 .join(" ");
854 Some(RenderedElement {
855 text: format!("[{}]", chord_str),
856 kind: ElementKind::Normal,
857 })
858 }
859 StatusBarElement::LineEnding => Some(RenderedElement {
860 text: format!(" {} ", ctx.state.buffer.line_ending().display_name()),
861 kind: ElementKind::LineEnding,
862 }),
863 StatusBarElement::Encoding => Some(RenderedElement {
864 text: format!(" {} ", ctx.state.buffer.encoding().display_name()),
865 kind: ElementKind::Encoding,
866 }),
867 StatusBarElement::Language => {
868 let text = if ctx.state.language == "text"
869 && ctx.state.display_name != "Text"
870 && ctx.state.display_name != "Plain Text"
871 && ctx.state.display_name != "text"
872 {
873 format!(" {} [syntax only] ", &ctx.state.display_name)
874 } else {
875 format!(" {} ", &ctx.state.display_name)
876 };
877 Some(RenderedElement {
878 text,
879 kind: ElementKind::Language,
880 })
881 }
882 StatusBarElement::Lsp => {
883 if ctx.lsp_status.is_empty() {
884 return None;
885 }
886 Some(RenderedElement {
887 text: format!(" {} ", ctx.lsp_status),
888 kind: ElementKind::Lsp,
889 })
890 }
891 StatusBarElement::Warnings => {
892 if ctx.general_warning_count == 0 {
893 return None;
894 }
895 Some(RenderedElement {
896 text: format!(" [\u{26a0} {}] ", ctx.general_warning_count),
897 kind: ElementKind::WarningBadge,
898 })
899 }
900 StatusBarElement::Update => {
901 let version = ctx.update_available?;
902 Some(RenderedElement {
903 text: format!(" {} ", t!("status.update_available", version = version)),
904 kind: ElementKind::Update,
905 })
906 }
907 StatusBarElement::Palette => {
908 let shortcut = ctx
909 .keybindings
910 .get_keybinding_for_action(
911 &crate::input::keybindings::Action::QuickOpen,
912 crate::input::keybindings::KeyContext::Global,
913 )
914 .unwrap_or_else(|| "?".to_string());
915 Some(RenderedElement {
916 text: format!(" {} ", t!("status.palette", shortcut = shortcut)),
917 kind: ElementKind::Palette,
918 })
919 }
920 StatusBarElement::Clock => {
921 let now = chrono::Local::now();
922 let text = format!("{:02}:{:02}", now.hour(), now.minute());
923 Some(RenderedElement {
924 text,
925 kind: ElementKind::Clock,
926 })
927 }
928 StatusBarElement::RemoteIndicator => {
929 let (text, state) = if let Some(over) = ctx.remote_state_override {
939 (format!(" {} ", over.label()), over.state())
940 } else {
941 match ctx.remote_connection {
942 None => (" Local ".to_string(), RemoteIndicatorState::Local),
943 Some(conn) if conn.contains("(Disconnected)") => {
944 (format!(" {} ", conn), RemoteIndicatorState::Disconnected)
945 }
946 Some(conn) => (format!(" {} ", conn), RemoteIndicatorState::Connected),
947 }
948 };
949 Some(RenderedElement {
950 text,
951 kind: ElementKind::RemoteIndicator(state),
952 })
953 }
954 }
955 }
956
957 fn element_style(
959 kind: ElementKind,
960 theme: &crate::view::theme::Theme,
961 hover: StatusBarHover,
962 _warning_level: WarningLevel,
963 lsp_state: LspIndicatorState,
964 ) -> Style {
965 match kind {
966 ElementKind::Normal | ElementKind::Messages | ElementKind::Clock => Style::default()
967 .fg(theme.status_bar_fg)
968 .bg(theme.status_bar_bg),
969 ElementKind::RemoteDisconnected => Style::default()
970 .fg(theme.status_error_indicator_fg)
971 .bg(theme.status_error_indicator_bg),
972 ElementKind::LineEnding => {
973 let is_hovering = hover == StatusBarHover::LineEndingIndicator;
974 let (fg, bg) = if is_hovering {
975 (theme.menu_hover_fg, theme.menu_hover_bg)
976 } else {
977 (theme.status_bar_fg, theme.status_bar_bg)
978 };
979 let mut style = Style::default().fg(fg).bg(bg);
980 if is_hovering {
981 style = style.add_modifier(Modifier::UNDERLINED);
982 }
983 style
984 }
985 ElementKind::Encoding => {
986 let is_hovering = hover == StatusBarHover::EncodingIndicator;
987 let (fg, bg) = if is_hovering {
988 (theme.menu_hover_fg, theme.menu_hover_bg)
989 } else {
990 (theme.status_bar_fg, theme.status_bar_bg)
991 };
992 let mut style = Style::default().fg(fg).bg(bg);
993 if is_hovering {
994 style = style.add_modifier(Modifier::UNDERLINED);
995 }
996 style
997 }
998 ElementKind::Language => {
999 let is_hovering = hover == StatusBarHover::LanguageIndicator;
1000 let (fg, bg) = if is_hovering {
1001 (theme.menu_hover_fg, theme.menu_hover_bg)
1002 } else {
1003 (theme.status_bar_fg, theme.status_bar_bg)
1004 };
1005 let mut style = Style::default().fg(fg).bg(bg);
1006 if is_hovering {
1007 style = style.add_modifier(Modifier::UNDERLINED);
1008 }
1009 style
1010 }
1011 ElementKind::Lsp => {
1012 let is_hovering = hover == StatusBarHover::LspIndicator;
1013 let (fg, bg) = match lsp_state {
1019 LspIndicatorState::Error => {
1020 (theme.diagnostic_error_fg, theme.diagnostic_error_bg)
1021 }
1022 LspIndicatorState::Off => {
1023 (theme.diagnostic_warning_fg, theme.diagnostic_warning_bg)
1024 }
1025 LspIndicatorState::On => (theme.diagnostic_info_fg, theme.diagnostic_info_bg),
1026 LspIndicatorState::OffDismissed => (theme.status_bar_fg, theme.status_bar_bg),
1034 LspIndicatorState::None => (theme.status_bar_fg, theme.status_bar_bg),
1035 };
1036 let mut style = Style::default().fg(fg).bg(bg);
1037 if is_hovering && lsp_state != LspIndicatorState::None {
1042 style = style.add_modifier(Modifier::UNDERLINED);
1043 }
1044 style
1045 }
1046 ElementKind::WarningBadge => {
1047 let is_hovering = hover == StatusBarHover::WarningBadge;
1048 let (fg, bg) = if is_hovering {
1049 (
1050 theme.status_warning_indicator_hover_fg,
1051 theme.status_warning_indicator_hover_bg,
1052 )
1053 } else {
1054 (
1055 theme.status_warning_indicator_fg,
1056 theme.status_warning_indicator_bg,
1057 )
1058 };
1059 let mut style = Style::default().fg(fg).bg(bg);
1060 if is_hovering {
1061 style = style.add_modifier(Modifier::UNDERLINED);
1062 }
1063 style
1064 }
1065 ElementKind::Update => Style::default()
1066 .fg(theme.menu_highlight_fg)
1067 .bg(theme.menu_dropdown_bg),
1068 ElementKind::Palette => Style::default()
1069 .fg(theme.help_indicator_fg)
1070 .bg(theme.help_indicator_bg),
1071 ElementKind::RemoteIndicator(state) => {
1072 let is_hovering = hover == StatusBarHover::RemoteIndicator;
1073 let (fg, bg) = match state {
1074 RemoteIndicatorState::Connecting | RemoteIndicatorState::Connected => {
1080 (theme.help_indicator_fg, theme.help_indicator_bg)
1081 }
1082 RemoteIndicatorState::FailedAttach | RemoteIndicatorState::Disconnected => (
1086 theme.status_error_indicator_fg,
1087 theme.status_error_indicator_bg,
1088 ),
1089 RemoteIndicatorState::Local => (theme.status_bar_fg, theme.status_bar_bg),
1091 };
1092 let mut style = Style::default().fg(fg).bg(bg);
1093 if is_hovering {
1094 style = style.add_modifier(Modifier::UNDERLINED);
1095 }
1096 style
1097 }
1098 }
1099 }
1100
1101 fn update_layout_for_element(
1103 layout: &mut StatusBarLayout,
1104 kind: ElementKind,
1105 row: u16,
1106 start_col: u16,
1107 end_col: u16,
1108 ) {
1109 match kind {
1110 ElementKind::LineEnding => {
1111 layout.line_ending_indicator = Some((row, start_col, end_col))
1112 }
1113 ElementKind::Encoding => layout.encoding_indicator = Some((row, start_col, end_col)),
1114 ElementKind::Language => layout.language_indicator = Some((row, start_col, end_col)),
1115 ElementKind::Lsp => layout.lsp_indicator = Some((row, start_col, end_col)),
1116 ElementKind::WarningBadge => layout.warning_badge = Some((row, start_col, end_col)),
1117 ElementKind::Messages => layout.message_area = Some((row, start_col, end_col)),
1118 ElementKind::RemoteIndicator(_) => {
1119 layout.remote_indicator = Some((row, start_col, end_col))
1120 }
1121 _ => {}
1122 }
1123 }
1124
1125 fn element_spans(
1130 rendered: &RenderedElement,
1131 theme: &crate::view::theme::Theme,
1132 hover: StatusBarHover,
1133 warning_level: WarningLevel,
1134 lsp_state: LspIndicatorState,
1135 ) -> (Vec<Span<'static>>, usize) {
1136 let base_style = Style::default()
1137 .fg(theme.status_bar_fg)
1138 .bg(theme.status_bar_bg);
1139 let width = str_width(&rendered.text);
1140
1141 if rendered.kind == ElementKind::RemoteDisconnected && rendered.text.starts_with(SSH_PREFIX)
1142 {
1143 let error_style = Style::default()
1144 .fg(theme.status_error_indicator_fg)
1145 .bg(theme.status_error_indicator_bg);
1146 if let Some(term_off) = rendered.text.find(SSH_PREFIX_TERMINATOR) {
1147 let split_at = term_off + SSH_PREFIX_TERMINATOR.len();
1148 let prefix = rendered.text[..split_at].to_string();
1149 let rest = rendered.text[split_at..].to_string();
1150 return (
1151 vec![
1152 Span::styled(prefix, error_style),
1153 Span::styled(rest, base_style),
1154 ],
1155 width,
1156 );
1157 }
1158 return (
1159 vec![Span::styled(rendered.text.clone(), error_style)],
1160 width,
1161 );
1162 }
1163
1164 let style = Self::element_style(rendered.kind, theme, hover, warning_level, lsp_state);
1165 let spans = if rendered.kind == ElementKind::Clock {
1166 vec![
1168 Span::styled(rendered.text[..2].to_string(), style),
1169 Span::styled(":".to_string(), style.add_modifier(Modifier::SLOW_BLINK)),
1170 Span::styled(rendered.text[3..].to_string(), style),
1171 ]
1172 } else {
1173 vec![Span::styled(rendered.text.clone(), style)]
1174 };
1175 (spans, width)
1176 }
1177
1178 fn render_side(
1180 config_side: &[StatusBarElement],
1181 ctx: &mut StatusBarContext<'_>,
1182 ) -> Vec<(Vec<Span<'static>>, usize, ElementKind)> {
1183 let rendered: Vec<RenderedElement> = config_side
1184 .iter()
1185 .filter_map(|elem| Self::render_element(elem, ctx))
1186 .filter(|e| !e.text.is_empty())
1187 .collect();
1188
1189 let theme = ctx.theme;
1190 let hover = ctx.hover;
1191 let warning_level = ctx.warning_level;
1192 let lsp_state = ctx.lsp_indicator_state;
1193 rendered
1194 .into_iter()
1195 .map(|r| {
1196 let kind = r.kind;
1197 let (spans, width) =
1198 Self::element_spans(&r, theme, hover, warning_level, lsp_state);
1199 (spans, width, kind)
1200 })
1201 .collect()
1202 }
1203
1204 fn render_status(
1206 frame: &mut Frame,
1207 area: Rect,
1208 ctx: &mut StatusBarContext<'_>,
1209 config: &StatusBarConfig,
1210 ) -> StatusBarLayout {
1211 let mut layout = StatusBarLayout::default();
1212 let base_style = Style::default()
1213 .fg(ctx.theme.status_bar_fg)
1214 .bg(ctx.theme.status_bar_bg);
1215 let available_width = area.width as usize;
1216
1217 if available_width == 0 || area.height == 0 {
1218 return layout;
1219 }
1220
1221 ctx.remote_indicator_on_bar = config
1226 .left
1227 .iter()
1228 .chain(config.right.iter())
1229 .any(|e| matches!(e, StatusBarElement::RemoteIndicator));
1230
1231 let left_items = Self::render_side(&config.left, ctx);
1232 let mut right_items = Self::render_side(&config.right, ctx);
1233
1234 const SEPARATOR: &str = " | ";
1235 let separator_width = str_width(SEPARATOR);
1236
1237 let total_right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1246 let left_min_target = available_width
1247 .saturating_mul(2)
1248 .saturating_div(5) .min(40); let right_budget = available_width.saturating_sub(left_min_target + 1);
1251 if total_right_width > right_budget && right_items.len() > 1 {
1252 let mut current = total_right_width;
1253 while current > right_budget && right_items.len() > 1 {
1254 if let Some(dropped) = right_items.pop() {
1255 current = current.saturating_sub(dropped.1);
1256 } else {
1257 break;
1258 }
1259 }
1260 }
1261
1262 let right_width: usize = right_items.iter().map(|(_, w, _)| *w).sum();
1263
1264 let narrow = available_width < 15;
1265 let left_max_width = if narrow {
1266 available_width
1267 } else if available_width > right_width + 1 {
1268 available_width - right_width - 1
1269 } else {
1270 1
1271 };
1272
1273 let mut spans: Vec<Span<'static>> = Vec::new();
1277 let mut used_left: usize = 0;
1278
1279 for (idx, (item_spans, width, kind)) in left_items.into_iter().enumerate() {
1280 let sep_width = if idx == 0 { 0 } else { separator_width };
1281 if used_left + sep_width >= left_max_width {
1282 break;
1283 }
1284 if sep_width > 0 {
1285 spans.push(Span::styled(SEPARATOR, base_style));
1286 used_left += sep_width;
1287 }
1288
1289 let remaining = left_max_width - used_left;
1290 let start_col = used_left;
1291
1292 if width <= remaining {
1293 spans.extend(item_spans);
1294 used_left += width;
1295
1296 Self::update_layout_for_element(
1297 &mut layout,
1298 kind,
1299 area.y,
1300 area.x + start_col as u16,
1301 area.x + (start_col + width) as u16,
1302 );
1303 } else {
1304 let group_text: String = item_spans.iter().map(|s| s.content.as_ref()).collect();
1308 let truncated = truncate_to_width(&group_text, remaining);
1309 let truncated_width = str_width(&truncated);
1310 let overflow_style = Self::element_style(
1311 kind,
1312 ctx.theme,
1313 ctx.hover,
1314 ctx.warning_level,
1315 ctx.lsp_indicator_state,
1316 );
1317 spans.push(Span::styled(truncated, overflow_style));
1318 used_left += truncated_width;
1319
1320 Self::update_layout_for_element(
1321 &mut layout,
1322 kind,
1323 area.y,
1324 area.x + start_col as u16,
1325 area.x + (start_col + truncated_width) as u16,
1326 );
1327 break;
1328 }
1329 }
1330
1331 if narrow {
1332 if used_left < available_width {
1333 spans.push(Span::styled(
1334 " ".repeat(available_width - used_left),
1335 base_style,
1336 ));
1337 }
1338 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1339 return layout;
1340 }
1341
1342 let mut col_offset = used_left;
1343 if col_offset + right_width < available_width {
1344 let padding = available_width - col_offset - right_width;
1345 spans.push(Span::styled(" ".repeat(padding), base_style));
1346 col_offset = available_width - right_width;
1347 } else if col_offset < available_width {
1348 spans.push(Span::styled(" ", base_style));
1349 col_offset += 1;
1350 }
1351
1352 let mut current_col = area.x + col_offset as u16;
1353 for (item_spans, width, kind) in right_items {
1354 Self::update_layout_for_element(
1355 &mut layout,
1356 kind,
1357 area.y,
1358 current_col,
1359 current_col + width as u16,
1360 );
1361 spans.extend(item_spans);
1362 current_col += width as u16;
1363 }
1364
1365 frame.render_widget(Paragraph::new(Line::from(spans)), area);
1366 layout
1367 }
1368
1369 #[allow(clippy::too_many_arguments)]
1380 pub fn render_search_options(
1381 frame: &mut Frame,
1382 area: Rect,
1383 case_sensitive: bool,
1384 whole_word: bool,
1385 use_regex: bool,
1386 confirm_each: Option<bool>, theme: &crate::view::theme::Theme,
1388 keybindings: &crate::input::keybindings::KeybindingResolver,
1389 hover: SearchOptionsHover,
1390 ) -> SearchOptionsLayout {
1391 use crate::primitives::display_width::str_width;
1392
1393 let mut layout = SearchOptionsLayout {
1394 row: area.y,
1395 ..Default::default()
1396 };
1397
1398 let base_style = Style::default()
1400 .fg(theme.menu_dropdown_fg)
1401 .bg(theme.menu_dropdown_bg);
1402
1403 let hover_style = Style::default()
1405 .fg(theme.menu_hover_fg)
1406 .bg(theme.menu_hover_bg);
1407
1408 let get_shortcut = |action: &crate::input::keybindings::Action| -> Option<String> {
1410 keybindings
1411 .get_keybinding_for_action(action, crate::input::keybindings::KeyContext::Prompt)
1412 .or_else(|| {
1413 keybindings.get_keybinding_for_action(
1414 action,
1415 crate::input::keybindings::KeyContext::Global,
1416 )
1417 })
1418 };
1419
1420 let case_shortcut =
1422 get_shortcut(&crate::input::keybindings::Action::ToggleSearchCaseSensitive);
1423 let word_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchWholeWord);
1424 let regex_shortcut = get_shortcut(&crate::input::keybindings::Action::ToggleSearchRegex);
1425
1426 let case_checkbox = if case_sensitive { "[x]" } else { "[ ]" };
1428 let word_checkbox = if whole_word { "[x]" } else { "[ ]" };
1429 let regex_checkbox = if use_regex { "[x]" } else { "[ ]" };
1430
1431 let active_style = Style::default()
1433 .fg(theme.menu_highlight_fg)
1434 .bg(theme.menu_dropdown_bg);
1435
1436 let shortcut_style = Style::default()
1438 .fg(theme.help_separator_fg)
1439 .bg(theme.menu_dropdown_bg);
1440
1441 let hover_shortcut_style = Style::default()
1443 .fg(theme.menu_hover_fg)
1444 .bg(theme.menu_hover_bg);
1445
1446 let mut spans = Vec::new();
1447 let mut current_col = area.x;
1448
1449 spans.push(Span::styled(" ", base_style));
1451 current_col += 1;
1452
1453 let get_checkbox_style = |is_hovered: bool, is_checked: bool| -> Style {
1455 if is_hovered {
1456 hover_style
1457 } else if is_checked {
1458 active_style
1459 } else {
1460 base_style
1461 }
1462 };
1463
1464 let case_hovered = hover == SearchOptionsHover::CaseSensitive;
1466 let case_start = current_col;
1467 let case_label = format!("{} {}", case_checkbox, t!("search.case_sensitive"));
1468 let case_shortcut_text = case_shortcut
1469 .as_ref()
1470 .map(|s| format!(" ({})", s))
1471 .unwrap_or_default();
1472 let case_full_width = str_width(&case_label) + str_width(&case_shortcut_text);
1473
1474 spans.push(Span::styled(
1475 case_label,
1476 get_checkbox_style(case_hovered, case_sensitive),
1477 ));
1478 if !case_shortcut_text.is_empty() {
1479 spans.push(Span::styled(
1480 case_shortcut_text,
1481 if case_hovered {
1482 hover_shortcut_style
1483 } else {
1484 shortcut_style
1485 },
1486 ));
1487 }
1488 current_col += case_full_width as u16;
1489 layout.case_sensitive = Some((case_start, current_col));
1490
1491 spans.push(Span::styled(" ", base_style));
1493 current_col += 3;
1494
1495 let word_hovered = hover == SearchOptionsHover::WholeWord;
1497 let word_start = current_col;
1498 let word_label = format!("{} {}", word_checkbox, t!("search.whole_word"));
1499 let word_shortcut_text = word_shortcut
1500 .as_ref()
1501 .map(|s| format!(" ({})", s))
1502 .unwrap_or_default();
1503 let word_full_width = str_width(&word_label) + str_width(&word_shortcut_text);
1504
1505 spans.push(Span::styled(
1506 word_label,
1507 get_checkbox_style(word_hovered, whole_word),
1508 ));
1509 if !word_shortcut_text.is_empty() {
1510 spans.push(Span::styled(
1511 word_shortcut_text,
1512 if word_hovered {
1513 hover_shortcut_style
1514 } else {
1515 shortcut_style
1516 },
1517 ));
1518 }
1519 current_col += word_full_width as u16;
1520 layout.whole_word = Some((word_start, current_col));
1521
1522 spans.push(Span::styled(" ", base_style));
1524 current_col += 3;
1525
1526 let regex_hovered = hover == SearchOptionsHover::Regex;
1528 let regex_start = current_col;
1529 let regex_label = format!("{} {}", regex_checkbox, t!("search.regex"));
1530 let regex_shortcut_text = regex_shortcut
1531 .as_ref()
1532 .map(|s| format!(" ({})", s))
1533 .unwrap_or_default();
1534 let regex_full_width = str_width(®ex_label) + str_width(®ex_shortcut_text);
1535
1536 spans.push(Span::styled(
1537 regex_label,
1538 get_checkbox_style(regex_hovered, use_regex),
1539 ));
1540 if !regex_shortcut_text.is_empty() {
1541 spans.push(Span::styled(
1542 regex_shortcut_text,
1543 if regex_hovered {
1544 hover_shortcut_style
1545 } else {
1546 shortcut_style
1547 },
1548 ));
1549 }
1550 current_col += regex_full_width as u16;
1551 layout.regex = Some((regex_start, current_col));
1552
1553 if use_regex && confirm_each.is_some() {
1555 let hint = " \u{2502} $1,$2,…";
1556 spans.push(Span::styled(hint, shortcut_style));
1557 current_col += str_width(hint) as u16;
1558 }
1559
1560 if let Some(confirm_value) = confirm_each {
1562 let confirm_shortcut =
1563 get_shortcut(&crate::input::keybindings::Action::ToggleSearchConfirmEach);
1564 let confirm_checkbox = if confirm_value { "[x]" } else { "[ ]" };
1565
1566 spans.push(Span::styled(" ", base_style));
1568 current_col += 3;
1569
1570 let confirm_hovered = hover == SearchOptionsHover::ConfirmEach;
1571 let confirm_start = current_col;
1572 let confirm_label = format!("{} {}", confirm_checkbox, t!("search.confirm_each"));
1573 let confirm_shortcut_text = confirm_shortcut
1574 .as_ref()
1575 .map(|s| format!(" ({})", s))
1576 .unwrap_or_default();
1577 let confirm_full_width = str_width(&confirm_label) + str_width(&confirm_shortcut_text);
1578
1579 spans.push(Span::styled(
1580 confirm_label,
1581 get_checkbox_style(confirm_hovered, confirm_value),
1582 ));
1583 if !confirm_shortcut_text.is_empty() {
1584 spans.push(Span::styled(
1585 confirm_shortcut_text,
1586 if confirm_hovered {
1587 hover_shortcut_style
1588 } else {
1589 shortcut_style
1590 },
1591 ));
1592 }
1593 current_col += confirm_full_width as u16;
1594 layout.confirm_each = Some((confirm_start, current_col));
1595 }
1596
1597 let current_width = (current_col - area.x) as usize;
1599 let available_width = area.width as usize;
1600 if current_width < available_width {
1601 spans.push(Span::styled(
1602 " ".repeat(available_width.saturating_sub(current_width)),
1603 base_style,
1604 ));
1605 }
1606
1607 let options_line = Paragraph::new(Line::from(spans));
1608 frame.render_widget(options_line, area);
1609
1610 layout
1611 }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617 use std::path::PathBuf;
1618
1619 #[test]
1620 fn test_truncate_path_short_path() {
1621 let path = PathBuf::from("/home/user/project");
1622 let result = truncate_path(&path, 50);
1623
1624 assert!(!result.truncated);
1625 assert_eq!(result.suffix, "/home/user/project");
1626 assert!(result.prefix.is_empty());
1627 }
1628
1629 #[test]
1630 fn test_truncate_path_long_path() {
1631 let path = PathBuf::from(
1632 "/private/var/folders/p6/nlmq3k8146990kpkxl73mq340000gn/T/.tmpNYt4Fc/project_root",
1633 );
1634 let result = truncate_path(&path, 40);
1635
1636 assert!(result.truncated, "Path should be truncated");
1637 assert_eq!(result.prefix, "/private");
1638 assert!(
1639 result.suffix.contains("project_root"),
1640 "Suffix should contain project_root"
1641 );
1642 }
1643
1644 #[test]
1645 fn test_truncate_path_preserves_last_components() {
1646 let path = PathBuf::from("/a/b/c/d/e/f/g/h/i/j/project/src");
1647 let result = truncate_path(&path, 30);
1648
1649 assert!(result.truncated);
1650 assert!(
1652 result.suffix.contains("src"),
1653 "Should preserve last component 'src', got: {}",
1654 result.suffix
1655 );
1656 }
1657
1658 #[test]
1659 fn test_truncate_path_display_len() {
1660 let path = PathBuf::from("/private/var/folders/deep/nested/path/here");
1661 let result = truncate_path(&path, 30);
1662
1663 let display = result.to_string_plain();
1665 assert!(
1666 display.len() <= 35, "Display should be truncated to around 30 chars, got {} chars: {}",
1668 display.len(),
1669 display
1670 );
1671 }
1672
1673 #[test]
1674 fn test_truncate_path_root_only() {
1675 let path = PathBuf::from("/");
1676 let result = truncate_path(&path, 50);
1677
1678 assert!(!result.truncated);
1679 assert_eq!(result.suffix, "/");
1680 }
1681
1682 #[test]
1683 fn test_truncate_path_multibyte_single_component_does_not_panic() {
1684 let path = PathBuf::from("/ユーザーのプロジェクト名前/file");
1690 let result = truncate_path(&path, 5);
1691 let display = result.to_string_plain();
1692 assert!(display.is_char_boundary(display.len()));
1693 assert!(display.ends_with("..."));
1694 }
1695
1696 #[test]
1697 fn test_truncate_path_multibyte_last_component_does_not_panic() {
1698 let path = PathBuf::from("/a/ユーザーのプロジェクト名前");
1705 let result = truncate_path(&path, 13);
1706 let display = result.to_string_plain();
1707 assert!(display.is_char_boundary(display.len()));
1708 }
1709
1710 #[test]
1711 fn test_truncated_path_to_string_plain() {
1712 let truncated = TruncatedPath {
1713 prefix: "/home".to_string(),
1714 truncated: true,
1715 suffix: "/project/src".to_string(),
1716 };
1717
1718 assert_eq!(truncated.to_string_plain(), "/home/[...]/project/src");
1719 }
1720
1721 #[test]
1722 fn test_truncated_path_to_string_plain_no_truncation() {
1723 let truncated = TruncatedPath {
1724 prefix: String::new(),
1725 truncated: false,
1726 suffix: "/home/user/project".to_string(),
1727 };
1728
1729 assert_eq!(truncated.to_string_plain(), "/home/user/project");
1730 }
1731
1732 #[test]
1733 fn test_remote_indicator_element_kind_equality() {
1734 assert_eq!(
1738 ElementKind::RemoteIndicator(RemoteIndicatorState::Local),
1739 ElementKind::RemoteIndicator(RemoteIndicatorState::Local)
1740 );
1741 let distinct = [
1742 RemoteIndicatorState::Local,
1743 RemoteIndicatorState::Connecting,
1744 RemoteIndicatorState::Connected,
1745 RemoteIndicatorState::FailedAttach,
1746 RemoteIndicatorState::Disconnected,
1747 ];
1748 for (i, a) in distinct.iter().enumerate() {
1749 for (j, b) in distinct.iter().enumerate() {
1750 if i == j {
1751 continue;
1752 }
1753 assert_ne!(
1754 ElementKind::RemoteIndicator(*a),
1755 ElementKind::RemoteIndicator(*b),
1756 "expected {:?} != {:?}",
1757 a,
1758 b
1759 );
1760 }
1761 }
1762 }
1763
1764 #[test]
1765 fn test_remote_indicator_state_default_is_local() {
1766 assert_eq!(RemoteIndicatorState::default(), RemoteIndicatorState::Local);
1769 }
1770
1771 #[test]
1772 fn test_remote_indicator_override_deserializes_kind_tags() {
1773 let cases: &[(&str, RemoteIndicatorOverride)] = &[
1777 (r#"{"kind":"local"}"#, RemoteIndicatorOverride::Local),
1778 (
1779 r#"{"kind":"connecting","label":"Building"}"#,
1780 RemoteIndicatorOverride::Connecting {
1781 label: Some("Building".into()),
1782 },
1783 ),
1784 (
1785 r#"{"kind":"connecting"}"#,
1786 RemoteIndicatorOverride::Connecting { label: None },
1787 ),
1788 (
1789 r#"{"kind":"connected","label":"Container:abc"}"#,
1790 RemoteIndicatorOverride::Connected {
1791 label: Some("Container:abc".into()),
1792 },
1793 ),
1794 (
1795 r#"{"kind":"failed_attach","error":"exit 1"}"#,
1796 RemoteIndicatorOverride::FailedAttach {
1797 error: Some("exit 1".into()),
1798 },
1799 ),
1800 (
1801 r#"{"kind":"disconnected","label":"Container:abc"}"#,
1802 RemoteIndicatorOverride::Disconnected {
1803 label: Some("Container:abc".into()),
1804 },
1805 ),
1806 ];
1807 for (json, expected) in cases {
1808 let parsed: RemoteIndicatorOverride = serde_json::from_str(json)
1809 .unwrap_or_else(|e| panic!("failed to parse {}: {}", json, e));
1810 assert_eq!(&parsed, expected, "wire shape mismatch for {}", json);
1811 }
1812 }
1813
1814 #[test]
1815 fn test_remote_indicator_override_labels() {
1816 let connecting = RemoteIndicatorOverride::Connecting { label: None };
1820 assert!(
1821 connecting.label().contains("Connecting"),
1822 "connecting default label should mention Connecting, got {:?}",
1823 connecting.label()
1824 );
1825
1826 let connecting_labeled = RemoteIndicatorOverride::Connecting {
1827 label: Some("Building".into()),
1828 };
1829 assert!(
1830 connecting_labeled.label().contains("Building"),
1831 "labeled connecting should include the label, got {:?}",
1832 connecting_labeled.label()
1833 );
1834
1835 let failed_bare = RemoteIndicatorOverride::FailedAttach { error: None };
1836 assert_eq!(failed_bare.label(), "Attach failed");
1837
1838 let failed_detail = RemoteIndicatorOverride::FailedAttach {
1839 error: Some("exit 1".into()),
1840 };
1841 assert!(
1842 failed_detail.label().contains("exit 1"),
1843 "failed with error should include the error, got {:?}",
1844 failed_detail.label()
1845 );
1846 }
1847
1848 #[test]
1849 fn test_remote_indicator_override_state_projection() {
1850 assert_eq!(
1851 RemoteIndicatorOverride::Local.state(),
1852 RemoteIndicatorState::Local
1853 );
1854 assert_eq!(
1855 RemoteIndicatorOverride::Connecting { label: None }.state(),
1856 RemoteIndicatorState::Connecting
1857 );
1858 assert_eq!(
1859 RemoteIndicatorOverride::Connected { label: None }.state(),
1860 RemoteIndicatorState::Connected
1861 );
1862 assert_eq!(
1863 RemoteIndicatorOverride::FailedAttach { error: None }.state(),
1864 RemoteIndicatorState::FailedAttach
1865 );
1866 assert_eq!(
1867 RemoteIndicatorOverride::Disconnected { label: None }.state(),
1868 RemoteIndicatorState::Disconnected
1869 );
1870 }
1871}