editor: Make blame and inline blame work for multibuffers (#37366)
Release Notes: - Added blame view and inline blame support for multi buffer editors --------- Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
co-authored by
Kirill Bulatov
parent
92283285ae
commit
c1ca7303a8
+229
-137
@@ -10,16 +10,18 @@ use gpui::{
|
||||
AnyElement, App, AppContext as _, Context, Entity, Hsla, ScrollHandle, Subscription, Task,
|
||||
TextStyle, WeakEntity, Window,
|
||||
};
|
||||
use language::{Bias, Buffer, BufferSnapshot, Edit};
|
||||
use itertools::Itertools;
|
||||
use language::{Bias, BufferSnapshot, Edit};
|
||||
use markdown::Markdown;
|
||||
use multi_buffer::RowInfo;
|
||||
use multi_buffer::{MultiBuffer, RowInfo};
|
||||
use project::{
|
||||
Project, ProjectItem,
|
||||
Project, ProjectItem as _,
|
||||
git_store::{GitStoreEvent, Repository, RepositoryEvent},
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use sum_tree::SumTree;
|
||||
use text::BufferId;
|
||||
use workspace::Workspace;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -63,16 +65,19 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Entity<Project>,
|
||||
buffer: Entity<Buffer>,
|
||||
struct GitBlameBuffer {
|
||||
entries: SumTree<GitBlameEntry>,
|
||||
commit_details: HashMap<Oid, ParsedCommitMessage>,
|
||||
buffer_snapshot: BufferSnapshot,
|
||||
buffer_edits: text::Subscription,
|
||||
commit_details: HashMap<Oid, ParsedCommitMessage>,
|
||||
}
|
||||
|
||||
pub struct GitBlame {
|
||||
project: Entity<Project>,
|
||||
multi_buffer: WeakEntity<MultiBuffer>,
|
||||
buffers: HashMap<BufferId, GitBlameBuffer>,
|
||||
task: Task<Result<()>>,
|
||||
focused: bool,
|
||||
generated: bool,
|
||||
changed_while_blurred: bool,
|
||||
user_triggered: bool,
|
||||
regenerate_on_edit_task: Task<Result<()>>,
|
||||
@@ -184,44 +189,44 @@ impl gpui::Global for GlobalBlameRenderer {}
|
||||
|
||||
impl GitBlame {
|
||||
pub fn new(
|
||||
buffer: Entity<Buffer>,
|
||||
multi_buffer: Entity<MultiBuffer>,
|
||||
project: Entity<Project>,
|
||||
user_triggered: bool,
|
||||
focused: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let entries = SumTree::from_item(
|
||||
GitBlameEntry {
|
||||
rows: buffer.read(cx).max_point().row + 1,
|
||||
blame: None,
|
||||
let multi_buffer_subscription = cx.subscribe(
|
||||
&multi_buffer,
|
||||
|git_blame, multi_buffer, event, cx| match event {
|
||||
multi_buffer::Event::DirtyChanged => {
|
||||
if !multi_buffer.read(cx).is_dirty(cx) {
|
||||
git_blame.generate(cx);
|
||||
}
|
||||
}
|
||||
multi_buffer::Event::ExcerptsAdded { .. }
|
||||
| multi_buffer::Event::ExcerptsEdited { .. } => git_blame.regenerate_on_edit(cx),
|
||||
_ => {}
|
||||
},
|
||||
&(),
|
||||
);
|
||||
|
||||
let buffer_subscriptions = cx.subscribe(&buffer, |this, buffer, event, cx| match event {
|
||||
language::BufferEvent::DirtyChanged => {
|
||||
if !buffer.read(cx).is_dirty() {
|
||||
this.generate(cx);
|
||||
}
|
||||
}
|
||||
language::BufferEvent::Edited => {
|
||||
this.regenerate_on_edit(cx);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let project_subscription = cx.subscribe(&project, {
|
||||
let buffer = buffer.clone();
|
||||
let multi_buffer = multi_buffer.downgrade();
|
||||
|
||||
move |this, _, event, cx| {
|
||||
move |git_blame, _, event, cx| {
|
||||
if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
|
||||
let project_entry_id = buffer.read(cx).entry_id(cx);
|
||||
let Some(multi_buffer) = multi_buffer.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let project_entry_id = multi_buffer
|
||||
.read(cx)
|
||||
.as_singleton()
|
||||
.and_then(|it| it.read(cx).entry_id(cx));
|
||||
if updated
|
||||
.iter()
|
||||
.any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
|
||||
{
|
||||
log::debug!("Updated buffers. Regenerating blame data...",);
|
||||
this.generate(cx);
|
||||
git_blame.generate(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,24 +244,17 @@ impl GitBlame {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let buffer_snapshot = buffer.read(cx).snapshot();
|
||||
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
|
||||
let mut this = Self {
|
||||
project,
|
||||
buffer,
|
||||
buffer_snapshot,
|
||||
entries,
|
||||
buffer_edits,
|
||||
multi_buffer: multi_buffer.downgrade(),
|
||||
buffers: HashMap::default(),
|
||||
user_triggered,
|
||||
focused,
|
||||
changed_while_blurred: false,
|
||||
commit_details: HashMap::default(),
|
||||
task: Task::ready(Ok(())),
|
||||
generated: false,
|
||||
regenerate_on_edit_task: Task::ready(Ok(())),
|
||||
_regenerate_subscriptions: vec![
|
||||
buffer_subscriptions,
|
||||
multi_buffer_subscription,
|
||||
project_subscription,
|
||||
git_store_subscription,
|
||||
],
|
||||
@@ -265,56 +263,63 @@ impl GitBlame {
|
||||
this
|
||||
}
|
||||
|
||||
pub fn repository(&self, cx: &App) -> Option<Entity<Repository>> {
|
||||
pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
|
||||
self.project
|
||||
.read(cx)
|
||||
.git_store()
|
||||
.read(cx)
|
||||
.repository_and_path_for_buffer_id(self.buffer.read(cx).remote_id(), cx)
|
||||
.repository_and_path_for_buffer_id(id, cx)
|
||||
.map(|(repo, _)| repo)
|
||||
}
|
||||
|
||||
pub fn has_generated_entries(&self) -> bool {
|
||||
self.generated
|
||||
!self.buffers.is_empty()
|
||||
}
|
||||
|
||||
pub fn details_for_entry(&self, entry: &BlameEntry) -> Option<ParsedCommitMessage> {
|
||||
self.commit_details.get(&entry.sha).cloned()
|
||||
pub fn details_for_entry(
|
||||
&self,
|
||||
buffer: BufferId,
|
||||
entry: &BlameEntry,
|
||||
) -> Option<ParsedCommitMessage> {
|
||||
self.buffers
|
||||
.get(&buffer)?
|
||||
.commit_details
|
||||
.get(&entry.sha)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn blame_for_rows<'a>(
|
||||
&'a mut self,
|
||||
rows: &'a [RowInfo],
|
||||
cx: &App,
|
||||
) -> impl 'a + Iterator<Item = Option<BlameEntry>> {
|
||||
self.sync(cx);
|
||||
|
||||
let buffer_id = self.buffer_snapshot.remote_id();
|
||||
let mut cursor = self.entries.cursor::<u32>(&());
|
||||
cx: &'a mut App,
|
||||
) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
|
||||
rows.iter().map(move |info| {
|
||||
let row = info
|
||||
.buffer_row
|
||||
.filter(|_| info.buffer_id == Some(buffer_id))?;
|
||||
cursor.seek_forward(&row, Bias::Right);
|
||||
cursor.item()?.blame.clone()
|
||||
let buffer_id = info.buffer_id?;
|
||||
self.sync(cx, buffer_id);
|
||||
|
||||
let buffer_row = info.buffer_row?;
|
||||
let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(&());
|
||||
cursor.seek_forward(&buffer_row, Bias::Right);
|
||||
Some((buffer_id, cursor.item()?.blame.clone()?))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn max_author_length(&mut self, cx: &App) -> usize {
|
||||
self.sync(cx);
|
||||
|
||||
pub fn max_author_length(&mut self, cx: &mut App) -> usize {
|
||||
let mut max_author_length = 0;
|
||||
self.sync_all(cx);
|
||||
|
||||
for entry in self.entries.iter() {
|
||||
let author_len = entry
|
||||
.blame
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.author.as_ref())
|
||||
.map(|author| author.len());
|
||||
if let Some(author_len) = author_len
|
||||
&& author_len > max_author_length
|
||||
{
|
||||
max_author_length = author_len;
|
||||
for buffer in self.buffers.values() {
|
||||
for entry in buffer.entries.iter() {
|
||||
let author_len = entry
|
||||
.blame
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.author.as_ref())
|
||||
.map(|author| author.len());
|
||||
if let Some(author_len) = author_len
|
||||
&& author_len > max_author_length
|
||||
{
|
||||
max_author_length = author_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,22 +341,48 @@ impl GitBlame {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &App) {
|
||||
let edits = self.buffer_edits.consume();
|
||||
let new_snapshot = self.buffer.read(cx).snapshot();
|
||||
fn sync_all(&mut self, cx: &mut App) {
|
||||
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
|
||||
return;
|
||||
};
|
||||
multi_buffer
|
||||
.read(cx)
|
||||
.excerpt_buffer_ids()
|
||||
.into_iter()
|
||||
.for_each(|id| self.sync(cx, id));
|
||||
}
|
||||
|
||||
fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
|
||||
let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(buffer) = self
|
||||
.multi_buffer
|
||||
.upgrade()
|
||||
.and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let edits = blame_buffer.buffer_edits.consume();
|
||||
let new_snapshot = buffer.read(cx).snapshot();
|
||||
|
||||
let mut row_edits = edits
|
||||
.into_iter()
|
||||
.map(|edit| {
|
||||
let old_point_range = self.buffer_snapshot.offset_to_point(edit.old.start)
|
||||
..self.buffer_snapshot.offset_to_point(edit.old.end);
|
||||
let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
|
||||
..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
|
||||
let new_point_range = new_snapshot.offset_to_point(edit.new.start)
|
||||
..new_snapshot.offset_to_point(edit.new.end);
|
||||
|
||||
if old_point_range.start.column
|
||||
== self.buffer_snapshot.line_len(old_point_range.start.row)
|
||||
== blame_buffer
|
||||
.buffer_snapshot
|
||||
.line_len(old_point_range.start.row)
|
||||
&& (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
|
||||
|| self.buffer_snapshot.line_len(old_point_range.end.row) == 0)
|
||||
|| blame_buffer
|
||||
.buffer_snapshot
|
||||
.line_len(old_point_range.end.row)
|
||||
== 0)
|
||||
{
|
||||
Edit {
|
||||
old: old_point_range.start.row + 1..old_point_range.end.row + 1,
|
||||
@@ -375,7 +406,7 @@ impl GitBlame {
|
||||
.peekable();
|
||||
|
||||
let mut new_entries = SumTree::default();
|
||||
let mut cursor = self.entries.cursor::<u32>(&());
|
||||
let mut cursor = blame_buffer.entries.cursor::<u32>(&());
|
||||
|
||||
while let Some(mut edit) = row_edits.next() {
|
||||
while let Some(next_edit) = row_edits.peek() {
|
||||
@@ -433,17 +464,28 @@ impl GitBlame {
|
||||
new_entries.append(cursor.suffix(), &());
|
||||
drop(cursor);
|
||||
|
||||
self.buffer_snapshot = new_snapshot;
|
||||
self.entries = new_entries;
|
||||
blame_buffer.buffer_snapshot = new_snapshot;
|
||||
blame_buffer.entries = new_entries;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn check_invariants(&mut self, cx: &mut Context<Self>) {
|
||||
self.sync(cx);
|
||||
assert_eq!(
|
||||
self.entries.summary().rows,
|
||||
self.buffer.read(cx).max_point().row + 1
|
||||
);
|
||||
self.sync_all(cx);
|
||||
for (&id, buffer) in &self.buffers {
|
||||
assert_eq!(
|
||||
buffer.entries.summary().rows,
|
||||
self.multi_buffer
|
||||
.upgrade()
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.buffer(id)
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.max_point()
|
||||
.row
|
||||
+ 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn generate(&mut self, cx: &mut Context<Self>) {
|
||||
@@ -451,62 +493,105 @@ impl GitBlame {
|
||||
self.changed_while_blurred = true;
|
||||
return;
|
||||
}
|
||||
let buffer_edits = self.buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
let snapshot = self.buffer.read(cx).snapshot();
|
||||
let blame = self.project.update(cx, |project, cx| {
|
||||
project.blame_buffer(&self.buffer, None, cx)
|
||||
let Some(multi_buffer) = self.multi_buffer.upgrade() else {
|
||||
return Vec::new();
|
||||
};
|
||||
multi_buffer
|
||||
.read(cx)
|
||||
.all_buffer_ids()
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
let buffer = multi_buffer.read(cx).buffer(id)?;
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
|
||||
|
||||
let blame_buffer = project.blame_buffer(&buffer, None, cx);
|
||||
Some((id, snapshot, buffer_edits, blame_buffer))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let provider_registry = GitHostingProviderRegistry::default_global(cx);
|
||||
|
||||
self.task = cx.spawn(async move |this, cx| {
|
||||
let result = cx
|
||||
let (result, errors) = cx
|
||||
.background_spawn({
|
||||
let snapshot = snapshot.clone();
|
||||
async move {
|
||||
let Some(Blame {
|
||||
entries,
|
||||
messages,
|
||||
remote_url,
|
||||
}) = blame.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut res = vec![];
|
||||
let mut errors = vec![];
|
||||
for (id, snapshot, buffer_edits, blame) in blame {
|
||||
match blame.await {
|
||||
Ok(Some(Blame {
|
||||
entries,
|
||||
messages,
|
||||
remote_url,
|
||||
})) => {
|
||||
let entries = build_blame_entry_sum_tree(
|
||||
entries,
|
||||
snapshot.max_point().row,
|
||||
);
|
||||
let commit_details = parse_commit_messages(
|
||||
messages,
|
||||
remote_url,
|
||||
provider_registry.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let entries = build_blame_entry_sum_tree(entries, snapshot.max_point().row);
|
||||
let commit_details =
|
||||
parse_commit_messages(messages, remote_url, provider_registry).await;
|
||||
|
||||
anyhow::Ok(Some((entries, commit_details)))
|
||||
res.push((
|
||||
id,
|
||||
snapshot,
|
||||
buffer_edits,
|
||||
Some(entries),
|
||||
commit_details,
|
||||
));
|
||||
}
|
||||
Ok(None) => {
|
||||
res.push((id, snapshot, buffer_edits, None, Default::default()))
|
||||
}
|
||||
Err(e) => errors.push(e),
|
||||
}
|
||||
}
|
||||
(res, errors)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
this.update(cx, |this, cx| match result {
|
||||
Ok(None) => {
|
||||
// Nothing to do, e.g. no repository found
|
||||
this.update(cx, |this, cx| {
|
||||
this.buffers.clear();
|
||||
for (id, snapshot, buffer_edits, entries, commit_details) in result {
|
||||
let Some(entries) = entries else {
|
||||
continue;
|
||||
};
|
||||
this.buffers.insert(
|
||||
id,
|
||||
GitBlameBuffer {
|
||||
buffer_edits,
|
||||
buffer_snapshot: snapshot,
|
||||
entries,
|
||||
commit_details,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Some((entries, commit_details))) => {
|
||||
this.buffer_edits = buffer_edits;
|
||||
this.buffer_snapshot = snapshot;
|
||||
this.entries = entries;
|
||||
this.commit_details = commit_details;
|
||||
this.generated = true;
|
||||
cx.notify();
|
||||
cx.notify();
|
||||
if !errors.is_empty() {
|
||||
this.project.update(cx, |_, cx| {
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {errors:?}");
|
||||
let notification = errors
|
||||
.into_iter()
|
||||
.format_with(",", |e, f| f(&format_args!("{:#}", e)))
|
||||
.to_string();
|
||||
cx.emit(project::Event::Toast {
|
||||
notification_id: "git-blame".into(),
|
||||
message: notification,
|
||||
});
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
log::debug!("failed to get git blame data: {errors:?}");
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(error) => this.project.update(cx, |_, cx| {
|
||||
if this.user_triggered {
|
||||
log::error!("failed to get git blame data: {error:?}");
|
||||
let notification = format!("{:#}", error).trim().to_string();
|
||||
cx.emit(project::Event::Toast {
|
||||
notification_id: "git-blame".into(),
|
||||
message: notification,
|
||||
});
|
||||
} else {
|
||||
// If we weren't triggered by a user, we just log errors in the background, instead of sending
|
||||
// notifications.
|
||||
log::debug!("failed to get git blame data: {error:?}");
|
||||
}
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -520,7 +605,7 @@ impl GitBlame {
|
||||
this.update(cx, |this, cx| {
|
||||
this.generate(cx);
|
||||
})
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +744,9 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
expected
|
||||
.into_iter()
|
||||
.map(|it| Some((buffer_id, it?)))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -705,6 +793,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
|
||||
|
||||
@@ -785,6 +874,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -806,14 +896,14 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Some(blame_entry("1b1b1b", 0..1)),
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some((buffer_id, blame_entry("1b1b1b", 0..1))),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
|
||||
None,
|
||||
None,
|
||||
Some(blame_entry("3a3a3a", 5..6)),
|
||||
Some(blame_entry("0d0d0d", 6..7)),
|
||||
Some(blame_entry("3a3a3a", 7..8)),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 5..6))),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 6..7))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 7..8))),
|
||||
]
|
||||
);
|
||||
// Subset of lines
|
||||
@@ -831,8 +921,8 @@ mod tests {
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
Some(blame_entry("0d0d0d", 1..2)),
|
||||
Some(blame_entry("3a3a3a", 2..3)),
|
||||
Some((buffer_id, blame_entry("0d0d0d", 1..2))),
|
||||
Some((buffer_id, blame_entry("3a3a3a", 2..3))),
|
||||
None
|
||||
]
|
||||
);
|
||||
@@ -852,7 +942,7 @@ mod tests {
|
||||
cx
|
||||
)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some(blame_entry("0d0d0d", 1..2)), None, None]
|
||||
vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -895,6 +985,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
|
||||
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
|
||||
@@ -1061,8 +1152,9 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
|
||||
|
||||
let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
|
||||
let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
|
||||
cx.executor().run_until_parked();
|
||||
git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user