mirror of
https://github.com/servo/servo.git
synced 2025-08-04 13:10:20 +01:00
filepicker
This commit is contained in:
parent
0c11e8340b
commit
256c7e894e
5 changed files with 135 additions and 46 deletions
|
@ -7,48 +7,88 @@ use ipc_channel::ipc::{self, IpcReceiver, IpcSender};
|
||||||
use mime_classifier::MIMEClassifier;
|
use mime_classifier::MIMEClassifier;
|
||||||
use mime_guess::guess_mime_type_opt;
|
use mime_guess::guess_mime_type_opt;
|
||||||
use net_traits::blob_url_store::{BlobURLStoreEntry, BlobURLStoreError};
|
use net_traits::blob_url_store::{BlobURLStoreEntry, BlobURLStoreError};
|
||||||
use net_traits::filemanager_thread::{FileManagerThreadMsg, FileManagerResult};
|
use net_traits::filemanager_thread::{FileManagerThreadMsg, FileManagerResult, FilterPattern};
|
||||||
use net_traits::filemanager_thread::{SelectedFile, FileManagerThreadError, SelectedFileId};
|
use net_traits::filemanager_thread::{SelectedFile, FileManagerThreadError, SelectedFileId};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
use tinyfiledialogs;
|
||||||
use url::Origin;
|
use url::Origin;
|
||||||
use util::thread::spawn_named;
|
use util::thread::spawn_named;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub trait FileManagerThreadFactory {
|
pub trait FileManagerThreadFactory<UI: 'static + UIProvider> {
|
||||||
fn new() -> Self;
|
fn new(&'static UI) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileManagerThreadFactory for IpcSender<FileManagerThreadMsg> {
|
pub trait UIProvider where Self: Sync {
|
||||||
|
fn open_file_dialog(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<String>;
|
||||||
|
|
||||||
|
fn open_file_dialog_multi(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<Vec<String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TFDProvider;
|
||||||
|
|
||||||
|
impl UIProvider for TFDProvider {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn open_file_dialog(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<String> {
|
||||||
|
tinyfiledialogs::open_file_dialog("Pick a file", path, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
|
fn open_file_dialog_multi(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<Vec<String>> {
|
||||||
|
tinyfiledialogs::open_file_dialog_multi("Pick files", path, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
fn open_file_dialog(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
fn open_file_dialog_multi(&self, path: &str,
|
||||||
|
filter: Option<(&[&str], &str)>) -> Option<Vec<String>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<UI: 'static + UIProvider> FileManagerThreadFactory<UI> for IpcSender<FileManagerThreadMsg> {
|
||||||
/// Create a FileManagerThread
|
/// Create a FileManagerThread
|
||||||
fn new() -> IpcSender<FileManagerThreadMsg> {
|
fn new(ui: &'static UI) -> IpcSender<FileManagerThreadMsg> {
|
||||||
let (chan, recv) = ipc::channel().unwrap();
|
let (chan, recv) = ipc::channel().unwrap();
|
||||||
|
|
||||||
spawn_named("FileManager".to_owned(), move || {
|
spawn_named("FileManager".to_owned(), move || {
|
||||||
FileManager::new(recv).start();
|
FileManager::new(recv, ui).start();
|
||||||
});
|
});
|
||||||
|
|
||||||
chan
|
chan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FileManager {
|
struct FileManager<UI: 'static + UIProvider> {
|
||||||
receiver: IpcReceiver<FileManagerThreadMsg>,
|
receiver: IpcReceiver<FileManagerThreadMsg>,
|
||||||
idmap: HashMap<Uuid, PathBuf>,
|
idmap: HashMap<Uuid, PathBuf>,
|
||||||
classifier: Arc<MIMEClassifier>,
|
classifier: Arc<MIMEClassifier>,
|
||||||
blob_url_store: Arc<RwLock<BlobURLStore>>,
|
blob_url_store: Arc<RwLock<BlobURLStore>>,
|
||||||
|
ui: &'static UI,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileManager {
|
impl<UI: 'static + UIProvider> FileManager<UI> {
|
||||||
fn new(recv: IpcReceiver<FileManagerThreadMsg>) -> FileManager {
|
fn new(recv: IpcReceiver<FileManagerThreadMsg>, ui: &'static UI) -> FileManager<UI> {
|
||||||
FileManager {
|
FileManager {
|
||||||
receiver: recv,
|
receiver: recv,
|
||||||
idmap: HashMap::new(),
|
idmap: HashMap::new(),
|
||||||
classifier: Arc::new(MIMEClassifier::new()),
|
classifier: Arc::new(MIMEClassifier::new()),
|
||||||
blob_url_store: Arc::new(RwLock::new(BlobURLStore::new())),
|
blob_url_store: Arc::new(RwLock::new(BlobURLStore::new())),
|
||||||
|
ui: ui
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,8 +96,8 @@ impl FileManager {
|
||||||
fn start(&mut self) {
|
fn start(&mut self) {
|
||||||
loop {
|
loop {
|
||||||
match self.receiver.recv().unwrap() {
|
match self.receiver.recv().unwrap() {
|
||||||
FileManagerThreadMsg::SelectFile(sender) => self.select_file(sender),
|
FileManagerThreadMsg::SelectFile(filter, sender) => self.select_file(filter, sender),
|
||||||
FileManagerThreadMsg::SelectFiles(sender) => self.select_files(sender),
|
FileManagerThreadMsg::SelectFiles(filter, sender) => self.select_files(filter, sender),
|
||||||
FileManagerThreadMsg::ReadFile(sender, id) => {
|
FileManagerThreadMsg::ReadFile(sender, id) => {
|
||||||
match self.try_read_file(id) {
|
match self.try_read_file(id) {
|
||||||
Ok(buffer) => { let _ = sender.send(Ok(buffer)); }
|
Ok(buffer) => { let _ = sender.send(Ok(buffer)); }
|
||||||
|
@ -74,33 +114,51 @@ impl FileManager {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
impl FileManager {
|
fn select_file(&mut self, _filter: Vec<FilterPattern>,
|
||||||
fn select_file(&mut self, sender: IpcSender<FileManagerResult<SelectedFile>>) {
|
sender: IpcSender<FileManagerResult<SelectedFile>>) {
|
||||||
// TODO: Pull the dialog UI in and get selected
|
match self.ui.open_file_dialog("", None) {
|
||||||
// XXX: "test.txt" is "tests/unit/net/test.txt", for temporary testing purpose
|
Some(s) => {
|
||||||
let selected_path = Path::new("test.txt");
|
let selected_path = Path::new(&s);
|
||||||
|
|
||||||
match self.create_entry(selected_path) {
|
match self.create_entry(selected_path) {
|
||||||
Some(triple) => { let _ = sender.send(Ok(triple)); }
|
Some(triple) => { let _ = sender.send(Ok(triple)); }
|
||||||
None => { let _ = sender.send(Err(FileManagerThreadError::InvalidSelection)); }
|
None => { let _ = sender.send(Err(FileManagerThreadError::InvalidSelection)); }
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = sender.send(Err(FileManagerThreadError::UserCancelled));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_files(&mut self, sender: IpcSender<FileManagerResult<Vec<SelectedFile>>>) {
|
fn select_files(&mut self, _filter: Vec<FilterPattern>,
|
||||||
let selected_paths = vec![Path::new("test.txt")];
|
sender: IpcSender<FileManagerResult<Vec<SelectedFile>>>) {
|
||||||
|
match self.ui.open_file_dialog_multi("", None) {
|
||||||
|
Some(v) => {
|
||||||
|
let mut selected_paths = vec![];
|
||||||
|
|
||||||
let mut replies = vec![];
|
for s in &v {
|
||||||
|
selected_paths.push(Path::new(s));
|
||||||
|
}
|
||||||
|
|
||||||
for path in selected_paths {
|
let mut replies = vec![];
|
||||||
match self.create_entry(path) {
|
|
||||||
Some(triple) => replies.push(triple),
|
for path in selected_paths {
|
||||||
None => { let _ = sender.send(Err(FileManagerThreadError::InvalidSelection)); }
|
match self.create_entry(path) {
|
||||||
};
|
Some(triple) => replies.push(triple),
|
||||||
|
None => { let _ = sender.send(Err(FileManagerThreadError::InvalidSelection)); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = sender.send(Ok(replies));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = sender.send(Err(FileManagerThreadError::UserCancelled));
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = sender.send(Ok(replies));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_entry(&mut self, file_path: &Path) -> Option<SelectedFile> {
|
fn create_entry(&mut self, file_path: &Path) -> Option<SelectedFile> {
|
||||||
|
@ -195,4 +253,3 @@ impl BlobURLStore {
|
||||||
self.entries.remove(&id);
|
self.entries.remove(&id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ use data_loader;
|
||||||
use devtools_traits::DevtoolsControlMsg;
|
use devtools_traits::DevtoolsControlMsg;
|
||||||
use fetch::methods::{fetch, FetchContext};
|
use fetch::methods::{fetch, FetchContext};
|
||||||
use file_loader;
|
use file_loader;
|
||||||
use filemanager_thread::FileManagerThreadFactory;
|
use filemanager_thread::{FileManagerThreadFactory, TFDProvider};
|
||||||
use hsts::HstsList;
|
use hsts::HstsList;
|
||||||
use http_loader::{self, HttpState};
|
use http_loader::{self, HttpState};
|
||||||
use hyper::client::pool::Pool;
|
use hyper::client::pool::Pool;
|
||||||
|
@ -49,6 +49,8 @@ use util::prefs;
|
||||||
use util::thread::spawn_named;
|
use util::thread::spawn_named;
|
||||||
use websocket_loader;
|
use websocket_loader;
|
||||||
|
|
||||||
|
const TFD_PROVIDER: &'static TFDProvider = &TFDProvider;
|
||||||
|
|
||||||
pub enum ProgressSender {
|
pub enum ProgressSender {
|
||||||
Channel(IpcSender<ProgressMsg>),
|
Channel(IpcSender<ProgressMsg>),
|
||||||
Listener(AsyncResponseTarget),
|
Listener(AsyncResponseTarget),
|
||||||
|
@ -161,7 +163,7 @@ pub fn new_resource_threads(user_agent: String,
|
||||||
profiler_chan: ProfilerChan) -> ResourceThreads {
|
profiler_chan: ProfilerChan) -> ResourceThreads {
|
||||||
ResourceThreads::new(new_core_resource_thread(user_agent, devtools_chan, profiler_chan),
|
ResourceThreads::new(new_core_resource_thread(user_agent, devtools_chan, profiler_chan),
|
||||||
StorageThreadFactory::new(),
|
StorageThreadFactory::new(),
|
||||||
FileManagerThreadFactory::new())
|
FileManagerThreadFactory::new(TFD_PROVIDER))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,13 +18,16 @@ pub struct SelectedFile {
|
||||||
pub type_string: String,
|
pub type_string: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct FilterPattern(pub String);
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub enum FileManagerThreadMsg {
|
pub enum FileManagerThreadMsg {
|
||||||
/// Select a single file, return triple (FileID, FileName, lastModified)
|
/// Select a single file, return triple (FileID, FileName, lastModified)
|
||||||
SelectFile(IpcSender<FileManagerResult<SelectedFile>>),
|
SelectFile(Vec<FilterPattern>, IpcSender<FileManagerResult<SelectedFile>>),
|
||||||
|
|
||||||
/// Select multiple files, return a vector of triples
|
/// Select multiple files, return a vector of triples
|
||||||
SelectFiles(IpcSender<FileManagerResult<Vec<SelectedFile>>>),
|
SelectFiles(Vec<FilterPattern>, IpcSender<FileManagerResult<Vec<SelectedFile>>>),
|
||||||
|
|
||||||
/// Read file, return the bytes
|
/// Read file, return the bytes
|
||||||
ReadFile(IpcSender<FileManagerResult<Vec<u8>>>, SelectedFileId),
|
ReadFile(IpcSender<FileManagerResult<Vec<u8>>>, SelectedFileId),
|
||||||
|
@ -43,8 +46,10 @@ pub type FileManagerResult<T> = Result<T, FileManagerThreadError>;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub enum FileManagerThreadError {
|
pub enum FileManagerThreadError {
|
||||||
/// The selection action is invalid, nothing is selected
|
/// The selection action is invalid due to exceptional reason
|
||||||
InvalidSelection,
|
InvalidSelection,
|
||||||
|
/// The selection action is cancelled by user
|
||||||
|
UserCancelled,
|
||||||
/// Failure to process file information such as file name, modified time etc.
|
/// Failure to process file information such as file name, modified time etc.
|
||||||
FileInfoProcessingError,
|
FileInfoProcessingError,
|
||||||
/// Failure to read the file content
|
/// Failure to read the file content
|
||||||
|
|
|
@ -33,7 +33,7 @@ use dom::validation::Validatable;
|
||||||
use dom::virtualmethods::VirtualMethods;
|
use dom::virtualmethods::VirtualMethods;
|
||||||
use ipc_channel::ipc::{self, IpcSender};
|
use ipc_channel::ipc::{self, IpcSender};
|
||||||
use net_traits::IpcSend;
|
use net_traits::IpcSend;
|
||||||
use net_traits::filemanager_thread::FileManagerThreadMsg;
|
use net_traits::filemanager_thread::{FileManagerThreadMsg, FilterPattern};
|
||||||
use script_traits::ScriptMsg as ConstellationMsg;
|
use script_traits::ScriptMsg as ConstellationMsg;
|
||||||
use std::borrow::ToOwned;
|
use std::borrow::ToOwned;
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
|
@ -993,7 +993,7 @@ impl Activatable for HTMLInputElement {
|
||||||
// https://html.spec.whatwg.org/multipage/#reset-button-state-%28type=reset%29:activation-behaviour-2
|
// https://html.spec.whatwg.org/multipage/#reset-button-state-%28type=reset%29:activation-behaviour-2
|
||||||
// https://html.spec.whatwg.org/multipage/#checkbox-state-%28type=checkbox%29:activation-behaviour-2
|
// https://html.spec.whatwg.org/multipage/#checkbox-state-%28type=checkbox%29:activation-behaviour-2
|
||||||
// https://html.spec.whatwg.org/multipage/#radio-button-state-%28type=radio%29:activation-behaviour-2
|
// https://html.spec.whatwg.org/multipage/#radio-button-state-%28type=radio%29:activation-behaviour-2
|
||||||
InputType::InputSubmit | InputType::InputReset
|
InputType::InputSubmit | InputType::InputReset | InputType::InputFile
|
||||||
| InputType::InputCheckbox | InputType::InputRadio => self.is_mutable(),
|
| InputType::InputCheckbox | InputType::InputRadio => self.is_mutable(),
|
||||||
_ => false
|
_ => false
|
||||||
}
|
}
|
||||||
|
@ -1140,9 +1140,11 @@ impl Activatable for HTMLInputElement {
|
||||||
let mut files: Vec<Root<File>> = vec![];
|
let mut files: Vec<Root<File>> = vec![];
|
||||||
let mut error = None;
|
let mut error = None;
|
||||||
|
|
||||||
|
let filter = filter_from_accept(self.Accept());
|
||||||
|
|
||||||
if self.Multiple() {
|
if self.Multiple() {
|
||||||
let (chan, recv) = ipc::channel().expect("Error initializing channel");
|
let (chan, recv) = ipc::channel().expect("Error initializing channel");
|
||||||
let msg = FileManagerThreadMsg::SelectFiles(chan);
|
let msg = FileManagerThreadMsg::SelectFiles(filter, chan);
|
||||||
let _ = filemanager.send(msg).unwrap();
|
let _ = filemanager.send(msg).unwrap();
|
||||||
|
|
||||||
match recv.recv().expect("IpcSender side error") {
|
match recv.recv().expect("IpcSender side error") {
|
||||||
|
@ -1155,7 +1157,7 @@ impl Activatable for HTMLInputElement {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
let (chan, recv) = ipc::channel().expect("Error initializing channel");
|
let (chan, recv) = ipc::channel().expect("Error initializing channel");
|
||||||
let msg = FileManagerThreadMsg::SelectFile(chan);
|
let msg = FileManagerThreadMsg::SelectFile(filter, chan);
|
||||||
let _ = filemanager.send(msg).unwrap();
|
let _ = filemanager.send(msg).unwrap();
|
||||||
|
|
||||||
match recv.recv().expect("IpcSender side error") {
|
match recv.recv().expect("IpcSender side error") {
|
||||||
|
@ -1228,3 +1230,10 @@ impl Activatable for HTMLInputElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn filter_from_accept(_s: DOMString) -> Vec<FilterPattern> {
|
||||||
|
/// TODO: it means not pattern restriction now
|
||||||
|
/// Blocked by https://github.com/cybergeek94/mime_guess/issues/19
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,15 +3,29 @@
|
||||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
use ipc_channel::ipc::{self, IpcSender};
|
use ipc_channel::ipc::{self, IpcSender};
|
||||||
use net::filemanager_thread::FileManagerThreadFactory;
|
use net::filemanager_thread::{FileManagerThreadFactory, UIProvider};
|
||||||
use net_traits::filemanager_thread::{FileManagerThreadMsg, FileManagerThreadError};
|
use net_traits::filemanager_thread::{FilterPattern, FileManagerThreadMsg, FileManagerThreadError};
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const TEST_PROVIDER: &'static TestProvider = &TestProvider;
|
||||||
|
|
||||||
|
struct TestProvider;
|
||||||
|
|
||||||
|
impl UIProvider for TestProvider {
|
||||||
|
fn open_file_dialog(&self, _: &str, _: Option<(&[&str], &str)>) -> Option<String> {
|
||||||
|
Some("test.txt".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_file_dialog_multi(&self, _: &str, _: Option<(&[&str], &str)>) -> Option<Vec<String>> {
|
||||||
|
Some(vec!["test.txt".to_string()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_filemanager() {
|
fn test_filemanager() {
|
||||||
let chan: IpcSender<FileManagerThreadMsg> = FileManagerThreadFactory::new();
|
let chan: IpcSender<FileManagerThreadMsg> = FileManagerThreadFactory::new(TEST_PROVIDER);
|
||||||
|
|
||||||
// Try to open a dummy file "tests/unit/net/test.txt" in tree
|
// Try to open a dummy file "tests/unit/net/test.txt" in tree
|
||||||
let mut handler = File::open("test.txt").expect("test.txt is stolen");
|
let mut handler = File::open("test.txt").expect("test.txt is stolen");
|
||||||
|
@ -20,11 +34,13 @@ fn test_filemanager() {
|
||||||
handler.read_to_end(&mut test_file_content)
|
handler.read_to_end(&mut test_file_content)
|
||||||
.expect("Read tests/unit/net/test.txt error");
|
.expect("Read tests/unit/net/test.txt error");
|
||||||
|
|
||||||
|
let patterns = vec![FilterPattern(".txt".to_string())];
|
||||||
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// Try to select a dummy file "tests/unit/net/test.txt"
|
// Try to select a dummy file "tests/unit/net/test.txt"
|
||||||
let (tx, rx) = ipc::channel().unwrap();
|
let (tx, rx) = ipc::channel().unwrap();
|
||||||
chan.send(FileManagerThreadMsg::SelectFile(tx)).unwrap();
|
chan.send(FileManagerThreadMsg::SelectFile(patterns.clone(), tx)).unwrap();
|
||||||
let selected = rx.recv().expect("File manager channel is broken")
|
let selected = rx.recv().expect("File manager channel is broken")
|
||||||
.expect("The file manager failed to find test.txt");
|
.expect("The file manager failed to find test.txt");
|
||||||
|
|
||||||
|
@ -66,7 +82,7 @@ fn test_filemanager() {
|
||||||
|
|
||||||
{
|
{
|
||||||
let (tx, rx) = ipc::channel().unwrap();
|
let (tx, rx) = ipc::channel().unwrap();
|
||||||
let _ = chan.send(FileManagerThreadMsg::SelectFile(tx));
|
let _ = chan.send(FileManagerThreadMsg::SelectFile(patterns.clone(), tx));
|
||||||
|
|
||||||
assert!(rx.try_recv().is_err(), "The thread should not respond normally after exited");
|
assert!(rx.try_recv().is_err(), "The thread should not respond normally after exited");
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue