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
This commit is contained in:
34 files changed
+3297
-86
No files matched your search
@@ -0,0 +1,456 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user