Files
Ely-GPUI-Components/src/collab/track.rs
T
ZacharyZhang-NY 153cf95b99 T22: Collaboration
- presence: avatars ringed in each peer's color with where they are, press to
  follow; a breathing live dot; follow mode frames the view and stops on Escape
- remote cursors and selections painted over a field from its own layout
- comment threads with replies, reactions and resolve; a marker that opens a
  thread in a bubble; a sidebar of open and resolved threads
- annotations washed in their authors' colors, notes on hover
- suggestion mode: edits become suggestions word by word; track changes shows
  them struck and underlined, to accept or reject
- sharing: invite by email with checked chips, roles, the access list and who
  the link lets in, in one dialog
- primitives::hold_focus gives a mode its own focus, shared with ZenMode
- AvatarGroup's tuck and count shared; Avatar::ring_color; TagInput::check;
  TextInput::bounds_for_range; Popover::size
- icons: smile-plus, user-plus, message-square-diff
- gallery: the Collaboration page with scripted states
2026-09-26 05:10:37 -04:00

457 lines
16 KiB
Rust

use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce,
SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*,
};
use similar::{ChangeTag, TextDiff};
use super::peers::Peer;
use crate::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
documents::Change,
primitives::IconName,
theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize},
typography::format::plural,
};
/// How strongly an author's color washes the words they would add.
const WASH: f32 = 0.14;
/// How someone works on a shared document: changing it, suggesting changes for others to take, or only reading.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditMode {
Editing,
Suggesting,
Viewing,
}
const MODES: [(EditMode, &str, &str, IconName); 3] = [
(EditMode::Editing, "editing", "Editing", IconName::Pencil),
(
EditMode::Suggesting,
"suggesting",
"Suggesting",
IconName::MessageSquareDiff,
),
(EditMode::Viewing, "viewing", "Viewing", IconName::Eye),
];
type OnMode = Rc<dyn Fn(EditMode, &mut Window, &mut App)>;
/// Picks how you work on a shared document: edit it, suggest changes for others to take, or only read.
#[derive(IntoElement)]
pub struct SuggestionMode {
id: ElementId,
mode: EditMode,
on_change: OnMode,
}
impl SuggestionMode {
pub fn new(
id: impl Into<ElementId>,
mode: EditMode,
on_change: impl Fn(EditMode, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
mode,
on_change: Rc::new(on_change),
}
}
}
impl RenderOnce for SuggestionMode {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
let on_change = self.on_change;
let (_, value, _, _) = MODES
.into_iter()
.find(|(mode, ..)| *mode == self.mode)
.expect("every mode is listed");
MODES
.into_iter()
.fold(
SegmentedControl::new(self.id, value).size(ControlSize::Sm),
|control, (_, value, label, icon)| control.segment(value, label, Some(icon)),
)
.on_change(move |value, window, cx| {
let (mode, ..) = MODES
.into_iter()
.find(|(_, named, ..)| *named == value.as_ref())
.expect("a listed mode");
log::info!("suggestion mode: {mode:?}");
on_change(mode, window, cx)
})
}
}
/// A change someone suggests: who, the stretch of the text it replaces, empty to insert, and what goes there, empty to delete.
#[derive(Clone, Debug, PartialEq)]
pub struct Suggestion {
pub author: Peer,
pub range: Range<usize>,
pub text: SharedString,
}
/// What a reviewer decides: take or drop one suggestion, or all of them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Decision {
Accept(usize),
Reject(usize),
AcceptAll,
RejectAll,
}
/// The text with every suggestion shown in place: what each removes kept and marked, what it adds after it, by suggestion.
pub(crate) fn tracked(
base: &str,
suggestions: &[Suggestion],
) -> (String, Vec<(Range<usize>, usize, Change)>) {
let mut text = String::new();
let mut marks = Vec::new();
let mut at = 0;
for (ix, suggestion) in suggestions.iter().enumerate() {
text.push_str(&base[at..suggestion.range.start]);
for (part, change) in [
(&base[suggestion.range.clone()], Change::Removed),
(suggestion.text.as_ref(), Change::Added),
] {
if !part.is_empty() {
let start = text.len();
text.push_str(part);
marks.push((start..text.len(), ix, change));
}
}
at = suggestion.range.end;
}
text.push_str(&base[at..]);
(text, marks)
}
/// The changes that turn `base` into `edited`, word by word, as `author`'s suggestions.
pub fn suggested(base: &str, edited: &str, author: &Peer) -> Vec<Suggestion> {
let diff = TextDiff::configure().diff_unicode_words(base, edited);
let mut found = Vec::new();
let mut open: Option<Suggestion> = None;
let mut at = 0;
for change in diff.iter_all_changes() {
let value = change.value();
if change.tag() == ChangeTag::Equal {
found.extend(open.take());
at += value.len();
continue;
}
let suggestion = open.get_or_insert_with(|| Suggestion {
author: author.clone(),
range: at..at,
text: SharedString::default(),
});
if change.tag() == ChangeTag::Delete {
at += value.len();
suggestion.range.end = at;
} else {
suggestion.text = format!("{}{value}", suggestion.text).into();
}
}
found.extend(open);
found
}
/// `base` with suggestion `ix` taken, and the others moved to match.
pub fn accept(base: &str, suggestions: &[Suggestion], ix: usize) -> (String, Vec<Suggestion>) {
let taken = &suggestions[ix];
let text = format!(
"{}{}{}",
&base[..taken.range.start],
taken.text,
&base[taken.range.end..]
);
let shift = taken.text.len() as isize - taken.range.len() as isize;
let rest = suggestions
.iter()
.enumerate()
.filter(|(at, _)| *at != ix)
.map(|(at, suggestion)| {
let mut suggestion = suggestion.clone();
if at > ix {
suggestion.range = suggestion.range.start.saturating_add_signed(shift)
..suggestion.range.end.saturating_add_signed(shift);
}
suggestion
})
.collect();
(text, rest)
}
/// What a suggestion does, in a line: replace, add or delete, with the words.
fn described(base: &str, suggestion: &Suggestion) -> String {
let (removed, added) = (
base[suggestion.range.clone()].trim(),
suggestion.text.trim(),
);
match (removed.is_empty(), added.is_empty()) {
(true, true) => "Change the spacing".to_string(),
(true, false) => format!("Add “{added}”"),
(false, true) => format!("Delete “{removed}”"),
(false, false) => format!("Replace “{removed}” with “{added}”"),
}
}
type OnDecide = Rc<dyn Fn(Decision, &mut Window, &mut App)>;
/// Suggested changes in place: removed words struck and added ones underlined, each in its author's color, then each change with who made it, to accept or reject, one by one or all at once.
#[derive(IntoElement)]
pub struct TrackChanges {
id: ElementId,
base: SharedString,
suggestions: Vec<Suggestion>,
on_decide: Option<OnDecide>,
}
impl TrackChanges {
/// `suggestions` in order, none overlapping, their ranges in `base`.
pub fn new(
id: impl Into<ElementId>,
base: impl Into<SharedString>,
suggestions: impl IntoIterator<Item = Suggestion>,
) -> Self {
let base = base.into();
let suggestions: Vec<Suggestion> = suggestions.into_iter().collect();
for suggestion in &suggestions {
let range = &suggestion.range;
assert!(
range.end <= base.len()
&& base.is_char_boundary(range.start)
&& base.is_char_boundary(range.end),
"suggestion {range:?} lies outside the text"
);
assert!(
!(range.is_empty() && suggestion.text.is_empty()),
"suggestion at {range:?} changes nothing"
);
}
assert!(
suggestions
.windows(2)
.all(|pair| pair[0].range.end <= pair[1].range.start),
"suggestions come in order without overlapping"
);
Self {
id: id.into(),
base,
suggestions,
on_decide: None,
}
}
pub fn on_decide(
mut self,
handler: impl Fn(Decision, &mut Window, &mut App) + 'static,
) -> Self {
self.on_decide = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TrackChanges {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors.clone();
let thickness = theme.underline_thickness();
let (text, marks) = tracked(&self.base, &self.suggestions);
let styles: Vec<(Range<usize>, HighlightStyle)> = marks
.into_iter()
.map(|(range, ix, change)| {
let color = self.suggestions[ix].author.color(cx);
let style = match change {
Change::Removed => HighlightStyle {
color: Some(color),
strikethrough: Some(StrikethroughStyle {
thickness,
color: Some(color),
}),
..HighlightStyle::default()
},
Change::Added => HighlightStyle {
background_color: Some(color.opacity(WASH)),
underline: Some(UnderlineStyle {
thickness,
color: Some(color),
wavy: false,
}),
..HighlightStyle::default()
},
};
(range, style)
})
.collect();
let decide = |decision: Decision| {
let on_decide = self.on_decide.clone();
move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| {
log::info!("track changes: {decision:?}");
if let Some(on_decide) = &on_decide {
on_decide(decision, window, cx);
}
}
};
let pending = !self.suggestions.is_empty() && self.on_decide.is_some();
let rows = self.suggestions.iter().enumerate().map(|(ix, suggestion)| {
let id = |what: &str| (self.id.clone(), format!("{what}-{ix}"));
div()
.flex()
.items_center()
.gap_2()
.py_1p5()
.child(suggestion.author.avatar(id("author"), AvatarSize::Xs))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.child(
div()
.font_weight(FontWeight::MEDIUM)
.child(suggestion.author.name.clone()),
)
.child(
div()
.text_color(colors.fg_muted)
.child(described(&self.base, suggestion)),
),
)
.when(self.on_decide.is_some(), |row| {
row.child(
IconButton::new(id("accept"), IconName::Check)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Accept")
.on_click(decide(Decision::Accept(ix))),
)
.child(
IconButton::new(id("reject"), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Reject")
.on_click(decide(Decision::Reject(ix))),
)
})
});
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(TextSize::Sm))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(div().flex_1().text_color(colors.fg_muted).child(plural(
self.suggestions.len() as u64,
"suggestion",
"suggestions",
)))
.when(pending, |bar| {
bar.child(
Button::new((self.id.clone(), "reject-all"), "Reject all")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(decide(Decision::RejectAll)),
)
.child(
Button::new((self.id.clone(), "accept-all"), "Accept all")
.size(ControlSize::Sm)
.on_click(decide(Decision::AcceptAll)),
)
}),
)
.child(
div()
.p_3()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Base))
.child(StyledText::new(text).with_highlights(styles)),
)
.child(div().flex().flex_col().children(rows))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn suggestion(range: Range<usize>, text: &str) -> Suggestion {
Suggestion {
author: Peer::new("ada", "Ada", 0),
range,
text: text.to_string().into(),
}
}
#[test]
fn tracked_text_keeps_both_sides_of_each_change() {
let base = "A lift blends color toward white.";
let (text, marks) = tracked(
base,
&[
suggestion(2..6, "tint"),
suggestion(14..14, "every "),
suggestion(19..26, ""),
],
);
assert_eq!(text, "A lifttint blends every color toward white.");
let shown: Vec<(&str, usize, Change)> = marks
.iter()
.map(|(range, ix, change)| (&text[range.clone()], *ix, *change))
.collect();
assert_eq!(
shown,
[
("lift", 0, Change::Removed),
("tint", 0, Change::Added),
("every ", 1, Change::Added),
(" toward", 2, Change::Removed),
]
);
}
#[test]
fn edits_become_suggestions_word_by_word() {
let found = suggested(
"A lift blends color toward white.",
"A tint blends every color toward white.",
&Peer::new("ada", "Ada", 0),
);
assert_eq!(
found,
[suggestion(2..6, "tint"), suggestion(14..14, "every ")]
);
let (text, rest) = accept("A lift blends color toward white.", &found, 0);
assert_eq!(
accept(&text, &rest, 0).0,
"A tint blends every color toward white."
);
}
#[test]
fn accepting_one_moves_the_rest() {
let base = "A lift blends color.";
let all = [suggestion(2..6, "tint"), suggestion(14..14, "every ")];
let (text, rest) = accept(base, &all, 0);
assert_eq!(text, "A tint blends color.");
assert_eq!(
rest,
[suggestion(14..14, "every ")],
"same length, same place"
);
let (text, rest) = accept(base, &[suggestion(2..6, "shade of"), all[1].clone()], 0);
assert_eq!(text, "A shade of blends color.");
let (text, _) = accept(&text, &rest, 0);
assert_eq!(text, "A shade of blends every color.");
}
}