Skip to main content

ghostscope_ui/model/
panel_state.rs

1use crate::action::ResponseType;
2use crate::events::UiTraceEvent;
3use std::collections::{HashMap, HashSet, VecDeque};
4use std::time::Instant;
5
6/// Cached trace event with pre-formatted timestamp
7#[derive(Debug, Clone)]
8pub struct CachedTraceEvent {
9    pub event: UiTraceEvent,
10    pub formatted_timestamp: String,
11}
12
13/// Source panel state
14#[derive(Debug, Clone)]
15pub struct SourcePanelState {
16    pub content: Vec<String>,
17    pub current_line: Option<usize>,
18    pub cursor_line: usize,
19    pub cursor_col: usize,
20    pub scroll_offset: usize,
21    pub horizontal_scroll_offset: usize,
22    pub file_path: Option<String>,
23    pub language: String,
24    pub area_height: u16,
25    pub area_width: u16,
26
27    // Search state
28    pub search_query: String,
29    pub search_matches: Vec<(usize, usize, usize)>, // (line_idx, start, end)
30    pub current_match: Option<usize>,
31    pub is_searching: bool,
32
33    // File search state
34    // Note: File list is stored in command_panel.file_completion_cache (single source of truth)
35    // This panel only maintains filtering/selection state
36    pub file_search_query: String,
37    pub file_search_cursor_pos: usize, // Cursor position in the search query
38    pub file_search_filtered_indices: Vec<usize>,
39    pub file_search_selected: usize,
40    pub file_search_scroll: usize,
41    pub file_search_message: Option<String>,
42    pub is_file_searching: bool,
43
44    // Navigation state
45    pub number_buffer: String,
46    pub expecting_g: bool,
47    pub g_pressed: bool,
48
49    // Mode state
50    pub mode: SourcePanelMode,
51
52    // Traced lines tracking
53    pub traced_lines: HashSet<usize>, // Line numbers with active traces (1-based)
54    pub disabled_lines: HashSet<usize>, // Line numbers with disabled traces (1-based)
55    pub pending_trace_line: Option<usize>, // Line waiting for trace confirmation (1-based)
56    // Map from trace_id to (file_path, line_number) for managing trace status
57    pub trace_locations: HashMap<u32, (String, usize)>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SourcePanelMode {
62    Normal,
63    TextSearch,
64    FileSearch,
65}
66
67impl SourcePanelState {
68    pub fn new() -> Self {
69        Self {
70            content: vec!["// No source code loaded".to_string()],
71            current_line: None,
72            cursor_line: 0,
73            cursor_col: 0,
74            scroll_offset: 0,
75            horizontal_scroll_offset: 0,
76            file_path: None,
77            language: "c".to_string(),
78            area_height: 10,
79            area_width: 80,
80            search_query: String::new(),
81            search_matches: Vec::new(),
82            current_match: None,
83            is_searching: false,
84            file_search_query: String::new(),
85            file_search_cursor_pos: 0,
86            file_search_filtered_indices: Vec::new(),
87            file_search_selected: 0,
88            file_search_scroll: 0,
89            file_search_message: None,
90            is_file_searching: false,
91            number_buffer: String::new(),
92            expecting_g: false,
93            g_pressed: false,
94            mode: SourcePanelMode::Normal,
95            traced_lines: HashSet::new(),
96            disabled_lines: HashSet::new(),
97            pending_trace_line: None,
98            trace_locations: HashMap::new(),
99        }
100    }
101}
102
103impl Default for SourcePanelState {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109/// eBPF panel state
110#[derive(Debug)]
111pub struct EbpfPanelState {
112    pub trace_events: VecDeque<CachedTraceEvent>,
113    pub scroll_offset: usize,
114    pub max_messages: usize,
115    pub auto_scroll: bool,
116    pub cursor_trace_index: usize, // Index of the selected trace (not line)
117    pub show_cursor: bool,         // Whether to show cursor highlighting
118    pub display_mode: DisplayMode, // Current display mode
119    pub next_message_id: u64,      // Simple counter for message numbering
120    // Numeric jump input for N+G
121    pub numeric_prefix: Option<String>,
122    pub g_pressed: bool, // whether first 'g' was pressed (for 'gg')
123    // Expanded card view state
124    pub view_mode: EbpfViewMode,
125    pub expanded_scroll: usize,   // scroll offset inside expanded card
126    pub last_inner_height: usize, // last known inner height (for half-page scroll)
127}
128
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub enum DisplayMode {
131    AutoRefresh, // Default mode: always show latest trace, auto-scroll
132    Scroll,      // Manual mode: show cursor, manual navigation
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum EbpfViewMode {
137    List,
138    Expanded { index: usize, scroll: usize },
139}
140
141impl EbpfPanelState {
142    pub fn new() -> Self {
143        Self::new_with_max_messages(2000)
144    }
145
146    pub fn new_with_max_messages(max_messages: usize) -> Self {
147        Self {
148            trace_events: VecDeque::new(),
149            scroll_offset: 0,
150            max_messages,
151            auto_scroll: true,
152            cursor_trace_index: 0,
153            show_cursor: false,
154            display_mode: DisplayMode::AutoRefresh,
155            next_message_id: 1,
156            numeric_prefix: None,
157            g_pressed: false,
158            view_mode: EbpfViewMode::List,
159            expanded_scroll: 0,
160            last_inner_height: 0,
161        }
162    }
163
164    pub fn add_trace_event(&mut self, trace_event: UiTraceEvent) {
165        // Format timestamp once when adding the event
166        let formatted_timestamp = crate::utils::format_timestamp_ns(trace_event.timestamp);
167        let cached_event = CachedTraceEvent {
168            event: trace_event,
169            formatted_timestamp,
170        };
171
172        self.trace_events.push_back(cached_event);
173        if self.trace_events.len() > self.max_messages {
174            self.trace_events.pop_front();
175        }
176
177        // Only auto-scroll in auto-refresh mode
178        if self.display_mode == DisplayMode::AutoRefresh {
179            self.scroll_to_bottom();
180        }
181    }
182
183    pub fn runtime_warning_event(message: String, timestamp: u64) -> UiTraceEvent {
184        UiTraceEvent::text_event(0, timestamp, 0, 0, message, Some(1))
185    }
186
187    pub fn add_runtime_warning_message(&mut self, message: String, timestamp: u64) {
188        self.add_trace_event(Self::runtime_warning_event(message, timestamp));
189    }
190
191    pub fn scroll_up(&mut self) {
192        if self.scroll_offset > 0 {
193            self.scroll_offset -= 1;
194            self.auto_scroll = false;
195        }
196    }
197
198    pub fn scroll_down(&mut self) {
199        let total_lines = self.trace_events.len();
200        if self.scroll_offset + 1 < total_lines {
201            self.scroll_offset += 1;
202        } else {
203            self.auto_scroll = true;
204        }
205    }
206
207    pub fn scroll_to_bottom(&mut self) {
208        // For now, just set scroll offset to 0 to show all messages
209        self.scroll_offset = 0;
210        self.auto_scroll = true;
211        self.show_cursor = false;
212    }
213
214    pub fn move_cursor_up(&mut self) {
215        self.enter_scroll_mode();
216        if self.cursor_trace_index > 0 {
217            self.cursor_trace_index -= 1;
218        }
219    }
220
221    pub fn move_cursor_down(&mut self) {
222        self.enter_scroll_mode();
223        if self.cursor_trace_index + 1 < self.trace_events.len() {
224            self.cursor_trace_index += 1;
225        }
226    }
227
228    pub fn move_cursor_up_10(&mut self) {
229        self.enter_scroll_mode();
230        self.cursor_trace_index = self.cursor_trace_index.saturating_sub(10);
231    }
232
233    pub fn move_cursor_down_10(&mut self) {
234        self.enter_scroll_mode();
235        let max_index = self.trace_events.len().saturating_sub(1);
236        self.cursor_trace_index = (self.cursor_trace_index + 10).min(max_index);
237    }
238
239    // Jump to first trace (gg)
240    pub fn jump_to_first(&mut self) {
241        self.enter_scroll_mode();
242        self.cursor_trace_index = 0;
243    }
244
245    // Jump to last trace (G without prefix)
246    pub fn jump_to_last(&mut self) {
247        self.enter_scroll_mode();
248        self.cursor_trace_index = self.trace_events.len().saturating_sub(1);
249    }
250
251    // Start or append numeric prefix for N G
252    pub fn push_numeric_digit(&mut self, ch: char) {
253        if ch.is_ascii_digit() {
254            self.enter_scroll_mode();
255            let s = self.numeric_prefix.get_or_insert_with(String::new);
256            if s.len() < 9 {
257                s.push(ch);
258            }
259            // typing number cancels pending 'g'
260            self.g_pressed = false;
261        }
262    }
263
264    // Confirm 'G' action: if numeric prefix present, jump to that message number; else jump to last
265    pub fn confirm_goto(&mut self) {
266        if let Some(s) = self.numeric_prefix.take() {
267            if let Ok(num) = s.parse::<u64>() {
268                self.jump_to_message_number(num);
269                return;
270            }
271        }
272        self.jump_to_last();
273    }
274
275    // Jump to specific message number (1-based). Clamp to [first,last]
276    pub fn jump_to_message_number(&mut self, message_number: u64) {
277        self.enter_scroll_mode();
278        if self.trace_events.is_empty() {
279            self.cursor_trace_index = 0;
280            return;
281        }
282
283        // Message numbers are 1-based sequential (1, 2, 3, ...)
284        // Convert to 0-based index
285        if message_number == 0 {
286            self.cursor_trace_index = 0;
287            return;
288        }
289
290        let target_index = (message_number - 1) as usize;
291        self.cursor_trace_index = target_index.min(self.trace_events.len().saturating_sub(1));
292    }
293
294    // Exit to auto-refresh (ESC)
295    pub fn exit_to_auto_refresh(&mut self) {
296        self.numeric_prefix = None;
297        self.g_pressed = false;
298        self.hide_cursor();
299        self.scroll_to_bottom();
300    }
301
302    // Handle 'g' key (support 'gg')
303    pub fn handle_g_key(&mut self) {
304        self.enter_scroll_mode();
305        if self.g_pressed {
306            self.g_pressed = false;
307            self.jump_to_first();
308        } else {
309            self.g_pressed = true;
310        }
311    }
312
313    /// Enter scroll mode and set cursor to the last trace
314    fn enter_scroll_mode(&mut self) {
315        if self.display_mode != DisplayMode::Scroll {
316            self.display_mode = DisplayMode::Scroll;
317            self.show_cursor = true;
318            self.auto_scroll = false;
319            // Set cursor to the last trace when entering scroll mode
320            self.cursor_trace_index = self.trace_events.len().saturating_sub(1);
321        }
322    }
323
324    pub fn hide_cursor(&mut self) {
325        self.display_mode = DisplayMode::AutoRefresh;
326        self.show_cursor = false;
327        self.auto_scroll = true;
328    }
329
330    // Expanded view helpers
331    pub fn open_expanded_current(&mut self) {
332        if self.cursor_trace_index < self.trace_events.len() {
333            self.view_mode = EbpfViewMode::Expanded {
334                index: self.cursor_trace_index,
335                scroll: 0,
336            };
337            self.expanded_scroll = 0;
338        }
339    }
340
341    pub fn close_expanded(&mut self) {
342        self.view_mode = EbpfViewMode::List;
343        self.expanded_scroll = 0;
344    }
345
346    pub fn is_expanded(&self) -> bool {
347        matches!(self.view_mode, EbpfViewMode::Expanded { .. })
348    }
349
350    pub fn expanded_index(&self) -> Option<usize> {
351        if let EbpfViewMode::Expanded { index, .. } = self.view_mode {
352            Some(index)
353        } else {
354            None
355        }
356    }
357
358    pub fn set_expanded_scroll(&mut self, value: usize) {
359        self.expanded_scroll = value;
360        if let EbpfViewMode::Expanded { index, .. } = self.view_mode {
361            self.view_mode = EbpfViewMode::Expanded {
362                index,
363                scroll: self.expanded_scroll,
364            };
365        }
366    }
367
368    pub fn scroll_expanded_up(&mut self, lines: usize) {
369        let new_off = self.expanded_scroll.saturating_sub(lines);
370        self.set_expanded_scroll(new_off);
371    }
372
373    pub fn scroll_expanded_down(&mut self, lines: usize, max_lines: usize) {
374        let new_off = (self.expanded_scroll + lines).min(max_lines);
375        self.set_expanded_scroll(new_off);
376    }
377}
378
379impl Default for EbpfPanelState {
380    fn default() -> Self {
381        Self::new()
382    }
383}
384
385/// Command panel state
386#[derive(Debug)]
387pub struct CommandPanelState {
388    // Input state
389    pub input_text: String,
390    pub cursor_position: usize,
391
392    // Mode and interaction state
393    pub mode: InteractionMode,
394    pub input_state: InputState,
395
396    // History
397    pub command_history: Vec<CommandHistoryItem>,
398    pub history_index: Option<usize>,
399    pub unsent_input_backup: Option<String>,
400
401    // Script editing
402    pub script_cache: Option<ScriptCache>,
403
404    // Command mode navigation - cursor in unified line view
405    pub command_cursor_line: usize,
406    pub command_cursor_column: usize,
407    pub cached_panel_width: u16, // Cached panel width for text wrapping calculations
408
409    // File completion cache
410    pub file_completion_cache:
411        Option<crate::components::command_panel::file_completion::FileCompletionCache>,
412
413    // Saved cursor states for mode switching
414    pub saved_input_cursor: usize, // Input mode cursor position
415    pub saved_script_cursor: Option<(usize, usize)>, // Script mode (line, col)
416    pub previous_mode: Option<InteractionMode>, // Track mode for 'i' key return
417
418    // Display
419    pub static_lines: Vec<StaticTextLine>,
420    pub scroll_offset: usize,
421    pub styled_buffer: Option<Vec<ratatui::text::Line<'static>>>,
422    pub styled_at_history_index: Option<usize>,
423
424    // Vim-like escape sequence
425    pub jk_escape_state: JkEscapeState,
426    pub jk_timer: Option<Instant>,
427
428    // Configuration
429    pub max_history_items: usize,
430
431    // New history management
432    pub command_history_manager: crate::components::command_panel::CommandHistory,
433    pub history_search: crate::components::command_panel::HistorySearchState,
434    pub auto_suggestion: crate::components::command_panel::AutoSuggestionState,
435
436    // Batch loading state for source command
437    pub batch_loading: Option<BatchLoadingState>,
438}
439
440/// State for tracking batch trace loading (e.g., from source command)
441#[derive(Debug, Clone)]
442pub struct BatchLoadingState {
443    pub filename: String,
444    pub total_count: usize,
445    pub completed_count: usize,
446    pub success_count: usize,
447    pub failed_count: usize,
448    pub disabled_count: usize,
449    pub details: Vec<crate::events::TraceLoadDetail>,
450    requested_enabled: VecDeque<bool>,
451}
452
453impl BatchLoadingState {
454    pub fn new(filename: String, traces: &[crate::events::TraceDefinition]) -> Self {
455        Self {
456            filename,
457            total_count: traces.len(),
458            completed_count: 0,
459            success_count: 0,
460            failed_count: 0,
461            disabled_count: 0,
462            details: Vec::new(),
463            requested_enabled: traces.iter().map(|trace| trace.enabled).collect(),
464        }
465    }
466
467    pub fn record_script_compilation(&mut self, details: &crate::events::ScriptCompilationDetails) {
468        self.completed_count += 1;
469
470        let requested_enabled = self.requested_enabled.pop_front().unwrap_or(true);
471        self.success_count += details.success_count;
472        self.failed_count += details.failed_count;
473
474        let mut trace_ids = details.trace_ids.iter().copied();
475        for result in &details.results {
476            match &result.status {
477                crate::events::ExecutionStatus::Success => {
478                    let trace_id = trace_ids.next();
479                    let status = if requested_enabled {
480                        crate::events::LoadStatus::Created
481                    } else {
482                        self.disabled_count += 1;
483                        crate::events::LoadStatus::CreatedDisabled
484                    };
485
486                    self.details.push(crate::events::TraceLoadDetail {
487                        target: result.target_name.clone(),
488                        trace_id,
489                        status,
490                        error: None,
491                    });
492                }
493                crate::events::ExecutionStatus::Failed(error) => {
494                    self.details.push(crate::events::TraceLoadDetail {
495                        target: result.target_name.clone(),
496                        trace_id: None,
497                        status: crate::events::LoadStatus::Failed,
498                        error: Some(error.clone()),
499                    });
500                }
501                crate::events::ExecutionStatus::Skipped(_) => {}
502            }
503        }
504    }
505}
506
507#[derive(Debug, Clone, Copy, PartialEq)]
508pub enum InteractionMode {
509    Input,        // Normal input mode
510    Command,      // Command mode (previously VimCommand)
511    ScriptEditor, // Multi-line script editing mode
512}
513
514#[derive(Debug, Clone, PartialEq)]
515pub enum JkEscapeState {
516    None, // No escape sequence in progress
517    J,    // 'j' was pressed, waiting for 'k'
518}
519
520#[derive(Debug, Clone, PartialEq)]
521pub enum InputState {
522    Ready, // Normal input state, shows (ghostscope)
523    WaitingResponse {
524        // Waiting for response, completely hide input
525        command: String,
526        sent_time: Instant,
527        command_type: CommandType,
528    },
529    ScriptEditor, // Script editing mode
530}
531
532#[derive(Debug, Clone, PartialEq)]
533pub enum CommandType {
534    Script,
535    Enable { trace_id: u32 },
536    Disable { trace_id: u32 },
537    Delete { trace_id: u32 },
538    EnableAll,
539    DisableAll,
540    DeleteAll,
541    InfoFunction { target: String, verbose: bool },
542    InfoLine { target: String, verbose: bool },
543    InfoAddress { target: String, verbose: bool },
544    InfoTrace { trace_id: Option<u32> },
545    InfoTraceAll,
546    InfoSource,
547    InfoShare,
548    InfoShareAll,
549    InfoFile,
550    SaveTraces,
551    LoadTraces,
552    SrcPath,
553    SrcPathAdd,
554    SrcPathMap,
555    SrcPathRemove,
556    SrcPathClear,
557    SrcPathReset,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq)]
561pub enum ScriptStatus {
562    Draft,     // Script is being edited
563    Submitted, // Script was submitted successfully
564    Error,     // Script had errors
565}
566
567#[derive(Debug, Clone)]
568pub struct SavedScript {
569    pub content: String,    // Script content
570    pub cursor_line: usize, // Saved cursor line
571    pub cursor_col: usize,  // Saved cursor column
572}
573
574#[derive(Debug, Clone)]
575pub struct ScriptCache {
576    pub target: String,                // Trace target (function name or file:line)
577    pub original_command: String,      // Original trace command (e.g., "trace main")
578    pub selected_index: Option<usize>, // Optional index to filter a single address (1-based)
579    pub lines: Vec<String>,            // Script lines
580    pub cursor_line: usize,            // Current cursor line (0-based)
581    pub cursor_col: usize,             // Current cursor column (0-based)
582    pub status: ScriptStatus,          // Current script status
583    pub saved_scripts: HashMap<String, SavedScript>, // target -> complete script cache
584}
585
586#[derive(Debug, Clone)]
587pub struct CommandHistoryItem {
588    pub command: String,
589    pub response: Option<String>,
590    pub response_styled: Option<Vec<ratatui::text::Line<'static>>>,
591    pub timestamp: std::time::Instant,
592    pub prompt: String,
593    pub response_type: Option<ResponseType>,
594}
595
596#[derive(Debug, Clone)]
597pub struct StaticTextLine {
598    pub content: String,
599    pub line_type: LineType,
600    pub history_index: Option<usize>,
601    pub response_type: Option<ResponseType>,
602    /// Optional pre-styled content (takes precedence over content if present)
603    pub styled_content: Option<ratatui::text::Line<'static>>,
604}
605
606#[derive(Debug, Clone, Copy, PartialEq)]
607pub enum LineType {
608    Command,
609    Response,
610    RuntimeAlert,
611    CurrentInput,
612    Welcome,
613}
614
615impl CommandPanelState {
616    pub fn new() -> Self {
617        Self::new_with_config(&crate::model::ui_state::HistoryConfig::default())
618    }
619
620    pub fn new_with_config(history_config: &crate::model::ui_state::HistoryConfig) -> Self {
621        Self {
622            input_text: String::new(),
623            cursor_position: 0,
624            mode: InteractionMode::Input,
625            input_state: InputState::Ready,
626            command_history: Vec::new(),
627            history_index: None,
628            unsent_input_backup: None,
629            script_cache: None,
630            command_cursor_line: 0,
631            command_cursor_column: 0,
632            cached_panel_width: 80, // Default width
633            file_completion_cache: None,
634            saved_input_cursor: 0,
635            saved_script_cursor: None,
636            previous_mode: None,
637            static_lines: Vec::new(),
638            scroll_offset: 0,
639            styled_buffer: None,
640            styled_at_history_index: None,
641            jk_escape_state: JkEscapeState::None,
642            jk_timer: None,
643            max_history_items: history_config.max_entries,
644
645            // New history management
646            command_history_manager:
647                crate::components::command_panel::CommandHistory::new_with_config(history_config),
648            history_search: crate::components::command_panel::HistorySearchState::new(),
649            auto_suggestion: crate::components::command_panel::AutoSuggestionState::new(),
650            batch_loading: None,
651        }
652    }
653
654    /// Update cached panel width for text wrapping calculations
655    pub fn update_panel_width(&mut self, width: u16) {
656        self.cached_panel_width = width;
657    }
658
659    /// Reflow command-mode cursor after width changes to keep it within valid bounds
660    pub fn reflow_command_cursor_after_width_change(&mut self) {
661        if self.mode != InteractionMode::Command {
662            return;
663        }
664
665        let wrapped = self.get_command_mode_wrapped_lines(self.cached_panel_width);
666        if wrapped.is_empty() {
667            self.command_cursor_line = 0;
668            self.command_cursor_column = 0;
669            return;
670        }
671
672        if self.command_cursor_line >= wrapped.len() {
673            self.command_cursor_line = wrapped.len().saturating_sub(1);
674        }
675
676        // Clamp cursor column to the current line's length (unicode-aware by chars)
677        let line_len = wrapped[self.command_cursor_line].chars().count();
678        if self.command_cursor_column > line_len {
679            self.command_cursor_column = line_len;
680        }
681    }
682
683    /// Remap command-mode cursor from old wrapped coordinates to new width-aware coordinates
684    pub fn remap_command_cursor_on_width_change(&mut self, old_width: u16, new_width: u16) {
685        if self.mode != InteractionMode::Command {
686            return;
687        }
688
689        // Build mapping from wrapped (old) to logical (line index + char offset)
690        let logical_lines = self.get_command_mode_lines();
691        if logical_lines.is_empty() {
692            self.command_cursor_line = 0;
693            self.command_cursor_column = 0;
694            return;
695        }
696
697        // Find which logical line the current wrapped cursor line falls into (under old width)
698        let mut acc_old = 0usize;
699        let mut target_logical_idx = 0usize;
700        let mut logical_char_offset = 0usize;
701        let mut found = false;
702
703        for (i, line) in logical_lines.iter().enumerate() {
704            let wraps_old = self.wrap_text_unicode(line, old_width);
705            let wrap_count = wraps_old.len();
706            if self.command_cursor_line < acc_old + wrap_count {
707                let within = self.command_cursor_line - acc_old;
708                // Sum lengths of previous wraps to get char offset, then add current column (clamped)
709                let offset: usize = wraps_old
710                    .iter()
711                    .take(within)
712                    .map(|s| s.chars().count())
713                    .sum();
714                let this_len = wraps_old[within].chars().count();
715                let col = self.command_cursor_column.min(this_len);
716                logical_char_offset = offset + col;
717                target_logical_idx = i;
718                found = true;
719                break;
720            }
721            acc_old += wrap_count;
722        }
723
724        if !found {
725            // Fallback to simple clamp with new width
726            self.cached_panel_width = new_width;
727            self.reflow_command_cursor_after_width_change();
728            return;
729        }
730
731        // Map back into wrapped coordinates under the new width
732        let mut acc_new = 0usize;
733        for (i, line) in logical_lines.iter().enumerate() {
734            let wraps_new = self.wrap_text_unicode(line, new_width);
735            if i == target_logical_idx {
736                let mut remaining = logical_char_offset;
737                let mut widx = 0usize;
738                while widx < wraps_new.len() {
739                    let seg_len = wraps_new[widx].chars().count();
740                    if remaining <= seg_len {
741                        self.command_cursor_line = acc_new + widx;
742                        self.command_cursor_column = remaining.min(seg_len);
743                        break;
744                    }
745                    remaining -= seg_len;
746                    widx += 1;
747                }
748
749                if widx >= wraps_new.len() {
750                    // Place at end of logical line if offset exceeds
751                    self.command_cursor_line = acc_new + wraps_new.len().saturating_sub(1);
752                    self.command_cursor_column =
753                        wraps_new.last().map(|s| s.chars().count()).unwrap_or(0);
754                }
755                break;
756            } else {
757                acc_new += wraps_new.len();
758            }
759        }
760    }
761
762    /// Clean up file completion cache if unused for too long
763    pub fn cleanup_file_completion_cache(&mut self) {
764        if let Some(cache) = &self.file_completion_cache {
765            if cache.should_cleanup() {
766                tracing::debug!("Cleaning up unused file completion cache");
767                self.file_completion_cache = None;
768            }
769        }
770    }
771
772    /// Enter command mode from current mode, saving cursor state
773    pub fn enter_command_mode(&mut self, panel_width: u16) {
774        self.cached_panel_width = panel_width;
775        self.previous_mode = Some(self.mode);
776
777        match self.mode {
778            InteractionMode::Input => {
779                self.saved_input_cursor = self.cursor_position;
780                // Set command cursor to the current input line (last line)
781                // Use wrapped lines to handle text that exceeds panel width
782                let wrapped_lines = self.get_command_mode_wrapped_lines(panel_width);
783
784                self.command_cursor_line = wrapped_lines.len().saturating_sub(1);
785                // Calculate column position including prompt prefix
786                let prompt = crate::ui::strings::UIStrings::GHOSTSCOPE_PROMPT;
787                self.command_cursor_column = prompt.chars().count() + self.cursor_position;
788            }
789            InteractionMode::ScriptEditor => {
790                if let Some(ref script_cache) = self.script_cache {
791                    self.saved_script_cursor =
792                        Some((script_cache.cursor_line, script_cache.cursor_col));
793                    // Calculate which line in the unified view corresponds to current script cursor
794                    let lines = self.get_command_mode_lines();
795                    let script_start_line = self.get_script_start_line();
796                    // Add 3 for header lines (target, separator, prompt)
797                    self.command_cursor_line = (script_start_line + 3 + script_cache.cursor_line)
798                        .min(lines.len().saturating_sub(1));
799                    self.command_cursor_column = script_cache.cursor_col;
800                }
801            }
802            InteractionMode::Command => {
803                // Already in command mode, no change needed
804                return;
805            }
806        }
807
808        self.mode = InteractionMode::Command;
809    }
810
811    /// Exit command mode and return to previous mode, restoring cursor state
812    pub fn exit_command_mode(&mut self) {
813        if let Some(previous_mode) = self.previous_mode {
814            match previous_mode {
815                InteractionMode::Input => {
816                    self.cursor_position = self.saved_input_cursor;
817                    self.mode = InteractionMode::Input;
818                }
819                InteractionMode::ScriptEditor => {
820                    if let Some((line, col)) = self.saved_script_cursor {
821                        if let Some(ref mut script_cache) = self.script_cache {
822                            script_cache.cursor_line = line;
823                            script_cache.cursor_col = col;
824                        }
825                    }
826                    self.mode = InteractionMode::ScriptEditor;
827                }
828                InteractionMode::Command => {
829                    // This shouldn't happen, but handle gracefully
830                    self.mode = InteractionMode::Input;
831                }
832            }
833            self.previous_mode = None;
834        } else {
835            // Fallback to input mode if no previous mode
836            self.mode = InteractionMode::Input;
837        }
838    }
839
840    /// Get total number of lines in unified view (for command mode navigation)
841    pub fn get_total_lines(&self) -> usize {
842        // Use wrapped lines to get accurate line count that considers text wrapping
843        let wrapped_lines = self.get_command_mode_wrapped_lines(self.cached_panel_width);
844        wrapped_lines.len()
845    }
846
847    /// Get script start line offset in unified view
848    fn get_script_start_line(&self) -> usize {
849        // Use static_lines count (same as renderer) to ensure consistency
850        let mut offset = self.static_lines.len();
851
852        offset += 3; // Header + separator + prompt
853        offset
854    }
855
856    /// Get all lines for command mode (unified view)
857    pub fn get_command_mode_lines(&self) -> Vec<String> {
858        let mut lines = Vec::new();
859
860        // Use static_lines directly (same as renderer) to ensure consistency
861        for static_line in &self.static_lines {
862            lines.push(static_line.content.clone());
863        }
864
865        // Add current content based on mode
866        // In command mode, show content based on previous_mode
867        let display_mode = if self.mode == InteractionMode::Command {
868            self.previous_mode.unwrap_or(InteractionMode::Input)
869        } else {
870            self.mode
871        };
872
873        match display_mode {
874            InteractionMode::Input => {
875                if matches!(self.input_state, InputState::Ready) {
876                    lines.push(format!("(ghostscope) {}", self.input_text));
877                }
878            }
879            InteractionMode::ScriptEditor => {
880                if let Some(ref script_cache) = self.script_cache {
881                    lines.push(format!(
882                        "🔨 Entering script mode for target: {}",
883                        script_cache.target
884                    ));
885                    lines.push("─".repeat(50));
886                    lines.push("Script Editor (Ctrl+s to submit, Esc to cancel):".to_string());
887
888                    for (idx, line) in script_cache.lines.iter().enumerate() {
889                        lines.push(format!("{:3} │ {}", idx + 1, line));
890                    }
891                }
892            }
893            InteractionMode::Command => {
894                // This shouldn't happen in normal flow, but handle gracefully
895                if matches!(self.input_state, InputState::Ready) {
896                    lines.push(format!("(ghostscope) {}", self.input_text));
897                }
898            }
899        }
900
901        lines
902    }
903
904    /// Get wrapped lines for command mode navigation (considering text wrapping)
905    pub fn get_command_mode_wrapped_lines(&self, available_width: u16) -> Vec<String> {
906        let logical_lines = self.get_command_mode_lines();
907        let mut wrapped_lines = Vec::new();
908
909        for logical_line in logical_lines {
910            let wrapped = self.wrap_text_unicode(&logical_line, available_width);
911            wrapped_lines.extend(wrapped);
912        }
913
914        // Add current input if not already included (for navigation)
915        if matches!(self.previous_mode, Some(InteractionMode::Input)) {
916            let current_input_line = format!("(ghostscope) {}", self.input_text);
917
918            // Check if it's already included
919            let should_add = if wrapped_lines.is_empty() {
920                true
921            } else {
922                !wrapped_lines.iter().any(|line| line == &current_input_line)
923            };
924
925            if should_add {
926                let wrapped = self.wrap_text_unicode(&current_input_line, available_width);
927                wrapped_lines.extend(wrapped);
928            }
929        }
930
931        wrapped_lines
932    }
933
934    /// Wrap text considering Unicode character widths
935    fn wrap_text_unicode(&self, text: &str, width: u16) -> Vec<String> {
936        use unicode_width::UnicodeWidthChar;
937
938        if width <= 2 {
939            return vec![text.to_string()];
940        }
941
942        let max_width = width as usize;
943        let mut lines = Vec::new();
944
945        for line in text.lines() {
946            // Calculate the actual display width using Unicode width
947            let line_width: usize = line
948                .chars()
949                .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
950                .sum();
951
952            if line_width <= max_width {
953                lines.push(line.to_string());
954            } else {
955                // Need to wrap this line
956                let mut current_line = String::new();
957                let mut current_width = 0;
958
959                for ch in line.chars() {
960                    let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
961
962                    if current_width + char_width > max_width && !current_line.is_empty() {
963                        // Start a new line
964                        lines.push(current_line);
965                        current_line = ch.to_string();
966                        current_width = char_width;
967                    } else {
968                        current_line.push(ch);
969                        current_width += char_width;
970                    }
971                }
972
973                if !current_line.is_empty() {
974                    lines.push(current_line);
975                }
976            }
977        }
978
979        if lines.is_empty() {
980            lines.push(String::new());
981        }
982
983        lines
984    }
985
986    /// Move command cursor up
987    pub fn move_command_cursor_up(&mut self) {
988        if self.command_cursor_line > 0 {
989            self.command_cursor_line -= 1;
990            // Adjust column to fit new line - use wrapped lines for accurate navigation
991            let lines = self.get_command_mode_wrapped_lines(self.cached_panel_width);
992            if self.command_cursor_line < lines.len() {
993                let line_len = lines[self.command_cursor_line].chars().count();
994                self.command_cursor_column = self.command_cursor_column.min(line_len);
995            }
996        }
997    }
998
999    /// Move command cursor down
1000    pub fn move_command_cursor_down(&mut self) {
1001        // Use wrapped lines for accurate navigation
1002        let lines = self.get_command_mode_wrapped_lines(self.cached_panel_width);
1003        if self.command_cursor_line + 1 < lines.len() {
1004            self.command_cursor_line += 1;
1005            // Adjust column to fit new line
1006            if self.command_cursor_line < lines.len() {
1007                let line_len = lines[self.command_cursor_line].chars().count();
1008                self.command_cursor_column = self.command_cursor_column.min(line_len);
1009            }
1010        }
1011    }
1012
1013    /// Move command cursor left (supports line wrapping and Unicode)
1014    pub fn move_command_cursor_left(&mut self) {
1015        if self.command_cursor_column > 0 {
1016            // Move left within current line
1017            self.command_cursor_column -= 1;
1018        } else if self.command_cursor_line > 0 {
1019            // At beginning of line, wrap to end of previous line
1020            self.command_cursor_line -= 1;
1021            let lines = self.get_command_mode_wrapped_lines(self.cached_panel_width);
1022            if self.command_cursor_line < lines.len() {
1023                // Use Unicode-aware character counting
1024                self.command_cursor_column = lines[self.command_cursor_line].chars().count();
1025            }
1026        }
1027    }
1028
1029    /// Move command cursor right (supports line wrapping and Unicode)
1030    pub fn move_command_cursor_right(&mut self) {
1031        let lines = self.get_command_mode_wrapped_lines(self.cached_panel_width);
1032        if self.command_cursor_line < lines.len() {
1033            // Use Unicode-aware character counting
1034            let line_len = lines[self.command_cursor_line].chars().count();
1035            if self.command_cursor_column < line_len {
1036                // Move right within current line
1037                self.command_cursor_column += 1;
1038            } else if self.command_cursor_line + 1 < lines.len() {
1039                // At end of line, wrap to beginning of next line
1040                self.command_cursor_line += 1;
1041                self.command_cursor_column = 0;
1042            }
1043        }
1044    }
1045
1046    /// Navigate to previous command in history (Ctrl+p)
1047    pub fn history_previous(&mut self) {
1048        if self.command_history.is_empty() {
1049            return;
1050        }
1051
1052        match self.history_index {
1053            None => {
1054                // Currently at input line, save current input and go to most recent command
1055                self.unsent_input_backup = Some(self.input_text.clone());
1056                self.history_index = Some(self.command_history.len() - 1);
1057                self.input_text = self.command_history[self.command_history.len() - 1]
1058                    .command
1059                    .clone();
1060                self.cursor_position = self.input_text.len();
1061            }
1062            Some(current_idx) => {
1063                if current_idx > 0 {
1064                    // Go to previous command
1065                    self.history_index = Some(current_idx - 1);
1066                    self.input_text = self.command_history[current_idx - 1].command.clone();
1067                    self.cursor_position = self.input_text.len();
1068                }
1069                // If already at first command (index 0), do nothing
1070            }
1071        }
1072    }
1073
1074    /// Navigate to next command in history (Ctrl+n)
1075    pub fn history_next(&mut self) {
1076        if self.command_history.is_empty() {
1077            return;
1078        }
1079
1080        match self.history_index {
1081            None => {
1082                // Already at current input line, do nothing
1083            }
1084            Some(current_idx) => {
1085                if current_idx + 1 < self.command_history.len() {
1086                    // Go to next command
1087                    self.history_index = Some(current_idx + 1);
1088                    self.input_text = self.command_history[current_idx + 1].command.clone();
1089                    self.cursor_position = self.input_text.len();
1090                } else {
1091                    // At last command, go back to current input
1092                    self.history_index = None;
1093                    if let Some(backup) = self.unsent_input_backup.take() {
1094                        self.input_text = backup;
1095                    } else {
1096                        self.input_text.clear();
1097                    }
1098                    self.cursor_position = self.input_text.len();
1099                }
1100            }
1101        }
1102    }
1103
1104    /// Move command cursor up by half a page (fast scroll)
1105    pub fn move_command_half_page_up(&mut self) {
1106        let jump_size = 10; // Half page size
1107        if self.command_cursor_line >= jump_size {
1108            self.command_cursor_line -= jump_size;
1109        } else {
1110            self.command_cursor_line = 0;
1111        }
1112
1113        // Adjust column to fit new line
1114        let lines = self.get_command_mode_lines();
1115        if self.command_cursor_line < lines.len() {
1116            let line_len = lines[self.command_cursor_line].chars().count();
1117            self.command_cursor_column = self.command_cursor_column.min(line_len);
1118        }
1119    }
1120
1121    /// Move command cursor down by half a page (fast scroll)
1122    pub fn move_command_half_page_down(&mut self) {
1123        let jump_size = 10; // Half page size
1124        let total_lines = self.get_total_lines();
1125
1126        if self.command_cursor_line + jump_size < total_lines {
1127            self.command_cursor_line += jump_size;
1128        } else {
1129            self.command_cursor_line = total_lines.saturating_sub(1);
1130        }
1131
1132        // Adjust column to fit new line
1133        let lines = self.get_command_mode_lines();
1134        if self.command_cursor_line < lines.len() {
1135            let line_len = lines[self.command_cursor_line].chars().count();
1136            self.command_cursor_column = self.command_cursor_column.min(line_len);
1137        }
1138    }
1139
1140    // === History Management Methods ===
1141
1142    /// Update auto suggestion based on current input
1143    pub fn update_auto_suggestion(&mut self) {
1144        tracing::debug!("update_auto_suggestion: input_text='{}'", self.input_text);
1145
1146        self.auto_suggestion
1147            .update(&self.input_text, &self.command_history_manager);
1148
1149        if let Some(suggestion) = self.auto_suggestion.get_full_suggestion() {
1150            tracing::debug!("update_auto_suggestion: found suggestion='{}'", suggestion);
1151        } else {
1152            tracing::debug!("update_auto_suggestion: no suggestion found");
1153        }
1154    }
1155
1156    /// Accept the current auto suggestion
1157    /// This treats the suggestion as a new command entry, clearing all related state
1158    pub fn accept_auto_suggestion(&mut self) {
1159        if let Some(suggestion) = self.auto_suggestion.get_full_suggestion() {
1160            tracing::debug!(
1161                "accept_auto_suggestion: before - input_text='{}', cursor_position={}",
1162                self.input_text,
1163                self.cursor_position
1164            );
1165            tracing::debug!(
1166                "accept_auto_suggestion: accepting suggestion='{}'",
1167                suggestion
1168            );
1169
1170            self.input_text = suggestion.to_string();
1171            self.cursor_position = self.input_text.len();
1172            self.auto_suggestion.clear();
1173
1174            // Clear any search state - this is a new command entry
1175            self.history_search.clear();
1176
1177            tracing::debug!(
1178                "accept_auto_suggestion: after - input_text='{}', cursor_position={}",
1179                self.input_text,
1180                self.cursor_position
1181            );
1182        } else {
1183            tracing::debug!("accept_auto_suggestion: no suggestion available");
1184        }
1185    }
1186
1187    /// Start history search mode
1188    pub fn start_history_search(&mut self) {
1189        tracing::debug!(
1190            "start_history_search: command_history.len()={}",
1191            self.command_history.len()
1192        );
1193        self.history_search.start_search();
1194        // Clear current input when starting search
1195        self.input_text.clear();
1196        self.cursor_position = 0;
1197        tracing::debug!(
1198            "start_history_search: after clear - command_history.len()={}",
1199            self.command_history.len()
1200        );
1201    }
1202
1203    /// Update history search query and find matches
1204    pub fn update_history_search(&mut self, query: String) {
1205        self.history_search
1206            .update_query(query, &self.command_history_manager);
1207        // Don't modify input_text here - keep it as the actual user input (search query)
1208        // The renderer will decide what to display based on search state
1209    }
1210
1211    /// Move to next history search match
1212    pub fn next_history_match(&mut self) {
1213        // Just update the match, don't modify input_text
1214        // The renderer will display the matched command
1215        self.history_search
1216            .next_match(&self.command_history_manager);
1217    }
1218
1219    /// Exit history search mode
1220    pub fn exit_history_search(&mut self) {
1221        self.history_search.clear();
1222        self.auto_suggestion.clear();
1223    }
1224
1225    /// Exit history search mode and set the selected command as new input
1226    pub fn exit_history_search_with_selection(&mut self, selected_command: &str) {
1227        self.input_text = selected_command.to_string();
1228        self.cursor_position = self.input_text.len();
1229        self.history_search.clear();
1230        self.auto_suggestion.clear();
1231    }
1232
1233    /// Add command to history when executed
1234    pub fn add_command_to_history(&mut self, command: &str) {
1235        self.command_history_manager.add_command(command);
1236    }
1237
1238    /// Add command entry to both history manager and command_history, then update static_lines
1239    /// This is the unified method for adding commands that ensures data consistency
1240    pub fn add_command_entry(&mut self, command: &str) {
1241        use std::time::Instant;
1242
1243        // Add to history manager (for Ctrl+R search)
1244        self.command_history_manager.add_command(command);
1245
1246        // Add to command_history (for display)
1247        let item = CommandHistoryItem {
1248            command: command.to_string(),
1249            response: None, // Will be filled when response arrives
1250            response_styled: None,
1251            timestamp: Instant::now(),
1252            prompt: "(ghostscope) ".to_string(),
1253            response_type: None,
1254        };
1255        self.command_history.push(item);
1256
1257        tracing::debug!(
1258            "add_command_entry: Added command '{}', history length: {}",
1259            command,
1260            self.command_history.len()
1261        );
1262
1263        // Limit history size
1264        const MAX_HISTORY: usize = 1000;
1265        if self.command_history.len() > MAX_HISTORY {
1266            self.command_history.remove(0);
1267        }
1268
1269        // Update static_lines for command mode navigation
1270        crate::components::command_panel::ResponseFormatter::update_static_lines(self);
1271    }
1272
1273    /// Check if currently in history search mode
1274    pub fn is_in_history_search(&self) -> bool {
1275        self.history_search.is_active
1276    }
1277
1278    /// Get current history search query
1279    pub fn get_history_search_query(&self) -> &str {
1280        &self.history_search.query
1281    }
1282
1283    // Removed old add_welcome_lines - now using add_styled_welcome_lines
1284
1285    /// Add styled welcome message lines directly (new simplified approach)
1286    pub fn add_styled_welcome_lines(
1287        &mut self,
1288        styled_lines: Vec<ratatui::text::Line<'static>>,
1289        response_type: ResponseType,
1290    ) {
1291        // Clear existing welcome messages to prevent duplicates
1292        self.static_lines
1293            .retain(|line| !matches!(line.line_type, LineType::Welcome));
1294
1295        for styled_line in styled_lines {
1296            // Extract plain text for content field (for compatibility)
1297            let content: String = styled_line
1298                .spans
1299                .iter()
1300                .map(|span| span.content.as_ref())
1301                .collect();
1302
1303            self.static_lines.push(StaticTextLine {
1304                content,
1305                line_type: LineType::Welcome,
1306                history_index: None,
1307                response_type: Some(response_type),
1308                styled_content: Some(styled_line),
1309            });
1310        }
1311        // Clear any cached styled buffer since we've added new content
1312        self.styled_buffer = None;
1313        self.styled_at_history_index = None;
1314    }
1315
1316    /// Get auto suggestion text for rendering
1317    pub fn get_suggestion_text(&self) -> Option<&str> {
1318        self.auto_suggestion.get_suggestion_text()
1319    }
1320
1321    /// Get the text that should be displayed in the input line
1322    /// In normal mode, this is just the input text (auto-suggestions are handled separately)
1323    /// In history search mode, this is the matched command
1324    pub fn get_display_text(&self) -> &str {
1325        if self.is_in_history_search() {
1326            // In history search mode, display the matched command if available
1327            if let Some(matched_command) = self
1328                .history_search
1329                .current_match(&self.command_history_manager)
1330            {
1331                return matched_command;
1332            }
1333        }
1334        // In normal mode, always display just the actual input text
1335        // Auto-suggestions are displayed separately in the renderer
1336        &self.input_text
1337    }
1338
1339    /// Get the position where cursor should be displayed
1340    /// This is always based on the actual input text, not auto-suggestions
1341    pub fn get_display_cursor_position(&self) -> usize {
1342        let result = if self.is_in_history_search() {
1343            // In history search mode, cursor should be at the end of the search query
1344            self.history_search.query.len()
1345        } else {
1346            // Normal mode, use actual cursor position in the input text
1347            self.cursor_position
1348        };
1349
1350        tracing::debug!("get_display_cursor_position: is_in_history_search={}, input_text='{}', cursor_position={}, result={}",
1351            self.is_in_history_search(), self.input_text, self.cursor_position, result);
1352        result
1353    }
1354}
1355
1356impl Default for CommandPanelState {
1357    fn default() -> Self {
1358        Self::new()
1359    }
1360}
1361
1362#[cfg(test)]
1363mod tests {
1364    use super::*;
1365    use crate::events::{
1366        ExecutionStatus, LoadStatus, ScriptCompilationDetails, ScriptExecutionResult,
1367        TraceDefinition,
1368    };
1369
1370    #[test]
1371    fn runtime_warning_message_is_added_to_ebpf_panel() {
1372        let mut state = EbpfPanelState::new_with_max_messages(10);
1373
1374        state.add_runtime_warning_message(
1375            "Warning: TUI trace queue saturated; dropped 7 events before display".to_string(),
1376            123_456,
1377        );
1378
1379        let cached = state.trace_events.back().expect("warning event");
1380        assert_eq!(cached.event.trace_id, 0);
1381        assert_eq!(cached.event.timestamp, 123_456);
1382        assert_eq!(cached.event.pid, 0);
1383        assert_eq!(cached.event.tid, 0);
1384        assert_eq!(
1385            cached.event.to_formatted_output(),
1386            vec!["Warning: TUI trace queue saturated; dropped 7 events before display".to_string()]
1387        );
1388    }
1389
1390    fn trace_definition(target: &str, enabled: bool) -> TraceDefinition {
1391        TraceDefinition {
1392            target: target.to_string(),
1393            script: "print value".to_string(),
1394            enabled,
1395            selected_index: None,
1396        }
1397    }
1398
1399    fn successful_compilation(trace_id: u32, target: &str) -> ScriptCompilationDetails {
1400        ScriptCompilationDetails {
1401            trace_ids: vec![trace_id],
1402            results: vec![ScriptExecutionResult {
1403                pc_address: 0x1234,
1404                target_name: target.to_string(),
1405                binary_path: "/tmp/app".to_string(),
1406                status: ExecutionStatus::Success,
1407                source_file: None,
1408                source_line: None,
1409                is_inline: None,
1410            }],
1411            total_count: 1,
1412            success_count: 1,
1413            failed_count: 0,
1414        }
1415    }
1416
1417    fn failed_compilation(target: &str, error: &str) -> ScriptCompilationDetails {
1418        ScriptCompilationDetails {
1419            trace_ids: vec![],
1420            results: vec![ScriptExecutionResult {
1421                pc_address: 0x1234,
1422                target_name: target.to_string(),
1423                binary_path: "/tmp/app".to_string(),
1424                status: ExecutionStatus::Failed(error.to_string()),
1425                source_file: None,
1426                source_line: None,
1427                is_inline: None,
1428            }],
1429            total_count: 1,
1430            success_count: 0,
1431            failed_count: 1,
1432        }
1433    }
1434
1435    #[test]
1436    fn batch_loading_records_disabled_trace_requests() {
1437        let traces = vec![
1438            trace_definition("disabled_target", false),
1439            trace_definition("enabled_target", true),
1440        ];
1441        let mut batch = BatchLoadingState::new("saved.gs".to_string(), &traces);
1442
1443        batch.record_script_compilation(&successful_compilation(42, "disabled_target"));
1444        batch.record_script_compilation(&successful_compilation(43, "enabled_target"));
1445
1446        assert_eq!(batch.completed_count, 2);
1447        assert_eq!(batch.success_count, 2);
1448        assert_eq!(batch.failed_count, 0);
1449        assert_eq!(batch.disabled_count, 1);
1450        assert_eq!(batch.details.len(), 2);
1451        assert!(matches!(
1452            batch.details[0].status,
1453            LoadStatus::CreatedDisabled
1454        ));
1455        assert_eq!(batch.details[0].trace_id, Some(42));
1456        assert!(matches!(batch.details[1].status, LoadStatus::Created));
1457        assert_eq!(batch.details[1].trace_id, Some(43));
1458    }
1459
1460    #[test]
1461    fn batch_loading_does_not_report_failed_disabled_restore_as_disabled() {
1462        let traces = vec![trace_definition("disabled_target", false)];
1463        let mut batch = BatchLoadingState::new("saved.gs".to_string(), &traces);
1464
1465        batch.record_script_compilation(&failed_compilation(
1466            "disabled_target",
1467            "Failed to restore disabled trace #42; trace remains active",
1468        ));
1469
1470        assert_eq!(batch.completed_count, 1);
1471        assert_eq!(batch.success_count, 0);
1472        assert_eq!(batch.failed_count, 1);
1473        assert_eq!(batch.disabled_count, 0);
1474        assert_eq!(batch.details.len(), 1);
1475        assert!(matches!(batch.details[0].status, LoadStatus::Failed));
1476        assert_eq!(batch.details[0].trace_id, None);
1477    }
1478}