mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-08 18:41:48 +00:00
Make operations for all buffer manipulations
This commit is contained in:
parent
f1b3692a35
commit
210286da48
1 changed files with 252 additions and 284 deletions
|
@ -6,7 +6,7 @@ use crate::{
|
|||
use anyhow::{anyhow, Result};
|
||||
use call::ActiveCall;
|
||||
use client::RECEIVE_TIMEOUT;
|
||||
use collections::BTreeMap;
|
||||
use collections::{BTreeMap, HashSet};
|
||||
use fs::Fs as _;
|
||||
use futures::StreamExt as _;
|
||||
use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
|
||||
|
@ -486,14 +486,44 @@ enum ClientOperation {
|
|||
project_root_name: String,
|
||||
full_path: PathBuf,
|
||||
},
|
||||
SearchProject {
|
||||
project_root_name: String,
|
||||
query: String,
|
||||
detach: bool,
|
||||
},
|
||||
EditBuffer {
|
||||
project_root_name: String,
|
||||
full_path: PathBuf,
|
||||
edits: Vec<(Range<usize>, Arc<str>)>,
|
||||
},
|
||||
CloseBuffer {
|
||||
project_root_name: String,
|
||||
full_path: PathBuf,
|
||||
},
|
||||
SaveBuffer {
|
||||
project_root_name: String,
|
||||
full_path: PathBuf,
|
||||
detach: bool,
|
||||
},
|
||||
RequestLspDataInBuffer {
|
||||
project_root_name: String,
|
||||
full_path: PathBuf,
|
||||
offset: usize,
|
||||
kind: LspRequestKind,
|
||||
detach: bool,
|
||||
},
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum LspRequestKind {
|
||||
Rename,
|
||||
Completion,
|
||||
CodeAction,
|
||||
Definition,
|
||||
Highlights,
|
||||
}
|
||||
|
||||
impl TestPlan {
|
||||
async fn next_operation(&mut self, clients: &[(Rc<TestClient>, TestAppContext)]) -> Operation {
|
||||
let operation = loop {
|
||||
|
@ -679,27 +709,44 @@ impl TestPlan {
|
|||
}
|
||||
},
|
||||
|
||||
// Mutate buffers
|
||||
// Query and mutate buffers
|
||||
60.. => {
|
||||
let Some(project) = choose_random_project(client, &mut self.rng) else { continue };
|
||||
let project_root_name = root_name_for_project(&project, cx);
|
||||
|
||||
match self.rng.gen_range(0..100_u32) {
|
||||
// Manipulate an existing buffer
|
||||
0..=80 => {
|
||||
0..=70 => {
|
||||
let Some(buffer) = client
|
||||
.buffers_for_project(&project)
|
||||
.iter()
|
||||
.choose(&mut self.rng)
|
||||
.cloned() else { continue };
|
||||
|
||||
let full_path = buffer
|
||||
.read_with(cx, |buffer, cx| buffer.file().unwrap().full_path(cx));
|
||||
|
||||
match self.rng.gen_range(0..100_u32) {
|
||||
0..=9 => {
|
||||
let (full_path, edits) = buffer.read_with(cx, |buffer, cx| {
|
||||
(
|
||||
buffer.file().unwrap().full_path(cx),
|
||||
buffer.get_random_edits(&mut self.rng, 3),
|
||||
)
|
||||
// Close the buffer
|
||||
0..=15 => {
|
||||
break ClientOperation::CloseBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
};
|
||||
}
|
||||
// Save the buffer
|
||||
16..=29 if buffer.read_with(cx, |b, _| b.is_dirty()) => {
|
||||
let detach = self.rng.gen_bool(0.3);
|
||||
break ClientOperation::SaveBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
detach,
|
||||
};
|
||||
}
|
||||
// Edit the buffer
|
||||
30..=69 => {
|
||||
let edits = buffer.read_with(cx, |buffer, _| {
|
||||
buffer.get_random_edits(&mut self.rng, 3)
|
||||
});
|
||||
break ClientOperation::EditBuffer {
|
||||
project_root_name,
|
||||
|
@ -707,10 +754,42 @@ impl TestPlan {
|
|||
edits,
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
// Make an LSP request
|
||||
_ => {
|
||||
let offset = buffer.read_with(cx, |buffer, _| {
|
||||
buffer.clip_offset(
|
||||
self.rng.gen_range(0..=buffer.len()),
|
||||
language::Bias::Left,
|
||||
)
|
||||
});
|
||||
let detach = self.rng.gen();
|
||||
break ClientOperation::RequestLspDataInBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
offset,
|
||||
kind: match self.rng.gen_range(0..5_u32) {
|
||||
0 => LspRequestKind::Rename,
|
||||
1 => LspRequestKind::Highlights,
|
||||
2 => LspRequestKind::Definition,
|
||||
3 => LspRequestKind::CodeAction,
|
||||
4.. => LspRequestKind::Completion,
|
||||
},
|
||||
detach,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
71..=80 => {
|
||||
let query = self.rng.gen_range('a'..='z').to_string();
|
||||
let detach = self.rng.gen_bool(0.3);
|
||||
break ClientOperation::SearchProject {
|
||||
project_root_name,
|
||||
query,
|
||||
detach,
|
||||
};
|
||||
}
|
||||
|
||||
// Open a buffer
|
||||
81.. => {
|
||||
let worktree = project.read_with(cx, |project, cx| {
|
||||
|
@ -1066,21 +1145,159 @@ async fn apply_client_operation(
|
|||
);
|
||||
let project = project_for_root_name(client, &project_root_name, cx)
|
||||
.expect("invalid project in test operation");
|
||||
let buffer = client
|
||||
.buffers_for_project(&project)
|
||||
.iter()
|
||||
.find(|buffer| {
|
||||
buffer.read_with(cx, |buffer, cx| {
|
||||
buffer.file().unwrap().full_path(cx) == full_path
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.expect("invalid buffer path in test operation");
|
||||
let buffer =
|
||||
buffer_for_full_path(&*client.buffers_for_project(&project), &full_path, cx)
|
||||
.expect("invalid buffer path in test operation");
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
buffer.edit(edits, None, cx);
|
||||
});
|
||||
}
|
||||
|
||||
ClientOperation::CloseBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
} => {
|
||||
log::info!(
|
||||
"{}: dropping buffer {:?} in project {}",
|
||||
client.username,
|
||||
full_path,
|
||||
project_root_name
|
||||
);
|
||||
let project = project_for_root_name(client, &project_root_name, cx)
|
||||
.expect("invalid project in test operation");
|
||||
let buffer =
|
||||
buffer_for_full_path(&*client.buffers_for_project(&project), &full_path, cx)
|
||||
.expect("invalid buffer path in test operation");
|
||||
cx.update(|_| {
|
||||
client.buffers_for_project(&project).remove(&buffer);
|
||||
drop(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
ClientOperation::SaveBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
detach,
|
||||
} => {
|
||||
log::info!(
|
||||
"{}: saving buffer {:?} in project {}{}",
|
||||
client.username,
|
||||
full_path,
|
||||
project_root_name,
|
||||
if detach { ", detaching" } else { ", awaiting" }
|
||||
);
|
||||
let project = project_for_root_name(client, &project_root_name, cx)
|
||||
.expect("invalid project in test operation");
|
||||
let buffer =
|
||||
buffer_for_full_path(&*client.buffers_for_project(&project), &full_path, cx)
|
||||
.expect("invalid buffer path in test operation");
|
||||
let (requested_version, save) =
|
||||
buffer.update(cx, |buffer, cx| (buffer.version(), buffer.save(cx)));
|
||||
let save = cx.background().spawn(async move {
|
||||
let (saved_version, _, _) = save
|
||||
.await
|
||||
.map_err(|err| anyhow!("save request failed: {:?}", err))?;
|
||||
assert!(saved_version.observed_all(&requested_version));
|
||||
anyhow::Ok(())
|
||||
});
|
||||
if detach {
|
||||
log::info!("{}: detaching save request", client.username);
|
||||
cx.update(|cx| save.detach_and_log_err(cx));
|
||||
} else {
|
||||
save.await?;
|
||||
}
|
||||
}
|
||||
|
||||
ClientOperation::RequestLspDataInBuffer {
|
||||
project_root_name,
|
||||
full_path,
|
||||
offset,
|
||||
kind,
|
||||
detach,
|
||||
} => {
|
||||
log::info!(
|
||||
"{}: request LSP {:?} for buffer {:?} in project {}{}",
|
||||
client.username,
|
||||
kind,
|
||||
full_path,
|
||||
project_root_name,
|
||||
if detach { ", detaching" } else { ", awaiting" }
|
||||
);
|
||||
|
||||
let project = project_for_root_name(client, &project_root_name, cx)
|
||||
.expect("invalid project in test operation");
|
||||
let buffer =
|
||||
buffer_for_full_path(&*client.buffers_for_project(&project), &full_path, cx)
|
||||
.expect("invalid buffer path in test operation");
|
||||
let request = match kind {
|
||||
LspRequestKind::Rename => cx.spawn(|mut cx| async move {
|
||||
project
|
||||
.update(&mut cx, |p, cx| p.prepare_rename(buffer, offset, cx))
|
||||
.await?;
|
||||
anyhow::Ok(())
|
||||
}),
|
||||
LspRequestKind::Completion => cx.spawn(|mut cx| async move {
|
||||
project
|
||||
.update(&mut cx, |p, cx| p.completions(&buffer, offset, cx))
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
LspRequestKind::CodeAction => cx.spawn(|mut cx| async move {
|
||||
project
|
||||
.update(&mut cx, |p, cx| p.code_actions(&buffer, offset..offset, cx))
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
LspRequestKind::Definition => cx.spawn(|mut cx| async move {
|
||||
project
|
||||
.update(&mut cx, |p, cx| p.definition(&buffer, offset, cx))
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
LspRequestKind::Highlights => cx.spawn(|mut cx| async move {
|
||||
project
|
||||
.update(&mut cx, |p, cx| p.document_highlights(&buffer, offset, cx))
|
||||
.await?;
|
||||
Ok(())
|
||||
}),
|
||||
};
|
||||
if detach {
|
||||
request.detach();
|
||||
} else {
|
||||
request.await?;
|
||||
}
|
||||
}
|
||||
|
||||
ClientOperation::SearchProject {
|
||||
project_root_name,
|
||||
query,
|
||||
detach,
|
||||
} => {
|
||||
log::info!(
|
||||
"{}: search project {} for {:?}{}",
|
||||
client.username,
|
||||
project_root_name,
|
||||
query,
|
||||
if detach { ", detaching" } else { ", awaiting" }
|
||||
);
|
||||
let project = project_for_root_name(client, &project_root_name, cx)
|
||||
.expect("invalid project in test operation");
|
||||
let search = project.update(cx, |project, cx| {
|
||||
project.search(SearchQuery::text(query, false, false), cx)
|
||||
});
|
||||
let search = cx.background().spawn(async move {
|
||||
search
|
||||
.await
|
||||
.map_err(|err| anyhow!("search request failed: {:?}", err))
|
||||
});
|
||||
if detach {
|
||||
log::info!("{}: detaching save request", client.username);
|
||||
cx.update(|cx| search.detach_and_log_err(cx));
|
||||
} else {
|
||||
search.await?;
|
||||
}
|
||||
}
|
||||
|
||||
ClientOperation::Other => {
|
||||
let choice = plan.lock().rng.gen_range(0..100);
|
||||
match choice {
|
||||
|
@ -1090,12 +1307,6 @@ async fn apply_client_operation(
|
|||
{
|
||||
randomly_mutate_worktrees(client, &plan, cx).await?;
|
||||
}
|
||||
60..=84
|
||||
if !client.local_projects().is_empty()
|
||||
|| !client.remote_projects().is_empty() =>
|
||||
{
|
||||
randomly_query_and_mutate_buffers(client, &plan, cx).await?;
|
||||
}
|
||||
_ => randomly_mutate_fs(client, &plan).await,
|
||||
}
|
||||
}
|
||||
|
@ -1103,6 +1314,21 @@ async fn apply_client_operation(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn buffer_for_full_path(
|
||||
buffers: &HashSet<ModelHandle<language::Buffer>>,
|
||||
full_path: &PathBuf,
|
||||
cx: &TestAppContext,
|
||||
) -> Option<ModelHandle<language::Buffer>> {
|
||||
buffers
|
||||
.iter()
|
||||
.find(|buffer| {
|
||||
buffer.read_with(cx, |buffer, cx| {
|
||||
buffer.file().unwrap().full_path(cx) == *full_path
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn project_for_root_name(
|
||||
client: &TestClient,
|
||||
root_name: &str,
|
||||
|
@ -1246,264 +1472,6 @@ async fn randomly_mutate_worktrees(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn randomly_query_and_mutate_buffers(
|
||||
client: &TestClient,
|
||||
plan: &Arc<Mutex<TestPlan>>,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<()> {
|
||||
let project = choose_random_project(client, &mut plan.lock().rng).unwrap();
|
||||
let has_buffers_for_project = !client.buffers_for_project(&project).is_empty();
|
||||
let buffer = if !has_buffers_for_project || plan.lock().rng.gen() {
|
||||
let Some(worktree) = project.read_with(cx, |project, cx| {
|
||||
project
|
||||
.worktrees(cx)
|
||||
.filter(|worktree| {
|
||||
let worktree = worktree.read(cx);
|
||||
worktree.is_visible() && worktree.entries(false).any(|e| e.is_file())
|
||||
})
|
||||
.choose(&mut plan.lock().rng)
|
||||
}) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (worktree_root_name, project_path) = worktree.read_with(cx, |worktree, _| {
|
||||
let entry = worktree
|
||||
.entries(false)
|
||||
.filter(|e| e.is_file())
|
||||
.choose(&mut plan.lock().rng)
|
||||
.unwrap();
|
||||
(
|
||||
worktree.root_name().to_string(),
|
||||
(worktree.id(), entry.path.clone()),
|
||||
)
|
||||
});
|
||||
log::info!(
|
||||
"{}: opening path {:?} in worktree {} ({})",
|
||||
client.username,
|
||||
project_path.1,
|
||||
project_path.0,
|
||||
worktree_root_name,
|
||||
);
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer(project_path.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
log::info!(
|
||||
"{}: opened path {:?} in worktree {} ({}) with buffer id {}",
|
||||
client.username,
|
||||
project_path.1,
|
||||
project_path.0,
|
||||
worktree_root_name,
|
||||
buffer.read_with(cx, |buffer, _| buffer.remote_id())
|
||||
);
|
||||
client.buffers_for_project(&project).insert(buffer.clone());
|
||||
buffer
|
||||
} else {
|
||||
client
|
||||
.buffers_for_project(&project)
|
||||
.iter()
|
||||
.choose(&mut plan.lock().rng)
|
||||
.unwrap()
|
||||
.clone()
|
||||
};
|
||||
|
||||
let choice = plan.lock().rng.gen_range(0..100);
|
||||
match choice {
|
||||
0..=9 => {
|
||||
cx.update(|cx| {
|
||||
log::info!(
|
||||
"{}: dropping buffer {:?}",
|
||||
client.username,
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
client.buffers_for_project(&project).remove(&buffer);
|
||||
drop(buffer);
|
||||
});
|
||||
}
|
||||
10..=19 => {
|
||||
let completions = project.update(cx, |project, cx| {
|
||||
log::info!(
|
||||
"{}: requesting completions for buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.read(cx).remote_id(),
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
let offset = plan.lock().rng.gen_range(0..=buffer.read(cx).len());
|
||||
project.completions(&buffer, offset, cx)
|
||||
});
|
||||
let completions = cx.background().spawn(async move {
|
||||
completions
|
||||
.await
|
||||
.map_err(|err| anyhow!("completions request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching completions request", client.username);
|
||||
cx.update(|cx| completions.detach_and_log_err(cx));
|
||||
} else {
|
||||
completions.await?;
|
||||
}
|
||||
}
|
||||
20..=29 => {
|
||||
let code_actions = project.update(cx, |project, cx| {
|
||||
log::info!(
|
||||
"{}: requesting code actions for buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.read(cx).remote_id(),
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
let range = buffer.read(cx).random_byte_range(0, &mut plan.lock().rng);
|
||||
project.code_actions(&buffer, range, cx)
|
||||
});
|
||||
let code_actions = cx.background().spawn(async move {
|
||||
code_actions
|
||||
.await
|
||||
.map_err(|err| anyhow!("code actions request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching code actions request", client.username);
|
||||
cx.update(|cx| code_actions.detach_and_log_err(cx));
|
||||
} else {
|
||||
code_actions.await?;
|
||||
}
|
||||
}
|
||||
30..=39 if buffer.read_with(cx, |buffer, _| buffer.is_dirty()) => {
|
||||
let (requested_version, save) = buffer.update(cx, |buffer, cx| {
|
||||
log::info!(
|
||||
"{}: saving buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.remote_id(),
|
||||
buffer.file().unwrap().full_path(cx)
|
||||
);
|
||||
(buffer.version(), buffer.save(cx))
|
||||
});
|
||||
let save = cx.background().spawn(async move {
|
||||
let (saved_version, _, _) = save
|
||||
.await
|
||||
.map_err(|err| anyhow!("save request failed: {:?}", err))?;
|
||||
assert!(saved_version.observed_all(&requested_version));
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching save request", client.username);
|
||||
cx.update(|cx| save.detach_and_log_err(cx));
|
||||
} else {
|
||||
save.await?;
|
||||
}
|
||||
}
|
||||
40..=44 => {
|
||||
let prepare_rename = project.update(cx, |project, cx| {
|
||||
log::info!(
|
||||
"{}: preparing rename for buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.read(cx).remote_id(),
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
let offset = plan.lock().rng.gen_range(0..=buffer.read(cx).len());
|
||||
project.prepare_rename(buffer, offset, cx)
|
||||
});
|
||||
let prepare_rename = cx.background().spawn(async move {
|
||||
prepare_rename
|
||||
.await
|
||||
.map_err(|err| anyhow!("prepare rename request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching prepare rename request", client.username);
|
||||
cx.update(|cx| prepare_rename.detach_and_log_err(cx));
|
||||
} else {
|
||||
prepare_rename.await?;
|
||||
}
|
||||
}
|
||||
45..=49 => {
|
||||
let definitions = project.update(cx, |project, cx| {
|
||||
log::info!(
|
||||
"{}: requesting definitions for buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.read(cx).remote_id(),
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
let offset = plan.lock().rng.gen_range(0..=buffer.read(cx).len());
|
||||
project.definition(&buffer, offset, cx)
|
||||
});
|
||||
let definitions = cx.background().spawn(async move {
|
||||
definitions
|
||||
.await
|
||||
.map_err(|err| anyhow!("definitions request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching definitions request", client.username);
|
||||
cx.update(|cx| definitions.detach_and_log_err(cx));
|
||||
} else {
|
||||
let definitions = definitions.await?;
|
||||
client
|
||||
.buffers_for_project(&project)
|
||||
.extend(definitions.into_iter().map(|loc| loc.target.buffer));
|
||||
}
|
||||
}
|
||||
50..=54 => {
|
||||
let highlights = project.update(cx, |project, cx| {
|
||||
log::info!(
|
||||
"{}: requesting highlights for buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.read(cx).remote_id(),
|
||||
buffer.read(cx).file().unwrap().full_path(cx)
|
||||
);
|
||||
let offset = plan.lock().rng.gen_range(0..=buffer.read(cx).len());
|
||||
project.document_highlights(&buffer, offset, cx)
|
||||
});
|
||||
let highlights = cx.background().spawn(async move {
|
||||
highlights
|
||||
.await
|
||||
.map_err(|err| anyhow!("highlights request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching highlights request", client.username);
|
||||
cx.update(|cx| highlights.detach_and_log_err(cx));
|
||||
} else {
|
||||
highlights.await?;
|
||||
}
|
||||
}
|
||||
55..=59 => {
|
||||
let search = project.update(cx, |project, cx| {
|
||||
let query = plan.lock().rng.gen_range('a'..='z');
|
||||
log::info!("{}: project-wide search {:?}", client.username, query);
|
||||
project.search(SearchQuery::text(query, false, false), cx)
|
||||
});
|
||||
let search = cx.background().spawn(async move {
|
||||
search
|
||||
.await
|
||||
.map_err(|err| anyhow!("search request failed: {:?}", err))
|
||||
});
|
||||
if plan.lock().rng.gen_bool(0.3) {
|
||||
log::info!("{}: detaching search request", client.username);
|
||||
cx.update(|cx| search.detach_and_log_err(cx));
|
||||
} else {
|
||||
let search = search.await?;
|
||||
client
|
||||
.buffers_for_project(&project)
|
||||
.extend(search.into_keys());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
log::info!(
|
||||
"{}: updating buffer {} ({:?})",
|
||||
client.username,
|
||||
buffer.remote_id(),
|
||||
buffer.file().unwrap().full_path(cx)
|
||||
);
|
||||
if plan.lock().rng.gen_bool(0.7) {
|
||||
buffer.randomly_edit(&mut plan.lock().rng, 5, cx);
|
||||
} else {
|
||||
buffer.randomly_undo_redo(&mut plan.lock().rng, cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn choose_random_project(client: &TestClient, rng: &mut StdRng) -> Option<ModelHandle<Project>> {
|
||||
client
|
||||
.local_projects()
|
||||
|
|
Loading…
Reference in a new issue