Compare commits

..

112 Commits

Author SHA1 Message Date
Micah
47db569c52 Correct my own mistake and move plugin test to its own step 2025-09-23 21:19:26 -07:00
Micah
1d3f8c8e9d Run tests in CI (this is the part where I have to try at least twice) 2025-09-23 21:13:24 -07:00
Micah
a894313a4b Make script exit with code 1 when tests fail 2025-09-23 21:09:13 -07:00
Micah
7f73ae80dc Add script for running plugin tests locally
They current fail
2025-09-23 21:02:23 -07:00
Micah
80a381dbb1 Use SerializationService as a fallback for when patch application fails (#1030) 2025-09-21 15:09:20 -07:00
KAS
59e36491a5 Fix a grammar error and a typo (#1113) 2025-09-16 11:00:34 -07:00
Micah
c1326ba06e Add Arm64 builds to CI/Releases + build on ubuntu 22.04 (#1098) 2025-08-30 14:10:59 -07:00
Micah
e2633126ee Change Foreman to Rokit in CONTRIBUTING.md (#1110) 2025-08-30 13:42:52 -07:00
Micah
5f33435f3c Move to using Rokit, update tools, and don't install unnecessary tools (#1109) 2025-08-29 18:45:15 -07:00
boatbomber
54e0ff230b Improvements to sync reminder UX (#1096) 2025-08-28 17:15:34 -07:00
wad
4e9e6233ff fix: apply gameId and placeId only after initial sync (#1104) 2025-08-15 18:12:36 -07:00
Micah
0056849b51 Put Rojo version in crash message (#1101) 2025-08-13 15:46:08 -07:00
ffrostfall
2ddb21ec5f Add option for emitting absolute paths to rojo sourcemap (#1092)
Co-authored-by: Micah <micah@uplift.games>
2025-08-04 11:33:35 -07:00
Micah
a4eb65ca3f Add YAML middleware that behaves like TOML and JSON (#1093) 2025-08-02 20:58:13 -07:00
Sebastian Stachowicz
3002d250a1 Fix Table diff colors (#1084) 2025-07-31 19:36:03 -07:00
Micah
9598553e5d Normalize paths in sourcemap generation (#1085) 2025-07-31 09:19:57 -07:00
Sebastian Stachowicz
7f68d9887b Fixed nil -> nil props showing up as failing in patch visualizer (plugin) (#1081) 2025-07-25 15:27:11 -07:00
Micah
e092a7301f Change background color of web UI to gray (#1080) 2025-07-25 15:04:42 -07:00
Cameron Campbell
6dfdfbe514 Cache Rust Dependencies in release.yml. (#1079) 2025-07-22 16:13:23 -07:00
morosanu
7860f2717f Fix auto connect for play mode (#1066) 2025-07-22 15:12:16 -07:00
boatbomber
60f19df9a0 Show update indicator on version header (#1069) 2025-06-21 02:53:45 +00:00
boatbomber
951f0cda0b Show the plugin version on the Error page (#1068) 2025-06-20 18:28:04 -07:00
Micah
227042d6b1 Add current maintainers to author field of Cargo.toml files (#1053) 2025-05-21 20:55:39 -07:00
Micah
b2c4f550ee Release v7.5.1 (#1035) 2025-04-25 13:56:01 -07:00
Ken Loeffler
4ddbefa88f Change release build linux version to ubuntu-latest (#1034) 2025-04-25 20:04:43 +01:00
Ken Loeffler
d935115591 Release 7.5.0 (#1033) 2025-04-25 19:46:16 +01:00
Cameron Campbell
bd2ea42732 Fixes issues with refs in the plugin. (#1005) 2025-04-18 08:44:11 -07:00
Micah
3bac38ee34 Re-add the hack to write NeedsPivotMigration as false for models (#1027) 2025-04-16 15:03:09 -07:00
Micah
a7a4f6d8f2 Update rbx-dom-lua database to latest version (#1029) 2025-04-13 07:42:41 -07:00
Micah
80b6facbd3 Add missing CHANGELOG entry for 7.4.4 (#1025) 2025-04-07 16:22:08 -07:00
Parritz
7dee898400 Add place ID blacklist config (#1021) 2025-04-03 08:37:40 -07:00
Micah
4c4b2dbe17 Add legacy and runContext script sync rule middlewares (#909) 2025-04-02 12:47:27 -07:00
Sasial
73ed5ae697 Add Support for Plugin Scripts (#1008) 2025-04-02 11:37:49 -07:00
Micah
833320de64 Update rbx-dom (#1023) 2025-04-02 11:32:27 -07:00
boatbomber
0d6ff8ef8a Improve notification layout (#997) 2025-01-13 16:06:49 -08:00
Jack T
55a207a275 Fix clippy lint warnings (#1004) 2025-01-13 10:07:53 -08:00
Jack T
f33d1f1cc4 Ignore .git directory when building VfsSnapshot in build script (#1002) 2025-01-01 01:38:34 -08:00
boatbomber
19ca2b12fc Add locked tooltip (#998)
Adds the ability to define descriptive tooltips for settings when they
are locked.


![image](https://github.com/user-attachments/assets/5d5778c8-911b-4358-b4e6-f0389270ad76)


Makes some minor improvements to tooltip layout logic as well.
2024-12-28 15:03:11 -08:00
boatbomber
b7d3394464 Plugin dev ux improvements (#992)
Co-authored-by: kennethloeffler <kenloef@gmail.com>
2024-11-10 15:53:58 -08:00
boatbomber
8c33100d7a Use FontFace and consistent text sizing (#988) 2024-11-09 12:05:57 +00:00
Kenneth Loeffler
80c406f196 Fix returning NoProjectFound for any project load error (#985)
In #917, we accidentally changed ServeSession::new's project loading
logic so that it always returns `ServeSession::ProjectNotFound` if the
load fails for any reason. This PR fixes this so that it returns the
right error when there is an error loading the project, and moves the
`NoProjectFound` error to `project::Error`, since I think it makes more
sense there.
2024-11-08 08:40:32 +00:00
Kenneth Loeffler
bc2c76e5e2 Use 7.5.0-prealpha for master branch version, not 7.4.4
<p dir="auto">in <a class="issue-link js-issue-link" data-error-text="Failed to load title" data-id="2636858743" data-permission-text="Title is private" data-url="https://github.com/rojo-rbx/rojo/issues/989" data-hovercard-type="pull_request" data-hovercard-url="/rojo-rbx/rojo/pull/989/hovercard" href="https://github.com/rojo-rbx/rojo/pull/989">#989</a>, we changed Rojo's version number on the master branch to 7.4.4. This is a little odd, because 7.4.4 is already released, is diverged from the master branch, and we are not working towards 7.4.4 on the master branch. If we're going to spend time on this, I think we should use a more appropriate version number.</p>
<p dir="auto">This PR changes the version number to 7.5.0-prealpha, since Rojo's master branch is currently undergoing development towards 7.5.0. We will most likely <strong>not</strong> be making a release of this version - the only intent is better clarity for those running Rojo's latest master.</p>
2024-11-06 15:59:03 +00:00
boatbomber
4a7bddbc09 Update version in master branch (#989) 2024-11-05 18:10:24 -08:00
boatbomber
e316fdbaef Make sync reminder more detailed (#987) 2024-11-05 22:47:07 +00:00
Micah
34106f470f Remove maplit dependency and stop using a macro for hashmaps (#982) 2024-10-31 11:56:54 -07:00
Micah
d9ab0e7de8 Support $schema in JSON structures (#974) 2024-10-24 10:55:51 -07:00
Micah
5ca1573e2e Correct mistake in build command docs (#977) 2024-10-18 14:08:58 -07:00
Micah
c9ce996626 Update workflow and tooling versions (#910) 2024-09-03 15:36:36 -07:00
Kenneth Loeffler
73097075d4 Update rbx-dom dependencies (#965) 2024-08-22 20:03:06 +01:00
Micah
5e1cab2e75 Actually include attribute-defined properties in patch computation (#944) 2024-08-19 15:41:02 -07:00
Micah
30f439caec Add 7.4.3 to changelog (#960)
After 7.4.3 released, I forgot to update the changelog on master. This
fixes that.
2024-08-15 16:42:11 +00:00
boatbomber
4b5db4e5a9 Check for compatible updates in plugin (#832) 2024-08-05 11:34:29 -07:00
Barış
3fa1d6b09c Set linguist language of lua files to luau (#956) 2024-08-02 10:03:57 -07:00
Micah
6051a5f1f1 Update Changelog to include 7.4.2 (#951) 2024-07-23 14:39:04 -07:00
Kenneth Loeffler
5f7dd45361 Sleep between file copies and serve for macOS serve tests (#945) 2024-07-20 09:52:05 -07:00
Micah
3ca975d81d Correct issue with default.project.json files with no name being named default after change (#917)
Co-authored-by: Kenneth Loeffler <kenloef@gmail.com>
2024-07-15 09:24:51 -07:00
Micah
7e2bab921a Support setting referent properties via attributes (#843)
Co-authored-by: Kenneth Loeffler <kenloef@gmail.com>
2024-06-20 23:48:52 +01:00
dependabot[bot]
a7b45ee859 Bump h2 from 0.3.24 to 0.3.26 (#921) 2024-05-30 12:45:38 -07:00
boatbomber
62f4a1f3c2 Use history recording and don't do anything permanent (#915) 2024-05-30 12:28:58 -07:00
boatbomber
3d4e387d35 Redesign settings UI in plugin (#886) 2024-05-13 10:36:03 -07:00
Micah
2c46640105 Allow openScriptsExternally option to be changed during sync (#911) 2024-05-08 12:34:00 -07:00
dependabot[bot]
41443d3989 Bump rustls from 0.21.10 to 0.21.11 (#905) 2024-04-19 20:03:55 +00:00
Kenneth Loeffler
4b3470d30b Fix removing trailing newlines by using str::replace in memofs (#903) 2024-04-17 11:55:23 -07:00
Kenneth Loeffler
ce71a3df4d Release workflow maintenance (#902) 2024-04-17 11:55:08 -07:00
Kenneth Loeffler
7232721b87 Use dtolnay/rust-toolchain and upgrade to checkout v4 in CI workflow (#900)
This PR performs some routine maintenance on our CI workflow:

* Replaces `actions-rs/toolchain` with `dtolnay/rust-toolchain`. The
actions at `actions-rs` are no longer maintained, and they use
deprecated GitHub Actions APIs. dtolnay's action does not support the
`override` option, but we didn't actually need to use it anyway.
* Upgrades `actions/checkout` to v4, because v3 causes some warnings
since it uses Node.js 16, which is deprecated.
2024-04-09 14:55:42 -07:00
boatbomber
b2f133e6f1 Patch visualizer redesign (#883) 2024-04-02 00:04:58 -07:00
Kenneth Loeffler
87920964d7 Release memofs 0.3.0, bump Rojo dependency (#894) 2024-03-25 10:48:27 -07:00
Barış
c7a4f892e3 Add never option to Confirmation (#893) 2024-03-14 19:41:21 +00:00
EgoMoose
8f9e307930 Trim plugin version string (#890)
Duplicate of https://github.com/rojo-rbx/rojo/pull/889, but based on
master as per request.

This PR is a very small change that fixes the string pattern that reads
the rojo version from `Version.txt`. Currently this reads an extra
new-line character which makes reading the version text in the plugin
difficult.

It seems the rust side of things already trims the string when
comparing, but the lua side does not.
2024-03-13 09:50:41 -07:00
Micah
856d43ce69 Update Cargo dependencies (#887) 2024-03-04 15:20:58 -08:00
boatbomber
26181a5a1f Use GuiState instead of manual calculation for tooltips (#884) 2024-02-29 14:50:06 -08:00
boatbomber
edf87bf9a3 Build tree ancestry correctly (#882) 2024-02-29 14:27:46 -08:00
Kenneth Loeffler
5f51538e0b Update master's changelog in preparation for 7.4.1 release (#873)
This PR edits the changelog on master to reflect 7.4.1's release
2024-02-21 01:46:01 +00:00
Micah
48bb760739 Make the name field in projects optional (#870)
Closes #858.

If a project is named `default.project.json`, it acts as an `init` file
and gains the name of the folder it's inside of. If it is named
something other than `default.project.json`, it gains the name of the
file with `.project.json` trimmed off. So e.g. `foo.project.json`
becomes `foo`.
2024-02-20 17:25:57 -08:00
Micah
42121a9fc9 Allow building Rojo with profile-with-tracy feature (#862) 2024-02-20 14:56:55 -08:00
Filip Tibell
02d79a4749 Migrate to using Rustls (#861) 2024-02-20 14:56:31 -08:00
Kenneth Loeffler
ddb26c73bd rbx_dom_lua rojo-rbx/rbx-dom@6ccd30f (custom pivot get/set) (#868) 2024-02-20 12:08:55 -08:00
boatbomber
8ff064fe28 Add benchmarking, perf gains, and better settings UI (#850) 2024-02-12 15:58:35 -08:00
Kenneth Loeffler
cf25eb0833 Normalize line endings to LF in Lua middleware (#854) 2024-02-12 14:58:03 -08:00
boatbomber
5c4260f3ac Catch failed http requests that didn't error so we can handle them correctly (#847) 2024-02-01 21:29:36 +00:00
Kenneth Loeffler
7abf19804c Ignore any unreadable property in Reconciler:diff (#848) 2024-02-01 21:05:44 +00:00
boatbomber
df707d5bef Lint plugin src (#846) 2024-01-31 21:08:07 -08:00
boatbomber
f3b0b0027e Catch more sync failures (#845)
- Catch removal failures
- Catch name change failures
- Don't remove IDs for instances if they weren't actually destroyed
2024-01-31 17:07:01 -08:00
boatbomber
106a01223e Show failed additions and removals in visualizer (#844) 2024-01-31 14:45:28 -08:00
boatbomber
506a60d0be Play Solo & Test Server auto connect (#840)
When enabled, the `baseurl` of the session is written to
`workspace:SetAttribute("__Rojo_ConnectionUrl")` so that the test server
can connect to that session automatically.

This works for Play Solo and Local Test Server. It is marked
experimental for now (and disabled by default) since connecting during a
playtest session is... not polished. Rojo may overwrite things and cause
headaches. Further work can be done later.
2024-01-30 12:51:45 -08:00
boatbomber
4018607b77 Visualize table changes (#834)
Implements a pop out diff view for table properties like Attributes and
Tags
2024-01-22 12:26:41 -08:00
boatbomber
1cc720ad34 Use Studio theming (#838)
Updates our Theme provider to use Studio colors. A few components look
ever so slightly different now, but more in line with Studio.
2024-01-22 11:22:21 -08:00
Micah
73828af715 Add a new syncRules field project files to allow users to specify middleware to use for files (#813) 2024-01-19 22:18:17 -08:00
Micah
c0a96e3811 Release v7.4.0 (#837) 2024-01-16 12:12:40 -08:00
Micah
9d0d76f0a5 Ensure plugin and Cargo version match exact at compile-time (#836) 2024-01-16 14:09:10 -06:00
Micah
c7173ac832 Don't serialize emitLegacyScripts if it's None (#835) 2024-01-16 10:09:16 -08:00
boatbomber
b12ce47e7e Don't remind to sync if the lock is claimed (#833)
If the sync lock is claimed in Team Create, the user cannot sync.
Therefore, a sync reminder notification is unhelpful as it is calling to
an invalid action.
2024-01-12 12:35:29 -08:00
Barış
269272983b Changed file extensions of init command from lua to luau (#831) 2024-01-05 16:00:49 -08:00
Kenneth Loeffler
6adc5eb9fb Conserve CI minutes via cache, skip macOS+Windows MSRV builds (#827)
Windows and macOS runners consume GitHub Actions minutes at [2x and 10x
the rate of Linux runners,
respectively](https://docs.github.com/en/billing/managing-billing-for-github-actions/about-billing-for-github-actions#minute-multipliers).
This is a bit concerning now that there are two Windows jobs and two
macOS jobs, introduced in #825.

This PR aims to reduce the cost by:
* Adding [rust-cache](https://github.com/Swatinem/rust-cache/) to reduce
the amount of time spent. I'm aware there were some concerns raised
about CI caches in general in #496 - are they still a blocker?
* Removing the unnecessary Windows and macOS MSRV build jobs. If an MSRV
build fails on one platform due to usage of new language features, then
it will fail on all of them.

@Kampfkarren may have to change this repository's required status checks
before this PR can be merged
2024-01-02 15:49:24 -08:00
boatbomber
fd8bc8ae3f Improve visualization for arrays (#829) 2024-01-02 17:32:37 +00:00
Kenneth Loeffler
3369b0d429 Downgrade to Notify 4, use FSEvents, use minimal recursive watches (#830) 2024-01-02 09:26:06 -08:00
Kenneth Loeffler
097d39e8ce Fix move_folders_of_stuff (#826)
This is a fairly important test verifying whether the action of moving a
folder into a watched folder is correctly detected and processed. It was
disabled in
b43b45be8f.
The fact that it failed indicates a possible bug in change processing,
so in this PR, I'll re-enable the test, investigate why it fails, and
fix it.
2023-12-31 12:02:54 -08:00
Kenneth Loeffler
11fa08e6d6 Run CI workflow on Windows and macOS (#825)
This PR adds macOS and Windows jobs to the CI workflow. This allows us
to see when changes break functionality on any supported platform, which
is particularly important for changes that involve the file system or
file watcher.
2023-12-29 17:46:58 -08:00
Kenneth Loeffler
96987af71d Fix broken serve tests on macOS (#824)
Right now, serve tests will fail when Rojo is built with the FSEvent
backend. The cause is essentially due to the fact that `/var` (where
temporary directories for serve tests are located) on macOS is actually
a symlink to `/private/var`. Paths coming from FSEvent always have
symlinks expanded, but Rojo never expands symlinks. So, Rojo's paths
during these tests look like `/var/*` while the FSEvent paths look like
`/private/var/*`. When Rojo's change processor receives these events, it
considers them outside the project and does not apply any changes,
causing serve tests to time out.

To work around this, we can call `Path::canonicalize` before passing the
project path to `rojo serve` during serve tests. Rojo does need to
better support symlinks (which would also solve the problem), but I
think that can be left for another day because it's larger in scope and
I mostly just want working tests before addressing #609.
2023-12-28 23:17:00 +00:00
Vee
23327cb3ef Fix preloading assets in plugin (#819)
`gatherAssetUrlsRecursive` now returns asset URLs deeper than one layer.
2023-12-04 16:02:25 +00:00
Jack T
b43b45be8f Upgrade to Notify 6 (#816) 2023-11-23 16:16:43 -08:00
Micah
41994ec82e Release v7.4.0-rc3 (#811) 2023-10-25 17:01:06 -07:00
Micah
cd14ea7c62 Remove unnecessary borrows (#810) 2023-10-25 17:59:02 -05:00
Kenneth Loeffler
9f13bca6b8 rbx_dom_lua rojo-rbx/rbx-dom@440f3723 (attribute validation) (#809)
Brings over some changes to rbx_dom_lua to validate attribute names
before calling `Instance:SetAttribute`. This should prevent Rojo from
falling over when it attempts to sync an attribute with an invalid name.
2023-10-25 14:21:58 -07:00
Kenneth Loeffler
f4252c3e97 Update changelog in preparation for 7.4.0-rc3 (#808)
Summarizes recent changes since 7.4.0-rc2 in the changelog
2023-10-23 22:54:48 +00:00
Kenneth Loeffler
6598867d3d Bump rbx_binary to 0.7.3 (#807)
Bumps rbx_binary's version to 0.7.3 to bring in the missing
`SecurityCapabilities` serialization fallback default
2023-10-23 22:43:00 +00:00
dependabot[bot]
f39e040a0d Bump rustix from 0.38.15 to 0.38.20 (#806) 2023-10-23 22:38:28 +00:00
Kenneth Loeffler
a3d140269b Demote unapplied patch warnings to debug logs (#805)
These warnings always appear for properties like `Capabilities`,
`SourceAssetId`, etc. and tend to scare users who are syncing models.
This information is now surfaced in the patch visualizer, so I think
these warnings can be demoted to debug logs.
2023-10-23 11:47:50 -07:00
Kenneth Loeffler
feac29ea40 Fix PatchTree incorrect changeList entries on decode failure (#804) 2023-10-22 16:58:12 -07:00
Kenneth Loeffler
834c8cdbca rbx_dom_lua rojo-rbx/rbx-dom@0e10232b (SecurityCapabilities) (#803)
Closes #802.
2023-10-22 16:57:59 -07:00
Kenneth Loeffler
d441fbdf91 Bump rbx_dom_lua rojo-rbx/rbx-dom@e7a5b91c (ScriptEditorService) (#801) 2023-10-17 14:15:47 -07:00
Filip Tibell
e897f524dc Skip sourcemap generation when unaffected by changes in watch mode (#800) 2023-10-13 08:38:21 -07:00
316 changed files with 31331 additions and 5611 deletions

2
.dir-locals.el Normal file
View File

@@ -0,0 +1,2 @@
((nil . ((eglot-luau-rojo-project-path . "plugin.project.json")
(eglot-luau-rojo-sourcemap-enabled . 't))))

View File

@@ -23,4 +23,7 @@ insert_final_newline = true
insert_final_newline = true insert_final_newline = true
[*.lua] [*.lua]
indent_style = tab
[*.luau]
indent_style = tab indent_style = tab

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.lua linguist-language=Luau

View File

@@ -11,12 +11,12 @@ jobs:
name: Check Actions name: Check Actions
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Changelog check - name: Changelog check
uses: Zomzog/changelog-checker@v1.3.0 uses: Zomzog/changelog-checker@v1.3.0
with: with:
fileName: CHANGELOG.md fileName: CHANGELOG.md
noChangelogLabel: skip changelog noChangelogLabel: skip changelog
checkNotification: Simple checkNotification: Simple
env: env:

View File

@@ -12,28 +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.70.0] 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: with:
submodules: true 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 Aftman - name: Restore Rust Cache
uses: ok-nick/setup-aftman@v0.3.0 uses: actions/cache/restore@v4
with: with:
version: 'v0.2.7' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
@@ -41,33 +41,114 @@ jobs:
- name: Test - name: Test
run: cargo test --locked --verbose run: cargo test --locked --verbose
lint: - name: Save Rust Cache
name: Rustfmt, Clippy, & Stylua 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: with:
submodules: true submodules: true
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@1.79.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') }}
test-plugin:
name: Test Plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v1.1.0'
- name: Test
run: lune run test-plugin
env:
RBX_API_KEY: ${{ secrets.PLUGIN_TEST_API_KEY }}
RBX_UNIVERSE_ID: ${{ vars.PLUGIN_TEST_UNIVERSE_ID }}
RBX_PLACE_ID: ${{ vars.PLUGIN_TEST_PLACE_ID }}
lint:
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 Aftman - name: Restore Rust Cache
uses: ok-nick/setup-aftman@v0.3.0 uses: actions/cache/restore@v4
with: with:
version: 'v0.2.7' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v1.1.0'
- name: Stylua - name: Stylua
run: stylua --check plugin/src run: stylua --check plugin/src
- 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') }}

View File

@@ -8,51 +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
with: with:
submodules: true submodules: true
- name: Setup Aftman - name: Setup Rokit
uses: ok-nick/setup-aftman@v0.1.0 uses: CompeyDev/setup-rokit@v0.1.2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} version: 'v1.1.0'
trust-check: false
version: 'v0.2.6'
- 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
@@ -65,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
@@ -89,70 +87,64 @@ jobs:
env: env:
BIN: rojo BIN: rojo
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Get Version from Tag
shell: bash
# https://github.community/t/how-to-get-just-the-tag-name/16241/7#M1027
run: |
echo "PROJECT_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "Version is: ${{ env.PROJECT_VERSION }}"
- 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 Aftman - name: Restore Rust Cache
uses: ok-nick/setup-aftman@v0.1.0 uses: actions/cache/restore@v4
with: with:
token: ${{ secrets.GITHUB_TOKEN }} path: |
trust-check: false ~/.cargo/registry
version: 'v0.2.6' ~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose --target ${{ matrix.target }} run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env:
# Build into a known directory so we can find our build artifact more
# 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/${{ matrix.target }}/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/${{ matrix.target }}/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 }}

8
.gitignore vendored
View File

@@ -10,8 +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
# Roblox Studio holds 'lock' files on places # Roblox Studio holds 'lock' files on places
*.rbxl.lock *.rbxl.lock
@@ -19,3 +19,7 @@
# Snapshot files from the 'insta' Rust crate # Snapshot files from the 'insta' Rust crate
**/*.snap.new **/*.snap.new
# Macos file system junk
._*
.DS_STORE

3
.gitmodules vendored
View File

@@ -16,3 +16,6 @@
[submodule "plugin/Packages/Highlighter"] [submodule "plugin/Packages/Highlighter"]
path = plugin/Packages/Highlighter path = plugin/Packages/Highlighter
url = https://github.com/boatbomber/highlighter.git url = https://github.com/boatbomber/highlighter.git
[submodule ".lune/opencloud-execute"]
path = .lune/opencloud-execute
url = https://github.com/Dekkonot/opencloud-luau-execute-lune.git

5
.lune/.luaurc Normal file
View File

@@ -0,0 +1,5 @@
{
"aliases": {
"lune": "~/.lune/.typedefs/0.10.2/"
}
}

112
.lune/test-plugin.luau Normal file
View File

@@ -0,0 +1,112 @@
local serde = require("@lune/serde")
local net = require("@lune/net")
local stdio = require("@lune/stdio")
local process = require("@lune/process")
local fs = require("@lune/fs")
local luau_execute = require("./opencloud-execute")
local TEST_SCRIPT = fs.readFile("plugin/run-tests.server.lua")
local PATH_VERSION_MATCH = "assets/%d+/versions/(.+)"
local UNIVERSE_ID = process.env["RBX_UNIVERSE_ID"]
local PLACE_ID = process.env["RBX_PLACE_ID"]
local API_KEY = process.env["RBX_API_KEY"]
if not UNIVERSE_ID then
error("no universe ID specified. try providing one with the env var `RBX_UNIVERSE_ID`")
end
if not PLACE_ID then
error("no place ID specified. try providing one with the env var `RBX_PLACE_ID`")
end
if not API_KEY then
error("no API key specified. try providing one with the env var `RBX_API_KEY`")
end
--stylua: ignore
local upload_result = process.exec("cargo", {
"run", "--",
"upload", "plugin/test-place.project.json",
"--api_key", API_KEY,
"--universe_id", UNIVERSE_ID,
"--asset_id", PLACE_ID
}, {
stdio = "none"
})
if not upload_result.ok then
print("Failed to upload plugin test place")
print("Not dumping stdout or stderr to avoid leaking secrets")
process.exit(1)
end
-- This is /probably/ not necessary because Rojo generally does not have enough
-- activity that there will be multiple CI runs happening at once, but
-- it's better safe than sorry.
local version_response = net.request({
method = "GET",
url = `https://apis.roblox.com/assets/v1/assets/{PLACE_ID}/versions`,
query = {
maxPageSize = 1,
},
headers = {
["User-Agent"] = `Rojo/PluginTesting 1.0.0; {_VERSION}`,
["x-api-key"] = API_KEY,
},
})
if not version_response.ok then
error(
`Failed to fetch version of Roblox place to run tests on because: {version_response.statusCode} - {version_response.statusMessage}\n{version_response.body}`
)
end
local place_version_raw = serde.decode("json", version_response.body).assetVersions[1].path
assert(typeof(place_version_raw) == "string", "the result from asset version endpoint was not as expected")
local place_version = string.match(place_version_raw, PATH_VERSION_MATCH)
local task = luau_execute.create_task_versioned(UNIVERSE_ID, PLACE_ID, place_version, TEST_SCRIPT)
print(`Running test script on {UNIVERSE_ID}/{PLACE_ID}@{place_version}`)
print(`Task ID: {luau_execute.task_id(task)}`)
luau_execute.await_finish(task)
print("Output from task:\n")
local logs = luau_execute.get_structured_logs(task)
for _, log in logs do
if log.messageType == "OUTPUT" or log.messageType == "MESSAGE_TYPE_UNSPECIFIED" then
stdio.write(stdio.color("reset"))
elseif log.messageType == "INFO" then
stdio.write(stdio.color("cyan"))
elseif log.messageType == "WARNING" then
stdio.write(stdio.color("yellow"))
elseif log.messageType == "ERROR" then
stdio.write(stdio.color("red"))
end
stdio.write(log.message)
stdio.write(`{stdio.color("reset")}\n`)
end
local results = luau_execute.get_output(task)[1]
if not results then
error("plugin tests did not return any results")
end
local status = luau_execute.check_status(task)
if status == "COMPLETE" then
if results.failureCount == 0 then
process.exit(0)
else
process.exit(1)
end
else
print()
print("Task did not finish successfully")
local err = luau_execute.get_error(task)
if err then
print(`Error from task: {err.code}`)
print(err.message)
end
process.exit(1)
end

8
.vscode/extensions.json vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"luau-lsp.sourcemap.rojoProjectFile": "plugin.project.json",
"luau-lsp.sourcemap.autogenerate": true
}

View File

@@ -1,6 +1,221 @@
# Rojo Changelog # Rojo Changelog
## Unreleased Changes ## Unreleased
* Added fallback method for when an Instance can't be synced through normal means ([#1030])
This should make it possible to sync `MeshParts` and `Unions`!
The fallback involves deleting and recreating Instances. This will break
properties that reference them that Rojo does not know about, so be weary.
* Add auto-reconnect and improve UX for sync reminders ([#1096])
* Add support for syncing `yml` and `yaml` files (behaves similar to JSON and TOML) ([#1093])
* Fixed colors of Table diff ([#1084])
* Fixed `sourcemap` command outputting paths with OS-specific path separators ([#1085])
* Fixed nil -> nil properties showing up as failing to sync in plugin's patch visualizer ([#1081])
* Changed the background of the server's in-browser UI to be gray instead of white ([#1080])
* Fixed `Auto Connect Playtest Server` no longer functioning due to Roblox change ([#1066])
* Added an update indicator to the version header when a new version of the plugin is available. ([#1069])
* Added `--absolute` flag to the sourcemap subcommand, which will emit absolute paths instead of relative paths. ([#1092])
* Fixed applying `gameId` and `placeId` before initial sync was accepted ([#1104])
[#1030]: https://github.com/rojo-rbx/rojo/pull/1030
[#1096]: https://github.com/rojo-rbx/rojo/pull/1096
[#1093]: https://github.com/rojo-rbx/rojo/pull/1093
[#1084]: https://github.com/rojo-rbx/rojo/pull/1084
[#1085]: https://github.com/rojo-rbx/rojo/pull/1085
[#1081]: https://github.com/rojo-rbx/rojo/pull/1081
[#1080]: https://github.com/rojo-rbx/rojo/pull/1080
[#1066]: https://github.com/rojo-rbx/rojo/pull/1066
[#1069]: https://github.com/rojo-rbx/rojo/pull/1069
[#1092]: https://github.com/rojo-rbx/rojo/pull/1092
[#1104]: https://github.com/rojo-rbx/rojo/pull/1104
## 7.5.1 - April 25th, 2025
* Fixed output spam related to `Instance.Capabilities` in the plugin
## 7.5.0 - April 25th, 2025
* Fixed an edge case that caused model pivots to not be built correctly in some cases ([#1027])
* Add `blockedPlaceIds` project config field to allow blocking place ids from being live synced ([#1021])
* Adds support for `.plugin.lua(u)` files - this applies the `Plugin` RunContext. ([#1008])
* Added support for Roblox's `Content` type. This replaces the old `Content` type with `ContentId` to reflect Roblox's change.
If you were previously using the fully-qualified syntax for `Content` you will need to switch it to `ContentId`.
* Added support for `Enum` attributes
* Significantly improved performance of `.rbxm` parsing
* Support for a `$schema` field in all special JSON files (`.project.json`, `.model.json`, and `.meta.json`) ([#974])
* Projects may now manually link `Ref` properties together using `Attributes`. ([#843])
This has two parts: using `id` or `$id` in JSON files or a `Rojo_Target` attribute, an Instance
is given an ID. Then, that ID may be used elsewhere in the project to point to an Instance
using an attribute named `Rojo_Target_PROP_NAME`, where `PROP_NAME` is the name of a property.
As an example, here is a `model.json` for an ObjectValue that refers to itself:
```json
{
"id": "arbitrary string",
"attributes": {
"Rojo_Target_Value": "arbitrary string"
}
}
```
This is a very rough implementation and the usage will become more ergonomic
over time.
* Updated Undo/Redo history to be more robust ([#915])
* Added popout diff visualizer for table properties like Attributes and Tags ([#834])
* Updated Theme to use Studio colors ([#838])
* Improved patch visualizer UX ([#883])
* Added update notifications for newer compatible versions in the Studio plugin. ([#832])
* Added experimental setting for Auto Connect in playtests ([#840])
* Improved settings UI ([#886])
* `Open Scripts Externally` option can now be changed while syncing ([#911])
* The sync reminder notification will now tell you what was last synced and when ([#987])
* Fixed notification and tooltip text sometimes getting cut off ([#988])
* Projects may now specify rules for syncing files as if they had a different file extension. ([#813])
This is specified via a new field on project files, `syncRules`:
```json
{
"syncRules": [
{
"pattern": "*.foo",
"use": "text",
"exclude": "*.exclude.foo",
},
{
"pattern": "*.bar.baz",
"use": "json",
"suffix": ".bar.baz",
},
],
"name": "SyncRulesAreCool",
"tree": {
"$path": "src"
}
}
```
The `pattern` field is a glob used to match the sync rule to files. If present, the `suffix` field allows you to specify parts of a file's name get cut off by Rojo to name the Instance, including the file extension. If it isn't specified, Rojo will only cut off the first part of the file extension, up to the first dot.
Additionally, the `exclude` field allows files to be excluded from the sync rule if they match a pattern specified by it. If it's not present, all files that match `pattern` will be modified using the sync rule.
The `use` field corresponds to one of the potential file type that Rojo will currently include in a project. Files that match the provided pattern will be treated as if they had the file extension for that file type.
| `use` value | file extension |
|:---------------|:----------------|
| `serverScript` | `.server.lua` |
| `clientScript` | `.client.lua` |
| `moduleScript` | `.lua` |
| `json` | `.json` |
| `toml` | `.toml` |
| `csv` | `.csv` |
| `text` | `.txt` |
| `jsonModel` | `.model.json` |
| `rbxm` | `.rbxm` |
| `rbxmx` | `.rbxmx` |
| `project` | `.project.json` |
| `ignore` | None! |
Additionally, there are `use` values for specific script types ([#909]):
| `use` value | script type |
|:-------------------------|:---------------------------------------|
| `legacyServerScript` | `Script` with `Enum.RunContext.Legacy` |
| `legacyClientScript` | `LocalScript` |
| `runContextServerScript` | `Script` with `Enum.RunContext.Server` |
| `runContextClientScript` | `Script` with `Enum.RunContext.Client` |
| `pluginScript` | `Script` with `Enum.RunContext.Plugin` |
**All** sync rules are reset between project files, so they must be specified in each one when nesting them. This is to ensure that nothing can break other projects by changing how files are synced!
[#813]: https://github.com/rojo-rbx/rojo/pull/813
[#832]: https://github.com/rojo-rbx/rojo/pull/832
[#834]: https://github.com/rojo-rbx/rojo/pull/834
[#838]: https://github.com/rojo-rbx/rojo/pull/838
[#840]: https://github.com/rojo-rbx/rojo/pull/840
[#843]: https://github.com/rojo-rbx/rojo/pull/843
[#883]: https://github.com/rojo-rbx/rojo/pull/883
[#886]: https://github.com/rojo-rbx/rojo/pull/886
[#909]: https://github.com/rojo-rbx/rojo/pull/909
[#911]: https://github.com/rojo-rbx/rojo/pull/911
[#915]: https://github.com/rojo-rbx/rojo/pull/915
[#974]: https://github.com/rojo-rbx/rojo/pull/974
[#987]: https://github.com/rojo-rbx/rojo/pull/987
[#988]: https://github.com/rojo-rbx/rojo/pull/988
[#1008]: https://github.com/rojo-rbx/rojo/pull/1008
[#1021]: https://github.com/rojo-rbx/rojo/pull/1021
[#1027]: https://github.com/rojo-rbx/rojo/pull/1027
## [7.4.4] - August 22nd, 2024
* Fixed issue with reading attributes from `Lighting` in new place files
* `Instance.Archivable` will now default to `true` when building a project into a binary (`rbxm`/`rbxl`) file rather than `false`.
## [7.4.3] - August 6th, 2024
* Fixed issue with building binary files introduced in 7.4.2
* Fixed `value of type nil cannot be converted to number` warning spam in output. [#955]
[#955]: https://github.com/rojo-rbx/rojo/pull/955
## [7.4.2] - July 23, 2024
* Added Never option to Confirmation ([#893])
* Fixed removing trailing newlines ([#903])
* Updated the internal property database, correcting an issue with `SurfaceAppearance.Color` that was reported [here][Surface_Appearance_Color_1] and [here][Surface_Appearance_Color_2] ([#948])
[#893]: https://github.com/rojo-rbx/rojo/pull/893
[#903]: https://github.com/rojo-rbx/rojo/pull/903
[#948]: https://github.com/rojo-rbx/rojo/pull/948
[Surface_Appearance_Color_1]: https://devforum.roblox.com/t/jailbreak-custom-character-turned-shiny-black-no-texture/3075563
[Surface_Appearance_Color_2]: https://devforum.roblox.com/t/surfaceappearance-not-displaying-correctly/3075588
## [7.4.1] - February 20, 2024
* Made the `name` field optional on project files ([#870])
Files named `default.project.json` inherit the name of the folder they're in and all other projects
are named as expect (e.g. `foo.project.json` becomes an Instance named `foo`)
There is no change in behavior if `name` is set.
* Fixed incorrect results when building model pivots ([#865])
* Fixed incorrect results when serving model pivots ([#868])
* Rojo now converts any line endings to LF, preventing spurious diffs when syncing Lua files on Windows ([#854])
* Fixed Rojo plugin failing to connect when project contains certain unreadable properties ([#848])
* Fixed various cases where patch visualizer would not display sync failures ([#845], [#844])
* Fixed http error handling so Rojo can be used in Github Codespaces ([#847])
[#848]: https://github.com/rojo-rbx/rojo/pull/848
[#845]: https://github.com/rojo-rbx/rojo/pull/845
[#844]: https://github.com/rojo-rbx/rojo/pull/844
[#847]: https://github.com/rojo-rbx/rojo/pull/847
[#854]: https://github.com/rojo-rbx/rojo/pull/854
[#865]: https://github.com/rojo-rbx/rojo/pull/865
[#868]: https://github.com/rojo-rbx/rojo/pull/868
[#870]: https://github.com/rojo-rbx/rojo/pull/870
## [7.4.0] - January 16, 2024
* Improved the visualization for array properties like Tags ([#829])
* Significantly improved performance of `rojo serve`, `rojo build --watch`, and `rojo sourcemap --watch` on macOS. ([#830])
* Changed *.lua files that init command generates to *.luau ([#831])
* Does not remind users to sync if the sync lock is claimed already ([#833])
[#829]: https://github.com/rojo-rbx/rojo/pull/829
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#831]: https://github.com/rojo-rbx/rojo/pull/831
[#833]: https://github.com/rojo-rbx/rojo/pull/833
## [7.4.0-rc3] - October 25, 2023
* Changed `sourcemap --watch` to only generate the sourcemap when it's necessary ([#800])
* Switched script source property getter and setter to `ScriptEditorService` methods ([#801])
This ensures that the script editor reflects any changes Rojo makes to a script while it is open in the script editor.
* Fixed issues when handling `SecurityCapabilities` values ([#803], [#807])
* Fixed Rojo plugin erroring out when attempting to sync attributes with invalid names ([#809])
[#800]: https://github.com/rojo-rbx/rojo/pull/800
[#801]: https://github.com/rojo-rbx/rojo/pull/801
[#803]: https://github.com/rojo-rbx/rojo/pull/803
[#807]: https://github.com/rojo-rbx/rojo/pull/807
[#809]: https://github.com/rojo-rbx/rojo/pull/809
## [7.4.0-rc2] - October 3, 2023 ## [7.4.0-rc2] - October 3, 2023
* Fixed bug with parsing version for plugin validation ([#797]) * Fixed bug with parsing version for plugin validation ([#797])
@@ -123,7 +338,6 @@
* Add buttons for navigation on the Connected page ([#722]) * Add buttons for navigation on the Connected page ([#722])
### Fixes ### Fixes
* Significantly improved performance of `rojo serve` and `rojo build` on macOS. [#783]
* Significantly improved performance of `rojo sourcemap` ([#668]) * Significantly improved performance of `rojo sourcemap` ([#668])
* Fixed the diff visualizer of connected sessions. ([#674]) * Fixed the diff visualizer of connected sessions. ([#674])
* Fixed disconnected session activity. ([#675]) * Fixed disconnected session activity. ([#675])
@@ -157,7 +371,6 @@
[#770]: https://github.com/rojo-rbx/rojo/pull/770 [#770]: https://github.com/rojo-rbx/rojo/pull/770
[#771]: https://github.com/rojo-rbx/rojo/pull/771 [#771]: https://github.com/rojo-rbx/rojo/pull/771
[#774]: https://github.com/rojo-rbx/rojo/pull/774 [#774]: https://github.com/rojo-rbx/rojo/pull/774
[#783]: https://github.com/rojo-rbx/rojo/pull/783
[rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299 [rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299
[rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296 [rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296
@@ -514,7 +727,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019) ## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154)) * Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
* `Content` propertyes can now be specified in projects and model files as regular string literals. * `Content` properties can now be specified in projects and model files as regular string literals.
* Added support for `BrickColor` properties. * Added support for `BrickColor` properties.
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`. * Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
* Improved performance when working with XML models and places * Improved performance when working with XML models and places

View File

@@ -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.

1177
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.4.0-rc2" version = "7.5.1"
rust-version = "1.70.0" rust-version = "1.79.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
homepage = "https://rojo.space" homepage = "https://rojo.space"
@@ -26,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/*"]
@@ -40,7 +46,7 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" } memofs = { version = "0.3.0", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously # 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" }
@@ -49,68 +55,67 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
# 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.7.2" rbx_binary = "1.0.0"
rbx_dom_weak = "2.6.0" rbx_dom_weak = "3.0.0"
rbx_reflection = "4.4.0" rbx_reflection = "5.0.0"
rbx_reflection_database = "0.2.8" rbx_reflection_database = "1.0.3"
rbx_xml = "0.13.2" rbx_xml = "1.0.0"
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"] }
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"
num_cpus = "1.15.0" rayon = "1.9.0"
opener = "0.5.0" reqwest = { version = "0.11.24", default-features = false, features = [
rayon = "1.7.0"
reqwest = { version = "0.11.10", features = [
"blocking", "blocking",
"json", "json",
"native-tls-vendored", "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.114"
toml = "0.5.9" toml = "0.5.11"
termcolor = "1.1.2" termcolor = "1.4.1"
thiserror = "1.0.30" thiserror = "1.0.57"
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] } tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
uuid = { version = "1.0.0", features = ["v4", "serde"] } uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.1.18", features = ["derive"] } clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.6" profiling = "1.0.15"
tracy-client = { version = "0.13.2", optional = true } yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" winreg = "0.10.1"
[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.19" 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", "yaml"] } 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"

View File

@@ -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>&nbsp;</div> <div>&nbsp;</div>
@@ -43,4 +43,4 @@ Pull requests are welcome!
Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -1,5 +0,0 @@
[tools]
rojo = "rojo-rbx/rojo@7.3.0"
selene = "Kampfkarren/selene@0.25.0"
stylua = "JohnnyMorganz/stylua@0.18.2"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

Before

Width:  |  Height:  |  Size: 975 B

After

Width:  |  Height:  |  Size: 975 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 229 B

After

Width:  |  Height:  |  Size: 229 B

View File

Before

Width:  |  Height:  |  Size: 584 B

After

Width:  |  Height:  |  Size: 584 B

View File

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 607 B

View File

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

View File

@@ -20,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") {
@@ -41,31 +45,29 @@ 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 our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?; let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let plugin_version = let plugin_version =
Version::parse(fs::read_to_string(&plugin_root.join("Version.txt"))?.trim())?; Version::parse(fs::read_to_string(plugin_dir.join("Version.txt"))?.trim())?;
assert!( assert_eq!(
our_version.major == plugin_version.major, our_version, plugin_version,
"plugin version does not match Cargo version"
);
assert!(
our_version.minor == plugin_version.minor,
"plugin version does not match Cargo version" "plugin version does not match Cargo version"
); );
let snapshot = VfsSnapshot::dir(hashmap! { let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?, "default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?, "plugin" => VfsSnapshot::dir(hashmap! {
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?, "fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?, "http" => snapshot_from_fs_path(&plugin_dir.join("http"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?, "log" => snapshot_from_fs_path(&plugin_dir.join("log"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?, "rbx_dom_lua" => snapshot_from_fs_path(&plugin_dir.join("rbx_dom_lua"))?,
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?, "src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_root.join("Version.txt"))?, "Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
}),
}); });
let out_path = Path::new(&out_dir).join("plugin.bincode"); let out_path = Path::new(&out_dir).join("plugin.bincode");

View File

@@ -1,7 +1,13 @@
# memofs Changelog # memofs Changelog
## Unreleased Changes ## Unreleased Changes
* Changed the `StdBackend` file watcher to use `PollWatcher` on macOS.
## 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.

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.2.0" version = "0.3.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
@@ -11,7 +15,7 @@ homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # 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"] }

View File

@@ -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;
@@ -155,6 +155,24 @@ 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 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();
@@ -258,6 +276,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].
@@ -428,3 +473,23 @@ impl VfsLock<'_> {
self.inner.commit_event(event) self.inner.commit_event(event)
} }
} }
#[cfg(test)]
mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot};
/// https://github.com/rojo-rbx/rojo/issues/899
#[test]
fn read_to_string_lf_normalized_keeps_trailing_newline() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("test", VfsSnapshot::file("bar\r\nfoo\r\n\r\n"))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.read_to_string_lf_normalized("test").unwrap().as_str(),
"bar\nfoo\n\n"
);
}
}

View File

@@ -1,38 +1,24 @@
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::{DebouncedEvent, RecursiveMode, Watcher}; use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
#[cfg(target_os = "macos")]
use notify::PollWatcher;
#[cfg(not(target_os = "macos"))]
use notify::{watcher, RecommendedWatcher};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent}; use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
/// `VfsBackend` that uses `std::fs` and the `notify` crate. /// `VfsBackend` that uses `std::fs` and the `notify` crate.
pub struct StdBackend { pub struct StdBackend {
// We use PollWatcher on macos because using the KQueue watcher
// can cause some gnarly performance problems.
#[cfg(target_os = "macos")]
watcher: PollWatcher,
#[cfg(not(target_os = "macos"))]
watcher: RecommendedWatcher, watcher: RecommendedWatcher,
watcher_receiver: Receiver<VfsEvent>, watcher_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>,
} }
impl StdBackend { impl StdBackend {
pub fn new() -> StdBackend { pub fn new() -> StdBackend {
let (notify_tx, notify_rx) = mpsc::channel(); let (notify_tx, notify_rx) = mpsc::channel();
#[cfg(target_os = "macos")]
let watcher = PollWatcher::new(notify_tx, Duration::from_millis(50)).unwrap();
#[cfg(not(target_os = "macos"))]
let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap(); let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap();
let (tx, rx) = crossbeam_channel::unbounded(); let (tx, rx) = crossbeam_channel::unbounded();
@@ -63,6 +49,7 @@ impl StdBackend {
Self { Self {
watcher, watcher,
watcher_receiver: rx, watcher_receiver: rx,
watches: HashSet::new(),
} }
} }
} }
@@ -112,12 +99,22 @@ impl VfsBackend for StdBackend {
} }
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(|inner| io::Error::new(io::ErrorKind::Other, inner))
}
} }
fn unwatch(&mut self, path: &Path) -> io::Result<()> { fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path);
self.watcher self.watcher
.unwatch(path) .unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner)) .map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))

View File

@@ -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"

View File

@@ -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;

View File

@@ -3,25 +3,25 @@
"tree": { "tree": {
"$className": "Folder", "$className": "Folder",
"Plugin": { "Plugin": {
"$path": "src" "$path": "plugin/src"
}, },
"Packages": { "Packages": {
"$path": "Packages", "$path": "plugin/Packages",
"Log": { "Log": {
"$path": "log" "$path": "plugin/log"
}, },
"Http": { "Http": {
"$path": "http" "$path": "plugin/http"
}, },
"Fmt": { "Fmt": {
"$path": "fmt" "$path": "plugin/fmt"
}, },
"RbxDom": { "RbxDom": {
"$path": "rbx_dom_lua" "$path": "plugin/rbx_dom_lua"
} }
}, },
"Version": { "Version": {
"$path": "Version.txt" "$path": "plugin/Version.txt"
} }
} }
} }

View File

@@ -1 +1 @@
7.4.0-rc2 7.5.1

View File

@@ -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.
@@ -242,4 +242,4 @@ return {
debugOutputBuffer = debugOutputBuffer, debugOutputBuffer = debugOutputBuffer,
fmt = fmt, fmt = fmt,
debugify = debugify, debugify = debugify,
} }

View File

@@ -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

View File

@@ -31,4 +31,4 @@ function Response:json()
return HttpService:JSONDecode(self.body) return HttpService:JSONDecode(self.body)
end end
return Response return Response

View File

@@ -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))
@@ -63,4 +68,4 @@ function Http.jsonDecode(source)
return HttpService:JSONDecode(source) return HttpService:JSONDecode(source)
end end
return Http return Http

View File

@@ -2,4 +2,4 @@ return function()
it("should load", function() it("should load", function()
require(script.Parent) require(script.Parent)
end) end)
end end

View File

@@ -57,4 +57,4 @@ function Log.error(template, ...)
error(Fmt.fmt(template, ...)) error(Fmt.fmt(template, ...))
end end
return Log return Log

View File

@@ -2,4 +2,4 @@ return function()
it("should load", function() it("should load", function()
require(script.Parent) require(script.Parent)
end) end)
end end

View File

@@ -188,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,
}, },
@@ -205,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 = {}
@@ -300,7 +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(keypoint.time, keypoint.value, keypoint.envelope) -- TODO: Add a test for NaN or Infinity values and envelopes
-- Right now it isn't possible because it'd fail the roundtrip.
-- It's more important that it works right now, though.
local value = keypoint.value or 0
local envelope = keypoint.envelope or 0
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, value, envelope)
end end
return NumberSequence.new(keypoints) return NumberSequence.new(keypoints)
@@ -412,15 +462,6 @@ types = {
end, end,
}, },
SecurityCapabilities = {
fromPod = function(_pod)
error("SecurityCapabilities is not implemented")
end,
toPod = function(_roblox)
error("SecurityCapabilities is not implemented")
end,
},
SharedString = { SharedString = {
fromPod = function(_pod) fromPod = function(_pod)
error("SharedString is not supported") error("SharedString is not supported")
@@ -502,9 +543,32 @@ types = {
}, },
} }
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)

View File

@@ -5,6 +5,7 @@ Error.Kind = {
UnknownProperty = "UnknownProperty", UnknownProperty = "UnknownProperty",
PropertyNotReadable = "PropertyNotReadable", PropertyNotReadable = "PropertyNotReadable",
PropertyNotWritable = "PropertyNotWritable", PropertyNotWritable = "PropertyNotWritable",
CannotParseBinaryString = "CannotParseBinaryString",
Roblox = "Roblox", Roblox = "Roblox",
} }

View File

@@ -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": [
@@ -370,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": {

View File

@@ -1,4 +1,7 @@
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 --- A list of `Enum.Material` values that are used for Terrain.MaterialColors
local TERRAIN_MATERIAL_COLORS = { local TERRAIN_MATERIAL_COLORS = {
@@ -25,6 +28,21 @@ local TERRAIN_MATERIAL_COLORS = {
Enum.Material.Pavement, 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.
-- --
-- The reflection database refers to these as having scriptability = "Custom" -- The reflection database refers to these as having scriptability = "Custom"
@@ -35,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 = {
@@ -95,6 +139,19 @@ return {
return true, instance:ScaleTo(value) return true, instance:ScaleTo(value)
end, 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 = { Terrain = {
MaterialColors = { MaterialColors = {
@@ -109,9 +166,44 @@ return {
return true, colors return true, colors
end, end,
write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 }) 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 for material, color in value do
instance:SetMaterialColor(material, color) instance:SetMaterialColor(material, color)
end 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 return true
end, end,
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
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
@@ -8,4 +8,12 @@ local Settings = require(Rojo.Plugin.Settings)
Settings:set("logLevel", "Trace") Settings:set("logLevel", "Trace")
Settings:set("typecheckingEnabled", true) Settings:set("typecheckingEnabled", true)
require(Rojo.Plugin.runTests)(TestEZ) local results = require(Rojo.Plugin.runTests)(TestEZ)
-- Roblox's Luau execution gets mad about cyclical tables.
-- Rather than making TestEZ not do that, we just send back the important info.
return {
failureCount = results.failureCount,
successCount = results.successCount,
skippedCount = results.skippedCount,
}

View File

@@ -10,6 +10,8 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse) local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
@@ -45,14 +47,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 = {}
@@ -62,10 +57,30 @@ local function rejectWrongPlaceId(infoResponseBody)
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(tostring(game.PlaceId), table.concat(idList, "\n")) ):format(game.PlaceId, table.concat(idList, "\n"))
return Promise.reject(message)
end
end
if infoResponseBody.unexpectedPlaceIds ~= nil then
local foundId = table.find(infoResponseBody.unexpectedPlaceIds, game.PlaceId)
if foundId then
local idList = {}
for _, id in ipairs(infoResponseBody.unexpectedPlaceIds) do
table.insert(idList, "- " .. tostring(id))
end
local message = (
"Found a Rojo server, but its project is set to not be used with a specific list of places."
.. "\nYour place ID is %u, but needs to not be one of these:"
.. "\n%s"
.. "\n\nTo change this list, edit 'blockedPlaceIds' in your .project.json file."
):format(game.PlaceId, table.concat(idList, "\n"))
return Promise.reject(message) return Promise.reject(message)
end end
@@ -185,10 +200,10 @@ function ApiContext:write(patch)
body = Http.jsonEncode(body) body = Http.jsonEncode(body)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body) return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
Log.info("Write response: {:?}", body) Log.info("Write response: {:?}", responseBody)
return body return responseBody
end) end)
end end
@@ -239,4 +254,32 @@ function ApiContext:open(id)
end) end)
end end
function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSerialize(body))
return body
end)
end
function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRefPatch(body))
return body
end)
end
return ApiContext return ApiContext

View File

@@ -32,7 +32,7 @@ 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)
@@ -57,20 +57,21 @@ function Checkbox:render()
end, end,
}, { }, {
StateTip = e(Tooltip.Trigger, { StateTip = e(Tooltip.Trigger, {
text = (if self.props.locked then "[LOCKED] " else "") text = (if self.props.locked
.. (if self.props.active then "Enabled" else "Disabled"), 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 = if self.props.locked then Assets.Images.Checkbox.Locked else 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),
@@ -83,7 +84,7 @@ 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),
}, { }, {
@@ -91,7 +92,7 @@ function Checkbox:render()
Image = if self.props.locked Image = if self.props.locked
then Assets.Images.Checkbox.Locked then Assets.Images.Checkbox.Locked
else Assets.Images.Checkbox.Inactive, else Assets.Images.Checkbox.Inactive,
ImageColor3 = theme.Inactive.IconColor, 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),

View File

@@ -0,0 +1,126 @@
local StudioService = game:GetService("StudioService")
local AssetService = game:GetService("AssetService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local e = Roact.createElement
local EditableImage = require(Plugin.App.Components.EditableImage)
local imageCache = {}
local function getImageSizeAndPixels(image)
if not imageCache[image] then
local editableImage = AssetService:CreateEditableImageAsync(image)
imageCache[image] = {
Size = editableImage.Size,
Pixels = editableImage:ReadPixels(Vector2.zero, editableImage.Size),
}
end
return imageCache[image].Size, table.clone(imageCache[image].Pixels)
end
local function getRecoloredClassIcon(className, color)
local iconProps = StudioService:GetClassIcon(className)
if iconProps and color then
local success, editableImageSize, editableImagePixels = pcall(function()
local size, pixels = getImageSizeAndPixels(iconProps.Image)
local minVal, maxVal = math.huge, -math.huge
for i = 1, #pixels, 4 do
if pixels[i + 3] == 0 then
continue
end
local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
minVal = math.min(minVal, pixelVal)
maxVal = math.max(maxVal, pixelVal)
end
local hue, sat, val = color:ToHSV()
for i = 1, #pixels, 4 do
if pixels[i + 3] == 0 then
continue
end
local pixelVal = math.max(pixels[i], pixels[i + 1], pixels[i + 2])
local newVal = val
if minVal < maxVal then
-- Remap minVal - maxVal to val*0.9 - val
newVal = val * (0.9 + 0.1 * (pixelVal - minVal) / (maxVal - minVal))
end
local newPixelColor = Color3.fromHSV(hue, sat, newVal)
pixels[i], pixels[i + 1], pixels[i + 2] = newPixelColor.R, newPixelColor.G, newPixelColor.B
end
return size, pixels
end)
if success then
iconProps.EditableImagePixels = editableImagePixels
iconProps.EditableImageSize = editableImageSize
end
end
return iconProps
end
local ClassIcon = Roact.PureComponent:extend("ClassIcon")
function ClassIcon:init()
self.state = {
iconProps = nil,
}
end
function ClassIcon:updateIcon()
local props = self.props
local iconProps = getRecoloredClassIcon(props.className, props.color)
self:setState({
iconProps = iconProps,
})
end
function ClassIcon:didMount()
self:updateIcon()
end
function ClassIcon:didUpdate(lastProps)
if lastProps.className ~= self.props.className or lastProps.color ~= self.props.color then
self:updateIcon()
end
end
function ClassIcon:render()
local iconProps = self.state.iconProps
if not iconProps then
return nil
end
return e(
"ImageLabel",
{
Size = self.props.size,
Position = self.props.position,
LayoutOrder = self.props.layoutOrder,
AnchorPoint = self.props.anchorPoint,
ImageTransparency = self.props.transparency,
Image = iconProps.Image,
ImageRectOffset = iconProps.ImageRectOffset,
ImageRectSize = iconProps.ImageRectSize,
BackgroundTransparency = 1,
},
if iconProps.EditableImagePixels
then e(EditableImage, {
size = iconProps.EditableImageSize,
pixels = iconProps.EditableImagePixels,
})
else nil
)
end
return ClassIcon

View File

@@ -1,4 +1,5 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
@@ -7,6 +8,8 @@ Highlighter.matchStudioSettings()
local e = Roact.createElement local e = Roact.createElement
local Theme = require(Plugin.App.Theme)
local CodeLabel = Roact.PureComponent:extend("CodeLabel") local CodeLabel = Roact.PureComponent:extend("CodeLabel")
function CodeLabel:init() function CodeLabel:init()
@@ -40,22 +43,24 @@ function CodeLabel:updateHighlights()
end end
function CodeLabel:render() function CodeLabel:render()
return e("TextLabel", { return Theme.with(function(theme)
Size = self.props.size, return e("TextLabel", {
Position = self.props.position, Size = self.props.size,
Text = self.props.text, Position = self.props.position,
BackgroundTransparency = 1, Text = self.props.text,
Font = Enum.Font.RobotoMono, BackgroundTransparency = 1,
TextSize = 16, FontFace = theme.Font.Code,
TextXAlignment = Enum.TextXAlignment.Left, TextSize = theme.TextSize.Code,
TextYAlignment = Enum.TextYAlignment.Top, TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Color3.fromRGB(255, 255, 255), TextYAlignment = Enum.TextYAlignment.Top,
[Roact.Ref] = self.labelRef, TextColor3 = Color3.fromRGB(255, 255, 255),
}, { [Roact.Ref] = self.labelRef,
SyntaxHighlights = e("Folder", { }, {
[Roact.Ref] = self.highlightsRef, SyntaxHighlights = e("Folder", {
}), [Roact.Ref] = self.highlightsRef,
}) }),
})
end)
end end
return CodeLabel return CodeLabel

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -10,9 +8,11 @@ local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil) local bindingUtil = require(Plugin.App.bindingUtil)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local SlicedImage = require(script.Parent.SlicedImage) local SlicedImage = require(script.Parent.SlicedImage)
local ScrollingFrame = require(script.Parent.ScrollingFrame) local ScrollingFrame = require(script.Parent.ScrollingFrame)
local Tooltip = require(script.Parent.Tooltip)
local e = Roact.createElement local e = Roact.createElement
@@ -44,29 +44,29 @@ end
function Dropdown:render() function Dropdown:render()
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.Dropdown local dropdownTheme = theme.Dropdown
local optionButtons = {} local optionButtons = {}
local width = -1 local width = -1
for i, option in self.props.options do for i, option in self.props.options do
local text = tostring(option or "") local text = tostring(option or "")
local textSize = TextService:GetTextSize(text, 15, Enum.Font.GothamMedium, Vector2.new(math.huge, 20)) local textBounds = getTextBoundsAsync(text, theme.Font.Main, theme.TextSize.Body, math.huge)
if textSize.X > width then if textBounds.X > width then
width = textSize.X width = textBounds.X
end end
optionButtons[text] = e("TextButton", { optionButtons[text] = e("TextButton", {
Text = text, Text = text,
LayoutOrder = i, LayoutOrder = i,
Size = UDim2.new(1, 0, 0, 24), Size = UDim2.new(1, 0, 0, 24),
BackgroundColor3 = theme.BackgroundColor, BackgroundColor3 = dropdownTheme.BackgroundColor,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
BackgroundTransparency = self.props.transparency, BackgroundTransparency = self.props.transparency,
BorderSizePixel = 0, BorderSizePixel = 0,
TextColor3 = theme.TextColor, TextColor3 = dropdownTheme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15, TextSize = theme.TextSize.Body,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
[Roact.Event.Activated] = function() [Roact.Event.Activated] = function()
if self.props.locked then if self.props.locked then
@@ -103,15 +103,13 @@ function Dropdown:render()
}, { }, {
Border = e(SlicedImage, { Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder, slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor, color = dropdownTheme.BorderColor,
transparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}, { }, {
DropArrow = e("ImageLabel", { DropArrow = e("ImageLabel", {
Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow, Image = if self.props.locked then Assets.Images.Dropdown.Locked else Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a) ImageColor3 = dropdownTheme.IconColor,
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),
ImageTransparency = self.props.transparency, ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 18, 0, 18), Size = UDim2.new(0, 18, 0, 18),
@@ -122,15 +120,21 @@ function Dropdown:render()
end), end),
BackgroundTransparency = 1, 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", { Active = e("TextLabel", {
Size = UDim2.new(1, -30, 1, 0), Size = UDim2.new(1, -30, 1, 0),
Position = UDim2.new(0, 6, 0, 0), Position = UDim2.new(0, 6, 0, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Text = self.props.active, Text = self.props.active,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 15, TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor, TextColor3 = dropdownTheme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
}), }),
@@ -138,7 +142,7 @@ function Dropdown:render()
Options = if self.state.open Options = if self.state.open
then e(SlicedImage, { then e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = theme.BackgroundColor, color = dropdownTheme.BackgroundColor,
position = UDim2.new(1, 0, 1, 3), position = UDim2.new(1, 0, 1, 3),
size = self.openBinding:map(function(a) size = self.openBinding:map(function(a)
return UDim2.new(1, 0, a * math.min(3, #self.props.options), 0) return UDim2.new(1, 0, a * math.min(3, #self.props.options), 0)
@@ -147,7 +151,7 @@ function Dropdown:render()
}, { }, {
Border = e(SlicedImage, { Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder, slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor, color = dropdownTheme.BorderColor,
transparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
}), }),
@@ -167,7 +171,7 @@ function Dropdown:render()
self.setContentSize(object.AbsoluteContentSize) self.setContentSize(object.AbsoluteContentSize)
end, end,
}), }),
Roact.createFragment(optionButtons), Options = Roact.createFragment(optionButtons),
}), }),
}) })
else nil, else nil,

View File

@@ -0,0 +1,41 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local e = Roact.createElement
local EditableImage = Roact.PureComponent:extend("EditableImage")
function EditableImage:init()
self.ref = Roact.createRef()
end
function EditableImage:writePixels()
local image = self.ref.current
if not image then
return
end
if not self.props.pixels then
return
end
image:WritePixels(Vector2.zero, self.props.size, self.props.pixels)
end
function EditableImage:render()
return e("EditableImage", {
Size = self.props.size,
[Roact.Ref] = self.ref,
})
end
function EditableImage:didMount()
self:writePixels()
end
function EditableImage:didUpdate()
self:writePixels()
end
return EditableImage

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
local TextService = game:GetService("TextService")
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
@@ -9,16 +8,14 @@ local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper) local Flipper = require(Packages.Flipper)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local bindingUtil = require(script.Parent.bindingUtil)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) 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 BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis
local e = Roact.createElement local e = Roact.createElement
local Notification = Roact.Component:extend("Notification") local Notification = Roact.Component:extend("Notification")
@@ -78,7 +75,9 @@ function Notification:didMount()
end end
function Notification:willUnmount() function Notification:willUnmount()
task.cancel(self.timeout) if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end end
function Notification:render() function Notification:render()
@@ -86,51 +85,49 @@ function Notification:render()
return 1 - value return 1 - value
end) end)
local textBounds = TextService:GetTextSize(self.props.text, 15, Enum.Font.GothamMedium, Vector2.new(350, 700)) 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,
})
local actionButtons = {} buttonsX += getTextBoundsAsync(action.text, theme.Font.Main, theme.TextSize.Large, math.huge).X + (theme.TextSize.Body * 2)
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()
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end,
layoutOrder = -action.layoutOrder,
transparency = transparency,
})
buttonsX += TextService:GetTextSize( count += 1
action.text, end
18,
Enum.Font.GothamMedium,
Vector2.new(math.huge, math.huge)
).X + 30
count += 1 buttonsX += (count - 1) * 5
end end
buttonsX += (count - 1) * 5 local paddingY, logoSize = 20, 32
end 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 paddingY, logoSize = 20, 32 local size = self.binding:map(function(value)
local actionsY = if self.props.actions then 35 else 0 return UDim2.fromOffset(
local contentX = math.max(textBounds.X, buttonsX) (35 + 40 + contentX) * value,
5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
)
end)
local size = self.binding:map(function(value)
return UDim2.fromOffset(
(35 + 40 + contentX) * value,
5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
)
end)
return Theme.with(function(theme)
return e("TextButton", { return e("TextButton", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = size, Size = size,
@@ -144,31 +141,31 @@ function Notification:render()
}, { }, {
e(BorderedContainer, { e(BorderedContainer, {
transparency = transparency, transparency = transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.fromScale(1, 1),
}, { }, {
Contents = e("Frame", { Contents = e("Frame", {
Size = UDim2.new(0, 35 + contentX, 1, -paddingY), Size = UDim2.fromScale(1, 1),
Position = UDim2.new(0, 0, 0, paddingY / 2),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Logo = e("ImageLabel", { Logo = e("ImageLabel", {
ImageTransparency = transparency, ImageTransparency = transparency,
Image = Assets.Images.PluginButton, Image = Assets.Images.PluginButton,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, logoSize, 0, logoSize), Size = UDim2.fromOffset(logoSize, logoSize),
Position = UDim2.new(0, 0, 0, 0), Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0), AnchorPoint = Vector2.new(0, 0),
}), }),
Info = e("TextLabel", { Info = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 15, TextSize = theme.TextSize.Body,
TextColor3 = theme.Notification.InfoColor, TextColor3 = theme.Notification.InfoColor,
TextTransparency = transparency, TextTransparency = transparency,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
TextWrapped = true, TextWrapped = true,
Size = UDim2.new(0, textBounds.X, 0, textBounds.Y), Size = UDim2.new(0, textBounds.X, 1, -actionsY),
Position = UDim2.fromOffset(35, 0), Position = UDim2.fromOffset(35, 0),
LayoutOrder = 1, LayoutOrder = 1,
@@ -176,8 +173,8 @@ function Notification:render()
}), }),
Actions = if self.props.actions Actions = if self.props.actions
then e("Frame", { then e("Frame", {
Size = UDim2.new(1, -40, 0, 35), Size = UDim2.new(1, -40, 0, actionsY),
Position = UDim2.new(1, 0, 1, 0), Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1), AnchorPoint = Vector2.new(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
@@ -196,32 +193,12 @@ function Notification:render()
Padding = e("UIPadding", { Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17), PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15), PaddingRight = UDim.new(0, 15),
PaddingTop = UDim.new(0, paddingY / 2),
PaddingBottom = UDim.new(0, paddingY / 2),
}), }),
}), }),
}) })
end) end)
end end
local Notifications = Roact.Component:extend("Notifications") return Notification
function Notifications:render()
local notifs = {}
for id, notif in self.props.notifications do
notifs["NotifID_" .. id] = e(Notification, {
soundPlayer = self.props.soundPlayer,
text = notif.text,
timestamp = notif.timestamp,
timeout = notif.timeout,
actions = notif.actions,
layoutOrder = (notif.timestamp - baseClock),
onClose = function()
self.props.onClose(id)
end,
})
end
return Roact.createFragment(notifs)
end
return Notifications

View File

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

View File

@@ -14,6 +14,123 @@ local EMPTY_TABLE = {}
local e = Roact.createElement 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") local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init() function ChangeList:init()
@@ -36,8 +153,9 @@ function ChangeList:render()
PaddingRight = UDim.new(0, 5), PaddingRight = UDim.new(0, 5),
} }
local headerRow = changes[1]
local headers = e("Frame", { local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30), Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = rowTransparency, BackgroundTransparency = rowTransparency,
BackgroundColor3 = theme.Diff.Row, BackgroundColor3 = theme.Diff.Row,
LayoutOrder = 0, LayoutOrder = 0,
@@ -49,36 +167,36 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left, HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
}), }),
A = e("TextLabel", { ColumnA = e("TextLabel", {
Text = tostring(changes[1][1]), Text = tostring(headerRow[1]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, FontFace = theme.Font.Bold,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0), Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1, LayoutOrder = 1,
}), }),
B = e("TextLabel", { ColumnB = e("TextLabel", {
Text = tostring(changes[1][2]), Text = tostring(headerRow[2]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, FontFace = theme.Font.Bold,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0), Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2, LayoutOrder = 2,
}), }),
C = e("TextLabel", { ColumnC = e("TextLabel", {
Text = tostring(changes[1][3]), Text = tostring(headerRow[3]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamBold, FontFace = theme.Font.Bold,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
@@ -95,91 +213,8 @@ function ChangeList:render()
local metadata = values[4] or EMPTY_TABLE local metadata = values[4] or EMPTY_TABLE
local isWarning = metadata.isWarning local isWarning = metadata.isWarning
-- Special case for .Source updates
-- because we want to display a syntax highlighted diff for better UX
if self.props.showSourceDiff and tostring(values[1]) == "Source" then
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
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,
}),
A = e("TextLabel", {
Text = (if isWarning then "" else "") .. tostring(values[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
Button = e("TextButton", {
Text = "",
Size = UDim2.new(0.7, 0, 1, -4),
LayoutOrder = 2,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
if props.showSourceDiff then
props.showSourceDiff(tostring(values[2]), tostring(values[3]))
end
end,
}, {
e(BorderedContainer, {
size = UDim2.new(1, 0, 1, 0),
transparency = self.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,
Font = Enum.Font.GothamMedium,
TextSize = 14,
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 = self.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,
}),
}),
}),
})
continue
end
rows[row] = e("Frame", { rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30), Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1, BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row, BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0, BorderSizePixel = 0,
@@ -192,44 +227,25 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left, HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
}), }),
A = e("TextLabel", { ColumnA = e("TextLabel", {
Text = (if isWarning then "" else "") .. tostring(values[1]), Text = (if isWarning then "" else "") .. tostring(values[1]),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, TextColor3 = if isWarning then theme.Diff.Warning else theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0), Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1, LayoutOrder = 1,
}), }),
B = e( Content = e(RowContent, {
"Frame", values = values,
{ metadata = metadata,
BackgroundTransparency = 1, transparency = props.transparency,
Size = UDim2.new(0.35, 0, 1, 0), showStringDiff = props.showStringDiff,
LayoutOrder = 2, showTableDiff = props.showTableDiff,
}, }),
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
C = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
})
),
}) })
end end
@@ -253,8 +269,8 @@ function ChangeList:render()
}, { }, {
Headers = headers, Headers = headers,
Values = e(ScrollingFrame, { Values = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -30), size = UDim2.new(1, 0, 1, -24),
position = UDim2.new(0, 0, 0, 30), position = UDim2.new(0, 0, 0, 24),
contentSize = self.contentSize, contentSize = self.contentSize,
transparency = props.transparency, transparency = props.transparency,
}, rows), }, rows),

View File

@@ -30,10 +30,10 @@ local function DisplayValue(props)
}), }),
}), }),
Label = e("TextLabel", { Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255), Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = props.textColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
@@ -53,8 +53,23 @@ local function DisplayValue(props)
elseif next(props.value) == nil then elseif next(props.value) == nil then
-- If it's empty, show empty braces -- If it's empty, show empty braces
textRepresentation = "{}" 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 else
-- If it has children, list them out -- Otherwise, show the table contents as a dictionary
local out, i = {}, 0 local out, i = {}, 0
for k, v in pairs(props.value) do for k, v in pairs(props.value) do
i += 1 i += 1
@@ -75,8 +90,8 @@ local function DisplayValue(props)
return e("TextLabel", { return e("TextLabel", {
Text = textRepresentation, Text = textRepresentation,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = props.textColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
@@ -89,11 +104,16 @@ local function DisplayValue(props)
-- Or special text handling tostring for some? -- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise. -- 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", { return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "), Text = textRepresentation,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = props.textColor, TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,

View File

@@ -1,5 +1,4 @@
local SelectionService = game:GetService("Selection") local SelectionService = game:GetService("Selection")
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
@@ -15,7 +14,8 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList) local ChangeList = require(script.Parent.ChangeList)
local Tooltip = require(script.Parent.Parent.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local ClassIcon = require(Plugin.App.Components.ClassIcon)
local Expansion = Roact.Component:extend("Expansion") local Expansion = Roact.Component:extend("Expansion")
@@ -28,13 +28,14 @@ function Expansion:render()
return e("Frame", { return e("Frame", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, -props.indent, 1, -30), Size = UDim2.new(1, -props.indent, 1, -24),
Position = UDim2.new(0, props.indent, 0, 30), Position = UDim2.new(0, props.indent, 0, 24),
}, { }, {
ChangeList = e(ChangeList, { ChangeList = e(ChangeList, {
changes = props.changeList, changes = props.changeList,
transparency = props.transparency, transparency = props.transparency,
showSourceDiff = props.showSourceDiff, showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}), }),
}) })
end end
@@ -43,7 +44,7 @@ local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init() function DomLabel:init()
local initHeight = self.props.elementHeight:getValue() local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 30 self.expanded = initHeight > 24
self.motor = Flipper.SingleMotor.new(initHeight) self.motor = Flipper.SingleMotor.new(initHeight)
self.binding = bindingUtil.fromMotor(self.motor) self.binding = bindingUtil.fromMotor(self.motor)
@@ -52,7 +53,7 @@ function DomLabel:init()
renderExpansion = self.expanded, renderExpansion = self.expanded,
}) })
self.motor:onStep(function(value) self.motor:onStep(function(value)
local renderExpansion = value > 30 local renderExpansion = value > 24
self.props.setElementHeight(value) self.props.setElementHeight(value)
if self.props.updateEvent then if self.props.updateEvent then
@@ -80,7 +81,7 @@ function DomLabel:didUpdate(prevProps)
then then
-- Close the expansion when the domlabel is changed to a different thing -- Close the expansion when the domlabel is changed to a different thing
self.expanded = false self.expanded = false
self.motor:setGoal(Flipper.Spring.new(30, { self.motor:setGoal(Flipper.Spring.new(24, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
})) }))
@@ -89,33 +90,59 @@ end
function DomLabel:render() function DomLabel:render()
local props = self.props local props = self.props
local depth = props.depth or 1
return Theme.with(function(theme) return Theme.with(function(theme)
local iconProps = StudioService:GetClassIcon(props.className) local color = if props.isWarning
local indent = (props.depth or 0) * 20 + 25 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 -- Line guides help indent depth remain readable
local lineGuides = {} local lineGuides = {}
for i = 1, props.depth or 0 do for i = 2, depth do
table.insert( if props.depthsComplete[i] then
lineGuides, continue
e("Frame", { end
Name = "Line_" .. i, if props.isFinalChild and i == depth then
Size = UDim2.new(0, 2, 1, 2), -- This line stops halfway down to merge with our connector for the right angle
Position = UDim2.new(0, (20 * i) + 15, 0, -1), lineGuides["Line_" .. i] = e("Frame", {
Size = UDim2.new(0, 2, 0, 15),
Position = UDim2.new(0, (12 * (i - 1)) + 6, 0, -1),
BorderSizePixel = 0, BorderSizePixel = 0,
BackgroundTransparency = props.transparency, BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor, 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 end
return e("Frame", { return e("Frame", {
Name = "Change",
ClipsDescendants = true, ClipsDescendants = true,
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil, BackgroundTransparency = if props.elementIndex % 2 == 0 then 0.985 else 1,
BorderSizePixel = 0, BackgroundColor3 = theme.Diff.Row,
BackgroundTransparency = props.patchType and props.transparency or 1,
Size = self.binding:map(function(expand) Size = self.binding:map(function(expand)
return UDim2.new(1, 0, 0, expand) return UDim2.new(1, 0, 0, expand)
end), end),
@@ -145,8 +172,8 @@ function DomLabel:render()
if props.changeList then if props.changeList then
self.expanded = not self.expanded self.expanded = not self.expanded
local goalHeight = 30 local goalHeight = 24
+ (if self.expanded then math.clamp(#props.changeList * 30, 30, 30 * 6) else 0) + (if self.expanded then math.clamp(#props.changeList * 24, 24, 24 * 6) else 0)
self.motor:setGoal(Flipper.Spring.new(goalHeight, { self.motor:setGoal(Flipper.Spring.new(goalHeight, {
frequency = 5, frequency = 5,
dampingRatio = 1, dampingRatio = 1,
@@ -171,46 +198,81 @@ function DomLabel:render()
indent = indent, indent = indent,
transparency = props.transparency, transparency = props.transparency,
changeList = props.changeList, changeList = props.changeList,
showSourceDiff = props.showSourceDiff, showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}) })
else nil, else nil,
DiffIcon = if props.patchType DiffIcon = if props.patchType
then e("ImageLabel", { then e("ImageLabel", {
Image = Assets.Images.Diff[props.patchType], Image = Assets.Images.Diff[props.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor, ImageColor3 = color,
ImageTransparency = props.transparency, ImageTransparency = props.transparency,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20), Size = UDim2.new(0, 14, 0, 14),
Position = UDim2.new(0, 0, 0, 15), Position = UDim2.new(0, 0, 0, 12),
AnchorPoint = Vector2.new(0, 0.5), AnchorPoint = Vector2.new(0, 0.5),
}) })
else nil, else nil,
ClassIcon = e("ImageLabel", { ClassIcon = e(ClassIcon, {
Image = iconProps.Image, className = props.className,
ImageTransparency = props.transparency, color = color,
ImageRectOffset = iconProps.ImageRectOffset, transparency = props.transparency,
ImageRectSize = iconProps.ImageRectSize, size = UDim2.new(0, 16, 0, 16),
BackgroundTransparency = 1, position = UDim2.new(0, indent + 2, 0, 12),
Size = UDim2.new(0, 20, 0, 20), anchorPoint = Vector2.new(0, 0.5),
Position = UDim2.new(0, indent, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
}), }),
InstanceName = e("TextLabel", { InstanceName = e("TextLabel", {
Text = (if props.isWarning then "" else "") .. props.name .. (props.hint and string.format( Text = (if props.isWarning then "" else "") .. props.name,
' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint
) or ""),
RichText = true, RichText = true,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium, FontFace = if props.patchType then theme.Font.Bold else theme.Font.Main,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = if props.isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor, TextColor3 = color,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, -indent - 50, 0, 30), Size = UDim2.new(1, -indent - 50, 0, 24),
Position = UDim2.new(0, indent + 30, 0, 0), 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), LineGuides = e("Folder", nil, lineGuides),
}) })

View File

@@ -8,8 +8,8 @@ local PatchTree = require(Plugin.PatchTree)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller) local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local e = Roact.createElement local e = Roact.createElement
@@ -55,33 +55,60 @@ function PatchVisualizer:render()
end end
-- Recusively draw tree -- Recusively draw tree
local scrollElements, elementHeights = {}, {} local scrollElements, elementHeights, elementIndex = {}, {}, 0
if patchTree then if patchTree then
local elementTotal = patchTree:getCount()
local depthsComplete = {}
local function drawNode(node, depth) local function drawNode(node, depth)
local elementHeight, setElementHeight = Roact.createBinding(30) elementIndex += 1
table.insert(elementHeights, elementHeight)
table.insert( local parentNode = patchTree:getNode(node.parentId)
scrollElements, local isFinalChild = true
e(DomLabel, { if parentNode then
updateEvent = self.updateEvent, for _id, sibling in parentNode.children do
elementHeight = elementHeight, if type(sibling) == "table" and sibling.name and sibling.name > node.name then
setElementHeight = setElementHeight, isFinalChild = false
patchType = node.patchType, break
className = node.className, end
isWarning = node.isWarning, end
instance = node.instance, end
name = node.name,
hint = node.hint, local elementHeight, setElementHeight = Roact.createBinding(24)
changeList = node.changeList, elementHeights[elementIndex] = elementHeight
depth = depth, scrollElements[elementIndex] = e(DomLabel, {
transparency = self.props.transparency, transparency = self.props.transparency,
showSourceDiff = self.props.showSourceDiff, 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 end
patchTree:forEach(function(node, depth) patchTree:forEach(function(node, depth)
depthsComplete[depth] = false
for i = depth + 1, #depthsComplete do
depthsComplete[i] = nil
end
drawNode(node, depth) drawNode(node, depth)
end) end)
end end
@@ -91,21 +118,23 @@ function PatchVisualizer:render()
transparency = self.props.transparency, transparency = self.props.transparency,
size = self.props.size, size = self.props.size,
position = self.props.position, position = self.props.position,
anchorPoint = self.props.anchorPoint,
layoutOrder = self.props.layoutOrder, layoutOrder = self.props.layoutOrder,
}, { }, {
CleanMerge = e("TextLabel", { CleanMerge = e("TextLabel", {
Visible = #scrollElements == 0, Visible = #scrollElements == 0,
Text = "No changes to sync, project is up to date.", Text = "No changes to sync, project is up to date.",
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextSize = 15, TextSize = theme.TextSize.Medium,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextWrapped = true, TextWrapped = true,
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
VirtualScroller = e(VirtualScroller, { VirtualScroller = e(VirtualScroller, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, -2),
position = UDim2.new(0, 0, 0, 2),
transparency = self.props.transparency, transparency = self.props.transparency,
count = #scrollElements, count = #scrollElements,
updateEvent = self.updateEvent.Event, updateEvent = self.updateEvent.Event,

View File

@@ -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", {
@@ -28,16 +34,21 @@ local function ScrollingFrame(props)
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( then props.contentSize:map(function(value)
0, return UDim2.new(
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y) 0,
then value.X if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
else 0, then value.X
0, else 0,
value.Y 0,
) value.Y
end), )
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,

View File

@@ -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,

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -9,7 +7,9 @@ local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter) local Highlighter = require(Packages.Highlighter)
local StringDiff = require(script:FindFirstChild("StringDiff")) local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local CodeLabel = require(Plugin.App.Components.CodeLabel) local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
@@ -31,7 +31,6 @@ function StringDiffVisualizer:init()
end) end)
end) end)
self:calculateContentSize()
self:updateScriptBackground() self:updateScriptBackground()
self:setState({ self:setState({
@@ -52,8 +51,7 @@ function StringDiffVisualizer:updateScriptBackground()
end end
function StringDiffVisualizer:didUpdate(previousProps) function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then if previousProps.oldString ~= self.props.oldString or previousProps.newString ~= self.props.newString then
self:calculateContentSize()
local add, remove = self:calculateDiffLines() local add, remove = self:calculateDiffLines()
self:setState({ self:setState({
add = add, add = add,
@@ -62,29 +60,30 @@ function StringDiffVisualizer:didUpdate(previousProps)
end end
end end
function StringDiffVisualizer:calculateContentSize() function StringDiffVisualizer:calculateContentSize(theme)
local oldText, newText = self.props.oldText, self.props.newText local oldString, newString = self.props.oldString, self.props.newString
local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) local oldStringBounds = getTextBoundsAsync(oldString, theme.Font.Code, theme.TextSize.Code, math.huge)
local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999)) local newStringBounds = getTextBoundsAsync(newString, theme.Font.Code, theme.TextSize.Code, math.huge)
self.setContentSize( self.setContentSize(
Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y)) Vector2.new(math.max(oldStringBounds.X, newStringBounds.X), math.max(oldStringBounds.Y, newStringBounds.Y))
) )
end end
function StringDiffVisualizer:calculateDiffLines() function StringDiffVisualizer:calculateDiffLines()
local oldText, newText = self.props.oldText, self.props.newText Timer.start("StringDiffVisualizer:calculateDiffLines")
local oldString, newString = self.props.oldString, self.props.newString
-- Diff the two texts -- Diff the two texts
local startClock = os.clock() local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldText, newText) local diffs = StringDiff.findDiffs(oldString, newString)
local stopClock = os.clock() local stopClock = os.clock()
Log.trace( Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections", "Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldText, #oldString,
#newText, #newString,
math.round((stopClock - startClock) * 1000 * 1000), math.round((stopClock - startClock) * 1000 * 1000),
#diffs #diffs
) )
@@ -133,13 +132,16 @@ function StringDiffVisualizer:calculateDiffLines()
end end
end end
Timer.stop()
return add, remove return add, remove
end end
function StringDiffVisualizer:render() function StringDiffVisualizer:render()
local oldText, newText = self.props.oldText, self.props.newText local oldString, newString = self.props.oldString, self.props.newString
return Theme.with(function(theme) return Theme.with(function(theme)
self:calculateContentSize(theme)
return e(BorderedContainer, { return e(BorderedContainer, {
size = self.props.size, size = self.props.size,
position = self.props.position, position = self.props.position,
@@ -175,8 +177,8 @@ function StringDiffVisualizer:render()
Source = e(CodeLabel, { Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0), position = UDim2.new(0, 0, 0, 0),
text = oldText, text = oldString,
lineBackground = theme.Diff.Remove, lineBackground = theme.Diff.Background.Remove,
markedLines = self.state.remove, markedLines = self.state.remove,
}), }),
}), }),
@@ -190,8 +192,8 @@ function StringDiffVisualizer:render()
Source = e(CodeLabel, { Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0), position = UDim2.new(0, 0, 0, 0),
text = newText, text = newString,
lineBackground = theme.Diff.Add, lineBackground = theme.Diff.Background.Add,
markedLines = self.state.add, markedLines = self.state.add,
}), }),
}), }),

View File

@@ -0,0 +1,195 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(Plugin.App.Components.PatchVisualizer.DisplayValue)
local e = Roact.createElement
local Array = Roact.Component:extend("Array")
function Array:init()
self:setState({
diff = self:calculateDiff(),
})
end
function Array:calculateDiff()
Timer.start("Array:calculateDiff")
--[[
Find the indexes that are added or removed from the array,
and display them side by side with gaps for the indexes that
dont exist in the opposite array.
]]
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
local i, j = 1, 1
local diff = {}
while i <= #oldTable and j <= #newTable do
if oldTable[i] == newTable[j] then
table.insert(diff, { oldTable[i], newTable[j] }) -- Unchanged
i += 1
j += 1
elseif not table.find(newTable, oldTable[i], j) then
table.insert(diff, { oldTable[i], nil }) -- Removal
i += 1
elseif not table.find(oldTable, newTable[j], i) then
table.insert(diff, { nil, newTable[j] }) -- Addition
j += 1
else
if table.find(newTable, oldTable[i], j) then
table.insert(diff, { nil, newTable[j] }) -- Addition
j += 1
else
table.insert(diff, { oldTable[i], nil }) -- Removal
i += 1
end
end
end
-- Handle remaining elements
while i <= #oldTable do
table.insert(diff, { oldTable[i], nil }) -- Remaining Removals
i += 1
end
while j <= #newTable do
table.insert(diff, { nil, newTable[j] }) -- Remaining Additions
j += 1
end
Timer.stop()
return diff
end
function Array:didUpdate(previousProps)
if previousProps.oldTable ~= self.props.oldTable or previousProps.newTable ~= self.props.newTable then
self:setState({
diff = self:calculateDiff(),
})
end
end
function Array:render()
return Theme.with(function(theme)
local diff = self.state.diff
local lines = table.create(#diff)
for i, element in diff do
local oldValue = element[1]
local newValue = element[2]
local patchType = if oldValue == nil then "Add" elseif newValue == nil then "Remove" else "Remain"
table.insert(
lines,
e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = theme.Diff.Background[patchType],
BorderSizePixel = 0,
LayoutOrder = i,
}, {
DiffIcon = if patchType ~= "Remain"
then e("ImageLabel", {
Image = Assets.Images.Diff[patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = self.props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 15, 0, 15),
Position = UDim2.new(0, 7, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
Old = e("Frame", {
Size = UDim2.new(0.5, -30, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
}, {
Display = if oldValue ~= nil
then e(DisplayValue, {
value = oldValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
})
else nil,
}),
New = e("Frame", {
Size = UDim2.new(0.5, -10, 1, 0),
Position = UDim2.new(0.5, 5, 0, 0),
BackgroundTransparency = 1,
}, {
Display = if newValue ~= nil
then e(DisplayValue, {
value = newValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
})
else nil,
}),
})
)
end
return Roact.createFragment({
Headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = self.props.transparency:map(function(t)
return 0.95 + (0.05 * t)
end),
BackgroundColor3 = theme.Diff.Row,
}, {
ColumnA = e("TextLabel", {
Size = UDim2.new(0.5, -30, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = "Old",
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
ColumnB = e("TextLabel", {
Size = UDim2.new(0.5, -10, 1, 0),
Position = UDim2.new(0.5, 5, 0, 0),
BackgroundTransparency = 1,
Text = "New",
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
Separator = e("Frame", {
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, 0),
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
}),
}),
KeyValues = e(ScrollingFrame, {
position = UDim2.new(0, 1, 0, 25),
size = UDim2.new(1, -2, 1, -27),
scrollingDirection = Enum.ScrollingDirection.Y,
transparency = self.props.transparency,
}, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Top,
}),
Lines = Roact.createFragment(lines),
}),
})
end)
end
return Array

View File

@@ -0,0 +1,209 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(Plugin.App.Components.PatchVisualizer.DisplayValue)
local e = Roact.createElement
local Dictionary = Roact.Component:extend("Dictionary")
function Dictionary:init()
self:setState({
diff = self:calculateDiff(),
})
end
function Dictionary:calculateDiff()
Timer.start("Dictionary:calculateDiff")
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
-- Diff the two tables and find the added keys, removed keys, and changed keys
local diff = {}
for key, oldValue in oldTable do
local newValue = newTable[key]
if newValue == nil then
table.insert(diff, {
key = key,
patchType = "Remove",
})
elseif newValue ~= oldValue then
-- Note: should this do some sort of deep comparison for various types?
table.insert(diff, {
key = key,
patchType = "Edit",
})
else
table.insert(diff, {
key = key,
patchType = "Remain",
})
end
end
for key in newTable do
if oldTable[key] == nil then
table.insert(diff, {
key = key,
patchType = "Add",
})
end
end
table.sort(diff, function(a, b)
return a.key < b.key
end)
Timer.stop()
return diff
end
function Dictionary:didUpdate(previousProps)
if previousProps.oldTable ~= self.props.oldTable or previousProps.newTable ~= self.props.newTable then
self:setState({
diff = self:calculateDiff(),
})
end
end
function Dictionary:render()
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
local diff = self.state.diff
return Theme.with(function(theme)
local lines = table.create(#diff)
for order, line in diff do
local key = line.key
local oldValue = oldTable[key]
local newValue = newTable[key]
table.insert(
lines,
e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
LayoutOrder = order,
BorderSizePixel = 0,
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = theme.Diff.Background[line.patchType],
}, {
DiffIcon = if line.patchType ~= "Remain"
then e("ImageLabel", {
Image = Assets.Images.Diff[line.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = self.props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 15, 0, 15),
Position = UDim2.new(0, 7, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
KeyName = e("TextLabel", {
Size = UDim2.new(0.3, -15, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = key,
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Diff.Text[line.patchType],
TextTruncate = Enum.TextTruncate.AtEnd,
}),
OldValue = e("Frame", {
Size = UDim2.new(0.35, -7, 1, 0),
Position = UDim2.new(0.3, 15, 0, 0),
BackgroundTransparency = 1,
}, {
e(DisplayValue, {
value = oldValue,
transparency = self.props.transparency,
textColor = theme.Diff.Text[line.patchType],
}),
}),
NewValue = e("Frame", {
Size = UDim2.new(0.35, -8, 1, 0),
Position = UDim2.new(0.65, 8, 0, 0),
BackgroundTransparency = 1,
}, {
e(DisplayValue, {
value = newValue,
transparency = self.props.transparency,
textColor = theme.Diff.Text[line.patchType],
}),
}),
})
)
end
return Roact.createFragment({
Headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = self.props.transparency:map(function(t)
return 0.95 + (0.05 * t)
end),
BackgroundColor3 = theme.Diff.Row,
}, {
ColumnA = e("TextLabel", {
Size = UDim2.new(0.3, -15, 1, 0),
Position = UDim2.new(0, 30, 0, 0),
BackgroundTransparency = 1,
Text = "Key",
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
ColumnB = e("TextLabel", {
Size = UDim2.new(0.35, -7, 1, 0),
Position = UDim2.new(0.3, 15, 0, 0),
BackgroundTransparency = 1,
Text = "Old",
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
ColumnC = e("TextLabel", {
Size = UDim2.new(0.35, -8, 1, 0),
Position = UDim2.new(0.65, 8, 0, 0),
BackgroundTransparency = 1,
Text = "New",
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextTruncate = Enum.TextTruncate.AtEnd,
}),
Separator = e("Frame", {
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, 0),
BackgroundTransparency = 0,
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
}),
}),
KeyValues = e(ScrollingFrame, {
position = UDim2.new(0, 1, 0, 25),
size = UDim2.new(1, -2, 1, -27),
scrollingDirection = Enum.ScrollingDirection.Y,
transparency = self.props.transparency,
}, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
VerticalAlignment = Enum.VerticalAlignment.Top,
}),
Lines = Roact.createFragment(lines),
}),
})
end)
end
return Dictionary

View File

@@ -0,0 +1,48 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Array = require(script:FindFirstChild("Array"))
local Dictionary = require(script:FindFirstChild("Dictionary"))
local e = Roact.createElement
local TableDiffVisualizer = Roact.Component:extend("TableDiffVisualizer")
function TableDiffVisualizer:render()
local oldTable, newTable = self.props.oldTable or {}, self.props.newTable or {}
-- Ensure we're diffing tables, not mixing types
if type(oldTable) ~= "table" then
oldTable = {}
end
if type(newTable) ~= "table" then
newTable = {}
end
local isArray = next(newTable) == 1 or next(oldTable) == 1
return e(BorderedContainer, {
size = self.props.size,
position = self.props.position,
anchorPoint = self.props.anchorPoint,
transparency = self.props.transparency,
}, {
Content = if isArray
then e(Array, {
oldTable = oldTable,
newTable = newTable,
transparency = self.props.transparency,
})
else e(Dictionary, {
oldTable = oldTable,
newTable = newTable,
transparency = self.props.transparency,
}),
})
end
return TableDiffVisualizer

View File

@@ -0,0 +1,59 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local SlicedImage = require(Plugin.App.Components.SlicedImage)
local e = Roact.createElement
return function(props)
return Theme.with(function(theme)
return e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = props.color,
transparency = props.transparency:map(function(transparency)
return 0.9 + (0.1 * transparency)
end),
layoutOrder = props.layoutOrder,
position = props.position,
anchorPoint = props.anchorPoint,
size = UDim2.new(0, 0, 0, theme.TextSize.Medium),
automaticSize = Enum.AutomaticSize.X,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 4),
PaddingRight = UDim.new(0, 4),
PaddingTop = UDim.new(0, 2),
PaddingBottom = UDim.new(0, 2),
}),
Icon = if props.icon
then e("ImageLabel", {
Size = UDim2.new(0, 12, 0, 12),
Position = UDim2.new(0, 0, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
Image = props.icon,
BackgroundTransparency = 1,
ImageColor3 = props.color,
ImageTransparency = props.transparency,
})
else nil,
Text = e("TextLabel", {
Text = props.text,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Small,
TextColor3 = props.color,
TextXAlignment = Enum.TextXAlignment.Center,
TextTransparency = props.transparency,
Size = UDim2.new(0, 0, 1, 0),
Position = UDim2.new(0, if props.icon then 15 else 0, 0, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
}),
})
end)
end

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -10,6 +8,7 @@ local Flipper = require(Packages.Flipper)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local bindingUtil = require(Plugin.App.bindingUtil) local bindingUtil = require(Plugin.App.bindingUtil)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local SlicedImage = require(script.Parent.SlicedImage) local SlicedImage = require(script.Parent.SlicedImage)
local TouchRipple = require(script.Parent.TouchRipple) local TouchRipple = require(script.Parent.TouchRipple)
@@ -41,18 +40,17 @@ end
function TextButton:render() function TextButton:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local textSize = local textBounds = getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Large, math.huge)
TextService:GetTextSize(self.props.text, 18, Enum.Font.GothamSemibold, Vector2.new(math.huge, math.huge))
local style = self.props.style local style = self.props.style
theme = theme.Button[style] local buttonTheme = theme.Button[style]
local bindingHover = bindingUtil.deriveProperty(self.binding, "hover") local bindingHover = bindingUtil.deriveProperty(self.binding, "hover")
local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled") local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled")
return e("ImageButton", { return e("ImageButton", {
Size = UDim2.new(0, 15 + textSize.X + 15, 0, 34), Size = UDim2.new(0, (theme.TextSize.Body * 2) + textBounds.X, 0, 34),
Position = self.props.position, Position = self.props.position,
AnchorPoint = self.props.anchorPoint, AnchorPoint = self.props.anchorPoint,
@@ -74,18 +72,22 @@ function TextButton:render()
end, end,
}, { }, {
TouchRipple = e(TouchRipple, { TouchRipple = e(TouchRipple, {
color = theme.ActionFillColor, color = buttonTheme.ActionFillColor,
transparency = self.props.transparency:map(function(value) transparency = self.props.transparency:map(function(value)
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, value }) return bindingUtil.blendAlpha({ buttonTheme.ActionFillTransparency, value })
end), end),
zIndex = 2, zIndex = 2,
}), }),
Text = e("TextLabel", { Text = e("TextLabel", {
Text = self.props.text, Text = self.props.text,
Font = Enum.Font.GothamSemibold, FontFace = theme.Font.Main,
TextSize = 18, TextSize = theme.TextSize.Large,
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.TextColor, theme.Disabled.TextColor), TextColor3 = bindingUtil.mapLerp(
bindingEnabled,
buttonTheme.Enabled.TextColor,
buttonTheme.Disabled.TextColor
),
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
@@ -95,7 +97,11 @@ function TextButton:render()
Border = style == "Bordered" and e(SlicedImage, { Border = style == "Bordered" and e(SlicedImage, {
slice = Assets.Slices.RoundedBorder, slice = Assets.Slices.RoundedBorder,
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BorderColor, theme.Disabled.BorderColor), color = bindingUtil.mapLerp(
bindingEnabled,
buttonTheme.Enabled.BorderColor,
buttonTheme.Disabled.BorderColor
),
transparency = self.props.transparency, transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
@@ -105,14 +111,18 @@ function TextButton:render()
HoverOverlay = e(SlicedImage, { HoverOverlay = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = theme.ActionFillColor, color = buttonTheme.ActionFillColor,
transparency = Roact.joinBindings({ transparency = Roact.joinBindings({
hover = bindingHover:map(function(value) hover = bindingHover:map(function(value)
return 1 - value return 1 - value
end), end),
transparency = self.props.transparency, transparency = self.props.transparency,
}):map(function(values) }):map(function(values)
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, values.hover, values.transparency }) return bindingUtil.blendAlpha({
buttonTheme.ActionFillTransparency,
values.hover,
values.transparency,
})
end), end),
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
@@ -124,8 +134,8 @@ function TextButton:render()
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = bindingUtil.mapLerp( color = bindingUtil.mapLerp(
bindingEnabled, bindingEnabled,
theme.Enabled.BackgroundColor, buttonTheme.Enabled.BackgroundColor,
theme.Disabled.BackgroundColor buttonTheme.Disabled.BackgroundColor
), ),
transparency = self.props.transparency, transparency = self.props.transparency,

View File

@@ -38,14 +38,18 @@ end
function TextInput:render() function TextInput:render()
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.TextInput local textInputTheme = theme.TextInput
local bindingHover = bindingUtil.deriveProperty(self.binding, "hover") local bindingHover = bindingUtil.deriveProperty(self.binding, "hover")
local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled") local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled")
return e(SlicedImage, { return e(SlicedImage, {
slice = Assets.Slices.RoundedBorder, slice = Assets.Slices.RoundedBorder,
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BorderColor, theme.Disabled.BorderColor), color = bindingUtil.mapLerp(
bindingEnabled,
textInputTheme.Enabled.BorderColor,
textInputTheme.Disabled.BorderColor
),
transparency = self.props.transparency, transparency = self.props.transparency,
size = self.props.size or UDim2.new(1, 0, 1, 0), size = self.props.size or UDim2.new(1, 0, 1, 0),
@@ -55,14 +59,18 @@ function TextInput:render()
}, { }, {
HoverOverlay = e(SlicedImage, { HoverOverlay = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = theme.ActionFillColor, color = textInputTheme.ActionFillColor,
transparency = Roact.joinBindings({ transparency = Roact.joinBindings({
hover = bindingHover:map(function(value) hover = bindingHover:map(function(value)
return 1 - value return 1 - value
end), end),
transparency = self.props.transparency, transparency = self.props.transparency,
}):map(function(values) }):map(function(values)
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, values.hover, values.transparency }) return bindingUtil.blendAlpha({
textInputTheme.ActionFillTransparency,
values.hover,
values.transparency,
})
end), end),
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, 0, 1, 0),
zIndex = -1, zIndex = -1,
@@ -72,14 +80,18 @@ function TextInput:render()
Size = UDim2.fromScale(1, 1), Size = UDim2.fromScale(1, 1),
Text = self.props.text, Text = self.props.text,
PlaceholderText = self.props.placeholder, PlaceholderText = self.props.placeholder,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Disabled.TextColor, theme.Enabled.TextColor), TextColor3 = bindingUtil.mapLerp(
bindingEnabled,
textInputTheme.Disabled.TextColor,
textInputTheme.Enabled.TextColor
),
PlaceholderColor3 = bindingUtil.mapLerp( PlaceholderColor3 = bindingUtil.mapLerp(
bindingEnabled, bindingEnabled,
theme.Disabled.PlaceholderColor, textInputTheme.Disabled.PlaceholderColor,
theme.Enabled.PlaceholderColor textInputTheme.Enabled.PlaceholderColor
), ),
TextSize = 18, TextSize = theme.TextSize.Large,
TextEditable = self.props.enabled, TextEditable = self.props.enabled,
ClearTextOnFocus = self.props.clearTextOnFocus, ClearTextOnFocus = self.props.clearTextOnFocus,

View File

@@ -1,4 +1,3 @@
local TextService = game:GetService("TextService")
local HttpService = game:GetService("HttpService") local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
@@ -8,6 +7,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local e = Roact.createElement local e = Roact.createElement
@@ -21,50 +22,48 @@ local Y_OVERLAP = 10 -- Let the triangle tail piece overlap the target a bit to
local TooltipContext = Roact.createContext({}) local TooltipContext = Roact.createContext({})
local function Popup(props) local function Popup(props)
local textSize = TextService:GetTextSize(
props.Text,
16,
Enum.Font.GothamMedium,
Vector2.new(math.min(props.parentSize.X, 160), math.huge)
) + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue()
local spaceBelow = props.parentSize.Y
- (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE)
local spaceAbove = trigger.AbsolutePosition.Y + Y_OVERLAP - TAIL_SIZE
-- If there's not enough space below, and there's more space above, then show the tooltip above the trigger
local displayAbove = spaceBelow < textSize.Y and spaceAbove > spaceBelow
local X = math.clamp(props.Position.X - X_OFFSET, 0, props.parentSize.X - textSize.X)
local Y = 0
if displayAbove then
Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0)
else
Y = math.min(
trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP,
props.parentSize.Y - textSize.Y
)
end
return Theme.with(function(theme) return Theme.with(function(theme)
local textXSpace = math.min(props.parentSize.X, 250) - TEXT_PADDING.X
local textBounds = getTextBoundsAsync(props.Text, theme.Font.Main, theme.TextSize.Medium, textXSpace)
local contentSize = textBounds + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue()
local spaceBelow = props.parentSize.Y
- (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE)
local spaceAbove = trigger.AbsolutePosition.Y + Y_OVERLAP - TAIL_SIZE
-- If there's not enough space below, and there's more space above, then show the tooltip above the trigger
local displayAbove = spaceBelow < contentSize.Y and spaceAbove > spaceBelow
local X = math.clamp(props.Position.X - X_OFFSET, 0, math.max(props.parentSize.X - contentSize.X, 1))
local Y = 0
if displayAbove then
Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - contentSize.Y + Y_OVERLAP, 0)
else
Y = math.min(
trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP,
props.parentSize.Y - contentSize.Y
)
end
return e(BorderedContainer, { return e(BorderedContainer, {
position = UDim2.fromOffset(X, Y), position = UDim2.fromOffset(X, Y),
size = UDim2.fromOffset(textSize.X, textSize.Y), size = UDim2.fromOffset(contentSize.X, contentSize.Y),
transparency = props.transparency, transparency = props.transparency,
}, { }, {
Label = e("TextLabel", { Label = e("TextLabel", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Position = UDim2.fromScale(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.new(1, -TEXT_PADDING.X, 1, -TEXT_PADDING.Y),
AnchorPoint = Vector2.new(0.5, 0.5), AnchorPoint = Vector2.new(0.5, 0.5),
Size = UDim2.fromOffset(textBounds.X, textBounds.Y),
Text = props.Text, Text = props.Text,
TextSize = 16, TextSize = theme.TextSize.Medium,
Font = Enum.Font.GothamMedium, FontFace = theme.Font.Main,
TextWrapped = true, TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
TextColor3 = theme.Button.Bordered.Enabled.TextColor, TextColor3 = theme.Button.Bordered.Enabled.TextColor,
TextTransparency = props.transparency, TextTransparency = props.transparency,
}), }),
@@ -72,8 +71,8 @@ local function Popup(props)
Tail = e("ImageLabel", { Tail = e("ImageLabel", {
ZIndex = 100, ZIndex = 100,
Position = if displayAbove Position = if displayAbove
then UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 1, -1) then UDim2.new(0, math.clamp(props.Position.X - X, 6, contentSize.X - 6), 1, -1)
else UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 0, -TAIL_SIZE + 1), else UDim2.new(0, math.clamp(props.Position.X - X, 6, contentSize.X - 6), 0, -TAIL_SIZE + 1),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE), Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0), AnchorPoint = Vector2.new(0.5, 0),
Rotation = if displayAbove then 180 else 0, Rotation = if displayAbove then 180 else 0,
@@ -163,7 +162,6 @@ local Trigger = Roact.Component:extend("TooltipTrigger")
function Trigger:init() function Trigger:init()
self.id = HttpService:GenerateGUID(false) self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef() self.ref = Roact.createRef()
self.mousePos = Vector2.zero
self.showingPopup = false self.showingPopup = false
self.destroy = function() self.destroy = function()
@@ -195,18 +193,22 @@ end
function Trigger:isHovering() function Trigger:isHovering()
local rbx = self.ref.current local rbx = self.ref.current
if rbx then if rbx then
local pos = rbx.AbsolutePosition return rbx.GuiState == Enum.GuiState.Hover
local size = rbx.AbsoluteSize
local mousePos = self.mousePos
return mousePos.X >= pos.X
and mousePos.X <= pos.X + size.X
and mousePos.Y >= pos.Y
and mousePos.Y <= pos.Y + size.Y
end end
return false return false
end end
function Trigger:getMousePos()
local rbx = self.ref.current
if rbx then
local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui")
if widget then
return widget:GetRelativeMousePosition()
end
end
return Vector2.zero
end
function Trigger:managePopup() function Trigger:managePopup()
if self:isHovering() then if self:isHovering() then
if self.showingPopup or self.showDelayThread then if self.showingPopup or self.showDelayThread then
@@ -214,10 +216,10 @@ function Trigger:managePopup()
return return
end end
self.showDelayThread = task.delay(DELAY, function() self.showDelayThread = task.delay(self.props.delay or DELAY, function()
self.props.context.addTip(self.id, { self.props.context.addTip(self.id, {
Text = self.props.text, Text = self.props.text,
Position = self.mousePos, Position = self:getMousePos(),
Trigger = self.ref, Trigger = self.ref,
}) })
self.showDelayThread = nil self.showDelayThread = nil
@@ -234,13 +236,7 @@ function Trigger:managePopup()
end end
function Trigger:render() function Trigger:render()
local function recalculate(rbx) local function recalculate()
local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui")
if not widget then
return
end
self.mousePos = widget:GetRelativeMousePosition()
self:managePopup() self:managePopup()
end end
@@ -250,11 +246,9 @@ function Trigger:render()
ZIndex = self.props.zIndex or 100, ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref, [Roact.Ref] = self.ref,
[Roact.Change.GuiState] = recalculate,
[Roact.Change.AbsolutePosition] = recalculate, [Roact.Change.AbsolutePosition] = recalculate,
[Roact.Change.AbsoluteSize] = recalculate, [Roact.Change.AbsoluteSize] = recalculate,
[Roact.Event.MouseMoved] = recalculate,
[Roact.Event.MouseLeave] = recalculate,
[Roact.Event.MouseEnter] = recalculate,
}) })
end end

View File

@@ -131,8 +131,8 @@ function VirtualScroller:render()
Position = props.position, Position = props.position,
AnchorPoint = props.anchorPoint, AnchorPoint = props.anchorPoint,
BackgroundTransparency = props.backgroundTransparency or 1, BackgroundTransparency = props.backgroundTransparency or 1,
BackgroundColor3 = props.backgroundColor3, BackgroundColor3 = props.backgroundColor3 or theme.BorderedContainer.BackgroundColor,
BorderColor3 = props.borderColor3, BorderColor3 = props.borderColor3 or theme.BorderedContainer.BorderColor,
CanvasSize = self.totalCanvas:map(function(s) CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(0, s) return UDim2.fromOffset(0, s)
end), end),

View File

@@ -1,19 +1,19 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local PatchTree = require(Plugin.PatchTree)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui) local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement local e = Roact.createElement
@@ -24,50 +24,75 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({ self:setState({
showingSourceDiff = false, patchTree = nil,
oldSource = "", showingStringDiff = false,
newSource = "", oldString = "",
newString = "",
showingTableDiff = false,
oldTable = {},
newTable = {},
}) })
if self.props.confirmData and self.props.confirmData.patch and self.props.confirmData.instanceMap then
self:buildPatchTree()
end
end
function ConfirmingPage:didUpdate(prevProps)
if prevProps.confirmData ~= self.props.confirmData then
self:buildPatchTree()
end
end
function ConfirmingPage:buildPatchTree()
Timer.start("ConfirmingPage:buildPatchTree")
self:setState({
patchTree = PatchTree.build(
self.props.confirmData.patch,
self.props.confirmData.instanceMap,
{ "Property", "Current", "Incoming" }
),
})
Timer.stop()
end end
function ConfirmingPage:render() function ConfirmingPage:render()
return Theme.with(function(theme) return Theme.with(function(theme)
local pageContent = Roact.createFragment({ local pageContent = Roact.createFragment({
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Title = e("TextLabel", { Title = e("TextLabel", {
Text = string.format( Text = string.format(
"Sync changes for project '%s':", "Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN" self.props.confirmData.serverInfo.projectName or "UNKNOWN"
), ),
LayoutOrder = 2, FontFace = theme.Font.Thin,
Font = Enum.Font.Gotham,
LineHeight = 1.2, LineHeight = 1.2,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20), Size = UDim2.new(1, 0, 0, theme.TextSize.Large + 2),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
PatchVisualizer = e(PatchVisualizer, { PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -150), size = UDim2.new(1, 0, 1, -100),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
changeListHeaders = { "Property", "Current", "Incoming" }, patchTree = self.state.patchTree,
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
showSourceDiff = function(oldSource: string, newSource: string) showStringDiff = function(oldString: string, newString: string)
self:setState({ self:setState({
showingSourceDiff = true, showingStringDiff = true,
oldSource = oldSource, oldString = oldString,
newSource = newSource, newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
}) })
end, end,
}), }),
@@ -123,6 +148,11 @@ function ConfirmingPage:render()
}), }),
}), }),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
}),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
@@ -131,15 +161,10 @@ function ConfirmingPage:render()
Padding = UDim.new(0, 10), Padding = UDim.new(0, 10),
}), }),
Padding = e("UIPadding", { StringDiff = e(StudioPluginGui, {
PaddingLeft = UDim.new(0, 20), id = "Rojo_ConfirmingStringDiff",
PaddingRight = UDim.new(0, 20), title = "String diff",
}), active = self.state.showingStringDiff,
SourceDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingSourceDiff",
title = "Source diff",
active = self.state.showingSourceDiff,
isEphemeral = true, isEphemeral = true,
initDockState = Enum.InitialDockState.Float, initDockState = Enum.InitialDockState.Float,
@@ -151,7 +176,7 @@ function ConfirmingPage:render()
onClose = function() onClose = function()
self:setState({ self:setState({
showingSourceDiff = false, showingStringDiff = false,
}) })
end, end,
}, { }, {
@@ -167,8 +192,46 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
oldText = self.state.oldSource, oldString = self.state.oldString,
newText = self.state.newSource, newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}), }),
}), }),
}), }),

View File

@@ -3,9 +3,8 @@ local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local bindingUtil = require(Plugin.App.bindingUtil) local timeUtil = require(Plugin.timeUtil)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local PatchSet = require(Plugin.PatchSet) local PatchSet = require(Plugin.PatchSet)
@@ -18,86 +17,188 @@ local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer) local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer) local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement local e = Roact.createElement
local AGE_UNITS = { local ChangesViewer = Roact.Component:extend("ChangesViewer")
{ 31556909, "year" },
{ 2629743, "month" },
{ 604800, "week" },
{ 86400, "day" },
{ 3600, "hour" },
{
60,
"minute",
},
}
function timeSinceText(elapsed: number): string
if elapsed < 3 then
return "just now"
end
local ageText = string.format("%d seconds ago", elapsed) function ChangesViewer:init()
for _, UnitData in ipairs(AGE_UNITS) do
local UnitSeconds, UnitName = UnitData[1], UnitData[2]
if elapsed > UnitSeconds then
local c = math.floor(elapsed / UnitSeconds)
ageText = string.format("%d %s%s ago", c, UnitName, c > 1 and "s" or "")
break
end
end
return ageText
end
local ChangesDrawer = Roact.Component:extend("ChangesDrawer")
function ChangesDrawer:init()
-- Hold onto the serve session during the lifecycle of this component -- Hold onto the serve session during the lifecycle of this component
-- so that it can still render during the fade out after disconnecting -- so that it can still render during the fade out after disconnecting
self.serveSession = self.props.serveSession self.serveSession = self.props.serveSession
end end
function ChangesDrawer:render() function ChangesViewer:render()
if self.props.rendered == false or self.serveSession == nil then if self.props.rendered == false or self.serveSession == nil or self.props.patchData == nil then
return nil return nil
end end
local unapplied = PatchSet.countChanges(self.props.patchData.unapplied)
local applied = PatchSet.countChanges(self.props.patchData.patch) - unapplied
return Theme.with(function(theme) return Theme.with(function(theme)
return e(BorderedContainer, { return Roact.createFragment({
transparency = self.props.transparency, Navbar = e("Frame", {
size = self.props.height:map(function(y) Size = UDim2.new(1, 0, 0, 40),
return UDim2.new(1, 0, y, -220 * y) BackgroundTransparency = 1,
end),
position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1),
layoutOrder = self.props.layoutOrder,
}, {
Close = e(IconButton, {
icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0, 0),
anchorPoint = Vector2.new(1, 0),
onClick = self.props.onClose,
}, { }, {
Tip = e(Tooltip.Trigger, { Close = e(IconButton, {
text = "Close the patch visualizer", icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.Settings.Navbar.BackButtonColor,
transparency = self.props.transparency,
position = UDim2.new(0, 0, 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = self.props.onBack,
}, {
Tip = e(Tooltip.Trigger, {
text = "Close",
}),
}),
Title = e("TextLabel", {
Text = "Sync",
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Large,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = theme.TextColor,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, -40, 0, theme.TextSize.Large + 2),
Position = UDim2.new(0, 40, 0, 0),
BackgroundTransparency = 1,
}),
Subtitle = e("TextLabel", {
Text = DateTime.fromUnixTimestamp(self.props.patchData.timestamp):FormatLocalTime("LTS", "en-us"),
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Medium,
TextColor3 = theme.SubTextColor,
TextTruncate = Enum.TextTruncate.AtEnd,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, -40, 0, theme.TextSize.Medium),
Position = UDim2.new(0, 40, 0, theme.TextSize.Large + 2),
BackgroundTransparency = 1,
}),
Info = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 10, 0, 24),
AutomaticSize = Enum.AutomaticSize.X,
Position = UDim2.new(1, -5, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
}, {
Tooltip = e(Tooltip.Trigger, {
text = `{applied} changes applied`
.. (if unapplied > 0 then `, {unapplied} changes failed` else ""),
}),
Content = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 4),
}),
StatusIcon = e("ImageLabel", {
BackgroundTransparency = 1,
Image = if unapplied > 0
then Assets.Images.Icons.SyncWarning
else Assets.Images.Icons.SyncSuccess,
ImageColor3 = if unapplied > 0 then theme.Diff.Warning else theme.TextColor,
Size = UDim2.new(0, 24, 0, 24),
LayoutOrder = 10,
}),
StatusSpacer = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 6, 0, 4),
LayoutOrder = 9,
}),
AppliedIcon = e("ImageLabel", {
BackgroundTransparency = 1,
Image = Assets.Images.Icons.Checkmark,
ImageColor3 = theme.TextColor,
Size = UDim2.new(0, 16, 0, 16),
LayoutOrder = 1,
}),
AppliedText = e("TextLabel", {
Text = applied,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextTransparency = self.props.transparency,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Warnings = if unapplied > 0
then Roact.createFragment({
WarningsSpacer = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 4, 0, 4),
LayoutOrder = 3,
}),
UnappliedIcon = e("ImageLabel", {
BackgroundTransparency = 1,
Image = Assets.Images.Icons.Exclamation,
ImageColor3 = theme.Diff.Warning,
Size = UDim2.new(0, 4, 0, 16),
LayoutOrder = 4,
}),
UnappliedText = e("TextLabel", {
Text = unapplied,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Diff.Warning,
TextTransparency = self.props.transparency,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundTransparency = 1,
LayoutOrder = 5,
}),
})
else nil,
}),
}),
Divider = e("Frame", {
BackgroundColor3 = theme.Settings.DividerColor,
BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1),
Position = UDim2.new(0, 0, 1, 0),
BorderSizePixel = 0,
}, {
Gradient = e("UIGradient", {
Transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 1),
NumberSequenceKeypoint.new(0.1, 0),
NumberSequenceKeypoint.new(0.9, 0),
NumberSequenceKeypoint.new(1, 1),
}),
}),
}), }),
}), }),
PatchVisualizer = e(PatchVisualizer, { Patch = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, 0), size = UDim2.new(1, -10, 1, -65),
position = UDim2.new(0, 5, 1, -5),
anchorPoint = Vector2.new(0, 1),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = self.props.layoutOrder,
patchTree = self.props.patchTree, patchTree = self.props.patchTree,
showSourceDiff = self.props.showSourceDiff, showStringDiff = self.props.showStringDiff,
showTableDiff = self.props.showTableDiff,
}), }),
}) })
end) end)
@@ -116,13 +217,13 @@ local function ConnectionDetails(props)
}, { }, {
ProjectName = e("TextLabel", { ProjectName = e("TextLabel", {
Text = props.projectName, Text = props.projectName,
Font = Enum.Font.GothamBold, FontFace = theme.Font.Bold,
TextSize = 20, TextSize = theme.TextSize.Large,
TextColor3 = theme.ConnectionDetails.ProjectNameColor, TextColor3 = theme.ConnectionDetails.ProjectNameColor,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
Size = UDim2.new(1, 0, 0, 20), Size = UDim2.new(1, 0, 0, theme.TextSize.Large),
LayoutOrder = 1, LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
@@ -130,13 +231,13 @@ local function ConnectionDetails(props)
Address = e("TextLabel", { Address = e("TextLabel", {
Text = props.address, Text = props.address,
Font = Enum.Font.Code, FontFace = theme.Font.Code,
TextSize = 15, TextSize = theme.TextSize.Medium,
TextColor3 = theme.ConnectionDetails.AddressColor, TextColor3 = theme.ConnectionDetails.AddressColor,
TextTransparency = props.transparency, TextTransparency = props.transparency,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
Size = UDim2.new(1, 0, 0, 15), Size = UDim2.new(1, 0, 0, theme.TextSize.Medium),
LayoutOrder = 2, LayoutOrder = 2,
BackgroundTransparency = 1, BackgroundTransparency = 1,
@@ -165,20 +266,7 @@ function ConnectedPage:getChangeInfoText()
if patchData == nil then if patchData == nil then
return "" return ""
end end
return timeUtil.elapsedToText(DateTime.now().UnixTimestamp - patchData.timestamp)
local elapsed = os.time() - patchData.timestamp
local unapplied = PatchSet.countChanges(patchData.unapplied)
return "<i>Synced "
.. timeSinceText(elapsed)
.. (if unapplied > 0
then string.format(
', <font color="#FF8E3C">but %d change%s failed to apply</font>',
unapplied,
unapplied == 1 and "" or "s"
)
else "")
.. "</i>"
end end
function ConnectedPage:startChangeInfoTextUpdater() function ConnectedPage:startChangeInfoTextUpdater()
@@ -188,17 +276,13 @@ function ConnectedPage:startChangeInfoTextUpdater()
-- Start a new updater -- Start a new updater
self.changeInfoTextUpdater = task.defer(function() self.changeInfoTextUpdater = task.defer(function()
while true do while true do
if self.state.hoveringChangeInfo then self.setChangeInfoText(self:getChangeInfoText())
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
else
self.setChangeInfoText(self:getChangeInfoText())
end
local elapsed = os.time() - self.props.patchData.timestamp local elapsed = DateTime.now().UnixTimestamp - self.props.patchData.timestamp
local updateInterval = 1 local updateInterval = 1
-- Update timestamp text as frequently as currently needed -- Update timestamp text as frequently as currently needed
for _, UnitData in ipairs(AGE_UNITS) do for _, UnitData in ipairs(timeUtil.AGE_UNITS) do
local UnitSeconds = UnitData[1] local UnitSeconds = UnitData[1]
if elapsed > UnitSeconds then if elapsed > UnitSeconds then
updateInterval = UnitSeconds updateInterval = UnitSeconds
@@ -219,29 +303,12 @@ function ConnectedPage:stopChangeInfoTextUpdater()
end end
function ConnectedPage:init() function ConnectedPage:init()
self.changeDrawerMotor = Flipper.SingleMotor.new(0)
self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
self.changeDrawerMotor:onStep(function(value)
local renderChanges = value > 0.05
self:setState(function(state)
if state.renderChanges == renderChanges then
return nil
end
return {
renderChanges = renderChanges,
}
end)
end)
self:setState({ self:setState({
renderChanges = false, renderChanges = false,
hoveringChangeInfo = false, hoveringChangeInfo = false,
showingSourceDiff = false, showingStringDiff = false,
oldSource = "", oldString = "",
newSource = "", newString = "",
}) })
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("") self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
@@ -258,12 +325,16 @@ function ConnectedPage:didUpdate(previousProps)
-- New patch recieved -- New patch recieved
self:startChangeInfoTextUpdater() self:startChangeInfoTextUpdater()
self:setState({ self:setState({
showingSourceDiff = false, showingStringDiff = false,
}) })
end end
end end
function ConnectedPage:render() function ConnectedPage:render()
local syncWarning = self.props.patchData
and self.props.patchData.unapplied
and PatchSet.countChanges(self.props.patchData.unapplied) > 0
return Theme.with(function(theme) return Theme.with(function(theme)
return Roact.createFragment({ return Roact.createFragment({
Padding = e("UIPadding", { Padding = e("UIPadding", {
@@ -278,9 +349,88 @@ function ConnectedPage:render()
Padding = UDim.new(0, 10), Padding = UDim.new(0, 10),
}), }),
Header = e(Header, { Heading = e("Frame", {
transparency = self.props.transparency, BackgroundTransparency = 1,
layoutOrder = 1, Size = UDim2.new(1, 0, 0, 32),
}, {
Header = e(Header, {
transparency = self.props.transparency,
}),
ChangeInfo = e("TextButton", {
Text = "",
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
BackgroundColor3 = theme.BorderedContainer.BorderedColor,
BackgroundTransparency = if self.state.hoveringChangeInfo then 0.7 else 1,
BorderSizePixel = 0,
Position = UDim2.new(1, -5, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
[Roact.Event.MouseEnter] = function()
self:setState({
hoveringChangeInfo = true,
})
end,
[Roact.Event.MouseLeave] = function()
self:setState({
hoveringChangeInfo = false,
})
end,
[Roact.Event.Activated] = function()
self:setState(function(prevState)
prevState = prevState or {}
return {
renderChanges = not prevState.renderChanges,
}
end)
end,
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 5),
}),
Tooltip = e(Tooltip.Trigger, {
text = if self.state.renderChanges then "Hide changes" else "View changes",
}),
Content = e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
Text = e("TextLabel", {
BackgroundTransparency = 1,
Text = self.changeInfoText,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor,
TextTransparency = self.props.transparency,
TextXAlignment = Enum.TextXAlignment.Right,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
LayoutOrder = 1,
}),
Icon = e("ImageLabel", {
BackgroundTransparency = 1,
Image = if syncWarning
then Assets.Images.Icons.SyncWarning
else Assets.Images.Icons.SyncSuccess,
ImageColor3 = if syncWarning then theme.Diff.Warning else theme.Header.VersionColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 24, 0, 24),
LayoutOrder = 2,
}),
}),
}),
}), }),
ConnectionDetails = e(ConnectionDetails, { ConnectionDetails = e(ConnectionDetails, {
@@ -330,83 +480,65 @@ function ConnectedPage:render()
}), }),
}), }),
ChangeInfo = e("TextButton", { ChangesViewer = e(StudioPluginGui, {
Text = self.changeInfoText, id = "Rojo_ChangesViewer",
Font = Enum.Font.Gotham, title = "View changes",
TextSize = 14, active = self.state.renderChanges,
TextWrapped = true, isEphemeral = true,
RichText = true,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 28), initDockState = Enum.InitialDockState.Float,
overridePreviousState = true,
floatingSize = Vector2.new(400, 500),
minimumSize = Vector2.new(300, 300),
LayoutOrder = 4, zIndexBehavior = Enum.ZIndexBehavior.Sibling,
BackgroundTransparency = 1,
[Roact.Event.MouseEnter] = function() onClose = function()
self:setState({ self:setState({
hoveringChangeInfo = true, renderChanges = false,
}) })
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
end,
[Roact.Event.MouseLeave] = function()
self:setState({
hoveringChangeInfo = false,
})
self.setChangeInfoText(self:getChangeInfoText())
end,
[Roact.Event.Activated] = function()
if self.state.renderChanges then
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
else
self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, {
frequency = 3,
dampingRatio = 1,
}))
end
end, end,
}, { }, {
Tooltip = e(Tooltip.Trigger, { TooltipsProvider = e(Tooltip.Provider, nil, {
text = if self.state.renderChanges then "Hide the changes" else "View the changes", Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Changes = e(ChangesViewer, {
transparency = self.props.transparency,
rendered = self.state.renderChanges,
patchData = self.props.patchData,
patchTree = self.props.patchTree,
serveSession = self.props.serveSession,
showStringDiff = function(oldString: string, newString: string)
self:setState({
showingStringDiff = true,
oldString = oldString,
newString = newString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
onBack = function()
self:setState({
renderChanges = false,
})
end,
}),
}),
}), }),
}), }),
ChangesDrawer = e(ChangesDrawer, { StringDiff = e(StudioPluginGui, {
rendered = self.state.renderChanges, id = "Rojo_ConnectedStringDiff",
transparency = self.props.transparency, title = "String diff",
patchTree = self.props.patchTree, active = self.state.showingStringDiff,
serveSession = self.props.serveSession,
height = self.changeDrawerHeight,
layoutOrder = 5,
showSourceDiff = function(oldSource: string, newSource: string)
self:setState({
showingSourceDiff = true,
oldSource = oldSource,
newSource = newSource,
})
end,
onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
end,
}),
SourceDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedSourceDiff",
title = "Source diff",
active = self.state.showingSourceDiff,
isEphemeral = true, isEphemeral = true,
initDockState = Enum.InitialDockState.Float, initDockState = Enum.InitialDockState.Float,
@@ -418,7 +550,7 @@ function ConnectedPage:render()
onClose = function() onClose = function()
self:setState({ self:setState({
showingSourceDiff = false, showingStringDiff = false,
}) })
end, end,
}, { }, {
@@ -434,8 +566,46 @@ function ConnectedPage:render()
anchorPoint = Vector2.new(0, 0), anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency, transparency = self.props.transparency,
oldText = self.state.oldSource, oldString = self.state.oldString,
newText = self.state.newSource, newString = self.state.newString,
}),
}),
}),
}),
TableDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedTableDiff",
title = "Table diff",
active = self.state.showingTableDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
overridePreviousState = false,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = function()
self:setState({
showingTableDiff = false,
})
end,
}, {
TooltipsProvider = e(Tooltip.Provider, nil, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
e(TableDiffVisualizer, {
size = UDim2.new(1, -10, 1, -10),
position = UDim2.new(0, 5, 0, 5),
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldTable = self.state.oldTable,
newTable = self.state.newTable,
}), }),
}), }),
}), }),

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -7,8 +5,10 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip) local Tooltip = require(Plugin.App.Components.Tooltip)
@@ -24,43 +24,44 @@ function Error:init()
end end
function Error:render() function Error:render()
return e(BorderedContainer, { return Theme.with(function(theme)
size = Roact.joinBindings({ return e(BorderedContainer, {
containerSize = self.props.containerSize, size = Roact.joinBindings({
contentSize = self.contentSize, containerSize = self.props.containerSize,
}):map(function(values) contentSize = self.contentSize,
local maximumSize = values.containerSize }):map(function(values)
maximumSize -= Vector2.new(14, 14) * 2 -- Page padding local maximumSize = values.containerSize
maximumSize -= Vector2.new(0, 34 + 10) -- Buttons and spacing maximumSize -= Vector2.new(14, 14) * 2 -- Page padding
maximumSize -= Vector2.new(0, 34 + 10) -- Buttons and spacing
local outerSize = values.contentSize + ERROR_PADDING * 2 local outerSize = values.contentSize + ERROR_PADDING * 2
return UDim2.new(1, 0, 0, math.min(outerSize.Y, maximumSize.Y)) return UDim2.new(1, 0, 0, math.min(outerSize.Y, maximumSize.Y))
end),
transparency = self.props.transparency,
}, {
ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize:map(function(value)
return value + ERROR_PADDING * 2
end), end),
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = self.props.layoutOrder,
[Roact.Change.AbsoluteSize] = function(object)
local containerSize = object.AbsoluteSize - ERROR_PADDING * 2
local textBounds = TextService:GetTextSize(
self.props.errorMessage,
16,
Enum.Font.Code,
Vector2.new(containerSize.X, math.huge)
)
self.setContentSize(Vector2.new(containerSize.X, textBounds.Y))
end,
}, { }, {
ErrorMessage = Theme.with(function(theme) ScrollingFrame = e(ScrollingFrame, {
return e("TextBox", { size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize:map(function(value)
return value + ERROR_PADDING * 2
end),
transparency = self.props.transparency,
[Roact.Change.AbsoluteSize] = function(object)
local containerSize = object.AbsoluteSize - ERROR_PADDING * 2
local textBounds = getTextBoundsAsync(
self.props.errorMessage,
theme.Font.Code,
theme.TextSize.Code,
containerSize.X
)
self.setContentSize(Vector2.new(containerSize.X, textBounds.Y))
end,
}, {
ErrorMessage = e("TextBox", {
[Roact.Event.InputBegan] = function(rbx, input) [Roact.Event.InputBegan] = function(rbx, input)
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
return return
@@ -71,8 +72,8 @@ function Error:render()
Text = self.props.errorMessage, Text = self.props.errorMessage,
TextEditable = false, TextEditable = false,
Font = Enum.Font.Code, FontFace = theme.Font.Code,
TextSize = 16, TextSize = theme.TextSize.Code,
TextColor3 = theme.ErrorColor, TextColor3 = theme.ErrorColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top, TextYAlignment = Enum.TextYAlignment.Top,
@@ -81,17 +82,17 @@ function Error:render()
ClearTextOnFocus = false, ClearTextOnFocus = false,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
}) }),
end),
Padding = e("UIPadding", { Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, ERROR_PADDING.X), PaddingLeft = UDim.new(0, ERROR_PADDING.X),
PaddingRight = UDim.new(0, ERROR_PADDING.X), PaddingRight = UDim.new(0, ERROR_PADDING.X),
PaddingTop = UDim.new(0, ERROR_PADDING.Y), PaddingTop = UDim.new(0, ERROR_PADDING.Y),
PaddingBottom = UDim.new(0, ERROR_PADDING.Y), PaddingBottom = UDim.new(0, ERROR_PADDING.Y),
}),
}), }),
}), })
}) end)
end end
local ErrorPage = Roact.Component:extend("ErrorPage") local ErrorPage = Roact.Component:extend("ErrorPage")
@@ -109,16 +110,21 @@ function ErrorPage:render()
self.setContainerSize(object.AbsoluteSize) self.setContainerSize(object.AbsoluteSize)
end, end,
}, { }, {
Error = e(Error, { Header = e(Header, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
}), }),
Error = e(Error, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
transparency = self.props.transparency,
layoutOrder = 2,
}),
Buttons = e("Frame", { Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 35), Size = UDim2.new(1, 0, 0, 35),
LayoutOrder = 2, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Close = e(TextButton, { Close = e(TextButton, {

View File

@@ -27,8 +27,8 @@ local function AddressEntry(props)
}, { }, {
Host = e("TextBox", { Host = e("TextBox", {
Text = props.host or "", Text = props.host or "",
Font = Enum.Font.Code, FontFace = theme.Font.Code,
TextSize = 18, TextSize = theme.TextSize.Large,
TextColor3 = theme.AddressEntry.TextColor, TextColor3 = theme.AddressEntry.TextColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency, TextTransparency = props.transparency,
@@ -51,8 +51,8 @@ local function AddressEntry(props)
Port = e("TextBox", { Port = e("TextBox", {
Text = props.port or "", Text = props.port or "",
Font = Enum.Font.Code, FontFace = theme.Font.Code,
TextSize = 18, TextSize = theme.TextSize.Large,
TextColor3 = theme.AddressEntry.TextColor, TextColor3 = theme.AddressEntry.TextColor,
TextTransparency = props.transparency, TextTransparency = props.transparency,
PlaceholderText = Config.defaultPort, PlaceholderText = Config.defaultPort,

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages local Packages = Rojo.Packages
@@ -9,24 +7,55 @@ local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local Checkbox = require(Plugin.App.Components.Checkbox) local Checkbox = require(Plugin.App.Components.Checkbox)
local Dropdown = require(Plugin.App.Components.Dropdown) local Dropdown = require(Plugin.App.Components.Dropdown)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local Tag = require(Plugin.App.Components.Tag)
local e = Roact.createElement local e = Roact.createElement
local DIVIDER_FADE_SIZE = 0.1 local DIVIDER_FADE_SIZE = 0.1
local TAG_TYPES = {
unstable = {
text = "UNSTABLE",
icon = Assets.Images.Icons.Warning,
color = { "Settings", "Setting", "UnstableColor" },
},
debug = {
text = "DEBUG",
icon = Assets.Images.Icons.Debug,
color = { "Settings", "Setting", "DebugColor" },
},
}
local function getTextBounds(text, textSize, font, lineHeight, bounds) local function getTextBoundsWithLineHeight(
local textBounds = TextService:GetTextSize(text, textSize, font, bounds) text: string,
font: Font,
textSize: number,
width: number,
lineHeight: number
)
local textBounds = getTextBoundsAsync(text, font, textSize, width)
local lineCount = textBounds.Y / textSize local lineCount = math.ceil(textBounds.Y / textSize)
local lineHeightAbsolute = textSize * lineHeight local lineHeightAbsolute = textSize * lineHeight
return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize)) return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize))
end end
local function getThemeColorFromPath(theme, path)
local color = theme
for _, key in path do
if color[key] == nil then
return theme.BrandColor
end
color = color[key]
end
return color
end
local Setting = Roact.Component:extend("Setting") local Setting = Roact.Component:extend("Setting")
function Setting:init() function Setting:init()
@@ -51,11 +80,11 @@ end
function Setting:render() function Setting:render()
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.Settings local settingsTheme = theme.Settings
return e("Frame", { return e("Frame", {
Size = self.contentSize:map(function(value) Size = self.contentSize:map(function(value)
return UDim2.new(1, 0, 0, 20 + value.Y + 20) return UDim2.new(1, 0, 0, value.Y + 20)
end), end),
LayoutOrder = self.props.layoutOrder, LayoutOrder = self.props.layoutOrder,
ZIndex = -self.props.layoutOrder, ZIndex = -self.props.layoutOrder,
@@ -85,6 +114,7 @@ function Setting:render()
then self.props.input then self.props.input
elseif self.props.options ~= nil then e(Dropdown, { elseif self.props.options ~= nil then e(Dropdown, {
locked = self.props.locked, locked = self.props.locked,
lockedTooltip = self.props.lockedTooltip,
options = self.props.options, options = self.props.options,
active = self.state.setting, active = self.state.setting,
transparency = self.props.transparency, transparency = self.props.transparency,
@@ -94,6 +124,7 @@ function Setting:render()
}) })
else e(Checkbox, { else e(Checkbox, {
locked = self.props.locked, locked = self.props.locked,
lockedTooltip = self.props.lockedTooltip,
active = self.state.setting, active = self.state.setting,
transparency = self.props.transparency, transparency = self.props.transparency,
onClick = function() onClick = function()
@@ -106,7 +137,7 @@ function Setting:render()
then e(IconButton, { then e(IconButton, {
icon = Assets.Images.Icons.Reset, icon = Assets.Images.Icons.Reset,
iconSize = 24, iconSize = 24,
color = theme.BackButtonColor, color = settingsTheme.BackButtonColor,
transparency = self.props.transparency, transparency = self.props.transparency,
visible = self.props.showReset, visible = self.props.showReset,
layoutOrder = -1, layoutOrder = -1,
@@ -120,29 +151,49 @@ function Setting:render()
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Name = e("TextLabel", { Heading = e("Frame", {
Text = (if self.props.experimental then '<font color="#FF8E3C">⚠ </font>' else "") Size = UDim2.new(1, 0, 0, theme.TextSize.Medium),
.. self.props.name,
Font = Enum.Font.GothamBold,
TextSize = 17,
TextColor3 = theme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
RichText = true,
Size = UDim2.new(1, 0, 0, 17),
LayoutOrder = 1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, {
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Tag = if self.props.tag and TAG_TYPES[self.props.tag]
then e(Tag, {
layoutOrder = 1,
transparency = self.props.transparency,
text = TAG_TYPES[self.props.tag].text,
icon = TAG_TYPES[self.props.tag].icon,
color = getThemeColorFromPath(theme, TAG_TYPES[self.props.tag].color),
})
else nil,
Name = e("TextLabel", {
Text = self.props.name,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Medium,
TextColor3 = if self.props.tag and TAG_TYPES[self.props.tag]
then getThemeColorFromPath(theme, TAG_TYPES[self.props.tag].color)
else settingsTheme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
RichText = true,
Size = UDim2.new(1, 0, 0, theme.TextSize.Medium),
LayoutOrder = 2,
BackgroundTransparency = 1,
}),
}), }),
Description = e("TextLabel", { Description = e("TextLabel", {
Text = (if self.props.experimental then '<font color="#FF8E3C">[Experimental] </font>' else "") Text = self.props.description,
.. self.props.description, FontFace = theme.Font.Main,
Font = Enum.Font.Gotham,
LineHeight = 1.2, LineHeight = 1.2,
TextSize = 14, TextSize = theme.TextSize.Body,
TextColor3 = theme.Setting.DescriptionColor, TextColor3 = settingsTheme.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
TextWrapped = true, TextWrapped = true,
@@ -152,20 +203,18 @@ function Setting:render()
containerSize = self.containerSize, containerSize = self.containerSize,
inputSize = self.inputSize, inputSize = self.inputSize,
}):map(function(values) }):map(function(values)
local desc = (if self.props.experimental then "[Experimental] " else "")
.. self.props.description
local offset = values.inputSize.X + 5 local offset = values.inputSize.X + 5
local textBounds = getTextBounds( local textBounds = getTextBoundsWithLineHeight(
desc, self.props.description,
14, theme.Font.Main,
Enum.Font.Gotham, theme.TextSize.Body,
1.2, values.containerSize.X - offset,
Vector2.new(values.containerSize.X - offset, math.huge) 1.2
) )
return UDim2.new(1, -offset, 0, textBounds.Y) return UDim2.new(1, -offset, 0, textBounds.Y)
end), end),
LayoutOrder = 2, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
@@ -173,21 +222,16 @@ function Setting:render()
VerticalAlignment = Enum.VerticalAlignment.Center, VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical, FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder, SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 6), Padding = UDim.new(0, 5),
[Roact.Change.AbsoluteContentSize] = function(object) [Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize) self.setContentSize(object.AbsoluteContentSize)
end, end,
}), }),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 20),
}),
}), }),
Divider = e("Frame", { Divider = e("Frame", {
BackgroundColor3 = theme.DividerColor, BackgroundColor3 = settingsTheme.DividerColor,
BackgroundTransparency = self.props.transparency, BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1), Size = UDim2.new(1, 0, 0, 1),
BorderSizePixel = 0, BorderSizePixel = 0,

View File

@@ -26,11 +26,12 @@ local function invertTbl(tbl)
end end
local invertedLevels = invertTbl(Log.Level) local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId" } local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local syncReminderModes = { "None", "Notify", "Fullscreen" }
local function Navbar(props) local function Navbar(props)
return Theme.with(function(theme) return Theme.with(function(theme)
theme = theme.Settings.Navbar local navbarTheme = theme.Settings.Navbar
return e("Frame", { return e("Frame", {
Size = UDim2.new(1, 0, 0, 46), Size = UDim2.new(1, 0, 0, 46),
@@ -40,7 +41,7 @@ local function Navbar(props)
Back = e(IconButton, { Back = e(IconButton, {
icon = Assets.Images.Icons.Back, icon = Assets.Images.Icons.Back,
iconSize = 24, iconSize = 24,
color = theme.BackButtonColor, color = navbarTheme.BackButtonColor,
transparency = props.transparency, transparency = props.transparency,
position = UDim2.new(0, 0, 0.5, 0), position = UDim2.new(0, 0, 0.5, 0),
@@ -55,9 +56,9 @@ local function Navbar(props)
Text = e("TextLabel", { Text = e("TextLabel", {
Text = "Settings", Text = "Settings",
Font = Enum.Font.Gotham, FontFace = theme.Font.Thin,
TextSize = 18, TextSize = theme.TextSize.Large,
TextColor3 = theme.TextColor, TextColor3 = navbarTheme.TextColor,
TextTransparency = props.transparency, TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, 0, 1, 0),
@@ -75,18 +76,30 @@ function SettingsPage:init()
end end
function SettingsPage:render() function SettingsPage:render()
return Theme.with(function(theme) local layoutOrder = 0
theme = theme.Settings local function layoutIncrement()
layoutOrder += 1
return layoutOrder
end
return e(ScrollingFrame, { return Roact.createFragment({
size = UDim2.new(1, 0, 1, 0), Navbar = e(Navbar, {
onBack = self.props.onBack,
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
Content = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -47),
position = UDim2.new(0, 0, 0, 47),
contentSize = self.contentSize, contentSize = self.contentSize,
transparency = self.props.transparency, transparency = self.props.transparency,
}, { }, {
Navbar = e(Navbar, { AutoReconnect = e(Setting, {
onBack = self.props.onBack, id = "autoReconnect",
name = "Auto Reconnect",
description = "Reconnect to server on place open if the served project matches the last sync to the place",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 0, layoutOrder = layoutIncrement(),
}), }),
ShowNotifications = e(Setting, { ShowNotifications = e(Setting, {
@@ -94,16 +107,29 @@ function SettingsPage:render()
name = "Show Notifications", name = "Show Notifications",
description = "Popup notifications in viewport", description = "Popup notifications in viewport",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = layoutIncrement(),
}), }),
SyncReminder = e(Setting, { SyncReminderMode = e(Setting, {
id = "syncReminder", id = "syncReminderMode",
name = "Sync Reminder", name = "Sync Reminder",
description = "Notify to sync when opening a place that has previously been synced", description = "What type of reminders you receive for syncing your project",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = Settings:getBinding("showNotifications"), visible = Settings:getBinding("showNotifications"),
layoutOrder = 2,
options = syncReminderModes,
}),
SyncReminderPolling = e(Setting, {
id = "syncReminderPolling",
name = "Sync Reminder Polling",
description = "Look for available sync servers periodically",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = Settings:getBindings("syncReminderMode", "showNotifications"):map(function(values)
return values.syncReminderMode ~= "None" and values.showNotifications
end),
}), }),
ConfirmationBehavior = e(Setting, { ConfirmationBehavior = e(Setting, {
@@ -111,7 +137,7 @@ function SettingsPage:render()
name = "Confirmation Behavior", name = "Confirmation Behavior",
description = "When to prompt for confirmation before syncing", description = "When to prompt for confirmation before syncing",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = layoutIncrement(),
options = confirmationBehaviors, options = confirmationBehaviors,
}), }),
@@ -121,7 +147,7 @@ function SettingsPage:render()
name = "Confirmation Threshold", name = "Confirmation Threshold",
description = "How many modified instances to be considered a large change", description = "How many modified instances to be considered a large change",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 4, layoutOrder = layoutIncrement(),
visible = Settings:getBinding("confirmationBehavior"):map(function(value) visible = Settings:getBinding("confirmationBehavior"):map(function(value)
return value == "Large Changes" return value == "Large Changes"
end), end),
@@ -152,17 +178,52 @@ function SettingsPage:render()
name = "Play Sounds", name = "Play Sounds",
description = "Toggle sound effects", description = "Toggle sound effects",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 5, layoutOrder = layoutIncrement(),
}),
EnableSyncFallback = e(Setting, {
id = "enableSyncFallback",
name = "Enable Sync Fallback",
description = "Whether Instances that fail to sync are remade as a fallback. If this is enabled, Instances may be destroyed and remade when syncing.",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
CheckForUpdates = e(Setting, {
id = "checkForUpdates",
name = "Check For Updates",
description = "Notify about newer compatible Rojo releases",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
CheckForPreleases = e(Setting, {
id = "checkForPrereleases",
name = "Include Prerelease Updates",
description = "Include prereleases when checking for updates",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = if string.find(debug.traceback(), "\n[^\n]-user_.-$") == nil
then false -- Must be a local install to allow prerelease checks
else Settings:getBinding("checkForUpdates"),
}),
AutoConnectPlaytestServer = e(Setting, {
id = "autoConnectPlaytestServer",
name = "Auto Connect Playtest Server",
description = "Automatically connect game server to Rojo when playtesting while connected in Edit",
tag = "unstable",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}), }),
OpenScriptsExternally = e(Setting, { OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally", id = "openScriptsExternally",
name = "Open Scripts Externally", name = "Open Scripts Externally",
description = "Attempt to open scripts in an external editor", description = "Attempt to open scripts in an external editor",
locked = self.props.syncActive, tag = "unstable",
experimental = true,
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 6, layoutOrder = layoutIncrement(),
}), }),
TwoWaySync = e(Setting, { TwoWaySync = e(Setting, {
@@ -170,17 +231,19 @@ function SettingsPage:render()
name = "Two-Way Sync", name = "Two-Way Sync",
description = "Editing files in Studio will sync them into the filesystem", description = "Editing files in Studio will sync them into the filesystem",
locked = self.props.syncActive, locked = self.props.syncActive,
experimental = true, lockedTooltip = "(Cannot change while currently syncing. Disconnect first.)",
tag = "unstable",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 7, layoutOrder = layoutIncrement(),
}), }),
LogLevel = e(Setting, { LogLevel = e(Setting, {
id = "logLevel", id = "logLevel",
name = "Log Level", name = "Log Level",
description = "Plugin output verbosity level", description = "Plugin output verbosity level",
tag = "debug",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 100, layoutOrder = layoutIncrement(),
options = invertedLevels, options = invertedLevels,
showReset = Settings:getBinding("logLevel"):map(function(value) showReset = Settings:getBinding("logLevel"):map(function(value)
@@ -195,8 +258,18 @@ function SettingsPage:render()
id = "typecheckingEnabled", id = "typecheckingEnabled",
name = "Typechecking", name = "Typechecking",
description = "Toggle typechecking on the API surface", description = "Toggle typechecking on the API surface",
tag = "debug",
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 101, layoutOrder = layoutIncrement(),
}),
TimingLogsEnabled = e(Setting, {
id = "timingLogsEnabled",
name = "Timing Logs",
description = "Toggle logging timing of internal actions for benchmarking Rojo performance",
tag = "debug",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
@@ -212,8 +285,8 @@ function SettingsPage:render()
PaddingLeft = UDim.new(0, 20), PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20), PaddingRight = UDim.new(0, 20),
}), }),
}) }),
end) })
end end
return SettingsPage return SettingsPage

View File

@@ -1,7 +1,6 @@
--[[ --[[
Theming system taking advantage of Roact's new context API. Theming system provided through Roact's context.
Doesn't use colors provided by Studio and instead just branches on theme Uses Studio colors when possible.
name. This isn't exactly best practice.
]] ]]
-- Studio does not exist outside Roblox Studio, so we'll lazily initialize it -- Studio does not exist outside Roblox Studio, so we'll lazily initialize it
@@ -15,237 +14,18 @@ local function getStudio()
return _Studio return _Studio
end end
local ContentProvider = game:GetService("ContentProvider")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local strict = require(script.Parent.Parent.strict) local strict = require(script.Parent.Parent.strict)
local BRAND_COLOR = Color3.fromHex("E13835") local BRAND_COLOR = Color3.fromHex("E13835")
local lightTheme = strict("LightTheme", { local Context = Roact.createContext({})
BackgroundColor = Color3.fromHex("FFFFFF"),
Button = {
Solid = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = Color3.fromHex("000000"),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = Color3.fromHex("393939"),
BorderColor = Color3.fromHex("ACACAC"),
},
Disabled = {
TextColor = Color3.fromHex("393939"),
BorderColor = Color3.fromHex("ACACAC"),
},
},
},
Checkbox = {
Active = {
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = Color3.fromHex("EEEEEE"),
BorderColor = Color3.fromHex("AFAFAF"),
},
},
Dropdown = {
TextColor = Color3.fromHex("000000"),
BorderColor = Color3.fromHex("AFAFAF"),
BackgroundColor = Color3.fromHex("EEEEEE"),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = Color3.fromHex("EEEEEE"),
},
},
TextInput = {
Enabled = {
TextColor = Color3.fromHex("000000"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
BorderColor = Color3.fromHex("ACACAC"),
},
Disabled = {
TextColor = Color3.fromHex("393939"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
BorderColor = Color3.fromHex("AFAFAF"),
},
ActionFillColor = Color3.fromHex("000000"),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = Color3.fromHex("000000"),
PlaceholderColor = Color3.fromHex("8C8C8C"),
},
BorderedContainer = {
BorderColor = Color3.fromHex("CBCBCB"),
BackgroundColor = Color3.fromHex("EEEEEE"),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = Color3.fromHex("EEEEEE"),
},
Diff = {
Add = Color3.fromHex("baffbd"),
Remove = Color3.fromHex("ffbdba"),
Edit = Color3.fromHex("bacdff"),
Row = Color3.fromHex("000000"),
Warning = Color3.fromHex("FF8E3C"),
},
ConnectionDetails = {
ProjectNameColor = Color3.fromHex("000000"),
AddressColor = Color3.fromHex("000000"),
DisconnectColor = BRAND_COLOR,
},
Settings = {
DividerColor = Color3.fromHex("CBCBCB"),
Navbar = {
BackButtonColor = Color3.fromHex("000000"),
TextColor = Color3.fromHex("000000"),
},
Setting = {
NameColor = Color3.fromHex("000000"),
DescriptionColor = Color3.fromHex("5F5F5F"),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = Color3.fromHex("727272"),
},
Notification = {
InfoColor = Color3.fromHex("000000"),
CloseColor = BRAND_COLOR,
},
ErrorColor = Color3.fromHex("000000"),
ScrollBarColor = Color3.fromHex("000000"),
})
local darkTheme = strict("DarkTheme", {
BackgroundColor = Color3.fromHex("2E2E2E"),
Button = {
Solid = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = Color3.fromHex("DBDBDB"),
BorderColor = Color3.fromHex("535353"),
},
Disabled = {
TextColor = Color3.fromHex("DBDBDB"),
BorderColor = Color3.fromHex("535353"),
},
},
},
Checkbox = {
Active = {
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = Color3.fromHex("484848"),
BorderColor = Color3.fromHex("5A5A5A"),
},
},
Dropdown = {
TextColor = Color3.fromHex("FFFFFF"),
BorderColor = Color3.fromHex("5A5A5A"),
BackgroundColor = Color3.fromHex("2B2B2B"),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = Color3.fromHex("484848"),
},
},
TextInput = {
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
BorderColor = Color3.fromHex("535353"),
},
Disabled = {
TextColor = Color3.fromHex("484848"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
BorderColor = Color3.fromHex("5A5A5A"),
},
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = Color3.fromHex("FFFFFF"),
PlaceholderColor = Color3.fromHex("8B8B8B"),
},
BorderedContainer = {
BorderColor = Color3.fromHex("535353"),
BackgroundColor = Color3.fromHex("2B2B2B"),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = Color3.fromHex("2B2B2B"),
},
Diff = {
Add = Color3.fromHex("273732"),
Remove = Color3.fromHex("3F2D32"),
Edit = Color3.fromHex("193345"),
Row = Color3.fromHex("FFFFFF"),
Warning = Color3.fromHex("FF8E3C"),
},
ConnectionDetails = {
ProjectNameColor = Color3.fromHex("FFFFFF"),
AddressColor = Color3.fromHex("FFFFFF"),
DisconnectColor = Color3.fromHex("FFFFFF"),
},
Settings = {
DividerColor = Color3.fromHex("535353"),
Navbar = {
BackButtonColor = Color3.fromHex("FFFFFF"),
TextColor = Color3.fromHex("FFFFFF"),
},
Setting = {
NameColor = Color3.fromHex("FFFFFF"),
DescriptionColor = Color3.fromHex("D3D3D3"),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = Color3.fromHex("D3D3D3"),
},
Notification = {
InfoColor = Color3.fromHex("FFFFFF"),
CloseColor = Color3.fromHex("FFFFFF"),
},
ErrorColor = Color3.fromHex("FFFFFF"),
ScrollBarColor = Color3.fromHex("FFFFFF"),
})
local Context = Roact.createContext(lightTheme)
local StudioProvider = Roact.Component:extend("StudioProvider") local StudioProvider = Roact.Component:extend("StudioProvider")
@@ -253,25 +33,209 @@ local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:updateTheme() function StudioProvider:updateTheme()
local studioTheme = getStudio().Theme local studioTheme = getStudio().Theme
if studioTheme.Name == "Light" then local isDark = studioTheme.Name == "Dark"
self:setState({
theme = lightTheme,
})
elseif studioTheme.Name == "Dark" then
self:setState({
theme = darkTheme,
})
else
Log.warn("Unexpected theme '{}'' -- falling back to light theme!", studioTheme.Name)
self:setState({ local theme = strict(studioTheme.Name .. "Theme", {
theme = lightTheme, Font = {
}) Main = Font.new("rbxasset://fonts/families/Montserrat.json", Enum.FontWeight.Medium, Enum.FontStyle.Normal),
end Bold = Font.new("rbxasset://fonts/families/Montserrat.json", Enum.FontWeight.Bold, Enum.FontStyle.Normal),
Thin = Font.new(
"rbxasset://fonts/families/Montserrat.json",
Enum.FontWeight.Regular,
Enum.FontStyle.Normal
),
Code = Font.new(
"rbxasset://fonts/families/Inconsolata.json",
Enum.FontWeight.Regular,
Enum.FontStyle.Normal
),
},
TextSize = {
Body = 15,
Small = 13,
Medium = 16,
Large = 18,
Code = 16,
},
BrandColor = BRAND_COLOR,
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
SubTextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
Button = {
Solid = {
-- Solid uses brand theming, not Studio theming.
ActionFillColor = Color3.fromHex("FFFFFF"),
ActionFillTransparency = 0.8,
Enabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Disabled = {
TextColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
},
Bordered = {
ActionFillColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Selected
),
ActionFillTransparency = 0.9,
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.ButtonText,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
},
Checkbox = {
Active = {
-- Active checkboxes use brand theming, not Studio theming.
IconColor = Color3.fromHex("FFFFFF"),
BackgroundColor = BRAND_COLOR,
},
Inactive = {
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
},
Dropdown = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.ButtonText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground),
IconColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldIndicator,
Enum.StudioStyleGuideModifier.Disabled
),
},
TextInput = {
Enabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
Disabled = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
},
ActionFillColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ActionFillTransparency = 0.9,
},
AddressEntry = {
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
PlaceholderColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.SubText),
},
BorderedContainer = {
BorderColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Spinner = {
ForegroundColor = BRAND_COLOR,
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Diff = {
-- Very bright different colors in case some places were not updated to use
-- the new background diff colors.
Add = Color3.fromRGB(255, 0, 255),
Remove = Color3.fromRGB(255, 0, 255),
Edit = Color3.fromRGB(255, 0, 255),
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
Background = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Text = {
Add = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remove = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Edit = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
},
ConnectionDetails = {
ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
AddressColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DisconnectColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Settings = {
DividerColor = studioTheme:GetColor(
Enum.StudioStyleGuideColor.CheckedFieldBorder,
Enum.StudioStyleGuideModifier.Disabled
),
Navbar = {
BackButtonColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
TextColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Setting = {
NameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
DescriptionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
UnstableColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
DebugColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InfoText),
},
},
Header = {
LogoColor = BRAND_COLOR,
VersionColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
Notification = {
InfoColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
CloseColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
ErrorColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
ScrollBarColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
})
self:setState({
theme = theme,
})
end end
function StudioProvider:init() function StudioProvider:init()
self:updateTheme() self:updateTheme()
-- Preload the Fonts so that getTextBoundsAsync won't yield
local fontAssetIds = {}
for _, font in self.state.theme.Font do
table.insert(fontAssetIds, font.Family)
end
pcall(ContentProvider.PreloadAsync, ContentProvider, fontAssetIds)
end end
function StudioProvider:render() function StudioProvider:render()

View File

@@ -0,0 +1,41 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local params = Instance.new("GetTextBoundsParams")
local function getTextBoundsAsync(
text: string,
font: Font,
textSize: number,
width: number,
richText: boolean?
): Vector2
if type(text) ~= "string" then
Log.warn(`Invalid text. Expected string, received {type(text)} instead`)
return Vector2.zero
end
if #text >= 200_000 then
Log.warn(`Invalid text. Exceeds the 199,999 character limit`)
return Vector2.zero
end
params.Text = text
params.Font = font
params.Size = textSize
params.Width = width
params.RichText = not not richText
local success, bounds = pcall(TextService.GetTextBoundsAsync, TextService, params)
if not success then
Log.warn(`Failed to get text bounds: {bounds}`)
return Vector2.zero
end
return bounds
end
return getTextBoundsAsync

View File

@@ -9,6 +9,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
@@ -23,10 +24,11 @@ local PatchTree = require(Plugin.PatchTree)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer) local soundPlayer = require(Plugin.soundPlayer)
local ignorePlaceIds = require(Plugin.ignorePlaceIds) local ignorePlaceIds = require(Plugin.ignorePlaceIds)
local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local Page = require(script.Page) local Page = require(script.Page)
local Notifications = require(script.Notifications) local Notifications = require(script.Components.Notifications)
local Tooltip = require(script.Components.Tooltip) local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToolbar = require(script.Components.Studio.StudioToolbar)
@@ -51,9 +53,9 @@ local App = Roact.Component:extend("App")
function App:init() function App:init()
preloadAssets() preloadAssets()
local priorHost, priorPort = self:getPriorEndpoint() local priorSyncInfo = self:getPriorSyncInfo()
self.host, self.setHost = Roact.createBinding(priorHost or "") self.host, self.setHost = Roact.createBinding(priorSyncInfo.host or "")
self.port, self.setPort = Roact.createBinding(priorPort or "") self.port, self.setPort = Roact.createBinding(priorSyncInfo.port or "")
self.confirmationBindable = Instance.new("BindableEvent") self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event self.confirmationEvent = self.confirmationBindable.Event
@@ -77,17 +79,18 @@ function App:init()
action action
) )
) )
local dismissNotif = self:addNotification( local dismissNotif = self:addNotification({
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action), text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10, timeout = 10,
{ onClose = function()
cleanup()
end,
actions = {
Restore = { Restore = {
text = "Restore", text = "Restore",
style = "Solid", style = "Solid",
layoutOrder = 1, layoutOrder = 1,
onClick = function(notification) onClick = function()
cleanup()
notification:dismiss()
ChangeHistoryService:Redo() ChangeHistoryService:Redo()
end, end,
}, },
@@ -95,13 +98,9 @@ function App:init()
text = "Dismiss", text = "Dismiss",
style = "Bordered", style = "Bordered",
layoutOrder = 2, layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
}, },
} },
) })
undoConnection = ChangeHistoryService.OnUndo:Once(function() undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch -- Our notif is now out of date- redoing will not restore the patch
@@ -118,6 +117,13 @@ function App:init()
end) end)
end) end)
self.disconnectUpdatesCheckChanged = Settings:onChanged("checkForUpdates", function()
self:checkForUpdates()
end)
self.disconnectPrereleasesCheckChanged = Settings:onChanged("checkForPrereleases", function()
self:checkForUpdates()
end)
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
guiEnabled = false, guiEnabled = false,
@@ -131,44 +137,65 @@ function App:init()
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
if if RunService:IsEdit() then
RunService:IsEdit() self:checkForUpdates()
and self.serveSession == nil
and Settings:get("syncReminder") self:startSyncReminderPolling()
and self:getLastSyncTimestamp() self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
then if enabled then
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, { self:startSyncReminderPolling()
Connect = { else
text = "Connect", self:stopSyncReminderPolling()
style = "Solid", end
layoutOrder = 1, end)
onClick = function(notification)
notification:dismiss() self:tryAutoReconnect():andThen(function(didReconnect)
self:startSession() if not didReconnect then
end, self:checkSyncReminder()
}, end
Dismiss = { end)
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
})
end end
if self:isAutoConnectPlaytestServerAvailable() then
self:useRunningConnectionInfo()
self:startSession()
end
self.autoConnectPlaytestServerListener = Settings:onChanged("autoConnectPlaytestServer", function(enabled)
if enabled then
if self:isAutoConnectPlaytestServerWriteable() and self.serveSession ~= nil then
-- Write the existing session
local baseUrl = self.serveSession.__apiContext.__baseUrl
self:setRunningConnectionInfo(baseUrl)
end
else
self:clearRunningConnectionInfo()
end
end)
end end
function App:willUnmount() function App:willUnmount()
self.waypointConnection:Disconnect() self.waypointConnection:Disconnect()
self.confirmationBindable:Destroy() self.confirmationBindable:Destroy()
self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged()
if self.disconnectSyncReminderPollingChanged then
self.disconnectSyncReminderPollingChanged()
end
self:stopSyncReminderPolling()
self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo()
end end
function App:addNotification( function App:addNotification(notif: {
text: string, text: string,
isFullscreen: boolean?,
timeout: number?, timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }? actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> ()? } }?,
) onClose: (any) -> ()?,
})
if not Settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
@@ -176,17 +203,17 @@ function App:addNotification(
self.notifId += 1 self.notifId += 1
local id = self.notifId local id = self.notifId
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = { local notifications = table.clone(prevState.notifications)
text = text, notifications[id] = Dictionary.merge({
timestamp = DateTime.now().UnixTimestampMillis, timeout = notif.timeout or 5,
timeout = timeout or 3, isFullscreen = notif.isFullscreen or false,
actions = actions, }, notif)
}
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
return function() return function()
self:closeNotification(id) self:closeNotification(id)
@@ -198,62 +225,60 @@ function App:closeNotification(id: number)
return return
end end
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = nil local notifications = table.clone(prevState.notifications)
notifications[id] = nil
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
end end
function App:getPriorEndpoint() function App:checkForUpdates()
local priorEndpoints = Settings:get("priorEndpoints") local updateMessage = Version.getUpdateMessage()
if not priorEndpoints then
return if updateMessage then
self:addNotification({
text = updateMessage,
timeout = 500,
actions = {
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
},
},
})
end
end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? }
local priorSyncInfos = Settings:get("priorEndpoints")
if not priorSyncInfos then
return {}
end end
local id = tostring(game.PlaceId) local id = tostring(game.PlaceId)
if ignorePlaceIds[id] then if ignorePlaceIds[id] then
return return {}
end end
local place = priorEndpoints[id] return priorSyncInfos[id] or {}
if not place then
return
end
return place.host, place.port
end end
function App:getLastSyncTimestamp() function App:setPriorSyncInfo(host: string, port: string, projectName: string)
local priorEndpoints = Settings:get("priorEndpoints") local priorSyncInfos = Settings:get("priorEndpoints")
if not priorEndpoints then if not priorSyncInfos then
return priorSyncInfos = {}
end end
local id = tostring(game.PlaceId) local now = os.time()
if ignorePlaceIds[id] then
return
end
local place = priorEndpoints[id]
if not place then
return
end
return place.timestamp
end
function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
priorEndpoints = {}
end
-- Clear any stale saves to avoid disc bloat -- Clear any stale saves to avoid disc bloat
for placeId, endpoint in priorEndpoints do for placeId, syncInfo in priorSyncInfos do
if os.time() - endpoint.timestamp > 12_960_000 then if now - (syncInfo.timestamp or now) > 12_960_000 then
priorEndpoints[placeId] = nil priorSyncInfos[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId) Log.trace("Cleared stale saved endpoint for {}", placeId)
end end
end end
@@ -263,24 +288,43 @@ function App:setPriorEndpoint(host: string, port: string)
return return
end end
priorEndpoints[id] = { priorSyncInfos[id] = {
host = if host ~= Config.defaultHost then host else nil, host = if host ~= Config.defaultHost then host else nil,
port = if port ~= Config.defaultPort then port else nil, port = if port ~= Config.defaultPort then port else nil,
timestamp = os.time(), projectName = projectName,
timestamp = now,
} }
Log.trace("Saved last used endpoint for {}", game.PlaceId) Log.trace("Saved last used endpoint for {}", game.PlaceId)
Settings:set("priorEndpoints", priorEndpoints) Settings:set("priorEndpoints", priorSyncInfos)
end end
function App:getHostAndPort() function App:getHostAndPort()
local host = self.host:getValue() local host = self.host:getValue()
local port = self.port:getValue() local port = self.port:getValue()
local host = if #host > 0 then host else Config.defaultHost return if #host > 0 then host else Config.defaultHost, if #port > 0 then port else Config.defaultPort
local port = if #port > 0 then port else Config.defaultPort end
return host, port function App:isSyncLockAvailable()
if #Players:GetPlayers() == 0 then
-- Team Create is not active, so no one can be holding the lock
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
-- No lock is made yet, so it is available
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
-- Someone else is holding the lock
return false, lock.Value
end
-- The lock exists, but is not claimed
return true
end end
function App:claimSyncLock() function App:claimSyncLock()
@@ -289,6 +333,12 @@ function App:claimSyncLock()
return true return true
end end
local isAvailable, priorOwner = self:isSyncLockAvailable()
if not isAvailable then
Log.trace("Skipping sync lock because it is already claimed")
return false, priorOwner
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock") local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then if not lock then
lock = Instance.new("ObjectValue") lock = Instance.new("ObjectValue")
@@ -300,11 +350,6 @@ function App:claimSyncLock()
return true return true
end end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
Log.trace("Found existing sync lock owned by {}", lock.Value)
return false, lock.Value
end
lock.Value = Players.LocalPlayer lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock") Log.trace("Claimed existing sync lock")
return true return true
@@ -326,13 +371,209 @@ function App:releaseSyncLock()
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value) Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end end
function App:findActiveServer()
local host, port = self:getHostAndPort()
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
Log.trace("Checking for active sync server at {}", baseUrl)
local apiContext = ApiContext.new(baseUrl)
return apiContext:connect():andThen(function(serverInfo)
apiContext:disconnect()
return serverInfo, host, port
end)
end
function App:tryAutoReconnect()
if not Settings:get("autoReconnect") then
return Promise.resolve(false)
end
local priorSyncInfo = self:getPriorSyncInfo()
if not priorSyncInfo.projectName then
Log.trace("No prior sync info found, skipping auto-reconnect")
return Promise.resolve(false)
end
return self:findActiveServer()
:andThen(function(serverInfo)
-- change
if serverInfo.projectName == priorSyncInfo.projectName then
Log.trace("Auto-reconnect found matching server, reconnecting...")
self:addNotification({
text = `Auto-reconnect discovered project '{serverInfo.projectName}'...`,
})
self:startSession()
return true
end
Log.trace("Auto-reconnect found different server, not reconnecting")
return false
end)
:catch(function()
Log.trace("Auto-reconnect did not find a server, not reconnecting")
return false
end)
end
function App:checkSyncReminder()
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
if self.serveSession ~= nil or not self:isSyncLockAvailable() then
-- Already syncing or cannot sync, no reason to remind
return
end
local priorSyncInfo = self:getPriorSyncInfo()
self:findActiveServer()
:andThen(function(serverInfo, host, port)
self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`
)
end)
:catch(function()
if priorSyncInfo.timestamp and priorSyncInfo.projectName then
-- We didn't find an active server,
-- but this place has a prior sync
-- so we should remind the user to serve
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`
)
end
end)
end
function App:startSyncReminderPolling()
if
self.syncReminderPollingThread ~= nil
or Settings:get("syncReminderMode") == "None"
or not Settings:get("syncReminderPolling")
then
return
end
Log.trace("Starting sync reminder polling thread")
self.syncReminderPollingThread = task.spawn(function()
while task.wait(30) do
if self.syncReminderPollingThread == nil then
-- The polling thread was stopped, so exit
return
end
if self.dismissSyncReminder then
-- There is already a sync reminder being shown
task.wait(5)
continue
end
self:checkSyncReminder()
end
end)
end
function App:stopSyncReminderPolling()
if self.syncReminderPollingThread then
Log.trace("Stopping sync reminder polling thread")
task.cancel(self.syncReminderPollingThread)
self.syncReminderPollingThread = nil
end
end
function App:sendSyncReminder(message: string)
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
self.dismissSyncReminder = self:addNotification({
text = message,
timeout = 120,
isFullscreen = Settings:get("syncReminderMode") == "Fullscreen",
onClose = function()
self.dismissSyncReminder = nil
end,
actions = {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
},
},
})
end
function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunning()
and RunService:IsStudio()
and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl")
end
function App:isAutoConnectPlaytestServerWriteable()
return RunService:IsEdit() and Settings:get("autoConnectPlaytestServer")
end
function App:setRunningConnectionInfo(baseUrl: string)
if not self:isAutoConnectPlaytestServerWriteable() then
return
end
Log.trace("Setting connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", baseUrl)
end
function App:clearRunningConnectionInfo()
if not RunService:IsEdit() then
-- Only write connection info from edit mode
return
end
Log.trace("Clearing connection info for play solo auto-connect")
workspace:SetAttribute("__Rojo_ConnectionUrl", nil)
end
function App:useRunningConnectionInfo()
local connectionInfo = workspace:GetAttribute("__Rojo_ConnectionUrl")
if not connectionInfo then
return
end
Log.trace("Using connection info for play solo auto-connect")
local host, port = string.match(connectionInfo, "^(.+):(.-)$")
self.setHost(host)
self.setPort(port)
end
function App:startSession() function App:startSession()
local claimedLock, priorOwner = self:claimSyncLock() local claimedLock, priorOwner = self:claimSyncLock()
if not claimedLock then if not claimedLock then
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner)) local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg) Log.warn(msg)
self:addNotification(msg, 10) self:addNotification({
text = msg,
timeout = 10,
})
self:setState({ self:setState({
appStatus = AppStatus.Error, appStatus = AppStatus.Error,
errorMessage = msg, errorMessage = msg,
@@ -344,11 +585,6 @@ function App:startSession()
local host, port = self:getHostAndPort() local host, port = self:getHostAndPort()
local sessionOptions = {
openScriptsExternally = Settings:get("openScriptsExternally"),
twoWaySync = Settings:get("twoWaySync"),
}
local baseUrl = if string.find(host, "^https?://") local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port) then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port) else string.format("http://%s:%s", host, port)
@@ -356,17 +592,16 @@ function App:startSession()
local serveSession = ServeSession.new({ local serveSession = ServeSession.new({
apiContext = apiContext, apiContext = apiContext,
openScriptsExternally = sessionOptions.openScriptsExternally, twoWaySync = Settings:get("twoWaySync"),
twoWaySync = sessionOptions.twoWaySync,
}) })
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap) self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch -- Build new tree for patch
self:setState({ self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }), patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
}) })
end) end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch) self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata -- Update tree with unapplied metadata
self:setState(function(prevState) self:setState(function(prevState)
return { return {
@@ -376,7 +611,7 @@ function App:startSession()
end) end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied) serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = os.time() local now = DateTime.now().UnixTimestamp
local old = self.state.patchData local old = self.state.patchData
if PatchSet.isEmpty(patch) then if PatchSet.isEmpty(patch) then
@@ -409,15 +644,22 @@ function App:startSession()
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then if status == ServeSession.Status.Connecting then
self:setPriorEndpoint(host, port) if self.dismissSyncReminder then
self.dismissSyncReminder()
self.dismissSyncReminder = nil
end
self:setState({ self:setState({
appStatus = AppStatus.Connecting, appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Connecting to session...") self:addNotification({
text = "Connecting to session...",
})
elseif status == ServeSession.Status.Connected then elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true self.knownProjects[details] = true
self:setPriorSyncInfo(host, port, details)
self:setRunningConnectionInfo(baseUrl)
local address = ("%s:%s"):format(host, port) local address = ("%s:%s"):format(host, port)
self:setState({ self:setState({
@@ -426,10 +668,13 @@ function App:startSession()
address = address, address = address,
toolbarIcon = Assets.Images.PluginButtonConnected, toolbarIcon = Assets.Images.PluginButtonConnected,
}) })
self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5) self:addNotification({
text = string.format("Connected to session '%s' at %s.", details, address),
})
elseif status == ServeSession.Status.Disconnected then elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock() self:releaseSyncLock()
self:clearRunningConnectionInfo()
self:setState({ self:setState({
patchData = { patchData = {
patch = PatchSet.newEmpty(), patch = PatchSet.newEmpty(),
@@ -448,13 +693,19 @@ function App:startSession()
errorMessage = tostring(details), errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning, toolbarIcon = Assets.Images.PluginButtonWarning,
}) })
self:addNotification(tostring(details), 10) self:addNotification({
text = tostring(details),
timeout = 10,
})
else else
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Disconnected from session.") self:addNotification({
text = "Disconnected from session.",
timeout = 10,
})
end end
end end
end) end)
@@ -465,6 +716,12 @@ function App:startSession()
return "Accept" return "Accept"
end end
-- Play solo auto-connect does not require confirmation
if self:isAutoConnectPlaytestServerAvailable() then
Log.trace("Accepting patch without confirmation because play solo auto-connect is enabled")
return "Accept"
end
local confirmationBehavior = Settings:get("confirmationBehavior") local confirmationBehavior = Settings:get("confirmationBehavior")
if confirmationBehavior == "Initial" then if confirmationBehavior == "Initial" then
-- Only confirm if we haven't synced this project yet this session -- Only confirm if we haven't synced this project yet this session
@@ -493,6 +750,9 @@ function App:startSession()
return "Accept" return "Accept"
end end
end end
elseif confirmationBehavior == "Never" then
Log.trace("Accepting patch without confirmation because behavior is set to Never")
return "Accept"
end end
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive -- The datamodel name gets overwritten by Studio, making confirmation of it intrusive
@@ -523,13 +783,13 @@ function App:startSession()
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification( self:addNotification({
string.format( text = string.format(
"Please accept%sor abort the initializing sync session.", "Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " " Settings:get("twoWaySync") and ", reject, " or " "
), ),
7 timeout = 7,
) })
return self.confirmationEvent:Wait() return self.confirmationEvent:Wait()
end) end)
@@ -580,7 +840,7 @@ function App:render()
value = self.props.plugin, value = self.props.plugin,
}, { }, {
e(Theme.StudioProvider, nil, { e(Theme.StudioProvider, nil, {
e(Tooltip.Provider, nil, { tooltip = e(Tooltip.Provider, nil, {
gui = e(StudioPluginGui, { gui = e(StudioPluginGui, {
id = pluginName, id = pluginName,
title = pluginName, title = pluginName,
@@ -690,19 +950,7 @@ function App:render()
ResetOnSpawn = false, ResetOnSpawn = false,
DisplayOrder = 100, DisplayOrder = 100,
}, { }, {
layout = e("UIListLayout", { Notifications = e(Notifications, {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications, notifications = self.state.notifications,
onClose = function(id) onClose = function(id)

View File

@@ -25,6 +25,12 @@ local Assets = {
Back = "rbxassetid://6017213752", Back = "rbxassetid://6017213752",
Reset = "rbxassetid://10142422327", Reset = "rbxassetid://10142422327",
Expand = "rbxassetid://12045401097", Expand = "rbxassetid://12045401097",
Warning = "rbxassetid://16571019891",
Debug = "rbxassetid://16588411361",
Checkmark = "rbxassetid://16571012729",
Exclamation = "rbxassetid://16571172190",
SyncSuccess = "rbxassetid://16565035221",
SyncWarning = "rbxassetid://16565325171",
}, },
Diff = { Diff = {
Add = "rbxassetid://10434145835", Add = "rbxassetid://10434145835",

View File

@@ -3,7 +3,8 @@ local strict = require(script.Parent.strict)
local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
local Version = script.Parent.Parent.Version local Version = script.Parent.Parent.Version
local major, minor, patch, metadata = Version.Value:match("^(%d+)%.(%d+)%.(%d+)(.*)$") local trimmedVersionValue = Version.Value:gsub("^%s+", ""):gsub("%s+$", "")
local major, minor, patch, metadata = trimmedVersionValue:match("^(%d+)%.(%d+)%.(%d+)(.*)$")
local realVersion = { major, minor, patch, metadata } local realVersion = { major, minor, patch, metadata }
for i = 1, 3 do for i = 1, 3 do

View File

@@ -112,28 +112,33 @@ end
function InstanceMap:destroyInstance(instance) function InstanceMap:destroyInstance(instance)
local id = self.fromInstances[instance] local id = self.fromInstances[instance]
local descendants = instance:GetDescendants()
-- Because the user might want to Undo this change, we cannot use Destroy
-- since that locks that parent and prevents ChangeHistoryService from
-- ever bringing it back. Instead, we parent to nil.
instance.Parent = nil
-- After the instance is successfully destroyed,
-- we can remove all the id mappings
if id ~= nil then if id ~= nil then
self:removeId(id) self:removeId(id)
end end
for _, descendantInstance in ipairs(instance:GetDescendants()) do for _, descendantInstance in descendants do
self:removeInstance(descendantInstance) self:removeInstance(descendantInstance)
end end
instance:Destroy()
end end
function InstanceMap:destroyId(id) function InstanceMap:destroyId(id)
local instance = self.fromIds[id] local instance = self.fromIds[id]
self:removeId(id)
if instance ~= nil then if instance ~= nil then
for _, descendantInstance in ipairs(instance:GetDescendants()) do self:destroyInstance(instance)
self:removeInstance(descendantInstance) else
end -- There is no instance with this id, so we can just remove the id
-- without worrying about instance destruction
instance:Destroy() self:removeId(id)
end end
end end

View File

@@ -211,9 +211,11 @@ end
function PatchSet.countChanges(patch) function PatchSet.countChanges(patch)
local count = 0 local count = 0
for _ in patch.added do for _, add in patch.added do
-- Adding an instance is 1 change -- Adding an instance is 1 change per property
count += 1 for _ in add.Properties do
count += 1
end
end end
for _ in patch.removed do for _ in patch.removed do
-- Removing an instance is 1 change -- Removing an instance is 1 change
@@ -280,6 +282,22 @@ function PatchSet.assign(target, ...)
return target return target
end end
function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
table.insert(idList, id)
end
return table.freeze(idList)
end
function PatchSet.updatedIdList(patchSet): { string }
local idList = table.create(#patchSet.updated)
for _, item in patchSet.updated do
table.insert(idList, item.id)
end
return table.freeze(idList)
end
--[[ --[[
Create a list of human-readable statements summarizing the contents of this Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users. patch, intended to be displayed to users.

View File

@@ -11,6 +11,7 @@ local Packages = Rojo.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Timer = require(Plugin.Timer)
local Types = require(Plugin.Types) local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue) local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty) local getProperty = require(Plugin.Reconciler.getProperty)
@@ -78,6 +79,15 @@ function Tree.new()
return setmetatable(tree, Tree) return setmetatable(tree, Tree)
end end
-- Iterates over all nodes and counts them up
function Tree:getCount()
local count = 0
self:forEach(function()
count += 1
end)
return count
end
-- Iterates over all sub-nodes, depth first -- Iterates over all sub-nodes, depth first
-- node is where to start from, defaults to root -- node is where to start from, defaults to root
-- depth is used for recursion but can be used to set the starting depth -- depth is used for recursion but can be used to set the starting depth
@@ -122,6 +132,7 @@ end
-- props must contain id, and cannot contain children or parentId -- props must contain id, and cannot contain children or parentId
-- other than those three, it can hold anything -- other than those three, it can hold anything
function Tree:addNode(parent, props) function Tree:addNode(parent, props)
Timer.start("Tree:addNode")
assert(props.id, "props must contain id") assert(props.id, "props must contain id")
parent = parent or "ROOT" parent = parent or "ROOT"
@@ -132,6 +143,7 @@ function Tree:addNode(parent, props)
for k, v in props do for k, v in props do
node[k] = v node[k] = v
end end
Timer.stop()
return node return node
end end
@@ -142,18 +154,21 @@ function Tree:addNode(parent, props)
local parentNode = self:getNode(parent) local parentNode = self:getNode(parent)
if not parentNode then if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props) Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
Timer.stop()
return return
end end
parentNode.children[node.id] = node parentNode.children[node.id] = node
self.idToNode[node.id] = node self.idToNode[node.id] = node
Timer.stop()
return node return node
end end
-- Given a list of ancestor ids in descending order, builds the nodes for them -- Given a list of ancestor ids in descending order, builds the nodes for them
-- using the patch and instanceMap info -- using the patch and instanceMap info
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap) function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
Timer.start("Tree:buildAncestryNodes")
-- Build nodes for ancestry by going up the tree -- Build nodes for ancestry by going up the tree
previousId = previousId or "ROOT" previousId = previousId or "ROOT"
@@ -171,6 +186,8 @@ function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, p
}) })
previousId = ancestorId previousId = ancestorId
end end
Timer.stop()
end end
local PatchTree = {} local PatchTree = {}
@@ -178,10 +195,12 @@ local PatchTree = {}
-- Builds a new tree from a patch and instanceMap -- Builds a new tree from a patch and instanceMap
-- uses changeListHeaders in node.changeList -- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders) function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("PatchTree.build")
local tree = Tree.new() local tree = Tree.new()
local knownAncestors = {} local knownAncestors = {}
Timer.start("patch.updated")
for _, change in patch.updated do for _, change in patch.updated do
local instance = instanceMap.fromIds[change.id] local instance = instanceMap.fromIds[change.id]
if not instance then if not instance then
@@ -209,15 +228,14 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
-- Gather detail text -- Gather detail text
local changeList, hint = nil, nil local changeList, changeInfo = nil, nil
if next(change.changedProperties) or change.changedName then if next(change.changedProperties) or change.changedName then
changeList = {} changeList = {}
local hintBuffer, i = {}, 0 local changeIndex = 0
local function addProp(prop: string, current: any?, incoming: any?, metadata: any?) local function addProp(prop: string, current: any?, incoming: any?, metadata: any?)
i += 1 changeIndex += 1
hintBuffer[i] = prop changeList[changeIndex] = { prop, current, incoming, metadata }
changeList[i] = { prop, current, incoming, metadata }
end end
-- Gather the changes -- Gather the changes
@@ -233,23 +251,13 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
addProp( addProp(
prop, prop,
if currentSuccess then currentValue else "[Error]", if currentSuccess then currentValue else "[Error]",
if incomingSuccess then incomingValue else next(incoming) if incomingSuccess then incomingValue else select(2, next(incoming))
) )
end end
-- Finalize detail values changeInfo = {
edits = changeIndex,
-- Trim hint to top 3 }
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header -- Sort changes and add header
table.sort(changeList, function(a, b) table.sort(changeList, function(a, b)
@@ -265,11 +273,13 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
className = instance.ClassName, className = instance.ClassName,
name = instance.Name, name = instance.Name,
instance = instance, instance = instance,
hint = hint, changeInfo = changeInfo,
changeList = changeList, changeList = changeList,
}) })
end end
Timer.stop()
Timer.start("patch.removed")
for _, idOrInstance in patch.removed do for _, idOrInstance in patch.removed do
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
if not instance then if not instance then
@@ -311,7 +321,9 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
instance = instance, instance = instance,
}) })
end end
Timer.stop()
Timer.start("patch.added")
for id, change in patch.added do for id, change in patch.added do
-- Gather ancestors from existing DOM or future additions -- Gather ancestors from existing DOM or future additions
local ancestryIds = {} local ancestryIds = {}
@@ -346,36 +358,24 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap) tree:buildAncestryNodes(previousId, ancestryIds, patch, instanceMap)
-- Gather detail text -- Gather detail text
local changeList, hint = nil, nil local changeList, changeInfo = nil, nil
if next(change.Properties) then if next(change.Properties) then
changeList = {} changeList = {}
local hintBuffer, i = {}, 0 local changeIndex = 0
local function addProp(prop: string, incoming: any)
changeIndex += 1
changeList[changeIndex] = { prop, "N/A", incoming }
end
for prop, incoming in change.Properties do for prop, incoming in change.Properties do
i += 1
hintBuffer[i] = prop
local success, incomingValue = decodeValue(incoming, instanceMap) local success, incomingValue = decodeValue(incoming, instanceMap)
if success then addProp(prop, if success then incomingValue else select(2, next(incoming)))
table.insert(changeList, { prop, "N/A", incomingValue })
else
table.insert(changeList, { prop, "N/A", next(incoming) })
end
end end
-- Finalize detail values changeInfo = {
edits = changeIndex,
-- Trim hint to top 3 }
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header -- Sort changes and add header
table.sort(changeList, function(a, b) table.sort(changeList, function(a, b)
@@ -390,61 +390,118 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
patchType = "Add", patchType = "Add",
className = change.ClassName, className = change.ClassName,
name = change.Name, name = change.Name,
hint = hint, changeInfo = changeInfo,
changeList = changeList, changeList = changeList,
instance = instanceMap.fromIds[id], instance = instanceMap.fromIds[id],
}) })
end end
Timer.stop()
Timer.stop()
return tree return tree
end end
-- Creates a deep copy of a tree for immutability purposes in Roact
function PatchTree.clone(tree)
if not tree then
return
end
local newTree = Tree.new()
tree:forEach(function(node)
newTree:addNode(node.parentId, table.clone(node))
end)
return newTree
end
-- Updates the metadata of a tree with the unapplied patch and currently existing instances -- Updates the metadata of a tree with the unapplied patch and currently existing instances
-- Builds a new tree from the data if one isn't provided -- Builds a new tree from the data if one isn't provided
-- Always returns a new tree for immutability purposes in Roact -- Always returns a new tree for immutability purposes in Roact
function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch) function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
Timer.start("PatchTree.updateMetadata")
if tree then if tree then
tree = PatchTree.clone(tree) -- A shallow copy is enough for our purposes here since we really only need a new top-level object
-- for immutable comparison checks in Roact
tree = table.clone(tree)
else else
tree = PatchTree.build(patch, instanceMap) tree = PatchTree.build(patch, instanceMap)
end end
-- Update isWarning metadata -- Update isWarning metadata
Timer.start("isWarning")
for _, failedChange in unappliedPatch.updated do for _, failedChange in unappliedPatch.updated do
local node = tree:getNode(failedChange.id) local node = tree:getNode(failedChange.id)
if node then if not node then
node.isWarning = true continue
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if node.changeList then
for _, change in node.changeList do
if failedChange.changedProperties[change[1]] then
Log.trace(" Marked property as warning: {}", change[1])
if change[4] == nil then
change[4] = {}
end
change[4].isWarning = true
end
end
end
end end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if not node.changeList then
continue
end
local warnings = 0
for _, change in node.changeList do
local property = change[1]
local propertyFailedToApply = if property == "Name"
then failedChange.changedName ~= nil -- Name is not in changedProperties, so it needs a special case
else failedChange.changedProperties[property] ~= nil
if not propertyFailedToApply then
-- This change didn't fail, no need to mark
continue
end
warnings += 1
if change[4] == nil then
change[4] = { isWarning = true }
else
change[4].isWarning = true
end
Log.trace(" Marked property as warning: {}.{}", node.name, property)
end
node.changeInfo = {
edits = (node.changeInfo.edits or (#node.changeList - 1)) - warnings,
failed = if warnings > 0 then warnings else nil,
}
end end
for failedAdditionId in unappliedPatch.added do
local node = tree:getNode(failedAdditionId)
if not node then
continue
end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
if not node.changeList then
continue
end
for _, change in node.changeList do
-- Failed addition means that all properties failed to be added
if change[4] == nil then
change[4] = { isWarning = true }
else
change[4].isWarning = true
end
Log.trace(" Marked property as warning: {}.{}", node.name, change[1])
end
node.changeInfo = {
failed = node.changeInfo.edits or (#node.changeList - 1),
}
end
for _, failedRemovalIdOrInstance in unappliedPatch.removed do
local failedRemovalId = if Types.RbxId(failedRemovalIdOrInstance)
then failedRemovalIdOrInstance
else instanceMap.fromInstances[failedRemovalIdOrInstance]
if not failedRemovalId then
continue
end
local node = tree:getNode(failedRemovalId)
if not node then
continue
end
node.isWarning = true
Log.trace("Marked node as warning: {} {}", node.id, node.name)
end
Timer.stop()
-- Update if instances exist -- Update if instances exist
Timer.start("instanceAncestry")
tree:forEach(function(node) tree:forEach(function(node)
if node.instance then if node.instance then
if node.instance.Parent == nil and node.instance ~= game then if node.instance.Parent == nil and node.instance ~= game then
@@ -460,7 +517,9 @@ function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
end end
end end
end) end)
Timer.stop()
Timer.stop()
return tree return tree
end end

View File

@@ -5,8 +5,6 @@
Patches can come from the server or be generated by the client. Patches can come from the server or be generated by the client.
]] ]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Packages = script.Parent.Parent.Parent.Packages local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
@@ -16,19 +14,28 @@ local invariant = require(script.Parent.Parent.invariant)
local decodeValue = require(script.Parent.decodeValue) local decodeValue = require(script.Parent.decodeValue)
local reify = require(script.Parent.reify) local reify = require(script.Parent.reify)
local reifyInstance, applyDeferredRefs = reify.reifyInstance, reify.applyDeferredRefs
local setProperty = require(script.Parent.setProperty) local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch) local function applyPatch(instanceMap, patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
-- Tracks any portions of the patch that could not be applied to the DOM. -- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty() local unappliedPatch = PatchSet.newEmpty()
-- Contains a list of all of the ref properties that we'll need to assign.
-- It is imperative that refs are assigned after all instances are created
-- to ensure that referents can be mapped to instances correctly.
local deferredRefs = {}
for _, removedIdOrInstance in ipairs(patch.removed) do for _, removedIdOrInstance in ipairs(patch.removed) do
if Types.RbxId(removedIdOrInstance) then local removeInstanceSuccess = pcall(function()
instanceMap:destroyId(removedIdOrInstance) if Types.RbxId(removedIdOrInstance) then
else instanceMap:destroyId(removedIdOrInstance)
instanceMap:destroyInstance(removedIdOrInstance) else
instanceMap:destroyInstance(removedIdOrInstance)
end
end)
if not removeInstanceSuccess then
table.insert(unappliedPatch.removed, removedIdOrInstance)
end end
end end
@@ -65,7 +72,7 @@ local function applyPatch(instanceMap, patch)
) )
end end
local failedToReify = reify(instanceMap, patch.added, id, parentInstance) local failedToReify = reifyInstance(deferredRefs, instanceMap, patch.added, id, parentInstance)
if not PatchSet.isEmpty(failedToReify) then if not PatchSet.isEmpty(failedToReify) then
Log.debug("Failed to reify as part of applying a patch: {:#?}", failedToReify) Log.debug("Failed to reify as part of applying a patch: {:#?}", failedToReify)
@@ -130,7 +137,7 @@ local function applyPatch(instanceMap, patch)
[update.id] = mockVirtualInstance, [update.id] = mockVirtualInstance,
} }
local failedToReify = reify(instanceMap, mockAdded, update.id, instance.Parent) local failedToReify = reifyInstance(deferredRefs, instanceMap, mockAdded, update.id, instance.Parent)
local newInstance = instanceMap.fromIds[update.id] local newInstance = instanceMap.fromIds[update.id]
@@ -159,10 +166,14 @@ local function applyPatch(instanceMap, patch)
end end
-- See you later, original instance. -- See you later, original instance.
--
-- Because the user might want to Undo this change, we cannot use Destroy
-- since that locks that parent and prevents ChangeHistoryService from
-- ever bringing it back. Instead, we parent to nil.
-- TODO: Can this fail? Some kinds of instance may not appreciate -- TODO: Can this fail? Some kinds of instance may not appreciate
-- being destroyed, like services. -- being reparented, like services.
instance:Destroy() instance.Parent = nil
-- This completes your rebuilding a plane mid-flight safety -- This completes your rebuilding a plane mid-flight safety
-- instruction. Please sit back, relax, and enjoy your flight. -- instruction. Please sit back, relax, and enjoy your flight.
@@ -170,7 +181,13 @@ local function applyPatch(instanceMap, patch)
end end
if update.changedName ~= nil then if update.changedName ~= nil then
instance.Name = update.changedName local setNameSuccess = pcall(function()
instance.Name = update.changedName
end)
if not setNameSuccess then
unappliedUpdate.changedName = update.changedName
partiallyApplied = true
end
end end
if update.changedMetadata ~= nil then if update.changedMetadata ~= nil then
@@ -183,15 +200,27 @@ local function applyPatch(instanceMap, patch)
if update.changedProperties ~= nil then if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do for propertyName, propertyValue in pairs(update.changedProperties) do
local ok, decodedValue = decodeValue(propertyValue, instanceMap) -- Because refs may refer to instances that we haven't constructed yet,
if not ok then -- we defer applying any ref properties until all instances are created.
if next(propertyValue) == "Ref" then
table.insert(deferredRefs, {
id = update.id,
instance = instance,
propertyName = propertyName,
virtualValue = propertyValue,
})
continue
end
local decodeSuccess, decodedValue = decodeValue(propertyValue, instanceMap)
if not decodeSuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true partiallyApplied = true
continue continue
end end
local ok = setProperty(instance, propertyName, decodedValue) local setPropertySuccess = setProperty(instance, propertyName, decodedValue)
if not ok then if not setPropertySuccess then
unappliedUpdate.changedProperties[propertyName] = propertyValue unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true partiallyApplied = true
end end
@@ -203,7 +232,7 @@ local function applyPatch(instanceMap, patch)
end end
end end
ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp) applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch return unappliedPatch
end end

View File

@@ -4,25 +4,41 @@ return function()
local InstanceMap = require(script.Parent.Parent.InstanceMap) local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet) local PatchSet = require(script.Parent.Parent.PatchSet)
local dummy = Instance.new("Folder") local container = Instance.new("Folder")
local function wasDestroyed(instance)
local tempContainer = Instance.new("Folder")
local function wasRemoved(instance)
-- If an instance was destroyed, its parent property is locked. -- If an instance was destroyed, its parent property is locked.
local ok = pcall(function() -- If an instance was removed, its parent property is nil.
-- We need to ensure we only remove, so that ChangeHistoryService can still Undo.
local isParentUnlocked = pcall(function()
local oldParent = instance.Parent local oldParent = instance.Parent
instance.Parent = dummy instance.Parent = tempContainer
instance.Parent = oldParent instance.Parent = oldParent
end) end)
return not ok return instance.Parent == nil and isParentUnlocked
end end
beforeEach(function()
container:ClearAllChildren()
end)
afterAll(function()
container:Destroy()
tempContainer:Destroy()
end)
it("should return an empty patch if given an empty patch", function() it("should return an empty patch if given an empty patch", function()
local patch = applyPatch(InstanceMap.new(), PatchSet.newEmpty()) local patch = applyPatch(InstanceMap.new(), PatchSet.newEmpty())
assert(PatchSet.isEmpty(patch), "expected remaining patch to be empty") assert(PatchSet.isEmpty(patch), "expected remaining patch to be empty")
end) end)
it("should destroy instances listed for remove", function() it("should remove instances listed for remove", function()
local root = Instance.new("Folder") local root = Instance.new("Folder")
root.Name = "ROOT"
root.Parent = container
local child = Instance.new("Folder") local child = Instance.new("Folder")
child.Name = "Child" child.Name = "Child"
@@ -38,14 +54,16 @@ return function()
local unapplied = applyPatch(instanceMap, patch) local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty") assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
assert(not wasDestroyed(root), "expected root to be left alone") assert(not wasRemoved(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed") assert(wasRemoved(child), "expected child to be removed")
instanceMap:stop() instanceMap:stop()
end) end)
it("should destroy IDs listed for remove", function() it("should remove IDs listed for remove", function()
local root = Instance.new("Folder") local root = Instance.new("Folder")
root.Name = "ROOT"
root.Parent = container
local child = Instance.new("Folder") local child = Instance.new("Folder")
child.Name = "Child" child.Name = "Child"
@@ -62,8 +80,8 @@ return function()
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty") assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(instanceMap:size()).to.equal(1) expect(instanceMap:size()).to.equal(1)
assert(not wasDestroyed(root), "expected root to be left alone") assert(not wasRemoved(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed") assert(wasRemoved(child), "expected child to be removed")
instanceMap:stop() instanceMap:stop()
end) end)
@@ -73,6 +91,8 @@ return function()
-- tests on reify, not here. -- tests on reify, not here.
local root = Instance.new("Folder") local root = Instance.new("Folder")
root.Name = "ROOT"
root.Parent = container
local instanceMap = InstanceMap.new() local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root) instanceMap:insert("ROOT", root)
@@ -113,6 +133,8 @@ return function()
it("should return unapplied additions when instances cannot be created", function() it("should return unapplied additions when instances cannot be created", function()
local root = Instance.new("Folder") local root = Instance.new("Folder")
root.Name = "ROOT"
root.Parent = container
local instanceMap = InstanceMap.new() local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root) instanceMap:insert("ROOT", root)
@@ -159,6 +181,7 @@ return function()
it("should recreate instances when changedClassName is set, preserving children", function() it("should recreate instances when changedClassName is set, preserving children", function()
local root = Instance.new("Folder") local root = Instance.new("Folder")
root.Name = "Initial Root Name" root.Name = "Initial Root Name"
root.Parent = container
local child = Instance.new("Folder") local child = Instance.new("Folder")
child.Name = "Child" child.Name = "Child"

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