diff --git a/src/collab/comments.rs b/src/collab/comments.rs index 591359b..111731b 100644 --- a/src/collab/comments.rs +++ b/src/collab/comments.rs @@ -2,7 +2,7 @@ use std::rc::Rc; use gpui::{ App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, + SharedString, Styled, Window, div, prelude::*, }; use jiff::Timestamp; @@ -11,9 +11,9 @@ use super::{ reactions::{Reaction, Reactions}, }; use crate::{ - buttons::{Button, ButtonVariant, SegmentedControl}, - feedback::EmptyState, - forms::{Enter, Input, OnFlag, OnValue, Run, TextInput}, + buttons::{Button, ButtonVariant}, + forms::{Enter, Input, OnFlag, Run, TextInput}, + i18n, overlays::Popover, primitives::IconName, theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize}, @@ -39,8 +39,6 @@ pub struct Thread { } type OnReact = Rc; -type OnThreadFlag = Rc; -type OnThreadReact = Rc; /// A field to answer in and a button to send; Enter sends too, never an empty answer. #[derive(IntoElement)] @@ -86,14 +84,17 @@ impl RenderOnce for Reply { .child(Input::new(&self.field).size(ControlSize::Sm)), ) .child( - Button::new((self.id.clone(), "send"), "Reply") - .variant(ButtonVariant::Primary) - .size(ControlSize::Sm) - .disabled(empty) - .on_click(move |_, window, cx| { - log::info!("reply: sent"); - send(window, cx) - }), + Button::new( + (self.id.clone(), "send"), + i18n::text(cx, "comments.reply", &[]), + ) + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .disabled(empty) + .on_click(move |_, window, cx| { + log::info!("reply: sent"); + send(window, cx) + }), ) } } @@ -166,7 +167,15 @@ impl RenderOnce for CommentThread { let resolve = self.on_resolve.clone().map(|resolve| { Button::new( (self.id.clone(), "resolve"), - if resolved { "Reopen" } else { "Resolve" }, + i18n::text( + cx, + if resolved { + "comments.reopen" + } else { + "comments.resolve" + }, + &[], + ), ) .variant(ButtonVariant::Ghost) .size(ControlSize::Sm) @@ -343,150 +352,3 @@ impl RenderOnce for CommentMarker { .size(ControlSize::Sm) } } - -/// Every conversation on a document down a column, the open ones or the resolved: a press picks one, and the one picked takes replies. -#[derive(IntoElement)] -pub struct CommentSidebar { - id: ElementId, - threads: Vec, - active: Option, - reply: Option<(Entity, Run)>, - on_select: Option, - on_resolve: Option, - on_react: Option, -} - -impl CommentSidebar { - pub fn new(id: impl Into, threads: impl IntoIterator) -> Self { - Self { - id: id.into(), - threads: threads.into_iter().collect(), - active: None, - reply: None, - on_select: None, - on_resolve: None, - on_react: None, - } - } - - /// The thread picked, by key, and what a press on another asks. - pub fn active( - mut self, - active: Option, - on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.active = active; - self.on_select = Some(Rc::new(on_select)); - self - } - - /// Answers the picked thread in `field`. - pub fn reply( - mut self, - field: &Entity, - on_send: impl Fn(&mut Window, &mut App) + 'static, - ) -> Self { - self.reply = Some((field.clone(), Rc::new(on_send))); - self - } - - /// Gets a thread's key and true to resolve it, false to reopen. - pub fn on_resolve( - mut self, - handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_resolve = Some(Rc::new(handler)); - self - } - - /// Gets a thread's key, a comment's index and the emoji to toggle on it. - pub fn on_react( - mut self, - handler: impl Fn(&SharedString, usize, &SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_react = Some(Rc::new(handler)); - self - } -} - -impl RenderOnce for CommentSidebar { - fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let showing = window.use_keyed_state((self.id.clone(), "resolved"), cx, |_, _| false); - let resolved = *showing.read(cx); - let open = self - .threads - .iter() - .filter(|thread| !thread.resolved) - .count(); - let done = self.threads.len() - open; - let filter = SegmentedControl::new( - (self.id.clone(), "filter"), - if resolved { "resolved" } else { "open" }, - ) - .size(ControlSize::Sm) - .segment("open", format!("Open {open}"), None) - .segment("resolved", format!("Resolved {done}"), None) - .on_change(move |value, _, cx| { - showing.update(cx, |showing, cx| { - *showing = value.as_ref() == "resolved"; - cx.notify(); - }) - }); - let shown: Vec = self - .threads - .into_iter() - .filter(|thread| thread.resolved == resolved) - .collect(); - let empty = shown.is_empty().then(|| { - EmptyState::new( - (self.id.clone(), "empty"), - IconName::MessageSquare, - if resolved { - "Nothing resolved yet" - } else { - "No open comments" - }, - ) - }); - let threads = shown.into_iter().map(|thread| { - let key = thread.key.clone(); - let active = self.active.as_ref() == Some(&key); - let mut item = CommentThread::new((self.id.clone(), format!("thread-{key}")), thread) - .active(active); - if active && let Some((field, send)) = &self.reply { - let send = send.clone(); - item = item.reply(field, move |window, cx| send(window, cx)); - } - if let Some(resolve) = self.on_resolve.clone() { - let key = key.clone(); - item = item.on_resolve(move |on, window, cx| resolve(&key, on, window, cx)); - } - if let Some(react) = self.on_react.clone() { - let key = key.clone(); - item = - item.on_react(move |ix, emoji, window, cx| react(&key, ix, emoji, window, cx)); - } - let select = self.on_select.clone(); - div() - .id((self.id.clone(), format!("pick-{key}"))) - .when_some(select.filter(|_| !active), |row, select| { - row.on_click(move |_, window, cx| { - log::info!("comment sidebar: {key}"); - select(&key, window, cx) - }) - }) - .child(item) - }); - div() - .id(self.id.clone()) - .size_full() - .flex() - .flex_col() - .gap_3() - .p_3() - .overflow_y_scroll() - .child(filter) - .children(empty) - .children(threads) - } -} diff --git a/src/collab/mod.rs b/src/collab/mod.rs index ff988ee..d02e932 100644 --- a/src/collab/mod.rs +++ b/src/collab/mod.rs @@ -4,14 +4,16 @@ mod peers; mod reactions; mod remote; mod share; +mod sidebar; #[cfg(all(test, feature = "test-support"))] mod tests; mod track; pub use annotate::{AnnotatedText, Annotation}; -pub use comments::{Comment, CommentMarker, CommentSidebar, CommentThread, Reply, Thread}; +pub use comments::{Comment, CommentMarker, CommentThread, Reply, Thread}; pub use peers::{FollowMode, LiveIndicator, Peer, PresenceAvatars}; pub use reactions::{Reaction, ReactionPicker, Reactions, toggled}; pub use remote::{RemoteCursor, RemoteSelection}; pub use share::{AccessList, InviteInput, LinkAccess, Member, PermissionSelect, Role, ShareDialog}; +pub use sidebar::CommentSidebar; pub use track::{Decision, EditMode, Suggestion, SuggestionMode, TrackChanges, accept, suggested}; diff --git a/src/collab/sidebar.rs b/src/collab/sidebar.rs new file mode 100644 index 0000000..4381af8 --- /dev/null +++ b/src/collab/sidebar.rs @@ -0,0 +1,179 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use super::comments::{CommentThread, Thread}; +use crate::{ + buttons::SegmentedControl, + feedback::EmptyState, + forms::{OnValue, Run, TextInput}, + i18n, + primitives::IconName, + theme::ControlSize, +}; + +type OnThreadFlag = Rc; +type OnThreadReact = Rc; + +/// Every conversation on a document down a column, split into open and resolved when the owner resolves: a press picks one, and the one picked takes replies. +#[derive(IntoElement)] +pub struct CommentSidebar { + id: ElementId, + threads: Vec, + active: Option, + reply: Option<(Entity, Run)>, + on_select: Option, + on_resolve: Option, + on_react: Option, +} + +impl CommentSidebar { + pub fn new(id: impl Into, threads: impl IntoIterator) -> Self { + Self { + id: id.into(), + threads: threads.into_iter().collect(), + active: None, + reply: None, + on_select: None, + on_resolve: None, + on_react: None, + } + } + + /// The thread picked, by key, and what a press on another asks. + pub fn active( + mut self, + active: Option, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.active = active; + self.on_select = Some(Rc::new(on_select)); + self + } + + /// Answers the picked thread in `field`. + pub fn reply( + mut self, + field: &Entity, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.reply = Some((field.clone(), Rc::new(on_send))); + self + } + + /// Gets a thread's key and true to resolve it, false to reopen. + pub fn on_resolve( + mut self, + handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } + + /// Gets a thread's key, a comment's index and the emoji to toggle on it. + pub fn on_react( + mut self, + handler: impl Fn(&SharedString, usize, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_react = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommentSidebar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let showing = window.use_keyed_state((self.id.clone(), "resolved"), cx, |_, _| false); + // Without resolving, one list. + let resolving = self.on_resolve.is_some(); + let resolved = resolving && *showing.read(cx); + let open = self + .threads + .iter() + .filter(|thread| !thread.resolved) + .count(); + let done = self.threads.len() - open; + let filter = resolving.then(|| { + SegmentedControl::new( + (self.id.clone(), "filter"), + if resolved { "resolved" } else { "open" }, + ) + .size(ControlSize::Sm) + .segment( + "open", + i18n::text(cx, "comments.open", &[("n", &open.to_string())]), + None, + ) + .segment( + "resolved", + i18n::text(cx, "comments.resolved", &[("n", &done.to_string())]), + None, + ) + .on_change(move |value, _, cx| { + showing.update(cx, |showing, cx| { + *showing = value.as_ref() == "resolved"; + cx.notify(); + }) + }) + }); + let shown: Vec = self + .threads + .into_iter() + .filter(|thread| !resolving || thread.resolved == resolved) + .collect(); + let none = match (resolving, resolved) { + (false, _) => "comments.none", + (true, true) => "comments.none_resolved", + (true, false) => "comments.none_open", + }; + let empty = shown.is_empty().then(|| { + EmptyState::new( + (self.id.clone(), "empty"), + IconName::MessageSquare, + i18n::text(cx, none, &[]), + ) + }); + let threads = shown.into_iter().map(|thread| { + let key = thread.key.clone(); + let active = self.active.as_ref() == Some(&key); + let mut item = CommentThread::new((self.id.clone(), format!("thread-{key}")), thread) + .active(active); + if active && let Some((field, send)) = &self.reply { + let send = send.clone(); + item = item.reply(field, move |window, cx| send(window, cx)); + } + if let Some(resolve) = self.on_resolve.clone() { + let key = key.clone(); + item = item.on_resolve(move |on, window, cx| resolve(&key, on, window, cx)); + } + if let Some(react) = self.on_react.clone() { + let key = key.clone(); + item = + item.on_react(move |ix, emoji, window, cx| react(&key, ix, emoji, window, cx)); + } + let select = self.on_select.clone(); + div() + .id((self.id.clone(), format!("pick-{key}"))) + .when_some(select.filter(|_| !active), |row, select| { + row.on_click(move |_, window, cx| { + log::info!("comment sidebar: {key}"); + select(&key, window, cx) + }) + }) + .child(item) + }); + div() + .id(self.id.clone()) + .size_full() + .flex() + .flex_col() + .gap_3() + .p_3() + .overflow_y_scroll() + .children(filter) + .children(empty) + .children(threads) + } +} diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 700dc35..7160098 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -40,6 +40,27 @@ const EN: &[(&str, &str)] = &[ ("templates.list", "Templates"), ("templates.use", "Use template"), ("templates.none", "No template matches"), + ("comments.reply", "Reply"), + ("comments.resolve", "Resolve"), + ("comments.reopen", "Reopen"), + ("comments.open", "Open {n}"), + ("comments.resolved", "Resolved {n}"), + ("comments.none_open", "No open comments"), + ("comments.none_resolved", "Nothing resolved yet"), + ("comments.none", "No comments"), + ("time.now", "just now"), + ("time.ago", "{span} ago"), + ("time.in", "in {span}"), + ("time.minute.one", "{n} minute"), + ("time.minute.other", "{n} minutes"), + ("time.hour.one", "{n} hour"), + ("time.hour.other", "{n} hours"), + ("time.day.one", "{n} day"), + ("time.day.other", "{n} days"), + ("time.month.one", "{n} month"), + ("time.month.other", "{n} months"), + ("time.year.one", "{n} year"), + ("time.year.other", "{n} years"), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -78,6 +99,27 @@ const ZH_CN: &[(&str, &str)] = &[ ("templates.list", "模板"), ("templates.use", "使用模板"), ("templates.none", "没有匹配的模板"), + ("comments.reply", "回复"), + ("comments.resolve", "解决"), + ("comments.reopen", "重新打开"), + ("comments.open", "未解决 {n}"), + ("comments.resolved", "已解决 {n}"), + ("comments.none_open", "没有未解决的批注"), + ("comments.none_resolved", "还没有已解决的批注"), + ("comments.none", "没有批注"), + ("time.now", "刚刚"), + ("time.ago", "{span}前"), + ("time.in", "{span}后"), + ("time.minute.one", "{n} 分钟"), + ("time.minute.other", "{n} 分钟"), + ("time.hour.one", "{n} 小时"), + ("time.hour.other", "{n} 小时"), + ("time.day.one", "{n} 天"), + ("time.day.other", "{n} 天"), + ("time.month.one", "{n} 个月"), + ("time.month.other", "{n} 个月"), + ("time.year.one", "{n} 年"), + ("time.year.other", "{n} 年"), ]; /// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key. diff --git a/src/typography/format.rs b/src/typography/format.rs index 4c4e283..dee1d97 100644 --- a/src/typography/format.rs +++ b/src/typography/format.rs @@ -207,10 +207,23 @@ pub fn plural(count: u64, one: &str, other: &str) -> String { /// "just now", "3 minutes ago", "in 2 days". pub fn relative(then: Timestamp, now: Timestamp) -> String { + let Some((count, unit, future)) = span(then, now) else { + return "just now".to_string(); + }; + let label = plural(count, unit, &format!("{unit}s")); + if future { + format!("in {label}") + } else { + format!("{label} ago") + } +} + +/// The rounded distance: count, unit, and whether ahead; none under 45 s. +pub(crate) fn span(then: Timestamp, now: Timestamp) -> Option<(u64, &'static str, bool)> { let delta = now.as_second() - then.as_second(); let seconds = delta.unsigned_abs(); if seconds < 45 { - return "just now".to_string(); + return None; } let round = |unit: u64| (seconds + unit / 2) / unit; let (count, unit) = match seconds { @@ -225,12 +238,7 @@ pub fn relative(then: Timestamp, now: Timestamp) -> String { s if s < 548 * 86_400 => (1, "year"), _ => (round(365 * 86_400), "year"), }; - let label = plural(count, unit, &format!("{unit}s")); - if delta < 0 { - format!("in {label}") - } else { - format!("{label} ago") - } + Some((count, unit, delta < 0)) } /// The system's time zone for `user`. An unknown zone stops here rather than reading as UTC. diff --git a/src/typography/formatted.rs b/src/typography/formatted.rs index 0f03ef3..d5e4ecd 100644 --- a/src/typography/formatted.rs +++ b/src/typography/formatted.rs @@ -1,13 +1,15 @@ use std::time::Duration; -use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Task, Window, div}; +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Task, Window, div, +}; use jiff::{Timestamp, tz::TimeZone}; use super::{ format::{self, DurationStyle, Separators}, text::tabular, }; -use crate::theme::ActiveTheme; +use crate::{i18n, theme::ActiveTheme}; const TICK: Duration = Duration::from_secs(30); @@ -196,10 +198,23 @@ pub(crate) fn fresh(id: impl Into, window: &mut Window, cx: &mut App) }); } +/// `then` against `now`, in the shown locale. +pub(crate) fn relative_text(cx: &App, then: Timestamp, now: Timestamp) -> SharedString { + let Some((count, unit, future)) = format::span(then, now) else { + return i18n::text(cx, "time.now", &[]); + }; + let form = if count == 1 { "one" } else { "other" }; + let n = count.to_string(); + let span = i18n::text(cx, &format!("time.{unit}.{form}"), &[("n", &n)]); + let key = if future { "time.in" } else { "time.ago" }; + i18n::text(cx, key, &[("span", &span)]) +} + impl RenderOnce for RelativeTime { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { fresh(self.id, window, cx); - figure(format::relative(self.at, Timestamp::now())) + let text = relative_text(cx, self.at, Timestamp::now()); + figure(text.to_string()) } } @@ -239,6 +254,30 @@ impl RenderOnce for DateTimeText { }; let text = format::datetime(self.at, &zone, self.pattern) .unwrap_or_else(|error| panic!("DateTimeText pattern {:?}: {error}", self.pattern)); - figure(text) + figure(text.to_string()) + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + use jiff::Timestamp; + + use super::{format, relative_text}; + use crate::i18n::I18n; + + #[gpui::test] + fn relative_time_reads_in_the_shown_locale(cx: &mut TestAppContext) { + let now = Timestamp::from_second(1_000_000_000).unwrap(); + let at = |s: i64| Timestamp::from_second(1_000_000_000 - s).unwrap(); + for s in [10, 60, 600, -7_200, 86_400 * 3, 86_400 * 400] { + let english = cx.update(|cx| relative_text(cx, at(s), now)); + assert_eq!(english.as_ref(), format::relative(at(s), now)); + } + cx.update(|cx| cx.set_global(I18n::new("zh-CN"))); + let chinese = |s: i64| cx.update(|cx| relative_text(cx, at(s), now).to_string()); + assert_eq!(chinese(10), "刚刚"); + assert_eq!(chinese(600), "10 分钟前"); + assert_eq!(chinese(-7_200), "2 小时后"); } }