mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-08 07:35:01 +03:00
vim: Support gn command and remap gn to gl (#9982)
Release Notes: - Resolves #4273 @algora-pbc /claim #4273 This is a work-in-progress. The process for `gn` command is: - maintain updated vim.workspace_state.search.initial_query - modify editor.select_next_state with vim.workspace_state.search.initial_query - use editor.select_next() - merge selections - set editor.select_next_state to previous state To make this possible, several private members and editor structures are made public. `gN` is not yet implemented and the cursor still does not jump to the next selection in the first use. Maybe there is an better way to do this? --------- Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
This commit is contained in:
parent
fe4b345603
commit
bf9b443b4a
@ -137,8 +137,10 @@
|
||||
"g d": "editor::GoToDefinition",
|
||||
"g shift-d": "editor::GoToTypeDefinition",
|
||||
"g x": "editor::OpenUrl",
|
||||
"g n": "vim::SelectNext",
|
||||
"g shift-n": "vim::SelectPrevious",
|
||||
"g n": "vim::SelectNextMatch",
|
||||
"g shift-n": "vim::SelectPreviousMatch",
|
||||
"g l": "vim::SelectNext",
|
||||
"g shift-l": "vim::SelectPrevious",
|
||||
"g >": [
|
||||
"editor::SelectNext",
|
||||
{
|
||||
|
@ -963,7 +963,7 @@ impl BufferSearchBar {
|
||||
done_rx
|
||||
}
|
||||
|
||||
fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
|
||||
pub fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
|
||||
let new_index = self
|
||||
.active_searchable_item
|
||||
.as_ref()
|
||||
|
@ -39,6 +39,7 @@ ui.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
schemars.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
command_palette.workspace = true
|
||||
|
@ -1,4 +1,3 @@
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
|
||||
use collections::HashMap;
|
||||
@ -10,10 +9,13 @@ use editor::{
|
||||
};
|
||||
use gpui::{actions, ViewContext, WindowContext};
|
||||
use language::{Point, Selection, SelectionGoal};
|
||||
use workspace::Workspace;
|
||||
use search::BufferSearchBar;
|
||||
use util::ResultExt;
|
||||
use workspace::{searchable::Direction, Workspace};
|
||||
|
||||
use crate::{
|
||||
motion::{start_of_line, Motion},
|
||||
normal::substitute::substitute,
|
||||
object::Object,
|
||||
state::{Mode, Operator},
|
||||
utils::{copy_selections_content, yank_selections_content},
|
||||
@ -31,6 +33,8 @@ actions!(
|
||||
OtherEnd,
|
||||
SelectNext,
|
||||
SelectPrevious,
|
||||
SelectNextMatch,
|
||||
SelectPreviousMatch,
|
||||
]
|
||||
);
|
||||
|
||||
@ -47,14 +51,29 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
|
||||
},
|
||||
);
|
||||
workspace.register_action(other_end);
|
||||
workspace.register_action(delete);
|
||||
workspace.register_action(yank);
|
||||
|
||||
workspace.register_action(|workspace, action, cx| {
|
||||
select_next(workspace, action, cx).ok();
|
||||
workspace.register_action(|_, _: &VisualDelete, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.record_current_action(cx);
|
||||
delete(vim, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, action, cx| {
|
||||
select_previous(workspace, action, cx).ok();
|
||||
workspace.register_action(|_, _: &VisualYank, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
yank(vim, cx);
|
||||
});
|
||||
});
|
||||
|
||||
workspace.register_action(select_next);
|
||||
workspace.register_action(select_previous);
|
||||
workspace.register_action(|workspace, _: &SelectNextMatch, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
select_match(workspace, vim, Direction::Next, cx);
|
||||
});
|
||||
});
|
||||
workspace.register_action(|workspace, _: &SelectPreviousMatch, cx| {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
select_match(workspace, vim, Direction::Prev, cx);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -333,70 +352,65 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace
|
||||
});
|
||||
}
|
||||
|
||||
pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.record_current_action(cx);
|
||||
vim.update_active_editor(cx, |vim, editor, cx| {
|
||||
let mut original_columns: HashMap<_, _> = Default::default();
|
||||
let line_mode = editor.selections.line_mode;
|
||||
pub fn delete(vim: &mut Vim, cx: &mut WindowContext) {
|
||||
vim.update_active_editor(cx, |vim, editor, cx| {
|
||||
let mut original_columns: HashMap<_, _> = Default::default();
|
||||
let line_mode = editor.selections.line_mode;
|
||||
|
||||
editor.transact(cx, |editor, cx| {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if line_mode {
|
||||
let mut position = selection.head();
|
||||
if !selection.reversed {
|
||||
position = movement::left(map, position);
|
||||
}
|
||||
original_columns.insert(selection.id, position.to_point(map).column);
|
||||
}
|
||||
selection.goal = SelectionGoal::None;
|
||||
});
|
||||
});
|
||||
copy_selections_content(vim, editor, line_mode, cx);
|
||||
editor.insert("", cx);
|
||||
|
||||
// Fixup cursor position after the deletion
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let mut cursor = selection.head().to_point(map);
|
||||
|
||||
if let Some(column) = original_columns.get(&selection.id) {
|
||||
cursor.column = *column
|
||||
}
|
||||
let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
|
||||
selection.collapse_to(cursor, selection.goal)
|
||||
});
|
||||
if vim.state().mode == Mode::VisualBlock {
|
||||
s.select_anchors(vec![s.first_anchor()])
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
vim.update_active_editor(cx, |vim, editor, cx| {
|
||||
let line_mode = editor.selections.line_mode;
|
||||
yank_selections_content(vim, editor, line_mode, cx);
|
||||
editor.change_selections(None, cx, |s| {
|
||||
editor.transact(cx, |editor, cx| {
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if line_mode {
|
||||
selection.start = start_of_line(map, false, selection.start);
|
||||
};
|
||||
selection.collapse_to(selection.start, SelectionGoal::None)
|
||||
let mut position = selection.head();
|
||||
if !selection.reversed {
|
||||
position = movement::left(map, position);
|
||||
}
|
||||
original_columns.insert(selection.id, position.to_point(map).column);
|
||||
}
|
||||
selection.goal = SelectionGoal::None;
|
||||
});
|
||||
});
|
||||
copy_selections_content(vim, editor, line_mode, cx);
|
||||
editor.insert("", cx);
|
||||
|
||||
// Fixup cursor position after the deletion
|
||||
editor.set_clip_at_line_ends(true, cx);
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
let mut cursor = selection.head().to_point(map);
|
||||
|
||||
if let Some(column) = original_columns.get(&selection.id) {
|
||||
cursor.column = *column
|
||||
}
|
||||
let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
|
||||
selection.collapse_to(cursor, selection.goal)
|
||||
});
|
||||
if vim.state().mode == Mode::VisualBlock {
|
||||
s.select_anchors(vec![s.first_anchor()])
|
||||
}
|
||||
});
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
})
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
}
|
||||
|
||||
pub fn yank(vim: &mut Vim, cx: &mut WindowContext) {
|
||||
vim.update_active_editor(cx, |vim, editor, cx| {
|
||||
let line_mode = editor.selections.line_mode;
|
||||
yank_selections_content(vim, editor, line_mode, cx);
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.move_with(|map, selection| {
|
||||
if line_mode {
|
||||
selection.start = start_of_line(map, false, selection.start);
|
||||
};
|
||||
selection.collapse_to(selection.start, SelectionGoal::None)
|
||||
});
|
||||
if vim.state().mode == Mode::VisualBlock {
|
||||
s.select_anchors(vec![s.first_anchor()])
|
||||
}
|
||||
});
|
||||
});
|
||||
vim.switch_mode(Mode::Normal, true, cx);
|
||||
}
|
||||
|
||||
pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
|
||||
@ -442,48 +456,112 @@ pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_next(
|
||||
_: &mut Workspace,
|
||||
_: &SelectNext,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) -> Result<()> {
|
||||
pub fn select_next(_: &mut Workspace, _: &SelectNext, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
let count =
|
||||
vim.take_count(cx)
|
||||
.unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
|
||||
vim.update_active_editor(cx, |_, editor, cx| {
|
||||
for _ in 0..count {
|
||||
match editor.select_next(&Default::default(), cx) {
|
||||
Err(a) => return Err(a),
|
||||
_ => {}
|
||||
if editor
|
||||
.select_next(&Default::default(), cx)
|
||||
.log_err()
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_previous(
|
||||
_: &mut Workspace,
|
||||
_: &SelectPrevious,
|
||||
cx: &mut ViewContext<Workspace>,
|
||||
) -> Result<()> {
|
||||
pub fn select_previous(_: &mut Workspace, _: &SelectPrevious, cx: &mut ViewContext<Workspace>) {
|
||||
Vim::update(cx, |vim, cx| {
|
||||
let count =
|
||||
vim.take_count(cx)
|
||||
.unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
|
||||
vim.update_active_editor(cx, |_, editor, cx| {
|
||||
for _ in 0..count {
|
||||
match editor.select_previous(&Default::default(), cx) {
|
||||
Err(a) => return Err(a),
|
||||
_ => {}
|
||||
if editor
|
||||
.select_previous(&Default::default(), cx)
|
||||
.log_err()
|
||||
.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
.unwrap_or(Ok(()))
|
||||
});
|
||||
}
|
||||
|
||||
pub fn select_match(
|
||||
workspace: &mut Workspace,
|
||||
vim: &mut Vim,
|
||||
direction: Direction,
|
||||
cx: &mut WindowContext,
|
||||
) {
|
||||
let count = vim.take_count(cx).unwrap_or(1);
|
||||
let pane = workspace.active_pane().clone();
|
||||
let vim_is_normal = vim.state().mode == Mode::Normal;
|
||||
let mut start_selection = 0usize;
|
||||
let mut end_selection = 0usize;
|
||||
|
||||
vim.update_active_editor(cx, |_, editor, _| {
|
||||
editor.set_collapse_matches(false);
|
||||
});
|
||||
|
||||
if vim_is_normal {
|
||||
pane.update(cx, |pane, cx| {
|
||||
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
|
||||
search_bar.update(cx, |search_bar, cx| {
|
||||
// without update_match_index there is a bug when the cursor is before the first match
|
||||
search_bar.update_match_index(cx);
|
||||
search_bar.select_match(direction.opposite(), 1, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
vim.update_active_editor(cx, |_, editor, cx| {
|
||||
let latest = editor.selections.newest::<usize>(cx);
|
||||
start_selection = latest.start;
|
||||
end_selection = latest.end;
|
||||
});
|
||||
|
||||
pane.update(cx, |pane, cx| {
|
||||
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
|
||||
search_bar.update(cx, |search_bar, cx| {
|
||||
search_bar.update_match_index(cx);
|
||||
search_bar.select_match(direction, count, cx);
|
||||
});
|
||||
}
|
||||
});
|
||||
vim.update_active_editor(cx, |_, editor, cx| {
|
||||
let latest = editor.selections.newest::<usize>(cx);
|
||||
if vim_is_normal {
|
||||
start_selection = latest.start;
|
||||
end_selection = latest.end;
|
||||
} else {
|
||||
start_selection = start_selection.min(latest.start);
|
||||
end_selection = end_selection.max(latest.end);
|
||||
}
|
||||
if direction == Direction::Prev {
|
||||
std::mem::swap(&mut start_selection, &mut end_selection);
|
||||
}
|
||||
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
|
||||
s.select_ranges([start_selection..end_selection]);
|
||||
});
|
||||
editor.set_collapse_matches(true);
|
||||
});
|
||||
match vim.maybe_pop_operator() {
|
||||
Some(Operator::Change) => substitute(vim, None, false, cx),
|
||||
Some(Operator::Delete) => {
|
||||
vim.stop_recording();
|
||||
delete(vim, cx)
|
||||
}
|
||||
Some(Operator::Yank) => yank(vim, cx),
|
||||
_ => {} // Ignoring other operators
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -1052,4 +1130,69 @@ mod test {
|
||||
cx.simulate_keystrokes(["cmd-shift-p", "escape"]);
|
||||
assert_eq!(cx.mode(), Mode::VisualBlock);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_gn(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state("aaˇ aa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇaa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["g", "n"]).await;
|
||||
cx.assert_shared_state("aa «aaˇ» aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["g", "n"]).await;
|
||||
cx.assert_shared_state("aa «aa aaˇ» aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["escape", "d", "g", "n"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa aa ˇ aa aa").await;
|
||||
|
||||
cx.set_shared_state("aaˇ aa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇaa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["3", "g", "n"]).await;
|
||||
cx.assert_shared_state("aa aa aa «aaˇ» aa").await;
|
||||
|
||||
cx.set_shared_state("aaˇ aa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇaa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["g", "shift-n"]).await;
|
||||
cx.assert_shared_state("aa «ˇaa» aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["g", "shift-n"]).await;
|
||||
cx.assert_shared_state("«ˇaa aa» aa aa aa").await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_dgn_repeat(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state("aaˇ aa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇaa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["d", "g", "n"]).await;
|
||||
|
||||
cx.assert_shared_state("aa ˇ aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["."]).await;
|
||||
cx.assert_shared_state("aa ˇ aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["."]).await;
|
||||
cx.assert_shared_state("aa ˇ aa").await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_cgn_repeat(cx: &mut gpui::TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state("aaˇ aa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇaa aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["c", "g", "n", "x", "escape"])
|
||||
.await;
|
||||
cx.assert_shared_state("aa ˇx aa aa aa").await;
|
||||
cx.simulate_shared_keystrokes(["."]).await;
|
||||
cx.assert_shared_state("aa x ˇx aa aa").await;
|
||||
}
|
||||
}
|
||||
|
14
crates/vim/test_data/test_cgn_repeat.json
Normal file
14
crates/vim/test_data/test_cgn_repeat.json
Normal file
@ -0,0 +1,14 @@
|
||||
{"Put":{"state":"aaˇ aa aa aa aa"}}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
|
||||
{"Key":"c"}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Key":"x"}
|
||||
{"Key":"escape"}
|
||||
{"Get":{"state":"aa ˇx aa aa aa","mode":"Normal"}}
|
||||
{"Key":"."}
|
||||
{"Get":{"state":"aa x ˇx aa aa","mode":"Normal"}}
|
14
crates/vim/test_data/test_dgn_repeat.json
Normal file
14
crates/vim/test_data/test_dgn_repeat.json
Normal file
@ -0,0 +1,14 @@
|
||||
{"Put":{"state":"aaˇ aa aa aa aa"}}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
|
||||
{"Key":"d"}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Get":{"state":"aa ˇ aa aa aa","mode":"Normal"}}
|
||||
{"Key":"."}
|
||||
{"Get":{"state":"aa ˇ aa aa","mode":"Normal"}}
|
||||
{"Key":"."}
|
||||
{"Get":{"state":"aa ˇ aa","mode":"Normal"}}
|
39
crates/vim/test_data/test_gn.json
Normal file
39
crates/vim/test_data/test_gn.json
Normal file
@ -0,0 +1,39 @@
|
||||
{"Put":{"state":"aaˇ aa aa aa aa"}}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Get":{"state":"aa «aaˇ» aa aa aa","mode":"Visual"}}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Get":{"state":"aa «aa aaˇ» aa aa","mode":"Visual"}}
|
||||
{"Key":"escape"}
|
||||
{"Key":"d"}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Get":{"state":"aa aa ˇ aa aa","mode":"Normal"}}
|
||||
{"Put":{"state":"aaˇ aa aa aa aa"}}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
|
||||
{"Key":"3"}
|
||||
{"Key":"g"}
|
||||
{"Key":"n"}
|
||||
{"Get":{"state":"aa aa aa «aaˇ» aa","mode":"Visual"}}
|
||||
{"Put":{"state":"aaˇ aa aa aa aa"}}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"a"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
|
||||
{"Key":"g"}
|
||||
{"Key":"shift-n"}
|
||||
{"Get":{"state":"aa «ˇaa» aa aa aa","mode":"Visual"}}
|
||||
{"Key":"g"}
|
||||
{"Key":"shift-n"}
|
||||
{"Get":{"state":"«ˇaa aa» aa aa aa","mode":"Visual"}}
|
@ -24,6 +24,15 @@ pub enum Direction {
|
||||
Next,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn opposite(&self) -> Self {
|
||||
match self {
|
||||
Direction::Prev => Direction::Next,
|
||||
Direction::Next => Direction::Prev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct SearchOptions {
|
||||
pub case: bool,
|
||||
|
Loading…
Reference in New Issue
Block a user