Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ddbefa88f | ||
|
|
d935115591 | ||
|
|
bd2ea42732 | ||
|
|
3bac38ee34 | ||
|
|
a7a4f6d8f2 | ||
|
|
80b6facbd3 | ||
|
|
7dee898400 | ||
|
|
4c4b2dbe17 | ||
|
|
73ed5ae697 | ||
|
|
833320de64 | ||
|
|
0d6ff8ef8a | ||
|
|
55a207a275 | ||
|
|
f33d1f1cc4 | ||
|
|
19ca2b12fc | ||
|
|
b7d3394464 | ||
|
|
8c33100d7a | ||
|
|
80c406f196 | ||
|
|
bc2c76e5e2 | ||
|
|
4a7bddbc09 | ||
|
|
e316fdbaef | ||
|
|
34106f470f | ||
|
|
d9ab0e7de8 | ||
|
|
5ca1573e2e | ||
|
|
c9ce996626 | ||
|
|
73097075d4 | ||
|
|
5e1cab2e75 | ||
|
|
30f439caec | ||
|
|
4b5db4e5a9 | ||
|
|
3fa1d6b09c | ||
|
|
6051a5f1f1 | ||
|
|
5f7dd45361 | ||
|
|
3ca975d81d | ||
|
|
7e2bab921a | ||
|
|
a7b45ee859 | ||
|
|
62f4a1f3c2 | ||
|
|
3d4e387d35 | ||
|
|
2c46640105 | ||
|
|
41443d3989 | ||
|
|
4b3470d30b | ||
|
|
ce71a3df4d | ||
|
|
7232721b87 | ||
|
|
b2f133e6f1 | ||
|
|
87920964d7 | ||
|
|
c7a4f892e3 | ||
|
|
8f9e307930 | ||
|
|
856d43ce69 | ||
|
|
26181a5a1f | ||
|
|
edf87bf9a3 | ||
|
|
5f51538e0b | ||
|
|
48bb760739 | ||
|
|
42121a9fc9 | ||
|
|
02d79a4749 | ||
|
|
ddb26c73bd | ||
|
|
8ff064fe28 | ||
|
|
cf25eb0833 | ||
|
|
5c4260f3ac | ||
|
|
7abf19804c | ||
|
|
df707d5bef | ||
|
|
f3b0b0027e | ||
|
|
106a01223e | ||
|
|
506a60d0be | ||
|
|
4018607b77 | ||
|
|
1cc720ad34 | ||
|
|
73828af715 | ||
|
|
c0a96e3811 | ||
|
|
9d0d76f0a5 | ||
|
|
c7173ac832 | ||
|
|
b12ce47e7e | ||
|
|
269272983b | ||
|
|
6adc5eb9fb | ||
|
|
fd8bc8ae3f | ||
|
|
3369b0d429 | ||
|
|
097d39e8ce | ||
|
|
11fa08e6d6 | ||
|
|
96987af71d | ||
|
|
23327cb3ef | ||
|
|
b43b45be8f | ||
|
|
41994ec82e | ||
|
|
cd14ea7c62 | ||
|
|
9f13bca6b8 | ||
|
|
f4252c3e97 | ||
|
|
6598867d3d | ||
|
|
f39e040a0d | ||
|
|
a3d140269b | ||
|
|
feac29ea40 | ||
|
|
834c8cdbca | ||
|
|
d441fbdf91 | ||
|
|
e897f524dc | ||
|
|
1caf9446d8 | ||
|
|
bfd2c885db | ||
|
|
f467fa4e59 | ||
|
|
41fca4a2bb | ||
|
|
d38f955144 | ||
|
|
010e50a25d | ||
|
|
eab7c607cd | ||
|
|
3cafbf7f1a | ||
|
|
d7277b5a5b | ||
|
|
bb8dd1402d | ||
|
|
539cd0d418 | ||
|
|
0f8e1625d5 | ||
|
|
840e9bedb2 | ||
|
|
e11ad476fc | ||
|
|
c43726bc75 | ||
|
|
c9ab933a23 | ||
|
|
066a0b1668 | ||
|
|
aa68fe412e | ||
|
|
d748ea7e40 | ||
|
|
a7a282078f | ||
|
|
2fad3b588a | ||
|
|
4cb5d4a9c5 | ||
|
|
5b22ef192e | ||
|
|
34024d8524 | ||
|
|
ecc31dea15 | ||
|
|
d0e48d9bdc | ||
|
|
f6fc5599c0 | ||
|
|
89b6666436 | ||
|
|
94d45a2262 | ||
|
|
dc17a185ca | ||
|
|
4915477823 | ||
|
|
8662d2227c | ||
|
|
dd01a9bef3 | ||
|
|
6e320b1fd5 | ||
|
|
6e40993199 | ||
|
|
9d48af2b50 | ||
|
|
28d48a76e3 | ||
|
|
80eb14f9da | ||
|
|
623fa06d52 | ||
|
|
7154113c13 | ||
|
|
0a932ff880 | ||
|
|
7ef4a1ff12 | ||
|
|
ccc52b69d2 | ||
|
|
8139fdc738 | ||
|
|
a4fd53d516 | ||
|
|
27357110b5 | ||
|
|
fde78738b6 | ||
|
|
ce530e795a | ||
|
|
658d211779 | ||
|
|
66c1cd0d93 | ||
|
|
55ac231cec | ||
|
|
67674d53a2 | ||
|
|
8646b2dfce | ||
|
|
a2f68c2e3c | ||
|
|
5b1a090c5e | ||
|
|
e9efa238b0 | ||
|
|
0dabd8a1f6 | ||
|
|
b7a1f82f56 | ||
|
|
2507e096b7 | ||
|
|
b303b0a99c | ||
|
|
342fb57d14 | ||
|
|
a9ca77e27f | ||
|
|
6542304340 | ||
|
|
6b0f7f94b6 | ||
|
|
d87c76a23e | ||
|
|
305423b856 | ||
|
|
4b62190aff |
2
.dir-locals.el
Normal file
@@ -0,0 +1,2 @@
|
||||
((nil . ((eglot-luau-rojo-project-path . "plugin.project.json")
|
||||
(eglot-luau-rojo-sourcemap-enabled . 't))))
|
||||
@@ -23,4 +23,7 @@ insert_final_newline = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.lua]
|
||||
indent_style = tab
|
||||
|
||||
[*.luau]
|
||||
indent_style = tab
|
||||
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# stylua formatting
|
||||
0f8e1625d572a5fe0f7b5c08653ff92cc837d346
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.lua linguist-language=Luau
|
||||
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
||||
patreon: lpghatguy
|
||||
23
.github/workflows/changelog.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Changelog Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check Actions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Changelog check
|
||||
uses: Zomzog/changelog-checker@v1.3.0
|
||||
with:
|
||||
fileName: CHANGELOG.md
|
||||
noChangelogLabel: skip changelog
|
||||
checkNotification: Simple
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
86
.github/workflows/ci.yml
vendored
@@ -12,32 +12,27 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
rust_version: [stable, 1.69.0]
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: ${{ matrix.rust_version }}
|
||||
override: true
|
||||
profile: minimal
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.3.0
|
||||
uses: ok-nick/setup-aftman@v0.4.2
|
||||
with:
|
||||
version: 'v0.2.7'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
version: 'v0.3.0'
|
||||
|
||||
- name: Build
|
||||
run: cargo build --locked --verbose
|
||||
@@ -45,33 +40,60 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --locked --verbose
|
||||
|
||||
lint:
|
||||
name: Rustfmt and Clippy
|
||||
msrv:
|
||||
name: Check MSRV
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
uses: dtolnay/rust-toolchain@1.70.0
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.3.0
|
||||
uses: ok-nick/setup-aftman@v0.4.2
|
||||
with:
|
||||
version: 'v0.2.7'
|
||||
version: 'v0.3.0'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
- name: Build
|
||||
run: cargo build --locked --verbose
|
||||
|
||||
lint:
|
||||
name: Rustfmt, Clippy, Stylua, & Selene
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.4.2
|
||||
with:
|
||||
version: 'v0.3.0'
|
||||
|
||||
- name: Stylua
|
||||
run: stylua --check plugin/src
|
||||
|
||||
- name: Selene
|
||||
run: selene plugin/src
|
||||
|
||||
- name: Rustfmt
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy
|
||||
run: cargo clippy
|
||||
|
||||
|
||||
114
.github/workflows/release.yml
vendored
@@ -8,55 +8,39 @@ jobs:
|
||||
create-release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
run: |
|
||||
gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ github.ref_name }}
|
||||
|
||||
build-plugin:
|
||||
needs: ["create-release"]
|
||||
name: Build Roblox Studio Plugin
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.1.0
|
||||
uses: ok-nick/setup-aftman@v0.4.2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
version: 'v0.3.0'
|
||||
|
||||
- name: Build Plugin
|
||||
run: rojo build plugin --output Rojo.rbxm
|
||||
run: rojo build plugin.project.json --output Rojo.rbxm
|
||||
|
||||
- name: Upload Plugin to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: Rojo.rbxm
|
||||
asset_name: Rojo.rbxm
|
||||
asset_content_type: application/octet-stream
|
||||
run: |
|
||||
gh release upload ${{ github.ref_name }} Rojo.rbxm
|
||||
|
||||
- name: Upload Plugin to Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Rojo.rbxm
|
||||
path: Rojo.rbxm
|
||||
@@ -69,7 +53,7 @@ jobs:
|
||||
# https://doc.rust-lang.org/rustc/platform-support.html
|
||||
include:
|
||||
- host: linux
|
||||
os: ubuntu-20.04
|
||||
os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
label: linux-x86_64
|
||||
|
||||
@@ -93,75 +77,55 @@ jobs:
|
||||
env:
|
||||
BIN: rojo
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Get Version from Tag
|
||||
shell: bash
|
||||
# https://github.community/t/how-to-get-just-the-tag-name/16241/7#M1027
|
||||
run: |
|
||||
echo "PROJECT_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
echo "Version is: ${{ env.PROJECT_VERSION }}"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
target: ${{ matrix.target }}
|
||||
override: true
|
||||
profile: minimal
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.1.0
|
||||
uses: ok-nick/setup-aftman@v0.4.2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
shell: bash
|
||||
version: 'v0.3.0'
|
||||
|
||||
- name: Build Release
|
||||
run: cargo build --release --locked --verbose
|
||||
run: cargo build --release --locked --verbose --target ${{ matrix.target }}
|
||||
env:
|
||||
# Build into a known directory so we can find our build artifact more
|
||||
# easily.
|
||||
CARGO_TARGET_DIR: output
|
||||
|
||||
# On platforms that use OpenSSL, ensure it is statically linked to
|
||||
# make binaries more portable.
|
||||
OPENSSL_STATIC: 1
|
||||
|
||||
- name: Create Release Archive
|
||||
- name: Generate Artifact Name
|
||||
shell: bash
|
||||
env:
|
||||
TAG_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
echo "ARTIFACT_NAME=$BIN-${TAG_NAME#v}-${{ matrix.label }}.zip" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create Archive and Upload to Release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir staging
|
||||
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
cp "output/release/$BIN.exe" staging/
|
||||
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
|
||||
cd staging
|
||||
7z a ../release.zip *
|
||||
7z a ../$ARTIFACT_NAME *
|
||||
else
|
||||
cp "output/release/$BIN" staging/
|
||||
cp "output/${{ matrix.target }}/release/$BIN" staging/
|
||||
cd staging
|
||||
zip ../release.zip *
|
||||
zip ../$ARTIFACT_NAME *
|
||||
fi
|
||||
|
||||
- name: Upload Archive to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
||||
asset_path: release.zip
|
||||
asset_name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
|
||||
asset_content_type: application/octet-stream
|
||||
gh release upload ${{ github.ref_name }} ../$ARTIFACT_NAME
|
||||
|
||||
- name: Upload Archive to Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
|
||||
path: release.zip
|
||||
path: ${{ env.ARTIFACT_NAME }}
|
||||
name: ${{ env.ARTIFACT_NAME }}
|
||||
|
||||
11
.gitignore
vendored
@@ -10,11 +10,8 @@
|
||||
/*.rbxl
|
||||
/*.rbxlx
|
||||
|
||||
# Test places for the Roblox Studio Plugin
|
||||
/plugin/*.rbxlx
|
||||
|
||||
# Packages for the Roblox Studio Plugin
|
||||
/plugin/*Packages
|
||||
# Sourcemap for the Rojo plugin (for better intellisense)
|
||||
/sourcemap.json
|
||||
|
||||
# Roblox Studio holds 'lock' files on places
|
||||
*.rbxl.lock
|
||||
@@ -22,3 +19,7 @@
|
||||
|
||||
# Snapshot files from the 'insta' Rust crate
|
||||
**/*.snap.new
|
||||
|
||||
# Macos file system junk
|
||||
._*
|
||||
.DS_STORE
|
||||
|
||||
18
.gitmodules
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
[submodule "plugin/Packages/Roact"]
|
||||
path = plugin/Packages/Roact
|
||||
url = https://github.com/roblox/roact.git
|
||||
[submodule "plugin/Packages/Flipper"]
|
||||
path = plugin/Packages/Flipper
|
||||
url = https://github.com/reselim/flipper.git
|
||||
[submodule "plugin/Packages/Promise"]
|
||||
path = plugin/Packages/Promise
|
||||
url = https://github.com/evaera/roblox-lua-promise.git
|
||||
[submodule "plugin/Packages/t"]
|
||||
path = plugin/Packages/t
|
||||
url = https://github.com/osyrisrblx/t.git
|
||||
[submodule "plugin/Packages/TestEZ"]
|
||||
path = plugin/Packages/TestEZ
|
||||
url = https://github.com/roblox/testez.git
|
||||
[submodule "plugin/Packages/Highlighter"]
|
||||
path = plugin/Packages/Highlighter
|
||||
url = https://github.com/boatbomber/highlighter.git
|
||||
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"JohnnyMorganz.luau-lsp",
|
||||
"JohnnyMorganz.stylua",
|
||||
"Kampfkarren.selene-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"luau-lsp.sourcemap.rojoProjectFile": "plugin.project.json",
|
||||
"luau-lsp.sourcemap.autogenerate": true
|
||||
}
|
||||
342
CHANGELOG.md
@@ -1,6 +1,344 @@
|
||||
# Rojo Changelog
|
||||
|
||||
## Unreleased Changes
|
||||
## 7.5.0 - April 25th, 2025
|
||||
* Fixed an edge case that caused model pivots to not be built correctly in some cases ([#1027])
|
||||
* Add `blockedPlaceIds` project config field to allow blocking place ids from being live synced ([#1021])
|
||||
* Adds support for `.plugin.lua(u)` files - this applies the `Plugin` RunContext. ([#1008])
|
||||
* Added support for Roblox's `Content` type. This replaces the old `Content` type with `ContentId` to reflect Roblox's change.
|
||||
If you were previously using the fully-qualified syntax for `Content` you will need to switch it to `ContentId`.
|
||||
* Added support for `Enum` attributes
|
||||
* Significantly improved performance of `.rbxm` parsing
|
||||
* Support for a `$schema` field in all special JSON files (`.project.json`, `.model.json`, and `.meta.json`) ([#974])
|
||||
* Projects may now manually link `Ref` properties together using `Attributes`. ([#843])
|
||||
This has two parts: using `id` or `$id` in JSON files or a `Rojo_Target` attribute, an Instance
|
||||
is given an ID. Then, that ID may be used elsewhere in the project to point to an Instance
|
||||
using an attribute named `Rojo_Target_PROP_NAME`, where `PROP_NAME` is the name of a property.
|
||||
|
||||
As an example, here is a `model.json` for an ObjectValue that refers to itself:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "arbitrary string",
|
||||
"attributes": {
|
||||
"Rojo_Target_Value": "arbitrary string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a very rough implementation and the usage will become more ergonomic
|
||||
over time.
|
||||
|
||||
* Updated Undo/Redo history to be more robust ([#915])
|
||||
* Added popout diff visualizer for table properties like Attributes and Tags ([#834])
|
||||
* Updated Theme to use Studio colors ([#838])
|
||||
* Improved patch visualizer UX ([#883])
|
||||
* Added update notifications for newer compatible versions in the Studio plugin. ([#832])
|
||||
* Added experimental setting for Auto Connect in playtests ([#840])
|
||||
* Improved settings UI ([#886])
|
||||
* `Open Scripts Externally` option can now be changed while syncing ([#911])
|
||||
* The sync reminder notification will now tell you what was last synced and when ([#987])
|
||||
* Fixed notification and tooltip text sometimes getting cut off ([#988])
|
||||
* Projects may now specify rules for syncing files as if they had a different file extension. ([#813])
|
||||
This is specified via a new field on project files, `syncRules`:
|
||||
|
||||
```json
|
||||
{
|
||||
"syncRules": [
|
||||
{
|
||||
"pattern": "*.foo",
|
||||
"use": "text",
|
||||
"exclude": "*.exclude.foo",
|
||||
},
|
||||
{
|
||||
"pattern": "*.bar.baz",
|
||||
"use": "json",
|
||||
"suffix": ".bar.baz",
|
||||
},
|
||||
],
|
||||
"name": "SyncRulesAreCool",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `pattern` field is a glob used to match the sync rule to files. If present, the `suffix` field allows you to specify parts of a file's name get cut off by Rojo to name the Instance, including the file extension. If it isn't specified, Rojo will only cut off the first part of the file extension, up to the first dot.
|
||||
|
||||
Additionally, the `exclude` field allows files to be excluded from the sync rule if they match a pattern specified by it. If it's not present, all files that match `pattern` will be modified using the sync rule.
|
||||
|
||||
The `use` field corresponds to one of the potential file type that Rojo will currently include in a project. Files that match the provided pattern will be treated as if they had the file extension for that file type.
|
||||
|
||||
| `use` value | file extension |
|
||||
|:---------------|:----------------|
|
||||
| `serverScript` | `.server.lua` |
|
||||
| `clientScript` | `.client.lua` |
|
||||
| `moduleScript` | `.lua` |
|
||||
| `json` | `.json` |
|
||||
| `toml` | `.toml` |
|
||||
| `csv` | `.csv` |
|
||||
| `text` | `.txt` |
|
||||
| `jsonModel` | `.model.json` |
|
||||
| `rbxm` | `.rbxm` |
|
||||
| `rbxmx` | `.rbxmx` |
|
||||
| `project` | `.project.json` |
|
||||
| `ignore` | None! |
|
||||
|
||||
Additionally, there are `use` values for specific script types ([#909]):
|
||||
|
||||
| `use` value | script type |
|
||||
|:-------------------------|:---------------------------------------|
|
||||
| `legacyServerScript` | `Script` with `Enum.RunContext.Legacy` |
|
||||
| `legacyClientScript` | `LocalScript` |
|
||||
| `runContextServerScript` | `Script` with `Enum.RunContext.Server` |
|
||||
| `runContextClientScript` | `Script` with `Enum.RunContext.Client` |
|
||||
| `pluginScript` | `Script` with `Enum.RunContext.Plugin` |
|
||||
|
||||
**All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced!
|
||||
|
||||
[#813]: https://github.com/rojo-rbx/rojo/pull/813
|
||||
[#832]: https://github.com/rojo-rbx/rojo/pull/832
|
||||
[#834]: https://github.com/rojo-rbx/rojo/pull/834
|
||||
[#838]: https://github.com/rojo-rbx/rojo/pull/838
|
||||
[#840]: https://github.com/rojo-rbx/rojo/pull/840
|
||||
[#843]: https://github.com/rojo-rbx/rojo/pull/843
|
||||
[#883]: https://github.com/rojo-rbx/rojo/pull/883
|
||||
[#886]: https://github.com/rojo-rbx/rojo/pull/886
|
||||
[#909]: https://github.com/rojo-rbx/rojo/pull/909
|
||||
[#911]: https://github.com/rojo-rbx/rojo/pull/911
|
||||
[#915]: https://github.com/rojo-rbx/rojo/pull/915
|
||||
[#974]: https://github.com/rojo-rbx/rojo/pull/974
|
||||
[#987]: https://github.com/rojo-rbx/rojo/pull/987
|
||||
[#988]: https://github.com/rojo-rbx/rojo/pull/988
|
||||
[#1008]: https://github.com/rojo-rbx/rojo/pull/1008
|
||||
[#1021]: https://github.com/rojo-rbx/rojo/pull/1021
|
||||
[#1027]: https://github.com/rojo-rbx/rojo/pull/1027
|
||||
|
||||
## [7.4.4] - August 22nd, 2024
|
||||
* Fixed issue with reading attributes from `Lighting` in new place files
|
||||
* `Instance.Archivable` will now default to `true` when building a project into a binary (`rbxm`/`rbxl`) file rather than `false`.
|
||||
|
||||
## [7.4.3] - August 6th, 2024
|
||||
* Fixed issue with building binary files introduced in 7.4.2
|
||||
* Fixed `value of type nil cannot be converted to number` warning spam in output. [#955]
|
||||
|
||||
[#955]: https://github.com/rojo-rbx/rojo/pull/955
|
||||
|
||||
## [7.4.2] - July 23, 2024
|
||||
* Added Never option to Confirmation ([#893])
|
||||
* Fixed removing trailing newlines ([#903])
|
||||
* Updated the internal property database, correcting an issue with `SurfaceAppearance.Color` that was reported [here][Surface_Appearance_Color_1] and [here][Surface_Appearance_Color_2] ([#948])
|
||||
|
||||
[#893]: https://github.com/rojo-rbx/rojo/pull/893
|
||||
[#903]: https://github.com/rojo-rbx/rojo/pull/903
|
||||
[#948]: https://github.com/rojo-rbx/rojo/pull/948
|
||||
[Surface_Appearance_Color_1]: https://devforum.roblox.com/t/jailbreak-custom-character-turned-shiny-black-no-texture/3075563
|
||||
[Surface_Appearance_Color_2]: https://devforum.roblox.com/t/surfaceappearance-not-displaying-correctly/3075588
|
||||
|
||||
## [7.4.1] - February 20, 2024
|
||||
* Made the `name` field optional on project files ([#870])
|
||||
|
||||
Files named `default.project.json` inherit the name of the folder they're in and all other projects
|
||||
are named as expect (e.g. `foo.project.json` becomes an Instance named `foo`)
|
||||
|
||||
There is no change in behavior if `name` is set.
|
||||
* Fixed incorrect results when building model pivots ([#865])
|
||||
* Fixed incorrect results when serving model pivots ([#868])
|
||||
* Rojo now converts any line endings to LF, preventing spurious diffs when syncing Lua files on Windows ([#854])
|
||||
* Fixed Rojo plugin failing to connect when project contains certain unreadable properties ([#848])
|
||||
* Fixed various cases where patch visualizer would not display sync failures ([#845], [#844])
|
||||
* Fixed http error handling so Rojo can be used in Github Codespaces ([#847])
|
||||
|
||||
[#848]: https://github.com/rojo-rbx/rojo/pull/848
|
||||
[#845]: https://github.com/rojo-rbx/rojo/pull/845
|
||||
[#844]: https://github.com/rojo-rbx/rojo/pull/844
|
||||
[#847]: https://github.com/rojo-rbx/rojo/pull/847
|
||||
[#854]: https://github.com/rojo-rbx/rojo/pull/854
|
||||
[#865]: https://github.com/rojo-rbx/rojo/pull/865
|
||||
[#868]: https://github.com/rojo-rbx/rojo/pull/868
|
||||
[#870]: https://github.com/rojo-rbx/rojo/pull/870
|
||||
|
||||
## [7.4.0] - January 16, 2024
|
||||
* Improved the visualization for array properties like Tags ([#829])
|
||||
* Significantly improved performance of `rojo serve`, `rojo build --watch`, and `rojo sourcemap --watch` on macOS. ([#830])
|
||||
* Changed *.lua files that init command generates to *.luau ([#831])
|
||||
* Does not remind users to sync if the sync lock is claimed already ([#833])
|
||||
|
||||
[#829]: https://github.com/rojo-rbx/rojo/pull/829
|
||||
[#830]: https://github.com/rojo-rbx/rojo/pull/830
|
||||
[#831]: https://github.com/rojo-rbx/rojo/pull/831
|
||||
[#833]: https://github.com/rojo-rbx/rojo/pull/833
|
||||
|
||||
## [7.4.0-rc3] - October 25, 2023
|
||||
* Changed `sourcemap --watch` to only generate the sourcemap when it's necessary ([#800])
|
||||
* Switched script source property getter and setter to `ScriptEditorService` methods ([#801])
|
||||
|
||||
This ensures that the script editor reflects any changes Rojo makes to a script while it is open in the script editor.
|
||||
|
||||
* Fixed issues when handling `SecurityCapabilities` values ([#803], [#807])
|
||||
* Fixed Rojo plugin erroring out when attempting to sync attributes with invalid names ([#809])
|
||||
|
||||
[#800]: https://github.com/rojo-rbx/rojo/pull/800
|
||||
[#801]: https://github.com/rojo-rbx/rojo/pull/801
|
||||
[#803]: https://github.com/rojo-rbx/rojo/pull/803
|
||||
[#807]: https://github.com/rojo-rbx/rojo/pull/807
|
||||
[#809]: https://github.com/rojo-rbx/rojo/pull/809
|
||||
|
||||
## [7.4.0-rc2] - October 3, 2023
|
||||
* Fixed bug with parsing version for plugin validation ([#797])
|
||||
|
||||
[#797]: https://github.com/rojo-rbx/rojo/pull/797
|
||||
|
||||
## [7.4.0-rc1] - October 3, 2023
|
||||
### Additions
|
||||
#### Project format
|
||||
* Added support for `.toml` files to `$path` ([#633])
|
||||
* Added support for `Font` and `CFrame` attributes ([rbx-dom#299], [rbx-dom#296])
|
||||
* Added the `emitLegacyScripts` field to the project format ([#765]). The behavior is outlined below:
|
||||
|
||||
| `emitLegacyScripts` Value | Action Taken by Rojo |
|
||||
|---------------------------|------------------------------------------------------------------------------------------------------------------|
|
||||
| false | Rojo emits Scripts with the appropriate `RunContext` for `*.client.lua` and `*.server.lua` files in the project. |
|
||||
| true (default) | Rojo emits LocalScripts and Scripts with legacy `RunContext` (same behavior as previously). |
|
||||
|
||||
|
||||
It can be used like this:
|
||||
```json
|
||||
{
|
||||
"emitLegacyScripts": false,
|
||||
"name": "MyCoolRunContextProject",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added `Terrain` classname inference, similar to services ([#771])
|
||||
|
||||
`Terrain` may now be defined in projects without using `$className`:
|
||||
```json
|
||||
"Workspace": {
|
||||
"Terrain": {
|
||||
"$path": "path/to/terrain.rbxm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added support for `Terrain.MaterialColors` ([#770])
|
||||
|
||||
`Terrain.MaterialColors` is now represented in projects in a human readable format:
|
||||
```json
|
||||
"Workspace": {
|
||||
"Terrain": {
|
||||
"$path": "path/to/terrain.rbxm"
|
||||
"$properties": {
|
||||
"MaterialColors": {
|
||||
"Grass": [10, 20, 30],
|
||||
"Asphalt": [40, 50, 60],
|
||||
"LeafyGrass": [255, 155, 55]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added better support for `Font` properties ([#731])
|
||||
|
||||
`FontFace` properties may now be defined using implicit property syntax:
|
||||
```json
|
||||
"TextBox": {
|
||||
"$className": "TextBox",
|
||||
"$properties": {
|
||||
"FontFace": {
|
||||
"family": "rbxasset://fonts/families/RobotoMono.json",
|
||||
"weight": "Thin",
|
||||
"style": "Normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Patch visualizer and notifications
|
||||
* Added a setting to control patch confirmation behavior ([#774])
|
||||
|
||||
This is a new setting for controlling when the Rojo plugin prompts for confirmation before syncing. It has four options:
|
||||
* Initial (default): prompts only once for a project in a given Studio session
|
||||
* Always: always prompts for confirmation
|
||||
* Large Changes: only prompts when there are more than X changed instances. The number of instances is configurable - an additional setting for the number of instances becomes available when this option is chosen
|
||||
* Unlisted PlaceId: only prompts if the place ID is not present in servePlaceIds
|
||||
|
||||
* Added the ability to select Instances in patch visualizer ([#709])
|
||||
|
||||
Double-clicking an instance in the patch visualizer sets Roblox Studio's selection to the instance.
|
||||
|
||||
* Added a sync reminder notification. ([#689])
|
||||
|
||||
Rojo detects if you have previously synced to a place, and displays a notification reminding you to sync again:
|
||||
|
||||

|
||||
|
||||
* Added rich Source diffs in patch visualizer ([#748])
|
||||
|
||||
A "View Diff" button for script sources is now present in the patch visualizer. Clicking it displays a side-by-side diff of the script changes:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
* Patch visualizer now indicates what changes failed to apply. ([#717])
|
||||
|
||||
A clickable warning label is displayed when the Rojo plugin is unable to apply changes. Clicking the label displays precise information about which changes failed:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Miscellaneous
|
||||
* Added `plugin` flag to the `build` command that outputs to the local plugins folder ([#735])
|
||||
|
||||
This is a flag that builds a Rojo project into Roblox Studio's plugins directory. This allows you to build a Rojo project and load it into Studio as a plugin without having to type the full path to the plugins directory. It can be used like this: `rojo build <PATH-TO-PROJECT> --plugin <FILE-NAME>`
|
||||
|
||||
* Added new plugin template to the `init` command ([#738])
|
||||
|
||||
This is a new template geared towards plugins. It is similar to the model template, but creates a `Script` instead of a `ModuleScript` in the `src` directory. It can be used like this: `rojo init --kind plugin`
|
||||
|
||||
* Added protection against syncing non-place projects as a place. ([#691])
|
||||
* Add buttons for navigation on the Connected page ([#722])
|
||||
|
||||
### Fixes
|
||||
* Significantly improved performance of `rojo sourcemap` ([#668])
|
||||
* Fixed the diff visualizer of connected sessions. ([#674])
|
||||
* Fixed disconnected session activity. ([#675])
|
||||
* Skip confirming patches that contain only a datamodel name change. ([#688])
|
||||
* Fix Rojo breaking when users undo/redo in Studio ([#708])
|
||||
* Improve tooltip behavior ([#723])
|
||||
* Better settings controls ([#725])
|
||||
* Rework patch visualizer with many fixes and improvements ([#713], [#726], [#755])
|
||||
|
||||
[#668]: https://github.com/rojo-rbx/rojo/pull/668
|
||||
[#674]: https://github.com/rojo-rbx/rojo/pull/674
|
||||
[#675]: https://github.com/rojo-rbx/rojo/pull/675
|
||||
[#688]: https://github.com/rojo-rbx/rojo/pull/688
|
||||
[#689]: https://github.com/rojo-rbx/rojo/pull/689
|
||||
[#691]: https://github.com/rojo-rbx/rojo/pull/691
|
||||
[#709]: https://github.com/rojo-rbx/rojo/pull/709
|
||||
[#708]: https://github.com/rojo-rbx/rojo/pull/708
|
||||
[#713]: https://github.com/rojo-rbx/rojo/pull/713
|
||||
[#717]: https://github.com/rojo-rbx/rojo/pull/717
|
||||
[#722]: https://github.com/rojo-rbx/rojo/pull/722
|
||||
[#723]: https://github.com/rojo-rbx/rojo/pull/723
|
||||
[#725]: https://github.com/rojo-rbx/rojo/pull/725
|
||||
[#726]: https://github.com/rojo-rbx/rojo/pull/726
|
||||
[#633]: https://github.com/rojo-rbx/rojo/pull/633
|
||||
[#735]: https://github.com/rojo-rbx/rojo/pull/735
|
||||
[#731]: https://github.com/rojo-rbx/rojo/pull/731
|
||||
[#738]: https://github.com/rojo-rbx/rojo/pull/738
|
||||
[#748]: https://github.com/rojo-rbx/rojo/pull/748
|
||||
[#755]: https://github.com/rojo-rbx/rojo/pull/755
|
||||
[#765]: https://github.com/rojo-rbx/rojo/pull/765
|
||||
[#770]: https://github.com/rojo-rbx/rojo/pull/770
|
||||
[#771]: https://github.com/rojo-rbx/rojo/pull/771
|
||||
[#774]: https://github.com/rojo-rbx/rojo/pull/774
|
||||
[rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299
|
||||
[rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296
|
||||
|
||||
## [7.3.0] - April 22, 2023
|
||||
* Added `$attributes` to project format. ([#574])
|
||||
@@ -566,4 +904,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
||||
* More robust syncing with a new reconciler
|
||||
|
||||
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
|
||||
@@ -16,6 +16,23 @@ You'll want these tools to work on Rojo:
|
||||
* Latest stable Rust compiler
|
||||
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
||||
* [Foreman](https://github.com/Roblox/foreman)
|
||||
* [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:
|
||||
|
||||
*(Make sure you've enabled the Studio setting to reload plugins on file change!)*
|
||||
|
||||
```bash
|
||||
bash scripts/watch-build-plugin.sh
|
||||
```
|
||||
|
||||
You can also run the plugin's unit tests with the following:
|
||||
|
||||
*(Make sure you have `run-in-roblox` installed first!)*
|
||||
|
||||
```bash
|
||||
bash scripts/unit-test-plugin.sh
|
||||
```
|
||||
|
||||
## Documentation
|
||||
Documentation impacts way more people than the individual lines of code we write.
|
||||
|
||||
1471
Cargo.lock
generated
97
Cargo.toml
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "7.3.0"
|
||||
rust-version = "1.68.2"
|
||||
version = "7.5.0"
|
||||
rust-version = "1.70.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "Enables professional-grade development tools for Roblox developers"
|
||||
license = "MPL-2.0"
|
||||
@@ -12,9 +12,7 @@ readme = "README.md"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
exclude = [
|
||||
"/test-projects/**",
|
||||
]
|
||||
exclude = ["/test-projects/**"]
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
@@ -28,7 +26,9 @@ default = []
|
||||
# Enable this feature to live-reload assets from the web UI.
|
||||
dev_live_assets = []
|
||||
|
||||
profile-with-tracy = ["profiling/profile-with-tracy", "tracy-client"]
|
||||
# Run Rojo with this feature to open a Tracy session.
|
||||
# Currently uses protocol v63, last supported in Tracy 0.9.1.
|
||||
profile-with-tracy = ["profiling/profile-with-tracy"]
|
||||
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
@@ -42,7 +42,7 @@ name = "build"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
memofs = { version = "0.2.0", path = "crates/memofs" }
|
||||
memofs = { version = "0.3.0", path = "crates/memofs" }
|
||||
|
||||
# These dependencies can be uncommented when working on rbx-dom simultaneously
|
||||
# rbx_binary = { path = "../rbx-dom/rbx_binary" }
|
||||
@@ -51,60 +51,65 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
|
||||
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
|
||||
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
|
||||
|
||||
rbx_binary = "0.7.0"
|
||||
rbx_dom_weak = "2.4.0"
|
||||
rbx_reflection = "4.2.0"
|
||||
rbx_reflection_database = "0.2.6"
|
||||
rbx_xml = "0.13.0"
|
||||
rbx_binary = "1.0.0"
|
||||
rbx_dom_weak = "3.0.0"
|
||||
rbx_reflection = "5.0.0"
|
||||
rbx_reflection_database = "1.0.2"
|
||||
rbx_xml = "1.0.0"
|
||||
|
||||
anyhow = "1.0.44"
|
||||
backtrace = "0.3.61"
|
||||
anyhow = "1.0.80"
|
||||
backtrace = "0.3.69"
|
||||
bincode = "1.3.3"
|
||||
crossbeam-channel = "0.5.1"
|
||||
csv = "1.1.6"
|
||||
env_logger = "0.9.0"
|
||||
fs-err = "2.6.0"
|
||||
futures = "0.3.17"
|
||||
globset = "0.4.8"
|
||||
crossbeam-channel = "0.5.12"
|
||||
csv = "1.3.0"
|
||||
env_logger = "0.9.3"
|
||||
fs-err = "2.11.0"
|
||||
futures = "0.3.30"
|
||||
globset = "0.4.14"
|
||||
humantime = "2.1.0"
|
||||
hyper = { version = "0.14.13", features = ["server", "tcp", "http1"] }
|
||||
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
|
||||
jod-thread = "0.1.2"
|
||||
log = "0.4.14"
|
||||
maplit = "1.0.2"
|
||||
notify = "4.0.17"
|
||||
opener = "0.5.0"
|
||||
reqwest = { version = "0.11.10", features = ["blocking", "json", "native-tls-vendored"] }
|
||||
log = "0.4.21"
|
||||
num_cpus = "1.16.0"
|
||||
opener = "0.5.2"
|
||||
rayon = "1.9.0"
|
||||
reqwest = { version = "0.11.24", default-features = false, features = [
|
||||
"blocking",
|
||||
"json",
|
||||
"rustls-tls",
|
||||
] }
|
||||
ritz = "0.1.0"
|
||||
roblox_install = "1.0.0"
|
||||
serde = { version = "1.0.130", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.68"
|
||||
termcolor = "1.1.2"
|
||||
thiserror = "1.0.30"
|
||||
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] }
|
||||
uuid = { version = "1.0.0", features = ["v4", "serde"] }
|
||||
clap = { version = "3.1.18", features = ["derive"] }
|
||||
profiling = "1.0.6"
|
||||
tracy-client = { version = "0.13.2", optional = true }
|
||||
serde = { version = "1.0.197", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.114"
|
||||
toml = "0.5.11"
|
||||
termcolor = "1.4.1"
|
||||
thiserror = "1.0.57"
|
||||
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
|
||||
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
||||
clap = { version = "3.2.25", features = ["derive"] }
|
||||
profiling = "1.0.15"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.10.1"
|
||||
|
||||
[build-dependencies]
|
||||
memofs = { version = "0.2.0", path = "crates/memofs" }
|
||||
memofs = { version = "0.3.0", path = "crates/memofs" }
|
||||
|
||||
embed-resource = "1.6.4"
|
||||
anyhow = "1.0.44"
|
||||
embed-resource = "1.8.0"
|
||||
anyhow = "1.0.80"
|
||||
bincode = "1.3.3"
|
||||
fs-err = "2.6.0"
|
||||
fs-err = "2.11.0"
|
||||
maplit = "1.0.2"
|
||||
semver = "1.0.22"
|
||||
|
||||
[dev-dependencies]
|
||||
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
||||
|
||||
criterion = "0.3.5"
|
||||
insta = { version = "1.8.0", features = ["redactions", "yaml"] }
|
||||
paste = "1.0.5"
|
||||
pretty_assertions = "1.2.1"
|
||||
serde_yaml = "0.8.21"
|
||||
tempfile = "3.2.0"
|
||||
walkdir = "2.3.2"
|
||||
criterion = "0.3.6"
|
||||
insta = { version = "1.36.1", features = ["redactions", "yaml"] }
|
||||
paste = "1.0.14"
|
||||
pretty_assertions = "1.4.0"
|
||||
serde_yaml = "0.8.26"
|
||||
tempfile = "3.10.1"
|
||||
walkdir = "2.5.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<a href="https://rojo.space"><img src="assets/logo-512.png" alt="Rojo" height="217" /></a>
|
||||
<a href="https://rojo.space"><img src="assets/brand_images/logo-512.png" alt="Rojo" height="217" /></a>
|
||||
</div>
|
||||
|
||||
<div> </div>
|
||||
@@ -8,7 +8,6 @@
|
||||
<a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a>
|
||||
<a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a>
|
||||
<a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a>
|
||||
<a href="https://www.patreon.com/lpghatguy"><img src="https://img.shields.io/badge/sponsor-patreon-red" alt="Patreon" /></a>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@@ -41,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
|
||||
|
||||
Pull requests are welcome!
|
||||
|
||||
Rojo supports Rust 1.58.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
|
||||
Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
|
||||
|
||||
## License
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
wally = "UpliftGames/wally@0.3.1"
|
||||
rojo = "rojo-rbx/rojo@7.2.1"
|
||||
selene = "Kampfkarren/selene@0.20.0"
|
||||
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"
|
||||
|
||||
|
Before Width: | Height: | Size: 975 B After Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
@@ -2,7 +2,7 @@
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build this library or plugin, use:
|
||||
To build this library, use:
|
||||
|
||||
```bash
|
||||
rojo build -o "{project_name}.rbxmx"
|
||||
|
||||
17
assets/default-plugin-project/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# {project_name}
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build this plugin to your local plugins folder, use:
|
||||
|
||||
```bash
|
||||
rojo build -p "{project_name}.rbxm"
|
||||
```
|
||||
|
||||
You can include the `watch` flag to re-build it on save:
|
||||
|
||||
```bash
|
||||
rojo build -p "{project_name}.rbxm" --watch
|
||||
```
|
||||
|
||||
For more help, check out [the Rojo documentation](https://rojo.space/docs).
|
||||
6
assets/default-plugin-project/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "{project_name}",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
3
assets/default-plugin-project/gitignore.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Plugin model files
|
||||
/{project_name}.rbxmx
|
||||
/{project_name}.rbxm
|
||||
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 584 B After Width: | Height: | Size: 584 B |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
BIN
assets/images/icons/debug.png
Normal file
|
After Width: | Height: | Size: 183 B |
BIN
assets/images/icons/expand.png
Normal file
|
After Width: | Height: | Size: 273 B |
BIN
assets/images/icons/reset.png
Normal file
|
After Width: | Height: | Size: 933 B |
BIN
assets/images/icons/warning.png
Normal file
|
After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
BIN
assets/images/syncsuccess.png
Normal file
|
After Width: | Height: | Size: 574 B |
BIN
assets/images/syncwarning.png
Normal file
|
After Width: | Height: | Size: 607 B |
@@ -31,11 +31,12 @@ fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
|
||||
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
|
||||
let dir = tempdir().unwrap();
|
||||
let input = input_path.as_ref().to_path_buf();
|
||||
let output = dir.path().join("output.rbxlx");
|
||||
let output = Some(dir.path().join("output.rbxlx"));
|
||||
|
||||
let options = BuildCommand {
|
||||
project: input,
|
||||
watch: false,
|
||||
plugin: None,
|
||||
output,
|
||||
};
|
||||
|
||||
|
||||
37
build.rs
@@ -7,6 +7,7 @@ use fs_err as fs;
|
||||
use fs_err::File;
|
||||
use maplit::hashmap;
|
||||
use memofs::VfsSnapshot;
|
||||
use semver::Version;
|
||||
|
||||
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
@@ -19,6 +20,10 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||
|
||||
let file_name = entry.file_name().to_str().unwrap().to_owned();
|
||||
|
||||
if file_name.starts_with(".git") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// We can skip any TestEZ test files since they aren't necessary for
|
||||
// the plugin to run.
|
||||
if file_name.ends_with(".spec.lua") || file_name.ends_with(".spec.luau") {
|
||||
@@ -40,21 +45,33 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
let out_dir = env::var_os("OUT_DIR").unwrap();
|
||||
|
||||
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
|
||||
let plugin_root = PathBuf::from(root_dir).join("plugin");
|
||||
let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let plugin_dir = root_dir.join("plugin");
|
||||
|
||||
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
|
||||
let plugin_version =
|
||||
Version::parse(fs::read_to_string(plugin_dir.join("Version.txt"))?.trim())?;
|
||||
|
||||
assert_eq!(
|
||||
our_version, plugin_version,
|
||||
"plugin version does not match Cargo version"
|
||||
);
|
||||
|
||||
let snapshot = VfsSnapshot::dir(hashmap! {
|
||||
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
|
||||
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
|
||||
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
|
||||
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
|
||||
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
|
||||
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
|
||||
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
|
||||
"default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
|
||||
"plugin" => VfsSnapshot::dir(hashmap! {
|
||||
"fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
|
||||
"http" => snapshot_from_fs_path(&plugin_dir.join("http"))?,
|
||||
"log" => snapshot_from_fs_path(&plugin_dir.join("log"))?,
|
||||
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_dir.join("rbx_dom_lua"))?,
|
||||
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
|
||||
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
|
||||
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
|
||||
}),
|
||||
});
|
||||
|
||||
let out_path = Path::new(&out_dir).join("plugin.bincode");
|
||||
let out_file = File::create(&out_path)?;
|
||||
let out_file = File::create(out_path)?;
|
||||
|
||||
bincode::serialize_into(out_file, &snapshot)?;
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased Changes
|
||||
|
||||
## 0.3.0 (2024-03-15)
|
||||
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830]
|
||||
* Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854]
|
||||
|
||||
[#830]: https://github.com/rojo-rbx/rojo/pull/830
|
||||
[#854]: https://github.com/rojo-rbx/rojo/pull/854
|
||||
|
||||
## 0.2.0 (2021-08-23)
|
||||
* Updated to `crossbeam-channel` 0.5.1.
|
||||
|
||||
@@ -15,4 +22,4 @@
|
||||
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
|
||||
|
||||
## 0.1.0 (2020-03-10)
|
||||
* Initial release
|
||||
* Initial release
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "memofs"
|
||||
description = "Virtual filesystem with configurable backends."
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
@@ -11,7 +11,7 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
crossbeam-channel = "0.5.1"
|
||||
fs-err = "2.3.0"
|
||||
notify = "4.0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
crossbeam-channel = "0.5.12"
|
||||
fs-err = "2.11.0"
|
||||
notify = "4.0.17"
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
|
||||
@@ -50,6 +50,12 @@ impl InMemoryFs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryFsInner {
|
||||
entries: HashMap<PathBuf, Entry>,
|
||||
|
||||
@@ -22,9 +22,9 @@ mod noop_backend;
|
||||
mod snapshot;
|
||||
mod std_backend;
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::{io, str};
|
||||
|
||||
pub use in_memory_fs::InMemoryFs;
|
||||
pub use noop_backend::NoopBackend;
|
||||
@@ -155,6 +155,24 @@ impl VfsInner {
|
||||
Ok(Arc::new(contents))
|
||||
}
|
||||
|
||||
fn read_to_string<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<String>> {
|
||||
let path = path.as_ref();
|
||||
let contents = self.backend.read(path)?;
|
||||
|
||||
if self.watch_enabled {
|
||||
self.backend.watch(path)?;
|
||||
}
|
||||
|
||||
let contents_str = str::from_utf8(&contents).map_err(|_| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("File was not valid UTF-8: {}", path.display()),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Arc::new(contents_str.into()))
|
||||
}
|
||||
|
||||
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
|
||||
let path = path.as_ref();
|
||||
let contents = contents.as_ref();
|
||||
@@ -194,11 +212,8 @@ impl VfsInner {
|
||||
}
|
||||
|
||||
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
|
||||
match event {
|
||||
VfsEvent::Remove(path) => {
|
||||
let _ = self.backend.unwatch(&path);
|
||||
}
|
||||
_ => {}
|
||||
if let VfsEvent::Remove(path) = event {
|
||||
let _ = self.backend.unwatch(path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -261,6 +276,33 @@ impl Vfs {
|
||||
self.inner.lock().unwrap().read(path)
|
||||
}
|
||||
|
||||
/// Read a file from the VFS (or from the underlying backend if it isn't
|
||||
/// resident) into a string.
|
||||
///
|
||||
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string].
|
||||
///
|
||||
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
|
||||
#[inline]
|
||||
pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
|
||||
let path = path.as_ref();
|
||||
self.inner.lock().unwrap().read_to_string(path)
|
||||
}
|
||||
|
||||
/// Read a file from the VFS (or the underlying backend if it isn't
|
||||
/// resident) into a string, and normalize its line endings to LF.
|
||||
///
|
||||
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string], but also performs
|
||||
/// line ending normalization.
|
||||
///
|
||||
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
|
||||
#[inline]
|
||||
pub fn read_to_string_lf_normalized<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
|
||||
let path = path.as_ref();
|
||||
let contents = self.inner.lock().unwrap().read_to_string(path)?;
|
||||
|
||||
Ok(contents.replace("\r\n", "\n").into())
|
||||
}
|
||||
|
||||
/// Write a file to the VFS and the underlying backend.
|
||||
///
|
||||
/// Roughly equivalent to [`std::fs::write`][std::fs::write].
|
||||
@@ -431,3 +473,23 @@ impl VfsLock<'_> {
|
||||
self.inner.commit_event(event)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{InMemoryFs, Vfs, VfsSnapshot};
|
||||
|
||||
/// https://github.com/rojo-rbx/rojo/issues/899
|
||||
#[test]
|
||||
fn read_to_string_lf_normalized_keeps_trailing_newline() {
|
||||
let mut imfs = InMemoryFs::new();
|
||||
imfs.load_snapshot("test", VfsSnapshot::file("bar\r\nfoo\r\n\r\n"))
|
||||
.unwrap();
|
||||
|
||||
let vfs = Vfs::new(imfs);
|
||||
|
||||
assert_eq!(
|
||||
vfs.read_to_string_lf_normalized("test").unwrap().as_str(),
|
||||
"bar\nfoo\n\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,3 +74,9 @@ impl VfsBackend for NoopBackend {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashSet, io};
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
@@ -13,6 +13,7 @@ use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
|
||||
pub struct StdBackend {
|
||||
watcher: RecommendedWatcher,
|
||||
watcher_receiver: Receiver<VfsEvent>,
|
||||
watches: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl StdBackend {
|
||||
@@ -48,6 +49,7 @@ impl StdBackend {
|
||||
Self {
|
||||
watcher,
|
||||
watcher_receiver: rx,
|
||||
watches: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,14 +99,30 @@ impl VfsBackend for StdBackend {
|
||||
}
|
||||
|
||||
fn watch(&mut self, path: &Path) -> io::Result<()> {
|
||||
self.watcher
|
||||
.watch(path, RecursiveMode::NonRecursive)
|
||||
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
|
||||
if self.watches.contains(path)
|
||||
|| path
|
||||
.ancestors()
|
||||
.any(|ancestor| self.watches.contains(ancestor))
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
self.watches.insert(path.to_path_buf());
|
||||
self.watcher
|
||||
.watch(path, RecursiveMode::Recursive)
|
||||
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
|
||||
}
|
||||
}
|
||||
|
||||
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
|
||||
self.watches.remove(path);
|
||||
self.watcher
|
||||
.unwatch(path)
|
||||
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StdBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ edition = "2018"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde = "1.0.99"
|
||||
serde_yaml = "0.8.9"
|
||||
serde = "1.0.197"
|
||||
serde_yaml = "0.8.26"
|
||||
|
||||
@@ -5,19 +5,13 @@ use serde::Serialize;
|
||||
/// Enables redacting any value that serializes as a string.
|
||||
///
|
||||
/// Used for transforming Rojo instance IDs into something deterministic.
|
||||
#[derive(Default)]
|
||||
pub struct RedactionMap {
|
||||
ids: HashMap<String, usize>,
|
||||
last_id: usize,
|
||||
}
|
||||
|
||||
impl RedactionMap {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
ids: HashMap::new(),
|
||||
last_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_redacted_value(&self, id: impl ToString) -> Option<String> {
|
||||
let id = id.to_string();
|
||||
|
||||
|
||||
27
plugin.project.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "Rojo",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "plugin/src"
|
||||
},
|
||||
"Packages": {
|
||||
"$path": "plugin/Packages",
|
||||
"Log": {
|
||||
"$path": "plugin/log"
|
||||
},
|
||||
"Http": {
|
||||
"$path": "plugin/http"
|
||||
},
|
||||
"Fmt": {
|
||||
"$path": "plugin/fmt"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "plugin/rbx_dom_lua"
|
||||
}
|
||||
},
|
||||
"Version": {
|
||||
"$path": "plugin/Version.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
plugin/Packages/Flipper
Submodule
1
plugin/Packages/Highlighter
Submodule
1
plugin/Packages/Promise
Submodule
1
plugin/Packages/Roact
Submodule
1
plugin/Packages/TestEZ
Submodule
1
plugin/Packages/t
Submodule
1
plugin/Version.txt
Normal file
@@ -0,0 +1 @@
|
||||
7.5.0
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "Rojo",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "src"
|
||||
},
|
||||
"Packages": {
|
||||
"$path": "Packages",
|
||||
|
||||
"Log": {
|
||||
"$path": "log"
|
||||
},
|
||||
"Http": {
|
||||
"$path": "http"
|
||||
},
|
||||
"Fmt": {
|
||||
"$path": "fmt"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "rbx_dom_lua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ local function debugImpl(buffer, value, extendedForm)
|
||||
elseif valueType == "table" then
|
||||
local valueMeta = getmetatable(value)
|
||||
|
||||
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
|
||||
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
|
||||
-- This type implement's the metamethod we made up to line up with
|
||||
-- Rust's 'Debug' trait.
|
||||
|
||||
@@ -242,4 +242,4 @@ return {
|
||||
debugOutputBuffer = debugOutputBuffer,
|
||||
fmt = fmt,
|
||||
debugify = debugify,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ Error.__index = Error
|
||||
|
||||
Error.Kind = {
|
||||
HttpNotEnabled = {
|
||||
message = "Rojo requires HTTP access, which is not enabled.\n" ..
|
||||
"Check your game settings, located in the 'Home' tab of Studio.",
|
||||
message = "Rojo requires HTTP access, which is not enabled.\n"
|
||||
.. "Check your game settings, located in the 'Home' tab of Studio.",
|
||||
},
|
||||
ConnectFailed = {
|
||||
message = "Couldn't connect to the Rojo server.\n" ..
|
||||
"Make sure the server is running — use 'rojo serve' to run it!",
|
||||
message = "Couldn't connect to the Rojo server.\n"
|
||||
.. "Make sure the server is running — use 'rojo serve' to run it!",
|
||||
},
|
||||
Timeout = {
|
||||
message = "HTTP request timed out.",
|
||||
@@ -63,4 +63,13 @@ function Error.fromRobloxErrorString(message)
|
||||
return Error.new(Error.Kind.Unknown, message)
|
||||
end
|
||||
|
||||
function Error.fromResponse(response)
|
||||
local lower = (response.body or ""):lower()
|
||||
if response.code == 408 or response.code == 504 or lower:find("timed? ?out") then
|
||||
return Error.new(Error.Kind.Timeout)
|
||||
end
|
||||
|
||||
return Error.new(Error.Kind.Unknown, string.format("%s: %s", tostring(response.code), tostring(response.body)))
|
||||
end
|
||||
|
||||
return Error
|
||||
|
||||
@@ -31,4 +31,4 @@ function Response:json()
|
||||
return HttpService:JSONDecode(self.body)
|
||||
end
|
||||
|
||||
return Response
|
||||
return Response
|
||||
|
||||
@@ -30,8 +30,13 @@ local function performRequest(requestParams)
|
||||
end)
|
||||
|
||||
if success then
|
||||
Log.trace("Request {} success, status code {}", requestId, response.StatusCode)
|
||||
resolve(HttpResponse.fromRobloxResponse(response))
|
||||
Log.trace("Request {} success, response {:#?}", requestId, response)
|
||||
local httpResponse = HttpResponse.fromRobloxResponse(response)
|
||||
if httpResponse:isSuccess() then
|
||||
resolve(httpResponse)
|
||||
else
|
||||
reject(HttpError.fromResponse(httpResponse))
|
||||
end
|
||||
else
|
||||
Log.trace("Request {} failure: {:?}", requestId, response)
|
||||
reject(HttpError.fromRobloxErrorString(response))
|
||||
@@ -63,4 +68,4 @@ function Http.jsonDecode(source)
|
||||
return HttpService:JSONDecode(source)
|
||||
end
|
||||
|
||||
return Http
|
||||
return Http
|
||||
|
||||
@@ -2,4 +2,4 @@ return function()
|
||||
it("should load", function()
|
||||
require(script.Parent)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,4 +57,4 @@ function Log.error(template, ...)
|
||||
error(Fmt.fmt(template, ...))
|
||||
end
|
||||
|
||||
return Log
|
||||
return Log
|
||||
|
||||
@@ -2,4 +2,4 @@ return function()
|
||||
it("should load", function()
|
||||
require(script.Parent)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,8 +20,8 @@ local function serializeFloat(value)
|
||||
return value
|
||||
end
|
||||
|
||||
local ALL_AXES = {"X", "Y", "Z"}
|
||||
local ALL_FACES = {"Right", "Top", "Back", "Left", "Bottom", "Front"}
|
||||
local ALL_AXES = { "X", "Y", "Z" }
|
||||
local ALL_FACES = { "Right", "Top", "Back", "Left", "Bottom", "Front" }
|
||||
|
||||
local EncodedValue = {}
|
||||
|
||||
@@ -37,7 +37,10 @@ types = {
|
||||
if ok then
|
||||
output[key] = result
|
||||
else
|
||||
local warning = ("Could not decode attribute value of type %q: %s"):format(typeof(value), tostring(result))
|
||||
local warning = ("Could not decode attribute value of type %q: %s"):format(
|
||||
typeof(value),
|
||||
tostring(result)
|
||||
)
|
||||
warn(warning)
|
||||
end
|
||||
end
|
||||
@@ -53,7 +56,10 @@ types = {
|
||||
if ok then
|
||||
output[key] = result
|
||||
else
|
||||
local warning = ("Could not encode attribute value of type %q: %s"):format(typeof(value), tostring(result))
|
||||
local warning = ("Could not encode attribute value of type %q: %s"):format(
|
||||
typeof(value),
|
||||
tostring(result)
|
||||
)
|
||||
warn(warning)
|
||||
end
|
||||
end
|
||||
@@ -111,6 +117,7 @@ types = {
|
||||
local pos = pod.position
|
||||
local orient = pod.orientation
|
||||
|
||||
--stylua: ignore
|
||||
return CFrame.new(
|
||||
pos[1], pos[2], pos[3],
|
||||
orient[1][1], orient[1][2], orient[1][3],
|
||||
@@ -120,17 +127,14 @@ types = {
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
local x, y, z,
|
||||
r00, r01, r02,
|
||||
r10, r11, r12,
|
||||
r20, r21, r22 = roblox:GetComponents()
|
||||
local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = roblox:GetComponents()
|
||||
|
||||
return {
|
||||
position = {x, y, z},
|
||||
position = { x, y, z },
|
||||
orientation = {
|
||||
{r00, r01, r02},
|
||||
{r10, r11, r12},
|
||||
{r20, r21, r22},
|
||||
{ r00, r01, r02 },
|
||||
{ r10, r11, r12 },
|
||||
{ r20, r21, r22 },
|
||||
},
|
||||
}
|
||||
end,
|
||||
@@ -140,7 +144,7 @@ types = {
|
||||
fromPod = unpackDecoder(Color3.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.r, roblox.g, roblox.b}
|
||||
return { roblox.r, roblox.g, roblox.b }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -161,10 +165,7 @@ types = {
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(pod.keypoints) do
|
||||
keypoints[index] = ColorSequenceKeypoint.new(
|
||||
keypoint.time,
|
||||
types.Color3.fromPod(keypoint.color)
|
||||
)
|
||||
keypoints[index] = ColorSequenceKeypoint.new(keypoint.time, types.Color3.fromPod(keypoint.color))
|
||||
end
|
||||
|
||||
return ColorSequence.new(keypoints)
|
||||
@@ -187,6 +188,38 @@ types = {
|
||||
},
|
||||
|
||||
Content = {
|
||||
fromPod = function(pod): Content
|
||||
if type(pod) == "string" then
|
||||
if pod == "None" then
|
||||
return Content.none
|
||||
else
|
||||
error(`unexpected Content value '{pod}'`)
|
||||
end
|
||||
else
|
||||
local ty, value = next(pod)
|
||||
if ty == "Uri" then
|
||||
return Content.fromUri(value)
|
||||
elseif ty == "Object" then
|
||||
error("Object deserializing is not currently implemented")
|
||||
else
|
||||
error(`Unknown Content type '{ty}' (could not deserialize)`)
|
||||
end
|
||||
end
|
||||
end,
|
||||
toPod = function(roblox: Content)
|
||||
if roblox.SourceType == Enum.ContentSourceType.None then
|
||||
return "None"
|
||||
elseif roblox.SourceType == Enum.ContentSourceType.Uri then
|
||||
return { Uri = roblox.Uri }
|
||||
elseif roblox.SourceType == Enum.ContentSourceType.Object then
|
||||
error("Object serializing is not currently implemented")
|
||||
else
|
||||
error(`Unknown Content type '{roblox.SourceType} (could not serialize)`)
|
||||
end
|
||||
end,
|
||||
},
|
||||
|
||||
ContentId = {
|
||||
fromPod = identity,
|
||||
toPod = identity,
|
||||
},
|
||||
@@ -204,6 +237,19 @@ types = {
|
||||
end,
|
||||
},
|
||||
|
||||
EnumItem = {
|
||||
fromPod = function(pod)
|
||||
return Enum[pod.type]:FromValue(pod.value)
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
return {
|
||||
type = tostring(roblox.EnumType),
|
||||
value = roblox.Value,
|
||||
}
|
||||
end,
|
||||
},
|
||||
|
||||
Faces = {
|
||||
fromPod = function(pod)
|
||||
local faces = {}
|
||||
@@ -265,11 +311,32 @@ types = {
|
||||
toPod = identity,
|
||||
},
|
||||
|
||||
MaterialColors = {
|
||||
fromPod = function(pod: { [string]: { number } })
|
||||
local real = {}
|
||||
for name, color in pod do
|
||||
real[Enum.Material[name]] = Color3.fromRGB(color[1], color[2], color[3])
|
||||
end
|
||||
return real
|
||||
end,
|
||||
toPod = function(roblox: { [Enum.Material]: Color3 })
|
||||
local pod = {}
|
||||
for material, color in roblox do
|
||||
pod[material.Name] = {
|
||||
math.round(math.clamp(color.R, 0, 1) * 255),
|
||||
math.round(math.clamp(color.G, 0, 1) * 255),
|
||||
math.round(math.clamp(color.B, 0, 1) * 255),
|
||||
}
|
||||
end
|
||||
return pod
|
||||
end,
|
||||
},
|
||||
|
||||
NumberRange = {
|
||||
fromPod = unpackDecoder(NumberRange.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.Min, roblox.Max}
|
||||
return { roblox.Min, roblox.Max }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -278,11 +345,12 @@ types = {
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(pod.keypoints) do
|
||||
keypoints[index] = NumberSequenceKeypoint.new(
|
||||
keypoint.time,
|
||||
keypoint.value,
|
||||
keypoint.envelope
|
||||
)
|
||||
-- TODO: Add a test for NaN or Infinity values and envelopes
|
||||
-- Right now it isn't possible because it'd fail the roundtrip.
|
||||
-- It's more important that it works right now, though.
|
||||
local value = keypoint.value or 0
|
||||
local envelope = keypoint.envelope or 0
|
||||
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, value, envelope)
|
||||
end
|
||||
|
||||
return NumberSequence.new(keypoints)
|
||||
@@ -337,10 +405,7 @@ types = {
|
||||
|
||||
Ray = {
|
||||
fromPod = function(pod)
|
||||
return Ray.new(
|
||||
types.Vector3.fromPod(pod.origin),
|
||||
types.Vector3.fromPod(pod.direction)
|
||||
)
|
||||
return Ray.new(types.Vector3.fromPod(pod.origin), types.Vector3.fromPod(pod.direction))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -353,10 +418,7 @@ types = {
|
||||
|
||||
Rect = {
|
||||
fromPod = function(pod)
|
||||
return Rect.new(
|
||||
types.Vector2.fromPod(pod[1]),
|
||||
types.Vector2.fromPod(pod[2])
|
||||
)
|
||||
return Rect.new(types.Vector2.fromPod(pod[1]), types.Vector2.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -368,31 +430,28 @@ types = {
|
||||
},
|
||||
|
||||
Ref = {
|
||||
fromPod = function(_pod)
|
||||
fromPod = function(_)
|
||||
error("Ref cannot be decoded on its own")
|
||||
end,
|
||||
|
||||
toPod = function(_roblox)
|
||||
toPod = function(_)
|
||||
error("Ref can not be encoded on its own")
|
||||
end,
|
||||
},
|
||||
|
||||
Region3 = {
|
||||
fromPod = function(pod)
|
||||
fromPod = function(_)
|
||||
error("Region3 is not implemented")
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
toPod = function(_)
|
||||
error("Region3 is not implemented")
|
||||
end,
|
||||
},
|
||||
|
||||
Region3int16 = {
|
||||
fromPod = function(pod)
|
||||
return Region3int16.new(
|
||||
types.Vector3int16.fromPod(pod[1]),
|
||||
types.Vector3int16.fromPod(pod[2])
|
||||
)
|
||||
return Region3int16.new(types.Vector3int16.fromPod(pod[1]), types.Vector3int16.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -404,11 +463,11 @@ types = {
|
||||
},
|
||||
|
||||
SharedString = {
|
||||
fromPod = function(pod)
|
||||
fromPod = function(_pod)
|
||||
error("SharedString is not supported")
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
toPod = function(_roblox)
|
||||
error("SharedString is not supported")
|
||||
end,
|
||||
},
|
||||
@@ -422,16 +481,13 @@ types = {
|
||||
fromPod = unpackDecoder(UDim.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.Scale, roblox.Offset}
|
||||
return { roblox.Scale, roblox.Offset }
|
||||
end,
|
||||
},
|
||||
|
||||
UDim2 = {
|
||||
fromPod = function(pod)
|
||||
return UDim2.new(
|
||||
types.UDim.fromPod(pod[1]),
|
||||
types.UDim.fromPod(pod[2])
|
||||
)
|
||||
return UDim2.new(types.UDim.fromPod(pod[1]), types.UDim.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -462,7 +518,7 @@ types = {
|
||||
fromPod = unpackDecoder(Vector2int16.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.X, roblox.Y}
|
||||
return { roblox.X, roblox.Y }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -482,14 +538,37 @@ types = {
|
||||
fromPod = unpackDecoder(Vector3int16.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.X, roblox.Y, roblox.Z}
|
||||
return { roblox.X, roblox.Y, roblox.Z }
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
types.OptionalCFrame = {
|
||||
fromPod = function(pod)
|
||||
if pod == nil then
|
||||
return nil
|
||||
else
|
||||
return types.CFrame.fromPod(pod)
|
||||
end
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
if roblox == nil then
|
||||
return nil
|
||||
else
|
||||
return types.CFrame.toPod(roblox)
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
function EncodedValue.decode(encodedValue)
|
||||
local ty, value = next(encodedValue)
|
||||
|
||||
if ty == nil then
|
||||
-- If the encoded pair is empty, assume it is an unoccupied optional value
|
||||
return true, nil
|
||||
end
|
||||
|
||||
local typeImpl = types[ty]
|
||||
if typeImpl == nil then
|
||||
return false, "Couldn't decode value " .. tostring(ty)
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
return function()
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local EncodedValue = require(script.Parent.EncodedValue)
|
||||
local allValues = require(script.Parent.allValues)
|
||||
|
||||
local function deepEq(a, b)
|
||||
if typeof(a) ~= typeof(b) then
|
||||
return false
|
||||
end
|
||||
|
||||
local ty = typeof(a)
|
||||
|
||||
if ty == "table" then
|
||||
local visited = {}
|
||||
|
||||
for key, valueA in pairs(a) do
|
||||
visited[key] = true
|
||||
|
||||
if not deepEq(valueA, b[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
for key, valueB in pairs(b) do
|
||||
if visited[key] then
|
||||
continue
|
||||
end
|
||||
|
||||
if not deepEq(valueB, a[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
else
|
||||
return a == b
|
||||
end
|
||||
end
|
||||
|
||||
local extraAssertions = {
|
||||
CFrame = function(value)
|
||||
expect(value).to.equal(CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12))
|
||||
end,
|
||||
}
|
||||
|
||||
for testName, testEntry in pairs(allValues) do
|
||||
it("round trip " .. testName, function()
|
||||
local ok, decoded = EncodedValue.decode(testEntry.value)
|
||||
assert(ok, decoded)
|
||||
|
||||
if extraAssertions[testName] ~= nil then
|
||||
extraAssertions[testName](decoded)
|
||||
end
|
||||
|
||||
local ok, encoded = EncodedValue.encode(decoded, testEntry.ty)
|
||||
assert(ok, encoded)
|
||||
|
||||
if not deepEq(encoded, testEntry.value) then
|
||||
local expected = HttpService:JSONEncode(testEntry.value)
|
||||
local actual = HttpService:JSONEncode(encoded)
|
||||
|
||||
local message = string.format(
|
||||
"Round-trip results did not match.\nExpected:\n%s\nActual:\n%s",
|
||||
expected, actual
|
||||
)
|
||||
|
||||
error(message)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ Error.Kind = {
|
||||
UnknownProperty = "UnknownProperty",
|
||||
PropertyNotReadable = "PropertyNotReadable",
|
||||
PropertyNotWritable = "PropertyNotWritable",
|
||||
CannotParseBinaryString = "CannotParseBinaryString",
|
||||
Roblox = "Roblox",
|
||||
}
|
||||
|
||||
@@ -25,4 +26,4 @@ function Error:__tostring()
|
||||
return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
|
||||
end
|
||||
|
||||
return Error
|
||||
return Error
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
0.0
|
||||
]
|
||||
},
|
||||
"TestEnumItem": {
|
||||
"EnumItem": {
|
||||
"type": "Material",
|
||||
"value": 256
|
||||
}
|
||||
},
|
||||
"TestNumber": {
|
||||
"Float64": 1337.0
|
||||
},
|
||||
@@ -170,9 +176,23 @@
|
||||
},
|
||||
"ty": "ColorSequence"
|
||||
},
|
||||
"Content": {
|
||||
"ContentId": {
|
||||
"value": {
|
||||
"Content": "rbxassetid://12345"
|
||||
"ContentId": "rbxassetid://12345"
|
||||
},
|
||||
"ty": "ContentId"
|
||||
},
|
||||
"Content_None": {
|
||||
"value": {
|
||||
"Content": "None"
|
||||
},
|
||||
"ty": "Content"
|
||||
},
|
||||
"Content_Uri": {
|
||||
"value": {
|
||||
"Content": {
|
||||
"Uri": "rbxasset://abc/123.rojo"
|
||||
}
|
||||
},
|
||||
"ty": "Content"
|
||||
},
|
||||
@@ -182,6 +202,15 @@
|
||||
},
|
||||
"ty": "Enum"
|
||||
},
|
||||
"EnumItem": {
|
||||
"value": {
|
||||
"EnumItem": {
|
||||
"type": "Material",
|
||||
"value": 256
|
||||
}
|
||||
},
|
||||
"ty": "EnumItem"
|
||||
},
|
||||
"Faces": {
|
||||
"value": {
|
||||
"Faces": [
|
||||
@@ -230,6 +259,118 @@
|
||||
},
|
||||
"ty": "Int64"
|
||||
},
|
||||
"MaterialColors": {
|
||||
"value": {
|
||||
"MaterialColors": {
|
||||
"Grass": [
|
||||
106,
|
||||
127,
|
||||
63
|
||||
],
|
||||
"Slate": [
|
||||
63,
|
||||
127,
|
||||
107
|
||||
],
|
||||
"Concrete": [
|
||||
127,
|
||||
102,
|
||||
63
|
||||
],
|
||||
"Brick": [
|
||||
138,
|
||||
86,
|
||||
62
|
||||
],
|
||||
"Sand": [
|
||||
143,
|
||||
126,
|
||||
95
|
||||
],
|
||||
"WoodPlanks": [
|
||||
139,
|
||||
109,
|
||||
79
|
||||
],
|
||||
"Rock": [
|
||||
102,
|
||||
108,
|
||||
111
|
||||
],
|
||||
"Glacier": [
|
||||
101,
|
||||
176,
|
||||
234
|
||||
],
|
||||
"Snow": [
|
||||
195,
|
||||
199,
|
||||
218
|
||||
],
|
||||
"Sandstone": [
|
||||
137,
|
||||
90,
|
||||
71
|
||||
],
|
||||
"Mud": [
|
||||
58,
|
||||
46,
|
||||
36
|
||||
],
|
||||
"Basalt": [
|
||||
30,
|
||||
30,
|
||||
37
|
||||
],
|
||||
"Ground": [
|
||||
102,
|
||||
92,
|
||||
59
|
||||
],
|
||||
"CrackedLava": [
|
||||
232,
|
||||
156,
|
||||
74
|
||||
],
|
||||
"Asphalt": [
|
||||
115,
|
||||
123,
|
||||
107
|
||||
],
|
||||
"Cobblestone": [
|
||||
132,
|
||||
123,
|
||||
90
|
||||
],
|
||||
"Ice": [
|
||||
129,
|
||||
194,
|
||||
224
|
||||
],
|
||||
"LeafyGrass": [
|
||||
115,
|
||||
132,
|
||||
74
|
||||
],
|
||||
"Salt": [
|
||||
198,
|
||||
189,
|
||||
181
|
||||
],
|
||||
"Limestone": [
|
||||
206,
|
||||
173,
|
||||
148
|
||||
],
|
||||
"Pavement": [
|
||||
148,
|
||||
148,
|
||||
140
|
||||
]
|
||||
}
|
||||
},
|
||||
"ty": "MaterialColors"
|
||||
},
|
||||
"NumberRange": {
|
||||
"value": {
|
||||
"NumberRange": [
|
||||
@@ -258,6 +399,41 @@
|
||||
},
|
||||
"ty": "NumberSequence"
|
||||
},
|
||||
"OptionalCFrame-None": {
|
||||
"value": {
|
||||
"OptionalCFrame": null
|
||||
},
|
||||
"ty": "OptionalCFrame"
|
||||
},
|
||||
"OptionalCFrame-Some": {
|
||||
"value": {
|
||||
"OptionalCFrame": {
|
||||
"position": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"orientation": [
|
||||
[
|
||||
1.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
1.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
1.0
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"ty": "OptionalCFrame"
|
||||
},
|
||||
"PhysicalProperties-Custom": {
|
||||
"value": {
|
||||
"PhysicalProperties": {
|
||||
|
||||
@@ -136,4 +136,4 @@ end
|
||||
return {
|
||||
decode = decodeBase64,
|
||||
encode = encodeBase64,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
return function()
|
||||
local base64 = require(script.Parent.base64)
|
||||
|
||||
it("should encode and decode", function()
|
||||
local function try(str, expected)
|
||||
local encoded = base64.encode(str)
|
||||
expect(encoded).to.equal(expected)
|
||||
expect(base64.decode(encoded)).to.equal(str)
|
||||
end
|
||||
|
||||
try("Man", "TWFu")
|
||||
try("Ma", "TWE=")
|
||||
try("M", "TQ==")
|
||||
try("ManM", "TWFuTQ==")
|
||||
try(
|
||||
[[Man is distinguished, not only by his reason, but by this ]]..
|
||||
[[singular passion from other animals, which is a lust of the ]]..
|
||||
[[mind, that by a perseverance of delight in the continued and ]]..
|
||||
[[indefatigable generation of knowledge, exceeds the short ]]..
|
||||
[[vehemence of any carnal pleasure.]],
|
||||
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
|
||||
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
|
||||
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
|
||||
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
|
||||
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
|
||||
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
|
||||
)
|
||||
end)
|
||||
end
|
||||
@@ -1,4 +1,47 @@
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local ScriptEditorService = game:GetService("ScriptEditorService")
|
||||
|
||||
local Error = require(script.Parent.Error)
|
||||
|
||||
--- A list of `Enum.Material` values that are used for Terrain.MaterialColors
|
||||
local TERRAIN_MATERIAL_COLORS = {
|
||||
Enum.Material.Grass,
|
||||
Enum.Material.Slate,
|
||||
Enum.Material.Concrete,
|
||||
Enum.Material.Brick,
|
||||
Enum.Material.Sand,
|
||||
Enum.Material.WoodPlanks,
|
||||
Enum.Material.Rock,
|
||||
Enum.Material.Glacier,
|
||||
Enum.Material.Snow,
|
||||
Enum.Material.Sandstone,
|
||||
Enum.Material.Mud,
|
||||
Enum.Material.Basalt,
|
||||
Enum.Material.Ground,
|
||||
Enum.Material.CrackedLava,
|
||||
Enum.Material.Asphalt,
|
||||
Enum.Material.Cobblestone,
|
||||
Enum.Material.Ice,
|
||||
Enum.Material.LeafyGrass,
|
||||
Enum.Material.Salt,
|
||||
Enum.Material.Limestone,
|
||||
Enum.Material.Pavement,
|
||||
}
|
||||
|
||||
local function isAttributeNameValid(attributeName)
|
||||
-- For SetAttribute to succeed, the attribute name must be less than or
|
||||
-- equal to 100 characters...
|
||||
return #attributeName <= 100
|
||||
-- ...and must only contain alphanumeric characters, periods, hyphens,
|
||||
-- underscores, or forward slashes.
|
||||
and attributeName:match("[^%w%.%-_/]") == nil
|
||||
end
|
||||
|
||||
local function isAttributeNameReserved(attributeName)
|
||||
-- For SetAttribute to succeed, attribute names must not use the RBX
|
||||
-- prefix, which is reserved by Roblox.
|
||||
return attributeName:sub(1, 3) == "RBX"
|
||||
end
|
||||
|
||||
-- Defines how to read and write properties that aren't directly scriptable.
|
||||
--
|
||||
@@ -10,19 +53,45 @@ return {
|
||||
return true, instance:GetAttributes()
|
||||
end,
|
||||
write = function(instance, _, value)
|
||||
local existing = instance:GetAttributes()
|
||||
|
||||
for key, attr in pairs(value) do
|
||||
instance:SetAttribute(key, attr)
|
||||
if typeof(value) ~= "table" then
|
||||
return false, Error.new(Error.Kind.CannotParseBinaryString)
|
||||
end
|
||||
|
||||
for key in pairs(existing) do
|
||||
if value[key] == nil then
|
||||
instance:SetAttribute(key, nil)
|
||||
local existing = instance:GetAttributes()
|
||||
local didAllWritesSucceed = true
|
||||
|
||||
for attributeName, attributeValue in pairs(value) do
|
||||
if isAttributeNameReserved(attributeName) then
|
||||
-- If the attribute name is reserved, then we don't
|
||||
-- really care about reporting any failures about
|
||||
-- it.
|
||||
continue
|
||||
end
|
||||
|
||||
if not isAttributeNameValid(attributeName) then
|
||||
didAllWritesSucceed = false
|
||||
continue
|
||||
end
|
||||
|
||||
instance:SetAttribute(attributeName, attributeValue)
|
||||
end
|
||||
|
||||
for existingAttributeName in pairs(existing) do
|
||||
if isAttributeNameReserved(existingAttributeName) then
|
||||
continue
|
||||
end
|
||||
|
||||
if not isAttributeNameValid(existingAttributeName) then
|
||||
didAllWritesSucceed = false
|
||||
continue
|
||||
end
|
||||
|
||||
if value[existingAttributeName] == nil then
|
||||
instance:SetAttribute(existingAttributeName, nil)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
return didAllWritesSucceed
|
||||
end,
|
||||
},
|
||||
Tags = {
|
||||
@@ -52,10 +121,10 @@ return {
|
||||
},
|
||||
LocalizationTable = {
|
||||
Contents = {
|
||||
read = function(instance, key)
|
||||
read = function(instance, _)
|
||||
return true, instance:GetContents()
|
||||
end,
|
||||
write = function(instance, key, value)
|
||||
write = function(instance, _, value)
|
||||
instance:SetContents(value)
|
||||
return true
|
||||
end,
|
||||
@@ -70,5 +139,73 @@ return {
|
||||
return true, instance:ScaleTo(value)
|
||||
end,
|
||||
},
|
||||
WorldPivotData = {
|
||||
read = function(instance)
|
||||
return true, instance.WorldPivot
|
||||
end,
|
||||
write = function(instance, _, value)
|
||||
if value == nil then
|
||||
return true, nil
|
||||
else
|
||||
instance.WorldPivot = value
|
||||
return true
|
||||
end
|
||||
end,
|
||||
},
|
||||
},
|
||||
Terrain = {
|
||||
MaterialColors = {
|
||||
read = function(instance: Terrain)
|
||||
-- There's no way to get a list of every color, so we have to
|
||||
-- make one.
|
||||
local colors = {}
|
||||
for _, material in TERRAIN_MATERIAL_COLORS do
|
||||
colors[material] = instance:GetMaterialColor(material)
|
||||
end
|
||||
|
||||
return true, colors
|
||||
end,
|
||||
write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 })
|
||||
if typeof(value) ~= "table" then
|
||||
return false, Error.new(Error.Kind.CannotParseBinaryString)
|
||||
end
|
||||
|
||||
for material, color in value do
|
||||
instance:SetMaterialColor(material, color)
|
||||
end
|
||||
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
Script = {
|
||||
Source = {
|
||||
read = function(instance: Script)
|
||||
return true, ScriptEditorService:GetEditorSource(instance)
|
||||
end,
|
||||
write = function(instance: Script, _, value: string)
|
||||
task.spawn(function()
|
||||
ScriptEditorService:UpdateSourceAsync(instance, function()
|
||||
return value
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
ModuleScript = {
|
||||
Source = {
|
||||
read = function(instance: ModuleScript)
|
||||
return true, ScriptEditorService:GetEditorSource(instance)
|
||||
end,
|
||||
write = function(instance: ModuleScript, _, value: string)
|
||||
task.spawn(function()
|
||||
ScriptEditorService:UpdateSourceAsync(instance, function()
|
||||
return value
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ local function findCanonicalPropertyDescriptor(className, propertyName)
|
||||
return PropertyDescriptor.fromRaw(
|
||||
currentClass.Properties[aliasData.AliasFor],
|
||||
currentClassName,
|
||||
aliasData.AliasFor)
|
||||
aliasData.AliasFor
|
||||
)
|
||||
end
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
return function()
|
||||
local RbxDom = require(script.Parent)
|
||||
|
||||
it("should load", function()
|
||||
expect(RbxDom).to.be.ok()
|
||||
end)
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
|
||||
local TestEZ = require(ReplicatedStorage.Packages:WaitForChild("TestEZ", 10))
|
||||
|
||||
local Rojo = ReplicatedStorage.Rojo
|
||||
|
||||
|
||||
@@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
|
||||
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
|
||||
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
|
||||
|
||||
--[[
|
||||
Returns a promise that will never resolve nor reject.
|
||||
]]
|
||||
local function hangingPromise()
|
||||
return Promise.new(function() end)
|
||||
end
|
||||
|
||||
local function rejectFailedRequests(response)
|
||||
if response.code >= 400 then
|
||||
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
|
||||
@@ -31,15 +24,17 @@ end
|
||||
local function rejectWrongProtocolVersion(infoResponseBody)
|
||||
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
|
||||
local message = (
|
||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
||||
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
|
||||
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
|
||||
"\nYour server is version %s, with protocol version %s." ..
|
||||
"\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible."
|
||||
.. "\nMake sure you have matching versions of both the Rojo plugin and server!"
|
||||
.. "\n\nYour client is version %s, with protocol version %s. It expects server version %s."
|
||||
.. "\nYour server is version %s, with protocol version %s."
|
||||
.. "\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
||||
):format(
|
||||
Version.display(Config.version), Config.protocolVersion,
|
||||
Version.display(Config.version),
|
||||
Config.protocolVersion,
|
||||
Config.expectedServerVersionString,
|
||||
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
|
||||
infoResponseBody.serverVersion,
|
||||
infoResponseBody.protocolVersion
|
||||
)
|
||||
|
||||
return Promise.reject(message)
|
||||
@@ -50,14 +45,7 @@ end
|
||||
|
||||
local function rejectWrongPlaceId(infoResponseBody)
|
||||
if infoResponseBody.expectedPlaceIds ~= nil then
|
||||
local foundId = false
|
||||
|
||||
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
|
||||
if id == game.PlaceId then
|
||||
foundId = true
|
||||
break
|
||||
end
|
||||
end
|
||||
local foundId = table.find(infoResponseBody.expectedPlaceIds, game.PlaceId)
|
||||
|
||||
if not foundId then
|
||||
local idList = {}
|
||||
@@ -66,14 +54,31 @@ local function rejectWrongPlaceId(infoResponseBody)
|
||||
end
|
||||
|
||||
local message = (
|
||||
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
|
||||
"\nYour place ID is %s, but needs to be one of these:" ..
|
||||
"\n%s" ..
|
||||
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
||||
):format(
|
||||
tostring(game.PlaceId),
|
||||
table.concat(idList, "\n")
|
||||
)
|
||||
"Found a Rojo server, but its project is set to only be used with a specific list of places."
|
||||
.. "\nYour place ID is %u, but needs to be one of these:"
|
||||
.. "\n%s"
|
||||
.. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
||||
):format(game.PlaceId, table.concat(idList, "\n"))
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
end
|
||||
|
||||
if infoResponseBody.unexpectedPlaceIds ~= nil then
|
||||
local foundId = table.find(infoResponseBody.unexpectedPlaceIds, game.PlaceId)
|
||||
|
||||
if foundId then
|
||||
local idList = {}
|
||||
for _, id in ipairs(infoResponseBody.unexpectedPlaceIds) do
|
||||
table.insert(idList, "- " .. tostring(id))
|
||||
end
|
||||
|
||||
local message = (
|
||||
"Found a Rojo server, but its project is set to not be used with a specific list of places."
|
||||
.. "\nYour place ID is %u, but needs to not be one of these:"
|
||||
.. "\n%s"
|
||||
.. "\n\nTo change this list, edit 'blockedPlaceIds' in your .project.json file."
|
||||
):format(game.PlaceId, table.concat(idList, "\n"))
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
@@ -93,6 +98,7 @@ function ApiContext.new(baseUrl)
|
||||
__sessionId = nil,
|
||||
__messageCursor = -1,
|
||||
__connected = true,
|
||||
__activeRequests = {},
|
||||
}
|
||||
|
||||
return setmetatable(self, ApiContext)
|
||||
@@ -113,6 +119,11 @@ end
|
||||
|
||||
function ApiContext:disconnect()
|
||||
self.__connected = false
|
||||
for request in self.__activeRequests do
|
||||
Log.trace("Cancelling request {}", request)
|
||||
request:cancel()
|
||||
end
|
||||
self.__activeRequests = {}
|
||||
end
|
||||
|
||||
function ApiContext:setMessageCursor(index)
|
||||
@@ -142,18 +153,15 @@ end
|
||||
function ApiContext:read(ids)
|
||||
local url = ("%s/api/read/%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
|
||||
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(validateApiRead(body))
|
||||
assert(validateApiRead(body))
|
||||
|
||||
return body
|
||||
end)
|
||||
return body
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:write(patch)
|
||||
@@ -190,63 +198,58 @@ function ApiContext:write(patch)
|
||||
|
||||
body = Http.jsonEncode(body)
|
||||
|
||||
return Http.post(url, body)
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
Log.info("Write response: {:?}", body)
|
||||
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
|
||||
Log.info("Write response: {:?}", responseBody)
|
||||
|
||||
return body
|
||||
end)
|
||||
return responseBody
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:retrieveMessages()
|
||||
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
|
||||
|
||||
local function sendRequest()
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
if err.type == Http.Error.Kind.Timeout then
|
||||
if self.__connected then
|
||||
return sendRequest()
|
||||
else
|
||||
return hangingPromise()
|
||||
end
|
||||
end
|
||||
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
end
|
||||
|
||||
return sendRequest()
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
local request = Http.get(url):catch(function(err)
|
||||
if err.type == Http.Error.Kind.Timeout and self.__connected then
|
||||
return sendRequest()
|
||||
end
|
||||
|
||||
assert(validateApiSubscribe(body))
|
||||
|
||||
self:setMessageCursor(body.messageCursor)
|
||||
|
||||
return body.messages
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
|
||||
Log.trace("Tracking request {}", request)
|
||||
self.__activeRequests[request] = true
|
||||
|
||||
return request:finally(function(...)
|
||||
Log.trace("Cleaning up request {}", request)
|
||||
self.__activeRequests[request] = nil
|
||||
return ...
|
||||
end)
|
||||
end
|
||||
|
||||
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
assert(validateApiSubscribe(body))
|
||||
|
||||
self:setMessageCursor(body.messageCursor)
|
||||
|
||||
return body.messages
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:open(id)
|
||||
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
||||
|
||||
return Http.post(url, "")
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
return nil
|
||||
end)
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
return ApiContext
|
||||
|
||||
@@ -23,18 +23,16 @@ end
|
||||
|
||||
function Checkbox:didUpdate(lastProps)
|
||||
if lastProps.active ~= self.props.active then
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
})
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
function Checkbox:render()
|
||||
return Theme.with(function(theme)
|
||||
theme = theme.Checkbox
|
||||
local checkboxTheme = theme.Checkbox
|
||||
|
||||
local activeTransparency = Roact.joinBindings({
|
||||
self.binding:map(function(value)
|
||||
@@ -51,22 +49,29 @@ function Checkbox:render()
|
||||
ZIndex = self.props.zIndex,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Event.Activated] = self.props.onClick,
|
||||
[Roact.Event.Activated] = function()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
self.props.onClick()
|
||||
end,
|
||||
}, {
|
||||
StateTip = e(Tooltip.Trigger, {
|
||||
text = if self.props.active then "Enabled" else "Disabled",
|
||||
text = (if self.props.locked
|
||||
then (self.props.lockedTooltip or "(Cannot be changed right now)") .. "\n"
|
||||
else "") .. (if self.props.active then "Enabled" else "Disabled"),
|
||||
}),
|
||||
|
||||
Active = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = theme.Active.BackgroundColor,
|
||||
color = checkboxTheme.Active.BackgroundColor,
|
||||
transparency = activeTransparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
zIndex = 2,
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Checkbox.Active,
|
||||
ImageColor3 = theme.Active.IconColor,
|
||||
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
|
||||
ImageColor3 = checkboxTheme.Active.IconColor,
|
||||
ImageTransparency = activeTransparency,
|
||||
|
||||
Size = UDim2.new(0, 16, 0, 16),
|
||||
@@ -79,13 +84,15 @@ function Checkbox:render()
|
||||
|
||||
Inactive = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = theme.Inactive.BorderColor,
|
||||
color = checkboxTheme.Inactive.BorderColor,
|
||||
transparency = self.props.transparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Checkbox.Inactive,
|
||||
ImageColor3 = theme.Inactive.IconColor,
|
||||
Image = if self.props.locked
|
||||
then Assets.Images.Checkbox.Locked
|
||||
else Assets.Images.Checkbox.Inactive,
|
||||
ImageColor3 = checkboxTheme.Inactive.IconColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
|
||||
Size = UDim2.new(0, 16, 0, 16),
|
||||
|
||||
126
plugin/src/App/Components/ClassIcon.lua
Normal file
@@ -0,0 +1,126 @@
|
||||
local StudioService = game:GetService("StudioService")
|
||||
local AssetService = game:GetService("AssetService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local EditableImage = require(Plugin.App.Components.EditableImage)
|
||||
|
||||
local imageCache = {}
|
||||
local function getImageSizeAndPixels(image)
|
||||
if not imageCache[image] then
|
||||
local editableImage = AssetService:CreateEditableImageAsync(image)
|
||||
imageCache[image] = {
|
||||
Size = editableImage.Size,
|
||||
Pixels = editableImage:ReadPixels(Vector2.zero, editableImage.Size),
|
||||
}
|
||||
end
|
||||
|
||||
return imageCache[image].Size, table.clone(imageCache[image].Pixels)
|
||||
end
|
||||
|
||||
local function getRecoloredClassIcon(className, color)
|
||||
local iconProps = StudioService:GetClassIcon(className)
|
||||
|
||||
if iconProps and color then
|
||||
local success, editableImageSize, editableImagePixels = pcall(function()
|
||||
local size, pixels = getImageSizeAndPixels(iconProps.Image)
|
||||
|
||||
local minVal, maxVal = math.huge, -math.huge
|
||||
for i = 1, #pixels, 4 do
|
||||
if pixels[i + 3] == 0 then
|
||||
continue
|
||||
end
|
||||
local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
|
||||
minVal = math.min(minVal, pixelVal)
|
||||
maxVal = math.max(maxVal, pixelVal)
|
||||
end
|
||||
|
||||
local hue, sat, val = color:ToHSV()
|
||||
for i = 1, #pixels, 4 do
|
||||
if pixels[i + 3] == 0 then
|
||||
continue
|
||||
end
|
||||
|
||||
local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
|
||||
local newVal = val
|
||||
if minVal < maxVal then
|
||||
-- Remap minVal - maxVal to val*0.9 - val
|
||||
newVal = val * (0.9 + 0.1 * (pixelVal - minVal) / (maxVal - minVal))
|
||||
end
|
||||
|
||||
local newPixelColor = Color3.fromHSV(hue, sat, newVal)
|
||||
pixels[i], pixels[i + 1], pixels[i + 2] = newPixelColor.R, newPixelColor.G, newPixelColor.B
|
||||
end
|
||||
return size, pixels
|
||||
end)
|
||||
if success then
|
||||
iconProps.EditableImagePixels = editableImagePixels
|
||||
iconProps.EditableImageSize = editableImageSize
|
||||
end
|
||||
end
|
||||
|
||||
return iconProps
|
||||
end
|
||||
|
||||
local ClassIcon = Roact.PureComponent:extend("ClassIcon")
|
||||
|
||||
function ClassIcon:init()
|
||||
self.state = {
|
||||
iconProps = nil,
|
||||
}
|
||||
end
|
||||
|
||||
function ClassIcon:updateIcon()
|
||||
local props = self.props
|
||||
local iconProps = getRecoloredClassIcon(props.className, props.color)
|
||||
self:setState({
|
||||
iconProps = iconProps,
|
||||
})
|
||||
end
|
||||
|
||||
function ClassIcon:didMount()
|
||||
self:updateIcon()
|
||||
end
|
||||
|
||||
function ClassIcon:didUpdate(lastProps)
|
||||
if lastProps.className ~= self.props.className or lastProps.color ~= self.props.color then
|
||||
self:updateIcon()
|
||||
end
|
||||
end
|
||||
|
||||
function ClassIcon:render()
|
||||
local iconProps = self.state.iconProps
|
||||
if not iconProps then
|
||||
return nil
|
||||
end
|
||||
|
||||
return e(
|
||||
"ImageLabel",
|
||||
{
|
||||
Size = self.props.size,
|
||||
Position = self.props.position,
|
||||
LayoutOrder = self.props.layoutOrder,
|
||||
AnchorPoint = self.props.anchorPoint,
|
||||
ImageTransparency = self.props.transparency,
|
||||
Image = iconProps.Image,
|
||||
ImageRectOffset = iconProps.ImageRectOffset,
|
||||
ImageRectSize = iconProps.ImageRectSize,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
if iconProps.EditableImagePixels
|
||||
then e(EditableImage, {
|
||||
size = iconProps.EditableImageSize,
|
||||
pixels = iconProps.EditableImagePixels,
|
||||
})
|
||||
else nil
|
||||
)
|
||||
end
|
||||
|
||||
return ClassIcon
|
||||
66
plugin/src/App/Components/CodeLabel.lua
Normal file
@@ -0,0 +1,66 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Highlighter = require(Packages.Highlighter)
|
||||
Highlighter.matchStudioSettings()
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
|
||||
local CodeLabel = Roact.PureComponent:extend("CodeLabel")
|
||||
|
||||
function CodeLabel:init()
|
||||
self.labelRef = Roact.createRef()
|
||||
self.highlightsRef = Roact.createRef()
|
||||
end
|
||||
|
||||
function CodeLabel:didMount()
|
||||
Highlighter.highlight({
|
||||
textObject = self.labelRef:getValue(),
|
||||
})
|
||||
self:updateHighlights()
|
||||
end
|
||||
|
||||
function CodeLabel:didUpdate()
|
||||
self:updateHighlights()
|
||||
end
|
||||
|
||||
function CodeLabel:updateHighlights()
|
||||
local highlights = self.highlightsRef:getValue()
|
||||
if not highlights then
|
||||
return
|
||||
end
|
||||
|
||||
for _, lineLabel in highlights:GetChildren() do
|
||||
local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0")
|
||||
lineLabel.BackgroundColor3 = self.props.lineBackground
|
||||
lineLabel.BorderSizePixel = 0
|
||||
lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1
|
||||
end
|
||||
end
|
||||
|
||||
function CodeLabel:render()
|
||||
return Theme.with(function(theme)
|
||||
return e("TextLabel", {
|
||||
Size = self.props.size,
|
||||
Position = self.props.position,
|
||||
Text = self.props.text,
|
||||
BackgroundTransparency = 1,
|
||||
FontFace = theme.Font.Code,
|
||||
TextSize = theme.TextSize.Code,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
[Roact.Ref] = self.labelRef,
|
||||
}, {
|
||||
SyntaxHighlights = e("Folder", {
|
||||
[Roact.Ref] = self.highlightsRef,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return CodeLabel
|
||||
@@ -1,5 +1,3 @@
|
||||
local TextService = game:GetService("TextService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
@@ -10,9 +8,11 @@ local Flipper = require(Packages.Flipper)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
|
||||
|
||||
local SlicedImage = require(script.Parent.SlicedImage)
|
||||
local ScrollingFrame = require(script.Parent.ScrollingFrame)
|
||||
local Tooltip = require(script.Parent.Tooltip)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -29,45 +29,49 @@ function Dropdown:init()
|
||||
})
|
||||
end
|
||||
|
||||
function Dropdown:didUpdate()
|
||||
self.openMotor:setGoal(
|
||||
Flipper.Spring.new(self.state.open and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
function Dropdown:didUpdate(prevProps)
|
||||
if self.props.locked and not prevProps.locked then
|
||||
self:setState({
|
||||
open = false,
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
self.openMotor:setGoal(Flipper.Spring.new(self.state.open and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
}))
|
||||
end
|
||||
|
||||
function Dropdown:render()
|
||||
return Theme.with(function(theme)
|
||||
theme = theme.Dropdown
|
||||
local dropdownTheme = theme.Dropdown
|
||||
|
||||
local optionButtons = {}
|
||||
local width = -1
|
||||
for i, option in self.props.options do
|
||||
local text = tostring(option or "")
|
||||
local textSize = TextService:GetTextSize(
|
||||
text, 15, Enum.Font.GothamMedium,
|
||||
Vector2.new(math.huge, 20)
|
||||
)
|
||||
if textSize.X > width then
|
||||
width = textSize.X
|
||||
local textBounds = getTextBoundsAsync(text, theme.Font.Main, theme.TextSize.Body, math.huge)
|
||||
if textBounds.X > width then
|
||||
width = textBounds.X
|
||||
end
|
||||
|
||||
optionButtons[text] = e("TextButton", {
|
||||
Text = text,
|
||||
LayoutOrder = i,
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
BackgroundColor3 = theme.BackgroundColor,
|
||||
BackgroundColor3 = dropdownTheme.BackgroundColor,
|
||||
TextTransparency = self.props.transparency,
|
||||
BackgroundTransparency = self.props.transparency,
|
||||
BorderSizePixel = 0,
|
||||
TextColor3 = theme.TextColor,
|
||||
TextColor3 = dropdownTheme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextSize = 15,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = theme.TextSize.Body,
|
||||
FontFace = theme.Font.Main,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
self:setState({
|
||||
open = false,
|
||||
})
|
||||
@@ -81,7 +85,7 @@ function Dropdown:render()
|
||||
end
|
||||
|
||||
return e("ImageButton", {
|
||||
Size = UDim2.new(0, width+50, 0, 28),
|
||||
Size = UDim2.new(0, width + 50, 0, 28),
|
||||
Position = self.props.position,
|
||||
AnchorPoint = self.props.anchorPoint,
|
||||
LayoutOrder = self.props.layoutOrder,
|
||||
@@ -89,6 +93,9 @@ function Dropdown:render()
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
self:setState({
|
||||
open = not self.state.open,
|
||||
})
|
||||
@@ -96,15 +103,13 @@ function Dropdown:render()
|
||||
}, {
|
||||
Border = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = theme.BorderColor,
|
||||
color = dropdownTheme.BorderColor,
|
||||
transparency = self.props.transparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}, {
|
||||
DropArrow = e("ImageLabel", {
|
||||
Image = Assets.Images.Dropdown.Arrow,
|
||||
ImageColor3 = self.openBinding:map(function(a)
|
||||
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
|
||||
end),
|
||||
Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow,
|
||||
ImageColor3 = dropdownTheme.IconColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
|
||||
Size = UDim2.new(0, 18, 0, 18),
|
||||
@@ -115,53 +120,61 @@ function Dropdown:render()
|
||||
end),
|
||||
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
StateTip = if self.props.locked
|
||||
then e(Tooltip.Trigger, {
|
||||
text = self.props.lockedTooltip or "(Cannot be changed right now)",
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
Active = e("TextLabel", {
|
||||
Size = UDim2.new(1, -30, 1, 0),
|
||||
Position = UDim2.new(0, 6, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = self.props.active,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 15,
|
||||
TextColor3 = theme.TextColor,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = dropdownTheme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = self.props.transparency,
|
||||
}),
|
||||
}),
|
||||
Options = if self.state.open then e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = theme.BackgroundColor,
|
||||
position = UDim2.new(1, 0, 1, 3),
|
||||
size = self.openBinding:map(function(a)
|
||||
return UDim2.new(1, 0, a*math.min(3, #self.props.options), 0)
|
||||
end),
|
||||
anchorPoint = Vector2.new(1, 0),
|
||||
}, {
|
||||
Border = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = theme.BorderColor,
|
||||
transparency = self.props.transparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}),
|
||||
ScrollingFrame = e(ScrollingFrame, {
|
||||
size = UDim2.new(1, -4, 1, -4),
|
||||
position = UDim2.new(0, 2, 0, 2),
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
Options = if self.state.open
|
||||
then e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = dropdownTheme.BackgroundColor,
|
||||
position = UDim2.new(1, 0, 1, 3),
|
||||
size = self.openBinding:map(function(a)
|
||||
return UDim2.new(1, 0, a * math.min(3, #self.props.options), 0)
|
||||
end),
|
||||
anchorPoint = Vector2.new(1, 0),
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
VerticalAlignment = Enum.VerticalAlignment.Top,
|
||||
FillDirection = Enum.FillDirection.Vertical,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 0),
|
||||
|
||||
[Roact.Change.AbsoluteContentSize] = function(object)
|
||||
self.setContentSize(object.AbsoluteContentSize)
|
||||
end,
|
||||
Border = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = dropdownTheme.BorderColor,
|
||||
transparency = self.props.transparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}),
|
||||
Roact.createFragment(optionButtons),
|
||||
}),
|
||||
}) else nil,
|
||||
ScrollingFrame = e(ScrollingFrame, {
|
||||
size = UDim2.new(1, -4, 1, -4),
|
||||
position = UDim2.new(0, 2, 0, 2),
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
VerticalAlignment = Enum.VerticalAlignment.Top,
|
||||
FillDirection = Enum.FillDirection.Vertical,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 0),
|
||||
|
||||
[Roact.Change.AbsoluteContentSize] = function(object)
|
||||
self.setContentSize(object.AbsoluteContentSize)
|
||||
end,
|
||||
}),
|
||||
Options = Roact.createFragment(optionButtons),
|
||||
}),
|
||||
})
|
||||
else nil,
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
41
plugin/src/App/Components/EditableImage.lua
Normal file
@@ -0,0 +1,41 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local EditableImage = Roact.PureComponent:extend("EditableImage")
|
||||
|
||||
function EditableImage:init()
|
||||
self.ref = Roact.createRef()
|
||||
end
|
||||
|
||||
function EditableImage:writePixels()
|
||||
local image = self.ref.current
|
||||
if not image then
|
||||
return
|
||||
end
|
||||
if not self.props.pixels then
|
||||
return
|
||||
end
|
||||
|
||||
image:WritePixels(Vector2.zero, self.props.size, self.props.pixels)
|
||||
end
|
||||
|
||||
function EditableImage:render()
|
||||
return e("EditableImage", {
|
||||
Size = self.props.size,
|
||||
[Roact.Ref] = self.ref,
|
||||
})
|
||||
end
|
||||
|
||||
function EditableImage:didMount()
|
||||
self:writePixels()
|
||||
end
|
||||
|
||||
function EditableImage:didUpdate()
|
||||
self:writePixels()
|
||||
end
|
||||
|
||||
return EditableImage
|
||||
@@ -31,13 +31,13 @@ local function Header(props)
|
||||
|
||||
Version = e("TextLabel", {
|
||||
Text = Version.display(Config.version),
|
||||
Font = Enum.Font.Gotham,
|
||||
TextSize = 14,
|
||||
FontFace = theme.Font.Thin,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Header.VersionColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
|
||||
Size = UDim2.new(1, 0, 0, 14),
|
||||
Size = UDim2.new(1, 0, 0, theme.TextSize.Body),
|
||||
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
@@ -38,15 +38,11 @@ function IconButton:render()
|
||||
[Roact.Event.Activated] = self.props.onClick,
|
||||
|
||||
[Roact.Event.MouseEnter] = function()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(1, HOVER_SPRING_PROPS)
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(1, HOVER_SPRING_PROPS))
|
||||
end,
|
||||
|
||||
[Roact.Event.MouseLeave] = function()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(0, HOVER_SPRING_PROPS)
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(0, HOVER_SPRING_PROPS))
|
||||
end,
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
|
||||
@@ -4,12 +4,133 @@ local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local DisplayValue = require(script.Parent.DisplayValue)
|
||||
|
||||
local EMPTY_TABLE = {}
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local function ViewDiffButton(props)
|
||||
return Theme.with(function(theme)
|
||||
return e("TextButton", {
|
||||
Text = "",
|
||||
Size = UDim2.new(0.7, 0, 1, -4),
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
[Roact.Event.Activated] = props.onClick,
|
||||
}, {
|
||||
e(BorderedContainer, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
transparency = props.transparency:map(function(t)
|
||||
return 0.5 + (0.5 * t)
|
||||
end),
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
Padding = UDim.new(0, 5),
|
||||
}),
|
||||
Label = e("TextLabel", {
|
||||
Text = "View Diff",
|
||||
BackgroundTransparency = 1,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(0, 65, 1, 0),
|
||||
LayoutOrder = 1,
|
||||
}),
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Icons.Expand,
|
||||
ImageColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
ImageTransparency = props.transparency,
|
||||
|
||||
Size = UDim2.new(0, 16, 0, 16),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
local function RowContent(props)
|
||||
local values = props.values
|
||||
local metadata = props.metadata
|
||||
|
||||
if props.showStringDiff and values[1] == "Source" then
|
||||
-- Special case for .Source updates
|
||||
return e(ViewDiffButton, {
|
||||
transparency = props.transparency,
|
||||
onClick = function()
|
||||
if not props.showStringDiff then
|
||||
return
|
||||
end
|
||||
props.showStringDiff(tostring(values[2]), tostring(values[3]))
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
if props.showTableDiff and (type(values[2]) == "table" or type(values[3]) == "table") then
|
||||
-- Special case for table properties (like Attributes/Tags)
|
||||
return e(ViewDiffButton, {
|
||||
transparency = props.transparency,
|
||||
onClick = function()
|
||||
if not props.showTableDiff then
|
||||
return
|
||||
end
|
||||
props.showTableDiff(values[2], values[3])
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return Roact.createFragment({
|
||||
ColumnB = e(
|
||||
"Frame",
|
||||
{
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 2,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[2],
|
||||
transparency = props.transparency,
|
||||
textColor = if metadata.isWarning
|
||||
then theme.Diff.Warning
|
||||
else theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
),
|
||||
ColumnC = e(
|
||||
"Frame",
|
||||
{
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 3,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[3],
|
||||
transparency = props.transparency,
|
||||
textColor = if metadata.isWarning
|
||||
then theme.Diff.Warning
|
||||
else theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
local ChangeList = Roact.Component:extend("ChangeList")
|
||||
|
||||
function ChangeList:init()
|
||||
@@ -26,16 +147,15 @@ function ChangeList:render()
|
||||
return 0.93 + (0.07 * t)
|
||||
end)
|
||||
|
||||
local columnVisibility = props.columnVisibility
|
||||
|
||||
local rows = {}
|
||||
local pad = {
|
||||
PaddingLeft = UDim.new(0, 5),
|
||||
PaddingRight = UDim.new(0, 5),
|
||||
}
|
||||
|
||||
local headerRow = changes[1]
|
||||
local headers = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 30),
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
BackgroundTransparency = rowTransparency,
|
||||
BackgroundColor3 = theme.Diff.Row,
|
||||
LayoutOrder = 0,
|
||||
@@ -47,39 +167,36 @@ function ChangeList:render()
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
}),
|
||||
A = e("TextLabel", {
|
||||
Visible = columnVisibility[1],
|
||||
Text = tostring(changes[1][1]),
|
||||
ColumnA = e("TextLabel", {
|
||||
Text = tostring(headerRow[1]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(0.3, 0, 1, 0),
|
||||
LayoutOrder = 1,
|
||||
}),
|
||||
B = e("TextLabel", {
|
||||
Visible = columnVisibility[2],
|
||||
Text = tostring(changes[1][2]),
|
||||
ColumnB = e("TextLabel", {
|
||||
Text = tostring(headerRow[2]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
C = e("TextLabel", {
|
||||
Visible = columnVisibility[3],
|
||||
Text = tostring(changes[1][3]),
|
||||
ColumnC = e("TextLabel", {
|
||||
Text = tostring(headerRow[3]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
@@ -93,8 +210,11 @@ function ChangeList:render()
|
||||
continue -- Skip headers, already handled above
|
||||
end
|
||||
|
||||
local metadata = values[4] or EMPTY_TABLE
|
||||
local isWarning = metadata.isWarning
|
||||
|
||||
rows[row] = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 30),
|
||||
Size = UDim2.new(1, 0, 0, 24),
|
||||
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
|
||||
BackgroundColor3 = theme.Diff.Row,
|
||||
BorderSizePixel = 0,
|
||||
@@ -107,45 +227,25 @@ function ChangeList:render()
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
}),
|
||||
A = e("TextLabel", {
|
||||
Visible = columnVisibility[1],
|
||||
Text = tostring(values[1]),
|
||||
ColumnA = e("TextLabel", {
|
||||
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = if isWarning then theme.Diff.Warning else theme.TextColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(0.3, 0, 1, 0),
|
||||
LayoutOrder = 1,
|
||||
}),
|
||||
B = e(
|
||||
"Frame",
|
||||
{
|
||||
Visible = columnVisibility[2],
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 2,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[2],
|
||||
transparency = props.transparency,
|
||||
})
|
||||
),
|
||||
C = e(
|
||||
"Frame",
|
||||
{
|
||||
Visible = columnVisibility[3],
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 3,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[3],
|
||||
transparency = props.transparency,
|
||||
})
|
||||
),
|
||||
Content = e(RowContent, {
|
||||
values = values,
|
||||
metadata = metadata,
|
||||
transparency = props.transparency,
|
||||
showStringDiff = props.showStringDiff,
|
||||
showTableDiff = props.showTableDiff,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
@@ -169,8 +269,8 @@ function ChangeList:render()
|
||||
}, {
|
||||
Headers = headers,
|
||||
Values = e(ScrollingFrame, {
|
||||
size = UDim2.new(1, 0, 1, -30),
|
||||
position = UDim2.new(0, 0, 0, 30),
|
||||
size = UDim2.new(1, 0, 1, -24),
|
||||
position = UDim2.new(0, 0, 0, 24),
|
||||
contentSize = self.contentSize,
|
||||
transparency = props.transparency,
|
||||
}, rows),
|
||||
|
||||
@@ -30,11 +30,11 @@ local function DisplayValue(props)
|
||||
}),
|
||||
}),
|
||||
Label = e("TextLabel", {
|
||||
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
|
||||
Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = props.textColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
@@ -42,7 +42,6 @@ local function DisplayValue(props)
|
||||
Position = UDim2.new(0, 25, 0, 0),
|
||||
}),
|
||||
})
|
||||
|
||||
elseif t == "table" then
|
||||
-- Showing a memory address for tables is useless, so we want to show the best we can
|
||||
local textRepresentation = nil
|
||||
@@ -54,18 +53,33 @@ local function DisplayValue(props)
|
||||
elseif next(props.value) == nil then
|
||||
-- If it's empty, show empty braces
|
||||
textRepresentation = "{}"
|
||||
elseif next(props.value) == 1 then
|
||||
-- We don't need to support mixed tables, so checking the first key is enough
|
||||
-- to determine if it's a simple array
|
||||
local out, i = table.create(#props.value), 0
|
||||
for _, v in props.value do
|
||||
i += 1
|
||||
|
||||
-- Wrap strings in quotes
|
||||
if type(v) == "string" then
|
||||
v = '"' .. v .. '"'
|
||||
end
|
||||
|
||||
out[i] = tostring(v)
|
||||
end
|
||||
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
|
||||
else
|
||||
-- If it has children, list them out
|
||||
-- Otherwise, show the table contents as a dictionary
|
||||
local out, i = {}, 0
|
||||
for k, v in pairs(props.value) do
|
||||
i += 1
|
||||
|
||||
-- Wrap strings in quotes
|
||||
if type(k) == "string" then
|
||||
k = "\"" .. k .. "\""
|
||||
k = '"' .. k .. '"'
|
||||
end
|
||||
if type(v) == "string" then
|
||||
v = "\"" .. v .. "\""
|
||||
v = '"' .. v .. '"'
|
||||
end
|
||||
|
||||
out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
|
||||
@@ -76,9 +90,9 @@ local function DisplayValue(props)
|
||||
return e("TextLabel", {
|
||||
Text = textRepresentation,
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = props.textColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
@@ -90,12 +104,17 @@ local function DisplayValue(props)
|
||||
-- Or special text handling tostring for some?
|
||||
-- Will add as needed, let's see what cases arise.
|
||||
|
||||
local textRepresentation = string.gsub(tostring(props.value), "%s", " ")
|
||||
if t == "string" then
|
||||
textRepresentation = '"' .. textRepresentation .. '"'
|
||||
end
|
||||
|
||||
return e("TextLabel", {
|
||||
Text = string.gsub(tostring(props.value), "%s", " "),
|
||||
Text = textRepresentation,
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = props.textColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
local StudioService = game:GetService("StudioService")
|
||||
local SelectionService = game:GetService("Selection")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
@@ -14,6 +14,8 @@ local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
local e = Roact.createElement
|
||||
|
||||
local ChangeList = require(script.Parent.ChangeList)
|
||||
local Tooltip = require(Plugin.App.Components.Tooltip)
|
||||
local ClassIcon = require(Plugin.App.Components.ClassIcon)
|
||||
|
||||
local Expansion = Roact.Component:extend("Expansion")
|
||||
|
||||
@@ -26,13 +28,14 @@ function Expansion:render()
|
||||
|
||||
return e("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, -props.indent, 1, -30),
|
||||
Position = UDim2.new(0, props.indent, 0, 30),
|
||||
Size = UDim2.new(1, -props.indent, 1, -24),
|
||||
Position = UDim2.new(0, props.indent, 0, 24),
|
||||
}, {
|
||||
ChangeList = e(ChangeList, {
|
||||
changes = props.changeList,
|
||||
transparency = props.transparency,
|
||||
columnVisibility = props.columnVisibility,
|
||||
showStringDiff = props.showStringDiff,
|
||||
showTableDiff = props.showTableDiff,
|
||||
}),
|
||||
})
|
||||
end
|
||||
@@ -40,13 +43,8 @@ end
|
||||
local DomLabel = Roact.Component:extend("DomLabel")
|
||||
|
||||
function DomLabel:init()
|
||||
self.maxElementHeight = 0
|
||||
if self.props.changeList then
|
||||
self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
|
||||
end
|
||||
|
||||
local initHeight = self.props.elementHeight:getValue()
|
||||
self.expanded = initHeight > 30
|
||||
self.expanded = initHeight > 24
|
||||
|
||||
self.motor = Flipper.SingleMotor.new(initHeight)
|
||||
self.binding = bindingUtil.fromMotor(self.motor)
|
||||
@@ -55,7 +53,7 @@ function DomLabel:init()
|
||||
renderExpansion = self.expanded,
|
||||
})
|
||||
self.motor:onStep(function(value)
|
||||
local renderExpansion = value > 30
|
||||
local renderExpansion = value > 24
|
||||
|
||||
self.props.setElementHeight(value)
|
||||
if self.props.updateEvent then
|
||||
@@ -74,35 +72,77 @@ function DomLabel:init()
|
||||
end)
|
||||
end
|
||||
|
||||
function DomLabel:didUpdate(prevProps)
|
||||
if
|
||||
prevProps.instance ~= self.props.instance
|
||||
or prevProps.patchType ~= self.props.patchType
|
||||
or prevProps.name ~= self.props.name
|
||||
or prevProps.changeList ~= self.props.changeList
|
||||
then
|
||||
-- Close the expansion when the domlabel is changed to a different thing
|
||||
self.expanded = false
|
||||
self.motor:setGoal(Flipper.Spring.new(24, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
function DomLabel:render()
|
||||
local props = self.props
|
||||
local depth = props.depth or 1
|
||||
|
||||
return Theme.with(function(theme)
|
||||
local iconProps = StudioService:GetClassIcon(props.className)
|
||||
local indent = (props.depth or 0) * 20 + 25
|
||||
local color = if props.isWarning
|
||||
then theme.Diff.Warning
|
||||
elseif props.patchType then theme.Diff[props.patchType]
|
||||
else theme.TextColor
|
||||
|
||||
local indent = (depth - 1) * 12 + 15
|
||||
|
||||
-- Line guides help indent depth remain readable
|
||||
local lineGuides = {}
|
||||
for i = 1, props.depth or 0 do
|
||||
table.insert(
|
||||
lineGuides,
|
||||
e("Frame", {
|
||||
Name = "Line_" .. i,
|
||||
Size = UDim2.new(0, 2, 1, 2),
|
||||
Position = UDim2.new(0, (20 * i) + 15, 0, -1),
|
||||
for i = 2, depth do
|
||||
if props.depthsComplete[i] then
|
||||
continue
|
||||
end
|
||||
if props.isFinalChild and i == depth then
|
||||
-- This line stops halfway down to merge with our connector for the right angle
|
||||
lineGuides["Line_" .. i] = e("Frame", {
|
||||
Size = UDim2.new(0, 2, 0, 15),
|
||||
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = props.transparency,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
})
|
||||
)
|
||||
else
|
||||
-- All other lines go all the way
|
||||
-- with the exception of the final element, which stops halfway down
|
||||
lineGuides["Line_" .. i] = e("Frame", {
|
||||
Size = UDim2.new(0, 2, 1, if props.isFinalElement then -9 else 2),
|
||||
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = props.transparency,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
if depth ~= 1 then
|
||||
lineGuides["Connector"] = e("Frame", {
|
||||
Size = UDim2.new(0, 8, 0, 2),
|
||||
Position = UDim2.new(0, 2 + (12 * props.depth), 0, 12),
|
||||
AnchorPoint = Vector2.xAxis,
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = props.transparency,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
})
|
||||
end
|
||||
|
||||
return e("Frame", {
|
||||
Name = "Change",
|
||||
ClipsDescendants = true,
|
||||
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil,
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = props.patchType and props.transparency or 1,
|
||||
BackgroundTransparency = if props.elementIndex % 2 == 0 then 0.985 else 1,
|
||||
BackgroundColor3 = theme.Diff.Row,
|
||||
Size = self.binding:map(function(expand)
|
||||
return UDim2.new(1, 0, 0, expand)
|
||||
end),
|
||||
@@ -111,66 +151,128 @@ function DomLabel:render()
|
||||
PaddingLeft = UDim.new(0, 10),
|
||||
PaddingRight = UDim.new(0, 10),
|
||||
}),
|
||||
ExpandButton = if props.changeList
|
||||
then e("TextButton", {
|
||||
BackgroundTransparency = 1,
|
||||
Text = "",
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
[Roact.Event.Activated] = function()
|
||||
self.expanded = not self.expanded
|
||||
self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end,
|
||||
})
|
||||
else nil,
|
||||
Button = e("TextButton", {
|
||||
BackgroundTransparency = 1,
|
||||
Text = "",
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
[Roact.Event.Activated] = function(_rbx: Instance, _input: InputObject, clickCount: number)
|
||||
if clickCount == 1 then
|
||||
-- Double click opens the instance in explorer
|
||||
self.lastDoubleClickTime = os.clock()
|
||||
if props.instance then
|
||||
SelectionService:Set({ props.instance })
|
||||
end
|
||||
elseif clickCount == 0 then
|
||||
-- Single click expands the changes
|
||||
task.wait(0.25)
|
||||
if os.clock() - (self.lastDoubleClickTime or 0) <= 0.25 then
|
||||
-- This is a double click, so don't expand
|
||||
return
|
||||
end
|
||||
|
||||
if props.changeList then
|
||||
self.expanded = not self.expanded
|
||||
local goalHeight = 24
|
||||
+ (if self.expanded then math.clamp(#props.changeList * 24, 24, 24 * 6) else 0)
|
||||
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
end,
|
||||
}, {
|
||||
StateTip = if (props.instance or props.changeList)
|
||||
then e(Tooltip.Trigger, {
|
||||
text = (if props.changeList
|
||||
then "Click to " .. (if self.expanded then "hide" else "view") .. " changes"
|
||||
else "") .. (if props.instance
|
||||
then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer"
|
||||
else ""),
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
Expansion = if props.changeList
|
||||
then e(Expansion, {
|
||||
rendered = self.state.renderExpansion,
|
||||
indent = indent,
|
||||
transparency = props.transparency,
|
||||
changeList = props.changeList,
|
||||
columnVisibility = props.columnVisibility,
|
||||
showStringDiff = props.showStringDiff,
|
||||
showTableDiff = props.showTableDiff,
|
||||
})
|
||||
else nil,
|
||||
DiffIcon = if props.patchType
|
||||
then e("ImageLabel", {
|
||||
Image = Assets.Images.Diff[props.patchType],
|
||||
ImageColor3 = theme.AddressEntry.PlaceholderColor,
|
||||
ImageColor3 = color,
|
||||
ImageTransparency = props.transparency,
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0, 20, 0, 20),
|
||||
Position = UDim2.new(0, 0, 0, 15),
|
||||
Size = UDim2.new(0, 14, 0, 14),
|
||||
Position = UDim2.new(0, 0, 0, 12),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
})
|
||||
else nil,
|
||||
ClassIcon = e("ImageLabel", {
|
||||
Image = iconProps.Image,
|
||||
ImageTransparency = props.transparency,
|
||||
ImageRectOffset = iconProps.ImageRectOffset,
|
||||
ImageRectSize = iconProps.ImageRectSize,
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0, 20, 0, 20),
|
||||
Position = UDim2.new(0, indent, 0, 15),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
ClassIcon = e(ClassIcon, {
|
||||
className = props.className,
|
||||
color = color,
|
||||
transparency = props.transparency,
|
||||
size = UDim2.new(0, 16, 0, 16),
|
||||
position = UDim2.new(0, indent + 2, 0, 12),
|
||||
anchorPoint = Vector2.new(0, 0.5),
|
||||
}),
|
||||
InstanceName = e("TextLabel", {
|
||||
Text = props.name .. (props.hint and string.format(
|
||||
' <font color="#%s">%s</font>',
|
||||
theme.AddressEntry.PlaceholderColor:ToHex(),
|
||||
props.hint
|
||||
) or ""),
|
||||
Text = (if props.isWarning then "⚠ " else "") .. props.name,
|
||||
RichText = true,
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
FontFace = if props.patchType then theme.Font.Bold else theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = color,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(1, -indent - 50, 0, 30),
|
||||
Position = UDim2.new(0, indent + 30, 0, 0),
|
||||
Size = UDim2.new(1, -indent - 50, 0, 24),
|
||||
Position = UDim2.new(0, indent + 22, 0, 0),
|
||||
}),
|
||||
ChangeInfo = e("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, -indent - 80, 0, 24),
|
||||
Position = UDim2.new(1, -2, 0, 0),
|
||||
AnchorPoint = Vector2.new(1, 0),
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 4),
|
||||
}),
|
||||
Edits = if props.changeInfo and props.changeInfo.edits
|
||||
then e("TextLabel", {
|
||||
Text = props.changeInfo.edits .. if props.changeInfo.failed then "," else "",
|
||||
BackgroundTransparency = 1,
|
||||
FontFace = theme.Font.Thin,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.SubTextColor,
|
||||
TextTransparency = props.transparency,
|
||||
Size = UDim2.new(0, 0, 0, theme.TextSize.Body),
|
||||
AutomaticSize = Enum.AutomaticSize.X,
|
||||
LayoutOrder = 2,
|
||||
})
|
||||
else nil,
|
||||
Failed = if props.changeInfo and props.changeInfo.failed
|
||||
then e("TextLabel", {
|
||||
Text = props.changeInfo.failed,
|
||||
BackgroundTransparency = 1,
|
||||
FontFace = theme.Font.Thin,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Diff.Warning,
|
||||
TextTransparency = props.transparency,
|
||||
Size = UDim2.new(0, 0, 0, theme.TextSize.Body),
|
||||
AutomaticSize = Enum.AutomaticSize.X,
|
||||
LayoutOrder = 6,
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
LineGuides = e("Folder", nil, lineGuides),
|
||||
})
|
||||
|
||||
@@ -1,143 +1,18 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
local PatchTree = require(Plugin.PatchTree)
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
||||
local getProperty = require(Plugin.Reconciler.getProperty)
|
||||
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local function alphabeticalNext(t, state)
|
||||
-- Equivalent of the next function, but returns the keys in the alphabetic
|
||||
-- order of node names. We use a temporary ordered key table that is stored in the
|
||||
-- table being iterated.
|
||||
|
||||
local key = nil
|
||||
if state == nil then
|
||||
-- First iteration, generate the index
|
||||
local orderedIndex, i = table.create(5), 0
|
||||
for k in t do
|
||||
i += 1
|
||||
orderedIndex[i] = k
|
||||
end
|
||||
table.sort(orderedIndex, function(a, b)
|
||||
local nodeA, nodeB = t[a], t[b]
|
||||
return (nodeA.name or "") < (nodeB.name or "")
|
||||
end)
|
||||
|
||||
t.__orderedIndex = orderedIndex
|
||||
key = orderedIndex[1]
|
||||
else
|
||||
-- Fetch the next value
|
||||
for i, orderedState in t.__orderedIndex do
|
||||
if orderedState == state then
|
||||
key = t.__orderedIndex[i + 1]
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if key then
|
||||
return key, t[key]
|
||||
end
|
||||
|
||||
-- No more value to return, cleanup
|
||||
t.__orderedIndex = nil
|
||||
return
|
||||
end
|
||||
|
||||
local function alphabeticalPairs(t)
|
||||
-- Equivalent of the pairs() iterator, but sorted
|
||||
return alphabeticalNext, t, nil
|
||||
end
|
||||
|
||||
local function Tree()
|
||||
local tree = {
|
||||
idToNode = {},
|
||||
ROOT = {
|
||||
className = "DataModel",
|
||||
name = "ROOT",
|
||||
children = {},
|
||||
},
|
||||
}
|
||||
-- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT
|
||||
tree.idToNode["ROOT"] = tree.ROOT
|
||||
|
||||
function tree:getNode(id, target)
|
||||
if self.idToNode[id] then
|
||||
return self.idToNode[id]
|
||||
end
|
||||
|
||||
for nodeId, node in target or tree.ROOT.children do
|
||||
if nodeId == id then
|
||||
self.idToNode[id] = node
|
||||
return node
|
||||
end
|
||||
local descendant = self:getNode(id, node.children)
|
||||
if descendant then
|
||||
return descendant
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function tree:addNode(parent, props)
|
||||
parent = parent or "ROOT"
|
||||
|
||||
local node = self:getNode(props.id)
|
||||
if node then
|
||||
for k, v in props do
|
||||
node[k] = v
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
node = table.clone(props)
|
||||
node.children = {}
|
||||
|
||||
local parentNode = self:getNode(parent)
|
||||
if not parentNode then
|
||||
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
|
||||
return
|
||||
end
|
||||
|
||||
parentNode.children[node.id] = node
|
||||
self.idToNode[node.id] = node
|
||||
|
||||
return node
|
||||
end
|
||||
|
||||
function tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
-- Build nodes for ancestry by going up the tree
|
||||
local previousId = "ROOT"
|
||||
for _, ancestorId in ancestry do
|
||||
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
|
||||
if not value then
|
||||
Log.warn("Failed to find ancestor object for " .. ancestorId)
|
||||
continue
|
||||
end
|
||||
self:addNode(previousId, {
|
||||
id = ancestorId,
|
||||
className = value.ClassName,
|
||||
name = value.Name,
|
||||
})
|
||||
previousId = ancestorId
|
||||
end
|
||||
end
|
||||
|
||||
return tree
|
||||
end
|
||||
|
||||
local DomLabel = require(script.DomLabel)
|
||||
|
||||
local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
|
||||
@@ -153,250 +28,125 @@ function PatchVisualizer:willUnmount()
|
||||
end
|
||||
|
||||
function PatchVisualizer:shouldUpdate(nextProps)
|
||||
if self.props.patchTree ~= nextProps.patchTree then
|
||||
return true
|
||||
end
|
||||
|
||||
local currentPatch, nextPatch = self.props.patch, nextProps.patch
|
||||
|
||||
return not PatchSet.isEqual(currentPatch, nextPatch)
|
||||
end
|
||||
|
||||
function PatchVisualizer:buildTree(patch, instanceMap)
|
||||
local tree = Tree()
|
||||
|
||||
for _, change in patch.updated do
|
||||
local instance = instanceMap.fromIds[change.id]
|
||||
if not instance then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Gather ancestors from existing DOM
|
||||
local ancestry = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject]
|
||||
while parentObject do
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
|
||||
-- Gather detail text
|
||||
local changeList, hint = nil, nil
|
||||
if next(change.changedProperties) or change.changedName then
|
||||
changeList = {}
|
||||
|
||||
local hintBuffer, i = {}, 0
|
||||
local function addProp(prop: string, current: any?, incoming: any?)
|
||||
i += 1
|
||||
hintBuffer[i] = prop
|
||||
changeList[i] = { prop, current, incoming }
|
||||
end
|
||||
|
||||
-- Gather the changes
|
||||
|
||||
if change.changedName then
|
||||
addProp("Name", instance.Name, change.changedName)
|
||||
end
|
||||
|
||||
for prop, incoming in change.changedProperties do
|
||||
local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap)
|
||||
local currentSuccess, currentValue = getProperty(instance, prop)
|
||||
|
||||
addProp(
|
||||
prop,
|
||||
if currentSuccess then currentValue else "[Error]",
|
||||
if incomingSuccess then incomingValue else next(incoming)
|
||||
)
|
||||
end
|
||||
|
||||
-- Finalize detail values
|
||||
|
||||
-- Trim hint to top 3
|
||||
table.sort(hintBuffer)
|
||||
if #hintBuffer > 3 then
|
||||
hintBuffer = {
|
||||
hintBuffer[1],
|
||||
hintBuffer[2],
|
||||
hintBuffer[3],
|
||||
i - 3 .. " more",
|
||||
}
|
||||
end
|
||||
hint = table.concat(hintBuffer, ", ")
|
||||
|
||||
-- Sort changes and add header
|
||||
table.sort(changeList, function(a, b)
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
|
||||
end
|
||||
|
||||
-- Add this node to tree
|
||||
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
||||
id = change.id,
|
||||
patchType = "Edit",
|
||||
className = instance.ClassName,
|
||||
name = instance.Name,
|
||||
hint = hint,
|
||||
changeList = changeList,
|
||||
})
|
||||
if currentPatch ~= nil or nextPatch ~= nil then
|
||||
return not PatchSet.isEqual(currentPatch, nextPatch)
|
||||
end
|
||||
|
||||
for _, instance in patch.removed do
|
||||
-- Gather ancestors from existing DOM
|
||||
-- (note that they may have no ID if they're being removed as unknown)
|
||||
local ancestry = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
while parentObject do
|
||||
instanceMap:insert(parentId, parentObject)
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
|
||||
-- Add this node to tree
|
||||
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
|
||||
instanceMap:insert(nodeId, instance)
|
||||
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
||||
id = nodeId,
|
||||
patchType = "Remove",
|
||||
className = instance.ClassName,
|
||||
name = instance.Name,
|
||||
})
|
||||
end
|
||||
|
||||
for _, change in patch.added do
|
||||
-- Gather ancestors from existing DOM or future additions
|
||||
local ancestry = {}
|
||||
local parentId = change.Parent
|
||||
local parentData = patch.added[parentId]
|
||||
local parentObject = instanceMap.fromIds[parentId]
|
||||
while parentId do
|
||||
table.insert(ancestry, 1, parentId)
|
||||
parentId = nil
|
||||
|
||||
if parentData then
|
||||
parentId = parentData.Parent
|
||||
parentData = patch.added[parentId]
|
||||
parentObject = instanceMap.fromIds[parentId]
|
||||
elseif parentObject then
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
parentData = patch.added[parentId]
|
||||
end
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(ancestry, patch, instanceMap)
|
||||
|
||||
-- Gather detail text
|
||||
local changeList, hint = nil, nil
|
||||
if next(change.Properties) then
|
||||
changeList = {}
|
||||
|
||||
local hintBuffer, i = {}, 0
|
||||
for prop, incoming in change.Properties do
|
||||
i += 1
|
||||
hintBuffer[i] = prop
|
||||
|
||||
local success, incomingValue = decodeValue(incoming, instanceMap)
|
||||
if success then
|
||||
table.insert(changeList, { prop, "N/A", incomingValue })
|
||||
else
|
||||
table.insert(changeList, { prop, "N/A", next(incoming) })
|
||||
end
|
||||
end
|
||||
|
||||
-- Finalize detail values
|
||||
|
||||
-- Trim hint to top 3
|
||||
table.sort(hintBuffer)
|
||||
if #hintBuffer > 3 then
|
||||
hintBuffer = {
|
||||
hintBuffer[1],
|
||||
hintBuffer[2],
|
||||
hintBuffer[3],
|
||||
i - 3 .. " more",
|
||||
}
|
||||
end
|
||||
hint = table.concat(hintBuffer, ", ")
|
||||
|
||||
-- Sort changes and add header
|
||||
table.sort(changeList, function(a, b)
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
|
||||
end
|
||||
|
||||
-- Add this node to tree
|
||||
tree:addNode(change.Parent, {
|
||||
id = change.Id,
|
||||
patchType = "Add",
|
||||
className = change.ClassName,
|
||||
name = change.Name,
|
||||
hint = hint,
|
||||
changeList = changeList,
|
||||
})
|
||||
end
|
||||
|
||||
return tree
|
||||
return false
|
||||
end
|
||||
|
||||
function PatchVisualizer:render()
|
||||
local patch = self.props.patch
|
||||
local instanceMap = self.props.instanceMap
|
||||
|
||||
local tree = self:buildTree(patch, instanceMap)
|
||||
local patchTree = self.props.patchTree
|
||||
if patchTree == nil and self.props.patch ~= nil then
|
||||
patchTree = PatchTree.build(
|
||||
self.props.patch,
|
||||
self.props.instanceMap,
|
||||
self.props.changeListHeaders or { "Property", "Current", "Incoming" }
|
||||
)
|
||||
if self.props.unappliedPatch then
|
||||
patchTree =
|
||||
PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch)
|
||||
end
|
||||
end
|
||||
|
||||
-- Recusively draw tree
|
||||
local scrollElements, elementHeights = {}, {}
|
||||
local function drawNode(node, depth)
|
||||
local elementHeight, setElementHeight = Roact.createBinding(30)
|
||||
table.insert(elementHeights, elementHeight)
|
||||
table.insert(
|
||||
scrollElements,
|
||||
e(DomLabel, {
|
||||
columnVisibility = self.props.columnVisibility,
|
||||
local scrollElements, elementHeights, elementIndex = {}, {}, 0
|
||||
|
||||
if patchTree then
|
||||
local elementTotal = patchTree:getCount()
|
||||
local depthsComplete = {}
|
||||
local function drawNode(node, depth)
|
||||
elementIndex += 1
|
||||
|
||||
local parentNode = patchTree:getNode(node.parentId)
|
||||
local isFinalChild = true
|
||||
if parentNode then
|
||||
for _id, sibling in parentNode.children do
|
||||
if type(sibling) == "table" and sibling.name and sibling.name > node.name then
|
||||
isFinalChild = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local elementHeight, setElementHeight = Roact.createBinding(24)
|
||||
elementHeights[elementIndex] = elementHeight
|
||||
scrollElements[elementIndex] = e(DomLabel, {
|
||||
transparency = self.props.transparency,
|
||||
showStringDiff = self.props.showStringDiff,
|
||||
showTableDiff = self.props.showTableDiff,
|
||||
updateEvent = self.updateEvent,
|
||||
elementHeight = elementHeight,
|
||||
setElementHeight = setElementHeight,
|
||||
elementIndex = elementIndex,
|
||||
isFinalElement = elementIndex == elementTotal,
|
||||
depth = depth,
|
||||
depthsComplete = table.clone(depthsComplete),
|
||||
hasChildren = (node.children ~= nil and next(node.children) ~= nil),
|
||||
isFinalChild = isFinalChild,
|
||||
patchType = node.patchType,
|
||||
className = node.className,
|
||||
isWarning = node.isWarning,
|
||||
instance = node.instance,
|
||||
name = node.name,
|
||||
hint = node.hint,
|
||||
changeInfo = node.changeInfo,
|
||||
changeList = node.changeList,
|
||||
depth = depth,
|
||||
transparency = self.props.transparency,
|
||||
})
|
||||
)
|
||||
|
||||
for _, childNode in alphabeticalPairs(node.children) do
|
||||
drawNode(childNode, depth + 1)
|
||||
if isFinalChild then
|
||||
depthsComplete[depth] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, node in alphabeticalPairs(tree.ROOT.children) do
|
||||
drawNode(node, 0)
|
||||
|
||||
patchTree:forEach(function(node, depth)
|
||||
depthsComplete[depth] = false
|
||||
for i = depth + 1, #depthsComplete do
|
||||
depthsComplete[i] = nil
|
||||
end
|
||||
|
||||
drawNode(node, depth)
|
||||
end)
|
||||
end
|
||||
|
||||
return e(BorderedContainer, {
|
||||
transparency = self.props.transparency,
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
}, {
|
||||
VirtualScroller = e(VirtualScroller, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
return Theme.with(function(theme)
|
||||
return e(BorderedContainer, {
|
||||
transparency = self.props.transparency,
|
||||
count = #scrollElements,
|
||||
updateEvent = self.updateEvent.Event,
|
||||
render = function(i)
|
||||
return scrollElements[i]
|
||||
end,
|
||||
getHeightBinding = function(i)
|
||||
return elementHeights[i]
|
||||
end,
|
||||
}),
|
||||
})
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
anchorPoint = self.props.anchorPoint,
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
}, {
|
||||
CleanMerge = e("TextLabel", {
|
||||
Visible = #scrollElements == 0,
|
||||
Text = "No changes to sync, project is up to date.",
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Medium,
|
||||
TextColor3 = theme.TextColor,
|
||||
TextWrapped = true,
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
|
||||
VirtualScroller = e(VirtualScroller, {
|
||||
size = UDim2.new(1, 0, 1, -2),
|
||||
position = UDim2.new(0, 0, 0, 2),
|
||||
transparency = self.props.transparency,
|
||||
count = #scrollElements,
|
||||
updateEvent = self.updateEvent.Event,
|
||||
render = function(i)
|
||||
return scrollElements[i]
|
||||
end,
|
||||
getHeightBinding = function(i)
|
||||
return elementHeights[i]
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return PatchVisualizer
|
||||
|
||||
@@ -10,6 +10,12 @@ local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local scrollDirToAutoSize = {
|
||||
[Enum.ScrollingDirection.X] = Enum.AutomaticSize.X,
|
||||
[Enum.ScrollingDirection.Y] = Enum.AutomaticSize.Y,
|
||||
[Enum.ScrollingDirection.XY] = Enum.AutomaticSize.XY,
|
||||
}
|
||||
|
||||
local function ScrollingFrame(props)
|
||||
return Theme.with(function(theme)
|
||||
return e("ScrollingFrame", {
|
||||
@@ -23,19 +29,31 @@ local function ScrollingFrame(props)
|
||||
BottomImage = Assets.Images.ScrollBar.Bottom,
|
||||
|
||||
ElasticBehavior = Enum.ElasticBehavior.Always,
|
||||
ScrollingDirection = Enum.ScrollingDirection.Y,
|
||||
ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y,
|
||||
|
||||
Size = props.size,
|
||||
Position = props.position,
|
||||
AnchorPoint = props.anchorPoint,
|
||||
CanvasSize = props.contentSize:map(function(value)
|
||||
return UDim2.new(0, 0, 0, value.Y)
|
||||
end),
|
||||
CanvasSize = if props.contentSize
|
||||
then props.contentSize:map(function(value)
|
||||
return UDim2.new(
|
||||
0,
|
||||
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
|
||||
then value.X
|
||||
else 0,
|
||||
0,
|
||||
value.Y
|
||||
)
|
||||
end)
|
||||
else UDim2.new(),
|
||||
AutomaticCanvasSize = if props.contentSize == nil
|
||||
then scrollDirToAutoSize[props.scrollingDirection or Enum.ScrollingDirection.XY]
|
||||
else nil,
|
||||
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize]
|
||||
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize],
|
||||
}, props[Roact.Children])
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -20,6 +20,7 @@ local function SlicedImage(props)
|
||||
Size = props.size,
|
||||
Position = props.position,
|
||||
AnchorPoint = props.anchorPoint,
|
||||
AutomaticSize = props.automaticSize,
|
||||
|
||||
ZIndex = props.zIndex,
|
||||
LayoutOrder = props.layoutOrder,
|
||||
|
||||
441
plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua
Normal file
@@ -0,0 +1,441 @@
|
||||
--[[
|
||||
Based on DiffMatchPatch by Neil Fraser.
|
||||
https://github.com/google/diff-match-patch
|
||||
]]
|
||||
|
||||
export type DiffAction = number
|
||||
export type Diff = { actionType: DiffAction, value: string }
|
||||
export type Diffs = { Diff }
|
||||
|
||||
local StringDiff = {
|
||||
ActionTypes = table.freeze({
|
||||
Equal = 0,
|
||||
Delete = 1,
|
||||
Insert = 2,
|
||||
}),
|
||||
}
|
||||
|
||||
function StringDiff.findDiffs(text1: string, text2: string): Diffs
|
||||
-- Validate inputs
|
||||
if type(text1) ~= "string" or type(text2) ~= "string" then
|
||||
error(
|
||||
string.format(
|
||||
"Invalid inputs to StringDiff.findDiffs, expected strings and got (%s, %s)",
|
||||
type(text1),
|
||||
type(text2)
|
||||
),
|
||||
2
|
||||
)
|
||||
end
|
||||
|
||||
-- Shortcut if the texts are identical
|
||||
if text1 == text2 then
|
||||
return { { actionType = StringDiff.ActionTypes.Equal, value = text1 } }
|
||||
end
|
||||
|
||||
-- Trim off any shared prefix and suffix
|
||||
-- These are easy to detect and can be dealt with quickly without needing a complex diff
|
||||
-- and later we simply add them as Equal to the start and end of the diff
|
||||
local sharedPrefix, sharedSuffix
|
||||
local prefixLength = StringDiff._sharedPrefix(text1, text2)
|
||||
if prefixLength > 0 then
|
||||
-- Store the prefix
|
||||
sharedPrefix = string.sub(text1, 1, prefixLength)
|
||||
-- Now trim it off
|
||||
text1 = string.sub(text1, prefixLength + 1)
|
||||
text2 = string.sub(text2, prefixLength + 1)
|
||||
end
|
||||
|
||||
local suffixLength = StringDiff._sharedSuffix(text1, text2)
|
||||
if suffixLength > 0 then
|
||||
-- Store the suffix
|
||||
sharedSuffix = string.sub(text1, -suffixLength)
|
||||
-- Now trim it off
|
||||
text1 = string.sub(text1, 1, -suffixLength - 1)
|
||||
text2 = string.sub(text2, 1, -suffixLength - 1)
|
||||
end
|
||||
|
||||
-- Compute the diff on the middle block where the changes lie
|
||||
local diffs = StringDiff._computeDiff(text1, text2)
|
||||
|
||||
-- Restore the prefix and suffix
|
||||
if sharedPrefix then
|
||||
table.insert(diffs, 1, { actionType = StringDiff.ActionTypes.Equal, value = sharedPrefix })
|
||||
end
|
||||
if sharedSuffix then
|
||||
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = sharedSuffix })
|
||||
end
|
||||
|
||||
-- Cleanup the diff
|
||||
diffs = StringDiff._reorderAndMerge(diffs)
|
||||
|
||||
return diffs
|
||||
end
|
||||
|
||||
function StringDiff._sharedPrefix(text1: string, text2: string): number
|
||||
-- Uses a binary search to find the largest common prefix between the two strings
|
||||
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
|
||||
|
||||
-- Shortcut common cases
|
||||
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, 1) ~= string.byte(text2, 1)) then
|
||||
return 0
|
||||
end
|
||||
|
||||
local pointerMin = 1
|
||||
local pointerMax = math.min(#text1, #text2)
|
||||
local pointerMid = pointerMax
|
||||
local pointerStart = 1
|
||||
while pointerMin < pointerMid do
|
||||
if string.sub(text1, pointerStart, pointerMid) == string.sub(text2, pointerStart, pointerMid) then
|
||||
pointerMin = pointerMid
|
||||
pointerStart = pointerMin
|
||||
else
|
||||
pointerMax = pointerMid
|
||||
end
|
||||
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
|
||||
end
|
||||
|
||||
return pointerMid
|
||||
end
|
||||
|
||||
function StringDiff._sharedSuffix(text1: string, text2: string): number
|
||||
-- Uses a binary search to find the largest common suffix between the two strings
|
||||
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
|
||||
|
||||
-- Shortcut common cases
|
||||
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, -1) ~= string.byte(text2, -1)) then
|
||||
return 0
|
||||
end
|
||||
|
||||
local pointerMin = 1
|
||||
local pointerMax = math.min(#text1, #text2)
|
||||
local pointerMid = pointerMax
|
||||
local pointerEnd = 1
|
||||
while pointerMin < pointerMid do
|
||||
if string.sub(text1, -pointerMid, -pointerEnd) == string.sub(text2, -pointerMid, -pointerEnd) then
|
||||
pointerMin = pointerMid
|
||||
pointerEnd = pointerMin
|
||||
else
|
||||
pointerMax = pointerMid
|
||||
end
|
||||
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
|
||||
end
|
||||
|
||||
return pointerMid
|
||||
end
|
||||
|
||||
function StringDiff._computeDiff(text1: string, text2: string): Diffs
|
||||
-- Assumes that the prefix and suffix have already been trimmed off
|
||||
-- and shortcut returns have been made so these texts must be different
|
||||
|
||||
local text1Length, text2Length = #text1, #text2
|
||||
|
||||
if text1Length == 0 then
|
||||
-- It's simply inserting all of text2 into text1
|
||||
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
|
||||
end
|
||||
|
||||
if text2Length == 0 then
|
||||
-- It's simply deleting all of text1
|
||||
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
|
||||
end
|
||||
|
||||
local longText = if text1Length > text2Length then text1 else text2
|
||||
local shortText = if text1Length > text2Length then text2 else text1
|
||||
local shortTextLength = #shortText
|
||||
|
||||
-- Shortcut if the shorter string exists entirely inside the longer one
|
||||
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
|
||||
if indexOf ~= nil then
|
||||
local diffs = {
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
|
||||
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
|
||||
}
|
||||
-- Swap insertions for deletions if diff is reversed
|
||||
if text1Length > text2Length then
|
||||
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
|
||||
end
|
||||
return diffs
|
||||
end
|
||||
|
||||
if shortTextLength == 1 then
|
||||
-- Single character string
|
||||
-- After the previous shortcut, the character can't be an equality
|
||||
return {
|
||||
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
|
||||
}
|
||||
end
|
||||
|
||||
return StringDiff._bisect(text1, text2)
|
||||
end
|
||||
|
||||
function StringDiff._bisect(text1: string, text2: string): Diffs
|
||||
-- Find the 'middle snake' of a diff, split the problem in two
|
||||
-- and return the recursively constructed diff
|
||||
-- See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations
|
||||
|
||||
-- Cache the text lengths to prevent multiple calls
|
||||
local text1Length = #text1
|
||||
local text2Length = #text2
|
||||
|
||||
local _sub, _element
|
||||
local maxD = math.ceil((text1Length + text2Length) / 2)
|
||||
local vOffset = maxD
|
||||
local vLength = 2 * maxD
|
||||
local v1 = table.create(vLength)
|
||||
local v2 = table.create(vLength)
|
||||
|
||||
-- Setting all elements to -1 is faster in Lua than mixing integers and nil
|
||||
for x = 0, vLength - 1 do
|
||||
v1[x] = -1
|
||||
v2[x] = -1
|
||||
end
|
||||
v1[vOffset + 1] = 0
|
||||
v2[vOffset + 1] = 0
|
||||
local delta = text1Length - text2Length
|
||||
|
||||
-- If the total number of characters is odd, then
|
||||
-- the front path will collide with the reverse path
|
||||
local front = (delta % 2 ~= 0)
|
||||
|
||||
-- Offsets for start and end of k loop
|
||||
-- Prevents mapping of space beyond the grid
|
||||
local k1Start = 0
|
||||
local k1End = 0
|
||||
local k2Start = 0
|
||||
local k2End = 0
|
||||
for d = 0, maxD - 1 do
|
||||
-- Walk the front path one step
|
||||
for k1 = -d + k1Start, d - k1End, 2 do
|
||||
local k1_offset = vOffset + k1
|
||||
local x1
|
||||
if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then
|
||||
x1 = v1[k1_offset + 1]
|
||||
else
|
||||
x1 = v1[k1_offset - 1] + 1
|
||||
end
|
||||
local y1 = x1 - k1
|
||||
while
|
||||
(x1 <= text1Length)
|
||||
and (y1 <= text2Length)
|
||||
and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1))
|
||||
do
|
||||
x1 = x1 + 1
|
||||
y1 = y1 + 1
|
||||
end
|
||||
v1[k1_offset] = x1
|
||||
if x1 > text1Length + 1 then
|
||||
-- Ran off the right of the graph
|
||||
k1End = k1End + 2
|
||||
elseif y1 > text2Length + 1 then
|
||||
-- Ran off the bottom of the graph
|
||||
k1Start = k1Start + 2
|
||||
elseif front then
|
||||
local k2_offset = vOffset + delta - k1
|
||||
if k2_offset >= 0 and k2_offset < vLength and v2[k2_offset] ~= -1 then
|
||||
-- Mirror x2 onto top-left coordinate system
|
||||
local x2 = text1Length - v2[k2_offset] + 1
|
||||
if x1 > x2 then
|
||||
-- Overlap detected
|
||||
return StringDiff._bisectSplit(text1, text2, x1, y1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walk the reverse path one step
|
||||
for k2 = -d + k2Start, d - k2End, 2 do
|
||||
local k2_offset = vOffset + k2
|
||||
local x2
|
||||
if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then
|
||||
x2 = v2[k2_offset + 1]
|
||||
else
|
||||
x2 = v2[k2_offset - 1] + 1
|
||||
end
|
||||
local y2 = x2 - k2
|
||||
while
|
||||
(x2 <= text1Length)
|
||||
and (y2 <= text2Length)
|
||||
and (string.sub(text1, -x2, -x2) == string.sub(text2, -y2, -y2))
|
||||
do
|
||||
x2 = x2 + 1
|
||||
y2 = y2 + 1
|
||||
end
|
||||
v2[k2_offset] = x2
|
||||
if x2 > text1Length + 1 then
|
||||
-- Ran off the left of the graph
|
||||
k2End = k2End + 2
|
||||
elseif y2 > text2Length + 1 then
|
||||
-- Ran off the top of the graph
|
||||
k2Start = k2Start + 2
|
||||
elseif not front then
|
||||
local k1_offset = vOffset + delta - k2
|
||||
if k1_offset >= 0 and k1_offset < vLength and v1[k1_offset] ~= -1 then
|
||||
local x1 = v1[k1_offset]
|
||||
local y1 = vOffset + x1 - k1_offset
|
||||
-- Mirror x2 onto top-left coordinate system
|
||||
x2 = text1Length - x2 + 1
|
||||
if x1 > x2 then
|
||||
-- Overlap detected
|
||||
return StringDiff._bisectSplit(text1, text2, x1, y1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Number of diffs equals number of characters, no commonality at all
|
||||
return {
|
||||
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
|
||||
}
|
||||
end
|
||||
|
||||
function StringDiff._bisectSplit(text1: string, text2: string, x: number, y: number): Diffs
|
||||
-- Given the location of the 'middle snake',
|
||||
-- split the diff in two parts and recurse
|
||||
|
||||
local text1a = string.sub(text1, 1, x - 1)
|
||||
local text2a = string.sub(text2, 1, y - 1)
|
||||
local text1b = string.sub(text1, x)
|
||||
local text2b = string.sub(text2, y)
|
||||
|
||||
-- Compute both diffs serially
|
||||
local diffs = StringDiff.findDiffs(text1a, text2a)
|
||||
local diffsB = StringDiff.findDiffs(text1b, text2b)
|
||||
|
||||
-- Merge diffs
|
||||
table.move(diffsB, 1, #diffsB, #diffs + 1, diffs)
|
||||
return diffs
|
||||
end
|
||||
|
||||
function StringDiff._reorderAndMerge(diffs: Diffs): Diffs
|
||||
-- Reorder and merge like edit sections and merge equalities
|
||||
-- Any edit section can move as long as it doesn't cross an equality
|
||||
|
||||
-- Add a dummy entry at the end
|
||||
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = "" })
|
||||
|
||||
local pointer = 1
|
||||
local countDelete, countInsert = 0, 0
|
||||
local textDelete, textInsert = "", ""
|
||||
local commonLength
|
||||
while diffs[pointer] do
|
||||
local actionType = diffs[pointer].actionType
|
||||
if actionType == StringDiff.ActionTypes.Insert then
|
||||
countInsert = countInsert + 1
|
||||
textInsert = textInsert .. diffs[pointer].value
|
||||
pointer = pointer + 1
|
||||
elseif actionType == StringDiff.ActionTypes.Delete then
|
||||
countDelete = countDelete + 1
|
||||
textDelete = textDelete .. diffs[pointer].value
|
||||
pointer = pointer + 1
|
||||
elseif actionType == StringDiff.ActionTypes.Equal then
|
||||
-- Upon reaching an equality, check for prior redundancies
|
||||
if countDelete + countInsert > 1 then
|
||||
if (countDelete > 0) and (countInsert > 0) then
|
||||
-- Factor out any common prefixies
|
||||
commonLength = StringDiff._sharedPrefix(textInsert, textDelete)
|
||||
if commonLength > 0 then
|
||||
local back_pointer = pointer - countDelete - countInsert
|
||||
if
|
||||
(back_pointer > 1) and (diffs[back_pointer - 1].actionType == StringDiff.ActionTypes.Equal)
|
||||
then
|
||||
diffs[back_pointer - 1].value = diffs[back_pointer - 1].value
|
||||
.. string.sub(textInsert, 1, commonLength)
|
||||
else
|
||||
table.insert(diffs, 1, {
|
||||
actionType = StringDiff.ActionTypes.Equal,
|
||||
value = string.sub(textInsert, 1, commonLength),
|
||||
})
|
||||
pointer = pointer + 1
|
||||
end
|
||||
textInsert = string.sub(textInsert, commonLength + 1)
|
||||
textDelete = string.sub(textDelete, commonLength + 1)
|
||||
end
|
||||
-- Factor out any common suffixies
|
||||
commonLength = StringDiff._sharedSuffix(textInsert, textDelete)
|
||||
if commonLength ~= 0 then
|
||||
diffs[pointer].value = string.sub(textInsert, -commonLength) .. diffs[pointer].value
|
||||
textInsert = string.sub(textInsert, 1, -commonLength - 1)
|
||||
textDelete = string.sub(textDelete, 1, -commonLength - 1)
|
||||
end
|
||||
end
|
||||
-- Delete the offending records and add the merged ones
|
||||
pointer = pointer - countDelete - countInsert
|
||||
for _ = 1, countDelete + countInsert do
|
||||
table.remove(diffs, pointer)
|
||||
end
|
||||
if #textDelete > 0 then
|
||||
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Delete, value = textDelete })
|
||||
pointer = pointer + 1
|
||||
end
|
||||
if #textInsert > 0 then
|
||||
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Insert, value = textInsert })
|
||||
pointer = pointer + 1
|
||||
end
|
||||
pointer = pointer + 1
|
||||
elseif (pointer > 1) and (diffs[pointer - 1].actionType == StringDiff.ActionTypes.Equal) then
|
||||
-- Merge this equality with the previous one
|
||||
diffs[pointer - 1].value = diffs[pointer - 1].value .. diffs[pointer].value
|
||||
table.remove(diffs, pointer)
|
||||
else
|
||||
pointer = pointer + 1
|
||||
end
|
||||
countInsert, countDelete = 0, 0
|
||||
textDelete, textInsert = "", ""
|
||||
end
|
||||
end
|
||||
if diffs[#diffs].value == "" then
|
||||
-- Remove the dummy entry at the end
|
||||
diffs[#diffs] = nil
|
||||
end
|
||||
|
||||
-- Second pass: look for single edits surrounded on both sides by equalities
|
||||
-- which can be shifted sideways to eliminate an equality
|
||||
-- e.g: A<ins>BA</ins>C -> <ins>AB</ins>AC
|
||||
local changes = false
|
||||
pointer = 2
|
||||
-- Intentionally ignore the first and last element (don't need checking)
|
||||
while pointer < #diffs do
|
||||
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
|
||||
if
|
||||
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||
then
|
||||
-- This is a single edit surrounded by equalities
|
||||
local currentDiff = diffs[pointer]
|
||||
local currentText = currentDiff.value
|
||||
local prevText = prevDiff.value
|
||||
local nextText = nextDiff.value
|
||||
if #prevText == 0 then
|
||||
table.remove(diffs, pointer - 1)
|
||||
changes = true
|
||||
elseif string.sub(currentText, -#prevText) == prevText then
|
||||
-- Shift the edit over the previous equality
|
||||
currentDiff.value = prevText .. string.sub(currentText, 1, -#prevText - 1)
|
||||
nextDiff.value = prevText .. nextDiff.value
|
||||
table.remove(diffs, pointer - 1)
|
||||
changes = true
|
||||
elseif string.sub(currentText, 1, #nextText) == nextText then
|
||||
-- Shift the edit over the next equality
|
||||
prevDiff.value = prevText .. nextText
|
||||
currentDiff.value = string.sub(currentText, #nextText + 1) .. nextText
|
||||
table.remove(diffs, pointer + 1)
|
||||
changes = true
|
||||
end
|
||||
end
|
||||
pointer = pointer + 1
|
||||
end
|
||||
|
||||
-- If shifts were made, the diffs need reordering and another shift sweep
|
||||
if changes then
|
||||
return StringDiff._reorderAndMerge(diffs)
|
||||
end
|
||||
|
||||
return diffs
|
||||
end
|
||||
|
||||
return StringDiff
|
||||
204
plugin/src/App/Components/StringDiffVisualizer/init.lua
Normal file
@@ -0,0 +1,204 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Log = require(Packages.Log)
|
||||
local Highlighter = require(Packages.Highlighter)
|
||||
local StringDiff = require(script:FindFirstChild("StringDiff"))
|
||||
|
||||
local Timer = require(Plugin.Timer)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
|
||||
|
||||
local CodeLabel = require(Plugin.App.Components.CodeLabel)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
|
||||
|
||||
function StringDiffVisualizer:init()
|
||||
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
|
||||
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||
|
||||
-- Ensure that the script background is up to date with the current theme
|
||||
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
|
||||
task.defer(function()
|
||||
-- Defer to allow Highlighter to process the theme change first
|
||||
self:updateScriptBackground()
|
||||
end)
|
||||
end)
|
||||
|
||||
self:updateScriptBackground()
|
||||
|
||||
self:setState({
|
||||
add = {},
|
||||
remove = {},
|
||||
})
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:willUnmount()
|
||||
self.themeChangedConnection:Disconnect()
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:updateScriptBackground()
|
||||
local backgroundColor = Highlighter.getTokenColor("background")
|
||||
if backgroundColor ~= self.scriptBackground:getValue() then
|
||||
self.setScriptBackground(backgroundColor)
|
||||
end
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:didUpdate(previousProps)
|
||||
if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
|
||||
local add, remove = self:calculateDiffLines()
|
||||
self:setState({
|
||||
add = add,
|
||||
remove = remove,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:calculateContentSize(theme)
|
||||
local oldString, newString = self.props.oldString, self.props.newString
|
||||
|
||||
local oldStringBounds = getTextBoundsAsync(oldString, theme.Font.Code, theme.TextSize.Code, math.huge)
|
||||
local newStringBounds = getTextBoundsAsync(newString, theme.Font.Code, theme.TextSize.Code, math.huge)
|
||||
|
||||
self.setContentSize(
|
||||
Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
|
||||
)
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:calculateDiffLines()
|
||||
Timer.start("StringDiffVisualizer:calculateDiffLines")
|
||||
local oldString, newString = self.props.oldString, self.props.newString
|
||||
|
||||
-- Diff the two texts
|
||||
local startClock = os.clock()
|
||||
local diffs = StringDiff.findDiffs(oldString, newString)
|
||||
local stopClock = os.clock()
|
||||
|
||||
Log.trace(
|
||||
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
|
||||
#oldString,
|
||||
#newString,
|
||||
math.round((stopClock - startClock) * 1000 * 1000),
|
||||
#diffs
|
||||
)
|
||||
|
||||
-- Determine which lines to highlight
|
||||
local add, remove = {}, {}
|
||||
|
||||
local oldLineNum, newLineNum = 1, 1
|
||||
for _, diff in diffs do
|
||||
local actionType, text = diff.actionType, diff.value
|
||||
local lines = select(2, string.gsub(text, "\n", "\n"))
|
||||
|
||||
if actionType == StringDiff.ActionTypes.Equal then
|
||||
oldLineNum += lines
|
||||
newLineNum += lines
|
||||
elseif actionType == StringDiff.ActionTypes.Insert then
|
||||
if lines > 0 then
|
||||
local textLines = string.split(text, "\n")
|
||||
for i, textLine in textLines do
|
||||
if string.match(textLine, "%S") then
|
||||
add[newLineNum + i - 1] = true
|
||||
end
|
||||
end
|
||||
else
|
||||
if string.match(text, "%S") then
|
||||
add[newLineNum] = true
|
||||
end
|
||||
end
|
||||
newLineNum += lines
|
||||
elseif actionType == StringDiff.ActionTypes.Delete then
|
||||
if lines > 0 then
|
||||
local textLines = string.split(text, "\n")
|
||||
for i, textLine in textLines do
|
||||
if string.match(textLine, "%S") then
|
||||
remove[oldLineNum + i - 1] = true
|
||||
end
|
||||
end
|
||||
else
|
||||
if string.match(text, "%S") then
|
||||
remove[oldLineNum] = true
|
||||
end
|
||||
end
|
||||
oldLineNum += lines
|
||||
else
|
||||
Log.warn("Unknown diff action: {} {}", actionType, text)
|
||||
end
|
||||
end
|
||||
|
||||
Timer.stop()
|
||||
return add, remove
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:render()
|
||||
local oldString, newString = self.props.oldString, self.props.newString
|
||||
|
||||
return Theme.with(function(theme)
|
||||
self:calculateContentSize(theme)
|
||||
|
||||
return e(BorderedContainer, {
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
anchorPoint = self.props.anchorPoint,
|
||||
transparency = self.props.transparency,
|
||||
}, {
|
||||
Background = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
Position = UDim2.new(0, 0, 0, 0),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = self.scriptBackground,
|
||||
ZIndex = -10,
|
||||
}, {
|
||||
UICorner = e("UICorner", {
|
||||
CornerRadius = UDim.new(0, 5),
|
||||
}),
|
||||
}),
|
||||
Separator = e("Frame", {
|
||||
Size = UDim2.new(0, 2, 1, 0),
|
||||
Position = UDim2.new(0.5, 0, 0, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
BackgroundTransparency = 0.5,
|
||||
}),
|
||||
Old = e(ScrollingFrame, {
|
||||
position = UDim2.new(0, 2, 0, 2),
|
||||
size = UDim2.new(0.5, -7, 1, -4),
|
||||
scrollingDirection = Enum.ScrollingDirection.XY,
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
}, {
|
||||
Source = e(CodeLabel, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
position = UDim2.new(0, 0, 0, 0),
|
||||
text = oldString,
|
||||
lineBackground = theme.Diff.Remove,
|
||||
markedLines = self.state.remove,
|
||||
}),
|
||||
}),
|
||||
New = e(ScrollingFrame, {
|
||||
position = UDim2.new(0.5, 5, 0, 2),
|
||||
size = UDim2.new(0.5, -7, 1, -4),
|
||||
scrollingDirection = Enum.ScrollingDirection.XY,
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
}, {
|
||||
Source = e(CodeLabel, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
position = UDim2.new(0, 0, 0, 0),
|
||||
text = newString,
|
||||
lineBackground = theme.Diff.Add,
|
||||
markedLines = self.state.add,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return StringDiffVisualizer
|
||||
@@ -10,11 +10,15 @@ local StudioPluginContext = require(script.Parent.StudioPluginContext)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local StudioPluginAction = Roact.Component:extend("StudioPluginAction")
|
||||
local StudioPluginAction = Roact.Component:extend("StudioPluginAction")
|
||||
|
||||
function StudioPluginAction:init()
|
||||
self.pluginAction = self.props.plugin:CreatePluginAction(
|
||||
self.props.name, self.props.title, self.props.description, self.props.icon, self.props.bindable
|
||||
self.props.name,
|
||||
self.props.title,
|
||||
self.props.description,
|
||||
self.props.icon,
|
||||
self.props.bindable
|
||||
)
|
||||
|
||||
self.pluginAction.Triggered:Connect(self.props.onTriggered)
|
||||
@@ -31,9 +35,12 @@ end
|
||||
local function StudioPluginActionWrapper(props)
|
||||
return e(StudioPluginContext.Consumer, {
|
||||
render = function(plugin)
|
||||
return e(StudioPluginAction, Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
}))
|
||||
return e(
|
||||
StudioPluginAction,
|
||||
Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
@@ -36,7 +38,10 @@ function StudioPluginGui:init()
|
||||
minimumSize.Y
|
||||
)
|
||||
|
||||
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo)
|
||||
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(
|
||||
if self.props.isEphemeral then HttpService:GenerateGUID(false) else self.props.id,
|
||||
dockWidgetPluginGuiInfo
|
||||
)
|
||||
|
||||
pluginGui.Name = self.props.id
|
||||
pluginGui.Title = self.props.title
|
||||
@@ -76,6 +81,12 @@ function StudioPluginGui:didUpdate(lastProps)
|
||||
if self.props.active ~= lastProps.active then
|
||||
-- This is intentionally in didUpdate to make sure the initial active state
|
||||
-- (if the PluginGui is open initially) is preserved.
|
||||
|
||||
-- Studio widgets are very unreliable and sometimes need to be flickered
|
||||
-- in order to force them to render correctly
|
||||
-- This happens within a single frame so it doesn't flicker visibly
|
||||
self.pluginGui.Enabled = self.props.active
|
||||
self.pluginGui.Enabled = not self.props.active
|
||||
self.pluginGui.Enabled = self.props.active
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,12 +18,8 @@ StudioToggleButton.defaultProps = {
|
||||
}
|
||||
|
||||
function StudioToggleButton:init()
|
||||
local button = self.props.toolbar:CreateButton(
|
||||
self.props.name,
|
||||
self.props.tooltip,
|
||||
self.props.icon,
|
||||
self.props.text
|
||||
)
|
||||
local button =
|
||||
self.props.toolbar:CreateButton(self.props.name, self.props.tooltip, self.props.icon, self.props.text)
|
||||
|
||||
button.Click:Connect(function()
|
||||
if self.props.onClick then
|
||||
@@ -61,9 +57,12 @@ end
|
||||
local function StudioToggleButtonWrapper(props)
|
||||
return e(StudioToolbarContext.Consumer, {
|
||||
render = function(toolbar)
|
||||
return e(StudioToggleButton, Dictionary.merge(props, {
|
||||
toolbar = toolbar,
|
||||
}))
|
||||
return e(
|
||||
StudioToggleButton,
|
||||
Dictionary.merge(props, {
|
||||
toolbar = toolbar,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -36,9 +36,12 @@ end
|
||||
local function StudioToolbarWrapper(props)
|
||||
return e(StudioPluginContext.Consumer, {
|
||||
render = function(plugin)
|
||||
return e(StudioToolbar, Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
}))
|
||||
return e(
|
||||
StudioToolbar,
|
||||
Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
195
plugin/src/App/Components/TableDiffVisualizer/Array.lua
Normal file
@@ -0,0 +1,195 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local Timer = require(Plugin.Timer)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
local DisplayValue = require(Plugin.App.Components.PatchVisualizer.DisplayValue)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local Array = Roact.Component:extend("Array")
|
||||
|
||||
function Array:init()
|
||||
self:setState({
|
||||
diff = self:calculateDiff(),
|
||||
})
|
||||
end
|
||||
|
||||
function Array:calculateDiff()
|
||||
Timer.start("Array:calculateDiff")
|
||||
--[[
|
||||
Find the indexes that are added or removed from the array,
|
||||
and display them side by side with gaps for the indexes that
|
||||
dont exist in the opposite array.
|
||||
]]
|
||||
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
|
||||
|
||||
local i, j = 1, 1
|
||||
local diff = {}
|
||||
|
||||
while i <= #oldTable and j <= #newTable do
|
||||
if oldTable[i] == newTable[j] then
|
||||
table.insert(diff, { oldTable[i], newTable[j] }) -- Unchanged
|
||||
i += 1
|
||||
j += 1
|
||||
elseif not table.find(newTable, oldTable[i], j) then
|
||||
table.insert(diff, { oldTable[i], nil }) -- Removal
|
||||
i += 1
|
||||
elseif not table.find(oldTable, newTable[j], i) then
|
||||
table.insert(diff, { nil, newTable[j] }) -- Addition
|
||||
j += 1
|
||||
else
|
||||
if table.find(newTable, oldTable[i], j) then
|
||||
table.insert(diff, { nil, newTable[j] }) -- Addition
|
||||
j += 1
|
||||
else
|
||||
table.insert(diff, { oldTable[i], nil }) -- Removal
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Handle remaining elements
|
||||
while i <= #oldTable do
|
||||
table.insert(diff, { oldTable[i], nil }) -- Remaining Removals
|
||||
i += 1
|
||||
end
|
||||
while j <= #newTable do
|
||||
table.insert(diff, { nil, newTable[j] }) -- Remaining Additions
|
||||
j += 1
|
||||
end
|
||||
|
||||
Timer.stop()
|
||||
return diff
|
||||
end
|
||||
|
||||
function Array:didUpdate(previousProps)
|
||||
if previousProps.oldTable ~= self.props.oldTable or previousProps.newTable ~= self.props.newTable then
|
||||
self:setState({
|
||||
diff = self:calculateDiff(),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Array:render()
|
||||
return Theme.with(function(theme)
|
||||
local diff = self.state.diff
|
||||
local lines = table.create(#diff)
|
||||
|
||||
for i, element in diff do
|
||||
local oldValue = element[1]
|
||||
local newValue = element[2]
|
||||
|
||||
local patchType = if oldValue == nil then "Add" elseif newValue == nil then "Remove" else "Remain"
|
||||
|
||||
table.insert(
|
||||
lines,
|
||||
e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 25),
|
||||
BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency,
|
||||
BackgroundColor3 = if patchType == "Remain" then theme.Diff.Row else theme.Diff[patchType],
|
||||
BorderSizePixel = 0,
|
||||
LayoutOrder = i,
|
||||
}, {
|
||||
DiffIcon = if patchType ~= "Remain"
|
||||
then e("ImageLabel", {
|
||||
Image = Assets.Images.Diff[patchType],
|
||||
ImageColor3 = theme.AddressEntry.PlaceholderColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0, 15, 0, 15),
|
||||
Position = UDim2.new(0, 7, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
})
|
||||
else nil,
|
||||
Old = e("Frame", {
|
||||
Size = UDim2.new(0.5, -30, 1, 0),
|
||||
Position = UDim2.new(0, 30, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Display = if oldValue ~= nil
|
||||
then e(DisplayValue, {
|
||||
value = oldValue,
|
||||
transparency = self.props.transparency,
|
||||
textColor = theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
New = e("Frame", {
|
||||
Size = UDim2.new(0.5, -10, 1, 0),
|
||||
Position = UDim2.new(0.5, 5, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Display = if newValue ~= nil
|
||||
then e(DisplayValue, {
|
||||
value = newValue,
|
||||
transparency = self.props.transparency,
|
||||
textColor = theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
return Roact.createFragment({
|
||||
Headers = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 25),
|
||||
BackgroundTransparency = self.props.transparency:map(function(t)
|
||||
return 0.95 + (0.05 * t)
|
||||
end),
|
||||
BackgroundColor3 = theme.Diff.Row,
|
||||
}, {
|
||||
ColumnA = e("TextLabel", {
|
||||
Size = UDim2.new(0.5, -30, 1, 0),
|
||||
Position = UDim2.new(0, 30, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "Old",
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
ColumnB = e("TextLabel", {
|
||||
Size = UDim2.new(0.5, -10, 1, 0),
|
||||
Position = UDim2.new(0.5, 5, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "New",
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
Separator = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 1),
|
||||
Position = UDim2.new(0, 0, 1, 0),
|
||||
BackgroundTransparency = 0,
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
}),
|
||||
}),
|
||||
KeyValues = e(ScrollingFrame, {
|
||||
position = UDim2.new(0, 1, 0, 25),
|
||||
size = UDim2.new(1, -2, 1, -27),
|
||||
scrollingDirection = Enum.ScrollingDirection.Y,
|
||||
transparency = self.props.transparency,
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Top,
|
||||
}),
|
||||
Lines = Roact.createFragment(lines),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return Array
|
||||
211
plugin/src/App/Components/TableDiffVisualizer/Dictionary.lua
Normal file
@@ -0,0 +1,211 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local Timer = require(Plugin.Timer)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
local DisplayValue = require(Plugin.App.Components.PatchVisualizer.DisplayValue)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local Dictionary = Roact.Component:extend("Dictionary")
|
||||
|
||||
function Dictionary:init()
|
||||
self:setState({
|
||||
diff = self:calculateDiff(),
|
||||
})
|
||||
end
|
||||
|
||||
function Dictionary:calculateDiff()
|
||||
Timer.start("Dictionary:calculateDiff")
|
||||
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
|
||||
|
||||
-- Diff the two tables and find the added keys, removed keys, and changed keys
|
||||
local diff = {}
|
||||
|
||||
for key, oldValue in oldTable do
|
||||
local newValue = newTable[key]
|
||||
if newValue == nil then
|
||||
table.insert(diff, {
|
||||
key = key,
|
||||
patchType = "Remove",
|
||||
})
|
||||
elseif newValue ~= oldValue then
|
||||
-- Note: should this do some sort of deep comparison for various types?
|
||||
table.insert(diff, {
|
||||
key = key,
|
||||
patchType = "Edit",
|
||||
})
|
||||
else
|
||||
table.insert(diff, {
|
||||
key = key,
|
||||
patchType = "Remain",
|
||||
})
|
||||
end
|
||||
end
|
||||
for key in newTable do
|
||||
if oldTable[key] == nil then
|
||||
table.insert(diff, {
|
||||
key = key,
|
||||
patchType = "Add",
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(diff, function(a, b)
|
||||
return a.key < b.key
|
||||
end)
|
||||
|
||||
Timer.stop()
|
||||
return diff
|
||||
end
|
||||
|
||||
function Dictionary:didUpdate(previousProps)
|
||||
if previousProps.oldTable ~= self.props.oldTable or previousProps.newTable ~= self.props.newTable then
|
||||
self:setState({
|
||||
diff = self:calculateDiff(),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function Dictionary:render()
|
||||
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
|
||||
local diff = self.state.diff
|
||||
|
||||
return Theme.with(function(theme)
|
||||
local lines = table.create(#diff)
|
||||
for order, line in diff do
|
||||
local key = line.key
|
||||
local oldValue = oldTable[key]
|
||||
local newValue = newTable[key]
|
||||
|
||||
table.insert(
|
||||
lines,
|
||||
e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 25),
|
||||
LayoutOrder = order,
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
|
||||
BackgroundColor3 = if line.patchType == "Remain"
|
||||
then theme.Diff.Row
|
||||
else theme.Diff[line.patchType],
|
||||
}, {
|
||||
DiffIcon = if line.patchType ~= "Remain"
|
||||
then e("ImageLabel", {
|
||||
Image = Assets.Images.Diff[line.patchType],
|
||||
ImageColor3 = theme.AddressEntry.PlaceholderColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0, 15, 0, 15),
|
||||
Position = UDim2.new(0, 7, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
})
|
||||
else nil,
|
||||
KeyName = e("TextLabel", {
|
||||
Size = UDim2.new(0.3, -15, 1, 0),
|
||||
Position = UDim2.new(0, 30, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = key,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
OldValue = e("Frame", {
|
||||
Size = UDim2.new(0.35, -7, 1, 0),
|
||||
Position = UDim2.new(0.3, 15, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
e(DisplayValue, {
|
||||
value = oldValue,
|
||||
transparency = self.props.transparency,
|
||||
textColor = theme.Settings.Setting.DescriptionColor,
|
||||
}),
|
||||
}),
|
||||
NewValue = e("Frame", {
|
||||
Size = UDim2.new(0.35, -8, 1, 0),
|
||||
Position = UDim2.new(0.65, 8, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
e(DisplayValue, {
|
||||
value = newValue,
|
||||
transparency = self.props.transparency,
|
||||
textColor = theme.Settings.Setting.DescriptionColor,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
return Roact.createFragment({
|
||||
Headers = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 25),
|
||||
BackgroundTransparency = self.props.transparency:map(function(t)
|
||||
return 0.95 + (0.05 * t)
|
||||
end),
|
||||
BackgroundColor3 = theme.Diff.Row,
|
||||
}, {
|
||||
ColumnA = e("TextLabel", {
|
||||
Size = UDim2.new(0.3, -15, 1, 0),
|
||||
Position = UDim2.new(0, 30, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "Key",
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
ColumnB = e("TextLabel", {
|
||||
Size = UDim2.new(0.35, -7, 1, 0),
|
||||
Position = UDim2.new(0.3, 15, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "Old",
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
ColumnC = e("TextLabel", {
|
||||
Size = UDim2.new(0.35, -8, 1, 0),
|
||||
Position = UDim2.new(0.65, 8, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
Text = "New",
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
FontFace = theme.Font.Bold,
|
||||
TextSize = theme.TextSize.Body,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
}),
|
||||
Separator = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 1),
|
||||
Position = UDim2.new(0, 0, 1, 0),
|
||||
BackgroundTransparency = 0,
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
}),
|
||||
}),
|
||||
KeyValues = e(ScrollingFrame, {
|
||||
position = UDim2.new(0, 1, 0, 25),
|
||||
size = UDim2.new(1, -2, 1, -27),
|
||||
scrollingDirection = Enum.ScrollingDirection.Y,
|
||||
transparency = self.props.transparency,
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Top,
|
||||
}),
|
||||
Lines = Roact.createFragment(lines),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return Dictionary
|
||||
48
plugin/src/App/Components/TableDiffVisualizer/init.lua
Normal file
@@ -0,0 +1,48 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local Array = require(script:FindFirstChild("Array"))
|
||||
local Dictionary = require(script:FindFirstChild("Dictionary"))
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local TableDiffVisualizer = Roact.Component:extend("TableDiffVisualizer")
|
||||
|
||||
function TableDiffVisualizer:render()
|
||||
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
|
||||
|
||||
-- Ensure we're diffing tables, not mixing types
|
||||
if type(oldTable) ~= "table" then
|
||||
oldTable = {}
|
||||
end
|
||||
if type(newTable) ~= "table" then
|
||||
newTable = {}
|
||||
end
|
||||
|
||||
local isArray = next(newTable) == 1 or next(oldTable) == 1
|
||||
|
||||
return e(BorderedContainer, {
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
anchorPoint = self.props.anchorPoint,
|
||||
transparency = self.props.transparency,
|
||||
}, {
|
||||
Content = if isArray
|
||||
then e(Array, {
|
||||
oldTable = oldTable,
|
||||
newTable = newTable,
|
||||
transparency = self.props.transparency,
|
||||
})
|
||||
else e(Dictionary, {
|
||||
oldTable = oldTable,
|
||||
newTable = newTable,
|
||||
transparency = self.props.transparency,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return TableDiffVisualizer
|
||||
59
plugin/src/App/Components/Tag.lua
Normal file
@@ -0,0 +1,59 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
|
||||
local SlicedImage = require(Plugin.App.Components.SlicedImage)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
return function(props)
|
||||
return Theme.with(function(theme)
|
||||
return e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = props.color,
|
||||
transparency = props.transparency:map(function(transparency)
|
||||
return 0.9 + (0.1 * transparency)
|
||||
end),
|
||||
layoutOrder = props.layoutOrder,
|
||||
position = props.position,
|
||||
anchorPoint = props.anchorPoint,
|
||||
size = UDim2.new(0, 0, 0, theme.TextSize.Medium),
|
||||
automaticSize = Enum.AutomaticSize.X,
|
||||
}, {
|
||||
Padding = e("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 4),
|
||||
PaddingRight = UDim.new(0, 4),
|
||||
PaddingTop = UDim.new(0, 2),
|
||||
PaddingBottom = UDim.new(0, 2),
|
||||
}),
|
||||
Icon = if props.icon
|
||||
then e("ImageLabel", {
|
||||
Size = UDim2.new(0, 12, 0, 12),
|
||||
Position = UDim2.new(0, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
Image = props.icon,
|
||||
BackgroundTransparency = 1,
|
||||
ImageColor3 = props.color,
|
||||
ImageTransparency = props.transparency,
|
||||
})
|
||||
else nil,
|
||||
Text = e("TextLabel", {
|
||||
Text = props.text,
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Small,
|
||||
TextColor3 = props.color,
|
||||
TextXAlignment = Enum.TextXAlignment.Center,
|
||||
TextTransparency = props.transparency,
|
||||
Size = UDim2.new(0, 0, 1, 0),
|
||||
Position = UDim2.new(0, if props.icon then 15 else 0, 0, 0),
|
||||
AutomaticSize = Enum.AutomaticSize.X,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
@@ -1,5 +1,3 @@
|
||||
local TextService = game:GetService("TextService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
@@ -10,6 +8,7 @@ local Flipper = require(Packages.Flipper)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
|
||||
|
||||
local SlicedImage = require(script.Parent.SlicedImage)
|
||||
local TouchRipple = require(script.Parent.TouchRipple)
|
||||
@@ -41,20 +40,17 @@ end
|
||||
|
||||
function TextButton:render()
|
||||
return Theme.with(function(theme)
|
||||
local textSize = TextService:GetTextSize(
|
||||
self.props.text, 18, Enum.Font.GothamSemibold,
|
||||
Vector2.new(math.huge, math.huge)
|
||||
)
|
||||
local textBounds = getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Large, math.huge)
|
||||
|
||||
local style = self.props.style
|
||||
|
||||
theme = theme.Button[style]
|
||||
local buttonTheme = theme.Button[style]
|
||||
|
||||
local bindingHover = bindingUtil.deriveProperty(self.binding, "hover")
|
||||
local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled")
|
||||
|
||||
return e("ImageButton", {
|
||||
Size = UDim2.new(0, 15 + textSize.X + 15, 0, 34),
|
||||
Size = UDim2.new(0, (theme.TextSize.Body * 2) + textBounds.X, 0, 34),
|
||||
Position = self.props.position,
|
||||
AnchorPoint = self.props.anchorPoint,
|
||||
|
||||
@@ -76,18 +72,22 @@ function TextButton:render()
|
||||
end,
|
||||
}, {
|
||||
TouchRipple = e(TouchRipple, {
|
||||
color = theme.ActionFillColor,
|
||||
color = buttonTheme.ActionFillColor,
|
||||
transparency = self.props.transparency:map(function(value)
|
||||
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, value })
|
||||
return bindingUtil.blendAlpha({ buttonTheme.ActionFillTransparency, value })
|
||||
end),
|
||||
zIndex = 2,
|
||||
}),
|
||||
|
||||
Text = e("TextLabel", {
|
||||
Text = self.props.text,
|
||||
Font = Enum.Font.GothamSemibold,
|
||||
TextSize = 18,
|
||||
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor),
|
||||
FontFace = theme.Font.Main,
|
||||
TextSize = theme.TextSize.Large,
|
||||
TextColor3 = bindingUtil.mapLerp(
|
||||
bindingEnabled,
|
||||
buttonTheme.Enabled.TextColor,
|
||||
buttonTheme.Disabled.TextColor
|
||||
),
|
||||
TextTransparency = self.props.transparency,
|
||||
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
@@ -97,7 +97,11 @@ function TextButton:render()
|
||||
|
||||
Border = style == "Bordered" and e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BorderColor, theme.Disabled.BorderColor),
|
||||
color = bindingUtil.mapLerp(
|
||||
bindingEnabled,
|
||||
buttonTheme.Enabled.BorderColor,
|
||||
buttonTheme.Disabled.BorderColor
|
||||
),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
@@ -107,14 +111,18 @@ function TextButton:render()
|
||||
|
||||
HoverOverlay = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = theme.ActionFillColor,
|
||||
color = buttonTheme.ActionFillColor,
|
||||
transparency = Roact.joinBindings({
|
||||
hover = bindingHover:map(function(value)
|
||||
return 1 - value
|
||||
end),
|
||||
transparency = self.props.transparency,
|
||||
}):map(function(values)
|
||||
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, values.hover, values.transparency })
|
||||
return bindingUtil.blendAlpha({
|
||||
buttonTheme.ActionFillTransparency,
|
||||
values.hover,
|
||||
values.transparency,
|
||||
})
|
||||
end),
|
||||
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
@@ -124,7 +132,11 @@ function TextButton:render()
|
||||
|
||||
Background = style == "Solid" and e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BackgroundColor, theme.Disabled.BackgroundColor),
|
||||
color = bindingUtil.mapLerp(
|
||||
bindingEnabled,
|
||||
buttonTheme.Enabled.BackgroundColor,
|
||||
buttonTheme.Disabled.BackgroundColor
|
||||
),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
|
||||