diff --git a/code-rs/Cargo.lock b/code-rs/Cargo.lock index d15926efcd1..2b4878142c2 100644 --- a/code-rs/Cargo.lock +++ b/code-rs/Cargo.lock @@ -1288,6 +1288,7 @@ dependencies = [ "dunce", "env-flags", "eventsource-stream", + "filetime", "fs2", "futures", "htmd", @@ -1298,6 +1299,7 @@ dependencies = [ "libc", "maplit", "mime_guess", + "once_cell", "openssl-sys", "os_info", "portable-pty", diff --git a/code-rs/Cargo.toml b/code-rs/Cargo.toml index 17efdae2740..83e8fa5171b 100644 --- a/code-rs/Cargo.toml +++ b/code-rs/Cargo.toml @@ -114,6 +114,7 @@ env-flags = "0.1.1" env_logger = "0.11.5" escargot = "0.5" eventsource-stream = "0.2.3" +filetime = "0.2" futures = "0.3" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" diff --git a/code-rs/core/Cargo.toml b/code-rs/core/Cargo.toml index e594c6775fa..0911c4bbdd6 100644 --- a/code-rs/core/Cargo.toml +++ b/code-rs/core/Cargo.toml @@ -102,7 +102,9 @@ windows-sys = { version = "0.61.2", features = [ ] } [dev-dependencies] +filetime = { workspace = true } maplit = { workspace = true } +once_cell = { workspace = true } pretty_assertions = { workspace = true } tokio-test = { workspace = true } wiremock = { workspace = true } diff --git a/code-rs/core/src/rollout/list.rs b/code-rs/core/src/rollout/list.rs index 33d189a308f..30cc64efaf0 100644 --- a/code-rs/core/src/rollout/list.rs +++ b/code-rs/core/src/rollout/list.rs @@ -11,6 +11,7 @@ use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; use time::macros::format_description; +use time::format_description::well_known::Rfc3339; use uuid::Uuid; use super::SESSIONS_SUBDIR; @@ -47,6 +48,8 @@ pub struct ConversationItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent response in the tail, if available. pub updated_at: Option, + /// RFC3339 timestamp string for the file's last modification time, if available. + pub modified_at: Option, } #[derive(Default)] @@ -147,7 +150,7 @@ async fn traverse_directories_for_paths( anchor: Option, allowed_sources: &[SessionSource], ) -> io::Result { - let mut items: Vec = Vec::with_capacity(page_size); + let mut candidates: Vec<(OffsetDateTime, OffsetDateTime, Uuid, ConversationItem)> = Vec::new(); let mut scanned_files = 0usize; let mut anchor_passed = anchor.is_none(); let (anchor_ts, anchor_id) = match anchor { @@ -171,7 +174,7 @@ async fn traverse_directories_for_paths( if scanned_files >= MAX_SCAN_FILES { break 'outer; } - let mut day_files = collect_files(day_path, |name_str, path| { + let day_files = collect_files(day_path, |name_str, path| { if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { return None; } @@ -180,11 +183,22 @@ async fn traverse_directories_for_paths( .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) }) .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); + let mut day_entries = Vec::with_capacity(day_files.len()); for (ts, sid, _name_str, path) in day_files.into_iter() { + let modified = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(OffsetDateTime::from) + .unwrap_or(OffsetDateTime::UNIX_EPOCH); + day_entries.push((modified, ts, sid, path)); + } + day_entries.sort_by_key(|(modified, ts, sid, _)| { + (Reverse(*modified), Reverse(*ts), Reverse(*sid)) + }); + for (modified, ts, sid, path) in day_entries.into_iter() { scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + if scanned_files >= MAX_SCAN_FILES { break 'outer; } if !anchor_passed { @@ -194,9 +208,6 @@ async fn traverse_directories_for_paths( continue; } } - if items.len() == page_size { - break 'outer; - } let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT) .await .unwrap_or_default(); @@ -216,19 +227,39 @@ async fn traverse_directories_for_paths( .. } = summary; updated_at = updated_at.or_else(|| created_at.clone()); - items.push(ConversationItem { - path, - head, - tail, - created_at, - updated_at, - }); + let modified_at = if modified == OffsetDateTime::UNIX_EPOCH { + updated_at.clone().or_else(|| created_at.clone()) + } else { + modified.format(&Rfc3339).ok() + }; + candidates.push(( + modified, + ts, + sid, + ConversationItem { + path, + head, + tail, + created_at, + updated_at, + modified_at, + }, + )); } } } } } + candidates.sort_by_key(|(modified, ts, sid, _)| { + (Reverse(*modified), Reverse(*ts), Reverse(*sid)) + }); + + let mut items: Vec = Vec::with_capacity(page_size.min(candidates.len())); + for (_, _, _, item) in candidates.into_iter().take(page_size) { + items.push(item); + } + let next = build_next_cursor(&items); Ok(ConversationsPage { items, diff --git a/code-rs/core/src/rollout/tests.rs b/code-rs/core/src/rollout/tests.rs index 2954a2505f0..232431bbc35 100644 --- a/code-rs/core/src/rollout/tests.rs +++ b/code-rs/core/src/rollout/tests.rs @@ -6,10 +6,12 @@ use std::io::BufWriter; use std::io::Write; use std::path::{Path, PathBuf}; +use filetime::{set_file_mtime, FileTime}; use tempfile::TempDir; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; +use time::format_description::well_known::Rfc3339; use time::macros::format_description; use uuid::Uuid; @@ -19,6 +21,7 @@ use crate::rollout::list::ConversationsPage; use crate::rollout::list::Cursor; use crate::rollout::list::get_conversation; use crate::rollout::list::get_conversations; +use std::time::{Duration, SystemTime}; use code_protocol::models::{ContentItem, ResponseItem}; use code_protocol::ConversationId; use code_protocol::protocol::{ @@ -426,7 +429,7 @@ async fn test_pagination_cursor() { ]; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); - assert_page_summary(&page1, &expected_page1_items, Some(expected_cursor1.clone()), 3); + assert_page_summary(&page1, &expected_page1_items, Some(expected_cursor1.clone()), 5); let page2 = get_conversations( home, @@ -524,6 +527,69 @@ async fn test_get_conversation_contents() { } } +#[tokio::test] +async fn test_list_conversations_prefers_recent_mtime() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let older_uuid = Uuid::from_u128(0x11); + let newer_uuid = Uuid::from_u128(0x22); + + write_session_file( + home, + "2025-08-01T10-00-00", + older_uuid, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-09-01T10-00-00", + newer_uuid, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + + let older_path = home + .join("sessions") + .join("2025") + .join("08") + .join("01") + .join(format!("rollout-2025-08-01T10-00-00-{older_uuid}.jsonl")); + + let future_time = SystemTime::now() + Duration::from_secs(300); + set_file_mtime(&older_path, FileTime::from_system_time(future_time)).unwrap(); + + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); + + let first = page.items.first().expect("expected at least one session"); + assert_eq!(first.path, older_path); + + let modified_at = first + .modified_at + .as_deref() + .expect("expected modified_at"); + let updated_at = first + .updated_at + .as_deref() + .expect("expected updated_at"); + + let modified_dt = OffsetDateTime::parse(modified_at, &Rfc3339).expect("parse modified_at"); + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let updated_dt = PrimitiveDateTime::parse(updated_at, format) + .expect("parse updated_at") + .assume_utc(); + assert!( + modified_dt > updated_dt, + "modified_at should reflect newer filesystem mtime" + ); +} + #[tokio::test] async fn test_stable_ordering_same_second_pagination() { let temp = TempDir::new().unwrap(); diff --git a/code-rs/tui/src/bottom_pane/mcp_settings_view.rs b/code-rs/tui/src/bottom_pane/mcp_settings_view.rs index cc2d22b5eea..797767d7c79 100644 --- a/code-rs/tui/src/bottom_pane/mcp_settings_view.rs +++ b/code-rs/tui/src/bottom_pane/mcp_settings_view.rs @@ -113,6 +113,8 @@ impl<'a> BottomPaneView<'a> for McpSettingsView { block.render(area, buf); let mut lines: Vec> = Vec::new(); + let mut selected_line_index: usize = 0; + if self.rows.is_empty() { lines.push(Line::from(vec![Span::styled("No MCP servers configured.", Style::default().fg(crate::colors::text_dim()))])); lines.push(Line::from("")); @@ -123,6 +125,7 @@ impl<'a> BottomPaneView<'a> for McpSettingsView { let check = if row.enabled { "[on ]" } else { "[off]" }; let name = format!("{} {}", check, row.name); let name_style = if sel { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() }; + let arrow_line_index = lines.len(); lines.push(Line::from(vec![ Span::styled(if sel { "› " } else { " " }, Style::default()), Span::styled(name, name_style), @@ -133,18 +136,29 @@ impl<'a> BottomPaneView<'a> for McpSettingsView { Span::styled(" ", Style::default()), Span::styled(row.summary.clone(), sum_style), ])); + if sel { + selected_line_index = arrow_line_index; + } } // Add New… let add_sel = self.selected == self.rows.len(); let add_style = if add_sel { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() }; lines.push(Line::from("")); + let add_line_index = lines.len(); lines.push(Line::from(vec![Span::styled(if add_sel { "› " } else { " " }, Style::default()), Span::styled("Add new server…", add_style)])); + if add_sel { + selected_line_index = add_line_index; + } // Close let close_sel = self.selected == self.rows.len().saturating_add(1); let close_style = if close_sel { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() }; + let close_line_index = lines.len(); lines.push(Line::from(vec![Span::styled(if close_sel { "› " } else { " " }, Style::default()), Span::styled("Close", close_style)])); + if close_sel { + selected_line_index = close_line_index; + } lines.push(Line::from("")); lines.push(Line::from(vec![ @@ -156,9 +170,24 @@ impl<'a> BottomPaneView<'a> for McpSettingsView { Span::styled(" Close", Style::default().fg(crate::colors::text_dim())), ])); + let total_lines = lines.len(); + let viewport_height = inner.height as usize; + let selected_line_index = selected_line_index.min(total_lines.saturating_sub(1)); + let mut scroll_top = 0usize; + if viewport_height > 0 && total_lines > viewport_height { + let half = viewport_height / 2; + let mut candidate = selected_line_index.saturating_sub(half); + let max_scroll = total_lines - viewport_height; + if candidate > max_scroll { + candidate = max_scroll; + } + scroll_top = candidate; + } + let paragraph = Paragraph::new(lines) .alignment(Alignment::Left) - .style(Style::default().bg(crate::colors::background()).fg(crate::colors::text())); + .style(Style::default().bg(crate::colors::background()).fg(crate::colors::text())) + .scroll((scroll_top as u16, 0)); paragraph.render(Rect { x: inner.x.saturating_add(1), y: inner.y, width: inner.width.saturating_sub(2), height: inner.height }, buf); } }