Compare commits
239 Commits
v7.2.1-sta
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
|
110b9f0df3
|
|||
|
917d17a738
|
|||
|
14bbdaf560
|
|||
|
5b1b5db06c
|
|||
|
33dd0f5ed1
|
|||
|
95fe993de3
|
|||
| a6e9939d6c | |||
|
5957368c04
|
|||
|
78916c8a63
|
|||
|
791ccfcfd1
|
|||
|
3500ebe02a
|
|||
|
|
2a1102fc55 | ||
|
|
02b41133f8 | ||
|
0e1364945f
|
|||
| 3a6aae65f7 | |||
| d13d229eef | |||
| 9a485d88ce | |||
|
020d72faef
|
|||
|
60d150f4c6
|
|||
|
73dab330b5
|
|||
|
790312a5b0
|
|||
|
5c396322d9
|
|||
|
37e44e474a
|
|||
|
|
d08780fc14 | ||
|
|
b89cc7f398 | ||
|
|
42568b9709 | ||
|
|
87f58e0a55 | ||
|
|
a61a1bef55 | ||
|
|
a99e877b7c | ||
|
|
93e9c51204 | ||
|
|
015b5bda14 | ||
|
|
2b47861a4f | ||
|
|
9b5a07191b | ||
|
|
071b6e7e23 | ||
|
|
31ec216a95 | ||
|
|
ea70d89291 | ||
|
|
03410ced6d | ||
|
|
825726c883 | ||
|
|
54e63d88d4 | ||
|
|
4018c97cb6 | ||
|
|
d0b029f995 | ||
|
|
aabe6d11b2 | ||
|
|
181cc37744 | ||
|
|
cd78f5c02c | ||
|
|
441c469966 | ||
|
|
f3c423d77d | ||
|
|
beb497878b | ||
|
|
6ea95d487c | ||
|
|
80a381dbb1 | ||
|
|
59e36491a5 | ||
|
|
c1326ba06e | ||
|
|
e2633126ee | ||
|
|
5f33435f3c | ||
|
|
54e0ff230b | ||
|
|
4e9e6233ff | ||
|
|
0056849b51 | ||
|
|
2ddb21ec5f | ||
|
|
a4eb65ca3f | ||
|
|
3002d250a1 | ||
|
|
9598553e5d | ||
|
|
7f68d9887b | ||
|
|
e092a7301f | ||
|
|
6dfdfbe514 | ||
|
|
7860f2717f | ||
|
|
60f19df9a0 | ||
|
|
951f0cda0b | ||
|
|
227042d6b1 | ||
|
|
b2c4f550ee | ||
|
|
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 | ||
|
|
e17771a6a5 | ||
|
|
bac30ae78b | ||
|
|
c0219922b2 | ||
|
|
b5ed952d5c | ||
|
|
7994bc4909 | ||
|
|
b88d34c639 | ||
|
|
96cb1ee3fd | ||
|
|
003abe86bb | ||
|
|
6ec411a618 | ||
|
|
c7c0903804 | ||
|
|
cdc972a5ce | ||
|
|
17de912608 | ||
|
|
9876508887 | ||
|
|
72d62220e8 | ||
|
|
46ad337fa5 | ||
|
|
7a3ba7721f |
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))))
|
||||||
@@ -24,3 +24,6 @@ insert_final_newline = true
|
|||||||
|
|
||||||
[*.lua]
|
[*.lua]
|
||||||
indent_style = tab
|
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 }}
|
||||||
118
.github/workflows/ci.yml
vendored
@@ -12,32 +12,28 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build and Test
|
name: Build and Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
rust_version: [stable, 1.58.1]
|
os: [ubuntu-22.04, windows-latest, macos-latest, windows-11-arm, ubuntu-22.04-arm]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
toolchain: ${{ matrix.rust_version }}
|
|
||||||
override: true
|
|
||||||
profile: minimal
|
|
||||||
|
|
||||||
- name: Setup Foreman
|
- name: Restore Rust Cache
|
||||||
uses: Roblox/setup-foreman@v1
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
- name: Install packages
|
~/.cargo/git
|
||||||
run: |
|
target
|
||||||
cd plugin
|
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
wally install
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --locked --verbose
|
run: cargo build --locked --verbose
|
||||||
@@ -45,33 +41,93 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --locked --verbose
|
run: cargo test --locked --verbose
|
||||||
|
|
||||||
lint:
|
- name: Save Rust Cache
|
||||||
name: Rustfmt and Clippy
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
msrv:
|
||||||
|
name: Check MSRV
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: true
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@1.88.0
|
||||||
|
|
||||||
|
- name: Restore Rust Cache
|
||||||
|
uses: actions/cache/restore@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --locked --verbose
|
||||||
|
|
||||||
|
- name: Save Rust Cache
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
lint:
|
||||||
|
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:
|
with:
|
||||||
toolchain: stable
|
|
||||||
override: true
|
|
||||||
components: rustfmt, clippy
|
components: rustfmt, clippy
|
||||||
|
|
||||||
- name: Setup Foreman
|
- name: Restore Rust Cache
|
||||||
uses: Roblox/setup-foreman@v1
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Install packages
|
- name: Setup Rokit
|
||||||
run: |
|
uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
cd plugin
|
with:
|
||||||
wally install
|
version: 'v1.1.0'
|
||||||
cd ..
|
|
||||||
|
- name: Stylua
|
||||||
|
run: stylua --check plugin/src
|
||||||
|
|
||||||
|
- name: Selene
|
||||||
|
run: selene plugin/src
|
||||||
|
|
||||||
- name: Rustfmt
|
- name: Rustfmt
|
||||||
run: cargo fmt -- --check
|
run: cargo fmt -- --check
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
run: cargo clippy
|
run: cargo clippy
|
||||||
|
|
||||||
|
- name: Save Rust Cache
|
||||||
|
uses: actions/cache/save@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|||||||
138
.github/workflows/release.yml
vendored
@@ -8,53 +8,39 @@ jobs:
|
|||||||
create-release:
|
create-release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
run: |
|
||||||
tag_name: ${{ github.ref }}
|
gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ github.ref_name }}
|
||||||
release_name: ${{ github.ref }}
|
|
||||||
draft: true
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
build-plugin:
|
build-plugin:
|
||||||
needs: ["create-release"]
|
needs: ["create-release"]
|
||||||
name: Build Roblox Studio Plugin
|
name: Build Roblox Studio Plugin
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Foreman
|
|
||||||
uses: Roblox/setup-foreman@v1
|
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
submodules: true
|
||||||
|
|
||||||
- name: Install packages
|
- name: Setup Rokit
|
||||||
run: |
|
uses: CompeyDev/setup-rokit@v0.1.2
|
||||||
cd plugin
|
with:
|
||||||
wally install
|
version: 'v1.1.0'
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Plugin
|
- name: Build Plugin
|
||||||
run: rojo build plugin --output Rojo.rbxm
|
run: rojo build plugin.project.json --output Rojo.rbxm
|
||||||
|
|
||||||
- name: Upload Plugin to Release
|
- name: Upload Plugin to Release
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
run: |
|
||||||
upload_url: ${{ needs.create-release.outputs.upload_url }}
|
gh release upload ${{ github.ref_name }} Rojo.rbxm
|
||||||
asset_path: Rojo.rbxm
|
|
||||||
asset_name: Rojo.rbxm
|
|
||||||
asset_content_type: application/octet-stream
|
|
||||||
|
|
||||||
- name: Upload Plugin to Artifacts
|
- name: Upload Plugin to Artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: Rojo.rbxm
|
name: Rojo.rbxm
|
||||||
path: Rojo.rbxm
|
path: Rojo.rbxm
|
||||||
@@ -67,15 +53,25 @@ jobs:
|
|||||||
# https://doc.rust-lang.org/rustc/platform-support.html
|
# https://doc.rust-lang.org/rustc/platform-support.html
|
||||||
include:
|
include:
|
||||||
- host: linux
|
- host: linux
|
||||||
os: ubuntu-20.04
|
os: ubuntu-22.04
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
label: linux-x86_64
|
label: linux-x86_64
|
||||||
|
|
||||||
|
- host: linux
|
||||||
|
os: ubuntu-22.04-arm
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
label: linux-aarch64
|
||||||
|
|
||||||
- host: windows
|
- host: windows
|
||||||
os: windows-latest
|
os: windows-latest
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
label: windows-x86_64
|
label: windows-x86_64
|
||||||
|
|
||||||
|
- host: windows
|
||||||
|
os: windows-11-arm
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
label: windows-aarch64
|
||||||
|
|
||||||
- host: macos
|
- host: macos
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
@@ -91,72 +87,64 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
BIN: rojo
|
BIN: rojo
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
- name: Get Version from Tag
|
submodules: true
|
||||||
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 }}"
|
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: actions-rs/toolchain@v1
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: stable
|
targets: ${{ matrix.target }}
|
||||||
target: ${{ matrix.target }}
|
|
||||||
override: true
|
|
||||||
profile: minimal
|
|
||||||
|
|
||||||
- name: Setup Foreman
|
- name: Restore Rust Cache
|
||||||
uses: Roblox/setup-foreman@v1
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
- name: Install packages
|
~/.cargo/git
|
||||||
run: |
|
target
|
||||||
cd plugin
|
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
wally install
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Release
|
- 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
|
- name: Save Rust Cache
|
||||||
# make binaries more portable.
|
uses: actions/cache/save@v4
|
||||||
OPENSSL_STATIC: 1
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
- name: Create Release Archive
|
- name: Generate Artifact Name
|
||||||
shell: bash
|
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: |
|
run: |
|
||||||
mkdir staging
|
mkdir staging
|
||||||
|
|
||||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||||
cp "output/release/$BIN.exe" staging/
|
cp "target/${{ matrix.target }}/release/$BIN.exe" staging/
|
||||||
cd staging
|
cd staging
|
||||||
7z a ../release.zip *
|
7z a ../$ARTIFACT_NAME *
|
||||||
else
|
else
|
||||||
cp "output/release/$BIN" staging/
|
cp "target/${{ matrix.target }}/release/$BIN" staging/
|
||||||
cd staging
|
cd staging
|
||||||
zip ../release.zip *
|
zip ../$ARTIFACT_NAME *
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Upload Archive to Release
|
gh release upload ${{ github.ref_name }} ../$ARTIFACT_NAME
|
||||||
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
|
|
||||||
|
|
||||||
- name: Upload Archive to Artifacts
|
- name: Upload Archive to Artifacts
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
|
path: ${{ env.ARTIFACT_NAME }}
|
||||||
path: release.zip
|
name: ${{ env.ARTIFACT_NAME }}
|
||||||
|
|||||||
14
.gitignore
vendored
@@ -10,11 +10,8 @@
|
|||||||
/*.rbxl
|
/*.rbxl
|
||||||
/*.rbxlx
|
/*.rbxlx
|
||||||
|
|
||||||
# Test places for the Roblox Studio Plugin
|
# Sourcemap for the Rojo plugin (for better intellisense)
|
||||||
/plugin/*.rbxlx
|
/sourcemap.json
|
||||||
|
|
||||||
# Packages for the Roblox Studio Plugin
|
|
||||||
/plugin/*Packages
|
|
||||||
|
|
||||||
# Roblox Studio holds 'lock' files on places
|
# Roblox Studio holds 'lock' files on places
|
||||||
*.rbxl.lock
|
*.rbxl.lock
|
||||||
@@ -22,3 +19,10 @@
|
|||||||
|
|
||||||
# Snapshot files from the 'insta' Rust crate
|
# Snapshot files from the 'insta' Rust crate
|
||||||
**/*.snap.new
|
**/*.snap.new
|
||||||
|
|
||||||
|
# Macos file system junk
|
||||||
|
._*
|
||||||
|
.DS_STORE
|
||||||
|
|
||||||
|
# JetBrains IDEs
|
||||||
|
/.idea/
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
1158
CHANGELOG.md
@@ -15,12 +15,29 @@ You'll want these tools to work on Rojo:
|
|||||||
|
|
||||||
* Latest stable Rust compiler
|
* Latest stable Rust compiler
|
||||||
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
||||||
* [Foreman](https://github.com/Roblox/foreman)
|
* [Rokit](https://github.com/rojo-rbx/rokit)
|
||||||
|
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
|
||||||
|
|
||||||
|
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
|
||||||
Documentation impacts way more people than the individual lines of code we write.
|
Documentation impacts way more people than the individual lines of code we write.
|
||||||
|
|
||||||
If you find any problems in documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
|
If you find any problems in the documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
|
||||||
|
|
||||||
## Bug Reports and Feature Requests
|
## Bug Reports and Feature Requests
|
||||||
Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right.
|
Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right.
|
||||||
|
|||||||
2596
Cargo.lock
generated
116
Cargo.toml
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
version = "7.2.1"
|
version = "7.7.0-rc.1"
|
||||||
rust-version = "1.58.1"
|
rust-version = "1.88"
|
||||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
authors = [
|
||||||
|
"Lucien Greathouse <me@lpghatguy.com>",
|
||||||
|
"Micah Reid <git@dekkonot.com>",
|
||||||
|
"Ken Loeffler <kenloef@gmail.com>",
|
||||||
|
]
|
||||||
description = "Enables professional-grade development tools for Roblox developers"
|
description = "Enables professional-grade development tools for Roblox developers"
|
||||||
license = "MPL-2.0"
|
license = "MPL-2.0"
|
||||||
homepage = "https://rojo.space"
|
homepage = "https://rojo.space"
|
||||||
@@ -12,9 +16,7 @@ readme = "README.md"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
exclude = [
|
exclude = ["/test-projects/**"]
|
||||||
"/test-projects/**",
|
|
||||||
]
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
panic = "abort"
|
panic = "abort"
|
||||||
@@ -28,7 +30,9 @@ default = []
|
|||||||
# Enable this feature to live-reload assets from the web UI.
|
# Enable this feature to live-reload assets from the web UI.
|
||||||
dev_live_assets = []
|
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]
|
[workspace]
|
||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
@@ -42,69 +46,85 @@ name = "build"
|
|||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
memofs = { version = "0.2.0", path = "crates/memofs" }
|
memofs = { version = "0.3.1", path = "crates/memofs" }
|
||||||
|
|
||||||
# These dependencies can be uncommented when working on rbx-dom simultaneously
|
# These dependencies can be uncommented when working on rbx-dom simultaneously
|
||||||
# rbx_binary = { path = "../rbx-dom/rbx_binary" }
|
# rbx_binary = { path = "../rbx-dom/rbx_binary", features = [
|
||||||
|
# "unstable_text_format",
|
||||||
|
# ] }
|
||||||
# rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
|
# rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
|
||||||
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
|
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
|
||||||
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
|
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
|
||||||
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
|
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
|
||||||
|
|
||||||
rbx_binary = "0.6.5"
|
rbx_binary = { version = "2.0.1", features = ["unstable_text_format"] }
|
||||||
rbx_dom_weak = "2.4.0"
|
rbx_dom_weak = "4.1.0"
|
||||||
rbx_reflection = "4.2.0"
|
rbx_reflection = "6.1.0"
|
||||||
rbx_reflection_database = "0.2.2"
|
rbx_reflection_database = "2.0.2"
|
||||||
rbx_xml = "0.12.3"
|
rbx_xml = "2.0.1"
|
||||||
|
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.80"
|
||||||
backtrace = "0.3.61"
|
backtrace = "0.3.69"
|
||||||
bincode = "1.3.3"
|
bincode = "1.3.3"
|
||||||
crossbeam-channel = "0.5.1"
|
crossbeam-channel = "0.5.12"
|
||||||
csv = "1.1.6"
|
csv = "1.3.0"
|
||||||
env_logger = "0.9.0"
|
env_logger = "0.9.3"
|
||||||
fs-err = "2.6.0"
|
fs-err = "2.11.0"
|
||||||
futures = "0.3.17"
|
futures = "0.3.30"
|
||||||
globset = "0.4.8"
|
globset = "0.4.14"
|
||||||
humantime = "2.1.0"
|
humantime = "2.1.0"
|
||||||
hyper = { version = "0.14.13", features = ["server", "tcp", "http1"] }
|
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
|
||||||
|
hyper-tungstenite = "0.11.0"
|
||||||
jod-thread = "0.1.2"
|
jod-thread = "0.1.2"
|
||||||
log = "0.4.14"
|
log = "0.4.21"
|
||||||
maplit = "1.0.2"
|
num_cpus = "1.16.0"
|
||||||
notify = "4.0.17"
|
opener = "0.5.2"
|
||||||
opener = "0.5.0"
|
rayon = "1.9.0"
|
||||||
reqwest = { version = "0.11.10", features = ["blocking", "json", "native-tls-vendored"] }
|
reqwest = { version = "0.11.24", default-features = false, features = [
|
||||||
|
"blocking",
|
||||||
|
"json",
|
||||||
|
"rustls-tls",
|
||||||
|
] }
|
||||||
ritz = "0.1.0"
|
ritz = "0.1.0"
|
||||||
roblox_install = "1.0.0"
|
roblox_install = "1.0.0"
|
||||||
serde = { version = "1.0.130", features = ["derive", "rc"] }
|
serde = { version = "1.0.197", features = ["derive", "rc"] }
|
||||||
serde_json = "1.0.68"
|
serde_json = "1.0.145"
|
||||||
termcolor = "1.1.2"
|
jsonc-parser = { version = "0.27.0", features = ["serde"] }
|
||||||
thiserror = "1.0.30"
|
strum = { version = "0.27", features = ["derive"] }
|
||||||
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] }
|
toml = "0.5.11"
|
||||||
uuid = { version = "1.0.0", features = ["v4", "serde"] }
|
termcolor = "1.4.1"
|
||||||
clap = { version = "3.1.18", features = ["derive"] }
|
thiserror = "1.0.57"
|
||||||
profiling = "1.0.6"
|
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
|
||||||
tracy-client = { version = "0.13.2", optional = true }
|
uuid = { version = "1.7.0", features = ["v4", "serde"] }
|
||||||
|
clap = { version = "3.2.25", features = ["derive"] }
|
||||||
|
profiling = "1.0.15"
|
||||||
|
yaml-rust2 = "0.10.3"
|
||||||
|
data-encoding = "2.8.0"
|
||||||
|
|
||||||
|
blake3 = "1.5.0"
|
||||||
|
float-cmp = "0.9.0"
|
||||||
|
indexmap = { version = "2.10.0", features = ["serde"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
winreg = "0.10.1"
|
winreg = "0.10.1"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
memofs = { version = "0.2.0", path = "crates/memofs" }
|
memofs = { version = "0.3.0", path = "crates/memofs" }
|
||||||
|
|
||||||
embed-resource = "1.6.4"
|
embed-resource = "1.8.0"
|
||||||
anyhow = "1.0.44"
|
anyhow = "1.0.80"
|
||||||
bincode = "1.3.3"
|
bincode = "1.3.3"
|
||||||
fs-err = "2.6.0"
|
fs-err = "2.11.0"
|
||||||
maplit = "1.0.2"
|
maplit = "1.0.2"
|
||||||
|
semver = "1.0.22"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
||||||
|
|
||||||
criterion = "0.3.5"
|
criterion = "0.3.6"
|
||||||
insta = { version = "1.8.0", features = ["redactions"] }
|
insta = { version = "1.36.1", features = ["redactions", "yaml"] }
|
||||||
paste = "1.0.5"
|
paste = "1.0.14"
|
||||||
pretty_assertions = "1.2.1"
|
pretty_assertions = "1.4.0"
|
||||||
serde_yaml = "0.8.21"
|
serde_yaml = "0.8.26"
|
||||||
tempfile = "3.2.0"
|
tempfile = "3.10.1"
|
||||||
walkdir = "2.3.2"
|
walkdir = "2.5.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div align="center">
|
<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> </div>
|
<div> </div>
|
||||||
@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
|
|||||||
|
|
||||||
Pull requests are welcome!
|
Pull requests are welcome!
|
||||||
|
|
||||||
Rojo supports Rust 1.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.88 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||||
|
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 |
|
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 |
@@ -17,6 +17,10 @@ html {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #e7e7e7
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width:100%;
|
max-width:100%;
|
||||||
max-height:100%;
|
max-height:100%;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
To build this library or plugin, use:
|
To build this library, use:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rojo build -o "{project_name}.rbxmx"
|
rojo build -o "{project_name}.rbxmx"
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
# Roblox Studio lock files
|
# Roblox Studio lock files
|
||||||
/*.rbxlx.lock
|
/*.rbxlx.lock
|
||||||
/*.rbxl.lock
|
/*.rbxl.lock
|
||||||
|
|
||||||
|
sourcemap.json
|
||||||
@@ -4,3 +4,5 @@
|
|||||||
# Roblox Studio lock files
|
# Roblox Studio lock files
|
||||||
/*.rbxlx.lock
|
/*.rbxlx.lock
|
||||||
/*.rbxl.lock
|
/*.rbxl.lock
|
||||||
|
|
||||||
|
sourcemap.json
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("Hello world, from client!")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
print("Hello world, from server!")
|
||||||
3
assets/project-templates/place/src/shared/Hello.luau
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
return function()
|
||||||
|
print("Hello, world!")
|
||||||
|
end
|
||||||
17
assets/project-templates/plugin/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/project-templates/plugin/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "{project_name}",
|
||||||
|
"tree": {
|
||||||
|
"$path": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
assets/project-templates/plugin/gitignore.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Plugin model files
|
||||||
|
/{project_name}.rbxmx
|
||||||
|
/{project_name}.rbxm
|
||||||
|
|
||||||
|
sourcemap.json
|
||||||
1
assets/project-templates/plugin/src/init.server.luau
Normal file
@@ -0,0 +1 @@
|
|||||||
|
print("Hello world, from plugin!")
|
||||||
@@ -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) {
|
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let input = input_path.as_ref().to_path_buf();
|
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 {
|
let options = BuildCommand {
|
||||||
project: input,
|
project: input,
|
||||||
watch: false,
|
watch: false,
|
||||||
|
plugin: None,
|
||||||
output,
|
output,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
47
build.rs
@@ -7,6 +7,7 @@ use fs_err as fs;
|
|||||||
use fs_err::File;
|
use fs_err::File;
|
||||||
use maplit::hashmap;
|
use maplit::hashmap;
|
||||||
use memofs::VfsSnapshot;
|
use memofs::VfsSnapshot;
|
||||||
|
use semver::Version;
|
||||||
|
|
||||||
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||||
println!("cargo:rerun-if-changed={}", path.display());
|
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();
|
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
|
// We can skip any TestEZ test files since they aren't necessary for
|
||||||
// the plugin to run.
|
// the plugin to run.
|
||||||
if file_name.ends_with(".spec.lua") || file_name.ends_with(".spec.luau") {
|
if file_name.ends_with(".spec.lua") || file_name.ends_with(".spec.luau") {
|
||||||
@@ -40,23 +45,39 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
|||||||
fn main() -> Result<(), anyhow::Error> {
|
fn main() -> Result<(), anyhow::Error> {
|
||||||
let out_dir = env::var_os("OUT_DIR").unwrap();
|
let out_dir = env::var_os("OUT_DIR").unwrap();
|
||||||
|
|
||||||
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
|
let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||||
let plugin_root = PathBuf::from(root_dir).join("plugin");
|
let plugin_dir = root_dir.join("plugin");
|
||||||
|
let templates_dir = root_dir.join("assets").join("project-templates");
|
||||||
|
|
||||||
let snapshot = VfsSnapshot::dir(hashmap! {
|
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
|
||||||
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
|
let plugin_version =
|
||||||
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
|
Version::parse(fs::read_to_string(plugin_dir.join("Version.txt"))?.trim())?;
|
||||||
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
|
|
||||||
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
|
assert_eq!(
|
||||||
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
|
our_version, plugin_version,
|
||||||
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
|
"plugin version does not match Cargo version"
|
||||||
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
|
);
|
||||||
|
|
||||||
|
let template_snapshot = snapshot_from_fs_path(&templates_dir)?;
|
||||||
|
|
||||||
|
let plugin_snapshot = VfsSnapshot::dir(hashmap! {
|
||||||
|
"default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
|
||||||
|
"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 template_file = File::create(Path::new(&out_dir).join("templates.bincode"))?;
|
||||||
let out_file = File::create(&out_path)?;
|
let plugin_file = File::create(Path::new(&out_dir).join("plugin.bincode"))?;
|
||||||
|
|
||||||
bincode::serialize_into(out_file, &snapshot)?;
|
bincode::serialize_into(plugin_file, &plugin_snapshot)?;
|
||||||
|
bincode::serialize_into(template_file, &template_snapshot)?;
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc");
|
println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc");
|
||||||
println!("cargo:rerun-if-changed=build/windows/rojo.manifest");
|
println!("cargo:rerun-if-changed=build/windows/rojo.manifest");
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
# memofs Changelog
|
# memofs Changelog
|
||||||
|
|
||||||
## Unreleased Changes
|
## Unreleased Changes
|
||||||
|
* Added `Vfs::canonicalize`. [#1201]
|
||||||
|
|
||||||
|
## 0.3.1 (2025-11-27)
|
||||||
|
* Added `Vfs::exists`. [#1169]
|
||||||
|
* Added `create_dir` and `create_dir_all` to allow creating directories. [#937]
|
||||||
|
|
||||||
|
[#1169]: https://github.com/rojo-rbx/rojo/pull/1169
|
||||||
|
[#937]: https://github.com/rojo-rbx/rojo/pull/937
|
||||||
|
|
||||||
|
## 0.3.0 (2024-03-15)
|
||||||
|
* 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)
|
## 0.2.0 (2021-08-23)
|
||||||
* Updated to `crossbeam-channel` 0.5.1.
|
* Updated to `crossbeam-channel` 0.5.1.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "memofs"
|
name = "memofs"
|
||||||
description = "Virtual filesystem with configurable backends."
|
description = "Virtual filesystem with configurable backends."
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
authors = [
|
||||||
|
"Lucien Greathouse <me@lpghatguy.com>",
|
||||||
|
"Micah Reid <git@dekkonot.com>",
|
||||||
|
"Ken Loeffler <kenloef@gmail.com>",
|
||||||
|
]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -11,7 +15,10 @@ 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
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
crossbeam-channel = "0.5.1"
|
crossbeam-channel = "0.5.12"
|
||||||
fs-err = "2.3.0"
|
fs-err = "2.11.0"
|
||||||
notify = "4.0.15"
|
notify = "4.0.17"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0.197", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10.1"
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ impl InMemoryFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryFs {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct InMemoryFsInner {
|
struct InMemoryFsInner {
|
||||||
entries: HashMap<PathBuf, Entry>,
|
entries: HashMap<PathBuf, Entry>,
|
||||||
@@ -151,6 +157,11 @@ impl VfsBackend for InMemoryFs {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exists(&mut self, path: &Path) -> io::Result<bool> {
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
Ok(inner.entries.contains_key(path))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
||||||
let inner = self.inner.lock().unwrap();
|
let inner = self.inner.lock().unwrap();
|
||||||
|
|
||||||
@@ -170,6 +181,21 @@ impl VfsBackend for InMemoryFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
|
||||||
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
let mut path_buf = path.to_path_buf();
|
||||||
|
while let Some(parent) = path_buf.parent() {
|
||||||
|
inner.load_snapshot(parent.to_path_buf(), VfsSnapshot::empty_dir())?;
|
||||||
|
path_buf.pop();
|
||||||
|
}
|
||||||
|
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
||||||
let mut inner = self.inner.lock().unwrap();
|
let mut inner = self.inner.lock().unwrap();
|
||||||
|
|
||||||
@@ -206,6 +232,33 @@ impl VfsBackend for InMemoryFs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: We rely on Rojo to prepend cwd to any relative path before storing paths
|
||||||
|
// in MemoFS. The current implementation will error if no prepended absolute path
|
||||||
|
// is found. It really only normalizes paths within the provided path's context.
|
||||||
|
// Example: "/Users/username/project/../other/file.txt" ->
|
||||||
|
// "/Users/username/other/file.txt"
|
||||||
|
// Erroneous example: "/Users/../../other/file.txt" -> "/other/file.txt"
|
||||||
|
// This is not very robust. We should implement proper path normalization here or otherwise
|
||||||
|
// warn if we are missing context and can not fully canonicalize the path correctly.
|
||||||
|
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
|
||||||
|
let mut normalized = PathBuf::new();
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
normalized.pop();
|
||||||
|
}
|
||||||
|
std::path::Component::CurDir => {}
|
||||||
|
_ => normalized.push(component),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = self.inner.lock().unwrap();
|
||||||
|
match inner.entries.get(&normalized) {
|
||||||
|
Some(_) => Ok(normalized),
|
||||||
|
None => not_found(&normalized),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
let inner = self.inner.lock().unwrap();
|
let inner = self.inner.lock().unwrap();
|
||||||
|
|
||||||
@@ -222,23 +275,17 @@ impl VfsBackend for InMemoryFs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn must_be_file<T>(path: &Path) -> io::Result<T> {
|
fn must_be_file<T>(path: &Path) -> io::Result<T> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other(format!(
|
||||||
io::ErrorKind::Other,
|
"path {} was a directory, but must be a file",
|
||||||
format!(
|
path.display()
|
||||||
"path {} was a directory, but must be a file",
|
)))
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn must_be_dir<T>(path: &Path) -> io::Result<T> {
|
fn must_be_dir<T>(path: &Path) -> io::Result<T> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other(format!(
|
||||||
io::ErrorKind::Other,
|
"path {} was a file, but must be a directory",
|
||||||
format!(
|
path.display()
|
||||||
"path {} was a file, but must be a directory",
|
)))
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn not_found<T>(path: &Path) -> io::Result<T> {
|
fn not_found<T>(path: &Path) -> io::Result<T> {
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ mod noop_backend;
|
|||||||
mod snapshot;
|
mod snapshot;
|
||||||
mod std_backend;
|
mod std_backend;
|
||||||
|
|
||||||
use std::io;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex, MutexGuard};
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::{io, str};
|
||||||
|
|
||||||
pub use in_memory_fs::InMemoryFs;
|
pub use in_memory_fs::InMemoryFs;
|
||||||
pub use noop_backend::NoopBackend;
|
pub use noop_backend::NoopBackend;
|
||||||
@@ -70,10 +70,14 @@ impl<T> IoResultExt<T> for io::Result<T> {
|
|||||||
pub trait VfsBackend: sealed::Sealed + Send + 'static {
|
pub trait VfsBackend: sealed::Sealed + Send + 'static {
|
||||||
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
|
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
|
||||||
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>;
|
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>;
|
||||||
|
fn exists(&mut self, path: &Path) -> io::Result<bool>;
|
||||||
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>;
|
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>;
|
||||||
|
fn create_dir(&mut self, path: &Path) -> io::Result<()>;
|
||||||
|
fn create_dir_all(&mut self, path: &Path) -> io::Result<()>;
|
||||||
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
|
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
|
||||||
fn remove_file(&mut self, path: &Path) -> io::Result<()>;
|
fn remove_file(&mut self, path: &Path) -> io::Result<()>;
|
||||||
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
|
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
|
||||||
|
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf>;
|
||||||
|
|
||||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
|
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent>;
|
||||||
fn watch(&mut self, path: &Path) -> io::Result<()>;
|
fn watch(&mut self, path: &Path) -> io::Result<()>;
|
||||||
@@ -155,6 +159,29 @@ impl VfsInner {
|
|||||||
Ok(Arc::new(contents))
|
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 exists<P: AsRef<Path>>(&mut self, path: P) -> io::Result<bool> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.backend.exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
|
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let contents = contents.as_ref();
|
let contents = contents.as_ref();
|
||||||
@@ -172,6 +199,16 @@ impl VfsInner {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.backend.create_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.backend.create_dir_all(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let _ = self.backend.unwatch(path);
|
let _ = self.backend.unwatch(path);
|
||||||
@@ -189,16 +226,18 @@ impl VfsInner {
|
|||||||
self.backend.metadata(path)
|
self.backend.metadata(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn canonicalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.backend.canonicalize(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
self.backend.event_receiver()
|
self.backend.event_receiver()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
|
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
|
||||||
match event {
|
if let VfsEvent::Remove(path) = event {
|
||||||
VfsEvent::Remove(path) => {
|
let _ = self.backend.unwatch(path);
|
||||||
let _ = self.backend.unwatch(&path);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -261,6 +300,33 @@ impl Vfs {
|
|||||||
self.inner.lock().unwrap().read(path)
|
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.
|
/// Write a file to the VFS and the underlying backend.
|
||||||
///
|
///
|
||||||
/// Roughly equivalent to [`std::fs::write`][std::fs::write].
|
/// Roughly equivalent to [`std::fs::write`][std::fs::write].
|
||||||
@@ -284,6 +350,42 @@ impl Vfs {
|
|||||||
self.inner.lock().unwrap().read_dir(path)
|
self.inner.lock().unwrap().read_dir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return whether the given path exists.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::exists`][std::fs::exists].
|
||||||
|
///
|
||||||
|
/// [std::fs::exists]: https://doc.rust-lang.org/stable/std/fs/fn.exists.html
|
||||||
|
#[inline]
|
||||||
|
pub fn exists<P: AsRef<Path>>(&self, path: P) -> io::Result<bool> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.lock().unwrap().exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a directory at the provided location.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
|
||||||
|
/// Similiar to that function, this function will fail if the parent of the
|
||||||
|
/// path does not exist.
|
||||||
|
///
|
||||||
|
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
|
||||||
|
#[inline]
|
||||||
|
pub fn create_dir<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.lock().unwrap().create_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a directory at the provided location, recursively creating
|
||||||
|
/// all parent components if they are missing.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
|
||||||
|
///
|
||||||
|
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
|
||||||
|
#[inline]
|
||||||
|
pub fn create_dir_all<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.lock().unwrap().create_dir_all(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a file.
|
/// Remove a file.
|
||||||
///
|
///
|
||||||
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
|
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
|
||||||
@@ -317,6 +419,19 @@ impl Vfs {
|
|||||||
self.inner.lock().unwrap().metadata(path)
|
self.inner.lock().unwrap().metadata(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a path via the underlying backend.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::canonicalize`][std::fs::canonicalize]. Relative paths are
|
||||||
|
/// resolved against the backend's current working directory (if applicable) and errors are
|
||||||
|
/// surfaced directly from the backend.
|
||||||
|
///
|
||||||
|
/// [std::fs::canonicalize]: https://doc.rust-lang.org/stable/std/fs/fn.canonicalize.html
|
||||||
|
#[inline]
|
||||||
|
pub fn canonicalize<P: AsRef<Path>>(&self, path: P) -> io::Result<PathBuf> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.lock().unwrap().canonicalize(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieve a handle to the event receiver for this `Vfs`.
|
/// Retrieve a handle to the event receiver for this `Vfs`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
@@ -386,6 +501,31 @@ impl VfsLock<'_> {
|
|||||||
self.inner.read_dir(path)
|
self.inner.read_dir(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Creates a directory at the provided location.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
|
||||||
|
/// Similiar to that function, this function will fail if the parent of the
|
||||||
|
/// path does not exist.
|
||||||
|
///
|
||||||
|
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
|
||||||
|
#[inline]
|
||||||
|
pub fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.create_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a directory at the provided location, recursively creating
|
||||||
|
/// all parent components if they are missing.
|
||||||
|
///
|
||||||
|
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
|
||||||
|
///
|
||||||
|
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
|
||||||
|
#[inline]
|
||||||
|
pub fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.create_dir_all(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a file.
|
/// Remove a file.
|
||||||
///
|
///
|
||||||
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
|
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
|
||||||
@@ -419,6 +559,13 @@ impl VfsLock<'_> {
|
|||||||
self.inner.metadata(path)
|
self.inner.metadata(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normalize a path via the underlying backend.
|
||||||
|
#[inline]
|
||||||
|
pub fn normalize<P: AsRef<Path>>(&mut self, path: P) -> io::Result<PathBuf> {
|
||||||
|
let path = path.as_ref();
|
||||||
|
self.inner.canonicalize(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Retrieve a handle to the event receiver for this `Vfs`.
|
/// Retrieve a handle to the event receiver for this `Vfs`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
pub fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
@@ -431,3 +578,83 @@ impl VfsLock<'_> {
|
|||||||
self.inner.commit_event(event)
|
self.inner.commit_event(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::{InMemoryFs, StdBackend, Vfs, VfsSnapshot};
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// https://github.com/rojo-rbx/rojo/issues/1200
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_in_memory_success() {
|
||||||
|
let mut imfs = InMemoryFs::new();
|
||||||
|
let contents = "Lorem ipsum dolor sit amet.".to_string();
|
||||||
|
|
||||||
|
imfs.load_snapshot("/test/file.txt", VfsSnapshot::file(contents.to_string()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let vfs = Vfs::new(imfs);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
vfs.canonicalize("/test/nested/../file.txt").unwrap(),
|
||||||
|
PathBuf::from("/test/file.txt")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
vfs.read_to_string(vfs.canonicalize("/test/nested/../file.txt").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.to_string(),
|
||||||
|
contents.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_in_memory_missing_errors() {
|
||||||
|
let imfs = InMemoryFs::new();
|
||||||
|
let vfs = Vfs::new(imfs);
|
||||||
|
|
||||||
|
let err = vfs.canonicalize("test").unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_std_backend_success() {
|
||||||
|
let contents = "Lorem ipsum dolor sit amet.".to_string();
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = dir.path().join("file.txt");
|
||||||
|
fs_err::write(&file_path, contents.to_string()).unwrap();
|
||||||
|
|
||||||
|
let vfs = Vfs::new(StdBackend::new());
|
||||||
|
let canonicalized = vfs.canonicalize(&file_path).unwrap();
|
||||||
|
assert_eq!(canonicalized, file_path.canonicalize().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
vfs.read_to_string(&canonicalized).unwrap().to_string(),
|
||||||
|
contents.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalize_std_backend_missing_errors() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let file_path = dir.path().join("test");
|
||||||
|
|
||||||
|
let vfs = Vfs::new(StdBackend::new());
|
||||||
|
let err = vfs.canonicalize(&file_path).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
|
use crate::{Metadata, ReadDir, VfsBackend, VfsEvent};
|
||||||
|
|
||||||
@@ -15,45 +15,43 @@ impl NoopBackend {
|
|||||||
|
|
||||||
impl VfsBackend for NoopBackend {
|
impl VfsBackend for NoopBackend {
|
||||||
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
|
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
|
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
}
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
fn exists(&mut self, _path: &Path) -> io::Result<bool> {
|
||||||
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
|
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
}
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
fn create_dir(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
|
fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
|
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
|
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
}
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
fn canonicalize(&mut self, _path: &Path) -> io::Result<PathBuf> {
|
||||||
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
@@ -61,16 +59,16 @@ impl VfsBackend for NoopBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn watch(&mut self, _path: &Path) -> io::Result<()> {
|
fn watch(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
|
||||||
"NoopBackend doesn't do anything",
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
|
fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
|
||||||
Err(io::Error::new(
|
Err(io::Error::other("NoopBackend doesn't do anything"))
|
||||||
io::ErrorKind::Other,
|
}
|
||||||
"NoopBackend doesn't do anything",
|
}
|
||||||
))
|
|
||||||
|
impl Default for NoopBackend {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::io;
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::{collections::HashSet, io};
|
||||||
|
|
||||||
use crossbeam_channel::Receiver;
|
use crossbeam_channel::Receiver;
|
||||||
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
|
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
|
||||||
@@ -13,6 +13,7 @@ use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
|
|||||||
pub struct StdBackend {
|
pub struct StdBackend {
|
||||||
watcher: RecommendedWatcher,
|
watcher: RecommendedWatcher,
|
||||||
watcher_receiver: Receiver<VfsEvent>,
|
watcher_receiver: Receiver<VfsEvent>,
|
||||||
|
watches: HashSet<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StdBackend {
|
impl StdBackend {
|
||||||
@@ -48,6 +49,7 @@ impl StdBackend {
|
|||||||
Self {
|
Self {
|
||||||
watcher,
|
watcher,
|
||||||
watcher_receiver: rx,
|
watcher_receiver: rx,
|
||||||
|
watches: HashSet::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,10 @@ impl VfsBackend for StdBackend {
|
|||||||
fs_err::write(path, data)
|
fs_err::write(path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn exists(&mut self, path: &Path) -> io::Result<bool> {
|
||||||
|
std::fs::exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
||||||
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
|
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
|
||||||
let mut entries = entries?;
|
let mut entries = entries?;
|
||||||
@@ -76,6 +82,14 @@ impl VfsBackend for StdBackend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
|
||||||
|
fs_err::create_dir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
|
||||||
|
fs_err::create_dir_all(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
||||||
fs_err::remove_file(path)
|
fs_err::remove_file(path)
|
||||||
}
|
}
|
||||||
@@ -92,19 +106,37 @@ impl VfsBackend for StdBackend {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn canonicalize(&mut self, path: &Path) -> io::Result<PathBuf> {
|
||||||
|
fs_err::canonicalize(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||||
self.watcher_receiver.clone()
|
self.watcher_receiver.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn watch(&mut self, path: &Path) -> io::Result<()> {
|
fn watch(&mut self, path: &Path) -> io::Result<()> {
|
||||||
self.watcher
|
if self.watches.contains(path)
|
||||||
.watch(path, RecursiveMode::NonRecursive)
|
|| path
|
||||||
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
|
.ancestors()
|
||||||
|
.any(|ancestor| self.watches.contains(ancestor))
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
self.watches.insert(path.to_path_buf());
|
||||||
|
self.watcher
|
||||||
|
.watch(path, RecursiveMode::Recursive)
|
||||||
|
.map_err(io::Error::other)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
|
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
|
||||||
self.watcher
|
self.watches.remove(path);
|
||||||
.unwatch(path)
|
self.watcher.unwatch(path).map_err(io::Error::other)
|
||||||
.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
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = "1.0.99"
|
serde = "1.0.197"
|
||||||
serde_yaml = "0.8.9"
|
serde_yaml = "0.8.26"
|
||||||
|
|||||||
@@ -5,19 +5,13 @@ use serde::Serialize;
|
|||||||
/// Enables redacting any value that serializes as a string.
|
/// Enables redacting any value that serializes as a string.
|
||||||
///
|
///
|
||||||
/// Used for transforming Rojo instance IDs into something deterministic.
|
/// Used for transforming Rojo instance IDs into something deterministic.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct RedactionMap {
|
pub struct RedactionMap {
|
||||||
ids: HashMap<String, usize>,
|
ids: HashMap<String, usize>,
|
||||||
last_id: usize,
|
last_id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RedactionMap {
|
impl RedactionMap {
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
ids: HashMap::new(),
|
|
||||||
last_id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_redacted_value(&self, id: impl ToString) -> Option<String> {
|
pub fn get_redacted_value(&self, id: impl ToString) -> Option<String> {
|
||||||
let id = id.to_string();
|
let id = id.to_string();
|
||||||
|
|
||||||
@@ -28,6 +22,12 @@ impl RedactionMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the numeric ID that was assigned to the provided value,
|
||||||
|
/// if one exists.
|
||||||
|
pub fn get_id_for_value(&self, value: impl ToString) -> Option<usize> {
|
||||||
|
self.ids.get(&value.to_string()).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn intern(&mut self, id: impl ToString) {
|
pub fn intern(&mut self, id: impl ToString) {
|
||||||
let last_id = &mut self.last_id;
|
let last_id = &mut self.last_id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
[tools]
|
|
||||||
rojo = { source = "rojo-rbx/rojo", version = "7.2.1" }
|
|
||||||
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }
|
|
||||||
selene = { source = "Kampfkarren/selene", version = "0.20.0" }
|
|
||||||
wally = { source = "UpliftGames/wally", version = "0.3.1"}
|
|
||||||
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.7.0-rc.1
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
local function defaultTableDebug(buffer, input)
|
local function defaultTableDebug(buffer, input)
|
||||||
buffer:writeRaw("{")
|
buffer:writeRaw("{")
|
||||||
|
|
||||||
for key, value in pairs(input) do
|
for key, value in input do
|
||||||
buffer:write("[{:?}] = {:?}", key, value)
|
buffer:write("[{:?}] = {:?}", key, value)
|
||||||
|
|
||||||
if next(input, key) ~= nil then
|
if next(input, key) ~= nil then
|
||||||
@@ -50,7 +50,7 @@ local function defaultTableDebugExtended(buffer, input)
|
|||||||
buffer:writeLineRaw("{")
|
buffer:writeLineRaw("{")
|
||||||
buffer:indent()
|
buffer:indent()
|
||||||
|
|
||||||
for key, value in pairs(input) do
|
for key, value in input do
|
||||||
buffer:writeLine("[{:?}] = {:#?},", key, value)
|
buffer:writeLine("[{:?}] = {:#?},", key, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ local function debugImpl(buffer, value, extendedForm)
|
|||||||
elseif valueType == "table" then
|
elseif valueType == "table" then
|
||||||
local valueMeta = getmetatable(value)
|
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
|
-- This type implement's the metamethod we made up to line up with
|
||||||
-- Rust's 'Debug' trait.
|
-- Rust's 'Debug' trait.
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ Error.__index = Error
|
|||||||
|
|
||||||
Error.Kind = {
|
Error.Kind = {
|
||||||
HttpNotEnabled = {
|
HttpNotEnabled = {
|
||||||
message = "Rojo requires HTTP access, which is not enabled.\n" ..
|
message = "Rojo requires HTTP access, which is not enabled.\n"
|
||||||
"Check your game settings, located in the 'Home' tab of Studio.",
|
.. "Check your game settings, located in the 'Home' tab of Studio.",
|
||||||
},
|
},
|
||||||
ConnectFailed = {
|
ConnectFailed = {
|
||||||
message = "Couldn't connect to the Rojo server.\n" ..
|
message = "Couldn't connect to the Rojo server.\n"
|
||||||
"Make sure the server is running — use 'rojo serve' to run it!",
|
.. "Make sure the server is running — use 'rojo serve' to run it!",
|
||||||
},
|
},
|
||||||
Timeout = {
|
Timeout = {
|
||||||
message = "HTTP request timed out.",
|
message = "HTTP request timed out.",
|
||||||
@@ -63,4 +63,13 @@ function Error.fromRobloxErrorString(message)
|
|||||||
return Error.new(Error.Kind.Unknown, message)
|
return Error.new(Error.Kind.Unknown, message)
|
||||||
end
|
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
|
return Error
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ local function performRequest(requestParams)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if success then
|
if success then
|
||||||
Log.trace("Request {} success, status code {}", requestId, response.StatusCode)
|
Log.trace("Request {} success, response {:#?}", requestId, response)
|
||||||
resolve(HttpResponse.fromRobloxResponse(response))
|
local httpResponse = HttpResponse.fromRobloxResponse(response)
|
||||||
|
if httpResponse:isSuccess() then
|
||||||
|
resolve(httpResponse)
|
||||||
|
else
|
||||||
|
reject(HttpError.fromResponse(httpResponse))
|
||||||
|
end
|
||||||
else
|
else
|
||||||
Log.trace("Request {} failure: {:?}", requestId, response)
|
Log.trace("Request {} failure: {:?}", requestId, response)
|
||||||
reject(HttpError.fromRobloxErrorString(response))
|
reject(HttpError.fromRobloxErrorString(response))
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ local function serializeFloat(value)
|
|||||||
return value
|
return value
|
||||||
end
|
end
|
||||||
|
|
||||||
local ALL_AXES = {"X", "Y", "Z"}
|
local ALL_AXES = { "X", "Y", "Z" }
|
||||||
local ALL_FACES = {"Right", "Top", "Back", "Left", "Bottom", "Front"}
|
local ALL_FACES = { "Right", "Top", "Back", "Left", "Bottom", "Front" }
|
||||||
|
|
||||||
local EncodedValue = {}
|
local EncodedValue = {}
|
||||||
|
|
||||||
@@ -37,7 +37,10 @@ types = {
|
|||||||
if ok then
|
if ok then
|
||||||
output[key] = result
|
output[key] = result
|
||||||
else
|
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)
|
warn(warning)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -53,7 +56,10 @@ types = {
|
|||||||
if ok then
|
if ok then
|
||||||
output[key] = result
|
output[key] = result
|
||||||
else
|
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)
|
warn(warning)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -111,6 +117,7 @@ types = {
|
|||||||
local pos = pod.position
|
local pos = pod.position
|
||||||
local orient = pod.orientation
|
local orient = pod.orientation
|
||||||
|
|
||||||
|
--stylua: ignore
|
||||||
return CFrame.new(
|
return CFrame.new(
|
||||||
pos[1], pos[2], pos[3],
|
pos[1], pos[2], pos[3],
|
||||||
orient[1][1], orient[1][2], orient[1][3],
|
orient[1][1], orient[1][2], orient[1][3],
|
||||||
@@ -120,17 +127,14 @@ types = {
|
|||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
local x, y, z,
|
local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = roblox:GetComponents()
|
||||||
r00, r01, r02,
|
|
||||||
r10, r11, r12,
|
|
||||||
r20, r21, r22 = roblox:GetComponents()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position = {x, y, z},
|
position = { x, y, z },
|
||||||
orientation = {
|
orientation = {
|
||||||
{r00, r01, r02},
|
{ r00, r01, r02 },
|
||||||
{r10, r11, r12},
|
{ r10, r11, r12 },
|
||||||
{r20, r21, r22},
|
{ r20, r21, r22 },
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
end,
|
end,
|
||||||
@@ -140,7 +144,7 @@ types = {
|
|||||||
fromPod = unpackDecoder(Color3.new),
|
fromPod = unpackDecoder(Color3.new),
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
return {roblox.r, roblox.g, roblox.b}
|
return { roblox.r, roblox.g, roblox.b }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -161,10 +165,7 @@ types = {
|
|||||||
local keypoints = {}
|
local keypoints = {}
|
||||||
|
|
||||||
for index, keypoint in ipairs(pod.keypoints) do
|
for index, keypoint in ipairs(pod.keypoints) do
|
||||||
keypoints[index] = ColorSequenceKeypoint.new(
|
keypoints[index] = ColorSequenceKeypoint.new(keypoint.time, types.Color3.fromPod(keypoint.color))
|
||||||
keypoint.time,
|
|
||||||
types.Color3.fromPod(keypoint.color)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
return ColorSequence.new(keypoints)
|
return ColorSequence.new(keypoints)
|
||||||
@@ -187,6 +188,38 @@ types = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Content = {
|
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,
|
fromPod = identity,
|
||||||
toPod = identity,
|
toPod = identity,
|
||||||
},
|
},
|
||||||
@@ -204,6 +237,19 @@ types = {
|
|||||||
end,
|
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 = {
|
Faces = {
|
||||||
fromPod = function(pod)
|
fromPod = function(pod)
|
||||||
local faces = {}
|
local faces = {}
|
||||||
@@ -238,6 +284,23 @@ types = {
|
|||||||
toPod = serializeFloat,
|
toPod = serializeFloat,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
Font = {
|
||||||
|
fromPod = function(pod)
|
||||||
|
return Font.new(
|
||||||
|
pod.family,
|
||||||
|
if pod.weight ~= nil then Enum.FontWeight[pod.weight] else nil,
|
||||||
|
if pod.style ~= nil then Enum.FontStyle[pod.style] else nil
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
toPod = function(roblox)
|
||||||
|
return {
|
||||||
|
family = roblox.Family,
|
||||||
|
weight = roblox.Weight.Name,
|
||||||
|
style = roblox.Style.Name,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
|
||||||
Int32 = {
|
Int32 = {
|
||||||
fromPod = identity,
|
fromPod = identity,
|
||||||
toPod = identity,
|
toPod = identity,
|
||||||
@@ -248,11 +311,32 @@ types = {
|
|||||||
toPod = identity,
|
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 = {
|
NumberRange = {
|
||||||
fromPod = unpackDecoder(NumberRange.new),
|
fromPod = unpackDecoder(NumberRange.new),
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
return {roblox.Min, roblox.Max}
|
return { roblox.Min, roblox.Max }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -261,11 +345,12 @@ types = {
|
|||||||
local keypoints = {}
|
local keypoints = {}
|
||||||
|
|
||||||
for index, keypoint in ipairs(pod.keypoints) do
|
for index, keypoint in ipairs(pod.keypoints) do
|
||||||
keypoints[index] = NumberSequenceKeypoint.new(
|
-- TODO: Add a test for NaN or Infinity values and envelopes
|
||||||
keypoint.time,
|
-- Right now it isn't possible because it'd fail the roundtrip.
|
||||||
keypoint.value,
|
-- It's more important that it works right now, though.
|
||||||
keypoint.envelope
|
local value = keypoint.value or 0
|
||||||
)
|
local envelope = keypoint.envelope or 0
|
||||||
|
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, value, envelope)
|
||||||
end
|
end
|
||||||
|
|
||||||
return NumberSequence.new(keypoints)
|
return NumberSequence.new(keypoints)
|
||||||
@@ -293,13 +378,26 @@ types = {
|
|||||||
if pod == "Default" then
|
if pod == "Default" then
|
||||||
return nil
|
return nil
|
||||||
else
|
else
|
||||||
return PhysicalProperties.new(
|
-- Passing `nil` instead of not passing anything gives
|
||||||
pod.density,
|
-- different results, so we have to branch here.
|
||||||
pod.friction,
|
if pod.acousticAbsorption then
|
||||||
pod.elasticity,
|
return (PhysicalProperties.new :: any)(
|
||||||
pod.frictionWeight,
|
pod.density,
|
||||||
pod.elasticityWeight
|
pod.friction,
|
||||||
)
|
pod.elasticity,
|
||||||
|
pod.frictionWeight,
|
||||||
|
pod.elasticityWeight,
|
||||||
|
pod.acousticAbsorption
|
||||||
|
)
|
||||||
|
else
|
||||||
|
return PhysicalProperties.new(
|
||||||
|
pod.density,
|
||||||
|
pod.friction,
|
||||||
|
pod.elasticity,
|
||||||
|
pod.frictionWeight,
|
||||||
|
pod.elasticityWeight
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
|
|
||||||
@@ -313,6 +411,7 @@ types = {
|
|||||||
elasticity = roblox.Elasticity,
|
elasticity = roblox.Elasticity,
|
||||||
frictionWeight = roblox.FrictionWeight,
|
frictionWeight = roblox.FrictionWeight,
|
||||||
elasticityWeight = roblox.ElasticityWeight,
|
elasticityWeight = roblox.ElasticityWeight,
|
||||||
|
acousticAbsorption = roblox.AcousticAbsorption,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
@@ -320,10 +419,7 @@ types = {
|
|||||||
|
|
||||||
Ray = {
|
Ray = {
|
||||||
fromPod = function(pod)
|
fromPod = function(pod)
|
||||||
return Ray.new(
|
return Ray.new(types.Vector3.fromPod(pod.origin), types.Vector3.fromPod(pod.direction))
|
||||||
types.Vector3.fromPod(pod.origin),
|
|
||||||
types.Vector3.fromPod(pod.direction)
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
@@ -336,10 +432,7 @@ types = {
|
|||||||
|
|
||||||
Rect = {
|
Rect = {
|
||||||
fromPod = function(pod)
|
fromPod = function(pod)
|
||||||
return Rect.new(
|
return Rect.new(types.Vector2.fromPod(pod[1]), types.Vector2.fromPod(pod[2]))
|
||||||
types.Vector2.fromPod(pod[1]),
|
|
||||||
types.Vector2.fromPod(pod[2])
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
@@ -351,31 +444,28 @@ types = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
Ref = {
|
Ref = {
|
||||||
fromPod = function(_pod)
|
fromPod = function(_)
|
||||||
error("Ref cannot be decoded on its own")
|
error("Ref cannot be decoded on its own")
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(_roblox)
|
toPod = function(_)
|
||||||
error("Ref can not be encoded on its own")
|
error("Ref can not be encoded on its own")
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
Region3 = {
|
Region3 = {
|
||||||
fromPod = function(pod)
|
fromPod = function(_)
|
||||||
error("Region3 is not implemented")
|
error("Region3 is not implemented")
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(_)
|
||||||
error("Region3 is not implemented")
|
error("Region3 is not implemented")
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
Region3int16 = {
|
Region3int16 = {
|
||||||
fromPod = function(pod)
|
fromPod = function(pod)
|
||||||
return Region3int16.new(
|
return Region3int16.new(types.Vector3int16.fromPod(pod[1]), types.Vector3int16.fromPod(pod[2]))
|
||||||
types.Vector3int16.fromPod(pod[1]),
|
|
||||||
types.Vector3int16.fromPod(pod[2])
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
@@ -387,11 +477,11 @@ types = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
SharedString = {
|
SharedString = {
|
||||||
fromPod = function(pod)
|
fromPod = function(_pod)
|
||||||
error("SharedString is not supported")
|
error("SharedString is not supported")
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(_roblox)
|
||||||
error("SharedString is not supported")
|
error("SharedString is not supported")
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -405,16 +495,13 @@ types = {
|
|||||||
fromPod = unpackDecoder(UDim.new),
|
fromPod = unpackDecoder(UDim.new),
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
return {roblox.Scale, roblox.Offset}
|
return { roblox.Scale, roblox.Offset }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
UDim2 = {
|
UDim2 = {
|
||||||
fromPod = function(pod)
|
fromPod = function(pod)
|
||||||
return UDim2.new(
|
return UDim2.new(types.UDim.fromPod(pod[1]), types.UDim.fromPod(pod[2]))
|
||||||
types.UDim.fromPod(pod[1]),
|
|
||||||
types.UDim.fromPod(pod[2])
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
@@ -445,7 +532,7 @@ types = {
|
|||||||
fromPod = unpackDecoder(Vector2int16.new),
|
fromPod = unpackDecoder(Vector2int16.new),
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
return {roblox.X, roblox.Y}
|
return { roblox.X, roblox.Y }
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -465,14 +552,37 @@ types = {
|
|||||||
fromPod = unpackDecoder(Vector3int16.new),
|
fromPod = unpackDecoder(Vector3int16.new),
|
||||||
|
|
||||||
toPod = function(roblox)
|
toPod = function(roblox)
|
||||||
return {roblox.X, roblox.Y, roblox.Z}
|
return { roblox.X, roblox.Y, roblox.Z }
|
||||||
end,
|
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)
|
function EncodedValue.decode(encodedValue)
|
||||||
local ty, value = next(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]
|
local typeImpl = types[ty]
|
||||||
if typeImpl == nil then
|
if typeImpl == nil then
|
||||||
return false, "Couldn't decode value " .. tostring(ty)
|
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",
|
UnknownProperty = "UnknownProperty",
|
||||||
PropertyNotReadable = "PropertyNotReadable",
|
PropertyNotReadable = "PropertyNotReadable",
|
||||||
PropertyNotWritable = "PropertyNotWritable",
|
PropertyNotWritable = "PropertyNotWritable",
|
||||||
|
CannotParseBinaryString = "CannotParseBinaryString",
|
||||||
Roblox = "Roblox",
|
Roblox = "Roblox",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ function PropertyDescriptor:read(instance)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if self.scriptability == "Custom" then
|
if self.scriptability == "Custom" then
|
||||||
|
if customProperties[self.className] == nil then
|
||||||
|
local fullName = ("%s.%s"):format(instance.className, self.name)
|
||||||
|
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
|
||||||
|
end
|
||||||
|
|
||||||
local interface = customProperties[self.className][self.name]
|
local interface = customProperties[self.className][self.name]
|
||||||
|
|
||||||
return interface.read(instance, self.name)
|
return interface.read(instance, self.name)
|
||||||
@@ -79,6 +84,11 @@ function PropertyDescriptor:write(instance, value)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if self.scriptability == "Custom" then
|
if self.scriptability == "Custom" then
|
||||||
|
if customProperties[self.className] == nil then
|
||||||
|
local fullName = ("%s.%s"):format(instance.className, self.name)
|
||||||
|
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
|
||||||
|
end
|
||||||
|
|
||||||
local interface = customProperties[self.className][self.name]
|
local interface = customProperties[self.className][self.name]
|
||||||
|
|
||||||
return interface.write(instance, self.name, value)
|
return interface.write(instance, self.name, value)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
0.0
|
0.0
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"TestEnumItem": {
|
||||||
|
"EnumItem": {
|
||||||
|
"type": "Material",
|
||||||
|
"value": 256
|
||||||
|
}
|
||||||
|
},
|
||||||
"TestNumber": {
|
"TestNumber": {
|
||||||
"Float64": 1337.0
|
"Float64": 1337.0
|
||||||
},
|
},
|
||||||
@@ -170,9 +176,23 @@
|
|||||||
},
|
},
|
||||||
"ty": "ColorSequence"
|
"ty": "ColorSequence"
|
||||||
},
|
},
|
||||||
"Content": {
|
"ContentId": {
|
||||||
"value": {
|
"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"
|
"ty": "Content"
|
||||||
},
|
},
|
||||||
@@ -182,6 +202,15 @@
|
|||||||
},
|
},
|
||||||
"ty": "Enum"
|
"ty": "Enum"
|
||||||
},
|
},
|
||||||
|
"EnumItem": {
|
||||||
|
"value": {
|
||||||
|
"EnumItem": {
|
||||||
|
"type": "Material",
|
||||||
|
"value": 256
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ty": "EnumItem"
|
||||||
|
},
|
||||||
"Faces": {
|
"Faces": {
|
||||||
"value": {
|
"value": {
|
||||||
"Faces": [
|
"Faces": [
|
||||||
@@ -207,6 +236,17 @@
|
|||||||
},
|
},
|
||||||
"ty": "Float64"
|
"ty": "Float64"
|
||||||
},
|
},
|
||||||
|
"Font": {
|
||||||
|
"value": {
|
||||||
|
"Font": {
|
||||||
|
"family": "rbxasset://fonts/families/SourceSansPro.json",
|
||||||
|
"weight": "Regular",
|
||||||
|
"style": "Normal",
|
||||||
|
"cachedFaceId": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ty": "Font"
|
||||||
|
},
|
||||||
"Int32": {
|
"Int32": {
|
||||||
"value": {
|
"value": {
|
||||||
"Int32": 6014
|
"Int32": 6014
|
||||||
@@ -219,6 +259,118 @@
|
|||||||
},
|
},
|
||||||
"ty": "Int64"
|
"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": {
|
"NumberRange": {
|
||||||
"value": {
|
"value": {
|
||||||
"NumberRange": [
|
"NumberRange": [
|
||||||
@@ -247,6 +399,41 @@
|
|||||||
},
|
},
|
||||||
"ty": "NumberSequence"
|
"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": {
|
"PhysicalProperties-Custom": {
|
||||||
"value": {
|
"value": {
|
||||||
"PhysicalProperties": {
|
"PhysicalProperties": {
|
||||||
@@ -254,7 +441,8 @@
|
|||||||
"friction": 1.0,
|
"friction": 1.0,
|
||||||
"elasticity": 0.0,
|
"elasticity": 0.0,
|
||||||
"frictionWeight": 50.0,
|
"frictionWeight": 50.0,
|
||||||
"elasticityWeight": 25.0
|
"elasticityWeight": 25.0,
|
||||||
|
"acousticAbsorption": 0.15625
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ty": "PhysicalProperties"
|
"ty": "PhysicalProperties"
|
||||||
|
|||||||
@@ -1,139 +1,10 @@
|
|||||||
-- Thanks to Tiffany352 for this base64 implementation!
|
local EncodingService = game:GetService("EncodingService")
|
||||||
|
|
||||||
local floor = math.floor
|
|
||||||
local char = string.char
|
|
||||||
|
|
||||||
local function encodeBase64(str)
|
|
||||||
local out = {}
|
|
||||||
local nOut = 0
|
|
||||||
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
||||||
local strLen = #str
|
|
||||||
|
|
||||||
-- 3 octets become 4 hextets
|
|
||||||
for i = 1, strLen - 2, 3 do
|
|
||||||
local b1, b2, b3 = str:byte(i, i + 3)
|
|
||||||
local word = b3 + b2 * 256 + b1 * 256 * 256
|
|
||||||
|
|
||||||
local h4 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h3 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h2 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h1 = word % 64 + 1
|
|
||||||
|
|
||||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
|
||||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
|
||||||
out[nOut + 3] = alphabet:sub(h3, h3)
|
|
||||||
out[nOut + 4] = alphabet:sub(h4, h4)
|
|
||||||
nOut = nOut + 4
|
|
||||||
end
|
|
||||||
|
|
||||||
local remainder = strLen % 3
|
|
||||||
|
|
||||||
if remainder == 2 then
|
|
||||||
-- 16 input bits -> 3 hextets (2 full, 1 partial)
|
|
||||||
local b1, b2 = str:byte(-2, -1)
|
|
||||||
-- partial is 4 bits long, leaving 2 bits of zero padding ->
|
|
||||||
-- offset = 4
|
|
||||||
local word = b2 * 4 + b1 * 4 * 256
|
|
||||||
|
|
||||||
local h3 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h2 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h1 = word % 64 + 1
|
|
||||||
|
|
||||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
|
||||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
|
||||||
out[nOut + 3] = alphabet:sub(h3, h3)
|
|
||||||
out[nOut + 4] = "="
|
|
||||||
elseif remainder == 1 then
|
|
||||||
-- 8 input bits -> 2 hextets (2 full, 1 partial)
|
|
||||||
local b1 = str:byte(-1, -1)
|
|
||||||
-- partial is 2 bits long, leaving 4 bits of zero padding ->
|
|
||||||
-- offset = 16
|
|
||||||
local word = b1 * 16
|
|
||||||
|
|
||||||
local h2 = word % 64 + 1
|
|
||||||
word = floor(word / 64)
|
|
||||||
local h1 = word % 64 + 1
|
|
||||||
|
|
||||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
|
||||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
|
||||||
out[nOut + 3] = "="
|
|
||||||
out[nOut + 4] = "="
|
|
||||||
end
|
|
||||||
-- if the remainder is 0, then no work is needed
|
|
||||||
|
|
||||||
return table.concat(out, "")
|
|
||||||
end
|
|
||||||
|
|
||||||
local function decodeBase64(str)
|
|
||||||
local out = {}
|
|
||||||
local nOut = 0
|
|
||||||
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
||||||
local strLen = #str
|
|
||||||
local acc = 0
|
|
||||||
local nAcc = 0
|
|
||||||
|
|
||||||
local alphabetLut = {}
|
|
||||||
for i = 1, #alphabet do
|
|
||||||
alphabetLut[alphabet:sub(i, i)] = i - 1
|
|
||||||
end
|
|
||||||
|
|
||||||
-- 4 hextets become 3 octets
|
|
||||||
for i = 1, strLen do
|
|
||||||
local ch = str:sub(i, i)
|
|
||||||
local byte = alphabetLut[ch]
|
|
||||||
if byte then
|
|
||||||
acc = acc * 64 + byte
|
|
||||||
nAcc = nAcc + 1
|
|
||||||
end
|
|
||||||
|
|
||||||
if nAcc == 4 then
|
|
||||||
local b3 = acc % 256
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
local b2 = acc % 256
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
local b1 = acc % 256
|
|
||||||
|
|
||||||
out[nOut + 1] = char(b1)
|
|
||||||
out[nOut + 2] = char(b2)
|
|
||||||
out[nOut + 3] = char(b3)
|
|
||||||
nOut = nOut + 3
|
|
||||||
nAcc = 0
|
|
||||||
acc = 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if nAcc == 3 then
|
|
||||||
-- 3 hextets -> 16 bit output
|
|
||||||
acc = acc * 64
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
local b2 = acc % 256
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
local b1 = acc % 256
|
|
||||||
|
|
||||||
out[nOut + 1] = char(b1)
|
|
||||||
out[nOut + 2] = char(b2)
|
|
||||||
elseif nAcc == 2 then
|
|
||||||
-- 2 hextets -> 8 bit output
|
|
||||||
acc = acc * 64
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
acc = acc * 64
|
|
||||||
acc = floor(acc / 256)
|
|
||||||
local b1 = acc % 256
|
|
||||||
|
|
||||||
out[nOut + 1] = char(b1)
|
|
||||||
elseif nAcc == 1 then
|
|
||||||
error("Base64 has invalid length")
|
|
||||||
end
|
|
||||||
|
|
||||||
return table.concat(out, "")
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
decode = decodeBase64,
|
decode = function(input: string)
|
||||||
encode = encodeBase64,
|
return buffer.tostring(EncodingService:Base64Decode(buffer.fromstring(input)))
|
||||||
|
end,
|
||||||
|
encode = function(input: string)
|
||||||
|
return buffer.tostring(EncodingService:Base64Encode(buffer.fromstring(input)))
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
@@ -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 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.
|
-- Defines how to read and write properties that aren't directly scriptable.
|
||||||
--
|
--
|
||||||
@@ -10,19 +53,45 @@ return {
|
|||||||
return true, instance:GetAttributes()
|
return true, instance:GetAttributes()
|
||||||
end,
|
end,
|
||||||
write = function(instance, _, value)
|
write = function(instance, _, value)
|
||||||
local existing = instance:GetAttributes()
|
if typeof(value) ~= "table" then
|
||||||
|
return false, Error.new(Error.Kind.CannotParseBinaryString)
|
||||||
for key, attr in pairs(value) do
|
|
||||||
instance:SetAttribute(key, attr)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
for key in pairs(existing) do
|
local existing = instance:GetAttributes()
|
||||||
if value[key] == nil then
|
local didAllWritesSucceed = true
|
||||||
instance:SetAttribute(key, nil)
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
return true
|
return didAllWritesSucceed
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
Tags = {
|
Tags = {
|
||||||
@@ -52,13 +121,117 @@ return {
|
|||||||
},
|
},
|
||||||
LocalizationTable = {
|
LocalizationTable = {
|
||||||
Contents = {
|
Contents = {
|
||||||
read = function(instance, key)
|
read = function(instance, _)
|
||||||
return true, instance:GetContents()
|
return true, instance:GetContents()
|
||||||
end,
|
end,
|
||||||
write = function(instance, key, value)
|
write = function(instance, _, value)
|
||||||
instance:SetContents(value)
|
instance:SetContents(value)
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Model = {
|
||||||
|
Scale = {
|
||||||
|
read = function(instance, _, _)
|
||||||
|
return true, instance:GetScale()
|
||||||
|
end,
|
||||||
|
write = function(instance, _, value)
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StyleRule = {
|
||||||
|
PropertiesSerialize = {
|
||||||
|
read = function(instance: StyleRule)
|
||||||
|
return true, instance:GetProperties()
|
||||||
|
end,
|
||||||
|
write = function(instance: StyleRule, _, value: { [any]: any })
|
||||||
|
if typeof(value) ~= "table" then
|
||||||
|
return false, Error.new(Error.Kind.CannotParseBinaryString)
|
||||||
|
end
|
||||||
|
|
||||||
|
local existing = instance:GetProperties()
|
||||||
|
|
||||||
|
for itemName, itemValue in pairs(value) do
|
||||||
|
instance:SetProperty(itemName, itemValue)
|
||||||
|
end
|
||||||
|
|
||||||
|
for existingItemName in pairs(existing) do
|
||||||
|
if value[existingItemName] == nil then
|
||||||
|
instance:SetProperty(existingItemName, nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ local function findCanonicalPropertyDescriptor(className, propertyName)
|
|||||||
return PropertyDescriptor.fromRaw(
|
return PropertyDescriptor.fromRaw(
|
||||||
currentClass.Properties[aliasData.AliasFor],
|
currentClass.Properties[aliasData.AliasFor],
|
||||||
currentClassName,
|
currentClassName,
|
||||||
aliasData.AliasFor)
|
aliasData.AliasFor
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
return nil
|
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,19 +1,11 @@
|
|||||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||||
|
|
||||||
local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
|
local TestEZ = require(ReplicatedStorage.Packages:WaitForChild("TestEZ", 10))
|
||||||
|
|
||||||
local Rojo = ReplicatedStorage.Rojo
|
local Rojo = ReplicatedStorage.Rojo
|
||||||
|
|
||||||
local DevSettings = require(Rojo.Plugin.DevSettings)
|
local Settings = require(Rojo.Plugin.Settings)
|
||||||
|
Settings:set("logLevel", "Trace")
|
||||||
local setDevSettings = not DevSettings:hasChangedValues()
|
Settings:set("typecheckingEnabled", true)
|
||||||
|
|
||||||
if setDevSettings then
|
|
||||||
DevSettings:createTestSettings()
|
|
||||||
end
|
|
||||||
|
|
||||||
require(Rojo.Plugin.runTests)(TestEZ)
|
require(Rojo.Plugin.runTests)(TestEZ)
|
||||||
|
|
||||||
if setDevSettings then
|
|
||||||
DevSettings:resetValues()
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
local Packages = script.Parent.Parent.Packages
|
local Packages = script.Parent.Parent.Packages
|
||||||
|
local HttpService = game:GetService("HttpService")
|
||||||
local Http = require(Packages.Http)
|
local Http = require(Packages.Http)
|
||||||
local Log = require(Packages.Log)
|
local Log = require(Packages.Log)
|
||||||
local Promise = require(Packages.Promise)
|
local Promise = require(Packages.Promise)
|
||||||
@@ -9,14 +10,9 @@ local Version = require(script.Parent.Version)
|
|||||||
|
|
||||||
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
|
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
|
||||||
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
|
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
|
||||||
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
|
local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket)
|
||||||
|
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
|
||||||
--[[
|
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
|
||||||
Returns a promise that will never resolve nor reject.
|
|
||||||
]]
|
|
||||||
local function hangingPromise()
|
|
||||||
return Promise.new(function() end)
|
|
||||||
end
|
|
||||||
|
|
||||||
local function rejectFailedRequests(response)
|
local function rejectFailedRequests(response)
|
||||||
if response.code >= 400 then
|
if response.code >= 400 then
|
||||||
@@ -31,15 +27,17 @@ end
|
|||||||
local function rejectWrongProtocolVersion(infoResponseBody)
|
local function rejectWrongProtocolVersion(infoResponseBody)
|
||||||
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
|
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
|
||||||
local message = (
|
local message = (
|
||||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
"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!" ..
|
.. "\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." ..
|
.. "\n\nYour client is version %s, with protocol version %s. It expects server version %s."
|
||||||
"\nYour server is version %s, with protocol version %s." ..
|
.. "\nYour server is version %s, with protocol version %s."
|
||||||
"\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
.. "\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
||||||
):format(
|
):format(
|
||||||
Version.display(Config.version), Config.protocolVersion,
|
Version.display(Config.version),
|
||||||
|
Config.protocolVersion,
|
||||||
Config.expectedServerVersionString,
|
Config.expectedServerVersionString,
|
||||||
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
|
infoResponseBody.serverVersion,
|
||||||
|
infoResponseBody.protocolVersion
|
||||||
)
|
)
|
||||||
|
|
||||||
return Promise.reject(message)
|
return Promise.reject(message)
|
||||||
@@ -50,14 +48,7 @@ end
|
|||||||
|
|
||||||
local function rejectWrongPlaceId(infoResponseBody)
|
local function rejectWrongPlaceId(infoResponseBody)
|
||||||
if infoResponseBody.expectedPlaceIds ~= nil then
|
if infoResponseBody.expectedPlaceIds ~= nil then
|
||||||
local foundId = false
|
local foundId = table.find(infoResponseBody.expectedPlaceIds, game.PlaceId)
|
||||||
|
|
||||||
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
|
|
||||||
if id == game.PlaceId then
|
|
||||||
foundId = true
|
|
||||||
break
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if not foundId then
|
if not foundId then
|
||||||
local idList = {}
|
local idList = {}
|
||||||
@@ -66,14 +57,31 @@ local function rejectWrongPlaceId(infoResponseBody)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local message = (
|
local message = (
|
||||||
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
|
"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:" ..
|
.. "\nYour place ID is %u, but needs to be one of these:"
|
||||||
"\n%s" ..
|
.. "\n%s"
|
||||||
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
.. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
||||||
):format(
|
):format(game.PlaceId, table.concat(idList, "\n"))
|
||||||
tostring(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)
|
return Promise.reject(message)
|
||||||
end
|
end
|
||||||
@@ -92,7 +100,9 @@ function ApiContext.new(baseUrl)
|
|||||||
__baseUrl = baseUrl,
|
__baseUrl = baseUrl,
|
||||||
__sessionId = nil,
|
__sessionId = nil,
|
||||||
__messageCursor = -1,
|
__messageCursor = -1,
|
||||||
|
__wsClient = nil,
|
||||||
__connected = true,
|
__connected = true,
|
||||||
|
__activeRequests = {},
|
||||||
}
|
}
|
||||||
|
|
||||||
return setmetatable(self, ApiContext)
|
return setmetatable(self, ApiContext)
|
||||||
@@ -113,6 +123,17 @@ end
|
|||||||
|
|
||||||
function ApiContext:disconnect()
|
function ApiContext:disconnect()
|
||||||
self.__connected = false
|
self.__connected = false
|
||||||
|
for request in self.__activeRequests do
|
||||||
|
Log.trace("Cancelling request {}", request)
|
||||||
|
request:cancel()
|
||||||
|
end
|
||||||
|
self.__activeRequests = {}
|
||||||
|
|
||||||
|
if self.__wsClient then
|
||||||
|
Log.trace("Closing WebSocket client")
|
||||||
|
self.__wsClient:Close()
|
||||||
|
end
|
||||||
|
self.__wsClient = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function ApiContext:setMessageCursor(index)
|
function ApiContext:setMessageCursor(index)
|
||||||
@@ -142,18 +163,15 @@ end
|
|||||||
function ApiContext:read(ids)
|
function ApiContext:read(ids)
|
||||||
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
||||||
|
|
||||||
return Http.get(url)
|
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||||
:andThen(rejectFailedRequests)
|
if body.sessionId ~= self.__sessionId then
|
||||||
:andThen(Http.Response.json)
|
return Promise.reject("Server changed ID")
|
||||||
:andThen(function(body)
|
end
|
||||||
if body.sessionId ~= self.__sessionId then
|
|
||||||
return Promise.reject("Server changed ID")
|
|
||||||
end
|
|
||||||
|
|
||||||
assert(validateApiRead(body))
|
assert(validateApiRead(body))
|
||||||
|
|
||||||
return body
|
return body
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ApiContext:write(patch)
|
function ApiContext:write(patch)
|
||||||
@@ -190,62 +208,120 @@ function ApiContext:write(patch)
|
|||||||
|
|
||||||
body = Http.jsonEncode(body)
|
body = Http.jsonEncode(body)
|
||||||
|
|
||||||
return Http.post(url, body)
|
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
|
||||||
:andThen(rejectFailedRequests)
|
Log.info("Write response: {:?}", responseBody)
|
||||||
:andThen(Http.Response.json)
|
|
||||||
:andThen(function(body)
|
|
||||||
Log.info("Write response: {:?}", body)
|
|
||||||
|
|
||||||
return body
|
return responseBody
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ApiContext:retrieveMessages()
|
function ApiContext:connectWebSocket(packetHandlers)
|
||||||
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
|
local url = ("%s/api/socket/%s"):format(self.__baseUrl, self.__messageCursor)
|
||||||
|
-- Convert HTTP/HTTPS URL to WS/WSS
|
||||||
|
url = url:gsub("^http://", "ws://"):gsub("^https://", "wss://")
|
||||||
|
|
||||||
local function sendRequest()
|
return Promise.new(function(resolve, reject)
|
||||||
return Http.get(url)
|
local success, wsClient =
|
||||||
:catch(function(err)
|
pcall(HttpService.CreateWebStreamClient, HttpService, Enum.WebStreamClientType.WebSocket, {
|
||||||
if err.type == Http.Error.Kind.Timeout then
|
Url = url,
|
||||||
if self.__connected then
|
})
|
||||||
return sendRequest()
|
if not success then
|
||||||
else
|
reject("Failed to create WebSocket client: " .. tostring(wsClient))
|
||||||
return hangingPromise()
|
return
|
||||||
end
|
end
|
||||||
end
|
self.__wsClient = wsClient
|
||||||
|
|
||||||
return Promise.reject(err)
|
local closed, errored, received
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
return sendRequest()
|
received = self.__wsClient.MessageReceived:Connect(function(msg)
|
||||||
:andThen(rejectFailedRequests)
|
local data = Http.jsonDecode(msg)
|
||||||
:andThen(Http.Response.json)
|
if data.sessionId ~= self.__sessionId then
|
||||||
:andThen(function(body)
|
Log.warn("Received message with wrong session ID; ignoring")
|
||||||
if body.sessionId ~= self.__sessionId then
|
return
|
||||||
return Promise.reject("Server changed ID")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
assert(validateApiSubscribe(body))
|
assert(validateApiSocketPacket(data))
|
||||||
|
|
||||||
self:setMessageCursor(body.messageCursor)
|
Log.trace("Received websocket packet: {:#?}", data)
|
||||||
|
|
||||||
return body.messages
|
local handler = packetHandlers[data.packetType]
|
||||||
|
if handler then
|
||||||
|
local ok, err = pcall(handler, data.body)
|
||||||
|
if not ok then
|
||||||
|
Log.error("Error in WebSocket packet handler for type '%s': %s", data.packetType, err)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Log.warn("No handler for WebSocket packet type '%s'", data.packetType)
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
closed = self.__wsClient.Closed:Connect(function()
|
||||||
|
closed:Disconnect()
|
||||||
|
errored:Disconnect()
|
||||||
|
received:Disconnect()
|
||||||
|
|
||||||
|
if self.__connected then
|
||||||
|
reject("WebSocket connection closed unexpectedly")
|
||||||
|
else
|
||||||
|
resolve()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
errored = self.__wsClient.Error:Connect(function(code, msg)
|
||||||
|
closed:Disconnect()
|
||||||
|
errored:Disconnect()
|
||||||
|
received:Disconnect()
|
||||||
|
|
||||||
|
reject("WebSocket error: " .. code .. " - " .. msg)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ApiContext:open(id)
|
function ApiContext:open(id)
|
||||||
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
||||||
|
|
||||||
return Http.post(url, "")
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ApiContext:serialize(ids: { string })
|
||||||
|
local url = ("%s/api/serialize"):format(self.__baseUrl)
|
||||||
|
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
|
||||||
|
|
||||||
|
return Http.post(url, request_body)
|
||||||
:andThen(rejectFailedRequests)
|
:andThen(rejectFailedRequests)
|
||||||
:andThen(Http.Response.json)
|
:andThen(Http.Response.json)
|
||||||
:andThen(function(body)
|
:andThen(function(response_body)
|
||||||
if body.sessionId ~= self.__sessionId then
|
if response_body.sessionId ~= self.__sessionId then
|
||||||
return Promise.reject("Server changed ID")
|
return Promise.reject("Server changed ID")
|
||||||
end
|
end
|
||||||
|
|
||||||
return nil
|
assert(validateApiSerialize(response_body))
|
||||||
|
|
||||||
|
return response_body
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function ApiContext:refPatch(ids: { string })
|
||||||
|
local url = ("%s/api/ref-patch"):format(self.__baseUrl)
|
||||||
|
local request_body = Http.jsonEncode({ sessionId = self.__sessionId, ids = ids })
|
||||||
|
|
||||||
|
return Http.post(url, request_body)
|
||||||
|
:andThen(rejectFailedRequests)
|
||||||
|
:andThen(Http.Response.json)
|
||||||
|
:andThen(function(response_body)
|
||||||
|
if response_body.sessionId ~= self.__sessionId then
|
||||||
|
return Promise.reject("Server changed ID")
|
||||||
|
end
|
||||||
|
|
||||||
|
assert(validateApiRefPatch(response_body))
|
||||||
|
|
||||||
|
return response_body
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ local function BorderedContainer(props)
|
|||||||
layoutOrder = props.layoutOrder,
|
layoutOrder = props.layoutOrder,
|
||||||
}, {
|
}, {
|
||||||
Content = e("Frame", {
|
Content = e("Frame", {
|
||||||
Size = UDim2.new(1, 0, 1, 0),
|
Size = UDim2.new(1, -2, 1, -2),
|
||||||
|
Position = UDim2.new(0, 1, 0, 1),
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
|
ZIndex = 2,
|
||||||
}, props[Roact.Children]),
|
}, props[Roact.Children]),
|
||||||
|
|
||||||
Border = e(SlicedImage, {
|
Border = e(SlicedImage, {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
|
|||||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||||
|
|
||||||
local SlicedImage = require(script.Parent.SlicedImage)
|
local SlicedImage = require(script.Parent.SlicedImage)
|
||||||
|
local Tooltip = require(script.Parent.Tooltip)
|
||||||
|
|
||||||
local e = Roact.createElement
|
local e = Roact.createElement
|
||||||
|
|
||||||
@@ -22,18 +23,16 @@ end
|
|||||||
|
|
||||||
function Checkbox:didUpdate(lastProps)
|
function Checkbox:didUpdate(lastProps)
|
||||||
if lastProps.active ~= self.props.active then
|
if lastProps.active ~= self.props.active then
|
||||||
self.motor:setGoal(
|
self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||||
Flipper.Spring.new(self.props.active and 1 or 0, {
|
frequency = 6,
|
||||||
frequency = 6,
|
dampingRatio = 1.1,
|
||||||
dampingRatio = 1.1,
|
}))
|
||||||
})
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Checkbox:render()
|
function Checkbox:render()
|
||||||
return Theme.with(function(theme)
|
return Theme.with(function(theme)
|
||||||
theme = theme.Checkbox
|
local checkboxTheme = theme.Checkbox
|
||||||
|
|
||||||
local activeTransparency = Roact.joinBindings({
|
local activeTransparency = Roact.joinBindings({
|
||||||
self.binding:map(function(value)
|
self.binding:map(function(value)
|
||||||
@@ -50,18 +49,29 @@ function Checkbox:render()
|
|||||||
ZIndex = self.props.zIndex,
|
ZIndex = self.props.zIndex,
|
||||||
BackgroundTransparency = 1,
|
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.locked
|
||||||
|
then (self.props.lockedTooltip or "(Cannot be changed right now)") .. "\n"
|
||||||
|
else "") .. (if self.props.active then "Enabled" else "Disabled"),
|
||||||
|
}),
|
||||||
|
|
||||||
Active = e(SlicedImage, {
|
Active = e(SlicedImage, {
|
||||||
slice = Assets.Slices.RoundedBackground,
|
slice = Assets.Slices.RoundedBackground,
|
||||||
color = theme.Active.BackgroundColor,
|
color = checkboxTheme.Active.BackgroundColor,
|
||||||
transparency = activeTransparency,
|
transparency = activeTransparency,
|
||||||
size = UDim2.new(1, 0, 1, 0),
|
size = UDim2.new(1, 0, 1, 0),
|
||||||
zIndex = 2,
|
zIndex = 2,
|
||||||
}, {
|
}, {
|
||||||
Icon = e("ImageLabel", {
|
Icon = e("ImageLabel", {
|
||||||
Image = Assets.Images.Checkbox.Active,
|
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
|
||||||
ImageColor3 = theme.Active.IconColor,
|
ImageColor3 = checkboxTheme.Active.IconColor,
|
||||||
ImageTransparency = activeTransparency,
|
ImageTransparency = activeTransparency,
|
||||||
|
|
||||||
Size = UDim2.new(0, 16, 0, 16),
|
Size = UDim2.new(0, 16, 0, 16),
|
||||||
@@ -74,13 +84,15 @@ function Checkbox:render()
|
|||||||
|
|
||||||
Inactive = e(SlicedImage, {
|
Inactive = e(SlicedImage, {
|
||||||
slice = Assets.Slices.RoundedBorder,
|
slice = Assets.Slices.RoundedBorder,
|
||||||
color = theme.Inactive.BorderColor,
|
color = checkboxTheme.Inactive.BorderColor,
|
||||||
transparency = self.props.transparency,
|
transparency = self.props.transparency,
|
||||||
size = UDim2.new(1, 0, 1, 0),
|
size = UDim2.new(1, 0, 1, 0),
|
||||||
}, {
|
}, {
|
||||||
Icon = e("ImageLabel", {
|
Icon = e("ImageLabel", {
|
||||||
Image = Assets.Images.Checkbox.Inactive,
|
Image = if self.props.locked
|
||||||
ImageColor3 = theme.Inactive.IconColor,
|
then Assets.Images.Checkbox.Locked
|
||||||
|
else Assets.Images.Checkbox.Inactive,
|
||||||
|
ImageColor3 = checkboxTheme.Inactive.IconColor,
|
||||||
ImageTransparency = self.props.transparency,
|
ImageTransparency = self.props.transparency,
|
||||||
|
|
||||||
Size = UDim2.new(0, 16, 0, 16),
|
Size = UDim2.new(0, 16, 0, 16),
|
||||||
|
|||||||
160
plugin/src/App/Components/ClassIcon.lua
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
local StudioService = game:GetService("StudioService")
|
||||||
|
local AssetService = game:GetService("AssetService")
|
||||||
|
|
||||||
|
type CachedImageInfo = {
|
||||||
|
pixels: buffer,
|
||||||
|
size: Vector2,
|
||||||
|
}
|
||||||
|
|
||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
|
||||||
|
local e = Roact.createElement
|
||||||
|
|
||||||
|
local EditableImage = require(Plugin.App.Components.EditableImage)
|
||||||
|
|
||||||
|
local imageCache: { [string]: CachedImageInfo } = {}
|
||||||
|
|
||||||
|
local function cloneBuffer(b: buffer): buffer
|
||||||
|
local newBuffer = buffer.create(buffer.len(b))
|
||||||
|
buffer.copy(newBuffer, 0, b)
|
||||||
|
return newBuffer
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getImageSizeAndPixels(image: string): (Vector2, buffer)
|
||||||
|
local cachedImage = imageCache[image]
|
||||||
|
|
||||||
|
if not cachedImage then
|
||||||
|
local editableImage = AssetService:CreateEditableImageAsync(Content.fromUri(image))
|
||||||
|
local size = editableImage.Size
|
||||||
|
local pixels = editableImage:ReadPixelsBuffer(Vector2.zero, size)
|
||||||
|
imageCache[image] = {
|
||||||
|
pixels = pixels,
|
||||||
|
size = size,
|
||||||
|
}
|
||||||
|
|
||||||
|
return size, cloneBuffer(pixels)
|
||||||
|
end
|
||||||
|
|
||||||
|
return cachedImage.size, cloneBuffer(cachedImage.pixels)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function getRecoloredClassIcon(className, color)
|
||||||
|
local iconProps = StudioService:GetClassIcon(className)
|
||||||
|
|
||||||
|
if iconProps and color then
|
||||||
|
--stylua: ignore
|
||||||
|
local success, editableImageSize, editableImagePixels = pcall(function(_iconProps: { [any]: any }, _color: Color3): (Vector2, buffer)
|
||||||
|
local size, pixels = getImageSizeAndPixels(_iconProps.Image)
|
||||||
|
local pixelsLen = buffer.len(pixels)
|
||||||
|
|
||||||
|
local minVal, maxVal = math.huge, -math.huge
|
||||||
|
|
||||||
|
for i = 0, pixelsLen, 4 do
|
||||||
|
if buffer.readu8(pixels, i + 3) == 0 then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local pixelVal = math.max(
|
||||||
|
buffer.readu8(pixels, i),
|
||||||
|
buffer.readu8(pixels, i + 1),
|
||||||
|
buffer.readu8(pixels, i + 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
minVal = math.min(minVal, pixelVal)
|
||||||
|
maxVal = math.max(maxVal, pixelVal)
|
||||||
|
end
|
||||||
|
|
||||||
|
local hue, sat, val = _color:ToHSV()
|
||||||
|
|
||||||
|
for i = 0, pixelsLen, 4 do
|
||||||
|
if buffer.readu8(pixels, i + 3) == 0 then
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
local gIndex = i + 1
|
||||||
|
local bIndex = i + 2
|
||||||
|
|
||||||
|
local pixelVal = math.max(
|
||||||
|
buffer.readu8(pixels, i),
|
||||||
|
buffer.readu8(pixels, gIndex),
|
||||||
|
buffer.readu8(pixels, bIndex)
|
||||||
|
)
|
||||||
|
local newVal = val
|
||||||
|
if minVal < maxVal then
|
||||||
|
-- Remap minVal - maxVal to val*0.9 - val
|
||||||
|
newVal = val * (0.9 + 0.1 * (pixelVal - minVal) / (maxVal - minVal))
|
||||||
|
end
|
||||||
|
|
||||||
|
local newPixelColor = Color3.fromHSV(hue, sat, newVal)
|
||||||
|
buffer.writeu8(pixels, i, newPixelColor.R)
|
||||||
|
buffer.writeu8(pixels, gIndex, newPixelColor.G)
|
||||||
|
buffer.writeu8(pixels, bIndex, newPixelColor.B)
|
||||||
|
end
|
||||||
|
return size, pixels
|
||||||
|
end, iconProps, color)
|
||||||
|
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
|
||||||
182
plugin/src/App/Components/Dropdown.lua
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
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
|
||||||
|
|
||||||
|
local Dropdown = Roact.Component:extend("Dropdown")
|
||||||
|
|
||||||
|
function Dropdown:init()
|
||||||
|
self.openMotor = Flipper.SingleMotor.new(0)
|
||||||
|
self.openBinding = bindingUtil.fromMotor(self.openMotor)
|
||||||
|
|
||||||
|
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||||
|
|
||||||
|
self:setState({
|
||||||
|
open = false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
local dropdownTheme = theme.Dropdown
|
||||||
|
|
||||||
|
local optionButtons = {}
|
||||||
|
local width = -1
|
||||||
|
for i, option in self.props.options do
|
||||||
|
local text = tostring(option or "")
|
||||||
|
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 = dropdownTheme.BackgroundColor,
|
||||||
|
TextTransparency = self.props.transparency,
|
||||||
|
BackgroundTransparency = self.props.transparency,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
TextColor3 = dropdownTheme.TextColor,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
|
||||||
|
[Roact.Event.Activated] = function()
|
||||||
|
if self.props.locked then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:setState({
|
||||||
|
open = false,
|
||||||
|
})
|
||||||
|
self.props.onClick(option)
|
||||||
|
end,
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingLeft = UDim.new(0, 6),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return e("ImageButton", {
|
||||||
|
Size = UDim2.new(0, width + 50, 0, 28),
|
||||||
|
Position = self.props.position,
|
||||||
|
AnchorPoint = self.props.anchorPoint,
|
||||||
|
LayoutOrder = self.props.layoutOrder,
|
||||||
|
ZIndex = self.props.zIndex,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
|
||||||
|
[Roact.Event.Activated] = function()
|
||||||
|
if self.props.locked then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:setState({
|
||||||
|
open = not self.state.open,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
}, {
|
||||||
|
Border = e(SlicedImage, {
|
||||||
|
slice = Assets.Slices.RoundedBorder,
|
||||||
|
color = dropdownTheme.BorderColor,
|
||||||
|
transparency = self.props.transparency,
|
||||||
|
size = UDim2.new(1, 0, 1, 0),
|
||||||
|
}, {
|
||||||
|
DropArrow = e("ImageLabel", {
|
||||||
|
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),
|
||||||
|
Position = UDim2.new(1, -6, 0.5, 0),
|
||||||
|
AnchorPoint = Vector2.new(1, 0.5),
|
||||||
|
Rotation = self.openBinding:map(function(a)
|
||||||
|
return a * 180
|
||||||
|
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,
|
||||||
|
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 = 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),
|
||||||
|
}, {
|
||||||
|
Border = e(SlicedImage, {
|
||||||
|
slice = Assets.Slices.RoundedBorder,
|
||||||
|
color = dropdownTheme.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,
|
||||||
|
}, {
|
||||||
|
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
|
||||||
|
|
||||||
|
return Dropdown
|
||||||
42
plugin/src/App/Components/EditableImage.lua
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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 :: EditableImage
|
||||||
|
|
||||||
|
if not image then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not self.props.pixels then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
image:WritePixelsBuffer(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
|
||||||
@@ -9,8 +9,70 @@ local Assets = require(Plugin.Assets)
|
|||||||
local Config = require(Plugin.Config)
|
local Config = require(Plugin.Config)
|
||||||
local Version = require(Plugin.Version)
|
local Version = require(Plugin.Version)
|
||||||
|
|
||||||
|
local Tooltip = require(Plugin.App.Components.Tooltip)
|
||||||
|
local SlicedImage = require(script.Parent.SlicedImage)
|
||||||
|
|
||||||
local e = Roact.createElement
|
local e = Roact.createElement
|
||||||
|
|
||||||
|
local function VersionIndicator(props)
|
||||||
|
local updateMessage = Version.getUpdateMessage()
|
||||||
|
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
return e("Frame", {
|
||||||
|
LayoutOrder = props.layoutOrder,
|
||||||
|
Size = UDim2.new(0, 0, 0, 25),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
AutomaticSize = Enum.AutomaticSize.X,
|
||||||
|
}, {
|
||||||
|
Border = if updateMessage
|
||||||
|
then e(SlicedImage, {
|
||||||
|
slice = Assets.Slices.RoundedBorder,
|
||||||
|
color = theme.Button.Bordered.Enabled.BorderColor,
|
||||||
|
transparency = props.transparency,
|
||||||
|
size = UDim2.fromScale(1, 1),
|
||||||
|
zIndex = 0,
|
||||||
|
}, {
|
||||||
|
Indicator = e("ImageLabel", {
|
||||||
|
Size = UDim2.new(0, 10, 0, 10),
|
||||||
|
ScaleType = Enum.ScaleType.Fit,
|
||||||
|
Image = Assets.Images.Circles[16],
|
||||||
|
ImageColor3 = theme.Header.LogoColor,
|
||||||
|
ImageTransparency = props.transparency,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Position = UDim2.new(1, 0, 0, 0),
|
||||||
|
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
|
||||||
|
Tip = if updateMessage
|
||||||
|
then e(Tooltip.Trigger, {
|
||||||
|
text = updateMessage,
|
||||||
|
delay = 0.1,
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
|
||||||
|
VersionText = e("TextLabel", {
|
||||||
|
Text = Version.display(Config.version),
|
||||||
|
FontFace = theme.Font.Thin,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = theme.Header.VersionColor,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextTransparency = props.transparency,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
|
||||||
|
Size = UDim2.new(0, 0, 1, 0),
|
||||||
|
AutomaticSize = Enum.AutomaticSize.X,
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingLeft = UDim.new(0, 6),
|
||||||
|
PaddingRight = UDim.new(0, 6),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
local function Header(props)
|
local function Header(props)
|
||||||
return Theme.with(function(theme)
|
return Theme.with(function(theme)
|
||||||
return e("Frame", {
|
return e("Frame", {
|
||||||
@@ -29,18 +91,9 @@ local function Header(props)
|
|||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Version = e("TextLabel", {
|
VersionIndicator = e(VersionIndicator, {
|
||||||
Text = Version.display(Config.version),
|
transparency = props.transparency,
|
||||||
Font = Enum.Font.Gotham,
|
layoutOrder = 2,
|
||||||
TextSize = 14,
|
|
||||||
TextColor3 = theme.Header.VersionColor,
|
|
||||||
TextXAlignment = Enum.TextXAlignment.Left,
|
|
||||||
TextTransparency = props.transparency,
|
|
||||||
|
|
||||||
Size = UDim2.new(1, 0, 0, 14),
|
|
||||||
|
|
||||||
LayoutOrder = 2,
|
|
||||||
BackgroundTransparency = 1,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
Layout = e("UIListLayout", {
|
Layout = e("UIListLayout", {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function IconButton:render()
|
|||||||
Position = self.props.position,
|
Position = self.props.position,
|
||||||
AnchorPoint = self.props.anchorPoint,
|
AnchorPoint = self.props.anchorPoint,
|
||||||
|
|
||||||
|
Visible = self.props.visible,
|
||||||
LayoutOrder = self.props.layoutOrder,
|
LayoutOrder = self.props.layoutOrder,
|
||||||
ZIndex = self.props.zIndex,
|
ZIndex = self.props.zIndex,
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
@@ -37,15 +38,11 @@ function IconButton:render()
|
|||||||
[Roact.Event.Activated] = self.props.onClick,
|
[Roact.Event.Activated] = self.props.onClick,
|
||||||
|
|
||||||
[Roact.Event.MouseEnter] = function()
|
[Roact.Event.MouseEnter] = function()
|
||||||
self.motor:setGoal(
|
self.motor:setGoal(Flipper.Spring.new(1, HOVER_SPRING_PROPS))
|
||||||
Flipper.Spring.new(1, HOVER_SPRING_PROPS)
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
|
|
||||||
[Roact.Event.MouseLeave] = function()
|
[Roact.Event.MouseLeave] = function()
|
||||||
self.motor:setGoal(
|
self.motor:setGoal(Flipper.Spring.new(0, HOVER_SPRING_PROPS))
|
||||||
Flipper.Spring.new(0, HOVER_SPRING_PROPS)
|
|
||||||
)
|
|
||||||
end,
|
end,
|
||||||
}, {
|
}, {
|
||||||
Icon = e("ImageLabel", {
|
Icon = e("ImageLabel", {
|
||||||
@@ -74,6 +71,8 @@ function IconButton:render()
|
|||||||
|
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
Children = Roact.createFragment(self.props[Roact.Children]),
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
local StudioService = game:GetService("StudioService")
|
||||||
|
|
||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
local Log = require(Packages.Log)
|
||||||
|
|
||||||
|
local Theme = require(Plugin.App.Theme)
|
||||||
|
local Assets = require(Plugin.Assets)
|
||||||
|
|
||||||
|
local TextButton = require(Plugin.App.Components.TextButton)
|
||||||
|
|
||||||
|
local e = Roact.createElement
|
||||||
|
|
||||||
|
local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotificationnNotification")
|
||||||
|
|
||||||
|
function FullscreenNotification:init()
|
||||||
|
self.transparency, self.setTransparency = Roact.createBinding(0)
|
||||||
|
self.lifetime = self.props.timeout
|
||||||
|
end
|
||||||
|
|
||||||
|
function FullscreenNotification:dismiss()
|
||||||
|
if self.props.onClose then
|
||||||
|
self.props.onClose()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function FullscreenNotification:didMount()
|
||||||
|
self.props.soundPlayer:play(Assets.Sounds.Notification)
|
||||||
|
|
||||||
|
self.timeout = task.spawn(function()
|
||||||
|
local clock = os.clock()
|
||||||
|
local seen = false
|
||||||
|
while task.wait(1 / 10) do
|
||||||
|
local now = os.clock()
|
||||||
|
local dt = now - clock
|
||||||
|
clock = now
|
||||||
|
|
||||||
|
if not seen then
|
||||||
|
seen = StudioService.ActiveScript == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if not seen then
|
||||||
|
-- Don't run down timer before being viewed
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
self.lifetime -= dt
|
||||||
|
if self.lifetime <= 0 then
|
||||||
|
self:dismiss()
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.timeout = nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function FullscreenNotification:willUnmount()
|
||||||
|
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
|
||||||
|
task.cancel(self.timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function FullscreenNotification:render()
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
local actionButtons = {}
|
||||||
|
if self.props.actions then
|
||||||
|
for key, action in self.props.actions do
|
||||||
|
actionButtons[key] = e(TextButton, {
|
||||||
|
text = action.text,
|
||||||
|
style = action.style,
|
||||||
|
onClick = function()
|
||||||
|
self:dismiss()
|
||||||
|
if action.onClick then
|
||||||
|
local success, err = pcall(action.onClick, self)
|
||||||
|
if not success then
|
||||||
|
Log.warn("Error in notification action: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
layoutOrder = -action.layoutOrder,
|
||||||
|
transparency = self.transparency,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return e("Frame", {
|
||||||
|
BackgroundColor3 = theme.BackgroundColor,
|
||||||
|
Size = UDim2.fromScale(1, 1),
|
||||||
|
ZIndex = self.props.layoutOrder,
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingLeft = UDim.new(0, 17),
|
||||||
|
PaddingRight = UDim.new(0, 15),
|
||||||
|
PaddingTop = UDim.new(0, 10),
|
||||||
|
PaddingBottom = UDim.new(0, 10),
|
||||||
|
}),
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
FillDirection = Enum.FillDirection.Vertical,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||||
|
Padding = UDim.new(0, 10),
|
||||||
|
}),
|
||||||
|
Logo = e("ImageLabel", {
|
||||||
|
ImageTransparency = self.transparency,
|
||||||
|
Image = Assets.Images.Logo,
|
||||||
|
ImageColor3 = theme.Header.LogoColor,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Size = UDim2.fromOffset(60, 27),
|
||||||
|
LayoutOrder = 1,
|
||||||
|
}),
|
||||||
|
Info = e("TextLabel", {
|
||||||
|
Text = self.props.text,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = theme.Notification.InfoColor,
|
||||||
|
TextTransparency = self.transparency,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Center,
|
||||||
|
TextYAlignment = Enum.TextYAlignment.Center,
|
||||||
|
TextWrapped = true,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
|
||||||
|
AutomaticSize = Enum.AutomaticSize.Y,
|
||||||
|
Size = UDim2.fromScale(0.4, 0),
|
||||||
|
LayoutOrder = 2,
|
||||||
|
}),
|
||||||
|
Actions = if self.props.actions
|
||||||
|
then e("Frame", {
|
||||||
|
Size = UDim2.new(1, -40, 0, 37),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
LayoutOrder = 3,
|
||||||
|
}, {
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
Padding = UDim.new(0, 5),
|
||||||
|
}),
|
||||||
|
Buttons = Roact.createFragment(actionButtons),
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return FullscreenNotification
|
||||||
204
plugin/src/App/Components/Notifications/Notification.lua
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
local StudioService = game:GetService("StudioService")
|
||||||
|
|
||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
local Flipper = require(Packages.Flipper)
|
||||||
|
local Log = require(Packages.Log)
|
||||||
|
|
||||||
|
local Theme = require(Plugin.App.Theme)
|
||||||
|
local Assets = require(Plugin.Assets)
|
||||||
|
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||||
|
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
|
||||||
|
|
||||||
|
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||||
|
local TextButton = require(Plugin.App.Components.TextButton)
|
||||||
|
|
||||||
|
local e = Roact.createElement
|
||||||
|
|
||||||
|
local Notification = Roact.Component:extend("Notification")
|
||||||
|
|
||||||
|
function Notification:init()
|
||||||
|
self.motor = Flipper.SingleMotor.new(0)
|
||||||
|
self.binding = bindingUtil.fromMotor(self.motor)
|
||||||
|
|
||||||
|
self.lifetime = self.props.timeout
|
||||||
|
|
||||||
|
self.motor:onStep(function(value)
|
||||||
|
if value <= 0 and self.props.onClose then
|
||||||
|
self.props.onClose()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Notification:dismiss()
|
||||||
|
self.motor:setGoal(Flipper.Spring.new(0, {
|
||||||
|
frequency = 5,
|
||||||
|
dampingRatio = 1,
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
|
||||||
|
function Notification:didMount()
|
||||||
|
self.motor:setGoal(Flipper.Spring.new(1, {
|
||||||
|
frequency = 3,
|
||||||
|
dampingRatio = 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
self.props.soundPlayer:play(Assets.Sounds.Notification)
|
||||||
|
|
||||||
|
self.timeout = task.spawn(function()
|
||||||
|
local clock = os.clock()
|
||||||
|
local seen = false
|
||||||
|
while task.wait(1 / 10) do
|
||||||
|
local now = os.clock()
|
||||||
|
local dt = now - clock
|
||||||
|
clock = now
|
||||||
|
|
||||||
|
if not seen then
|
||||||
|
seen = StudioService.ActiveScript == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if not seen then
|
||||||
|
-- Don't run down timer before being viewed
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
self.lifetime -= dt
|
||||||
|
if self.lifetime <= 0 then
|
||||||
|
self:dismiss()
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Notification:willUnmount()
|
||||||
|
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
|
||||||
|
task.cancel(self.timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Notification:render()
|
||||||
|
local transparency = self.binding:map(function(value)
|
||||||
|
return 1 - value
|
||||||
|
end)
|
||||||
|
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
local actionButtons = {}
|
||||||
|
local buttonsX = 0
|
||||||
|
if self.props.actions then
|
||||||
|
local count = 0
|
||||||
|
for key, action in self.props.actions do
|
||||||
|
actionButtons[key] = e(TextButton, {
|
||||||
|
text = action.text,
|
||||||
|
style = action.style,
|
||||||
|
onClick = function()
|
||||||
|
self:dismiss()
|
||||||
|
if action.onClick then
|
||||||
|
local success, err = pcall(action.onClick, self)
|
||||||
|
if not success then
|
||||||
|
Log.warn("Error in notification action: " .. tostring(err))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
layoutOrder = -action.layoutOrder,
|
||||||
|
transparency = transparency,
|
||||||
|
})
|
||||||
|
|
||||||
|
buttonsX += getTextBoundsAsync(action.text, theme.Font.Main, theme.TextSize.Large, math.huge).X + (theme.TextSize.Body * 2)
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
buttonsX += (count - 1) * 5
|
||||||
|
end
|
||||||
|
|
||||||
|
local paddingY, logoSize = 20, 32
|
||||||
|
local actionsY = if self.props.actions then 37 else 0
|
||||||
|
local textXSpace = math.max(250, buttonsX) + 35
|
||||||
|
local textBounds = getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Body, textXSpace)
|
||||||
|
local contentX = math.max(textBounds.X, buttonsX)
|
||||||
|
|
||||||
|
local size = self.binding:map(function(value)
|
||||||
|
return UDim2.fromOffset(
|
||||||
|
(35 + 40 + contentX) * value,
|
||||||
|
5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
return e("TextButton", {
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Size = size,
|
||||||
|
LayoutOrder = self.props.layoutOrder,
|
||||||
|
Text = "",
|
||||||
|
ClipsDescendants = true,
|
||||||
|
|
||||||
|
[Roact.Event.Activated] = function()
|
||||||
|
self:dismiss()
|
||||||
|
end,
|
||||||
|
}, {
|
||||||
|
e(BorderedContainer, {
|
||||||
|
transparency = transparency,
|
||||||
|
size = UDim2.fromScale(1, 1),
|
||||||
|
}, {
|
||||||
|
Contents = e("Frame", {
|
||||||
|
Size = UDim2.fromScale(1, 1),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
Logo = e("ImageLabel", {
|
||||||
|
ImageTransparency = transparency,
|
||||||
|
Image = Assets.Images.PluginButton,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Size = UDim2.fromOffset(logoSize, logoSize),
|
||||||
|
Position = UDim2.new(0, 0, 0, 0),
|
||||||
|
AnchorPoint = Vector2.new(0, 0),
|
||||||
|
}),
|
||||||
|
Info = e("TextLabel", {
|
||||||
|
Text = self.props.text,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = theme.Notification.InfoColor,
|
||||||
|
TextTransparency = transparency,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextYAlignment = Enum.TextYAlignment.Center,
|
||||||
|
TextWrapped = true,
|
||||||
|
|
||||||
|
Size = UDim2.new(0, textBounds.X, 1, -actionsY),
|
||||||
|
Position = UDim2.fromOffset(35, 0),
|
||||||
|
|
||||||
|
LayoutOrder = 1,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}),
|
||||||
|
Actions = if self.props.actions
|
||||||
|
then e("Frame", {
|
||||||
|
Size = UDim2.new(1, -40, 0, actionsY),
|
||||||
|
Position = UDim2.fromScale(1, 1),
|
||||||
|
AnchorPoint = Vector2.new(1, 1),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
Padding = UDim.new(0, 5),
|
||||||
|
}),
|
||||||
|
Buttons = Roact.createFragment(actionButtons),
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
}),
|
||||||
|
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingLeft = UDim.new(0, 17),
|
||||||
|
PaddingRight = UDim.new(0, 15),
|
||||||
|
PaddingTop = UDim.new(0, paddingY / 2),
|
||||||
|
PaddingBottom = UDim.new(0, paddingY / 2),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Notification
|
||||||
66
plugin/src/App/Components/Notifications/init.lua
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
|
||||||
|
local e = Roact.createElement
|
||||||
|
|
||||||
|
local Notification = require(script.Notification)
|
||||||
|
local FullscreenNotification = require(script.FullscreenNotification)
|
||||||
|
|
||||||
|
local Notifications = Roact.Component:extend("Notifications")
|
||||||
|
|
||||||
|
function Notifications:render()
|
||||||
|
local popupNotifs = {}
|
||||||
|
local fullscreenNotifs = {}
|
||||||
|
|
||||||
|
for id, notif in self.props.notifications do
|
||||||
|
local targetTable = if notif.isFullscreen then fullscreenNotifs else popupNotifs
|
||||||
|
local targetComponent = if notif.isFullscreen then FullscreenNotification else Notification
|
||||||
|
targetTable["NotifID_" .. id] = e(targetComponent, {
|
||||||
|
soundPlayer = self.props.soundPlayer,
|
||||||
|
text = notif.text,
|
||||||
|
timeout = notif.timeout,
|
||||||
|
actions = notif.actions,
|
||||||
|
layoutOrder = id,
|
||||||
|
onClose = function()
|
||||||
|
if notif.onClose then
|
||||||
|
notif.onClose()
|
||||||
|
end
|
||||||
|
self.props.onClose(id)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return e("Frame", {
|
||||||
|
Size = UDim2.fromScale(1, 1),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
Fullscreen = e("Frame", {
|
||||||
|
Size = UDim2.fromScale(1, 1),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
notifs = Roact.createFragment(fullscreenNotifs),
|
||||||
|
}),
|
||||||
|
Popups = e("Frame", {
|
||||||
|
Size = UDim2.fromScale(1, 1),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Bottom,
|
||||||
|
Padding = UDim.new(0, 5),
|
||||||
|
}),
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingTop = UDim.new(0, 5),
|
||||||
|
PaddingBottom = UDim.new(0, 5),
|
||||||
|
PaddingLeft = UDim.new(0, 5),
|
||||||
|
PaddingRight = UDim.new(0, 5),
|
||||||
|
}),
|
||||||
|
notifs = Roact.createFragment(popupNotifs),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return Notifications
|
||||||
281
plugin/src/App/Components/PatchVisualizer/ChangeList.lua
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
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()
|
||||||
|
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||||
|
end
|
||||||
|
|
||||||
|
function ChangeList:render()
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
local props = self.props
|
||||||
|
local changes = props.changes
|
||||||
|
|
||||||
|
-- Color alternating rows for readability
|
||||||
|
local rowTransparency = props.transparency:map(function(t)
|
||||||
|
return 0.93 + (0.07 * t)
|
||||||
|
end)
|
||||||
|
|
||||||
|
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, 24),
|
||||||
|
BackgroundTransparency = rowTransparency,
|
||||||
|
BackgroundColor3 = theme.Diff.Row,
|
||||||
|
LayoutOrder = 0,
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", pad),
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||||
|
}),
|
||||||
|
ColumnA = e("TextLabel", {
|
||||||
|
Text = tostring(headerRow[1]),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
ColumnB = e("TextLabel", {
|
||||||
|
Text = tostring(headerRow[2]),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
ColumnC = e("TextLabel", {
|
||||||
|
Text = tostring(headerRow[3]),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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 = 3,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
for row, values in changes do
|
||||||
|
if row == 1 then
|
||||||
|
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, 24),
|
||||||
|
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
|
||||||
|
BackgroundColor3 = theme.Diff.Row,
|
||||||
|
BorderSizePixel = 0,
|
||||||
|
LayoutOrder = row,
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", pad),
|
||||||
|
Layout = e("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Horizontal,
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||||
|
}),
|
||||||
|
ColumnA = e("TextLabel", {
|
||||||
|
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
Content = e(RowContent, {
|
||||||
|
values = values,
|
||||||
|
metadata = metadata,
|
||||||
|
transparency = props.transparency,
|
||||||
|
showStringDiff = props.showStringDiff,
|
||||||
|
showTableDiff = props.showTableDiff,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(
|
||||||
|
rows,
|
||||||
|
e("UIListLayout", {
|
||||||
|
FillDirection = Enum.FillDirection.Vertical,
|
||||||
|
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||||
|
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = Enum.VerticalAlignment.Top,
|
||||||
|
|
||||||
|
[Roact.Change.AbsoluteContentSize] = function(object)
|
||||||
|
self.setContentSize(object.AbsoluteContentSize)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return e("Frame", {
|
||||||
|
Size = UDim2.new(1, 0, 1, 0),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
}, {
|
||||||
|
Headers = headers,
|
||||||
|
Values = e(ScrollingFrame, {
|
||||||
|
size = UDim2.new(1, 0, 1, -24),
|
||||||
|
position = UDim2.new(0, 0, 0, 24),
|
||||||
|
contentSize = self.contentSize,
|
||||||
|
transparency = props.transparency,
|
||||||
|
}, rows),
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return ChangeList
|
||||||
126
plugin/src/App/Components/PatchVisualizer/DisplayValue.lua
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 e = Roact.createElement
|
||||||
|
|
||||||
|
local function DisplayValue(props)
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
local t = typeof(props.value)
|
||||||
|
if t == "Color3" then
|
||||||
|
-- Colors get a blot that shows the color
|
||||||
|
return Roact.createFragment({
|
||||||
|
Blot = e("Frame", {
|
||||||
|
BackgroundTransparency = props.transparency,
|
||||||
|
BackgroundColor3 = props.value,
|
||||||
|
Size = UDim2.new(0, 20, 0, 20),
|
||||||
|
Position = UDim2.new(0, 0, 0.5, 0),
|
||||||
|
AnchorPoint = Vector2.new(0, 0.5),
|
||||||
|
}, {
|
||||||
|
Corner = e("UICorner", {
|
||||||
|
CornerRadius = UDim.new(0, 4),
|
||||||
|
}),
|
||||||
|
Stroke = e("UIStroke", {
|
||||||
|
Color = theme.BorderedContainer.BorderColor,
|
||||||
|
Transparency = props.transparency,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
Label = e("TextLabel", {
|
||||||
|
Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = props.textColor,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextTransparency = props.transparency,
|
||||||
|
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||||
|
Size = UDim2.new(1, -25, 1, 0),
|
||||||
|
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
|
||||||
|
|
||||||
|
local meta = getmetatable(props.value)
|
||||||
|
if meta and meta.__tostring then
|
||||||
|
-- If the table has a tostring metamethod, use that
|
||||||
|
textRepresentation = tostring(props.value)
|
||||||
|
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
|
||||||
|
-- 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 .. '"'
|
||||||
|
end
|
||||||
|
if type(v) == "string" then
|
||||||
|
v = '"' .. v .. '"'
|
||||||
|
end
|
||||||
|
|
||||||
|
out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
|
||||||
|
end
|
||||||
|
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
|
||||||
|
end
|
||||||
|
|
||||||
|
return e("TextLabel", {
|
||||||
|
Text = textRepresentation,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = props.textColor,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextTransparency = props.transparency,
|
||||||
|
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||||
|
Size = UDim2.new(1, 0, 1, 0),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TODO: Maybe add visualizations to other datatypes?
|
||||||
|
-- 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 = textRepresentation,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
FontFace = theme.Font.Main,
|
||||||
|
TextSize = theme.TextSize.Body,
|
||||||
|
TextColor3 = props.textColor,
|
||||||
|
TextXAlignment = Enum.TextXAlignment.Left,
|
||||||
|
TextTransparency = props.transparency,
|
||||||
|
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||||
|
Size = UDim2.new(1, 0, 1, 0),
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return DisplayValue
|
||||||
282
plugin/src/App/Components/PatchVisualizer/DomLabel.lua
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
local SelectionService = game:GetService("Selection")
|
||||||
|
|
||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
local Flipper = require(Packages.Flipper)
|
||||||
|
|
||||||
|
local Assets = require(Plugin.Assets)
|
||||||
|
local Theme = require(Plugin.App.Theme)
|
||||||
|
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")
|
||||||
|
|
||||||
|
function Expansion:render()
|
||||||
|
local props = self.props
|
||||||
|
|
||||||
|
if not props.rendered then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return e("Frame", {
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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,
|
||||||
|
showStringDiff = props.showStringDiff,
|
||||||
|
showTableDiff = props.showTableDiff,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local DomLabel = Roact.Component:extend("DomLabel")
|
||||||
|
|
||||||
|
function DomLabel:init()
|
||||||
|
local initHeight = self.props.elementHeight:getValue()
|
||||||
|
self.expanded = initHeight > 24
|
||||||
|
|
||||||
|
self.motor = Flipper.SingleMotor.new(initHeight)
|
||||||
|
self.binding = bindingUtil.fromMotor(self.motor)
|
||||||
|
|
||||||
|
self:setState({
|
||||||
|
renderExpansion = self.expanded,
|
||||||
|
})
|
||||||
|
self.motor:onStep(function(value)
|
||||||
|
local renderExpansion = value > 24
|
||||||
|
|
||||||
|
self.props.setElementHeight(value)
|
||||||
|
if self.props.updateEvent then
|
||||||
|
self.props.updateEvent:Fire()
|
||||||
|
end
|
||||||
|
|
||||||
|
self:setState(function(state)
|
||||||
|
if state.renderExpansion == renderExpansion then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderExpansion = renderExpansion,
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
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 color = if props.isWarning
|
||||||
|
then theme.Diff.Warning
|
||||||
|
elseif props.patchType then theme.Diff.Background[props.patchType]
|
||||||
|
else theme.TextColor
|
||||||
|
|
||||||
|
local indent = (depth - 1) * 12 + 15
|
||||||
|
|
||||||
|
-- Line guides help indent depth remain readable
|
||||||
|
local lineGuides = {}
|
||||||
|
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", {
|
||||||
|
ClipsDescendants = true,
|
||||||
|
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),
|
||||||
|
}, {
|
||||||
|
Padding = e("UIPadding", {
|
||||||
|
PaddingLeft = UDim.new(0, 10),
|
||||||
|
PaddingRight = UDim.new(0, 10),
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
showStringDiff = props.showStringDiff,
|
||||||
|
showTableDiff = props.showTableDiff,
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
DiffIcon = if props.patchType
|
||||||
|
then e("ImageLabel", {
|
||||||
|
Image = Assets.Images.Diff[props.patchType],
|
||||||
|
ImageColor3 = color,
|
||||||
|
ImageTransparency = props.transparency,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
Size = UDim2.new(0, 14, 0, 14),
|
||||||
|
Position = UDim2.new(0, 0, 0, 12),
|
||||||
|
AnchorPoint = Vector2.new(0, 0.5),
|
||||||
|
})
|
||||||
|
else nil,
|
||||||
|
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 = (if props.isWarning then "⚠ " else "") .. props.name,
|
||||||
|
RichText = true,
|
||||||
|
BackgroundTransparency = 1,
|
||||||
|
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, 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),
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return DomLabel
|
||||||
152
plugin/src/App/Components/PatchVisualizer/init.lua
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
local Rojo = script:FindFirstAncestor("Rojo")
|
||||||
|
local Plugin = Rojo.Plugin
|
||||||
|
local Packages = Rojo.Packages
|
||||||
|
|
||||||
|
local Roact = require(Packages.Roact)
|
||||||
|
|
||||||
|
local PatchTree = require(Plugin.PatchTree)
|
||||||
|
local PatchSet = require(Plugin.PatchSet)
|
||||||
|
|
||||||
|
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 DomLabel = require(script.DomLabel)
|
||||||
|
|
||||||
|
local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
|
||||||
|
|
||||||
|
function PatchVisualizer:init()
|
||||||
|
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||||
|
|
||||||
|
self.updateEvent = Instance.new("BindableEvent")
|
||||||
|
end
|
||||||
|
|
||||||
|
function PatchVisualizer:willUnmount()
|
||||||
|
self.updateEvent:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
function PatchVisualizer:shouldUpdate(nextProps)
|
||||||
|
if self.props.patchTree ~= nextProps.patchTree then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local currentPatch, nextPatch = self.props.patch, nextProps.patch
|
||||||
|
if currentPatch ~= nil or nextPatch ~= nil then
|
||||||
|
return not PatchSet.isEqual(currentPatch, nextPatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function PatchVisualizer:render()
|
||||||
|
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, 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,
|
||||||
|
changeInfo = node.changeInfo,
|
||||||
|
changeList = node.changeList,
|
||||||
|
})
|
||||||
|
|
||||||
|
if isFinalChild then
|
||||||
|
depthsComplete[depth] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
patchTree:forEach(function(node, depth)
|
||||||
|
depthsComplete[depth] = false
|
||||||
|
for i = depth + 1, #depthsComplete do
|
||||||
|
depthsComplete[i] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
drawNode(node, depth)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Theme.with(function(theme)
|
||||||
|
return e(BorderedContainer, {
|
||||||
|
transparency = self.props.transparency,
|
||||||
|
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 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)
|
local function ScrollingFrame(props)
|
||||||
return Theme.with(function(theme)
|
return Theme.with(function(theme)
|
||||||
return e("ScrollingFrame", {
|
return e("ScrollingFrame", {
|
||||||
@@ -23,19 +29,31 @@ local function ScrollingFrame(props)
|
|||||||
BottomImage = Assets.Images.ScrollBar.Bottom,
|
BottomImage = Assets.Images.ScrollBar.Bottom,
|
||||||
|
|
||||||
ElasticBehavior = Enum.ElasticBehavior.Always,
|
ElasticBehavior = Enum.ElasticBehavior.Always,
|
||||||
ScrollingDirection = Enum.ScrollingDirection.Y,
|
ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y,
|
||||||
|
|
||||||
Size = props.size,
|
Size = props.size,
|
||||||
Position = props.position,
|
Position = props.position,
|
||||||
AnchorPoint = props.anchorPoint,
|
AnchorPoint = props.anchorPoint,
|
||||||
CanvasSize = props.contentSize:map(function(value)
|
CanvasSize = if props.contentSize
|
||||||
return UDim2.new(0, 0, 0, value.Y)
|
then props.contentSize:map(function(value)
|
||||||
end),
|
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,
|
BorderSizePixel = 0,
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
|
|
||||||
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize]
|
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize],
|
||||||
}, props[Roact.Children])
|
}, props[Roact.Children])
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ local function SlicedImage(props)
|
|||||||
Size = props.size,
|
Size = props.size,
|
||||||
Position = props.position,
|
Position = props.position,
|
||||||
AnchorPoint = props.anchorPoint,
|
AnchorPoint = props.anchorPoint,
|
||||||
|
AutomaticSize = props.automaticSize,
|
||||||
|
|
||||||
ZIndex = props.zIndex,
|
ZIndex = props.zIndex,
|
||||||
LayoutOrder = props.layoutOrder,
|
LayoutOrder = props.layoutOrder,
|
||||||
|
|||||||
734
plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua
Normal file
@@ -0,0 +1,734 @@
|
|||||||
|
--!strict
|
||||||
|
--[[
|
||||||
|
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._cleanupSemantic(diffs)
|
||||||
|
diffs = StringDiff._reorderAndMerge(diffs)
|
||||||
|
|
||||||
|
-- Remove any empty diffs
|
||||||
|
local cursor = 1
|
||||||
|
while cursor and diffs[cursor] do
|
||||||
|
if diffs[cursor].value == "" then
|
||||||
|
table.remove(diffs, cursor)
|
||||||
|
else
|
||||||
|
cursor += 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return diffs
|
||||||
|
end
|
||||||
|
|
||||||
|
function StringDiff._computeDiff(text1: string, text2: string): Diffs
|
||||||
|
-- Assumes that the prefix and suffix have already been trimmed off
|
||||||
|
-- and shortcut returns have been made so these texts must be different
|
||||||
|
|
||||||
|
local text1Length, text2Length = #text1, #text2
|
||||||
|
|
||||||
|
if text1Length == 0 then
|
||||||
|
-- It's simply inserting all of text2 into text1
|
||||||
|
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
|
||||||
|
end
|
||||||
|
|
||||||
|
if text2Length == 0 then
|
||||||
|
-- It's simply deleting all of text1
|
||||||
|
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
|
||||||
|
end
|
||||||
|
|
||||||
|
local longText = if text1Length > text2Length then text1 else text2
|
||||||
|
local shortText = if text1Length > text2Length then text2 else text1
|
||||||
|
local shortTextLength = #shortText
|
||||||
|
|
||||||
|
-- Shortcut if the shorter string exists entirely inside the longer one
|
||||||
|
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
|
||||||
|
if indexOf ~= nil then
|
||||||
|
local diffs = {
|
||||||
|
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
|
||||||
|
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
|
||||||
|
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
|
||||||
|
}
|
||||||
|
-- Swap insertions for deletions if diff is reversed
|
||||||
|
if text1Length > text2Length then
|
||||||
|
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
|
||||||
|
end
|
||||||
|
return diffs
|
||||||
|
end
|
||||||
|
|
||||||
|
if shortTextLength == 1 then
|
||||||
|
-- Single character string
|
||||||
|
-- After the previous shortcut, the character can't be an equality
|
||||||
|
return {
|
||||||
|
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
|
||||||
|
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
return StringDiff._bisect(text1, text2)
|
||||||
|
end
|
||||||
|
|
||||||
|
function StringDiff._cleanupSemantic(diffs: Diffs): Diffs
|
||||||
|
-- Reduce the number of edits by eliminating semantically trivial equalities.
|
||||||
|
local changes = false
|
||||||
|
local equalities = {} -- Stack of indices where equalities are found.
|
||||||
|
local equalitiesLength = 0 -- Keeping our own length var is faster.
|
||||||
|
local lastEquality: string? = nil
|
||||||
|
-- Always equal to diffs[equalities[equalitiesLength]].value
|
||||||
|
local pointer = 1 -- Index of current position.
|
||||||
|
-- Number of characters that changed prior to the equality.
|
||||||
|
local length_insertions1 = 0
|
||||||
|
local length_deletions1 = 0
|
||||||
|
-- Number of characters that changed after the equality.
|
||||||
|
local length_insertions2 = 0
|
||||||
|
local length_deletions2 = 0
|
||||||
|
|
||||||
|
while diffs[pointer] do
|
||||||
|
if diffs[pointer].actionType == StringDiff.ActionTypes.Equal then -- Equality found.
|
||||||
|
equalitiesLength = equalitiesLength + 1
|
||||||
|
equalities[equalitiesLength] = pointer
|
||||||
|
length_insertions1 = length_insertions2
|
||||||
|
length_deletions1 = length_deletions2
|
||||||
|
length_insertions2 = 0
|
||||||
|
length_deletions2 = 0
|
||||||
|
lastEquality = diffs[pointer].value
|
||||||
|
else -- An insertion or deletion.
|
||||||
|
if diffs[pointer].actionType == StringDiff.ActionTypes.Insert then
|
||||||
|
length_insertions2 = length_insertions2 + #diffs[pointer].value
|
||||||
|
else
|
||||||
|
length_deletions2 = length_deletions2 + #diffs[pointer].value
|
||||||
|
end
|
||||||
|
-- Eliminate an equality that is smaller or equal to the edits on both
|
||||||
|
-- sides of it.
|
||||||
|
if
|
||||||
|
lastEquality
|
||||||
|
and (#lastEquality <= math.max(length_insertions1, length_deletions1))
|
||||||
|
and (#lastEquality <= math.max(length_insertions2, length_deletions2))
|
||||||
|
then
|
||||||
|
-- Duplicate record.
|
||||||
|
table.insert(
|
||||||
|
diffs,
|
||||||
|
equalities[equalitiesLength],
|
||||||
|
{ actionType = StringDiff.ActionTypes.Delete, value = lastEquality }
|
||||||
|
)
|
||||||
|
-- Change second copy to insert.
|
||||||
|
diffs[equalities[equalitiesLength] + 1].actionType = StringDiff.ActionTypes.Insert
|
||||||
|
-- Throw away the equality we just deleted.
|
||||||
|
equalitiesLength = equalitiesLength - 1
|
||||||
|
-- Throw away the previous equality (it needs to be reevaluated).
|
||||||
|
equalitiesLength = equalitiesLength - 1
|
||||||
|
pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0
|
||||||
|
length_insertions1, length_deletions1 = 0, 0 -- Reset the counters.
|
||||||
|
length_insertions2, length_deletions2 = 0, 0
|
||||||
|
lastEquality = nil
|
||||||
|
changes = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Normalize the diff.
|
||||||
|
if changes then
|
||||||
|
StringDiff._reorderAndMerge(diffs)
|
||||||
|
end
|
||||||
|
StringDiff._cleanupSemanticLossless(diffs)
|
||||||
|
|
||||||
|
-- Find any overlaps between deletions and insertions.
|
||||||
|
-- e.g: <del>abcxxx</del><ins>xxxdef</ins>
|
||||||
|
-- -> <del>abc</del>xxx<ins>def</ins>
|
||||||
|
-- e.g: <del>xxxabc</del><ins>defxxx</ins>
|
||||||
|
-- -> <ins>def</ins>xxx<del>abc</del>
|
||||||
|
-- Only extract an overlap if it is as big as the edit ahead or behind it.
|
||||||
|
pointer = 2
|
||||||
|
while diffs[pointer] do
|
||||||
|
if
|
||||||
|
diffs[pointer - 1].actionType == StringDiff.ActionTypes.Delete
|
||||||
|
and diffs[pointer].actionType == StringDiff.ActionTypes.Insert
|
||||||
|
then
|
||||||
|
local deletion = diffs[pointer - 1].value
|
||||||
|
local insertion = diffs[pointer].value
|
||||||
|
local overlap_length1 = StringDiff._commonOverlap(deletion, insertion)
|
||||||
|
local overlap_length2 = StringDiff._commonOverlap(insertion, deletion)
|
||||||
|
if overlap_length1 >= overlap_length2 then
|
||||||
|
if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then
|
||||||
|
-- Overlap found. Insert an equality and trim the surrounding edits.
|
||||||
|
table.insert(
|
||||||
|
diffs,
|
||||||
|
pointer,
|
||||||
|
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(insertion, 1, overlap_length1) }
|
||||||
|
)
|
||||||
|
diffs[pointer - 1].value = string.sub(deletion, 1, #deletion - overlap_length1)
|
||||||
|
diffs[pointer + 1].value = string.sub(insertion, overlap_length1 + 1)
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then
|
||||||
|
-- Reverse overlap found.
|
||||||
|
-- Insert an equality and swap and trim the surrounding edits.
|
||||||
|
table.insert(
|
||||||
|
diffs,
|
||||||
|
pointer,
|
||||||
|
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(deletion, 1, overlap_length2) }
|
||||||
|
)
|
||||||
|
diffs[pointer - 1] = {
|
||||||
|
actionType = StringDiff.ActionTypes.Insert,
|
||||||
|
value = string.sub(insertion, 1, #insertion - overlap_length2),
|
||||||
|
}
|
||||||
|
diffs[pointer + 1] = {
|
||||||
|
actionType = StringDiff.ActionTypes.Delete,
|
||||||
|
value = string.sub(deletion, overlap_length2 + 1),
|
||||||
|
}
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
return diffs
|
||||||
|
end
|
||||||
|
|
||||||
|
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._commonOverlap(text1: string, text2: string): number
|
||||||
|
-- Determine if the suffix of one string is the prefix of another.
|
||||||
|
|
||||||
|
-- Cache the text lengths to prevent multiple calls.
|
||||||
|
local text1_length = #text1
|
||||||
|
local text2_length = #text2
|
||||||
|
-- Eliminate the null case.
|
||||||
|
if text1_length == 0 or text2_length == 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
-- Truncate the longer string.
|
||||||
|
if text1_length > text2_length then
|
||||||
|
text1 = string.sub(text1, text1_length - text2_length + 1)
|
||||||
|
elseif text1_length < text2_length then
|
||||||
|
text2 = string.sub(text2, 1, text1_length)
|
||||||
|
end
|
||||||
|
local text_length = math.min(text1_length, text2_length)
|
||||||
|
-- Quick check for the worst case.
|
||||||
|
if text1 == text2 then
|
||||||
|
return text_length
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Start by looking for a single character match
|
||||||
|
-- and increase length until no match is found.
|
||||||
|
-- Performance analysis: https://neil.fraser.name/news/2010/11/04/
|
||||||
|
local best = 0
|
||||||
|
local length = 1
|
||||||
|
while true do
|
||||||
|
local pattern = string.sub(text1, text_length - length + 1)
|
||||||
|
local found = string.find(text2, pattern, 1, true)
|
||||||
|
if found == nil then
|
||||||
|
return best
|
||||||
|
end
|
||||||
|
length = length + found - 1
|
||||||
|
if found == 1 or string.sub(text1, text_length - length + 1) == string.sub(text2, 1, length) then
|
||||||
|
best = length
|
||||||
|
length = length + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function StringDiff._cleanupSemanticScore(one: string, two: string): number
|
||||||
|
-- Given two strings, compute a score representing whether the internal
|
||||||
|
-- boundary falls on logical boundaries.
|
||||||
|
-- Scores range from 6 (best) to 0 (worst).
|
||||||
|
|
||||||
|
if (#one == 0) or (#two == 0) then
|
||||||
|
-- Edges are the best.
|
||||||
|
return 6
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Each port of this function behaves slightly differently due to
|
||||||
|
-- subtle differences in each language's definition of things like
|
||||||
|
-- 'whitespace'. Since this function's purpose is largely cosmetic,
|
||||||
|
-- the choice has been made to use each language's native features
|
||||||
|
-- rather than force total conformity.
|
||||||
|
local char1 = string.sub(one, -1)
|
||||||
|
local char2 = string.sub(two, 1, 1)
|
||||||
|
local nonAlphaNumeric1 = string.match(char1, "%W")
|
||||||
|
local nonAlphaNumeric2 = string.match(char2, "%W")
|
||||||
|
local whitespace1 = nonAlphaNumeric1 and string.match(char1, "%s")
|
||||||
|
local whitespace2 = nonAlphaNumeric2 and string.match(char2, "%s")
|
||||||
|
local lineBreak1 = whitespace1 and string.match(char1, "%c")
|
||||||
|
local lineBreak2 = whitespace2 and string.match(char2, "%c")
|
||||||
|
local blankLine1 = lineBreak1 and string.match(one, "\n\r?\n$")
|
||||||
|
local blankLine2 = lineBreak2 and string.match(two, "^\r?\n\r?\n")
|
||||||
|
|
||||||
|
if blankLine1 or blankLine2 then
|
||||||
|
-- Five points for blank lines.
|
||||||
|
return 5
|
||||||
|
elseif lineBreak1 or lineBreak2 then
|
||||||
|
-- Four points for line breaks
|
||||||
|
-- DEVIATION: Prefer to start on a line break instead of end on it
|
||||||
|
return if lineBreak1 then 4 else 4.5
|
||||||
|
elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then
|
||||||
|
-- Three points for end of sentences.
|
||||||
|
return 3
|
||||||
|
elseif whitespace1 or whitespace2 then
|
||||||
|
-- Two points for whitespace.
|
||||||
|
return 2
|
||||||
|
elseif nonAlphaNumeric1 or nonAlphaNumeric2 then
|
||||||
|
-- One point for non-alphanumeric.
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function StringDiff._cleanupSemanticLossless(diffs: Diffs)
|
||||||
|
-- Look for single edits surrounded on both sides by equalities
|
||||||
|
-- which can be shifted sideways to align the edit to a word boundary.
|
||||||
|
-- e.g: The c<ins>at c</ins>ame. -> The <ins>cat </ins>came.
|
||||||
|
|
||||||
|
local pointer = 2
|
||||||
|
-- Intentionally ignore the first and last element (don't need checking).
|
||||||
|
while diffs[pointer + 1] do
|
||||||
|
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
|
||||||
|
if
|
||||||
|
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||||
|
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||||
|
then
|
||||||
|
-- This is a single edit surrounded by equalities.
|
||||||
|
local diff = diffs[pointer]
|
||||||
|
|
||||||
|
local equality1 = prevDiff.value
|
||||||
|
local edit = diff.value
|
||||||
|
local equality2 = nextDiff.value
|
||||||
|
|
||||||
|
-- First, shift the edit as far left as possible.
|
||||||
|
local commonOffset = StringDiff._sharedSuffix(equality1, edit)
|
||||||
|
if commonOffset > 0 then
|
||||||
|
local commonString = string.sub(edit, -commonOffset)
|
||||||
|
equality1 = string.sub(equality1, 1, -commonOffset - 1)
|
||||||
|
edit = commonString .. string.sub(edit, 1, -commonOffset - 1)
|
||||||
|
equality2 = commonString .. equality2
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Second, step character by character right, looking for the best fit.
|
||||||
|
local bestEquality1 = equality1
|
||||||
|
local bestEdit = edit
|
||||||
|
local bestEquality2 = equality2
|
||||||
|
local bestScore = StringDiff._cleanupSemanticScore(equality1, edit)
|
||||||
|
+ StringDiff._cleanupSemanticScore(edit, equality2)
|
||||||
|
|
||||||
|
while string.byte(edit, 1) == string.byte(equality2, 1) do
|
||||||
|
equality1 = equality1 .. string.sub(edit, 1, 1)
|
||||||
|
edit = string.sub(edit, 2) .. string.sub(equality2, 1, 1)
|
||||||
|
equality2 = string.sub(equality2, 2)
|
||||||
|
local score = StringDiff._cleanupSemanticScore(equality1, edit)
|
||||||
|
+ StringDiff._cleanupSemanticScore(edit, equality2)
|
||||||
|
-- The > (rather than >=) encourages leading rather than trailing whitespace on edits.
|
||||||
|
-- I just think it looks better for indentation changes to start the line,
|
||||||
|
-- since then indenting several lines all have aligned diffs at the start
|
||||||
|
if score > bestScore then
|
||||||
|
bestScore = score
|
||||||
|
bestEquality1 = equality1
|
||||||
|
bestEdit = edit
|
||||||
|
bestEquality2 = equality2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if prevDiff.value ~= bestEquality1 then
|
||||||
|
-- We have an improvement, save it back to the diff.
|
||||||
|
if #bestEquality1 > 0 then
|
||||||
|
diffs[pointer - 1].value = bestEquality1
|
||||||
|
else
|
||||||
|
table.remove(diffs, pointer - 1)
|
||||||
|
pointer = pointer - 1
|
||||||
|
end
|
||||||
|
diffs[pointer].value = bestEdit
|
||||||
|
if #bestEquality2 > 0 then
|
||||||
|
diffs[pointer + 1].value = bestEquality2
|
||||||
|
else
|
||||||
|
table.remove(diffs, pointer + 1)
|
||||||
|
pointer = pointer - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pointer = pointer + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function StringDiff._bisect(text1: string, text2: string): Diffs
|
||||||
|
-- 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
|
||||||