WIP
This commit is contained in:
parent
fd8ea2acfc
commit
251baacdab
12 changed files with 75 additions and 33 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -11,6 +11,7 @@ dependencies = [
|
||||||
"agent-client-protocol",
|
"agent-client-protocol",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"buffer_diff",
|
"buffer_diff",
|
||||||
|
"chrono",
|
||||||
"collections",
|
"collections",
|
||||||
"editor",
|
"editor",
|
||||||
"env_logger 0.11.8",
|
"env_logger 0.11.8",
|
||||||
|
|
|
@ -21,6 +21,7 @@ agent-client-protocol.workspace = true
|
||||||
agent.workspace = true
|
agent.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
buffer_diff.workspace = true
|
buffer_diff.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
collections.workspace = true
|
collections.workspace = true
|
||||||
editor.workspace = true
|
editor.workspace = true
|
||||||
file_icons.workspace = true
|
file_icons.workspace = true
|
||||||
|
|
|
@ -6,11 +6,13 @@ mod terminal;
|
||||||
pub use connection::*;
|
pub use connection::*;
|
||||||
pub use diff::*;
|
pub use diff::*;
|
||||||
pub use mention::*;
|
pub use mention::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
pub use terminal::*;
|
pub use terminal::*;
|
||||||
|
|
||||||
use action_log::ActionLog;
|
use action_log::ActionLog;
|
||||||
use agent_client_protocol as acp;
|
use agent_client_protocol as acp;
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use editor::Bias;
|
use editor::Bias;
|
||||||
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
|
use futures::{FutureExt, channel::oneshot, future::BoxFuture};
|
||||||
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
|
use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity};
|
||||||
|
@ -632,6 +634,13 @@ impl PlanEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AcpThreadMetadata {
|
||||||
|
pub id: acp::SessionId,
|
||||||
|
pub title: SharedString,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AcpThread {
|
pub struct AcpThread {
|
||||||
title: SharedString,
|
title: SharedString,
|
||||||
entries: Vec<AgentThreadEntry>,
|
entries: Vec<AgentThreadEntry>,
|
||||||
|
@ -1608,7 +1617,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use anyhow::anyhow;
|
use anyhow::anyhow;
|
||||||
use futures::{channel::mpsc, future::LocalBoxFuture, select};
|
use futures::{channel::mpsc, future::LocalBoxFuture, select};
|
||||||
use gpui::{AsyncApp, TestAppContext, WeakEntity};
|
use gpui::{App, AsyncApp, TestAppContext, WeakEntity};
|
||||||
use indoc::indoc;
|
use indoc::indoc;
|
||||||
use project::{FakeFs, Fs};
|
use project::{FakeFs, Fs};
|
||||||
use rand::Rng as _;
|
use rand::Rng as _;
|
||||||
|
@ -2284,7 +2293,7 @@ mod tests {
|
||||||
self: Rc<Self>,
|
self: Rc<Self>,
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
_cwd: &Path,
|
_cwd: &Path,
|
||||||
cx: &mut gpui::App,
|
cx: &mut App,
|
||||||
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
) -> Task<gpui::Result<Entity<AcpThread>>> {
|
||||||
let session_id = acp::SessionId(
|
let session_id = acp::SessionId(
|
||||||
rand::thread_rng()
|
rand::thread_rng()
|
||||||
|
@ -2300,6 +2309,10 @@ mod tests {
|
||||||
Task::ready(Ok(thread))
|
Task::ready(Ok(thread))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
|
fn authenticate(&self, method: acp::AuthMethodId, _cx: &mut App) -> Task<gpui::Result<()>> {
|
||||||
if self.auth_methods().iter().any(|m| m.id == method) {
|
if self.auth_methods().iter().any(|m| m.id == method) {
|
||||||
Task::ready(Ok(()))
|
Task::ready(Ok(()))
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::AcpThread;
|
use crate::{AcpThread, AcpThreadMetadata};
|
||||||
use agent_client_protocol::{self as acp};
|
use agent_client_protocol::{self as acp};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use collections::IndexMap;
|
use collections::IndexMap;
|
||||||
|
@ -26,6 +26,8 @@ pub trait AgentConnection {
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> Task<Result<Entity<AcpThread>>>;
|
) -> Task<Result<Entity<AcpThread>>>;
|
||||||
|
|
||||||
|
fn list_threads(&self, _cx: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>>;
|
||||||
|
|
||||||
fn auth_methods(&self) -> &[acp::AuthMethod];
|
fn auth_methods(&self) -> &[acp::AuthMethod];
|
||||||
|
|
||||||
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
|
fn authenticate(&self, method: acp::AuthMethodId, cx: &mut App) -> Task<Result<()>>;
|
||||||
|
@ -264,6 +266,10 @@ mod test_support {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt(
|
fn prompt(
|
||||||
&self,
|
&self,
|
||||||
_id: Option<UserMessageId>,
|
_id: Option<UserMessageId>,
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
|
use crate::ThreadsDatabase;
|
||||||
use crate::{
|
use crate::{
|
||||||
AgentResponseEvent, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DeletePathTool,
|
AgentResponseEvent, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DeletePathTool,
|
||||||
DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool,
|
DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool,
|
||||||
MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, Thread,
|
MovePathTool, NowTool, OpenTool, ReadFileTool, TerminalTool, ThinkingTool, Thread,
|
||||||
ToolCallAuthorization, UserMessageContent, WebSearchTool, templates::Templates,
|
ToolCallAuthorization, UserMessageContent, WebSearchTool, templates::Templates,
|
||||||
};
|
};
|
||||||
use acp_thread::AgentModelSelector;
|
use acp_thread::{AcpThreadMetadata, AgentModelSelector};
|
||||||
use agent_client_protocol as acp;
|
use agent_client_protocol as acp;
|
||||||
use agent_settings::AgentSettings;
|
use agent_settings::AgentSettings;
|
||||||
use anyhow::{Context as _, Result, anyhow};
|
use anyhow::{Context as _, Result, anyhow};
|
||||||
use collections::{HashSet, IndexMap};
|
use collections::{HashSet, IndexMap};
|
||||||
use fs::Fs;
|
use fs::Fs;
|
||||||
use futures::channel::mpsc;
|
use futures::channel::mpsc;
|
||||||
|
use futures::future::Shared;
|
||||||
use futures::{StreamExt, future};
|
use futures::{StreamExt, future};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
|
App, AppContext, AsyncApp, Context, Entity, SharedString, Subscription, Task, WeakEntity,
|
||||||
|
@ -166,6 +168,7 @@ pub struct NativeAgent {
|
||||||
models: LanguageModels,
|
models: LanguageModels,
|
||||||
project: Entity<Project>,
|
project: Entity<Project>,
|
||||||
prompt_store: Option<Entity<PromptStore>>,
|
prompt_store: Option<Entity<PromptStore>>,
|
||||||
|
thread_database: Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>>,
|
||||||
fs: Arc<dyn Fs>,
|
fs: Arc<dyn Fs>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
@ -208,6 +211,7 @@ impl NativeAgent {
|
||||||
context_server_registry: cx.new(|cx| {
|
context_server_registry: cx.new(|cx| {
|
||||||
ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
|
ContextServerRegistry::new(project.read(cx).context_server_store(), cx)
|
||||||
}),
|
}),
|
||||||
|
thread_database: ThreadsDatabase::connect(cx),
|
||||||
templates,
|
templates,
|
||||||
models: LanguageModels::new(cx),
|
models: LanguageModels::new(cx),
|
||||||
project,
|
project,
|
||||||
|
@ -751,6 +755,23 @@ impl acp_thread::AgentConnection for NativeAgentConnection {
|
||||||
Task::ready(Ok(()))
|
Task::ready(Ok(()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, cx: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
let database = self.0.read(cx).thread_database.clone();
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let database = database.await.map_err(|e| anyhow!(e))?;
|
||||||
|
let results = database.list_threads().await?;
|
||||||
|
|
||||||
|
Ok(results
|
||||||
|
.into_iter()
|
||||||
|
.map(|thread| AcpThreadMetadata {
|
||||||
|
id: thread.id,
|
||||||
|
title: thread.title,
|
||||||
|
updated_at: thread.updated_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
|
fn model_selector(&self) -> Option<Rc<dyn AgentModelSelector>> {
|
||||||
Some(Rc::new(self.clone()) as Rc<dyn AgentModelSelector>)
|
Some(Rc::new(self.clone()) as Rc<dyn AgentModelSelector>)
|
||||||
}
|
}
|
||||||
|
|
|
@ -216,10 +216,6 @@ impl Column for DataType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GlobalThreadsDatabase(Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>>);
|
|
||||||
|
|
||||||
impl Global for GlobalThreadsDatabase {}
|
|
||||||
|
|
||||||
pub(crate) struct ThreadsDatabase {
|
pub(crate) struct ThreadsDatabase {
|
||||||
executor: BackgroundExecutor,
|
executor: BackgroundExecutor,
|
||||||
connection: Arc<Mutex<Connection>>,
|
connection: Arc<Mutex<Connection>>,
|
||||||
|
@ -234,34 +230,26 @@ impl ThreadsDatabase {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThreadsDatabase {
|
impl ThreadsDatabase {
|
||||||
fn global_future(
|
pub fn connect(cx: &mut App) -> Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>> {
|
||||||
cx: &mut App,
|
|
||||||
) -> Shared<Task<Result<Arc<ThreadsDatabase>, Arc<anyhow::Error>>>> {
|
|
||||||
GlobalThreadsDatabase::global(cx).0.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(cx: &mut App) {
|
|
||||||
let executor = cx.background_executor().clone();
|
let executor = cx.background_executor().clone();
|
||||||
let database_future = executor
|
executor
|
||||||
.spawn({
|
.spawn({
|
||||||
let executor = executor.clone();
|
let executor = executor.clone();
|
||||||
let threads_dir = paths::data_dir().join("threads");
|
|
||||||
async move {
|
async move {
|
||||||
match ThreadsDatabase::new(threads_dir, executor) {
|
match ThreadsDatabase::new(executor) {
|
||||||
Ok(db) => Ok(Arc::new(db)),
|
Ok(db) => Ok(Arc::new(db)),
|
||||||
Err(err) => Err(Arc::new(err)),
|
Err(err) => Err(Arc::new(err)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.shared();
|
.shared()
|
||||||
|
|
||||||
cx.set_global(GlobalThreadsDatabase(database_future));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(threads_dir: PathBuf, executor: BackgroundExecutor) -> Result<Self> {
|
pub fn new(executor: BackgroundExecutor) -> Result<Self> {
|
||||||
let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) {
|
let connection = if *ZED_STATELESS || cfg!(any(feature = "test-support", test)) {
|
||||||
Connection::open_memory(Some("THREAD_FALLBACK_DB"))
|
Connection::open_memory(Some("THREAD_FALLBACK_DB"))
|
||||||
} else {
|
} else {
|
||||||
|
let threads_dir = paths::data_dir().join("threads");
|
||||||
std::fs::create_dir_all(&threads_dir)?;
|
std::fs::create_dir_all(&threads_dir)?;
|
||||||
let sqlite_path = threads_dir.join("threads.db");
|
let sqlite_path = threads_dir.join("threads.db");
|
||||||
Connection::open_file(&sqlite_path.to_string_lossy())
|
Connection::open_file(&sqlite_path.to_string_lossy())
|
||||||
|
@ -397,7 +385,6 @@ mod tests {
|
||||||
use gpui::TestAppContext;
|
use gpui::TestAppContext;
|
||||||
use http_client::FakeHttpClient;
|
use http_client::FakeHttpClient;
|
||||||
use language_model::Role;
|
use language_model::Role;
|
||||||
use pretty_assertions::assert_matches;
|
|
||||||
use project::Project;
|
use project::Project;
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
|
|
||||||
|
@ -408,7 +395,6 @@ mod tests {
|
||||||
cx.set_global(settings_store);
|
cx.set_global(settings_store);
|
||||||
Project::init_settings(cx);
|
Project::init_settings(cx);
|
||||||
language::init(cx);
|
language::init(cx);
|
||||||
ThreadsDatabase::init(cx);
|
|
||||||
|
|
||||||
let http_client = FakeHttpClient::with_404_response();
|
let http_client = FakeHttpClient::with_404_response();
|
||||||
let clock = Arc::new(clock::FakeSystemClock::new());
|
let clock = Arc::new(clock::FakeSystemClock::new());
|
||||||
|
@ -453,10 +439,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
let db = cx
|
let db = cx.update(|cx| ThreadsDatabase::connect(cx)).await.unwrap();
|
||||||
.update(|cx| ThreadsDatabase::global_future(cx))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let threads = db.list_threads().await.unwrap();
|
let threads = db.list_threads().await.unwrap();
|
||||||
assert_eq!(threads.len(), 1);
|
assert_eq!(threads.len(), 1);
|
||||||
let thread = db
|
let thread = db
|
||||||
|
|
|
@ -10,7 +10,7 @@ use ui::App;
|
||||||
use util::ResultExt as _;
|
use util::ResultExt as _;
|
||||||
|
|
||||||
use crate::AgentServerCommand;
|
use crate::AgentServerCommand;
|
||||||
use acp_thread::{AcpThread, AgentConnection, AuthRequired};
|
use acp_thread::{AcpThread, AcpThreadMetadata, AgentConnection, AuthRequired};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct OldAcpClientDelegate {
|
struct OldAcpClientDelegate {
|
||||||
|
@ -451,6 +451,10 @@ impl AgentConnection for AcpConnection {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _cx: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
Task::ready(Ok(Vec::default()))
|
||||||
|
}
|
||||||
|
|
||||||
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
fn auth_methods(&self) -> &[acp::AuthMethod] {
|
||||||
&[]
|
&[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ use anyhow::{Context as _, Result};
|
||||||
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity};
|
||||||
|
|
||||||
use crate::{AgentServerCommand, acp::UnsupportedVersion};
|
use crate::{AgentServerCommand, acp::UnsupportedVersion};
|
||||||
use acp_thread::{AcpThread, AgentConnection, AuthRequired};
|
use acp_thread::{AcpThread, AcpThreadMetadata, AgentConnection, AuthRequired};
|
||||||
|
|
||||||
pub struct AcpConnection {
|
pub struct AcpConnection {
|
||||||
server_name: &'static str,
|
server_name: &'static str,
|
||||||
|
@ -169,6 +169,10 @@ impl AgentConnection for AcpConnection {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _cx: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
Task::ready(Ok(Vec::default()))
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt(
|
fn prompt(
|
||||||
&self,
|
&self,
|
||||||
_id: Option<acp_thread::UserMessageId>,
|
_id: Option<acp_thread::UserMessageId>,
|
||||||
|
|
|
@ -30,7 +30,7 @@ use util::{ResultExt, debug_panic};
|
||||||
use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
|
use crate::claude::mcp_server::{ClaudeZedMcpServer, McpConfig};
|
||||||
use crate::claude::tools::ClaudeTool;
|
use crate::claude::tools::ClaudeTool;
|
||||||
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
|
use crate::{AgentServer, AgentServerCommand, AllAgentServersSettings};
|
||||||
use acp_thread::{AcpThread, AgentConnection};
|
use acp_thread::{AcpThread, AcpThreadMetadata, AgentConnection};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ClaudeCode;
|
pub struct ClaudeCode;
|
||||||
|
@ -209,6 +209,10 @@ impl AgentConnection for ClaudeAgentConnection {
|
||||||
Task::ready(Err(anyhow!("Authentication not supported")))
|
Task::ready(Err(anyhow!("Authentication not supported")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _cx: &mut App) -> Task<Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
Task::ready(Ok(Vec::default()))
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt(
|
fn prompt(
|
||||||
&self,
|
&self,
|
||||||
_id: Option<acp_thread::UserMessageId>,
|
_id: Option<acp_thread::UserMessageId>,
|
||||||
|
|
|
@ -3583,7 +3583,7 @@ fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod tests {
|
pub(crate) mod tests {
|
||||||
use acp_thread::StubAgentConnection;
|
use acp_thread::{AcpThreadMetadata, StubAgentConnection};
|
||||||
use agent::{TextThreadStore, ThreadStore};
|
use agent::{TextThreadStore, ThreadStore};
|
||||||
use agent_client_protocol::SessionId;
|
use agent_client_protocol::SessionId;
|
||||||
use editor::EditorSettings;
|
use editor::EditorSettings;
|
||||||
|
@ -3819,6 +3819,10 @@ pub(crate) mod tests {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_threads(&self, _cx: &mut App) -> Task<gpui::Result<Vec<AcpThreadMetadata>>> {
|
||||||
|
Task::ready(Ok(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt(
|
fn prompt(
|
||||||
&self,
|
&self,
|
||||||
_id: Option<acp_thread::UserMessageId>,
|
_id: Option<acp_thread::UserMessageId>,
|
||||||
|
|
|
@ -9,6 +9,7 @@ mod context_picker;
|
||||||
mod context_server_configuration;
|
mod context_server_configuration;
|
||||||
mod context_strip;
|
mod context_strip;
|
||||||
mod debug;
|
mod debug;
|
||||||
|
mod history_store;
|
||||||
mod inline_assistant;
|
mod inline_assistant;
|
||||||
mod inline_prompt_editor;
|
mod inline_prompt_editor;
|
||||||
mod language_model_selector;
|
mod language_model_selector;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
use crate::history_store::{HistoryEntry, HistoryStore};
|
||||||
use crate::{AgentPanel, RemoveSelectedThread};
|
use crate::{AgentPanel, RemoveSelectedThread};
|
||||||
use agent::history_store::{HistoryEntry, HistoryStore};
|
|
||||||
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
|
use chrono::{Datelike as _, Local, NaiveDate, TimeDelta};
|
||||||
use editor::{Editor, EditorEvent};
|
use editor::{Editor, EditorEvent};
|
||||||
use fuzzy::{StringMatch, StringMatchCandidate};
|
use fuzzy::{StringMatch, StringMatchCandidate};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue