mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(core/parse,tui): restore jq search + alt scroll
This commit is contained in:
@@ -173,11 +173,10 @@ Code supports MCP for extended capabilities:
|
||||
- **API integrations**: Connect to external services
|
||||
- **Custom tools**: Build your own extensions
|
||||
|
||||
Configure MCP in `~/.codex/config.toml`:
|
||||
Configure MCP in `~/.codex/config.toml`. Define each server under a named table like `[mcp_servers.<name>]` (this maps to the JSON `mcpServers` object used by other clients):
|
||||
|
||||
```toml
|
||||
[[mcp_servers]]
|
||||
name = "filesystem"
|
||||
[mcp_servers.filesystem]
|
||||
command = "npx"
|
||||
args = ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/project"]
|
||||
```
|
||||
|
||||
@@ -207,6 +207,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_rg_with_path_and_glob_filter() {
|
||||
let inner = "rg \"codex-run-as-apply-patch\" -n codex-rs -g\"*.rs\"";
|
||||
assert_parsed(
|
||||
&vec_str(&["bash", "-lc", inner]),
|
||||
vec![ParsedCommand::Search {
|
||||
cmd: "rg codex-run-as-apply-patch -n codex-rs '-g*.rs'".to_string(),
|
||||
query: Some("codex-run-as-apply-patch".to_string()),
|
||||
path: Some("codex-rs".to_string()),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_rg_files_then_head() {
|
||||
let inner = "rg --files | head -n 50";
|
||||
@@ -225,6 +238,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_jq_with_basic_filter() {
|
||||
assert_parsed(
|
||||
&vec_str(&["jq", ".name, .description", "package.json"]),
|
||||
vec![ParsedCommand::Search {
|
||||
cmd: "jq '.name, .description' package.json".to_string(),
|
||||
query: Some(".name, .description".to_string()),
|
||||
path: Some("package.json".to_string()),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_jq_with_directory_path() {
|
||||
assert_parsed(
|
||||
&vec_str(&["jq", "-r", ".name, .bin", "codex-cli/package.json"]),
|
||||
vec![ParsedCommand::Search {
|
||||
cmd: "jq -r '.name, .bin' codex-cli/package.json".to_string(),
|
||||
query: Some(".name, .bin".to_string()),
|
||||
path: Some("codex-cli/package.json".to_string()),
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_cat() {
|
||||
let inner = "cat webview/README.md";
|
||||
@@ -1171,6 +1208,52 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
|
||||
(query, path)
|
||||
}
|
||||
|
||||
fn parse_jq_filter_and_path(args: &[String]) -> (Option<String>, Option<String>) {
|
||||
let mut query: Option<String> = None;
|
||||
let mut path: Option<String> = None;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
if arg == "--" {
|
||||
// Treat everything after `--` as positional operands.
|
||||
i += 1;
|
||||
while i < args.len() {
|
||||
let positional = &args[i];
|
||||
if query.is_none() {
|
||||
query = Some(positional.clone());
|
||||
} else if path.is_none() {
|
||||
path = Some(positional.clone());
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
let flag = arg.as_str();
|
||||
let mut skip = match flag {
|
||||
"-f" | "--from-file" | "-L" | "--module-path" | "--indent" => 1,
|
||||
"--arg" | "--argjson" | "--argfile" | "--rawfile" | "--slurpfile" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
i += 1;
|
||||
while skip > 0 && i < args.len() {
|
||||
i += 1;
|
||||
skip -= 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if query.is_none() {
|
||||
query = Some(arg.clone());
|
||||
} else if path.is_none() {
|
||||
path = Some(arg.clone());
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(query, path)
|
||||
}
|
||||
|
||||
fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
let [bash, flag, script] = original else {
|
||||
return None;
|
||||
@@ -1180,111 +1263,108 @@ fn parse_bash_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
}
|
||||
if let Some(tree) = try_parse_bash(script) {
|
||||
if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) {
|
||||
if !all_commands.is_empty() {
|
||||
let script_tokens = shlex_split(script)
|
||||
.unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
|
||||
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
|
||||
// bias toward the primary command when pipelines are present.
|
||||
// First, drop obvious small formatting helpers (e.g., wc/awk/etc).
|
||||
let had_multiple_commands = all_commands.len() > 1;
|
||||
// The bash AST walker yields commands in right-to-left order for
|
||||
// connector/pipeline sequences. Reverse to reflect actual execution order.
|
||||
let mut filtered_commands = drop_small_formatting_commands(all_commands);
|
||||
filtered_commands.reverse();
|
||||
if filtered_commands.is_empty() {
|
||||
return Some(vec![ParsedCommand::Unknown {
|
||||
cmd: script.clone(),
|
||||
}]);
|
||||
}
|
||||
let mut commands: Vec<ParsedCommand> = filtered_commands
|
||||
.into_iter()
|
||||
.map(|tokens| summarize_main_tokens(&tokens))
|
||||
.collect();
|
||||
if commands.len() > 1 {
|
||||
commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
|
||||
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
|
||||
while let Some(next) = simplify_once(&commands) {
|
||||
commands = next;
|
||||
if all_commands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if commands.len() == 1 {
|
||||
// If we reduced to a single command, attribute the full original script
|
||||
// for clearer UX in file-reading and listing scenarios, or when there were
|
||||
// no connectors in the original script. For search commands that came from
|
||||
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
|
||||
let had_connectors = had_multiple_commands
|
||||
|| script_tokens
|
||||
.iter()
|
||||
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
|
||||
commands = commands
|
||||
let script_tokens = shlex_split(script)
|
||||
.unwrap_or_else(|| vec!["bash".to_string(), flag.clone(), script.clone()]);
|
||||
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
|
||||
// bias toward the primary command when pipelines are present.
|
||||
let had_multiple_commands = all_commands.len() > 1;
|
||||
// The bash AST walker yields commands in right-to-left order for
|
||||
// connector/pipeline sequences. Reverse to reflect actual execution order.
|
||||
let mut filtered_commands = drop_small_formatting_commands(all_commands);
|
||||
filtered_commands.reverse();
|
||||
if filtered_commands.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut commands: Vec<ParsedCommand> = filtered_commands
|
||||
.into_iter()
|
||||
.map(|pc| match pc {
|
||||
ParsedCommand::Read { name, cmd, .. } => {
|
||||
if had_connectors {
|
||||
let has_pipe = script_tokens.iter().any(|t| t == "|");
|
||||
let has_sed_n = script_tokens.windows(2).any(|w| {
|
||||
w.first().map(|s| s.as_str()) == Some("sed")
|
||||
&& w.get(1).map(|s| s.as_str()) == Some("-n")
|
||||
});
|
||||
if has_pipe && has_sed_n {
|
||||
ParsedCommand::Read {
|
||||
cmd: script.clone(),
|
||||
name,
|
||||
.map(|tokens| summarize_main_tokens(&tokens))
|
||||
.collect();
|
||||
if commands.len() > 1 {
|
||||
commands
|
||||
.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true"));
|
||||
// Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`.
|
||||
while let Some(next) = simplify_once(&commands) {
|
||||
commands = next;
|
||||
}
|
||||
}
|
||||
if commands.len() == 1 {
|
||||
// If we reduced to a single command, attribute the full original script
|
||||
// for clearer UX in file-reading and listing scenarios, or when there were
|
||||
// no connectors in the original script. For search commands that came from
|
||||
// a pipeline (e.g. `rg --files | sed -n`), keep only the primary command.
|
||||
let had_connectors = had_multiple_commands
|
||||
|| script_tokens
|
||||
.iter()
|
||||
.any(|t| t == "|" || t == "&&" || t == "||" || t == ";");
|
||||
commands = commands
|
||||
.into_iter()
|
||||
.map(|pc| match pc {
|
||||
ParsedCommand::Read { name, cmd, .. } => {
|
||||
if had_connectors {
|
||||
let has_pipe = script_tokens.iter().any(|t| t == "|");
|
||||
let has_sed_n = script_tokens.windows(2).any(|w| {
|
||||
w.first().map(|s| s.as_str()) == Some("sed")
|
||||
&& w.get(1).map(|s| s.as_str()) == Some("-n")
|
||||
});
|
||||
if has_pipe && has_sed_n {
|
||||
ParsedCommand::Read {
|
||||
cmd: script.clone(),
|
||||
name,
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Read {
|
||||
cmd: cmd.clone(),
|
||||
name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Read {
|
||||
cmd: cmd.clone(),
|
||||
cmd: shlex_join(&script_tokens),
|
||||
name,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Read {
|
||||
cmd: shlex_join(&script_tokens),
|
||||
name,
|
||||
}
|
||||
ParsedCommand::ListFiles { path, cmd, .. } => {
|
||||
if had_connectors {
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: cmd.clone(),
|
||||
path,
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: shlex_join(&script_tokens),
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ParsedCommand::ListFiles { path, cmd, .. } => {
|
||||
if had_connectors {
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: cmd.clone(),
|
||||
path,
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::ListFiles {
|
||||
cmd: shlex_join(&script_tokens),
|
||||
path,
|
||||
ParsedCommand::Search {
|
||||
query, path, cmd, ..
|
||||
} => {
|
||||
if had_connectors {
|
||||
ParsedCommand::Search {
|
||||
cmd: cmd.clone(),
|
||||
query,
|
||||
path,
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Search {
|
||||
cmd: shlex_join(&script_tokens),
|
||||
query,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ParsedCommand::Search {
|
||||
query, path, cmd, ..
|
||||
} => {
|
||||
if had_connectors {
|
||||
ParsedCommand::Search {
|
||||
cmd: cmd.clone(),
|
||||
query,
|
||||
path,
|
||||
}
|
||||
} else {
|
||||
ParsedCommand::Search {
|
||||
cmd: shlex_join(&script_tokens),
|
||||
query,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
return Some(commands);
|
||||
other => other,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
return Some(commands);
|
||||
}
|
||||
}
|
||||
Some(vec![ParsedCommand::Unknown {
|
||||
cmd: script.clone(),
|
||||
}])
|
||||
None
|
||||
}
|
||||
|
||||
/// Return true if this looks like a small formatting helper in a pipeline.
|
||||
@@ -1406,6 +1486,15 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand {
|
||||
path,
|
||||
}
|
||||
}
|
||||
Some((head, tail)) if head == "jq" => {
|
||||
let args_no_connector = trim_at_connector(tail);
|
||||
let (query, path) = parse_jq_filter_and_path(&args_no_connector);
|
||||
ParsedCommand::Search {
|
||||
cmd: shlex_join(main_cmd),
|
||||
query,
|
||||
path,
|
||||
}
|
||||
}
|
||||
Some((head, tail)) if head == "cat" => {
|
||||
// Support both `cat <file>` and `cat -- <file>` forms.
|
||||
let effective_tail: &[String] = if tail.first().map(|s| s.as_str()) == Some("--") {
|
||||
|
||||
@@ -9564,6 +9564,111 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
let local_default_ref = format!("refs/heads/{}", default_branch);
|
||||
let local_default_exists = Command::new("git")
|
||||
.current_dir(&git_root)
|
||||
.args([
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&local_default_ref,
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if local_default_exists {
|
||||
let ff_local = Command::new("git")
|
||||
.current_dir(&work_cwd)
|
||||
.args(["merge", "--ff-only", &local_default_ref])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if !matches!(ff_local, Ok(ref o) if o.status.success()) {
|
||||
let merge_local = Command::new("git")
|
||||
.current_dir(&work_cwd)
|
||||
.args(["merge", "--no-ff", "--no-commit", &local_default_ref])
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(out) = merge_local {
|
||||
if out.status.success() {
|
||||
let _ = Command::new("git")
|
||||
.current_dir(&work_cwd)
|
||||
.args([
|
||||
"commit",
|
||||
"-m",
|
||||
&format!(
|
||||
"merge local {} into {} before merge",
|
||||
default_branch, branch_label
|
||||
),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
} else {
|
||||
let updated_worktree_status = ChatWidget::git_short_status(&work_cwd)
|
||||
.await
|
||||
.map(|s| {
|
||||
if s.trim().is_empty() {
|
||||
"clean".to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|err| format!("status unavailable: {}", err));
|
||||
let updated_diff = ChatWidget::git_diff_stat(&work_cwd)
|
||||
.await
|
||||
.ok()
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(worktree_diff_stat.clone());
|
||||
send_agent_handoff(
|
||||
vec![format!(
|
||||
"merge conflicts while merging local '{}' into '{}'",
|
||||
default_branch, branch_label
|
||||
)],
|
||||
Some(
|
||||
"The worktree currently has an in-progress merge that needs to be resolved. Please complete it before retrying the final merge.".to_string(),
|
||||
),
|
||||
updated_worktree_status,
|
||||
repo_status_for_agent.clone(),
|
||||
updated_diff,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
let updated_worktree_status = ChatWidget::git_short_status(&work_cwd)
|
||||
.await
|
||||
.map(|s| {
|
||||
if s.trim().is_empty() {
|
||||
"clean".to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|err| format!("status unavailable: {}", err));
|
||||
let updated_diff = ChatWidget::git_diff_stat(&work_cwd)
|
||||
.await
|
||||
.ok()
|
||||
.map(|d| d.trim().to_string())
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(worktree_diff_stat.clone());
|
||||
send_agent_handoff(
|
||||
vec![format!(
|
||||
"failed to merge local '{}' into '{}'",
|
||||
default_branch, branch_label
|
||||
)],
|
||||
None,
|
||||
updated_worktree_status,
|
||||
repo_status_for_agent.clone(),
|
||||
updated_diff,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let on_default = match Command::new("git")
|
||||
.current_dir(&git_root)
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
|
||||
@@ -940,6 +940,7 @@ pub(crate) struct ExecCell {
|
||||
cached_out_lines: std::cell::RefCell<Option<Vec<Line<'static>>>>,
|
||||
// Cached per-width wrap totals (rows) for finalized execs
|
||||
cached_wrap: std::cell::RefCell<Option<ExecWrapCache>>,
|
||||
has_bold_command: bool,
|
||||
}
|
||||
|
||||
// Cache of wrapped-row totals for ExecCell at a given width.
|
||||
@@ -1403,6 +1404,24 @@ impl HistoryCell for ExecCell {
|
||||
};
|
||||
HistoryCellType::Exec { kind, status }
|
||||
}
|
||||
fn gutter_symbol(&self) -> Option<&'static str> {
|
||||
match self.kind() {
|
||||
HistoryCellType::Exec {
|
||||
kind: ExecKind::Run,
|
||||
status,
|
||||
} => {
|
||||
if matches!(status, ExecStatus::Error) {
|
||||
Some("✖")
|
||||
} else if self.has_bold_command {
|
||||
Some("❯")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
HistoryCellType::Exec { .. } => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
// Fallback textual representation (used for height measurement only when custom rendering).
|
||||
// For completed executions, cache the computed lines since they are immutable.
|
||||
@@ -5397,6 +5416,20 @@ pub(crate) fn new_completed_exec_command(
|
||||
new_exec_cell(command, parsed, Some(output))
|
||||
}
|
||||
|
||||
fn command_has_bold_token(command: &[String]) -> bool {
|
||||
let command_escaped = strip_bash_lc_and_escape(command);
|
||||
let normalized = normalize_shell_command_display(&command_escaped);
|
||||
let trimmed = normalized.trim_start();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.take_while(|ch| !ch.is_whitespace())
|
||||
.count()
|
||||
> 4
|
||||
}
|
||||
|
||||
fn new_exec_cell(
|
||||
command: Vec<String>,
|
||||
parsed: Vec<ParsedCommand>,
|
||||
@@ -5407,6 +5440,7 @@ fn new_exec_cell(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_bold_command = command_has_bold_token(&command);
|
||||
ExecCell {
|
||||
command,
|
||||
parsed,
|
||||
@@ -5418,6 +5452,7 @@ fn new_exec_cell(
|
||||
cached_pre_lines: std::cell::RefCell::new(None),
|
||||
cached_out_lines: std::cell::RefCell::new(None),
|
||||
cached_wrap: std::cell::RefCell::new(None),
|
||||
has_bold_command,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
use std::io::BufWriter;
|
||||
use std::io::Write;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use crossterm::cursor::MoveTo;
|
||||
@@ -76,6 +77,7 @@ pub fn init(config: &Config) -> Result<(Tui, TerminalInfo)> {
|
||||
crate::syntax_highlight::init_highlight_from_config(&config.tui.highlight);
|
||||
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
enable_alternate_scroll_mode()?;
|
||||
// Enable focus change events so we can detect when the terminal window/tab
|
||||
// regains focus and proactively repaint the UI (helps terminals that clear
|
||||
// their alt‑screen buffer while unfocused). However, certain environments
|
||||
@@ -247,6 +249,7 @@ pub fn restore() -> Result<()> {
|
||||
let _ = execute!(stdout(), PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::empty()));
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
}
|
||||
disable_alternate_scroll_mode()?;
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
// Best‑effort: disable focus change notifications if supported.
|
||||
let _ = execute!(stdout(), DisableFocusChange);
|
||||
@@ -270,6 +273,7 @@ pub fn leave_alt_screen_only() -> Result<()> {
|
||||
// being echoed into the normal buffer by some terminals.
|
||||
let _ = execute!(stdout(), DisableBracketedPaste);
|
||||
let _ = execute!(stdout(), DisableFocusChange);
|
||||
let _ = disable_alternate_scroll_mode();
|
||||
// Pop keyboard enhancement flags so keys like Enter/Arrows don't emit
|
||||
// enhanced escape sequences (e.g., kitty/xterm modifyOtherKeys) into the buffer.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
@@ -299,6 +303,7 @@ pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui:
|
||||
let _ = execute!(stdout(), EnableFocusChange);
|
||||
}
|
||||
let _ = execute!(stdout(), EnableBracketedPaste);
|
||||
let _ = enable_alternate_scroll_mode();
|
||||
execute!(
|
||||
stdout(),
|
||||
crossterm::terminal::EnterAlternateScreen,
|
||||
@@ -311,6 +316,20 @@ pub fn enter_alt_screen_only(theme_fg: ratatui::style::Color, theme_bg: ratatui:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enable_alternate_scroll_mode() -> Result<()> {
|
||||
let mut handle = stdout();
|
||||
handle.write_all(b"\x1b[?1007h")?;
|
||||
handle.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disable_alternate_scroll_mode() -> Result<()> {
|
||||
let mut handle = stdout();
|
||||
handle.write_all(b"\x1b[?1007l")?;
|
||||
handle.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the current screen (normal buffer) with the theme background and reset cursor.
|
||||
// Removed: clear_screen_with_theme — we no longer hard-clear the normal buffer in terminal mode.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user