Compare commits

..

136 Commits

Author SHA1 Message Date
8053909bd0 Add --git-since option to rojo serve
- Add new GitFilter struct for tracking files changed since a Git reference
- Only sync changed (added/deleted/modified) files to Roblox Studio
- Files remain acknowledged once synced, even if content is reverted
- Add enhanced logging for debugging sync issues
- Force acknowledge project structure to prevent 'Cannot sync a model as a place' errors
2026-01-19 22:02:59 +01:00
Micah
d08780fc14 Ensure that pruned Instances aren't treated as existing in syncback (#1179)
Closes #1178.
2025-11-29 21:21:48 -08:00
Micah
b89cc7f398 Release memofs v0.3.1 (#1175) 2025-11-27 12:32:57 -08:00
Micah
42568b9709 Release Rojo v7.7.0-rc.1 (#1174) 2025-11-27 12:10:57 -08:00
boatbomber
87f58e0a55 Use WebSocket instead of Long Polling (#1142) 2025-11-26 19:57:01 -08:00
Micah
a61a1bef55 Roundtrip schemas in syncback (#1173) 2025-11-26 16:11:39 -08:00
Micah
a99e877b7c Actually skip .gitignore if --skip-git is passed to init (#1172) 2025-11-26 13:59:12 -08:00
Ken Loeffler
93e9c51204 Fix rojo plugin install by adding Vfs::exists (#1169) 2025-11-21 07:04:34 -08:00
Ken Loeffler
015b5bda14 Set crate and plugin versions to 7.7.0-prealpha (#1170) 2025-11-21 07:02:09 -08:00
Micah
2b47861a4f Properly support EnumItem variants in hashing and variant_eq (#1165) 2025-11-19 19:18:14 -08:00
Micah
9b5a07191b Implement Syncback to support converting Roblox files to a Rojo project (#937)
This is a very large commit.
Consider checking the linked PR for more information.
2025-11-19 09:21:33 -08:00
boatbomber
071b6e7e23 Improved string diff viewer (#994) 2025-11-18 20:26:44 -08:00
quaywinn
31ec216a95 Remove pairs() and ipairs() (#1150) 2025-11-18 18:49:52 -08:00
Micah
ea70d89291 Support .jsonc extension for all JSON files (#1159) 2025-11-18 18:47:43 -08:00
quaywinn
03410ced6d Use buffer for ClassIcon EditableImages (#1149) 2025-11-07 13:07:19 -08:00
Micah
825726c883 Release 7.6.1 (#1151) 2025-11-06 18:49:05 -08:00
boatbomber
54e63d88d4 Slightly improve initial sync hangs (#1140) 2025-11-06 00:06:42 -08:00
boatbomber
4018c97cb6 Make CHANGELOG.md use consistent style (#1146) 2025-10-28 19:26:48 -07:00
boatbomber
d0b029f995 Add JSONC Support for Project, Meta, and Model JSON files (#1144)
Replaces `serde_json` parsing with `jsonc-parser` throughout the
codebase, enabling support for **comments** and **trailing commas** in
all JSON files including `.project.json`, `.model.json`, and
`.meta.json` files.
MSRV bumps from `1.83.0` to `1.88.0` in order to
use the jsonc_parser dependency.
2025-10-28 17:29:57 -07:00
Sebastian Stachowicz
aabe6d11b2 Update default gitignores to include sourcemap (#1145) 2025-10-28 17:28:55 -07:00
boatbomber
181cc37744 Improve sync fallback robustness (#1135) 2025-10-20 20:13:47 -07:00
boatbomber
cd78f5c02c Fix postcommit callbacks being skipped (#1132) 2025-10-14 12:13:59 -07:00
Micah
441c469966 Release Rojo v7.6.0 (#1125) 2025-10-10 19:17:55 -07:00
Micah
f3c423d77d Fix the various lints (#1124) 2025-10-10 13:00:56 -07:00
Micah
beb497878b Add flag for skipping git initialization to init command (#1122) 2025-10-07 17:12:22 -07:00
Micah
6ea95d487c Refactor init command (#1117) 2025-09-30 14:38:38 -07:00
Micah
80a381dbb1 Use SerializationService as a fallback for when patch application fails (#1030) 2025-09-21 15:09:20 -07:00
KAS
59e36491a5 Fix a grammar error and a typo (#1113) 2025-09-16 11:00:34 -07:00
Micah
c1326ba06e Add Arm64 builds to CI/Releases + build on ubuntu 22.04 (#1098) 2025-08-30 14:10:59 -07:00
Micah
e2633126ee Change Foreman to Rokit in CONTRIBUTING.md (#1110) 2025-08-30 13:42:52 -07:00
Micah
5f33435f3c Move to using Rokit, update tools, and don't install unnecessary tools (#1109) 2025-08-29 18:45:15 -07:00
boatbomber
54e0ff230b Improvements to sync reminder UX (#1096) 2025-08-28 17:15:34 -07:00
wad
4e9e6233ff fix: apply gameId and placeId only after initial sync (#1104) 2025-08-15 18:12:36 -07:00
Micah
0056849b51 Put Rojo version in crash message (#1101) 2025-08-13 15:46:08 -07:00
ffrostfall
2ddb21ec5f Add option for emitting absolute paths to rojo sourcemap (#1092)
Co-authored-by: Micah <micah@uplift.games>
2025-08-04 11:33:35 -07:00
Micah
a4eb65ca3f Add YAML middleware that behaves like TOML and JSON (#1093) 2025-08-02 20:58:13 -07:00
Sebastian Stachowicz
3002d250a1 Fix Table diff colors (#1084) 2025-07-31 19:36:03 -07:00
Micah
9598553e5d Normalize paths in sourcemap generation (#1085) 2025-07-31 09:19:57 -07:00
Sebastian Stachowicz
7f68d9887b Fixed nil -> nil props showing up as failing in patch visualizer (plugin) (#1081) 2025-07-25 15:27:11 -07:00
Micah
e092a7301f Change background color of web UI to gray (#1080) 2025-07-25 15:04:42 -07:00
Cameron Campbell
6dfdfbe514 Cache Rust Dependencies in release.yml. (#1079) 2025-07-22 16:13:23 -07:00
morosanu
7860f2717f Fix auto connect for play mode (#1066) 2025-07-22 15:12:16 -07:00
boatbomber
60f19df9a0 Show update indicator on version header (#1069) 2025-06-21 02:53:45 +00:00
boatbomber
951f0cda0b Show the plugin version on the Error page (#1068) 2025-06-20 18:28:04 -07:00
Micah
227042d6b1 Add current maintainers to author field of Cargo.toml files (#1053) 2025-05-21 20:55:39 -07:00
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
Micah
1caf9446d8 Rojo 7.4.0-rc2 (#798) 2023-10-04 05:36:57 +00:00
Micah
bfd2c885db Properly handle build metadata in semver parsing in plugin (#797) 2023-10-04 05:23:18 +00:00
542 changed files with 49184 additions and 7616 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
[*.lua]
indent_style = tab
[*.luau]
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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Changelog check
uses: Zomzog/changelog-checker@v1.3.0
with:
fileName: CHANGELOG.md
fileName: CHANGELOG.md
noChangelogLabel: skip changelog
checkNotification: Simple
env:

View File

@@ -12,28 +12,28 @@ on:
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
rust_version: [stable, 1.70.0]
os: [ubuntu-22.04, windows-latest, macos-latest, windows-11-arm, ubuntu-22.04-arm]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust_version }}
override: true
profile: minimal
uses: dtolnay/rust-toolchain@stable
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
version: 'v0.2.7'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --locked --verbose
@@ -41,33 +41,93 @@ jobs:
- name: Test
run: cargo test --locked --verbose
lint:
name: Rustfmt, Clippy, & Stylua
- name: Save Rust Cache
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
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@1.88.0
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --locked --verbose
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
lint:
name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
- name: Restore Rust Cache
uses: actions/cache/restore@v4
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
run: stylua --check plugin/src
- name: Selene
run: selene plugin/src
- name: Rustfmt
run: cargo fmt -- --check
- name: 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:
name: Create Release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
steps:
- uses: actions/checkout@v4
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: true
prerelease: false
run: |
gh release create ${{ github.ref_name }} --draft --verify-tag --title ${{ github.ref_name }}
build-plugin:
needs: ["create-release"]
name: Build Roblox Studio Plugin
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
version: 'v1.1.0'
- name: Build Plugin
run: rojo build plugin --output Rojo.rbxm
run: rojo build plugin.project.json --output Rojo.rbxm
- name: Upload Plugin to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: Rojo.rbxm
asset_name: Rojo.rbxm
asset_content_type: application/octet-stream
run: |
gh release upload ${{ github.ref_name }} Rojo.rbxm
- name: Upload Plugin to Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Rojo.rbxm
path: Rojo.rbxm
@@ -65,15 +53,25 @@ jobs:
# https://doc.rust-lang.org/rustc/platform-support.html
include:
- host: linux
os: ubuntu-20.04
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
label: linux-x86_64
- host: linux
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
label: linux-aarch64
- host: windows
os: windows-latest
target: x86_64-pc-windows-msvc
label: windows-x86_64
- host: windows
os: windows-11-arm
target: aarch64-pc-windows-msvc
label: windows-aarch64
- host: macos
os: macos-latest
target: x86_64-apple-darwin
@@ -89,70 +87,64 @@ jobs:
env:
BIN: rojo
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
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
uses: actions-rs/toolchain@v1
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
target: ${{ matrix.target }}
override: true
profile: minimal
targets: ${{ matrix.target }}
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build Release
run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env:
# Build into a known directory so we can find our build artifact more
# easily.
CARGO_TARGET_DIR: output
# On platforms that use OpenSSL, ensure it is statically linked to
# make binaries more portable.
OPENSSL_STATIC: 1
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Create Release Archive
- name: Generate Artifact Name
shell: bash
env:
TAG_NAME: ${{ github.ref_name }}
run: |
echo "ARTIFACT_NAME=$BIN-${TAG_NAME#v}-${{ matrix.label }}.zip" >> "$GITHUB_ENV"
- name: Create Archive and Upload to Release
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
cp "target/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging
7z a ../release.zip *
7z a ../$ARTIFACT_NAME *
else
cp "output/${{ matrix.target }}/release/$BIN" staging/
cp "target/${{ matrix.target }}/release/$BIN" staging/
cd staging
zip ../release.zip *
zip ../$ARTIFACT_NAME *
fi
- name: Upload Archive to Release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: release.zip
asset_name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
asset_content_type: application/octet-stream
gh release upload ${{ github.ref_name }} ../$ARTIFACT_NAME
- name: Upload Archive to Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
path: release.zip
path: ${{ env.ARTIFACT_NAME }}
name: ${{ env.ARTIFACT_NAME }}

8
.gitignore vendored
View File

@@ -10,8 +10,8 @@
/*.rbxl
/*.rbxlx
# Test places for the Roblox Studio Plugin
/plugin/*.rbxlx
# Sourcemap for the Rojo plugin (for better intellisense)
/sourcemap.json
# Roblox Studio holds 'lock' files on places
*.rbxl.lock
@@ -19,3 +19,7 @@
# Snapshot files from the 'insta' Rust crate
**/*.snap.new
# Macos file system junk
._*
.DS_STORE

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
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,29 @@ You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* [Foreman](https://github.com/Roblox/foreman)
* [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 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
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.

2218
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
[package]
name = "rojo"
version = "7.4.0-rc1"
rust-version = "1.70.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
version = "7.7.0-rc.1"
rust-version = "1.88"
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"
license = "MPL-2.0"
homepage = "https://rojo.space"
@@ -26,7 +30,9 @@ default = []
# Enable this feature to live-reload assets from the web UI.
dev_live_assets = []
profile-with-tracy = ["profiling/profile-with-tracy", "tracy-client"]
# Run Rojo with this feature to open a Tracy session.
# Currently uses protocol v63, last supported in Tracy 0.9.1.
profile-with-tracy = ["profiling/profile-with-tracy"]
[workspace]
members = ["crates/*"]
@@ -40,77 +46,85 @@ name = "build"
harness = false
[dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" }
memofs = { version = "0.3.1", path = "crates/memofs" }
# These dependencies can be uncommented when working on rbx-dom simultaneously
# rbx_binary = { path = "../rbx-dom/rbx_binary" }
# rbx_binary = { path = "../rbx-dom/rbx_binary", features = [
# "unstable_text_format",
# ] }
# rbx_dom_weak = { path = "../rbx-dom/rbx_dom_weak" }
# rbx_reflection = { path = "../rbx-dom/rbx_reflection" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "0.7.2"
rbx_dom_weak = "2.6.0"
rbx_reflection = "4.4.0"
rbx_reflection_database = "0.2.8"
rbx_xml = "0.13.2"
rbx_binary = { version = "2.0.1", features = ["unstable_text_format"] }
rbx_dom_weak = "4.1.0"
rbx_reflection = "6.1.0"
rbx_reflection_database = "2.0.2"
rbx_xml = "2.0.1"
anyhow = "1.0.44"
backtrace = "0.3.61"
anyhow = "1.0.80"
backtrace = "0.3.69"
bincode = "1.3.3"
crossbeam-channel = "0.5.1"
csv = "1.1.6"
env_logger = "0.9.0"
fs-err = "2.6.0"
futures = "0.3.17"
globset = "0.4.8"
crossbeam-channel = "0.5.12"
csv = "1.3.0"
env_logger = "0.9.3"
fs-err = "2.11.0"
futures = "0.3.30"
globset = "0.4.14"
humantime = "2.1.0"
hyper = { version = "0.14.13", features = ["server", "tcp", "http1"] }
hyper = { version = "0.14.28", features = ["server", "tcp", "http1"] }
hyper-tungstenite = "0.11.0"
jod-thread = "0.1.2"
log = "0.4.14"
maplit = "1.0.2"
notify = "4.0.17"
num_cpus = "1.15.0"
opener = "0.5.0"
rayon = "1.7.0"
reqwest = { version = "0.11.10", features = [
log = "0.4.21"
num_cpus = "1.16.0"
opener = "0.5.2"
rayon = "1.9.0"
reqwest = { version = "0.11.24", default-features = false, features = [
"blocking",
"json",
"native-tls-vendored",
"rustls-tls",
] }
ritz = "0.1.0"
roblox_install = "1.0.0"
serde = { version = "1.0.130", features = ["derive", "rc"] }
serde_json = "1.0.68"
toml = "0.5.9"
termcolor = "1.1.2"
thiserror = "1.0.30"
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] }
uuid = { version = "1.0.0", features = ["v4", "serde"] }
clap = { version = "3.1.18", features = ["derive"] }
profiling = "1.0.6"
tracy-client = { version = "0.13.2", optional = true }
serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
strum = { version = "0.27", features = ["derive"] }
toml = "0.5.11"
termcolor = "1.4.1"
thiserror = "1.0.57"
tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread", "macros"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15"
yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
blake3 = "1.5.0"
float-cmp = "0.9.0"
indexmap = { version = "2.10.0", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
winreg = "0.10.1"
[build-dependencies]
memofs = { version = "0.2.0", path = "crates/memofs" }
memofs = { version = "0.3.0", path = "crates/memofs" }
embed-resource = "1.6.4"
anyhow = "1.0.44"
embed-resource = "1.8.0"
anyhow = "1.0.80"
bincode = "1.3.3"
fs-err = "2.6.0"
fs-err = "2.11.0"
maplit = "1.0.2"
semver = "1.0.19"
semver = "1.0.22"
[dev-dependencies]
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.5"
insta = { version = "1.8.0", features = ["redactions", "yaml"] }
paste = "1.0.5"
pretty_assertions = "1.2.1"
serde_yaml = "0.8.21"
tempfile = "3.2.0"
walkdir = "2.3.2"
criterion = "0.3.6"
insta = { version = "1.36.1", features = ["redactions", "yaml"] }
paste = "1.0.14"
pretty_assertions = "1.4.0"
serde_yaml = "0.8.26"
tempfile = "3.10.1"
walkdir = "2.5.0"

View File

@@ -1,5 +1,5 @@
<div align="center">
<a href="https://rojo.space"><img src="assets/logo-512.png" alt="Rojo" height="217" /></a>
<a href="https://rojo.space"><img src="assets/brand_images/logo-512.png" alt="Rojo" height="217" /></a>
</div>
<div>&nbsp;</div>
@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome!
Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
Rojo supports Rust 1.88 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License
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;
}
body {
background-color: #e7e7e7
}
img {
max-width:100%;
max-height:100%;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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();
if file_name.starts_with(".git") {
continue;
}
// We can skip any TestEZ test files since they aren't necessary for
// the plugin to run.
if file_name.ends_with(".spec.lua") || file_name.ends_with(".spec.luau") {
@@ -41,37 +45,39 @@ fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
fn main() -> Result<(), anyhow::Error> {
let out_dir = env::var_os("OUT_DIR").unwrap();
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
let plugin_root = PathBuf::from(root_dir).join("plugin");
let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let plugin_dir = root_dir.join("plugin");
let templates_dir = root_dir.join("assets").join("project-templates");
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let 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!(
our_version.major == plugin_version.major,
"plugin version does not match Cargo version"
);
assert!(
our_version.minor == plugin_version.minor,
assert_eq!(
our_version, plugin_version,
"plugin version does not match Cargo version"
);
let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_root.join("Version.txt"))?,
let template_snapshot = snapshot_from_fs_path(&templates_dir)?;
let plugin_snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
"plugin" => VfsSnapshot::dir(hashmap! {
"fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
"http" => snapshot_from_fs_path(&plugin_dir.join("http"))?,
"log" => snapshot_from_fs_path(&plugin_dir.join("log"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_dir.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_dir.join("src"))?,
"Packages" => snapshot_from_fs_path(&plugin_dir.join("Packages"))?,
"Version.txt" => snapshot_from_fs_path(&plugin_dir.join("Version.txt"))?,
}),
});
let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(out_path)?;
let template_file = File::create(Path::new(&out_dir).join("templates.bincode"))?;
let plugin_file = File::create(Path::new(&out_dir).join("plugin.bincode"))?;
bincode::serialize_into(out_file, &snapshot)?;
bincode::serialize_into(plugin_file, &plugin_snapshot)?;
bincode::serialize_into(template_file, &template_snapshot)?;
println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc");
println!("cargo:rerun-if-changed=build/windows/rojo.manifest");

View File

@@ -1,7 +1,20 @@
# memofs Changelog
## Unreleased Changes
* Changed the `StdBackend` file watcher to use `PollWatcher` on macOS.
## 0.3.1 (2025-11-27)
* Added `Vfs::exists`. [#1169]
* Added `create_dir` and `create_dir_all` to allow creating directories. [#937]
[#1169]: https://github.com/rojo-rbx/rojo/pull/1169
[#937]: https://github.com/rojo-rbx/rojo/pull/937
## 0.3.0 (2024-03-15)
* Changed `StdBackend` file watching component to use minimal recursive watches. [#830]
* Added `Vfs::read_to_string` and `Vfs::read_to_string_lf_normalized` [#854]
[#830]: https://github.com/rojo-rbx/rojo/pull/830
[#854]: https://github.com/rojo-rbx/rojo/pull/854
## 0.2.0 (2021-08-23)
* Updated to `crossbeam-channel` 0.5.1.

View File

@@ -1,8 +1,12 @@
[package]
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.2.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
version = "0.3.1"
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
edition = "2018"
readme = "README.md"
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
[dependencies]
crossbeam-channel = "0.5.1"
fs-err = "2.3.0"
notify = "4.0.15"
serde = { version = "1.0", features = ["derive"] }
crossbeam-channel = "0.5.12"
fs-err = "2.11.0"
notify = "4.0.17"
serde = { version = "1.0.197", features = ["derive"] }

View File

@@ -157,6 +157,11 @@ impl VfsBackend for InMemoryFs {
)
}
fn exists(&mut self, path: &Path) -> io::Result<bool> {
let inner = self.inner.lock().unwrap();
Ok(inner.entries.contains_key(path))
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let inner = self.inner.lock().unwrap();
@@ -176,6 +181,21 @@ impl VfsBackend for InMemoryFs {
}
}
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
}
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
let mut path_buf = path.to_path_buf();
while let Some(parent) = path_buf.parent() {
inner.load_snapshot(parent.to_path_buf(), VfsSnapshot::empty_dir())?;
path_buf.pop();
}
inner.load_snapshot(path.to_path_buf(), VfsSnapshot::empty_dir())
}
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
let mut inner = self.inner.lock().unwrap();
@@ -228,23 +248,17 @@ impl VfsBackend for InMemoryFs {
}
fn must_be_file<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new(
io::ErrorKind::Other,
format!(
"path {} was a directory, but must be a file",
path.display()
),
))
Err(io::Error::other(format!(
"path {} was a directory, but must be a file",
path.display()
)))
}
fn must_be_dir<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new(
io::ErrorKind::Other,
format!(
"path {} was a file, but must be a directory",
path.display()
),
))
Err(io::Error::other(format!(
"path {} was a file, but must be a directory",
path.display()
)))
}
fn not_found<T>(path: &Path) -> io::Result<T> {

View File

@@ -22,9 +22,9 @@ mod noop_backend;
mod snapshot;
mod std_backend;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::{io, str};
pub use in_memory_fs::InMemoryFs;
pub use noop_backend::NoopBackend;
@@ -70,7 +70,10 @@ impl<T> IoResultExt<T> for io::Result<T> {
pub trait VfsBackend: sealed::Sealed + Send + 'static {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>>;
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()>;
fn exists(&mut self, path: &Path) -> io::Result<bool>;
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir>;
fn create_dir(&mut self, path: &Path) -> io::Result<()>;
fn create_dir_all(&mut self, path: &Path) -> io::Result<()>;
fn metadata(&mut self, path: &Path) -> io::Result<Metadata>;
fn remove_file(&mut self, path: &Path) -> io::Result<()>;
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()>;
@@ -155,6 +158,29 @@ impl VfsInner {
Ok(Arc::new(contents))
}
fn read_to_string<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
let contents_str = str::from_utf8(&contents).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("File was not valid UTF-8: {}", path.display()),
)
})?;
Ok(Arc::new(contents_str.into()))
}
fn exists<P: AsRef<Path>>(&mut self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.backend.exists(path)
}
fn write<P: AsRef<Path>, C: AsRef<[u8]>>(&mut self, path: P, contents: C) -> io::Result<()> {
let path = path.as_ref();
let contents = contents.as_ref();
@@ -172,6 +198,16 @@ impl VfsInner {
Ok(dir)
}
fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir(path)
}
fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.backend.create_dir_all(path)
}
fn remove_file<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
let _ = self.backend.unwatch(path);
@@ -258,6 +294,33 @@ impl Vfs {
self.inner.lock().unwrap().read(path)
}
/// Read a file from the VFS (or from the underlying backend if it isn't
/// resident) into a string.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string].
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
self.inner.lock().unwrap().read_to_string(path)
}
/// Read a file from the VFS (or the underlying backend if it isn't
/// resident) into a string, and normalize its line endings to LF.
///
/// Roughly equivalent to [`std::fs::read_to_string`][std::fs::read_to_string], but also performs
/// line ending normalization.
///
/// [std::fs::read_to_string]: https://doc.rust-lang.org/stable/std/fs/fn.read_to_string.html
#[inline]
pub fn read_to_string_lf_normalized<P: AsRef<Path>>(&self, path: P) -> io::Result<Arc<String>> {
let path = path.as_ref();
let contents = self.inner.lock().unwrap().read_to_string(path)?;
Ok(contents.replace("\r\n", "\n").into())
}
/// Write a file to the VFS and the underlying backend.
///
/// Roughly equivalent to [`std::fs::write`][std::fs::write].
@@ -281,6 +344,42 @@ impl Vfs {
self.inner.lock().unwrap().read_dir(path)
}
/// Return whether the given path exists.
///
/// Roughly equivalent to [`std::fs::exists`][std::fs::exists].
///
/// [std::fs::exists]: https://doc.rust-lang.org/stable/std/fs/fn.exists.html
#[inline]
pub fn exists<P: AsRef<Path>>(&self, path: P) -> io::Result<bool> {
let path = path.as_ref();
self.inner.lock().unwrap().exists(path)
}
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.lock().unwrap().create_dir_all(path)
}
/// Remove a file.
///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -383,6 +482,31 @@ impl VfsLock<'_> {
self.inner.read_dir(path)
}
/// Creates a directory at the provided location.
///
/// Roughly equivalent to [`std::fs::create_dir`][std::fs::create_dir].
/// Similiar to that function, this function will fail if the parent of the
/// path does not exist.
///
/// [std::fs::create_dir]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir.html
#[inline]
pub fn create_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir(path)
}
/// Creates a directory at the provided location, recursively creating
/// all parent components if they are missing.
///
/// Roughly equivalent to [`std::fs::create_dir_all`][std::fs::create_dir_all].
///
/// [std::fs::create_dir_all]: https://doc.rust-lang.org/stable/std/fs/fn.create_dir_all.html
#[inline]
pub fn create_dir_all<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
let path = path.as_ref();
self.inner.create_dir_all(path)
}
/// Remove a file.
///
/// Roughly equivalent to [`std::fs::remove_file`][std::fs::remove_file].
@@ -428,3 +552,23 @@ impl VfsLock<'_> {
self.inner.commit_event(event)
}
}
#[cfg(test)]
mod test {
use crate::{InMemoryFs, Vfs, VfsSnapshot};
/// https://github.com/rojo-rbx/rojo/issues/899
#[test]
fn read_to_string_lf_normalized_keeps_trailing_newline() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot("test", VfsSnapshot::file("bar\r\nfoo\r\n\r\n"))
.unwrap();
let vfs = Vfs::new(imfs);
assert_eq!(
vfs.read_to_string_lf_normalized("test").unwrap().as_str(),
"bar\nfoo\n\n"
);
}
}

View File

@@ -15,45 +15,39 @@ impl NoopBackend {
impl VfsBackend for NoopBackend {
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn exists(&mut self, _path: &Path) -> io::Result<bool> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn create_dir(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn create_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -61,17 +55,11 @@ impl VfsBackend for NoopBackend {
}
fn watch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
}

View File

@@ -1,38 +1,24 @@
use std::io;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::{collections::HashSet, io};
use crossbeam_channel::Receiver;
use notify::{DebouncedEvent, RecursiveMode, Watcher};
#[cfg(target_os = "macos")]
use notify::PollWatcher;
#[cfg(not(target_os = "macos"))]
use notify::{watcher, RecommendedWatcher};
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
/// `VfsBackend` that uses `std::fs` and the `notify` crate.
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_receiver: Receiver<VfsEvent>,
watches: HashSet<PathBuf>,
}
impl StdBackend {
pub fn new() -> StdBackend {
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 (tx, rx) = crossbeam_channel::unbounded();
@@ -63,6 +49,7 @@ impl StdBackend {
Self {
watcher,
watcher_receiver: rx,
watches: HashSet::new(),
}
}
}
@@ -76,6 +63,10 @@ impl VfsBackend for StdBackend {
fs_err::write(path, data)
}
fn exists(&mut self, path: &Path) -> io::Result<bool> {
std::fs::exists(path)
}
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
let mut entries = entries?;
@@ -91,6 +82,14 @@ impl VfsBackend for StdBackend {
})
}
fn create_dir(&mut self, path: &Path) -> io::Result<()> {
fs_err::create_dir(path)
}
fn create_dir_all(&mut self, path: &Path) -> io::Result<()> {
fs_err::create_dir_all(path)
}
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs_err::remove_file(path)
}
@@ -112,15 +111,23 @@ impl VfsBackend for StdBackend {
}
fn watch(&mut self, path: &Path) -> io::Result<()> {
self.watcher
.watch(path, RecursiveMode::NonRecursive)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
if self.watches.contains(path)
|| path
.ancestors()
.any(|ancestor| self.watches.contains(ancestor))
{
Ok(())
} else {
self.watches.insert(path.to_path_buf());
self.watcher
.watch(path, RecursiveMode::Recursive)
.map_err(io::Error::other)
}
}
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watcher
.unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
self.watches.remove(path);
self.watcher.unwatch(path).map_err(io::Error::other)
}
}

View File

@@ -6,5 +6,5 @@ edition = "2018"
publish = false
[dependencies]
serde = "1.0.99"
serde_yaml = "0.8.9"
serde = "1.0.197"
serde_yaml = "0.8.26"

View File

@@ -5,19 +5,13 @@ use serde::Serialize;
/// Enables redacting any value that serializes as a string.
///
/// Used for transforming Rojo instance IDs into something deterministic.
#[derive(Default)]
pub struct RedactionMap {
ids: HashMap<String, usize>,
last_id: usize,
}
impl RedactionMap {
pub fn new() -> Self {
Self {
ids: HashMap::new(),
last_id: 0,
}
}
pub fn get_redacted_value(&self, id: impl ToString) -> Option<String> {
let id = id.to_string();
@@ -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) {
let last_id = &mut self.last_id;

View File

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

View File

@@ -1 +1 @@
7.4.0-rc1
7.7.0-rc.1

View File

@@ -25,7 +25,7 @@
local function defaultTableDebug(buffer, input)
buffer:writeRaw("{")
for key, value in pairs(input) do
for key, value in input do
buffer:write("[{:?}] = {:?}", key, value)
if next(input, key) ~= nil then
@@ -50,7 +50,7 @@ local function defaultTableDebugExtended(buffer, input)
buffer:writeLineRaw("{")
buffer:indent()
for key, value in pairs(input) do
for key, value in input do
buffer:writeLine("[{:?}] = {:#?},", key, value)
end
@@ -70,7 +70,7 @@ local function debugImpl(buffer, value, extendedForm)
elseif valueType == "table" then
local valueMeta = getmetatable(value)
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
if valueMeta ~= nil and valueMeta.__fmtDebug ~= nil then
-- This type implement's the metamethod we made up to line up with
-- Rust's 'Debug' trait.
@@ -242,4 +242,4 @@ return {
debugOutputBuffer = debugOutputBuffer,
fmt = fmt,
debugify = debugify,
}
}

View File

@@ -3,12 +3,12 @@ Error.__index = Error
Error.Kind = {
HttpNotEnabled = {
message = "Rojo requires HTTP access, which is not enabled.\n" ..
"Check your game settings, located in the 'Home' tab of Studio.",
message = "Rojo requires HTTP access, which is not enabled.\n"
.. "Check your game settings, located in the 'Home' tab of Studio.",
},
ConnectFailed = {
message = "Couldn't connect to the Rojo server.\n" ..
"Make sure the server is running — use 'rojo serve' to run it!",
message = "Couldn't connect to the Rojo server.\n"
.. "Make sure the server is running — use 'rojo serve' to run it!",
},
Timeout = {
message = "HTTP request timed out.",
@@ -63,4 +63,13 @@ function Error.fromRobloxErrorString(message)
return Error.new(Error.Kind.Unknown, message)
end
function Error.fromResponse(response)
local lower = (response.body or ""):lower()
if response.code == 408 or response.code == 504 or lower:find("timed? ?out") then
return Error.new(Error.Kind.Timeout)
end
return Error.new(Error.Kind.Unknown, string.format("%s: %s", tostring(response.code), tostring(response.body)))
end
return Error

View File

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

View File

@@ -30,8 +30,13 @@ local function performRequest(requestParams)
end)
if success then
Log.trace("Request {} success, status code {}", requestId, response.StatusCode)
resolve(HttpResponse.fromRobloxResponse(response))
Log.trace("Request {} success, response {:#?}", requestId, response)
local httpResponse = HttpResponse.fromRobloxResponse(response)
if httpResponse:isSuccess() then
resolve(httpResponse)
else
reject(HttpError.fromResponse(httpResponse))
end
else
Log.trace("Request {} failure: {:?}", requestId, response)
reject(HttpError.fromRobloxErrorString(response))
@@ -63,4 +68,4 @@ function Http.jsonDecode(source)
return HttpService:JSONDecode(source)
end
return Http
return Http

View File

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

View File

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

View File

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

View File

@@ -188,6 +188,38 @@ types = {
},
Content = {
fromPod = function(pod): Content
if type(pod) == "string" then
if pod == "None" then
return Content.none
else
error(`unexpected Content value '{pod}'`)
end
else
local ty, value = next(pod)
if ty == "Uri" then
return Content.fromUri(value)
elseif ty == "Object" then
error("Object deserializing is not currently implemented")
else
error(`Unknown Content type '{ty}' (could not deserialize)`)
end
end
end,
toPod = function(roblox: Content)
if roblox.SourceType == Enum.ContentSourceType.None then
return "None"
elseif roblox.SourceType == Enum.ContentSourceType.Uri then
return { Uri = roblox.Uri }
elseif roblox.SourceType == Enum.ContentSourceType.Object then
error("Object serializing is not currently implemented")
else
error(`Unknown Content type '{roblox.SourceType} (could not serialize)`)
end
end,
},
ContentId = {
fromPod = identity,
toPod = identity,
},
@@ -205,6 +237,19 @@ types = {
end,
},
EnumItem = {
fromPod = function(pod)
return Enum[pod.type]:FromValue(pod.value)
end,
toPod = function(roblox)
return {
type = tostring(roblox.EnumType),
value = roblox.Value,
}
end,
},
Faces = {
fromPod = function(pod)
local faces = {}
@@ -300,7 +345,12 @@ types = {
local keypoints = {}
for index, keypoint in ipairs(pod.keypoints) do
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, keypoint.value, keypoint.envelope)
-- TODO: Add a test for NaN or Infinity values and envelopes
-- Right now it isn't possible because it'd fail the roundtrip.
-- It's more important that it works right now, though.
local value = keypoint.value or 0
local envelope = keypoint.envelope or 0
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, value, envelope)
end
return NumberSequence.new(keypoints)
@@ -328,13 +378,26 @@ types = {
if pod == "Default" then
return nil
else
return PhysicalProperties.new(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight
)
-- Passing `nil` instead of not passing anything gives
-- different results, so we have to branch here.
if pod.acousticAbsorption then
return (PhysicalProperties.new :: any)(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight,
pod.acousticAbsorption
)
else
return PhysicalProperties.new(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight
)
end
end
end,
@@ -348,6 +411,7 @@ types = {
elasticity = roblox.Elasticity,
frictionWeight = roblox.FrictionWeight,
elasticityWeight = roblox.ElasticityWeight,
acousticAbsorption = roblox.AcousticAbsorption,
}
end
end,
@@ -412,15 +476,6 @@ types = {
end,
},
SecurityCapabilities = {
fromPod = function(_pod)
error("SecurityCapabilities is not implemented")
end,
toPod = function(_roblox)
error("SecurityCapabilities is not implemented")
end,
},
SharedString = {
fromPod = function(_pod)
error("SharedString is not supported")
@@ -502,9 +557,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)
local ty, value = next(encodedValue)
if ty == nil then
-- If the encoded pair is empty, assume it is an unoccupied optional value
return true, nil
end
local typeImpl = types[ty]
if typeImpl == nil then
return false, "Couldn't decode value " .. tostring(ty)

View File

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

View File

@@ -15,6 +15,12 @@
0.0
]
},
"TestEnumItem": {
"EnumItem": {
"type": "Material",
"value": 256
}
},
"TestNumber": {
"Float64": 1337.0
},
@@ -170,9 +176,23 @@
},
"ty": "ColorSequence"
},
"Content": {
"ContentId": {
"value": {
"Content": "rbxassetid://12345"
"ContentId": "rbxassetid://12345"
},
"ty": "ContentId"
},
"Content_None": {
"value": {
"Content": "None"
},
"ty": "Content"
},
"Content_Uri": {
"value": {
"Content": {
"Uri": "rbxasset://abc/123.rojo"
}
},
"ty": "Content"
},
@@ -182,6 +202,15 @@
},
"ty": "Enum"
},
"EnumItem": {
"value": {
"EnumItem": {
"type": "Material",
"value": 256
}
},
"ty": "EnumItem"
},
"Faces": {
"value": {
"Faces": [
@@ -370,6 +399,41 @@
},
"ty": "NumberSequence"
},
"OptionalCFrame-None": {
"value": {
"OptionalCFrame": null
},
"ty": "OptionalCFrame"
},
"OptionalCFrame-Some": {
"value": {
"OptionalCFrame": {
"position": [
0.0,
0.0,
0.0
],
"orientation": [
[
1.0,
0.0,
0.0
],
[
0.0,
1.0,
0.0
],
[
0.0,
0.0,
1.0
]
]
}
},
"ty": "OptionalCFrame"
},
"PhysicalProperties-Custom": {
"value": {
"PhysicalProperties": {
@@ -377,7 +441,8 @@
"friction": 1.0,
"elasticity": 0.0,
"frictionWeight": 50.0,
"elasticityWeight": 25.0
"elasticityWeight": 25.0,
"acousticAbsorption": 0.15625
}
},
"ty": "PhysicalProperties"

View File

@@ -1,139 +1,10 @@
-- Thanks to Tiffany352 for this base64 implementation!
local floor = math.floor
local char = string.char
local function encodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
-- 3 octets become 4 hextets
for i = 1, strLen - 2, 3 do
local b1, b2, b3 = str:byte(i, i + 3)
local word = b3 + b2 * 256 + b1 * 256 * 256
local h4 = word % 64 + 1
word = floor(word / 64)
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = alphabet:sub(h4, h4)
nOut = nOut + 4
end
local remainder = strLen % 3
if remainder == 2 then
-- 16 input bits -> 3 hextets (2 full, 1 partial)
local b1, b2 = str:byte(-2, -1)
-- partial is 4 bits long, leaving 2 bits of zero padding ->
-- offset = 4
local word = b2 * 4 + b1 * 4 * 256
local h3 = word % 64 + 1
word = floor(word / 64)
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = alphabet:sub(h3, h3)
out[nOut + 4] = "="
elseif remainder == 1 then
-- 8 input bits -> 2 hextets (2 full, 1 partial)
local b1 = str:byte(-1, -1)
-- partial is 2 bits long, leaving 4 bits of zero padding ->
-- offset = 16
local word = b1 * 16
local h2 = word % 64 + 1
word = floor(word / 64)
local h1 = word % 64 + 1
out[nOut + 1] = alphabet:sub(h1, h1)
out[nOut + 2] = alphabet:sub(h2, h2)
out[nOut + 3] = "="
out[nOut + 4] = "="
end
-- if the remainder is 0, then no work is needed
return table.concat(out, "")
end
local function decodeBase64(str)
local out = {}
local nOut = 0
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local strLen = #str
local acc = 0
local nAcc = 0
local alphabetLut = {}
for i = 1, #alphabet do
alphabetLut[alphabet:sub(i, i)] = i - 1
end
-- 4 hextets become 3 octets
for i = 1, strLen do
local ch = str:sub(i, i)
local byte = alphabetLut[ch]
if byte then
acc = acc * 64 + byte
nAcc = nAcc + 1
end
if nAcc == 4 then
local b3 = acc % 256
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
out[nOut + 3] = char(b3)
nOut = nOut + 3
nAcc = 0
acc = 0
end
end
if nAcc == 3 then
-- 3 hextets -> 16 bit output
acc = acc * 64
acc = floor(acc / 256)
local b2 = acc % 256
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
out[nOut + 2] = char(b2)
elseif nAcc == 2 then
-- 2 hextets -> 8 bit output
acc = acc * 64
acc = floor(acc / 256)
acc = acc * 64
acc = floor(acc / 256)
local b1 = acc % 256
out[nOut + 1] = char(b1)
elseif nAcc == 1 then
error("Base64 has invalid length")
end
return table.concat(out, "")
end
local EncodingService = game:GetService("EncodingService")
return {
decode = decodeBase64,
encode = encodeBase64,
decode = function(input: string)
return buffer.tostring(EncodingService:Base64Decode(buffer.fromstring(input)))
end,
encode = function(input: string)
return buffer.tostring(EncodingService:Base64Encode(buffer.fromstring(input)))
end,
}

View File

@@ -1,4 +1,7 @@
local CollectionService = game:GetService("CollectionService")
local ScriptEditorService = game:GetService("ScriptEditorService")
local Error = require(script.Parent.Error)
--- A list of `Enum.Material` values that are used for Terrain.MaterialColors
local TERRAIN_MATERIAL_COLORS = {
@@ -25,6 +28,21 @@ local TERRAIN_MATERIAL_COLORS = {
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.
--
-- The reflection database refers to these as having scriptability = "Custom"
@@ -35,19 +53,45 @@ return {
return true, instance:GetAttributes()
end,
write = function(instance, _, value)
local existing = instance:GetAttributes()
for key, attr in pairs(value) do
instance:SetAttribute(key, attr)
if typeof(value) ~= "table" then
return false, Error.new(Error.Kind.CannotParseBinaryString)
end
for key in pairs(existing) do
if value[key] == nil then
instance:SetAttribute(key, nil)
local existing = instance:GetAttributes()
local didAllWritesSucceed = true
for attributeName, attributeValue in pairs(value) do
if isAttributeNameReserved(attributeName) then
-- If the attribute name is reserved, then we don't
-- really care about reporting any failures about
-- it.
continue
end
if not isAttributeNameValid(attributeName) then
didAllWritesSucceed = false
continue
end
instance:SetAttribute(attributeName, attributeValue)
end
for existingAttributeName in pairs(existing) do
if isAttributeNameReserved(existingAttributeName) then
continue
end
if not isAttributeNameValid(existingAttributeName) then
didAllWritesSucceed = false
continue
end
if value[existingAttributeName] == nil then
instance:SetAttribute(existingAttributeName, nil)
end
end
return true
return didAllWritesSucceed
end,
},
Tags = {
@@ -95,6 +139,19 @@ return {
return true, instance:ScaleTo(value)
end,
},
WorldPivotData = {
read = function(instance)
return true, instance.WorldPivot
end,
write = function(instance, _, value)
if value == nil then
return true, nil
else
instance.WorldPivot = value
return true
end
end,
},
},
Terrain = {
MaterialColors = {
@@ -109,9 +166,70 @@ return {
return true, colors
end,
write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 })
if typeof(value) ~= "table" then
return false, Error.new(Error.Kind.CannotParseBinaryString)
end
for material, color in value do
instance:SetMaterialColor(material, color)
end
return true
end,
},
},
Script = {
Source = {
read = function(instance: Script)
return true, ScriptEditorService:GetEditorSource(instance)
end,
write = function(instance: Script, _, value: string)
task.spawn(function()
ScriptEditorService:UpdateSourceAsync(instance, function()
return value
end)
end)
return true
end,
},
},
ModuleScript = {
Source = {
read = function(instance: ModuleScript)
return true, ScriptEditorService:GetEditorSource(instance)
end,
write = function(instance: ModuleScript, _, value: string)
task.spawn(function()
ScriptEditorService:UpdateSourceAsync(instance, function()
return value
end)
end)
return true
end,
},
},
StyleRule = {
PropertiesSerialize = {
read = function(instance: StyleRule)
return true, instance:GetProperties()
end,
write = function(instance: StyleRule, _, value: { [any]: any })
if typeof(value) ~= "table" then
return false, Error.new(Error.Kind.CannotParseBinaryString)
end
local existing = instance:GetProperties()
for itemName, itemValue in pairs(value) do
instance:SetProperty(itemName, itemValue)
end
for existingItemName in pairs(existing) do
if value[existingItemName] == nil then
instance:SetProperty(existingItemName, nil)
end
end
return true
end,
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
local TestEZ = require(ReplicatedStorage.Packages:WaitForChild("TestEZ", 10))
local Rojo = ReplicatedStorage.Rojo

View File

@@ -1,4 +1,5 @@
local Packages = script.Parent.Parent.Packages
local HttpService = game:GetService("HttpService")
local Http = require(Packages.Http)
local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
@@ -9,7 +10,9 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSocketPacket = Types.ifEnabled(Types.ApiSocketPacket)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
local function rejectFailedRequests(response)
if response.code >= 400 then
@@ -45,14 +48,7 @@ end
local function rejectWrongPlaceId(infoResponseBody)
if infoResponseBody.expectedPlaceIds ~= nil then
local foundId = false
for _, id in ipairs(infoResponseBody.expectedPlaceIds) do
if id == game.PlaceId then
foundId = true
break
end
end
local foundId = table.find(infoResponseBody.expectedPlaceIds, game.PlaceId)
if not foundId then
local idList = {}
@@ -62,10 +58,30 @@ local function rejectWrongPlaceId(infoResponseBody)
local message = (
"Found a Rojo server, but its project is set to only be used with a specific list of places."
.. "\nYour place ID is %s, but needs to be one of these:"
.. "\nYour place ID is %u, but needs to be one of these:"
.. "\n%s"
.. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
):format(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)
end
@@ -84,6 +100,7 @@ function ApiContext.new(baseUrl)
__baseUrl = baseUrl,
__sessionId = nil,
__messageCursor = -1,
__wsClient = nil,
__connected = true,
__activeRequests = {},
}
@@ -111,6 +128,12 @@ function ApiContext:disconnect()
request:cancel()
end
self.__activeRequests = {}
if self.__wsClient then
Log.trace("Closing WebSocket client")
self.__wsClient:Close()
end
self.__wsClient = nil
end
function ApiContext:setMessageCursor(index)
@@ -185,45 +208,72 @@ function ApiContext:write(patch)
body = Http.jsonEncode(body)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
Log.info("Write response: {:?}", body)
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(responseBody)
Log.info("Write response: {:?}", responseBody)
return body
return responseBody
end)
end
function ApiContext:retrieveMessages()
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
function ApiContext:connectWebSocket(packetHandlers)
local url = ("%s/api/socket/%s"):format(self.__baseUrl, self.__messageCursor)
-- Convert HTTP/HTTPS URL to WS/WSS
url = url:gsub("^http://", "ws://"):gsub("^https://", "wss://")
local function sendRequest()
local request = Http.get(url):catch(function(err)
if err.type == Http.Error.Kind.Timeout and self.__connected then
return sendRequest()
return Promise.new(function(resolve, reject)
local success, wsClient =
pcall(HttpService.CreateWebStreamClient, HttpService, Enum.WebStreamClientType.WebSocket, {
Url = url,
})
if not success then
reject("Failed to create WebSocket client: " .. tostring(wsClient))
return
end
self.__wsClient = wsClient
local closed, errored, received
received = self.__wsClient.MessageReceived:Connect(function(msg)
local data = Http.jsonDecode(msg)
if data.sessionId ~= self.__sessionId then
Log.warn("Received message with wrong session ID; ignoring")
return
end
return Promise.reject(err)
assert(validateApiSocketPacket(data))
Log.trace("Received websocket packet: {:#?}", data)
local handler = packetHandlers[data.packetType]
if handler then
local ok, err = pcall(handler, data.body)
if not ok then
Log.error("Error in WebSocket packet handler for type '%s': %s", data.packetType, err)
end
else
Log.warn("No handler for WebSocket packet type '%s'", data.packetType)
end
end)
Log.trace("Tracking request {}", request)
self.__activeRequests[request] = true
closed = self.__wsClient.Closed:Connect(function()
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
return request:finally(function(...)
Log.trace("Cleaning up request {}", request)
self.__activeRequests[request] = nil
return ...
if self.__connected then
reject("WebSocket connection closed unexpectedly")
else
resolve()
end
end)
end
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
errored = self.__wsClient.Error:Connect(function(code, msg)
closed:Disconnect()
errored:Disconnect()
received:Disconnect()
assert(validateApiSubscribe(body))
self:setMessageCursor(body.messageCursor)
return body.messages
reject("WebSocket error: " .. code .. " - " .. msg)
end)
end)
end
@@ -239,4 +289,32 @@ function ApiContext:open(id)
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

View File

@@ -32,7 +32,7 @@ end
function Checkbox:render()
return Theme.with(function(theme)
theme = theme.Checkbox
local checkboxTheme = theme.Checkbox
local activeTransparency = Roact.joinBindings({
self.binding:map(function(value)
@@ -57,20 +57,21 @@ function Checkbox:render()
end,
}, {
StateTip = e(Tooltip.Trigger, {
text = (if self.props.locked then "[LOCKED] " else "")
.. (if self.props.active then "Enabled" else "Disabled"),
text = (if self.props.locked
then (self.props.lockedTooltip or "(Cannot be changed right now)") .. "\n"
else "") .. (if self.props.active then "Enabled" else "Disabled"),
}),
Active = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = theme.Active.BackgroundColor,
color = checkboxTheme.Active.BackgroundColor,
transparency = activeTransparency,
size = UDim2.new(1, 0, 1, 0),
zIndex = 2,
}, {
Icon = e("ImageLabel", {
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
ImageColor3 = theme.Active.IconColor,
ImageColor3 = checkboxTheme.Active.IconColor,
ImageTransparency = activeTransparency,
Size = UDim2.new(0, 16, 0, 16),
@@ -83,7 +84,7 @@ function Checkbox:render()
Inactive = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.Inactive.BorderColor,
color = checkboxTheme.Inactive.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}, {
@@ -91,7 +92,7 @@ function Checkbox:render()
Image = if self.props.locked
then Assets.Images.Checkbox.Locked
else Assets.Images.Checkbox.Inactive,
ImageColor3 = theme.Inactive.IconColor,
ImageColor3 = checkboxTheme.Inactive.IconColor,
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 16, 0, 16),

View File

@@ -0,0 +1,160 @@
local StudioService = game:GetService("StudioService")
local AssetService = game:GetService("AssetService")
type CachedImageInfo = {
pixels: buffer,
size: Vector2,
}
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local e = Roact.createElement
local EditableImage = require(Plugin.App.Components.EditableImage)
local imageCache: { [string]: CachedImageInfo } = {}
local function cloneBuffer(b: buffer): buffer
local newBuffer = buffer.create(buffer.len(b))
buffer.copy(newBuffer, 0, b)
return newBuffer
end
local function getImageSizeAndPixels(image: string): (Vector2, buffer)
local cachedImage = imageCache[image]
if not cachedImage then
local editableImage = AssetService:CreateEditableImageAsync(Content.fromUri(image))
local size = editableImage.Size
local pixels = editableImage:ReadPixelsBuffer(Vector2.zero, size)
imageCache[image] = {
pixels = pixels,
size = size,
}
return size, cloneBuffer(pixels)
end
return cachedImage.size, cloneBuffer(cachedImage.pixels)
end
local function getRecoloredClassIcon(className, color)
local iconProps = StudioService:GetClassIcon(className)
if iconProps and color then
--stylua: ignore
local success, editableImageSize, editableImagePixels = pcall(function(_iconProps: { [any]: any }, _color: Color3): (Vector2, buffer)
local size, pixels = getImageSizeAndPixels(_iconProps.Image)
local pixelsLen = buffer.len(pixels)
local minVal, maxVal = math.huge, -math.huge
for i = 0, pixelsLen, 4 do
if buffer.readu8(pixels, i + 3) == 0 then
continue
end
local pixelVal = math.max(
buffer.readu8(pixels, i),
buffer.readu8(pixels, i + 1),
buffer.readu8(pixels, i + 2)
)
minVal = math.min(minVal, pixelVal)
maxVal = math.max(maxVal, pixelVal)
end
local hue, sat, val = _color:ToHSV()
for i = 0, pixelsLen, 4 do
if buffer.readu8(pixels, i + 3) == 0 then
continue
end
local gIndex = i + 1
local bIndex = i + 2
local pixelVal = math.max(
buffer.readu8(pixels, i),
buffer.readu8(pixels, gIndex),
buffer.readu8(pixels, bIndex)
)
local newVal = val
if minVal < maxVal then
-- Remap minVal - maxVal to val*0.9 - val
newVal = val * (0.9 + 0.1 * (pixelVal - minVal) / (maxVal - minVal))
end
local newPixelColor = Color3.fromHSV(hue, sat, newVal)
buffer.writeu8(pixels, i, newPixelColor.R)
buffer.writeu8(pixels, gIndex, newPixelColor.G)
buffer.writeu8(pixels, bIndex, newPixelColor.B)
end
return size, pixels
end, iconProps, color)
if success then
iconProps.EditableImagePixels = editableImagePixels
iconProps.EditableImageSize = editableImageSize
end
end
return iconProps
end
local ClassIcon = Roact.PureComponent:extend("ClassIcon")
function ClassIcon:init()
self.state = {
iconProps = nil,
}
end
function ClassIcon:updateIcon()
local props = self.props
local iconProps = getRecoloredClassIcon(props.className, props.color)
self:setState({
iconProps = iconProps,
})
end
function ClassIcon:didMount()
self:updateIcon()
end
function ClassIcon:didUpdate(lastProps)
if lastProps.className ~= self.props.className or lastProps.color ~= self.props.color then
self:updateIcon()
end
end
function ClassIcon:render()
local iconProps = self.state.iconProps
if not iconProps then
return nil
end
return e(
"ImageLabel",
{
Size = self.props.size,
Position = self.props.position,
LayoutOrder = self.props.layoutOrder,
AnchorPoint = self.props.anchorPoint,
ImageTransparency = self.props.transparency,
Image = iconProps.Image,
ImageRectOffset = iconProps.ImageRectOffset,
ImageRectSize = iconProps.ImageRectSize,
BackgroundTransparency = 1,
},
if iconProps.EditableImagePixels
then e(EditableImage, {
size = iconProps.EditableImageSize,
pixels = iconProps.EditableImagePixels,
})
else nil
)
end
return ClassIcon

View File

@@ -1,61 +0,0 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local e = Roact.createElement
local CodeLabel = Roact.PureComponent:extend("CodeLabel")
function CodeLabel:init()
self.labelRef = Roact.createRef()
self.highlightsRef = Roact.createRef()
end
function CodeLabel:didMount()
Highlighter.highlight({
textObject = self.labelRef:getValue(),
})
self:updateHighlights()
end
function CodeLabel:didUpdate()
self:updateHighlights()
end
function CodeLabel:updateHighlights()
local highlights = self.highlightsRef:getValue()
if not highlights then
return
end
for _, lineLabel in highlights:GetChildren() do
local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0")
lineLabel.BackgroundColor3 = self.props.lineBackground
lineLabel.BorderSizePixel = 0
lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1
end
end
function CodeLabel:render()
return e("TextLabel", {
Size = self.props.size,
Position = self.props.position,
Text = self.props.text,
BackgroundTransparency = 1,
Font = Enum.Font.RobotoMono,
TextSize = 16,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
[Roact.Ref] = self.labelRef,
}, {
SyntaxHighlights = e("Folder", {
[Roact.Ref] = self.highlightsRef,
}),
})
end
return CodeLabel

View File

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

View File

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

View File

@@ -9,8 +9,70 @@ local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config)
local Version = require(Plugin.Version)
local Tooltip = require(Plugin.App.Components.Tooltip)
local SlicedImage = require(script.Parent.SlicedImage)
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)
return Theme.with(function(theme)
return e("Frame", {
@@ -29,18 +91,9 @@ local function Header(props)
BackgroundTransparency = 1,
}),
Version = e("TextLabel", {
Text = Version.display(Config.version),
Font = Enum.Font.Gotham,
TextSize = 14,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 0, 14),
LayoutOrder = 2,
BackgroundTransparency = 1,
VersionIndicator = e(VersionIndicator, {
transparency = props.transparency,
layoutOrder = 2,
}),
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 Rojo = script:FindFirstAncestor("Rojo")
@@ -9,16 +8,14 @@ local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Log = require(Packages.Log)
local bindingUtil = require(script.Parent.bindingUtil)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local bindingUtil = require(Plugin.App.bindingUtil)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis
local e = Roact.createElement
local Notification = Roact.Component:extend("Notification")
@@ -78,7 +75,9 @@ function Notification:didMount()
end
function Notification:willUnmount()
task.cancel(self.timeout)
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end
function Notification:render()
@@ -86,51 +85,49 @@ function Notification:render()
return 1 - value
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 = {}
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 += getTextBoundsAsync(action.text, theme.Font.Main, theme.TextSize.Large, math.huge).X + (theme.TextSize.Body * 2)
buttonsX += TextService:GetTextSize(
action.text,
18,
Enum.Font.GothamMedium,
Vector2.new(math.huge, math.huge)
).X + 30
count += 1
end
count += 1
buttonsX += (count - 1) * 5
end
buttonsX += (count - 1) * 5
end
local paddingY, logoSize = 20, 32
local actionsY = if self.props.actions then 37 else 0
local textXSpace = math.max(250, buttonsX) + 35
local textBounds = getTextBoundsAsync(self.props.text, theme.Font.Main, theme.TextSize.Body, textXSpace)
local contentX = math.max(textBounds.X, buttonsX)
local paddingY, logoSize = 20, 32
local actionsY = if self.props.actions then 35 else 0
local contentX = math.max(textBounds.X, buttonsX)
local size = self.binding:map(function(value)
return UDim2.fromOffset(
(35 + 40 + contentX) * value,
5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
)
end)
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", {
BackgroundTransparency = 1,
Size = size,
@@ -144,31 +141,31 @@ function Notification:render()
}, {
e(BorderedContainer, {
transparency = transparency,
size = UDim2.new(1, 0, 1, 0),
size = UDim2.fromScale(1, 1),
}, {
Contents = e("Frame", {
Size = UDim2.new(0, 35 + contentX, 1, -paddingY),
Position = UDim2.new(0, 0, 0, paddingY / 2),
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Logo = e("ImageLabel", {
ImageTransparency = transparency,
Image = Assets.Images.PluginButton,
BackgroundTransparency = 1,
Size = UDim2.new(0, logoSize, 0, logoSize),
Size = UDim2.fromOffset(logoSize, logoSize),
Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0),
}),
Info = e("TextLabel", {
Text = self.props.text,
Font = Enum.Font.GothamMedium,
TextSize = 15,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Notification.InfoColor,
TextTransparency = transparency,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
TextWrapped = true,
Size = UDim2.new(0, textBounds.X, 0, textBounds.Y),
Size = UDim2.new(0, textBounds.X, 1, -actionsY),
Position = UDim2.fromOffset(35, 0),
LayoutOrder = 1,
@@ -176,8 +173,8 @@ function Notification:render()
}),
Actions = if self.props.actions
then e("Frame", {
Size = UDim2.new(1, -40, 0, 35),
Position = UDim2.new(1, 0, 1, 0),
Size = UDim2.new(1, -40, 0, actionsY),
Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1),
BackgroundTransparency = 1,
}, {
@@ -196,32 +193,12 @@ function Notification:render()
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15),
PaddingTop = UDim.new(0, paddingY / 2),
PaddingBottom = UDim.new(0, paddingY / 2),
}),
}),
})
end)
end
local Notifications = Roact.Component:extend("Notifications")
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
return Notification

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 function ViewDiffButton(props)
return Theme.with(function(theme)
return e("TextButton", {
Text = "",
Size = UDim2.new(0.7, 0, 1, -4),
LayoutOrder = 2,
BackgroundTransparency = 1,
[Roact.Event.Activated] = props.onClick,
}, {
e(BorderedContainer, {
size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency:map(function(t)
return 0.5 + (0.5 * t)
end),
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 5),
}),
Label = e("TextLabel", {
Text = "View Diff",
BackgroundTransparency = 1,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0, 65, 1, 0),
LayoutOrder = 1,
}),
Icon = e("ImageLabel", {
Image = Assets.Images.Icons.Expand,
ImageColor3 = theme.Settings.Setting.DescriptionColor,
ImageTransparency = props.transparency,
Size = UDim2.new(0, 16, 0, 16),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
}),
})
end)
end
local function RowContent(props)
local values = props.values
local metadata = props.metadata
if props.showStringDiff and values[1] == "Source" then
-- Special case for .Source updates
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showStringDiff then
return
end
props.showStringDiff(tostring(values[2]), tostring(values[3]))
end,
})
end
if props.showTableDiff and (type(values[2]) == "table" or type(values[3]) == "table") then
-- Special case for table properties (like Attributes/Tags)
return e(ViewDiffButton, {
transparency = props.transparency,
onClick = function()
if not props.showTableDiff then
return
end
props.showTableDiff(values[2], values[3])
end,
})
end
return Theme.with(function(theme)
return Roact.createFragment({
ColumnB = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
ColumnC = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
textColor = if metadata.isWarning
then theme.Diff.Warning
else theme.Settings.Setting.DescriptionColor,
})
),
})
end)
end
local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init()
@@ -36,8 +153,9 @@ function ChangeList:render()
PaddingRight = UDim.new(0, 5),
}
local headerRow = changes[1]
local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = rowTransparency,
BackgroundColor3 = theme.Diff.Row,
LayoutOrder = 0,
@@ -49,36 +167,36 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Text = tostring(changes[1][1]),
ColumnA = e("TextLabel", {
Text = tostring(headerRow[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e("TextLabel", {
Text = tostring(changes[1][2]),
ColumnB = e("TextLabel", {
Text = tostring(headerRow[2]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
}),
C = e("TextLabel", {
Text = tostring(changes[1][3]),
ColumnC = e("TextLabel", {
Text = tostring(headerRow[3]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
@@ -95,91 +213,8 @@ function ChangeList:render()
local metadata = values[4] or EMPTY_TABLE
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", {
Size = UDim2.new(1, 0, 0, 30),
Size = UDim2.new(1, 0, 0, 24),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0,
@@ -192,44 +227,25 @@ function ChangeList:render()
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
ColumnA = 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,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = if isWarning then theme.Diff.Warning else theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e(
"Frame",
{
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
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,
})
),
Content = e(RowContent, {
values = values,
metadata = metadata,
transparency = props.transparency,
showStringDiff = props.showStringDiff,
showTableDiff = props.showTableDiff,
}),
})
end
@@ -253,8 +269,8 @@ function ChangeList:render()
}, {
Headers = headers,
Values = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -30),
position = UDim2.new(0, 0, 0, 30),
size = UDim2.new(1, 0, 1, -24),
position = UDim2.new(0, 0, 0, 24),
contentSize = self.contentSize,
transparency = props.transparency,
}, rows),

View File

@@ -30,10 +30,10 @@ local function DisplayValue(props)
}),
}),
Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
Text = string.format("%d, %d, %d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
@@ -53,8 +53,23 @@ local function DisplayValue(props)
elseif next(props.value) == nil then
-- If it's empty, show empty braces
textRepresentation = "{}"
elseif next(props.value) == 1 then
-- We don't need to support mixed tables, so checking the first key is enough
-- to determine if it's a simple array
local out, i = table.create(#props.value), 0
for _, v in props.value do
i += 1
-- Wrap strings in quotes
if type(v) == "string" then
v = '"' .. v .. '"'
end
out[i] = tostring(v)
end
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
else
-- If it has children, list them out
-- Otherwise, show the table contents as a dictionary
local out, i = {}, 0
for k, v in pairs(props.value) do
i += 1
@@ -75,8 +90,8 @@ local function DisplayValue(props)
return e("TextLabel", {
Text = textRepresentation,
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
@@ -89,11 +104,16 @@ local function DisplayValue(props)
-- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise.
local textRepresentation = string.gsub(tostring(props.value), "%s", " ")
if t == "string" then
textRepresentation = '"' .. textRepresentation .. '"'
end
return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "),
Text = textRepresentation,
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = props.textColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,

View File

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

View File

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

View File

@@ -10,6 +10,12 @@ local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local scrollDirToAutoSize = {
[Enum.ScrollingDirection.X] = Enum.AutomaticSize.X,
[Enum.ScrollingDirection.Y] = Enum.AutomaticSize.Y,
[Enum.ScrollingDirection.XY] = Enum.AutomaticSize.XY,
}
local function ScrollingFrame(props)
return Theme.with(function(theme)
return e("ScrollingFrame", {
@@ -28,16 +34,21 @@ local function ScrollingFrame(props)
Size = props.size,
Position = props.position,
AnchorPoint = props.anchorPoint,
CanvasSize = props.contentSize:map(function(value)
return UDim2.new(
0,
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
then value.X
else 0,
0,
value.Y
)
end),
CanvasSize = if props.contentSize
then props.contentSize:map(function(value)
return UDim2.new(
0,
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
then value.X
else 0,
0,
value.Y
)
end)
else UDim2.new(),
AutomaticCanvasSize = if props.contentSize == nil
then scrollDirToAutoSize[props.scrollingDirection or Enum.ScrollingDirection.XY]
else nil,
BorderSizePixel = 0,
BackgroundTransparency = 1,

View File

@@ -20,6 +20,7 @@ local function SlicedImage(props)
Size = props.size,
Position = props.position,
AnchorPoint = props.anchorPoint,
AutomaticSize = props.automaticSize,
ZIndex = props.zIndex,
LayoutOrder = props.layoutOrder,

View File

@@ -1,3 +1,4 @@
--!strict
--[[
Based on DiffMatchPatch by Neil Fraser.
https://github.com/google/diff-match-patch
@@ -67,8 +68,187 @@ function StringDiff.findDiffs(text1: string, text2: string): Diffs
end
-- Cleanup the diff
diffs = StringDiff._cleanupSemantic(diffs)
diffs = StringDiff._reorderAndMerge(diffs)
-- Remove any empty diffs
local cursor = 1
while cursor and diffs[cursor] do
if diffs[cursor].value == "" then
table.remove(diffs, cursor)
else
cursor += 1
end
end
return diffs
end
function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
local text1Length, text2Length = #text1, #text2
if text1Length == 0 then
-- It's simply inserting all of text2 into text1
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
end
if text2Length == 0 then
-- It's simply deleting all of text1
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
end
local longText = if text1Length > text2Length then text1 else text2
local shortText = if text1Length > text2Length then text2 else text1
local shortTextLength = #shortText
-- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
end
return diffs
end
if shortTextLength == 1 then
-- Single character string
-- After the previous shortcut, the character can't be an equality
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
return StringDiff._bisect(text1, text2)
end
function StringDiff._cleanupSemantic(diffs: Diffs): Diffs
-- Reduce the number of edits by eliminating semantically trivial equalities.
local changes = false
local equalities = {} -- Stack of indices where equalities are found.
local equalitiesLength = 0 -- Keeping our own length var is faster.
local lastEquality: string? = nil
-- Always equal to diffs[equalities[equalitiesLength]].value
local pointer = 1 -- Index of current position.
-- Number of characters that changed prior to the equality.
local length_insertions1 = 0
local length_deletions1 = 0
-- Number of characters that changed after the equality.
local length_insertions2 = 0
local length_deletions2 = 0
while diffs[pointer] do
if diffs[pointer].actionType == StringDiff.ActionTypes.Equal then -- Equality found.
equalitiesLength = equalitiesLength + 1
equalities[equalitiesLength] = pointer
length_insertions1 = length_insertions2
length_deletions1 = length_deletions2
length_insertions2 = 0
length_deletions2 = 0
lastEquality = diffs[pointer].value
else -- An insertion or deletion.
if diffs[pointer].actionType == StringDiff.ActionTypes.Insert then
length_insertions2 = length_insertions2 + #diffs[pointer].value
else
length_deletions2 = length_deletions2 + #diffs[pointer].value
end
-- Eliminate an equality that is smaller or equal to the edits on both
-- sides of it.
if
lastEquality
and (#lastEquality <= math.max(length_insertions1, length_deletions1))
and (#lastEquality <= math.max(length_insertions2, length_deletions2))
then
-- Duplicate record.
table.insert(
diffs,
equalities[equalitiesLength],
{ actionType = StringDiff.ActionTypes.Delete, value = lastEquality }
)
-- Change second copy to insert.
diffs[equalities[equalitiesLength] + 1].actionType = StringDiff.ActionTypes.Insert
-- Throw away the equality we just deleted.
equalitiesLength = equalitiesLength - 1
-- Throw away the previous equality (it needs to be reevaluated).
equalitiesLength = equalitiesLength - 1
pointer = (equalitiesLength > 0) and equalities[equalitiesLength] or 0
length_insertions1, length_deletions1 = 0, 0 -- Reset the counters.
length_insertions2, length_deletions2 = 0, 0
lastEquality = nil
changes = true
end
end
pointer = pointer + 1
end
-- Normalize the diff.
if changes then
StringDiff._reorderAndMerge(diffs)
end
StringDiff._cleanupSemanticLossless(diffs)
-- Find any overlaps between deletions and insertions.
-- e.g: <del>abcxxx</del><ins>xxxdef</ins>
-- -> <del>abc</del>xxx<ins>def</ins>
-- e.g: <del>xxxabc</del><ins>defxxx</ins>
-- -> <ins>def</ins>xxx<del>abc</del>
-- Only extract an overlap if it is as big as the edit ahead or behind it.
pointer = 2
while diffs[pointer] do
if
diffs[pointer - 1].actionType == StringDiff.ActionTypes.Delete
and diffs[pointer].actionType == StringDiff.ActionTypes.Insert
then
local deletion = diffs[pointer - 1].value
local insertion = diffs[pointer].value
local overlap_length1 = StringDiff._commonOverlap(deletion, insertion)
local overlap_length2 = StringDiff._commonOverlap(insertion, deletion)
if overlap_length1 >= overlap_length2 then
if overlap_length1 >= #deletion / 2 or overlap_length1 >= #insertion / 2 then
-- Overlap found. Insert an equality and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(insertion, 1, overlap_length1) }
)
diffs[pointer - 1].value = string.sub(deletion, 1, #deletion - overlap_length1)
diffs[pointer + 1].value = string.sub(insertion, overlap_length1 + 1)
pointer = pointer + 1
end
else
if overlap_length2 >= #deletion / 2 or overlap_length2 >= #insertion / 2 then
-- Reverse overlap found.
-- Insert an equality and swap and trim the surrounding edits.
table.insert(
diffs,
pointer,
{ actionType = StringDiff.ActionTypes.Equal, value = string.sub(deletion, 1, overlap_length2) }
)
diffs[pointer - 1] = {
actionType = StringDiff.ActionTypes.Insert,
value = string.sub(insertion, 1, #insertion - overlap_length2),
}
diffs[pointer + 1] = {
actionType = StringDiff.ActionTypes.Delete,
value = string.sub(deletion, overlap_length2 + 1),
}
pointer = pointer + 1
end
end
pointer = pointer + 1
end
pointer = pointer + 1
end
return diffs
end
@@ -124,51 +304,164 @@ function StringDiff._sharedSuffix(text1: string, text2: string): number
return pointerMid
end
function StringDiff._computeDiff(text1: string, text2: string): Diffs
-- Assumes that the prefix and suffix have already been trimmed off
-- and shortcut returns have been made so these texts must be different
function StringDiff._commonOverlap(text1: string, text2: string): number
-- Determine if the suffix of one string is the prefix of another.
local text1Length, text2Length = #text1, #text2
if text1Length == 0 then
-- It's simply inserting all of text2 into text1
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
-- Cache the text lengths to prevent multiple calls.
local text1_length = #text1
local text2_length = #text2
-- Eliminate the null case.
if text1_length == 0 or text2_length == 0 then
return 0
end
-- Truncate the longer string.
if text1_length > text2_length then
text1 = string.sub(text1, text1_length - text2_length + 1)
elseif text1_length < text2_length then
text2 = string.sub(text2, 1, text1_length)
end
local text_length = math.min(text1_length, text2_length)
-- Quick check for the worst case.
if text1 == text2 then
return text_length
end
if text2Length == 0 then
-- It's simply deleting all of text1
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
end
local longText = if text1Length > text2Length then text1 else text2
local shortText = if text1Length > text2Length then text2 else text1
local shortTextLength = #shortText
-- Shortcut if the shorter string exists entirely inside the longer one
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
if indexOf ~= nil then
local diffs = {
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
}
-- Swap insertions for deletions if diff is reversed
if text1Length > text2Length then
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
-- Start by looking for a single character match
-- and increase length until no match is found.
-- Performance analysis: https://neil.fraser.name/news/2010/11/04/
local best = 0
local length = 1
while true do
local pattern = string.sub(text1, text_length - length + 1)
local found = string.find(text2, pattern, 1, true)
if found == nil then
return best
end
return diffs
length = length + found - 1
if found == 1 or string.sub(text1, text_length - length + 1) == string.sub(text2, 1, length) then
best = length
length = length + 1
end
end
end
function StringDiff._cleanupSemanticScore(one: string, two: string): number
-- Given two strings, compute a score representing whether the internal
-- boundary falls on logical boundaries.
-- Scores range from 6 (best) to 0 (worst).
if (#one == 0) or (#two == 0) then
-- Edges are the best.
return 6
end
if shortTextLength == 1 then
-- Single character string
-- After the previous shortcut, the character can't be an equality
return {
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
}
end
-- Each port of this function behaves slightly differently due to
-- subtle differences in each language's definition of things like
-- 'whitespace'. Since this function's purpose is largely cosmetic,
-- the choice has been made to use each language's native features
-- rather than force total conformity.
local char1 = string.sub(one, -1)
local char2 = string.sub(two, 1, 1)
local nonAlphaNumeric1 = string.match(char1, "%W")
local nonAlphaNumeric2 = string.match(char2, "%W")
local whitespace1 = nonAlphaNumeric1 and string.match(char1, "%s")
local whitespace2 = nonAlphaNumeric2 and string.match(char2, "%s")
local lineBreak1 = whitespace1 and string.match(char1, "%c")
local lineBreak2 = whitespace2 and string.match(char2, "%c")
local blankLine1 = lineBreak1 and string.match(one, "\n\r?\n$")
local blankLine2 = lineBreak2 and string.match(two, "^\r?\n\r?\n")
return StringDiff._bisect(text1, text2)
if blankLine1 or blankLine2 then
-- Five points for blank lines.
return 5
elseif lineBreak1 or lineBreak2 then
-- Four points for line breaks
-- DEVIATION: Prefer to start on a line break instead of end on it
return if lineBreak1 then 4 else 4.5
elseif nonAlphaNumeric1 and not whitespace1 and whitespace2 then
-- Three points for end of sentences.
return 3
elseif whitespace1 or whitespace2 then
-- Two points for whitespace.
return 2
elseif nonAlphaNumeric1 or nonAlphaNumeric2 then
-- One point for non-alphanumeric.
return 1
end
return 0
end
function StringDiff._cleanupSemanticLossless(diffs: Diffs)
-- Look for single edits surrounded on both sides by equalities
-- which can be shifted sideways to align the edit to a word boundary.
-- e.g: The c<ins>at c</ins>ame. -> The <ins>cat </ins>came.
local pointer = 2
-- Intentionally ignore the first and last element (don't need checking).
while diffs[pointer + 1] do
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
if
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
then
-- This is a single edit surrounded by equalities.
local diff = diffs[pointer]
local equality1 = prevDiff.value
local edit = diff.value
local equality2 = nextDiff.value
-- First, shift the edit as far left as possible.
local commonOffset = StringDiff._sharedSuffix(equality1, edit)
if commonOffset > 0 then
local commonString = string.sub(edit, -commonOffset)
equality1 = string.sub(equality1, 1, -commonOffset - 1)
edit = commonString .. string.sub(edit, 1, -commonOffset - 1)
equality2 = commonString .. equality2
end
-- Second, step character by character right, looking for the best fit.
local bestEquality1 = equality1
local bestEdit = edit
local bestEquality2 = equality2
local bestScore = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
while string.byte(edit, 1) == string.byte(equality2, 1) do
equality1 = equality1 .. string.sub(edit, 1, 1)
edit = string.sub(edit, 2) .. string.sub(equality2, 1, 1)
equality2 = string.sub(equality2, 2)
local score = StringDiff._cleanupSemanticScore(equality1, edit)
+ StringDiff._cleanupSemanticScore(edit, equality2)
-- The > (rather than >=) encourages leading rather than trailing whitespace on edits.
-- I just think it looks better for indentation changes to start the line,
-- since then indenting several lines all have aligned diffs at the start
if score > bestScore then
bestScore = score
bestEquality1 = equality1
bestEdit = edit
bestEquality2 = equality2
end
end
if prevDiff.value ~= bestEquality1 then
-- We have an improvement, save it back to the diff.
if #bestEquality1 > 0 then
diffs[pointer - 1].value = bestEquality1
else
table.remove(diffs, pointer - 1)
pointer = pointer - 1
end
diffs[pointer].value = bestEdit
if #bestEquality2 > 0 then
diffs[pointer + 1].value = bestEquality2
else
table.remove(diffs, pointer + 1)
pointer = pointer - 1
end
end
end
pointer = pointer + 1
end
end
function StringDiff._bisect(text1: string, text2: string): Diffs

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
@@ -7,13 +5,15 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Highlighter = require(Packages.Highlighter)
Highlighter.matchStudioSettings()
local StringDiff = require(script:FindFirstChild("StringDiff"))
local Timer = require(Plugin.Timer)
local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local CodeLabel = require(Plugin.App.Components.CodeLabel)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local e = Roact.createElement
@@ -21,27 +21,29 @@ local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
function StringDiffVisualizer:init()
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.updateEvent = Instance.new("BindableEvent")
self.lineHeight, self.setLineHeight = Roact.createBinding(15)
self.canvasPosition, self.setCanvasPosition = Roact.createBinding(Vector2.zero)
self.windowWidth, self.setWindowWidth = Roact.createBinding(math.huge)
-- Ensure that the script background is up to date with the current theme
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
task.defer(function()
-- Defer to allow Highlighter to process the theme change first
-- Delay to allow Highlighter to process the theme change first
task.delay(1 / 20, function()
self:updateScriptBackground()
self:updateDiffs()
-- Rerender the virtual list elements
self.updateEvent:Fire()
end)
end)
self:calculateContentSize()
self:updateScriptBackground()
self:setState({
add = {},
remove = {},
})
self:updateDiffs()
end
function StringDiffVisualizer:willUnmount()
self.themeChangedConnection:Disconnect()
self.updateEvent:Destroy()
end
function StringDiffVisualizer:updateScriptBackground()
@@ -52,94 +54,189 @@ function StringDiffVisualizer:updateScriptBackground()
end
function StringDiffVisualizer:didUpdate(previousProps)
if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then
self:calculateContentSize()
local add, remove = self:calculateDiffLines()
self:setState({
add = add,
remove = remove,
})
if
previousProps.currentString ~= self.props.currentString
or previousProps.incomingString ~= self.props.incomingString
then
self:updateDiffs()
end
end
function StringDiffVisualizer:calculateContentSize()
local oldText, newText = self.props.oldText, self.props.newText
local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
self.setContentSize(
Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y))
)
end
function StringDiffVisualizer:calculateDiffLines()
local oldText, newText = self.props.oldText, self.props.newText
function StringDiffVisualizer:updateDiffs()
Timer.start("StringDiffVisualizer:updateDiffs")
local currentString, incomingString = self.props.currentString, self.props.incomingString
-- Diff the two texts
local startClock = os.clock()
local diffs = StringDiff.findDiffs(oldText, newText)
local diffs =
StringDiff.findDiffs((string.gsub(currentString, "\t", " ")), (string.gsub(incomingString, "\t", " ")))
local stopClock = os.clock()
Log.trace(
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
#oldText,
#newText,
#currentString,
#incomingString,
math.round((stopClock - startClock) * 1000 * 1000),
#diffs
)
-- Determine which lines to highlight
local add, remove = {}, {}
-- Build the rich text lines
local currentRichTextLines = Highlighter.buildRichTextLines({
src = currentString,
})
local incomingRichTextLines = Highlighter.buildRichTextLines({
src = incomingString,
})
local oldLineNum, newLineNum = 1, 1
local maxLines = math.max(#currentRichTextLines, #incomingRichTextLines)
-- Find the diff locations
local currentDiffs, incomingDiffs = {}, {}
local firstDiffLineNum = 0
local currentLineNum, incomingLineNum = 1, 1
local currentIdx, incomingIdx = 1, 1
for _, diff in diffs do
local actionType, text = diff.actionType, diff.value
local lines = select(2, string.gsub(text, "\n", "\n"))
local lineCount = select(2, string.gsub(text, "\n", "\n"))
local lines = string.split(text, "\n")
if actionType == StringDiff.ActionTypes.Equal then
oldLineNum += lines
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Insert then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
add[newLineNum + i - 1] = true
end
end
if lineCount > 0 then
-- Jump cursor ahead to last line
currentLineNum += lineCount
incomingLineNum += lineCount
currentIdx = #lines[#lines]
incomingIdx = #lines[#lines]
else
if string.match(text, "%S") then
add[newLineNum] = true
end
-- Move along this line
currentIdx += #text
incomingIdx += #text
end
continue
end
if actionType == StringDiff.ActionTypes.Insert then
if firstDiffLineNum == 0 then
firstDiffLineNum = incomingLineNum
end
for i, lineText in lines do
if i > 1 then
-- Move to next line
incomingLineNum += 1
incomingIdx = 0
end
if not incomingDiffs[incomingLineNum] then
incomingDiffs[incomingLineNum] = {}
end
-- Mark these characters on this line
table.insert(incomingDiffs[incomingLineNum], {
start = incomingIdx,
stop = incomingIdx + #lineText,
})
incomingIdx += #lineText
end
newLineNum += lines
elseif actionType == StringDiff.ActionTypes.Delete then
if lines > 0 then
local textLines = string.split(text, "\n")
for i, textLine in textLines do
if string.match(textLine, "%S") then
remove[oldLineNum + i - 1] = true
end
end
else
if string.match(text, "%S") then
remove[oldLineNum] = true
end
if firstDiffLineNum == 0 then
firstDiffLineNum = currentLineNum
end
for i, lineText in lines do
if i > 1 then
-- Move to next line
currentLineNum += 1
currentIdx = 0
end
if not currentDiffs[currentLineNum] then
currentDiffs[currentLineNum] = {}
end
-- Mark these characters on this line
table.insert(currentDiffs[currentLineNum], {
start = currentIdx,
stop = currentIdx + #lineText,
})
currentIdx += #lineText
end
oldLineNum += lines
else
Log.warn("Unknown diff action: {} {}", actionType, text)
end
end
return add, remove
Timer.stop()
self:setState({
maxLines = maxLines,
currentRichTextLines = currentRichTextLines,
incomingRichTextLines = incomingRichTextLines,
currentDiffs = currentDiffs,
incomingDiffs = incomingDiffs,
})
-- Scroll to the first diff line
task.defer(self.setCanvasPosition, Vector2.new(0, math.max(0, (firstDiffLineNum - 4) * 16)))
end
function StringDiffVisualizer:render()
local oldText, newText = self.props.oldText, self.props.newText
local currentDiffs, incomingDiffs = self.state.currentDiffs, self.state.incomingDiffs
local currentRichTextLines, incomingRichTextLines =
self.state.currentRichTextLines, self.state.incomingRichTextLines
local maxLines = self.state.maxLines
return Theme.with(function(theme)
self.setLineHeight(theme.TextSize.Code)
-- Calculate the width of the canvas
-- (One line at a time to avoid the char limit of getTextBoundsAsync)
local canvasWidth = 0
for i = 1, maxLines do
local currentLine = currentRichTextLines[i]
if currentLine and string.find(currentLine, "%S") then
local bounds = getTextBoundsAsync(currentLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
local incomingLine = incomingRichTextLines[i]
if incomingLine and string.find(incomingLine, "%S") then
local bounds = getTextBoundsAsync(incomingLine, theme.Font.Code, theme.TextSize.Code, math.huge, true)
if bounds.X > canvasWidth then
canvasWidth = bounds.X
end
end
end
local lineNumberWidth =
getTextBoundsAsync(tostring(maxLines), theme.Font.Code, theme.TextSize.Body, math.huge, true).X
canvasWidth += lineNumberWidth + 12
local removalScrollMarkers = {}
local insertionScrollMarkers = {}
for lineNum in currentDiffs do
table.insert(
removalScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Remove,
})
)
end
for lineNum in incomingDiffs do
table.insert(
insertionScrollMarkers,
e("Frame", {
Size = UDim2.fromScale(0.5, 1 / maxLines),
Position = UDim2.fromScale(0.5, (lineNum - 1) / maxLines),
BorderSizePixel = 0,
BackgroundColor3 = theme.Diff.Background.Add,
})
)
end
return e(BorderedContainer, {
size = self.props.size,
position = self.props.position,
@@ -157,43 +254,196 @@ function StringDiffVisualizer:render()
CornerRadius = UDim.new(0, 5),
}),
}),
Separator = e("Frame", {
Size = UDim2.new(0, 2, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
BackgroundTransparency = 0.5,
}),
Old = e(ScrollingFrame, {
position = UDim2.new(0, 2, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
Main = e("Frame", {
Size = UDim2.new(1, -10, 1, -2),
Position = UDim2.new(0, 2, 0, 2),
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(rbx)
self.setWindowWidth(rbx.AbsoluteSize.X * 0.5 - 10)
end,
}, {
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
Separator = e("Frame", {
Size = UDim2.new(0, 2, 1, 0),
Position = UDim2.new(0.5, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0),
BorderSizePixel = 0,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
BackgroundTransparency = 0.5,
}),
Current = e(VirtualScroller, {
position = UDim2.new(0, 0, 0, 0),
text = oldText,
lineBackground = theme.Diff.Remove,
markedLines = self.state.remove,
size = UDim2.new(0.5, -1, 1, 0),
transparency = self.props.transparency,
count = maxLines,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = currentDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Remove else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Remove,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = currentRichTextLines[i] or "",
RichText = true,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}),
Incoming = e(VirtualScroller, {
position = UDim2.new(0.5, 1, 0, 0),
size = UDim2.new(0.5, -1, 1, 0),
transparency = self.props.transparency,
count = maxLines,
updateEvent = self.updateEvent.Event,
canvasWidth = canvasWidth,
canvasPosition = self.canvasPosition,
onCanvasPositionChanged = self.setCanvasPosition,
render = function(i)
local lineDiffs = incomingDiffs[i]
local diffFrames = table.create(if lineDiffs then #lineDiffs else 0)
-- Show diff markers over the specific changed characters
if lineDiffs then
local charWidth = math.round(theme.TextSize.Code * 0.5)
for diffIdx, diff in lineDiffs do
local start, stop = diff.start, diff.stop
diffFrames[diffIdx] = e("Frame", {
Size = if #lineDiffs == 1
and start == 0
and stop == 0
then UDim2.fromScale(1, 1)
else UDim2.new(
0,
math.max(charWidth * (stop - start), charWidth * 0.4),
1,
0
),
Position = UDim2.fromOffset(charWidth * start, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 0.85,
BorderSizePixel = 0,
ZIndex = -1,
})
end
end
return Roact.createFragment({
LineNumber = e("TextLabel", {
Size = UDim2.new(0, lineNumberWidth + 8, 1, 0),
Text = i,
BackgroundColor3 = Color3.new(0, 0, 0),
BackgroundTransparency = 0.9,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Body,
TextColor3 = if lineDiffs then theme.Diff.Background.Add else theme.SubTextColor,
TextXAlignment = Enum.TextXAlignment.Right,
}, {
Padding = e("UIPadding", { PaddingRight = UDim.new(0, 6) }),
}),
Content = e("Frame", {
Size = UDim2.new(1, -(lineNumberWidth + 10), 1, 0),
Position = UDim2.fromScale(1, 0),
AnchorPoint = Vector2.new(1, 0),
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = if lineDiffs then 0.95 else 1,
BorderSizePixel = 0,
}, {
CodeLabel = e("TextLabel", {
Size = UDim2.fromScale(1, 1),
Position = UDim2.fromScale(0, 0),
Text = incomingRichTextLines[i] or "",
RichText = true,
BackgroundColor3 = theme.Diff.Background.Add,
BackgroundTransparency = 1,
BorderSizePixel = 0,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Code,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextColor3 = Color3.fromRGB(255, 255, 255),
}),
DiffFrames = Roact.createFragment(diffFrames),
}),
})
end,
getHeightBinding = function()
return self.lineHeight
end,
}),
}),
New = e(ScrollingFrame, {
position = UDim2.new(0.5, 5, 0, 2),
size = UDim2.new(0.5, -7, 1, -4),
scrollingDirection = Enum.ScrollingDirection.XY,
transparency = self.props.transparency,
contentSize = self.contentSize,
ScrollMarkers = e("Frame", {
Size = self.windowWidth:map(function(windowWidth)
return UDim2.new(0, 8, 1, -4 - (if canvasWidth > windowWidth then 10 else 0))
end),
Position = UDim2.new(1, -2, 0, 2),
AnchorPoint = Vector2.new(1, 0),
BackgroundTransparency = 1,
}, {
Source = e(CodeLabel, {
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = newText,
lineBackground = theme.Diff.Add,
markedLines = self.state.add,
}),
insertions = Roact.createFragment(insertionScrollMarkers),
removals = Roact.createFragment(removalScrollMarkers),
}),
})
end)

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

View File

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

View File

@@ -1,4 +1,3 @@
local TextService = game:GetService("TextService")
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo")
@@ -8,6 +7,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
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 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)
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, {
position = UDim2.fromOffset(X, Y),
size = UDim2.fromOffset(textSize.X, textSize.Y),
size = UDim2.fromOffset(contentSize.X, contentSize.Y),
transparency = props.transparency,
}, {
Label = e("TextLabel", {
BackgroundTransparency = 1,
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),
Size = UDim2.fromOffset(textBounds.X, textBounds.Y),
Text = props.Text,
TextSize = 16,
Font = Enum.Font.GothamMedium,
TextSize = theme.TextSize.Medium,
FontFace = theme.Font.Main,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Center,
TextColor3 = theme.Button.Bordered.Enabled.TextColor,
TextTransparency = props.transparency,
}),
@@ -72,8 +71,8 @@ local function Popup(props)
Tail = e("ImageLabel", {
ZIndex = 100,
Position = if displayAbove
then UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 1, -1)
else UDim2.new(0, math.clamp(props.Position.X - X, 6, textSize.X - 6), 0, -TAIL_SIZE + 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, contentSize.X - 6), 0, -TAIL_SIZE + 1),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0),
Rotation = if displayAbove then 180 else 0,
@@ -163,7 +162,6 @@ local Trigger = Roact.Component:extend("TooltipTrigger")
function Trigger:init()
self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef()
self.mousePos = Vector2.zero
self.showingPopup = false
self.destroy = function()
@@ -195,18 +193,22 @@ end
function Trigger:isHovering()
local rbx = self.ref.current
if rbx then
local pos = rbx.AbsolutePosition
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
return rbx.GuiState == Enum.GuiState.Hover
end
return false
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()
if self:isHovering() then
if self.showingPopup or self.showDelayThread then
@@ -214,10 +216,10 @@ function Trigger:managePopup()
return
end
self.showDelayThread = task.delay(DELAY, function()
self.showDelayThread = task.delay(self.props.delay or DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self.mousePos,
Position = self:getMousePos(),
Trigger = self.ref,
})
self.showDelayThread = nil
@@ -234,13 +236,7 @@ function Trigger:managePopup()
end
function Trigger:render()
local function recalculate(rbx)
local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui")
if not widget then
return
end
self.mousePos = widget:GetRelativeMousePosition()
local function recalculate()
self:managePopup()
end
@@ -250,11 +246,9 @@ function Trigger:render()
ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref,
[Roact.Change.GuiState] = recalculate,
[Roact.Change.AbsolutePosition] = recalculate,
[Roact.Change.AbsoluteSize] = recalculate,
[Roact.Event.MouseMoved] = recalculate,
[Roact.Event.MouseLeave] = recalculate,
[Roact.Event.MouseEnter] = recalculate,
})
end

View File

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

View File

@@ -1,5 +1,3 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
@@ -9,11 +7,11 @@ local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement
@@ -24,50 +22,52 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
showingSourceDiff = false,
oldSource = "",
newSource = "",
showingStringDiff = false,
currentString = "",
incomingString = "",
showingTableDiff = false,
oldTable = {},
newTable = {},
})
end
function ConfirmingPage:render()
return Theme.with(function(theme)
local pageContent = Roact.createFragment({
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Title = e("TextLabel", {
Text = string.format(
"Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
LayoutOrder = 2,
Font = Enum.Font.Gotham,
FontFace = theme.Font.Thin,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextSize = theme.TextSize.Body,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20),
Size = UDim2.new(1, 0, 0, theme.TextSize.Large + 2),
BackgroundTransparency = 1,
}),
PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -150),
size = UDim2.new(1, 0, 1, -100),
transparency = self.props.transparency,
layoutOrder = 3,
changeListHeaders = { "Property", "Current", "Incoming" },
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
patchTree = self.props.patchTree,
showSourceDiff = function(oldSource: string, newSource: string)
showStringDiff = function(currentString: string, incomingString: string)
self:setState({
showingSourceDiff = true,
oldSource = oldSource,
newSource = newSource,
showingStringDiff = true,
currentString = currentString,
incomingString = incomingString,
})
end,
showTableDiff = function(oldTable: { [any]: any? }, newTable: { [any]: any? })
self:setState({
showingTableDiff = true,
oldTable = oldTable,
newTable = newTable,
})
end,
}),
@@ -123,6 +123,11 @@ function ConfirmingPage:render()
}),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 8),
PaddingRight = UDim.new(0, 8),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
@@ -131,15 +136,10 @@ function ConfirmingPage:render()
Padding = UDim.new(0, 10),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
SourceDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingSourceDiff",
title = "Source diff",
active = self.state.showingSourceDiff,
StringDiff = e(StudioPluginGui, {
id = "Rojo_ConfirmingStringDiff",
title = "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
@@ -151,7 +151,7 @@ function ConfirmingPage:render()
onClose = function()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end,
}, {
@@ -167,8 +167,46 @@ function ConfirmingPage:render()
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldText = self.state.oldSource,
newText = self.state.newSource,
currentString = self.state.currentString,
incomingString = self.state.incomingString,
}),
}),
}),
}),
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 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 Assets = require(Plugin.Assets)
local PatchSet = require(Plugin.PatchSet)
@@ -18,86 +17,188 @@ local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
local TableDiffVisualizer = require(Plugin.App.Components.TableDiffVisualizer)
local e = Roact.createElement
local AGE_UNITS = {
{ 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 ChangesViewer = Roact.Component:extend("ChangesViewer")
local ageText = string.format("%d seconds ago", elapsed)
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()
function ChangesViewer:init()
-- Hold onto the serve session during the lifecycle of this component
-- so that it can still render during the fade out after disconnecting
self.serveSession = self.props.serveSession
end
function ChangesDrawer:render()
if self.props.rendered == false or self.serveSession == nil then
function ChangesViewer:render()
if self.props.rendered == false or self.serveSession == nil or self.props.patchData == nil then
return nil
end
local unapplied = PatchSet.countChanges(self.props.patchData.unapplied)
local applied = PatchSet.countChanges(self.props.patchData.patch) - unapplied
return Theme.with(function(theme)
return e(BorderedContainer, {
transparency = self.props.transparency,
size = self.props.height:map(function(y)
return UDim2.new(1, 0, y, -220 * y)
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,
return Roact.createFragment({
Navbar = e("Frame", {
Size = UDim2.new(1, 0, 0, 40),
BackgroundTransparency = 1,
}, {
Tip = e(Tooltip.Trigger, {
text = "Close the patch visualizer",
Close = e(IconButton, {
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, {
size = UDim2.new(1, 0, 1, 0),
Patch = e(PatchVisualizer, {
size = UDim2.new(1, -10, 1, -65),
position = UDim2.new(0, 5, 1, -5),
anchorPoint = Vector2.new(0, 1),
transparency = self.props.transparency,
layoutOrder = 3,
layoutOrder = self.props.layoutOrder,
patchTree = self.props.patchTree,
showSourceDiff = self.props.showSourceDiff,
showStringDiff = self.props.showStringDiff,
showTableDiff = self.props.showTableDiff,
}),
})
end)
@@ -116,13 +217,13 @@ local function ConnectionDetails(props)
}, {
ProjectName = e("TextLabel", {
Text = props.projectName,
Font = Enum.Font.GothamBold,
TextSize = 20,
FontFace = theme.Font.Bold,
TextSize = theme.TextSize.Large,
TextColor3 = theme.ConnectionDetails.ProjectNameColor,
TextTransparency = props.transparency,
TextXAlignment = Enum.TextXAlignment.Left,
Size = UDim2.new(1, 0, 0, 20),
Size = UDim2.new(1, 0, 0, theme.TextSize.Large),
LayoutOrder = 1,
BackgroundTransparency = 1,
@@ -130,13 +231,13 @@ local function ConnectionDetails(props)
Address = e("TextLabel", {
Text = props.address,
Font = Enum.Font.Code,
TextSize = 15,
FontFace = theme.Font.Code,
TextSize = theme.TextSize.Medium,
TextColor3 = theme.ConnectionDetails.AddressColor,
TextTransparency = props.transparency,
TextXAlignment = Enum.TextXAlignment.Left,
Size = UDim2.new(1, 0, 0, 15),
Size = UDim2.new(1, 0, 0, theme.TextSize.Medium),
LayoutOrder = 2,
BackgroundTransparency = 1,
@@ -165,20 +266,7 @@ function ConnectedPage:getChangeInfoText()
if patchData == nil then
return ""
end
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>"
return timeUtil.elapsedToText(DateTime.now().UnixTimestamp - patchData.timestamp)
end
function ConnectedPage:startChangeInfoTextUpdater()
@@ -188,17 +276,13 @@ function ConnectedPage:startChangeInfoTextUpdater()
-- Start a new updater
self.changeInfoTextUpdater = task.defer(function()
while true do
if self.state.hoveringChangeInfo then
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
else
self.setChangeInfoText(self:getChangeInfoText())
end
self.setChangeInfoText(self:getChangeInfoText())
local elapsed = os.time() - self.props.patchData.timestamp
local elapsed = DateTime.now().UnixTimestamp - self.props.patchData.timestamp
local updateInterval = 1
-- 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]
if elapsed > UnitSeconds then
updateInterval = UnitSeconds
@@ -219,29 +303,12 @@ function ConnectedPage:stopChangeInfoTextUpdater()
end
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({
renderChanges = false,
hoveringChangeInfo = false,
showingSourceDiff = false,
oldSource = "",
newSource = "",
showingStringDiff = false,
currentString = "",
incomingString = "",
})
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
@@ -258,12 +325,16 @@ function ConnectedPage:didUpdate(previousProps)
-- New patch recieved
self:startChangeInfoTextUpdater()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end
end
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 Roact.createFragment({
Padding = e("UIPadding", {
@@ -278,9 +349,88 @@ function ConnectedPage:render()
Padding = UDim.new(0, 10),
}),
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
Heading = e("Frame", {
BackgroundTransparency = 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, {
@@ -330,83 +480,65 @@ function ConnectedPage:render()
}),
}),
ChangeInfo = e("TextButton", {
Text = self.changeInfoText,
Font = Enum.Font.Gotham,
TextSize = 14,
TextWrapped = true,
RichText = true,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextTransparency = self.props.transparency,
ChangesViewer = e(StudioPluginGui, {
id = "Rojo_ChangesViewer",
title = "View changes",
active = self.state.renderChanges,
isEphemeral = true,
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,
BackgroundTransparency = 1,
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
[Roact.Event.MouseEnter] = function()
onClose = function()
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,
}, {
Tooltip = e(Tooltip.Trigger, {
text = if self.state.renderChanges then "Hide the changes" else "View the changes",
TooltipsProvider = e(Tooltip.Provider, nil, {
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(currentString: string, incomingString: string)
self:setState({
showingStringDiff = true,
currentString = currentString,
incomingString = incomingString,
})
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, {
rendered = self.state.renderChanges,
transparency = self.props.transparency,
patchTree = self.props.patchTree,
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,
StringDiff = e(StudioPluginGui, {
id = "Rojo_ConnectedStringDiff",
title = "String diff",
active = self.state.showingStringDiff,
isEphemeral = true,
initDockState = Enum.InitialDockState.Float,
@@ -418,7 +550,7 @@ function ConnectedPage:render()
onClose = function()
self:setState({
showingSourceDiff = false,
showingStringDiff = false,
})
end,
}, {
@@ -434,8 +566,46 @@ function ConnectedPage:render()
anchorPoint = Vector2.new(0, 0),
transparency = self.props.transparency,
oldText = self.state.oldSource,
newText = self.state.newSource,
currentString = self.state.currentString,
incomingString = self.state.incomingString,
}),
}),
}),
}),
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,
}),
}),
}),

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