mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 20:55:50 +00:00
Compare commits
23 Commits
v7.5.1
...
plugin-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47db569c52 | ||
|
|
1d3f8c8e9d | ||
|
|
a894313a4b | ||
|
|
7f73ae80dc | ||
|
|
80a381dbb1 | ||
|
|
59e36491a5 | ||
|
|
c1326ba06e | ||
|
|
e2633126ee | ||
|
|
5f33435f3c | ||
|
|
54e0ff230b | ||
|
|
4e9e6233ff | ||
|
|
0056849b51 | ||
|
|
2ddb21ec5f | ||
|
|
a4eb65ca3f | ||
|
|
3002d250a1 | ||
|
|
9598553e5d | ||
|
|
7f68d9887b | ||
|
|
e092a7301f | ||
|
|
6dfdfbe514 | ||
|
|
7860f2717f | ||
|
|
60f19df9a0 | ||
|
|
951f0cda0b | ||
|
|
227042d6b1 |
95
.github/workflows/ci.yml
vendored
95
.github/workflows/ci.yml
vendored
@@ -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,50 @@ jobs:
|
|||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@1.70.0
|
uses: dtolnay/rust-toolchain@1.79.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') }}
|
||||||
|
|
||||||
|
test-plugin:
|
||||||
|
name: Test Plugin
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Setup Rokit
|
||||||
|
uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
|
with:
|
||||||
|
version: 'v1.1.0'
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: lune run test-plugin
|
||||||
|
env:
|
||||||
|
RBX_API_KEY: ${{ secrets.PLUGIN_TEST_API_KEY }}
|
||||||
|
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_TEST_UNIVERSE_ID }}
|
||||||
|
RBX_PLACE_ID: ${{ vars.PLUGIN_TEST_PLACE_ID }}
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Rustfmt, Clippy, Stylua, & Selene
|
name: Rustfmt, Clippy, Stylua, & Selene
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -77,13 +118,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 +144,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') }}
|
||||||
|
|||||||
45
.github/workflows/release.yml
vendored
45
.github/workflows/release.yml
vendored
@@ -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
.gitmodules
vendored
3
.gitmodules
vendored
@@ -16,3 +16,6 @@
|
|||||||
[submodule "plugin/Packages/Highlighter"]
|
[submodule "plugin/Packages/Highlighter"]
|
||||||
path = plugin/Packages/Highlighter
|
path = plugin/Packages/Highlighter
|
||||||
url = https://github.com/boatbomber/highlighter.git
|
url = https://github.com/boatbomber/highlighter.git
|
||||||
|
[submodule ".lune/opencloud-execute"]
|
||||||
|
path = .lune/opencloud-execute
|
||||||
|
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git
|
||||||
|
|||||||
5
.lune/.luaurc
Normal file
5
.lune/.luaurc
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"aliases": {
|
||||||
|
"lune": "~/.lune/.typedefs/0.10.2/"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.lune/opencloud-execute
Submodule
1
.lune/opencloud-execute
Submodule
Submodule .lune/opencloud-execute added at 8ae86dd3ad
112
.lune/test-plugin.luau
Normal file
112
.lune/test-plugin.luau
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
local serde = require("@lune/serde")
|
||||||
|
local net = require("@lune/net")
|
||||||
|
local stdio = require("@lune/stdio")
|
||||||
|
local process = require("@lune/process")
|
||||||
|
local fs = require("@lune/fs")
|
||||||
|
|
||||||
|
local luau_execute = require("./opencloud-execute")
|
||||||
|
|
||||||
|
local TEST_SCRIPT = fs.readFile("plugin/run-tests.server.lua")
|
||||||
|
|
||||||
|
local PATH_VERSION_MATCH = "assets/%d+/versions/(.+)"
|
||||||
|
|
||||||
|
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
|
||||||
|
local PLACE_ID = process.env["RBX_PLACE_ID"]
|
||||||
|
local API_KEY = process.env["RBX_API_KEY"]
|
||||||
|
|
||||||
|
if not UNIVERSE_ID then
|
||||||
|
error("no universe ID specified. try providing one with the env var `RBX_UNIVERSE_ID`")
|
||||||
|
end
|
||||||
|
if not PLACE_ID then
|
||||||
|
error("no place ID specified. try providing one with the env var `RBX_PLACE_ID`")
|
||||||
|
end
|
||||||
|
if not API_KEY then
|
||||||
|
error("no API key specified. try providing one with the env var `RBX_API_KEY`")
|
||||||
|
end
|
||||||
|
|
||||||
|
--stylua: ignore
|
||||||
|
local upload_result = process.exec("cargo", {
|
||||||
|
"run", "--",
|
||||||
|
"upload", "plugin/test-place.project.json",
|
||||||
|
"--api_key", API_KEY,
|
||||||
|
"--universe_id", UNIVERSE_ID,
|
||||||
|
"--asset_id", PLACE_ID
|
||||||
|
}, {
|
||||||
|
stdio = "none"
|
||||||
|
})
|
||||||
|
|
||||||
|
if not upload_result.ok then
|
||||||
|
print("Failed to upload plugin test place")
|
||||||
|
print("Not dumping stdout or stderr to avoid leaking secrets")
|
||||||
|
process.exit(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- This is /probably/ not necessary because Rojo generally does not have enough
|
||||||
|
-- activity that there will be multiple CI runs happening at once, but
|
||||||
|
-- it's better safe than sorry.
|
||||||
|
local version_response = net.request({
|
||||||
|
method = "GET",
|
||||||
|
url = `https://apis.roblox.com/assets/v1/assets/{PLACE_ID}/versions`,
|
||||||
|
query = {
|
||||||
|
maxPageSize = 1,
|
||||||
|
},
|
||||||
|
headers = {
|
||||||
|
["User-Agent"] = `Rojo/PluginTesting 1.0.0; {_VERSION}`,
|
||||||
|
["x-api-key"] = API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if not version_response.ok then
|
||||||
|
error(
|
||||||
|
`Failed to fetch version of Roblox place to run tests on because: {version_response.statusCode} - {version_response.statusMessage}\n{version_response.body}`
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
local place_version_raw = serde.decode("json", version_response.body).assetVersions[1].path
|
||||||
|
assert(typeof(place_version_raw) == "string", "the result from asset version endpoint was not as expected")
|
||||||
|
|
||||||
|
local place_version = string.match(place_version_raw, PATH_VERSION_MATCH)
|
||||||
|
|
||||||
|
local task = luau_execute.create_task_versioned(UNIVERSE_ID, PLACE_ID, place_version, TEST_SCRIPT)
|
||||||
|
print(`Running test script on {UNIVERSE_ID}/{PLACE_ID}@{place_version}`)
|
||||||
|
print(`Task ID: {luau_execute.task_id(task)}`)
|
||||||
|
|
||||||
|
luau_execute.await_finish(task)
|
||||||
|
print("Output from task:\n")
|
||||||
|
local logs = luau_execute.get_structured_logs(task)
|
||||||
|
for _, log in logs do
|
||||||
|
if log.messageType == "OUTPUT" or log.messageType == "MESSAGE_TYPE_UNSPECIFIED" then
|
||||||
|
stdio.write(stdio.color("reset"))
|
||||||
|
elseif log.messageType == "INFO" then
|
||||||
|
stdio.write(stdio.color("cyan"))
|
||||||
|
elseif log.messageType == "WARNING" then
|
||||||
|
stdio.write(stdio.color("yellow"))
|
||||||
|
elseif log.messageType == "ERROR" then
|
||||||
|
stdio.write(stdio.color("red"))
|
||||||
|
end
|
||||||
|
stdio.write(log.message)
|
||||||
|
stdio.write(`{stdio.color("reset")}\n`)
|
||||||
|
end
|
||||||
|
|
||||||
|
local results = luau_execute.get_output(task)[1]
|
||||||
|
if not results then
|
||||||
|
error("plugin tests did not return any results")
|
||||||
|
end
|
||||||
|
|
||||||
|
local status = luau_execute.check_status(task)
|
||||||
|
if status == "COMPLETE" then
|
||||||
|
if results.failureCount == 0 then
|
||||||
|
process.exit(0)
|
||||||
|
else
|
||||||
|
process.exit(1)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print()
|
||||||
|
print("Task did not finish successfully")
|
||||||
|
local err = luau_execute.get_error(task)
|
||||||
|
if err then
|
||||||
|
print(`Error from task: {err.code}`)
|
||||||
|
print(err.message)
|
||||||
|
end
|
||||||
|
process.exit(1)
|
||||||
|
end
|
||||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,5 +1,36 @@
|
|||||||
# Rojo Changelog
|
# Rojo Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
* Added fallback method for when an Instance can't be synced through normal means ([#1030])
|
||||||
|
This should make it possible to sync `MeshParts` and `Unions`!
|
||||||
|
|
||||||
|
The fallback involves deleting and recreating Instances. This will break
|
||||||
|
properties that reference them that Rojo does not know about, so be weary.
|
||||||
|
|
||||||
|
* Add auto-reconnect and improve UX for sync reminders ([#1096])
|
||||||
|
* Add support for syncing `yml` and `yaml` files (behaves similar to JSON and TOML) ([#1093])
|
||||||
|
* Fixed colors of Table diff ([#1084])
|
||||||
|
* Fixed `sourcemap` command outputting paths with OS-specific path separators ([#1085])
|
||||||
|
* Fixed nil -> nil properties showing up as failing to sync in plugin's patch visualizer ([#1081])
|
||||||
|
* Changed the background of the server's in-browser UI to be gray instead of white ([#1080])
|
||||||
|
* Fixed `Auto Connect Playtest Server` no longer functioning due to Roblox change ([#1066])
|
||||||
|
* Added an update indicator to the version header when a new version of the plugin is available. ([#1069])
|
||||||
|
* Added `--absolute` flag to the sourcemap subcommand, which will emit absolute paths instead of relative paths. ([#1092])
|
||||||
|
* Fixed applying `gameId` and `placeId` before initial sync was accepted ([#1104])
|
||||||
|
|
||||||
|
[#1030]: https://github.com/rojo-rbx/rojo/pull/1030
|
||||||
|
[#1096]: https://github.com/rojo-rbx/rojo/pull/1096
|
||||||
|
[#1093]: https://github.com/rojo-rbx/rojo/pull/1093
|
||||||
|
[#1084]: https://github.com/rojo-rbx/rojo/pull/1084
|
||||||
|
[#1085]: https://github.com/rojo-rbx/rojo/pull/1085
|
||||||
|
[#1081]: https://github.com/rojo-rbx/rojo/pull/1081
|
||||||
|
[#1080]: https://github.com/rojo-rbx/rojo/pull/1080
|
||||||
|
[#1066]: https://github.com/rojo-rbx/rojo/pull/1066
|
||||||
|
[#1069]: https://github.com/rojo-rbx/rojo/pull/1069
|
||||||
|
[#1092]: https://github.com/rojo-rbx/rojo/pull/1092
|
||||||
|
[#1104]: https://github.com/rojo-rbx/rojo/pull/1104
|
||||||
|
|
||||||
## 7.5.1 - April 25th, 2025
|
## 7.5.1 - April 25th, 2025
|
||||||
* Fixed output spam related to `Instance.Capabilities` in the plugin
|
* Fixed output spam related to `Instance.Capabilities` in the plugin
|
||||||
|
|
||||||
@@ -696,7 +727,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
|||||||
|
|
||||||
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
|
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
|
||||||
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
|
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
|
||||||
* `Content` propertyes can now be specified in projects and model files as regular string literals.
|
* `Content` properties can now be specified in projects and model files as regular string literals.
|
||||||
* Added support for `BrickColor` properties.
|
* Added support for `BrickColor` properties.
|
||||||
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
|
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
|
||||||
* Improved performance when working with XML models and places
|
* Improved performance when working with XML models and places
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
49
Cargo.lock
generated
49
Cargo.lock
generated
@@ -45,6 +45,12 @@ version = "1.0.80"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
|
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arraydeque"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayref"
|
name = "arrayref"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -395,6 +401,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "data-encoding"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "diff"
|
name = "diff"
|
||||||
version = "0.1.13"
|
version = "0.1.13"
|
||||||
@@ -519,6 +531,12 @@ version = "1.0.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
@@ -751,6 +769,24 @@ version = "0.14.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
|
||||||
|
dependencies = [
|
||||||
|
"foldhash",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashlink"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||||
|
dependencies = [
|
||||||
|
"hashbrown 0.15.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -1840,6 +1876,7 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"csv",
|
"csv",
|
||||||
|
"data-encoding",
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"fs-err",
|
"fs-err",
|
||||||
@@ -1879,6 +1916,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"winreg 0.10.1",
|
"winreg 0.10.1",
|
||||||
|
"yaml-rust2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2877,6 +2915,17 @@ dependencies = [
|
|||||||
"linked-hash-map",
|
"linked-hash-map",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaml-rust2"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ce2a4ff45552406d02501cea6c18d8a7e50228e7736a872951fe2fe75c91be7"
|
||||||
|
dependencies = [
|
||||||
|
"arraydeque",
|
||||||
|
"encoding_rs",
|
||||||
|
"hashlink",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yansi"
|
name = "yansi"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
|
|||||||
10
Cargo.toml
10
Cargo.toml
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
version = "7.5.1"
|
version = "7.5.1"
|
||||||
rust-version = "1.70.0"
|
rust-version = "1.79.0"
|
||||||
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"
|
||||||
@@ -89,6 +93,8 @@ tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
|
|||||||
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
||||||
clap = { version = "3.2.25", features = ["derive"] }
|
clap = { version = "3.2.25", features = ["derive"] }
|
||||||
profiling = "1.0.15"
|
profiling = "1.0.15"
|
||||||
|
yaml-rust2 = "0.10.3"
|
||||||
|
data-encoding = "2.8.0"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.10.1"
|
winreg = "0.10.1"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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%;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
name = "memofs"
|
name = "memofs"
|
||||||
description = "Virtual filesystem with configurable backends."
|
description = "Virtual filesystem with configurable backends."
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Submodule plugin/Packages/t updated: 1f9754254b...1dbfccc182
@@ -8,4 +8,12 @@ local Settings = require(Rojo.Plugin.Settings)
|
|||||||
Settings:set("logLevel", "Trace")
|
Settings:set("logLevel", "Trace")
|
||||||
Settings:set("typecheckingEnabled", true)
|
Settings:set("typecheckingEnabled", true)
|
||||||
|
|
||||||
require(Rojo.Plugin.runTests)(TestEZ)
|
local results = require(Rojo.Plugin.runTests)(TestEZ)
|
||||||
|
|
||||||
|
-- Roblox's Luau execution gets mad about cyclical tables.
|
||||||
|
-- Rather than making TestEZ not do that, we just send back the important info.
|
||||||
|
return {
|
||||||
|
failureCount = results.failureCount,
|
||||||
|
successCount = results.successCount,
|
||||||
|
skippedCount = results.skippedCount,
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ 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 validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
|
||||||
|
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
|
||||||
@@ -252,4 +254,32 @@ function ApiContext:open(id)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function ApiContext:serialize(ids: { string })
|
||||||
|
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
||||||
|
|
||||||
|
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||||
|
if body.sessionId ~= self.__sessionId then
|
||||||
|
return Promise.reject("Server changed ID")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert(validateApiSerialize(body))
|
||||||
|
|
||||||
|
return body
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ApiContext:refPatch(ids: { string })
|
||||||
|
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
||||||
|
|
||||||
|
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||||
|
if body.sessionId ~= self.__sessionId then
|
||||||
|
return Promise.reject("Server changed ID")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert(validateApiRefPatch(body))
|
||||||
|
|
||||||
|
return body
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
return ApiContext
|
return ApiContext
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,8 +75,10 @@ function Notification:didMount()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Notification:willUnmount()
|
function Notification:willUnmount()
|
||||||
|
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
|
||||||
task.cancel(self.timeout)
|
task.cancel(self.timeout)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Notification:render()
|
function Notification:render()
|
||||||
local transparency = self.binding:map(function(value)
|
local transparency = self.binding:map(function(value)
|
||||||
@@ -95,10 +95,13 @@ function Notification:render()
|
|||||||
text = action.text,
|
text = action.text,
|
||||||
style = action.style,
|
style = action.style,
|
||||||
onClick = function()
|
onClick = function()
|
||||||
|
self:dismiss()
|
||||||
|
if action.onClick then
|
||||||
local success, err = pcall(action.onClick, self)
|
local success, err = pcall(action.onClick, self)
|
||||||
if not success then
|
if not success then
|
||||||
Log.warn("Error in notification action: " .. tostring(err))
|
Log.warn("Error in notification action: " .. tostring(err))
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
layoutOrder = -action.layoutOrder,
|
layoutOrder = -action.layoutOrder,
|
||||||
transparency = transparency,
|
transparency = transparency,
|
||||||
@@ -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
|
|
||||||
66
plugin/src/App/Components/Notifications/init.lua
Normal file
66
plugin/src/App/Components/Notifications/init.lua
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ function StringDiffVisualizer:render()
|
|||||||
size = UDim2.new(1, 0, 1, 0),
|
size = UDim2.new(1, 0, 1, 0),
|
||||||
position = UDim2.new(0, 0, 0, 0),
|
position = UDim2.new(0, 0, 0, 0),
|
||||||
text = oldString,
|
text = oldString,
|
||||||
lineBackground = theme.Diff.Remove,
|
lineBackground = theme.Diff.Background.Remove,
|
||||||
markedLines = self.state.remove,
|
markedLines = self.state.remove,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -193,7 +193,7 @@ function StringDiffVisualizer:render()
|
|||||||
size = UDim2.new(1, 0, 1, 0),
|
size = UDim2.new(1, 0, 1, 0),
|
||||||
position = UDim2.new(0, 0, 0, 0),
|
position = UDim2.new(0, 0, 0, 0),
|
||||||
text = newString,
|
text = newString,
|
||||||
lineBackground = theme.Diff.Add,
|
lineBackground = theme.Diff.Background.Add,
|
||||||
markedLines = self.state.add,
|
markedLines = self.state.add,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
}, {
|
}, {
|
||||||
|
|||||||
@@ -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],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -165,12 +165,29 @@ function StudioProvider:updateTheme()
|
|||||||
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
|
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
|
||||||
},
|
},
|
||||||
Diff = {
|
Diff = {
|
||||||
|
-- Very bright different colors in case some places were not updated to use
|
||||||
|
-- the new background diff colors.
|
||||||
|
Add = Color3.fromRGB(255, 0, 255),
|
||||||
|
Remove = Color3.fromRGB(255, 0, 255),
|
||||||
|
Edit = Color3.fromRGB(255, 0, 255),
|
||||||
|
|
||||||
|
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
|
||||||
|
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
|
||||||
|
|
||||||
|
Background = {
|
||||||
-- Studio doesn't have good colors since their diffs use backgrounds, not text
|
-- 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),
|
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),
|
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),
|
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
|
||||||
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
|
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
|
||||||
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
|
},
|
||||||
|
|
||||||
|
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),
|
||||||
|
|||||||
@@ -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()
|
|
||||||
local timeSinceSync = timeUtil.elapsedToText(os.time() - syncInfo.timestamp)
|
|
||||||
local syncDetail = if syncInfo.projectName
|
|
||||||
then `project '{syncInfo.projectName}'`
|
|
||||||
else `{syncInfo.host or Config.defaultHost}:{syncInfo.port or Config.defaultPort}`
|
|
||||||
|
|
||||||
self:addNotification(
|
|
||||||
`You synced {syncDetail} to this place {timeSinceSync}. Would you like to reconnect?`,
|
|
||||||
300,
|
|
||||||
{
|
|
||||||
Connect = {
|
|
||||||
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)
|
||||||
|
|
||||||
|
self:tryAutoReconnect():andThen(function(didReconnect)
|
||||||
|
if not didReconnect then
|
||||||
|
self:checkSyncReminder()
|
||||||
|
end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
if self:isAutoConnectPlaytestServerAvailable() then
|
if self:isAutoConnectPlaytestServerAvailable() then
|
||||||
@@ -203,16 +179,23 @@ function App:willUnmount()
|
|||||||
|
|
||||||
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 +203,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 +225,32 @@ function App:closeNotification(id: number)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local notifications = table.clone(self.state.notifications)
|
self:setState(function(prevState)
|
||||||
|
local notifications = table.clone(prevState.notifications)
|
||||||
notifications[id] = nil
|
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
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
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 = {
|
Dismiss = {
|
||||||
text = "Dismiss",
|
text = "Dismiss",
|
||||||
style = "Bordered",
|
style = "Bordered",
|
||||||
layoutOrder = 2,
|
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 +371,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 +570,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,13 +595,13 @@ function App:startSession()
|
|||||||
twoWaySync = Settings:get("twoWaySync"),
|
twoWaySync = Settings:get("twoWaySync"),
|
||||||
})
|
})
|
||||||
|
|
||||||
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
|
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
|
||||||
-- Build new tree for patch
|
-- Build new tree for patch
|
||||||
self:setState({
|
self:setState({
|
||||||
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
|
-- Update tree with unapplied metadata
|
||||||
self:setState(function(prevState)
|
self:setState(function(prevState)
|
||||||
return {
|
return {
|
||||||
@@ -522,11 +644,18 @@ function App:startSession()
|
|||||||
|
|
||||||
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 +668,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 +693,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)
|
||||||
@@ -646,13 +783,13 @@ function App:startSession()
|
|||||||
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)
|
||||||
@@ -813,19 +950,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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -95,6 +99,8 @@ function ServeSession.new(options)
|
|||||||
__changeBatcher = changeBatcher,
|
__changeBatcher = changeBatcher,
|
||||||
__statusChangedCallback = nil,
|
__statusChangedCallback = nil,
|
||||||
__connections = connections,
|
__connections = connections,
|
||||||
|
__precommitCallbacks = {},
|
||||||
|
__postcommitCallbacks = {},
|
||||||
}
|
}
|
||||||
|
|
||||||
setmetatable(self, ServeSession)
|
setmetatable(self, ServeSession)
|
||||||
@@ -125,12 +131,46 @@ function ServeSession:setConfirmCallback(callback)
|
|||||||
self.__userConfirmCallback = callback
|
self.__userConfirmCallback = callback
|
||||||
end
|
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)
|
function ServeSession:hookPrecommit(callback)
|
||||||
return self.__reconciler: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
|
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()
|
||||||
@@ -139,10 +179,9 @@ function ServeSession:start()
|
|||||||
self.__apiContext
|
self.__apiContext
|
||||||
:connect()
|
:connect()
|
||||||
:andThen(function(serverInfo)
|
:andThen(function(serverInfo)
|
||||||
self:__applyGameAndPlaceId(serverInfo)
|
|
||||||
|
|
||||||
return self:__initialSync(serverInfo):andThen(function()
|
return self:__initialSync(serverInfo):andThen(function()
|
||||||
self:__setStatus(Status.Connected, serverInfo.projectName)
|
self:__setStatus(Status.Connected, serverInfo.projectName)
|
||||||
|
self:__applyGameAndPlaceId(serverInfo)
|
||||||
|
|
||||||
return self:__mainSyncLoop()
|
return self:__mainSyncLoop()
|
||||||
end)
|
end)
|
||||||
@@ -207,6 +246,169 @@ 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]
|
||||||
|
self.__instanceMap:insert(id, replacement)
|
||||||
|
Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
|
||||||
|
local oldParent = oldInstance.Parent
|
||||||
|
for _, child in oldInstance:GetChildren() do
|
||||||
|
child.Parent = replacement
|
||||||
|
end
|
||||||
|
|
||||||
|
replacement.Parent = oldParent
|
||||||
|
-- ChangeHistoryService doesn't like it if an Instance has been
|
||||||
|
-- Destroyed. So, we have to accept the potential memory hit and
|
||||||
|
-- just set the parent to `nil`.
|
||||||
|
oldInstance.Parent = nil
|
||||||
|
|
||||||
|
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 PatchSet.isEmpty(unappliedPatch) then
|
||||||
|
if historyRecording then
|
||||||
|
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local addedIdList = PatchSet.addedIdList(unappliedPatch)
|
||||||
|
local updatedIdList = PatchSet.updatedIdList(unappliedPatch)
|
||||||
|
|
||||||
|
local actualUnappliedPatches = PatchSet.newEmpty()
|
||||||
|
if Settings:get("enableSyncFallback") then
|
||||||
|
Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)")
|
||||||
|
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()
|
||||||
|
|
||||||
|
if addSuccess then
|
||||||
|
table.clear(unappliedPatch.added)
|
||||||
|
PatchSet.assign(actualUnappliedPatches, unappliedAddedRefs)
|
||||||
|
end
|
||||||
|
if updateSuccess then
|
||||||
|
table.clear(unappliedPatch.updated)
|
||||||
|
PatchSet.assign(actualUnappliedPatches, unappliedUpdateRefs)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Log.debug("Skipping ServeSession:__replaceInstances because of setting")
|
||||||
|
end
|
||||||
|
PatchSet.assign(actualUnappliedPatches, unappliedPatch)
|
||||||
|
|
||||||
|
if not PatchSet.isEmpty(actualUnappliedPatches) 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, actualUnappliedPatches)
|
||||||
|
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
|
||||||
@@ -281,15 +483,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)
|
||||||
@@ -312,14 +506,7 @@ function ServeSession:__mainSyncLoop()
|
|||||||
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
|
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
|
||||||
|
|
||||||
for _, message in messages do
|
for _, message in messages do
|
||||||
local unappliedPatch = self.__reconciler:applyPatch(message)
|
self:__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
|
||||||
end)
|
end)
|
||||||
:await()
|
:await()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ local ApiSubscribeResponse = t.interface({
|
|||||||
messages = t.array(ApiSubscribeMessage),
|
messages = t.array(ApiSubscribeMessage),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -82,6 +92,8 @@ return strict("Types", {
|
|||||||
ApiInstanceUpdate = ApiInstanceUpdate,
|
ApiInstanceUpdate = ApiInstanceUpdate,
|
||||||
ApiInstanceMetadata = ApiInstanceMetadata,
|
ApiInstanceMetadata = ApiInstanceMetadata,
|
||||||
ApiSubscribeMessage = ApiSubscribeMessage,
|
ApiSubscribeMessage = ApiSubscribeMessage,
|
||||||
|
ApiSerializeResponse = ApiSerializeResponse,
|
||||||
|
ApiRefPatchResponse = ApiRefPatchResponse,
|
||||||
ApiValue = ApiValue,
|
ApiValue = ApiValue,
|
||||||
RbxId = RbxId,
|
RbxId = RbxId,
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ return function(TestEZ)
|
|||||||
local Rojo = script.Parent.Parent
|
local Rojo = script.Parent.Parent
|
||||||
local Packages = Rojo.Packages
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
TestEZ.TestBootstrap:run({ Rojo.Plugin, Packages.Http, Packages.Log, Packages.RbxDom })
|
return TestEZ.TestBootstrap:run({ Rojo.Plugin, Packages.Http, Packages.Log, Packages.RbxDom })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
|
---
|
||||||
|
instances:
|
||||||
|
id-2:
|
||||||
|
Children: []
|
||||||
|
ClassName: Attachment
|
||||||
|
Id: id-2
|
||||||
|
Metadata:
|
||||||
|
ignoreUnknownInstances: true
|
||||||
|
Name: forced_parent
|
||||||
|
Parent: "00000000000000000000000000000000"
|
||||||
|
Properties: {}
|
||||||
|
messageCursor: 0
|
||||||
|
sessionId: id-1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: redactions.redacted_yaml(&info)
|
||||||
|
---
|
||||||
|
expectedPlaceIds: ~
|
||||||
|
gameId: ~
|
||||||
|
placeId: ~
|
||||||
|
projectName: forced_parent
|
||||||
|
protocolVersion: 4
|
||||||
|
rootInstanceId: id-2
|
||||||
|
serverVersion: "[server-version]"
|
||||||
|
sessionId: id-1
|
||||||
|
unexpectedPlaceIds: ~
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: model
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Folder" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Folder</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ObjectValue" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">id-2</string>
|
||||||
|
<Ref name="Value">2</Ref>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Part" referent="3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Part</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="Attachment" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">forced_parent</string>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
|
---
|
||||||
|
instances:
|
||||||
|
id-2:
|
||||||
|
Children:
|
||||||
|
- id-3
|
||||||
|
ClassName: DataModel
|
||||||
|
Id: id-2
|
||||||
|
Metadata:
|
||||||
|
ignoreUnknownInstances: true
|
||||||
|
Name: meshpart
|
||||||
|
Parent: "00000000000000000000000000000000"
|
||||||
|
Properties: {}
|
||||||
|
id-3:
|
||||||
|
Children:
|
||||||
|
- id-4
|
||||||
|
- id-5
|
||||||
|
ClassName: Workspace
|
||||||
|
Id: id-3
|
||||||
|
Metadata:
|
||||||
|
ignoreUnknownInstances: true
|
||||||
|
Name: Workspace
|
||||||
|
Parent: id-2
|
||||||
|
Properties:
|
||||||
|
NeedsPivotMigration:
|
||||||
|
Bool: false
|
||||||
|
id-4:
|
||||||
|
Children: []
|
||||||
|
ClassName: ObjectValue
|
||||||
|
Id: id-4
|
||||||
|
Metadata:
|
||||||
|
ignoreUnknownInstances: true
|
||||||
|
Name: ObjectValue
|
||||||
|
Parent: id-3
|
||||||
|
Properties:
|
||||||
|
Attributes:
|
||||||
|
Attributes:
|
||||||
|
Rojo_Target_Value:
|
||||||
|
String: sword
|
||||||
|
Value:
|
||||||
|
Ref: id-5
|
||||||
|
id-5:
|
||||||
|
Children: []
|
||||||
|
ClassName: MeshPart
|
||||||
|
Id: id-5
|
||||||
|
Metadata:
|
||||||
|
ignoreUnknownInstances: true
|
||||||
|
Name: Sword
|
||||||
|
Parent: id-3
|
||||||
|
Properties:
|
||||||
|
MeshId:
|
||||||
|
ContentId: "rbxasset://fonts/sword.mesh"
|
||||||
|
TextureID:
|
||||||
|
ContentId: "rbxasset://textures/SwordTexture.png"
|
||||||
|
messageCursor: 0
|
||||||
|
sessionId: id-1
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: redactions.redacted_yaml(&info)
|
||||||
|
---
|
||||||
|
expectedPlaceIds: ~
|
||||||
|
gameId: ~
|
||||||
|
placeId: ~
|
||||||
|
projectName: meshpart
|
||||||
|
protocolVersion: 4
|
||||||
|
rootInstanceId: id-2
|
||||||
|
serverVersion: "[server-version]"
|
||||||
|
sessionId: id-1
|
||||||
|
unexpectedPlaceIds: ~
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
source: tests/tests/serve.rs
|
||||||
|
expression: model
|
||||||
|
---
|
||||||
|
<roblox version="4">
|
||||||
|
<Item class="Folder" referent="0">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Folder</string>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ObjectValue" referent="1">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">id-5</string>
|
||||||
|
<BinaryString name="AttributesSerialize"></BinaryString>
|
||||||
|
<Ref name="Value">2</Ref>
|
||||||
|
</Properties>
|
||||||
|
<Item class="MeshPart" referent="2">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">Sword</string>
|
||||||
|
<Content name="MeshContent">
|
||||||
|
<uri>rbxasset://fonts/sword.mesh</uri>
|
||||||
|
</Content>
|
||||||
|
<Content name="TextureContent">
|
||||||
|
<uri>rbxasset://textures/SwordTexture.png</uri>
|
||||||
|
</Content>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
<Item class="ObjectValue" referent="3">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">id-4</string>
|
||||||
|
<BinaryString name="AttributesSerialize"></BinaryString>
|
||||||
|
<Ref name="Value">4</Ref>
|
||||||
|
</Properties>
|
||||||
|
<Item class="ObjectValue" referent="4">
|
||||||
|
<Properties>
|
||||||
|
<string name="Name">ObjectValue</string>
|
||||||
|
<BinaryString name="AttributesSerialize">AQAAABEAAABSb2pvX1RhcmdldF9WYWx1ZQIFAAAAc3dvcmQ=</BinaryString>
|
||||||
|
<Ref name="Value">null</Ref>
|
||||||
|
</Properties>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</Item>
|
||||||
|
</roblox>
|
||||||
6
rojo-test/serve-tests/forced_parent/default.project.json
Normal file
6
rojo-test/serve-tests/forced_parent/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "forced_parent",
|
||||||
|
"tree": {
|
||||||
|
"$className": "Attachment"
|
||||||
|
}
|
||||||
|
}
|
||||||
22
rojo-test/serve-tests/meshpart_with_id/default.project.json
Normal file
22
rojo-test/serve-tests/meshpart_with_id/default.project.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "meshpart",
|
||||||
|
"tree": {
|
||||||
|
"$className": "DataModel",
|
||||||
|
"Workspace": {
|
||||||
|
"Sword": {
|
||||||
|
"$id": "sword",
|
||||||
|
"$className": "MeshPart",
|
||||||
|
"$properties": {
|
||||||
|
"MeshId": "rbxasset://fonts/sword.mesh",
|
||||||
|
"TextureID": "rbxasset://textures/SwordTexture.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ObjectValue": {
|
||||||
|
"$className": "ObjectValue",
|
||||||
|
"$attributes": {
|
||||||
|
"Rojo_Target_Value": "sword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
rokit.toml
Normal file
6
rokit.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[tools]
|
||||||
|
rojo = "rojo-rbx/rojo@7.5.1"
|
||||||
|
selene = "Kampfkarren/selene@0.29.0"
|
||||||
|
stylua = "JohnnyMorganz/stylua@2.1.0"
|
||||||
|
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
|
||||||
|
lune = "lune-org/lune@0.10.2"
|
||||||
@@ -2,3 +2,6 @@ std = "roblox+testez"
|
|||||||
|
|
||||||
[config]
|
[config]
|
||||||
unused_variable = { allow_unused_self = true }
|
unused_variable = { allow_unused_self = true }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
roblox_manual_fromscale_or_fromoffset = "allow"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
io::{BufWriter, Write},
|
io::{BufWriter, Write},
|
||||||
mem::forget,
|
mem::forget,
|
||||||
path::{Path, PathBuf},
|
path::{self, Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
@@ -20,6 +21,7 @@ use crate::{
|
|||||||
use super::resolve_path;
|
use super::resolve_path;
|
||||||
|
|
||||||
const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
|
const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
|
||||||
|
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
|
||||||
|
|
||||||
/// Representation of a node in the generated sourcemap tree.
|
/// Representation of a node in the generated sourcemap tree.
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -28,8 +30,11 @@ struct SourcemapNode<'a> {
|
|||||||
name: &'a str,
|
name: &'a str,
|
||||||
class_name: Ustr,
|
class_name: Ustr,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(
|
||||||
file_paths: Vec<PathBuf>,
|
skip_serializing_if = "Vec::is_empty",
|
||||||
|
serialize_with = "crate::path_serializer::serialize_vec_absolute"
|
||||||
|
)]
|
||||||
|
file_paths: Vec<Cow<'a, Path>>,
|
||||||
|
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
children: Vec<SourcemapNode<'a>>,
|
children: Vec<SourcemapNode<'a>>,
|
||||||
@@ -57,6 +62,10 @@ pub struct SourcemapCommand {
|
|||||||
/// Whether to automatically recreate a snapshot when any input files change.
|
/// Whether to automatically recreate a snapshot when any input files change.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub watch: bool,
|
pub watch: bool,
|
||||||
|
|
||||||
|
/// Whether the sourcemap should use absolute paths instead of relative paths.
|
||||||
|
#[clap(long)]
|
||||||
|
pub absolute: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SourcemapCommand {
|
impl SourcemapCommand {
|
||||||
@@ -83,7 +92,7 @@ impl SourcemapCommand {
|
|||||||
.build_global()
|
.build_global()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
write_sourcemap(&session, self.output.as_deref(), filter)?;
|
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
|
||||||
|
|
||||||
if self.watch {
|
if self.watch {
|
||||||
let rt = Runtime::new().unwrap();
|
let rt = Runtime::new().unwrap();
|
||||||
@@ -94,7 +103,7 @@ impl SourcemapCommand {
|
|||||||
cursor = new_cursor;
|
cursor = new_cursor;
|
||||||
|
|
||||||
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
|
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
|
||||||
write_sourcemap(&session, self.output.as_deref(), filter)?;
|
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,13 +169,16 @@ fn recurse_create_node<'a>(
|
|||||||
referent: Ref,
|
referent: Ref,
|
||||||
project_dir: &Path,
|
project_dir: &Path,
|
||||||
filter: fn(&InstanceWithMeta) -> bool,
|
filter: fn(&InstanceWithMeta) -> bool,
|
||||||
|
use_absolute_paths: bool,
|
||||||
) -> Option<SourcemapNode<'a>> {
|
) -> Option<SourcemapNode<'a>> {
|
||||||
let instance = tree.get_instance(referent).expect("instance did not exist");
|
let instance = tree.get_instance(referent).expect("instance did not exist");
|
||||||
|
|
||||||
let children: Vec<_> = instance
|
let children: Vec<_> = instance
|
||||||
.children()
|
.children()
|
||||||
.par_iter()
|
.par_iter()
|
||||||
.filter_map(|&child_id| recurse_create_node(tree, child_id, project_dir, filter))
|
.filter_map(|&child_id| {
|
||||||
|
recurse_create_node(tree, child_id, project_dir, filter, use_absolute_paths)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// If this object has no children and doesn't pass the filter, it doesn't
|
// If this object has no children and doesn't pass the filter, it doesn't
|
||||||
@@ -181,14 +193,30 @@ fn recurse_create_node<'a>(
|
|||||||
.iter()
|
.iter()
|
||||||
// Not all paths listed as relevant are guaranteed to exist.
|
// Not all paths listed as relevant are guaranteed to exist.
|
||||||
.filter(|path| path.is_file())
|
.filter(|path| path.is_file())
|
||||||
.map(|path| path.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR))
|
.map(|path| path.as_path());
|
||||||
.map(|path| path.to_path_buf())
|
|
||||||
.collect();
|
let mut output_file_paths: Vec<Cow<'a, Path>> =
|
||||||
|
Vec::with_capacity(instance.metadata().relevant_paths.len());
|
||||||
|
|
||||||
|
if use_absolute_paths {
|
||||||
|
// It's somewhat important to note here that `path::absolute` takes in a Path and returns a PathBuf
|
||||||
|
for val in file_paths {
|
||||||
|
output_file_paths.push(Cow::Owned(
|
||||||
|
path::absolute(val).expect(ABSOLUTE_PATH_FAILED_ERR),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for val in file_paths {
|
||||||
|
output_file_paths.push(Cow::from(
|
||||||
|
val.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Some(SourcemapNode {
|
Some(SourcemapNode {
|
||||||
name: instance.name(),
|
name: instance.name(),
|
||||||
class_name: instance.class_name(),
|
class_name: instance.class_name(),
|
||||||
file_paths,
|
file_paths: output_file_paths,
|
||||||
children,
|
children,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -197,10 +225,17 @@ fn write_sourcemap(
|
|||||||
session: &ServeSession,
|
session: &ServeSession,
|
||||||
output: Option<&Path>,
|
output: Option<&Path>,
|
||||||
filter: fn(&InstanceWithMeta) -> bool,
|
filter: fn(&InstanceWithMeta) -> bool,
|
||||||
|
use_absolute_paths: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let tree = session.tree();
|
let tree = session.tree();
|
||||||
|
|
||||||
let root_node = recurse_create_node(&tree, tree.get_root_id(), session.root_dir(), filter);
|
let root_node = recurse_create_node(
|
||||||
|
&tree,
|
||||||
|
tree.get_root_id(),
|
||||||
|
session.root_dir(),
|
||||||
|
filter,
|
||||||
|
use_absolute_paths,
|
||||||
|
);
|
||||||
|
|
||||||
if let Some(output_path) = output {
|
if let Some(output_path) = output {
|
||||||
let mut file = BufWriter::new(File::create(output_path)?);
|
let mut file = BufWriter::new(File::create(output_path)?);
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ fn main() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
log::error!("Rojo crashed!");
|
log::error!(
|
||||||
|
"Rojo crashed! You are running Rojo {}.",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
);
|
||||||
log::error!("This is probably a Rojo bug.");
|
log::error!("This is probably a Rojo bug.");
|
||||||
log::error!("");
|
log::error!("");
|
||||||
log::error!(
|
log::error!(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ mod rbxmx;
|
|||||||
mod toml;
|
mod toml;
|
||||||
mod txt;
|
mod txt;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod yaml;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
@@ -41,6 +42,7 @@ use self::{
|
|||||||
rbxmx::snapshot_rbxmx,
|
rbxmx::snapshot_rbxmx,
|
||||||
toml::snapshot_toml,
|
toml::snapshot_toml,
|
||||||
txt::snapshot_txt,
|
txt::snapshot_txt,
|
||||||
|
yaml::snapshot_yaml,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default};
|
pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default};
|
||||||
@@ -212,6 +214,7 @@ pub enum Middleware {
|
|||||||
Rbxmx,
|
Rbxmx,
|
||||||
Toml,
|
Toml,
|
||||||
Text,
|
Text,
|
||||||
|
Yaml,
|
||||||
Ignore,
|
Ignore,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,6 +253,7 @@ impl Middleware {
|
|||||||
Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
|
Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
|
||||||
Self::Toml => snapshot_toml(context, vfs, path, name),
|
Self::Toml => snapshot_toml(context, vfs, path, name),
|
||||||
Self::Text => snapshot_txt(context, vfs, path, name),
|
Self::Text => snapshot_txt(context, vfs, path, name),
|
||||||
|
Self::Yaml => snapshot_yaml(context, vfs, path, name),
|
||||||
Self::Ignore => Ok(None),
|
Self::Ignore => Ok(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +319,7 @@ pub fn default_sync_rules() -> &'static [SyncRule] {
|
|||||||
sync_rule!("*.txt", Text),
|
sync_rule!("*.txt", Text),
|
||||||
sync_rule!("*.rbxmx", Rbxmx),
|
sync_rule!("*.rbxmx", Rbxmx),
|
||||||
sync_rule!("*.rbxm", Rbxm),
|
sync_rule!("*.rbxm", Rbxm),
|
||||||
|
sync_rule!("*.{yml,yaml}", Yaml),
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
source: src/snapshot_middleware/yaml.rs
|
||||||
|
expression: source
|
||||||
|
---
|
||||||
|
return {
|
||||||
|
string = "this is a string",
|
||||||
|
boolean = true,
|
||||||
|
integer = 1337,
|
||||||
|
float = 123456789.5,
|
||||||
|
["value-with-hypen"] = "it sure is",
|
||||||
|
sequence = {"wow", 8675309},
|
||||||
|
map = {
|
||||||
|
key = "value",
|
||||||
|
key2 = "value 2",
|
||||||
|
key3 = "value 3",
|
||||||
|
},
|
||||||
|
["nested-map"] = {{
|
||||||
|
key = "value",
|
||||||
|
}, {
|
||||||
|
key2 = "value 2",
|
||||||
|
}, {
|
||||||
|
key3 = "value 3",
|
||||||
|
}},
|
||||||
|
whatever_this_is = {"i imagine", "it's", "a", "sequence?"},
|
||||||
|
null1 = nil,
|
||||||
|
null2 = nil,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
source: src/snapshot_middleware/yaml.rs
|
||||||
|
expression: instance_snapshot
|
||||||
|
---
|
||||||
|
snapshot_id: "00000000000000000000000000000000"
|
||||||
|
metadata:
|
||||||
|
ignore_unknown_instances: false
|
||||||
|
instigating_source:
|
||||||
|
Path: /foo.yaml
|
||||||
|
relevant_paths:
|
||||||
|
- /foo.yaml
|
||||||
|
- /foo.meta.json
|
||||||
|
context:
|
||||||
|
emit_legacy_scripts: true
|
||||||
|
specified_id: ~
|
||||||
|
name: foo
|
||||||
|
class_name: ModuleScript
|
||||||
|
properties:
|
||||||
|
Source:
|
||||||
|
String: "return {\n\tstring = \"this is a string\",\n\tboolean = true,\n\tinteger = 1337,\n\tfloat = 123456789.5,\n\t[\"value-with-hypen\"] = \"it sure is\",\n\tsequence = {\"wow\", 8675309},\n\tmap = {\n\t\tkey = \"value\",\n\t\tkey2 = \"value 2\",\n\t\tkey3 = \"value 3\",\n\t},\n\t[\"nested-map\"] = {{\n\t\tkey = \"value\",\n\t}, {\n\t\tkey2 = \"value 2\",\n\t}, {\n\t\tkey3 = \"value 3\",\n\t}},\n\twhatever_this_is = {\"i imagine\", \"it's\", \"a\", \"sequence?\"},\n\tnull1 = nil,\n\tnull2 = nil,\n}"
|
||||||
|
children: []
|
||||||
234
src/snapshot_middleware/yaml.rs
Normal file
234
src/snapshot_middleware/yaml.rs
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use memofs::{IoResultExt, Vfs};
|
||||||
|
use rbx_dom_weak::ustr;
|
||||||
|
use yaml_rust2::{Yaml, YamlLoader};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
lua_ast::{Expression, Statement},
|
||||||
|
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::meta_file::AdjacentMetadata;
|
||||||
|
|
||||||
|
pub fn snapshot_yaml(
|
||||||
|
context: &InstanceContext,
|
||||||
|
vfs: &Vfs,
|
||||||
|
path: &Path,
|
||||||
|
name: &str,
|
||||||
|
) -> anyhow::Result<Option<InstanceSnapshot>> {
|
||||||
|
let contents = vfs.read_to_string(path)?;
|
||||||
|
|
||||||
|
let mut values = YamlLoader::load_from_str(&contents)?;
|
||||||
|
let value = values
|
||||||
|
.pop()
|
||||||
|
.context("all YAML documents must contain a document")?;
|
||||||
|
if !values.is_empty() {
|
||||||
|
anyhow::bail!("Rojo does not currently support multiple documents in a YAML file")
|
||||||
|
}
|
||||||
|
|
||||||
|
let as_lua = Statement::Return(yaml_to_luau(value)?);
|
||||||
|
|
||||||
|
let meta_path = path.with_file_name(format!("{}.meta.json", name));
|
||||||
|
|
||||||
|
let mut snapshot = InstanceSnapshot::new()
|
||||||
|
.name(name)
|
||||||
|
.class_name("ModuleScript")
|
||||||
|
.property(ustr("Source"), as_lua.to_string())
|
||||||
|
.metadata(
|
||||||
|
InstanceMetadata::new()
|
||||||
|
.instigating_source(path)
|
||||||
|
.relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
|
||||||
|
.context(context),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
|
||||||
|
let mut metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?;
|
||||||
|
metadata.apply_all(&mut snapshot)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(snapshot))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yaml_to_luau(value: Yaml) -> anyhow::Result<Expression> {
|
||||||
|
const MAX_FLOAT_INT: i64 = 1 << 53;
|
||||||
|
|
||||||
|
Ok(match value {
|
||||||
|
Yaml::String(str) => Expression::String(str),
|
||||||
|
Yaml::Boolean(bool) => Expression::Bool(bool),
|
||||||
|
Yaml::Integer(int) => {
|
||||||
|
if int <= MAX_FLOAT_INT {
|
||||||
|
Expression::Number(int as f64)
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"the integer '{int}' cannot be losslessly converted into a Luau number"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Yaml::Real(_) => {
|
||||||
|
let value = value.as_f64().expect("value should be a valid f64");
|
||||||
|
Expression::Number(value)
|
||||||
|
}
|
||||||
|
Yaml::Null => Expression::Nil,
|
||||||
|
Yaml::Array(values) => {
|
||||||
|
let new_values: anyhow::Result<Vec<Expression>> =
|
||||||
|
values.into_iter().map(yaml_to_luau).collect();
|
||||||
|
Expression::Array(new_values?)
|
||||||
|
}
|
||||||
|
Yaml::Hash(map) => {
|
||||||
|
let new_values: anyhow::Result<Vec<(Expression, Expression)>> = map
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
let k = yaml_to_luau(k)?;
|
||||||
|
let v = yaml_to_luau(v)?;
|
||||||
|
Ok((k, v))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Expression::table(new_values?)
|
||||||
|
}
|
||||||
|
Yaml::Alias(_) => {
|
||||||
|
anyhow::bail!("Rojo cannot convert YAML aliases to Luau")
|
||||||
|
}
|
||||||
|
Yaml::BadValue => {
|
||||||
|
anyhow::bail!("Rojo cannot convert YAML to Luau because of a parsing error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use memofs::{InMemoryFs, VfsSnapshot};
|
||||||
|
use rbx_dom_weak::types::Variant;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn instance_from_vfs() {
|
||||||
|
let mut imfs = InMemoryFs::new();
|
||||||
|
imfs.load_snapshot(
|
||||||
|
"/foo.yaml",
|
||||||
|
VfsSnapshot::file(
|
||||||
|
r#"
|
||||||
|
---
|
||||||
|
string: this is a string
|
||||||
|
boolean: true
|
||||||
|
integer: 1337
|
||||||
|
float: 123456789.5
|
||||||
|
value-with-hypen: it sure is
|
||||||
|
sequence:
|
||||||
|
- wow
|
||||||
|
- 8675309
|
||||||
|
map:
|
||||||
|
key: value
|
||||||
|
key2: "value 2"
|
||||||
|
key3: 'value 3'
|
||||||
|
nested-map:
|
||||||
|
- key: value
|
||||||
|
- key2: "value 2"
|
||||||
|
- key3: 'value 3'
|
||||||
|
whatever_this_is: [i imagine, it's, a, sequence?]
|
||||||
|
null1: ~
|
||||||
|
null2: null"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vfs = Vfs::new(imfs.clone());
|
||||||
|
|
||||||
|
let instance_snapshot = snapshot_yaml(
|
||||||
|
&InstanceContext::default(),
|
||||||
|
&vfs,
|
||||||
|
Path::new("/foo.yaml"),
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
insta::assert_yaml_snapshot!(instance_snapshot);
|
||||||
|
|
||||||
|
let source = instance_snapshot
|
||||||
|
.properties
|
||||||
|
.get(&ustr("Source"))
|
||||||
|
.expect("the result from snapshot_yaml should have a Source property");
|
||||||
|
if let Variant::String(source) = source {
|
||||||
|
insta::assert_snapshot!(source)
|
||||||
|
} else {
|
||||||
|
panic!("the Source property from snapshot_yaml was not a String")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "multiple documents")]
|
||||||
|
fn multiple_documents() {
|
||||||
|
let mut imfs = InMemoryFs::new();
|
||||||
|
imfs.load_snapshot(
|
||||||
|
"/foo.yaml",
|
||||||
|
VfsSnapshot::file(
|
||||||
|
r#"
|
||||||
|
---
|
||||||
|
document-1: this is a document
|
||||||
|
---
|
||||||
|
document-2: this is also a document"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vfs = Vfs::new(imfs.clone());
|
||||||
|
|
||||||
|
snapshot_yaml(
|
||||||
|
&InstanceContext::default(),
|
||||||
|
&vfs,
|
||||||
|
Path::new("/foo.yaml"),
|
||||||
|
"foo",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic = "cannot be losslessly converted into a Luau number"]
|
||||||
|
fn integer_border() {
|
||||||
|
let mut imfs = InMemoryFs::new();
|
||||||
|
imfs.load_snapshot(
|
||||||
|
"/allowed.yaml",
|
||||||
|
VfsSnapshot::file(
|
||||||
|
r#"
|
||||||
|
value: 9007199254740992
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
imfs.load_snapshot(
|
||||||
|
"/not-allowed.yaml",
|
||||||
|
VfsSnapshot::file(
|
||||||
|
r#"
|
||||||
|
value: 9007199254740993
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vfs = Vfs::new(imfs.clone());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
snapshot_yaml(
|
||||||
|
&InstanceContext::default(),
|
||||||
|
&vfs,
|
||||||
|
Path::new("/allowed.yaml"),
|
||||||
|
"allowed",
|
||||||
|
)
|
||||||
|
.is_ok(),
|
||||||
|
"snapshot_yaml failed to snapshot document with integer '9007199254740992' in it"
|
||||||
|
);
|
||||||
|
|
||||||
|
snapshot_yaml(
|
||||||
|
&InstanceContext::default(),
|
||||||
|
&vfs,
|
||||||
|
Path::new("/not-allowed.yaml"),
|
||||||
|
"not-allowed",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/web/api.rs
156
src/web/api.rs
@@ -1,11 +1,20 @@
|
|||||||
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
|
||||||
//! JSON.
|
//! JSON.
|
||||||
|
|
||||||
use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
|
use std::{
|
||||||
|
collections::{HashMap, HashSet},
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
use hyper::{body, Body, Method, Request, Response, StatusCode};
|
||||||
use opener::OpenError;
|
use opener::OpenError;
|
||||||
use rbx_dom_weak::types::Ref;
|
use rbx_dom_weak::{
|
||||||
|
types::{Ref, Variant},
|
||||||
|
InstanceBuilder, UstrMap, WeakDom,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
serve_session::ServeSession,
|
serve_session::ServeSession,
|
||||||
@@ -18,6 +27,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
util::{json, json_ok},
|
util::{json, json_ok},
|
||||||
},
|
},
|
||||||
|
web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
|
pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
|
||||||
@@ -31,10 +41,16 @@ pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> R
|
|||||||
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
|
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
|
||||||
service.handle_api_subscribe(request).await
|
service.handle_api_subscribe(request).await
|
||||||
}
|
}
|
||||||
|
(&Method::GET, path) if path.starts_with("/api/serialize/") => {
|
||||||
|
service.handle_api_serialize(request).await
|
||||||
|
}
|
||||||
|
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
|
||||||
|
service.handle_api_ref_patch(request).await
|
||||||
|
}
|
||||||
|
|
||||||
(&Method::POST, path) if path.starts_with("/api/open/") => {
|
(&Method::POST, path) if path.starts_with("/api/open/") => {
|
||||||
service.handle_api_open(request).await
|
service.handle_api_open(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
|
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
|
||||||
|
|
||||||
(_method, path) => json(
|
(_method, path) => json(
|
||||||
@@ -201,6 +217,126 @@ impl ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accepts a list of IDs and returns them serialized as a binary model.
|
||||||
|
/// The model is sent in a schema that causes Roblox to deserialize it as
|
||||||
|
/// a Luau `buffer`.
|
||||||
|
///
|
||||||
|
/// The returned model is a folder that contains ObjectValues with names
|
||||||
|
/// that correspond to the requested Instances. These values have their
|
||||||
|
/// `Value` property set to point to the requested Instance.
|
||||||
|
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
|
||||||
|
let argument = &request.uri().path()["/api/serialize/".len()..];
|
||||||
|
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
|
||||||
|
|
||||||
|
let requested_ids = match requested_ids {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(_) => {
|
||||||
|
return json(
|
||||||
|
ErrorResponse::bad_request("Malformed ID list"),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
|
||||||
|
|
||||||
|
let tree = self.serve_session.tree();
|
||||||
|
for id in &requested_ids {
|
||||||
|
if let Some(instance) = tree.get_instance(*id) {
|
||||||
|
let clone = response_dom.insert(
|
||||||
|
Ref::none(),
|
||||||
|
InstanceBuilder::new(instance.class_name())
|
||||||
|
.with_name(instance.name())
|
||||||
|
.with_properties(instance.properties().clone()),
|
||||||
|
);
|
||||||
|
let object_value = response_dom.insert(
|
||||||
|
response_dom.root_ref(),
|
||||||
|
InstanceBuilder::new("ObjectValue")
|
||||||
|
.with_name(id.to_string())
|
||||||
|
.with_property("Value", clone),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut child_ref = clone;
|
||||||
|
if let Some(parent_class) = parent_requirements(&instance.class_name()) {
|
||||||
|
child_ref =
|
||||||
|
response_dom.insert(object_value, InstanceBuilder::new(parent_class));
|
||||||
|
response_dom.transfer_within(clone, child_ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
response_dom.transfer_within(child_ref, object_value);
|
||||||
|
} else {
|
||||||
|
json(
|
||||||
|
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(tree);
|
||||||
|
|
||||||
|
let mut source = Vec::new();
|
||||||
|
rbx_binary::to_writer(&mut source, &response_dom, &[response_dom.root_ref()]).unwrap();
|
||||||
|
|
||||||
|
json_ok(SerializeResponse {
|
||||||
|
session_id: self.serve_session.session_id(),
|
||||||
|
model_contents: BufferEncode::new(source),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a list of all referent properties that point towards the
|
||||||
|
/// provided IDs. Used because the plugin does not store a RojoTree,
|
||||||
|
/// and referent properties need to be updated after the serialize
|
||||||
|
/// endpoint is used.
|
||||||
|
async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
|
||||||
|
let argument = &request.uri().path()["/api/ref-patch/".len()..];
|
||||||
|
let requested_ids: Result<HashSet<Ref>, _> =
|
||||||
|
argument.split(',').map(Ref::from_str).collect();
|
||||||
|
|
||||||
|
let requested_ids = match requested_ids {
|
||||||
|
Ok(ids) => ids,
|
||||||
|
Err(_) => {
|
||||||
|
return json(
|
||||||
|
ErrorResponse::bad_request("Malformed ID list"),
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
|
||||||
|
|
||||||
|
let tree = self.serve_session.tree();
|
||||||
|
for instance in tree.descendants(tree.get_root_id()) {
|
||||||
|
for (prop_name, prop_value) in instance.properties() {
|
||||||
|
let Variant::Ref(prop_value) = prop_value else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(target_id) = requested_ids.get(prop_value) {
|
||||||
|
let instance_id = instance.id();
|
||||||
|
let update =
|
||||||
|
instance_updates
|
||||||
|
.entry(instance_id)
|
||||||
|
.or_insert_with(|| InstanceUpdate {
|
||||||
|
id: instance_id,
|
||||||
|
changed_class_name: None,
|
||||||
|
changed_name: None,
|
||||||
|
changed_metadata: None,
|
||||||
|
changed_properties: UstrMap::default(),
|
||||||
|
});
|
||||||
|
update
|
||||||
|
.changed_properties
|
||||||
|
.insert(*prop_name, Some(Variant::Ref(*target_id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
json_ok(RefPatchResponse {
|
||||||
|
session_id: self.serve_session.session_id(),
|
||||||
|
patch: SubscribeMessage {
|
||||||
|
added: HashMap::new(),
|
||||||
|
removed: Vec::new(),
|
||||||
|
updated: instance_updates.into_values().collect(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Open a script with the given ID in the user's default text editor.
|
/// Open a script with the given ID in the user's default text editor.
|
||||||
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
|
||||||
let argument = &request.uri().path()["/api/open/".len()..];
|
let argument = &request.uri().path()["/api/open/".len()..];
|
||||||
@@ -306,3 +442,17 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
.map(|path| path.to_owned())
|
.map(|path| path.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Certain Instances MUST be a child of specific classes. This function
|
||||||
|
/// tracks that information for the Serialize endpoint.
|
||||||
|
///
|
||||||
|
/// If a parent requirement exists, it will be returned.
|
||||||
|
/// Otherwise returns `None`.
|
||||||
|
fn parent_requirements(class: &str) -> Option<&str> {
|
||||||
|
Some(match class {
|
||||||
|
"Attachment" | "Bone" => "Part",
|
||||||
|
"Animator" => "Humanoid",
|
||||||
|
"BaseWrap" | "WrapLayer" | "WrapTarget" | "WrapDeformer" => "MeshPart",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -208,6 +208,44 @@ pub struct OpenResponse {
|
|||||||
pub session_id: SessionId,
|
pub session_id: SessionId,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SerializeResponse {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub model_contents: BufferEncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Using this struct we can force Roblox to JSONDecode this as a buffer.
|
||||||
|
/// This is what Roblox's serde APIs use, so it saves a step in the plugin.
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BufferEncode {
|
||||||
|
m: (),
|
||||||
|
t: Cow<'static, str>,
|
||||||
|
base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BufferEncode {
|
||||||
|
pub fn new(content: Vec<u8>) -> Self {
|
||||||
|
let base64 = data_encoding::BASE64.encode(&content);
|
||||||
|
Self {
|
||||||
|
m: (),
|
||||||
|
t: Cow::Borrowed("buffer"),
|
||||||
|
base64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn model(&self) -> &str {
|
||||||
|
&self.base64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RefPatchResponse<'a> {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub patch: SubscribeMessage<'a>,
|
||||||
|
}
|
||||||
|
|
||||||
/// General response type returned from all Rojo routes
|
/// General response type returned from all Rojo routes
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::{
|
use std::{
|
||||||
|
fmt::Write as _,
|
||||||
fs,
|
fs,
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::Command,
|
process::Command,
|
||||||
@@ -11,7 +12,7 @@ use rbx_dom_weak::types::Ref;
|
|||||||
|
|
||||||
use tempfile::{tempdir, TempDir};
|
use tempfile::{tempdir, TempDir};
|
||||||
|
|
||||||
use librojo::web_api::{ReadResponse, ServerInfoResponse, SubscribeResponse};
|
use librojo::web_api::{ReadResponse, SerializeResponse, ServerInfoResponse, SubscribeResponse};
|
||||||
use rojo_insta_ext::RedactionMap;
|
use rojo_insta_ext::RedactionMap;
|
||||||
|
|
||||||
use crate::rojo_test::io_util::{
|
use crate::rojo_test::io_util::{
|
||||||
@@ -174,6 +175,18 @@ impl TestServeSession {
|
|||||||
|
|
||||||
reqwest::blocking::get(url)?.json()
|
reqwest::blocking::get(url)?.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> {
|
||||||
|
let mut id_list = String::with_capacity(ids.len() * 33);
|
||||||
|
for id in ids {
|
||||||
|
write!(id_list, "{id},").unwrap();
|
||||||
|
}
|
||||||
|
id_list.pop();
|
||||||
|
|
||||||
|
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list);
|
||||||
|
|
||||||
|
reqwest::blocking::get(url)?.json()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probably-okay way to generate random enough port numbers for running the
|
/// Probably-okay way to generate random enough port numbers for running the
|
||||||
@@ -187,3 +200,27 @@ fn get_port_number() -> usize {
|
|||||||
|
|
||||||
NEXT_PORT_NUMBER.fetch_add(1, Ordering::SeqCst)
|
NEXT_PORT_NUMBER.fetch_add(1, Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Takes a SerializeResponse and creates an XML model out of the response.
|
||||||
|
///
|
||||||
|
/// Since the provided structure intentionally includes unredacted referents,
|
||||||
|
/// some post-processing is done to ensure they don't show up in the model.
|
||||||
|
pub fn serialize_to_xml_model(response: &SerializeResponse, redactions: &RedactionMap) -> String {
|
||||||
|
let model_content = data_encoding::BASE64
|
||||||
|
.decode(response.model_contents.model().as_bytes())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut dom = rbx_binary::from_reader(model_content.as_slice()).unwrap();
|
||||||
|
// This makes me realize that maybe we need a `descendants_mut` iter.
|
||||||
|
let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect();
|
||||||
|
for referent in ref_list {
|
||||||
|
let inst = dom.get_by_ref_mut(referent).unwrap();
|
||||||
|
if let Some(id) = redactions.get_id_for_value(&inst.name) {
|
||||||
|
inst.name = format!("id-{id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
rbx_xml::to_writer_default(&mut data, &dom, dom.root().children()).unwrap();
|
||||||
|
String::from_utf8(data).expect("rbx_xml should never produce invalid utf-8")
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use insta::{assert_yaml_snapshot, with_settings};
|
use insta::{assert_snapshot, assert_yaml_snapshot, with_settings};
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
use crate::rojo_test::{internable::InternAndRedact, serve_util::run_serve_test};
|
use crate::rojo_test::{
|
||||||
|
internable::InternAndRedact,
|
||||||
|
serve_util::{run_serve_test, serialize_to_xml_model},
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty() {
|
fn empty() {
|
||||||
@@ -591,3 +594,66 @@ fn model_pivot_migration() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn meshpart_with_id() {
|
||||||
|
run_serve_test("meshpart_with_id", |session, mut redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_id = info.root_instance_id;
|
||||||
|
|
||||||
|
assert_yaml_snapshot!("meshpart_with_id_info", redactions.redacted_yaml(&info));
|
||||||
|
|
||||||
|
let read_response = session.get_api_read(root_id).unwrap();
|
||||||
|
assert_yaml_snapshot!(
|
||||||
|
"meshpart_with_id_all",
|
||||||
|
read_response.intern_and_redact(&mut redactions, root_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// This is a bit awkward, but it's fine.
|
||||||
|
let (meshpart, _) = read_response
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.find(|(_, inst)| inst.class_name == "MeshPart")
|
||||||
|
.unwrap();
|
||||||
|
let (objectvalue, _) = read_response
|
||||||
|
.instances
|
||||||
|
.iter()
|
||||||
|
.find(|(_, inst)| inst.class_name == "ObjectValue")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let serialize_response = session
|
||||||
|
.get_api_serialize(&[*meshpart, *objectvalue])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// We don't assert a snapshot on the SerializeResponse because the model includes the
|
||||||
|
// Refs from the DOM as names, which means it will obviously be different every time
|
||||||
|
// this code runs. Still, we ensure that the SessionId is right at least.
|
||||||
|
assert_eq!(serialize_response.session_id, info.session_id);
|
||||||
|
|
||||||
|
let model = serialize_to_xml_model(&serialize_response, &redactions);
|
||||||
|
assert_snapshot!("meshpart_with_id_serialize_model", model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forced_parent() {
|
||||||
|
run_serve_test("forced_parent", |session, mut redactions| {
|
||||||
|
let info = session.get_api_rojo().unwrap();
|
||||||
|
let root_id = info.root_instance_id;
|
||||||
|
|
||||||
|
assert_yaml_snapshot!("forced_parent_info", redactions.redacted_yaml(&info));
|
||||||
|
|
||||||
|
let read_response = session.get_api_read(root_id).unwrap();
|
||||||
|
assert_yaml_snapshot!(
|
||||||
|
"forced_parent_all",
|
||||||
|
read_response.intern_and_redact(&mut redactions, root_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
let serialize_response = session.get_api_serialize(&[root_id]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(serialize_response.session_id, info.session_id);
|
||||||
|
|
||||||
|
let model = serialize_to_xml_model(&serialize_response, &redactions);
|
||||||
|
assert_snapshot!("forced_parent_serialize_model", model);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user