mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-08-03 17:58:17 +00:00
feat: remove more code
This commit is contained in:
parent
ccd51eb19a
commit
7e9bddb763
5 changed files with 88 additions and 351 deletions
|
@ -1,92 +1,56 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as SyncMutex};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
iter,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex as SyncMutex},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use futures::future::join_all;
|
||||
use itertools::Itertools;
|
||||
use log::error;
|
||||
use log::trace;
|
||||
use log::warn;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use tower_lsp::lsp_types::SelectionRange;
|
||||
use itertools::{Format, Itertools};
|
||||
use log::{error, trace, warn};
|
||||
use tokio::sync::{broadcast, mpsc, watch, Mutex, RwLock};
|
||||
use tower_lsp::lsp_types::{
|
||||
CompletionResponse, DocumentSymbolResponse, Documentation, Hover, Location as LspLocation,
|
||||
MarkupContent, MarkupKind, Position as LspPosition, SemanticTokens, SemanticTokensDelta,
|
||||
SemanticTokensFullDeltaResult, SemanticTokensResult, SignatureHelp, SignatureInformation,
|
||||
SymbolInformation, SymbolKind, TextDocumentContentChangeEvent, Url,
|
||||
CompletionResponse, DiagnosticRelatedInformation, DocumentSymbolResponse, Documentation, Hover,
|
||||
Location as LspLocation, MarkupContent, MarkupKind, Position as LspPosition, SelectionRange,
|
||||
SemanticTokens, SemanticTokensDelta, SemanticTokensFullDeltaResult, SemanticTokensResult,
|
||||
SignatureHelp, SignatureInformation, SymbolInformation, SymbolKind,
|
||||
TextDocumentContentChangeEvent, Url,
|
||||
};
|
||||
use typst::diag::SourceDiagnostic;
|
||||
use typst_ts_core::typst::prelude::EcoVec;
|
||||
|
||||
use typst::diag::FileError;
|
||||
use typst::diag::FileResult;
|
||||
use typst::diag::SourceResult;
|
||||
use typst::foundations::Func;
|
||||
use typst::foundations::ParamInfo;
|
||||
use typst::foundations::Value;
|
||||
use typst::diag::{EcoString, FileError, FileResult, SourceDiagnostic, SourceResult, Tracepoint};
|
||||
use typst::foundations::{Func, ParamInfo, Value};
|
||||
use typst::layout::Position;
|
||||
use typst::model::Document;
|
||||
use typst::syntax::ast;
|
||||
use typst::syntax::ast::AstNode;
|
||||
use typst::syntax::LinkedNode;
|
||||
use typst::syntax::Source;
|
||||
use typst::syntax::Span;
|
||||
use typst::syntax::SyntaxKind;
|
||||
use typst::syntax::VirtualPath;
|
||||
use typst::syntax::{
|
||||
ast::{self, AstNode},
|
||||
FileId, LinkedNode, Source, Span, Spanned, SyntaxKind, VirtualPath,
|
||||
};
|
||||
use typst::World;
|
||||
use typst_preview::CompilationHandleImpl;
|
||||
use typst_ts_compiler::service::WorkspaceProvider;
|
||||
use typst_ts_compiler::service::{
|
||||
CompileActor, CompileClient as TsCompileClient, CompileExporter, Compiler, WorldExporter,
|
||||
};
|
||||
use typst_ts_compiler::service::{CompileDriver as CompileDriverInner, CompileMiddleware};
|
||||
use typst_ts_compiler::vfs::notify::{FileChangeSet, MemoryEvent};
|
||||
use typst_ts_compiler::NotifyApi;
|
||||
use typst_ts_compiler::Time;
|
||||
use typst_ts_compiler::TypstSystemWorld;
|
||||
use typst_ts_core::config::CompileOpts;
|
||||
use typst_ts_core::debug_loc::SourceSpanOffset;
|
||||
use typst_ts_core::error::prelude::*;
|
||||
use typst_ts_core::Bytes;
|
||||
use typst_ts_core::DynExporter;
|
||||
use typst_ts_core::Error;
|
||||
|
||||
use typst_preview::{CompilationHandle, CompileStatus};
|
||||
use typst_preview::{CompileHost, EditorServer, MemoryFiles, MemoryFilesShort, SourceFileServer};
|
||||
use typst_preview::{DocToSrcJumpInfo, Location};
|
||||
use typst_ts_core::ImmutPath;
|
||||
use typst_ts_core::TypstDocument;
|
||||
use typst_ts_core::TypstFileId;
|
||||
|
||||
use itertools::Format;
|
||||
use std::iter;
|
||||
use tower_lsp::lsp_types::DiagnosticRelatedInformation;
|
||||
use typst::diag::{EcoString, Tracepoint};
|
||||
use typst::syntax::{FileId, Spanned};
|
||||
use typst_ts_compiler::service::{
|
||||
CompileActor, CompileClient as TsCompileClient, CompileDriver as CompileDriverInner,
|
||||
CompileExporter, CompileMiddleware, Compiler, WorkspaceProvider, WorldExporter,
|
||||
};
|
||||
use typst_ts_compiler::vfs::notify::{FileChangeSet, MemoryEvent};
|
||||
use typst_ts_compiler::{NotifyApi, Time, TypstSystemWorld};
|
||||
use typst_ts_core::{
|
||||
config::CompileOpts, debug_loc::SourceSpanOffset, error::prelude::*, typst::prelude::EcoVec,
|
||||
Bytes, DynExporter, Error, ImmutPath, TypstDocument, TypstFileId,
|
||||
};
|
||||
|
||||
use crate::actor::render::PdfExportActor;
|
||||
use crate::actor::render::RenderActorRequest;
|
||||
use crate::analysis::analyze::analyze_expr;
|
||||
use crate::config::PositionEncoding;
|
||||
use crate::lsp_typst_boundary::lsp_to_typst;
|
||||
use crate::lsp_typst_boundary::typst_to_lsp;
|
||||
use crate::lsp_typst_boundary::LspDiagnostic;
|
||||
use crate::lsp_typst_boundary::LspRange;
|
||||
use crate::lsp_typst_boundary::LspRawRange;
|
||||
use crate::lsp_typst_boundary::LspSeverity;
|
||||
use crate::lsp_typst_boundary::TypstDiagnostic;
|
||||
use crate::lsp_typst_boundary::TypstSeverity;
|
||||
use crate::lsp_typst_boundary::TypstSpan;
|
||||
use crate::lsp::LspHost;
|
||||
use crate::lsp_typst_boundary::{
|
||||
lsp_to_typst, typst_to_lsp, LspDiagnostic, LspRange, LspRawRange, LspSeverity, TypstDiagnostic,
|
||||
TypstSeverity, TypstSpan,
|
||||
};
|
||||
use crate::semantic_tokens::SemanticTokenCache;
|
||||
use crate::server::LspHost;
|
||||
|
||||
use super::render::PdfExportActor;
|
||||
use super::render::RenderActorRequest;
|
||||
|
||||
type CompileService<H> = CompileActor<Reporter<CompileExporter<CompileDriver>, H>>;
|
||||
type CompileClient<H> = TsCompileClient<CompileService<H>>;
|
||||
|
|
|
@ -5,23 +5,10 @@ use futures::future::BoxFuture;
|
|||
use itertools::Itertools;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use tower_lsp::lsp_types::{
|
||||
self, ConfigurationItem, InitializeParams, PositionEncodingKind, Registration,
|
||||
};
|
||||
use tower_lsp::lsp_types::{self, ConfigurationItem, InitializeParams, PositionEncodingKind};
|
||||
|
||||
use crate::ext::InitializeParamsExt;
|
||||
|
||||
const CONFIG_REGISTRATION_ID: &str = "config";
|
||||
const CONFIG_METHOD_ID: &str = "workspace/didChangeConfiguration";
|
||||
|
||||
pub fn get_config_registration() -> Registration {
|
||||
Registration {
|
||||
id: CONFIG_REGISTRATION_ID.to_owned(),
|
||||
method: CONFIG_METHOD_ID.to_owned(),
|
||||
register_options: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExperimentalFormatterMode {
|
||||
|
|
|
@ -1,34 +1,68 @@
|
|||
pub use tower_lsp::Client as LspHost;
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use futures::FutureExt;
|
||||
use log::{error, info, trace};
|
||||
use once_cell::sync::OnceCell;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tower_lsp::lsp_types::*;
|
||||
use tower_lsp::{jsonrpc, LanguageServer};
|
||||
use typst::model::Document;
|
||||
use typst_ts_core::config::CompileOpts;
|
||||
|
||||
use crate::actor;
|
||||
use crate::actor::typst::CompileCluster;
|
||||
use crate::actor::typst::{
|
||||
CompilerQueryResponse, CompletionRequest, DocumentSymbolRequest, HoverRequest,
|
||||
OnSaveExportRequest, SelectionRangeRequest, SemanticTokensDeltaRequest,
|
||||
SemanticTokensFullRequest, SignatureHelpRequest, SymbolRequest,
|
||||
};
|
||||
use crate::config::{
|
||||
get_config_registration, Config, ConstConfig, ExperimentalFormatterMode, ExportPdfMode,
|
||||
SemanticTokensMode,
|
||||
Config, ConstConfig, ExperimentalFormatterMode, ExportPdfMode, SemanticTokensMode,
|
||||
};
|
||||
use crate::ext::InitializeParamsExt;
|
||||
// use crate::server::formatting::{get_formatting_registration,
|
||||
// get_formatting_unregistration};
|
||||
// use crate::workspace::Workspace;
|
||||
|
||||
use super::semantic_tokens::{
|
||||
get_semantic_tokens_options, get_semantic_tokens_registration,
|
||||
get_semantic_tokens_unregistration,
|
||||
};
|
||||
use super::TypstServer;
|
||||
|
||||
pub struct TypstServer {
|
||||
pub client: LspHost,
|
||||
pub document: Mutex<Arc<Document>>,
|
||||
// typst_thread: TypstThread,
|
||||
pub universe: OnceCell<CompileCluster>,
|
||||
pub config: Arc<RwLock<Config>>,
|
||||
pub const_config: OnceCell<ConstConfig>,
|
||||
}
|
||||
|
||||
impl TypstServer {
|
||||
pub fn new(client: LspHost) -> Self {
|
||||
Self {
|
||||
// typst_thread: Default::default(),
|
||||
universe: Default::default(),
|
||||
config: Default::default(),
|
||||
const_config: Default::default(),
|
||||
client,
|
||||
document: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn const_config(&self) -> &ConstConfig {
|
||||
self.const_config
|
||||
.get()
|
||||
.expect("const config should be initialized")
|
||||
}
|
||||
|
||||
pub fn universe(&self) -> &CompileCluster {
|
||||
self.universe.get().expect("universe should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! run_query {
|
||||
($self: expr, $query: ident, $req: expr) => {{
|
||||
|
@ -208,51 +242,19 @@ impl LanguageServer for TypstServer {
|
|||
}));
|
||||
}
|
||||
|
||||
// if const_config.supports_document_formatting_dynamic_registration {
|
||||
// trace!("setting up to dynamically register document formatting support");
|
||||
|
||||
// let client = self.client.clone();
|
||||
// let register = move || {
|
||||
// trace!("dynamically registering document formatting");
|
||||
// let client = client.clone();
|
||||
// async move {
|
||||
// client
|
||||
// .register_capability(vec![get_formatting_registration()])
|
||||
// .await
|
||||
// .context("could not register document formatting")
|
||||
// }
|
||||
// };
|
||||
|
||||
// let client = self.client.clone();
|
||||
// let unregister = move || {
|
||||
// trace!("unregistering document formatting");
|
||||
// let client = client.clone();
|
||||
// async move {
|
||||
// client
|
||||
// .unregister_capability(vec![get_formatting_unregistration()])
|
||||
// .await
|
||||
// .context("could not unregister document formatting")
|
||||
// }
|
||||
// };
|
||||
|
||||
// if config.formatter == ExperimentalFormatterMode::On {
|
||||
// if let Some(err) = register().await.err() {
|
||||
// error!("could not dynamically register document formatting:
|
||||
// {err}"); }
|
||||
// }
|
||||
|
||||
// config.listen_formatting(Box::new(move |formatter| match formatter {
|
||||
// ExperimentalFormatterMode::On => register().boxed(),
|
||||
// ExperimentalFormatterMode::Off => unregister().boxed(),
|
||||
// }));
|
||||
// }
|
||||
|
||||
if const_config.supports_config_change_registration {
|
||||
trace!("setting up to request config change notifications");
|
||||
|
||||
const CONFIG_REGISTRATION_ID: &str = "config";
|
||||
const CONFIG_METHOD_ID: &str = "workspace/didChangeConfiguration";
|
||||
|
||||
let err = self
|
||||
.client
|
||||
.register_capability(vec![get_config_registration()])
|
||||
.register_capability(vec![Registration {
|
||||
id: CONFIG_REGISTRATION_ID.to_owned(),
|
||||
method: CONFIG_METHOD_ID.to_owned(),
|
||||
register_options: None,
|
||||
}])
|
||||
.await
|
||||
.err();
|
||||
if let Some(err) = err {
|
||||
|
@ -260,16 +262,6 @@ impl LanguageServer for TypstServer {
|
|||
}
|
||||
}
|
||||
|
||||
// trace!("setting up to watch Typst files");
|
||||
// let watch_files_error = self
|
||||
// .client
|
||||
// .register_capability(vec![self.get_watcher_registration()])
|
||||
// .await
|
||||
// .err();
|
||||
// if let Some(err) = watch_files_error {
|
||||
// error!("could not register to watch Typst files: {err}");
|
||||
// }
|
||||
|
||||
info!("server initialized");
|
||||
}
|
||||
|
||||
|
@ -315,26 +307,6 @@ impl LanguageServer for TypstServer {
|
|||
}
|
||||
}
|
||||
|
||||
// async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams)
|
||||
// { let changes = params.changes;
|
||||
|
||||
// let mut workspace = self.workspace().write().await;
|
||||
|
||||
// for change in changes {
|
||||
// self.handle_file_change_event(&mut workspace, change);
|
||||
// }
|
||||
// }
|
||||
|
||||
// async fn did_change_workspace_folders(&self, params:
|
||||
// DidChangeWorkspaceFoldersParams) { let event = params.event;
|
||||
|
||||
// let mut workspace = self.workspace().write().await;
|
||||
|
||||
// if let Err(err) = workspace.handle_workspace_folders_change_event(&event)
|
||||
// { error!("error when changing workspace folders: {err}");
|
||||
// }
|
||||
// }
|
||||
|
||||
async fn execute_command(
|
||||
&self,
|
||||
params: ExecuteCommandParams,
|
||||
|
@ -543,29 +515,6 @@ impl LanguageServer for TypstServer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// async fn formatting(
|
||||
// &self,
|
||||
// params: DocumentFormattingParams,
|
||||
// ) -> jsonrpc::Result<Option<Vec<TextEdit>>> {
|
||||
// let uri = params.text_document.uri;
|
||||
|
||||
// let edits = self
|
||||
// .scope_with_source(&uri)
|
||||
// .await
|
||||
// .map_err(|err| {
|
||||
// error!("error getting document to format: {err} {uri}");
|
||||
// jsonrpc::Error::internal_error()
|
||||
// })?
|
||||
// .run2(|source, project| self.format_document(project, source))
|
||||
// .await
|
||||
// .map_err(|err| {
|
||||
// error!("error formatting document: {err} {uri}");
|
||||
// jsonrpc::Error::internal_error()
|
||||
// })?;
|
||||
|
||||
// Ok(Some(edits))
|
||||
// }
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LspCommand {
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
mod config;
|
||||
mod ext;
|
||||
mod lsp_typst_boundary;
|
||||
mod server;
|
||||
|
||||
// pub mod formatting;
|
||||
pub mod actor;
|
||||
|
@ -11,10 +10,10 @@ pub mod analysis;
|
|||
pub mod lsp;
|
||||
pub mod semantic_tokens;
|
||||
|
||||
use server::TypstServer;
|
||||
|
||||
use tower_lsp::{LspService, Server};
|
||||
|
||||
use lsp::TypstServer;
|
||||
|
||||
// #[derive(Debug, Clone)]
|
||||
// struct Args {}
|
||||
|
||||
|
@ -47,10 +46,6 @@ async fn main() {
|
|||
.filter_module("typst_ts_compiler::service::watch", log::LevelFilter::Debug)
|
||||
.try_init();
|
||||
|
||||
run().await;
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
let stdin = tokio::io::stdin();
|
||||
let stdout = tokio::io::stdout();
|
||||
|
||||
|
|
|
@ -1,158 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use once_cell::sync::OnceCell;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
pub use tower_lsp::Client as LspHost;
|
||||
use typst::model::Document;
|
||||
|
||||
use crate::actor::typst::CompileCluster;
|
||||
use crate::config::{Config, ConstConfig};
|
||||
|
||||
pub struct TypstServer {
|
||||
pub client: LspHost,
|
||||
pub document: Mutex<Arc<Document>>,
|
||||
// typst_thread: TypstThread,
|
||||
pub universe: OnceCell<CompileCluster>,
|
||||
pub config: Arc<RwLock<Config>>,
|
||||
pub const_config: OnceCell<ConstConfig>,
|
||||
}
|
||||
|
||||
impl TypstServer {
|
||||
pub fn new(client: LspHost) -> Self {
|
||||
Self {
|
||||
// typst_thread: Default::default(),
|
||||
universe: Default::default(),
|
||||
config: Default::default(),
|
||||
const_config: Default::default(),
|
||||
client,
|
||||
document: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn const_config(&self) -> &ConstConfig {
|
||||
self.const_config
|
||||
.get()
|
||||
.expect("const config should be initialized")
|
||||
}
|
||||
|
||||
pub fn universe(&self) -> &CompileCluster {
|
||||
self.universe.get().expect("universe should be initialized")
|
||||
}
|
||||
|
||||
// pub fn typst_global_scopes(&self) -> typst::foundations::Scopes {
|
||||
// typst::foundations::Scopes::new(Some(&TYPST_STDLIB))
|
||||
// }
|
||||
|
||||
// pub async fn register_workspace_files(&self) -> FsResult<()> {
|
||||
// let mut workspace = self.workspace().write().await;
|
||||
// workspace.register_files()
|
||||
// }
|
||||
|
||||
// async fn read_workspace(&self) -> RwLockReadGuard<Workspace> {
|
||||
// self.workspace().read().await
|
||||
// }
|
||||
|
||||
// async fn read_workspace_owned(&self) -> OwnedRwLockReadGuard<Workspace> {
|
||||
// Arc::clone(self.workspace()).read_owned().await
|
||||
// }
|
||||
|
||||
// pub async fn project_and_full_id(&self, uri: &Url) -> FsResult<(Project,
|
||||
// FullFileId)> { let workspace = self.read_workspace_owned().await;
|
||||
// let full_id = workspace.full_id(uri)?;
|
||||
// let project = Project::new(full_id.package(), workspace);
|
||||
// Ok((project, full_id))
|
||||
// }
|
||||
|
||||
// pub async fn scope_with_source(&self, uri: &Url) -> FsResult<SourceScope> {
|
||||
// let (project, _) = self.project_and_full_id(uri).await?;
|
||||
// let source = project.read_source_by_uri(uri)?;
|
||||
// Ok(SourceScope { project, source })
|
||||
// }
|
||||
|
||||
// pub async fn thread_with_world(
|
||||
// &self,
|
||||
// builder: impl Into<WorldBuilder<'_>>,
|
||||
// ) -> FsResult<WorldThread> {
|
||||
// let (main, project) =
|
||||
// builder.into().main_project(self.workspace()).await?;
|
||||
|
||||
// Ok(WorldThread {
|
||||
// main,
|
||||
// main_project: project,
|
||||
// typst_thread: &self.typst_thread,
|
||||
// })
|
||||
// }
|
||||
|
||||
// /// Run the given function on the Typst thread, passing back its return
|
||||
// /// value.
|
||||
// pub async fn typst<T: Send + 'static>(
|
||||
// &self,
|
||||
// f: impl FnOnce(runtime::Handle) -> T + Send + 'static,
|
||||
// ) -> T {
|
||||
// self.typst_thread.run(f).await
|
||||
// }
|
||||
}
|
||||
|
||||
// pub struct SourceScope {
|
||||
// source: Source,
|
||||
// project: Project,
|
||||
// }
|
||||
|
||||
// impl SourceScope {
|
||||
// pub fn run<T>(self, f: impl FnOnce(&Source, &Project) -> T) -> T {
|
||||
// f(&self.source, &self.project)
|
||||
// }
|
||||
|
||||
// pub fn run2<T>(self, f: impl FnOnce(Source, Project) -> T) -> T {
|
||||
// f(self.source, self.project)
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub struct WorldThread<'a> {
|
||||
// main: Source,
|
||||
// main_project: Project,
|
||||
// typst_thread: &'a TypstThread,
|
||||
// }
|
||||
|
||||
// impl<'a> WorldThread<'a> {
|
||||
// pub async fn run<T: Send + 'static>(
|
||||
// self,
|
||||
// f: impl FnOnce(ProjectWorld) -> T + Send + 'static,
|
||||
// ) -> T {
|
||||
// self.typst_thread
|
||||
// .run_with_world(self.main_project, self.main, f)
|
||||
// .await
|
||||
// }
|
||||
// }
|
||||
|
||||
// pub enum WorldBuilder<'a> {
|
||||
// MainUri(&'a Url),
|
||||
// MainAndProject(Source, Project),
|
||||
// }
|
||||
|
||||
// impl<'a> WorldBuilder<'a> {
|
||||
// async fn main_project(self, workspace: &Arc<RwLock<Workspace>>) ->
|
||||
// FsResult<(Source, Project)> { match self {
|
||||
// Self::MainUri(uri) => {
|
||||
// let workspace = Arc::clone(workspace).read_owned().await;
|
||||
// let full_id = workspace.full_id(uri)?;
|
||||
// let source = workspace.read_source(uri)?;
|
||||
// let project = Project::new(full_id.package(), workspace);
|
||||
// Ok((source, project))
|
||||
// }
|
||||
// Self::MainAndProject(main, project) => Ok((main, project)),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl<'a> From<&'a Url> for WorldBuilder<'a> {
|
||||
// fn from(uri: &'a Url) -> Self {
|
||||
// Self::MainUri(uri)
|
||||
// }
|
||||
// }
|
||||
|
||||
// impl From<(Source, Project)> for WorldBuilder<'static> {
|
||||
// fn from((main, project): (Source, Project)) -> Self {
|
||||
// Self::MainAndProject(main, project)
|
||||
// }
|
||||
// }
|
Loading…
Add table
Add a link
Reference in a new issue