mirror of https://github.com/lapce/lapce.git
start terminal
This commit is contained in:
parent
2cd226ae1f
commit
2d1f1e13e4
|
@ -1990,6 +1990,7 @@ dependencies = [
|
|||
name = "lapce-proxy"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"crossbeam-channel 0.5.1",
|
||||
"git2",
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
use anyhow::Result;
|
||||
use druid::{Point, Rect, Selector, Size, WidgetId};
|
||||
use indexmap::IndexMap;
|
||||
use lapce_proxy::terminal::TermId;
|
||||
use lsp_types::{
|
||||
CodeActionResponse, CompletionItem, CompletionResponse, Location, Position,
|
||||
PublishDiagnosticsParams, Range, TextEdit,
|
||||
|
@ -20,6 +21,7 @@
|
|||
data::EditorKind,
|
||||
editor::{EditorLocation, EditorLocationNew, HighlightTextLayout},
|
||||
palette::{NewPaletteItem, PaletteType},
|
||||
proxy::TerminalContent,
|
||||
split::SplitMoveDirection,
|
||||
state::LapceWorkspace,
|
||||
};
|
||||
|
@ -374,6 +376,7 @@ pub enum LapceUICommand {
|
|||
UpdateLineChanges(BufferId),
|
||||
PublishDiagnostics(PublishDiagnosticsParams),
|
||||
UpdateDiffFiles(Vec<PathBuf>),
|
||||
TerminalUpdateContent(TermId, TerminalContent),
|
||||
ReloadBuffer(BufferId, u64, String),
|
||||
EnsureVisible((Rect, (f64, f64), Option<EnsureVisiblePosition>)),
|
||||
EnsureRectVisible(Rect),
|
||||
|
|
|
@ -70,6 +70,7 @@
|
|||
source_control::{SourceControlData, SOURCE_CONTROL_BUFFER},
|
||||
split::SplitMoveDirection,
|
||||
state::{LapceWorkspace, LapceWorkspaceType, Mode, VisualMode},
|
||||
terminal::TerminalSplitData,
|
||||
theme::OldLapceTheme,
|
||||
};
|
||||
|
||||
|
@ -214,6 +215,7 @@ pub struct LapceTabData {
|
|||
pub workspace: Option<Arc<LapceWorkspace>>,
|
||||
pub main_split: LapceMainSplitData,
|
||||
pub completion: Arc<CompletionData>,
|
||||
pub terminal: Arc<TerminalSplitData>,
|
||||
pub palette: Arc<PaletteData>,
|
||||
pub source_control: Arc<SourceControlData>,
|
||||
pub proxy: Arc<LapceProxy>,
|
||||
|
@ -270,6 +272,8 @@ pub fn new(
|
|||
&config,
|
||||
);
|
||||
|
||||
let terminal = Arc::new(TerminalSplitData::new());
|
||||
|
||||
let mut panels = im::HashMap::new();
|
||||
panels.insert(
|
||||
PanelPosition::LeftTop,
|
||||
|
@ -279,11 +283,20 @@ pub fn new(
|
|||
shown: true,
|
||||
}),
|
||||
);
|
||||
panels.insert(
|
||||
PanelPosition::BottomLeft,
|
||||
Arc::new(PanelData {
|
||||
active: terminal.widget_id,
|
||||
widgets: vec![terminal.widget_id],
|
||||
shown: true,
|
||||
}),
|
||||
);
|
||||
let mut tab = Self {
|
||||
id: tab_id,
|
||||
workspace: None,
|
||||
main_split,
|
||||
completion,
|
||||
terminal,
|
||||
source_control,
|
||||
palette,
|
||||
proxy,
|
||||
|
|
|
@ -5,11 +5,13 @@
|
|||
use std::thread;
|
||||
use std::{path::PathBuf, process::Child, sync::Arc};
|
||||
|
||||
use alacritty_terminal::term::cell::Cell;
|
||||
use anyhow::{anyhow, Result};
|
||||
use crossbeam_utils::sync::WaitGroup;
|
||||
use druid::{ExtEventSink, WidgetId};
|
||||
use druid::{Target, WindowId};
|
||||
use lapce_proxy::dispatch::{FileNodeItem, NewBufferResponse};
|
||||
use lapce_proxy::terminal::TermId;
|
||||
use lsp_types::CompletionItem;
|
||||
use lsp_types::Position;
|
||||
use lsp_types::PublishDiagnosticsParams;
|
||||
|
@ -28,6 +30,8 @@
|
|||
use crate::state::LapceWorkspaceType;
|
||||
use crate::{buffer::BufferId, command::LAPCE_UI_COMMAND};
|
||||
|
||||
pub type TerminalContent = Vec<(alacritty_terminal::index::Point, Cell)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LapceProxy {
|
||||
peer: Arc<Mutex<Option<RpcPeer>>>,
|
||||
|
@ -139,6 +143,15 @@ pub fn new_buffer(&self, buffer_id: BufferId, path: PathBuf) -> Result<String> {
|
|||
return Ok(resp.content);
|
||||
}
|
||||
|
||||
pub fn new_terminal(&self, term_id: TermId) {
|
||||
self.peer.lock().as_ref().unwrap().send_rpc_notification(
|
||||
"new_terminal",
|
||||
&json!({
|
||||
"term_id": term_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&self, buffer_id: BufferId, delta: &RopeDelta, rev: u64) {
|
||||
self.peer.lock().as_ref().unwrap().send_rpc_notification(
|
||||
"update",
|
||||
|
@ -343,6 +356,10 @@ pub enum Notification {
|
|||
ListDir {
|
||||
items: Vec<FileNodeItem>,
|
||||
},
|
||||
TerminalUpdateContent {
|
||||
id: TermId,
|
||||
content: TerminalContent,
|
||||
},
|
||||
DiffFiles {
|
||||
files: Vec<PathBuf>,
|
||||
},
|
||||
|
@ -385,6 +402,7 @@ fn handle_notification(
|
|||
Notification::ListDir { mut items } => {}
|
||||
Notification::DiffFiles { files } => {}
|
||||
Notification::PublishDiagnostics { diagnostics } => {}
|
||||
Notification::TerminalUpdateContent { id, content } => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -519,6 +537,13 @@ fn handle_notification(
|
|||
Target::Widget(self.tab_id),
|
||||
);
|
||||
}
|
||||
Notification::TerminalUpdateContent { id, content } => {
|
||||
self.event_sink.submit_command(
|
||||
LAPCE_UI_COMMAND,
|
||||
LapceUICommand::TerminalUpdateContent(id, content),
|
||||
Target::Widget(self.tab_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
split::LapceSplitNew,
|
||||
state::{LapceWorkspace, LapceWorkspaceType},
|
||||
status::LapceStatusNew,
|
||||
terminal::TerminalPanel,
|
||||
};
|
||||
|
||||
pub struct LapceTabNew {
|
||||
|
@ -73,6 +74,8 @@ pub fn new(data: &LapceTabData) -> Self {
|
|||
data.source_control.widget_id,
|
||||
WidgetPod::new(source_control.boxed()),
|
||||
);
|
||||
let terminal = TerminalPanel::new(&data);
|
||||
panels.insert(data.terminal.widget_id, WidgetPod::new(terminal.boxed()));
|
||||
|
||||
Self {
|
||||
id: data.id,
|
||||
|
@ -186,6 +189,7 @@ fn event(
|
|||
Arc::make_mut(buffer).load_content(content);
|
||||
ctx.set_handled();
|
||||
}
|
||||
LapceUICommand::TerminalUpdateContent(id, content) => {}
|
||||
LapceUICommand::UpdateDiffFiles(files) => {
|
||||
let source_control = Arc::make_mut(&mut data.source_control);
|
||||
source_control.diff_files = files
|
||||
|
|
|
@ -1,95 +1,107 @@
|
|||
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use alacritty_terminal::{
|
||||
config::Config,
|
||||
event::{Event, EventListener, Notify},
|
||||
event_loop::{EventLoop, Notifier},
|
||||
grid::GridCell,
|
||||
sync::FairMutex,
|
||||
term::SizeInfo,
|
||||
tty, Term,
|
||||
use druid::{
|
||||
BoxConstraints, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx,
|
||||
PaintCtx, Point, Size, UpdateCtx, Widget, WidgetId, WidgetPod,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use lsp_types::notification;
|
||||
use lapce_proxy::terminal::TermId;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Terminal {
|
||||
pub term: Arc<FairMutex<Term<EventProxy>>>,
|
||||
sender: Sender<Event>,
|
||||
use crate::{data::LapceTabData, proxy::LapceProxy, split::LapceSplitNew};
|
||||
|
||||
pub struct TerminalSplitData {
|
||||
pub widget_id: WidgetId,
|
||||
pub split_id: WidgetId,
|
||||
pub terminals: im::HashMap<TermId, LapceTerminalData>,
|
||||
}
|
||||
|
||||
pub type TermConfig = Config<HashMap<String, String>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventProxy {
|
||||
sender: Sender<Event>,
|
||||
}
|
||||
|
||||
impl EventProxy {}
|
||||
|
||||
impl EventListener for EventProxy {
|
||||
fn send_event(&self, event: alacritty_terminal::event::Event) {
|
||||
self.sender.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
impl TerminalSplitData {
|
||||
pub fn new() -> Self {
|
||||
let config = TermConfig::default();
|
||||
let (sender, receiver) = crossbeam_channel::unbounded();
|
||||
let event_proxy = EventProxy {
|
||||
sender: sender.clone(),
|
||||
};
|
||||
let size = SizeInfo::new(10.0, 10.0, 1.0, 1.0, 0.0, 0.0, true);
|
||||
let pty = tty::new(&config, &size, None);
|
||||
let terminal = Term::new(&config, size, event_proxy.clone());
|
||||
let terminal = Arc::new(FairMutex::new(terminal));
|
||||
let event_loop =
|
||||
EventLoop::new(terminal.clone(), event_proxy, pty, false, false);
|
||||
let loop_tx = event_loop.channel();
|
||||
let notifier = Notifier(loop_tx.clone());
|
||||
event_loop.spawn();
|
||||
let split_id = WidgetId::next();
|
||||
let mut terminals = im::HashMap::new();
|
||||
|
||||
let terminal = Terminal {
|
||||
term: terminal,
|
||||
sender,
|
||||
};
|
||||
terminal.run(receiver, notifier);
|
||||
terminal
|
||||
}
|
||||
|
||||
fn run(&self, receiver: Receiver<Event>, notifier: Notifier) {
|
||||
let term = self.term.clone();
|
||||
std::thread::spawn(move || -> Result<()> {
|
||||
loop {
|
||||
let event = receiver.recv()?;
|
||||
match event {
|
||||
Event::MouseCursorDirty => {}
|
||||
Event::Title(_) => {}
|
||||
Event::ResetTitle => {}
|
||||
Event::ClipboardStore(_, _) => {}
|
||||
Event::ClipboardLoad(_, _) => {}
|
||||
Event::ColorRequest(_, _) => {}
|
||||
Event::PtyWrite(s) => {
|
||||
notifier.notify(s.into_bytes());
|
||||
}
|
||||
Event::CursorBlinkingChange(_) => {}
|
||||
Event::Wakeup => {
|
||||
for cell in term.lock().renderable_content().display_iter {
|
||||
if !cell.is_empty() {
|
||||
println!("{:?}", cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Bell => {}
|
||||
Event::Exit => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn insert<B: Into<String>>(&self, data: B) {
|
||||
self.sender.send(Event::PtyWrite(data.into()));
|
||||
Self {
|
||||
widget_id: WidgetId::next(),
|
||||
split_id,
|
||||
terminals,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LapceTerminalData {
|
||||
id: TermId,
|
||||
}
|
||||
|
||||
impl LapceTerminalData {
|
||||
pub fn new(proxy: Arc<LapceProxy>) -> Self {
|
||||
let id = TermId::next();
|
||||
proxy.new_terminal(id);
|
||||
Self { id }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TerminalPanel {
|
||||
widget_id: WidgetId,
|
||||
split: WidgetPod<LapceTabData, LapceSplitNew>,
|
||||
}
|
||||
|
||||
impl TerminalPanel {
|
||||
pub fn new(data: &LapceTabData) -> Self {
|
||||
let split = LapceSplitNew::new(data.terminal.split_id);
|
||||
Self {
|
||||
widget_id: data.terminal.widget_id,
|
||||
split: WidgetPod::new(split),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget<LapceTabData> for TerminalPanel {
|
||||
fn id(&self) -> Option<WidgetId> {
|
||||
Some(self.widget_id)
|
||||
}
|
||||
|
||||
fn event(
|
||||
&mut self,
|
||||
ctx: &mut EventCtx,
|
||||
event: &Event,
|
||||
data: &mut LapceTabData,
|
||||
env: &Env,
|
||||
) {
|
||||
self.split.event(ctx, event, data, env);
|
||||
}
|
||||
|
||||
fn lifecycle(
|
||||
&mut self,
|
||||
ctx: &mut LifeCycleCtx,
|
||||
event: &LifeCycle,
|
||||
data: &LapceTabData,
|
||||
env: &Env,
|
||||
) {
|
||||
self.split.lifecycle(ctx, event, data, env);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
ctx: &mut UpdateCtx,
|
||||
old_data: &LapceTabData,
|
||||
data: &LapceTabData,
|
||||
env: &Env,
|
||||
) {
|
||||
self.split.update(ctx, data, env);
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
ctx: &mut LayoutCtx,
|
||||
bc: &BoxConstraints,
|
||||
data: &LapceTabData,
|
||||
env: &Env,
|
||||
) -> Size {
|
||||
self.split.layout(ctx, bc, data, env);
|
||||
self.split.set_origin(ctx, data, env, Point::ZERO);
|
||||
bc.max()
|
||||
}
|
||||
|
||||
fn paint(&mut self, ctx: &mut PaintCtx, data: &LapceTabData, env: &Env) {
|
||||
self.split.paint(ctx, data, env);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ authors = ["Dongdong Zhou <dzhou121@gmail.com>"]
|
|||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
alacritty_terminal = "0.15.0"
|
||||
notify = "4.0.16"
|
||||
lapce-rpc = { path = "../rpc" }
|
||||
xi-core-lib = "0.3.0"
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
use crate::core_proxy::CoreProxy;
|
||||
use crate::lsp::LspCatalog;
|
||||
use crate::plugin::PluginCatalog;
|
||||
use crate::terminal::{TermId, Terminal, TerminalEvent};
|
||||
use anyhow::{anyhow, Result};
|
||||
use crossbeam_channel::{unbounded, Receiver, Sender};
|
||||
use git2::{DiffOptions, Oid, Repository};
|
||||
|
@ -31,6 +32,7 @@ pub struct Dispatcher {
|
|||
pub git_sender: Sender<(BufferId, u64)>,
|
||||
pub workspace: Arc<Mutex<PathBuf>>,
|
||||
pub buffers: Arc<Mutex<HashMap<BufferId, Buffer>>>,
|
||||
pub terminals: Arc<Mutex<HashMap<TermId, Terminal>>>,
|
||||
open_files: Arc<Mutex<HashMap<String, BufferId>>>,
|
||||
plugins: Arc<Mutex<PluginCatalog>>,
|
||||
pub lsp: Arc<Mutex<LspCatalog>>,
|
||||
|
@ -112,6 +114,9 @@ pub enum Notification {
|
|||
delta: RopeDelta,
|
||||
rev: u64,
|
||||
},
|
||||
NewTerminal {
|
||||
term_id: TermId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
@ -212,6 +217,7 @@ pub fn new(sender: Sender<Value>) -> Dispatcher {
|
|||
git_sender,
|
||||
workspace: Arc::new(Mutex::new(PathBuf::new())),
|
||||
buffers: Arc::new(Mutex::new(HashMap::new())),
|
||||
terminals: Arc::new(Mutex::new(HashMap::new())),
|
||||
open_files: Arc::new(Mutex::new(HashMap::new())),
|
||||
plugins: Arc::new(Mutex::new(plugins)),
|
||||
lsp: Arc::new(Mutex::new(LspCatalog::new())),
|
||||
|
@ -400,6 +406,27 @@ fn handle_notification(&self, rpc: Notification) {
|
|||
self.lsp.lock().update(buffer, &content_change, buffer.rev);
|
||||
}
|
||||
}
|
||||
Notification::NewTerminal { term_id } => {
|
||||
let (terminal, receiver) = Terminal::new();
|
||||
self.terminals.lock().insert(term_id, terminal);
|
||||
|
||||
let local_proxy = self.clone();
|
||||
thread::spawn(move || -> Result<()> {
|
||||
loop {
|
||||
let event = receiver.recv()?;
|
||||
match event {
|
||||
TerminalEvent::UpdateContent(content) => local_proxy
|
||||
.send_notification(
|
||||
"terminal_update_content",
|
||||
json!({
|
||||
"id": term_id,
|
||||
"content": content,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
pub mod dispatch;
|
||||
pub mod lsp;
|
||||
pub mod plugin;
|
||||
pub mod terminal;
|
||||
|
||||
use dispatch::Dispatcher;
|
||||
|
||||
|
|
|
@ -0,0 +1,143 @@
|
|||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{self, AtomicU64},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
use alacritty_terminal::{
|
||||
config::Config,
|
||||
event::{Event, EventListener, Notify},
|
||||
event_loop::{EventLoop, Notifier},
|
||||
grid::GridCell,
|
||||
index::Point,
|
||||
sync::FairMutex,
|
||||
term::{cell::Cell, SizeInfo},
|
||||
tty, Term,
|
||||
};
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use lsp_types::notification;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub struct Counter(AtomicU64);
|
||||
|
||||
impl Counter {
|
||||
pub const fn new() -> Counter {
|
||||
Counter(AtomicU64::new(1))
|
||||
}
|
||||
|
||||
pub fn next(&self) -> u64 {
|
||||
self.0.fetch_add(1, atomic::Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq, Hash, Copy, Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TermId(pub u64);
|
||||
|
||||
impl TermId {
|
||||
pub fn next() -> Self {
|
||||
static TERM_ID_COUNTER: Counter = Counter::new();
|
||||
Self(TERM_ID_COUNTER.next())
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TerminalEvent {
|
||||
UpdateContent(Vec<(Point, Cell)>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Terminal {
|
||||
pub term: Arc<FairMutex<Term<EventProxy>>>,
|
||||
sender: Sender<Event>,
|
||||
host_sender: Sender<TerminalEvent>,
|
||||
}
|
||||
|
||||
pub type TermConfig = Config<HashMap<String, String>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EventProxy {
|
||||
sender: Sender<Event>,
|
||||
}
|
||||
|
||||
impl EventProxy {}
|
||||
|
||||
impl EventListener for EventProxy {
|
||||
fn send_event(&self, event: alacritty_terminal::event::Event) {
|
||||
self.sender.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
pub fn new() -> (Self, Receiver<TerminalEvent>) {
|
||||
let config = TermConfig::default();
|
||||
let (sender, receiver) = crossbeam_channel::unbounded();
|
||||
let event_proxy = EventProxy {
|
||||
sender: sender.clone(),
|
||||
};
|
||||
let size = SizeInfo::new(10.0, 10.0, 1.0, 1.0, 0.0, 0.0, true);
|
||||
let pty = tty::new(&config, &size, None);
|
||||
let terminal = Term::new(&config, size, event_proxy.clone());
|
||||
let terminal = Arc::new(FairMutex::new(terminal));
|
||||
let event_loop =
|
||||
EventLoop::new(terminal.clone(), event_proxy, pty, false, false);
|
||||
let loop_tx = event_loop.channel();
|
||||
let notifier = Notifier(loop_tx.clone());
|
||||
event_loop.spawn();
|
||||
|
||||
let (host_sender, host_receiver) = crossbeam_channel::unbounded();
|
||||
let terminal = Terminal {
|
||||
term: terminal,
|
||||
sender,
|
||||
host_sender,
|
||||
};
|
||||
terminal.run(receiver, notifier);
|
||||
(terminal, host_receiver)
|
||||
}
|
||||
|
||||
fn run(&self, receiver: Receiver<Event>, notifier: Notifier) {
|
||||
let term = self.term.clone();
|
||||
let host_sender = self.host_sender.clone();
|
||||
std::thread::spawn(move || -> Result<()> {
|
||||
loop {
|
||||
let event = receiver.recv()?;
|
||||
match event {
|
||||
Event::MouseCursorDirty => {}
|
||||
Event::Title(_) => {}
|
||||
Event::ResetTitle => {}
|
||||
Event::ClipboardStore(_, _) => {}
|
||||
Event::ClipboardLoad(_, _) => {}
|
||||
Event::ColorRequest(_, _) => {}
|
||||
Event::PtyWrite(s) => {
|
||||
notifier.notify(s.into_bytes());
|
||||
}
|
||||
Event::CursorBlinkingChange(_) => {}
|
||||
Event::Wakeup => {
|
||||
let content = term
|
||||
.lock()
|
||||
.renderable_content()
|
||||
.display_iter
|
||||
.filter_map(|c| {
|
||||
if c.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((c.point, c.cell.clone()))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<(Point, Cell)>>();
|
||||
let event = TerminalEvent::UpdateContent(content);
|
||||
host_sender.send(event);
|
||||
}
|
||||
Event::Bell => {}
|
||||
Event::Exit => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn insert<B: Into<String>>(&self, data: B) {
|
||||
self.sender.send(Event::PtyWrite(data.into()));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue