Add show inspection and rollback-safe update UX
This commit is contained in:
parent
27a1b806cd
commit
1ad2f8a532
16 changed files with 2187 additions and 7 deletions
|
|
@ -7,4 +7,5 @@ pub mod query;
|
|||
pub mod remove;
|
||||
pub mod scope;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod update;
|
||||
|
|
|
|||
280
crates/aim-core/src/app/show.rs
Normal file
280
crates/aim-core/src/app/show.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
use crate::adapters::traits::AdapterError;
|
||||
use crate::app::add::{BuildAddPlanError, build_add_plan, build_add_plan_with};
|
||||
use crate::app::interaction::InteractionKind;
|
||||
use crate::domain::app::AppRecord;
|
||||
use crate::domain::show::{
|
||||
AdapterFailureKind, GitHubDiscoveryFailureKind, InstalledShow, MetadataSummary,
|
||||
RemoteArtifactSummary, RemoteInteractionSummary, RemoteShow, ShowResult, ShowResultError,
|
||||
SourceSummary, TrackedInstallPaths, UpdateChannelSummary, UpdateStrategySummary,
|
||||
};
|
||||
use crate::source::github::GitHubTransport;
|
||||
|
||||
pub fn build_show_result(
|
||||
query: &str,
|
||||
installed_apps: &[AppRecord],
|
||||
) -> Result<ShowResult, ShowResultError> {
|
||||
match resolve_installed_show(query, installed_apps) {
|
||||
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
|
||||
InstalledLookup::Missing => build_remote_show_result(query),
|
||||
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_installed_show_results(installed_apps: &[AppRecord]) -> Vec<InstalledShow> {
|
||||
installed_apps.iter().map(project_installed_show).collect()
|
||||
}
|
||||
|
||||
pub fn build_show_result_with<T: GitHubTransport + ?Sized>(
|
||||
query: &str,
|
||||
installed_apps: &[AppRecord],
|
||||
transport: &T,
|
||||
) -> Result<ShowResult, ShowResultError> {
|
||||
match resolve_installed_show(query, installed_apps) {
|
||||
InstalledLookup::Found(app) => Ok(ShowResult::Installed(project_installed_show(app))),
|
||||
InstalledLookup::Missing => {
|
||||
let plan = build_add_plan_with(query, transport).map_err(ShowResultError::from)?;
|
||||
let warnings = collect_metadata_warnings(&plan.metadata);
|
||||
let interactions = summarize_interactions(&plan.interactions);
|
||||
Ok(ShowResult::Remote(RemoteShow {
|
||||
source: project_source_summary(&plan.resolution.source),
|
||||
artifact: RemoteArtifactSummary {
|
||||
url: plan.selected_artifact.url,
|
||||
version: optional_version(plan.selected_artifact.version),
|
||||
arch: plan.selected_artifact.arch,
|
||||
trusted_checksum: plan.selected_artifact.trusted_checksum,
|
||||
selection_reason: plan.selected_artifact.selection_reason,
|
||||
},
|
||||
interactions,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
InstalledLookup::Ambiguous(matches) => Err(ambiguous_installed_match(query, matches)),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_remote_show_result(query: &str) -> Result<ShowResult, ShowResultError> {
|
||||
let plan = build_add_plan(query).map_err(ShowResultError::from)?;
|
||||
let warnings = collect_metadata_warnings(&plan.metadata);
|
||||
let interactions = summarize_interactions(&plan.interactions);
|
||||
|
||||
Ok(ShowResult::Remote(RemoteShow {
|
||||
source: project_source_summary(&plan.resolution.source),
|
||||
artifact: RemoteArtifactSummary {
|
||||
url: plan.selected_artifact.url,
|
||||
version: optional_version(plan.selected_artifact.version),
|
||||
arch: plan.selected_artifact.arch,
|
||||
trusted_checksum: plan.selected_artifact.trusted_checksum,
|
||||
selection_reason: plan.selected_artifact.selection_reason,
|
||||
},
|
||||
interactions,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
fn ambiguous_installed_match(query: &str, matches: Vec<String>) -> ShowResultError {
|
||||
ShowResultError::AmbiguousInstalledMatch {
|
||||
query: query.to_owned(),
|
||||
matches,
|
||||
}
|
||||
}
|
||||
|
||||
enum InstalledLookup<'a> {
|
||||
Found(&'a AppRecord),
|
||||
Missing,
|
||||
Ambiguous(Vec<String>),
|
||||
}
|
||||
|
||||
fn resolve_installed_show<'a>(query: &str, installed_apps: &'a [AppRecord]) -> InstalledLookup<'a> {
|
||||
let normalized_query = normalize_lookup(query);
|
||||
let matches = installed_apps
|
||||
.iter()
|
||||
.filter(|app| app_matches_installed_query(app, &normalized_query))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match matches.as_slice() {
|
||||
[] => InstalledLookup::Missing,
|
||||
[app] => InstalledLookup::Found(app),
|
||||
_ => InstalledLookup::Ambiguous(
|
||||
matches
|
||||
.iter()
|
||||
.map(|app| format!("{} ({})", app.display_name, app.stable_id))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_matches_installed_query(app: &AppRecord, normalized_query: &str) -> bool {
|
||||
let mut candidates = vec![
|
||||
normalize_lookup(&app.stable_id),
|
||||
normalize_lookup(&app.display_name),
|
||||
];
|
||||
|
||||
if let Some(source_input) = app.source_input.as_deref() {
|
||||
candidates.push(normalize_lookup(source_input));
|
||||
}
|
||||
|
||||
if let Some(source) = app.source.as_ref() {
|
||||
candidates.push(normalize_lookup(&source.locator));
|
||||
if let Some(canonical_locator) = source.canonical_locator.as_deref() {
|
||||
candidates.push(normalize_lookup(canonical_locator));
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate == normalized_query)
|
||||
}
|
||||
|
||||
fn normalize_lookup(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn optional_version(version: String) -> Option<String> {
|
||||
(version != "unresolved").then_some(version)
|
||||
}
|
||||
|
||||
fn collect_metadata_warnings(metadata: &[crate::domain::update::ParsedMetadata]) -> Vec<String> {
|
||||
metadata
|
||||
.iter()
|
||||
.flat_map(|item| item.warnings.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn project_installed_show(app: &AppRecord) -> InstalledShow {
|
||||
InstalledShow {
|
||||
stable_id: app.stable_id.clone(),
|
||||
display_name: app.display_name.clone(),
|
||||
installed_version: app.installed_version.clone().and_then(optional_version),
|
||||
source_input: app.source_input.clone(),
|
||||
source: app.source.as_ref().map(project_source_summary),
|
||||
install_scope: app.install.as_ref().map(|install| install.scope),
|
||||
tracked_paths: TrackedInstallPaths {
|
||||
payload_path: app
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|install| install.payload_path.clone()),
|
||||
desktop_entry_path: app
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|install| install.desktop_entry_path.clone()),
|
||||
icon_path: app
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|install| install.icon_path.clone()),
|
||||
},
|
||||
update_strategy: app
|
||||
.update_strategy
|
||||
.as_ref()
|
||||
.map(|strategy| UpdateStrategySummary {
|
||||
preferred: UpdateChannelSummary {
|
||||
kind: strategy.preferred.kind,
|
||||
locator: strategy.preferred.locator.clone(),
|
||||
reason: strategy.preferred.reason.clone(),
|
||||
},
|
||||
alternates: strategy
|
||||
.alternates
|
||||
.iter()
|
||||
.map(|alternate| UpdateChannelSummary {
|
||||
kind: alternate.kind,
|
||||
locator: alternate.locator.clone(),
|
||||
reason: alternate.reason.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
metadata: app
|
||||
.metadata
|
||||
.iter()
|
||||
.map(|item| MetadataSummary {
|
||||
kind: item.kind,
|
||||
version: item.hints.version.clone(),
|
||||
primary_download: item.hints.primary_download.clone(),
|
||||
checksum: item.hints.checksum.clone(),
|
||||
architecture: item.hints.architecture.clone(),
|
||||
channel_label: item.hints.channel_label.clone(),
|
||||
warnings: item.warnings.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_source_summary(source: &crate::domain::source::SourceRef) -> SourceSummary {
|
||||
SourceSummary {
|
||||
kind: source.kind,
|
||||
locator: source.locator.clone(),
|
||||
canonical_locator: source.canonical_locator.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_interactions(
|
||||
interactions: &[crate::app::interaction::InteractionRequest],
|
||||
) -> Vec<RemoteInteractionSummary> {
|
||||
interactions
|
||||
.iter()
|
||||
.filter_map(|interaction| match &interaction.kind {
|
||||
InteractionKind::SelectRegisteredApp { query, matches } => {
|
||||
let _ = query;
|
||||
let _ = matches;
|
||||
None
|
||||
}
|
||||
InteractionKind::ChooseTrackingPreference {
|
||||
requested_version,
|
||||
latest_version,
|
||||
} => Some(RemoteInteractionSummary::ChooseTrackingPreference {
|
||||
requested_version: requested_version.clone(),
|
||||
latest_version: latest_version.clone(),
|
||||
}),
|
||||
InteractionKind::SelectArtifact { candidates } => {
|
||||
Some(RemoteInteractionSummary::SelectArtifact {
|
||||
candidate_count: candidates.len(),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl From<BuildAddPlanError> for ShowResultError {
|
||||
fn from(value: BuildAddPlanError) -> Self {
|
||||
match value {
|
||||
BuildAddPlanError::Query(_) => Self::UnsupportedQuery,
|
||||
BuildAddPlanError::NoInstallableArtifact { source } => Self::NoInstallableArtifact {
|
||||
source: project_source_summary(&source),
|
||||
},
|
||||
BuildAddPlanError::Adapter(id, error) => Self::AdapterResolutionFailed {
|
||||
adapter_id: id.to_owned(),
|
||||
kind: match &error {
|
||||
AdapterError::UnsupportedQuery => AdapterFailureKind::UnsupportedQuery,
|
||||
AdapterError::UnsupportedSource => AdapterFailureKind::UnsupportedSource,
|
||||
AdapterError::ResolutionFailed(_) => AdapterFailureKind::ResolutionFailed,
|
||||
},
|
||||
detail: match error {
|
||||
AdapterError::ResolutionFailed(reason) => Some(reason),
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
BuildAddPlanError::GitHubDiscovery(error) => Self::GitHubDiscoveryFailed {
|
||||
kind: match &error {
|
||||
crate::source::github::GitHubDiscoveryError::Unsupported => {
|
||||
GitHubDiscoveryFailureKind::Unsupported
|
||||
}
|
||||
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(_) => {
|
||||
GitHubDiscoveryFailureKind::FixtureDocumentMissing
|
||||
}
|
||||
crate::source::github::GitHubDiscoveryError::NoReleases { .. } => {
|
||||
GitHubDiscoveryFailureKind::NoReleases
|
||||
}
|
||||
crate::source::github::GitHubDiscoveryError::Transport(_) => {
|
||||
GitHubDiscoveryFailureKind::Transport
|
||||
}
|
||||
},
|
||||
detail: match error {
|
||||
crate::source::github::GitHubDiscoveryError::FixtureDocumentMissing(url) => {
|
||||
Some(url)
|
||||
}
|
||||
crate::source::github::GitHubDiscoveryError::NoReleases { repo } => Some(repo),
|
||||
_ => None,
|
||||
},
|
||||
},
|
||||
BuildAddPlanError::NoCandidates => Self::NoInstallableCandidates,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
use std::path::Path;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::app::add::{build_add_plan, install_app_with_reporter};
|
||||
use crate::app::progress::{
|
||||
|
|
@ -190,16 +191,36 @@ fn execute_update(
|
|||
reason
|
||||
})?;
|
||||
|
||||
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter).map_err(
|
||||
|error| {
|
||||
let reason = format!("failed to install update: {error:?}");
|
||||
let rollback = stage_existing_installation(app, install_home).inspect_err(|reason| {
|
||||
reporter.report(&OperationEvent::Failed {
|
||||
stage: OperationStage::StagePayload,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
})?;
|
||||
|
||||
install_app_with_reporter(&query, &plan, install_home, requested_scope, reporter)
|
||||
.map_err(|error| {
|
||||
let install_reason = format!("failed to install update: {error:?}");
|
||||
let reason = match rollback.as_ref() {
|
||||
Some(rollback) => match rollback.restore() {
|
||||
Ok(()) => format!("{install_reason}; restored previous installation"),
|
||||
Err(restore_reason) => {
|
||||
format!("{install_reason}; rollback restore failed: {restore_reason}")
|
||||
}
|
||||
},
|
||||
None => install_reason,
|
||||
};
|
||||
reporter.report(&OperationEvent::Failed {
|
||||
stage: OperationStage::Finalize,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
reason
|
||||
},
|
||||
)
|
||||
})
|
||||
.inspect(|_| {
|
||||
if let Some(rollback) = rollback.as_ref() {
|
||||
let _ = rollback.cleanup();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn update_query(app: &AppRecord) -> Option<String> {
|
||||
|
|
@ -218,3 +239,118 @@ fn update_query(app: &AppRecord) -> Option<String> {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn stage_existing_installation(
|
||||
app: &AppRecord,
|
||||
install_home: &Path,
|
||||
) -> Result<Option<RollbackState>, String> {
|
||||
let Some(install) = app.install.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let tracked_paths = [
|
||||
install.payload_path.as_deref(),
|
||||
install.desktop_entry_path.as_deref(),
|
||||
install.icon_path.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(PathBuf::from)
|
||||
.filter(|path| path.exists())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if tracked_paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let stage_dir = install_home
|
||||
.join(".local/share/aim/rollback")
|
||||
.join(&app.stable_id);
|
||||
fs::create_dir_all(&stage_dir)
|
||||
.map_err(|error| format!("failed to create rollback staging directory: {error}"))?;
|
||||
|
||||
let mut entries = Vec::with_capacity(tracked_paths.len());
|
||||
for original_path in tracked_paths {
|
||||
let backup_path = stage_dir.join(
|
||||
original_path
|
||||
.file_name()
|
||||
.map(|name| name.to_os_string())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
fs::rename(&original_path, &backup_path).map_err(|error| {
|
||||
format!(
|
||||
"failed to stage existing install file {}: {error}",
|
||||
original_path.display()
|
||||
)
|
||||
})?;
|
||||
entries.push(RollbackEntry {
|
||||
original_path,
|
||||
backup_path,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Some(RollbackState { stage_dir, entries }))
|
||||
}
|
||||
|
||||
struct RollbackState {
|
||||
stage_dir: PathBuf,
|
||||
entries: Vec<RollbackEntry>,
|
||||
}
|
||||
|
||||
impl RollbackState {
|
||||
fn restore(&self) -> Result<(), String> {
|
||||
for entry in &self.entries {
|
||||
if let Some(parent) = entry.original_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"failed to recreate rollback parent {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&entry.backup_path, &entry.original_path).map_err(|error| {
|
||||
format!(
|
||||
"failed to restore {}: {error}",
|
||||
entry.original_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
self.cleanup()
|
||||
}
|
||||
|
||||
fn cleanup(&self) -> Result<(), String> {
|
||||
if self.stage_dir.exists() {
|
||||
fs::remove_dir_all(&self.stage_dir).map_err(|error| {
|
||||
format!(
|
||||
"failed to remove rollback staging directory {}: {error}",
|
||||
self.stage_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Some(parent) = self.stage_dir.parent()
|
||||
&& parent.exists()
|
||||
&& fs::read_dir(parent)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to inspect rollback parent directory {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?
|
||||
.next()
|
||||
.is_none()
|
||||
{
|
||||
fs::remove_dir(parent).map_err(|error| {
|
||||
format!(
|
||||
"failed to remove rollback parent directory {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct RollbackEntry {
|
||||
original_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod app;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod source;
|
||||
pub mod update;
|
||||
|
|
|
|||
125
crates/aim-core/src/domain/show.rs
Normal file
125
crates/aim-core/src/domain/show.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
use crate::domain::app::InstallScope;
|
||||
use crate::domain::source::SourceKind;
|
||||
use crate::domain::update::{ParsedMetadataKind, UpdateChannelKind};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ShowResult {
|
||||
Installed(InstalledShow),
|
||||
Remote(RemoteShow),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InstalledShow {
|
||||
pub stable_id: String,
|
||||
pub display_name: String,
|
||||
pub installed_version: Option<String>,
|
||||
pub source_input: Option<String>,
|
||||
pub source: Option<SourceSummary>,
|
||||
pub install_scope: Option<InstallScope>,
|
||||
pub tracked_paths: TrackedInstallPaths,
|
||||
pub update_strategy: Option<UpdateStrategySummary>,
|
||||
pub metadata: Vec<MetadataSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RemoteShow {
|
||||
pub source: SourceSummary,
|
||||
pub artifact: RemoteArtifactSummary,
|
||||
pub interactions: Vec<RemoteInteractionSummary>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SourceSummary {
|
||||
pub kind: SourceKind,
|
||||
pub locator: String,
|
||||
pub canonical_locator: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TrackedInstallPaths {
|
||||
pub payload_path: Option<String>,
|
||||
pub desktop_entry_path: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UpdateStrategySummary {
|
||||
pub preferred: UpdateChannelSummary,
|
||||
pub alternates: Vec<UpdateChannelSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct UpdateChannelSummary {
|
||||
pub kind: UpdateChannelKind,
|
||||
pub locator: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MetadataSummary {
|
||||
pub kind: ParsedMetadataKind,
|
||||
pub version: Option<String>,
|
||||
pub primary_download: Option<String>,
|
||||
pub checksum: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
pub channel_label: Option<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct RemoteArtifactSummary {
|
||||
pub url: String,
|
||||
pub version: Option<String>,
|
||||
pub arch: Option<String>,
|
||||
pub trusted_checksum: Option<String>,
|
||||
pub selection_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum RemoteInteractionSummary {
|
||||
ChooseTrackingPreference {
|
||||
requested_version: String,
|
||||
latest_version: String,
|
||||
},
|
||||
SelectArtifact {
|
||||
candidate_count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ShowResultError {
|
||||
AmbiguousInstalledMatch {
|
||||
query: String,
|
||||
matches: Vec<String>,
|
||||
},
|
||||
UnsupportedQuery,
|
||||
NoInstallableArtifact {
|
||||
source: SourceSummary,
|
||||
},
|
||||
AdapterResolutionFailed {
|
||||
adapter_id: String,
|
||||
kind: AdapterFailureKind,
|
||||
detail: Option<String>,
|
||||
},
|
||||
GitHubDiscoveryFailed {
|
||||
kind: GitHubDiscoveryFailureKind,
|
||||
detail: Option<String>,
|
||||
},
|
||||
NoInstallableCandidates,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AdapterFailureKind {
|
||||
UnsupportedQuery,
|
||||
UnsupportedSource,
|
||||
ResolutionFailed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum GitHubDiscoveryFailureKind {
|
||||
Unsupported,
|
||||
FixtureDocumentMissing,
|
||||
NoReleases,
|
||||
Transport,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue