agent: Add date separators to Thread History (#29961)

Adds time-bucket separators to the thread history list:


https://github.com/user-attachments/assets/c9ac3ec4-b632-4ea5-8234-382b48de2bd6

Note: I'm simulating that Today is next Thursday so that I can show the
"This Week" bucket.

Release Notes:

- agent: Add date separators to Thread History
This commit is contained in:
Agus Zubiaga 2025-05-06 07:18:48 -03:00 committed by GitHub
parent 4fdd14c3d8
commit de554589a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 766 additions and 175 deletions

View file

@ -55,13 +55,13 @@ use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurat
use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry}; use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry};
use crate::message_editor::{MessageEditor, MessageEditorEvent}; use crate::message_editor::{MessageEditor, MessageEditorEvent};
use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_history::{EntryTimeFormat, PastContext, PastThread, ThreadHistory};
use crate::thread_store::{TextThreadStore, ThreadStore}; use crate::thread_store::ThreadStore;
use crate::{ use crate::{
AddContextServer, AgentDiffPane, ContextStore, DeleteRecentlyOpenThread, ExpandMessageEditor, AddContextServer, AgentDiffPane, ContextStore, DeleteRecentlyOpenThread, ExpandMessageEditor,
Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, Follow, InlineAssistant, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff,
OpenHistory, ResetTrialUpsell, ThreadEvent, ToggleContextPicker, ToggleNavigationMenu, OpenHistory, ResetTrialUpsell, TextThreadStore, ThreadEvent, ToggleContextPicker,
ToggleOptionsMenu, ToggleNavigationMenu, ToggleOptionsMenu,
}; };
const AGENT_PANEL_KEY: &str = "agent_panel"; const AGENT_PANEL_KEY: &str = "agent_panel";
@ -2229,11 +2229,11 @@ impl AssistantPanel {
// TODO: Add keyboard navigation. // TODO: Add keyboard navigation.
match entry { match entry {
HistoryEntry::Thread(thread) => { HistoryEntry::Thread(thread) => {
PastThread::new(thread, cx.entity().downgrade(), false, vec![]) PastThread::new(thread, cx.entity().downgrade(), false, vec![], EntryTimeFormat::DateAndTime)
.into_any_element() .into_any_element()
} }
HistoryEntry::Context(context) => { HistoryEntry::Context(context) => {
PastContext::new(context, cx.entity().downgrade(), false, vec![]) PastContext::new(context, cx.entity().downgrade(), false, vec![], EntryTimeFormat::DateAndTime)
.into_any_element() .into_any_element()
} }
} }

View file

@ -1,11 +1,14 @@
use std::fmt::Display;
use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use assistant_context_editor::SavedContextMetadata; use assistant_context_editor::SavedContextMetadata;
use chrono::{Datelike as _, NaiveDate, TimeDelta, Utc};
use editor::{Editor, EditorEvent}; use editor::{Editor, EditorEvent};
use fuzzy::{StringMatch, StringMatchCandidate}; use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::{ use gpui::{
App, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task, UniformListScrollHandle, App, Empty, Entity, FocusHandle, Focusable, ScrollStrategy, Stateful, Task,
WeakEntity, Window, uniform_list, UniformListScrollHandle, WeakEntity, Window, uniform_list,
}; };
use time::{OffsetDateTime, UtcOffset}; use time::{OffsetDateTime, UtcOffset};
use ui::{ use ui::{
@ -23,14 +26,45 @@ pub struct ThreadHistory {
history_store: Entity<HistoryStore>, history_store: Entity<HistoryStore>,
scroll_handle: UniformListScrollHandle, scroll_handle: UniformListScrollHandle,
selected_index: usize, selected_index: usize,
search_query: SharedString,
search_editor: Entity<Editor>, search_editor: Entity<Editor>,
all_entries: Arc<Vec<HistoryEntry>>, all_entries: Arc<Vec<HistoryEntry>>,
matches: Vec<StringMatch>, // When the search is empty, we display date separators between history entries
_subscriptions: Vec<gpui::Subscription>, // This vector contains an enum of either a separator or an actual entry
_search_task: Option<Task<()>>, separated_items: Vec<HistoryListItem>,
_separated_items_task: Option<Task<()>>,
search_state: SearchState,
scrollbar_visibility: bool, scrollbar_visibility: bool,
scrollbar_state: ScrollbarState, scrollbar_state: ScrollbarState,
_subscriptions: Vec<gpui::Subscription>,
}
enum SearchState {
Empty,
Searching {
query: SharedString,
_task: Task<()>,
},
Searched {
query: SharedString,
matches: Vec<StringMatch>,
},
}
enum HistoryListItem {
BucketSeparator(TimeBucket),
Entry {
index: usize,
format: EntryTimeFormat,
},
}
impl HistoryListItem {
fn entry_index(&self) -> Option<usize> {
match self {
HistoryListItem::BucketSeparator(_) => None,
HistoryListItem::Entry { index, .. } => Some(*index),
}
}
} }
impl ThreadHistory { impl ThreadHistory {
@ -50,15 +84,10 @@ impl ThreadHistory {
cx.subscribe(&search_editor, |this, search_editor, event, cx| { cx.subscribe(&search_editor, |this, search_editor, event, cx| {
if let EditorEvent::BufferEdited = event { if let EditorEvent::BufferEdited = event {
let query = search_editor.read(cx).text(cx); let query = search_editor.read(cx).text(cx);
this.search_query = query.into(); this.search(query.into(), cx);
this.update_search(cx);
} }
}); });
let entries: Arc<Vec<_>> = history_store
.update(cx, |store, cx| store.entries(cx))
.into();
let history_store_subscription = cx.observe(&history_store, |this, _, cx| { let history_store_subscription = cx.observe(&history_store, |this, _, cx| {
this.update_all_entries(cx); this.update_all_entries(cx);
}); });
@ -66,20 +95,22 @@ impl ThreadHistory {
let scroll_handle = UniformListScrollHandle::default(); let scroll_handle = UniformListScrollHandle::default();
let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); let scrollbar_state = ScrollbarState::new(scroll_handle.clone());
Self { let mut this = Self {
assistant_panel, assistant_panel,
history_store, history_store,
scroll_handle, scroll_handle,
selected_index: 0, selected_index: 0,
search_query: SharedString::new_static(""), search_state: SearchState::Empty,
all_entries: entries, all_entries: Default::default(),
matches: Vec::new(), separated_items: Default::default(),
search_editor, search_editor,
_subscriptions: vec![search_editor_subscription, history_store_subscription],
_search_task: None,
scrollbar_visibility: true, scrollbar_visibility: true,
scrollbar_state, scrollbar_state,
} _subscriptions: vec![search_editor_subscription, history_store_subscription],
_separated_items_task: None,
};
this.update_all_entries(cx);
this
} }
fn update_all_entries(&mut self, cx: &mut Context<Self>) { fn update_all_entries(&mut self, cx: &mut Context<Self>) {
@ -87,31 +118,70 @@ impl ThreadHistory {
.history_store .history_store
.update(cx, |store, cx| store.entries(cx)) .update(cx, |store, cx| store.entries(cx))
.into(); .into();
self.matches.clear();
self.update_search(cx);
}
fn update_search(&mut self, cx: &mut Context<Self>) {
self._search_task.take();
if self.has_search_query() {
self.perform_search(cx);
} else {
self.matches.clear();
self.set_selected_index(0, cx); self.set_selected_index(0, cx);
self.update_separated_items(cx);
match &self.search_state {
SearchState::Empty => {}
SearchState::Searching { query, .. } | SearchState::Searched { query, .. } => {
self.search(query.clone(), cx);
}
}
cx.notify(); cx.notify();
} }
}
fn perform_search(&mut self, cx: &mut Context<Self>) { fn update_separated_items(&mut self, cx: &mut Context<Self>) {
let query = self.search_query.clone(); self._separated_items_task.take();
let mut separated_items = std::mem::take(&mut self.separated_items);
separated_items.clear();
let all_entries = self.all_entries.clone(); let all_entries = self.all_entries.clone();
let task = cx.spawn(async move |this, cx| { let bg_task = cx.background_spawn(async move {
let executor = cx.background_executor().clone(); let mut bucket = None;
let today = Utc::now().naive_local().date();
let matches = cx for (index, entry) in all_entries.iter().enumerate() {
.background_spawn(async move { let entry_date = entry.updated_at().naive_local().date();
let entry_bucket = TimeBucket::from_dates(today, entry_date);
if Some(entry_bucket) != bucket {
bucket = Some(entry_bucket);
separated_items.push(HistoryListItem::BucketSeparator(entry_bucket));
}
separated_items.push(HistoryListItem::Entry {
index,
format: entry_bucket.into(),
});
}
separated_items
});
let task = cx.spawn(async move |this, cx| {
let separated_items = bg_task.await;
this.update(cx, |this, cx| {
this.separated_items = separated_items;
cx.notify();
})
.log_err();
});
self._separated_items_task = Some(task);
}
fn search(&mut self, query: SharedString, cx: &mut Context<Self>) {
if query.is_empty() {
self.search_state = SearchState::Empty;
cx.notify();
return;
}
let all_entries = self.all_entries.clone();
let fuzzy_search_task = cx.background_spawn({
let query = query.clone();
let executor = cx.background_executor().clone();
async move {
let mut candidates = Vec::with_capacity(all_entries.len()); let mut candidates = Vec::with_capacity(all_entries.len());
for (idx, entry) in all_entries.iter().enumerate() { for (idx, entry) in all_entries.iter().enumerate() {
@ -136,39 +206,67 @@ impl ThreadHistory {
executor, executor,
) )
.await .await
}) }
.await;
this.update(cx, |this, cx| {
this.matches = matches;
this.set_selected_index(0, cx);
cx.notify();
})
.log_err();
}); });
self._search_task = Some(task); let task = cx.spawn({
} let query = query.clone();
async move |this, cx| {
let matches = fuzzy_search_task.await;
fn has_search_query(&self) -> bool { this.update(cx, |this, cx| {
!self.search_query.is_empty() let SearchState::Searching {
query: current_query,
_task,
} = &this.search_state
else {
return;
};
if &query == current_query {
this.search_state = SearchState::Searched {
query: query.clone(),
matches,
};
this.set_selected_index(0, cx);
cx.notify();
};
})
.log_err();
}
});
self.search_state = SearchState::Searching {
query: query.clone(),
_task: task,
};
cx.notify();
} }
fn matched_count(&self) -> usize { fn matched_count(&self) -> usize {
if self.has_search_query() { match &self.search_state {
self.matches.len() SearchState::Empty => self.all_entries.len(),
} else { SearchState::Searching { .. } => 0,
self.all_entries.len() SearchState::Searched { matches, .. } => matches.len(),
}
}
fn search_produced_no_matches(&self) -> bool {
match &self.search_state {
SearchState::Empty => false,
SearchState::Searching { .. } => false,
SearchState::Searched { matches, .. } => matches.is_empty(),
} }
} }
fn get_match(&self, ix: usize) -> Option<&HistoryEntry> { fn get_match(&self, ix: usize) -> Option<&HistoryEntry> {
if self.has_search_query() { match &self.search_state {
self.matches SearchState::Empty => self.all_entries.get(ix),
SearchState::Searching { .. } => None,
SearchState::Searched { matches, .. } => matches
.get(ix) .get(ix)
.and_then(|m| self.all_entries.get(m.candidate_id)) .and_then(|m| self.all_entries.get(m.candidate_id)),
} else {
self.all_entries.get(ix)
} }
} }
@ -311,6 +409,107 @@ impl ThreadHistory {
cx.notify(); cx.notify();
} }
} }
fn list_items(
&mut self,
range: Range<usize>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let range_start = range.start;
match &self.search_state {
SearchState::Empty => self
.separated_items
.get(range)
.iter()
.flat_map(|items| {
items
.iter()
.map(|item| self.render_list_item(item.entry_index(), item, vec![], cx))
})
.collect(),
SearchState::Searched { matches, .. } => matches[range]
.iter()
.enumerate()
.map(|(ix, m)| {
self.render_list_item(
Some(range_start + ix),
&HistoryListItem::Entry {
index: m.candidate_id,
format: EntryTimeFormat::DateAndTime,
},
m.positions.clone(),
cx,
)
})
.collect(),
SearchState::Searching { .. } => {
vec![]
}
}
}
fn render_list_item(
&self,
list_entry_ix: Option<usize>,
item: &HistoryListItem,
highlight_positions: Vec<usize>,
cx: &App,
) -> AnyElement {
match item {
HistoryListItem::Entry { index, format } => match self.all_entries.get(*index) {
Some(entry) => h_flex()
.w_full()
.pb_1()
.child(self.render_history_entry(
entry,
list_entry_ix == Some(self.selected_index),
highlight_positions,
*format,
))
.into_any(),
None => Empty.into_any_element(),
},
HistoryListItem::BucketSeparator(bucket) => div()
.px(DynamicSpacing::Base06.rems(cx))
.pt_2()
.pb_1()
.child(
Label::new(bucket.to_string())
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.into_any_element(),
}
}
fn render_history_entry(
&self,
entry: &HistoryEntry,
is_active: bool,
highlight_positions: Vec<usize>,
format: EntryTimeFormat,
) -> AnyElement {
match entry {
HistoryEntry::Thread(thread) => PastThread::new(
thread.clone(),
self.assistant_panel.clone(),
is_active,
highlight_positions,
format,
)
.into_any_element(),
HistoryEntry::Context(context) => PastContext::new(
context.clone(),
self.assistant_panel.clone(),
is_active,
highlight_positions,
format,
)
.into_any_element(),
}
}
} }
impl Focusable for ThreadHistory { impl Focusable for ThreadHistory {
@ -321,8 +520,6 @@ impl Focusable for ThreadHistory {
impl Render for ThreadHistory { impl Render for ThreadHistory {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let selected_index = self.selected_index;
v_flex() v_flex()
.key_context("ThreadHistory") .key_context("ThreadHistory")
.size_full() .size_full()
@ -366,7 +563,7 @@ impl Render for ThreadHistory {
.size(LabelSize::Small), .size(LabelSize::Small),
), ),
) )
} else if self.has_search_query() && self.matches.is_empty() { } else if self.search_produced_no_matches() {
view.justify_center().child( view.justify_center().child(
h_flex().w_full().justify_center().child( h_flex().w_full().justify_center().child(
Label::new("No threads match your search.").size(LabelSize::Small), Label::new("No threads match your search.").size(LabelSize::Small),
@ -379,56 +576,7 @@ impl Render for ThreadHistory {
cx.entity().clone(), cx.entity().clone(),
"thread-history", "thread-history",
self.matched_count(), self.matched_count(),
move |history, range, _window, _cx| { Self::list_items,
let range_start = range.start;
let assistant_panel = history.assistant_panel.clone();
let render_item = |index: usize,
entry: &HistoryEntry,
highlight_positions: Vec<usize>|
-> Div {
h_flex().w_full().pb_1().child(match entry {
HistoryEntry::Thread(thread) => PastThread::new(
thread.clone(),
assistant_panel.clone(),
selected_index == index + range_start,
highlight_positions,
)
.into_any_element(),
HistoryEntry::Context(context) => PastContext::new(
context.clone(),
assistant_panel.clone(),
selected_index == index + range_start,
highlight_positions,
)
.into_any_element(),
})
};
if history.has_search_query() {
history.matches[range]
.iter()
.enumerate()
.filter_map(|(index, m)| {
history.all_entries.get(m.candidate_id).map(
|entry| {
render_item(
index,
entry,
m.positions.clone(),
)
},
)
})
.collect()
} else {
history.all_entries[range]
.iter()
.enumerate()
.map(|(index, entry)| render_item(index, entry, vec![]))
.collect()
}
},
) )
.p_1() .p_1()
.track_scroll(self.scroll_handle.clone()) .track_scroll(self.scroll_handle.clone())
@ -448,6 +596,7 @@ pub struct PastThread {
assistant_panel: WeakEntity<AssistantPanel>, assistant_panel: WeakEntity<AssistantPanel>,
selected: bool, selected: bool,
highlight_positions: Vec<usize>, highlight_positions: Vec<usize>,
timestamp_format: EntryTimeFormat,
} }
impl PastThread { impl PastThread {
@ -456,12 +605,14 @@ impl PastThread {
assistant_panel: WeakEntity<AssistantPanel>, assistant_panel: WeakEntity<AssistantPanel>,
selected: bool, selected: bool,
highlight_positions: Vec<usize>, highlight_positions: Vec<usize>,
timestamp_format: EntryTimeFormat,
) -> Self { ) -> Self {
Self { Self {
thread, thread,
assistant_panel, assistant_panel,
selected, selected,
highlight_positions, highlight_positions,
timestamp_format,
} }
} }
} }
@ -470,13 +621,10 @@ impl RenderOnce for PastThread {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let summary = self.thread.summary; let summary = self.thread.summary;
let thread_timestamp = time_format::format_localized_timestamp( let thread_timestamp = self.timestamp_format.format_timestamp(
OffsetDateTime::from_unix_timestamp(self.thread.updated_at.timestamp()).unwrap(), &self.assistant_panel,
OffsetDateTime::now_utc(), self.thread.updated_at.timestamp(),
self.assistant_panel cx,
.update(cx, |this, _cx| this.local_timezone())
.unwrap_or(UtcOffset::UTC),
time_format::TimestampFormat::EnhancedAbsolute,
); );
ListItem::new(SharedString::from(self.thread.id.to_string())) ListItem::new(SharedString::from(self.thread.id.to_string()))
@ -540,6 +688,7 @@ pub struct PastContext {
assistant_panel: WeakEntity<AssistantPanel>, assistant_panel: WeakEntity<AssistantPanel>,
selected: bool, selected: bool,
highlight_positions: Vec<usize>, highlight_positions: Vec<usize>,
timestamp_format: EntryTimeFormat,
} }
impl PastContext { impl PastContext {
@ -548,12 +697,14 @@ impl PastContext {
assistant_panel: WeakEntity<AssistantPanel>, assistant_panel: WeakEntity<AssistantPanel>,
selected: bool, selected: bool,
highlight_positions: Vec<usize>, highlight_positions: Vec<usize>,
timestamp_format: EntryTimeFormat,
) -> Self { ) -> Self {
Self { Self {
context, context,
assistant_panel, assistant_panel,
selected, selected,
highlight_positions, highlight_positions,
timestamp_format,
} }
} }
} }
@ -561,13 +712,10 @@ impl PastContext {
impl RenderOnce for PastContext { impl RenderOnce for PastContext {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let summary = self.context.title; let summary = self.context.title;
let context_timestamp = time_format::format_localized_timestamp( let context_timestamp = self.timestamp_format.format_timestamp(
OffsetDateTime::from_unix_timestamp(self.context.mtime.timestamp()).unwrap(), &self.assistant_panel,
OffsetDateTime::now_utc(), self.context.mtime.timestamp(),
self.assistant_panel cx,
.update(cx, |this, _cx| this.local_timezone())
.unwrap_or(UtcOffset::UTC),
time_format::TimestampFormat::EnhancedAbsolute,
); );
ListItem::new(SharedString::from( ListItem::new(SharedString::from(
@ -627,3 +775,137 @@ impl RenderOnce for PastContext {
}) })
} }
} }
#[derive(Clone, Copy)]
pub enum EntryTimeFormat {
DateAndTime,
TimeOnly,
}
impl EntryTimeFormat {
fn format_timestamp(
&self,
assistant_panel: &WeakEntity<AssistantPanel>,
timestamp: i64,
cx: &App,
) -> String {
let timestamp = OffsetDateTime::from_unix_timestamp(timestamp).unwrap();
let timezone = assistant_panel
.read_with(cx, |this, _cx| this.local_timezone())
.unwrap_or(UtcOffset::UTC);
match &self {
EntryTimeFormat::DateAndTime => time_format::format_localized_timestamp(
timestamp,
OffsetDateTime::now_utc(),
timezone,
time_format::TimestampFormat::EnhancedAbsolute,
),
EntryTimeFormat::TimeOnly => time_format::format_time(timestamp),
}
}
}
impl From<TimeBucket> for EntryTimeFormat {
fn from(bucket: TimeBucket) -> Self {
match bucket {
TimeBucket::Today => EntryTimeFormat::TimeOnly,
TimeBucket::Yesterday => EntryTimeFormat::TimeOnly,
TimeBucket::ThisWeek => EntryTimeFormat::DateAndTime,
TimeBucket::PastWeek => EntryTimeFormat::DateAndTime,
TimeBucket::All => EntryTimeFormat::DateAndTime,
}
}
}
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
enum TimeBucket {
Today,
Yesterday,
ThisWeek,
PastWeek,
All,
}
impl TimeBucket {
fn from_dates(reference: NaiveDate, date: NaiveDate) -> Self {
if date == reference {
return TimeBucket::Today;
}
if date == reference - TimeDelta::days(1) {
return TimeBucket::Yesterday;
}
let week = date.iso_week();
if reference.iso_week() == week {
return TimeBucket::ThisWeek;
}
let last_week = (reference - TimeDelta::days(7)).iso_week();
if week == last_week {
return TimeBucket::PastWeek;
}
TimeBucket::All
}
}
impl Display for TimeBucket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TimeBucket::Today => write!(f, "Today"),
TimeBucket::Yesterday => write!(f, "Yesterday"),
TimeBucket::ThisWeek => write!(f, "This Week"),
TimeBucket::PastWeek => write!(f, "Past Week"),
TimeBucket::All => write!(f, "All"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn test_time_bucket_from_dates() {
let today = NaiveDate::from_ymd_opt(2023, 1, 15).unwrap();
let date = today;
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Today);
let date = NaiveDate::from_ymd_opt(2023, 1, 14).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::Yesterday);
let date = NaiveDate::from_ymd_opt(2023, 1, 13).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
let date = NaiveDate::from_ymd_opt(2023, 1, 11).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::ThisWeek);
let date = NaiveDate::from_ymd_opt(2023, 1, 8).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
let date = NaiveDate::from_ymd_opt(2023, 1, 5).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::PastWeek);
// All: not in this week or last week
let date = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
assert_eq!(TimeBucket::from_dates(today, date), TimeBucket::All);
// Test year boundary cases
let new_year = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
let date = NaiveDate::from_ymd_opt(2022, 12, 31).unwrap();
assert_eq!(
TimeBucket::from_dates(new_year, date),
TimeBucket::Yesterday
);
let date = NaiveDate::from_ymd_opt(2022, 12, 28).unwrap();
assert_eq!(TimeBucket::from_dates(new_year, date), TimeBucket::ThisWeek);
}
}

View file

@ -42,6 +42,82 @@ pub fn format_local_timestamp(
} }
} }
/// Formats the date component of a timestamp
pub fn format_date(
timestamp: OffsetDateTime,
reference: OffsetDateTime,
enhanced_formatting: bool,
) -> String {
format_absolute_date(timestamp, reference, enhanced_formatting)
}
/// Formats the time component of a timestamp
pub fn format_time(timestamp: OffsetDateTime) -> String {
format_absolute_time(timestamp)
}
/// Formats the date component of a timestamp in medium style
pub fn format_date_medium(
timestamp: OffsetDateTime,
reference: OffsetDateTime,
enhanced_formatting: bool,
) -> String {
format_absolute_date_medium(timestamp, reference, enhanced_formatting)
}
fn format_absolute_date(
timestamp: OffsetDateTime,
reference: OffsetDateTime,
#[allow(unused_variables)] enhanced_date_formatting: bool,
) -> String {
#[cfg(target_os = "macos")]
{
if !enhanced_date_formatting {
return macos::format_date(&timestamp);
}
let timestamp_date = timestamp.date();
let reference_date = reference.date();
if timestamp_date == reference_date {
"Today".to_string()
} else if reference_date.previous_day() == Some(timestamp_date) {
"Yesterday".to_string()
} else {
macos::format_date(&timestamp)
}
}
#[cfg(not(target_os = "macos"))]
{
// todo(linux) respect user's date/time preferences
// todo(windows) respect user's date/time preferences
let current_locale = CURRENT_LOCALE
.get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
format_timestamp_naive_date(
timestamp,
reference,
is_12_hour_time_by_locale(current_locale.as_str()),
)
}
}
fn format_absolute_time(timestamp: OffsetDateTime) -> String {
#[cfg(target_os = "macos")]
{
macos::format_time(&timestamp)
}
#[cfg(not(target_os = "macos"))]
{
// todo(linux) respect user's date/time preferences
// todo(windows) respect user's date/time preferences
let current_locale = CURRENT_LOCALE
.get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
format_timestamp_naive_time(
timestamp,
is_12_hour_time_by_locale(current_locale.as_str()),
)
}
}
fn format_absolute_timestamp( fn format_absolute_timestamp(
timestamp: OffsetDateTime, timestamp: OffsetDateTime,
reference: OffsetDateTime, reference: OffsetDateTime,
@ -52,22 +128,22 @@ fn format_absolute_timestamp(
if !enhanced_date_formatting { if !enhanced_date_formatting {
return format!( return format!(
"{} {}", "{} {}",
macos::format_date(&timestamp), format_absolute_date(timestamp, reference, enhanced_date_formatting),
macos::format_time(&timestamp) format_absolute_time(timestamp)
); );
} }
let timestamp_date = timestamp.date(); let timestamp_date = timestamp.date();
let reference_date = reference.date(); let reference_date = reference.date();
if timestamp_date == reference_date { if timestamp_date == reference_date {
format!("Today at {}", macos::format_time(&timestamp)) format!("Today at {}", format_absolute_time(timestamp))
} else if reference_date.previous_day() == Some(timestamp_date) { } else if reference_date.previous_day() == Some(timestamp_date) {
format!("Yesterday at {}", macos::format_time(&timestamp)) format!("Yesterday at {}", format_absolute_time(timestamp))
} else { } else {
format!( format!(
"{} {}", "{} {}",
macos::format_date(&timestamp), format_absolute_date(timestamp, reference, enhanced_date_formatting),
macos::format_time(&timestamp) format_absolute_time(timestamp)
) )
} }
} }
@ -79,14 +155,63 @@ fn format_absolute_timestamp(
} }
} }
fn format_absolute_timestamp_medium( fn format_absolute_date_medium(
timestamp: OffsetDateTime, timestamp: OffsetDateTime,
#[allow(unused_variables)] reference: OffsetDateTime, reference: OffsetDateTime,
enhanced_formatting: bool,
) -> String { ) -> String {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
if !enhanced_formatting {
return macos::format_date_medium(&timestamp);
}
let timestamp_date = timestamp.date();
let reference_date = reference.date();
if timestamp_date == reference_date {
"Today".to_string()
} else if reference_date.previous_day() == Some(timestamp_date) {
"Yesterday".to_string()
} else {
macos::format_date_medium(&timestamp) macos::format_date_medium(&timestamp)
} }
}
#[cfg(not(target_os = "macos"))]
{
// todo(linux) respect user's date/time preferences
// todo(windows) respect user's date/time preferences
let current_locale = CURRENT_LOCALE
.get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
if !enhanced_formatting {
return format_timestamp_naive_date_medium(
timestamp,
is_12_hour_time_by_locale(current_locale.as_str()),
);
}
let timestamp_date = timestamp.date();
let reference_date = reference.date();
if timestamp_date == reference_date {
"Today".to_string()
} else if reference_date.previous_day() == Some(timestamp_date) {
"Yesterday".to_string()
} else {
format_timestamp_naive_date_medium(
timestamp,
is_12_hour_time_by_locale(current_locale.as_str()),
)
}
}
}
fn format_absolute_timestamp_medium(
timestamp: OffsetDateTime,
reference: OffsetDateTime,
) -> String {
#[cfg(target_os = "macos")]
{
format_absolute_date_medium(timestamp, reference, false)
}
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
{ {
// todo(linux) respect user's date/time preferences // todo(linux) respect user's date/time preferences
@ -178,15 +303,9 @@ fn calculate_month_difference(timestamp: OffsetDateTime, reference: OffsetDateTi
/// Note: /// Note:
/// This function does not respect the user's date and time preferences. /// This function does not respect the user's date and time preferences.
/// This should only be used as a fallback mechanism when the OS time formatting fails. /// This should only be used as a fallback mechanism when the OS time formatting fails.
pub fn format_timestamp_naive( fn format_timestamp_naive_time(timestamp_local: OffsetDateTime, is_12_hour_time: bool) -> String {
timestamp_local: OffsetDateTime,
reference_local: OffsetDateTime,
is_12_hour_time: bool,
) -> String {
let timestamp_local_hour = timestamp_local.hour(); let timestamp_local_hour = timestamp_local.hour();
let timestamp_local_minute = timestamp_local.minute(); let timestamp_local_minute = timestamp_local.minute();
let reference_local_date = reference_local.date();
let timestamp_local_date = timestamp_local.date();
let (hour, meridiem) = if is_12_hour_time { let (hour, meridiem) = if is_12_hour_time {
let meridiem = if timestamp_local_hour >= 12 { let meridiem = if timestamp_local_hour >= 12 {
@ -206,38 +325,103 @@ pub fn format_timestamp_naive(
(timestamp_local_hour, None) (timestamp_local_hour, None)
}; };
let formatted_time = match meridiem { match meridiem {
Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem), Some(meridiem) => format!("{}:{:02} {}", hour, timestamp_local_minute, meridiem),
None => format!("{:02}:{:02}", hour, timestamp_local_minute), None => format!("{:02}:{:02}", hour, timestamp_local_minute),
}; }
}
let formatted_date = match meridiem { #[cfg(not(target_os = "macos"))]
Some(_) => format!( fn format_timestamp_naive_date(
timestamp_local: OffsetDateTime,
reference_local: OffsetDateTime,
is_12_hour_time: bool,
) -> String {
let reference_local_date = reference_local.date();
let timestamp_local_date = timestamp_local.date();
if timestamp_local_date == reference_local_date {
"Today".to_string()
} else if reference_local_date.previous_day() == Some(timestamp_local_date) {
"Yesterday".to_string()
} else {
match is_12_hour_time {
true => format!(
"{:02}/{:02}/{}", "{:02}/{:02}/{}",
timestamp_local_date.month() as u32, timestamp_local_date.month() as u32,
timestamp_local_date.day(), timestamp_local_date.day(),
timestamp_local_date.year() timestamp_local_date.year()
), ),
None => format!( false => format!(
"{:02}/{:02}/{}", "{:02}/{:02}/{}",
timestamp_local_date.day(), timestamp_local_date.day(),
timestamp_local_date.month() as u32, timestamp_local_date.month() as u32,
timestamp_local_date.year() timestamp_local_date.year()
), ),
}; }
}
}
#[cfg(not(target_os = "macos"))]
fn format_timestamp_naive_date_medium(
timestamp_local: OffsetDateTime,
is_12_hour_time: bool,
) -> String {
let timestamp_local_date = timestamp_local.date();
match is_12_hour_time {
true => format!(
"{:02}/{:02}/{}",
timestamp_local_date.month() as u32,
timestamp_local_date.day(),
timestamp_local_date.year()
),
false => format!(
"{:02}/{:02}/{}",
timestamp_local_date.day(),
timestamp_local_date.month() as u32,
timestamp_local_date.year()
),
}
}
pub fn format_timestamp_naive(
timestamp_local: OffsetDateTime,
reference_local: OffsetDateTime,
is_12_hour_time: bool,
) -> String {
let formatted_time = format_timestamp_naive_time(timestamp_local, is_12_hour_time);
let reference_local_date = reference_local.date();
let timestamp_local_date = timestamp_local.date();
if timestamp_local_date == reference_local_date { if timestamp_local_date == reference_local_date {
format!("Today at {}", formatted_time) format!("Today at {}", formatted_time)
} else if reference_local_date.previous_day() == Some(timestamp_local_date) { } else if reference_local_date.previous_day() == Some(timestamp_local_date) {
format!("Yesterday at {}", formatted_time) format!("Yesterday at {}", formatted_time)
} else { } else {
let formatted_date = match is_12_hour_time {
true => format!(
"{:02}/{:02}/{}",
timestamp_local_date.month() as u32,
timestamp_local_date.day(),
timestamp_local_date.year()
),
false => format!(
"{:02}/{:02}/{}",
timestamp_local_date.day(),
timestamp_local_date.month() as u32,
timestamp_local_date.year()
),
};
format!("{} {}", formatted_date, formatted_time) format!("{} {}", formatted_date, formatted_time)
} }
} }
#[cfg(not(target_os = "macos"))]
static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String { fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTime) -> String {
static CURRENT_LOCALE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
let current_locale = CURRENT_LOCALE let current_locale = CURRENT_LOCALE
.get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US"))); .get_or_init(|| sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")));
@ -245,8 +429,8 @@ fn format_timestamp_fallback(timestamp: OffsetDateTime, reference: OffsetDateTim
format_timestamp_naive(timestamp, reference, is_12_hour_time) format_timestamp_naive(timestamp, reference, is_12_hour_time)
} }
#[cfg(not(target_os = "macos"))]
/// Returns `true` if the locale is recognized as a 12-hour time locale. /// Returns `true` if the locale is recognized as a 12-hour time locale.
#[cfg(not(target_os = "macos"))]
fn is_12_hour_time_by_locale(locale: &str) -> bool { fn is_12_hour_time_by_locale(locale: &str) -> bool {
[ [
"es-MX", "es-CO", "es-SV", "es-NI", "es-MX", "es-CO", "es-SV", "es-NI",
@ -344,6 +528,131 @@ mod macos {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn test_format_date() {
let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
// Test with same date (today)
let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
assert_eq!(format_date(timestamp_today, reference, true), "Today");
// Test with previous day (yesterday)
let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
assert_eq!(
format_date(timestamp_yesterday, reference, true),
"Yesterday"
);
// Test with other date
let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
let result = format_date(timestamp_other, reference, true);
assert!(!result.is_empty());
assert_ne!(result, "Today");
assert_ne!(result, "Yesterday");
}
#[test]
fn test_format_time() {
let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
// We can't assert the exact output as it depends on the platform and locale
// But we can at least confirm it doesn't panic and returns a non-empty string
let result = format_time(timestamp);
assert!(!result.is_empty());
}
#[test]
fn test_format_date_medium() {
let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
// Test with enhanced formatting (today)
let result_enhanced = format_date_medium(timestamp, reference, true);
assert_eq!(result_enhanced, "Today");
// Test with standard formatting
let result_standard = format_date_medium(timestamp, reference, false);
assert!(!result_standard.is_empty());
// Test yesterday with enhanced formatting
let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
let result_yesterday = format_date_medium(timestamp_yesterday, reference, true);
assert_eq!(result_yesterday, "Yesterday");
// Test other date with enhanced formatting
let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
let result_other = format_date_medium(timestamp_other, reference, true);
assert!(!result_other.is_empty());
assert_ne!(result_other, "Today");
assert_ne!(result_other, "Yesterday");
}
#[test]
fn test_format_absolute_time() {
let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
// We can't assert the exact output as it depends on the platform and locale
// But we can at least confirm it doesn't panic and returns a non-empty string
let result = format_absolute_time(timestamp);
assert!(!result.is_empty());
}
#[test]
fn test_format_absolute_date() {
let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
// Test with same date (today)
let timestamp_today = create_offset_datetime(1990, 4, 12, 9, 30, 0);
assert_eq!(
format_absolute_date(timestamp_today, reference, true),
"Today"
);
// Test with previous day (yesterday)
let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
assert_eq!(
format_absolute_date(timestamp_yesterday, reference, true),
"Yesterday"
);
// Test with other date
let timestamp_other = create_offset_datetime(1990, 4, 10, 9, 30, 0);
let result = format_absolute_date(timestamp_other, reference, true);
assert!(!result.is_empty());
assert_ne!(result, "Today");
assert_ne!(result, "Yesterday");
}
#[test]
fn test_format_absolute_date_medium() {
let reference = create_offset_datetime(1990, 4, 12, 10, 30, 0);
let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
// Test with enhanced formatting (today)
let result_enhanced = format_absolute_date_medium(timestamp, reference, true);
assert_eq!(result_enhanced, "Today");
// Test with standard formatting
let result_standard = format_absolute_date_medium(timestamp, reference, false);
assert!(!result_standard.is_empty());
// Test yesterday with enhanced formatting
let timestamp_yesterday = create_offset_datetime(1990, 4, 11, 9, 30, 0);
let result_yesterday = format_absolute_date_medium(timestamp_yesterday, reference, true);
assert_eq!(result_yesterday, "Yesterday");
}
#[test]
fn test_format_timestamp_naive_time() {
let timestamp = create_offset_datetime(1990, 4, 12, 9, 30, 0);
assert_eq!(format_timestamp_naive_time(timestamp, true), "9:30 AM");
assert_eq!(format_timestamp_naive_time(timestamp, false), "09:30");
let timestamp_pm = create_offset_datetime(1990, 4, 12, 15, 45, 0);
assert_eq!(format_timestamp_naive_time(timestamp_pm, true), "3:45 PM");
assert_eq!(format_timestamp_naive_time(timestamp_pm, false), "15:45");
}
#[test] #[test]
fn test_format_24_hour_time() { fn test_format_24_hour_time() {
let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0); let reference = create_offset_datetime(1990, 4, 12, 16, 45, 0);