Prevent sending slash commands in CC threads (#36453)
Highlight them as errors in the editor, and add a leading space when sending them so users don't hit the odd behavior when sending these commands to the SDK. Release Notes: - N/A
This commit is contained in:
parent
7bcea7dc2c
commit
d30b017d1f
8 changed files with 263 additions and 16 deletions
|
@ -1,4 +1,4 @@
|
|||
use std::{path::Path, rc::Rc, sync::Arc};
|
||||
use std::{any::Any, path::Path, rc::Rc, sync::Arc};
|
||||
|
||||
use agent_servers::AgentServer;
|
||||
use anyhow::Result;
|
||||
|
@ -66,4 +66,8 @@ impl AgentServer for NativeAgentServer {
|
|||
Ok(Rc::new(connection) as Rc<dyn acp_thread::AgentConnection>)
|
||||
})
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ use project::Project;
|
|||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
any::Any,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
|
@ -40,6 +41,14 @@ pub trait AgentServer: Send {
|
|||
project: &Entity<Project>,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Rc<dyn AgentConnection>>>;
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any>;
|
||||
}
|
||||
|
||||
impl dyn AgentServer {
|
||||
pub fn downcast<T: 'static + AgentServer + Sized>(self: Rc<Self>) -> Option<Rc<T>> {
|
||||
self.into_any().downcast().ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentServerCommand {
|
||||
|
|
|
@ -65,6 +65,10 @@ impl AgentServer for ClaudeCode {
|
|||
|
||||
Task::ready(Ok(Rc::new(connection) as _))
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
struct ClaudeAgentConnection {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::{any::Any, path::Path};
|
||||
|
||||
use crate::{AgentServer, AgentServerCommand};
|
||||
use acp_thread::{AgentConnection, LoadError};
|
||||
|
@ -86,6 +86,10 @@ impl AgentServer for Gemini {
|
|||
result
|
||||
})
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
@ -24,6 +24,7 @@ pub struct EntryViewState {
|
|||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
entries: Vec<Entry>,
|
||||
prevent_slash_commands: bool,
|
||||
}
|
||||
|
||||
impl EntryViewState {
|
||||
|
@ -32,6 +33,7 @@ impl EntryViewState {
|
|||
project: Entity<Project>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
prevent_slash_commands: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace,
|
||||
|
@ -39,6 +41,7 @@ impl EntryViewState {
|
|||
thread_store,
|
||||
text_thread_store,
|
||||
entries: Vec::new(),
|
||||
prevent_slash_commands,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -77,6 +80,7 @@ impl EntryViewState {
|
|||
self.thread_store.clone(),
|
||||
self.text_thread_store.clone(),
|
||||
"Edit message - @ to include context",
|
||||
self.prevent_slash_commands,
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
|
@ -382,6 +386,7 @@ mod tests {
|
|||
project.clone(),
|
||||
thread_store,
|
||||
text_thread_store,
|
||||
false,
|
||||
)
|
||||
});
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ use assistant_slash_commands::codeblock_fence_for_path;
|
|||
use collections::{HashMap, HashSet};
|
||||
use editor::{
|
||||
Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
|
||||
EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, ToOffset,
|
||||
EditorEvent, EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer,
|
||||
SemanticsProvider, ToOffset,
|
||||
actions::Paste,
|
||||
display_map::{Crease, CreaseId, FoldId},
|
||||
};
|
||||
|
@ -19,8 +20,9 @@ use futures::{
|
|||
future::{Shared, join_all, try_join_all},
|
||||
};
|
||||
use gpui::{
|
||||
AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image,
|
||||
ImageFormat, Img, Task, TextStyle, WeakEntity,
|
||||
AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
HighlightStyle, Image, ImageFormat, Img, Subscription, Task, TextStyle, UnderlineStyle,
|
||||
WeakEntity,
|
||||
};
|
||||
use language::{Buffer, Language};
|
||||
use language_model::LanguageModelImage;
|
||||
|
@ -28,26 +30,30 @@ use project::{CompletionIntent, Project, ProjectPath, Worktree};
|
|||
use rope::Point;
|
||||
use settings::Settings;
|
||||
use std::{
|
||||
cell::Cell,
|
||||
ffi::OsStr,
|
||||
fmt::{Display, Write},
|
||||
ops::Range,
|
||||
path::{Path, PathBuf},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use text::OffsetRangeExt;
|
||||
use text::{OffsetRangeExt, ToOffset as _};
|
||||
use theme::ThemeSettings;
|
||||
use ui::{
|
||||
ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
|
||||
IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
|
||||
Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
|
||||
h_flex,
|
||||
h_flex, px,
|
||||
};
|
||||
use url::Url;
|
||||
use util::ResultExt;
|
||||
use workspace::{Workspace, notifications::NotifyResultExt as _};
|
||||
use zed_actions::agent::Chat;
|
||||
|
||||
const PARSE_SLASH_COMMAND_DEBOUNCE: Duration = Duration::from_millis(50);
|
||||
|
||||
pub struct MessageEditor {
|
||||
mention_set: MentionSet,
|
||||
editor: Entity<Editor>,
|
||||
|
@ -55,6 +61,9 @@ pub struct MessageEditor {
|
|||
workspace: WeakEntity<Workspace>,
|
||||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
prevent_slash_commands: bool,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
_parse_slash_command_task: Task<()>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
|
@ -73,6 +82,7 @@ impl MessageEditor {
|
|||
thread_store: Entity<ThreadStore>,
|
||||
text_thread_store: Entity<TextThreadStore>,
|
||||
placeholder: impl Into<Arc<str>>,
|
||||
prevent_slash_commands: bool,
|
||||
mode: EditorMode,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
|
@ -90,6 +100,9 @@ impl MessageEditor {
|
|||
text_thread_store.downgrade(),
|
||||
cx.weak_entity(),
|
||||
);
|
||||
let semantics_provider = Rc::new(SlashCommandSemanticsProvider {
|
||||
range: Cell::new(None),
|
||||
});
|
||||
let mention_set = MentionSet::default();
|
||||
let editor = cx.new(|cx| {
|
||||
let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
|
||||
|
@ -106,6 +119,9 @@ impl MessageEditor {
|
|||
max_entries_visible: 12,
|
||||
placement: Some(ContextMenuPlacement::Above),
|
||||
});
|
||||
if prevent_slash_commands {
|
||||
editor.set_semantics_provider(Some(semantics_provider.clone()));
|
||||
}
|
||||
editor
|
||||
});
|
||||
|
||||
|
@ -114,6 +130,24 @@ impl MessageEditor {
|
|||
})
|
||||
.detach();
|
||||
|
||||
let mut subscriptions = Vec::new();
|
||||
if prevent_slash_commands {
|
||||
subscriptions.push(cx.subscribe_in(&editor, window, {
|
||||
let semantics_provider = semantics_provider.clone();
|
||||
move |this, editor, event, window, cx| match event {
|
||||
EditorEvent::Edited { .. } => {
|
||||
this.highlight_slash_command(
|
||||
semantics_provider.clone(),
|
||||
editor.clone(),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Self {
|
||||
editor,
|
||||
project,
|
||||
|
@ -121,6 +155,9 @@ impl MessageEditor {
|
|||
thread_store,
|
||||
text_thread_store,
|
||||
workspace,
|
||||
prevent_slash_commands,
|
||||
_subscriptions: subscriptions,
|
||||
_parse_slash_command_task: Task::ready(()),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -590,6 +627,7 @@ impl MessageEditor {
|
|||
self.mention_set
|
||||
.contents(self.project.clone(), self.thread_store.clone(), window, cx);
|
||||
let editor = self.editor.clone();
|
||||
let prevent_slash_commands = self.prevent_slash_commands;
|
||||
|
||||
cx.spawn(async move |_, cx| {
|
||||
let contents = contents.await?;
|
||||
|
@ -612,7 +650,15 @@ impl MessageEditor {
|
|||
|
||||
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
|
||||
if crease_range.start > ix {
|
||||
chunks.push(text[ix..crease_range.start].into());
|
||||
let chunk = if prevent_slash_commands
|
||||
&& ix == 0
|
||||
&& parse_slash_command(&text[ix..]).is_some()
|
||||
{
|
||||
format!(" {}", &text[ix..crease_range.start]).into()
|
||||
} else {
|
||||
text[ix..crease_range.start].into()
|
||||
};
|
||||
chunks.push(chunk);
|
||||
}
|
||||
let chunk = match mention {
|
||||
Mention::Text { uri, content } => {
|
||||
|
@ -644,7 +690,14 @@ impl MessageEditor {
|
|||
}
|
||||
|
||||
if ix < text.len() {
|
||||
let last_chunk = text[ix..].trim_end();
|
||||
let last_chunk = if prevent_slash_commands
|
||||
&& ix == 0
|
||||
&& parse_slash_command(&text[ix..]).is_some()
|
||||
{
|
||||
format!(" {}", text[ix..].trim_end())
|
||||
} else {
|
||||
text[ix..].trim_end().to_owned()
|
||||
};
|
||||
if !last_chunk.is_empty() {
|
||||
chunks.push(last_chunk.into());
|
||||
}
|
||||
|
@ -990,6 +1043,48 @@ impl MessageEditor {
|
|||
cx.notify();
|
||||
}
|
||||
|
||||
fn highlight_slash_command(
|
||||
&mut self,
|
||||
semantics_provider: Rc<SlashCommandSemanticsProvider>,
|
||||
editor: Entity<Editor>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
struct InvalidSlashCommand;
|
||||
|
||||
self._parse_slash_command_task = cx.spawn_in(window, async move |_, cx| {
|
||||
cx.background_executor()
|
||||
.timer(PARSE_SLASH_COMMAND_DEBOUNCE)
|
||||
.await;
|
||||
editor
|
||||
.update_in(cx, |editor, window, cx| {
|
||||
let snapshot = editor.snapshot(window, cx);
|
||||
let range = parse_slash_command(&editor.text(cx));
|
||||
semantics_provider.range.set(range);
|
||||
if let Some((start, end)) = range {
|
||||
editor.highlight_text::<InvalidSlashCommand>(
|
||||
vec![
|
||||
snapshot.buffer_snapshot.anchor_after(start)
|
||||
..snapshot.buffer_snapshot.anchor_before(end),
|
||||
],
|
||||
HighlightStyle {
|
||||
underline: Some(UnderlineStyle {
|
||||
thickness: px(1.),
|
||||
color: Some(gpui::red()),
|
||||
wavy: true,
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
cx,
|
||||
);
|
||||
} else {
|
||||
editor.clear_highlights::<InvalidSlashCommand>(cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.editor.update(cx, |editor, cx| {
|
||||
|
@ -1416,6 +1511,118 @@ impl MentionSet {
|
|||
}
|
||||
}
|
||||
|
||||
struct SlashCommandSemanticsProvider {
|
||||
range: Cell<Option<(usize, usize)>>,
|
||||
}
|
||||
|
||||
impl SemanticsProvider for SlashCommandSemanticsProvider {
|
||||
fn hover(
|
||||
&self,
|
||||
buffer: &Entity<Buffer>,
|
||||
position: text::Anchor,
|
||||
cx: &mut App,
|
||||
) -> Option<Task<Vec<project::Hover>>> {
|
||||
let snapshot = buffer.read(cx).snapshot();
|
||||
let offset = position.to_offset(&snapshot);
|
||||
let (start, end) = self.range.get()?;
|
||||
if !(start..end).contains(&offset) {
|
||||
return None;
|
||||
}
|
||||
let range = snapshot.anchor_after(start)..snapshot.anchor_after(end);
|
||||
return Some(Task::ready(vec![project::Hover {
|
||||
contents: vec![project::HoverBlock {
|
||||
text: "Slash commands are not supported".into(),
|
||||
kind: project::HoverBlockKind::PlainText,
|
||||
}],
|
||||
range: Some(range),
|
||||
language: None,
|
||||
}]));
|
||||
}
|
||||
|
||||
fn inline_values(
|
||||
&self,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_range: Range<text::Anchor>,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn inlay_hints(
|
||||
&self,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_range: Range<text::Anchor>,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<Vec<project::InlayHint>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_inlay_hint(
|
||||
&self,
|
||||
_hint: project::InlayHint,
|
||||
_buffer_handle: Entity<Buffer>,
|
||||
_server_id: lsp::LanguageServerId,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<anyhow::Result<project::InlayHint>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn supports_inlay_hints(&self, _buffer: &Entity<Buffer>, _cx: &mut App) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn document_highlights(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Vec<project::DocumentHighlight>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn definitions(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_kind: editor::GotoDefinitionKind,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Vec<project::LocationLink>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn range_for_rename(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<Option<Range<text::Anchor>>>>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn perform_rename(
|
||||
&self,
|
||||
_buffer: &Entity<Buffer>,
|
||||
_position: text::Anchor,
|
||||
_new_name: String,
|
||||
_cx: &mut App,
|
||||
) -> Option<Task<Result<project::ProjectTransaction>>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_slash_command(text: &str) -> Option<(usize, usize)> {
|
||||
if let Some(remainder) = text.strip_prefix('/') {
|
||||
let pos = remainder
|
||||
.find(char::is_whitespace)
|
||||
.unwrap_or(remainder.len());
|
||||
let command = &remainder[..pos];
|
||||
if !command.is_empty() && command.chars().all(char::is_alphanumeric) {
|
||||
return Some((0, 1 + command.len()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{ops::Range, path::Path, sync::Arc};
|
||||
|
@ -1463,6 +1670,7 @@ mod tests {
|
|||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
"Test",
|
||||
false,
|
||||
EditorMode::AutoHeight {
|
||||
min_lines: 1,
|
||||
max_lines: None,
|
||||
|
@ -1661,6 +1869,7 @@ mod tests {
|
|||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
"Test",
|
||||
false,
|
||||
EditorMode::AutoHeight {
|
||||
max_lines: None,
|
||||
min_lines: 1,
|
||||
|
|
|
@ -7,7 +7,7 @@ use acp_thread::{AgentConnection, Plan};
|
|||
use action_log::ActionLog;
|
||||
use agent::{TextThreadStore, ThreadStore};
|
||||
use agent_client_protocol::{self as acp};
|
||||
use agent_servers::AgentServer;
|
||||
use agent_servers::{AgentServer, ClaudeCode};
|
||||
use agent_settings::{AgentProfileId, AgentSettings, CompletionMode, NotifyWhenAgentWaiting};
|
||||
use anyhow::bail;
|
||||
use audio::{Audio, Sound};
|
||||
|
@ -160,6 +160,7 @@ impl AcpThreadView {
|
|||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let prevent_slash_commands = agent.clone().downcast::<ClaudeCode>().is_some();
|
||||
let message_editor = cx.new(|cx| {
|
||||
MessageEditor::new(
|
||||
workspace.clone(),
|
||||
|
@ -167,6 +168,7 @@ impl AcpThreadView {
|
|||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
"Message the agent - @ to include context",
|
||||
prevent_slash_commands,
|
||||
editor::EditorMode::AutoHeight {
|
||||
min_lines: MIN_EDITOR_LINES,
|
||||
max_lines: Some(MAX_EDITOR_LINES),
|
||||
|
@ -184,6 +186,7 @@ impl AcpThreadView {
|
|||
project.clone(),
|
||||
thread_store.clone(),
|
||||
text_thread_store.clone(),
|
||||
prevent_slash_commands,
|
||||
)
|
||||
});
|
||||
|
||||
|
@ -3925,6 +3928,10 @@ pub(crate) mod tests {
|
|||
) -> Task<gpui::Result<Rc<dyn AgentConnection>>> {
|
||||
Task::ready(Ok(Rc::new(self.connection.clone())))
|
||||
}
|
||||
|
||||
fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
|
|
@ -167,7 +167,8 @@ pub fn hover_at_inlay(
|
|||
|
||||
let language_registry = project.read_with(cx, |p, _| p.languages().clone())?;
|
||||
let blocks = vec![inlay_hover.tooltip];
|
||||
let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
|
||||
let parsed_content =
|
||||
parse_blocks(&blocks, Some(&language_registry), None, cx).await;
|
||||
|
||||
let scroll_handle = ScrollHandle::new();
|
||||
|
||||
|
@ -251,7 +252,9 @@ fn show_hover(
|
|||
|
||||
let (excerpt_id, _, _) = editor.buffer().read(cx).excerpt_containing(anchor, cx)?;
|
||||
|
||||
let language_registry = editor.project()?.read(cx).languages().clone();
|
||||
let language_registry = editor
|
||||
.project()
|
||||
.map(|project| project.read(cx).languages().clone());
|
||||
let provider = editor.semantics_provider.clone()?;
|
||||
|
||||
if !ignore_timeout {
|
||||
|
@ -443,7 +446,8 @@ fn show_hover(
|
|||
text: format!("Unicode character U+{:02X}", invisible as u32),
|
||||
kind: HoverBlockKind::PlainText,
|
||||
}];
|
||||
let parsed_content = parse_blocks(&blocks, &language_registry, None, cx).await;
|
||||
let parsed_content =
|
||||
parse_blocks(&blocks, language_registry.as_ref(), None, cx).await;
|
||||
let scroll_handle = ScrollHandle::new();
|
||||
let subscription = this
|
||||
.update(cx, |_, cx| {
|
||||
|
@ -493,7 +497,8 @@ fn show_hover(
|
|||
|
||||
let blocks = hover_result.contents;
|
||||
let language = hover_result.language;
|
||||
let parsed_content = parse_blocks(&blocks, &language_registry, language, cx).await;
|
||||
let parsed_content =
|
||||
parse_blocks(&blocks, language_registry.as_ref(), language, cx).await;
|
||||
let scroll_handle = ScrollHandle::new();
|
||||
hover_highlights.push(range.clone());
|
||||
let subscription = this
|
||||
|
@ -583,7 +588,7 @@ fn same_diagnostic_hover(editor: &Editor, snapshot: &EditorSnapshot, anchor: Anc
|
|||
|
||||
async fn parse_blocks(
|
||||
blocks: &[HoverBlock],
|
||||
language_registry: &Arc<LanguageRegistry>,
|
||||
language_registry: Option<&Arc<LanguageRegistry>>,
|
||||
language: Option<Arc<Language>>,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Option<Entity<Markdown>> {
|
||||
|
@ -603,7 +608,7 @@ async fn parse_blocks(
|
|||
.new_window_entity(|_window, cx| {
|
||||
Markdown::new(
|
||||
combined_text.into(),
|
||||
Some(language_registry.clone()),
|
||||
language_registry.cloned(),
|
||||
language.map(|language| language.name()),
|
||||
cx,
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue