Compare commits

...

37 Commits

Author SHA1 Message Date
110b9f0df3 feat: resolve duplicate sibling names with incrementing suffixes
Instead of bailing when children have duplicate filesystem names,
syncback now resolves collisions by appending incrementing suffixes
(e.g. Foo, Foo1, Foo2). This handles both init-renamed children and
any other name collisions. Meta stem derivation is now path-based
to correctly handle collision suffixes and dotted names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:30:46 +01:00
917d17a738 fix: simplify meta stem to instance name + slugify + init-prefix
Drop the strip_suffix(extension) approach for computing adjacent meta
file names. Instead, use the instance name directly (slugified if it
has invalid filesystem chars, prefixed with '_' if it's "init"). This
is the same logic as the original code plus init-prefix handling, and
correctly preserves dots in instance names like "Name.new".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:09:45 +01:00
14bbdaf560 fix: handle dotted names and .lua extension in meta path + name check
Two bugs:

1. Meta stem fallback used raw instance name (unslugged), so names with
   forbidden chars like '/' would create bogus directory components in
   the meta path. Fix: fallback now slugifies + init-prefixes, matching
   name_for_inst.

2. AdjacentMetadata name check used split('.').next() to extract the
   filesystem stem, breaking dotted names like "Name.new" (stem became
   "Name", mismatched the instance name, wrote an unnecessary name
   property). Fix: check the conditions that cause name_for_inst to
   diverge (invalid chars or init-prefix) directly instead of comparing
   path stems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:30:21 +01:00
5b1b5db06c fix: derive adjacent meta stem from snapshot path, not instance name
The previous fix used split('.').next() to get the meta stem from the
snapshot path, which only takes the first dot-segment. This broke names
containing dots (e.g. "Name.new" → "Name.new.luau" would produce
"Name.meta.json" instead of "Name.new.meta.json").

Strip the full middleware extension (e.g. ".server.luau", ".txt") from
the snapshot path filename instead. This correctly handles all cases:
  Name.new.luau      → Name.new  → Name.new.meta.json
  _Init.server.luau  → _Init     → _Init.meta.json
  Name.new.txt       → Name.new  → Name.new.meta.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 16:33:09 +01:00
33dd0f5ed1 fix: derive adjacent meta path from snapshot path, not instance name
When a script/txt/csv child is renamed by name_for_inst (e.g. "Init" →
"_Init.luau"), the adjacent meta file must follow the same name. All
three callers were using the Roblox instance name to construct the meta
path, producing "Init.meta.json" instead of "_Init.meta.json" — which
collides with the parent directory's "init.meta.json" on
case-insensitive file systems.

Fix by deriving the meta stem from the first dot-segment of the
snapshot path file name, which already holds the resolved name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 21:53:53 +01:00
95fe993de3 feat: auto-resolve init-name conflicts during syncback
When a child instance has a Roblox name that would produce a filesystem
name of "init" (case-insensitive), syncback now automatically prefixes
it with '_' (e.g. "Init" → "_Init.luau") instead of erroring. The
corresponding meta.json writes the original name via the `name` property
so Rojo can restore it on the next snapshot.

The sibling dedup check is updated to use actual on-disk names for
existing children and the resolved (init-prefixed) name for new ones,
so genuine collisions still error while false positives from the `name`
property are avoided.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:05:31 +01:00
ari
a6e9939d6c Merge branch 'master' into name-prop 2026-01-20 01:10:20 +01:00
5957368c04 Remove redundant code
Can't remember why I added this one
2026-01-20 01:08:59 +01:00
78916c8a63 Revert 2 semantic changes 2026-01-20 00:59:34 +01:00
791ccfcfd1 Remove addition of 'Actor' to json_model_classes 2026-01-20 00:55:03 +01:00
3500ebe02a Update CHANGELOG.md 2026-01-20 00:54:18 +01:00
Ivan Matthew
2a1102fc55 Implement VFS Path normalization for improved cross-platform tree synchronization (#1201) 2026-01-19 15:04:59 -08:00
Ken Loeffler
02b41133f8 Use post for ref patch and serialize (#1192) 2026-01-19 22:44:42 +00:00
0e1364945f Avoid clone in src/syncback/file_names.rs 2026-01-12 14:41:12 +01:00
ari
3a6aae65f7 Avoid clone in src/syncback/file_names.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:46 +01:00
ari
d13d229eef Avoid clone in src/snapshot_middleware/json_model.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:18 +01:00
ari
9a485d88ce Avoid clone in src/snapshot_middleware/lua.rs
Co-authored-by: krakow10 <krakow20@gmail.com>
2026-01-12 14:35:06 +01:00
020d72faef fix: improve middleware selection for actor and other container classes 2025-12-18 05:10:53 +01:00
60d150f4c6 feat: optimize name handling for leaf scripts with invalid names
Prefer slugified filenames + adjacent meta files for scripts without children instead of forcing directory creation
2025-12-18 04:43:47 +01:00
73dab330b5 test: remove oudated json_model_legacy_name test 2025-12-15 20:32:28 +01:00
790312a5b0 fix: lack of .model.json support 2025-12-15 20:26:25 +01:00
5c396322d9 fix: name prop not properly syncing 2025-12-15 19:08:18 +01:00
37e44e474a feat: support name property in meta and model jsons 2025-12-15 18:45:59 +01:00
Micah
d08780fc14 Ensure that pruned Instances aren't treated as existing in syncback (#1179)
Closes #1178.
2025-11-29 21:21:48 -08:00
Micah
b89cc7f398 Release memofs v0.3.1 (#1175) 2025-11-27 12:32:57 -08:00
Micah
42568b9709 Release Rojo v7.7.0-rc.1 (#1174) 2025-11-27 12:10:57 -08:00
boatbomber
87f58e0a55 Use WebSocket instead of Long Polling (#1142) 2025-11-26 19:57:01 -08:00
Micah
a61a1bef55 Roundtrip schemas in syncback (#1173) 2025-11-26 16:11:39 -08:00
Micah
a99e877b7c Actually skip .gitignore if --skip-git is passed to init (#1172) 2025-11-26 13:59:12 -08:00
Ken Loeffler
93e9c51204 Fix rojo plugin install by adding Vfs::exists (#1169) 2025-11-21 07:04:34 -08:00
Ken Loeffler
015b5bda14 Set crate and plugin versions to 7.7.0-prealpha (#1170) 2025-11-21 07:02:09 -08:00
Micah
2b47861a4f Properly support EnumItem variants in hashing and variant_eq (#1165) 2025-11-19 19:18:14 -08:00
Micah
9b5a07191b Implement Syncback to support converting Roblox files to a Rojo project (#937)
This is a very large commit.
Consider checking the linked PR for more information.
2025-11-19 09:21:33 -08:00
boatbomber
071b6e7e23 Improved string diff viewer (#994) 2025-11-18 20:26:44 -08:00
quaywinn
31ec216a95 Remove pairs() and ipairs() (#1150) 2025-11-18 18:49:52 -08:00
Micah
ea70d89291 Support .jsonc extension for all JSON files (#1159) 2025-11-18 18:47:43 -08:00
quaywinn
03410ced6d Use buffer for ClassIcon EditableImages (#1149) 2025-11-07 13:07:19 -08:00
360 changed files with 10528 additions and 2026 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@
# Macos file system junk
._*
.DS_STORE
# JetBrains IDEs
/.idea/

View File

@@ -30,6 +30,62 @@ Making a new release? Simply add the new header with the version and date undern
-->
## Unreleased
* Fixed a bug caused by having reference properties (such as `ObjectValue.Value`) that point to an Instance not included in syncback. ([#1179])
* Implemented support for the "name" property in meta/model JSON files. ([#1187])
* Fixed instance replacement fallback failing when too many instances needed to be replaced. ([#1192])
* Fixed a bug where MacOS paths weren't being handled correctly. ([#1201])
[#1179]: https://github.com/rojo-rbx/rojo/pull/1179
[#1187]: https://github.com/rojo-rbx/rojo/pull/1187
[#1192]: https://github.com/rojo-rbx/rojo/pull/1192
[#1201]: https://github.com/rojo-rbx/rojo/pull/1201
## [7.7.0-rc.1] (November 27th, 2025)
* Fixed a bug where passing `--skip-git` to `rojo init` would still create a file named `gitignore.txt` ([#1172])
* A new command `rojo syncback` has been added. It can be used as `rojo syncback [path to project] --input [path to file]`. ([#937])
This command takes a Roblox file and pulls Instances out of it and places them in the correct position in the provided project.
Syncback is primarily controlled by the project file. Any Instances who are either referenced in the project file or a descendant
of one that is will be placed in an appropriate location.
In addition, a new field has been added to project files, `syncbackRules` to control how it behaves:
```json
{
"syncbackRules": {
"ignoreTrees": [
"ServerStorage/ImportantSecrets",
],
"ignorePaths": [
"src/ServerStorage/Secrets/*"
],
"ignoreProperties": {
"BasePart": ["Color"]
},
"syncCurrentCamera": false,
"syncUnscriptable": true,
}
}
```
A brief explanation of each field:
- `ignoreTrees` is a list of paths in the **roblox file** that should be ignored
- `ignorePaths` is a list of paths in the **file system** that should be ignored
- `ignoreProperties` is a list of properties that won't be synced back
- `syncCurrentCamera` is a toggle for whether to sync back the Workspace's CurrentCamera. Defaults to `false`.
- `syncUnscriptable` is a toggle for whether to sync back properties that cannot be set by the Roblox Studio plugin. Defaults to `true`.
* Fixed bugs and improved performance & UX for the script diff viewer ([#994])
* Rebuilt the internal communication between the server and plugin to use [websockets](https://devforum.roblox.com/t/websockets-support-in-studio-is-now-available/4021932/1) instead of [long polling](https://en.wikipedia.org/wiki/Push_technology#Long_polling) ([#1142])
* Added support for `.jsonc` files for all JSON-related files (e.g. `.project.jsonc` and `.meta.jsonc`) to accompany JSONC support ([#1159])
[7.7.0-rc.1]: https://github.com/rojo-rbx/rojo/releases/tag/v7.7.0-rc.1
[#937]: https://github.com/rojo-rbx/rojo/pull/937
[#994]: https://github.com/rojo-rbx/rojo/pull/994
[#1142]: https://github.com/rojo-rbx/rojo/pull/1142
[#1159]: https://github.com/rojo-rbx/rojo/pull/1159
[#1172]: https://github.com/rojo-rbx/rojo/pull/1172
## [7.6.1] (November 6th, 2025)

1617
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rojo"
version = "7.6.1"
version = "7.7.0-rc.1"
rust-version = "1.88"
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
@@ -46,20 +46,22 @@ name = "build"
harness = false
[dependencies]
memofs = { version = "0.3.0", path = "crates/memofs" }
memofs = { version = "0.3.1", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously
# rbx_binary = { path = "../rbx-dom/rbx_binary" }
# rbx_binary = { path = "../rbx-dom/rbx_binary", features = [
# "unstable_text_format",
# ] }
# rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "2.0.0"
rbx_dom_weak = "4.0.0"
rbx_reflection = "6.0.0"
rbx_reflection_database = "2.0.1"
rbx_xml = "2.0.0"
rbx_binary = { version = "2.0.1", features = ["unstable_text_format"] }
rbx_dom_weak = "4.1.0"
rbx_reflection = "6.1.0"
rbx_reflection_database = "2.0.2"
rbx_xml = "2.0.1"
anyhow = "1.0.80"
backtrace = "0.3.69"
@@ -72,6 +74,7 @@ futures = "0.3.30"
globset = "0.4.14"
humantime = "2.1.0"
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
hyper-tungstenite = "0.11.0"
jod-thread = "0.1.2"
log = "0.4.21"
num_cpus = "1.16.0"
@@ -87,16 +90,21 @@ roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
strum = { version = "0.27", features = ["derive"] }
toml = "0.5.11"
termcolor = "1.4.1"
thiserror = "1.0.57"
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15"
yaml-rust2 = "0.10.3"
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]
winreg = "0.10.1"

View File

@@ -1,6 +1,14 @@
# memofs Changelog
## Unreleased Changes
* Added `Vfs::canonicalize`. [#1201]
## 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)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830]

View File

@@ -1,7 +1,7 @@
[package]
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.3.0"
version = "0.3.1"
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
@@ -19,3 +19,6 @@ crossbeam-channel = "0.5.12"
fs-err = "2.11.0"
notify = "4.0.17"
serde = { version = "1.0.197", features = ["derive"] }
[dev-dependencies]
tempfile = "3.10.1"

View File

@@ -157,6 +157,11 @@ 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> {
let inner = self.inner.lock().unwrap();
@@ -176,6 +181,21 @@ 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<()> {
let mut inner = self.inner.lock().unwrap();
@@ -212,6 +232,33 @@ impl VfsBackend for InMemoryFs {
}
}
// TODO: We rely on Rojo to prepend cwd to any relative path before storing paths
// in MemoFS. The current implementation will error if no prepended absolute path
// is found. It really only normalizes paths within the provided path's context.
// Example: "/Users/username/project/../other/file.txt" ->
// "/Users/username/other/file.txt"
// Erroneous example: "/Users/../../other/file.txt" -> "/other/file.txt"
// This is not very robust. We should implement proper path normalization here or otherwise
// warn if we are missing context and can not fully canonicalize the path correctly.
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
normalized.pop();
}
std::path::Component::CurDir => {}
_ => normalized.push(component),
}
}
let inner = self.inner.lock().unwrap();
match inner.entries.get(&normalized) {
Some(_) => Ok(normalized),
None => not_found(&normalized),
}
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
let inner = self.inner.lock().unwrap();

View File

@@ -70,10 +70,14 @@ impl<T> IoResultExt<T> for io::Result<T> {
pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
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 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 remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf>;
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
fn watch(&mut self, path: &Path) -> io::Result<()>;
@@ -173,6 +177,11 @@ impl VfsInner {
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<()> {
let path = path.as_ref();
let contents = contents.as_ref();
@@ -190,6 +199,16 @@ impl VfsInner {
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<()> {
let path = path.as_ref();
let _ = self.backend.unwatch(path);
@@ -207,6 +226,11 @@ impl VfsInner {
self.backend.metadata(path)
}
fn canonicalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.backend.canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.backend.event_receiver()
}
@@ -326,6 +350,42 @@ impl Vfs {
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.
///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -359,6 +419,19 @@ impl Vfs {
self.inner.lock().unwrap().metadata(path)
}
/// Normalize a path via the underlying backend.
///
/// Roughly equivalent to [`std::fs::canonicalize`][std::fs::canonicalize]. Relative paths are
/// resolved against the backend's current working directory (if applicable) and errors are
/// surfaced directly from the backend.
///
/// [std::fs::canonicalize]: https://doc.rust-lang.org/stable/std/fs/fn.canonicalize.html
#[inline]
pub fn canonicalize<P: AsRef<Path>>(&self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.lock().unwrap().canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`.
#[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -428,6 +501,31 @@ impl VfsLock<'_> {
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.
///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -461,6 +559,13 @@ impl VfsLock<'_> {
self.inner.metadata(path)
}
/// Normalize a path via the underlying backend.
#[inline]
pub fn normalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
let path = path.as_ref();
self.inner.canonicalize(path)
}
/// Retrieve a handle to the event receiver for this `Vfs`.
#[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -476,7 +581,9 @@ impl VfsLock<'_> {
#[cfg(test)]
mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot};
use crate::{InMemoryFs, StdBackend, Vfs, VfsSnapshot};
use std::io;
use std::path::PathBuf;
/// https://github.com/rojo-rbx/rojo/issues/899
#[test]
@@ -492,4 +599,62 @@ mod test {
"bar\nfoo\n\n"
);
}
/// https://github.com/rojo-rbx/rojo/issues/1200
#[test]
fn canonicalize_in_memory_success() {
let mut imfs = InMemoryFs::new();
let contents = "Lorem ipsum dolor sit amet.".to_string();
imfs.load_snapshot("/test/file.txt", VfsSnapshot::file(contents.to_string()))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.canonicalize("/test/nested/../file.txt").unwrap(),
PathBuf::from("/test/file.txt")
);
assert_eq!(
vfs.read_to_string(vfs.canonicalize("/test/nested/../file.txt").unwrap())
.unwrap()
.to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_in_memory_missing_errors() {
let imfs = InMemoryFs::new();
let vfs = Vfs::new(imfs);
let err = vfs.canonicalize("test").unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
#[test]
fn canonicalize_std_backend_success() {
let contents = "Lorem ipsum dolor sit amet.".to_string();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("file.txt");
fs_err::write(&file_path, contents.to_string()).unwrap();
let vfs = Vfs::new(StdBackend::new());
let canonicalized = vfs.canonicalize(&file_path).unwrap();
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
assert_eq!(
vfs.read_to_string(&canonicalized).unwrap().to_string(),
contents.to_string()
);
}
#[test]
fn canonicalize_std_backend_missing_errors() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test");
let vfs = Vfs::new(StdBackend::new());
let err = vfs.canonicalize(&file_path).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::NotFound);
}
}

View File

@@ -1,5 +1,5 @@
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
@@ -22,10 +22,22 @@ impl VfsBackend for NoopBackend {
Err(io::Error::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> {
Err(io::Error::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<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
@@ -38,6 +50,10 @@ impl VfsBackend for NoopBackend {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn canonicalize(&mut self, _path: &Path) -> io::Result<PathBuf> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
crossbeam_channel::never()
}

View File

@@ -63,6 +63,10 @@ impl VfsBackend for StdBackend {
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> {
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?;
@@ -78,6 +82,14 @@ 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<()> {
fs_err::remove_file(path)
}
@@ -94,6 +106,10 @@ impl VfsBackend for StdBackend {
})
}
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
fs_err::canonicalize(path)
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.watcher_receiver.clone()
}

View File

@@ -1 +1 @@
7.6.1
7.7.0-rc.1

View File

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

View File

@@ -1,139 +1,10 @@
-- 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
local EncodingService = game:GetService("EncodingService")
return {
decode = decodeBase64,
encode = encodeBase64,
decode = function(input: string)
return buffer.tostring(EncodingService:Base64Decode(buffer.fromstring(input)))
end,
encode = function(input: string)
return buffer.tostring(EncodingService:Base64Encode(buffer.fromstring(input)))
end,
}

View File

@@ -208,4 +208,30 @@ return {
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

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
--!strict
--[[
Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch
@@ -67,8 +68,187 @@ function StringDiff.findDiffs(text1: string, text2: string): Diffs
end
-- Cleanup the diff
diffs = StringDiff._cleanupSemantic(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
end
@@ -124,51 +304,164 @@ function StringDiff._sharedSuffix(text1: string, text2: string): number
return pointerMid
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
function StringDiff._commonOverlap(text1: string, text2: string): number
-- Determine if the suffix of one string is the prefix of another.
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 } }
-- Cache the text lengths to prevent multiple calls.
local text1_length = #text1
local text2_length = #text2
-- Eliminate the null case.
if text1_length == 0 or text2_length == 0 then
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
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
-- Start by looking for a single character match
-- and increase length until no match is found.
-- Performance analysis: https://neil.fraser.name/news/2010/11/04/
local best = 0
local length = 1
while true do
local pattern = string.sub(text1, text_length - length + 1)
local found = string.find(text2, pattern, 1, true)
if found == nil then
return best
end
return diffs
length = length + found - 1
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
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
-- Each port of this function behaves slightly differently due to
-- subtle differences in each language's definition of things like
-- 'whitespace'. Since this function's purpose is largely cosmetic,
-- the choice has been made to use each language's native features
-- rather than force total conformity.
local char1 = string.sub(one, -1)
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")
return StringDiff._bisect(text1, text2)
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
return 0
end
function StringDiff._cleanupSemanticLossless(diffs: Diffs)
-- 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
function StringDiff._bisect(text1: string, text2: string): Diffs

View File

@@ -5,15 +5,15 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local e = Roact.createElement
@@ -21,26 +21,29 @@ local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
function StringDiffVisualizer:init()
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.updateEvent = Instance.new("BindableEvent")
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
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
task.defer(function()
-- Defer to allow Highlighter to process the theme change first
-- Delay to allow Highlighter to process the theme change first
task.delay(1 / 20, function()
self:updateScriptBackground()
self:updateDiffs()
-- Rerender the virtual list elements
self.updateEvent:Fire()
end)
end)
self:updateScriptBackground()
self:setState({
add = {},
remove = {},
})
self:updateDiffs()
end
function StringDiffVisualizer:willUnmount()
self.themeChangedConnection:Disconnect()
self.updateEvent:Destroy()
end
function StringDiffVisualizer:updateScriptBackground()
@@ -51,96 +54,188 @@ function StringDiffVisualizer:updateScriptBackground()
end
function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
local add, remove = self:calculateDiffLines()
self:setState({
add = add,
remove = remove,
})
if
previousProps.currentString ~= self.props.currentString
or previousProps.incomingString ~= self.props.incomingString
then
self:updateDiffs()
end
end
function StringDiffVisualizer:calculateContentSize(theme)
local oldString, newString = self.props.oldString, self.props.newString
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
function StringDiffVisualizer:updateDiffs()
Timer.start("StringDiffVisualizer:updateDiffs")
local currentString, incomingString = self.props.currentString, self.props.incomingString
-- Diff the two texts
local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldString, newString)
local diffs =
StringDiff.findDiffs((string.gsub(currentString, "\t", " ")), (string.gsub(incomingString, "\t", " ")))
local stopClock = os.clock()
Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldString,
#newString,
#currentString,
#incomingString,
math.round((stopClock - startClock) * 1000 * 1000),
#diffs
)
-- Determine which lines to highlight
local add, remove = {}, {}
-- Build the rich text lines
local currentRichTextLines = Highlighter.buildRichTextLines({
src = currentString,
})
local incomingRichTextLines = Highlighter.buildRichTextLines({
src = incomingString,
})
local oldLineNum, newLineNum = 1, 1
local maxLines = math.max(#currentRichTextLines, #incomingRichTextLines)
-- 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
local actionType, text = diff.actionType, diff.value
local lines = select(2, string.gsub(text, "\n", "\n"))
local lineCount = select(2, string.gsub(text, "\n", "\n"))
local lines = string.split(text, "\n")
if actionType == StringDiff.ActionTypes.Equal then
oldLineNum += lines
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Insert then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
add[newLineNum + i - 1] = true
end
end
if lineCount > 0 then
-- Jump cursor ahead to last line
currentLineNum += lineCount
incomingLineNum += lineCount
currentIdx = #lines[#lines]
incomingIdx = #lines[#lines]
else
if string.match(text, "%S") then
add[newLineNum] = true
end
-- Move along this line
currentIdx += #text
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
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
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Delete then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
remove[oldLineNum + i - 1] = true
end
end
else
if string.match(text, "%S") then
remove[oldLineNum] = true
end
if firstDiffLineNum == 0 then
firstDiffLineNum = currentLineNum
end
for i, lineText in lines do
if i > 1 then
-- Move to next line
currentLineNum += 1
currentIdx = 0
end
if not currentDiffs[currentLineNum] then
currentDiffs[currentLineNum] = {}
end
-- Mark these characters on this line
table.insert(currentDiffs[currentLineNum], {
start = currentIdx,
stop = currentIdx + #lineText,
})
currentIdx += #lineText
end
oldLineNum += lines
else
Log.warn("Unknown diff action: {} {}", actionType, text)
end
end
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
function StringDiffVisualizer:render()
local oldString, newString = self.props.oldString, self.props.newString
local currentDiffs, incomingDiffs = self.state.currentDiffs, self.state.incomingDiffs
local currentRichTextLines, incomingRichTextLines =
self.state.currentRichTextLines, self.state.incomingRichTextLines
local maxLines = self.state.maxLines
return Theme.with(function(theme)
self:calculateContentSize(theme)
self.setLineHeight(theme.TextSize.Code)
-- 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, {
size = self.props.size,
@@ -159,43 +254,196 @@ function StringDiffVisualizer:render()
CornerRadius = UDim.new(0, 5),
}),
}),
Separator = e("Frame", {
Size = UDim2.new(0, 2, 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,
}),
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,
Main = e("Frame", {
Size = UDim2.new(1, -10, 1, -2),
Position = UDim2.new(0, 2, 0, 2),
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(rbx)
self.setWindowWidth(rbx.AbsoluteSize.X * 0.5 - 10)
end,
}, {
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
Separator = e("Frame", {
Size = UDim2.new(0, 2, 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),
text = oldString,
lineBackground = theme.Diff.Background.Remove,
markedLines = self.state.remove,
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 = 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,
}),
}),
New = e(ScrollingFrame, {
position = UDim2.new(0.5, 5, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
ScrollMarkers = e("Frame", {
Size = self.windowWidth:map(function(windowWidth)
return UDim2.new(0, 8, 1, -4 - (if canvasWidth > windowWidth then 10 else 0))
end),
Position = UDim2.new(1, -2, 0, 2),
AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
}, {
Source = e(CodeLabel, {
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,
}),
insertions = Roact.createFragment(insertionScrollMarkers),
removals = Roact.createFragment(removalScrollMarkers),
}),
})
end)

View File

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

View File

@@ -23,8 +23,8 @@ function ConfirmingPage:init()
self:setState({
showingStringDiff = false,
oldString = "",
newString = "",
currentString = "",
incomingString = "",
showingTableDiff = false,
oldTable = {},
newTable = {},
@@ -56,11 +56,11 @@ function ConfirmingPage:render()
patchTree = self.props.patchTree,
showStringDiff = function(oldString: string, newString: string)
showStringDiff = function(currentString: string, incomingString: string)
self:setState({
showingStringDiff = true,
oldString = oldString,
newString = newString,
currentString = currentString,
incomingString = incomingString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
@@ -167,8 +167,8 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldString = self.state.oldString,
newString = self.state.newString,
currentString = self.state.currentString,
incomingString = self.state.incomingString,
}),
}),
}),

View File

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

View File

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

View File

@@ -174,6 +174,8 @@ function App:init()
end
function App:willUnmount()
self:endSession()
self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -201,7 +201,20 @@ function ServeSession:start()
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
return self:__mainSyncLoop()
return self.__apiContext:connectWebSocket({
["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)
:catch(function(err)
@@ -536,40 +549,6 @@ function ServeSession:__initialSync(serverInfo)
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)
self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect()

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
---
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">json_model_legacy_name</string>
</Properties>
<Item class="Folder" referent="1">
<Properties>
<string name="Name">Expected Name</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,23 @@
---
source: tests/tests/build.rs
assertion_line: 109
expression: contents
---
<roblox version="4">
<Item class="DataModel" referent="0">
<Properties>
<string name="Name">model_json_name_input</string>
</Properties>
<Item class="Workspace" referent="1">
<Properties>
<string name="Name">Workspace</string>
<bool name="NeedsPivotMigration">false</bool>
</Properties>
<Item class="StringValue" referent="2">
<Properties>
<string name="Name">/Bar</string>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,20 @@
---
source: tests/tests/build.rs
assertion_line: 108
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">slugified_name_roundtrip</string>
</Properties>
<Item class="Script" referent="1">
<Properties>
<string name="Name">/Script</string>
<token name="RunContext">0</token>
<string name="Source"><![CDATA[print("Hello world!")
]]></string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -1,6 +0,0 @@
{
"name": "json_model_legacy_name",
"tree": {
"$path": "folder"
}
}

View File

@@ -1,4 +0,0 @@
{
"Name": "Overridden Name",
"ClassName": "Folder"
}

View File

@@ -0,0 +1,11 @@
{
"name": "model_json_name_input",
"tree": {
"$className": "DataModel",
"Workspace": {
"$className": "Workspace",
"$path": "src"
}
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "/Bar",
"className": "StringValue"
}

View File

@@ -0,0 +1,4 @@
{
"name": "/Script"
}

View File

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

View File

@@ -0,0 +1,6 @@
{
"name": "slugified_name_roundtrip",
"tree": {
"$path": "src"
}
}

View File

@@ -0,0 +1,3 @@
{
"name": "/Script"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
---
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

@@ -0,0 +1,7 @@
---
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

View File

@@ -0,0 +1,5 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing container.model.json

View File

@@ -0,0 +1,7 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/int_value.model.json
Writing src/subfolder/string_value.txt
Writing src/subfolder

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/non-init.luau
Writing src/init-file

View File

@@ -0,0 +1,73 @@
---
source: tests/rojo_test/syncback_util.rs
expression: src/Message.rbxm
---
num_types: 1
num_instances: 1
chunks:
- Inst:
type_id: 0
type_name: Message
object_format: 0
referents:
- 0
- Prop:
type_id: 0
prop_name: AttributesSerialize
prop_type: String
values:
- ""
- Prop:
type_id: 0
prop_name: Capabilities
prop_type: SecurityCapabilities
values:
- 0
- Prop:
type_id: 0
prop_name: HistoryId
prop_type: UniqueId
values:
- "00000000000000000000000000000000"
- Prop:
type_id: 0
prop_name: Name
prop_type: String
values:
- Message
- Prop:
type_id: 0
prop_name: DefinesCapabilities
prop_type: Bool
values:
- false
- Prop:
type_id: 0
prop_name: SourceAssetId
prop_type: Int64
values:
- -1
- Prop:
type_id: 0
prop_name: Tags
prop_type: String
values:
- ""
- Prop:
type_id: 0
prop_name: Text
prop_type: String
values:
- This message should be written to the disk.
- Prop:
type_id: 0
prop_name: UniqueId
prop_type: UniqueId
values:
- 2030b7775c30713f085b9d4800005a8b
- Prnt:
version: 0
links:
- - 0
- -1
- End

View File

@@ -0,0 +1,5 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/Message.rbxm

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/IncludeMe/.gitkeep
Writing src/IncludeMe

View File

@@ -0,0 +1,5 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---

View File

@@ -0,0 +1,8 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/dir_with_meta/init.meta.json
Writing src/model_json.model.json
Writing src/project_json.project.json
Writing src/dir_with_meta

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
assertion_line: 101
expression: "String::from_utf8_lossy(&output.stdout)"
---

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing nested.project.json
Writing string_value.txt

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/modules/ClientModule.luau
Writing src/modules/ServerModule.luau

View File

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

View File

@@ -0,0 +1,6 @@
---
source: tests/rojo_test/syncback_util.rs
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing attribute_mismatch.luau
Writing property_mismatch.project.json

View File

@@ -0,0 +1,12 @@
---
source: tests/rojo_test/syncback_util.rs
assertion_line: 101
expression: "String::from_utf8_lossy(&output.stdout)"
---
Writing src/ChildWithDuplicates/DuplicateChild/.gitkeep
Writing src/ChildWithDuplicates/DuplicateChild1/.gitkeep
Writing src/ChildWithoutDuplicates/Child/.gitkeep
Writing src/ChildWithDuplicates/DuplicateChild
Writing src/ChildWithDuplicates/DuplicateChild1
Writing src/ChildWithoutDuplicates
Writing src/ChildWithoutDuplicates/Child

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