Compare commits

..

4 Commits

Author SHA1 Message Date
Micah
47db569c52 Correct my own mistake and move plugin test to its own step 2025-09-23 21:19:26 -07:00
Micah
1d3f8c8e9d Run tests in CI (this is the part where I have to try at least twice) 2025-09-23 21:13:24 -07:00
Micah
a894313a4b Make script exit with code 1 when tests fail 2025-09-23 21:09:13 -07:00
Micah
7f73ae80dc Add script for running plugin tests locally
They current fail
2025-09-23 21:02:23 -07:00
361 changed files with 3447 additions and 18773 deletions

View File

@@ -60,7 +60,7 @@ jobs:
submodules: true submodules: true
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@1.88.0 uses: dtolnay/rust-toolchain@1.79.0
- name: Restore Rust Cache - name: Restore Rust Cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4
@@ -83,6 +83,27 @@ jobs:
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
test-plugin:
name: Test Plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v1.1.0'
- name: Test
run: lune run test-plugin
env:
RBX_API_KEY: ${{ secrets.PLUGIN_TEST_API_KEY }}
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_TEST_UNIVERSE_ID }}
RBX_PLACE_ID: ${{ vars.PLUGIN_TEST_PLACE_ID }}
lint: lint:
name: Rustfmt, Clippy, Stylua, & Selene name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest runs-on: ubuntu-latest

3
.gitmodules vendored
View File

@@ -16,3 +16,6 @@
[submodule "plugin/Packages/Highlighter"] [submodule "plugin/Packages/Highlighter"]
path = plugin/Packages/Highlighter path = plugin/Packages/Highlighter
url = https://github.com/boatbomber/highlighter.git url = https://github.com/boatbomber/highlighter.git
[submodule ".lune/opencloud-execute"]
path = .lune/opencloud-execute
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git

5
.lune/.luaurc Normal file
View File

@@ -0,0 +1,5 @@
{
"aliases": {
"lune": "~/.lune/.typedefs/0.10.2/"
}
}

112
.lune/test-plugin.luau Normal file
View File

@@ -0,0 +1,112 @@
local serde = require("@lune/serde")
local net = require("@lune/net")
local stdio = require("@lune/stdio")
local process = require("@lune/process")
local fs = require("@lune/fs")
local luau_execute = require("./opencloud-execute")
local TEST_SCRIPT = fs.readFile("plugin/run-tests.server.lua")
local PATH_VERSION_MATCH = "assets/%d+/versions/(.+)"
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
local PLACE_ID = process.env["RBX_PLACE_ID"]
local API_KEY = process.env["RBX_API_KEY"]
if not UNIVERSE_ID then
error("no universe ID specified. try providing one with the env var `RBX_UNIVERSE_ID`")
end
if not PLACE_ID then
error("no place ID specified. try providing one with the env var `RBX_PLACE_ID`")
end
if not API_KEY then
error("no API key specified. try providing one with the env var `RBX_API_KEY`")
end
--stylua: ignore
local upload_result = process.exec("cargo", {
"run", "--",
"upload", "plugin/test-place.project.json",
"--api_key", API_KEY,
"--universe_id", UNIVERSE_ID,
"--asset_id", PLACE_ID
}, {
stdio = "none"
})
if not upload_result.ok then
print("Failed to upload plugin test place")
print("Not dumping stdout or stderr to avoid leaking secrets")
process.exit(1)
end
-- This is /probably/ not necessary because Rojo generally does not have enough
-- activity that there will be multiple CI runs happening at once, but
-- it's better safe than sorry.
local version_response = net.request({
method = "GET",
url = `https://apis.roblox.com/assets/v1/assets/{PLACE_ID}/versions`,
query = {
maxPageSize = 1,
},
headers = {
["User-Agent"] = `Rojo/PluginTesting 1.0.0; {_VERSION}`,
["x-api-key"] = API_KEY,
},
})
if not version_response.ok then
error(
`Failed to fetch version of Roblox place to run tests on because: {version_response.statusCode} - {version_response.statusMessage}\n{version_response.body}`
)
end
local place_version_raw = serde.decode("json", version_response.body).assetVersions[1].path
assert(typeof(place_version_raw) == "string", "the result from asset version endpoint was not as expected")
local place_version = string.match(place_version_raw, PATH_VERSION_MATCH)
local task = luau_execute.create_task_versioned(UNIVERSE_ID, PLACE_ID, place_version, TEST_SCRIPT)
print(`Running test script on {UNIVERSE_ID}/{PLACE_ID}@{place_version}`)
print(`Task ID: {luau_execute.task_id(task)}`)
luau_execute.await_finish(task)
print("Output from task:\n")
local logs = luau_execute.get_structured_logs(task)
for _, log in logs do
if log.messageType == "OUTPUT" or log.messageType == "MESSAGE_TYPE_UNSPECIFIED" then
stdio.write(stdio.color("reset"))
elseif log.messageType == "INFO" then
stdio.write(stdio.color("cyan"))
elseif log.messageType == "WARNING" then
stdio.write(stdio.color("yellow"))
elseif log.messageType == "ERROR" then
stdio.write(stdio.color("red"))
end
stdio.write(log.message)
stdio.write(`{stdio.color("reset")}\n`)
end
local results = luau_execute.get_output(task)[1]
if not results then
error("plugin tests did not return any results")
end
local status = luau_execute.check_status(task)
if status == "COMPLETE" then
if results.failureCount == 0 then
process.exit(0)
else
process.exit(1)
end
else
print()
print("Task did not finish successfully")
local err = luau_execute.get_error(task)
if err then
print(`Error from task: {err.code}`)
print(err.message)
end
process.exit(1)
end

File diff suppressed because it is too large Load Diff

1731
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.7.0-rc.1" version = "7.5.1"
rust-version = "1.88" rust-version = "1.79.0"
authors = [ authors = [
"Lucien Greathouse <me@lpghatguy.com>", "Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>", "Micah Reid <git@dekkonot.com>",
@@ -46,22 +46,20 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.3.1", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously # These dependencies can be uncommented when working on rbx-dom simultaneously
# rbx_binary = { path = "../rbx-dom/rbx_binary", features = [ # rbx_binary = { path = "../rbx-dom/rbx_binary" }
# "unstable_text_format",
# ] }
# rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" } # rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" } # rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" } # rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = { version = "2.0.1", features = ["unstable_text_format"] } rbx_binary = "1.0.0"
rbx_dom_weak = "4.1.0" rbx_dom_weak = "3.0.0"
rbx_reflection = "6.1.0" rbx_reflection = "5.0.0"
rbx_reflection_database = "2.0.2" rbx_reflection_database = "1.0.3"
rbx_xml = "2.0.1" rbx_xml = "1.0.0"
anyhow = "1.0.80" anyhow = "1.0.80"
backtrace = "0.3.69" backtrace = "0.3.69"
@@ -74,7 +72,6 @@ futures = "0.3.30"
globset = "0.4.14" globset = "0.4.14"
humantime = "2.1.0" humantime = "2.1.0"
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] } hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
hyper-tungstenite = "0.11.0"
jod-thread = "0.1.2" jod-thread = "0.1.2"
log = "0.4.21" log = "0.4.21"
num_cpus = "1.16.0" num_cpus = "1.16.0"
@@ -88,23 +85,17 @@ reqwest = { version = "0.11.24", default-features = false, features = [
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] } serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.145" serde_json = "1.0.114"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
strum = { version = "0.27", features = ["derive"] }
toml = "0.5.11" toml = "0.5.11"
termcolor = "1.4.1" termcolor = "1.4.1"
thiserror = "1.0.57" thiserror = "1.0.57"
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] } tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] } uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] } clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15" profiling = "1.0.15"
yaml-rust2 = "0.10.3" yaml-rust2 = "0.10.3"
data-encoding = "2.8.0" data-encoding = "2.8.0"
blake3 = "1.5.0"
float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"

View File

@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.88 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -1,5 +1,3 @@
# Roblox Studio lock files # Roblox Studio lock files
/*.rbxlx.lock /*.rbxlx.lock
/*.rbxl.lock /*.rbxl.lock
sourcemap.json

View File

@@ -4,5 +4,3 @@
# Roblox Studio lock files # Roblox Studio lock files
/*.rbxlx.lock /*.rbxlx.lock
/*.rbxl.lock /*.rbxl.lock
sourcemap.json

View File

@@ -1,5 +1,3 @@
# Plugin model files # Plugin model files
/{project_name}.rbxmx /{project_name}.rbxmx
/{project_name}.rbxm /{project_name}.rbxm
sourcemap.json

View File

@@ -1 +0,0 @@
print("Hello world, from client!")

View File

@@ -1 +0,0 @@
print("Hello world, from server!")

View File

@@ -1,3 +0,0 @@
return function()
print("Hello, world!")
end

View File

@@ -1 +0,0 @@
print("Hello world, from plugin!")

View File

@@ -47,7 +47,6 @@ fn main() -> Result<(), anyhow::Error> {
let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let plugin_dir = root_dir.join("plugin"); let plugin_dir = root_dir.join("plugin");
let templates_dir = root_dir.join("assets").join("project-templates");
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?; let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let plugin_version = let plugin_version =
@@ -58,9 +57,7 @@ fn main() -> Result<(), anyhow::Error> {
"plugin version does not match Cargo version" "plugin version does not match Cargo version"
); );
let template_snapshot = snapshot_from_fs_path(&templates_dir)?; let snapshot = VfsSnapshot::dir(hashmap! {
let plugin_snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?, "default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
"plugin" => VfsSnapshot::dir(hashmap! { "plugin" => VfsSnapshot::dir(hashmap! {
"fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?, "fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
@@ -73,11 +70,10 @@ fn main() -> Result<(), anyhow::Error> {
}), }),
}); });
let template_file = File::create(Path::new(&out_dir).join("templates.bincode"))?; let out_path = Path::new(&out_dir).join("plugin.bincode");
let plugin_file = File::create(Path::new(&out_dir).join("plugin.bincode"))?; let out_file = File::create(out_path)?;
bincode::serialize_into(plugin_file, &plugin_snapshot)?; bincode::serialize_into(out_file, &snapshot)?;
bincode::serialize_into(template_file, &template_snapshot)?;
println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc"); println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc");
println!("cargo:rerun-if-changed=build/windows/rojo.manifest"); println!("cargo:rerun-if-changed=build/windows/rojo.manifest");

View File

@@ -2,13 +2,6 @@
## Unreleased Changes ## Unreleased Changes
## 0.3.1 (2025-11-27)
* Added `Vfs::exists`. [#1169]
* Added `create_dir` and `create_dir_all` to allow creating directories. [#937]
[#1169]: https://github.com/rojo-rbx/rojo/pull/1169
[#937]: https://github.com/rojo-rbx/rojo/pull/937
## 0.3.0 (2024-03-15) ## 0.3.0 (2024-03-15)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830] * Changed `StdBackend` file watching component to use minimal recursive watches. [#830]
* Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854] * Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854]

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.3.1" version = "0.3.0"
authors = [ authors = [
"Lucien Greathouse <me@lpghatguy.com>", "Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>", "Micah Reid <git@dekkonot.com>",

View File

@@ -157,11 +157,6 @@ impl VfsBackend for InMemoryFs {
) )
} }
fn exists(&mut self, path: &Path) -> io::Result<bool> {
let inner = self.inner.lock().unwrap();
Ok(inner.entries.contains_key(path))
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let inner = self.inner.lock().unwrap(); let inner = self.inner.lock().unwrap();
@@ -181,21 +176,6 @@ impl VfsBackend for InMemoryFs {
} }
} }
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
}
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
let mut path_buf = path.to_path_buf();
while let Some(parent) = path_buf.parent() {
inner.load_snapshot(parent.to_path_buf(), VfsSnapshot::empty_dir())?;
path_buf.pop();
}
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
}
fn remove_file(&mut self, path: &Path) -> io::Result<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap(); let mut inner = self.inner.lock().unwrap();
@@ -248,17 +228,23 @@ impl VfsBackend for InMemoryFs {
} }
fn must_be_file<T>(path: &Path) -> io::Result<T> { fn must_be_file<T>(path: &Path) -> io::Result<T> {
Err(io::Error::other(format!( Err(io::Error::new(
"path {} was a directory, but must be a file", io::ErrorKind::Other,
path.display() format!(
))) "path {} was a directory, but must be a file",
path.display()
),
))
} }
fn must_be_dir<T>(path: &Path) -> io::Result<T> { fn must_be_dir<T>(path: &Path) -> io::Result<T> {
Err(io::Error::other(format!( Err(io::Error::new(
"path {} was a file, but must be a directory", io::ErrorKind::Other,
path.display() format!(
))) "path {} was a file, but must be a directory",
path.display()
),
))
} }
fn not_found<T>(path: &Path) -> io::Result<T> { fn not_found<T>(path: &Path) -> io::Result<T> {

View File

@@ -70,10 +70,7 @@ impl<T> IoResultExt<T> for io::Result<T> {
pub trait VfsBackend: sealed::Sealed + Send + 'static { pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>; fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>; fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>;
fn exists(&mut self, path: &Path) -> io::Result<bool>;
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>; fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>;
fn create_dir(&mut self, path: &Path) -> io::Result<()>;
fn create_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>; fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
@@ -176,11 +173,6 @@ impl VfsInner {
Ok(Arc::new(contents_str.into())) Ok(Arc::new(contents_str.into()))
} }
fn exists<P: AsRef<Path>>(&mut self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.backend.exists(path)
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> { fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let contents = contents.as_ref(); let contents = contents.as_ref();
@@ -198,16 +190,6 @@ impl VfsInner {
Ok(dir) Ok(dir)
} }
fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir(path)
}
fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir_all(path)
}
fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> { fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let _ = self.backend.unwatch(path); let _ = self.backend.unwatch(path);
@@ -344,42 +326,6 @@ impl Vfs {
self.inner.lock().unwrap().read_dir(path) self.inner.lock().unwrap().read_dir(path)
} }
/// Return whether the given path exists.
///
/// Roughly equivalent to [`std::fs::exists`][std::fs::exists].
///
/// [std::fs::exists]: https://doc.rust-lang.org/stable/std/fs/fn.exists.html
#[inline]
pub fn exists<P: AsRef<Path>>(&self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.inner.lock().unwrap().exists(path)
}
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir_all(path)
}
/// Remove a file. /// Remove a file.
/// ///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -482,31 +428,6 @@ impl VfsLock<'_> {
self.inner.read_dir(path) self.inner.read_dir(path)
} }
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir_all(path)
}
/// Remove a file. /// Remove a file.
/// ///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].

View File

@@ -15,39 +15,45 @@ impl NoopBackend {
impl VfsBackend for NoopBackend { impl VfsBackend for NoopBackend {
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> { fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> { fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
} io::ErrorKind::Other,
"NoopBackend doesn't do anything",
fn exists(&mut self, _path: &Path) -> io::Result<bool> { ))
Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
} io::ErrorKind::Other,
"NoopBackend doesn't do anything",
fn create_dir(&mut self, _path: &Path) -> io::Result<()> { ))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn create_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn remove_file(&mut self, _path: &Path) -> io::Result<()> { fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> { fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> { fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -55,11 +61,17 @@ impl VfsBackend for NoopBackend {
} }
fn watch(&mut self, _path: &Path) -> io::Result<()> { fn watch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn unwatch(&mut self, _path: &Path) -> io::Result<()> { fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything")) Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
} }

View File

@@ -63,10 +63,6 @@ impl VfsBackend for StdBackend {
fs_err::write(path, data) fs_err::write(path, data)
} }
fn exists(&mut self, path: &Path) -> io::Result<bool> {
std::fs::exists(path)
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect(); let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?; let mut entries = entries?;
@@ -82,14 +78,6 @@ impl VfsBackend for StdBackend {
}) })
} }
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
fs_err::create_dir(path)
}
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
fs_err::create_dir_all(path)
}
fn remove_file(&mut self, path: &Path) -> io::Result<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs_err::remove_file(path) fs_err::remove_file(path)
} }
@@ -121,13 +109,15 @@ impl VfsBackend for StdBackend {
self.watches.insert(path.to_path_buf()); self.watches.insert(path.to_path_buf());
self.watcher self.watcher
.watch(path, RecursiveMode::Recursive) .watch(path, RecursiveMode::Recursive)
.map_err(io::Error::other) .map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
} }
} }
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path); self.watches.remove(path);
self.watcher.unwatch(path).map_err(io::Error::other) self.watcher
.unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
} }
} }

View File

@@ -1 +1 @@
7.7.0-rc.1 7.5.1

View File

@@ -25,7 +25,7 @@
local function defaultTableDebug(buffer, input) local function defaultTableDebug(buffer, input)
buffer:writeRaw("{") buffer:writeRaw("{")
for key, value in input do for key, value in pairs(input) do
buffer:write("[{:?}] = {:?}", key, value) buffer:write("[{:?}] = {:?}", key, value)
if next(input, key) ~= nil then if next(input, key) ~= nil then
@@ -50,7 +50,7 @@ local function defaultTableDebugExtended(buffer, input)
buffer:writeLineRaw("{") buffer:writeLineRaw("{")
buffer:indent() buffer:indent()
for key, value in input do for key, value in pairs(input) do
buffer:writeLine("[{:?}] = {:#?},", key, value) buffer:writeLine("[{:?}] = {:#?},", key, value)
end end

View File

@@ -378,26 +378,13 @@ types = {
if pod == "Default" then if pod == "Default" then
return nil return nil
else else
-- Passing `nil` instead of not passing anything gives return PhysicalProperties.new(
-- different results, so we have to branch here. pod.density,
if pod.acousticAbsorption then pod.friction,
return (PhysicalProperties.new :: any)( pod.elasticity,
pod.density, pod.frictionWeight,
pod.friction, pod.elasticityWeight
pod.elasticity, )
pod.frictionWeight,
pod.elasticityWeight,
pod.acousticAbsorption
)
else
return PhysicalProperties.new(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight
)
end
end end
end, end,
@@ -411,7 +398,6 @@ types = {
elasticity = roblox.Elasticity, elasticity = roblox.Elasticity,
frictionWeight = roblox.FrictionWeight, frictionWeight = roblox.FrictionWeight,
elasticityWeight = roblox.ElasticityWeight, elasticityWeight = roblox.ElasticityWeight,
acousticAbsorption = roblox.AcousticAbsorption,
} }
end end
end, end,

View File

@@ -441,8 +441,7 @@
"friction": 1.0, "friction": 1.0,
"elasticity": 0.0, "elasticity": 0.0,
"frictionWeight": 50.0, "frictionWeight": 50.0,
"elasticityWeight": 25.0, "elasticityWeight": 25.0
"acousticAbsorption": 0.15625
} }
}, },
"ty": "PhysicalProperties" "ty": "PhysicalProperties"

View File

@@ -1,10 +1,139 @@
local EncodingService = game:GetService("EncodingService") -- Thanks to Tiffany352 for this base64 implementation!
local floor = math.floor
local char = string.char
local function encodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
-- 3 octets become 4 hextets
for i = 1, strLen - 2, 3 do
local b1, b2, b3 = str:byte(i, i + 3)
local word = b3 + b2 * 256 + b1 * 256 * 256
local h4 = word % 64 + 1
word = floor(word / 64)
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = alphabet:sub(h4, h4)
nOut = nOut + 4
end
local remainder = strLen % 3
if remainder == 2 then
-- 16 input bits -> 3 hextets (2 full, 1 partial)
local b1, b2 = str:byte(-2, -1)
-- partial is 4 bits long, leaving 2 bits of zero padding ->
-- offset = 4
local word = b2 * 4 + b1 * 4 * 256
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = "="
elseif remainder == 1 then
-- 8 input bits -> 2 hextets (2 full, 1 partial)
local b1 = str:byte(-1, -1)
-- partial is 2 bits long, leaving 4 bits of zero padding ->
-- offset = 16
local word = b1 * 16
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = "="
out[nOut + 4] = "="
end
-- if the remainder is 0, then no work is needed
return table.concat(out, "")
end
local function decodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
local acc = 0
local nAcc = 0
local alphabetLut = {}
for i = 1, #alphabet do
alphabetLut[alphabet:sub(i, i)] = i - 1
end
-- 4 hextets become 3 octets
for i = 1, strLen do
local ch = str:sub(i, i)
local byte = alphabetLut[ch]
if byte then
acc = acc * 64 + byte
nAcc = nAcc + 1
end
if nAcc == 4 then
local b3 = acc % 256
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
out[nOut + 3] = char(b3)
nOut = nOut + 3
nAcc = 0
acc = 0
end
end
if nAcc == 3 then
-- 3 hextets -> 16 bit output
acc = acc * 64
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
elseif nAcc == 2 then
-- 2 hextets -> 8 bit output
acc = acc * 64
acc = floor(acc / 256)
acc = acc * 64
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
elseif nAcc == 1 then
error("Base64 has invalid length")
end
return table.concat(out, "")
end
return { return {
decode = function(input: string) decode = decodeBase64,
return buffer.tostring(EncodingService:Base64Decode(buffer.fromstring(input))) encode = encodeBase64,
end,
encode = function(input: string)
return buffer.tostring(EncodingService:Base64Encode(buffer.fromstring(input)))
end,
} }

View File

@@ -208,30 +208,4 @@ return {
end, end,
}, },
}, },
StyleRule = {
PropertiesSerialize = {
read = function(instance: StyleRule)
return true, instance:GetProperties()
end,
write = function(instance: StyleRule, _, value: { [any]: any })
if typeof(value) ~= "table" then
return false, Error.new(Error.Kind.CannotParseBinaryString)
end
local existing = instance:GetProperties()
for itemName, itemValue in pairs(value) do
instance:SetProperty(itemName, itemValue)
end
for existingItemName in pairs(existing) do
if value[existingItemName] == nil then
instance:SetProperty(existingItemName, nil)
end
end
return true
end,
},
},
} }

File diff suppressed because it is too large Load Diff

View File

@@ -8,4 +8,12 @@ local Settings = require(Rojo.Plugin.Settings)
Settings:set("logLevel", "Trace") Settings:set("logLevel", "Trace")
Settings:set("typecheckingEnabled", true) Settings:set("typecheckingEnabled", true)
require(Rojo.Plugin.runTests)(TestEZ) local results = require(Rojo.Plugin.runTests)(TestEZ)
-- Roblox's Luau execution gets mad about cyclical tables.
-- Rather than making TestEZ not do that, we just send back the important info.
return {
failureCount = results.failureCount,
successCount = results.successCount,
skippedCount = results.skippedCount,
}

View File

@@ -1,5 +1,4 @@
local Packages = script.Parent.Parent.Packages local Packages = script.Parent.Parent.Packages
local HttpService = game:GetService("HttpService")
local Http = require(Packages.Http) local Http = require(Packages.Http)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Promise = require(Packages.Promise) local Promise = require(Packages.Promise)
@@ -10,7 +9,7 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket) local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse) local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse) local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
@@ -100,7 +99,6 @@ function ApiContext.new(baseUrl)
__baseUrl = baseUrl, __baseUrl = baseUrl,
__sessionId = nil, __sessionId = nil,
__messageCursor = -1, __messageCursor = -1,
__wsClient = nil,
__connected = true, __connected = true,
__activeRequests = {}, __activeRequests = {},
} }
@@ -128,12 +126,6 @@ function ApiContext:disconnect()
request:cancel() request:cancel()
end end
self.__activeRequests = {} self.__activeRequests = {}
if self.__wsClient then
Log.trace("Closing WebSocket client")
self.__wsClient:Close()
end
self.__wsClient = nil
end end
function ApiContext:setMessageCursor(index) function ApiContext:setMessageCursor(index)
@@ -215,65 +207,38 @@ function ApiContext:write(patch)
end) end)
end end
function ApiContext:connectWebSocket(packetHandlers) function ApiContext:retrieveMessages()
local url = ("%s/api/socket/%s"):format(self.__baseUrl, self.__messageCursor) local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
-- Convert HTTP/HTTPS URL to WS/WSS
url = url:gsub("^http://", "ws://"):gsub("^https://", "wss://")
return Promise.new(function(resolve, reject) local function sendRequest()
local success, wsClient = local request = Http.get(url):catch(function(err)
pcall(HttpService.CreateWebStreamClient, HttpService, Enum.WebStreamClientType.WebSocket, { if err.type == Http.Error.Kind.Timeout and self.__connected then
Url = url, return sendRequest()
}) end
if not success then
reject("Failed to create WebSocket client: " .. tostring(wsClient)) return Promise.reject(err)
return end)
Log.trace("Tracking request {}", request)
self.__activeRequests[request] = true
return request:finally(function(...)
Log.trace("Cleaning up request {}", request)
self.__activeRequests[request] = nil
return ...
end)
end
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end end
self.__wsClient = wsClient
local closed, errored, received assert(validateApiSubscribe(body))
received = self.__wsClient.MessageReceived:Connect(function(msg) self:setMessageCursor(body.messageCursor)
local data = Http.jsonDecode(msg)
if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring")
return
end
assert(validateApiSocketPacket(data)) return body.messages
Log.trace("Received websocket packet: {:#?}", data)
local handler = packetHandlers[data.packetType]
if handler then
local ok, err = pcall(handler, data.body)
if not ok then
Log.error("Error in WebSocket packet handler for type '%s': %s", data.packetType, err)
end
else
Log.warn("No handler for WebSocket packet type '%s'", data.packetType)
end
end)
closed = self.__wsClient.Closed:Connect(function()
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
if self.__connected then
reject("WebSocket connection closed unexpectedly")
else
resolve()
end
end)
errored = self.__wsClient.Error:Connect(function(code, msg)
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
reject("WebSocket error: " .. code .. " - " .. msg)
end)
end) end)
end end
@@ -290,39 +255,31 @@ function ApiContext:open(id)
end end
function ApiContext:serialize(ids: { string }) function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize"):format(self.__baseUrl) local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ","))
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(response_body) end
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSerialize(response_body)) assert(validateApiSerialize(body))
return response_body return body
end) end)
end end
function ApiContext:refPatch(ids: { string }) function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch"):format(self.__baseUrl) local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ","))
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
return Http.post(url, request_body) return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
:andThen(rejectFailedRequests) if body.sessionId ~= self.__sessionId then
:andThen(Http.Response.json) return Promise.reject("Server changed ID")
:andThen(function(response_body) end
if response_body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRefPatch(response_body)) assert(validateApiRefPatch(body))
return response_body return body
end) end)
end end
return ApiContext return ApiContext

View File

@@ -1,11 +1,6 @@
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local AssetService = game:GetService("AssetService") local AssetService = game:GetService("AssetService")
type CachedImageInfo = {
pixels: buffer,
size: Vector2,
}
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -16,71 +11,44 @@ local e = Roact.createElement
local EditableImage = require(Plugin.App.Components.EditableImage) local EditableImage = require(Plugin.App.Components.EditableImage)
local imageCache: { [string]: CachedImageInfo } = {} local imageCache = {}
local function getImageSizeAndPixels(image)
local function cloneBuffer(b: buffer): buffer if not imageCache[image] then
local newBuffer = buffer.create(buffer.len(b)) local editableImage = AssetService:CreateEditableImageAsync(image)
buffer.copy(newBuffer, 0, b)
return newBuffer
end
local function getImageSizeAndPixels(image: string): (Vector2, buffer)
local cachedImage = imageCache[image]
if not cachedImage then
local editableImage = AssetService:CreateEditableImageAsync(Content.fromUri(image))
local size = editableImage.Size
local pixels = editableImage:ReadPixelsBuffer(Vector2.zero, size)
imageCache[image] = { imageCache[image] = {
pixels = pixels, Size = editableImage.Size,
size = size, Pixels = editableImage:ReadPixels(Vector2.zero, editableImage.Size),
} }
return size, cloneBuffer(pixels)
end end
return cachedImage.size, cloneBuffer(cachedImage.pixels) return imageCache[image].Size, table.clone(imageCache[image].Pixels)
end end
local function getRecoloredClassIcon(className, color) local function getRecoloredClassIcon(className, color)
local iconProps = StudioService:GetClassIcon(className) local iconProps = StudioService:GetClassIcon(className)
if iconProps and color then if iconProps and color then
--stylua: ignore local success, editableImageSize, editableImagePixels = pcall(function()
local success, editableImageSize, editableImagePixels = pcall(function(_iconProps: { [any]: any }, _color: Color3): (Vector2, buffer) local size, pixels = getImageSizeAndPixels(iconProps.Image)
local size, pixels = getImageSizeAndPixels(_iconProps.Image)
local pixelsLen = buffer.len(pixels)
local minVal, maxVal = math.huge, -math.huge local minVal, maxVal = math.huge, -math.huge
for i = 1, #pixels, 4 do
for i = 0, pixelsLen, 4 do if pixels[i + 3] == 0 then
if buffer.readu8(pixels, i + 3) == 0 then
continue continue
end end
local pixelVal = math.max( local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
buffer.readu8(pixels, i),
buffer.readu8(pixels, i + 1),
buffer.readu8(pixels, i + 2)
)
minVal = math.min(minVal, pixelVal) minVal = math.min(minVal, pixelVal)
maxVal = math.max(maxVal, pixelVal) maxVal = math.max(maxVal, pixelVal)
end end
local hue, sat, val = _color:ToHSV() local hue, sat, val = color:ToHSV()
for i = 1, #pixels, 4 do
for i = 0, pixelsLen, 4 do if pixels[i + 3] == 0 then
if buffer.readu8(pixels, i + 3) == 0 then
continue continue
end end
local gIndex = i + 1
local bIndex = i + 2
local pixelVal = math.max( local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
buffer.readu8(pixels, i),
buffer.readu8(pixels, gIndex),
buffer.readu8(pixels, bIndex)
)
local newVal = val local newVal = val
if minVal < maxVal then if minVal < maxVal then
-- Remap minVal - maxVal to val*0.9 - val -- Remap minVal - maxVal to val*0.9 - val
@@ -88,12 +56,10 @@ local function getRecoloredClassIcon(className, color)
end end
local newPixelColor = Color3.fromHSV(hue, sat, newVal) local newPixelColor = Color3.fromHSV(hue, sat, newVal)
buffer.writeu8(pixels, i, newPixelColor.R) pixels[i], pixels[i + 1], pixels[i + 2] = newPixelColor.R, newPixelColor.G, newPixelColor.B
buffer.writeu8(pixels, gIndex, newPixelColor.G)
buffer.writeu8(pixels, bIndex, newPixelColor.B)
end end
return size, pixels return size, pixels
end, iconProps, color) end)
if success then if success then
iconProps.EditableImagePixels = editableImagePixels iconProps.EditableImagePixels = editableImagePixels
iconProps.EditableImageSize = editableImageSize iconProps.EditableImageSize = editableImageSize

View File

@@ -0,0 +1,66 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local e = Roact.createElement
local Theme = require(Plugin.App.Theme)
local CodeLabel = Roact.PureComponent:extend("CodeLabel")
function CodeLabel:init()
self.labelRef = Roact.createRef()
self.highlightsRef = Roact.createRef()
end
function CodeLabel:didMount()
Highlighter.highlight({
textObject = self.labelRef:getValue(),
})
self:updateHighlights()
end
function CodeLabel:didUpdate()
self:updateHighlights()
end
function CodeLabel:updateHighlights()
local highlights = self.highlightsRef:getValue()
if not highlights then
return
end
for _, lineLabel in highlights:GetChildren() do
local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0")
lineLabel.BackgroundColor3 = self.props.lineBackground
lineLabel.BorderSizePixel = 0
lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1
end
end
function CodeLabel:render()
return Theme.with(function(theme)
return e("TextLabel", {
Size = self.props.size,
Position = self.props.position,
Text = self.props.text,
BackgroundTransparency = 1,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
[Roact.Ref] = self.labelRef,
}, {
SyntaxHighlights = e("Folder", {
[Roact.Ref] = self.highlightsRef,
}),
})
end)
end
return CodeLabel

View File

@@ -12,8 +12,7 @@ function EditableImage:init()
end end
function EditableImage:writePixels() function EditableImage:writePixels()
local image = self.ref.current :: EditableImage local image = self.ref.current
if not image then if not image then
return return
end end
@@ -21,7 +20,7 @@ function EditableImage:writePixels()
return return
end end
image:WritePixelsBuffer(Vector2.zero, self.props.size, self.props.pixels) image:WritePixels(Vector2.zero, self.props.size, self.props.pixels)
end end
function EditableImage:render() function EditableImage:render()

View File

@@ -1,4 +1,3 @@
--!strict
--[[ --[[
Based on DiffMatchPatch by Neil Fraser. Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch https://github.com/google/diff-match-patch
@@ -68,187 +67,8 @@ function StringDiff.findDiffs(text1: string, text2: string): Diffs
end end
-- Cleanup the diff -- Cleanup the diff
diffs = StringDiff._cleanupSemantic(diffs)
diffs = StringDiff._reorderAndMerge(diffs) diffs = StringDiff._reorderAndMerge(diffs)
-- Remove any empty diffs
local cursor = 1
while cursor and diffs[cursor] do
if diffs[cursor].value == "" then
table.remove(diffs, cursor)
else
cursor += 1
end
end
return diffs
end
function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2
if text1Length == 0 then
-- It's simply inserting all of text2 into text1
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
end
if text2Length == 0 then
-- It's simply deleting all of text1
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
end
local longText = if text1Length > text2Length then text1 else text2
local shortText = if text1Length > text2Length then text2 else text1
local shortTextLength = #shortText
-- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end
return diffs
end
if shortTextLength == 1 then
-- Single character string
-- After the previous shortcut, the character can't be an equality
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
return StringDiff._bisect(text1, text2)
end
function StringDiff._cleanupSemantic(diffs: Diffs): Diffs
-- Reduce the number of edits by eliminating semantically trivial equalities.
local changes = false
local equalities = {} -- Stack of indices where equalities are found.
local equalitiesLength = 0 -- Keeping our own length var is faster.
local lastEquality: string? = nil
-- Always equal to diffs[equalities[equalitiesLength]].value
local pointer = 1 -- Index of current position.
-- Number of characters that changed prior to the equality.
local length_insertions1 = 0
local length_deletions1 = 0
-- Number of characters that changed after the equality.
local length_insertions2 = 0
local length_deletions2 = 0
while diffs[pointer] do
if diffs[pointer].actionType == StringDiff.ActionTypes.Equal then -- Equality found.
equalitiesLength = equalitiesLength + 1
equalities[equalitiesLength] = pointer
length_insertions1 = length_insertions2
length_deletions1 = length_deletions2
length_insertions2 = 0
length_deletions2 = 0
lastEquality = diffs[pointer].value
else -- An insertion or deletion.
if diffs[pointer].actionType == StringDiff.ActionTypes.Insert then
length_insertions2 = length_insertions2 + #diffs[pointer].value
else
length_deletions2 = length_deletions2 + #diffs[pointer].value
end
-- Eliminate an equality that is smaller or equal to the edits on both
-- sides of it.
if
lastEquality
and (#lastEquality <= math.max(length_insertions1, length_deletions1))
and (#lastEquality <= math.max(length_insertions2, length_deletions2))
then
-- Duplicate record.
table.insert(
diffs,
equalities[equalitiesLength],
{ actionType = StringDiff.ActionTypes.Delete, value = lastEquality }
)
-- Change second copy to insert.
diffs[equalities[equalitiesLength] + 1].actionType = StringDiff.ActionTypes.Insert
-- Throw away the equality we just deleted.
equalitiesLength = equalitiesLength - 1
-- Throw away the previous equality (it needs to be reevaluated).
equalitiesLength = equalitiesLength - 1
pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0
length_insertions1, length_deletions1 = 0, 0 -- Reset the counters.
length_insertions2, length_deletions2 = 0, 0
lastEquality = nil
changes = true
end
end
pointer = pointer + 1
end
-- Normalize the diff.
if changes then
StringDiff._reorderAndMerge(diffs)
end
StringDiff._cleanupSemanticLossless(diffs)
-- Find any overlaps between deletions and insertions.
-- e.g: <del>abcxxx</del><ins>xxxdef</ins>
-- -> <del>abc</del>xxx<ins>def</ins>
-- e.g: <del>xxxabc</del><ins>defxxx</ins>
-- -> <ins>def</ins>xxx<del>abc</del>
-- Only extract an overlap if it is as big as the edit ahead or behind it.
pointer = 2
while diffs[pointer] do
if
diffs[pointer - 1].actionType == StringDiff.ActionTypes.Delete
and diffs[pointer].actionType == StringDiff.ActionTypes.Insert
then
local deletion = diffs[pointer - 1].value
local insertion = diffs[pointer].value
local overlap_length1 = StringDiff._commonOverlap(deletion, insertion)
local overlap_length2 = StringDiff._commonOverlap(insertion, deletion)
if overlap_length1 >= overlap_length2 then
if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then
-- Overlap found. Insert an equality and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(insertion, 1, overlap_length1) }
)
diffs[pointer - 1].value = string.sub(deletion, 1, #deletion - overlap_length1)
diffs[pointer + 1].value = string.sub(insertion, overlap_length1 + 1)
pointer = pointer + 1
end
else
if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then
-- Reverse overlap found.
-- Insert an equality and swap and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(deletion, 1, overlap_length2) }
)
diffs[pointer - 1] = {
actionType = StringDiff.ActionTypes.Insert,
value = string.sub(insertion, 1, #insertion - overlap_length2),
}
diffs[pointer + 1] = {
actionType = StringDiff.ActionTypes.Delete,
value = string.sub(deletion, overlap_length2 + 1),
}
pointer = pointer + 1
end
end
pointer = pointer + 1
end
pointer = pointer + 1
end
return diffs return diffs
end end
@@ -304,164 +124,51 @@ function StringDiff._sharedSuffix(text1: string, text2: string): number
return pointerMid return pointerMid
end end
function StringDiff._commonOverlap(text1: string, text2: string): number function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Determine if the suffix of one string is the prefix of another. -- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
-- Cache the text lengths to prevent multiple calls. local text1Length, text2Length = #text1, #text2
local text1_length = #text1
local text2_length = #text2 if text1Length == 0 then
-- Eliminate the null case. -- It's simply inserting all of text2 into text1
if text1_length == 0 or text2_length == 0 then return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
return 0
end
-- Truncate the longer string.
if text1_length > text2_length then
text1 = string.sub(text1, text1_length - text2_length + 1)
elseif text1_length < text2_length then
text2 = string.sub(text2, 1, text1_length)
end
local text_length = math.min(text1_length, text2_length)
-- Quick check for the worst case.
if text1 == text2 then
return text_length
end end
-- Start by looking for a single character match if text2Length == 0 then
-- and increase length until no match is found. -- It's simply deleting all of text1
-- Performance analysis: https://neil.fraser.name/news/2010/11/04/ return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
local best = 0 end
local length = 1
while true do local longText = if text1Length > text2Length then text1 else text2
local pattern = string.sub(text1, text_length - length + 1) local shortText = if text1Length > text2Length then text2 else text1
local found = string.find(text2, pattern, 1, true) local shortTextLength = #shortText
if found == nil then
return best -- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end end
length = length + found - 1 return diffs
if found == 1 or string.sub(text1, text_length - length + 1) == string.sub(text2, 1, length) then
best = length
length = length + 1
end
end
end
function StringDiff._cleanupSemanticScore(one: string, two: string): number
-- Given two strings, compute a score representing whether the internal
-- boundary falls on logical boundaries.
-- Scores range from 6 (best) to 0 (worst).
if (#one == 0) or (#two == 0) then
-- Edges are the best.
return 6
end end
-- Each port of this function behaves slightly differently due to if shortTextLength == 1 then
-- subtle differences in each language's definition of things like -- Single character string
-- 'whitespace'. Since this function's purpose is largely cosmetic, -- After the previous shortcut, the character can't be an equality
-- the choice has been made to use each language's native features return {
-- rather than force total conformity. { actionType = StringDiff.ActionTypes.Delete, value = text1 },
local char1 = string.sub(one, -1) { actionType = StringDiff.ActionTypes.Insert, value = text2 },
local char2 = string.sub(two, 1, 1) }
local nonAlphaNumeric1 = string.match(char1, "%W")
local nonAlphaNumeric2 = string.match(char2, "%W")
local whitespace1 = nonAlphaNumeric1 and string.match(char1, "%s")
local whitespace2 = nonAlphaNumeric2 and string.match(char2, "%s")
local lineBreak1 = whitespace1 and string.match(char1, "%c")
local lineBreak2 = whitespace2 and string.match(char2, "%c")
local blankLine1 = lineBreak1 and string.match(one, "\n\r?\n$")
local blankLine2 = lineBreak2 and string.match(two, "^\r?\n\r?\n")
if blankLine1 or blankLine2 then
-- Five points for blank lines.
return 5
elseif lineBreak1 or lineBreak2 then
-- Four points for line breaks
-- DEVIATION: Prefer to start on a line break instead of end on it
return if lineBreak1 then 4 else 4.5
elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then
-- Three points for end of sentences.
return 3
elseif whitespace1 or whitespace2 then
-- Two points for whitespace.
return 2
elseif nonAlphaNumeric1 or nonAlphaNumeric2 then
-- One point for non-alphanumeric.
return 1
end end
return 0
end
function StringDiff._cleanupSemanticLossless(diffs: Diffs) return StringDiff._bisect(text1, text2)
-- Look for single edits surrounded on both sides by equalities
-- which can be shifted sideways to align the edit to a word boundary.
-- e.g: The c<ins>at c</ins>ame. -> The <ins>cat </ins>came.
local pointer = 2
-- Intentionally ignore the first and last element (don't need checking).
while diffs[pointer + 1] do
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
if
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
then
-- This is a single edit surrounded by equalities.
local diff = diffs[pointer]
local equality1 = prevDiff.value
local edit = diff.value
local equality2 = nextDiff.value
-- First, shift the edit as far left as possible.
local commonOffset = StringDiff._sharedSuffix(equality1, edit)
if commonOffset > 0 then
local commonString = string.sub(edit, -commonOffset)
equality1 = string.sub(equality1, 1, -commonOffset - 1)
edit = commonString .. string.sub(edit, 1, -commonOffset - 1)
equality2 = commonString .. equality2
end
-- Second, step character by character right, looking for the best fit.
local bestEquality1 = equality1
local bestEdit = edit
local bestEquality2 = equality2
local bestScore = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
while string.byte(edit, 1) == string.byte(equality2, 1) do
equality1 = equality1 .. string.sub(edit, 1, 1)
edit = string.sub(edit, 2) .. string.sub(equality2, 1, 1)
equality2 = string.sub(equality2, 2)
local score = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
-- The > (rather than >=) encourages leading rather than trailing whitespace on edits.
-- I just think it looks better for indentation changes to start the line,
-- since then indenting several lines all have aligned diffs at the start
if score > bestScore then
bestScore = score
bestEquality1 = equality1
bestEdit = edit
bestEquality2 = equality2
end
end
if prevDiff.value ~= bestEquality1 then
-- We have an improvement, save it back to the diff.
if #bestEquality1 > 0 then
diffs[pointer - 1].value = bestEquality1
else
table.remove(diffs, pointer - 1)
pointer = pointer - 1
end
diffs[pointer].value = bestEdit
if #bestEquality2 > 0 then
diffs[pointer + 1].value = bestEquality2
else
table.remove(diffs, pointer + 1)
pointer = pointer - 1
end
end
end
pointer = pointer + 1
end
end end
function StringDiff._bisect(text1: string, text2: string): Diffs function StringDiff._bisect(text1: string, text2: string): Diffs

View File

@@ -5,15 +5,15 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter) local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local StringDiff = require(script:FindFirstChild("StringDiff")) local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer) local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync) local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local e = Roact.createElement local e = Roact.createElement
@@ -21,29 +21,26 @@ local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
function StringDiffVisualizer:init() function StringDiffVisualizer:init()
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0)) self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
self.updateEvent = Instance.new("BindableEvent") self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.lineHeight, self.setLineHeight = Roact.createBinding(15)
self.canvasPosition, self.setCanvasPosition = Roact.createBinding(Vector2.zero)
self.windowWidth, self.setWindowWidth = Roact.createBinding(math.huge)
-- Ensure that the script background is up to date with the current theme -- Ensure that the script background is up to date with the current theme
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function() self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
-- Delay to allow Highlighter to process the theme change first task.defer(function()
task.delay(1 / 20, function() -- Defer to allow Highlighter to process the theme change first
self:updateScriptBackground() self:updateScriptBackground()
self:updateDiffs()
-- Rerender the virtual list elements
self.updateEvent:Fire()
end) end)
end) end)
self:updateScriptBackground() self:updateScriptBackground()
self:updateDiffs()
self:setState({
add = {},
remove = {},
})
end end
function StringDiffVisualizer:willUnmount() function StringDiffVisualizer:willUnmount()
self.themeChangedConnection:Disconnect() self.themeChangedConnection:Disconnect()
self.updateEvent:Destroy()
end end
function StringDiffVisualizer:updateScriptBackground() function StringDiffVisualizer:updateScriptBackground()
@@ -54,188 +51,96 @@ function StringDiffVisualizer:updateScriptBackground()
end end
function StringDiffVisualizer:didUpdate(previousProps) function StringDiffVisualizer:didUpdate(previousProps)
if if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
previousProps.currentString ~= self.props.currentString local add, remove = self:calculateDiffLines()
or previousProps.incomingString ~= self.props.incomingString self:setState({
then add = add,
self:updateDiffs() remove = remove,
})
end end
end end
function StringDiffVisualizer:updateDiffs() function StringDiffVisualizer:calculateContentSize(theme)
Timer.start("StringDiffVisualizer:updateDiffs") local oldString, newString = self.props.oldString, self.props.newString
local currentString, incomingString = self.props.currentString, self.props.incomingString
local oldStringBounds = getTextBoundsAsync(oldString, theme.Font.Code, theme.TextSize.Code, math.huge)
local newStringBounds = getTextBoundsAsync(newString, theme.Font.Code, theme.TextSize.Code, math.huge)
self.setContentSize(
Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
)
end
function StringDiffVisualizer:calculateDiffLines()
Timer.start("StringDiffVisualizer:calculateDiffLines")
local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts -- Diff the two texts
local startClock = os.clock() local startClock = os.clock()
local diffs = local diffs = StringDiff.findDiffs(oldString, newString)
StringDiff.findDiffs((string.gsub(currentString, "\t", " ")), (string.gsub(incomingString, "\t", " ")))
local stopClock = os.clock() local stopClock = os.clock()
Log.trace( Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", "Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#currentString, #oldString,
#incomingString, #newString,
math.round((stopClock - startClock) * 1000 * 1000), math.round((stopClock - startClock) * 1000 * 1000),
#diffs #diffs
) )
-- Build the rich text lines -- Determine which lines to highlight
local currentRichTextLines = Highlighter.buildRichTextLines({ local add, remove = {}, {}
src = currentString,
})
local incomingRichTextLines = Highlighter.buildRichTextLines({
src = incomingString,
})
local maxLines = math.max(#currentRichTextLines, #incomingRichTextLines) local oldLineNum, newLineNum = 1, 1
-- Find the diff locations
local currentDiffs, incomingDiffs = {}, {}
local firstDiffLineNum = 0
local currentLineNum, incomingLineNum = 1, 1
local currentIdx, incomingIdx = 1, 1
for _, diff in diffs do for _, diff in diffs do
local actionType, text = diff.actionType, diff.value local actionType, text = diff.actionType, diff.value
local lineCount = select(2, string.gsub(text, "\n", "\n")) local lines = select(2, string.gsub(text, "\n", "\n"))
local lines = string.split(text, "\n")
if actionType == StringDiff.ActionTypes.Equal then if actionType == StringDiff.ActionTypes.Equal then
if lineCount > 0 then oldLineNum += lines
-- Jump cursor ahead to last line newLineNum += lines
currentLineNum += lineCount elseif actionType == StringDiff.ActionTypes.Insert then
incomingLineNum += lineCount if lines > 0 then
currentIdx = #lines[#lines] local textLines = string.split(text, "\n")
incomingIdx = #lines[#lines] for i, textLine in textLines do
if string.match(textLine, "%S") then
add[newLineNum + i - 1] = true
end
end
else else
-- Move along this line if string.match(text, "%S") then
currentIdx += #text add[newLineNum] = true
incomingIdx += #text
end
continue
end
if actionType == StringDiff.ActionTypes.Insert then
if firstDiffLineNum == 0 then
firstDiffLineNum = incomingLineNum
end
for i, lineText in lines do
if i > 1 then
-- Move to next line
incomingLineNum += 1
incomingIdx = 0
end end
if not incomingDiffs[incomingLineNum] then
incomingDiffs[incomingLineNum] = {}
end
-- Mark these characters on this line
table.insert(incomingDiffs[incomingLineNum], {
start = incomingIdx,
stop = incomingIdx + #lineText,
})
incomingIdx += #lineText
end end
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Delete then elseif actionType == StringDiff.ActionTypes.Delete then
if firstDiffLineNum == 0 then if lines > 0 then
firstDiffLineNum = currentLineNum local textLines = string.split(text, "\n")
end for i, textLine in textLines do
if string.match(textLine, "%S") then
for i, lineText in lines do remove[oldLineNum + i - 1] = true
if i > 1 then end
-- Move to next line
currentLineNum += 1
currentIdx = 0
end end
if not currentDiffs[currentLineNum] then else
currentDiffs[currentLineNum] = {} if string.match(text, "%S") then
remove[oldLineNum] = true
end end
-- Mark these characters on this line
table.insert(currentDiffs[currentLineNum], {
start = currentIdx,
stop = currentIdx + #lineText,
})
currentIdx += #lineText
end end
oldLineNum += lines
else else
Log.warn("Unknown diff action: {} {}", actionType, text) Log.warn("Unknown diff action: {} {}", actionType, text)
end end
end end
Timer.stop() Timer.stop()
return add, remove
self:setState({
maxLines = maxLines,
currentRichTextLines = currentRichTextLines,
incomingRichTextLines = incomingRichTextLines,
currentDiffs = currentDiffs,
incomingDiffs = incomingDiffs,
})
-- Scroll to the first diff line
task.defer(self.setCanvasPosition, Vector2.new(0, math.max(0, (firstDiffLineNum - 4) * 16)))
end end
function StringDiffVisualizer:render() function StringDiffVisualizer:render()
local currentDiffs, incomingDiffs = self.state.currentDiffs, self.state.incomingDiffs local oldString, newString = self.props.oldString, self.props.newString
local currentRichTextLines, incomingRichTextLines =
self.state.currentRichTextLines, self.state.incomingRichTextLines
local maxLines = self.state.maxLines
return Theme.with(function(theme) return Theme.with(function(theme)
self.setLineHeight(theme.TextSize.Code) self:calculateContentSize(theme)
-- Calculate the width of the canvas
-- (One line at a time to avoid the char limit of getTextBoundsAsync)
local canvasWidth = 0
for i = 1, maxLines do
local currentLine = currentRichTextLines[i]
if currentLine and string.find(currentLine, "%S") then
local bounds = getTextBoundsAsync(currentLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
local incomingLine = incomingRichTextLines[i]
if incomingLine and string.find(incomingLine, "%S") then
local bounds = getTextBoundsAsync(incomingLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
end
local lineNumberWidth =
getTextBoundsAsync(tostring(maxLines), theme.Font.Code, theme.TextSize.Body, math.huge, true).X
canvasWidth += lineNumberWidth + 12
local removalScrollMarkers = {}
local insertionScrollMarkers = {}
for lineNum in currentDiffs do
table.insert(
removalScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Remove,
})
)
end
for lineNum in incomingDiffs do
table.insert(
insertionScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0.5, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Add,
})
)
end
return e(BorderedContainer, { return e(BorderedContainer, {
size = self.props.size, size = self.props.size,
@@ -254,196 +159,43 @@ function StringDiffVisualizer:render()
CornerRadius = UDim.new(0, 5), CornerRadius = UDim.new(0, 5),
}), }),
}), }),
Main = e("Frame", { Separator = e("Frame", {
Size = UDim2.new(1, -10, 1, -2), Size = UDim2.new(0, 2, 1, 0),
Position = UDim2.new(0, 2, 0, 2), Position = UDim2.new(0.5, 0, 0, 0),
BackgroundTransparency = 1, AnchorPoint = Vector2.new(0.5, 0),
[Roact.Change.AbsoluteSize] = function(rbx) BorderSizePixel = 0,
self.setWindowWidth(rbx.AbsoluteSize.X * 0.5 - 10) BackgroundColor3 = theme.BorderedContainer.BorderColor,
end, BackgroundTransparency = 0.5,
}),
Old = e(ScrollingFrame, {
position = UDim2.new(0, 2, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
}, { }, {
Separator = e("Frame", { Source = e(CodeLabel, {
Size = UDim2.new(0, 2, 1, 0), size = UDim2.new(1, 0, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
BackgroundTransparency = 0.5,
}),
Current = e(VirtualScroller, {
position = UDim2.new(0, 0, 0, 0), position = UDim2.new(0, 0, 0, 0),
size = UDim2.new(0.5, -1, 1, 0), text = oldString,
transparency = self.props.transparency, lineBackground = theme.Diff.Background.Remove,
count = maxLines, markedLines = self.state.remove,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = currentDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Remove else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = currentRichTextLines[i] or "",
RichText = true,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}),
Incoming = e(VirtualScroller, {
position = UDim2.new(0.5, 1, 0, 0),
size = UDim2.new(0.5, -1, 1, 0),
transparency = self.props.transparency,
count = maxLines,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = incomingDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Add else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = incomingRichTextLines[i] or "",
RichText = true,
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}), }),
}), }),
ScrollMarkers = e("Frame", { New = e(ScrollingFrame, {
Size = self.windowWidth:map(function(windowWidth) position = UDim2.new(0.5, 5, 0, 2),
return UDim2.new(0, 8, 1, -4 - (if canvasWidth > windowWidth then 10 else 0)) size = UDim2.new(0.5, -7, 1, -4),
end), scrollingDirection = Enum.ScrollingDirection.XY,
Position = UDim2.new(1, -2, 0, 2), transparency = self.props.transparency,
AnchorPoint = Vector2.new(1, 0), contentSize = self.contentSize,
BackgroundTransparency = 1,
}, { }, {
insertions = Roact.createFragment(insertionScrollMarkers), Source = e(CodeLabel, {
removals = Roact.createFragment(removalScrollMarkers), size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = newString,
lineBackground = theme.Diff.Background.Add,
markedLines = self.state.add,
}),
}), }),
}) })
end) end)

View File

@@ -15,10 +15,8 @@ local VirtualScroller = Roact.Component:extend("VirtualScroller")
function VirtualScroller:init() function VirtualScroller:init()
self.scrollFrameRef = Roact.createRef() self.scrollFrameRef = Roact.createRef()
self:setState({ self:setState({
WindowSize = Vector2.zero, WindowSize = Vector2.new(),
CanvasPosition = if self.props.canvasPosition CanvasPosition = Vector2.new(),
then self.props.canvasPosition:getValue() or Vector2.zero
else Vector2.zero,
}) })
self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0) self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0)
@@ -43,10 +41,6 @@ function VirtualScroller:didMount()
local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition") local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition")
self.canvasPositionChanged = canvasPositionSignal:Connect(function() self.canvasPositionChanged = canvasPositionSignal:Connect(function()
if self.props.onCanvasPositionChanged then
pcall(self.props.onCanvasPositionChanged, rbx.CanvasPosition)
end
if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then
self:setState({ CanvasPosition = rbx.CanvasPosition }) self:setState({ CanvasPosition = rbx.CanvasPosition })
self:refresh() self:refresh()
@@ -140,9 +134,8 @@ function VirtualScroller:render()
BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor, BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor,
BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor, BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor,
CanvasSize = self.totalCanvas:map(function(s) CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(props.canvasWidth or 0, s) return UDim2.fromOffset(0, s)
end), end),
CanvasPosition = self.props.canvasPosition,
ScrollBarThickness = 9, ScrollBarThickness = 9,
ScrollBarImageColor3 = theme.ScrollBarColor, ScrollBarImageColor3 = theme.ScrollBarColor,
ScrollBarImageTransparency = props.transparency:map(function(value) ScrollBarImageTransparency = props.transparency:map(function(value)
@@ -153,7 +146,7 @@ function VirtualScroller:render()
BottomImage = Assets.Images.ScrollBar.Bottom, BottomImage = Assets.Images.ScrollBar.Bottom,
ElasticBehavior = Enum.ElasticBehavior.Always, ElasticBehavior = Enum.ElasticBehavior.Always,
ScrollingDirection = Enum.ScrollingDirection.XY, ScrollingDirection = Enum.ScrollingDirection.Y,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar, VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
[Roact.Ref] = self.scrollFrameRef, [Roact.Ref] = self.scrollFrameRef,
}, { }, {

View File

@@ -4,6 +4,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local PatchTree = require(Plugin.PatchTree)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
@@ -22,13 +24,36 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({ self:setState({
patchTree = nil,
showingStringDiff = false, showingStringDiff = false,
currentString = "", oldString = "",
incomingString = "", newString = "",
showingTableDiff = false, showingTableDiff = false,
oldTable = {}, oldTable = {},
newTable = {}, newTable = {},
}) })
if self.props.confirmData and self.props.confirmData.patch and self.props.confirmData.instanceMap then
self:buildPatchTree()
end
end
function ConfirmingPage:didUpdate(prevProps)
if prevProps.confirmData ~= self.props.confirmData then
self:buildPatchTree()
end
end
function ConfirmingPage:buildPatchTree()
Timer.start("ConfirmingPage:buildPatchTree")
self:setState({
patchTree = PatchTree.build(
self.props.confirmData.patch,
self.props.confirmData.instanceMap,
{ "Property", "Current", "Incoming" }
),
})
Timer.stop()
end end
function ConfirmingPage:render() function ConfirmingPage:render()
@@ -54,13 +79,13 @@ function ConfirmingPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
patchTree = self.props.patchTree, patchTree = self.state.patchTree,
showStringDiff = function(currentString: string, incomingString: string) showStringDiff = function(oldString: string, newString: string)
self:setState({ self:setState({
showingStringDiff = true, showingStringDiff = true,
currentString = currentString, oldString = oldString,
incomingString = incomingString, newString = newString,
}) })
end, end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@@ -167,8 +192,8 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
currentString = self.state.currentString, oldString = self.state.oldString,
incomingString = self.state.incomingString, newString = self.state.newString,
}), }),
}), }),
}), }),

View File

@@ -307,8 +307,8 @@ function ConnectedPage:init()
renderChanges = false, renderChanges = false,
hoveringChangeInfo = false, hoveringChangeInfo = false,
showingStringDiff = false, showingStringDiff = false,
currentString = "", oldString = "",
incomingString = "", newString = "",
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
@@ -511,11 +511,11 @@ function ConnectedPage:render()
patchData = self.props.patchData, patchData = self.props.patchData,
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
serveSession = self.props.serveSession, serveSession = self.props.serveSession,
showStringDiff = function(currentString: string, incomingString: string) showStringDiff = function(oldString: string, newString: string)
self:setState({ self:setState({
showingStringDiff = true, showingStringDiff = true,
currentString = currentString, oldString = oldString,
incomingString = incomingString, newString = newString,
}) })
end, end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? }) showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@@ -566,8 +566,8 @@ function ConnectedPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
currentString = self.state.currentString, oldString = self.state.oldString,
incomingString = self.state.incomingString, newString = self.state.newString,
}), }),
}), }),
}), }),

View File

@@ -4,8 +4,6 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local Spinner = require(Plugin.App.Components.Spinner) local Spinner = require(Plugin.App.Components.Spinner)
local e = Roact.createElement local e = Roact.createElement
@@ -13,35 +11,11 @@ local e = Roact.createElement
local ConnectingPage = Roact.Component:extend("ConnectingPage") local ConnectingPage = Roact.Component:extend("ConnectingPage")
function ConnectingPage:render() function ConnectingPage:render()
return Theme.with(function(theme) return e(Spinner, {
return e("Frame", { position = UDim2.new(0.5, 0, 0.5, 0),
Size = UDim2.new(1, 0, 1, 0), anchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1, transparency = self.props.transparency,
}, { })
Spinner = e(Spinner, {
position = UDim2.new(0.5, 0, 0.5, 0),
anchorPoint = Vector2.new(0.5, 0.5),
transparency = self.props.transparency,
}),
Text = if type(self.props.text) == "string" and #self.props.text > 0
then e("TextLabel", {
Text = self.props.text,
Position = UDim2.new(0.5, 0, 0.5, 30),
Size = UDim2.new(1, -40, 0.5, -40),
AnchorPoint = Vector2.new(0.5, 0),
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Top,
RichText = true,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Medium,
TextColor3 = theme.SubTextColor,
TextTruncate = Enum.TextTruncate.AtEnd,
TextTransparency = self.props.transparency,
BackgroundTransparency = 1,
})
else nil,
})
end)
end end
return ConnectingPage return ConnectingPage

View File

@@ -44,7 +44,7 @@ end
local function blendAlpha(alphaValues) local function blendAlpha(alphaValues)
local alpha = 0 local alpha = 0
for _, value in alphaValues do for _, value in pairs(alphaValues) do
alpha = alpha + (1 - alpha) * value alpha = alpha + (1 - alpha) * value
end end

View File

@@ -174,8 +174,6 @@ function App:init()
end end
function App:willUnmount() function App:willUnmount()
self:endSession()
self.waypointConnection:Disconnect() self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy() self.confirmationBindable:Destroy()
@@ -597,12 +595,6 @@ function App:startSession()
twoWaySync = Settings:get("twoWaySync"), twoWaySync = Settings:get("twoWaySync"),
}) })
serveSession:setUpdateLoadingTextCallback(function(text: string)
self:setState({
connectingText = text,
})
end)
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap) self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch -- Build new tree for patch
self:setState({ self:setState({
@@ -610,32 +602,46 @@ function App:startSession()
}) })
end) end)
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch) self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
local now = DateTime.now().UnixTimestamp -- Update tree with unapplied metadata
self:setState(function(prevState) self:setState(function(prevState)
local oldPatchData = prevState.patchData
local newPatchData = {
patch = patch,
unapplied = unappliedPatch,
timestamp = now,
}
if PatchSet.isEmpty(patch) then
-- Keep existing patch info, but use new timestamp
newPatchData.patch = oldPatchData.patch
newPatchData.unapplied = oldPatchData.unapplied
elseif now - oldPatchData.timestamp < 2 then
-- Patches that apply in the same second are combined for human clarity
newPatchData.patch = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.patch, patch)
newPatchData.unapplied = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.unapplied, unappliedPatch)
end
return { return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch), patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
patchData = newPatchData,
} }
end) end)
end) end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = DateTime.now().UnixTimestamp
local old = self.state.patchData
if PatchSet.isEmpty(patch) then
-- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return
end
if now - old.timestamp < 2 then
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then if status == ServeSession.Status.Connecting then
if self.dismissSyncReminder then if self.dismissSyncReminder then
@@ -767,13 +773,11 @@ function App:startSession()
end end
end end
self:setState({
connectingText = "Computing diff view...",
})
self:setState({ self:setState({
appStatus = AppStatus.Confirming, appStatus = AppStatus.Confirming,
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
confirmData = { confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo, serverInfo = serverInfo,
}, },
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
@@ -884,7 +888,6 @@ function App:render()
ConfirmingPage = createPageElement(AppStatus.Confirming, { ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData, confirmData = self.state.confirmData,
patchTree = self.state.patchTree,
createPopup = not self.state.guiEnabled, createPopup = not self.state.guiEnabled,
onAbort = function() onAbort = function()
@@ -898,9 +901,7 @@ function App:render()
end, end,
}), }),
Connecting = createPageElement(AppStatus.Connecting, { Connecting = createPageElement(AppStatus.Connecting),
text = self.state.connectingText,
}),
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,

View File

@@ -74,7 +74,7 @@ local Assets = {
local function guardForTypos(name, map) local function guardForTypos(name, map)
strict(name, map) strict(name, map)
for key, child in map do for key, child in pairs(map) do
if type(child) == "table" then if type(child) == "table" then
guardForTypos(("%s.%s"):format(name, key), child) guardForTypos(("%s.%s"):format(name, key), child)
end end

View File

@@ -15,7 +15,7 @@ local encodePatchUpdate = require(script.Parent.encodePatchUpdate)
return function(instanceMap, propertyChanges) return function(instanceMap, propertyChanges)
local patch = PatchSet.newEmpty() local patch = PatchSet.newEmpty()
for instance, properties in propertyChanges do for instance, properties in pairs(propertyChanges) do
local instanceId = instanceMap.fromInstances[instance] local instanceId = instanceMap.fromInstances[instance]
if instanceId == nil then if instanceId == nil then

View File

@@ -10,7 +10,7 @@ return function(instance, instanceId, properties)
changedProperties = {}, changedProperties = {},
} }
for propertyName in properties do for propertyName in pairs(properties) do
if propertyName == "Name" then if propertyName == "Name" then
update.changedName = instance.Name update.changedName = instance.Name
else else

View File

@@ -21,7 +21,7 @@ return strict("Config", {
codename = "Epiphany", codename = "Epiphany",
version = realVersion, version = realVersion,
expectedServerVersionString = ("%d.%d or newer"):format(realVersion[1], realVersion[2]), expectedServerVersionString = ("%d.%d or newer"):format(realVersion[1], realVersion[2]),
protocolVersion = 5, protocolVersion = 4,
defaultHost = "localhost", defaultHost = "localhost",
defaultPort = "34872", defaultPort = "34872",
}) })

View File

@@ -14,7 +14,7 @@ local function merge(...)
local source = select(i, ...) local source = select(i, ...)
if source ~= nil then if source ~= nil then
for key, value in source do for key, value in pairs(source) do
if value == None then if value == None then
output[key] = nil output[key] = nil
else else

View File

@@ -63,7 +63,7 @@ function InstanceMap:__fmtDebug(output)
-- Collect all of the entries in the InstanceMap and sort them by their -- Collect all of the entries in the InstanceMap and sort them by their
-- label, which helps make our output deterministic. -- label, which helps make our output deterministic.
local entries = {} local entries = {}
for id, instance in self.fromIds do for id, instance in pairs(self.fromIds) do
local label = string.format("%s (%s)", instance:GetFullName(), instance.ClassName) local label = string.format("%s (%s)", instance:GetFullName(), instance.ClassName)
table.insert(entries, { id, label }) table.insert(entries, { id, label })
@@ -73,7 +73,7 @@ function InstanceMap:__fmtDebug(output)
return a[2] < b[2] return a[2] < b[2]
end) end)
for _, entry in entries do for _, entry in ipairs(entries) do
output:writeLine("{}: {}", entry[1], entry[2]) output:writeLine("{}: {}", entry[1], entry[2])
end end
@@ -227,7 +227,7 @@ function InstanceMap:__disconnectSignals(instance)
-- around the extra table. ValueBase objects force us to use multiple -- around the extra table. ValueBase objects force us to use multiple
-- signals to emulate the Instance.Changed event, however. -- signals to emulate the Instance.Changed event, however.
if typeof(signals) == "table" then if typeof(signals) == "table" then
for _, signal in signals do for _, signal in ipairs(signals) do
signal:Disconnect() signal:Disconnect()
end end
else else

View File

@@ -16,14 +16,6 @@ local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue) local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty) local getProperty = require(Plugin.Reconciler.getProperty)
local function yieldIfNeeded(clock)
if os.clock() - clock > 1 / 20 then
task.wait()
return os.clock()
end
return clock
end
local function alphabeticalNext(t, state) local function alphabeticalNext(t, state)
-- Equivalent of the next function, but returns the keys in the alphabetic -- Equivalent of the next function, but returns the keys in the alphabetic
-- order of node names. We use a temporary ordered key table that is stored in the -- order of node names. We use a temporary ordered key table that is stored in the
@@ -140,6 +132,7 @@ end
-- props must contain id, and cannot contain children or parentId -- props must contain id, and cannot contain children or parentId
-- other than those three, it can hold anything -- other than those three, it can hold anything
function Tree:addNode(parent, props) function Tree:addNode(parent, props)
Timer.start("Tree:addNode")
assert(props.id, "props must contain id") assert(props.id, "props must contain id")
parent = parent or "ROOT" parent = parent or "ROOT"
@@ -150,6 +143,7 @@ function Tree:addNode(parent, props)
for k, v in props do for k, v in props do
node[k] = v node[k] = v
end end
Timer.stop()
return node return node
end end
@@ -160,25 +154,25 @@ function Tree:addNode(parent, props)
local parentNode = self:getNode(parent) local parentNode = self:getNode(parent)
if not parentNode then if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props) Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
Timer.stop()
return return
end end
parentNode.children[node.id] = node parentNode.children[node.id] = node
self.idToNode[node.id] = node self.idToNode[node.id] = node
Timer.stop()
return node return node
end end
-- Given a list of ancestor ids in descending order, builds the nodes for them -- Given a list of ancestor ids in descending order, builds the nodes for them
-- using the patch and instanceMap info -- using the patch and instanceMap info
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap) function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
local clock = os.clock() Timer.start("Tree:buildAncestryNodes")
-- Build nodes for ancestry by going up the tree -- Build nodes for ancestry by going up the tree
previousId = previousId or "ROOT" previousId = previousId or "ROOT"
for _, ancestorId in ancestryIds do for _, ancestorId in ancestryIds do
clock = yieldIfNeeded(clock)
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId] local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
if not value then if not value then
Log.warn("Failed to find ancestor object for " .. ancestorId) Log.warn("Failed to find ancestor object for " .. ancestorId)
@@ -192,6 +186,8 @@ function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, p
}) })
previousId = ancestorId previousId = ancestorId
end end
Timer.stop()
end end
local PatchTree = {} local PatchTree = {}
@@ -200,16 +196,12 @@ local PatchTree = {}
-- uses changeListHeaders in node.changeList -- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders) function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("PatchTree.build") Timer.start("PatchTree.build")
local clock = os.clock()
local tree = Tree.new() local tree = Tree.new()
local knownAncestors = {} local knownAncestors = {}
Timer.start("patch.updated") Timer.start("patch.updated")
for _, change in patch.updated do for _, change in patch.updated do
clock = yieldIfNeeded(clock)
local instance = instanceMap.fromIds[change.id] local instance = instanceMap.fromIds[change.id]
if not instance then if not instance then
continue continue
@@ -289,8 +281,6 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("patch.removed") Timer.start("patch.removed")
for _, idOrInstance in patch.removed do for _, idOrInstance in patch.removed do
clock = yieldIfNeeded(clock)
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
if not instance then if not instance then
-- If we're viewing a past patch, the instance is already removed -- If we're viewing a past patch, the instance is already removed
@@ -335,8 +325,6 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("patch.added") Timer.start("patch.added")
for id, change in patch.added do for id, change in patch.added do
clock = yieldIfNeeded(clock)
-- Gather ancestors from existing DOM or future additions -- Gather ancestors from existing DOM or future additions
local ancestryIds = {} local ancestryIds = {}
local parentId = change.Parent local parentId = change.Parent

View File

@@ -38,13 +38,13 @@ local function trueEquals(a, b): boolean
-- For tables, try recursive deep equality -- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then if typeA == "table" and typeB == "table" then
local checkedKeys = {} local checkedKeys = {}
for key, value in a do for key, value in pairs(a) do
checkedKeys[key] = true checkedKeys[key] = true
if not trueEquals(value, b[key]) then if not trueEquals(value, b[key]) then
return false return false
end end
end end
for key, value in b do for key, value in pairs(b) do
if checkedKeys[key] then if checkedKeys[key] then
continue continue
end end

View File

@@ -14,7 +14,7 @@ return function()
local function size(dict) local function size(dict)
local len = 0 local len = 0
for _ in dict do for _ in pairs(dict) do
len = len + 1 len = len + 1
end end

View File

@@ -26,7 +26,7 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
for _, childId in ipairs(virtualInstance.Children) do for _, childId in ipairs(virtualInstance.Children) do
local virtualChild = virtualInstances[childId] local virtualChild = virtualInstances[childId]
for childIndex, childInstance in existingChildren do for childIndex, childInstance in ipairs(existingChildren) do
if not isExistingChildVisited[childIndex] then if not isExistingChildVisited[childIndex] then
-- We guard accessing Name and ClassName in order to avoid -- We guard accessing Name and ClassName in order to avoid
-- tripping over children of DataModel that Rojo won't have -- tripping over children of DataModel that Rojo won't have

View File

@@ -126,7 +126,7 @@ function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
}) })
end end
for _, entry in deferredRefs do for _, entry in ipairs(deferredRefs) do
local _, refId = next(entry.virtualValue) local _, refId = next(entry.virtualValue)
if refId == nil then if refId == nil then

View File

@@ -12,7 +12,7 @@ return function()
local function size(dict) local function size(dict)
local len = 0 local len = 0
for _ in dict do for _ in pairs(dict) do
len = len + 1 len = len + 1
end end

View File

@@ -48,12 +48,6 @@ local function debugPatch(object)
end) end)
end end
local function attemptReparent(instance, parent)
return pcall(function()
instance.Parent = parent
end)
end
local ServeSession = {} local ServeSession = {}
ServeSession.__index = ServeSession ServeSession.__index = ServeSession
@@ -107,7 +101,6 @@ function ServeSession.new(options)
__connections = connections, __connections = connections,
__precommitCallbacks = {}, __precommitCallbacks = {},
__postcommitCallbacks = {}, __postcommitCallbacks = {},
__updateLoadingText = function() end,
} }
setmetatable(self, ServeSession) setmetatable(self, ServeSession)
@@ -138,14 +131,6 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback self.__userConfirmCallback = callback
end end
function ServeSession:setUpdateLoadingTextCallback(callback)
self.__updateLoadingText = callback
end
function ServeSession:setLoadingText(text: string)
self.__updateLoadingText(text)
end
--[=[ --[=[
Hooks a function to run before patch application. Hooks a function to run before patch application.
The provided function is called with the incoming patch and an InstanceMap The provided function is called with the incoming patch and an InstanceMap
@@ -190,31 +175,15 @@ end
function ServeSession:start() function ServeSession:start()
self:__setStatus(Status.Connecting) self:__setStatus(Status.Connecting)
self:setLoadingText("Connecting to server...")
self.__apiContext self.__apiContext
:connect() :connect()
:andThen(function(serverInfo) :andThen(function(serverInfo)
self:setLoadingText("Loading initial data from server...")
return self:__initialSync(serverInfo):andThen(function() return self:__initialSync(serverInfo):andThen(function()
self:setLoadingText("Starting sync loop...")
self:__setStatus(Status.Connected, serverInfo.projectName) self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo) self:__applyGameAndPlaceId(serverInfo)
return self.__apiContext:connectWebSocket({ return self:__mainSyncLoop()
["messages"] = function(messagesPacket)
if self.__status == Status.Disconnected then
return
end
Log.debug("Received {} messages from Rojo server", #messagesPacket.messages)
for _, message in messagesPacket.messages do
self:__applyPatch(message)
end
self.__apiContext:setMessageCursor(messagesPacket.messageCursor)
end,
})
end) end)
end) end)
:catch(function(err) :catch(function(err)
@@ -322,52 +291,18 @@ function ServeSession:__replaceInstances(idList)
for id, replacement in replacements do for id, replacement in replacements do
local oldInstance = self.__instanceMap.fromIds[id] local oldInstance = self.__instanceMap.fromIds[id]
if not oldInstance then
-- TODO: Why would this happen?
Log.warn("Instance {} not found in InstanceMap during sync replacement", id)
continue
end
self.__instanceMap:insert(id, replacement) self.__instanceMap:insert(id, replacement)
Log.trace("Swapping Instance {} out via api/models/ endpoint", id) Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
local oldParent = oldInstance.Parent local oldParent = oldInstance.Parent
for _, child in oldInstance:GetChildren() do for _, child in oldInstance:GetChildren() do
-- Some children cannot be reparented, such as a TouchTransmitter child.Parent = replacement
local reparentSuccess, reparentError = attemptReparent(child, replacement)
if not reparentSuccess then
Log.warn(
"Could not reparent child {} of instance {} during sync replacement: {}",
child.Name,
oldInstance.Name,
reparentError
)
end
end end
replacement.Parent = oldParent
-- ChangeHistoryService doesn't like it if an Instance has been -- ChangeHistoryService doesn't like it if an Instance has been
-- Destroyed. So, we have to accept the potential memory hit and -- Destroyed. So, we have to accept the potential memory hit and
-- just set the parent to `nil`. -- just set the parent to `nil`.
local deleteSuccess, deleteError = attemptReparent(oldInstance, nil) oldInstance.Parent = nil
local replaceSuccess, replaceError = attemptReparent(replacement, oldParent)
if not (deleteSuccess and replaceSuccess) then
Log.warn(
"Could not swap instances {} and {} during sync replacement: {}",
oldInstance.Name,
replacement.Name,
(deleteError or "") .. "\n" .. (replaceError or "")
)
-- We need to revert the failed swap to avoid losing the old instance and children.
for _, child in replacement:GetChildren() do
attemptReparent(child, oldInstance)
end
attemptReparent(oldInstance, oldParent)
-- Our replacement should never have existed in the first place, so we can just destroy it.
replacement:Destroy()
continue
end
if selectionMap[oldInstance] then if selectionMap[oldInstance] then
-- This is a bit funky, but it saves the order of Selection -- This is a bit funky, but it saves the order of Selection
@@ -414,11 +349,18 @@ function ServeSession:__applyPatch(patch)
error(unappliedPatch) error(unappliedPatch)
end end
if Settings:get("enableSyncFallback") and not PatchSet.isEmpty(unappliedPatch) then if PatchSet.isEmpty(unappliedPatch) then
-- Some changes did not apply, let's try replacing them instead if historyRecording then
local addedIdList = PatchSet.addedIdList(unappliedPatch) ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
local updatedIdList = PatchSet.updatedIdList(unappliedPatch) end
return
end
local addedIdList = PatchSet.addedIdList(unappliedPatch)
local updatedIdList = PatchSet.updatedIdList(unappliedPatch)
local actualUnappliedPatches = PatchSet.newEmpty()
if Settings:get("enableSyncFallback") then
Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)") Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)") Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)")
local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList) local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList)
@@ -429,18 +371,20 @@ function ServeSession:__applyPatch(patch)
local updateSuccess, unappliedUpdateRefs = self:__replaceInstances(updatedIdList) local updateSuccess, unappliedUpdateRefs = self:__replaceInstances(updatedIdList)
Timer.stop() Timer.stop()
-- Update the unapplied patch to reflect which Instances were replaced successfully
if addSuccess then if addSuccess then
table.clear(unappliedPatch.added) table.clear(unappliedPatch.added)
PatchSet.assign(unappliedPatch, unappliedAddedRefs) PatchSet.assign(actualUnappliedPatches, unappliedAddedRefs)
end end
if updateSuccess then if updateSuccess then
table.clear(unappliedPatch.updated) table.clear(unappliedPatch.updated)
PatchSet.assign(unappliedPatch, unappliedUpdateRefs) PatchSet.assign(actualUnappliedPatches, unappliedUpdateRefs)
end end
else
Log.debug("Skipping ServeSession:__replaceInstances because of setting")
end end
PatchSet.assign(actualUnappliedPatches, unappliedPatch)
if not PatchSet.isEmpty(unappliedPatch) then if not PatchSet.isEmpty(actualUnappliedPatches) then
Log.debug( Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}", "Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch) PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
@@ -452,7 +396,7 @@ function ServeSession:__applyPatch(patch)
-- guaranteed to be called after the commit -- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do for _, callback in self.__postcommitCallbacks do
task.spawn(function() task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch) local success, err = pcall(callback, patch, self.__instanceMap, actualUnappliedPatches)
if not success then if not success then
Log.warn("Postcommit hook errored: {}", err) Log.warn("Postcommit hook errored: {}", err)
end end
@@ -474,13 +418,11 @@ function ServeSession:__initialSync(serverInfo)
-- For any instances that line up with the Rojo server's view, start -- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler. -- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs") Log.trace("Matching existing Roblox instances to Rojo IDs")
self:setLoadingText("Hydrating instance map...")
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us -- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like. -- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...") Log.trace("Computing changes that plugin needs to make to catch up to server...")
self:setLoadingText("Finding differences between server and Studio...")
local success, catchUpPatch = local success, catchUpPatch =
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game)
@@ -549,6 +491,40 @@ function ServeSession:__initialSync(serverInfo)
end) end)
end end
function ServeSession:__mainSyncLoop()
return Promise.new(function(resolve, reject)
while self.__status == Status.Connected do
local success, result = self.__apiContext
:retrieveMessages()
:andThen(function(messages)
if self.__status == Status.Disconnected then
-- In the time it took to retrieve messages, we disconnected
-- so we just resolve immediately without patching anything
return
end
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in messages do
self:__applyPatch(message)
end
end)
:await()
if self.__status == Status.Disconnected then
-- If we are no longer connected after applying, we stop silently
-- without checking for errors as they are no longer relevant
break
elseif success == false then
reject(result)
end
end
-- We are no longer connected, so we resolve the promise
resolve()
end)
end
function ServeSession:__stopInternal(err) function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err) self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect() self.__apiContext:disconnect()

View File

@@ -49,21 +49,12 @@ local ApiReadResponse = t.interface({
instances = t.map(RbxId, ApiInstance), instances = t.map(RbxId, ApiInstance),
}) })
local SocketPacketType = t.union(t.literal("messages")) local ApiSubscribeResponse = t.interface({
sessionId = t.string,
local MessagesPacket = t.interface({
messageCursor = t.number, messageCursor = t.number,
messages = t.array(ApiSubscribeMessage), messages = t.array(ApiSubscribeMessage),
}) })
local SocketPacketBody = t.union(MessagesPacket)
local ApiSocketPacket = t.interface({
sessionId = t.string,
packetType = SocketPacketType,
body = SocketPacketBody,
})
local ApiSerializeResponse = t.interface({ local ApiSerializeResponse = t.interface({
sessionId = t.string, sessionId = t.string,
modelContents = t.buffer, modelContents = t.buffer,
@@ -94,7 +85,7 @@ return strict("Types", {
ApiInfoResponse = ApiInfoResponse, ApiInfoResponse = ApiInfoResponse,
ApiReadResponse = ApiReadResponse, ApiReadResponse = ApiReadResponse,
ApiSocketPacket = ApiSocketPacket, ApiSubscribeResponse = ApiSubscribeResponse,
ApiError = ApiError, ApiError = ApiError,
ApiInstance = ApiInstance, ApiInstance = ApiInstance,

View File

@@ -9,7 +9,7 @@ local gatherAssetUrlsRecursive
function gatherAssetUrlsRecursive(currentTable, currentUrls) function gatherAssetUrlsRecursive(currentTable, currentUrls)
currentUrls = currentUrls or {} currentUrls = currentUrls or {}
for _, value in currentTable do for _, value in pairs(currentTable) do
if typeof(value) == "string" then if typeof(value) == "string" then
table.insert(currentUrls, value) table.insert(currentUrls, value)
elseif typeof(value) == "table" then elseif typeof(value) == "table" then

View File

@@ -2,5 +2,5 @@ return function(TestEZ)
local Rojo = script.Parent.Parent local Rojo = script.Parent.Parent
local Packages = Rojo.Packages local Packages = Rojo.Packages
TestEZ.TestBootstrap:run({ Rojo.Plugin, Packages.Http, Packages.Log, Packages.RbxDom }) return TestEZ.TestBootstrap:run({ Rojo.Plugin, Packages.Http, Packages.Log, Packages.RbxDom })
end end

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: add_folder projectName: add_folder
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,21 +1,19 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added:
- added: id-3:
id-3: Children: []
Children: [] ClassName: Folder
ClassName: Folder Id: id-3
Id: id-3 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: my-new-folder
Name: my-new-folder Parent: id-2
Parent: id-2 Properties: {}
Properties: {} removed: []
removed: [] updated: []
updated: []
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -7,7 +7,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: optional projectName: optional
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: edit_init projectName: edit_init
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,19 +1,19 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added: {}
- added: {} removed: []
removed: [] updated:
updated: - changedClassName: ~
- changedClassName: ~ changedMetadata: ~
changedMetadata: ~ changedName: ~
changedName: ~ changedProperties:
changedProperties: Source:
Source: String: "-- Edited contents"
String: "-- Edited contents" id: id-2
id: id-2
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: empty projectName: empty
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: empty_folder projectName: empty_folder
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,23 +1,21 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added:
- added: id-3:
id-3: Children: []
Children: [] ClassName: Model
ClassName: Model Id: id-3
Id: id-3 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: test
Name: test Parent: id-2
Parent: id-2 Properties:
Properties: NeedsPivotMigration:
NeedsPivotMigration: Bool: false
Bool: false removed: []
removed: [] updated: []
updated: []
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: forced_parent projectName: forced_parent
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: meshpart projectName: meshpart
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,29 +1,27 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added:
- added: id-6:
id-6: Children: []
Children: [] ClassName: Actor
ClassName: Actor Id: id-6
Id: id-6 Metadata:
Metadata: ignoreUnknownInstances: true
ignoreUnknownInstances: true Name: Actor
Name: Actor Parent: id-3
Parent: id-3 Properties:
Properties: NeedsPivotMigration:
NeedsPivotMigration: Bool: false
Bool: false removed: []
removed: [] updated:
updated: - changedClassName: ~
- changedClassName: ~ changedMetadata:
changedMetadata: ignoreUnknownInstances: true
ignoreUnknownInstances: true changedName: ~
changedName: ~ changedProperties: {}
changedProperties: {} id: id-3
id: id-3
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: move_folder_of_stuff projectName: move_folder_of_stuff
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,141 +1,141 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added:
- added: id-10:
id-10: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-10
Id: id-10 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "6"
Name: "6" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #6"
String: "File #6" id-11:
id-11: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-11
Id: id-11 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "7"
Name: "7" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #7"
String: "File #7" id-12:
id-12: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-12
Id: id-12 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "8"
Name: "8" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #8"
String: "File #8" id-13:
id-13: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-13
Id: id-13 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "9"
Name: "9" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #9"
String: "File #9" id-3:
id-3: Children:
Children: - id-4
- id-4 - id-5
- id-5 - id-6
- id-6 - id-7
- id-7 - id-8
- id-8 - id-9
- id-9 - id-10
- id-10 - id-11
- id-11 - id-12
- id-12 - id-13
- id-13 ClassName: Folder
ClassName: Folder Id: id-3
Id: id-3 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: new-stuff
Name: new-stuff Parent: id-2
Parent: id-2 Properties: {}
Properties: {} id-4:
id-4: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-4
Id: id-4 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "0"
Name: "0" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #0"
String: "File #0" id-5:
id-5: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-5
Id: id-5 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "1"
Name: "1" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #1"
String: "File #1" id-6:
id-6: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-6
Id: id-6 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "2"
Name: "2" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #2"
String: "File #2" id-7:
id-7: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-7
Id: id-7 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "3"
Name: "3" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #3"
String: "File #3" id-8:
id-8: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-8
Id: id-8 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "4"
Name: "4" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #4"
String: "File #4" id-9:
id-9: Children: []
Children: [] ClassName: StringValue
ClassName: StringValue Id: id-9
Id: id-9 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: "5"
Name: "5" Parent: id-3
Parent: id-3 Properties:
Properties: Value:
Value: String: "File #5"
String: "File #5" removed: []
removed: [] updated: []
updated: []
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: top-level projectName: top-level
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: no_name_project projectName: no_name_project
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: no_name_top_level_project projectName: no_name_top_level_project
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: pivot_migration projectName: pivot_migration
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: ref_properties projectName: ref_properties
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: ref_properties projectName: ref_properties
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,19 +1,17 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added: {}
- added: {} removed: []
removed: [] updated:
updated: - changedClassName: ~
- changedClassName: ~ changedMetadata: ~
changedMetadata: ~ changedName: ~
changedName: ~ changedProperties:
changedProperties: Scale:
Scale: Float32: 1
Float32: 1 id: id-8
id: id-8
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: ref_properties_remove projectName: ref_properties_remove
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,13 +1,12 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added: {}
- added: {} removed:
removed: - id-4
- id-4 updated: []
updated: []
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -1,49 +1,47 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added:
- added: id-11:
id-11: Children: []
Children: [] ClassName: Model
ClassName: Model Id: id-11
Id: id-11 Metadata:
Metadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false Name: ProjectPointer
Name: ProjectPointer Parent: id-7
Parent: id-7 Properties:
Properties: Attributes:
Attributes: Attributes:
Attributes: Rojo_Target_PrimaryPart:
Rojo_Target_PrimaryPart: String: project target
String: project target NeedsPivotMigration:
NeedsPivotMigration: Bool: false
Bool: false PrimaryPart:
PrimaryPart: Ref: id-9
Ref: id-9 removed: []
removed: [] updated:
updated: - changedClassName: ~
- changedClassName: ~ changedMetadata:
changedMetadata: ignoreUnknownInstances: false
ignoreUnknownInstances: false changedName: ~
changedName: ~ changedProperties:
changedProperties: Attributes:
Attributes: Attributes:
Attributes: Rojo_Id:
Rojo_Id: String: model target 2
String: model target 2 id: id-7
id: id-7 - changedClassName: ~
- changedClassName: ~ changedMetadata: ~
changedMetadata: ~ changedName: ~
changedName: ~ changedProperties:
changedProperties: Attributes:
Attributes: Attributes:
Attributes: Rojo_Target_PrimaryPart:
Rojo_Target_PrimaryPart: String: model target 2
String: model target 2 PrimaryPart: ~
PrimaryPart: ~ id: id-8
id: id-8
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: remove_file projectName: remove_file
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,13 +1,11 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added: {}
- added: {} removed:
removed: - id-3
- id-3 updated: []
updated: []
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: scripts projectName: scripts
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,19 +1,19 @@
--- ---
source: tests/tests/serve.rs source: tests/tests/serve.rs
expression: "socket_packet.intern_and_redact(&mut redactions, ())" expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
--- ---
body: messageCursor: 1
messageCursor: 1 messages:
messages: - added: {}
- added: {} removed: []
removed: [] updated:
updated: - changedClassName: ~
- changedClassName: ~ changedMetadata: ~
changedMetadata: ~ changedName: ~
changedName: ~ changedProperties:
changedProperties: Source:
Source: String: Updated foo!
String: Updated foo! id: id-4
id: id-4
packetType: messages
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: sync_rule_alone projectName: sync_rule_alone
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: sync_rule_complex projectName: sync_rule_complex
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: sync_rule_no_extension projectName: sync_rule_no_extension
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -6,7 +6,7 @@ expectedPlaceIds: ~
gameId: ~ gameId: ~
placeId: ~ placeId: ~
projectName: sync_rule_no_name_project projectName: sync_rule_no_name_project
protocolVersion: 5 protocolVersion: 4
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

@@ -1,6 +0,0 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing OnlyOneCopy/child_of_one.luau
Writing ReplicatedStorage/child_replicated_storage.luau

View File

@@ -1,7 +0,0 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/csv.csv
Writing src/csv_init/init.csv
Writing src/csv_init

Some files were not shown because too many files have changed in this diff Show More