mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(tui/undo): add timeline overlay previews
This commit is contained in:
@@ -1651,18 +1651,13 @@ impl App<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
AppEvent::ShowUndoOptions { index } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.show_undo_restore_options(index);
|
||||
}
|
||||
}
|
||||
AppEvent::PerformUndoRestore {
|
||||
index,
|
||||
commit,
|
||||
restore_files,
|
||||
restore_conversation,
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.perform_undo_restore(index, restore_files, restore_conversation);
|
||||
widget.perform_undo_restore(commit.as_deref(), restore_files, restore_conversation);
|
||||
}
|
||||
}
|
||||
AppEvent::DispatchCommand(command, command_text) => {
|
||||
|
||||
@@ -174,11 +174,9 @@ pub(crate) enum AppEvent {
|
||||
/// layer so it can be handled centrally. Includes the full command text.
|
||||
DispatchCommand(SlashCommand, String),
|
||||
|
||||
/// Open undo options for a previously captured snapshot.
|
||||
ShowUndoOptions { index: usize },
|
||||
/// Restore workspace state according to the chosen undo scope.
|
||||
PerformUndoRestore {
|
||||
index: usize,
|
||||
commit: Option<String>,
|
||||
restore_files: bool,
|
||||
restore_conversation: bool,
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ mod theme_selection_view;
|
||||
mod verbosity_selection_view;
|
||||
pub(crate) mod validation_settings_view;
|
||||
mod update_settings_view;
|
||||
mod undo_restore_view;
|
||||
mod undo_timeline_view;
|
||||
mod notifications_settings_view;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -84,7 +84,7 @@ use codex_core::config_types::ThemeName;
|
||||
use model_selection_view::ModelSelectionView;
|
||||
use theme_selection_view::ThemeSelectionView;
|
||||
use verbosity_selection_view::VerbositySelectionView;
|
||||
pub(crate) use undo_restore_view::UndoRestoreView;
|
||||
pub(crate) use undo_timeline_view::{UndoTimelineEntry, UndoTimelineEntryKind, UndoTimelineView};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ActiveViewKind {
|
||||
@@ -667,7 +667,7 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn show_undo_restore_view(&mut self, view: UndoRestoreView) {
|
||||
pub fn show_undo_timeline_view(&mut self, view: UndoTimelineView) {
|
||||
self.active_view = Some(Box::new(view));
|
||||
self.active_view_kind = ActiveViewKind::Other;
|
||||
self.status_view_active = false;
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::BottomPane;
|
||||
|
||||
pub(crate) struct UndoRestoreView {
|
||||
snapshot_index: usize,
|
||||
short_id: String,
|
||||
title_line: String,
|
||||
summary: Option<String>,
|
||||
timestamp_line: String,
|
||||
user_delta: usize,
|
||||
assistant_delta: usize,
|
||||
restore_files: bool,
|
||||
restore_conversation: bool,
|
||||
conversation_available: bool,
|
||||
selection: usize,
|
||||
is_complete: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
}
|
||||
|
||||
impl UndoRestoreView {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
snapshot_index: usize,
|
||||
short_id: String,
|
||||
title_line: String,
|
||||
summary: Option<String>,
|
||||
timestamp_line: String,
|
||||
user_delta: usize,
|
||||
assistant_delta: usize,
|
||||
restore_conversation_default: bool,
|
||||
conversation_available: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
Self {
|
||||
snapshot_index,
|
||||
short_id,
|
||||
title_line,
|
||||
summary,
|
||||
timestamp_line,
|
||||
user_delta,
|
||||
assistant_delta,
|
||||
restore_files: true,
|
||||
restore_conversation: restore_conversation_default && conversation_available,
|
||||
conversation_available,
|
||||
selection: 0,
|
||||
is_complete: false,
|
||||
app_event_tx,
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_files(&mut self) {
|
||||
self.restore_files = !self.restore_files;
|
||||
}
|
||||
|
||||
fn toggle_conversation(&mut self) {
|
||||
if self.conversation_available {
|
||||
self.restore_conversation = !self.restore_conversation;
|
||||
}
|
||||
}
|
||||
|
||||
fn send_restore_request(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::PerformUndoRestore {
|
||||
index: self.snapshot_index,
|
||||
restore_files: self.restore_files,
|
||||
restore_conversation: self.restore_conversation,
|
||||
});
|
||||
self.is_complete = true;
|
||||
}
|
||||
|
||||
fn checkbox(label: &str, enabled: bool, disabled: bool) -> Line<'static> {
|
||||
let box_text = if enabled { "[x]" } else { "[ ]" };
|
||||
let mut spans = Vec::new();
|
||||
let mut style = Style::default().fg(crate::colors::text());
|
||||
if disabled {
|
||||
style = Style::default().fg(crate::colors::text_dim());
|
||||
}
|
||||
spans.push(Span::styled(box_text, style.add_modifier(Modifier::BOLD)));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(label.to_string(), style));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn footer_line(&self) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
|
||||
Span::raw(" Navigate "),
|
||||
Span::styled("Space", Style::default().fg(crate::colors::success())),
|
||||
Span::raw(" Toggle "),
|
||||
Span::styled("Enter", Style::default().fg(crate::colors::success())),
|
||||
Span::raw(" Confirm "),
|
||||
Span::styled("Esc", Style::default().fg(crate::colors::error())),
|
||||
Span::raw(" Cancel"),
|
||||
])
|
||||
}
|
||||
|
||||
fn highlight_if_selected(line: Line<'static>, selected: bool) -> Line<'static> {
|
||||
if !selected {
|
||||
return line;
|
||||
}
|
||||
let mut line = line;
|
||||
let patch = Style::default().bg(crate::colors::selection());
|
||||
for span in &mut line.spans {
|
||||
span.style = span.style.patch(patch);
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn conversation_detail_line(&self) -> Option<Line<'static>> {
|
||||
if !self.conversation_available {
|
||||
return None;
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
if self.user_delta > 0 {
|
||||
parts.push(if self.user_delta == 1 {
|
||||
"1 user turn".to_string()
|
||||
} else {
|
||||
format!("{} user turns", self.user_delta)
|
||||
});
|
||||
}
|
||||
if self.assistant_delta > 0 {
|
||||
parts.push(if self.assistant_delta == 1 {
|
||||
"1 assistant reply".to_string()
|
||||
} else {
|
||||
format!("{} assistant replies", self.assistant_delta)
|
||||
});
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Line::from(vec![Span::styled(
|
||||
format!(" → {}", parts.join(", ")),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
)]))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for UndoRestoreView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
match key_event {
|
||||
KeyEvent { code: KeyCode::Up, .. } => {
|
||||
if self.selection > 0 {
|
||||
self.selection -= 1;
|
||||
}
|
||||
}
|
||||
KeyEvent { code: KeyCode::Down, .. } => {
|
||||
if self.selection < 3 {
|
||||
self.selection += 1;
|
||||
}
|
||||
}
|
||||
KeyEvent { code: KeyCode::Esc, .. } => {
|
||||
self.is_complete = true;
|
||||
}
|
||||
KeyEvent { code: KeyCode::Char(' '), .. } => match self.selection {
|
||||
0 => self.toggle_files(),
|
||||
1 => self.toggle_conversation(),
|
||||
_ => {}
|
||||
},
|
||||
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => match self.selection {
|
||||
0 => self.toggle_files(),
|
||||
1 => self.toggle_conversation(),
|
||||
2 => self.send_restore_request(),
|
||||
3 => self.is_complete = true,
|
||||
_ => {}
|
||||
},
|
||||
KeyEvent { code: KeyCode::Left | KeyCode::Right, .. } => match self.selection {
|
||||
0 => self.toggle_files(),
|
||||
1 => self.toggle_conversation(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.is_complete
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
15
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
|
||||
.title(" Restore snapshot ")
|
||||
.title_alignment(Alignment::Center);
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
self.title_line.clone(),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("Commit {}", self.short_id),
|
||||
Style::default().fg(crate::colors::text()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
self.timestamp_line.clone(),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
),
|
||||
]));
|
||||
if let Some(summary) = &self.summary {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
summary.clone(),
|
||||
Style::default().fg(crate::colors::text()),
|
||||
)]));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let files_line = Self::highlight_if_selected(
|
||||
Self::checkbox("Restore workspace files", self.restore_files, false),
|
||||
self.selection == 0,
|
||||
);
|
||||
lines.push(files_line);
|
||||
|
||||
let conversation_disabled = !self.conversation_available;
|
||||
let conversation_line = Self::checkbox(
|
||||
if conversation_disabled {
|
||||
"Restore conversation (no newer turns)"
|
||||
} else {
|
||||
"Restore conversation"
|
||||
},
|
||||
self.restore_conversation,
|
||||
conversation_disabled,
|
||||
);
|
||||
let conversation_line = Self::highlight_if_selected(conversation_line, self.selection == 1);
|
||||
lines.push(conversation_line);
|
||||
if let Some(detail) = self.conversation_detail_line() {
|
||||
lines.push(detail);
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
|
||||
let confirm_style = if self.selection == 2 {
|
||||
Style::default()
|
||||
.bg(crate::colors::selection())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Confirm restore",
|
||||
confirm_style,
|
||||
)]));
|
||||
|
||||
let cancel_style = if self.selection == 3 {
|
||||
Style::default()
|
||||
.bg(crate::colors::selection())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(vec![Span::styled(
|
||||
"Cancel",
|
||||
cancel_style,
|
||||
)]));
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(self.footer_line());
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
|
||||
paragraph.render(
|
||||
Rect {
|
||||
x: inner.x.saturating_add(1),
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
},
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
use std::cmp::max;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
use super::bottom_pane_view::{BottomPaneView, ConditionalUpdate};
|
||||
use super::{BottomPane, CancellationEvent};
|
||||
|
||||
const MAX_VISIBLE_LIST_ROWS: usize = 12;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum UndoTimelineEntryKind {
|
||||
Snapshot { commit: String },
|
||||
Current,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct UndoTimelineEntry {
|
||||
pub label: String,
|
||||
pub summary: Option<String>,
|
||||
pub timestamp_line: Option<String>,
|
||||
pub relative_time: Option<String>,
|
||||
pub stats_line: Option<String>,
|
||||
pub commit_line: Option<String>,
|
||||
pub conversation_lines: Vec<Line<'static>>,
|
||||
pub file_lines: Vec<Line<'static>>,
|
||||
pub conversation_available: bool,
|
||||
pub files_available: bool,
|
||||
pub kind: UndoTimelineEntryKind,
|
||||
}
|
||||
|
||||
impl UndoTimelineEntry {
|
||||
fn list_line_count(&self) -> usize {
|
||||
let mut rows = 1;
|
||||
if self.summary.is_some() {
|
||||
rows += 1;
|
||||
}
|
||||
if self.timestamp_line.is_some() || self.relative_time.is_some() {
|
||||
rows += 1;
|
||||
}
|
||||
if self.stats_line.is_some() {
|
||||
rows += 1;
|
||||
}
|
||||
if self.commit_line.is_some() {
|
||||
rows += 1;
|
||||
}
|
||||
rows + 1
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct UndoTimelineView {
|
||||
entries: Vec<UndoTimelineEntry>,
|
||||
selected: usize,
|
||||
top_row: usize,
|
||||
restore_files: bool,
|
||||
restore_conversation: bool,
|
||||
restore_files_forced_off: bool,
|
||||
restore_conversation_forced_off: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
is_complete: bool,
|
||||
}
|
||||
|
||||
impl UndoTimelineView {
|
||||
pub fn new(entries: Vec<UndoTimelineEntry>, initial_selected: usize, app_event_tx: AppEventSender) -> Self {
|
||||
let selected = initial_selected.min(entries.len().saturating_sub(1));
|
||||
let mut view = Self {
|
||||
entries,
|
||||
selected,
|
||||
top_row: 0,
|
||||
restore_files: true,
|
||||
restore_conversation: true,
|
||||
restore_files_forced_off: false,
|
||||
restore_conversation_forced_off: false,
|
||||
app_event_tx,
|
||||
is_complete: false,
|
||||
};
|
||||
view.align_toggles_to_selection();
|
||||
view.ensure_visible();
|
||||
view
|
||||
}
|
||||
|
||||
fn selected_entry(&self) -> Option<&UndoTimelineEntry> {
|
||||
self.entries.get(self.selected)
|
||||
}
|
||||
|
||||
fn move_up(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.selected == 0 {
|
||||
self.selected = self.entries.len().saturating_sub(1);
|
||||
} else {
|
||||
self.selected -= 1;
|
||||
}
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn move_down(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = (self.selected + 1) % self.entries.len();
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn page_up(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut remaining = MAX_VISIBLE_LIST_ROWS;
|
||||
while remaining > 0 && self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
remaining = remaining.saturating_sub(self.entries[self.selected].list_line_count());
|
||||
}
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn page_down(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut remaining = MAX_VISIBLE_LIST_ROWS;
|
||||
while remaining > 0 && self.selected + 1 < self.entries.len() {
|
||||
self.selected += 1;
|
||||
remaining = remaining.saturating_sub(self.entries[self.selected].list_line_count());
|
||||
}
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn go_home(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = 0;
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn go_end(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected = self.entries.len().saturating_sub(1);
|
||||
self.align_toggles_to_selection();
|
||||
self.ensure_visible();
|
||||
}
|
||||
|
||||
fn align_toggles_to_selection(&mut self) {
|
||||
let Some(entry) = self.entries.get(self.selected).cloned() else {
|
||||
return;
|
||||
};
|
||||
if entry.files_available {
|
||||
if self.restore_files_forced_off {
|
||||
self.restore_files = true;
|
||||
}
|
||||
self.restore_files_forced_off = false;
|
||||
} else {
|
||||
self.restore_files = false;
|
||||
self.restore_files_forced_off = true;
|
||||
}
|
||||
|
||||
if entry.conversation_available {
|
||||
if self.restore_conversation_forced_off {
|
||||
self.restore_conversation = true;
|
||||
}
|
||||
self.restore_conversation_forced_off = false;
|
||||
} else {
|
||||
self.restore_conversation = false;
|
||||
self.restore_conversation_forced_off = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_visible(&mut self) {
|
||||
if self.entries.is_empty() {
|
||||
self.top_row = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut cumulative = 0usize;
|
||||
for (idx, entry) in self.entries.iter().enumerate() {
|
||||
if idx < self.selected {
|
||||
cumulative = cumulative.saturating_add(entry.list_line_count());
|
||||
}
|
||||
}
|
||||
let selected_height = self
|
||||
.entries
|
||||
.get(self.selected)
|
||||
.map(|entry| entry.list_line_count())
|
||||
.unwrap_or(1);
|
||||
|
||||
if cumulative < self.top_row {
|
||||
self.top_row = cumulative;
|
||||
} else {
|
||||
let bottom = cumulative + selected_height;
|
||||
let window_bottom = self.top_row + MAX_VISIBLE_LIST_ROWS;
|
||||
if bottom > window_bottom {
|
||||
self.top_row = bottom.saturating_sub(MAX_VISIBLE_LIST_ROWS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_files(&mut self) {
|
||||
if let Some(entry) = self.selected_entry() {
|
||||
if entry.files_available {
|
||||
self.restore_files = !self.restore_files;
|
||||
self.restore_files_forced_off = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_conversation(&mut self) {
|
||||
if let Some(entry) = self.selected_entry() {
|
||||
if entry.conversation_available {
|
||||
self.restore_conversation = !self.restore_conversation;
|
||||
self.restore_conversation_forced_off = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm(&mut self) {
|
||||
if let Some(entry) = self.selected_entry() {
|
||||
match entry.kind {
|
||||
UndoTimelineEntryKind::Snapshot { ref commit } => {
|
||||
self.app_event_tx.send(AppEvent::PerformUndoRestore {
|
||||
commit: Some(commit.clone()),
|
||||
restore_files: self.restore_files && entry.files_available,
|
||||
restore_conversation: self.restore_conversation && entry.conversation_available,
|
||||
});
|
||||
self.is_complete = true;
|
||||
}
|
||||
UndoTimelineEntryKind::Current => {
|
||||
self.is_complete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn total_list_height(&self) -> usize {
|
||||
self.entries.iter().map(|entry| entry.list_line_count()).sum()
|
||||
}
|
||||
|
||||
fn visible_range(&self) -> (usize, usize) {
|
||||
let total = self.total_list_height();
|
||||
if total <= MAX_VISIBLE_LIST_ROWS {
|
||||
return (0, self.entries.len());
|
||||
}
|
||||
|
||||
let mut start_entry = 0usize;
|
||||
let mut spent = 0usize;
|
||||
while start_entry < self.entries.len() && spent + self.entries[start_entry].list_line_count() <= self.top_row {
|
||||
spent = spent.saturating_add(self.entries[start_entry].list_line_count());
|
||||
start_entry += 1;
|
||||
}
|
||||
|
||||
if start_entry > self.selected {
|
||||
start_entry = self.selected;
|
||||
}
|
||||
|
||||
while start_entry < self.selected {
|
||||
let span: usize = self.entries[start_entry..=self.selected]
|
||||
.iter()
|
||||
.map(|entry| entry.list_line_count())
|
||||
.sum();
|
||||
if span <= MAX_VISIBLE_LIST_ROWS {
|
||||
break;
|
||||
}
|
||||
start_entry += 1;
|
||||
}
|
||||
|
||||
let mut end_entry = start_entry;
|
||||
let mut used = 0usize;
|
||||
while end_entry < self.entries.len() {
|
||||
let lines = self.entries[end_entry].list_line_count();
|
||||
if used + lines > MAX_VISIBLE_LIST_ROWS && end_entry > self.selected {
|
||||
break;
|
||||
}
|
||||
used = used.saturating_add(lines);
|
||||
end_entry += 1;
|
||||
if used >= MAX_VISIBLE_LIST_ROWS && end_entry > self.selected {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if end_entry <= self.selected && end_entry < self.entries.len() {
|
||||
while end_entry <= self.selected && end_entry < self.entries.len() {
|
||||
used = used.saturating_add(self.entries[end_entry].list_line_count());
|
||||
end_entry += 1;
|
||||
}
|
||||
}
|
||||
|
||||
(start_entry, end_entry)
|
||||
}
|
||||
|
||||
fn render_list(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (start, end) = self.visible_range();
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
for (idx, entry) in self.entries[start..end].iter().enumerate() {
|
||||
let absolute_idx = start + idx;
|
||||
let selected = absolute_idx == self.selected;
|
||||
|
||||
let marker = if selected { "›" } else { " " };
|
||||
let mut title_spans = vec![
|
||||
Span::styled(format!("{marker} "), Style::default().fg(crate::colors::primary())),
|
||||
Span::styled(
|
||||
entry.label.clone(),
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::text())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
.bg(crate::colors::selection())
|
||||
} else {
|
||||
Style::default().fg(crate::colors::text())
|
||||
},
|
||||
),
|
||||
];
|
||||
if let Some(commit) = &entry.commit_line {
|
||||
title_spans.push(Span::raw(" "));
|
||||
title_spans.push(Span::styled(
|
||||
commit.clone(),
|
||||
if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.bg(crate::colors::selection())
|
||||
} else {
|
||||
Style::default().fg(crate::colors::text_dim())
|
||||
},
|
||||
));
|
||||
}
|
||||
lines.push(Line::from(title_spans));
|
||||
|
||||
if let Some(summary) = &entry.summary {
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.bg(crate::colors::selection())
|
||||
} else {
|
||||
Style::default().fg(crate::colors::text_dim())
|
||||
};
|
||||
lines.push(Line::from(Span::styled(format!(" {summary}"), style)));
|
||||
}
|
||||
|
||||
if entry.timestamp_line.is_some() || entry.relative_time.is_some() {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if let Some(ts) = &entry.timestamp_line {
|
||||
parts.push(ts.clone());
|
||||
}
|
||||
if let Some(rel) = &entry.relative_time {
|
||||
parts.push(rel.clone());
|
||||
}
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.bg(crate::colors::selection())
|
||||
} else {
|
||||
Style::default().fg(crate::colors::text_dim())
|
||||
};
|
||||
lines.push(Line::from(Span::styled(format!(" {}", parts.join(" • ")), style)));
|
||||
}
|
||||
|
||||
if let Some(stats) = &entry.stats_line {
|
||||
let style = if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.bg(crate::colors::selection())
|
||||
} else {
|
||||
Style::default().fg(crate::colors::text_dim())
|
||||
};
|
||||
lines.push(Line::from(Span::styled(format!(" {stats}"), style)));
|
||||
}
|
||||
|
||||
if selected {
|
||||
lines.push(Line::from(Span::styled(
|
||||
String::new(),
|
||||
Style::default().bg(crate::colors::selection()),
|
||||
)));
|
||||
} else {
|
||||
lines.push(Line::from(String::new()));
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.alignment(Alignment::Left)
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
|
||||
.wrap(ratatui::widgets::Wrap { trim: true });
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_preview(&self, area: Rect, buf: &mut Buffer) {
|
||||
let Some(entry) = self.selected_entry() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let [conversation_area, files_area, footer_area] = Layout::vertical([
|
||||
Constraint::Percentage(55),
|
||||
Constraint::Percentage(35),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
let conversation_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Conversation preview ")
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
|
||||
let conversation_inner = conversation_block.inner(conversation_area);
|
||||
conversation_block.render(conversation_area, buf);
|
||||
let conversation = Paragraph::new(entry.conversation_lines.clone())
|
||||
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||
.style(Style::default().bg(crate::colors::background()))
|
||||
.alignment(Alignment::Left);
|
||||
conversation.render(conversation_inner, buf);
|
||||
|
||||
let files_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" File changes ")
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
|
||||
let files_inner = files_block.inner(files_area);
|
||||
files_block.render(files_area, buf);
|
||||
let file_lines = if entry.file_lines.is_empty() {
|
||||
vec![Line::from(Span::styled(
|
||||
"No file changes captured for this snapshot.",
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
))]
|
||||
} else {
|
||||
entry.file_lines.clone()
|
||||
};
|
||||
let file_summary = Paragraph::new(file_lines)
|
||||
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||
.style(Style::default().bg(crate::colors::background()))
|
||||
.alignment(Alignment::Left);
|
||||
file_summary.render(files_inner, buf);
|
||||
|
||||
let footer_lines = self.footer_lines(entry);
|
||||
Paragraph::new(footer_lines)
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
|
||||
.wrap(ratatui::widgets::Wrap { trim: true })
|
||||
.render(footer_area, buf);
|
||||
}
|
||||
|
||||
fn footer_lines(&self, entry: &UndoTimelineEntry) -> Vec<Line<'static>> {
|
||||
let files_status = if entry.files_available {
|
||||
if self.restore_files {
|
||||
Span::styled("[x] Files", Style::default().fg(crate::colors::success()))
|
||||
} else {
|
||||
Span::styled("[ ] Files", Style::default().fg(crate::colors::text_dim()))
|
||||
}
|
||||
} else {
|
||||
Span::styled("[ ] Files", Style::default().fg(crate::colors::text_dim()))
|
||||
};
|
||||
|
||||
let convo_status = if entry.conversation_available {
|
||||
if self.restore_conversation {
|
||||
Span::styled("[x] Conversation", Style::default().fg(crate::colors::success()))
|
||||
} else {
|
||||
Span::styled("[ ] Conversation", Style::default().fg(crate::colors::text_dim()))
|
||||
}
|
||||
} else {
|
||||
Span::styled("[ ] Conversation", Style::default().fg(crate::colors::text_dim()))
|
||||
};
|
||||
|
||||
vec![
|
||||
Line::from(vec![files_status, Span::raw(" "), convo_status]),
|
||||
Line::from(vec![
|
||||
Span::styled("↑↓ PgUp PgDn", Style::default().fg(crate::colors::light_blue())),
|
||||
Span::raw(" Navigate "),
|
||||
Span::styled("Space", Style::default().fg(crate::colors::success())),
|
||||
Span::raw(" Toggle files "),
|
||||
Span::styled("C", Style::default().fg(crate::colors::success())),
|
||||
Span::raw(" Toggle conversation "),
|
||||
Span::styled("Enter", Style::default().fg(crate::colors::success())),
|
||||
Span::raw(" Restore "),
|
||||
Span::styled("Esc", Style::default().fg(crate::colors::error())),
|
||||
Span::raw(" Close"),
|
||||
]),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> BottomPaneView<'a> for UndoTimelineView {
|
||||
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
|
||||
match key_event.code {
|
||||
KeyCode::Up => self.move_up(),
|
||||
KeyCode::Down => self.move_down(),
|
||||
KeyCode::PageUp => self.page_up(),
|
||||
KeyCode::PageDown => self.page_down(),
|
||||
KeyCode::Home => self.go_home(),
|
||||
KeyCode::End => self.go_end(),
|
||||
KeyCode::Enter => self.confirm(),
|
||||
KeyCode::Esc => self.is_complete = true,
|
||||
KeyCode::Char(' ') => self.toggle_files(),
|
||||
KeyCode::Char('c') | KeyCode::Char('C') => self.toggle_conversation(),
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => self.toggle_files(),
|
||||
KeyCode::Tab => {
|
||||
if let Some(entry) = self.selected_entry() {
|
||||
if entry.conversation_available && !entry.files_available {
|
||||
self.toggle_conversation();
|
||||
} else if entry.files_available && !entry.conversation_available {
|
||||
self.toggle_files();
|
||||
} else {
|
||||
if self.restore_files {
|
||||
self.toggle_conversation();
|
||||
} else {
|
||||
self.toggle_files();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Right if key_event.modifiers.contains(KeyModifiers::CONTROL) => self.toggle_conversation(),
|
||||
KeyCode::Left if key_event.modifiers.contains(KeyModifiers::CONTROL) => self.toggle_files(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.is_complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
|
||||
self.is_complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn update_status_text(&mut self, _text: String) -> ConditionalUpdate {
|
||||
ConditionalUpdate::NeedsRedraw
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
max(MAX_VISIBLE_LIST_ROWS as u16 + 6, 24)
|
||||
}
|
||||
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
Clear.render(area, buf);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Restore workspace snapshot ")
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
|
||||
.title_alignment(Alignment::Center);
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
let [list_area, preview_area] = Layout::horizontal([
|
||||
Constraint::Percentage(38),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(inner);
|
||||
|
||||
let list_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Snapshots ")
|
||||
.border_style(Style::default().fg(crate::colors::border()))
|
||||
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
|
||||
let list_inner = list_block.inner(list_area);
|
||||
list_block.render(list_area, buf);
|
||||
self.render_list(list_inner, buf);
|
||||
|
||||
self.render_preview(preview_area, buf);
|
||||
}
|
||||
}
|
||||
+370
-110
@@ -13,6 +13,7 @@ use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use std::process::Command;
|
||||
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
@@ -219,13 +220,13 @@ use crate::app_event::{
|
||||
};
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::CustomPromptView;
|
||||
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionAction, SelectionItem};
|
||||
use crate::bottom_pane::list_selection_view::{ListSelectionView, SelectionItem};
|
||||
use crate::bottom_pane::CloudTasksView;
|
||||
use crate::bottom_pane::validation_settings_view;
|
||||
use crate::bottom_pane::validation_settings_view::{GroupStatus, ToolRow};
|
||||
use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::UndoRestoreView;
|
||||
use crate::bottom_pane::{UndoTimelineEntry, UndoTimelineEntryKind, UndoTimelineView};
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::LoginAccountsState;
|
||||
@@ -313,6 +314,7 @@ use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::symbols::scrollbar as scrollbar_symbols;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text as RtText;
|
||||
use textwrap::wrap;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
@@ -521,16 +523,6 @@ impl AutoCoordinatorUiState {
|
||||
}
|
||||
}
|
||||
|
||||
struct UndoSnapshotPreview {
|
||||
index: usize,
|
||||
short_id: String,
|
||||
summary: Option<String>,
|
||||
captured_at: DateTime<Local>,
|
||||
age: Option<std::time::Duration>,
|
||||
user_delta: usize,
|
||||
assistant_delta: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
@@ -752,6 +744,12 @@ struct GhostSnapshot {
|
||||
history: HistorySnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum UndoPreviewRole {
|
||||
User,
|
||||
Assistant,
|
||||
}
|
||||
|
||||
impl GhostSnapshot {
|
||||
fn new(
|
||||
commit: GhostCommit,
|
||||
@@ -1252,6 +1250,10 @@ enum SystemPlacement {
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
const MAX_UNDO_CONVERSATION_MESSAGES: usize = 8;
|
||||
const MAX_UNDO_PREVIEW_CHARS: usize = 160;
|
||||
const MAX_UNDO_FILE_LINES: usize = 24;
|
||||
|
||||
fn fmt_short_duration(&self, d: Duration) -> String {
|
||||
let s = d.as_secs();
|
||||
let h = s / 3600;
|
||||
@@ -7027,21 +7029,6 @@ impl ChatWidget<'_> {
|
||||
self.spawn_next_ghost_snapshot();
|
||||
}
|
||||
|
||||
fn snapshot_preview(&self, index: usize) -> Option<UndoSnapshotPreview> {
|
||||
self.ghost_snapshots.get(index).map(|snapshot| {
|
||||
let (user_delta, assistant_delta) = self.conversation_delta_since(&snapshot.conversation);
|
||||
UndoSnapshotPreview {
|
||||
index,
|
||||
short_id: snapshot.short_id(),
|
||||
summary: snapshot.summary.clone(),
|
||||
captured_at: snapshot.captured_at,
|
||||
age: snapshot.age_from(Local::now()),
|
||||
user_delta,
|
||||
assistant_delta,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn handle_undo_command(&mut self) {
|
||||
if self.ghost_snapshots_disabled {
|
||||
let reason = self
|
||||
@@ -7158,45 +7145,8 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn show_undo_snapshot_picker(&mut self) {
|
||||
let now = Local::now();
|
||||
let mut entries: Vec<(usize, &GhostSnapshot)> = self
|
||||
.ghost_snapshots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.collect();
|
||||
entries.reverse();
|
||||
|
||||
let mut items: Vec<SelectionItem> = Vec::new();
|
||||
for (display_idx, (actual_idx, snapshot)) in entries.into_iter().enumerate() {
|
||||
let idx = actual_idx;
|
||||
let short_id = snapshot.short_id();
|
||||
let name = snapshot
|
||||
.summary_snippet(80)
|
||||
.unwrap_or_else(|| format!("Snapshot {short_id}"));
|
||||
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if let Some(age) = snapshot.age_from(now) {
|
||||
details.push(format!("captured {} ago", format_duration(age)));
|
||||
} else {
|
||||
details.push("captured moments ago".to_string());
|
||||
}
|
||||
details.push(snapshot.captured_at.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
details.push(format!("commit {short_id}"));
|
||||
let description = Some(details.join(" • "));
|
||||
|
||||
let actions: Vec<SelectionAction> = vec![Box::new(move |tx: &AppEventSender| {
|
||||
tx.send(AppEvent::ShowUndoOptions { index: idx });
|
||||
})];
|
||||
|
||||
items.push(SelectionItem {
|
||||
name,
|
||||
description,
|
||||
is_current: display_idx == 0,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
||||
if items.is_empty() {
|
||||
let entries = self.build_undo_timeline_entries();
|
||||
if entries.len() <= 1 {
|
||||
self.push_background_tail(
|
||||
"/undo unavailable: no snapshots captured yet. Run a file-modifying command to create one.".to_string(),
|
||||
);
|
||||
@@ -7204,55 +7154,353 @@ impl ChatWidget<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut subtitle_lines: Vec<String> = Vec::new();
|
||||
subtitle_lines.push("Restores workspace files only; chat history stays unchanged.".to_string());
|
||||
subtitle_lines.push("Select a snapshot to jump back in time.".to_string());
|
||||
let view = ListSelectionView::new(
|
||||
" Restore a workspace snapshot ".to_string(),
|
||||
Some(subtitle_lines.join("\n")),
|
||||
Some("Enter restore • Esc cancel".to_string()),
|
||||
items,
|
||||
self.app_event_tx.clone(),
|
||||
8,
|
||||
);
|
||||
|
||||
self.bottom_pane.show_list_selection(
|
||||
"Restore snapshot".to_string(),
|
||||
Some(
|
||||
"Restores workspace files only; chat history stays unchanged.".to_string(),
|
||||
),
|
||||
Some("Enter restore • Esc cancel".to_string()),
|
||||
view,
|
||||
);
|
||||
let current_index = entries.len().saturating_sub(1);
|
||||
let view = UndoTimelineView::new(entries, current_index, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_undo_timeline_view(view);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn show_undo_restore_options(&mut self, index: usize) {
|
||||
let Some(preview) = self.snapshot_preview(index) else {
|
||||
if index >= self.ghost_snapshots.len() {
|
||||
self.push_background_tail("Selected snapshot is no longer available.".to_string());
|
||||
return;
|
||||
}
|
||||
let entries = self.build_undo_timeline_entries();
|
||||
let view = UndoTimelineView::new(entries, index, self.app_event_tx.clone());
|
||||
self.bottom_pane.show_undo_timeline_view(view);
|
||||
}
|
||||
|
||||
fn build_undo_timeline_entries(&self) -> Vec<UndoTimelineEntry> {
|
||||
let mut entries: Vec<UndoTimelineEntry> = Vec::with_capacity(self.ghost_snapshots.len().saturating_add(1));
|
||||
for snapshot in self.ghost_snapshots.iter() {
|
||||
entries.push(self.timeline_entry_for_snapshot(snapshot));
|
||||
}
|
||||
entries.push(self.timeline_entry_for_current());
|
||||
entries
|
||||
}
|
||||
|
||||
fn timeline_entry_for_snapshot(&self, snapshot: &GhostSnapshot) -> UndoTimelineEntry {
|
||||
let short_id = snapshot.short_id();
|
||||
let label = format!("Snapshot {short_id}");
|
||||
let summary = snapshot.summary.clone();
|
||||
let timestamp_line = Some(snapshot.captured_at.format("%Y-%m-%d %H:%M:%S").to_string());
|
||||
let relative_time = snapshot
|
||||
.age_from(Local::now())
|
||||
.map(|age| format!("captured {} ago", format_duration(age)));
|
||||
let (user_delta, assistant_delta) = self.conversation_delta_since(&snapshot.conversation);
|
||||
let stats_line = if user_delta == 0 && assistant_delta == 0 {
|
||||
Some("conversation already matches current state".to_string())
|
||||
} else if assistant_delta == 0 {
|
||||
Some(format!(
|
||||
"rewind {} user turn{}",
|
||||
user_delta,
|
||||
if user_delta == 1 { "" } else { "s" }
|
||||
))
|
||||
} else {
|
||||
Some(format!(
|
||||
"rewind {} user turn{} and {} assistant repl{}",
|
||||
user_delta,
|
||||
if user_delta == 1 { "" } else { "s" },
|
||||
assistant_delta,
|
||||
if assistant_delta == 1 { "y" } else { "ies" }
|
||||
))
|
||||
};
|
||||
|
||||
let timestamp = preview.captured_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let timestamp_line = preview
|
||||
.age
|
||||
.map(|age| format!("Captured {} ({})", timestamp, format_duration(age)))
|
||||
.unwrap_or_else(|| format!("Captured {}", timestamp));
|
||||
let title_line = "Select what to restore".to_string();
|
||||
let conversation_available = preview.user_delta > 0;
|
||||
let conversation_lines = Self::conversation_preview_lines_from_snapshot(&snapshot.history);
|
||||
let file_lines = self.timeline_file_lines_for_commit(snapshot.commit().id());
|
||||
|
||||
let view = UndoRestoreView::new(
|
||||
preview.index,
|
||||
preview.short_id.clone(),
|
||||
title_line,
|
||||
preview.summary.clone(),
|
||||
UndoTimelineEntry {
|
||||
label,
|
||||
summary,
|
||||
timestamp_line,
|
||||
preview.user_delta,
|
||||
preview.assistant_delta,
|
||||
false,
|
||||
conversation_available,
|
||||
self.app_event_tx.clone(),
|
||||
relative_time,
|
||||
stats_line,
|
||||
commit_line: Some(format!("commit {short_id}")),
|
||||
conversation_lines,
|
||||
file_lines,
|
||||
conversation_available: user_delta > 0,
|
||||
files_available: true,
|
||||
kind: UndoTimelineEntryKind::Snapshot {
|
||||
commit: snapshot.commit().id().to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_entry_for_current(&self) -> UndoTimelineEntry {
|
||||
let history_snapshot = self.history_snapshot_for_persistence();
|
||||
let conversation_lines = Self::conversation_preview_lines_from_snapshot(&history_snapshot);
|
||||
let file_lines = self.timeline_file_lines_for_current();
|
||||
UndoTimelineEntry {
|
||||
label: "Current workspace".to_string(),
|
||||
summary: None,
|
||||
timestamp_line: Some(Local::now().format("%Y-%m-%d %H:%M:%S").to_string()),
|
||||
relative_time: Some("current point".to_string()),
|
||||
stats_line: Some("Already at this point in time".to_string()),
|
||||
commit_line: None,
|
||||
conversation_lines,
|
||||
file_lines,
|
||||
conversation_available: false,
|
||||
files_available: false,
|
||||
kind: UndoTimelineEntryKind::Current,
|
||||
}
|
||||
}
|
||||
|
||||
fn conversation_preview_lines_from_snapshot(snapshot: &HistorySnapshot) -> Vec<Line<'static>> {
|
||||
let mut state = HistoryState::new();
|
||||
state.restore(snapshot);
|
||||
let mut messages: Vec<(UndoPreviewRole, String)> = Vec::new();
|
||||
for record in &state.records {
|
||||
match record {
|
||||
HistoryRecord::PlainMessage(msg) => match msg.kind {
|
||||
PlainMessageKind::User => {
|
||||
let text = Self::message_lines_to_plain_preview(&msg.lines);
|
||||
if !text.is_empty() {
|
||||
messages.push((UndoPreviewRole::User, text));
|
||||
}
|
||||
}
|
||||
PlainMessageKind::Assistant => {
|
||||
let text = Self::message_lines_to_plain_preview(&msg.lines);
|
||||
if !text.is_empty() {
|
||||
messages.push((UndoPreviewRole::Assistant, text));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
HistoryRecord::AssistantMessage(msg) => {
|
||||
let text = Self::markdown_to_plain_preview(&msg.markdown);
|
||||
if !text.is_empty() {
|
||||
messages.push((UndoPreviewRole::Assistant, text));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if messages.is_empty() {
|
||||
return vec![Line::from(Span::styled(
|
||||
"No conversation captured in this snapshot.",
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
))];
|
||||
}
|
||||
|
||||
let len = messages.len();
|
||||
let start = len.saturating_sub(Self::MAX_UNDO_CONVERSATION_MESSAGES);
|
||||
messages[start..]
|
||||
.iter()
|
||||
.map(|(role, text)| Self::conversation_line(*role, text.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn conversation_line(role: UndoPreviewRole, text: &str) -> Line<'static> {
|
||||
let (label, color) = match role {
|
||||
UndoPreviewRole::User => ("You", crate::colors::text_bright()),
|
||||
UndoPreviewRole::Assistant => ("Code", crate::colors::primary()),
|
||||
};
|
||||
let label_span = Span::styled(
|
||||
format!("{label}: "),
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
);
|
||||
self.bottom_pane.show_undo_restore_view(view);
|
||||
let content_span = Span::styled(text.to_string(), Style::default().fg(crate::colors::text()));
|
||||
Line::from(vec![label_span, content_span])
|
||||
}
|
||||
|
||||
fn message_lines_to_plain_preview(lines: &[MessageLine]) -> String {
|
||||
let mut segments: Vec<String> = Vec::new();
|
||||
for line in lines {
|
||||
match line.kind {
|
||||
MessageLineKind::Blank => continue,
|
||||
MessageLineKind::Metadata => continue,
|
||||
_ => {
|
||||
let mut text = String::new();
|
||||
for span in &line.spans {
|
||||
text.push_str(&span.text);
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
segments.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if segments.len() >= Self::MAX_UNDO_CONVERSATION_MESSAGES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let joined = segments.join(" ");
|
||||
Self::truncate_preview_text(joined, Self::MAX_UNDO_PREVIEW_CHARS)
|
||||
}
|
||||
|
||||
fn markdown_to_plain_preview(markdown: &str) -> String {
|
||||
let mut segments: Vec<String> = Vec::new();
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('#') {
|
||||
segments.push(trimmed.trim_start_matches('#').trim().to_string());
|
||||
} else {
|
||||
segments.push(trimmed.to_string());
|
||||
}
|
||||
if segments.len() >= Self::MAX_UNDO_CONVERSATION_MESSAGES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if segments.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let joined = segments.join(" ");
|
||||
Self::truncate_preview_text(joined, Self::MAX_UNDO_PREVIEW_CHARS)
|
||||
}
|
||||
|
||||
fn truncate_preview_text(text: String, limit: usize) -> String {
|
||||
if text.chars().count() <= limit {
|
||||
return text;
|
||||
}
|
||||
let truncated: String = text.chars().take(limit.saturating_sub(1)).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
fn timeline_file_lines_for_commit(&self, commit_id: &str) -> Vec<Line<'static>> {
|
||||
match self.git_numstat(["show", "--numstat", "--format=", commit_id]) {
|
||||
Ok(entries) => Self::file_change_lines(entries),
|
||||
Err(err) => vec![Line::from(Span::styled(
|
||||
err,
|
||||
Style::default().fg(crate::colors::error()),
|
||||
))],
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_file_lines_for_current(&self) -> Vec<Line<'static>> {
|
||||
match self.git_numstat(["diff", "--numstat", "HEAD"]) {
|
||||
Ok(entries) => {
|
||||
if entries.is_empty() {
|
||||
vec![Line::from(Span::styled(
|
||||
"Working tree clean",
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
))]
|
||||
} else {
|
||||
Self::file_change_lines(entries)
|
||||
}
|
||||
}
|
||||
Err(err) => vec![Line::from(Span::styled(
|
||||
err,
|
||||
Style::default().fg(crate::colors::error()),
|
||||
))],
|
||||
}
|
||||
}
|
||||
|
||||
fn git_numstat<I, S>(
|
||||
&self,
|
||||
args: I,
|
||||
) -> Result<Vec<(Option<u32>, Option<u32>, String)>, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
self.run_git_command(args, |stdout| {
|
||||
let mut out = Vec::new();
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = trimmed.splitn(3, '\t');
|
||||
let added = parts.next();
|
||||
let removed = parts.next();
|
||||
let path = parts.next();
|
||||
if let (Some(added), Some(removed), Some(path)) = (added, removed, path) {
|
||||
out.push((
|
||||
Self::parse_numstat_count(added),
|
||||
Self::parse_numstat_count(removed),
|
||||
path.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_git_command<I, S, F, T>(&self, args: I, parser: F) -> Result<T, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
F: FnOnce(String) -> Result<T, String>,
|
||||
{
|
||||
let args_vec: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
|
||||
let output = Command::new("git")
|
||||
.current_dir(&self.config.cwd)
|
||||
.args(&args_vec)
|
||||
.output()
|
||||
.map_err(|err| format!("git {} failed: {err}", args_vec.join(" ")))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let msg = stderr.trim();
|
||||
if msg.is_empty() {
|
||||
Err(format!(
|
||||
"git {} exited with status {}",
|
||||
args_vec.join(" "),
|
||||
output.status
|
||||
))
|
||||
} else {
|
||||
Err(msg.to_string())
|
||||
}
|
||||
} else {
|
||||
parser(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_numstat_count(raw: &str) -> Option<u32> {
|
||||
if raw == "-" {
|
||||
None
|
||||
} else {
|
||||
raw.parse::<u32>().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn file_change_lines(entries: Vec<(Option<u32>, Option<u32>, String)>) -> Vec<Line<'static>> {
|
||||
if entries.is_empty() {
|
||||
return vec![Line::from(Span::styled(
|
||||
"No file changes recorded for this snapshot.",
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
))];
|
||||
}
|
||||
|
||||
let max_entries = (Self::MAX_UNDO_FILE_LINES / 2).max(1);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
for (idx, (added, removed, path)) in entries.iter().enumerate() {
|
||||
if idx >= max_entries {
|
||||
break;
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
path.clone(),
|
||||
Style::default().fg(crate::colors::text()),
|
||||
)));
|
||||
|
||||
let added_text = added.map_or("-".to_string(), |v| v.to_string());
|
||||
let removed_text = removed.map_or("-".to_string(), |v| v.to_string());
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("+{added_text}"),
|
||||
Style::default().fg(crate::colors::success()),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("-{removed_text}"),
|
||||
Style::default().fg(crate::colors::error()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
if entries.len() > max_entries {
|
||||
let remaining = entries.len() - max_entries;
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("… and {remaining} more file{}", if remaining == 1 { "" } else { "s" }),
|
||||
Style::default().fg(crate::colors::text_dim()),
|
||||
)));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
pub(crate) fn restore_history_snapshot(&mut self, snapshot: &HistorySnapshot) {
|
||||
@@ -7364,21 +7612,33 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn perform_undo_restore(
|
||||
&mut self,
|
||||
index: usize,
|
||||
commit: Option<&str>,
|
||||
restore_files: bool,
|
||||
restore_conversation: bool,
|
||||
) {
|
||||
if index >= self.ghost_snapshots.len() {
|
||||
self.push_background_tail("Selected snapshot is no longer available.".to_string());
|
||||
let Some(commit_id) = commit else {
|
||||
self.push_background_tail("No snapshot selected.".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Some((index, snapshot)) = self
|
||||
.ghost_snapshots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, snap)| snap.commit().id() == commit_id)
|
||||
.map(|(idx, snap)| (idx, snap.clone()))
|
||||
else {
|
||||
self.push_background_tail(
|
||||
"Selected snapshot is no longer available.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if !restore_files && !restore_conversation {
|
||||
self.push_background_tail("No restore options selected.".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let snapshot = self.ghost_snapshots[index].clone();
|
||||
let mut files_restored = false;
|
||||
let mut conversation_rewind_requested = false;
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
@@ -1161,11 +1161,8 @@ fn pump_app_events(
|
||||
AppEvent::DispatchCommand(SlashCommand::Update, command_text) => {
|
||||
chat.handle_update_command(&command_text);
|
||||
}
|
||||
AppEvent::ShowUndoOptions { index } => {
|
||||
chat.show_undo_restore_options(index);
|
||||
}
|
||||
AppEvent::PerformUndoRestore { index, restore_files, restore_conversation } => {
|
||||
chat.perform_undo_restore(index, restore_files, restore_conversation);
|
||||
AppEvent::PerformUndoRestore { commit, restore_files, restore_conversation } => {
|
||||
chat.perform_undo_restore(commit.as_deref(), restore_files, restore_conversation);
|
||||
}
|
||||
AppEvent::ShowAgentsOverview => chat.show_agents_overview_ui(),
|
||||
AppEvent::RequestRedraw | AppEvent::Redraw | AppEvent::ScheduleFrameIn(_) => {}
|
||||
@@ -1411,12 +1408,14 @@ fn undo_options_view_shows_toggles() {
|
||||
|
||||
let plain = buffer_to_string(terminal.backend().buffer());
|
||||
let lower = plain.to_ascii_lowercase();
|
||||
assert!(lower.contains("restore workspace snapshot"), "expected overlay title\n{plain}");
|
||||
assert!(lower.contains("conversation preview"), "expected conversation preview block\n{plain}");
|
||||
assert!(
|
||||
lower.contains("restore workspace files"),
|
||||
"expected workspace toggle\n{plain}"
|
||||
lower.contains("[x] files") || lower.contains("[ ] files"),
|
||||
"expected files toggle\n{plain}"
|
||||
);
|
||||
assert!(
|
||||
lower.contains("restore conversation"),
|
||||
lower.contains("[x] conversation") || lower.contains("[ ] conversation"),
|
||||
"expected conversation toggle\n{plain}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user