Compare commits

..

7 Commits

Author SHA1 Message Date
Micah
825726c883 Release 7.6.1 (#1151) 2025-11-06 18:49:05 -08:00
boatbomber
54e63d88d4 Slightly improve initial sync hangs (#1140) 2025-11-06 00:06:42 -08:00
boatbomber
4018c97cb6 Make CHANGELOG.md use consistent style (#1146) 2025-10-28 19:26:48 -07:00
boatbomber
d0b029f995 Add JSONC Support for Project, Meta, and Model JSON files (#1144)
Replaces `serde_json` parsing with `jsonc-parser` throughout the
codebase, enabling support for **comments** and **trailing commas** in
all JSON files including `.project.json`, `.model.json`, and
`.meta.json` files.
MSRV bumps from `1.83.0` to `1.88.0` in order to
use the jsonc_parser dependency.
2025-10-28 17:29:57 -07:00
Sebastian Stachowicz
aabe6d11b2 Update default gitignores to include sourcemap (#1145) 2025-10-28 17:28:55 -07:00
boatbomber
181cc37744 Improve sync fallback robustness (#1135) 2025-10-20 20:13:47 -07:00
boatbomber
cd78f5c02c Fix postcommit callbacks being skipped (#1132) 2025-10-14 12:13:59 -07:00
25 changed files with 1774 additions and 592 deletions

View File

@@ -60,7 +60,7 @@ jobs:
submodules: true
- name: Install Rust
uses: dtolnay/rust-toolchain@1.83.0
uses: dtolnay/rust-toolchain@1.88.0
- name: Restore Rust Cache
uses: actions/cache/restore@v4

File diff suppressed because it is too large Load Diff

98
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "addr2line"
@@ -243,7 +243,7 @@ checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [
"heck",
"proc-macro-error",
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 1.0.109",
]
@@ -665,9 +665,9 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
@@ -1033,6 +1033,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonc-parser"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec4ac49f13c7b00f435f8a5bb55d725705e2cf620df35a5859321595102eb7e"
dependencies = [
"serde_json",
]
[[package]]
name = "kernel32-sys"
version = "0.2.2"
@@ -1393,9 +1402,9 @@ checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80"
dependencies = [
"pest",
"pest_meta",
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
@@ -1478,7 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 1.0.109",
"version_check",
@@ -1490,7 +1499,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"version_check",
]
@@ -1518,9 +1527,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
version = "1.0.78"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [
"unicode-ident",
]
@@ -1542,7 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd"
dependencies = [
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
@@ -1560,7 +1569,7 @@ version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
]
[[package]]
@@ -1655,9 +1664,9 @@ dependencies = [
[[package]]
name = "rbx_reflection_database"
version = "2.0.0+roblox-694"
version = "2.0.1+roblox-697"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "844ceb61f23bad59b06d7299b69ff276579316eafa9857981da3012a6223f663"
checksum = "d69035a14b103c5a9b8bc6a61d30f4ee6f2608afdee137dae09b26037dba5dc8"
dependencies = [
"dirs 5.0.1",
"log",
@@ -1884,7 +1893,7 @@ dependencies = [
[[package]]
name = "rojo"
version = "7.6.0"
version = "7.6.1"
dependencies = [
"anyhow",
"backtrace",
@@ -1903,6 +1912,7 @@ dependencies = [
"hyper",
"insta",
"jod-thread",
"jsonc-parser",
"log",
"maplit",
"memofs",
@@ -2054,10 +2064,11 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca"
[[package]]
name = "serde"
version = "1.0.197"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
@@ -2072,25 +2083,36 @@ dependencies = [
]
[[package]]
name = "serde_derive"
version = "1.0.197"
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"proc-macro2 1.0.78",
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
name = "serde_json"
version = "1.0.114"
version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
"serde_core",
]
[[package]]
@@ -2195,18 +2217,18 @@ version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.52"
version = "2.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"unicode-ident",
]
@@ -2289,9 +2311,9 @@ version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
@@ -2401,9 +2423,9 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]
@@ -2638,9 +2660,9 @@ dependencies = [
"bumpalo",
"log",
"once_cell",
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
"wasm-bindgen-shared",
]
@@ -2672,9 +2694,9 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -2970,9 +2992,9 @@ version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2 1.0.78",
"proc-macro2 1.0.103",
"quote 1.0.35",
"syn 2.0.52",
"syn 2.0.108",
]
[[package]]

View File

@@ -1,7 +1,7 @@
[package]
name = "rojo"
version = "7.6.0"
rust-version = "1.83"
version = "7.6.1"
rust-version = "1.88"
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
@@ -58,7 +58,7 @@ memofs = { version = "0.3.0", path = "crates/memofs" }
rbx_binary = "2.0.0"
rbx_dom_weak = "4.0.0"
rbx_reflection = "6.0.0"
rbx_reflection_database = "2.0.0"
rbx_reflection_database = "2.0.1"
rbx_xml = "2.0.0"
anyhow = "1.0.80"
@@ -85,7 +85,8 @@ reqwest = { version = "0.11.24", default-features = false, features = [
ritz = "0.1.0"
roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.114"
serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
toml = "0.5.11"
termcolor = "1.4.1"
thiserror = "1.0.57"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
7.6.0
7.6.1

View File

@@ -1,9 +1,9 @@
{
"Version": [
0,
694,
697,
0,
6940982
6970925
],
"Classes": {
"Accessory": {
@@ -550,6 +550,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -1975,6 +1978,47 @@
}
}
},
"AnimationGraphDefinition": {
"Name": "AnimationGraphDefinition",
"Tags": [],
"Superclass": "AnimationClip",
"Properties": {},
"DefaultProperties": {
"Archivable": {
"Bool": true
},
"Attributes": {
"Attributes": {}
},
"Capabilities": {
"SecurityCapabilities": 0
},
"GuidBinaryString": {
"BinaryString": "AAAAAAAAAAAAAAAAAAAAAA=="
},
"HistoryId": {
"UniqueId": "00000000000000000000000000000000"
},
"Loop": {
"Bool": true
},
"Priority": {
"Enum": 2
},
"Sandboxed": {
"Bool": false
},
"SourceAssetId": {
"Int64": -1
},
"Tags": {
"Tags": []
},
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
}
}
},
"AnimationImportData": {
"Name": "AnimationImportData",
"Tags": [
@@ -1995,6 +2039,84 @@
}
}
},
"AnimationNode": {
"Name": "AnimationNode",
"Tags": [
"NotCreatable",
"NotReplicated"
],
"Superclass": "Object",
"Properties": {},
"DefaultProperties": {}
},
"AnimationNodeDefinition": {
"Name": "AnimationNodeDefinition",
"Tags": [],
"Superclass": "Instance",
"Properties": {
"InputPinData": {
"Name": "InputPinData",
"Scriptability": "None",
"DataType": {
"Value": "BinaryString"
},
"Tags": [
"Hidden",
"NotScriptable"
],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
},
"NodeType": {
"Name": "NodeType",
"Scriptability": "ReadWrite",
"DataType": {
"Enum": "AnimationNodeType"
},
"Tags": [],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
}
},
"DefaultProperties": {
"Archivable": {
"Bool": true
},
"Attributes": {
"Attributes": {}
},
"Capabilities": {
"SecurityCapabilities": 0
},
"HistoryId": {
"UniqueId": "00000000000000000000000000000000"
},
"InputPinData": {
"BinaryString": ""
},
"NodeType": {
"Enum": 0
},
"Sandboxed": {
"Bool": false
},
"SourceAssetId": {
"Int64": -1
},
"Tags": {
"Tags": []
},
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
}
}
},
"AnimationRigData": {
"Name": "AnimationRigData",
"Tags": [],
@@ -6332,9 +6454,7 @@
},
"AudioSpeechToText": {
"Name": "AudioSpeechToText",
"Tags": [
"NotBrowsable"
],
"Tags": [],
"Superclass": "Instance",
"Properties": {
"Enabled": {
@@ -21030,7 +21150,7 @@
"NotBrowsable",
"NotCreatable",
"NotReplicated",
"Settings"
"Service"
],
"Superclass": "Instance",
"Properties": {
@@ -23979,6 +24099,27 @@
}
}
},
"EncodingService": {
"Name": "EncodingService",
"Tags": [
"NotCreatable",
"NotReplicated",
"Service"
],
"Superclass": "Instance",
"Properties": {},
"DefaultProperties": {
"Archivable": {
"Bool": true
},
"HistoryId": {
"UniqueId": "00000000000000000000000000000000"
},
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
}
}
},
"EqualizerSoundEffect": {
"Name": "EqualizerSoundEffect",
"Tags": [],
@@ -24135,27 +24276,6 @@
}
}
},
"ExampleService": {
"Name": "ExampleService",
"Tags": [
"NotCreatable",
"NotReplicated",
"Service"
],
"Superclass": "Instance",
"Properties": {},
"DefaultProperties": {
"Archivable": {
"Bool": true
},
"HistoryId": {
"UniqueId": "00000000000000000000000000000000"
},
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
}
}
},
"ExampleV2Service": {
"Name": "ExampleV2Service",
"Tags": [
@@ -26293,6 +26413,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -27586,7 +27709,7 @@
"NotBrowsable",
"NotCreatable",
"NotReplicated",
"Settings"
"Service"
],
"Superclass": "Instance",
"Properties": {
@@ -32260,6 +32383,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -41477,6 +41603,22 @@
"Serialization": "DoesNotSerialize"
}
}
},
"IsSlimEnabled": {
"Name": "IsSlimEnabled",
"Scriptability": "None",
"DataType": {
"Value": "Bool"
},
"Tags": [
"Hidden",
"NotScriptable"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
}
},
"DefaultProperties": {
@@ -41593,7 +41735,7 @@
"Tags": [
"NotCreatable",
"NotReplicated",
"Settings"
"Service"
],
"Superclass": "Instance",
"Properties": {},
@@ -42203,6 +42345,19 @@
}
}
},
"EmissiveFilePath": {
"Name": "EmissiveFilePath",
"Scriptability": "ReadWrite",
"DataType": {
"Value": "String"
},
"Tags": [],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
},
"IsPbr": {
"Name": "IsPbr",
"Scriptability": "Read",
@@ -45055,7 +45210,7 @@
"Name": "SlimHash",
"Scriptability": "None",
"DataType": {
"Value": "BinaryString"
"Value": "SharedString"
},
"Tags": [
"Hidden",
@@ -45063,7 +45218,7 @@
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
"Serialization": "Serializes"
}
}
},
@@ -45163,6 +45318,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -50221,7 +50379,7 @@
"Tags": [
"NotCreatable",
"NotReplicated",
"Settings"
"Service"
],
"Superclass": "Instance",
"Properties": {
@@ -51305,6 +51463,22 @@
}
}
},
"ChatAvailabilityStatus": {
"Name": "ChatAvailabilityStatus",
"Scriptability": "None",
"DataType": {
"Value": "String"
},
"Tags": [
"Hidden",
"NotReplicated"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
},
"ChatMode": {
"Name": "ChatMode",
"Scriptability": "None",
@@ -51568,10 +51742,12 @@
"DataType": {
"Value": "Bool"
},
"Tags": [],
"Tags": [
"Hidden"
],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
"Serialization": "DoesNotSerialize"
}
}
},
@@ -53409,6 +53585,23 @@
],
"Superclass": "LayerCollector",
"Properties": {
"Plugin": {
"Name": "Plugin",
"Scriptability": "None",
"DataType": {
"Value": "Ref"
},
"Tags": [
"Hidden",
"NotReplicated",
"ReadOnly"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
},
"Title": {
"Name": "Title",
"Scriptability": "ReadWrite",
@@ -58013,6 +58206,22 @@
}
}
},
"FrameNumber": {
"Name": "FrameNumber",
"Scriptability": "Read",
"DataType": {
"Value": "Int64"
},
"Tags": [
"NotReplicated",
"ReadOnly"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
},
"PredictionState": {
"Name": "PredictionState",
"Scriptability": "Read",
@@ -58043,22 +58252,6 @@
"Serialization": "DoesNotSerialize"
}
}
},
"ServerFrame": {
"Name": "ServerFrame",
"Scriptability": "Read",
"DataType": {
"Value": "Int64"
},
"Tags": [
"NotReplicated",
"ReadOnly"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
}
},
"DefaultProperties": {
@@ -66735,6 +66928,19 @@
}
}
},
"DefaultScriptSyncFileType": {
"Name": "DefaultScriptSyncFileType",
"Scriptability": "None",
"DataType": {
"Enum": "DefaultScriptSyncFileType"
},
"Tags": [],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
},
"DeprecatedObjectsShown": {
"Name": "DeprecatedObjectsShown",
"Scriptability": "ReadWrite",
@@ -74683,7 +74889,7 @@
"SecurityCapabilities": 0
},
"ChatTranslationFTUXShown": {
"Bool": true
"Bool": false
},
"ChatTranslationToggleEnabled": {
"Bool": false
@@ -76244,6 +76450,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -80533,7 +80742,7 @@
"Tags": [
"NotCreatable",
"NotReplicated",
"UserSettings"
"Service"
],
"Superclass": "Instance",
"Properties": {
@@ -82451,6 +82660,90 @@
}
}
},
"ValueCurve": {
"Name": "ValueCurve",
"Tags": [],
"Superclass": "Instance",
"Properties": {
"Length": {
"Name": "Length",
"Scriptability": "Read",
"DataType": {
"Value": "Int32"
},
"Tags": [
"NotReplicated",
"ReadOnly"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
},
"ValueType": {
"Name": "ValueType",
"Scriptability": "Read",
"DataType": {
"Value": "String"
},
"Tags": [
"NotReplicated",
"ReadOnly"
],
"Kind": {
"Canonical": {
"Serialization": "DoesNotSerialize"
}
}
},
"ValuesAndTimes": {
"Name": "ValuesAndTimes",
"Scriptability": "None",
"DataType": {
"Value": "BinaryString"
},
"Tags": [
"Hidden",
"NotScriptable"
],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
}
},
"DefaultProperties": {
"Archivable": {
"Bool": true
},
"Attributes": {
"Attributes": {}
},
"Capabilities": {
"SecurityCapabilities": 0
},
"HistoryId": {
"UniqueId": "00000000000000000000000000000000"
},
"Sandboxed": {
"Bool": false
},
"SourceAssetId": {
"Int64": -1
},
"Tags": {
"Tags": []
},
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
},
"ValuesAndTimes": {
"BinaryString": "AgAAAAAAAAABAAAAAAAAAA=="
}
}
},
"Vector3Curve": {
"Name": "Vector3Curve",
"Tags": [],
@@ -86753,21 +87046,6 @@
}
}
},
"UseImprovedModelLod": {
"Name": "UseImprovedModelLod",
"Scriptability": "None",
"DataType": {
"Enum": "RolloutState"
},
"Tags": [
"NotScriptable"
],
"Kind": {
"Canonical": {
"Serialization": "Serializes"
}
}
},
"UseNewLuauTypeSolver": {
"Name": "UseNewLuauTypeSolver",
"Scriptability": "None",
@@ -86942,6 +87220,9 @@
"SignalBehavior": {
"Enum": 0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -86975,9 +87256,6 @@
"UniqueId": {
"UniqueId": "00000000000000000000000000000000"
},
"UseImprovedModelLod": {
"Enum": 0
},
"UseNewLuauTypeSolver": {
"Enum": 0
},
@@ -87103,6 +87381,9 @@
"Scale": {
"Float32": 1.0
},
"SlimHash": {
"SharedString": ""
},
"SourceAssetId": {
"Int64": -1
},
@@ -88147,6 +88428,25 @@
"Timeout": 10
}
},
"AnimationNodeType": {
"name": "AnimationNodeType",
"items": {
"AddNode": 1,
"Blend1DNode": 3,
"Blend2DNode": 4,
"BlendNode": 2,
"ClipNode": 5,
"GraphOutput": 6,
"InvalidNode": 0,
"MaskNode": 7,
"PrioritySelectNode": 8,
"RandomSequenceNode": 9,
"SelectNode": 10,
"SequenceNode": 11,
"SpeedNode": 12,
"SubtractNode": 13
}
},
"AnimationPriority": {
"name": "AnimationPriority",
"items": {
@@ -88888,6 +89188,13 @@
"Relevance": 1
}
},
"CatalogTimedOptionFilter": {
"name": "CatalogTimedOptionFilter",
"items": {
"All": 1,
"TimedOptionOnly": 2
}
},
"CellBlock": {
"name": "CellBlock",
"items": {
@@ -89120,6 +89427,12 @@
"TriggerForIncompleteCompletions": 3
}
},
"CompressionAlgorithm": {
"name": "CompressionAlgorithm",
"items": {
"Zstd": 0
}
},
"ComputerCameraMovementMode": {
"name": "ComputerCameraMovementMode",
"items": {
@@ -89403,6 +89716,13 @@
"Timeout": 1
}
},
"DefaultScriptSyncFileType": {
"name": "DefaultScriptSyncFileType",
"items": {
"Lua": 0,
"Luau": 1
}
},
"DevCameraOcclusionMode": {
"name": "DevCameraOcclusionMode",
"items": {
@@ -89482,7 +89802,8 @@
"DeviceFeatureType": {
"name": "DeviceFeatureType",
"items": {
"DeviceCapture": 0
"DeviceCapture": 0,
"InExperienceFAE": 1
}
},
"DeviceForm": {
@@ -89670,6 +89991,16 @@
"DefaultScope": 0
}
},
"ExperienceEventStatus": {
"name": "ExperienceEventStatus",
"items": {
"Active": 0,
"Cancelled": 1,
"Moderated": 2,
"Unknown": 4,
"Unpublished": 3
}
},
"ExperienceStateRecordingLoadMode": {
"name": "ExperienceStateRecordingLoadMode",
"items": {
@@ -90141,6 +90472,16 @@
"UINotification": 3
}
},
"HashAlgorithm": {
"name": "HashAlgorithm",
"items": {
"Blake2b": 0,
"Blake3": 1,
"Md5": 2,
"Sha1": 3,
"Sha256": 4
}
},
"HighlightDepthMode": {
"name": "HighlightDepthMode",
"items": {
@@ -90531,14 +90872,14 @@
"Menu": 319,
"Minus": 45,
"Mode": 313,
"MouseBackButton": 1021,
"MouseLeftButton": 1018,
"MouseMiddleButton": 1020,
"MouseNoButton": 1022,
"MousePosition": 1025,
"MouseRightButton": 1019,
"MouseX": 1023,
"MouseY": 1024,
"MouseBackButton": 1029,
"MouseLeftButton": 1026,
"MouseMiddleButton": 1028,
"MouseNoButton": 1030,
"MousePosition": 1033,
"MouseRightButton": 1027,
"MouseX": 1031,
"MouseY": 1032,
"N": 110,
"Nine": 57,
"NumLock": 300,
@@ -90581,7 +90922,15 @@
"Tab": 9,
"Three": 51,
"Thumbstick1": 1016,
"Thumbstick1Down": 1019,
"Thumbstick1Left": 1020,
"Thumbstick1Right": 1021,
"Thumbstick1Up": 1018,
"Thumbstick2": 1017,
"Thumbstick2Down": 1023,
"Thumbstick2Left": 1024,
"Thumbstick2Right": 1025,
"Thumbstick2Up": 1022,
"Tilde": 126,
"Two": 50,
"U": 117,
@@ -91023,6 +91372,7 @@
"items": {
"Automatic": 0,
"Disabled": 2,
"SLIM": 4,
"StreamingMesh": 1
}
},
@@ -91044,6 +91394,30 @@
"PersistentPerPlayer": 3
}
},
"ModerationResultCategory": {
"name": "ModerationResultCategory",
"items": {
"Borderline": 1,
"NoViolationDetected": 2,
"ViolationDetected": 0
}
},
"ModerationResultLabel": {
"name": "ModerationResultLabel",
"items": {
"ChildExploitation": 0,
"DiscriminationSlursAndHateSpeech": 4,
"IllegalAndRegulatedGoodsAndActivities": 8,
"Other": 100,
"Profanity": 9,
"RealWorldSensitiveEvents": 5,
"RomanticAndSexualContent": 7,
"SuicideSelfInjuryAndHarmfulBehavior": 1,
"TerrorismAndViolentExtremism": 3,
"ThreatsBullyingAndHarassment": 2,
"ViolentContentAndGore": 6
}
},
"ModerationStatus": {
"name": "ModerationStatus",
"items": {
@@ -91855,6 +92229,14 @@
"UnderWater": 23
}
},
"ReviewableContentState": {
"name": "ReviewableContentState",
"items": {
"Completed": 1,
"Failed": 2,
"Pending": 0
}
},
"RibbonTool": {
"name": "RibbonTool",
"items": {
@@ -92084,6 +92466,7 @@
"AccessOutsideWrite": 2,
"Animation": 15,
"AssetRequire": 3,
"Assistant": 31,
"Audio": 8,
"Avatar": 16,
"Basic": 7,
@@ -92094,16 +92477,25 @@
"DataStore": 9,
"Environment": 18,
"Input": 17,
"InternalTest": 29,
"LegacySound": 20,
"LoadString": 4,
"LocalUser": 24,
"Network": 10,
"Physics": 11,
"Players": 21,
"Plugin": 23,
"PluginOrOpenCloud": 30,
"RemoteCommand": 32,
"RemoteEvent": 19,
"RobloxEngine": 27,
"RobloxScript": 26,
"RunClientScript": 0,
"RunServerScript": 1,
"ScriptGlobals": 5,
"UI": 12
"UI": 12,
"Unassigned": 28,
"WritePlayer": 25
}
},
"SelectionBehavior": {
@@ -92659,6 +93051,35 @@
"Voxel": 1
}
},
"TelemetryBackend": {
"name": "TelemetryBackend",
"items": {
"Counter": 6,
"EphemeralCounter": 4,
"EphemeralStat": 5,
"EventIngest": 1,
"Points": 2,
"Stat": 7,
"Teletune": 3,
"UNSPECIFIED": 0
}
},
"TelemetryStandardizedField": {
"name": "TelemetryStandardizedField",
"items": {
"AddArchitectureInfo": 7,
"AddCpuInfo": 8,
"AddCurrentContextName": 5,
"AddDatacenterId": 0,
"AddMemoryInfo": 9,
"AddOsInfo": 6,
"AddPlaceId": 1,
"AddPlaceInstanceId": 3,
"AddPlaySessionId": 4,
"AddSessionInfo": 10,
"AddUniverseId": 2
}
},
"TeleportMethod": {
"name": "TeleportMethod",
"items": {
@@ -93098,7 +93519,8 @@
"CaptureNotInGallery": 3,
"IneligibleCapture": 4,
"NeedPermission": 1,
"Success": 0
"Success": 0,
"UploadQuotaReached": 5
}
},
"UsageContext": {
@@ -93430,7 +93852,8 @@
"name": "WebStreamClientType",
"items": {
"RawStream": 1,
"SSE": 0
"SSE": 0,
"WebSocket": 2
}
},
"WeldConstraintPreserve": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,8 +43,8 @@ impl Serialize for Glob {
impl<'de> Deserialize<'de> for Glob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let glob = <&str as Deserialize>::deserialize(deserializer)?;
let glob = String::deserialize(deserializer)?;
Glob::new(glob).map_err(D::Error::custom)
Glob::new(&glob).map_err(D::Error::custom)
}
}

313
src/json.rs Normal file
View File

@@ -0,0 +1,313 @@
//! Utilities for parsing JSON with comments (JSONC) and deserializing to Rust types.
//!
//! This module provides convenient wrappers around `jsonc_parser` and `serde_json`
//! to reduce boilerplate and improve ergonomics when working with JSONC files.
use anyhow::Context as _;
use serde::de::DeserializeOwned;
/// Parse JSONC text into a `serde_json::Value`.
///
/// This handles the common pattern of calling `jsonc_parser::parse_to_serde_value`
/// and unwrapping the `Option` with a clear error message.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value(text: &str) -> anyhow::Result<serde_json::Value> {
jsonc_parser::parse_to_serde_value(text, &Default::default())
.context("Failed to parse JSONC")?
.ok_or_else(|| anyhow::anyhow!("File contains no JSON value"))
}
/// Parse JSONC text into a `serde_json::Value` with a custom context message.
///
/// This is useful when you want to provide a specific error message that includes
/// additional information like the file path.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value_with_context(
text: &str,
context: impl Fn() -> String,
) -> anyhow::Result<serde_json::Value> {
jsonc_parser::parse_to_serde_value(text, &Default::default())
.with_context(|| format!("{}: JSONC parse error", context()))?
.ok_or_else(|| anyhow::anyhow!("{}: File contains no JSON value", context()))
}
/// Parse JSONC text and deserialize it into a specific type.
///
/// This combines parsing JSONC and deserializing into a single operation,
/// eliminating the need to manually chain `parse_to_serde_value` and `from_value`.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_str<T: DeserializeOwned>(text: &str) -> anyhow::Result<T> {
let value = parse_value(text)?;
serde_json::from_value(value).context("Failed to deserialize JSON")
}
/// Parse JSONC text and deserialize it into a specific type with a custom context message.
///
/// This is useful when you want to provide a specific error message that includes
/// additional information like the file path.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_str_with_context<T: DeserializeOwned>(
text: &str,
context: impl Fn() -> String,
) -> anyhow::Result<T> {
let value = parse_value_with_context(text, &context)?;
serde_json::from_value(value).with_context(|| format!("{}: Invalid JSON structure", context()))
}
/// Parse JSONC bytes into a `serde_json::Value` with a custom context message.
///
/// This handles UTF-8 conversion and JSONC parsing in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value_from_slice_with_context(
slice: &[u8],
context: impl Fn() -> String,
) -> anyhow::Result<serde_json::Value> {
let text = std::str::from_utf8(slice)
.with_context(|| format!("{}: File is not valid UTF-8", context()))?;
parse_value_with_context(text, context)
}
/// Parse JSONC bytes and deserialize it into a specific type.
///
/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_slice<T: DeserializeOwned>(slice: &[u8]) -> anyhow::Result<T> {
let text = std::str::from_utf8(slice).context("File is not valid UTF-8")?;
from_str(text)
}
/// Parse JSONC bytes and deserialize it into a specific type with a custom context message.
///
/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_slice_with_context<T: DeserializeOwned>(
slice: &[u8],
context: impl Fn() -> String,
) -> anyhow::Result<T> {
let text = std::str::from_utf8(slice)
.with_context(|| format!("{}: File is not valid UTF-8", context()))?;
from_str_with_context(text, context)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[test]
fn test_parse_value() {
let value = parse_value(r#"{"foo": "bar"}"#).unwrap();
assert_eq!(value["foo"], "bar");
}
#[test]
fn test_parse_value_with_comments() {
let value = parse_value(
r#"{
// This is a comment
"foo": "bar" // Inline comment
}"#,
)
.unwrap();
assert_eq!(value["foo"], "bar");
}
#[test]
fn test_parse_value_with_trailing_comma() {
let value = parse_value(
r#"{
"foo": "bar",
"baz": 123,
}"#,
)
.unwrap();
assert_eq!(value["foo"], "bar");
assert_eq!(value["baz"], 123);
}
#[test]
fn test_parse_value_empty() {
let err = parse_value("").unwrap_err();
assert!(err.to_string().contains("no JSON value"));
}
#[test]
fn test_parse_value_invalid() {
let err = parse_value("{invalid}").unwrap_err();
assert!(err.to_string().contains("parse"));
}
#[test]
fn test_parse_value_with_context() {
let err = parse_value_with_context("{invalid}", || "test.json".to_string()).unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("parse"));
}
#[derive(Debug, Deserialize, PartialEq)]
struct TestStruct {
foo: String,
bar: i32,
}
#[test]
fn test_from_str() {
let result: TestStruct = from_str(r#"{"foo": "hello", "bar": 42}"#).unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_str_with_comments() {
let result: TestStruct = from_str(
r#"{
// Comment
"foo": "hello",
"bar": 42, // Trailing comma is fine
}"#,
)
.unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_str_invalid_type() {
let err = from_str::<TestStruct>(r#"{"foo": "hello"}"#).unwrap_err();
assert!(err.to_string().contains("deserialize"));
}
#[test]
fn test_from_str_with_context() {
let err = from_str_with_context::<TestStruct>(r#"{"foo": "hello"}"#, || {
"config.json".to_string()
})
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("Invalid JSON structure"));
}
#[test]
fn test_parse_value_from_slice_with_context() {
let err = parse_value_from_slice_with_context(b"{invalid}", || "test.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("parse"));
}
#[test]
fn test_parse_value_from_slice_with_context_invalid_utf8() {
let err = parse_value_from_slice_with_context(&[0xFF, 0xFF], || "test.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_from_slice() {
let result: TestStruct = from_slice(br#"{"foo": "hello", "bar": 42}"#).unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_slice_with_comments() {
let result: TestStruct = from_slice(
br#"{
// Comment
"foo": "hello",
"bar": 42, // Trailing comma is fine
}"#,
)
.unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_slice_invalid_utf8() {
let err = from_slice::<TestStruct>(&[0xFF, 0xFF]).unwrap_err();
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_from_slice_with_context() {
let err = from_slice_with_context::<TestStruct>(br#"{"foo": "hello"}"#, || {
"config.json".to_string()
})
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("Invalid JSON structure"));
}
#[test]
fn test_from_slice_with_context_invalid_utf8() {
let err =
from_slice_with_context::<TestStruct>(&[0xFF, 0xFF], || "config.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("UTF-8"));
}
}

View File

@@ -10,6 +10,7 @@ mod tree_view;
mod auth_cookie;
mod change_processor;
mod glob;
mod json;
mod lua_ast;
mod message_queue;
mod multimap;

View File

@@ -11,7 +11,7 @@ use rbx_dom_weak::{Ustr, UstrMap};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule};
use crate::{glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule};
static PROJECT_FILENAME: &str = "default.project.json";
@@ -214,8 +214,11 @@ impl Project {
project_file_location: PathBuf,
fallback_name: Option<&str>,
) -> Result<Self, Error> {
let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json {
source,
let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json {
source: serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
)),
path: project_file_location.clone(),
})?;
project.file_location = project_file_location;
@@ -399,13 +402,13 @@ mod test {
#[test]
fn path_node_required() {
let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap();
let path_node: PathNode = json::from_str(r#""src""#).unwrap();
assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
}
#[test]
fn path_node_optional() {
let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap();
let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap();
assert_eq!(
path_node,
PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
@@ -414,7 +417,7 @@ mod test {
#[test]
fn project_node_required() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$path": "src"
}"#,
@@ -429,7 +432,7 @@ mod test {
#[test]
fn project_node_optional() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$path": { "optional": "src" }
}"#,
@@ -446,7 +449,7 @@ mod test {
#[test]
fn project_node_none() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$className": "Folder"
}"#,
@@ -458,7 +461,7 @@ mod test {
#[test]
fn project_node_optional_serialize_absolute() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$path": { "optional": "..\\src" }
}"#,
@@ -471,7 +474,7 @@ mod test {
#[test]
fn project_node_optional_serialize_absolute_no_change() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$path": { "optional": "../src" }
}"#,
@@ -484,7 +487,7 @@ mod test {
#[test]
fn project_node_optional_serialize_optional() {
let project_node: ProjectNode = serde_json::from_str(
let project_node: ProjectNode = json::from_str(
r#"{
"$path": "..\\src"
}"#,
@@ -494,4 +497,57 @@ mod test {
let serialized = serde_json::to_string(&project_node).unwrap();
assert_eq!(serialized, r#"{"$path":"../src"}"#);
}
#[test]
fn project_with_jsonc_features() {
// Test that JSONC features (comments and trailing commas) are properly handled
let project_json = r#"{
// This is a single-line comment
"name": "TestProject",
/* This is a
multi-line comment */
"tree": {
"$path": "src", // Comment after value
},
"servePort": 34567,
"emitLegacyScripts": false,
// Test glob parsing with comments
"globIgnorePaths": [
"**/*.spec.lua", // Ignore test files
"**/*.test.lua",
],
"syncRules": [
{
"pattern": "*.data.json",
"use": "json", // Trailing comma in object
},
{
"pattern": "*.module.lua",
"use": "moduleScript",
}, // Trailing comma in array
], // Another trailing comma
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project with JSONC features");
// Verify the parsed values
assert_eq!(project.name, Some("TestProject".to_string()));
assert_eq!(project.serve_port, Some(34567));
assert_eq!(project.emit_legacy_scripts, Some(false));
// Verify glob_ignore_paths were parsed correctly
assert_eq!(project.glob_ignore_paths.len(), 2);
assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua"));
assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua"));
// Verify sync_rules were parsed correctly
assert_eq!(project.sync_rules.len(), 2);
assert!(project.sync_rules[0].include.is_match("data.data.json"));
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
}
}

View File

@@ -248,14 +248,15 @@ fn nonexhaustive_list(values: &[&str]) -> String {
#[cfg(test)]
mod test {
use super::*;
use crate::json;
fn resolve(class: &str, prop: &str, json_value: &str) -> Variant {
let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap();
let unresolved: UnresolvedValue = json::from_str(json_value).unwrap();
unresolved.resolve(class, prop).unwrap()
}
fn resolve_unambiguous(json_value: &str) -> Variant {
let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap();
let unresolved: UnresolvedValue = json::from_str(json_value).unwrap();
unresolved.resolve_unambiguous().unwrap()
}

View File

@@ -1,10 +1,10 @@
use std::path::Path;
use anyhow::Context;
use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::ustr;
use crate::{
json,
lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
@@ -19,8 +19,9 @@ pub fn snapshot_json(
) -> anyhow::Result<Option<InstanceSnapshot>> {
let contents = vfs.read(path)?;
let value: serde_json::Value = serde_json::from_slice(&contents)
.with_context(|| format!("File contains malformed JSON: {}", path.display()))?;
let value = json::parse_value_from_slice_with_context(&contents, || {
format!("File contains malformed JSON: {}", path.display())
})?;
let as_lua = json_to_lua(value).to_string();

View File

@@ -9,6 +9,7 @@ use rbx_dom_weak::{
use serde::Deserialize;
use crate::{
json,
resolution::UnresolvedValue,
snapshot::{InstanceContext, InstanceSnapshot},
RojoRef,
@@ -28,8 +29,9 @@ pub fn snapshot_json_model(
return Ok(None);
}
let mut instance: JsonModel = serde_json::from_str(contents_str)
.with_context(|| format!("File is not a valid JSON model: {}", path.display()))?;
let mut instance: JsonModel = json::from_str_with_context(contents_str, || {
format!("File is not a valid JSON model: {}", path.display())
})?;
if let Some(top_level_name) = &instance.name {
let new_name = format!("{}.model.json", top_level_name);

View File

@@ -4,7 +4,7 @@ use anyhow::{format_err, Context};
use rbx_dom_weak::{types::Attributes, Ustr, UstrMap};
use serde::{Deserialize, Serialize};
use crate::{resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef};
use crate::{json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef};
/// Represents metadata in a sibling file with the same basename.
///
@@ -34,7 +34,7 @@ pub struct AdjacentMetadata {
impl AdjacentMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| {
let mut meta: Self = json::from_slice_with_context(slice, || {
format!(
"File contained malformed .meta.json data: {}",
path.display()
@@ -131,7 +131,7 @@ pub struct DirectoryMetadata {
impl DirectoryMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| {
let mut meta: Self = json::from_slice_with_context(slice, || {
format!(
"File contained malformed init.meta.json data: {}",
path.display()

View File

@@ -17,6 +17,7 @@ use rbx_dom_weak::{
};
use crate::{
json,
serve_session::ServeSession,
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{
@@ -139,7 +140,7 @@ impl ApiService {
let body = body::to_bytes(request.into_body()).await.unwrap();
let request: WriteRequest = match serde_json::from_slice(&body) {
let request: WriteRequest = match json::from_slice(&body) {
Ok(request) => request,
Err(err) => {
return json(

View File

@@ -157,14 +157,20 @@ impl TestServeSession {
let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response"))
let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
}
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response"))
let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
}
pub fn get_api_subscribe(