1use std::collections::HashMap;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use par_term_emu_core_rust::shell_integration::ShellIntegrationMarker;
5use par_term_emu_core_rust::terminal::CommandExecution;
6
7pub use par_term_config::ScrollbackMark;
9
10const MAX_PROMPT_HEIGHT: usize = 6;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct CommandSnapshot {
21 pub id: usize,
22 pub command: Option<String>,
23 pub start_time: u64,
24 pub end_time: Option<u64>,
25 pub exit_code: Option<i32>,
26 pub duration_ms: Option<u64>,
27}
28
29impl CommandSnapshot {
30 pub fn from_core(command: &CommandExecution, id: usize) -> Self {
31 Self {
32 id,
33 command: Some(command.command.clone()),
34 start_time: command.start_time,
35 end_time: command.end_time,
36 exit_code: command.exit_code,
37 duration_ms: command.duration_ms,
38 }
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct LineMetadata {
45 pub line: usize,
46 pub exit_code: Option<i32>,
47 pub start_time: Option<u64>,
48 pub duration_ms: Option<u64>,
49 pub command: Option<String>,
50}
51
52#[derive(Default)]
53pub struct ScrollbackMetadata {
54 line_to_command: HashMap<usize, usize>,
56 commands: HashMap<usize, CommandSnapshot>,
58 prompt_lines: Vec<usize>,
60 line_timestamps: HashMap<usize, u64>,
62 current_command_start: Option<usize>,
64 last_marker: Option<ShellIntegrationMarker>,
66 last_marker_line: Option<usize>,
68 last_exit_code: Option<i32>,
70 last_exit_code_line: Option<usize>,
72 last_recorded_history_len: usize,
74 current_command_start_time_ms: Option<u64>,
76 prompt_start_pending: bool,
80}
81
82impl ScrollbackMetadata {
83 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn clear(&mut self) {
91 self.line_to_command.clear();
92 self.commands.clear();
93 self.prompt_lines.clear();
94 self.line_timestamps.clear();
95 self.current_command_start = None;
96 self.last_marker = None;
97 self.last_marker_line = None;
98 self.last_exit_code = None;
99 self.last_exit_code_line = None;
100 self.last_recorded_history_len = 0;
101 self.current_command_start_time_ms = None;
102 self.prompt_start_pending = false;
103 }
104
105 pub fn apply_event(
113 &mut self,
114 marker: Option<ShellIntegrationMarker>,
115 absolute_line: usize,
116 history_len: usize,
117 last_command: Option<CommandSnapshot>,
118 last_exit_code: Option<i32>,
119 ) {
120 let last_command_clone = last_command.clone();
121 let repeat_marker =
122 marker == self.last_marker && Some(absolute_line) == self.last_marker_line;
123 let mut finished_command = false;
124
125 match marker {
126 Some(ShellIntegrationMarker::PromptStart) => {
127 if !repeat_marker {
128 self.record_prompt_line(
129 absolute_line,
130 last_command.as_ref().map(|c| c.start_time),
131 );
132 self.prompt_start_pending = true;
133 }
134 }
135 Some(ShellIntegrationMarker::CommandStart)
136 | Some(ShellIntegrationMarker::CommandExecuted) => {
137 if !repeat_marker {
138 if !self.prompt_start_pending {
144 self.record_prompt_line(absolute_line, Some(now_ms()));
145 }
146 self.prompt_start_pending = false;
147 }
148 self.current_command_start = Some(absolute_line);
149 self.current_command_start_time_ms = Some(now_ms());
150 }
151 Some(ShellIntegrationMarker::CommandFinished) => {
152 #[allow(clippy::collapsible_if)]
153 if history_len > self.last_recorded_history_len {
154 if let Some(cmd) = last_command {
155 let start_line = self.finish_command(absolute_line, cmd);
156 self.last_recorded_history_len = history_len;
157 self.last_exit_code_line = Some(start_line);
158 finished_command = true;
159 }
160 } else if let Some(exit_code) = last_exit_code {
161 let start_time = self.current_command_start_time_ms.unwrap_or_else(now_ms);
166 let end_time = now_ms();
167 let duration_ms = end_time.saturating_sub(start_time);
168 let id = self.last_recorded_history_len;
169 let synthetic = CommandSnapshot {
170 id,
171 command: None,
172 start_time,
173 end_time: Some(end_time),
174 exit_code: Some(exit_code),
175 duration_ms: Some(duration_ms),
176 };
177 let start_line = self.finish_command(absolute_line, synthetic);
178 self.last_recorded_history_len =
180 self.last_recorded_history_len.saturating_add(1);
181 self.last_exit_code_line = Some(start_line);
182 finished_command = true;
183 }
184 }
185 _ => {}
186 }
187
188 if history_len > self.last_recorded_history_len
192 && let Some(ref cmd) = last_command_clone
193 {
194 let start_line = self.finish_command(absolute_line, cmd.clone());
195 self.last_recorded_history_len = history_len;
196 self.last_exit_code_line = Some(start_line);
197 finished_command = true;
198 }
199
200 if !finished_command && let Some(code) = last_exit_code {
204 let candidate_line = self
205 .current_command_start
206 .or_else(|| self.prompt_lines.last().copied())
207 .unwrap_or(absolute_line);
208
209 let exit_event_is_new = Some(candidate_line) != self.last_exit_code_line
210 || Some(code) != self.last_exit_code;
211
212 if exit_event_is_new {
213 let start_time = self.current_command_start_time_ms.unwrap_or_else(now_ms);
214 let end_time = now_ms();
215 let duration_ms = end_time.saturating_sub(start_time);
216 let id = self.last_recorded_history_len;
217 let synthetic = CommandSnapshot {
218 id,
219 command: last_command_clone.as_ref().and_then(|c| c.command.clone()),
220 start_time,
221 end_time: Some(end_time),
222 exit_code: Some(code),
223 duration_ms: Some(duration_ms),
224 };
225 let start_line = self.finish_command(candidate_line, synthetic);
226 self.last_recorded_history_len = self.last_recorded_history_len.saturating_add(1);
227 self.last_exit_code_line = Some(start_line);
228 }
229 }
230
231 self.last_marker = marker;
232 self.last_marker_line = Some(absolute_line);
233 self.last_exit_code = last_exit_code;
234 }
235
236 pub fn marks(&self) -> Vec<ScrollbackMark> {
238 let mut marks = Vec::with_capacity(self.prompt_lines.len());
239
240 for line in &self.prompt_lines {
241 let command_id = self.line_to_command.get(line);
242 let (exit_code, start_time, duration_ms, command) = command_id
243 .and_then(|id| self.commands.get(id))
244 .map(|cmd| {
245 (
246 cmd.exit_code,
247 Some(cmd.start_time),
248 cmd.duration_ms,
249 cmd.command.clone(),
250 )
251 })
252 .unwrap_or((None, None, None, None));
253
254 marks.push(ScrollbackMark {
255 line: *line,
256 exit_code,
257 start_time,
258 duration_ms,
259 command,
260 color: None,
261 trigger_id: None,
262 });
263 }
264
265 marks
266 }
267
268 pub fn metadata_for_line(&self, line: usize) -> Option<LineMetadata> {
270 let command_id = self.line_to_command.get(&line);
271 let base = command_id
272 .and_then(|id| self.commands.get(id))
273 .map(|cmd| LineMetadata {
274 line,
275 exit_code: cmd.exit_code,
276 start_time: Some(cmd.start_time),
277 duration_ms: cmd.duration_ms,
278 command: cmd.command.clone(),
279 });
280
281 if base.is_some() {
282 return base;
283 }
284
285 self.line_timestamps.get(&line).map(|ts| LineMetadata {
286 line,
287 exit_code: None,
288 start_time: Some(*ts),
289 duration_ms: None,
290 command: None,
291 })
292 }
293
294 pub fn previous_mark(&self, line: usize) -> Option<usize> {
296 match self.prompt_lines.binary_search(&line) {
297 Ok(idx) => {
298 if idx > 0 {
299 Some(self.prompt_lines[idx - 1])
300 } else {
301 None
302 }
303 }
304 Err(idx) => idx
305 .checked_sub(1)
306 .and_then(|i| self.prompt_lines.get(i).copied()),
307 }
308 }
309
310 pub fn next_mark(&self, line: usize) -> Option<usize> {
312 match self.prompt_lines.binary_search(&line) {
313 Ok(idx) => self.prompt_lines.get(idx + 1).copied(),
314 Err(idx) => self.prompt_lines.get(idx).copied(),
315 }
316 }
317
318 pub fn set_mark_command_at(&mut self, target_line: usize, command: String) {
320 let line = match self.prompt_lines.binary_search(&target_line) {
321 Ok(_) => Some(target_line),
322 Err(idx) => idx
323 .checked_sub(1)
324 .and_then(|i| self.prompt_lines.get(i).copied()),
325 };
326 if let Some(line) = line
327 && let Some(id) = self.line_to_command.get(&line)
328 && let Some(snapshot) = self.commands.get_mut(id)
329 && snapshot.command.is_none()
330 {
331 snapshot.command = Some(command);
332 }
333 }
334
335 fn record_prompt_line(&mut self, line: usize, timestamp: Option<u64>) {
336 if let Err(pos) = self.prompt_lines.binary_search(&line) {
337 self.prompt_lines.insert(pos, line);
338 }
339 if let Some(ts) = timestamp {
340 self.line_timestamps.entry(line).or_insert(ts);
341 }
342 }
343
344 fn finish_command(&mut self, end_line: usize, command: CommandSnapshot) -> usize {
345 let start_line = self
346 .current_command_start
347 .take()
348 .or_else(|| self.prompt_lines.last().copied())
349 .unwrap_or(end_line);
350
351 let start_line = match self.prompt_lines.binary_search(&start_line) {
355 Ok(_) => start_line, Err(pos) if pos > 0 => {
357 let prev = self.prompt_lines[pos - 1];
358 if start_line - prev <= MAX_PROMPT_HEIGHT {
359 prev
360 } else {
361 start_line
362 }
363 }
364 _ => start_line,
365 };
366
367 self.current_command_start_time_ms = None;
368
369 self.record_prompt_line(start_line, Some(command.start_time));
371
372 self.line_to_command.insert(start_line, command.id);
373 let start_time = command.start_time;
374 self.commands.insert(command.id, command);
375 self.line_timestamps.entry(start_line).or_insert(start_time);
376 start_line
377 }
378}
379
380fn now_ms() -> u64 {
381 SystemTime::now()
382 .duration_since(UNIX_EPOCH)
383 .unwrap_or_default()
384 .as_millis() as u64
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn snapshot(id: usize, exit_code: i32, start_time: u64, duration_ms: u64) -> CommandSnapshot {
392 CommandSnapshot {
393 id,
394 command: Some(format!("cmd-{id}")),
395 start_time,
396 end_time: Some(start_time + duration_ms),
397 exit_code: Some(exit_code),
398 duration_ms: Some(duration_ms),
399 }
400 }
401
402 #[test]
403 fn records_prompt_and_command() {
404 let mut meta = ScrollbackMetadata::new();
405
406 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 15, 0, None, None);
408 meta.apply_event(
409 Some(ShellIntegrationMarker::CommandExecuted),
410 15,
411 0,
412 None,
413 None,
414 );
415 meta.apply_event(
416 Some(ShellIntegrationMarker::CommandFinished),
417 15,
418 1,
419 Some(snapshot(0, 0, 1_000, 500)),
420 None,
421 );
422
423 let marks = meta.marks();
424 assert_eq!(marks.len(), 1);
425 let mark = &marks[0];
426 assert_eq!(mark.line, 15);
427 assert_eq!(mark.exit_code, Some(0));
428 assert_eq!(mark.start_time, Some(1_000));
429 }
430
431 #[test]
432 fn navigation_prev_next() {
433 let mut meta = ScrollbackMetadata::new();
434
435 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 5, 0, None, None);
436 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 10, 0, None, None);
437
438 assert_eq!(meta.previous_mark(7), Some(5));
439 assert_eq!(meta.next_mark(5), Some(10));
440 }
441
442 #[test]
443 fn records_when_history_advances_without_marker() {
444 let mut meta = ScrollbackMetadata::new();
445 let cmd = snapshot(0, 1, 2_000, 300);
446
447 meta.apply_event(None, 15, 1, Some(cmd), Some(1));
449
450 let marks = meta.marks();
451 assert_eq!(marks.len(), 1);
452 assert_eq!(marks[0].line, 15);
453 assert_eq!(marks[0].exit_code, Some(1));
454 }
455
456 #[test]
457 fn records_when_exit_code_arrives_without_history() {
458 let mut meta = ScrollbackMetadata::new();
459
460 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 20, 0, None, None);
462 meta.apply_event(
463 Some(ShellIntegrationMarker::CommandStart),
464 20,
465 0,
466 None,
467 None,
468 );
469
470 meta.apply_event(
472 Some(ShellIntegrationMarker::CommandFinished),
473 23,
474 0,
475 None,
476 Some(42),
477 );
478
479 let marks = meta.marks();
480 assert_eq!(marks.len(), 1);
481 assert_eq!(marks[0].line, 20);
482 assert_eq!(marks[0].exit_code, Some(42));
483 assert!(marks[0].start_time.is_some());
484 assert!(marks[0].duration_ms.is_some());
485 }
486
487 #[test]
488 fn synthesizes_exit_code_without_finished_marker() {
489 let mut meta = ScrollbackMetadata::new();
490
491 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
492
493 meta.apply_event(None, 1, 0, None, Some(7));
494
495 let marks = meta.marks();
496 assert_eq!(marks.len(), 1);
497 assert_eq!(marks[0].line, 0);
498 assert_eq!(marks[0].exit_code, Some(7));
499 }
500
501 #[test]
502 fn records_multiple_commands_when_history_missing() {
503 let mut meta = ScrollbackMetadata::new();
504
505 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
507 meta.apply_event(None, 0, 0, None, Some(0));
508
509 meta.apply_event(
511 Some(ShellIntegrationMarker::CommandStart),
512 10,
513 0,
514 None,
515 None,
516 );
517 meta.apply_event(None, 10, 0, None, Some(0));
518
519 let marks = meta.marks();
520 assert_eq!(marks.len(), 2);
521 assert_eq!(marks[0].exit_code, Some(0));
522 assert_eq!(marks[1].exit_code, Some(0));
523 assert_eq!(marks[1].line, 10);
524 }
525
526 #[test]
527 fn multiline_prompt_mark_at_prompt_start() {
528 let mut meta = ScrollbackMetadata::new();
529
530 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 10, 0, None, None);
532 meta.apply_event(
534 Some(ShellIntegrationMarker::CommandStart),
535 11,
536 0,
537 None,
538 None,
539 );
540 meta.apply_event(
542 Some(ShellIntegrationMarker::CommandFinished),
543 14,
544 1,
545 Some(snapshot(0, 0, 1_000, 500)),
546 None,
547 );
548
549 let marks = meta.marks();
550 assert_eq!(marks.len(), 1);
552 assert_eq!(marks[0].line, 10);
553 assert_eq!(marks[0].exit_code, Some(0));
554 }
555
556 #[test]
557 fn clear_resets_all_state() {
558 let mut meta = ScrollbackMetadata::new();
559
560 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 15, 0, None, None);
561 meta.apply_event(
562 Some(ShellIntegrationMarker::CommandFinished),
563 15,
564 1,
565 Some(snapshot(0, 0, 1_000, 500)),
566 None,
567 );
568
569 assert_eq!(meta.marks().len(), 1);
570
571 meta.clear();
572
573 assert!(meta.marks().is_empty());
574 assert_eq!(meta.previous_mark(100), None);
575 assert_eq!(meta.next_mark(0), None);
576 }
577
578 #[test]
580 fn single_line_prompt() {
581 let mut meta = ScrollbackMetadata::new();
582
583 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
585 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
586
587 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 2, 0, None, None);
589 meta.apply_event(
590 Some(ShellIntegrationMarker::CommandFinished),
591 2,
592 1,
593 Some(snapshot(0, 0, 1_000, 100)),
594 None,
595 );
596 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
597
598 let marks = meta.marks();
599 assert_eq!(marks.len(), 2);
600 assert_eq!(marks[0].line, 0);
601 assert_eq!(marks[0].exit_code, Some(0));
602 assert_eq!(marks[1].line, 2);
603 }
604
605 #[test]
607 fn three_line_prompt() {
608 let mut meta = ScrollbackMetadata::new();
609
610 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
612 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
613
614 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 6, 0, None, None);
616 meta.apply_event(
617 Some(ShellIntegrationMarker::CommandFinished),
618 6,
619 1,
620 Some(snapshot(0, 42, 1_000, 200)),
621 None,
622 );
623 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 8, 0, None, None);
624
625 let marks = meta.marks();
626 assert_eq!(marks.len(), 2);
627 assert_eq!(marks[0].line, 0);
629 assert_eq!(marks[0].exit_code, Some(42));
630 assert_eq!(marks[1].line, 6);
631 }
632
633 #[test]
635 fn tall_prompt_mark_at_top() {
636 let mut meta = ScrollbackMetadata::new();
637
638 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
640 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 5, 0, None, None);
641
642 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 9, 0, None, None);
644 meta.apply_event(
645 Some(ShellIntegrationMarker::CommandFinished),
646 9,
647 1,
648 Some(snapshot(0, 1, 2_000, 300)),
649 None,
650 );
651 meta.apply_event(
652 Some(ShellIntegrationMarker::CommandStart),
653 14,
654 0,
655 None,
656 None,
657 );
658
659 let marks = meta.marks();
660 assert_eq!(marks.len(), 2);
661 assert_eq!(marks[0].line, 0);
663 assert_eq!(marks[0].exit_code, Some(1));
664 assert_eq!(marks[1].line, 9);
665 }
666
667 #[test]
669 fn consecutive_single_line_prompts() {
670 let mut meta = ScrollbackMetadata::new();
671
672 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 0, 0, None, None);
674 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 0, 0, None, None);
675
676 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 2, 0, None, None);
678 meta.apply_event(
679 Some(ShellIntegrationMarker::CommandFinished),
680 2,
681 1,
682 Some(snapshot(0, 0, 1_000, 100)),
683 None,
684 );
685 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 2, 0, None, None);
686
687 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 4, 0, None, None);
689 meta.apply_event(
690 Some(ShellIntegrationMarker::CommandFinished),
691 4,
692 2,
693 Some(snapshot(1, 0, 2_000, 100)),
694 None,
695 );
696 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 4, 0, None, None);
697
698 meta.apply_event(Some(ShellIntegrationMarker::PromptStart), 6, 0, None, None);
700 meta.apply_event(
701 Some(ShellIntegrationMarker::CommandFinished),
702 6,
703 3,
704 Some(snapshot(2, 127, 3_000, 100)),
705 None,
706 );
707 meta.apply_event(Some(ShellIntegrationMarker::CommandStart), 6, 0, None, None);
708
709 let marks = meta.marks();
710 assert_eq!(marks.len(), 4, "each prompt should have its own mark");
711 assert_eq!(marks[0].line, 0);
712 assert_eq!(marks[1].line, 2);
713 assert_eq!(marks[2].line, 4);
714 assert_eq!(marks[3].line, 6);
715 assert_eq!(marks[0].exit_code, Some(0));
716 assert_eq!(marks[1].exit_code, Some(0));
717 assert_eq!(marks[2].exit_code, Some(127));
718 }
719}