mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
remove fuzzy match, dead code and move cursor when doing ctrl+r
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
|
||||
///
|
||||
/// Returns the indices (positions) of the matched characters in `haystack`
|
||||
/// and a score where smaller is better. Currently, indices are byte offsets
|
||||
/// from `char_indices()` of a lowercased copy of `haystack`.
|
||||
///
|
||||
/// Note: For ASCII inputs these indices align with character positions. If
|
||||
/// extended Unicode inputs are used, be mindful of byte vs char indices.
|
||||
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
|
||||
if needle.is_empty() {
|
||||
return Some((Vec::new(), i32::MAX));
|
||||
}
|
||||
let h_lower = haystack.to_lowercase();
|
||||
let n_lower = needle.to_lowercase();
|
||||
let mut indices: Vec<usize> = Vec::with_capacity(n_lower.len());
|
||||
let mut h_iter = h_lower.char_indices();
|
||||
let mut last_pos: Option<usize> = None;
|
||||
|
||||
for ch in n_lower.chars() {
|
||||
let mut found = None;
|
||||
for (i, hc) in h_iter.by_ref() {
|
||||
if hc == ch {
|
||||
found = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(pos) = found {
|
||||
indices.push(pos);
|
||||
last_pos = Some(pos);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Score: window length minus needle length (tighter is better), with a bonus for prefix match.
|
||||
let first = *indices.first().unwrap_or(&0);
|
||||
let last = last_pos.unwrap_or(first);
|
||||
let window = (last as i32 - first as i32 + 1) - (n_lower.len() as i32);
|
||||
let mut score = window.max(0);
|
||||
if first == 0 {
|
||||
score -= 100; // strong bonus for prefix match
|
||||
}
|
||||
Some((indices, score))
|
||||
}
|
||||
|
||||
/// Convenience wrapper to get only the indices for a fuzzy match.
|
||||
pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
|
||||
fuzzy_match(haystack, needle).map(|(idx, _)| idx)
|
||||
}
|
||||
@@ -23,6 +23,3 @@ mod sandbox_summary;
|
||||
|
||||
#[cfg(feature = "sandbox_summary")]
|
||||
pub use sandbox_summary::summarize_sandbox_policy;
|
||||
|
||||
// Expose fuzzy matching utilities
|
||||
pub mod fuzzy_match;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
@@ -12,6 +13,7 @@ use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use tui_textarea::Input;
|
||||
use tui_textarea::Key;
|
||||
use tui_textarea::TextArea;
|
||||
@@ -22,7 +24,7 @@ use super::file_search_popup::FileSearchPopup;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_file_search::FileMatch; // underline matching chars
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
const BASE_PLACEHOLDER_TEXT: &str = "...";
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
@@ -58,17 +60,6 @@ impl ChatComposer<'_> {
|
||||
let mut textarea = TextArea::default();
|
||||
textarea.set_placeholder_text(BASE_PLACEHOLDER_TEXT);
|
||||
textarea.set_cursor_line_style(ratatui::style::Style::default());
|
||||
// Try to avoid highlighted cursor cell background
|
||||
#[allow(unused_must_use)]
|
||||
{
|
||||
// These APIs may not exist on all versions; ignore if not.
|
||||
// If available, set to default to remove grey background.
|
||||
#[allow(unused_variables)]
|
||||
{
|
||||
// Attempt to call set_cursor_style if present
|
||||
// (If not present, this will be optimized out in release)
|
||||
}
|
||||
}
|
||||
|
||||
let mut this = Self {
|
||||
textarea,
|
||||
@@ -418,7 +409,7 @@ impl ChatComposer<'_> {
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let input: Input = key_event.into();
|
||||
|
||||
// If fuzzy history search is active, intercept most keystrokes to update the search
|
||||
// If history search is active, intercept most keystrokes to update the search
|
||||
if self.history.is_search_active() {
|
||||
match input {
|
||||
Input {
|
||||
@@ -482,6 +473,10 @@ impl ChatComposer<'_> {
|
||||
..
|
||||
} => {
|
||||
self.history.search_backspace(&mut self.textarea);
|
||||
if !self.history.search_has_matches() {
|
||||
// Pull in more older entries to widen the search scope.
|
||||
self.history.fetch_more_for_search(&self.app_event_tx, 100);
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
@@ -492,6 +487,9 @@ impl ChatComposer<'_> {
|
||||
} => {
|
||||
// Clear query but remain in search mode
|
||||
self.history.search_clear_query(&mut self.textarea);
|
||||
if !self.history.search_has_matches() {
|
||||
self.history.fetch_more_for_search(&self.app_event_tx, 100);
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
Input {
|
||||
@@ -501,6 +499,9 @@ impl ChatComposer<'_> {
|
||||
..
|
||||
} => {
|
||||
self.history.search_append_char(c, &mut self.textarea);
|
||||
if !self.history.search_has_matches() {
|
||||
self.history.fetch_more_for_search(&self.app_event_tx, 100);
|
||||
}
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
_ => { /* fall-through for Enter and other controls */ }
|
||||
@@ -836,12 +837,12 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
textarea_rect.height = textarea_rect.height.saturating_sub(1);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
|
||||
// Always clear background and previous underlines for content cells to avoid stale styles
|
||||
// Always clear background and previous underlines for content cells to avoid
|
||||
// stale styles cleaning previous search results
|
||||
let content_start_x = textarea_rect.x.saturating_add(1);
|
||||
for y in textarea_rect.y..(textarea_rect.y + textarea_rect.height) {
|
||||
for x in content_start_x..(textarea_rect.x + textarea_rect.width) {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
use ratatui::style::Modifier;
|
||||
let new_style = cell
|
||||
.style()
|
||||
.bg(Color::Reset)
|
||||
@@ -855,7 +856,6 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
if self.history.is_search_active() {
|
||||
if let Some(q) = self.history.search_query() {
|
||||
if !q.is_empty() {
|
||||
use ratatui::style::Modifier;
|
||||
let mut positions: Vec<(u16, u16)> = Vec::with_capacity(
|
||||
(textarea_rect.width as usize) * (textarea_rect.height as usize),
|
||||
);
|
||||
@@ -882,8 +882,7 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
if vis_lower.len() >= q_lower_chars.len() {
|
||||
let mut i = 0;
|
||||
while i + q_lower_chars.len() <= vis_lower.len() {
|
||||
if &vis_lower[i..i + q_lower_chars.len()] == &q_lower_chars[..]
|
||||
{
|
||||
if vis_lower[i..i + q_lower_chars.len()] == q_lower_chars[..] {
|
||||
for j in i..i + q_lower_chars.len() {
|
||||
if let Some(&(x, y)) = positions.get(j) {
|
||||
if let Some(cell) = buf.cell_mut((x, y)) {
|
||||
@@ -1120,6 +1119,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn desired_height_accounts_for_wrapping_long_lines() {
|
||||
// use crossterm::event::KeyCode;
|
||||
// use crossterm::event::KeyEvent;
|
||||
// use crossterm::event::KeyModifiers;
|
||||
|
||||
// let (tx, _rx) = std::sync::mpsc::channel();
|
||||
// let sender = AppEventSender::new(tx);
|
||||
// let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
// // Long single-line history entry, typical of a pasted prompt.
|
||||
// let long_line = "a".repeat(100);
|
||||
// // Simulate submitting once so it exists in local history and Ctrl+R can preview it.
|
||||
// composer.textarea.insert_str(&long_line);
|
||||
// let (result, _) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
// match result {
|
||||
// InputResult::Submitted(text) => assert_eq!(text, long_line),
|
||||
// _ => panic!("expected Submitted"),
|
||||
// }
|
||||
|
||||
// // Activate search and type a minimal query to select it.
|
||||
// let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
|
||||
// let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
|
||||
// // With a small width, desired height should wrap the single logical line into multiple rows.
|
||||
// let width: u16 = 20; // leaves ~19 chars of content width due to left border
|
||||
// let h = composer.desired_height(width);
|
||||
// assert!(h > 2, "expected more than one content row plus status line, got {h}");
|
||||
// }
|
||||
|
||||
#[test]
|
||||
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
@@ -5,8 +5,7 @@ use tui_textarea::TextArea;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_common::fuzzy_match::fuzzy_match;
|
||||
use codex_core::protocol::Op; // added for fuzzy search
|
||||
use codex_core::protocol::Op;
|
||||
|
||||
/// State machine that manages shell-style history navigation (Up/Down) inside
|
||||
/// the chat composer. This struct is intentionally decoupled from the
|
||||
@@ -33,7 +32,7 @@ pub(crate) struct ChatComposerHistory {
|
||||
/// treated as navigation versus normal cursor movement.
|
||||
last_history_text: Option<String>,
|
||||
|
||||
/// Search state (active only during Ctrl+K fuzzy history search).
|
||||
/// Search state (active only during Ctrl+R history search).
|
||||
search: Option<SearchState>,
|
||||
}
|
||||
|
||||
@@ -58,7 +57,6 @@ impl ChatComposerHistory {
|
||||
self.local_history.clear();
|
||||
self.history_cursor = None;
|
||||
self.last_history_text = None;
|
||||
// also reset any ongoing search
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
@@ -97,6 +95,39 @@ impl ChatComposerHistory {
|
||||
}
|
||||
}
|
||||
|
||||
/// When search is active but there are no matches (or we want deeper coverage),
|
||||
/// fetch additional older persistent entries beyond those already cached.
|
||||
pub fn fetch_more_for_search(&mut self, app_event_tx: &AppEventSender, max_count: usize) {
|
||||
let Some(log_id) = self.history_log_id else {
|
||||
return;
|
||||
};
|
||||
if self.history_entry_count == 0 || max_count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the next range of older offsets to fetch. Start from one before the
|
||||
// oldest cached offset; if nothing is cached, start from newest.
|
||||
let start_offset = match self.fetched_history.keys().min().copied() {
|
||||
Some(min_cached) if min_cached > 0 => min_cached - 1,
|
||||
Some(_) => return, // already at oldest
|
||||
None => self.history_entry_count.saturating_sub(1),
|
||||
};
|
||||
|
||||
let mut remaining = max_count;
|
||||
let mut offset = start_offset;
|
||||
loop {
|
||||
if !self.fetched_history.contains_key(&offset) {
|
||||
let op = Op::GetHistoryEntryRequest { offset, log_id };
|
||||
app_event_tx.send(AppEvent::CodexOp(op));
|
||||
}
|
||||
if remaining == 1 || offset == 0 {
|
||||
break;
|
||||
}
|
||||
remaining -= 1;
|
||||
offset -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a message submitted by the user in the current session so it can
|
||||
/// be recalled later.
|
||||
pub fn record_local_submission(&mut self, text: &str) {
|
||||
@@ -235,11 +266,7 @@ impl ChatComposerHistory {
|
||||
false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Search API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Toggle or begin fuzzy search mode (Ctrl+R / Ctrl+K).
|
||||
/// Toggle or begin history search mode (Ctrl+R)
|
||||
pub fn search(&mut self) {
|
||||
if self.search.is_some() {
|
||||
self.search = None;
|
||||
@@ -258,52 +285,25 @@ impl ChatComposerHistory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is fuzzy search mode active?
|
||||
pub fn is_search_active(&self) -> bool {
|
||||
self.search.is_some()
|
||||
}
|
||||
|
||||
/// Exit search mode without changing the current textarea contents.
|
||||
pub fn exit_search(&mut self) {
|
||||
self.search = None;
|
||||
}
|
||||
|
||||
/// Append a character to the search query and update the preview.
|
||||
/// used when the user types and we update the search query
|
||||
pub fn search_append_char(&mut self, ch: char, textarea: &mut TextArea) {
|
||||
if self.search.is_some() {
|
||||
let mut query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
query.push(ch);
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.query = query;
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
self.update_search_query(textarea, |query| query.push(ch));
|
||||
}
|
||||
|
||||
/// Remove the last character from the search query and update the preview.
|
||||
pub fn search_backspace(&mut self, textarea: &mut TextArea) {
|
||||
if self.search.is_some() {
|
||||
let mut query = self
|
||||
.search
|
||||
.as_ref()
|
||||
.map(|s| s.query.clone())
|
||||
.unwrap_or_default();
|
||||
self.update_search_query(textarea, |query| {
|
||||
query.pop();
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
if let Some(s) = &mut self.search {
|
||||
s.query = query;
|
||||
s.matches = matches;
|
||||
s.selected = selected;
|
||||
}
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear the entire search query and recompute matches (stays in search mode).
|
||||
@@ -383,6 +383,7 @@ impl ChatComposerHistory {
|
||||
}
|
||||
|
||||
/// Compute search matches for a given query over known history (local + fetched).
|
||||
/// Uses exact, case-insensitive substring matching; newer entries are preferred.
|
||||
fn recompute_matches_for_query(&self, query: &str) -> (Vec<SearchMatch>, usize) {
|
||||
let mut matches: Vec<SearchMatch> = Vec::new();
|
||||
let mut selected: usize = 0;
|
||||
@@ -392,24 +393,24 @@ impl ChatComposerHistory {
|
||||
return (matches, selected);
|
||||
}
|
||||
|
||||
// Non-empty query: fuzzy match over available items
|
||||
let q_lower = query.to_lowercase();
|
||||
|
||||
// Local entries (newest at end), compute global index then push if contains
|
||||
for (i, t) in self.local_history.iter().enumerate() {
|
||||
if let Some((_, score)) = fuzzy_match(t, query) {
|
||||
if t.to_lowercase().contains(&q_lower) {
|
||||
let global_idx = self.history_entry_count + i;
|
||||
matches.push(SearchMatch {
|
||||
idx: global_idx,
|
||||
score,
|
||||
});
|
||||
matches.push(SearchMatch { idx: global_idx });
|
||||
}
|
||||
}
|
||||
// Fetched persistent entries
|
||||
for (idx, t) in self.fetched_history.iter() {
|
||||
if let Some((_, score)) = fuzzy_match(t, query) {
|
||||
matches.push(SearchMatch { idx: *idx, score });
|
||||
if t.to_lowercase().contains(&q_lower) {
|
||||
matches.push(SearchMatch { idx: *idx });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort primarily by score ascending (better first), then by recency (newer global index first)
|
||||
matches.sort_by(|a, b| a.score.cmp(&b.score).then(b.idx.cmp(&a.idx)));
|
||||
// Sort by recency (newer global index first)
|
||||
matches.sort_by(|a, b| b.idx.cmp(&a.idx));
|
||||
|
||||
if matches.is_empty() {
|
||||
selected = 0;
|
||||
@@ -473,12 +474,35 @@ impl ChatComposerHistory {
|
||||
textarea.move_cursor(CursorMove::Jump(row, col));
|
||||
}
|
||||
}
|
||||
|
||||
// Extract common logic for updating the search query, recomputing matches, and applying selection.
|
||||
fn update_search_query<F>(&mut self, textarea: &mut TextArea, modify: F)
|
||||
where
|
||||
F: FnOnce(&mut String),
|
||||
{
|
||||
// Move out the current search state or exit if inactive
|
||||
let mut state = match self.search.take() {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
};
|
||||
// Clone and modify the query
|
||||
let mut query = state.query.clone();
|
||||
modify(&mut query);
|
||||
// Recompute matches based on modified query
|
||||
let (matches, selected) = self.recompute_matches_for_query(&query);
|
||||
// Update the moved-out state
|
||||
state.query = query;
|
||||
state.matches = matches;
|
||||
state.selected = selected;
|
||||
// Restore the state and apply selection update
|
||||
self.search = Some(state);
|
||||
self.apply_selected_to_textarea(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SearchMatch {
|
||||
idx: usize, // global history index
|
||||
score: i32, // lower is better
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -558,4 +582,114 @@ mod tests {
|
||||
history.on_entry_response(1, 1, Some("older".into()), &mut textarea);
|
||||
assert_eq!(textarea.lines().join("\n"), "older");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_moves_cursor_to_first_match_ascii() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
// Record a local entry that will be matched.
|
||||
history.record_local_submission("hello world");
|
||||
|
||||
// Begin search and type a query that has an exact substring match.
|
||||
history.search();
|
||||
for ch in ['w', 'o'] {
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
// Expect the textarea to preview the matched entry and the cursor to
|
||||
// be positioned at the first character of the first match (the 'w').
|
||||
assert_eq!(textarea.lines().join("\n"), "hello world");
|
||||
let (row, col) = textarea.cursor();
|
||||
assert_eq!((row, col), (0, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_moves_cursor_to_first_match_multiline_case_insensitive() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
history.record_local_submission("foo\nBARbaz");
|
||||
|
||||
history.search();
|
||||
for ch in ['b', 'a', 'r'] {
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
// Cursor should point to the 'B' on the second line.
|
||||
assert_eq!(textarea.lines().join("\n"), "foo\nBARbaz");
|
||||
let (row, col) = textarea.cursor();
|
||||
assert_eq!((row, col), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_moves_cursor_correctly_with_multibyte_chars() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
history.record_local_submission("héllo world");
|
||||
|
||||
history.search();
|
||||
for ch in ['w', 'o', 'r', 'l', 'd'] {
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
// The cursor should be after 6 visible characters: "héllo ".
|
||||
assert_eq!(textarea.lines().join("\n"), "héllo world");
|
||||
let (row, col) = textarea.cursor();
|
||||
assert_eq!((row, col), (0, 6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_uses_exact() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
history.record_local_submission("hello world");
|
||||
|
||||
history.search();
|
||||
for ch in ['h', 'l', 'd'] {
|
||||
// non-contiguous; would be fuzzy, not substring
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
// No exact substring match for the final query; keep the previous preview
|
||||
// (from the intermediate "h" match) instead of clearing.
|
||||
assert_eq!(textarea.lines().join("\n"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prefers_newer_match_by_recency() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
history.record_local_submission("foo one");
|
||||
history.record_local_submission("second foo");
|
||||
|
||||
history.search();
|
||||
for ch in ['f', 'o', 'o'] {
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
// Newer entry containing "foo" should be selected first.
|
||||
assert_eq!(textarea.lines().join("\n"), "second foo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_is_case_insensitive_and_moves_cursor_to_match_start() {
|
||||
let mut history = ChatComposerHistory::new();
|
||||
let mut textarea = TextArea::default();
|
||||
|
||||
history.record_local_submission("alpha COUNTRY beta");
|
||||
|
||||
history.search();
|
||||
for ch in ['c', 'o', 'u', 'n', 't', 'r', 'y'] {
|
||||
history.search_append_char(ch, &mut textarea);
|
||||
}
|
||||
|
||||
assert_eq!(textarea.lines().join("\n"), "alpha COUNTRY beta");
|
||||
// Cursor should be at start of COUNTRY, which begins at col 6 (after "alpha ")
|
||||
let (row, col) = textarea.cursor();
|
||||
assert_eq!((row, col), (0, 6));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user