Compare commits

...

67 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
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
Micah
441c469966 Release Rojo v7.6.0 (#1125) 2025-10-10 19:17:55 -07:00
Micah
f3c423d77d Fix the various lints (#1124) 2025-10-10 13:00:56 -07:00
Micah
beb497878b Add flag for skipping git initialization to init command (#1122) 2025-10-07 17:12:22 -07:00
Micah
6ea95d487c Refactor init command (#1117) 2025-09-30 14:38:38 -07:00
Micah
80a381dbb1 Use SerializationService as a fallback for when patch application fails (#1030) 2025-09-21 15:09:20 -07:00
KAS
59e36491a5 Fix a grammar error and a typo (#1113) 2025-09-16 11:00:34 -07:00
Micah
c1326ba06e Add Arm64 builds to CI/Releases + build on ubuntu 22.04 (#1098) 2025-08-30 14:10:59 -07:00
Micah
e2633126ee Change Foreman to Rokit in CONTRIBUTING.md (#1110) 2025-08-30 13:42:52 -07:00
Micah
5f33435f3c Move to using Rokit, update tools, and don't install unnecessary tools (#1109) 2025-08-29 18:45:15 -07:00
boatbomber
54e0ff230b Improvements to sync reminder UX (#1096) 2025-08-28 17:15:34 -07:00
wad
4e9e6233ff fix: apply gameId and placeId only after initial sync (#1104) 2025-08-15 18:12:36 -07:00
Micah
0056849b51 Put Rojo version in crash message (#1101) 2025-08-13 15:46:08 -07:00
ffrostfall
2ddb21ec5f Add option for emitting absolute paths to rojo sourcemap (#1092)
Co-authored-by: Micah <micah@uplift.games>
2025-08-04 11:33:35 -07:00
Micah
a4eb65ca3f Add YAML middleware that behaves like TOML and JSON (#1093) 2025-08-02 20:58:13 -07:00
Sebastian Stachowicz
3002d250a1 Fix Table diff colors (#1084) 2025-07-31 19:36:03 -07:00
Micah
9598553e5d Normalize paths in sourcemap generation (#1085) 2025-07-31 09:19:57 -07:00
Sebastian Stachowicz
7f68d9887b Fixed nil -> nil props showing up as failing in patch visualizer (plugin) (#1081) 2025-07-25 15:27:11 -07:00
Micah
e092a7301f Change background color of web UI to gray (#1080) 2025-07-25 15:04:42 -07:00
Cameron Campbell
6dfdfbe514 Cache Rust Dependencies in release.yml. (#1079) 2025-07-22 16:13:23 -07:00
morosanu
7860f2717f Fix auto connect for play mode (#1066) 2025-07-22 15:12:16 -07:00
boatbomber
60f19df9a0 Show update indicator on version header (#1069) 2025-06-21 02:53:45 +00:00
boatbomber
951f0cda0b Show the plugin version on the Error page (#1068) 2025-06-20 18:28:04 -07:00
Micah
227042d6b1 Add current maintainers to author field of Cargo.toml files (#1053) 2025-05-21 20:55:39 -07:00
417 changed files with 21604 additions and 3659 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-22.04, windows-latest, macos-latest, windows-11-arm, ubuntu-22.04-arm]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -26,13 +26,14 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
@@ -40,6 +41,15 @@ jobs:
- name: Test - name: Test
run: cargo test --locked --verbose run: cargo test --locked --verbose
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
msrv: msrv:
name: Check MSRV name: Check MSRV
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -50,19 +60,29 @@ jobs:
submodules: true submodules: true
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@1.70.0 uses: dtolnay/rust-toolchain@1.88.0
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
lint: lint:
name: Rustfmt, Clippy, Stylua, & Selene name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -77,13 +97,19 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v1.1.0'
- name: Stylua - name: Stylua
run: stylua --check plugin/src run: stylua --check plugin/src
@@ -97,3 +123,11 @@ jobs:
- name: Clippy - name: Clippy
run: cargo clippy run: cargo clippy
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

View File

@@ -25,10 +25,10 @@ jobs:
with: with:
submodules: true submodules: true
- name: Setup Aftman - name: Setup Rokit
uses: ok-nick/setup-aftman@v0.4.2 uses: CompeyDev/setup-rokit@v0.1.2
with: with:
version: 'v0.3.0' version: 'v1.1.0'
- name: Build Plugin - name: Build Plugin
run: rojo build plugin.project.json --output Rojo.rbxm run: rojo build plugin.project.json --output Rojo.rbxm
@@ -53,15 +53,25 @@ jobs:
# https://doc.rust-lang.org/rustc/platform-support.html # https://doc.rust-lang.org/rustc/platform-support.html
include: include:
- host: linux - host: linux
os: ubuntu-latest os: ubuntu-22.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
label: linux-x86_64 label: linux-x86_64
- host: linux
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
label: linux-aarch64
- host: windows - host: windows
os: windows-latest os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
label: windows-x86_64 label: windows-x86_64
- host: windows
os: windows-11-arm
target: aarch64-pc-windows-msvc
label: windows-aarch64
- host: macos - host: macos
os: macos-latest os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
@@ -86,17 +96,26 @@ jobs:
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Setup Aftman - name: Restore Rust Cache
uses: ok-nick/setup-aftman@v0.4.2 uses: actions/cache/restore@v4
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose --target ${{ matrix.target }} run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env:
# Build into a known directory so we can find our build artifact more - name: Save Rust Cache
# easily. uses: actions/cache/save@v4
CARGO_TARGET_DIR: output with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Generate Artifact Name - name: Generate Artifact Name
shell: bash shell: bash
@@ -113,11 +132,11 @@ jobs:
mkdir staging mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/ cp "target/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging cd staging
7z a ../$ARTIFACT_NAME * 7z a ../$ARTIFACT_NAME *
else else
cp "output/${{ matrix.target }}/release/$BIN" staging/ cp "target/${{ matrix.target }}/release/$BIN" staging/
cd staging cd staging
zip ../$ARTIFACT_NAME * zip ../$ARTIFACT_NAME *
fi fi

3
.gitignore vendored
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ You'll want these tools to work on Rojo:
* Latest stable Rust compiler * Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo) * Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* [Foreman](https://github.com/Roblox/foreman) * [Rokit](https://github.com/rojo-rbx/rokit)
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.) * [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change: When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change:
@@ -37,7 +37,7 @@ bash scripts/unit-test-plugin.sh
## Documentation ## Documentation
Documentation impacts way more people than the individual lines of code we write. Documentation impacts way more people than the individual lines of code we write.
If you find any problems in documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them. If you find any problems in the documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
## Bug Reports and Feature Requests ## Bug Reports and Feature Requests
Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right. Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right.

1791
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.5.1" version = "7.7.0-rc.1"
rust-version = "1.70.0" rust-version = "1.88"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
homepage = "https://rojo.space" homepage = "https://rojo.space"
@@ -42,20 +46,22 @@ name = "build"
harness = false harness = false
[dependencies] [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 # 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_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" } # rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" } # rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "1.0.0" rbx_binary = { version = "2.0.1", features = ["unstable_text_format"] }
rbx_dom_weak = "3.0.0" rbx_dom_weak = "4.1.0"
rbx_reflection = "5.0.0" rbx_reflection = "6.1.0"
rbx_reflection_database = "1.0.3" rbx_reflection_database = "2.0.2"
rbx_xml = "1.0.0" rbx_xml = "2.0.1"
anyhow = "1.0.80" anyhow = "1.0.80"
backtrace = "0.3.69" backtrace = "0.3.69"
@@ -68,6 +74,7 @@ futures = "0.3.30"
globset = "0.4.14" globset = "0.4.14"
humantime = "2.1.0" humantime = "2.1.0"
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] } hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
hyper-tungstenite = "0.11.0"
jod-thread = "0.1.2" jod-thread = "0.1.2"
log = "0.4.21" log = "0.4.21"
num_cpus = "1.16.0" num_cpus = "1.16.0"
@@ -81,14 +88,22 @@ reqwest = { version = "0.11.24", default-features = false, features = [
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] } serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.114" serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
strum = { version = "0.27", features = ["derive"] }
toml = "0.5.11" toml = "0.5.11"
termcolor = "1.4.1" termcolor = "1.4.1"
thiserror = "1.0.57" thiserror = "1.0.57"
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] } tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] } uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] } clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15" profiling = "1.0.15"
yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
blake3 = "1.5.0"
float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"

View File

@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.70.0 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 ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -1,5 +0,0 @@
[tools]
rojo = "rojo-rbx/rojo@7.4.1"
selene = "Kampfkarren/selene@0.27.1"
stylua = "JohnnyMorganz/stylua@0.20.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

@@ -17,6 +17,10 @@ html {
line-height: 1.4; line-height: 1.4;
} }
body {
background-color: #e7e7e7
}
img { img {
max-width:100%; max-width:100%;
max-height:100%; max-height:100%;

View File

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

View File

@@ -2,4 +2,4 @@ return {
hello = function() hello = function()
print("Hello world, from {project_name}!") print("Hello world, from {project_name}!")
end, end,
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,14 @@
# memofs Changelog # memofs Changelog
## Unreleased Changes ## 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) ## 0.3.0 (2024-03-15)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830] * Changed `StdBackend` file watching component to use minimal recursive watches. [#830]

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.3.0" version = "0.3.1"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@@ -15,3 +19,6 @@ crossbeam-channel = "0.5.12"
fs-err = "2.11.0" fs-err = "2.11.0"
notify = "4.0.17" notify = "4.0.17"
serde = { version = "1.0.197", features = ["derive"] } 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> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let inner = self.inner.lock().unwrap(); 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<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap(); 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> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
let inner = self.inner.lock().unwrap(); let inner = self.inner.lock().unwrap();
@@ -228,23 +275,17 @@ impl VfsBackend for InMemoryFs {
} }
fn must_be_file<T>(path: &Path) -> io::Result<T> { fn must_be_file<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new( Err(io::Error::other(format!(
io::ErrorKind::Other, "path {} was a directory, but must be a file",
format!( path.display()
"path {} was a directory, but must be a file", )))
path.display()
),
))
} }
fn must_be_dir<T>(path: &Path) -> io::Result<T> { fn must_be_dir<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new( Err(io::Error::other(format!(
io::ErrorKind::Other, "path {} was a file, but must be a directory",
format!( path.display()
"path {} was a file, but must be a directory", )))
path.display()
),
))
} }
fn not_found<T>(path: &Path) -> io::Result<T> { fn not_found<T>(path: &Path) -> io::Result<T> {

View File

@@ -70,10 +70,14 @@ impl<T> IoResultExt<T> for io::Result<T> {
pub trait VfsBackend: sealed::Sealed + Send + 'static { pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>; fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>; fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>;
fn exists(&mut self, path: &Path) -> io::Result<bool>;
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>; fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>;
fn create_dir(&mut self, path: &Path) -> io::Result<()>;
fn create_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>; fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
fn remove_file(&mut self, path: &Path) -> io::Result<()>; fn remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>; fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf>;
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>; fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
fn watch(&mut self, path: &Path) -> io::Result<()>; fn watch(&mut self, path: &Path) -> io::Result<()>;
@@ -173,6 +177,11 @@ impl VfsInner {
Ok(Arc::new(contents_str.into())) Ok(Arc::new(contents_str.into()))
} }
fn exists<P: AsRef<Path>>(&mut self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.backend.exists(path)
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> { fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let contents = contents.as_ref(); let contents = contents.as_ref();
@@ -190,6 +199,16 @@ impl VfsInner {
Ok(dir) Ok(dir)
} }
fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir(path)
}
fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir_all(path)
}
fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> { fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref(); let path = path.as_ref();
let _ = self.backend.unwatch(path); let _ = self.backend.unwatch(path);
@@ -207,6 +226,11 @@ impl VfsInner {
self.backend.metadata(path) 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> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.backend.event_receiver() self.backend.event_receiver()
} }
@@ -326,6 +350,42 @@ impl Vfs {
self.inner.lock().unwrap().read_dir(path) self.inner.lock().unwrap().read_dir(path)
} }
/// Return whether the given path exists.
///
/// Roughly equivalent to [`std::fs::exists`][std::fs::exists].
///
/// [std::fs::exists]: https://doc.rust-lang.org/stable/std/fs/fn.exists.html
#[inline]
pub fn exists<P: AsRef<Path>>(&self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.inner.lock().unwrap().exists(path)
}
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir_all(path)
}
/// Remove a file. /// Remove a file.
/// ///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -359,6 +419,19 @@ impl Vfs {
self.inner.lock().unwrap().metadata(path) 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`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -428,6 +501,31 @@ impl VfsLock<'_> {
self.inner.read_dir(path) self.inner.read_dir(path)
} }
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir_all(path)
}
/// Remove a file. /// Remove a file.
/// ///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file]. /// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -461,6 +559,13 @@ impl VfsLock<'_> {
self.inner.metadata(path) 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`. /// Retrieve a handle to the event receiver for this `Vfs`.
#[inline] #[inline]
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -476,7 +581,9 @@ impl VfsLock<'_> {
#[cfg(test)] #[cfg(test)]
mod 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 /// https://github.com/rojo-rbx/rojo/issues/899
#[test] #[test]
@@ -492,4 +599,62 @@ mod test {
"bar\nfoo\n\n" "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::io;
use std::path::Path; use std::path::{Path, PathBuf};
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
@@ -15,45 +15,43 @@ impl NoopBackend {
impl VfsBackend for NoopBackend { impl VfsBackend for NoopBackend {
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> { fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> { fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other, }
"NoopBackend doesn't do anything",
)) fn exists(&mut self, _path: &Path) -> io::Result<bool> {
Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other, }
"NoopBackend doesn't do anything",
)) fn create_dir(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn create_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
} }
fn remove_file(&mut self, _path: &Path) -> io::Result<()> { fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> { fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> { fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::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> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -61,17 +59,11 @@ impl VfsBackend for NoopBackend {
} }
fn watch(&mut self, _path: &Path) -> io::Result<()> { fn watch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
fn unwatch(&mut self, _path: &Path) -> io::Result<()> { fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new( Err(io::Error::other("NoopBackend doesn't do anything"))
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
} }
} }

View File

@@ -63,6 +63,10 @@ impl VfsBackend for StdBackend {
fs_err::write(path, data) fs_err::write(path, data)
} }
fn exists(&mut self, path: &Path) -> io::Result<bool> {
std::fs::exists(path)
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect(); let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?; let mut entries = entries?;
@@ -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<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs_err::remove_file(path) 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> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
self.watcher_receiver.clone() self.watcher_receiver.clone()
} }
@@ -109,15 +125,13 @@ impl VfsBackend for StdBackend {
self.watches.insert(path.to_path_buf()); self.watches.insert(path.to_path_buf());
self.watcher self.watcher
.watch(path, RecursiveMode::Recursive) .watch(path, RecursiveMode::Recursive)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .map_err(io::Error::other)
} }
} }
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path); self.watches.remove(path);
self.watcher self.watcher.unwatch(path).map_err(io::Error::other)
.unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
} }
} }

View File

@@ -22,6 +22,12 @@ impl RedactionMap {
} }
} }
/// Returns the numeric ID that was assigned to the provided value,
/// if one exists.
pub fn get_id_for_value(&self, value: impl ToString) -> Option<usize> {
self.ids.get(&value.to_string()).cloned()
}
pub fn intern(&mut self, id: impl ToString) { pub fn intern(&mut self, id: impl ToString) {
let last_id = &mut self.last_id; let last_id = &mut self.last_id;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,139 +1,10 @@
-- Thanks to Tiffany352 for this base64 implementation! local EncodingService = game:GetService("EncodingService")
local floor = math.floor
local char = string.char
local function encodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
-- 3 octets become 4 hextets
for i = 1, strLen - 2, 3 do
local b1, b2, b3 = str:byte(i, i + 3)
local word = b3 + b2 * 256 + b1 * 256 * 256
local h4 = word % 64 + 1
word = floor(word / 64)
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = alphabet:sub(h4, h4)
nOut = nOut + 4
end
local remainder = strLen % 3
if remainder == 2 then
-- 16 input bits -> 3 hextets (2 full, 1 partial)
local b1, b2 = str:byte(-2, -1)
-- partial is 4 bits long, leaving 2 bits of zero padding ->
-- offset = 4
local word = b2 * 4 + b1 * 4 * 256
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = "="
elseif remainder == 1 then
-- 8 input bits -> 2 hextets (2 full, 1 partial)
local b1 = str:byte(-1, -1)
-- partial is 2 bits long, leaving 4 bits of zero padding ->
-- offset = 16
local word = b1 * 16
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = "="
out[nOut + 4] = "="
end
-- if the remainder is 0, then no work is needed
return table.concat(out, "")
end
local function decodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
local acc = 0
local nAcc = 0
local alphabetLut = {}
for i = 1, #alphabet do
alphabetLut[alphabet:sub(i, i)] = i - 1
end
-- 4 hextets become 3 octets
for i = 1, strLen do
local ch = str:sub(i, i)
local byte = alphabetLut[ch]
if byte then
acc = acc * 64 + byte
nAcc = nAcc + 1
end
if nAcc == 4 then
local b3 = acc % 256
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
out[nOut + 3] = char(b3)
nOut = nOut + 3
nAcc = 0
acc = 0
end
end
if nAcc == 3 then
-- 3 hextets -> 16 bit output
acc = acc * 64
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
elseif nAcc == 2 then
-- 2 hextets -> 8 bit output
acc = acc * 64
acc = floor(acc / 256)
acc = acc * 64
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
elseif nAcc == 1 then
error("Base64 has invalid length")
end
return table.concat(out, "")
end
return { return {
decode = decodeBase64, decode = function(input: string)
encode = encodeBase64, 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, 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 Packages = script.Parent.Parent.Packages
local HttpService = game:GetService("HttpService")
local Http = require(Packages.Http) local Http = require(Packages.Http)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Promise = require(Packages.Promise) local Promise = require(Packages.Promise)
@@ -9,7 +10,9 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
@@ -97,6 +100,7 @@ function ApiContext.new(baseUrl)
__baseUrl = baseUrl, __baseUrl = baseUrl,
__sessionId = nil, __sessionId = nil,
__messageCursor = -1, __messageCursor = -1,
__wsClient = nil,
__connected = true, __connected = true,
__activeRequests = {}, __activeRequests = {},
} }
@@ -124,6 +128,12 @@ function ApiContext:disconnect()
request:cancel() request:cancel()
end end
self.__activeRequests = {} self.__activeRequests = {}
if self.__wsClient then
Log.trace("Closing WebSocket client")
self.__wsClient:Close()
end
self.__wsClient = nil
end end
function ApiContext:setMessageCursor(index) function ApiContext:setMessageCursor(index)
@@ -205,38 +215,65 @@ function ApiContext:write(patch)
end) end)
end end
function ApiContext:retrieveMessages() function ApiContext:connectWebSocket(packetHandlers)
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor) 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() return Promise.new(function(resolve, reject)
local request = Http.get(url):catch(function(err) local success, wsClient =
if err.type == Http.Error.Kind.Timeout and self.__connected then pcall(HttpService.CreateWebStreamClient, HttpService, Enum.WebStreamClientType.WebSocket, {
return sendRequest() 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 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) end)
Log.trace("Tracking request {}", request) closed = self.__wsClient.Closed:Connect(function()
self.__activeRequests[request] = true closed:Disconnect()
errored:Disconnect()
received:Disconnect()
return request:finally(function(...) if self.__connected then
Log.trace("Cleaning up request {}", request) reject("WebSocket connection closed unexpectedly")
self.__activeRequests[request] = nil else
return ... resolve()
end
end) end)
end
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) errored = self.__wsClient.Error:Connect(function(code, msg)
if body.sessionId ~= self.__sessionId then closed:Disconnect()
return Promise.reject("Server changed ID") errored:Disconnect()
end received:Disconnect()
assert(validateApiSubscribe(body)) reject("WebSocket error: " .. code .. " - " .. msg)
end)
self:setMessageCursor(body.messageCursor)
return body.messages
end) end)
end end
@@ -252,4 +289,40 @@ function ApiContext:open(id)
end) end)
end end
function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize"):format(self.__baseUrl)
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
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(response_body))
return response_body
end)
end
function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch"):format(self.__baseUrl)
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
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(response_body))
return response_body
end)
end
return ApiContext return ApiContext

View File

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

View File

@@ -9,8 +9,70 @@ local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local Tooltip = require(Plugin.App.Components.Tooltip)
local SlicedImage = require(script.Parent.SlicedImage)
local e = Roact.createElement local e = Roact.createElement
local function VersionIndicator(props)
local updateMessage = Version.getUpdateMessage()
return Theme.with(function(theme)
return e("Frame", {
LayoutOrder = props.layoutOrder,
Size = UDim2.new(0, 0, 0, 25),
BackgroundTransparency = 1,
AutomaticSize = Enum.AutomaticSize.X,
}, {
Border = if updateMessage
then e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.Button.Bordered.Enabled.BorderColor,
transparency = props.transparency,
size = UDim2.fromScale(1, 1),
zIndex = 0,
}, {
Indicator = e("ImageLabel", {
Size = UDim2.new(0, 10, 0, 10),
ScaleType = Enum.ScaleType.Fit,
Image = Assets.Images.Circles[16],
ImageColor3 = theme.Header.LogoColor,
ImageTransparency = props.transparency,
BackgroundTransparency = 1,
Position = UDim2.new(1, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
}),
})
else nil,
Tip = if updateMessage
then e(Tooltip.Trigger, {
text = updateMessage,
delay = 0.1,
})
else nil,
VersionText = e("TextLabel", {
Text = Version.display(Config.version),
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
PaddingRight = UDim.new(0, 6),
}),
}),
})
end)
end
local function Header(props) local function Header(props)
return Theme.with(function(theme) return Theme.with(function(theme)
return e("Frame", { return e("Frame", {
@@ -29,18 +91,9 @@ local function Header(props)
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
Version = e("TextLabel", { VersionIndicator = e(VersionIndicator, {
Text = Version.display(Config.version), transparency = props.transparency,
FontFace = theme.Font.Thin, layoutOrder = 2,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 0, theme.TextSize.Body),
LayoutOrder = 2,
BackgroundTransparency = 1,
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {

View File

@@ -0,0 +1,151 @@
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local TextButton = require(Plugin.App.Components.TextButton)
local e = Roact.createElement
local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotificationnNotification")
function FullscreenNotification:init()
self.transparency, self.setTransparency = Roact.createBinding(0)
self.lifetime = self.props.timeout
end
function FullscreenNotification:dismiss()
if self.props.onClose then
self.props.onClose()
end
end
function FullscreenNotification:didMount()
self.props.soundPlayer:play(Assets.Sounds.Notification)
self.timeout = task.spawn(function()
local clock = os.clock()
local seen = false
while task.wait(1 / 10) do
local now = os.clock()
local dt = now - clock
clock = now
if not seen then
seen = StudioService.ActiveScript == nil
end
if not seen then
-- Don't run down timer before being viewed
continue
end
self.lifetime -= dt
if self.lifetime <= 0 then
self:dismiss()
break
end
end
self.timeout = nil
end)
end
function FullscreenNotification:willUnmount()
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end
function FullscreenNotification:render()
return Theme.with(function(theme)
local actionButtons = {}
if self.props.actions then
for key, action in self.props.actions do
actionButtons[key] = e(TextButton, {
text = action.text,
style = action.style,
onClick = function()
self:dismiss()
if action.onClick then
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end
end,
layoutOrder = -action.layoutOrder,
transparency = self.transparency,
})
end
end
return e("Frame", {
BackgroundColor3 = theme.BackgroundColor,
Size = UDim2.fromScale(1, 1),
ZIndex = self.props.layoutOrder,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15),
PaddingTop = UDim.new(0, 10),
PaddingBottom = UDim.new(0, 10),
}),
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Vertical,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 10),
}),
Logo = e("ImageLabel", {
ImageTransparency = self.transparency,
Image = Assets.Images.Logo,
ImageColor3 = theme.Header.LogoColor,
BackgroundTransparency = 1,
Size = UDim2.fromOffset(60, 27),
LayoutOrder = 1,
}),
Info = e("TextLabel", {
Text = self.props.text,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Notification.InfoColor,
TextTransparency = self.transparency,
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
TextWrapped = true,
BackgroundTransparency = 1,
AutomaticSize = Enum.AutomaticSize.Y,
Size = UDim2.fromScale(0.4, 0),
LayoutOrder = 2,
}),
Actions = if self.props.actions
then e("Frame", {
Size = UDim2.new(1, -40, 0, 37),
BackgroundTransparency = 1,
LayoutOrder = 3,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Buttons = Roact.createFragment(actionButtons),
})
else nil,
})
end)
end
return FullscreenNotification

View File

@@ -16,8 +16,6 @@ local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis
local e = Roact.createElement local e = Roact.createElement
local Notification = Roact.Component:extend("Notification") local Notification = Roact.Component:extend("Notification")
@@ -77,7 +75,9 @@ function Notification:didMount()
end end
function Notification:willUnmount() function Notification:willUnmount()
task.cancel(self.timeout) if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end end
function Notification:render() function Notification:render()
@@ -95,9 +95,12 @@ function Notification:render()
text = action.text, text = action.text,
style = action.style, style = action.style,
onClick = function() onClick = function()
local success, err = pcall(action.onClick, self) self:dismiss()
if not success then if action.onClick then
Log.warn("Error in notification action: " .. tostring(err)) local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end end
end, end,
layoutOrder = -action.layoutOrder, layoutOrder = -action.layoutOrder,
@@ -138,17 +141,17 @@ function Notification:render()
}, { }, {
e(BorderedContainer, { e(BorderedContainer, {
transparency = transparency, transparency = transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.fromScale(1, 1),
}, { }, {
Contents = e("Frame", { Contents = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Logo = e("ImageLabel", { Logo = e("ImageLabel", {
ImageTransparency = transparency, ImageTransparency = transparency,
Image = Assets.Images.PluginButton, Image = Assets.Images.PluginButton,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, logoSize, 0, logoSize), Size = UDim2.fromOffset(logoSize, logoSize),
Position = UDim2.new(0, 0, 0, 0), Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0), AnchorPoint = Vector2.new(0, 0),
}), }),
@@ -171,7 +174,7 @@ function Notification:render()
Actions = if self.props.actions Actions = if self.props.actions
then e("Frame", { then e("Frame", {
Size = UDim2.new(1, -40, 0, actionsY), Size = UDim2.new(1, -40, 0, actionsY),
Position = UDim2.new(1, 0, 1, 0), Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1), AnchorPoint = Vector2.new(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
@@ -198,26 +201,4 @@ function Notification:render()
end) end)
end end
local Notifications = Roact.Component:extend("Notifications") return Notification
function Notifications:render()
local notifs = {}
for id, notif in self.props.notifications do
notifs["NotifID_" .. id] = e(Notification, {
soundPlayer = self.props.soundPlayer,
text = notif.text,
timestamp = notif.timestamp,
timeout = notif.timeout,
actions = notif.actions,
layoutOrder = (notif.timestamp - baseClock),
onClose = function()
self.props.onClose(id)
end,
})
end
return Roact.createFragment(notifs)
end
return Notifications

View File

@@ -0,0 +1,66 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local e = Roact.createElement
local Notification = require(script.Notification)
local FullscreenNotification = require(script.FullscreenNotification)
local Notifications = Roact.Component:extend("Notifications")
function Notifications:render()
local popupNotifs = {}
local fullscreenNotifs = {}
for id, notif in self.props.notifications do
local targetTable = if notif.isFullscreen then fullscreenNotifs else popupNotifs
local targetComponent = if notif.isFullscreen then FullscreenNotification else Notification
targetTable["NotifID_" .. id] = e(targetComponent, {
soundPlayer = self.props.soundPlayer,
text = notif.text,
timeout = notif.timeout,
actions = notif.actions,
layoutOrder = id,
onClose = function()
if notif.onClose then
notif.onClose()
end
self.props.onClose(id)
end,
})
end
return e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Fullscreen = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
notifs = Roact.createFragment(fullscreenNotifs),
}),
Popups = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = Roact.createFragment(popupNotifs),
}),
})
end
return Notifications

View File

@@ -95,7 +95,7 @@ function DomLabel:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local color = if props.isWarning local color = if props.isWarning
then theme.Diff.Warning then theme.Diff.Warning
elseif props.patchType then theme.Diff[props.patchType] elseif props.patchType then theme.Diff.Background[props.patchType]
else theme.TextColor else theme.TextColor
local indent = (depth - 1) * 12 + 15 local indent = (depth - 1) * 12 + 15

View File

@@ -1,3 +1,4 @@
--!strict
--[[ --[[
Based on DiffMatchPatch by Neil Fraser. Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch https://github.com/google/diff-match-patch
@@ -67,8 +68,187 @@ function StringDiff.findDiffs(text1: string, text2: string): Diffs
end end
-- Cleanup the diff -- Cleanup the diff
diffs = StringDiff._cleanupSemantic(diffs)
diffs = StringDiff._reorderAndMerge(diffs) diffs = StringDiff._reorderAndMerge(diffs)
-- Remove any empty diffs
local cursor = 1
while cursor and diffs[cursor] do
if diffs[cursor].value == "" then
table.remove(diffs, cursor)
else
cursor += 1
end
end
return diffs
end
function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2
if text1Length == 0 then
-- It's simply inserting all of text2 into text1
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
end
if text2Length == 0 then
-- It's simply deleting all of text1
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
end
local longText = if text1Length > text2Length then text1 else text2
local shortText = if text1Length > text2Length then text2 else text1
local shortTextLength = #shortText
-- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end
return diffs
end
if shortTextLength == 1 then
-- Single character string
-- After the previous shortcut, the character can't be an equality
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
return StringDiff._bisect(text1, text2)
end
function StringDiff._cleanupSemantic(diffs: Diffs): Diffs
-- Reduce the number of edits by eliminating semantically trivial equalities.
local changes = false
local equalities = {} -- Stack of indices where equalities are found.
local equalitiesLength = 0 -- Keeping our own length var is faster.
local lastEquality: string? = nil
-- Always equal to diffs[equalities[equalitiesLength]].value
local pointer = 1 -- Index of current position.
-- Number of characters that changed prior to the equality.
local length_insertions1 = 0
local length_deletions1 = 0
-- Number of characters that changed after the equality.
local length_insertions2 = 0
local length_deletions2 = 0
while diffs[pointer] do
if diffs[pointer].actionType == StringDiff.ActionTypes.Equal then -- Equality found.
equalitiesLength = equalitiesLength + 1
equalities[equalitiesLength] = pointer
length_insertions1 = length_insertions2
length_deletions1 = length_deletions2
length_insertions2 = 0
length_deletions2 = 0
lastEquality = diffs[pointer].value
else -- An insertion or deletion.
if diffs[pointer].actionType == StringDiff.ActionTypes.Insert then
length_insertions2 = length_insertions2 + #diffs[pointer].value
else
length_deletions2 = length_deletions2 + #diffs[pointer].value
end
-- Eliminate an equality that is smaller or equal to the edits on both
-- sides of it.
if
lastEquality
and (#lastEquality <= math.max(length_insertions1, length_deletions1))
and (#lastEquality <= math.max(length_insertions2, length_deletions2))
then
-- Duplicate record.
table.insert(
diffs,
equalities[equalitiesLength],
{ actionType = StringDiff.ActionTypes.Delete, value = lastEquality }
)
-- Change second copy to insert.
diffs[equalities[equalitiesLength] + 1].actionType = StringDiff.ActionTypes.Insert
-- Throw away the equality we just deleted.
equalitiesLength = equalitiesLength - 1
-- Throw away the previous equality (it needs to be reevaluated).
equalitiesLength = equalitiesLength - 1
pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0
length_insertions1, length_deletions1 = 0, 0 -- Reset the counters.
length_insertions2, length_deletions2 = 0, 0
lastEquality = nil
changes = true
end
end
pointer = pointer + 1
end
-- Normalize the diff.
if changes then
StringDiff._reorderAndMerge(diffs)
end
StringDiff._cleanupSemanticLossless(diffs)
-- Find any overlaps between deletions and insertions.
-- e.g: <del>abcxxx</del><ins>xxxdef</ins>
-- -> <del>abc</del>xxx<ins>def</ins>
-- e.g: <del>xxxabc</del><ins>defxxx</ins>
-- -> <ins>def</ins>xxx<del>abc</del>
-- Only extract an overlap if it is as big as the edit ahead or behind it.
pointer = 2
while diffs[pointer] do
if
diffs[pointer - 1].actionType == StringDiff.ActionTypes.Delete
and diffs[pointer].actionType == StringDiff.ActionTypes.Insert
then
local deletion = diffs[pointer - 1].value
local insertion = diffs[pointer].value
local overlap_length1 = StringDiff._commonOverlap(deletion, insertion)
local overlap_length2 = StringDiff._commonOverlap(insertion, deletion)
if overlap_length1 >= overlap_length2 then
if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then
-- Overlap found. Insert an equality and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(insertion, 1, overlap_length1) }
)
diffs[pointer - 1].value = string.sub(deletion, 1, #deletion - overlap_length1)
diffs[pointer + 1].value = string.sub(insertion, overlap_length1 + 1)
pointer = pointer + 1
end
else
if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then
-- Reverse overlap found.
-- Insert an equality and swap and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(deletion, 1, overlap_length2) }
)
diffs[pointer - 1] = {
actionType = StringDiff.ActionTypes.Insert,
value = string.sub(insertion, 1, #insertion - overlap_length2),
}
diffs[pointer + 1] = {
actionType = StringDiff.ActionTypes.Delete,
value = string.sub(deletion, overlap_length2 + 1),
}
pointer = pointer + 1
end
end
pointer = pointer + 1
end
pointer = pointer + 1
end
return diffs return diffs
end end
@@ -124,51 +304,164 @@ function StringDiff._sharedSuffix(text1: string, text2: string): number
return pointerMid return pointerMid
end end
function StringDiff._computeDiff(text1: string, text2: string): Diffs function StringDiff._commonOverlap(text1: string, text2: string): number
-- Assumes that the prefix and suffix have already been trimmed off -- Determine if the suffix of one string is the prefix of another.
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2 -- Cache the text lengths to prevent multiple calls.
local text1_length = #text1
if text1Length == 0 then local text2_length = #text2
-- It's simply inserting all of text2 into text1 -- Eliminate the null case.
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } } 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 end
if text2Length == 0 then -- Start by looking for a single character match
-- It's simply deleting all of text1 -- and increase length until no match is found.
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } } -- Performance analysis: https://neil.fraser.name/news/2010/11/04/
end local best = 0
local length = 1
local longText = if text1Length > text2Length then text1 else text2 while true do
local shortText = if text1Length > text2Length then text2 else text1 local pattern = string.sub(text1, text_length - length + 1)
local shortTextLength = #shortText local found = string.find(text2, pattern, 1, true)
if found == nil then
-- Shortcut if the shorter string exists entirely inside the longer one return best
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end end
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 end
if shortTextLength == 1 then -- Each port of this function behaves slightly differently due to
-- Single character string -- subtle differences in each language's definition of things like
-- After the previous shortcut, the character can't be an equality -- 'whitespace'. Since this function's purpose is largely cosmetic,
return { -- the choice has been made to use each language's native features
{ actionType = StringDiff.ActionTypes.Delete, value = text1 }, -- rather than force total conformity.
{ actionType = StringDiff.ActionTypes.Insert, value = text2 }, local char1 = string.sub(one, -1)
} local char2 = string.sub(two, 1, 1)
end 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 end
function StringDiff._bisect(text1: string, text2: string): Diffs function StringDiff._bisect(text1: string, text2: string): Diffs

View File

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

View File

@@ -93,7 +93,7 @@ function Array:render()
e("Frame", { e("Frame", {
Size = UDim2.new(1, 0, 0, 25), Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency, BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if patchType == "Remain" then theme.Diff.Row else theme.Diff[patchType], BackgroundColor3 = theme.Diff.Background[patchType],
BorderSizePixel = 0, BorderSizePixel = 0,
LayoutOrder = i, LayoutOrder = i,
}, { }, {

View File

@@ -91,9 +91,7 @@ function Dictionary:render()
LayoutOrder = order, LayoutOrder = order,
BorderSizePixel = 0, BorderSizePixel = 0,
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency, BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if line.patchType == "Remain" BackgroundColor3 = theme.Diff.Background[line.patchType],
then theme.Diff.Row
else theme.Diff[line.patchType],
}, { }, {
DiffIcon = if line.patchType ~= "Remain" DiffIcon = if line.patchType ~= "Remain"
then e("ImageLabel", { then e("ImageLabel", {
@@ -114,7 +112,7 @@ function Dictionary:render()
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Main, FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.Diff.Text[line.patchType],
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
}), }),
OldValue = e("Frame", { OldValue = e("Frame", {
@@ -125,7 +123,7 @@ function Dictionary:render()
e(DisplayValue, { e(DisplayValue, {
value = oldValue, value = oldValue,
transparency = self.props.transparency, transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor, textColor = theme.Diff.Text[line.patchType],
}), }),
}), }),
NewValue = e("Frame", { NewValue = e("Frame", {
@@ -136,7 +134,7 @@ function Dictionary:render()
e(DisplayValue, { e(DisplayValue, {
value = newValue, value = newValue,
transparency = self.props.transparency, transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor, textColor = theme.Diff.Text[line.patchType],
}), }),
}), }),
}) })

View File

@@ -216,7 +216,7 @@ function Trigger:managePopup()
return return
end end
self.showDelayThread = task.delay(DELAY, function() self.showDelayThread = task.delay(self.props.delay or DELAY, function()
self.props.context.addTip(self.id, { self.props.context.addTip(self.id, {
Text = self.props.text, Text = self.props.text,
Position = self:getMousePos(), Position = self:getMousePos(),

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local Spinner = require(Plugin.App.Components.Spinner) local Spinner = require(Plugin.App.Components.Spinner)
local e = Roact.createElement local e = Roact.createElement
@@ -11,11 +13,35 @@ local e = Roact.createElement
local ConnectingPage = Roact.Component:extend("ConnectingPage") local ConnectingPage = Roact.Component:extend("ConnectingPage")
function ConnectingPage:render() function ConnectingPage:render()
return e(Spinner, { return Theme.with(function(theme)
position = UDim2.new(0.5, 0, 0.5, 0), return e("Frame", {
anchorPoint = Vector2.new(0.5, 0.5), Size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency, 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 end
return ConnectingPage return ConnectingPage

View File

@@ -8,6 +8,7 @@ local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync) local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
@@ -38,6 +39,7 @@ function Error:render()
return UDim2.new(1, 0, 0, math.min(outerSize.Y, maximumSize.Y)) return UDim2.new(1, 0, 0, math.min(outerSize.Y, maximumSize.Y))
end), end),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = self.props.layoutOrder,
}, { }, {
ScrollingFrame = e(ScrollingFrame, { ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
@@ -108,16 +110,21 @@ function ErrorPage:render()
self.setContainerSize(object.AbsoluteSize) self.setContainerSize(object.AbsoluteSize)
end, end,
}, { }, {
Error = e(Error, { Header = e(Header, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
}), }),
Error = e(Error, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
transparency = self.props.transparency,
layoutOrder = 2,
}),
Buttons = e("Frame", { Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 35), Size = UDim2.new(1, 0, 0, 35),
LayoutOrder = 2, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Close = e(TextButton, { Close = e(TextButton, {

View File

@@ -27,6 +27,7 @@ end
local invertedLevels = invertTbl(Log.Level) local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" } local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local syncReminderModes = { "None", "Notify", "Fullscreen" }
local function Navbar(props) local function Navbar(props)
return Theme.with(function(theme) return Theme.with(function(theme)
@@ -93,6 +94,14 @@ function SettingsPage:render()
contentSize = self.contentSize, contentSize = self.contentSize,
transparency = self.props.transparency, transparency = self.props.transparency,
}, { }, {
AutoReconnect = e(Setting, {
id = "autoReconnect",
name = "Auto Reconnect",
description = "Reconnect to server on place open if the served project matches the last sync to the place",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
ShowNotifications = e(Setting, { ShowNotifications = e(Setting, {
id = "showNotifications", id = "showNotifications",
name = "Show Notifications", name = "Show Notifications",
@@ -101,13 +110,26 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
SyncReminder = e(Setting, { SyncReminderMode = e(Setting, {
id = "syncReminder", id = "syncReminderMode",
name = "Sync Reminder", name = "Sync Reminder",
description = "Notify to sync when opening a place that has previously been synced", description = "What type of reminders you receive for syncing your project",
transparency = self.props.transparency, transparency = self.props.transparency,
visible = Settings:getBinding("showNotifications"),
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
visible = Settings:getBinding("showNotifications"),
options = syncReminderModes,
}),
SyncReminderPolling = e(Setting, {
id = "syncReminderPolling",
name = "Sync Reminder Polling",
description = "Look for available sync servers periodically",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = Settings:getBindings("syncReminderMode", "showNotifications"):map(function(values)
return values.syncReminderMode ~= "None" and values.showNotifications
end),
}), }),
ConfirmationBehavior = e(Setting, { ConfirmationBehavior = e(Setting, {
@@ -159,6 +181,14 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
EnableSyncFallback = e(Setting, {
id = "enableSyncFallback",
name = "Enable Sync Fallback",
description = "Whether Instances that fail to sync are remade as a fallback. If this is enabled, Instances may be destroyed and remade when syncing.",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
CheckForUpdates = e(Setting, { CheckForUpdates = e(Setting, {
id = "checkForUpdates", id = "checkForUpdates",
name = "Check For Updates", name = "Check For Updates",

View File

@@ -165,12 +165,29 @@ function StudioProvider:updateTheme()
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground), BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
}, },
Diff = { Diff = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text -- Very bright different colors in case some places were not updated to use
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45), -- the new background diff colors.
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29), Add = Color3.fromRGB(255, 0, 255),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160), Remove = Color3.fromRGB(255, 0, 255),
Edit = Color3.fromRGB(255, 0, 255),
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText), Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText), Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
Background = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Text = {
Add = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remove = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Edit = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
}, },
ConnectionDetails = { ConnectionDetails = {
ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText), ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),

View File

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

View File

@@ -9,6 +9,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
@@ -27,7 +28,7 @@ local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local Page = require(script.Page) local Page = require(script.Page)
local Notifications = require(script.Notifications) local Notifications = require(script.Components.Notifications)
local Tooltip = require(script.Components.Tooltip) local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToolbar = require(script.Components.Studio.StudioToolbar)
@@ -78,17 +79,18 @@ function App:init()
action action
) )
) )
local dismissNotif = self:addNotification( local dismissNotif = self:addNotification({
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action), text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10, timeout = 10,
{ onClose = function()
cleanup()
end,
actions = {
Restore = { Restore = {
text = "Restore", text = "Restore",
style = "Solid", style = "Solid",
layoutOrder = 1, layoutOrder = 1,
onClick = function(notification) onClick = function()
cleanup()
notification:dismiss()
ChangeHistoryService:Redo() ChangeHistoryService:Redo()
end, end,
}, },
@@ -96,13 +98,9 @@ function App:init()
text = "Dismiss", text = "Dismiss",
style = "Bordered", style = "Bordered",
layoutOrder = 2, layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
}, },
} },
) })
undoConnection = ChangeHistoryService.OnUndo:Once(function() undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch -- Our notif is now out of date- redoing will not restore the patch
@@ -142,42 +140,20 @@ function App:init()
if RunService:IsEdit() then if RunService:IsEdit() then
self:checkForUpdates() self:checkForUpdates()
if self:startSyncReminderPolling()
Settings:get("syncReminder") self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
and self.serveSession == nil if enabled then
and self:getPriorSyncInfo().timestamp ~= nil self:startSyncReminderPolling()
and (self:isSyncLockAvailable()) else
then self:stopSyncReminderPolling()
local syncInfo = self:getPriorSyncInfo() end
local timeSinceSync = timeUtil.elapsedToText(os.time() - syncInfo.timestamp) end)
local syncDetail = if syncInfo.projectName
then `project '{syncInfo.projectName}'`
else `{syncInfo.host or Config.defaultHost}:{syncInfo.port or Config.defaultPort}`
self:addNotification( self:tryAutoReconnect():andThen(function(didReconnect)
`You synced {syncDetail} to this place {timeSinceSync}. Would you like to reconnect?`, if not didReconnect then
300, self:checkSyncReminder()
{ end
Connect = { end)
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
notification:dismiss()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
}
)
end
end end
if self:isAutoConnectPlaytestServerAvailable() then if self:isAutoConnectPlaytestServerAvailable() then
@@ -198,21 +174,30 @@ function App:init()
end end
function App:willUnmount() function App:willUnmount()
self:endSession()
self.waypointConnection:Disconnect() self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy() self.confirmationBindable:Destroy()
self.disconnectUpdatesCheckChanged() self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged() self.disconnectPrereleasesCheckChanged()
if self.disconnectSyncReminderPollingChanged then
self.disconnectSyncReminderPollingChanged()
end
self:stopSyncReminderPolling()
self.autoConnectPlaytestServerListener() self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo() self:clearRunningConnectionInfo()
end end
function App:addNotification( function App:addNotification(notif: {
text: string, text: string,
isFullscreen: boolean?,
timeout: number?, timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }? actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> ()? } }?,
) onClose: (any) -> ()?,
})
if not Settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
@@ -220,17 +205,17 @@ function App:addNotification(
self.notifId += 1 self.notifId += 1
local id = self.notifId local id = self.notifId
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = { local notifications = table.clone(prevState.notifications)
text = text, notifications[id] = Dictionary.merge({
timestamp = DateTime.now().UnixTimestampMillis, timeout = notif.timeout or 5,
timeout = timeout or 3, isFullscreen = notif.isFullscreen or false,
actions = actions, }, notif)
}
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
return function() return function()
self:closeNotification(id) self:closeNotification(id)
@@ -242,46 +227,32 @@ function App:closeNotification(id: number)
return return
end end
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = nil local notifications = table.clone(prevState.notifications)
notifications[id] = nil
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
end end
function App:checkForUpdates() function App:checkForUpdates()
if not Settings:get("checkForUpdates") then local updateMessage = Version.getUpdateMessage()
return
end
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil if updateMessage then
local latestCompatibleVersion = Version.retrieveLatestCompatible({ self:addNotification({
version = Config.version, text = updateMessage,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"), timeout = 500,
}) actions = {
if not latestCompatibleVersion then Dismiss = {
return text = "Dismiss",
end style = "Bordered",
layoutOrder = 2,
self:addNotification( },
string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
),
500,
{
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
}, },
} })
) end
end end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? } function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? }
@@ -402,8 +373,158 @@ function App:releaseSyncLock()
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value) Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end end
function App:findActiveServer()
local host, port = self:getHostAndPort()
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
Log.trace("Checking for active sync server at {}", baseUrl)
local apiContext = ApiContext.new(baseUrl)
return apiContext:connect():andThen(function(serverInfo)
apiContext:disconnect()
return serverInfo, host, port
end)
end
function App:tryAutoReconnect()
if not Settings:get("autoReconnect") then
return Promise.resolve(false)
end
local priorSyncInfo = self:getPriorSyncInfo()
if not priorSyncInfo.projectName then
Log.trace("No prior sync info found, skipping auto-reconnect")
return Promise.resolve(false)
end
return self:findActiveServer()
:andThen(function(serverInfo)
-- change
if serverInfo.projectName == priorSyncInfo.projectName then
Log.trace("Auto-reconnect found matching server, reconnecting...")
self:addNotification({
text = `Auto-reconnect discovered project '{serverInfo.projectName}'...`,
})
self:startSession()
return true
end
Log.trace("Auto-reconnect found different server, not reconnecting")
return false
end)
:catch(function()
Log.trace("Auto-reconnect did not find a server, not reconnecting")
return false
end)
end
function App:checkSyncReminder()
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
if self.serveSession ~= nil or not self:isSyncLockAvailable() then
-- Already syncing or cannot sync, no reason to remind
return
end
local priorSyncInfo = self:getPriorSyncInfo()
self:findActiveServer()
:andThen(function(serverInfo, host, port)
self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`
)
end)
:catch(function()
if priorSyncInfo.timestamp and priorSyncInfo.projectName then
-- We didn't find an active server,
-- but this place has a prior sync
-- so we should remind the user to serve
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`
)
end
end)
end
function App:startSyncReminderPolling()
if
self.syncReminderPollingThread ~= nil
or Settings:get("syncReminderMode") == "None"
or not Settings:get("syncReminderPolling")
then
return
end
Log.trace("Starting sync reminder polling thread")
self.syncReminderPollingThread = task.spawn(function()
while task.wait(30) do
if self.syncReminderPollingThread == nil then
-- The polling thread was stopped, so exit
return
end
if self.dismissSyncReminder then
-- There is already a sync reminder being shown
task.wait(5)
continue
end
self:checkSyncReminder()
end
end)
end
function App:stopSyncReminderPolling()
if self.syncReminderPollingThread then
Log.trace("Stopping sync reminder polling thread")
task.cancel(self.syncReminderPollingThread)
self.syncReminderPollingThread = nil
end
end
function App:sendSyncReminder(message: string)
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
self.dismissSyncReminder = self:addNotification({
text = message,
timeout = 120,
isFullscreen = Settings:get("syncReminderMode") == "Fullscreen",
onClose = function()
self.dismissSyncReminder = nil
end,
actions = {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
},
},
})
end
function App:isAutoConnectPlaytestServerAvailable() function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunMode() return RunService:IsRunning()
and RunService:IsStudio()
and RunService:IsServer() and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer") and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl") and workspace:GetAttribute("__Rojo_ConnectionUrl")
@@ -451,7 +572,10 @@ function App:startSession()
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg) Log.warn(msg)
self:addNotification(msg, 10) self:addNotification({
text = msg,
timeout = 10,
})
self:setState({ self:setState({
appStatus = AppStatus.Error, appStatus = AppStatus.Error,
errorMessage = msg, errorMessage = msg,
@@ -473,60 +597,59 @@ function App:startSession()
twoWaySync = Settings:get("twoWaySync"), twoWaySync = Settings:get("twoWaySync"),
}) })
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap) serveSession:setUpdateLoadingTextCallback(function(text: string)
self:setState({
connectingText = text,
})
end)
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch -- Build new tree for patch
self:setState({ self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }), patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
}) })
end) end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch) self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata local now = DateTime.now().UnixTimestamp
self:setState(function(prevState) self:setState(function(prevState)
local oldPatchData = prevState.patchData
local newPatchData = {
patch = patch,
unapplied = unappliedPatch,
timestamp = now,
}
if PatchSet.isEmpty(patch) then
-- Keep existing patch info, but use new timestamp
newPatchData.patch = oldPatchData.patch
newPatchData.unapplied = oldPatchData.unapplied
elseif now - oldPatchData.timestamp < 2 then
-- Patches that apply in the same second are combined for human clarity
newPatchData.patch = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.patch, patch)
newPatchData.unapplied = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.unapplied, unappliedPatch)
end
return { return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch), patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
patchData = newPatchData,
} }
end) end)
end) end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = DateTime.now().UnixTimestamp
local old = self.state.patchData
if PatchSet.isEmpty(patch) then
-- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return
end
if now - old.timestamp < 2 then
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then if status == ServeSession.Status.Connecting then
if self.dismissSyncReminder then
self.dismissSyncReminder()
self.dismissSyncReminder = nil
end
self:setState({ self:setState({
appStatus = AppStatus.Connecting, appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Connecting to session...") self:addNotification({
text = "Connecting to session...",
})
elseif status == ServeSession.Status.Connected then elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true self.knownProjects[details] = true
self:setPriorSyncInfo(host, port, details) self:setPriorSyncInfo(host, port, details)
@@ -539,7 +662,9 @@ function App:startSession()
address = address, address = address,
toolbarIcon = Assets.Images.PluginButtonConnected, toolbarIcon = Assets.Images.PluginButtonConnected,
}) })
self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5) self:addNotification({
text = string.format("Connected to session '%s' at %s.", details, address),
})
elseif status == ServeSession.Status.Disconnected then elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock() self:releaseSyncLock()
@@ -562,13 +687,19 @@ function App:startSession()
errorMessage = tostring(details), errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning, toolbarIcon = Assets.Images.PluginButtonWarning,
}) })
self:addNotification(tostring(details), 10) self:addNotification({
text = tostring(details),
timeout = 10,
})
else else
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Disconnected from session.") self:addNotification({
text = "Disconnected from session.",
timeout = 10,
})
end end
end end
end) end)
@@ -636,23 +767,25 @@ function App:startSession()
end end
end end
self:setState({
connectingText = "Computing diff view...",
})
self:setState({ self:setState({
appStatus = AppStatus.Confirming, appStatus = AppStatus.Confirming,
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
confirmData = { confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo, serverInfo = serverInfo,
}, },
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification( self:addNotification({
string.format( text = string.format(
"Please accept%sor abort the initializing sync session.", "Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " " Settings:get("twoWaySync") and ", reject, " or " "
), ),
7 timeout = 7,
) })
return self.confirmationEvent:Wait() return self.confirmationEvent:Wait()
end) end)
@@ -751,6 +884,7 @@ function App:render()
ConfirmingPage = createPageElement(AppStatus.Confirming, { ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData, confirmData = self.state.confirmData,
patchTree = self.state.patchTree,
createPopup = not self.state.guiEnabled, createPopup = not self.state.guiEnabled,
onAbort = function() onAbort = function()
@@ -764,7 +898,9 @@ function App:render()
end, end,
}), }),
Connecting = createPageElement(AppStatus.Connecting), Connecting = createPageElement(AppStatus.Connecting, {
text = self.state.connectingText,
}),
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,
@@ -813,19 +949,7 @@ function App:render()
ResetOnSpawn = false, ResetOnSpawn = false,
DisplayOrder = 100, DisplayOrder = 100,
}, { }, {
layout = e("UIListLayout", { Notifications = e(Notifications, {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications, notifications = self.state.notifications,
onClose = function(id) onClose = function(id)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -282,6 +282,22 @@ function PatchSet.assign(target, ...)
return target return target
end end
function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
table.insert(idList, id)
end
return table.freeze(idList)
end
function PatchSet.updatedIdList(patchSet): { string }
local idList = table.create(#patchSet.updated)
for _, item in patchSet.updated do
table.insert(idList, item.id)
end
return table.freeze(idList)
end
--[[ --[[
Create a list of human-readable statements summarizing the contents of this Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users. patch, intended to be displayed to users.

View File

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

View File

@@ -5,8 +5,6 @@
Patches can come from the server or be generated by the client. Patches can come from the server or be generated by the client.
]] ]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Packages = script.Parent.Parent.Parent.Packages local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
@@ -20,13 +18,6 @@ local reifyInstance, applyDeferredRefs = reify.reifyInstance, reify.applyDeferre
local setProperty = require(script.Parent.setProperty) local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch) local function applyPatch(instanceMap, patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp)
if not historyRecording then
-- There can only be one recording at a time
Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.")
end
-- Tracks any portions of the patch that could not be applied to the DOM. -- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty() local unappliedPatch = PatchSet.newEmpty()
@@ -73,9 +64,6 @@ local function applyPatch(instanceMap, patch)
if parentInstance == nil then if parentInstance == nil then
-- This would be peculiar. If you create an instance with no -- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all? -- parent, were you supposed to create it at all?
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
invariant( invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}", "Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id, id,
@@ -244,10 +232,6 @@ local function applyPatch(instanceMap, patch)
end end
end end
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch) applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch return unappliedPatch

View File

@@ -25,18 +25,26 @@ local function trueEquals(a, b): boolean
return true return true
end end
-- Treat nil and { Ref = "000...0" } as equal
if
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
then
return true
end
local typeA, typeB = typeof(a), typeof(b) local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality -- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then if typeA == "table" and typeB == "table" then
local checkedKeys = {} local checkedKeys = {}
for key, value in pairs(a) do for key, value in a do
checkedKeys[key] = true checkedKeys[key] = true
if not trueEquals(value, b[key]) then if not trueEquals(value, b[key]) then
return false return false
end end
end end
for key, value in pairs(b) do for key, value in b do
if checkedKeys[key] then if checkedKeys[key] then
continue continue
end end

View File

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

View File

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

View File

@@ -5,9 +5,6 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Timer = require(Plugin.Timer) local Timer = require(Plugin.Timer)
@@ -22,78 +19,17 @@ function Reconciler.new(instanceMap)
local self = { local self = {
-- Tracks all of the instances known by the reconciler by ID. -- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap, __instanceMap = instanceMap,
__precommitCallbacks = {},
__postcommitCallbacks = {},
} }
return setmetatable(self, Reconciler) return setmetatable(self, Reconciler)
end end
function Reconciler:hookPrecommit(callback: (patch: any, instanceMap: any) -> ()): () -> ()
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unappliedPatch: any) -> ()): () -> ()
table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end
function Reconciler:applyPatch(patch) function Reconciler:applyPatch(patch)
Timer.start("Reconciler:applyPatch") Timer.start("Reconciler:applyPatch")
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
Timer.stop()
Timer.start("apply")
local unappliedPatch = applyPatch(self.__instanceMap, patch) local unappliedPatch = applyPatch(self.__instanceMap, patch)
Timer.stop()
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do
task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end)
end
Timer.stop()
Timer.stop() Timer.stop()
return unappliedPatch return unappliedPatch
end end

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local SerializationService = game:GetService("SerializationService")
local Selection = game:GetService("Selection")
local Packages = script.Parent.Parent.Packages local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt) local Fmt = require(Packages.Fmt)
local t = require(Packages.t) local t = require(Packages.t)
local Promise = require(Packages.Promise) local Promise = require(Packages.Promise)
local Timer = require(script.Parent.Timer)
local ChangeBatcher = require(script.Parent.ChangeBatcher) local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate) local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
@@ -44,6 +48,12 @@ local function debugPatch(object)
end) end)
end end
local function attemptReparent(instance, parent)
return pcall(function()
instance.Parent = parent
end)
end
local ServeSession = {} local ServeSession = {}
ServeSession.__index = ServeSession ServeSession.__index = ServeSession
@@ -95,6 +105,9 @@ function ServeSession.new(options)
__changeBatcher = changeBatcher, __changeBatcher = changeBatcher,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__connections = connections, __connections = connections,
__precommitCallbacks = {},
__postcommitCallbacks = {},
__updateLoadingText = function() end,
} }
setmetatable(self, ServeSession) setmetatable(self, ServeSession)
@@ -125,26 +138,83 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback self.__userConfirmCallback = callback
end end
function ServeSession:hookPrecommit(callback) function ServeSession:setUpdateLoadingTextCallback(callback)
return self.__reconciler:hookPrecommit(callback) self.__updateLoadingText = callback
end 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
as parameters.
]=]
function ServeSession:hookPrecommit(callback)
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
--[=[
Hooks a function to run after patch application.
The provided function is called with the applied patch, the current
InstanceMap, and a PatchSet containing any unapplied changes.
]=]
function ServeSession:hookPostcommit(callback) function ServeSession:hookPostcommit(callback)
return self.__reconciler:hookPostcommit(callback) table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end end
function ServeSession:start() function ServeSession:start()
self:__setStatus(Status.Connecting) self:__setStatus(Status.Connecting)
self:setLoadingText("Connecting to server...")
self.__apiContext self.__apiContext
:connect() :connect()
:andThen(function(serverInfo) :andThen(function(serverInfo)
self:__applyGameAndPlaceId(serverInfo) self:setLoadingText("Loading initial data from server...")
return self:__initialSync(serverInfo):andThen(function() return self:__initialSync(serverInfo):andThen(function()
self:setLoadingText("Starting sync loop...")
self:__setStatus(Status.Connected, serverInfo.projectName) self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
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)
end) end)
:catch(function(err) :catch(function(err)
@@ -207,6 +277,194 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId) self.__apiContext:open(scriptId)
end end
function ServeSession:__replaceInstances(idList)
if #idList == 0 then
return true, PatchSet.newEmpty()
end
-- It would be annoying if selection went away, so we try to preserve it.
local selection = Selection:Get()
local selectionMap = {}
for i, instance in selection do
selectionMap[instance] = i
end
-- TODO: Should we do this in multiple requests so we can more granularly mark failures?
local modelSuccess, replacements = self.__apiContext
:serialize(idList)
:andThen(function(response)
Log.debug("Deserializing results from serialize endpoint")
local objects = SerializationService:DeserializeInstancesAsync(response.modelContents)
if not objects[1] then
return Promise.reject("Serialize endpoint did not deserialize into any Instances")
end
if #objects[1]:GetChildren() ~= #idList then
return Promise.reject("Serialize endpoint did not return the correct number of Instances")
end
local instanceMap = {}
for _, item in objects[1]:GetChildren() do
instanceMap[item.Name] = item.Value
end
return instanceMap
end)
:await()
local refSuccess, refPatch = self.__apiContext
:refPatch(idList)
:andThen(function(response)
return response.patch
end)
:await()
if not (modelSuccess and refSuccess) then
return false
end
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
-- 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
-- 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`.
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
-- which might matter for some use cases.
selection[selectionMap[oldInstance]] = replacement
end
end
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, refPatch)
if patchApplySuccess then
Selection:Set(selection)
return true, unappliedPatch
else
error(unappliedPatch)
end
end
function ServeSession:__applyPatch(patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp)
if not historyRecording then
-- There can only be one recording at a time
Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.")
end
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
Timer.stop()
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, patch)
if not patchApplySuccess then
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
-- This might make a weird stack trace but the only way applyPatch can
-- fail is if a bug occurs so it's probably fine.
error(unappliedPatch)
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)
Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)")
local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList)
Timer.stop()
Log.debug("ServeSession:__replaceInstances(unappliedPatch.updated)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.updated)")
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(unappliedPatch, unappliedAddedRefs)
end
if updateSuccess then
table.clear(unappliedPatch.updated)
PatchSet.assign(unappliedPatch, unappliedUpdateRefs)
end
end
if not PatchSet.isEmpty(unappliedPatch) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do
task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end)
end
Timer.stop()
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
end
function ServeSession:__initialSync(serverInfo) function ServeSession:__initialSync(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody) return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of -- Tell the API Context that we're up-to-date with the version of
@@ -216,11 +474,13 @@ function ServeSession:__initialSync(serverInfo)
-- For any instances that line up with the Rojo server's view, start -- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler. -- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs") Log.trace("Matching existing Roblox instances to Rojo IDs")
self:setLoadingText("Hydrating instance map...")
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us -- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like. -- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...") Log.trace("Computing changes that plugin needs to make to catch up to server...")
self:setLoadingText("Finding differences between server and Studio...")
local success, catchUpPatch = local success, catchUpPatch =
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game)
@@ -281,15 +541,7 @@ function ServeSession:__initialSync(serverInfo)
return self.__apiContext:write(inversePatch) return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch) self:__applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
return Promise.resolve() return Promise.resolve()
else else
return Promise.reject("Invalid user decision: " .. userDecision) return Promise.reject("Invalid user decision: " .. userDecision)
@@ -297,47 +549,6 @@ function ServeSession:__initialSync(serverInfo)
end) end)
end end
function ServeSession:__mainSyncLoop()
return Promise.new(function(resolve, reject)
while self.__status == Status.Connected do
local success, result = self.__apiContext
:retrieveMessages()
:andThen(function(messages)
if self.__status == Status.Disconnected then
-- In the time it took to retrieve messages, we disconnected
-- so we just resolve immediately without patching anything
return
end
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in messages do
local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
end
end)
:await()
if self.__status == Status.Disconnected then
-- If we are no longer connected after applying, we stop silently
-- without checking for errors as they are no longer relevant
break
elseif success == false then
reject(result)
end
end
-- We are no longer connected, so we resolve the promise
resolve()
end)
end
function ServeSession:__stopInternal(err) function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err) self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect() self.__apiContext:disconnect()

View File

@@ -12,12 +12,15 @@ local Roact = require(Packages.Roact)
local defaultSettings = { local defaultSettings = {
openScriptsExternally = false, openScriptsExternally = false,
twoWaySync = false, twoWaySync = false,
autoReconnect = false,
showNotifications = true, showNotifications = true,
syncReminder = true, enableSyncFallback = true,
syncReminderMode = "Notify" :: "None" | "Notify" | "Fullscreen",
syncReminderPolling = true,
checkForUpdates = true, checkForUpdates = true,
checkForPrereleases = false, checkForPrereleases = false,
autoConnectPlaytestServer = false, autoConnectPlaytestServer = false,
confirmationBehavior = "Initial", confirmationBehavior = "Initial" :: "Never" | "Initial" | "Large Changes" | "Unlisted PlaceId",
largeChangesConfirmationThreshold = 5, largeChangesConfirmationThreshold = 5,
playSounds = true, playSounds = true,
typecheckingEnabled = false, typecheckingEnabled = false,
@@ -108,4 +111,14 @@ function Settings:getBinding(name)
return bind return bind
end end
function Settings:getBindings(...: string)
local bindings = {}
for i = 1, select("#", ...) do
local source = select(i, ...)
bindings[source] = self:getBinding(source)
end
return Roact.joinBindings(bindings)
end
return Settings return Settings

View File

@@ -49,12 +49,31 @@ local ApiReadResponse = t.interface({
instances = t.map(RbxId, ApiInstance), instances = t.map(RbxId, ApiInstance),
}) })
local ApiSubscribeResponse = t.interface({ local SocketPacketType = t.union(t.literal("messages"))
sessionId = t.string,
local MessagesPacket = t.interface({
messageCursor = t.number, messageCursor = t.number,
messages = t.array(ApiSubscribeMessage), messages = t.array(ApiSubscribeMessage),
}) })
local SocketPacketBody = t.union(MessagesPacket)
local ApiSocketPacket = t.interface({
sessionId = t.string,
packetType = SocketPacketType,
body = SocketPacketBody,
})
local ApiSerializeResponse = t.interface({
sessionId = t.string,
modelContents = t.buffer,
})
local ApiRefPatchResponse = t.interface({
sessionId = t.string,
patch = ApiSubscribeMessage,
})
local ApiError = t.interface({ local ApiError = t.interface({
kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")), kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")),
details = t.string, details = t.string,
@@ -75,13 +94,15 @@ return strict("Types", {
ApiInfoResponse = ApiInfoResponse, ApiInfoResponse = ApiInfoResponse,
ApiReadResponse = ApiReadResponse, ApiReadResponse = ApiReadResponse,
ApiSubscribeResponse = ApiSubscribeResponse, ApiSocketPacket = ApiSocketPacket,
ApiError = ApiError, ApiError = ApiError,
ApiInstance = ApiInstance, ApiInstance = ApiInstance,
ApiInstanceUpdate = ApiInstanceUpdate, ApiInstanceUpdate = ApiInstanceUpdate,
ApiInstanceMetadata = ApiInstanceMetadata, ApiInstanceMetadata = ApiInstanceMetadata,
ApiSubscribeMessage = ApiSubscribeMessage, ApiSubscribeMessage = ApiSubscribeMessage,
ApiSerializeResponse = ApiSerializeResponse,
ApiRefPatchResponse = ApiRefPatchResponse,
ApiValue = ApiValue, ApiValue = ApiValue,
RbxId = RbxId, RbxId = RbxId,

View File

@@ -1,6 +1,20 @@
local Packages = script.Parent.Parent.Packages local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Http = require(Packages.Http) local Http = require(Packages.Http)
local Promise = require(Packages.Promise) local Promise = require(Packages.Promise)
local Log = require(Packages.Log)
local Config = require(Plugin.Config)
local Settings = require(Plugin.Settings)
local timeUtil = require(Plugin.timeUtil)
type LatestReleaseInfo = {
version: { number },
prerelease: boolean,
publishedUnixTimestamp: number,
}
local function compare(a, b) local function compare(a, b)
if a > b then if a > b then
@@ -88,14 +102,26 @@ function Version.display(version)
return output return output
end end
--[[
The GitHub API rate limit for unauthenticated requests is rather low,
and we don't release often enough to warrant checking it more than once a day.
--]]
Version._cachedLatestCompatible = nil :: {
value: LatestReleaseInfo?,
timestamp: number,
}?
function Version.retrieveLatestCompatible(options: { function Version.retrieveLatestCompatible(options: {
version: { number }, version: { number },
includePrereleases: boolean?, includePrereleases: boolean?,
}): { }): LatestReleaseInfo?
version: { number }, if Version._cachedLatestCompatible and os.clock() - Version._cachedLatestCompatible.timestamp < 60 * 60 * 24 then
prerelease: boolean, Log.debug("Using cached latest compatible version")
publishedUnixTimestamp: number, return Version._cachedLatestCompatible.value
}? end
Log.debug("Retrieving latest compatible version from GitHub")
local success, releases = Http.get("https://api.github.com/repos/rojo-rbx/rojo/releases?per_page=10") local success, releases = Http.get("https://api.github.com/repos/rojo-rbx/rojo/releases?per_page=10")
:andThen(function(response) :andThen(function(response)
if response.code >= 400 then if response.code >= 400 then
@@ -114,7 +140,7 @@ function Version.retrieveLatestCompatible(options: {
end end
-- Iterate through releases, looking for the latest compatible version -- Iterate through releases, looking for the latest compatible version
local latestCompatible = nil local latestCompatible: LatestReleaseInfo? = nil
for _, release in releases do for _, release in releases do
-- Skip prereleases if they are not requested -- Skip prereleases if they are not requested
if (not options.includePrereleases) and release.prerelease then if (not options.includePrereleases) and release.prerelease then
@@ -142,10 +168,43 @@ function Version.retrieveLatestCompatible(options: {
-- Don't return anything if the latest found is not newer than the current version -- Don't return anything if the latest found is not newer than the current version
if latestCompatible == nil or Version.compare(latestCompatible.version, options.version) <= 0 then if latestCompatible == nil or Version.compare(latestCompatible.version, options.version) <= 0 then
-- Cache as nil so we don't try again for a day
Version._cachedLatestCompatible = {
value = nil,
timestamp = os.clock(),
}
return nil return nil
end end
-- Cache the latest compatible version
Version._cachedLatestCompatible = {
value = latestCompatible,
timestamp = os.clock(),
}
return latestCompatible return latestCompatible
end end
function Version.getUpdateMessage(): string?
if not Settings:get("checkForUpdates") then
return
end
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil
local latestCompatibleVersion = Version.retrieveLatestCompatible({
version = Config.version,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"),
})
if not latestCompatibleVersion then
return
end
return string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
)
end
return Version return Version

View File

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

View File

@@ -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: ~ gameId: ~
placeId: ~ placeId: ~
projectName: add_folder projectName: add_folder
protocolVersion: 4 protocolVersion: 5
rootInstanceId: id-2 rootInstanceId: id-2
serverVersion: "[server-version]" serverVersion: "[server-version]"
sessionId: id-1 sessionId: id-1

View File

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

View File

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

View File

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

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