Merge pull request 'Indicate when a deployment is necessary' (#49) from issue/32 into master
Reviewed-on: #49
This commit is contained in:
commit
f021e507b8
9 changed files with 106 additions and 80 deletions
|
@ -6,6 +6,7 @@
|
|||
|
||||
- dtmt: split `build` into `build` and `package`
|
||||
- dtmt: implement deploying built bundles
|
||||
- dtmm: indicate when a deployment is necessary
|
||||
|
||||
=== Fixed
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, ErrorKind, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::{self, Context};
|
||||
use color_eyre::{Help, Result};
|
||||
|
@ -107,7 +108,7 @@ pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result<ModInfo>
|
|||
|
||||
let packages = files
|
||||
.into_iter()
|
||||
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
|
||||
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
|
||||
.collect();
|
||||
let info = ModInfo::new(mod_cfg, packages);
|
||||
|
||||
|
@ -171,14 +172,14 @@ async fn read_mod_dir_entry(res: Result<DirEntry>) -> Result<ModInfo> {
|
|||
|
||||
let packages = files
|
||||
.into_iter()
|
||||
.map(|(name, files)| PackageInfo::new(name, files.into_iter().collect()))
|
||||
.map(|(name, files)| Arc::new(PackageInfo::new(name, files.into_iter().collect())))
|
||||
.collect();
|
||||
let info = ModInfo::new(cfg, packages);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(mod_order))]
|
||||
pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result<Vector<ModInfo>>
|
||||
pub(crate) fn load_mods<'a, P, S>(mod_dir: P, mod_order: S) -> Result<Vector<Arc<ModInfo>>>
|
||||
where
|
||||
S: Iterator<Item = &'a LoadOrderEntry>,
|
||||
P: AsRef<Path> + std::fmt::Debug,
|
||||
|
@ -214,7 +215,7 @@ where
|
|||
.filter_map(|entry| {
|
||||
if let Some(mut info) = mods.remove(&entry.id) {
|
||||
info.enabled = entry.enabled;
|
||||
Some(info)
|
||||
Some(Arc::new(info))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ async fn handle_action(
|
|||
.await
|
||||
.submit_command(
|
||||
ACTION_FINISH_ADD_MOD,
|
||||
SingleUse::new(mod_info),
|
||||
SingleUse::new(Arc::new(mod_info)),
|
||||
Target::Auto,
|
||||
)
|
||||
.expect("failed to send command");
|
||||
|
|
|
@ -17,7 +17,7 @@ impl Default for View {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Data, Debug)]
|
||||
#[derive(Clone, Data, Debug, PartialEq)]
|
||||
pub struct PackageInfo {
|
||||
pub name: String,
|
||||
pub files: Vector<String>,
|
||||
|
@ -29,14 +29,14 @@ impl PackageInfo {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct ModResourceInfo {
|
||||
pub init: PathBuf,
|
||||
pub data: Option<PathBuf>,
|
||||
pub localization: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Data, Debug, Lens)]
|
||||
#[derive(Clone, Data, Debug, Lens, PartialEq)]
|
||||
pub(crate) struct ModInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
|
@ -44,14 +44,14 @@ pub(crate) struct ModInfo {
|
|||
pub enabled: bool,
|
||||
#[lens(ignore)]
|
||||
#[data(ignore)]
|
||||
pub packages: Vector<PackageInfo>,
|
||||
pub packages: Vector<Arc<PackageInfo>>,
|
||||
#[lens(ignore)]
|
||||
#[data(ignore)]
|
||||
pub resources: ModResourceInfo,
|
||||
}
|
||||
|
||||
impl ModInfo {
|
||||
pub fn new(cfg: ModConfig, packages: Vector<PackageInfo>) -> Self {
|
||||
pub fn new(cfg: ModConfig, packages: Vector<Arc<PackageInfo>>) -> Self {
|
||||
Self {
|
||||
id: cfg.id,
|
||||
name: cfg.name,
|
||||
|
@ -67,17 +67,12 @@ impl ModInfo {
|
|||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ModInfo {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.name.eq(&other.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Data, Lens)]
|
||||
pub(crate) struct State {
|
||||
pub current_view: View,
|
||||
pub mods: Vector<ModInfo>,
|
||||
pub mods: Vector<Arc<ModInfo>>,
|
||||
pub selected_mod_index: Option<usize>,
|
||||
pub dirty: bool,
|
||||
pub is_deployment_in_progress: bool,
|
||||
pub is_reset_in_progress: bool,
|
||||
pub is_save_in_progress: bool,
|
||||
|
@ -106,6 +101,7 @@ impl State {
|
|||
current_view: View::default(),
|
||||
mods: Vector::new(),
|
||||
selected_mod_index: None,
|
||||
dirty: false,
|
||||
is_deployment_in_progress: false,
|
||||
is_reset_in_progress: false,
|
||||
is_save_in_progress: false,
|
||||
|
@ -121,8 +117,8 @@ impl State {
|
|||
self.selected_mod_index = Some(index);
|
||||
}
|
||||
|
||||
pub fn add_mod(&mut self, info: ModInfo) {
|
||||
if let Some(pos) = self.mods.index_of(&info) {
|
||||
pub fn add_mod(&mut self, info: Arc<ModInfo>) {
|
||||
if let Some(pos) = self.mods.iter().position(|i| i.id == info.id) {
|
||||
self.mods.set(pos, info);
|
||||
self.selected_mod_index = Some(pos);
|
||||
} else {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use druid::{
|
||||
AppDelegate, Command, DelegateCtx, Env, FileInfo, Handled, Selector, SingleUse, Target,
|
||||
};
|
||||
|
@ -9,9 +11,9 @@ pub(crate) const ACTION_SELECT_MOD: Selector<usize> = Selector::new("dtmm.action
|
|||
pub(crate) const ACTION_SELECTED_MOD_UP: Selector = Selector::new("dtmm.action.selected-mod-up");
|
||||
pub(crate) const ACTION_SELECTED_MOD_DOWN: Selector =
|
||||
Selector::new("dtmm.action.selected-mod-down");
|
||||
pub(crate) const ACTION_START_DELETE_SELECTED_MOD: Selector<SingleUse<ModInfo>> =
|
||||
pub(crate) const ACTION_START_DELETE_SELECTED_MOD: Selector<SingleUse<Arc<ModInfo>>> =
|
||||
Selector::new("dtmm.action.srart-delete-selected-mod");
|
||||
pub(crate) const ACTION_FINISH_DELETE_SELECTED_MOD: Selector<SingleUse<ModInfo>> =
|
||||
pub(crate) const ACTION_FINISH_DELETE_SELECTED_MOD: Selector<SingleUse<Arc<ModInfo>>> =
|
||||
Selector::new("dtmm.action.finish-delete-selected-mod");
|
||||
|
||||
pub(crate) const ACTION_START_DEPLOY: Selector = Selector::new("dtmm.action.start-deploy");
|
||||
|
@ -23,7 +25,7 @@ pub(crate) const ACTION_FINISH_RESET_DEPLOYMENT: Selector =
|
|||
Selector::new("dtmm.action.finish-reset-deployment");
|
||||
|
||||
pub(crate) const ACTION_ADD_MOD: Selector<FileInfo> = Selector::new("dtmm.action.add-mod");
|
||||
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<ModInfo>> =
|
||||
pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<Arc<ModInfo>>> =
|
||||
Selector::new("dtmm.action.finish-add-mod");
|
||||
|
||||
pub(crate) const ACTION_LOG: Selector<SingleUse<String>> = Selector::new("dtmm.action.log");
|
||||
|
@ -33,11 +35,13 @@ pub(crate) const ACTION_START_SAVE_SETTINGS: Selector =
|
|||
pub(crate) const ACTION_FINISH_SAVE_SETTINGS: Selector =
|
||||
Selector::new("dtmm.action.finish-save-settings");
|
||||
|
||||
pub(crate) const ACTION_SET_DIRTY: Selector = Selector::new("dtmm.action.set-dirty");
|
||||
|
||||
pub(crate) enum AsyncAction {
|
||||
DeployMods(State),
|
||||
ResetDeployment(State),
|
||||
AddMod((State, FileInfo)),
|
||||
DeleteMod((State, ModInfo)),
|
||||
DeleteMod((State, Arc<ModInfo>)),
|
||||
SaveSettings(State),
|
||||
}
|
||||
|
||||
|
@ -81,6 +85,7 @@ impl AppDelegate<State> for Delegate {
|
|||
}
|
||||
cmd if cmd.is(ACTION_FINISH_DEPLOY) => {
|
||||
state.is_deployment_in_progress = false;
|
||||
state.dirty = false;
|
||||
Handled::Yes
|
||||
}
|
||||
cmd if cmd.is(ACTION_START_RESET_DEPLOYMENT) => {
|
||||
|
@ -165,7 +170,6 @@ impl AppDelegate<State> for Delegate {
|
|||
};
|
||||
|
||||
state.mods.remove(index);
|
||||
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
|
||||
|
||||
Handled::Yes
|
||||
}
|
||||
|
@ -188,7 +192,6 @@ impl AppDelegate<State> for Delegate {
|
|||
.expect("command type matched but didn't contain the expected value");
|
||||
if let Some(info) = info.take() {
|
||||
state.add_mod(info);
|
||||
// ctx.submit_command(ACTION_START_SAVE_SETTINGS);
|
||||
}
|
||||
Handled::Yes
|
||||
}
|
||||
|
@ -226,6 +229,10 @@ impl AppDelegate<State> for Delegate {
|
|||
|
||||
Handled::Yes
|
||||
}
|
||||
cmd if cmd.is(ACTION_SET_DIRTY) => {
|
||||
state.dirty = true;
|
||||
Handled::Yes
|
||||
}
|
||||
cmd => {
|
||||
if cfg!(debug_assertions) {
|
||||
tracing::warn!("Unknown command: {:?}", cmd);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use druid::im::Vector;
|
||||
use druid::{Data, Lens};
|
||||
|
||||
|
@ -5,9 +7,9 @@ use super::{ModInfo, State};
|
|||
|
||||
pub(crate) struct SelectedModLens;
|
||||
|
||||
impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
||||
impl Lens<State, Option<Arc<ModInfo>>> for SelectedModLens {
|
||||
#[tracing::instrument(name = "SelectedModLens::with", skip_all)]
|
||||
fn with<V, F: FnOnce(&Option<ModInfo>) -> V>(&self, data: &State, f: F) -> V {
|
||||
fn with<V, F: FnOnce(&Option<Arc<ModInfo>>) -> V>(&self, data: &State, f: F) -> V {
|
||||
let info = data
|
||||
.selected_mod_index
|
||||
.and_then(|i| data.mods.get(i).cloned());
|
||||
|
@ -16,16 +18,16 @@ impl Lens<State, Option<ModInfo>> for SelectedModLens {
|
|||
}
|
||||
|
||||
#[tracing::instrument(name = "SelectedModLens::with_mut", skip_all)]
|
||||
fn with_mut<V, F: FnOnce(&mut Option<ModInfo>) -> V>(&self, data: &mut State, f: F) -> V {
|
||||
fn with_mut<V, F: FnOnce(&mut Option<Arc<ModInfo>>) -> V>(&self, data: &mut State, f: F) -> V {
|
||||
match data.selected_mod_index {
|
||||
Some(i) => {
|
||||
let mut info = data.mods.get_mut(i).cloned();
|
||||
let ret = f(&mut info);
|
||||
|
||||
if let Some(info) = info {
|
||||
if let Some(new) = info {
|
||||
// TODO: Figure out a way to check for equality and
|
||||
// only update when needed
|
||||
data.mods.set(i, info);
|
||||
data.mods.set(i, new);
|
||||
} else {
|
||||
data.selected_mod_index = None;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use druid::widget::{Button, Controller, Scroll};
|
||||
use druid::{Data, Env, Event, EventCtx, Rect, UpdateCtx, Widget};
|
||||
|
||||
use crate::state::{State, ACTION_START_SAVE_SETTINGS};
|
||||
use crate::state::{State, ACTION_SET_DIRTY, ACTION_START_SAVE_SETTINGS};
|
||||
|
||||
pub struct DisabledButtonController;
|
||||
|
||||
|
@ -57,11 +57,16 @@ impl<T: Data, W: Widget<T>> Controller<T, Scroll<T, W>> for AutoScrollController
|
|||
}
|
||||
}
|
||||
|
||||
/// A controller that submits the command to save settings every time its widget's
|
||||
/// data changes.
|
||||
pub struct SaveSettingsController;
|
||||
macro_rules! compare_state_fields {
|
||||
($old:ident, $new:ident, $($field:ident),+) => {
|
||||
$($old.$field != $new.$field) || +
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Widget<State>> Controller<State, W> for SaveSettingsController {
|
||||
/// A controller that tracks state changes for certain fields and submits commands to handle them.
|
||||
pub struct DirtyStateController;
|
||||
|
||||
impl<W: Widget<State>> Controller<State, W> for DirtyStateController {
|
||||
fn update(
|
||||
&mut self,
|
||||
child: &mut W,
|
||||
|
@ -70,13 +75,14 @@ impl<W: Widget<State>> Controller<State, W> for SaveSettingsController {
|
|||
data: &State,
|
||||
env: &Env,
|
||||
) {
|
||||
// Only filter for the values that actually go into the settings file.
|
||||
if old_data.mods != data.mods
|
||||
|| old_data.game_dir != data.game_dir
|
||||
|| old_data.data_dir != data.data_dir
|
||||
{
|
||||
if compare_state_fields!(old_data, data, mods, game_dir, data_dir) {
|
||||
ctx.submit_command(ACTION_START_SAVE_SETTINGS);
|
||||
}
|
||||
|
||||
if compare_state_fields!(old_data, data, mods, game_dir) {
|
||||
ctx.submit_command(ACTION_SET_DIRTY);
|
||||
}
|
||||
|
||||
child.update(ctx, old_data, data, env)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use druid::im::Vector;
|
||||
use druid::lens;
|
||||
use druid::widget::{
|
||||
|
@ -15,7 +17,7 @@ use crate::state::{
|
|||
ACTION_START_RESET_DEPLOYMENT,
|
||||
};
|
||||
use crate::ui::theme;
|
||||
use crate::ui::widget::controller::{AutoScrollController, SaveSettingsController};
|
||||
use crate::ui::widget::controller::{AutoScrollController, DirtyStateController};
|
||||
use crate::ui::widget::PathBufFormatter;
|
||||
|
||||
const TITLE: &str = "Darktide Mod Manager";
|
||||
|
@ -31,43 +33,48 @@ pub(crate) fn new() -> WindowDesc<State> {
|
|||
}
|
||||
|
||||
fn build_top_bar() -> impl Widget<State> {
|
||||
let mods_button = Button::new("Mods")
|
||||
.on_click(|_ctx, state: &mut State, _env| state.current_view = View::Mods);
|
||||
|
||||
let settings_button = Button::new("Settings").on_click(|_ctx, state: &mut State, _env| {
|
||||
state.current_view = View::Settings;
|
||||
});
|
||||
|
||||
let deploy_button = {
|
||||
Button::dynamic(|state: &State, _| {
|
||||
let mut s = String::new();
|
||||
if state.dirty {
|
||||
s.push_str("! ");
|
||||
}
|
||||
s.push_str("Deploy Mods");
|
||||
s
|
||||
})
|
||||
.on_click(|ctx, _state: &mut State, _env| {
|
||||
ctx.submit_command(ACTION_START_DEPLOY);
|
||||
})
|
||||
.disabled_if(|data, _| data.is_deployment_in_progress || data.is_reset_in_progress)
|
||||
};
|
||||
|
||||
let reset_button = Button::new("Reset Game")
|
||||
.on_click(|ctx, _state: &mut State, _env| {
|
||||
ctx.submit_command(ACTION_START_RESET_DEPLOYMENT);
|
||||
})
|
||||
.disabled_if(|data, _| data.is_deployment_in_progress || data.is_reset_in_progress);
|
||||
|
||||
Flex::row()
|
||||
.must_fill_main_axis(true)
|
||||
.main_axis_alignment(MainAxisAlignment::SpaceBetween)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Button::new("Mods")
|
||||
.on_click(|_ctx, state: &mut State, _env| state.current_view = View::Mods),
|
||||
)
|
||||
.with_child(mods_button)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("Settings").on_click(|_ctx, state: &mut State, _env| {
|
||||
state.current_view = View::Settings;
|
||||
}),
|
||||
),
|
||||
.with_child(settings_button),
|
||||
)
|
||||
.with_child(
|
||||
Flex::row()
|
||||
.with_child(
|
||||
Button::new("Deploy Mods")
|
||||
.on_click(|ctx, _state: &mut State, _env| {
|
||||
ctx.submit_command(ACTION_START_DEPLOY);
|
||||
})
|
||||
.disabled_if(|data, _| {
|
||||
data.is_deployment_in_progress || data.is_reset_in_progress
|
||||
}),
|
||||
)
|
||||
.with_child(deploy_button)
|
||||
.with_default_spacer()
|
||||
.with_child(
|
||||
Button::new("Reset Game")
|
||||
.on_click(|ctx, _state: &mut State, _env| {
|
||||
ctx.submit_command(ACTION_START_RESET_DEPLOYMENT);
|
||||
})
|
||||
.disabled_if(|data, _| {
|
||||
data.is_deployment_in_progress || data.is_reset_in_progress
|
||||
}),
|
||||
),
|
||||
.with_child(reset_button),
|
||||
)
|
||||
.padding(theme::TOP_BAR_INSETS)
|
||||
.background(theme::TOP_BAR_BACKGROUND_COLOR)
|
||||
|
@ -77,9 +84,11 @@ fn build_top_bar() -> impl Widget<State> {
|
|||
|
||||
fn build_mod_list() -> impl Widget<State> {
|
||||
let list = List::new(|| {
|
||||
let checkbox =
|
||||
Checkbox::new("").lens(lens!((usize, ModInfo, bool), 1).then(ModInfo::enabled));
|
||||
let name = Label::raw().lens(lens!((usize, ModInfo, bool), 1).then(ModInfo::name));
|
||||
let checkbox = Checkbox::new("")
|
||||
.lens(lens!((usize, Arc<ModInfo>, bool), 1).then(ModInfo::enabled.in_arc()));
|
||||
|
||||
let name =
|
||||
Label::raw().lens(lens!((usize, Arc<ModInfo>, bool), 1).then(ModInfo::name.in_arc()));
|
||||
|
||||
Flex::row()
|
||||
.must_fill_main_axis(true)
|
||||
|
@ -109,8 +118,10 @@ fn build_mod_list() -> impl Widget<State> {
|
|||
.collect::<Vector<_>>()
|
||||
},
|
||||
|state, infos| {
|
||||
infos.into_iter().for_each(|(i, info, _)| {
|
||||
state.mods.set(i, info);
|
||||
infos.into_iter().for_each(|(i, new, _)| {
|
||||
if state.mods.get(i).cloned() != Some(new.clone()) {
|
||||
state.mods.set(i, new);
|
||||
}
|
||||
});
|
||||
},
|
||||
));
|
||||
|
@ -142,12 +153,12 @@ fn build_mod_details_buttons() -> impl Widget<State> {
|
|||
.on_click(|_ctx, enabled: &mut bool, _env| {
|
||||
*enabled = !(*enabled);
|
||||
})
|
||||
.lens(ModInfo::enabled)
|
||||
.lens(ModInfo::enabled.in_arc())
|
||||
},
|
||||
// TODO: Gray out
|
||||
|| Button::new("Enable Mod"),
|
||||
)
|
||||
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
||||
.disabled_if(|info: &Option<Arc<ModInfo>>, _env: &druid::Env| info.is_none())
|
||||
.lens(State::selected_mod);
|
||||
|
||||
let button_add_mod = Button::new("Add Mod").on_click(|ctx, _state: &mut State, _env| {
|
||||
|
@ -162,14 +173,14 @@ fn build_mod_details_buttons() -> impl Widget<State> {
|
|||
});
|
||||
|
||||
let button_delete_mod = Button::new("Delete Mod")
|
||||
.on_click(|ctx, data: &mut Option<ModInfo>, _env| {
|
||||
.on_click(|ctx, data: &mut Option<Arc<ModInfo>>, _env| {
|
||||
if let Some(info) = data {
|
||||
ctx.submit_command(
|
||||
ACTION_START_DELETE_SELECTED_MOD.with(SingleUse::new(info.clone())),
|
||||
);
|
||||
}
|
||||
})
|
||||
.disabled_if(|info: &Option<ModInfo>, _env: &druid::Env| info.is_none())
|
||||
.disabled_if(|info: &Option<Arc<ModInfo>>, _env: &druid::Env| info.is_none())
|
||||
.lens(State::selected_mod);
|
||||
|
||||
Flex::column()
|
||||
|
@ -203,10 +214,10 @@ fn build_mod_details_info() -> impl Widget<State> {
|
|||
// Force the label to take up the entire details' pane width,
|
||||
// so that we can center-align it.
|
||||
.expand_width()
|
||||
.lens(ModInfo::name);
|
||||
.lens(ModInfo::name.in_arc());
|
||||
let description = Label::raw()
|
||||
.with_line_break_mode(LineBreaking::WordWrap)
|
||||
.lens(ModInfo::description);
|
||||
.lens(ModInfo::description.in_arc());
|
||||
|
||||
Flex::column()
|
||||
.cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
|
@ -312,5 +323,5 @@ fn build_window() -> impl Widget<State> {
|
|||
.with_child(build_top_bar())
|
||||
.with_flex_child(build_main(), 1.0)
|
||||
.with_child(build_log_view())
|
||||
.controller(SaveSettingsController)
|
||||
.controller(DirtyStateController)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::io::ErrorKind;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use clap::{parser::ValueSource, ArgMatches};
|
||||
|
@ -38,6 +39,7 @@ impl<'a> From<&'a State> for ConfigSerialize<'a> {
|
|||
mod_order: state
|
||||
.mods
|
||||
.iter()
|
||||
.map(Arc::as_ref)
|
||||
.map(LoadOrderEntrySerialize::from)
|
||||
.collect(),
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue