mirror of
https://github.com/hexedtech/codemp.git
synced 2024-11-25 16:34:48 +01:00
feat: java glue (kind of)
This commit is contained in:
parent
6fe5372387
commit
23db5af4d2
7 changed files with 464 additions and 1 deletions
15
Cargo.toml
15
Cargo.toml
|
@ -26,3 +26,18 @@ tokio-stream = { version = "0.1" }
|
||||||
serde = { version = "1.0.193", features = ["derive"] }
|
serde = { version = "1.0.193", features = ["derive"] }
|
||||||
dashmap = { version = "5.5.3" }
|
dashmap = { version = "5.5.3" }
|
||||||
postcard = { version = "1.0.8" }
|
postcard = { version = "1.0.8" }
|
||||||
|
#java
|
||||||
|
jni = { version = "0.21.1", features = ["invocation"], optional = true }
|
||||||
|
jni-sys = { version = "0.3.0", optional = true }
|
||||||
|
rifgen = { git = "https://github.com/Kofituo/rifgen.git", rev = "d27d9785b2febcf5527f1deb6a846be5d583f7d7", optional = true }
|
||||||
|
lazy_static = {version = "1.4.0", optional = true }
|
||||||
|
log = { version = "0.4.21", optional = true }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
# java
|
||||||
|
flapigen = { version = "0.6.0", optional = true }
|
||||||
|
rifgen = { git = "https://github.com/Kofituo/rifgen.git", rev = "d27d9785b2febcf5527f1deb6a846be5d583f7d7", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["java"]
|
||||||
|
java = ["lazy_static", "jni", "jni-sys", "flapigen", "rifgen", "log"]
|
||||||
|
|
71
build.rs
Normal file
71
build.rs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
/// The main method of the buildscript, required by some glue modules.
|
||||||
|
fn main() {
|
||||||
|
#[cfg(feature = "java")] {
|
||||||
|
let pkg = "com.codemp.jni".to_string();
|
||||||
|
let pkg_folder = pkg.replace('.', "/"); // java moment
|
||||||
|
|
||||||
|
let out_dir = std::env::var("OUT_DIR").expect("cargo did not provide OUT_DIR");
|
||||||
|
let out_dir = std::path::Path::new(&out_dir);
|
||||||
|
let generated_glue_file = out_dir.join("generated_glue.in");
|
||||||
|
let src_dir = std::path::Path::new("src")
|
||||||
|
.join("glue")
|
||||||
|
.join("java");
|
||||||
|
let typemap_file = src_dir.join("typemap.in");
|
||||||
|
rifgen::Generator::new(rifgen::TypeCases::CamelCase, rifgen::Language::Java, vec![src_dir])
|
||||||
|
.generate_interface(&generated_glue_file);
|
||||||
|
|
||||||
|
// build java source path
|
||||||
|
let target = out_dir.parent().unwrap().parent().unwrap().parent().unwrap().to_path_buf();
|
||||||
|
let mut java_target = target.clone();
|
||||||
|
java_target.push("java");
|
||||||
|
let mut pkg_path = java_target.clone();
|
||||||
|
pkg_path.push("src");
|
||||||
|
pkg_path.push(pkg_folder);
|
||||||
|
|
||||||
|
// delete folder if it exists, then create it
|
||||||
|
recreate_path(&pkg_path);
|
||||||
|
|
||||||
|
// generate java code
|
||||||
|
let java_cfg = flapigen::JavaConfig::new(pkg_path.clone(), pkg);
|
||||||
|
let java_gen = flapigen::Generator::new(flapigen::LanguageConfig::JavaConfig(java_cfg))
|
||||||
|
.rustfmt_bindings(true);
|
||||||
|
java_gen.expand_many(
|
||||||
|
"codemp-intellij",
|
||||||
|
&[&generated_glue_file, &typemap_file],
|
||||||
|
out_dir.join("glue.rs")
|
||||||
|
);
|
||||||
|
|
||||||
|
//TODO panic if no jdk
|
||||||
|
|
||||||
|
// compile java code
|
||||||
|
let mut java_compiled = java_target.clone();
|
||||||
|
java_compiled.push("classes");
|
||||||
|
recreate_path(&java_compiled);
|
||||||
|
|
||||||
|
let mut javac_cmd = std::process::Command::new("javac");
|
||||||
|
javac_cmd.arg("-d").arg(java_compiled.as_os_str());
|
||||||
|
for java_file in pkg_path.read_dir().unwrap().filter_map(|e| e.ok()) {
|
||||||
|
javac_cmd.arg(java_file.path().as_os_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// jar it! FIXME
|
||||||
|
let mut jar_file = target.clone();
|
||||||
|
jar_file.push("codemp-java.jar");
|
||||||
|
let mut jar_cmd = std::process::Command::new("jar");
|
||||||
|
jar_cmd.arg("cf").arg(jar_file.as_os_str());
|
||||||
|
jar_cmd.arg("-C").arg(java_compiled.as_os_str());
|
||||||
|
for java_file in java_compiled.read_dir().unwrap().filter_map(|e| e.ok()) {
|
||||||
|
jar_cmd.arg(java_file.path().as_os_str());
|
||||||
|
}
|
||||||
|
jar_cmd.spawn().expect("failed to run jar!");
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={}", generated_glue_file.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recreate_path(path: &std::path::PathBuf) {
|
||||||
|
if path.exists() {
|
||||||
|
std::fs::remove_dir_all(path).expect("failed to delete old dir!");
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(path).expect("error while creating folder!");
|
||||||
|
}
|
333
src/glue/java/mod.rs
Normal file
333
src/glue/java/mod.rs
Normal file
|
@ -0,0 +1,333 @@
|
||||||
|
use api::Controller;
|
||||||
|
use codemp_proto::{cursor::{RowCol, CursorPosition}, files::BufferNode};
|
||||||
|
use rifgen::rifgen_attr::{generate_access_methods, generate_interface, generate_interface_doc};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Client,
|
||||||
|
Error,
|
||||||
|
Result,
|
||||||
|
api::TextChange,
|
||||||
|
buffer::{self, tools},
|
||||||
|
cursor,
|
||||||
|
workspace::Workspace
|
||||||
|
};
|
||||||
|
|
||||||
|
//rifgen generated code
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/glue.rs"));
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
/// the tokio runtime, since we can't easily have Java and Rust async work together
|
||||||
|
static ref RT: tokio::runtime::Runtime = tokio::runtime::Runtime::new()
|
||||||
|
.expect("could not start tokio runtime");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
/// the handler class that represent an instance of a CodeMP client
|
||||||
|
struct ClientHandler {
|
||||||
|
client: Client,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientHandler {
|
||||||
|
#[generate_interface(constructor)]
|
||||||
|
/// construct a new [ClientHandler]
|
||||||
|
fn new(address: &str) -> ClientHandler {
|
||||||
|
ClientHandler {
|
||||||
|
client: RT.block_on(Client::new(address)).unwrap(),
|
||||||
|
url: address.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// join a workspace by name
|
||||||
|
fn join_workspace(&mut self, workspace_id: &str) -> Result<WorkspaceHandler> {
|
||||||
|
RT.block_on(self.client.join_workspace(workspace_id))
|
||||||
|
.map(|workspace| {
|
||||||
|
Self::spawn_updater(workspace.clone());
|
||||||
|
WorkspaceHandler { workspace }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_updater(workspace: Arc<Workspace>) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||||
|
workspace.fetch_buffers().await.unwrap();
|
||||||
|
workspace.fetch_users().await.unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the url you are currently connected to
|
||||||
|
fn get_url(&self) -> String {
|
||||||
|
self.url.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
/// wraps a [codemp::workspace::Workspace] to be handled by Java
|
||||||
|
struct WorkspaceHandler {
|
||||||
|
workspace: Arc<Workspace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceHandler { // TODO: workspace leave / buffer detach ?
|
||||||
|
#[generate_interface(constructor)]
|
||||||
|
/// constructor required by flapigen, DO NOT CALL THIS
|
||||||
|
fn new() -> WorkspaceHandler {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// create a new buffer in current workspace
|
||||||
|
fn create_buffer(&mut self, path: &str) -> Result<BufferHandler> {
|
||||||
|
RT.block_on(self.workspace.create(path))?;
|
||||||
|
Ok(self.get_buffer(path).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// attach to a buffer and get a [crate::BufferHandler] for it
|
||||||
|
fn attach_to_buffer(&mut self, path: &str) -> Result<BufferHandler> {
|
||||||
|
RT.block_on(self.workspace.attach(path))
|
||||||
|
.map(|buffer| BufferHandler { buffer })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// updates the local list of the workspace's buffers
|
||||||
|
fn fetch_buffers(&mut self) -> Result<()> {
|
||||||
|
RT.block_on(self.workspace.fetch_buffers())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// updates the local list of the workspace's users
|
||||||
|
fn fetch_users(&mut self) -> Result<()> {
|
||||||
|
RT.block_on(self.workspace.fetch_users())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// gets a list of all users in a buffer
|
||||||
|
fn list_buffer_users(&mut self, path: &str) -> Result<StringVec> {
|
||||||
|
let mut res = StringVec::new();
|
||||||
|
RT.block_on(self.workspace.list_buffer_users(path))?
|
||||||
|
.iter()
|
||||||
|
.for_each(|u| res.push(Uuid::from(u.clone()).to_string()));
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// delete a buffer
|
||||||
|
fn delete_buffer(&mut self, path: &str) -> Result<()> {
|
||||||
|
RT.block_on(self.workspace.delete(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the workspace id
|
||||||
|
fn get_workspace_id(&self) -> String {
|
||||||
|
self.workspace.id().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get a [crate::CursorHandler] for the workspace's cursor
|
||||||
|
fn get_cursor(&self) -> CursorHandler {
|
||||||
|
CursorHandler {
|
||||||
|
cursor: self.workspace.cursor().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get a [crate::BufferHandler] for one of the workspace's buffers
|
||||||
|
fn get_buffer(&self, path: &str) -> Option<BufferHandler> {
|
||||||
|
self.workspace
|
||||||
|
.buffer_by_name(path)
|
||||||
|
.map(|buffer| BufferHandler { buffer })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the names of all buffers available in the workspace
|
||||||
|
fn get_filetree(&self) -> StringVec {
|
||||||
|
StringVec {
|
||||||
|
v: self.workspace.filetree()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// polls a list of buffers, returning the first ready one
|
||||||
|
fn select_buffer(
|
||||||
|
&mut self,
|
||||||
|
mut buffer_ids: StringVec,
|
||||||
|
timeout: i64,
|
||||||
|
) -> Result<Option<BufferHandler>> {
|
||||||
|
let mut buffers = Vec::new();
|
||||||
|
for id in buffer_ids.v.iter_mut() {
|
||||||
|
match self.get_buffer(id.as_str()) {
|
||||||
|
Some(buf) => buffers.push(buf.buffer),
|
||||||
|
None => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = RT.block_on(tools::select_buffer(
|
||||||
|
buffers.as_slice(),
|
||||||
|
Some(Duration::from_millis(timeout as u64)),
|
||||||
|
));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Err(e) => Err(e),
|
||||||
|
Ok(buffer) => Ok(buffer.map(|buffer| BufferHandler { buffer })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
#[generate_access_methods]
|
||||||
|
/// wraps a [codemp::proto::cursor::CursorEvent] to be handled by Java
|
||||||
|
struct CursorEventWrapper {
|
||||||
|
user: String,
|
||||||
|
buffer: String,
|
||||||
|
start_row: i32,
|
||||||
|
start_col: i32,
|
||||||
|
end_row: i32,
|
||||||
|
end_col: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
/// a handler providing Java access to [codemp::cursor::Controller] methods
|
||||||
|
struct CursorHandler {
|
||||||
|
pub cursor: Arc<cursor::Controller>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CursorHandler {
|
||||||
|
#[generate_interface(constructor)]
|
||||||
|
/// constructor required by flapigen, DO NOT CALL THIS
|
||||||
|
fn new() -> CursorHandler {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get next cursor event from current workspace, or block until one is available
|
||||||
|
fn recv(&self) -> Result<CursorEventWrapper> {
|
||||||
|
match RT.block_on(self.cursor.recv()) {
|
||||||
|
Err(err) => Err(err),
|
||||||
|
Ok(event) => Ok(CursorEventWrapper {
|
||||||
|
user: Uuid::from(event.user).to_string(),
|
||||||
|
buffer: event.position.buffer.path.clone(),
|
||||||
|
start_row: event.position.start.row,
|
||||||
|
start_col: event.position.start.col,
|
||||||
|
end_row: event.position.end.row,
|
||||||
|
end_col: event.position.end.col,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// broadcast a cursor event
|
||||||
|
/// will automatically fix start and end if they are accidentally inverted
|
||||||
|
fn send(
|
||||||
|
&self,
|
||||||
|
buffer: String,
|
||||||
|
start_row: i32,
|
||||||
|
start_col: i32,
|
||||||
|
end_row: i32,
|
||||||
|
end_col: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.cursor.send(CursorPosition {
|
||||||
|
buffer: BufferNode { path: buffer },
|
||||||
|
start: RowCol::from((start_row, start_col)),
|
||||||
|
end: RowCol::from((end_row, end_col)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
#[generate_access_methods]
|
||||||
|
/// wraps a [codemp::api::change::TextChange] to make it accessible from Java
|
||||||
|
struct TextChangeWrapper {
|
||||||
|
start: usize,
|
||||||
|
end: usize, //not inclusive
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
/// a handler providing Java access to [codemp::buffer::Controller] methods
|
||||||
|
struct BufferHandler {
|
||||||
|
pub buffer: Arc<buffer::Controller>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferHandler {
|
||||||
|
#[generate_interface(constructor)]
|
||||||
|
/// constructor required by flapigen, DO NOT CALL THIS
|
||||||
|
fn new() -> BufferHandler {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the name of the buffer
|
||||||
|
fn get_name(&self) -> String {
|
||||||
|
self.buffer.name().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the contents of the buffer
|
||||||
|
fn get_content(&self) -> String {
|
||||||
|
self.buffer.content()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// if a text change is available on the buffer, return it immediately
|
||||||
|
fn try_recv(&self) -> Result<Option<TextChangeWrapper>> {
|
||||||
|
match self.buffer.try_recv() {
|
||||||
|
Err(err) => Err(err),
|
||||||
|
Ok(None) => Ok(None),
|
||||||
|
Ok(Some(change)) => Ok(Some(TextChangeWrapper {
|
||||||
|
start: change.span.start,
|
||||||
|
end: change.span.end,
|
||||||
|
content: change.content.clone(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// broadcast a text change on the buffer
|
||||||
|
fn send(&self, start_offset: usize, end_offset: usize, content: String) -> Result<()> {
|
||||||
|
self.buffer.send(TextChange {
|
||||||
|
span: start_offset..end_offset,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface_doc]
|
||||||
|
/// a convenience struct allowing Java access to a Rust vector
|
||||||
|
struct StringVec {
|
||||||
|
//jni moment
|
||||||
|
v: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StringVec {
|
||||||
|
#[generate_interface(constructor)]
|
||||||
|
/// initialize an empty vector
|
||||||
|
fn new() -> StringVec {
|
||||||
|
Self { v: Vec::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// push a new value onto the vector
|
||||||
|
fn push(&mut self, s: String) {
|
||||||
|
self.v.push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// get the length of the underlying vector
|
||||||
|
fn length(&self) -> i64 {
|
||||||
|
self.v.len() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[generate_interface]
|
||||||
|
/// access the element at a given index
|
||||||
|
fn get(&self, idx: i64) -> Option<String> {
|
||||||
|
let elem: Option<&String> = self.v.get(idx as usize);
|
||||||
|
elem.map(|s| s.clone())
|
||||||
|
}
|
||||||
|
}
|
39
src/glue/java/typemap.in
Normal file
39
src/glue/java/typemap.in
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
foreign_typemap!( //thanks @tasn on GitHub for the idea
|
||||||
|
($p:r_type) <T> Result<T> => swig_i_type!(T) {
|
||||||
|
$out = match $p {
|
||||||
|
Ok(x) => {
|
||||||
|
swig_from_rust_to_i_type!(T, x, ret)
|
||||||
|
ret
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let (msg, exception_class) = match err {
|
||||||
|
Error::Filler { message } => (
|
||||||
|
message,
|
||||||
|
swig_jni_find_class!(CODEMP_EXCEPTION, "com/codemp/intellij/exceptions/CodeMPException")
|
||||||
|
),
|
||||||
|
Error::Transport { status, message } => (
|
||||||
|
format!("Status {}: {}", status, message),
|
||||||
|
swig_jni_find_class!(TRANSPORT_EXCEPTION, "com/codemp/intellij/exceptions/lib/TransportException")
|
||||||
|
),
|
||||||
|
Error::InvalidState { msg } => (
|
||||||
|
msg, swig_jni_find_class!(INVALID_STATE_EXCEPTION, "com/codemp/intellij/exceptions/lib/InvalidStateException")
|
||||||
|
),
|
||||||
|
Error::Deadlocked => (
|
||||||
|
"WOOT deadlocked (safe to retry)!".to_string(),
|
||||||
|
swig_jni_find_class!(DEADLOCKED_EXCEPTION, "com/codemp/intellij/exceptions/lib/DeadlockedException")
|
||||||
|
),
|
||||||
|
Error::Channel { send } => {
|
||||||
|
let verb = if send { "sending" } else { "reading" };
|
||||||
|
(
|
||||||
|
format!("Error while {} message on channel: the channel was closed!", verb),
|
||||||
|
swig_jni_find_class!(CHANNEL_EXCEPTION, "com/codemp/intellij/exceptions/lib/ChannelException")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
jni_throw(env, exception_class, &msg);
|
||||||
|
return <swig_i_type!(T)>::jni_invalid_value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
($p:f_type, unique_prefix="/*err*/") => "/*err*/swig_f_type!(T)" "swig_foreign_from_i_type!(T, $p)";
|
||||||
|
);
|
2
src/glue/mod.rs
Normal file
2
src/glue/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
#[cfg(feature = "java")]
|
||||||
|
pub mod java;
|
|
@ -137,6 +137,9 @@ pub mod workspace;
|
||||||
/// all-in-one imports : `use codemp::prelude::*;`
|
/// all-in-one imports : `use codemp::prelude::*;`
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
/// language-specific ffi "glue"
|
||||||
|
pub mod glue;
|
||||||
|
|
||||||
/// underlying OperationalTransform library used, re-exported
|
/// underlying OperationalTransform library used, re-exported
|
||||||
pub use woot;
|
pub use woot;
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ pub use crate::{
|
||||||
Result as CodempResult,
|
Result as CodempResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use crate::woot::crdt::Op as CodempOp;
|
pub use crate::woot::crdt::Op as CodempOp;
|
||||||
|
|
||||||
pub use crate::api::{
|
pub use crate::api::{
|
||||||
Controller as CodempController,
|
Controller as CodempController,
|
||||||
|
|
Loading…
Reference in a new issue