asset: AssetServer versioning and load group status
This commit is contained in:
parent
9d80b5965e
commit
9a51b3e0fd
@ -20,4 +20,5 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
crossbeam-channel = "0.4.2"
|
crossbeam-channel = "0.4.2"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
log = { version = "0.4", features = ["release_max_level_info"] }
|
||||||
notify = { version = "5.0.0-pre.2", optional = true }
|
notify = { version = "5.0.0-pre.2", optional = true }
|
||||||
@ -14,6 +14,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub type AssetVersion = usize;
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AssetServerError {
|
pub enum AssetServerError {
|
||||||
#[error("Asset folder path is not a directory.")]
|
#[error("Asset folder path is not a directory.")]
|
||||||
@ -38,8 +39,32 @@ struct LoaderThread {
|
|||||||
requests: Arc<RwLock<Vec<LoadRequest>>>,
|
requests: Arc<RwLock<Vec<LoadRequest>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AssetInfo {
|
||||||
|
handle_id: HandleId,
|
||||||
|
path: PathBuf,
|
||||||
|
load_state: LoadState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum LoadState {
|
||||||
|
Loading(AssetVersion),
|
||||||
|
Loaded(AssetVersion),
|
||||||
|
Failed(AssetVersion),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadState {
|
||||||
|
pub fn get_version(&self) -> AssetVersion {
|
||||||
|
match *self {
|
||||||
|
LoadState::Loaded(version) => version,
|
||||||
|
LoadState::Loading(version) => version,
|
||||||
|
LoadState::Failed(version) => version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AssetServer {
|
pub struct AssetServer {
|
||||||
asset_folders: Vec<PathBuf>,
|
asset_folders: RwLock<Vec<PathBuf>>,
|
||||||
loader_threads: RwLock<Vec<LoaderThread>>,
|
loader_threads: RwLock<Vec<LoaderThread>>,
|
||||||
max_loader_threads: usize,
|
max_loader_threads: usize,
|
||||||
asset_handlers: Arc<RwLock<Vec<Box<dyn AssetLoadRequestHandler>>>>,
|
asset_handlers: Arc<RwLock<Vec<Box<dyn AssetLoadRequestHandler>>>>,
|
||||||
@ -47,7 +72,8 @@ pub struct AssetServer {
|
|||||||
loaders: Vec<Resources>,
|
loaders: Vec<Resources>,
|
||||||
extension_to_handler_index: HashMap<String, usize>,
|
extension_to_handler_index: HashMap<String, usize>,
|
||||||
extension_to_loader_index: HashMap<String, usize>,
|
extension_to_loader_index: HashMap<String, usize>,
|
||||||
path_to_handle: RwLock<HashMap<PathBuf, HandleId>>,
|
asset_info: RwLock<HashMap<HandleId, AssetInfo>>,
|
||||||
|
asset_info_paths: RwLock<HashMap<PathBuf, HandleId>>,
|
||||||
#[cfg(feature = "filesystem_watcher")]
|
#[cfg(feature = "filesystem_watcher")]
|
||||||
filesystem_watcher: Arc<RwLock<Option<FilesystemWatcher>>>,
|
filesystem_watcher: Arc<RwLock<Option<FilesystemWatcher>>>,
|
||||||
}
|
}
|
||||||
@ -55,16 +81,17 @@ pub struct AssetServer {
|
|||||||
impl Default for AssetServer {
|
impl Default for AssetServer {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
AssetServer {
|
AssetServer {
|
||||||
max_loader_threads: 4,
|
|
||||||
asset_folders: Vec::new(),
|
|
||||||
loader_threads: RwLock::new(Vec::new()),
|
|
||||||
asset_handlers: Arc::new(RwLock::new(Vec::new())),
|
|
||||||
loaders: Vec::new(),
|
|
||||||
extension_to_handler_index: HashMap::new(),
|
|
||||||
extension_to_loader_index: HashMap::new(),
|
|
||||||
path_to_handle: RwLock::new(HashMap::default()),
|
|
||||||
#[cfg(feature = "filesystem_watcher")]
|
#[cfg(feature = "filesystem_watcher")]
|
||||||
filesystem_watcher: Arc::new(RwLock::new(None)),
|
filesystem_watcher: Arc::new(RwLock::new(None)),
|
||||||
|
max_loader_threads: 4,
|
||||||
|
asset_folders: Default::default(),
|
||||||
|
loader_threads: Default::default(),
|
||||||
|
asset_handlers: Default::default(),
|
||||||
|
loaders: Default::default(),
|
||||||
|
extension_to_handler_index: Default::default(),
|
||||||
|
extension_to_loader_index: Default::default(),
|
||||||
|
asset_info_paths: Default::default(),
|
||||||
|
asset_info: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,20 +127,20 @@ impl AssetServer {
|
|||||||
self.loaders.push(resources);
|
self.loaders.push(resources);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_asset_folder<P: AsRef<Path>>(&mut self, path: P) -> Result<(), AssetServerError> {
|
pub fn load_asset_folder<P: AsRef<Path>>(&self, path: P) -> Result<Vec<HandleId>, AssetServerError> {
|
||||||
let root_path = self.get_root_path()?;
|
let root_path = self.get_root_path()?;
|
||||||
let asset_folder = root_path.join(path);
|
let asset_folder = root_path.join(path);
|
||||||
self.load_assets_in_folder_recursive(&asset_folder)?;
|
let handle_ids = self.load_assets_in_folder_recursive(&asset_folder)?;
|
||||||
self.asset_folders.push(asset_folder);
|
self.asset_folders.write().unwrap().push(asset_folder);
|
||||||
Ok(())
|
Ok(handle_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_handle<T, P: AsRef<Path>>(&self, path: P) -> Option<Handle<T>> {
|
pub fn get_handle<T, P: AsRef<Path>>(&self, path: P) -> Option<Handle<T>> {
|
||||||
self.path_to_handle
|
self.asset_info_paths
|
||||||
.read()
|
.read()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get(path.as_ref())
|
.get(path.as_ref())
|
||||||
.map(|h| Handle::from(*h))
|
.map(|handle_id| Handle::from(*handle_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "filesystem_watcher")]
|
#[cfg(feature = "filesystem_watcher")]
|
||||||
@ -138,8 +165,8 @@ impl AssetServer {
|
|||||||
|
|
||||||
let _ = filesystem_watcher.get_or_insert_with(|| FilesystemWatcher::default());
|
let _ = filesystem_watcher.get_or_insert_with(|| FilesystemWatcher::default());
|
||||||
// watch current files
|
// watch current files
|
||||||
let path_to_handle = self.path_to_handle.read().unwrap();
|
let asset_info_paths = self.asset_info_paths.read().unwrap();
|
||||||
for asset_path in path_to_handle.keys() {
|
for asset_path in asset_info_paths.keys() {
|
||||||
Self::watch_path_for_changes(&mut filesystem_watcher, asset_path)?;
|
Self::watch_path_for_changes(&mut filesystem_watcher, asset_path)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,7 +260,6 @@ impl AssetServer {
|
|||||||
let asset = loader.load_from_file(path)?;
|
let asset = loader.load_from_file(path)?;
|
||||||
let handle = Handle::from(handle_id);
|
let handle = Handle::from(handle_id);
|
||||||
assets.set(handle, asset);
|
assets.set(handle, asset);
|
||||||
assets.set_path(handle, path);
|
|
||||||
Ok(handle)
|
Ok(handle)
|
||||||
} else {
|
} else {
|
||||||
Err(AssetServerError::MissingAssetHandler)
|
Err(AssetServerError::MissingAssetHandler)
|
||||||
@ -251,13 +277,27 @@ impl AssetServer {
|
|||||||
.to_str()
|
.to_str()
|
||||||
.expect("Extension should be a valid string."),
|
.expect("Extension should be a valid string."),
|
||||||
) {
|
) {
|
||||||
|
let mut new_version = 0;
|
||||||
let handle_id = {
|
let handle_id = {
|
||||||
let mut path_to_handle = self.path_to_handle.write().unwrap();
|
let mut asset_info = self.asset_info.write().unwrap();
|
||||||
if let Some(handle_id) = path_to_handle.get(path) {
|
let mut asset_info_paths = self.asset_info_paths.write().unwrap();
|
||||||
*handle_id
|
if let Some(asset_info) = asset_info_paths
|
||||||
|
.get(path)
|
||||||
|
.and_then(|handle_id| asset_info.get_mut(&handle_id))
|
||||||
|
{
|
||||||
|
asset_info.load_state = if let LoadState::Loaded(_version) = asset_info.load_state { new_version += 1; LoadState::Loading(new_version) } else { LoadState::Loading(new_version) };
|
||||||
|
asset_info.handle_id
|
||||||
} else {
|
} else {
|
||||||
let handle_id = HandleId::new();
|
let handle_id = HandleId::new();
|
||||||
path_to_handle.insert(path.to_owned(), handle_id.clone());
|
asset_info.insert(
|
||||||
|
handle_id,
|
||||||
|
AssetInfo {
|
||||||
|
handle_id,
|
||||||
|
path: path.to_owned(),
|
||||||
|
load_state: LoadState::Loading(new_version),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
asset_info_paths.insert(path.to_owned(), handle_id);
|
||||||
handle_id
|
handle_id
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -266,6 +306,7 @@ impl AssetServer {
|
|||||||
handle_id,
|
handle_id,
|
||||||
path: path.to_owned(),
|
path: path.to_owned(),
|
||||||
handler_index: *index,
|
handler_index: *index,
|
||||||
|
version: new_version,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: watching each asset explicitly is a simpler implementation, its possible it would be more efficient to watch
|
// TODO: watching each asset explicitly is a simpler implementation, its possible it would be more efficient to watch
|
||||||
@ -281,7 +322,42 @@ impl AssetServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_load_state(&self, handle_id: HandleId, load_state: LoadState) {
|
||||||
|
self.asset_info
|
||||||
|
.write()
|
||||||
|
.unwrap()
|
||||||
|
.get_mut(&handle_id)
|
||||||
|
.map(|asset_info| {
|
||||||
|
if load_state.get_version() >= asset_info.load_state.get_version() {
|
||||||
|
asset_info.load_state = load_state;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_load_state_untyped(&self, handle_id: HandleId) -> Option<LoadState> {
|
||||||
|
self.asset_info.read().unwrap().get(&handle_id).map(|asset_info| asset_info.load_state.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_load_state<T>(&self, handle: Handle<T>) -> Option<LoadState> {
|
||||||
|
self.get_load_state_untyped(handle.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_group_load_state(&self, handle_ids: &[HandleId]) -> Option<LoadState> {
|
||||||
|
let mut load_state = LoadState::Loaded(0);
|
||||||
|
for handle_id in handle_ids.iter() {
|
||||||
|
match self.get_load_state_untyped(*handle_id) {
|
||||||
|
Some(LoadState::Loaded(_)) => continue,
|
||||||
|
Some(LoadState::Loading(_)) => {load_state = LoadState::Loading(0);},
|
||||||
|
Some(LoadState::Failed(_)) => return Some(LoadState::Failed(0)),
|
||||||
|
None => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(load_state)
|
||||||
|
}
|
||||||
|
|
||||||
fn send_request_to_loader_thread(&self, load_request: LoadRequest) {
|
fn send_request_to_loader_thread(&self, load_request: LoadRequest) {
|
||||||
|
// NOTE: This lock makes the call to Arc::strong_count safe. Removing (or reordering) it could result in undefined behavior
|
||||||
let mut loader_threads = self.loader_threads.write().unwrap();
|
let mut loader_threads = self.loader_threads.write().unwrap();
|
||||||
if loader_threads.len() < self.max_loader_threads {
|
if loader_threads.len() < self.max_loader_threads {
|
||||||
let loader_thread = LoaderThread {
|
let loader_thread = LoaderThread {
|
||||||
@ -330,7 +406,7 @@ impl AssetServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_assets_in_folder_recursive(&self, path: &Path) -> Result<(), AssetServerError> {
|
fn load_assets_in_folder_recursive(&self, path: &Path) -> Result<Vec<HandleId>, AssetServerError> {
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
return Err(AssetServerError::AssetFolderNotADirectory(
|
return Err(AssetServerError::AssetFolderNotADirectory(
|
||||||
path.to_str().unwrap().to_string(),
|
path.to_str().unwrap().to_string(),
|
||||||
@ -338,21 +414,24 @@ impl AssetServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let root_path = self.get_root_path()?;
|
let root_path = self.get_root_path()?;
|
||||||
|
let mut handle_ids = Vec::new();
|
||||||
for entry in fs::read_dir(path)? {
|
for entry in fs::read_dir(path)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let child_path = entry.path();
|
let child_path = entry.path();
|
||||||
if !child_path.is_dir() {
|
if !child_path.is_dir() {
|
||||||
let relative_child_path = child_path.strip_prefix(&root_path).unwrap();
|
let relative_child_path = child_path.strip_prefix(&root_path).unwrap();
|
||||||
let _ = self.load_untyped(
|
let handle = self.load_untyped(
|
||||||
relative_child_path
|
relative_child_path
|
||||||
.to_str()
|
.to_str()
|
||||||
.expect("Path should be a valid string"),
|
.expect("Path should be a valid string"),
|
||||||
);
|
)?;
|
||||||
|
|
||||||
|
handle_ids.push(handle);
|
||||||
} else {
|
} else {
|
||||||
self.load_assets_in_folder_recursive(&child_path)?;
|
handle_ids.extend(self.load_assets_in_folder_recursive(&child_path)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(handle_ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,7 @@ use bevy_app::{AppBuilder, Events, FromResources};
|
|||||||
use bevy_core::bytes::Bytes;
|
use bevy_core::bytes::Bytes;
|
||||||
use bevy_type_registry::RegisterType;
|
use bevy_type_registry::RegisterType;
|
||||||
use legion::prelude::*;
|
use legion::prelude::*;
|
||||||
use std::{
|
use std::collections::HashMap;
|
||||||
collections::HashMap,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub enum AssetEvent<T: 'static> {
|
pub enum AssetEvent<T: 'static> {
|
||||||
Created { handle: Handle<T> },
|
Created { handle: Handle<T> },
|
||||||
@ -19,7 +16,6 @@ pub enum AssetEvent<T: 'static> {
|
|||||||
|
|
||||||
pub struct Assets<T: 'static> {
|
pub struct Assets<T: 'static> {
|
||||||
assets: HashMap<Handle<T>, T>,
|
assets: HashMap<Handle<T>, T>,
|
||||||
paths: HashMap<PathBuf, Handle<T>>,
|
|
||||||
events: Events<AssetEvent<T>>,
|
events: Events<AssetEvent<T>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -27,17 +23,12 @@ impl<T> Default for Assets<T> {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Assets {
|
Assets {
|
||||||
assets: HashMap::default(),
|
assets: HashMap::default(),
|
||||||
paths: HashMap::default(),
|
|
||||||
events: Events::default(),
|
events: Events::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> Assets<T> {
|
impl<T> Assets<T> {
|
||||||
pub fn get_with_path<P: AsRef<Path>>(&mut self, path: P) -> Option<Handle<T>> {
|
|
||||||
self.paths.get(path.as_ref()).map(|handle| *handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add(&mut self, asset: T) -> Handle<T> {
|
pub fn add(&mut self, asset: T) -> Handle<T> {
|
||||||
let handle = Handle::new();
|
let handle = Handle::new();
|
||||||
self.assets.insert(handle, asset);
|
self.assets.insert(handle, asset);
|
||||||
@ -68,11 +59,7 @@ impl<T> Assets<T> {
|
|||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_path<P: AsRef<Path>>(&mut self, handle: Handle<T>, path: P) {
|
pub fn get_with_id(&self, id: HandleId) -> Option<&T> {
|
||||||
self.paths.insert(path.as_ref().to_owned(), handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_id(&self, id: HandleId) -> Option<&T> {
|
|
||||||
self.assets.get(&Handle::from_id(id))
|
self.assets.get(&Handle::from_id(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use crate::{AssetLoadError, AssetLoader, AssetResult, Handle, HandleId};
|
use crate::{AssetLoadError, AssetLoader, AssetResult, AssetVersion, Handle, HandleId};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossbeam_channel::Sender;
|
use crossbeam_channel::Sender;
|
||||||
use fs::File;
|
use fs::File;
|
||||||
@ -10,6 +10,7 @@ pub struct LoadRequest {
|
|||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub handle_id: HandleId,
|
pub handle_id: HandleId,
|
||||||
pub handler_index: usize,
|
pub handler_index: usize,
|
||||||
|
pub version: AssetVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait AssetLoadRequestHandler: Send + Sync + 'static {
|
pub trait AssetLoadRequestHandler: Send + Sync + 'static {
|
||||||
@ -54,6 +55,7 @@ where
|
|||||||
handle: Handle::from(load_request.handle_id),
|
handle: Handle::from(load_request.handle_id),
|
||||||
result,
|
result,
|
||||||
path: load_request.path.clone(),
|
path: load_request.path.clone(),
|
||||||
|
version: load_request.version,
|
||||||
};
|
};
|
||||||
self.sender
|
self.sender
|
||||||
.send(asset_result)
|
.send(asset_result)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use crate::{Assets, Handle};
|
use crate::{AssetServer, Assets, Handle, AssetVersion, LoadState};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
||||||
use fs::File;
|
use fs::File;
|
||||||
@ -34,6 +34,7 @@ pub struct AssetResult<T: 'static> {
|
|||||||
pub result: Result<T, AssetLoadError>,
|
pub result: Result<T, AssetLoadError>,
|
||||||
pub handle: Handle<T>,
|
pub handle: Handle<T>,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
|
pub version: AssetVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AssetChannel<T: 'static> {
|
pub struct AssetChannel<T: 'static> {
|
||||||
@ -50,14 +51,22 @@ impl<T> AssetChannel<T> {
|
|||||||
|
|
||||||
pub fn update_asset_storage_system<T>(
|
pub fn update_asset_storage_system<T>(
|
||||||
asset_channel: Res<AssetChannel<T>>,
|
asset_channel: Res<AssetChannel<T>>,
|
||||||
|
asset_server: Res<AssetServer>,
|
||||||
mut assets: ResMut<Assets<T>>,
|
mut assets: ResMut<Assets<T>>,
|
||||||
) {
|
) {
|
||||||
loop {
|
loop {
|
||||||
match asset_channel.receiver.try_recv() {
|
match asset_channel.receiver.try_recv() {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let asset = result.result.unwrap();
|
match result.result {
|
||||||
assets.set(result.handle, asset);
|
Ok(asset) => {
|
||||||
assets.set_path(result.handle, &result.path);
|
assets.set(result.handle, asset);
|
||||||
|
asset_server.set_load_state(result.handle.id, LoadState::Loaded(result.version));
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
asset_server.set_load_state(result.handle.id, LoadState::Failed(result.version));
|
||||||
|
log::error!("Failed to load asset: {:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(TryRecvError::Empty) => {
|
Err(TryRecvError::Empty) => {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -27,11 +27,11 @@ impl FromResources for SceneLoader {
|
|||||||
impl AssetLoader<Scene> for SceneLoader {
|
impl AssetLoader<Scene> for SceneLoader {
|
||||||
fn from_bytes(&self, _asset_path: &Path, bytes: Vec<u8>) -> Result<Scene> {
|
fn from_bytes(&self, _asset_path: &Path, bytes: Vec<u8>) -> Result<Scene> {
|
||||||
let registry = self.property_type_registry.read().unwrap();
|
let registry = self.property_type_registry.read().unwrap();
|
||||||
let mut deserializer = ron::de::Deserializer::from_bytes(&bytes).unwrap();
|
let mut deserializer = ron::de::Deserializer::from_bytes(&bytes)?;
|
||||||
let scene_deserializer = SceneDeserializer {
|
let scene_deserializer = SceneDeserializer {
|
||||||
property_type_registry: ®istry,
|
property_type_registry: ®istry,
|
||||||
};
|
};
|
||||||
let scene = scene_deserializer.deserialize(&mut deserializer).unwrap();
|
let scene = scene_deserializer.deserialize(&mut deserializer)?;
|
||||||
Ok(scene)
|
Ok(scene)
|
||||||
}
|
}
|
||||||
fn extensions(&self) -> &[&str] {
|
fn extensions(&self) -> &[&str] {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user